aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authormorpheus65535 <[email protected]>2024-01-10 23:07:42 -0500
committerGitHub <[email protected]>2024-01-10 23:07:42 -0500
commit0e648b5588c7d8675238b1ceb2e04a29e23d8fb1 (patch)
tree51349958a9620210fe3502254d3243526ca7bbb1
parent0807bd99b956ee3abf18acc3bec43a87fc8b1530 (diff)
downloadbazarr-0e648b5588c7d8675238b1ceb2e04a29e23d8fb1.tar.gz
bazarr-0e648b5588c7d8675238b1ceb2e04a29e23d8fb1.zip
Improved subtitles synchronisation settings and added a manual sync modalv1.4.1-beta.14
-rw-r--r--bazarr/api/subtitles/subtitles.py102
-rw-r--r--bazarr/app/config.py4
-rw-r--r--bazarr/subtitles/processing.py4
-rw-r--r--bazarr/subtitles/sync.py4
-rw-r--r--bazarr/subtitles/tools/subsyncer.py54
-rw-r--r--bazarr/subtitles/upload.py8
-rw-r--r--bazarr/utilities/video_analyzer.py129
-rw-r--r--frontend/src/apis/hooks/subtitles.ts24
-rw-r--r--frontend/src/apis/raw/subtitles.ts22
-rw-r--r--frontend/src/components/SubtitleToolsMenu.tsx4
-rw-r--r--frontend/src/components/forms/SyncSubtitleForm.tsx183
-rw-r--r--frontend/src/pages/Settings/Providers/index.tsx53
-rw-r--r--frontend/src/pages/Settings/Providers/options.ts12
-rw-r--r--frontend/src/pages/Settings/Subtitles/index.tsx218
-rw-r--r--frontend/src/pages/Settings/Subtitles/options.ts30
-rw-r--r--frontend/src/types/api.d.ts28
-rw-r--r--frontend/src/types/form.d.ts7
-rw-r--r--frontend/src/types/settings.d.ts3
-rw-r--r--frontend/src/utilities/index.ts4
-rw-r--r--libs/ffsubsync/__init__.py2
-rw-r--r--libs/ffsubsync/_version.py6
-rw-r--r--libs/ffsubsync/aligners.py11
-rwxr-xr-xlibs/ffsubsync/ffsubsync.py70
-rwxr-xr-xlibs/ffsubsync/ffsubsync_gui.py6
-rw-r--r--libs/ffsubsync/sklearn_shim.py36
-rw-r--r--libs/ffsubsync/speech_transformers.py86
-rwxr-xr-xlibs/ffsubsync/subtitle_parser.py44
-rw-r--r--libs/version.txt2
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