From 0e648b5588c7d8675238b1ceb2e04a29e23d8fb1 Mon Sep 17 00:00:00 2001
From: morpheus65535 <louis_vezina@hotmail.com>
Date: Wed, 10 Jan 2024 23:07:42 -0500
Subject: [PATCH] Improved subtitles synchronisation settings and added a
 manual sync modal

---
 bazarr/api/subtitles/subtitles.py             | 102 ++++++--
 bazarr/app/config.py                          |   4 +
 bazarr/subtitles/processing.py                |   4 +-
 bazarr/subtitles/sync.py                      |   4 +-
 bazarr/subtitles/tools/subsyncer.py           |  54 +++--
 bazarr/subtitles/upload.py                    |   8 +-
 bazarr/utilities/video_analyzer.py            | 129 ++++++++++-
 frontend/src/apis/hooks/subtitles.ts          |  24 ++
 frontend/src/apis/raw/subtitles.ts            |  22 ++
 frontend/src/components/SubtitleToolsMenu.tsx |   4 +-
 .../src/components/forms/SyncSubtitleForm.tsx | 183 +++++++++++++++
 .../src/pages/Settings/Providers/index.tsx    |  53 ++++-
 .../src/pages/Settings/Providers/options.ts   |  12 +
 .../src/pages/Settings/Subtitles/index.tsx    | 218 +++++++++---------
 .../src/pages/Settings/Subtitles/options.ts   |  30 ++-
 frontend/src/types/api.d.ts                   |  28 +++
 frontend/src/types/form.d.ts                  |   7 +
 frontend/src/types/settings.d.ts              |   3 +
 frontend/src/utilities/index.ts               |   4 +
 libs/ffsubsync/__init__.py                    |   2 +-
 libs/ffsubsync/_version.py                    |   6 +-
 libs/ffsubsync/aligners.py                    |  11 +-
 libs/ffsubsync/ffsubsync.py                   |  70 ++++--
 libs/ffsubsync/ffsubsync_gui.py               |   6 +-
 libs/ffsubsync/sklearn_shim.py                |  36 ++-
 libs/ffsubsync/speech_transformers.py         |  86 ++++++-
 libs/ffsubsync/subtitle_parser.py             |  44 +++-
 libs/version.txt                              |   2 +-
 28 files changed, 931 insertions(+), 225 deletions(-)
 create mode 100644 frontend/src/components/forms/SyncSubtitleForm.tsx
 create mode 100644 frontend/src/pages/Settings/Providers/options.ts

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">
@@ -258,52 +289,6 @@ const SettingsSubtitlesView: FunctionComponent = () => {
           Search multiple providers at once (Don't choose this on low powered
           devices)
         </Message>
-        <Check
-          label="Use Embedded Subtitles"
-          settingKey="settings-general-use_embedded_subs"
-        ></Check>
-        <Message>
-          Use embedded subtitles in media files when determining missing ones.
-        </Message>
-        <CollapseBox indent settingKey="settings-general-use_embedded_subs">
-          <Check
-            label="Ignore Embedded PGS Subtitles"
-            settingKey="settings-general-ignore_pgs_subs"
-          ></Check>
-          <Message>
-            Ignores PGS Subtitles in Embedded Subtitles detection.
-          </Message>
-          <Check
-            label="Ignore Embedded VobSub Subtitles"
-            settingKey="settings-general-ignore_vobsub_subs"
-          ></Check>
-          <Message>
-            Ignores VobSub Subtitles in Embedded Subtitles detection.
-          </Message>
-          <Check
-            label="Ignore Embedded ASS Subtitles"
-            settingKey="settings-general-ignore_ass_subs"
-          ></Check>
-          <Message>
-            Ignores ASS Subtitles in Embedded Subtitles detection.
-          </Message>
-          <Check
-            label="Show Only Desired Languages"
-            settingKey="settings-general-embedded_subs_show_desired"
-          ></Check>
-          <Message>
-            Hide embedded subtitles for languages that are not currently
-            desired.
-          </Message>
-          <Selector
-            settingKey="settings-general-embedded_subtitles_parser"
-            settingOptions={{
-              onSaved: (v) => (v === undefined ? "ffprobe" : v),
-            }}
-            options={embeddedSubtitlesParserOption}
-          ></Selector>
-          <Message>Embedded subtitles video parser</Message>
-        </CollapseBox>
         <Check
           label="Skip video file hash calculation"
           settingKey="settings-general-skip_hashing"
@@ -314,15 +299,7 @@ const SettingsSubtitlesView: FunctionComponent = () => {
           search results scores.
         </Message>
       </Section>
-      <Section header="Post-Processing">
-        <Check
-          label="Encode Subtitles To UTF8"
-          settingKey="settings-general-utf8_encode"
-        ></Check>
-        <Message>
-          Re-encode downloaded Subtitles to UTF8. Should be left enabled in most
-          case.
-        </Message>
+      <Section header="Subzero Modifications">
         <Check
           label="Hearing Impaired"
           settingOptions={{ onLoaded: SubzeroModification("remove_HI") }}
@@ -390,14 +367,8 @@ const SettingsSubtitlesView: FunctionComponent = () => {
           Reverses the punctuation in right-to-left subtitles for problematic
           playback devices.
         </Message>
-        <Check
-          label="Permission (chmod)"
-          settingKey="settings-general-chmod_enabled"
-        ></Check>
-        <CollapseBox indent settingKey="settings-general-chmod_enabled">
-          <Text placeholder="0777" settingKey="settings-general-chmod"></Text>
-          <Message>Must be 4 digit octal</Message>
-        </CollapseBox>
+      </Section>
+      <Section header="Synchronizarion / Alignement">
         <Check
           label="Always use Audio Track as Reference for Syncing"
           settingKey="settings-subsync-force_audio"
@@ -406,6 +377,31 @@ const SettingsSubtitlesView: FunctionComponent = () => {
           Use the audio track as reference for syncing, instead of using the
           embedded subtitle.
         </Message>
+        <Check
+          label="No Fix Framerate"
+          settingKey="settings-subsync-no_fix_framerate"
+        ></Check>
+        <Message>
+          If specified, subsync will not attempt to correct a framerate mismatch
+          between reference and subtitles.
+        </Message>
+        <Check
+          label="Gold-Section Search"
+          settingKey="settings-subsync-gss"
+        ></Check>
+        <Message>
+          If specified, use golden-section search to try to find the optimal
+          framerate ratio between video and subtitles.
+        </Message>
+        <Selector
+          label="Max offset seconds"
+          options={syncMaxOffsetSecondsOptions}
+          settingKey="settings-subsync-max_offset_seconds"
+          defaultValue={60}
+        ></Selector>
+        <Message>
+          The max allowed offset seconds for any subtitle segment.
+        </Message>
         <Check
           label="Automatic Subtitles Synchronization"
           settingKey="settings-subsync-use_subsync"
@@ -443,6 +439,8 @@ const SettingsSubtitlesView: FunctionComponent = () => {
             <Slider settingKey="settings-subsync-subsync_movie_threshold"></Slider>
           </CollapseBox>
         </CollapseBox>
+      </Section>
+      <Section header="Custom post-processing">
         <Check
           settingKey="settings-general-use_postprocessing"
           label="Custom Post-Processing"
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 <gael.varoquaux@normalesup.org>
@@ -176,7 +206,7 @@ class Pipeline:
                 )
             step, param = pname.split("__", 1)
             fit_params_steps[step][param] = pval
-        for (step_idx, name, transformer) in self._iter(
+        for step_idx, name, transformer in self._iter(
             with_final=False, filter_passthrough=False
         ):
             if transformer is None or transformer == "passthrough":
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