diff options
author | morpheus65535 <[email protected]> | 2024-01-10 23:07:42 -0500 |
---|---|---|
committer | GitHub <[email protected]> | 2024-01-10 23:07:42 -0500 |
commit | 0e648b5588c7d8675238b1ceb2e04a29e23d8fb1 (patch) | |
tree | 51349958a9620210fe3502254d3243526ca7bbb1 | |
parent | 0807bd99b956ee3abf18acc3bec43a87fc8b1530 (diff) | |
download | bazarr-0e648b5588c7d8675238b1ceb2e04a29e23d8fb1.tar.gz bazarr-0e648b5588c7d8675238b1ceb2e04a29e23d8fb1.zip |
Improved subtitles synchronisation settings and added a manual sync modalv1.4.1-beta.14
28 files changed, 931 insertions, 225 deletions
diff --git a/bazarr/api/subtitles/subtitles.py b/bazarr/api/subtitles/subtitles.py index eb021613e..a83da76eb 100644 --- a/bazarr/api/subtitles/subtitles.py +++ b/bazarr/api/subtitles/subtitles.py @@ -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 diff --git a/bazarr/app/config.py b/bazarr/app/config.py index 0ef35fb3b..d490a6a4e 100644 --- a/bazarr/app/config.py +++ b/bazarr/app/config.py @@ -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), diff --git a/bazarr/subtitles/processing.py b/bazarr/subtitles/processing.py index 2144e9175..b5c032610 100644 --- a/bazarr/subtitles/processing.py +++ b/bazarr/subtitles/processing.py @@ -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) diff --git a/bazarr/subtitles/sync.py b/bazarr/subtitles/sync.py index bcdf37aff..5633f73e8 100644 --- a/bazarr/subtitles/sync.py +++ b/bazarr/subtitles/sync.py @@ -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() diff --git a/bazarr/subtitles/tools/subsyncer.py b/bazarr/subtitles/tools/subsyncer.py index 30945a8d0..79bb1b0eb 100644 --- a/bazarr/subtitles/tools/subsyncer.py +++ b/bazarr/subtitles/tools/subsyncer.py @@ -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: diff --git a/bazarr/subtitles/upload.py b/bazarr/subtitles/upload.py index aaeca7258..8ad16128e 100644 --- a/bazarr/subtitles/upload.py +++ b/bazarr/subtitles/upload.py @@ -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, diff --git a/bazarr/utilities/video_analyzer.py b/bazarr/utilities/video_analyzer.py index c1cde1fb3..1aad9b859 100644 --- a/bazarr/utilities/video_analyzer.py +++ b/bazarr/utilities/video_analyzer.py @@ -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.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 app.config import settings + +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} diff --git a/frontend/src/apis/hooks/subtitles.ts b/frontend/src/apis/hooks/subtitles.ts index 89626d8f9..0a4417257 100644 --- a/frontend/src/apis/hooks/subtitles.ts +++ b/frontend/src/apis/hooks/subtitles.ts @@ -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 } + ); +} diff --git a/frontend/src/apis/raw/subtitles.ts b/frontend/src/apis/raw/subtitles.ts index d31f897a7..b3d75eb70 100644 --- a/frontend/src/apis/raw/subtitles.ts +++ b/frontend/src/apis/raw/subtitles.ts @@ -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, diff --git a/frontend/src/components/SubtitleToolsMenu.tsx b/frontend/src/components/SubtitleToolsMenu.tsx index 953d748d5..ba44e94aa 100644 --- a/frontend/src/components/SubtitleToolsMenu.tsx +++ b/frontend/src/components/SubtitleToolsMenu.tsx @@ -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", diff --git a/frontend/src/components/forms/SyncSubtitleForm.tsx b/frontend/src/components/forms/SyncSubtitleForm.tsx new file mode 100644 index 000000000..349058f63 --- /dev/null +++ b/frontend/src/components/forms/SyncSubtitleForm.tsx @@ -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; diff --git a/frontend/src/pages/Settings/Providers/index.tsx b/frontend/src/pages/Settings/Providers/index.tsx index 4d18f4d1c..8a2a85a67 100644 --- a/frontend/src/pages/Settings/Providers/index.tsx +++ b/frontend/src/pages/Settings/Providers/index.tsx @@ -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> ); }; diff --git a/frontend/src/pages/Settings/Providers/options.ts b/frontend/src/pages/Settings/Providers/options.ts new file mode 100644 index 000000000..63227ca76 --- /dev/null +++ b/frontend/src/pages/Settings/Providers/options.ts @@ -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", + }, +]; diff --git a/frontend/src/pages/Settings/Subtitles/index.tsx b/frontend/src/pages/Settings/Subtitles/index.tsx index 51d59675e..9f77234ba 100644 --- a/frontend/src/pages/Settings/Subtitles/index.tsx +++ b/frontend/src/pages/Settings/Subtitles/index.tsx @@ -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"> @@ -259,52 +290,6 @@ const SettingsSubtitlesView: FunctionComponent = () => { 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" ></Check> @@ -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" @@ -407,6 +378,31 @@ const SettingsSubtitlesView: FunctionComponent = () => { 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" ></Check> @@ -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" diff --git a/frontend/src/pages/Settings/Subtitles/options.ts b/frontend/src/pages/Settings/Subtitles/options.ts index 2c57584fe..0af2f0fbb 100644 --- a/frontend/src/pages/Settings/Subtitles/options.ts +++ b/frontend/src/pages/Settings/Subtitles/options.ts @@ -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, + }, +]; diff --git a/frontend/src/types/api.d.ts b/frontend/src/types/api.d.ts index d714b5149..069be3029 100644 --- a/frontend/src/types/api.d.ts +++ b/frontend/src/types/api.d.ts @@ -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 { diff --git a/frontend/src/types/form.d.ts b/frontend/src/types/form.d.ts index 6019a3fa0..81b86be96 100644 --- a/frontend/src/types/form.d.ts +++ b/frontend/src/types/form.d.ts @@ -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 { diff --git a/frontend/src/types/settings.d.ts b/frontend/src/types/settings.d.ts index 89cb42a6d..d88489a0e 100644 --- a/frontend/src/types/settings.d.ts +++ b/frontend/src/types/settings.d.ts @@ -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 { diff --git a/frontend/src/utilities/index.ts b/frontend/src/utilities/index.ts index 8fa53a60b..549660722 100644 --- a/frontend/src/utilities/index.ts +++ b/frontend/src/utilities/index.ts @@ -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"; diff --git a/libs/ffsubsync/__init__.py b/libs/ffsubsync/__init__.py index 0ad6c1236..a97907205 100644 --- a/libs/ffsubsync/__init__.py +++ b/libs/ffsubsync/__init__.py @@ -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 diff --git a/libs/ffsubsync/_version.py b/libs/ffsubsync/_version.py index 7215e42bb..a39e32836 100644 --- a/libs/ffsubsync/_version.py +++ b/libs/ffsubsync/_version.py @@ -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 diff --git a/libs/ffsubsync/aligners.py b/libs/ffsubsync/aligners.py index f02243dd2..28b7bcf9d 100644 --- a/libs/ffsubsync/aligners.py +++ b/libs/ffsubsync/aligners.py @@ -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] diff --git a/libs/ffsubsync/ffsubsync.py b/libs/ffsubsync/ffsubsync.py index 6fc8f2a20..9a808a29b 100755 --- a/libs/ffsubsync/ffsubsync.py +++ b/libs/ffsubsync/ffsubsync.py @@ -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) diff --git a/libs/ffsubsync/ffsubsync_gui.py b/libs/ffsubsync/ffsubsync_gui.py index 1bdb45031..4ec851eec 100755 --- a/libs/ffsubsync/ffsubsync_gui.py +++ b/libs/ffsubsync/ffsubsync_gui.py @@ -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( diff --git a/libs/ffsubsync/sklearn_shim.py b/libs/ffsubsync/sklearn_shim.py index ac79e4f3c..c691852a1 100644 --- a/libs/ffsubsync/sklearn_shim.py +++ b/libs/ffsubsync/sklearn_shim.py @@ -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 <[email protected]> @@ -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": diff --git a/libs/ffsubsync/speech_transformers.py b/libs/ffsubsync/speech_transformers.py index 33b54db6a..72ca23e30 100644 --- a/libs/ffsubsync/speech_transformers.py +++ b/libs/ffsubsync/speech_transformers.py @@ -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: diff --git a/libs/ffsubsync/subtitle_parser.py b/libs/ffsubsync/subtitle_parser.py index ea5e6657c..b42d9bb9e 100755 --- a/libs/ffsubsync/subtitle_parser.py +++ b/libs/ffsubsync/subtitle_parser.py @@ -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), ) diff --git a/libs/version.txt b/libs/version.txt index ac120f6b4..6d73509c2 100644 --- a/libs/version.txt +++ b/libs/version.txt @@ -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 |