mirror of
https://github.com/morpheus65535/bazarr.git
synced 2024-11-10 09:02:44 +08: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 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 languages.get_languages import alpha3_from_alpha2
|
||||
from utilities.path_mappings import path_mappings
|
||||
from utilities.video_analyzer import subtitles_sync_references
|
||||
from subtitles.tools.subsyncer import SubSyncer
|
||||
from subtitles.tools.translate import translate_subtitles_file
|
||||
from subtitles.tools.mods import subtitles_apply_mods
|
||||
from subtitles.indexer.series import store_subtitles
|
||||
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 ..utils import authenticate
|
||||
|
@ -25,6 +26,56 @@ api_ns_subtitles = Namespace('Subtitles', description='Apply mods/tools on exter
|
|||
|
||||
@api_ns_subtitles.route('subtitles')
|
||||
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.add_argument('action', type=str, required=True,
|
||||
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('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('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('original_format', type=str, required=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
|
||||
@api_ns_subtitles.doc(parser=patch_request_parser)
|
||||
|
@ -79,19 +140,30 @@ class Subtitles(Resource):
|
|||
video_path = path_mappings.path_replace_movie(metadata.path)
|
||||
|
||||
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()
|
||||
if media_type == 'episode':
|
||||
subsync.sync(video_path=video_path, srt_path=subtitles_path,
|
||||
srt_lang=language, media_type='series', sonarr_series_id=metadata.sonarrSeriesId,
|
||||
sonarr_episode_id=id)
|
||||
else:
|
||||
try:
|
||||
subsync.sync(video_path=video_path, srt_path=subtitles_path,
|
||||
srt_lang=language, media_type='movies', radarr_id=id)
|
||||
except OSError:
|
||||
return 'Unable to edit subtitles file. Check logs.', 409
|
||||
del subsync
|
||||
gc.collect()
|
||||
try:
|
||||
if media_type == 'episode':
|
||||
sync_kwargs['sonarr_series_id'] = metadata.sonarrSeriesId
|
||||
sync_kwargs['sonarr_episode_id'] = id
|
||||
else:
|
||||
sync_kwargs['radarr_id'] = id
|
||||
subsync.sync(**sync_kwargs)
|
||||
except OSError:
|
||||
return 'Unable to edit subtitles file. Check logs.', 409
|
||||
finally:
|
||||
del subsync
|
||||
gc.collect()
|
||||
elif action == 'translate':
|
||||
from_language = subtitles_lang_from_filename(subtitles_path)
|
||||
dest_language = language
|
||||
|
|
|
@ -298,6 +298,10 @@ validators = [
|
|||
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_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
|
||||
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
|
||||
sync_subtitles(video_path=path, srt_path=downloaded_path,
|
||||
forced=subtitle.language.forced,
|
||||
srt_lang=downloaded_language_code2, media_type=media_type,
|
||||
srt_lang=downloaded_language_code2,
|
||||
percent_score=percent_score,
|
||||
sonarr_series_id=episode_metadata.sonarrSeriesId,
|
||||
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
|
||||
sync_subtitles(video_path=path, srt_path=downloaded_path,
|
||||
forced=subtitle.language.forced,
|
||||
srt_lang=downloaded_language_code2, media_type=media_type,
|
||||
srt_lang=downloaded_language_code2,
|
||||
percent_score=percent_score,
|
||||
radarr_id=movie_metadata.radarrId)
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ from app.config import settings
|
|||
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):
|
||||
if forced:
|
||||
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)):
|
||||
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)
|
||||
del subsync
|
||||
gc.collect()
|
||||
|
|
|
@ -30,8 +30,9 @@ class SubSyncer:
|
|||
self.vad = 'subs_then_webrtc'
|
||||
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,
|
||||
radarr_id=None):
|
||||
def sync(self, video_path, srt_path, srt_lang, sonarr_series_id=None, sonarr_episode_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.srtin = srt_path
|
||||
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)
|
||||
|
||||
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:
|
||||
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)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
|
@ -95,7 +117,7 @@ class SubSyncer:
|
|||
reversed_subtitles_path=srt_path,
|
||||
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,
|
||||
result=result)
|
||||
else:
|
||||
|
|
|
@ -137,16 +137,16 @@ def manual_upload_subtitle(path, language, forced, hi, media_type, subtitle, aud
|
|||
return
|
||||
series_id = episode_metadata.sonarrSeriesId
|
||||
episode_id = episode_metadata.sonarrEpisodeId
|
||||
sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2, media_type=media_type,
|
||||
percent_score=100, sonarr_series_id=episode_metadata.sonarrSeriesId, forced=forced,
|
||||
sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2, percent_score=100,
|
||||
sonarr_series_id=episode_metadata.sonarrSeriesId, forced=forced,
|
||||
sonarr_episode_id=episode_metadata.sonarrEpisodeId)
|
||||
else:
|
||||
if not movie_metadata:
|
||||
return
|
||||
series_id = ""
|
||||
episode_id = movie_metadata.radarrId
|
||||
sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2, media_type=media_type,
|
||||
percent_score=100, radarr_id=movie_metadata.radarrId, forced=forced)
|
||||
sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2, percent_score=100,
|
||||
radarr_id=movie_metadata.radarrId, forced=forced)
|
||||
|
||||
if use_postprocessing:
|
||||
command = pp_replace(postprocessing_cmd, path, subtitle_path, uploaded_language, uploaded_language_code2,
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
# coding=utf-8
|
||||
|
||||
import ast
|
||||
import logging
|
||||
import os
|
||||
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.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):
|
||||
|
@ -107,6 +108,110 @@ def embedded_audio_reader(file, file_size, episode_file_id=None, movie_file_id=N
|
|||
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):
|
||||
# Define default data keys value
|
||||
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))
|
||||
.where(TableMovies.path == path_mappings.path_replace_reverse_movie(file)))
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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[]) {
|
||||
const response = await this.get<DataWrapper<SubtitleInfo[]>>(`/info`, {
|
||||
filenames: names,
|
||||
|
|
|
@ -25,6 +25,7 @@ import {
|
|||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Divider, List, Menu, MenuProps, ScrollArea } from "@mantine/core";
|
||||
import { FunctionComponent, ReactElement, useCallback, useMemo } from "react";
|
||||
import { SyncSubtitleModal } from "./forms/SyncSubtitleForm";
|
||||
|
||||
export interface ToolOptions {
|
||||
key: string;
|
||||
|
@ -41,7 +42,8 @@ export function useTools() {
|
|||
{
|
||||
key: "sync",
|
||||
icon: faPlay,
|
||||
name: "Sync",
|
||||
name: "Sync...",
|
||||
modal: SyncSubtitleModal,
|
||||
},
|
||||
{
|
||||
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 { Layout, Section } from "../components";
|
||||
import {
|
||||
CollapseBox,
|
||||
Layout,
|
||||
Message,
|
||||
Password,
|
||||
Section,
|
||||
Selector,
|
||||
Text,
|
||||
} from "../components";
|
||||
import { ProviderView } from "./components";
|
||||
|
||||
const SettingsProvidersView: FunctionComponent = () => {
|
||||
|
@ -8,6 +18,47 @@ const SettingsProvidersView: FunctionComponent = () => {
|
|||
<Section header="Providers">
|
||||
<ProviderView></ProviderView>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
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 {
|
||||
Check,
|
||||
|
@ -6,7 +6,6 @@ import {
|
|||
Layout,
|
||||
Message,
|
||||
MultiSelector,
|
||||
Password,
|
||||
Section,
|
||||
Selector,
|
||||
Slider,
|
||||
|
@ -19,12 +18,12 @@ import {
|
|||
import {
|
||||
adaptiveSearchingDelayOption,
|
||||
adaptiveSearchingDeltaOption,
|
||||
antiCaptchaOption,
|
||||
colorOptions,
|
||||
embeddedSubtitlesParserOption,
|
||||
folderOptions,
|
||||
hiExtensionOptions,
|
||||
providerOptions,
|
||||
syncMaxOffsetSecondsOptions,
|
||||
} from "./options";
|
||||
|
||||
interface CommandOption {
|
||||
|
@ -128,7 +127,7 @@ const commandOptionElements: JSX.Element[] = commandOptions.map((op, idx) => (
|
|||
const SettingsSubtitlesView: FunctionComponent = () => {
|
||||
return (
|
||||
<Layout name="Subtitles">
|
||||
<Section header="Subtitles Options">
|
||||
<Section header="Basic Options">
|
||||
<Selector
|
||||
label="Subtitle Folder"
|
||||
options={folderOptions}
|
||||
|
@ -146,6 +145,65 @@ const SettingsSubtitlesView: FunctionComponent = () => {
|
|||
settingKey="settings-general-subfolder_custom"
|
||||
></Text>
|
||||
</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
|
||||
label="Upgrade Previously Downloaded Subtitles"
|
||||
settingKey="settings-general-upgrade_subs"
|
||||
|
@ -171,52 +229,25 @@ const SettingsSubtitlesView: FunctionComponent = () => {
|
|||
subtitles.
|
||||
</Message>
|
||||
</CollapseBox>
|
||||
<Selector
|
||||
label="Hearing-impaired subtitles extension"
|
||||
options={hiExtensionOptions}
|
||||
settingKey="settings-general-hi_extension"
|
||||
></Selector>
|
||||
</Section>
|
||||
<Section header="Encoding">
|
||||
<Check
|
||||
label="Encode Subtitles To UTF8"
|
||||
settingKey="settings-general-utf8_encode"
|
||||
></Check>
|
||||
<Message>
|
||||
What file extension to use when saving hearing-impaired subtitles to
|
||||
disk (e.g., video.en.sdh.srt).
|
||||
Re-encode downloaded Subtitles to UTF8. Should be left enabled in most
|
||||
case.
|
||||
</Message>
|
||||
</Section>
|
||||
<Section header="Anti-Captcha Options">
|
||||
<Selector
|
||||
clearable
|
||||
placeholder="Select a provider"
|
||||
settingKey="settings-general-anti_captcha_provider"
|
||||
settingOptions={{ onSubmit: (v) => (v === undefined ? "None" : v) }}
|
||||
options={antiCaptchaOption}
|
||||
></Selector>
|
||||
<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>
|
||||
<Section header="Permissions">
|
||||
<Check
|
||||
label="Change file permission (chmod)"
|
||||
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>
|
||||
</Section>
|
||||
<Section header="Performance / Optimization">
|
||||
|
@ -258,52 +289,6 @@ const SettingsSubtitlesView: FunctionComponent = () => {
|
|||
Search multiple providers at once (Don't choose this on low powered
|
||||
devices)
|
||||
</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
|
||||
label="Skip video file hash calculation"
|
||||
settingKey="settings-general-skip_hashing"
|
||||
|
@ -314,15 +299,7 @@ const SettingsSubtitlesView: FunctionComponent = () => {
|
|||
search results scores.
|
||||
</Message>
|
||||
</Section>
|
||||
<Section header="Post-Processing">
|
||||
<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>
|
||||
<Section header="Subzero Modifications">
|
||||
<Check
|
||||
label="Hearing Impaired"
|
||||
settingOptions={{ onLoaded: SubzeroModification("remove_HI") }}
|
||||
|
@ -390,14 +367,8 @@ const SettingsSubtitlesView: FunctionComponent = () => {
|
|||
Reverses the punctuation in right-to-left subtitles for problematic
|
||||
playback devices.
|
||||
</Message>
|
||||
<Check
|
||||
label="Permission (chmod)"
|
||||
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>
|
||||
</Section>
|
||||
<Section header="Synchronizarion / Alignement">
|
||||
<Check
|
||||
label="Always use Audio Track as Reference for Syncing"
|
||||
settingKey="settings-subsync-force_audio"
|
||||
|
@ -406,6 +377,31 @@ const SettingsSubtitlesView: FunctionComponent = () => {
|
|||
Use the audio track as reference for syncing, instead of using the
|
||||
embedded subtitle.
|
||||
</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
|
||||
label="Automatic Subtitles Synchronization"
|
||||
settingKey="settings-subsync-use_subsync"
|
||||
|
@ -443,6 +439,8 @@ const SettingsSubtitlesView: FunctionComponent = () => {
|
|||
<Slider settingKey="settings-subsync-subsync_movie_threshold"></Slider>
|
||||
</CollapseBox>
|
||||
</CollapseBox>
|
||||
</Section>
|
||||
<Section header="Custom post-processing">
|
||||
<Check
|
||||
settingKey="settings-general-use_postprocessing"
|
||||
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>[] = [
|
||||
{
|
||||
label: "ffprobe (faster)",
|
||||
|
@ -173,3 +162,22 @@ export const providerOptions: SelectorOption<string>[] = ProviderList.map(
|
|||
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!!!!!!
|
||||
}
|
||||
|
||||
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 {
|
||||
path: string;
|
||||
}
|
||||
|
@ -149,6 +171,12 @@ declare namespace Item {
|
|||
season: number;
|
||||
episode: number;
|
||||
};
|
||||
|
||||
type RefTracks = {
|
||||
audio_tracks: AudioTrack[];
|
||||
embedded_subtitles_tracks: SubtitleTrack[];
|
||||
external_subtitles_tracks: ExternalSubtitle[];
|
||||
};
|
||||
}
|
||||
|
||||
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";
|
||||
language: string;
|
||||
path: string;
|
||||
forced?: PythonBoolean;
|
||||
hi?: PythonBoolean;
|
||||
original_format?: PythonBoolean;
|
||||
reference?: string;
|
||||
max_offset_seconds?: string;
|
||||
no_fix_framerate?: PythonBoolean;
|
||||
gss?: PythonBoolean;
|
||||
}
|
||||
|
||||
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;
|
||||
debug: boolean;
|
||||
force_audio: boolean;
|
||||
max_offset_seconds: number;
|
||||
no_fix_framerate: boolean;
|
||||
gss: boolean;
|
||||
}
|
||||
|
||||
interface Analytic {
|
||||
|
|
|
@ -59,6 +59,10 @@ export function filterSubtitleBy(
|
|||
}
|
||||
}
|
||||
|
||||
export function toPython(value: boolean): PythonBoolean {
|
||||
return value ? "True" : "False";
|
||||
}
|
||||
|
||||
export * from "./env";
|
||||
export * from "./hooks";
|
||||
export * from "./validate";
|
||||
|
|
|
@ -14,7 +14,7 @@ try:
|
|||
datefmt="[%X]",
|
||||
handlers=[RichHandler(console=Console(file=sys.stderr))],
|
||||
)
|
||||
except ImportError:
|
||||
except: # noqa: E722
|
||||
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
|
||||
|
||||
from .version import __version__ # noqa
|
||||
|
|
|
@ -8,11 +8,11 @@ import json
|
|||
|
||||
version_json = '''
|
||||
{
|
||||
"date": "2022-01-07T20:35:34-0800",
|
||||
"date": "2023-04-20T11:25:58+0100",
|
||||
"dirty": false,
|
||||
"error": null,
|
||||
"full-revisionid": "9ae15d825b24b3445112683bbb7b2e4a9d3ecb8f",
|
||||
"version": "0.4.20"
|
||||
"full-revisionid": "0953aa240101a7aa235438496f796ef5f8d69d5b",
|
||||
"version": "0.4.25"
|
||||
}
|
||||
''' # END VERSION_JSON
|
||||
|
||||
|
|
|
@ -34,13 +34,16 @@ class FFTAligner(TransformerMixin):
|
|||
convolve = np.copy(convolve)
|
||||
if self.max_offset_samples is None:
|
||||
return convolve
|
||||
offset_to_index = lambda offset: 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")
|
||||
|
||||
def _offset_to_index(offset):
|
||||
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
|
||||
|
||||
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_score_ = convolve[best_idx]
|
||||
|
||||
|
|
|
@ -202,10 +202,7 @@ def try_sync(
|
|||
if args.output_encoding != "same":
|
||||
out_subs = out_subs.set_encoding(args.output_encoding)
|
||||
suppress_output_thresh = args.suppress_output_if_offset_less_than
|
||||
if suppress_output_thresh is None or (
|
||||
scale_step.scale_factor == 1.0
|
||||
and offset_seconds >= suppress_output_thresh
|
||||
):
|
||||
if offset_seconds >= (suppress_output_thresh or float("-inf")):
|
||||
logger.info("writing output to {}".format(srtout or "stdout"))
|
||||
out_subs.write_file(srtout)
|
||||
else:
|
||||
|
@ -216,11 +213,10 @@ def try_sync(
|
|||
)
|
||||
except FailedToFindAlignmentException as e:
|
||||
sync_was_successful = False
|
||||
logger.error(e)
|
||||
logger.error(str(e))
|
||||
except Exception as e:
|
||||
exc = e
|
||||
sync_was_successful = False
|
||||
logger.error(e)
|
||||
else:
|
||||
result["offset_seconds"] = offset_seconds
|
||||
result["framerate_scale_factor"] = scale_step.scale_factor
|
||||
|
@ -362,23 +358,29 @@ def validate_args(args: argparse.Namespace) -> None:
|
|||
)
|
||||
if not args.srtin:
|
||||
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:
|
||||
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.make_test_case:
|
||||
raise ValueError("test case is for sync and not subtitle extraction")
|
||||
if args.srtin:
|
||||
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:
|
||||
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):
|
||||
raise ValueError(
|
||||
error_string_template.format(action="read reference", file=args.reference)
|
||||
|
@ -506,27 +508,27 @@ def run(
|
|||
try:
|
||||
sync_was_successful = _run_impl(args, result)
|
||||
result["sync_was_successful"] = sync_was_successful
|
||||
return result
|
||||
finally:
|
||||
if log_handler is None or log_path is None:
|
||||
return result
|
||||
try:
|
||||
if log_handler is not None and log_path is not None:
|
||||
log_handler.close()
|
||||
logger.removeHandler(log_handler)
|
||||
if args.make_test_case:
|
||||
result["retval"] += make_test_case(
|
||||
args, _npy_savename(args), sync_was_successful
|
||||
)
|
||||
finally:
|
||||
if args.log_dir_path is None or not os.path.isdir(args.log_dir_path):
|
||||
os.remove(log_path)
|
||||
return result
|
||||
|
||||
|
||||
def add_main_args_for_cli(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"reference",
|
||||
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(
|
||||
"-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",
|
||||
"--reftrack",
|
||||
default=None,
|
||||
help="Which stream/track in the video file to use as reference, "
|
||||
"formatted according to ffmpeg conventions. For example, 0:s:0 "
|
||||
"uses the first subtitle track; 0:a:3 would use the third audio track. "
|
||||
"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`",
|
||||
help=(
|
||||
"Which stream/track in the video file to use as reference, "
|
||||
"formatted according to ffmpeg conventions. For example, 0:s:0 "
|
||||
"uses the first subtitle track; 0:a:3 would use the third audio track. "
|
||||
"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(
|
||||
"--overwrite-input",
|
||||
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(
|
||||
"--encoding",
|
||||
|
@ -642,7 +649,14 @@ def add_cli_only_args(parser: argparse.ArgumentParser) -> None:
|
|||
)
|
||||
parser.add_argument(
|
||||
"--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,
|
||||
help="Which voice activity detector to use for speech extraction "
|
||||
"(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(
|
||||
"--log-dir-path",
|
||||
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(
|
||||
"--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"
|
||||
"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("--gui-mode", 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():
|
||||
description = DESCRIPTION
|
||||
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)
|
||||
main_group = parser.add_argument_group("Basic")
|
||||
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
|
||||
(commit hash d205638475ca542dc46862652e3bb0be663a8eac) to be precise).
|
||||
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 itertools import islice
|
||||
|
@ -14,7 +44,7 @@ from typing_extensions import Protocol
|
|||
|
||||
class TransformerProtocol(Protocol):
|
||||
fit: Callable[..., "TransformerProtocol"]
|
||||
transform: Callable[["TransformerProtocol", Any], Any]
|
||||
transform: Callable[[Any], Any]
|
||||
|
||||
|
||||
# Author: Gael Varoquaux <gael.varoquaux@normalesup.org>
|
||||
|
@ -176,7 +206,7 @@ class Pipeline:
|
|||
)
|
||||
step, param = pname.split("__", 1)
|
||||
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
|
||||
):
|
||||
if transformer is None or transformer == "passthrough":
|
||||
|
|
|
@ -1,17 +1,24 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
import logging
|
||||
import io
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
from typing import cast, Callable, Dict, Optional, Union
|
||||
from typing import cast, Callable, Dict, List, Optional, Union
|
||||
|
||||
import ffmpeg
|
||||
import numpy as np
|
||||
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.generic_subtitles import GenericSubtitle
|
||||
from ffsubsync.sklearn_shim import TransformerMixin
|
||||
|
@ -144,7 +151,7 @@ def _make_webrtcvad_detector(
|
|||
asegment[start * bytes_per_frame : stop * bytes_per_frame],
|
||||
sample_rate=frame_rate,
|
||||
)
|
||||
except:
|
||||
except Exception:
|
||||
is_speech = False
|
||||
failures += 1
|
||||
# webrtcvad has low recall on mode 3, so treat non-speech as "not sure"
|
||||
|
@ -154,6 +161,49 @@ def _make_webrtcvad_detector(
|
|||
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:
|
||||
def __init__(self) -> None:
|
||||
self.start_frame_: Optional[int] = None
|
||||
|
@ -170,8 +220,8 @@ class ComputeSpeechFrameBoundariesMixin:
|
|||
) -> "ComputeSpeechFrameBoundariesMixin":
|
||||
nz = np.nonzero(speech_frames > 0.5)[0]
|
||||
if len(nz) > 0:
|
||||
self.start_frame_ = np.min(nz)
|
||||
self.end_frame_ = np.max(nz)
|
||||
self.start_frame_ = int(np.min(nz))
|
||||
self.end_frame_ = int(np.max(nz))
|
||||
return self
|
||||
|
||||
|
||||
|
@ -287,9 +337,13 @@ class VideoSpeechTransformer(TransformerMixin):
|
|||
detector = _make_auditok_detector(
|
||||
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:
|
||||
raise ValueError("unknown vad: %s" % self.vad)
|
||||
media_bstring = []
|
||||
media_bstring: List[np.ndarray] = []
|
||||
ffmpeg_args = [
|
||||
ffmpeg_bin_path(
|
||||
"ffmpeg", self.gui_mode, ffmpeg_resources_path=self.ffmpeg_path
|
||||
|
@ -324,10 +378,7 @@ class VideoSpeechTransformer(TransformerMixin):
|
|||
windows_per_buffer = 10000
|
||||
simple_progress = 0.0
|
||||
|
||||
@contextmanager
|
||||
def redirect_stderr(enter_result=None):
|
||||
yield enter_result
|
||||
|
||||
redirect_stderr = None
|
||||
tqdm_extra_args = {}
|
||||
should_print_redirected_stderr = self.gui_mode
|
||||
if self.gui_mode:
|
||||
|
@ -337,6 +388,13 @@ class VideoSpeechTransformer(TransformerMixin):
|
|||
tqdm_extra_args["file"] = sys.stdout
|
||||
except ImportError:
|
||||
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()
|
||||
with redirect_stderr(pbar_output):
|
||||
with tqdm.tqdm(
|
||||
|
@ -363,13 +421,17 @@ class VideoSpeechTransformer(TransformerMixin):
|
|||
assert self.gui_mode
|
||||
# no need to flush since we pass -u to do unbuffered output for gui mode
|
||||
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))
|
||||
process.wait()
|
||||
if len(media_bstring) == 0:
|
||||
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)
|
||||
logger.info("total of speech segments: %s", np.sum(self.video_speech_results_))
|
||||
return self
|
||||
|
||||
def transform(self, *_) -> np.ndarray:
|
||||
|
|
|
@ -1,17 +1,29 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any, cast, List, Optional
|
||||
|
||||
try:
|
||||
import cchardet as chardet
|
||||
except ImportError:
|
||||
import chardet # type: ignore
|
||||
import cchardet
|
||||
except: # noqa: E722
|
||||
cchardet = None
|
||||
try:
|
||||
import chardet
|
||||
except: # noqa: E722
|
||||
chardet = None
|
||||
try:
|
||||
import charset_normalizer
|
||||
except: # noqa: E722
|
||||
charset_normalizer = None
|
||||
import pysubs2
|
||||
from ffsubsync.sklearn_shim import TransformerMixin
|
||||
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.generic_subtitles import GenericSubtitle, GenericSubtitlesFile, SubsMixin
|
||||
|
||||
|
@ -61,6 +73,7 @@ class GenericSubtitleParser(SubsMixin, TransformerMixin):
|
|||
max_subtitle_seconds: Optional[int] = None,
|
||||
start_seconds: int = 0,
|
||||
skip_ssa_info: bool = False,
|
||||
strict: bool = False,
|
||||
) -> None:
|
||||
super(self.__class__, self).__init__()
|
||||
self.sub_format: str = fmt
|
||||
|
@ -72,6 +85,7 @@ class GenericSubtitleParser(SubsMixin, TransformerMixin):
|
|||
self.start_seconds: int = start_seconds
|
||||
# FIXME: hack to get tests to pass; remove
|
||||
self._skip_ssa_info: bool = skip_ssa_info
|
||||
self._strict: bool = strict
|
||||
|
||||
def fit(self, fname: str, *_) -> "GenericSubtitleParser":
|
||||
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:
|
||||
subs = f.read()
|
||||
if self.encoding == "infer":
|
||||
encodings_to_try = (chardet.detect(subs)["encoding"],)
|
||||
self.detected_encoding_ = encodings_to_try[0]
|
||||
for chardet_lib in (cchardet, charset_normalizer, chardet):
|
||||
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_)
|
||||
exc = None
|
||||
for encoding in encodings_to_try:
|
||||
try:
|
||||
decoded_subs = subs.decode(encoding, errors="replace").strip()
|
||||
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"):
|
||||
parsed_subs = pysubs2.SSAFile.from_string(decoded_subs)
|
||||
else:
|
||||
|
@ -144,4 +171,5 @@ def make_subtitle_parser(
|
|||
max_subtitle_seconds=max_subtitle_seconds,
|
||||
start_seconds=start_seconds,
|
||||
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
|
||||
dynaconf==3.1.12
|
||||
fese==0.1.2
|
||||
ffsubsync==0.4.20
|
||||
ffsubsync==0.4.25
|
||||
Flask-Compress==1.13 # modified to import brotli only if required
|
||||
flask-cors==3.0.10
|
||||
flask-migrate==4.0.4
|
||||
|
|
Loading…
Reference in a new issue