mirror of
https://github.com/morpheus65535/bazarr.git
synced 2025-04-24 14:47:16 -04:00
Improved subtitles synchronisation settings and added a manual sync modal
This commit is contained in:
parent
0807bd99b9
commit
0e648b5588
28 changed files with 932 additions and 226 deletions
|
@ -4,17 +4,18 @@ import os
|
||||||
import sys
|
import sys
|
||||||
import gc
|
import gc
|
||||||
|
|
||||||
from flask_restx import Resource, Namespace, reqparse
|
from flask_restx import Resource, Namespace, reqparse, fields, marshal
|
||||||
|
|
||||||
from app.database import TableEpisodes, TableMovies, database, select
|
from app.database import TableEpisodes, TableMovies, database, select
|
||||||
from languages.get_languages import alpha3_from_alpha2
|
from languages.get_languages import alpha3_from_alpha2
|
||||||
from utilities.path_mappings import path_mappings
|
from utilities.path_mappings import path_mappings
|
||||||
|
from utilities.video_analyzer import subtitles_sync_references
|
||||||
from subtitles.tools.subsyncer import SubSyncer
|
from subtitles.tools.subsyncer import SubSyncer
|
||||||
from subtitles.tools.translate import translate_subtitles_file
|
from subtitles.tools.translate import translate_subtitles_file
|
||||||
from subtitles.tools.mods import subtitles_apply_mods
|
from subtitles.tools.mods import subtitles_apply_mods
|
||||||
from subtitles.indexer.series import store_subtitles
|
from subtitles.indexer.series import store_subtitles
|
||||||
from subtitles.indexer.movies import store_subtitles_movie
|
from subtitles.indexer.movies import store_subtitles_movie
|
||||||
from app.config import settings
|
from app.config import settings, empty_values
|
||||||
from app.event_handler import event_stream
|
from app.event_handler import event_stream
|
||||||
|
|
||||||
from ..utils import authenticate
|
from ..utils import authenticate
|
||||||
|
@ -25,6 +26,56 @@ api_ns_subtitles = Namespace('Subtitles', description='Apply mods/tools on exter
|
||||||
|
|
||||||
@api_ns_subtitles.route('subtitles')
|
@api_ns_subtitles.route('subtitles')
|
||||||
class Subtitles(Resource):
|
class Subtitles(Resource):
|
||||||
|
get_request_parser = reqparse.RequestParser()
|
||||||
|
get_request_parser.add_argument('subtitlesPath', type=str, required=True, help='External subtitles file path')
|
||||||
|
get_request_parser.add_argument('sonarrEpisodeId', type=int, required=False, help='Sonarr Episode ID')
|
||||||
|
get_request_parser.add_argument('radarrMovieId', type=int, required=False, help='Radarr Movie ID')
|
||||||
|
|
||||||
|
audio_tracks_data_model = api_ns_subtitles.model('audio_tracks_data_model', {
|
||||||
|
'stream': fields.String(),
|
||||||
|
'name': fields.String(),
|
||||||
|
'language': fields.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
embedded_subtitles_data_model = api_ns_subtitles.model('embedded_subtitles_data_model', {
|
||||||
|
'stream': fields.String(),
|
||||||
|
'name': fields.String(),
|
||||||
|
'language': fields.String(),
|
||||||
|
'forced': fields.Boolean(),
|
||||||
|
'hearing_impaired': fields.Boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
external_subtitles_data_model = api_ns_subtitles.model('external_subtitles_data_model', {
|
||||||
|
'name': fields.String(),
|
||||||
|
'path': fields.String(),
|
||||||
|
'language': fields.String(),
|
||||||
|
'forced': fields.Boolean(),
|
||||||
|
'hearing_impaired': fields.Boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
get_response_model = api_ns_subtitles.model('SubtitlesGetResponse', {
|
||||||
|
'audio_tracks': fields.Nested(audio_tracks_data_model),
|
||||||
|
'embedded_subtitles_tracks': fields.Nested(embedded_subtitles_data_model),
|
||||||
|
'external_subtitles_tracks': fields.Nested(external_subtitles_data_model),
|
||||||
|
})
|
||||||
|
|
||||||
|
@authenticate
|
||||||
|
@api_ns_subtitles.response(200, 'Success')
|
||||||
|
@api_ns_subtitles.response(401, 'Not Authenticated')
|
||||||
|
@api_ns_subtitles.doc(parser=get_request_parser)
|
||||||
|
def get(self):
|
||||||
|
"""Return available audio and embedded subtitles tracks with external subtitles. Used for manual subsync
|
||||||
|
modal"""
|
||||||
|
args = self.get_request_parser.parse_args()
|
||||||
|
subtitlesPath = args.get('subtitlesPath')
|
||||||
|
episodeId = args.get('sonarrEpisodeId', None)
|
||||||
|
movieId = args.get('radarrMovieId', None)
|
||||||
|
|
||||||
|
result = subtitles_sync_references(subtitles_path=subtitlesPath, sonarr_episode_id=episodeId,
|
||||||
|
radarr_movie_id=movieId)
|
||||||
|
|
||||||
|
return marshal(result, self.get_response_model, envelope='data')
|
||||||
|
|
||||||
patch_request_parser = reqparse.RequestParser()
|
patch_request_parser = reqparse.RequestParser()
|
||||||
patch_request_parser.add_argument('action', type=str, required=True,
|
patch_request_parser.add_argument('action', type=str, required=True,
|
||||||
help='Action from ["sync", "translate" or mods name]')
|
help='Action from ["sync", "translate" or mods name]')
|
||||||
|
@ -32,10 +83,20 @@ class Subtitles(Resource):
|
||||||
patch_request_parser.add_argument('path', type=str, required=True, help='Subtitles file path')
|
patch_request_parser.add_argument('path', type=str, required=True, help='Subtitles file path')
|
||||||
patch_request_parser.add_argument('type', type=str, required=True, help='Media type from ["episode", "movie"]')
|
patch_request_parser.add_argument('type', type=str, required=True, help='Media type from ["episode", "movie"]')
|
||||||
patch_request_parser.add_argument('id', type=int, required=True, help='Media ID (episodeId, radarrId)')
|
patch_request_parser.add_argument('id', type=int, required=True, help='Media ID (episodeId, radarrId)')
|
||||||
patch_request_parser.add_argument('forced', type=str, required=False, help='Forced subtitles from ["True", "False"]')
|
patch_request_parser.add_argument('forced', type=str, required=False,
|
||||||
|
help='Forced subtitles from ["True", "False"]')
|
||||||
patch_request_parser.add_argument('hi', type=str, required=False, help='HI subtitles from ["True", "False"]')
|
patch_request_parser.add_argument('hi', type=str, required=False, help='HI subtitles from ["True", "False"]')
|
||||||
patch_request_parser.add_argument('original_format', type=str, required=False,
|
patch_request_parser.add_argument('original_format', type=str, required=False,
|
||||||
help='Use original subtitles format from ["True", "False"]')
|
help='Use original subtitles format from ["True", "False"]')
|
||||||
|
patch_request_parser.add_argument('reference', type=str, required=False,
|
||||||
|
help='Reference to use for sync from video file track number (a:0) or some '
|
||||||
|
'subtitles file path')
|
||||||
|
patch_request_parser.add_argument('max_offset_seconds', type=str, required=False,
|
||||||
|
help='Maximum offset seconds to allow')
|
||||||
|
patch_request_parser.add_argument('no_fix_framerate', type=str, required=False,
|
||||||
|
help='Don\'t try to fix framerate from ["True", "False"]')
|
||||||
|
patch_request_parser.add_argument('gss', type=str, required=False,
|
||||||
|
help='Use Golden-Section Search from ["True", "False"]')
|
||||||
|
|
||||||
@authenticate
|
@authenticate
|
||||||
@api_ns_subtitles.doc(parser=patch_request_parser)
|
@api_ns_subtitles.doc(parser=patch_request_parser)
|
||||||
|
@ -79,19 +140,30 @@ class Subtitles(Resource):
|
||||||
video_path = path_mappings.path_replace_movie(metadata.path)
|
video_path = path_mappings.path_replace_movie(metadata.path)
|
||||||
|
|
||||||
if action == 'sync':
|
if action == 'sync':
|
||||||
|
sync_kwargs = {
|
||||||
|
'video_path': video_path,
|
||||||
|
'srt_path': subtitles_path,
|
||||||
|
'srt_lang': language,
|
||||||
|
'reference': args.get('reference') if args.get('reference') not in empty_values else video_path,
|
||||||
|
'max_offset_seconds': args.get('max_offset_seconds') if args.get('max_offset_seconds') not in
|
||||||
|
empty_values else str(settings.subsync.max_offset_seconds),
|
||||||
|
'no_fix_framerate': args.get('no_fix_framerate') == 'True',
|
||||||
|
'gss': args.get('gss') == 'True',
|
||||||
|
}
|
||||||
|
|
||||||
subsync = SubSyncer()
|
subsync = SubSyncer()
|
||||||
if media_type == 'episode':
|
try:
|
||||||
subsync.sync(video_path=video_path, srt_path=subtitles_path,
|
if media_type == 'episode':
|
||||||
srt_lang=language, media_type='series', sonarr_series_id=metadata.sonarrSeriesId,
|
sync_kwargs['sonarr_series_id'] = metadata.sonarrSeriesId
|
||||||
sonarr_episode_id=id)
|
sync_kwargs['sonarr_episode_id'] = id
|
||||||
else:
|
else:
|
||||||
try:
|
sync_kwargs['radarr_id'] = id
|
||||||
subsync.sync(video_path=video_path, srt_path=subtitles_path,
|
subsync.sync(**sync_kwargs)
|
||||||
srt_lang=language, media_type='movies', radarr_id=id)
|
except OSError:
|
||||||
except OSError:
|
return 'Unable to edit subtitles file. Check logs.', 409
|
||||||
return 'Unable to edit subtitles file. Check logs.', 409
|
finally:
|
||||||
del subsync
|
del subsync
|
||||||
gc.collect()
|
gc.collect()
|
||||||
elif action == 'translate':
|
elif action == 'translate':
|
||||||
from_language = subtitles_lang_from_filename(subtitles_path)
|
from_language = subtitles_lang_from_filename(subtitles_path)
|
||||||
dest_language = language
|
dest_language = language
|
||||||
|
|
|
@ -298,6 +298,10 @@ validators = [
|
||||||
Validator('subsync.checker', must_exist=True, default={}, is_type_of=dict),
|
Validator('subsync.checker', must_exist=True, default={}, is_type_of=dict),
|
||||||
Validator('subsync.checker.blacklisted_providers', must_exist=True, default=[], is_type_of=list),
|
Validator('subsync.checker.blacklisted_providers', must_exist=True, default=[], is_type_of=list),
|
||||||
Validator('subsync.checker.blacklisted_languages', must_exist=True, default=[], is_type_of=list),
|
Validator('subsync.checker.blacklisted_languages', must_exist=True, default=[], is_type_of=list),
|
||||||
|
Validator('subsync.no_fix_framerate', must_exist=True, default=True, is_type_of=bool),
|
||||||
|
Validator('subsync.gss', must_exist=True, default=True, is_type_of=bool),
|
||||||
|
Validator('subsync.max_offset_seconds', must_exist=True, default=60, is_type_of=int,
|
||||||
|
is_in=[60, 120, 300, 600]),
|
||||||
|
|
||||||
# series_scores section
|
# series_scores section
|
||||||
Validator('series_scores.hash', must_exist=True, default=359, is_type_of=int),
|
Validator('series_scores.hash', must_exist=True, default=359, is_type_of=int),
|
||||||
|
|
|
@ -88,7 +88,7 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u
|
||||||
from .sync import sync_subtitles
|
from .sync import sync_subtitles
|
||||||
sync_subtitles(video_path=path, srt_path=downloaded_path,
|
sync_subtitles(video_path=path, srt_path=downloaded_path,
|
||||||
forced=subtitle.language.forced,
|
forced=subtitle.language.forced,
|
||||||
srt_lang=downloaded_language_code2, media_type=media_type,
|
srt_lang=downloaded_language_code2,
|
||||||
percent_score=percent_score,
|
percent_score=percent_score,
|
||||||
sonarr_series_id=episode_metadata.sonarrSeriesId,
|
sonarr_series_id=episode_metadata.sonarrSeriesId,
|
||||||
sonarr_episode_id=episode_metadata.sonarrEpisodeId)
|
sonarr_episode_id=episode_metadata.sonarrEpisodeId)
|
||||||
|
@ -106,7 +106,7 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u
|
||||||
from .sync import sync_subtitles
|
from .sync import sync_subtitles
|
||||||
sync_subtitles(video_path=path, srt_path=downloaded_path,
|
sync_subtitles(video_path=path, srt_path=downloaded_path,
|
||||||
forced=subtitle.language.forced,
|
forced=subtitle.language.forced,
|
||||||
srt_lang=downloaded_language_code2, media_type=media_type,
|
srt_lang=downloaded_language_code2,
|
||||||
percent_score=percent_score,
|
percent_score=percent_score,
|
||||||
radarr_id=movie_metadata.radarrId)
|
radarr_id=movie_metadata.radarrId)
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ from app.config import settings
|
||||||
from subtitles.tools.subsyncer import SubSyncer
|
from subtitles.tools.subsyncer import SubSyncer
|
||||||
|
|
||||||
|
|
||||||
def sync_subtitles(video_path, srt_path, srt_lang, forced, media_type, percent_score, sonarr_series_id=None,
|
def sync_subtitles(video_path, srt_path, srt_lang, forced, percent_score, sonarr_series_id=None,
|
||||||
sonarr_episode_id=None, radarr_id=None):
|
sonarr_episode_id=None, radarr_id=None):
|
||||||
if forced:
|
if forced:
|
||||||
logging.debug('BAZARR cannot sync forced subtitles. Skipping sync routine.')
|
logging.debug('BAZARR cannot sync forced subtitles. Skipping sync routine.')
|
||||||
|
@ -26,7 +26,7 @@ def sync_subtitles(video_path, srt_path, srt_lang, forced, media_type, percent_s
|
||||||
|
|
||||||
if not use_subsync_threshold or (use_subsync_threshold and percent_score < float(subsync_threshold)):
|
if not use_subsync_threshold or (use_subsync_threshold and percent_score < float(subsync_threshold)):
|
||||||
subsync = SubSyncer()
|
subsync = SubSyncer()
|
||||||
subsync.sync(video_path=video_path, srt_path=srt_path, srt_lang=srt_lang, media_type=media_type,
|
subsync.sync(video_path=video_path, srt_path=srt_path, srt_lang=srt_lang,
|
||||||
sonarr_series_id=sonarr_series_id, sonarr_episode_id=sonarr_episode_id, radarr_id=radarr_id)
|
sonarr_series_id=sonarr_series_id, sonarr_episode_id=sonarr_episode_id, radarr_id=radarr_id)
|
||||||
del subsync
|
del subsync
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
|
@ -30,8 +30,9 @@ class SubSyncer:
|
||||||
self.vad = 'subs_then_webrtc'
|
self.vad = 'subs_then_webrtc'
|
||||||
self.log_dir_path = os.path.join(args.config_dir, 'log')
|
self.log_dir_path = os.path.join(args.config_dir, 'log')
|
||||||
|
|
||||||
def sync(self, video_path, srt_path, srt_lang, media_type, sonarr_series_id=None, sonarr_episode_id=None,
|
def sync(self, video_path, srt_path, srt_lang, sonarr_series_id=None, sonarr_episode_id=None, radarr_id=None,
|
||||||
radarr_id=None):
|
reference=None, max_offset_seconds=str(settings.subsync.max_offset_seconds),
|
||||||
|
no_fix_framerate=settings.subsync.no_fix_framerate, gss=settings.subsync.gss):
|
||||||
self.reference = video_path
|
self.reference = video_path
|
||||||
self.srtin = srt_path
|
self.srtin = srt_path
|
||||||
self.srtout = f'{os.path.splitext(self.srtin)[0]}.synced.srt'
|
self.srtout = f'{os.path.splitext(self.srtin)[0]}.synced.srt'
|
||||||
|
@ -52,20 +53,41 @@ class SubSyncer:
|
||||||
logging.debug('BAZARR FFmpeg used is %s', ffmpeg_exe)
|
logging.debug('BAZARR FFmpeg used is %s', ffmpeg_exe)
|
||||||
|
|
||||||
self.ffmpeg_path = os.path.dirname(ffmpeg_exe)
|
self.ffmpeg_path = os.path.dirname(ffmpeg_exe)
|
||||||
unparsed_args = [self.reference, '-i', self.srtin, '-o', self.srtout, '--ffmpegpath', self.ffmpeg_path, '--vad',
|
|
||||||
self.vad, '--log-dir-path', self.log_dir_path, '--output-encoding', 'same']
|
|
||||||
if settings.subsync.force_audio:
|
|
||||||
unparsed_args.append('--no-fix-framerate')
|
|
||||||
unparsed_args.append('--reference-stream')
|
|
||||||
unparsed_args.append('a:0')
|
|
||||||
if settings.subsync.debug:
|
|
||||||
unparsed_args.append('--make-test-case')
|
|
||||||
parser = make_parser()
|
|
||||||
self.args = parser.parse_args(args=unparsed_args)
|
|
||||||
if os.path.isfile(self.srtout):
|
|
||||||
os.remove(self.srtout)
|
|
||||||
logging.debug('BAZARR deleted the previous subtitles synchronization attempt file.')
|
|
||||||
try:
|
try:
|
||||||
|
unparsed_args = [self.reference, '-i', self.srtin, '-o', self.srtout, '--ffmpegpath', self.ffmpeg_path,
|
||||||
|
'--vad', self.vad, '--log-dir-path', self.log_dir_path, '--max-offset-seconds',
|
||||||
|
max_offset_seconds, '--output-encoding', 'same']
|
||||||
|
if not settings.general.utf8_encode:
|
||||||
|
unparsed_args.append('--output-encoding')
|
||||||
|
unparsed_args.append('same')
|
||||||
|
|
||||||
|
if no_fix_framerate:
|
||||||
|
unparsed_args.append('--no-fix-framerate')
|
||||||
|
|
||||||
|
if gss:
|
||||||
|
unparsed_args.append('--gss')
|
||||||
|
|
||||||
|
if reference and reference != video_path and os.path.isfile(reference):
|
||||||
|
# subtitles path provided
|
||||||
|
self.reference = reference
|
||||||
|
elif reference and isinstance(reference, str) and len(reference) == 3 and reference[:2] in ['a:', 's:']:
|
||||||
|
# audio or subtitles track id provided
|
||||||
|
unparsed_args.append('--reference-stream')
|
||||||
|
unparsed_args.append(reference)
|
||||||
|
elif settings.subsync.force_audio:
|
||||||
|
# nothing else match and force audio settings is enabled
|
||||||
|
unparsed_args.append('--reference-stream')
|
||||||
|
unparsed_args.append('a:0')
|
||||||
|
|
||||||
|
if settings.subsync.debug:
|
||||||
|
unparsed_args.append('--make-test-case')
|
||||||
|
|
||||||
|
parser = make_parser()
|
||||||
|
self.args = parser.parse_args(args=unparsed_args)
|
||||||
|
|
||||||
|
if os.path.isfile(self.srtout):
|
||||||
|
os.remove(self.srtout)
|
||||||
|
logging.debug('BAZARR deleted the previous subtitles synchronization attempt file.')
|
||||||
result = run(self.args)
|
result = run(self.args)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception(
|
logging.exception(
|
||||||
|
@ -95,7 +117,7 @@ class SubSyncer:
|
||||||
reversed_subtitles_path=srt_path,
|
reversed_subtitles_path=srt_path,
|
||||||
hearing_impaired=None)
|
hearing_impaired=None)
|
||||||
|
|
||||||
if media_type == 'series':
|
if sonarr_episode_id:
|
||||||
history_log(action=5, sonarr_series_id=sonarr_series_id, sonarr_episode_id=sonarr_episode_id,
|
history_log(action=5, sonarr_series_id=sonarr_series_id, sonarr_episode_id=sonarr_episode_id,
|
||||||
result=result)
|
result=result)
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -137,16 +137,16 @@ def manual_upload_subtitle(path, language, forced, hi, media_type, subtitle, aud
|
||||||
return
|
return
|
||||||
series_id = episode_metadata.sonarrSeriesId
|
series_id = episode_metadata.sonarrSeriesId
|
||||||
episode_id = episode_metadata.sonarrEpisodeId
|
episode_id = episode_metadata.sonarrEpisodeId
|
||||||
sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2, media_type=media_type,
|
sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2, percent_score=100,
|
||||||
percent_score=100, sonarr_series_id=episode_metadata.sonarrSeriesId, forced=forced,
|
sonarr_series_id=episode_metadata.sonarrSeriesId, forced=forced,
|
||||||
sonarr_episode_id=episode_metadata.sonarrEpisodeId)
|
sonarr_episode_id=episode_metadata.sonarrEpisodeId)
|
||||||
else:
|
else:
|
||||||
if not movie_metadata:
|
if not movie_metadata:
|
||||||
return
|
return
|
||||||
series_id = ""
|
series_id = ""
|
||||||
episode_id = movie_metadata.radarrId
|
episode_id = movie_metadata.radarrId
|
||||||
sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2, media_type=media_type,
|
sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2, percent_score=100,
|
||||||
percent_score=100, radarr_id=movie_metadata.radarrId, forced=forced)
|
radarr_id=movie_metadata.radarrId, forced=forced)
|
||||||
|
|
||||||
if use_postprocessing:
|
if use_postprocessing:
|
||||||
command = pp_replace(postprocessing_cmd, path, subtitle_path, uploaded_language, uploaded_language_code2,
|
command = pp_replace(postprocessing_cmd, path, subtitle_path, uploaded_language, uploaded_language_code2,
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
# coding=utf-8
|
# coding=utf-8
|
||||||
|
import ast
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import pickle
|
import pickle
|
||||||
|
|
||||||
from knowit.api import know, KnowitException
|
|
||||||
|
|
||||||
from languages.custom_lang import CustomLanguage
|
|
||||||
from languages.get_languages import language_from_alpha3, alpha3_from_alpha2
|
|
||||||
from app.database import TableEpisodes, TableMovies, database, update, select
|
|
||||||
from utilities.path_mappings import path_mappings
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.database import TableEpisodes, TableMovies, database, update, select
|
||||||
|
from languages.custom_lang import CustomLanguage
|
||||||
|
from languages.get_languages import language_from_alpha2, language_from_alpha3, alpha3_from_alpha2
|
||||||
|
from utilities.path_mappings import path_mappings
|
||||||
|
|
||||||
|
from knowit.api import know, KnowitException
|
||||||
|
|
||||||
|
|
||||||
def _handle_alpha3(detected_language: dict):
|
def _handle_alpha3(detected_language: dict):
|
||||||
|
@ -107,6 +108,110 @@ def embedded_audio_reader(file, file_size, episode_file_id=None, movie_file_id=N
|
||||||
return audio_list
|
return audio_list
|
||||||
|
|
||||||
|
|
||||||
|
def subtitles_sync_references(subtitles_path, sonarr_episode_id=None, radarr_movie_id=None):
|
||||||
|
references_dict = {'audio_tracks': [], 'embedded_subtitles_tracks': [], 'external_subtitles_tracks': []}
|
||||||
|
data = None
|
||||||
|
|
||||||
|
if sonarr_episode_id:
|
||||||
|
media_data = database.execute(
|
||||||
|
select(TableEpisodes.path, TableEpisodes.file_size, TableEpisodes.episode_file_id, TableEpisodes.subtitles)
|
||||||
|
.where(TableEpisodes.sonarrEpisodeId == sonarr_episode_id)) \
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if not media_data:
|
||||||
|
return references_dict
|
||||||
|
|
||||||
|
data = parse_video_metadata(media_data.path, media_data.file_size, media_data.episode_file_id, None,
|
||||||
|
use_cache=True)
|
||||||
|
elif radarr_movie_id:
|
||||||
|
media_data = database.execute(
|
||||||
|
select(TableMovies.path, TableMovies.file_size, TableMovies.movie_file_id, TableMovies.subtitles)
|
||||||
|
.where(TableMovies.radarrId == radarr_movie_id)) \
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if not media_data:
|
||||||
|
return references_dict
|
||||||
|
|
||||||
|
data = parse_video_metadata(media_data.path, media_data.file_size, None, media_data.movie_file_id,
|
||||||
|
use_cache=True)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return references_dict
|
||||||
|
|
||||||
|
cache_provider = None
|
||||||
|
if "ffprobe" in data and data["ffprobe"]:
|
||||||
|
cache_provider = 'ffprobe'
|
||||||
|
elif 'mediainfo' in data and data["mediainfo"]:
|
||||||
|
cache_provider = 'mediainfo'
|
||||||
|
|
||||||
|
if cache_provider:
|
||||||
|
if 'audio' in data[cache_provider]:
|
||||||
|
track_id = 0
|
||||||
|
for detected_language in data[cache_provider]["audio"]:
|
||||||
|
name = detected_language.get("name", "").replace("(", "").replace(")", "")
|
||||||
|
|
||||||
|
if "language" not in detected_language:
|
||||||
|
language = 'Undefined'
|
||||||
|
else:
|
||||||
|
alpha3 = _handle_alpha3(detected_language)
|
||||||
|
language = language_from_alpha3(alpha3)
|
||||||
|
|
||||||
|
references_dict['audio_tracks'].append({'stream': f'a:{track_id}', 'name': name, 'language': language})
|
||||||
|
|
||||||
|
track_id += 1
|
||||||
|
|
||||||
|
if 'subtitle' in data[cache_provider]:
|
||||||
|
track_id = 0
|
||||||
|
bitmap_subs = ['dvd', 'pgs']
|
||||||
|
for detected_language in data[cache_provider]["subtitle"]:
|
||||||
|
if any([x in detected_language.get("name", "").lower() for x in bitmap_subs]):
|
||||||
|
# skipping bitmap based subtitles
|
||||||
|
track_id += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = detected_language.get("name", "").replace("(", "").replace(")", "")
|
||||||
|
|
||||||
|
if "language" not in detected_language:
|
||||||
|
language = 'Undefined'
|
||||||
|
else:
|
||||||
|
alpha3 = _handle_alpha3(detected_language)
|
||||||
|
language = language_from_alpha3(alpha3)
|
||||||
|
|
||||||
|
forced = detected_language.get("forced", False)
|
||||||
|
hearing_impaired = detected_language.get("hearing_impaired", False)
|
||||||
|
|
||||||
|
references_dict['embedded_subtitles_tracks'].append(
|
||||||
|
{'stream': f's:{track_id}', 'name': name, 'language': language, 'forced': forced,
|
||||||
|
'hearing_impaired': hearing_impaired}
|
||||||
|
)
|
||||||
|
|
||||||
|
track_id += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_subtitles = ast.literal_eval(media_data.subtitles)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
for subtitles in parsed_subtitles:
|
||||||
|
reversed_subtitles_path = path_mappings.path_replace_reverse(subtitles_path) if sonarr_episode_id else (
|
||||||
|
path_mappings.path_replace_reverse_movie(subtitles_path))
|
||||||
|
if subtitles[1] and subtitles[1] != reversed_subtitles_path:
|
||||||
|
language_dict = languages_from_colon_seperated_string(subtitles[0])
|
||||||
|
references_dict['external_subtitles_tracks'].append({
|
||||||
|
'name': os.path.basename(subtitles[1]),
|
||||||
|
'path': path_mappings.path_replace(subtitles[1]) if sonarr_episode_id else
|
||||||
|
path_mappings.path_replace_reverse_movie(subtitles[1]),
|
||||||
|
'language': language_dict['language'],
|
||||||
|
'forced': language_dict['forced'],
|
||||||
|
'hearing_impaired': language_dict['hi'],
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# excluding subtitles that is going to be synced from the external subtitles list
|
||||||
|
continue
|
||||||
|
|
||||||
|
return references_dict
|
||||||
|
|
||||||
|
|
||||||
def parse_video_metadata(file, file_size, episode_file_id=None, movie_file_id=None, use_cache=True):
|
def parse_video_metadata(file, file_size, episode_file_id=None, movie_file_id=None, use_cache=True):
|
||||||
# Define default data keys value
|
# Define default data keys value
|
||||||
data = {
|
data = {
|
||||||
|
@ -195,3 +300,15 @@ def parse_video_metadata(file, file_size, episode_file_id=None, movie_file_id=No
|
||||||
.values(ffprobe_cache=pickle.dumps(data, pickle.HIGHEST_PROTOCOL))
|
.values(ffprobe_cache=pickle.dumps(data, pickle.HIGHEST_PROTOCOL))
|
||||||
.where(TableMovies.path == path_mappings.path_replace_reverse_movie(file)))
|
.where(TableMovies.path == path_mappings.path_replace_reverse_movie(file)))
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def languages_from_colon_seperated_string(lang):
|
||||||
|
splitted_language = lang.split(':')
|
||||||
|
language = language_from_alpha2(splitted_language[0])
|
||||||
|
forced = hi = False
|
||||||
|
if len(splitted_language) > 1:
|
||||||
|
if splitted_language[1] == 'forced':
|
||||||
|
forced = True
|
||||||
|
elif splitted_language[1] == 'hi':
|
||||||
|
hi = True
|
||||||
|
return {'language': language, 'forced': forced, 'hi': hi}
|
||||||
|
|
|
@ -125,3 +125,27 @@ export function useSubtitleInfos(names: string[]) {
|
||||||
api.subtitles.info(names)
|
api.subtitles.info(names)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useRefTracksByEpisodeId(
|
||||||
|
subtitlesPath: string,
|
||||||
|
sonarrEpisodeId: number,
|
||||||
|
isEpisode: boolean
|
||||||
|
) {
|
||||||
|
return useQuery(
|
||||||
|
[QueryKeys.Episodes, sonarrEpisodeId, QueryKeys.Subtitles, subtitlesPath],
|
||||||
|
() => api.subtitles.getRefTracksByEpisodeId(subtitlesPath, sonarrEpisodeId),
|
||||||
|
{ enabled: isEpisode }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRefTracksByMovieId(
|
||||||
|
subtitlesPath: string,
|
||||||
|
radarrMovieId: number,
|
||||||
|
isMovie: boolean
|
||||||
|
) {
|
||||||
|
return useQuery(
|
||||||
|
[QueryKeys.Movies, radarrMovieId, QueryKeys.Subtitles, subtitlesPath],
|
||||||
|
() => api.subtitles.getRefTracksByMovieId(subtitlesPath, radarrMovieId),
|
||||||
|
{ enabled: isMovie }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -5,6 +5,28 @@ class SubtitlesApi extends BaseApi {
|
||||||
super("/subtitles");
|
super("/subtitles");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRefTracksByEpisodeId(
|
||||||
|
subtitlesPath: string,
|
||||||
|
sonarrEpisodeId: number
|
||||||
|
) {
|
||||||
|
const response = await this.get<DataWrapper<Item.RefTracks>>("", {
|
||||||
|
subtitlesPath,
|
||||||
|
sonarrEpisodeId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRefTracksByMovieId(
|
||||||
|
subtitlesPath: string,
|
||||||
|
radarrMovieId?: number | undefined
|
||||||
|
) {
|
||||||
|
const response = await this.get<DataWrapper<Item.RefTracks>>("", {
|
||||||
|
subtitlesPath,
|
||||||
|
radarrMovieId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
async info(names: string[]) {
|
async info(names: string[]) {
|
||||||
const response = await this.get<DataWrapper<SubtitleInfo[]>>(`/info`, {
|
const response = await this.get<DataWrapper<SubtitleInfo[]>>(`/info`, {
|
||||||
filenames: names,
|
filenames: names,
|
||||||
|
|
|
@ -25,6 +25,7 @@ import {
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Divider, List, Menu, MenuProps, ScrollArea } from "@mantine/core";
|
import { Divider, List, Menu, MenuProps, ScrollArea } from "@mantine/core";
|
||||||
import { FunctionComponent, ReactElement, useCallback, useMemo } from "react";
|
import { FunctionComponent, ReactElement, useCallback, useMemo } from "react";
|
||||||
|
import { SyncSubtitleModal } from "./forms/SyncSubtitleForm";
|
||||||
|
|
||||||
export interface ToolOptions {
|
export interface ToolOptions {
|
||||||
key: string;
|
key: string;
|
||||||
|
@ -41,7 +42,8 @@ export function useTools() {
|
||||||
{
|
{
|
||||||
key: "sync",
|
key: "sync",
|
||||||
icon: faPlay,
|
icon: faPlay,
|
||||||
name: "Sync",
|
name: "Sync...",
|
||||||
|
modal: SyncSubtitleModal,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "remove_HI",
|
key: "remove_HI",
|
||||||
|
|
183
frontend/src/components/forms/SyncSubtitleForm.tsx
Normal file
183
frontend/src/components/forms/SyncSubtitleForm.tsx
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
/* eslint-disable camelcase */
|
||||||
|
|
||||||
|
import {
|
||||||
|
useRefTracksByEpisodeId,
|
||||||
|
useRefTracksByMovieId,
|
||||||
|
useSubtitleAction,
|
||||||
|
} from "@/apis/hooks";
|
||||||
|
import { useModals, withModal } from "@/modules/modals";
|
||||||
|
import { task } from "@/modules/task";
|
||||||
|
import { syncMaxOffsetSecondsOptions } from "@/pages/Settings/Subtitles/options";
|
||||||
|
import { toPython } from "@/utilities";
|
||||||
|
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { Alert, Button, Checkbox, Divider, Stack, Text } from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
import { Selector, SelectorOption } from "../inputs";
|
||||||
|
|
||||||
|
const TaskName = "Syncing Subtitle";
|
||||||
|
|
||||||
|
function useReferencedSubtitles(
|
||||||
|
mediaType: "episode" | "movie",
|
||||||
|
mediaId: number,
|
||||||
|
subtitlesPath: string
|
||||||
|
) {
|
||||||
|
// We cannot call hooks conditionally, we rely on useQuery "enabled" option to do only the required API call
|
||||||
|
const episodeData = useRefTracksByEpisodeId(
|
||||||
|
subtitlesPath,
|
||||||
|
mediaId,
|
||||||
|
mediaType === "episode"
|
||||||
|
);
|
||||||
|
const movieData = useRefTracksByMovieId(
|
||||||
|
subtitlesPath,
|
||||||
|
mediaId,
|
||||||
|
mediaType === "movie"
|
||||||
|
);
|
||||||
|
|
||||||
|
const mediaData = mediaType === "episode" ? episodeData : movieData;
|
||||||
|
|
||||||
|
const subtitles: { group: string; value: string; label: string }[] = [];
|
||||||
|
|
||||||
|
if (!mediaData.data) {
|
||||||
|
return [];
|
||||||
|
} else {
|
||||||
|
if (mediaData.data.audio_tracks.length > 0) {
|
||||||
|
mediaData.data.audio_tracks.forEach((item) => {
|
||||||
|
subtitles.push({
|
||||||
|
group: "Embedded audio tracks",
|
||||||
|
value: item.stream,
|
||||||
|
label: `${item.name || item.language} (${item.stream})`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaData.data.embedded_subtitles_tracks.length > 0) {
|
||||||
|
mediaData.data.embedded_subtitles_tracks.forEach((item) => {
|
||||||
|
subtitles.push({
|
||||||
|
group: "Embedded subtitles tracks",
|
||||||
|
value: item.stream,
|
||||||
|
label: `${item.name || item.language} (${item.stream})`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaData.data.external_subtitles_tracks.length > 0) {
|
||||||
|
mediaData.data.external_subtitles_tracks.forEach((item) => {
|
||||||
|
if (item) {
|
||||||
|
subtitles.push({
|
||||||
|
group: "External Subtitles files",
|
||||||
|
value: item.path,
|
||||||
|
label: item.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return subtitles;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selections: FormType.ModifySubtitle[];
|
||||||
|
onSubmit?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
reference?: string;
|
||||||
|
maxOffsetSeconds?: string;
|
||||||
|
noFixFramerate: boolean;
|
||||||
|
gss: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SyncSubtitleForm: FunctionComponent<Props> = ({
|
||||||
|
selections,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
if (selections.length === 0) {
|
||||||
|
throw new Error("You need to select at least 1 media to sync");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mutateAsync } = useSubtitleAction();
|
||||||
|
const modals = useModals();
|
||||||
|
|
||||||
|
const mediaType = selections[0].type;
|
||||||
|
const mediaId = selections[0].id;
|
||||||
|
const subtitlesPath = selections[0].path;
|
||||||
|
|
||||||
|
const subtitles: SelectorOption<string>[] = useReferencedSubtitles(
|
||||||
|
mediaType,
|
||||||
|
mediaId,
|
||||||
|
subtitlesPath
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
initialValues: {
|
||||||
|
noFixFramerate: false,
|
||||||
|
gss: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit((parameters) => {
|
||||||
|
selections.forEach((s) => {
|
||||||
|
const form: FormType.ModifySubtitle = {
|
||||||
|
...s,
|
||||||
|
reference: parameters.reference,
|
||||||
|
max_offset_seconds: parameters.maxOffsetSeconds,
|
||||||
|
no_fix_framerate: toPython(parameters.noFixFramerate),
|
||||||
|
gss: toPython(parameters.gss),
|
||||||
|
};
|
||||||
|
|
||||||
|
task.create(s.path, TaskName, mutateAsync, { action: "sync", form });
|
||||||
|
});
|
||||||
|
|
||||||
|
onSubmit?.();
|
||||||
|
modals.closeSelf();
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Alert
|
||||||
|
title="Subtitles"
|
||||||
|
color="gray"
|
||||||
|
icon={<FontAwesomeIcon icon={faInfoCircle}></FontAwesomeIcon>}
|
||||||
|
>
|
||||||
|
<Text size="sm">{selections.length} subtitles selected</Text>
|
||||||
|
</Alert>
|
||||||
|
<Selector
|
||||||
|
clearable
|
||||||
|
disabled={subtitles.length === 0 || selections.length !== 1}
|
||||||
|
label="Reference"
|
||||||
|
placeholder="Default: choose automatically within video file"
|
||||||
|
options={subtitles}
|
||||||
|
{...form.getInputProps("reference")}
|
||||||
|
></Selector>
|
||||||
|
<Selector
|
||||||
|
clearable
|
||||||
|
label="Max Offset Seconds"
|
||||||
|
options={syncMaxOffsetSecondsOptions}
|
||||||
|
placeholder="Select..."
|
||||||
|
{...form.getInputProps("maxOffsetSeconds")}
|
||||||
|
></Selector>
|
||||||
|
<Checkbox
|
||||||
|
label="No Fix Framerate"
|
||||||
|
{...form.getInputProps("noFixFramerate")}
|
||||||
|
></Checkbox>
|
||||||
|
<Checkbox
|
||||||
|
label="Golden-Section Search"
|
||||||
|
{...form.getInputProps("gss")}
|
||||||
|
></Checkbox>
|
||||||
|
<Divider></Divider>
|
||||||
|
<Button type="submit">Sync</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SyncSubtitleModal = withModal(SyncSubtitleForm, "sync-subtitle", {
|
||||||
|
title: "Sync Subtitle Options",
|
||||||
|
size: "lg",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SyncSubtitleForm;
|
|
@ -1,5 +1,15 @@
|
||||||
|
import { antiCaptchaOption } from "@/pages/Settings/Providers/options";
|
||||||
|
import { Anchor } from "@mantine/core";
|
||||||
import { FunctionComponent } from "react";
|
import { FunctionComponent } from "react";
|
||||||
import { Layout, Section } from "../components";
|
import {
|
||||||
|
CollapseBox,
|
||||||
|
Layout,
|
||||||
|
Message,
|
||||||
|
Password,
|
||||||
|
Section,
|
||||||
|
Selector,
|
||||||
|
Text,
|
||||||
|
} from "../components";
|
||||||
import { ProviderView } from "./components";
|
import { ProviderView } from "./components";
|
||||||
|
|
||||||
const SettingsProvidersView: FunctionComponent = () => {
|
const SettingsProvidersView: FunctionComponent = () => {
|
||||||
|
@ -8,6 +18,47 @@ const SettingsProvidersView: FunctionComponent = () => {
|
||||||
<Section header="Providers">
|
<Section header="Providers">
|
||||||
<ProviderView></ProviderView>
|
<ProviderView></ProviderView>
|
||||||
</Section>
|
</Section>
|
||||||
|
<Section header="Anti-Captcha Options">
|
||||||
|
<Selector
|
||||||
|
clearable
|
||||||
|
label={"Choose the anti-captcha provider you want to use"}
|
||||||
|
placeholder="Select a provider"
|
||||||
|
settingKey="settings-general-anti_captcha_provider"
|
||||||
|
settingOptions={{ onSubmit: (v) => (v === undefined ? "None" : v) }}
|
||||||
|
options={antiCaptchaOption}
|
||||||
|
></Selector>
|
||||||
|
<Message></Message>
|
||||||
|
<CollapseBox
|
||||||
|
settingKey="settings-general-anti_captcha_provider"
|
||||||
|
on={(value) => value === "anti-captcha"}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
label="Account Key"
|
||||||
|
settingKey="settings-anticaptcha-anti_captcha_key"
|
||||||
|
></Text>
|
||||||
|
<Anchor href="http://getcaptchasolution.com/eixxo1rsnw">
|
||||||
|
Anti-Captcha.com
|
||||||
|
</Anchor>
|
||||||
|
<Message>Link to subscribe</Message>
|
||||||
|
</CollapseBox>
|
||||||
|
<CollapseBox
|
||||||
|
settingKey="settings-general-anti_captcha_provider"
|
||||||
|
on={(value) => value === "death-by-captcha"}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
label="Username"
|
||||||
|
settingKey="settings-deathbycaptcha-username"
|
||||||
|
></Text>
|
||||||
|
<Password
|
||||||
|
label="Password"
|
||||||
|
settingKey="settings-deathbycaptcha-password"
|
||||||
|
></Password>
|
||||||
|
<Anchor href="https://www.deathbycaptcha.com">
|
||||||
|
DeathByCaptcha.com
|
||||||
|
</Anchor>
|
||||||
|
<Message>Link to subscribe</Message>
|
||||||
|
</CollapseBox>
|
||||||
|
</Section>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
12
frontend/src/pages/Settings/Providers/options.ts
Normal file
12
frontend/src/pages/Settings/Providers/options.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { SelectorOption } from "@/components";
|
||||||
|
|
||||||
|
export const antiCaptchaOption: SelectorOption<string>[] = [
|
||||||
|
{
|
||||||
|
label: "Anti-Captcha",
|
||||||
|
value: "anti-captcha",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Death by Captcha",
|
||||||
|
value: "death-by-captcha",
|
||||||
|
},
|
||||||
|
];
|
|
@ -1,4 +1,4 @@
|
||||||
import { Anchor, Code, Table } from "@mantine/core";
|
import { Code, Table } from "@mantine/core";
|
||||||
import { FunctionComponent } from "react";
|
import { FunctionComponent } from "react";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
|
@ -6,7 +6,6 @@ import {
|
||||||
Layout,
|
Layout,
|
||||||
Message,
|
Message,
|
||||||
MultiSelector,
|
MultiSelector,
|
||||||
Password,
|
|
||||||
Section,
|
Section,
|
||||||
Selector,
|
Selector,
|
||||||
Slider,
|
Slider,
|
||||||
|
@ -19,12 +18,12 @@ import {
|
||||||
import {
|
import {
|
||||||
adaptiveSearchingDelayOption,
|
adaptiveSearchingDelayOption,
|
||||||
adaptiveSearchingDeltaOption,
|
adaptiveSearchingDeltaOption,
|
||||||
antiCaptchaOption,
|
|
||||||
colorOptions,
|
colorOptions,
|
||||||
embeddedSubtitlesParserOption,
|
embeddedSubtitlesParserOption,
|
||||||
folderOptions,
|
folderOptions,
|
||||||
hiExtensionOptions,
|
hiExtensionOptions,
|
||||||
providerOptions,
|
providerOptions,
|
||||||
|
syncMaxOffsetSecondsOptions,
|
||||||
} from "./options";
|
} from "./options";
|
||||||
|
|
||||||
interface CommandOption {
|
interface CommandOption {
|
||||||
|
@ -128,7 +127,7 @@ const commandOptionElements: JSX.Element[] = commandOptions.map((op, idx) => (
|
||||||
const SettingsSubtitlesView: FunctionComponent = () => {
|
const SettingsSubtitlesView: FunctionComponent = () => {
|
||||||
return (
|
return (
|
||||||
<Layout name="Subtitles">
|
<Layout name="Subtitles">
|
||||||
<Section header="Subtitles Options">
|
<Section header="Basic Options">
|
||||||
<Selector
|
<Selector
|
||||||
label="Subtitle Folder"
|
label="Subtitle Folder"
|
||||||
options={folderOptions}
|
options={folderOptions}
|
||||||
|
@ -146,6 +145,65 @@ const SettingsSubtitlesView: FunctionComponent = () => {
|
||||||
settingKey="settings-general-subfolder_custom"
|
settingKey="settings-general-subfolder_custom"
|
||||||
></Text>
|
></Text>
|
||||||
</CollapseBox>
|
</CollapseBox>
|
||||||
|
<Selector
|
||||||
|
label="Hearing-impaired subtitles extension"
|
||||||
|
options={hiExtensionOptions}
|
||||||
|
settingKey="settings-general-hi_extension"
|
||||||
|
></Selector>
|
||||||
|
<Message>
|
||||||
|
What file extension to use when saving hearing-impaired subtitles to
|
||||||
|
disk (e.g., video.en.sdh.srt).
|
||||||
|
</Message>
|
||||||
|
</Section>
|
||||||
|
<Section header="Embedded Subtitles">
|
||||||
|
<Check
|
||||||
|
label="Use Embedded Subtitles"
|
||||||
|
settingKey="settings-general-use_embedded_subs"
|
||||||
|
></Check>
|
||||||
|
<Message>
|
||||||
|
Use embedded subtitles in media files when determining missing ones.
|
||||||
|
</Message>
|
||||||
|
<CollapseBox indent settingKey="settings-general-use_embedded_subs">
|
||||||
|
<Selector
|
||||||
|
settingKey="settings-general-embedded_subtitles_parser"
|
||||||
|
settingOptions={{
|
||||||
|
onSaved: (v) => (v === undefined ? "ffprobe" : v),
|
||||||
|
}}
|
||||||
|
options={embeddedSubtitlesParserOption}
|
||||||
|
></Selector>
|
||||||
|
<Message>Embedded subtitles video parser</Message>
|
||||||
|
<Check
|
||||||
|
label="Ignore Embedded PGS Subtitles"
|
||||||
|
settingKey="settings-general-ignore_pgs_subs"
|
||||||
|
></Check>
|
||||||
|
<Message>
|
||||||
|
Ignores PGS Subtitles in Embedded Subtitles detection.
|
||||||
|
</Message>
|
||||||
|
<Check
|
||||||
|
label="Ignore Embedded VobSub Subtitles"
|
||||||
|
settingKey="settings-general-ignore_vobsub_subs"
|
||||||
|
></Check>
|
||||||
|
<Message>
|
||||||
|
Ignores VobSub Subtitles in Embedded Subtitles detection.
|
||||||
|
</Message>
|
||||||
|
<Check
|
||||||
|
label="Ignore Embedded ASS Subtitles"
|
||||||
|
settingKey="settings-general-ignore_ass_subs"
|
||||||
|
></Check>
|
||||||
|
<Message>
|
||||||
|
Ignores ASS Subtitles in Embedded Subtitles detection.
|
||||||
|
</Message>
|
||||||
|
<Check
|
||||||
|
label="Show Only Desired Languages"
|
||||||
|
settingKey="settings-general-embedded_subs_show_desired"
|
||||||
|
></Check>
|
||||||
|
<Message>
|
||||||
|
Hide embedded subtitles for languages that are not currently
|
||||||
|
desired.
|
||||||
|
</Message>
|
||||||
|
</CollapseBox>
|
||||||
|
</Section>
|
||||||
|
<Section header="Upgrading Subtitles">
|
||||||
<Check
|
<Check
|
||||||
label="Upgrade Previously Downloaded Subtitles"
|
label="Upgrade Previously Downloaded Subtitles"
|
||||||
settingKey="settings-general-upgrade_subs"
|
settingKey="settings-general-upgrade_subs"
|
||||||
|
@ -171,52 +229,25 @@ const SettingsSubtitlesView: FunctionComponent = () => {
|
||||||
subtitles.
|
subtitles.
|
||||||
</Message>
|
</Message>
|
||||||
</CollapseBox>
|
</CollapseBox>
|
||||||
<Selector
|
</Section>
|
||||||
label="Hearing-impaired subtitles extension"
|
<Section header="Encoding">
|
||||||
options={hiExtensionOptions}
|
<Check
|
||||||
settingKey="settings-general-hi_extension"
|
label="Encode Subtitles To UTF8"
|
||||||
></Selector>
|
settingKey="settings-general-utf8_encode"
|
||||||
|
></Check>
|
||||||
<Message>
|
<Message>
|
||||||
What file extension to use when saving hearing-impaired subtitles to
|
Re-encode downloaded Subtitles to UTF8. Should be left enabled in most
|
||||||
disk (e.g., video.en.sdh.srt).
|
case.
|
||||||
</Message>
|
</Message>
|
||||||
</Section>
|
</Section>
|
||||||
<Section header="Anti-Captcha Options">
|
<Section header="Permissions">
|
||||||
<Selector
|
<Check
|
||||||
clearable
|
label="Change file permission (chmod)"
|
||||||
placeholder="Select a provider"
|
settingKey="settings-general-chmod_enabled"
|
||||||
settingKey="settings-general-anti_captcha_provider"
|
></Check>
|
||||||
settingOptions={{ onSubmit: (v) => (v === undefined ? "None" : v) }}
|
<CollapseBox indent settingKey="settings-general-chmod_enabled">
|
||||||
options={antiCaptchaOption}
|
<Text placeholder="0777" settingKey="settings-general-chmod"></Text>
|
||||||
></Selector>
|
<Message>Must be 4 digit octal</Message>
|
||||||
<Message>Choose the anti-captcha provider you want to use</Message>
|
|
||||||
<CollapseBox
|
|
||||||
settingKey="settings-general-anti_captcha_provider"
|
|
||||||
on={(value) => value === "anti-captcha"}
|
|
||||||
>
|
|
||||||
<Anchor href="http://getcaptchasolution.com/eixxo1rsnw">
|
|
||||||
Anti-Captcha.com
|
|
||||||
</Anchor>
|
|
||||||
<Text
|
|
||||||
label="Account Key"
|
|
||||||
settingKey="settings-anticaptcha-anti_captcha_key"
|
|
||||||
></Text>
|
|
||||||
</CollapseBox>
|
|
||||||
<CollapseBox
|
|
||||||
settingKey="settings-general-anti_captcha_provider"
|
|
||||||
on={(value) => value === "death-by-captcha"}
|
|
||||||
>
|
|
||||||
<Anchor href="https://www.deathbycaptcha.com">
|
|
||||||
DeathByCaptcha.com
|
|
||||||
</Anchor>
|
|
||||||
<Text
|
|
||||||
label="Username"
|
|
||||||
settingKey="settings-deathbycaptcha-username"
|
|
||||||
></Text>
|
|
||||||
<Password
|
|
||||||
label="Password"
|
|
||||||
settingKey="settings-deathbycaptcha-password"
|
|
||||||
></Password>
|
|
||||||
</CollapseBox>
|
</CollapseBox>
|
||||||
</Section>
|
</Section>
|
||||||
<Section header="Performance / Optimization">
|
<Section header="Performance / Optimization">
|
||||||
|
@ -258,52 +289,6 @@ const SettingsSubtitlesView: FunctionComponent = () => {
|
||||||
Search multiple providers at once (Don't choose this on low powered
|
Search multiple providers at once (Don't choose this on low powered
|
||||||
devices)
|
devices)
|
||||||
</Message>
|
</Message>
|
||||||
<Check
|
|
||||||
label="Use Embedded Subtitles"
|
|
||||||
settingKey="settings-general-use_embedded_subs"
|
|
||||||
></Check>
|
|
||||||
<Message>
|
|
||||||
Use embedded subtitles in media files when determining missing ones.
|
|
||||||
</Message>
|
|
||||||
<CollapseBox indent settingKey="settings-general-use_embedded_subs">
|
|
||||||
<Check
|
|
||||||
label="Ignore Embedded PGS Subtitles"
|
|
||||||
settingKey="settings-general-ignore_pgs_subs"
|
|
||||||
></Check>
|
|
||||||
<Message>
|
|
||||||
Ignores PGS Subtitles in Embedded Subtitles detection.
|
|
||||||
</Message>
|
|
||||||
<Check
|
|
||||||
label="Ignore Embedded VobSub Subtitles"
|
|
||||||
settingKey="settings-general-ignore_vobsub_subs"
|
|
||||||
></Check>
|
|
||||||
<Message>
|
|
||||||
Ignores VobSub Subtitles in Embedded Subtitles detection.
|
|
||||||
</Message>
|
|
||||||
<Check
|
|
||||||
label="Ignore Embedded ASS Subtitles"
|
|
||||||
settingKey="settings-general-ignore_ass_subs"
|
|
||||||
></Check>
|
|
||||||
<Message>
|
|
||||||
Ignores ASS Subtitles in Embedded Subtitles detection.
|
|
||||||
</Message>
|
|
||||||
<Check
|
|
||||||
label="Show Only Desired Languages"
|
|
||||||
settingKey="settings-general-embedded_subs_show_desired"
|
|
||||||
></Check>
|
|
||||||
<Message>
|
|
||||||
Hide embedded subtitles for languages that are not currently
|
|
||||||
desired.
|
|
||||||
</Message>
|
|
||||||
<Selector
|
|
||||||
settingKey="settings-general-embedded_subtitles_parser"
|
|
||||||
settingOptions={{
|
|
||||||
onSaved: (v) => (v === undefined ? "ffprobe" : v),
|
|
||||||
}}
|
|
||||||
options={embeddedSubtitlesParserOption}
|
|
||||||
></Selector>
|
|
||||||
<Message>Embedded subtitles video parser</Message>
|
|
||||||
</CollapseBox>
|
|
||||||
<Check
|
<Check
|
||||||
label="Skip video file hash calculation"
|
label="Skip video file hash calculation"
|
||||||
settingKey="settings-general-skip_hashing"
|
settingKey="settings-general-skip_hashing"
|
||||||
|
@ -314,15 +299,7 @@ const SettingsSubtitlesView: FunctionComponent = () => {
|
||||||
search results scores.
|
search results scores.
|
||||||
</Message>
|
</Message>
|
||||||
</Section>
|
</Section>
|
||||||
<Section header="Post-Processing">
|
<Section header="Subzero Modifications">
|
||||||
<Check
|
|
||||||
label="Encode Subtitles To UTF8"
|
|
||||||
settingKey="settings-general-utf8_encode"
|
|
||||||
></Check>
|
|
||||||
<Message>
|
|
||||||
Re-encode downloaded Subtitles to UTF8. Should be left enabled in most
|
|
||||||
case.
|
|
||||||
</Message>
|
|
||||||
<Check
|
<Check
|
||||||
label="Hearing Impaired"
|
label="Hearing Impaired"
|
||||||
settingOptions={{ onLoaded: SubzeroModification("remove_HI") }}
|
settingOptions={{ onLoaded: SubzeroModification("remove_HI") }}
|
||||||
|
@ -390,14 +367,8 @@ const SettingsSubtitlesView: FunctionComponent = () => {
|
||||||
Reverses the punctuation in right-to-left subtitles for problematic
|
Reverses the punctuation in right-to-left subtitles for problematic
|
||||||
playback devices.
|
playback devices.
|
||||||
</Message>
|
</Message>
|
||||||
<Check
|
</Section>
|
||||||
label="Permission (chmod)"
|
<Section header="Synchronizarion / Alignement">
|
||||||
settingKey="settings-general-chmod_enabled"
|
|
||||||
></Check>
|
|
||||||
<CollapseBox indent settingKey="settings-general-chmod_enabled">
|
|
||||||
<Text placeholder="0777" settingKey="settings-general-chmod"></Text>
|
|
||||||
<Message>Must be 4 digit octal</Message>
|
|
||||||
</CollapseBox>
|
|
||||||
<Check
|
<Check
|
||||||
label="Always use Audio Track as Reference for Syncing"
|
label="Always use Audio Track as Reference for Syncing"
|
||||||
settingKey="settings-subsync-force_audio"
|
settingKey="settings-subsync-force_audio"
|
||||||
|
@ -406,6 +377,31 @@ const SettingsSubtitlesView: FunctionComponent = () => {
|
||||||
Use the audio track as reference for syncing, instead of using the
|
Use the audio track as reference for syncing, instead of using the
|
||||||
embedded subtitle.
|
embedded subtitle.
|
||||||
</Message>
|
</Message>
|
||||||
|
<Check
|
||||||
|
label="No Fix Framerate"
|
||||||
|
settingKey="settings-subsync-no_fix_framerate"
|
||||||
|
></Check>
|
||||||
|
<Message>
|
||||||
|
If specified, subsync will not attempt to correct a framerate mismatch
|
||||||
|
between reference and subtitles.
|
||||||
|
</Message>
|
||||||
|
<Check
|
||||||
|
label="Gold-Section Search"
|
||||||
|
settingKey="settings-subsync-gss"
|
||||||
|
></Check>
|
||||||
|
<Message>
|
||||||
|
If specified, use golden-section search to try to find the optimal
|
||||||
|
framerate ratio between video and subtitles.
|
||||||
|
</Message>
|
||||||
|
<Selector
|
||||||
|
label="Max offset seconds"
|
||||||
|
options={syncMaxOffsetSecondsOptions}
|
||||||
|
settingKey="settings-subsync-max_offset_seconds"
|
||||||
|
defaultValue={60}
|
||||||
|
></Selector>
|
||||||
|
<Message>
|
||||||
|
The max allowed offset seconds for any subtitle segment.
|
||||||
|
</Message>
|
||||||
<Check
|
<Check
|
||||||
label="Automatic Subtitles Synchronization"
|
label="Automatic Subtitles Synchronization"
|
||||||
settingKey="settings-subsync-use_subsync"
|
settingKey="settings-subsync-use_subsync"
|
||||||
|
@ -443,6 +439,8 @@ const SettingsSubtitlesView: FunctionComponent = () => {
|
||||||
<Slider settingKey="settings-subsync-subsync_movie_threshold"></Slider>
|
<Slider settingKey="settings-subsync-subsync_movie_threshold"></Slider>
|
||||||
</CollapseBox>
|
</CollapseBox>
|
||||||
</CollapseBox>
|
</CollapseBox>
|
||||||
|
</Section>
|
||||||
|
<Section header="Custom post-processing">
|
||||||
<Check
|
<Check
|
||||||
settingKey="settings-general-use_postprocessing"
|
settingKey="settings-general-use_postprocessing"
|
||||||
label="Custom Post-Processing"
|
label="Custom Post-Processing"
|
||||||
|
|
|
@ -31,17 +31,6 @@ export const folderOptions: SelectorOption<string>[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const antiCaptchaOption: SelectorOption<string>[] = [
|
|
||||||
{
|
|
||||||
label: "Anti-Captcha",
|
|
||||||
value: "anti-captcha",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Death by Captcha",
|
|
||||||
value: "death-by-captcha",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const embeddedSubtitlesParserOption: SelectorOption<string>[] = [
|
export const embeddedSubtitlesParserOption: SelectorOption<string>[] = [
|
||||||
{
|
{
|
||||||
label: "ffprobe (faster)",
|
label: "ffprobe (faster)",
|
||||||
|
@ -173,3 +162,22 @@ export const providerOptions: SelectorOption<string>[] = ProviderList.map(
|
||||||
value: v.key,
|
value: v.key,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const syncMaxOffsetSecondsOptions: SelectorOption<number>[] = [
|
||||||
|
{
|
||||||
|
label: "60",
|
||||||
|
value: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "120",
|
||||||
|
value: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "300",
|
||||||
|
value: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "600",
|
||||||
|
value: 600,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
28
frontend/src/types/api.d.ts
vendored
28
frontend/src/types/api.d.ts
vendored
|
@ -51,6 +51,28 @@ interface Subtitle {
|
||||||
path: string | null | undefined; // TODO: FIX ME!!!!!!
|
path: string | null | undefined; // TODO: FIX ME!!!!!!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AudioTrack {
|
||||||
|
stream: string;
|
||||||
|
name: string;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubtitleTrack {
|
||||||
|
stream: string;
|
||||||
|
name: string;
|
||||||
|
language: string;
|
||||||
|
forced: boolean;
|
||||||
|
hearing_impaired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExternalSubtitle {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
language: string;
|
||||||
|
forced: boolean;
|
||||||
|
hearing_impaired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface PathType {
|
interface PathType {
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
@ -149,6 +171,12 @@ declare namespace Item {
|
||||||
season: number;
|
season: number;
|
||||||
episode: number;
|
episode: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RefTracks = {
|
||||||
|
audio_tracks: AudioTrack[];
|
||||||
|
embedded_subtitles_tracks: SubtitleTrack[];
|
||||||
|
external_subtitles_tracks: ExternalSubtitle[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
declare namespace Wanted {
|
declare namespace Wanted {
|
||||||
|
|
7
frontend/src/types/form.d.ts
vendored
7
frontend/src/types/form.d.ts
vendored
|
@ -41,6 +41,13 @@ declare namespace FormType {
|
||||||
type: "episode" | "movie";
|
type: "episode" | "movie";
|
||||||
language: string;
|
language: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
forced?: PythonBoolean;
|
||||||
|
hi?: PythonBoolean;
|
||||||
|
original_format?: PythonBoolean;
|
||||||
|
reference?: string;
|
||||||
|
max_offset_seconds?: string;
|
||||||
|
no_fix_framerate?: PythonBoolean;
|
||||||
|
gss?: PythonBoolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DownloadSeries {
|
interface DownloadSeries {
|
||||||
|
|
3
frontend/src/types/settings.d.ts
vendored
3
frontend/src/types/settings.d.ts
vendored
|
@ -114,6 +114,9 @@ declare namespace Settings {
|
||||||
subsync_movie_threshold: number;
|
subsync_movie_threshold: number;
|
||||||
debug: boolean;
|
debug: boolean;
|
||||||
force_audio: boolean;
|
force_audio: boolean;
|
||||||
|
max_offset_seconds: number;
|
||||||
|
no_fix_framerate: boolean;
|
||||||
|
gss: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Analytic {
|
interface Analytic {
|
||||||
|
|
|
@ -59,6 +59,10 @@ export function filterSubtitleBy(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toPython(value: boolean): PythonBoolean {
|
||||||
|
return value ? "True" : "False";
|
||||||
|
}
|
||||||
|
|
||||||
export * from "./env";
|
export * from "./env";
|
||||||
export * from "./hooks";
|
export * from "./hooks";
|
||||||
export * from "./validate";
|
export * from "./validate";
|
||||||
|
|
|
@ -14,7 +14,7 @@ try:
|
||||||
datefmt="[%X]",
|
datefmt="[%X]",
|
||||||
handlers=[RichHandler(console=Console(file=sys.stderr))],
|
handlers=[RichHandler(console=Console(file=sys.stderr))],
|
||||||
)
|
)
|
||||||
except ImportError:
|
except: # noqa: E722
|
||||||
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
|
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
|
||||||
|
|
||||||
from .version import __version__ # noqa
|
from .version import __version__ # noqa
|
||||||
|
|
|
@ -8,11 +8,11 @@ import json
|
||||||
|
|
||||||
version_json = '''
|
version_json = '''
|
||||||
{
|
{
|
||||||
"date": "2022-01-07T20:35:34-0800",
|
"date": "2023-04-20T11:25:58+0100",
|
||||||
"dirty": false,
|
"dirty": false,
|
||||||
"error": null,
|
"error": null,
|
||||||
"full-revisionid": "9ae15d825b24b3445112683bbb7b2e4a9d3ecb8f",
|
"full-revisionid": "0953aa240101a7aa235438496f796ef5f8d69d5b",
|
||||||
"version": "0.4.20"
|
"version": "0.4.25"
|
||||||
}
|
}
|
||||||
''' # END VERSION_JSON
|
''' # END VERSION_JSON
|
||||||
|
|
||||||
|
|
|
@ -34,13 +34,16 @@ class FFTAligner(TransformerMixin):
|
||||||
convolve = np.copy(convolve)
|
convolve = np.copy(convolve)
|
||||||
if self.max_offset_samples is None:
|
if self.max_offset_samples is None:
|
||||||
return convolve
|
return convolve
|
||||||
offset_to_index = lambda offset: len(convolve) - 1 + offset - len(substring)
|
|
||||||
convolve[: offset_to_index(-self.max_offset_samples)] = float("-inf")
|
def _offset_to_index(offset):
|
||||||
convolve[offset_to_index(self.max_offset_samples) :] = float("-inf")
|
return len(convolve) - 1 + offset - len(substring)
|
||||||
|
|
||||||
|
convolve[: _offset_to_index(-self.max_offset_samples)] = float("-inf")
|
||||||
|
convolve[_offset_to_index(self.max_offset_samples) :] = float("-inf")
|
||||||
return convolve
|
return convolve
|
||||||
|
|
||||||
def _compute_argmax(self, convolve: np.ndarray, substring: np.ndarray) -> None:
|
def _compute_argmax(self, convolve: np.ndarray, substring: np.ndarray) -> None:
|
||||||
best_idx = np.argmax(convolve)
|
best_idx = int(np.argmax(convolve))
|
||||||
self.best_offset_ = len(convolve) - 1 - best_idx - len(substring)
|
self.best_offset_ = len(convolve) - 1 - best_idx - len(substring)
|
||||||
self.best_score_ = convolve[best_idx]
|
self.best_score_ = convolve[best_idx]
|
||||||
|
|
||||||
|
|
|
@ -202,10 +202,7 @@ def try_sync(
|
||||||
if args.output_encoding != "same":
|
if args.output_encoding != "same":
|
||||||
out_subs = out_subs.set_encoding(args.output_encoding)
|
out_subs = out_subs.set_encoding(args.output_encoding)
|
||||||
suppress_output_thresh = args.suppress_output_if_offset_less_than
|
suppress_output_thresh = args.suppress_output_if_offset_less_than
|
||||||
if suppress_output_thresh is None or (
|
if offset_seconds >= (suppress_output_thresh or float("-inf")):
|
||||||
scale_step.scale_factor == 1.0
|
|
||||||
and offset_seconds >= suppress_output_thresh
|
|
||||||
):
|
|
||||||
logger.info("writing output to {}".format(srtout or "stdout"))
|
logger.info("writing output to {}".format(srtout or "stdout"))
|
||||||
out_subs.write_file(srtout)
|
out_subs.write_file(srtout)
|
||||||
else:
|
else:
|
||||||
|
@ -216,11 +213,10 @@ def try_sync(
|
||||||
)
|
)
|
||||||
except FailedToFindAlignmentException as e:
|
except FailedToFindAlignmentException as e:
|
||||||
sync_was_successful = False
|
sync_was_successful = False
|
||||||
logger.error(e)
|
logger.error(str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
exc = e
|
exc = e
|
||||||
sync_was_successful = False
|
sync_was_successful = False
|
||||||
logger.error(e)
|
|
||||||
else:
|
else:
|
||||||
result["offset_seconds"] = offset_seconds
|
result["offset_seconds"] = offset_seconds
|
||||||
result["framerate_scale_factor"] = scale_step.scale_factor
|
result["framerate_scale_factor"] = scale_step.scale_factor
|
||||||
|
@ -362,23 +358,29 @@ def validate_args(args: argparse.Namespace) -> None:
|
||||||
)
|
)
|
||||||
if not args.srtin:
|
if not args.srtin:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"need to specify input srt if --overwrite-input is specified since we cannot overwrite stdin"
|
"need to specify input srt if --overwrite-input "
|
||||||
|
"is specified since we cannot overwrite stdin"
|
||||||
)
|
)
|
||||||
if args.srtout is not None:
|
if args.srtout is not None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"overwrite input set but output file specified; refusing to run in case this was not intended"
|
"overwrite input set but output file specified; "
|
||||||
|
"refusing to run in case this was not intended"
|
||||||
)
|
)
|
||||||
if args.extract_subs_from_stream is not None:
|
if args.extract_subs_from_stream is not None:
|
||||||
if args.make_test_case:
|
if args.make_test_case:
|
||||||
raise ValueError("test case is for sync and not subtitle extraction")
|
raise ValueError("test case is for sync and not subtitle extraction")
|
||||||
if args.srtin:
|
if args.srtin:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"stream specified for reference subtitle extraction; -i flag for sync input not allowed"
|
"stream specified for reference subtitle extraction; "
|
||||||
|
"-i flag for sync input not allowed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_file_permissions(args: argparse.Namespace) -> None:
|
def validate_file_permissions(args: argparse.Namespace) -> None:
|
||||||
error_string_template = "unable to {action} {file}; try ensuring file exists and has correct permissions"
|
error_string_template = (
|
||||||
|
"unable to {action} {file}; "
|
||||||
|
"try ensuring file exists and has correct permissions"
|
||||||
|
)
|
||||||
if args.reference is not None and not os.access(args.reference, os.R_OK):
|
if args.reference is not None and not os.access(args.reference, os.R_OK):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
error_string_template.format(action="read reference", file=args.reference)
|
error_string_template.format(action="read reference", file=args.reference)
|
||||||
|
@ -506,27 +508,27 @@ def run(
|
||||||
try:
|
try:
|
||||||
sync_was_successful = _run_impl(args, result)
|
sync_was_successful = _run_impl(args, result)
|
||||||
result["sync_was_successful"] = sync_was_successful
|
result["sync_was_successful"] = sync_was_successful
|
||||||
|
return result
|
||||||
finally:
|
finally:
|
||||||
if log_handler is None or log_path is None:
|
if log_handler is not None and log_path is not None:
|
||||||
return result
|
|
||||||
try:
|
|
||||||
log_handler.close()
|
log_handler.close()
|
||||||
logger.removeHandler(log_handler)
|
logger.removeHandler(log_handler)
|
||||||
if args.make_test_case:
|
if args.make_test_case:
|
||||||
result["retval"] += make_test_case(
|
result["retval"] += make_test_case(
|
||||||
args, _npy_savename(args), sync_was_successful
|
args, _npy_savename(args), sync_was_successful
|
||||||
)
|
)
|
||||||
finally:
|
|
||||||
if args.log_dir_path is None or not os.path.isdir(args.log_dir_path):
|
if args.log_dir_path is None or not os.path.isdir(args.log_dir_path):
|
||||||
os.remove(log_path)
|
os.remove(log_path)
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def add_main_args_for_cli(parser: argparse.ArgumentParser) -> None:
|
def add_main_args_for_cli(parser: argparse.ArgumentParser) -> None:
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"reference",
|
"reference",
|
||||||
nargs="?",
|
nargs="?",
|
||||||
help="Reference (video, subtitles, or a numpy array with VAD speech) to which to synchronize input subtitles.",
|
help=(
|
||||||
|
"Reference (video, subtitles, or a numpy array with VAD speech) "
|
||||||
|
"to which to synchronize input subtitles."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-i", "--srtin", nargs="*", help="Input subtitles file (default=stdin)."
|
"-i", "--srtin", nargs="*", help="Input subtitles file (default=stdin)."
|
||||||
|
@ -554,11 +556,13 @@ def add_main_args_for_cli(parser: argparse.ArgumentParser) -> None:
|
||||||
"--reference-track",
|
"--reference-track",
|
||||||
"--reftrack",
|
"--reftrack",
|
||||||
default=None,
|
default=None,
|
||||||
help="Which stream/track in the video file to use as reference, "
|
help=(
|
||||||
"formatted according to ffmpeg conventions. For example, 0:s:0 "
|
"Which stream/track in the video file to use as reference, "
|
||||||
"uses the first subtitle track; 0:a:3 would use the third audio track. "
|
"formatted according to ffmpeg conventions. For example, 0:s:0 "
|
||||||
"You can also drop the leading `0:`; i.e. use s:0 or a:3, respectively. "
|
"uses the first subtitle track; 0:a:3 would use the third audio track. "
|
||||||
"Example: `ffs ref.mkv -i in.srt -o out.srt --reference-stream s:2`",
|
"You can also drop the leading `0:`; i.e. use s:0 or a:3, respectively. "
|
||||||
|
"Example: `ffs ref.mkv -i in.srt -o out.srt --reference-stream s:2`"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -574,7 +578,10 @@ def add_cli_only_args(parser: argparse.ArgumentParser) -> None:
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--overwrite-input",
|
"--overwrite-input",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="If specified, will overwrite the input srt instead of writing the output to a new file.",
|
help=(
|
||||||
|
"If specified, will overwrite the input srt "
|
||||||
|
"instead of writing the output to a new file."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--encoding",
|
"--encoding",
|
||||||
|
@ -642,7 +649,14 @@ def add_cli_only_args(parser: argparse.ArgumentParser) -> None:
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--vad",
|
"--vad",
|
||||||
choices=["subs_then_webrtc", "webrtc", "subs_then_auditok", "auditok"],
|
choices=[
|
||||||
|
"subs_then_webrtc",
|
||||||
|
"webrtc",
|
||||||
|
"subs_then_auditok",
|
||||||
|
"auditok",
|
||||||
|
"subs_then_silero",
|
||||||
|
"silero",
|
||||||
|
],
|
||||||
default=None,
|
default=None,
|
||||||
help="Which voice activity detector to use for speech extraction "
|
help="Which voice activity detector to use for speech extraction "
|
||||||
"(if using video / audio as a reference, default={}).".format(DEFAULT_VAD),
|
"(if using video / audio as a reference, default={}).".format(DEFAULT_VAD),
|
||||||
|
@ -680,7 +694,10 @@ def add_cli_only_args(parser: argparse.ArgumentParser) -> None:
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--log-dir-path",
|
"--log-dir-path",
|
||||||
default=None,
|
default=None,
|
||||||
help="If provided, will save log file ffsubsync.log to this path (must be an existing directory).",
|
help=(
|
||||||
|
"If provided, will save log file ffsubsync.log to this path "
|
||||||
|
"(must be an existing directory)."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--gss",
|
"--gss",
|
||||||
|
@ -688,6 +705,11 @@ def add_cli_only_args(parser: argparse.ArgumentParser) -> None:
|
||||||
help="If specified, use golden-section search to try to find"
|
help="If specified, use golden-section search to try to find"
|
||||||
"the optimal framerate ratio between video and subtitles.",
|
"the optimal framerate ratio between video and subtitles.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--strict",
|
||||||
|
action="store_true",
|
||||||
|
help="If specified, refuse to parse srt files with formatting issues.",
|
||||||
|
)
|
||||||
parser.add_argument("--vlc-mode", action="store_true", help=argparse.SUPPRESS)
|
parser.add_argument("--vlc-mode", action="store_true", help=argparse.SUPPRESS)
|
||||||
parser.add_argument("--gui-mode", action="store_true", help=argparse.SUPPRESS)
|
parser.add_argument("--gui-mode", action="store_true", help=argparse.SUPPRESS)
|
||||||
parser.add_argument("--skip-sync", action="store_true", help=argparse.SUPPRESS)
|
parser.add_argument("--skip-sync", action="store_true", help=argparse.SUPPRESS)
|
||||||
|
|
|
@ -64,7 +64,11 @@ _menu = [
|
||||||
def make_parser():
|
def make_parser():
|
||||||
description = DESCRIPTION
|
description = DESCRIPTION
|
||||||
if update_available():
|
if update_available():
|
||||||
description += '\nUpdate available! Please go to "File" -> "Download latest release" to update FFsubsync.'
|
description += (
|
||||||
|
"\nUpdate available! Please go to "
|
||||||
|
'"File" -> "Download latest release"'
|
||||||
|
" to update FFsubsync."
|
||||||
|
)
|
||||||
parser = GooeyParser(description=description)
|
parser = GooeyParser(description=description)
|
||||||
main_group = parser.add_argument_group("Basic")
|
main_group = parser.add_argument_group("Basic")
|
||||||
main_group.add_argument(
|
main_group.add_argument(
|
||||||
|
|
|
@ -4,7 +4,37 @@ This module borrows and adapts `Pipeline` from `sklearn.pipeline` and
|
||||||
`TransformerMixin` from `sklearn.base` in the scikit-learn framework
|
`TransformerMixin` from `sklearn.base` in the scikit-learn framework
|
||||||
(commit hash d205638475ca542dc46862652e3bb0be663a8eac) to be precise).
|
(commit hash d205638475ca542dc46862652e3bb0be663a8eac) to be precise).
|
||||||
Both are BSD licensed and allow for this sort of thing; attribution
|
Both are BSD licensed and allow for this sort of thing; attribution
|
||||||
is given as a comment above each class.
|
is given as a comment above each class. License reproduced below:
|
||||||
|
|
||||||
|
BSD 3-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2007-2022 The scikit-learn developers.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
* Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
* Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
"""
|
"""
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from itertools import islice
|
from itertools import islice
|
||||||
|
@ -14,7 +44,7 @@ from typing_extensions import Protocol
|
||||||
|
|
||||||
class TransformerProtocol(Protocol):
|
class TransformerProtocol(Protocol):
|
||||||
fit: Callable[..., "TransformerProtocol"]
|
fit: Callable[..., "TransformerProtocol"]
|
||||||
transform: Callable[["TransformerProtocol", Any], Any]
|
transform: Callable[[Any], Any]
|
||||||
|
|
||||||
|
|
||||||
# Author: Gael Varoquaux <gael.varoquaux@normalesup.org>
|
# Author: Gael Varoquaux <gael.varoquaux@normalesup.org>
|
||||||
|
@ -176,7 +206,7 @@ class Pipeline:
|
||||||
)
|
)
|
||||||
step, param = pname.split("__", 1)
|
step, param = pname.split("__", 1)
|
||||||
fit_params_steps[step][param] = pval
|
fit_params_steps[step][param] = pval
|
||||||
for (step_idx, name, transformer) in self._iter(
|
for step_idx, name, transformer in self._iter(
|
||||||
with_final=False, filter_passthrough=False
|
with_final=False, filter_passthrough=False
|
||||||
):
|
):
|
||||||
if transformer is None or transformer == "passthrough":
|
if transformer is None or transformer == "passthrough":
|
||||||
|
|
|
@ -1,17 +1,24 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
import os
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
import logging
|
import logging
|
||||||
import io
|
import io
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import cast, Callable, Dict, Optional, Union
|
from typing import cast, Callable, Dict, List, Optional, Union
|
||||||
|
|
||||||
import ffmpeg
|
import ffmpeg
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import tqdm
|
import tqdm
|
||||||
|
|
||||||
from ffsubsync.constants import *
|
from ffsubsync.constants import (
|
||||||
|
DEFAULT_ENCODING,
|
||||||
|
DEFAULT_MAX_SUBTITLE_SECONDS,
|
||||||
|
DEFAULT_SCALE_FACTOR,
|
||||||
|
DEFAULT_START_SECONDS,
|
||||||
|
SAMPLE_RATE,
|
||||||
|
)
|
||||||
from ffsubsync.ffmpeg_utils import ffmpeg_bin_path, subprocess_args
|
from ffsubsync.ffmpeg_utils import ffmpeg_bin_path, subprocess_args
|
||||||
from ffsubsync.generic_subtitles import GenericSubtitle
|
from ffsubsync.generic_subtitles import GenericSubtitle
|
||||||
from ffsubsync.sklearn_shim import TransformerMixin
|
from ffsubsync.sklearn_shim import TransformerMixin
|
||||||
|
@ -144,7 +151,7 @@ def _make_webrtcvad_detector(
|
||||||
asegment[start * bytes_per_frame : stop * bytes_per_frame],
|
asegment[start * bytes_per_frame : stop * bytes_per_frame],
|
||||||
sample_rate=frame_rate,
|
sample_rate=frame_rate,
|
||||||
)
|
)
|
||||||
except:
|
except Exception:
|
||||||
is_speech = False
|
is_speech = False
|
||||||
failures += 1
|
failures += 1
|
||||||
# webrtcvad has low recall on mode 3, so treat non-speech as "not sure"
|
# webrtcvad has low recall on mode 3, so treat non-speech as "not sure"
|
||||||
|
@ -154,6 +161,49 @@ def _make_webrtcvad_detector(
|
||||||
return _detect
|
return _detect
|
||||||
|
|
||||||
|
|
||||||
|
def _make_silero_detector(
|
||||||
|
sample_rate: int, frame_rate: int, non_speech_label: float
|
||||||
|
) -> Callable[[bytes], np.ndarray]:
|
||||||
|
import torch
|
||||||
|
|
||||||
|
window_duration = 1.0 / sample_rate # duration in seconds
|
||||||
|
frames_per_window = int(window_duration * frame_rate + 0.5)
|
||||||
|
bytes_per_frame = 1
|
||||||
|
|
||||||
|
model, _ = torch.hub.load(
|
||||||
|
repo_or_dir="snakers4/silero-vad",
|
||||||
|
model="silero_vad",
|
||||||
|
force_reload=False,
|
||||||
|
onnx=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
exception_logged = False
|
||||||
|
|
||||||
|
def _detect(asegment) -> np.ndarray:
|
||||||
|
asegment = np.frombuffer(asegment, np.int16).astype(np.float32) / (1 << 15)
|
||||||
|
asegment = torch.FloatTensor(asegment)
|
||||||
|
media_bstring = []
|
||||||
|
failures = 0
|
||||||
|
for start in range(0, len(asegment) // bytes_per_frame, frames_per_window):
|
||||||
|
stop = min(start + frames_per_window, len(asegment))
|
||||||
|
try:
|
||||||
|
speech_prob = model(
|
||||||
|
asegment[start * bytes_per_frame : stop * bytes_per_frame],
|
||||||
|
frame_rate,
|
||||||
|
).item()
|
||||||
|
except Exception:
|
||||||
|
nonlocal exception_logged
|
||||||
|
if not exception_logged:
|
||||||
|
exception_logged = True
|
||||||
|
logger.exception("exception occurred during speech detection")
|
||||||
|
speech_prob = 0.0
|
||||||
|
failures += 1
|
||||||
|
media_bstring.append(1.0 - (1.0 - speech_prob) * (1.0 - non_speech_label))
|
||||||
|
return np.array(media_bstring)
|
||||||
|
|
||||||
|
return _detect
|
||||||
|
|
||||||
|
|
||||||
class ComputeSpeechFrameBoundariesMixin:
|
class ComputeSpeechFrameBoundariesMixin:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.start_frame_: Optional[int] = None
|
self.start_frame_: Optional[int] = None
|
||||||
|
@ -170,8 +220,8 @@ class ComputeSpeechFrameBoundariesMixin:
|
||||||
) -> "ComputeSpeechFrameBoundariesMixin":
|
) -> "ComputeSpeechFrameBoundariesMixin":
|
||||||
nz = np.nonzero(speech_frames > 0.5)[0]
|
nz = np.nonzero(speech_frames > 0.5)[0]
|
||||||
if len(nz) > 0:
|
if len(nz) > 0:
|
||||||
self.start_frame_ = np.min(nz)
|
self.start_frame_ = int(np.min(nz))
|
||||||
self.end_frame_ = np.max(nz)
|
self.end_frame_ = int(np.max(nz))
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
@ -287,9 +337,13 @@ class VideoSpeechTransformer(TransformerMixin):
|
||||||
detector = _make_auditok_detector(
|
detector = _make_auditok_detector(
|
||||||
self.sample_rate, self.frame_rate, self._non_speech_label
|
self.sample_rate, self.frame_rate, self._non_speech_label
|
||||||
)
|
)
|
||||||
|
elif "silero" in self.vad:
|
||||||
|
detector = _make_silero_detector(
|
||||||
|
self.sample_rate, self.frame_rate, self._non_speech_label
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError("unknown vad: %s" % self.vad)
|
raise ValueError("unknown vad: %s" % self.vad)
|
||||||
media_bstring = []
|
media_bstring: List[np.ndarray] = []
|
||||||
ffmpeg_args = [
|
ffmpeg_args = [
|
||||||
ffmpeg_bin_path(
|
ffmpeg_bin_path(
|
||||||
"ffmpeg", self.gui_mode, ffmpeg_resources_path=self.ffmpeg_path
|
"ffmpeg", self.gui_mode, ffmpeg_resources_path=self.ffmpeg_path
|
||||||
|
@ -324,10 +378,7 @@ class VideoSpeechTransformer(TransformerMixin):
|
||||||
windows_per_buffer = 10000
|
windows_per_buffer = 10000
|
||||||
simple_progress = 0.0
|
simple_progress = 0.0
|
||||||
|
|
||||||
@contextmanager
|
redirect_stderr = None
|
||||||
def redirect_stderr(enter_result=None):
|
|
||||||
yield enter_result
|
|
||||||
|
|
||||||
tqdm_extra_args = {}
|
tqdm_extra_args = {}
|
||||||
should_print_redirected_stderr = self.gui_mode
|
should_print_redirected_stderr = self.gui_mode
|
||||||
if self.gui_mode:
|
if self.gui_mode:
|
||||||
|
@ -337,6 +388,13 @@ class VideoSpeechTransformer(TransformerMixin):
|
||||||
tqdm_extra_args["file"] = sys.stdout
|
tqdm_extra_args["file"] = sys.stdout
|
||||||
except ImportError:
|
except ImportError:
|
||||||
should_print_redirected_stderr = False
|
should_print_redirected_stderr = False
|
||||||
|
if redirect_stderr is None:
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def redirect_stderr(enter_result=None):
|
||||||
|
yield enter_result
|
||||||
|
|
||||||
|
assert redirect_stderr is not None
|
||||||
pbar_output = io.StringIO()
|
pbar_output = io.StringIO()
|
||||||
with redirect_stderr(pbar_output):
|
with redirect_stderr(pbar_output):
|
||||||
with tqdm.tqdm(
|
with tqdm.tqdm(
|
||||||
|
@ -363,13 +421,17 @@ class VideoSpeechTransformer(TransformerMixin):
|
||||||
assert self.gui_mode
|
assert self.gui_mode
|
||||||
# no need to flush since we pass -u to do unbuffered output for gui mode
|
# no need to flush since we pass -u to do unbuffered output for gui mode
|
||||||
print(pbar_output.read())
|
print(pbar_output.read())
|
||||||
in_bytes = np.frombuffer(in_bytes, np.uint8)
|
if "silero" not in self.vad:
|
||||||
|
in_bytes = np.frombuffer(in_bytes, np.uint8)
|
||||||
media_bstring.append(detector(in_bytes))
|
media_bstring.append(detector(in_bytes))
|
||||||
|
process.wait()
|
||||||
if len(media_bstring) == 0:
|
if len(media_bstring) == 0:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Unable to detect speech. Perhaps try specifying a different stream / track, or a different vad."
|
"Unable to detect speech. "
|
||||||
|
"Perhaps try specifying a different stream / track, or a different vad."
|
||||||
)
|
)
|
||||||
self.video_speech_results_ = np.concatenate(media_bstring)
|
self.video_speech_results_ = np.concatenate(media_bstring)
|
||||||
|
logger.info("total of speech segments: %s", np.sum(self.video_speech_results_))
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def transform(self, *_) -> np.ndarray:
|
def transform(self, *_) -> np.ndarray:
|
||||||
|
|
|
@ -1,17 +1,29 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Any, cast, List, Optional
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import cchardet as chardet
|
import cchardet
|
||||||
except ImportError:
|
except: # noqa: E722
|
||||||
import chardet # type: ignore
|
cchardet = None
|
||||||
|
try:
|
||||||
|
import chardet
|
||||||
|
except: # noqa: E722
|
||||||
|
chardet = None
|
||||||
|
try:
|
||||||
|
import charset_normalizer
|
||||||
|
except: # noqa: E722
|
||||||
|
charset_normalizer = None
|
||||||
import pysubs2
|
import pysubs2
|
||||||
from ffsubsync.sklearn_shim import TransformerMixin
|
from ffsubsync.sklearn_shim import TransformerMixin
|
||||||
import srt
|
import srt
|
||||||
|
|
||||||
from ffsubsync.constants import *
|
from ffsubsync.constants import (
|
||||||
|
DEFAULT_ENCODING,
|
||||||
|
DEFAULT_MAX_SUBTITLE_SECONDS,
|
||||||
|
DEFAULT_START_SECONDS,
|
||||||
|
)
|
||||||
from ffsubsync.file_utils import open_file
|
from ffsubsync.file_utils import open_file
|
||||||
from ffsubsync.generic_subtitles import GenericSubtitle, GenericSubtitlesFile, SubsMixin
|
from ffsubsync.generic_subtitles import GenericSubtitle, GenericSubtitlesFile, SubsMixin
|
||||||
|
|
||||||
|
@ -61,6 +73,7 @@ class GenericSubtitleParser(SubsMixin, TransformerMixin):
|
||||||
max_subtitle_seconds: Optional[int] = None,
|
max_subtitle_seconds: Optional[int] = None,
|
||||||
start_seconds: int = 0,
|
start_seconds: int = 0,
|
||||||
skip_ssa_info: bool = False,
|
skip_ssa_info: bool = False,
|
||||||
|
strict: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
super(self.__class__, self).__init__()
|
super(self.__class__, self).__init__()
|
||||||
self.sub_format: str = fmt
|
self.sub_format: str = fmt
|
||||||
|
@ -72,6 +85,7 @@ class GenericSubtitleParser(SubsMixin, TransformerMixin):
|
||||||
self.start_seconds: int = start_seconds
|
self.start_seconds: int = start_seconds
|
||||||
# FIXME: hack to get tests to pass; remove
|
# FIXME: hack to get tests to pass; remove
|
||||||
self._skip_ssa_info: bool = skip_ssa_info
|
self._skip_ssa_info: bool = skip_ssa_info
|
||||||
|
self._strict: bool = strict
|
||||||
|
|
||||||
def fit(self, fname: str, *_) -> "GenericSubtitleParser":
|
def fit(self, fname: str, *_) -> "GenericSubtitleParser":
|
||||||
if self.caching and self.fit_fname == ("<stdin>" if fname is None else fname):
|
if self.caching and self.fit_fname == ("<stdin>" if fname is None else fname):
|
||||||
|
@ -80,15 +94,28 @@ class GenericSubtitleParser(SubsMixin, TransformerMixin):
|
||||||
with open_file(fname, "rb") as f:
|
with open_file(fname, "rb") as f:
|
||||||
subs = f.read()
|
subs = f.read()
|
||||||
if self.encoding == "infer":
|
if self.encoding == "infer":
|
||||||
encodings_to_try = (chardet.detect(subs)["encoding"],)
|
for chardet_lib in (cchardet, charset_normalizer, chardet):
|
||||||
self.detected_encoding_ = encodings_to_try[0]
|
if chardet_lib is not None:
|
||||||
|
try:
|
||||||
|
detected_encoding = cast(
|
||||||
|
Optional[str], chardet_lib.detect(subs)["encoding"]
|
||||||
|
)
|
||||||
|
except: # noqa: E722
|
||||||
|
continue
|
||||||
|
if detected_encoding is not None:
|
||||||
|
self.detected_encoding_ = detected_encoding
|
||||||
|
encodings_to_try = (detected_encoding,)
|
||||||
|
break
|
||||||
|
assert self.detected_encoding_ is not None
|
||||||
logger.info("detected encoding: %s" % self.detected_encoding_)
|
logger.info("detected encoding: %s" % self.detected_encoding_)
|
||||||
exc = None
|
exc = None
|
||||||
for encoding in encodings_to_try:
|
for encoding in encodings_to_try:
|
||||||
try:
|
try:
|
||||||
decoded_subs = subs.decode(encoding, errors="replace").strip()
|
decoded_subs = subs.decode(encoding, errors="replace").strip()
|
||||||
if self.sub_format == "srt":
|
if self.sub_format == "srt":
|
||||||
parsed_subs = srt.parse(decoded_subs)
|
parsed_subs = srt.parse(
|
||||||
|
decoded_subs, ignore_errors=not self._strict
|
||||||
|
)
|
||||||
elif self.sub_format in ("ass", "ssa", "sub"):
|
elif self.sub_format in ("ass", "ssa", "sub"):
|
||||||
parsed_subs = pysubs2.SSAFile.from_string(decoded_subs)
|
parsed_subs = pysubs2.SSAFile.from_string(decoded_subs)
|
||||||
else:
|
else:
|
||||||
|
@ -144,4 +171,5 @@ def make_subtitle_parser(
|
||||||
max_subtitle_seconds=max_subtitle_seconds,
|
max_subtitle_seconds=max_subtitle_seconds,
|
||||||
start_seconds=start_seconds,
|
start_seconds=start_seconds,
|
||||||
skip_ssa_info=kwargs.get("skip_ssa_info", False),
|
skip_ssa_info=kwargs.get("skip_ssa_info", False),
|
||||||
|
strict=kwargs.get("strict", False),
|
||||||
)
|
)
|
||||||
|
|
|
@ -10,7 +10,7 @@ deep-translator==1.9.1
|
||||||
dogpile.cache==1.1.8
|
dogpile.cache==1.1.8
|
||||||
dynaconf==3.1.12
|
dynaconf==3.1.12
|
||||||
fese==0.1.2
|
fese==0.1.2
|
||||||
ffsubsync==0.4.20
|
ffsubsync==0.4.25
|
||||||
Flask-Compress==1.13 # modified to import brotli only if required
|
Flask-Compress==1.13 # modified to import brotli only if required
|
||||||
flask-cors==3.0.10
|
flask-cors==3.0.10
|
||||||
flask-migrate==4.0.4
|
flask-migrate==4.0.4
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue