Improved subtitles synchronisation settings and added a manual sync modal

pull/2366/head v1.4.1-beta.14
morpheus65535 4 months ago committed by GitHub
parent 0807bd99b9
commit 0e648b5588
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -4,17 +4,18 @@ import os
import sys import sys
import gc import gc
from flask_restx import Resource, Namespace, reqparse from flask_restx import Resource, Namespace, reqparse, fields, marshal
from app.database import TableEpisodes, TableMovies, database, select from app.database import TableEpisodes, TableMovies, database, select
from languages.get_languages import alpha3_from_alpha2 from languages.get_languages import alpha3_from_alpha2
from utilities.path_mappings import path_mappings from utilities.path_mappings import path_mappings
from utilities.video_analyzer import subtitles_sync_references
from subtitles.tools.subsyncer import SubSyncer from subtitles.tools.subsyncer import SubSyncer
from subtitles.tools.translate import translate_subtitles_file from subtitles.tools.translate import translate_subtitles_file
from subtitles.tools.mods import subtitles_apply_mods from subtitles.tools.mods import subtitles_apply_mods
from subtitles.indexer.series import store_subtitles from subtitles.indexer.series import store_subtitles
from subtitles.indexer.movies import store_subtitles_movie from subtitles.indexer.movies import store_subtitles_movie
from app.config import settings from app.config import settings, empty_values
from app.event_handler import event_stream from app.event_handler import event_stream
from ..utils import authenticate from ..utils import authenticate
@ -25,6 +26,56 @@ api_ns_subtitles = Namespace('Subtitles', description='Apply mods/tools on exter
@api_ns_subtitles.route('subtitles') @api_ns_subtitles.route('subtitles')
class Subtitles(Resource): class Subtitles(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('subtitlesPath', type=str, required=True, help='External subtitles file path')
get_request_parser.add_argument('sonarrEpisodeId', type=int, required=False, help='Sonarr Episode ID')
get_request_parser.add_argument('radarrMovieId', type=int, required=False, help='Radarr Movie ID')
audio_tracks_data_model = api_ns_subtitles.model('audio_tracks_data_model', {
'stream': fields.String(),
'name': fields.String(),
'language': fields.String(),
})
embedded_subtitles_data_model = api_ns_subtitles.model('embedded_subtitles_data_model', {
'stream': fields.String(),
'name': fields.String(),
'language': fields.String(),
'forced': fields.Boolean(),
'hearing_impaired': fields.Boolean(),
})
external_subtitles_data_model = api_ns_subtitles.model('external_subtitles_data_model', {
'name': fields.String(),
'path': fields.String(),
'language': fields.String(),
'forced': fields.Boolean(),
'hearing_impaired': fields.Boolean(),
})
get_response_model = api_ns_subtitles.model('SubtitlesGetResponse', {
'audio_tracks': fields.Nested(audio_tracks_data_model),
'embedded_subtitles_tracks': fields.Nested(embedded_subtitles_data_model),
'external_subtitles_tracks': fields.Nested(external_subtitles_data_model),
})
@authenticate
@api_ns_subtitles.response(200, 'Success')
@api_ns_subtitles.response(401, 'Not Authenticated')
@api_ns_subtitles.doc(parser=get_request_parser)
def get(self):
"""Return available audio and embedded subtitles tracks with external subtitles. Used for manual subsync
modal"""
args = self.get_request_parser.parse_args()
subtitlesPath = args.get('subtitlesPath')
episodeId = args.get('sonarrEpisodeId', None)
movieId = args.get('radarrMovieId', None)
result = subtitles_sync_references(subtitles_path=subtitlesPath, sonarr_episode_id=episodeId,
radarr_movie_id=movieId)
return marshal(result, self.get_response_model, envelope='data')
patch_request_parser = reqparse.RequestParser() patch_request_parser = reqparse.RequestParser()
patch_request_parser.add_argument('action', type=str, required=True, patch_request_parser.add_argument('action', type=str, required=True,
help='Action from ["sync", "translate" or mods name]') help='Action from ["sync", "translate" or mods name]')
@ -32,10 +83,20 @@ class Subtitles(Resource):
patch_request_parser.add_argument('path', type=str, required=True, help='Subtitles file path') patch_request_parser.add_argument('path', type=str, required=True, help='Subtitles file path')
patch_request_parser.add_argument('type', type=str, required=True, help='Media type from ["episode", "movie"]') patch_request_parser.add_argument('type', type=str, required=True, help='Media type from ["episode", "movie"]')
patch_request_parser.add_argument('id', type=int, required=True, help='Media ID (episodeId, radarrId)') patch_request_parser.add_argument('id', type=int, required=True, help='Media ID (episodeId, radarrId)')
patch_request_parser.add_argument('forced', type=str, required=False, help='Forced subtitles from ["True", "False"]') patch_request_parser.add_argument('forced', type=str, required=False,
help='Forced subtitles from ["True", "False"]')
patch_request_parser.add_argument('hi', type=str, required=False, help='HI subtitles from ["True", "False"]') patch_request_parser.add_argument('hi', type=str, required=False, help='HI subtitles from ["True", "False"]')
patch_request_parser.add_argument('original_format', type=str, required=False, patch_request_parser.add_argument('original_format', type=str, required=False,
help='Use original subtitles format from ["True", "False"]') help='Use original subtitles format from ["True", "False"]')
patch_request_parser.add_argument('reference', type=str, required=False,
help='Reference to use for sync from video file track number (a:0) or some '
'subtitles file path')
patch_request_parser.add_argument('max_offset_seconds', type=str, required=False,
help='Maximum offset seconds to allow')
patch_request_parser.add_argument('no_fix_framerate', type=str, required=False,
help='Don\'t try to fix framerate from ["True", "False"]')
patch_request_parser.add_argument('gss', type=str, required=False,
help='Use Golden-Section Search from ["True", "False"]')
@authenticate @authenticate
@api_ns_subtitles.doc(parser=patch_request_parser) @api_ns_subtitles.doc(parser=patch_request_parser)
@ -79,19 +140,30 @@ class Subtitles(Resource):
video_path = path_mappings.path_replace_movie(metadata.path) video_path = path_mappings.path_replace_movie(metadata.path)
if action == 'sync': if action == 'sync':
sync_kwargs = {
'video_path': video_path,
'srt_path': subtitles_path,
'srt_lang': language,
'reference': args.get('reference') if args.get('reference') not in empty_values else video_path,
'max_offset_seconds': args.get('max_offset_seconds') if args.get('max_offset_seconds') not in
empty_values else str(settings.subsync.max_offset_seconds),
'no_fix_framerate': args.get('no_fix_framerate') == 'True',
'gss': args.get('gss') == 'True',
}
subsync = SubSyncer() subsync = SubSyncer()
if media_type == 'episode': try:
subsync.sync(video_path=video_path, srt_path=subtitles_path, if media_type == 'episode':
srt_lang=language, media_type='series', sonarr_series_id=metadata.sonarrSeriesId, sync_kwargs['sonarr_series_id'] = metadata.sonarrSeriesId
sonarr_episode_id=id) sync_kwargs['sonarr_episode_id'] = id
else: else:
try: sync_kwargs['radarr_id'] = id
subsync.sync(video_path=video_path, srt_path=subtitles_path, subsync.sync(**sync_kwargs)
srt_lang=language, media_type='movies', radarr_id=id) except OSError:
except OSError: return 'Unable to edit subtitles file. Check logs.', 409
return 'Unable to edit subtitles file. Check logs.', 409 finally:
del subsync del subsync
gc.collect() gc.collect()
elif action == 'translate': elif action == 'translate':
from_language = subtitles_lang_from_filename(subtitles_path) from_language = subtitles_lang_from_filename(subtitles_path)
dest_language = language dest_language = language

@ -298,6 +298,10 @@ validators = [
Validator('subsync.checker', must_exist=True, default={}, is_type_of=dict), Validator('subsync.checker', must_exist=True, default={}, is_type_of=dict),
Validator('subsync.checker.blacklisted_providers', must_exist=True, default=[], is_type_of=list), Validator('subsync.checker.blacklisted_providers', must_exist=True, default=[], is_type_of=list),
Validator('subsync.checker.blacklisted_languages', must_exist=True, default=[], is_type_of=list), Validator('subsync.checker.blacklisted_languages', must_exist=True, default=[], is_type_of=list),
Validator('subsync.no_fix_framerate', must_exist=True, default=True, is_type_of=bool),
Validator('subsync.gss', must_exist=True, default=True, is_type_of=bool),
Validator('subsync.max_offset_seconds', must_exist=True, default=60, is_type_of=int,
is_in=[60, 120, 300, 600]),
# series_scores section # series_scores section
Validator('series_scores.hash', must_exist=True, default=359, is_type_of=int), Validator('series_scores.hash', must_exist=True, default=359, is_type_of=int),

@ -88,7 +88,7 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u
from .sync import sync_subtitles from .sync import sync_subtitles
sync_subtitles(video_path=path, srt_path=downloaded_path, sync_subtitles(video_path=path, srt_path=downloaded_path,
forced=subtitle.language.forced, forced=subtitle.language.forced,
srt_lang=downloaded_language_code2, media_type=media_type, srt_lang=downloaded_language_code2,
percent_score=percent_score, percent_score=percent_score,
sonarr_series_id=episode_metadata.sonarrSeriesId, sonarr_series_id=episode_metadata.sonarrSeriesId,
sonarr_episode_id=episode_metadata.sonarrEpisodeId) sonarr_episode_id=episode_metadata.sonarrEpisodeId)
@ -106,7 +106,7 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u
from .sync import sync_subtitles from .sync import sync_subtitles
sync_subtitles(video_path=path, srt_path=downloaded_path, sync_subtitles(video_path=path, srt_path=downloaded_path,
forced=subtitle.language.forced, forced=subtitle.language.forced,
srt_lang=downloaded_language_code2, media_type=media_type, srt_lang=downloaded_language_code2,
percent_score=percent_score, percent_score=percent_score,
radarr_id=movie_metadata.radarrId) radarr_id=movie_metadata.radarrId)

@ -8,7 +8,7 @@ from app.config import settings
from subtitles.tools.subsyncer import SubSyncer from subtitles.tools.subsyncer import SubSyncer
def sync_subtitles(video_path, srt_path, srt_lang, forced, media_type, percent_score, sonarr_series_id=None, def sync_subtitles(video_path, srt_path, srt_lang, forced, percent_score, sonarr_series_id=None,
sonarr_episode_id=None, radarr_id=None): sonarr_episode_id=None, radarr_id=None):
if forced: if forced:
logging.debug('BAZARR cannot sync forced subtitles. Skipping sync routine.') logging.debug('BAZARR cannot sync forced subtitles. Skipping sync routine.')
@ -26,7 +26,7 @@ def sync_subtitles(video_path, srt_path, srt_lang, forced, media_type, percent_s
if not use_subsync_threshold or (use_subsync_threshold and percent_score < float(subsync_threshold)): if not use_subsync_threshold or (use_subsync_threshold and percent_score < float(subsync_threshold)):
subsync = SubSyncer() subsync = SubSyncer()
subsync.sync(video_path=video_path, srt_path=srt_path, srt_lang=srt_lang, media_type=media_type, subsync.sync(video_path=video_path, srt_path=srt_path, srt_lang=srt_lang,
sonarr_series_id=sonarr_series_id, sonarr_episode_id=sonarr_episode_id, radarr_id=radarr_id) sonarr_series_id=sonarr_series_id, sonarr_episode_id=sonarr_episode_id, radarr_id=radarr_id)
del subsync del subsync
gc.collect() gc.collect()

@ -30,8 +30,9 @@ class SubSyncer:
self.vad = 'subs_then_webrtc' self.vad = 'subs_then_webrtc'
self.log_dir_path = os.path.join(args.config_dir, 'log') self.log_dir_path = os.path.join(args.config_dir, 'log')
def sync(self, video_path, srt_path, srt_lang, media_type, sonarr_series_id=None, sonarr_episode_id=None, def sync(self, video_path, srt_path, srt_lang, sonarr_series_id=None, sonarr_episode_id=None, radarr_id=None,
radarr_id=None): reference=None, max_offset_seconds=str(settings.subsync.max_offset_seconds),
no_fix_framerate=settings.subsync.no_fix_framerate, gss=settings.subsync.gss):
self.reference = video_path self.reference = video_path
self.srtin = srt_path self.srtin = srt_path
self.srtout = f'{os.path.splitext(self.srtin)[0]}.synced.srt' self.srtout = f'{os.path.splitext(self.srtin)[0]}.synced.srt'
@ -52,20 +53,41 @@ class SubSyncer:
logging.debug('BAZARR FFmpeg used is %s', ffmpeg_exe) logging.debug('BAZARR FFmpeg used is %s', ffmpeg_exe)
self.ffmpeg_path = os.path.dirname(ffmpeg_exe) self.ffmpeg_path = os.path.dirname(ffmpeg_exe)
unparsed_args = [self.reference, '-i', self.srtin, '-o', self.srtout, '--ffmpegpath', self.ffmpeg_path, '--vad',
self.vad, '--log-dir-path', self.log_dir_path, '--output-encoding', 'same']
if settings.subsync.force_audio:
unparsed_args.append('--no-fix-framerate')
unparsed_args.append('--reference-stream')
unparsed_args.append('a:0')
if settings.subsync.debug:
unparsed_args.append('--make-test-case')
parser = make_parser()
self.args = parser.parse_args(args=unparsed_args)
if os.path.isfile(self.srtout):
os.remove(self.srtout)
logging.debug('BAZARR deleted the previous subtitles synchronization attempt file.')
try: try:
unparsed_args = [self.reference, '-i', self.srtin, '-o', self.srtout, '--ffmpegpath', self.ffmpeg_path,
'--vad', self.vad, '--log-dir-path', self.log_dir_path, '--max-offset-seconds',
max_offset_seconds, '--output-encoding', 'same']
if not settings.general.utf8_encode:
unparsed_args.append('--output-encoding')
unparsed_args.append('same')
if no_fix_framerate:
unparsed_args.append('--no-fix-framerate')
if gss:
unparsed_args.append('--gss')
if reference and reference != video_path and os.path.isfile(reference):
# subtitles path provided
self.reference = reference
elif reference and isinstance(reference, str) and len(reference) == 3 and reference[:2] in ['a:', 's:']:
# audio or subtitles track id provided
unparsed_args.append('--reference-stream')
unparsed_args.append(reference)
elif settings.subsync.force_audio:
# nothing else match and force audio settings is enabled
unparsed_args.append('--reference-stream')
unparsed_args.append('a:0')
if settings.subsync.debug:
unparsed_args.append('--make-test-case')
parser = make_parser()
self.args = parser.parse_args(args=unparsed_args)
if os.path.isfile(self.srtout):
os.remove(self.srtout)
logging.debug('BAZARR deleted the previous subtitles synchronization attempt file.')
result = run(self.args) result = run(self.args)
except Exception: except Exception:
logging.exception( logging.exception(
@ -95,7 +117,7 @@ class SubSyncer:
reversed_subtitles_path=srt_path, reversed_subtitles_path=srt_path,
hearing_impaired=None) hearing_impaired=None)
if media_type == 'series': if sonarr_episode_id:
history_log(action=5, sonarr_series_id=sonarr_series_id, sonarr_episode_id=sonarr_episode_id, history_log(action=5, sonarr_series_id=sonarr_series_id, sonarr_episode_id=sonarr_episode_id,
result=result) result=result)
else: else:

@ -137,16 +137,16 @@ def manual_upload_subtitle(path, language, forced, hi, media_type, subtitle, aud
return return
series_id = episode_metadata.sonarrSeriesId series_id = episode_metadata.sonarrSeriesId
episode_id = episode_metadata.sonarrEpisodeId episode_id = episode_metadata.sonarrEpisodeId
sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2, media_type=media_type, sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2, percent_score=100,
percent_score=100, sonarr_series_id=episode_metadata.sonarrSeriesId, forced=forced, sonarr_series_id=episode_metadata.sonarrSeriesId, forced=forced,
sonarr_episode_id=episode_metadata.sonarrEpisodeId) sonarr_episode_id=episode_metadata.sonarrEpisodeId)
else: else:
if not movie_metadata: if not movie_metadata:
return return
series_id = "" series_id = ""
episode_id = movie_metadata.radarrId episode_id = movie_metadata.radarrId
sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2, media_type=media_type, sync_subtitles(video_path=path, srt_path=subtitle_path, srt_lang=uploaded_language_code2, percent_score=100,
percent_score=100, radarr_id=movie_metadata.radarrId, forced=forced) radarr_id=movie_metadata.radarrId, forced=forced)
if use_postprocessing: if use_postprocessing:
command = pp_replace(postprocessing_cmd, path, subtitle_path, uploaded_language, uploaded_language_code2, command = pp_replace(postprocessing_cmd, path, subtitle_path, uploaded_language, uploaded_language_code2,

@ -1,15 +1,16 @@
# coding=utf-8 # coding=utf-8
import ast
import logging import logging
import os
import pickle import pickle
from knowit.api import know, KnowitException from app.config import settings
from languages.custom_lang import CustomLanguage
from languages.get_languages import language_from_alpha3, alpha3_from_alpha2
from app.database import TableEpisodes, TableMovies, database, update, select from 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 utilities.path_mappings import path_mappings
from app.config import settings
from knowit.api import know, KnowitException
def _handle_alpha3(detected_language: dict): def _handle_alpha3(detected_language: dict):
@ -107,6 +108,110 @@ def embedded_audio_reader(file, file_size, episode_file_id=None, movie_file_id=N
return audio_list return audio_list
def subtitles_sync_references(subtitles_path, sonarr_episode_id=None, radarr_movie_id=None):
references_dict = {'audio_tracks': [], 'embedded_subtitles_tracks': [], 'external_subtitles_tracks': []}
data = None
if sonarr_episode_id:
media_data = database.execute(
select(TableEpisodes.path, TableEpisodes.file_size, TableEpisodes.episode_file_id, TableEpisodes.subtitles)
.where(TableEpisodes.sonarrEpisodeId == sonarr_episode_id)) \
.first()
if not media_data:
return references_dict
data = parse_video_metadata(media_data.path, media_data.file_size, media_data.episode_file_id, None,
use_cache=True)
elif radarr_movie_id:
media_data = database.execute(
select(TableMovies.path, TableMovies.file_size, TableMovies.movie_file_id, TableMovies.subtitles)
.where(TableMovies.radarrId == radarr_movie_id)) \
.first()
if not media_data:
return references_dict
data = parse_video_metadata(media_data.path, media_data.file_size, None, media_data.movie_file_id,
use_cache=True)
if not data:
return references_dict
cache_provider = None
if "ffprobe" in data and data["ffprobe"]:
cache_provider = 'ffprobe'
elif 'mediainfo' in data and data["mediainfo"]:
cache_provider = 'mediainfo'
if cache_provider:
if 'audio' in data[cache_provider]:
track_id = 0
for detected_language in data[cache_provider]["audio"]:
name = detected_language.get("name", "").replace("(", "").replace(")", "")
if "language" not in detected_language:
language = 'Undefined'
else:
alpha3 = _handle_alpha3(detected_language)
language = language_from_alpha3(alpha3)
references_dict['audio_tracks'].append({'stream': f'a:{track_id}', 'name': name, 'language': language})
track_id += 1
if 'subtitle' in data[cache_provider]:
track_id = 0
bitmap_subs = ['dvd', 'pgs']
for detected_language in data[cache_provider]["subtitle"]:
if any([x in detected_language.get("name", "").lower() for x in bitmap_subs]):
# skipping bitmap based subtitles
track_id += 1
continue
name = detected_language.get("name", "").replace("(", "").replace(")", "")
if "language" not in detected_language:
language = 'Undefined'
else:
alpha3 = _handle_alpha3(detected_language)
language = language_from_alpha3(alpha3)
forced = detected_language.get("forced", False)
hearing_impaired = detected_language.get("hearing_impaired", False)
references_dict['embedded_subtitles_tracks'].append(
{'stream': f's:{track_id}', 'name': name, 'language': language, 'forced': forced,
'hearing_impaired': hearing_impaired}
)
track_id += 1
try:
parsed_subtitles = ast.literal_eval(media_data.subtitles)
except ValueError:
pass
else:
for subtitles in parsed_subtitles:
reversed_subtitles_path = path_mappings.path_replace_reverse(subtitles_path) if sonarr_episode_id else (
path_mappings.path_replace_reverse_movie(subtitles_path))
if subtitles[1] and subtitles[1] != reversed_subtitles_path:
language_dict = languages_from_colon_seperated_string(subtitles[0])
references_dict['external_subtitles_tracks'].append({
'name': os.path.basename(subtitles[1]),
'path': path_mappings.path_replace(subtitles[1]) if sonarr_episode_id else
path_mappings.path_replace_reverse_movie(subtitles[1]),
'language': language_dict['language'],
'forced': language_dict['forced'],
'hearing_impaired': language_dict['hi'],
})
else:
# excluding subtitles that is going to be synced from the external subtitles list
continue
return references_dict
def parse_video_metadata(file, file_size, episode_file_id=None, movie_file_id=None, use_cache=True): def parse_video_metadata(file, file_size, episode_file_id=None, movie_file_id=None, use_cache=True):
# Define default data keys value # Define default data keys value
data = { data = {
@ -195,3 +300,15 @@ def parse_video_metadata(file, file_size, episode_file_id=None, movie_file_id=No
.values(ffprobe_cache=pickle.dumps(data, pickle.HIGHEST_PROTOCOL)) .values(ffprobe_cache=pickle.dumps(data, pickle.HIGHEST_PROTOCOL))
.where(TableMovies.path == path_mappings.path_replace_reverse_movie(file))) .where(TableMovies.path == path_mappings.path_replace_reverse_movie(file)))
return data return data
def languages_from_colon_seperated_string(lang):
splitted_language = lang.split(':')
language = language_from_alpha2(splitted_language[0])
forced = hi = False
if len(splitted_language) > 1:
if splitted_language[1] == 'forced':
forced = True
elif splitted_language[1] == 'hi':
hi = True
return {'language': language, 'forced': forced, 'hi': hi}

@ -125,3 +125,27 @@ export function useSubtitleInfos(names: string[]) {
api.subtitles.info(names) api.subtitles.info(names)
); );
} }
export function useRefTracksByEpisodeId(
subtitlesPath: string,
sonarrEpisodeId: number,
isEpisode: boolean
) {
return useQuery(
[QueryKeys.Episodes, sonarrEpisodeId, QueryKeys.Subtitles, subtitlesPath],
() => api.subtitles.getRefTracksByEpisodeId(subtitlesPath, sonarrEpisodeId),
{ enabled: isEpisode }
);
}
export function useRefTracksByMovieId(
subtitlesPath: string,
radarrMovieId: number,
isMovie: boolean
) {
return useQuery(
[QueryKeys.Movies, radarrMovieId, QueryKeys.Subtitles, subtitlesPath],
() => api.subtitles.getRefTracksByMovieId(subtitlesPath, radarrMovieId),
{ enabled: isMovie }
);
}

@ -5,6 +5,28 @@ class SubtitlesApi extends BaseApi {
super("/subtitles"); super("/subtitles");
} }
async getRefTracksByEpisodeId(
subtitlesPath: string,
sonarrEpisodeId: number
) {
const response = await this.get<DataWrapper<Item.RefTracks>>("", {
subtitlesPath,
sonarrEpisodeId,
});
return response.data;
}
async getRefTracksByMovieId(
subtitlesPath: string,
radarrMovieId?: number | undefined
) {
const response = await this.get<DataWrapper<Item.RefTracks>>("", {
subtitlesPath,
radarrMovieId,
});
return response.data;
}
async info(names: string[]) { async info(names: string[]) {
const response = await this.get<DataWrapper<SubtitleInfo[]>>(`/info`, { const response = await this.get<DataWrapper<SubtitleInfo[]>>(`/info`, {
filenames: names, filenames: names,

@ -25,6 +25,7 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Divider, List, Menu, MenuProps, ScrollArea } from "@mantine/core"; import { Divider, List, Menu, MenuProps, ScrollArea } from "@mantine/core";
import { FunctionComponent, ReactElement, useCallback, useMemo } from "react"; import { FunctionComponent, ReactElement, useCallback, useMemo } from "react";
import { SyncSubtitleModal } from "./forms/SyncSubtitleForm";
export interface ToolOptions { export interface ToolOptions {
key: string; key: string;
@ -41,7 +42,8 @@ export function useTools() {
{ {
key: "sync", key: "sync",
icon: faPlay, icon: faPlay,
name: "Sync", name: "Sync...",
modal: SyncSubtitleModal,
}, },
{ {
key: "remove_HI", key: "remove_HI",

@ -0,0 +1,183 @@
/* eslint-disable camelcase */
import {
useRefTracksByEpisodeId,
useRefTracksByMovieId,
useSubtitleAction,
} from "@/apis/hooks";
import { useModals, withModal } from "@/modules/modals";
import { task } from "@/modules/task";
import { syncMaxOffsetSecondsOptions } from "@/pages/Settings/Subtitles/options";
import { toPython } from "@/utilities";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Alert, Button, Checkbox, Divider, Stack, Text } from "@mantine/core";
import { useForm } from "@mantine/form";
import { FunctionComponent } from "react";
import { Selector, SelectorOption } from "../inputs";
const TaskName = "Syncing Subtitle";
function useReferencedSubtitles(
mediaType: "episode" | "movie",
mediaId: number,
subtitlesPath: string
) {
// We cannot call hooks conditionally, we rely on useQuery "enabled" option to do only the required API call
const episodeData = useRefTracksByEpisodeId(
subtitlesPath,
mediaId,
mediaType === "episode"
);
const movieData = useRefTracksByMovieId(
subtitlesPath,
mediaId,
mediaType === "movie"
);
const mediaData = mediaType === "episode" ? episodeData : movieData;
const subtitles: { group: string; value: string; label: string }[] = [];
if (!mediaData.data) {
return [];
} else {
if (mediaData.data.audio_tracks.length > 0) {
mediaData.data.audio_tracks.forEach((item) => {
subtitles.push({
group: "Embedded audio tracks",
value: item.stream,
label: `${item.name || item.language} (${item.stream})`,
});
});
}
if (mediaData.data.embedded_subtitles_tracks.length > 0) {
mediaData.data.embedded_subtitles_tracks.forEach((item) => {
subtitles.push({
group: "Embedded subtitles tracks",
value: item.stream,
label: `${item.name || item.language} (${item.stream})`,
});
});
}
if (mediaData.data.external_subtitles_tracks.length > 0) {
mediaData.data.external_subtitles_tracks.forEach((item) => {
if (item) {
subtitles.push({
group: "External Subtitles files",
value: item.path,
label: item.name,
});
}
});
}
return subtitles;
}
}
interface Props {
selections: FormType.ModifySubtitle[];
onSubmit?: VoidFunction;
}
interface FormValues {
reference?: string;
maxOffsetSeconds?: string;
noFixFramerate: boolean;
gss: boolean;
}
const SyncSubtitleForm: FunctionComponent<Props> = ({
selections,
onSubmit,
}) => {
if (selections.length === 0) {
throw new Error("You need to select at least 1 media to sync");
}
const { mutateAsync } = useSubtitleAction();
const modals = useModals();
const mediaType = selections[0].type;
const mediaId = selections[0].id;
const subtitlesPath = selections[0].path;
const subtitles: SelectorOption<string>[] = useReferencedSubtitles(
mediaType,
mediaId,
subtitlesPath
);
const form = useForm<FormValues>({
initialValues: {
noFixFramerate: false,
gss: false,
},
});
return (
<form
onSubmit={form.onSubmit((parameters) => {
selections.forEach((s) => {
const form: FormType.ModifySubtitle = {
...s,
reference: parameters.reference,
max_offset_seconds: parameters.maxOffsetSeconds,
no_fix_framerate: toPython(parameters.noFixFramerate),
gss: toPython(parameters.gss),
};
task.create(s.path, TaskName, mutateAsync, { action: "sync", form });
});
onSubmit?.();
modals.closeSelf();
})}
>
<Stack>
<Alert
title="Subtitles"
color="gray"
icon={<FontAwesomeIcon icon={faInfoCircle}></FontAwesomeIcon>}
>
<Text size="sm">{selections.length} subtitles selected</Text>
</Alert>
<Selector
clearable
disabled={subtitles.length === 0 || selections.length !== 1}
label="Reference"
placeholder="Default: choose automatically within video file"
options={subtitles}
{...form.getInputProps("reference")}
></Selector>
<Selector
clearable
label="Max Offset Seconds"
options={syncMaxOffsetSecondsOptions}
placeholder="Select..."
{...form.getInputProps("maxOffsetSeconds")}
></Selector>
<Checkbox
label="No Fix Framerate"
{...form.getInputProps("noFixFramerate")}
></Checkbox>
<Checkbox
label="Golden-Section Search"
{...form.getInputProps("gss")}
></Checkbox>
<Divider></Divider>
<Button type="submit">Sync</Button>
</Stack>
</form>
);
};
export const SyncSubtitleModal = withModal(SyncSubtitleForm, "sync-subtitle", {
title: "Sync Subtitle Options",
size: "lg",
});
export default SyncSubtitleForm;

@ -1,5 +1,15 @@
import { antiCaptchaOption } from "@/pages/Settings/Providers/options";
import { Anchor } from "@mantine/core";
import { FunctionComponent } from "react"; import { FunctionComponent } from "react";
import { Layout, Section } from "../components"; import {
CollapseBox,
Layout,
Message,
Password,
Section,
Selector,
Text,
} from "../components";
import { ProviderView } from "./components"; import { ProviderView } from "./components";
const SettingsProvidersView: FunctionComponent = () => { const SettingsProvidersView: FunctionComponent = () => {
@ -8,6 +18,47 @@ const SettingsProvidersView: FunctionComponent = () => {
<Section header="Providers"> <Section header="Providers">
<ProviderView></ProviderView> <ProviderView></ProviderView>
</Section> </Section>
<Section header="Anti-Captcha Options">
<Selector
clearable
label={"Choose the anti-captcha provider you want to use"}
placeholder="Select a provider"
settingKey="settings-general-anti_captcha_provider"
settingOptions={{ onSubmit: (v) => (v === undefined ? "None" : v) }}
options={antiCaptchaOption}
></Selector>
<Message></Message>
<CollapseBox
settingKey="settings-general-anti_captcha_provider"
on={(value) => value === "anti-captcha"}
>
<Text
label="Account Key"
settingKey="settings-anticaptcha-anti_captcha_key"
></Text>
<Anchor href="http://getcaptchasolution.com/eixxo1rsnw">
Anti-Captcha.com
</Anchor>
<Message>Link to subscribe</Message>
</CollapseBox>
<CollapseBox
settingKey="settings-general-anti_captcha_provider"
on={(value) => value === "death-by-captcha"}
>
<Text
label="Username"
settingKey="settings-deathbycaptcha-username"
></Text>
<Password
label="Password"
settingKey="settings-deathbycaptcha-password"
></Password>
<Anchor href="https://www.deathbycaptcha.com">
DeathByCaptcha.com
</Anchor>
<Message>Link to subscribe</Message>
</CollapseBox>
</Section>
</Layout> </Layout>
); );
}; };

@ -0,0 +1,12 @@
import { SelectorOption } from "@/components";
export const antiCaptchaOption: SelectorOption<string>[] = [
{
label: "Anti-Captcha",
value: "anti-captcha",
},
{
label: "Death by Captcha",
value: "death-by-captcha",
},
];

@ -1,4 +1,4 @@
import { Anchor, Code, Table } from "@mantine/core"; import { Code, Table } from "@mantine/core";
import { FunctionComponent } from "react"; import { FunctionComponent } from "react";
import { import {
Check, Check,
@ -6,7 +6,6 @@ import {
Layout, Layout,
Message, Message,
MultiSelector, MultiSelector,
Password,
Section, Section,
Selector, Selector,
Slider, Slider,
@ -19,12 +18,12 @@ import {
import { import {
adaptiveSearchingDelayOption, adaptiveSearchingDelayOption,
adaptiveSearchingDeltaOption, adaptiveSearchingDeltaOption,
antiCaptchaOption,
colorOptions, colorOptions,
embeddedSubtitlesParserOption, embeddedSubtitlesParserOption,
folderOptions, folderOptions,
hiExtensionOptions, hiExtensionOptions,
providerOptions, providerOptions,
syncMaxOffsetSecondsOptions,
} from "./options"; } from "./options";
interface CommandOption { interface CommandOption {
@ -128,7 +127,7 @@ const commandOptionElements: JSX.Element[] = commandOptions.map((op, idx) => (
const SettingsSubtitlesView: FunctionComponent = () => { const SettingsSubtitlesView: FunctionComponent = () => {
return ( return (
<Layout name="Subtitles"> <Layout name="Subtitles">
<Section header="Subtitles Options"> <Section header="Basic Options">
<Selector <Selector
label="Subtitle Folder" label="Subtitle Folder"
options={folderOptions} options={folderOptions}
@ -146,6 +145,65 @@ const SettingsSubtitlesView: FunctionComponent = () => {
settingKey="settings-general-subfolder_custom" settingKey="settings-general-subfolder_custom"
></Text> ></Text>
</CollapseBox> </CollapseBox>
<Selector
label="Hearing-impaired subtitles extension"
options={hiExtensionOptions}
settingKey="settings-general-hi_extension"
></Selector>
<Message>
What file extension to use when saving hearing-impaired subtitles to
disk (e.g., video.en.sdh.srt).
</Message>
</Section>
<Section header="Embedded Subtitles">
<Check
label="Use Embedded Subtitles"
settingKey="settings-general-use_embedded_subs"
></Check>
<Message>
Use embedded subtitles in media files when determining missing ones.
</Message>
<CollapseBox indent settingKey="settings-general-use_embedded_subs">
<Selector
settingKey="settings-general-embedded_subtitles_parser"
settingOptions={{
onSaved: (v) => (v === undefined ? "ffprobe" : v),
}}
options={embeddedSubtitlesParserOption}
></Selector>
<Message>Embedded subtitles video parser</Message>
<Check
label="Ignore Embedded PGS Subtitles"
settingKey="settings-general-ignore_pgs_subs"
></Check>
<Message>
Ignores PGS Subtitles in Embedded Subtitles detection.
</Message>
<Check
label="Ignore Embedded VobSub Subtitles"
settingKey="settings-general-ignore_vobsub_subs"
></Check>
<Message>
Ignores VobSub Subtitles in Embedded Subtitles detection.
</Message>
<Check
label="Ignore Embedded ASS Subtitles"
settingKey="settings-general-ignore_ass_subs"
></Check>
<Message>
Ignores ASS Subtitles in Embedded Subtitles detection.
</Message>
<Check
label="Show Only Desired Languages"
settingKey="settings-general-embedded_subs_show_desired"
></Check>
<Message>
Hide embedded subtitles for languages that are not currently
desired.
</Message>
</CollapseBox>
</Section>
<Section header="Upgrading Subtitles">
<Check <Check
label="Upgrade Previously Downloaded Subtitles" label="Upgrade Previously Downloaded Subtitles"
settingKey="settings-general-upgrade_subs" settingKey="settings-general-upgrade_subs"
@ -171,52 +229,25 @@ const SettingsSubtitlesView: FunctionComponent = () => {
subtitles. subtitles.
</Message> </Message>
</CollapseBox> </CollapseBox>
<Selector </Section>
label="Hearing-impaired subtitles extension" <Section header="Encoding">
options={hiExtensionOptions} <Check
settingKey="settings-general-hi_extension" label="Encode Subtitles To UTF8"
></Selector> settingKey="settings-general-utf8_encode"
></Check>
<Message> <Message>
What file extension to use when saving hearing-impaired subtitles to Re-encode downloaded Subtitles to UTF8. Should be left enabled in most
disk (e.g., video.en.sdh.srt). case.
</Message> </Message>
</Section> </Section>
<Section header="Anti-Captcha Options"> <Section header="Permissions">
<Selector <Check
clearable label="Change file permission (chmod)"
placeholder="Select a provider" settingKey="settings-general-chmod_enabled"
settingKey="settings-general-anti_captcha_provider" ></Check>
settingOptions={{ onSubmit: (v) => (v === undefined ? "None" : v) }} <CollapseBox indent settingKey="settings-general-chmod_enabled">
options={antiCaptchaOption} <Text placeholder="0777" settingKey="settings-general-chmod"></Text>
></Selector> <Message>Must be 4 digit octal</Message>
<Message>Choose the anti-captcha provider you want to use</Message>
<CollapseBox
settingKey="settings-general-anti_captcha_provider"
on={(value) => value === "anti-captcha"}
>
<Anchor href="http://getcaptchasolution.com/eixxo1rsnw">
Anti-Captcha.com
</Anchor>
<Text
label="Account Key"
settingKey="settings-anticaptcha-anti_captcha_key"
></Text>
</CollapseBox>
<CollapseBox
settingKey="settings-general-anti_captcha_provider"
on={(value) => value === "death-by-captcha"}
>
<Anchor href="https://www.deathbycaptcha.com">
DeathByCaptcha.com
</Anchor>
<Text
label="Username"
settingKey="settings-deathbycaptcha-username"
></Text>
<Password
label="Password"
settingKey="settings-deathbycaptcha-password"
></Password>
</CollapseBox> </CollapseBox>
</Section> </Section>
<Section header="Performance / Optimization"> <Section header="Performance / Optimization">
@ -258,52 +289,6 @@ const SettingsSubtitlesView: FunctionComponent = () => {
Search multiple providers at once (Don't choose this on low powered Search multiple providers at once (Don't choose this on low powered
devices) devices)
</Message> </Message>
<Check
label="Use Embedded Subtitles"
settingKey="settings-general-use_embedded_subs"
></Check>
<Message>
Use embedded subtitles in media files when determining missing ones.
</Message>
<CollapseBox indent settingKey="settings-general-use_embedded_subs">
<Check
label="Ignore Embedded PGS Subtitles"
settingKey="settings-general-ignore_pgs_subs"
></Check>
<Message>
Ignores PGS Subtitles in Embedded Subtitles detection.
</Message>
<Check
label="Ignore Embedded VobSub Subtitles"
settingKey="settings-general-ignore_vobsub_subs"
></Check>
<Message>
Ignores VobSub Subtitles in Embedded Subtitles detection.
</Message>
<Check
label="Ignore Embedded ASS Subtitles"
settingKey="settings-general-ignore_ass_subs"
></Check>
<Message>
Ignores ASS Subtitles in Embedded Subtitles detection.
</Message>
<Check
label="Show Only Desired Languages"
settingKey="settings-general-embedded_subs_show_desired"
></Check>
<Message>
Hide embedded subtitles for languages that are not currently
desired.
</Message>
<Selector
settingKey="settings-general-embedded_subtitles_parser"
settingOptions={{
onSaved: (v) => (v === undefined ? "ffprobe" : v),
}}
options={embeddedSubtitlesParserOption}
></Selector>
<Message>Embedded subtitles video parser</Message>
</CollapseBox>
<Check <Check
label="Skip video file hash calculation" label="Skip video file hash calculation"
settingKey="settings-general-skip_hashing" settingKey="settings-general-skip_hashing"
@ -314,15 +299,7 @@ const SettingsSubtitlesView: FunctionComponent = () => {
search results scores. search results scores.
</Message> </Message>
</Section> </Section>
<Section header="Post-Processing"> <Section header="Subzero Modifications">
<Check
label="Encode Subtitles To UTF8"
settingKey="settings-general-utf8_encode"
></Check>
<Message>
Re-encode downloaded Subtitles to UTF8. Should be left enabled in most
case.
</Message>
<Check <Check
label="Hearing Impaired" label="Hearing Impaired"
settingOptions={{ onLoaded: SubzeroModification("remove_HI") }} settingOptions={{ onLoaded: SubzeroModification("remove_HI") }}
@ -390,14 +367,8 @@ const SettingsSubtitlesView: FunctionComponent = () => {
Reverses the punctuation in right-to-left subtitles for problematic Reverses the punctuation in right-to-left subtitles for problematic
playback devices. playback devices.
</Message> </Message>
<Check </Section>
label="Permission (chmod)" <Section header="Synchronizarion / Alignement">
settingKey="settings-general-chmod_enabled"
></Check>
<CollapseBox indent settingKey="settings-general-chmod_enabled">
<Text placeholder="0777" settingKey="settings-general-chmod"></Text>
<Message>Must be 4 digit octal</Message>
</CollapseBox>
<Check <Check
label="Always use Audio Track as Reference for Syncing" label="Always use Audio Track as Reference for Syncing"
settingKey="settings-subsync-force_audio" settingKey="settings-subsync-force_audio"
@ -406,6 +377,31 @@ const SettingsSubtitlesView: FunctionComponent = () => {
Use the audio track as reference for syncing, instead of using the Use the audio track as reference for syncing, instead of using the
embedded subtitle. embedded subtitle.
</Message> </Message>
<Check
label="No Fix Framerate"
settingKey="settings-subsync-no_fix_framerate"
></Check>
<Message>
If specified, subsync will not attempt to correct a framerate mismatch
between reference and subtitles.
</Message>
<Check
label="Gold-Section Search"
settingKey="settings-subsync-gss"
></Check>
<Message>
If specified, use golden-section search to try to find the optimal
framerate ratio between video and subtitles.
</Message>
<Selector
label="Max offset seconds"
options={syncMaxOffsetSecondsOptions}
settingKey="settings-subsync-max_offset_seconds"
defaultValue={60}
></Selector>
<Message>
The max allowed offset seconds for any subtitle segment.
</Message>
<Check <Check
label="Automatic Subtitles Synchronization" label="Automatic Subtitles Synchronization"
settingKey="settings-subsync-use_subsync" settingKey="settings-subsync-use_subsync"
@ -443,6 +439,8 @@ const SettingsSubtitlesView: FunctionComponent = () => {
<Slider settingKey="settings-subsync-subsync_movie_threshold"></Slider> <Slider settingKey="settings-subsync-subsync_movie_threshold"></Slider>
</CollapseBox> </CollapseBox>
</CollapseBox> </CollapseBox>
</Section>
<Section header="Custom post-processing">
<Check <Check
settingKey="settings-general-use_postprocessing" settingKey="settings-general-use_postprocessing"
label="Custom Post-Processing" label="Custom Post-Processing"

@ -31,17 +31,6 @@ export const folderOptions: SelectorOption<string>[] = [
}, },
]; ];
export const antiCaptchaOption: SelectorOption<string>[] = [
{
label: "Anti-Captcha",
value: "anti-captcha",
},
{
label: "Death by Captcha",
value: "death-by-captcha",
},
];
export const embeddedSubtitlesParserOption: SelectorOption<string>[] = [ export const embeddedSubtitlesParserOption: SelectorOption<string>[] = [
{ {
label: "ffprobe (faster)", label: "ffprobe (faster)",
@ -173,3 +162,22 @@ export const providerOptions: SelectorOption<string>[] = ProviderList.map(
value: v.key, value: v.key,
}) })
); );
export const syncMaxOffsetSecondsOptions: SelectorOption<number>[] = [
{
label: "60",
value: 60,
},
{
label: "120",
value: 120,
},
{
label: "300",
value: 300,
},
{
label: "600",
value: 600,
},
];

@ -51,6 +51,28 @@ interface Subtitle {
path: string | null | undefined; // TODO: FIX ME!!!!!! path: string | null | undefined; // TODO: FIX ME!!!!!!
} }
interface AudioTrack {
stream: string;
name: string;
language: string;
}
interface SubtitleTrack {
stream: string;
name: string;
language: string;
forced: boolean;
hearing_impaired: boolean;
}
interface ExternalSubtitle {
name: string;
path: string;
language: string;
forced: boolean;
hearing_impaired: boolean;
}
interface PathType { interface PathType {
path: string; path: string;
} }
@ -149,6 +171,12 @@ declare namespace Item {
season: number; season: number;
episode: number; episode: number;
}; };
type RefTracks = {
audio_tracks: AudioTrack[];
embedded_subtitles_tracks: SubtitleTrack[];
external_subtitles_tracks: ExternalSubtitle[];
};
} }
declare namespace Wanted { declare namespace Wanted {

@ -41,6 +41,13 @@ declare namespace FormType {
type: "episode" | "movie"; type: "episode" | "movie";
language: string; language: string;
path: string; path: string;
forced?: PythonBoolean;
hi?: PythonBoolean;
original_format?: PythonBoolean;
reference?: string;
max_offset_seconds?: string;
no_fix_framerate?: PythonBoolean;
gss?: PythonBoolean;
} }
interface DownloadSeries { interface DownloadSeries {

@ -114,6 +114,9 @@ declare namespace Settings {
subsync_movie_threshold: number; subsync_movie_threshold: number;
debug: boolean; debug: boolean;
force_audio: boolean; force_audio: boolean;
max_offset_seconds: number;
no_fix_framerate: boolean;
gss: boolean;
} }
interface Analytic { interface Analytic {

@ -59,6 +59,10 @@ export function filterSubtitleBy(
} }
} }
export function toPython(value: boolean): PythonBoolean {
return value ? "True" : "False";
}
export * from "./env"; export * from "./env";
export * from "./hooks"; export * from "./hooks";
export * from "./validate"; export * from "./validate";

@ -14,7 +14,7 @@ try:
datefmt="[%X]", datefmt="[%X]",
handlers=[RichHandler(console=Console(file=sys.stderr))], handlers=[RichHandler(console=Console(file=sys.stderr))],
) )
except ImportError: except: # noqa: E722
logging.basicConfig(stream=sys.stderr, level=logging.INFO) logging.basicConfig(stream=sys.stderr, level=logging.INFO)
from .version import __version__ # noqa from .version import __version__ # noqa

@ -8,11 +8,11 @@ import json
version_json = ''' version_json = '''
{ {
"date": "2022-01-07T20:35:34-0800", "date": "2023-04-20T11:25:58+0100",
"dirty": false, "dirty": false,
"error": null, "error": null,
"full-revisionid": "9ae15d825b24b3445112683bbb7b2e4a9d3ecb8f", "full-revisionid": "0953aa240101a7aa235438496f796ef5f8d69d5b",
"version": "0.4.20" "version": "0.4.25"
} }
''' # END VERSION_JSON ''' # END VERSION_JSON

@ -34,13 +34,16 @@ class FFTAligner(TransformerMixin):
convolve = np.copy(convolve) convolve = np.copy(convolve)
if self.max_offset_samples is None: if self.max_offset_samples is None:
return convolve return convolve
offset_to_index = lambda offset: len(convolve) - 1 + offset - len(substring)
convolve[: offset_to_index(-self.max_offset_samples)] = float("-inf") def _offset_to_index(offset):
convolve[offset_to_index(self.max_offset_samples) :] = float("-inf") return len(convolve) - 1 + offset - len(substring)
convolve[: _offset_to_index(-self.max_offset_samples)] = float("-inf")
convolve[_offset_to_index(self.max_offset_samples) :] = float("-inf")
return convolve return convolve
def _compute_argmax(self, convolve: np.ndarray, substring: np.ndarray) -> None: def _compute_argmax(self, convolve: np.ndarray, substring: np.ndarray) -> None:
best_idx = np.argmax(convolve) best_idx = int(np.argmax(convolve))
self.best_offset_ = len(convolve) - 1 - best_idx - len(substring) self.best_offset_ = len(convolve) - 1 - best_idx - len(substring)
self.best_score_ = convolve[best_idx] self.best_score_ = convolve[best_idx]

@ -202,10 +202,7 @@ def try_sync(
if args.output_encoding != "same": if args.output_encoding != "same":
out_subs = out_subs.set_encoding(args.output_encoding) out_subs = out_subs.set_encoding(args.output_encoding)
suppress_output_thresh = args.suppress_output_if_offset_less_than suppress_output_thresh = args.suppress_output_if_offset_less_than
if suppress_output_thresh is None or ( if offset_seconds >= (suppress_output_thresh or float("-inf")):
scale_step.scale_factor == 1.0
and offset_seconds >= suppress_output_thresh
):
logger.info("writing output to {}".format(srtout or "stdout")) logger.info("writing output to {}".format(srtout or "stdout"))
out_subs.write_file(srtout) out_subs.write_file(srtout)
else: else:
@ -216,11 +213,10 @@ def try_sync(
) )
except FailedToFindAlignmentException as e: except FailedToFindAlignmentException as e:
sync_was_successful = False sync_was_successful = False
logger.error(e) logger.error(str(e))
except Exception as e: except Exception as e:
exc = e exc = e
sync_was_successful = False sync_was_successful = False
logger.error(e)
else: else:
result["offset_seconds"] = offset_seconds result["offset_seconds"] = offset_seconds
result["framerate_scale_factor"] = scale_step.scale_factor result["framerate_scale_factor"] = scale_step.scale_factor
@ -362,23 +358,29 @@ def validate_args(args: argparse.Namespace) -> None:
) )
if not args.srtin: if not args.srtin:
raise ValueError( raise ValueError(
"need to specify input srt if --overwrite-input is specified since we cannot overwrite stdin" "need to specify input srt if --overwrite-input "
"is specified since we cannot overwrite stdin"
) )
if args.srtout is not None: if args.srtout is not None:
raise ValueError( raise ValueError(
"overwrite input set but output file specified; refusing to run in case this was not intended" "overwrite input set but output file specified; "
"refusing to run in case this was not intended"
) )
if args.extract_subs_from_stream is not None: if args.extract_subs_from_stream is not None:
if args.make_test_case: if args.make_test_case:
raise ValueError("test case is for sync and not subtitle extraction") raise ValueError("test case is for sync and not subtitle extraction")
if args.srtin: if args.srtin:
raise ValueError( raise ValueError(
"stream specified for reference subtitle extraction; -i flag for sync input not allowed" "stream specified for reference subtitle extraction; "
"-i flag for sync input not allowed"
) )
def validate_file_permissions(args: argparse.Namespace) -> None: def validate_file_permissions(args: argparse.Namespace) -> None:
error_string_template = "unable to {action} {file}; try ensuring file exists and has correct permissions" error_string_template = (
"unable to {action} {file}; "
"try ensuring file exists and has correct permissions"
)
if args.reference is not None and not os.access(args.reference, os.R_OK): if args.reference is not None and not os.access(args.reference, os.R_OK):
raise ValueError( raise ValueError(
error_string_template.format(action="read reference", file=args.reference) error_string_template.format(action="read reference", file=args.reference)
@ -506,27 +508,27 @@ def run(
try: try:
sync_was_successful = _run_impl(args, result) sync_was_successful = _run_impl(args, result)
result["sync_was_successful"] = sync_was_successful result["sync_was_successful"] = sync_was_successful
return result
finally: finally:
if log_handler is None or log_path is None: if log_handler is not None and log_path is not None:
return result
try:
log_handler.close() log_handler.close()
logger.removeHandler(log_handler) logger.removeHandler(log_handler)
if args.make_test_case: if args.make_test_case:
result["retval"] += make_test_case( result["retval"] += make_test_case(
args, _npy_savename(args), sync_was_successful args, _npy_savename(args), sync_was_successful
) )
finally:
if args.log_dir_path is None or not os.path.isdir(args.log_dir_path): if args.log_dir_path is None or not os.path.isdir(args.log_dir_path):
os.remove(log_path) os.remove(log_path)
return result
def add_main_args_for_cli(parser: argparse.ArgumentParser) -> None: def add_main_args_for_cli(parser: argparse.ArgumentParser) -> None:
parser.add_argument( parser.add_argument(
"reference", "reference",
nargs="?", nargs="?",
help="Reference (video, subtitles, or a numpy array with VAD speech) to which to synchronize input subtitles.", help=(
"Reference (video, subtitles, or a numpy array with VAD speech) "
"to which to synchronize input subtitles."
),
) )
parser.add_argument( parser.add_argument(
"-i", "--srtin", nargs="*", help="Input subtitles file (default=stdin)." "-i", "--srtin", nargs="*", help="Input subtitles file (default=stdin)."
@ -554,11 +556,13 @@ def add_main_args_for_cli(parser: argparse.ArgumentParser) -> None:
"--reference-track", "--reference-track",
"--reftrack", "--reftrack",
default=None, default=None,
help="Which stream/track in the video file to use as reference, " help=(
"formatted according to ffmpeg conventions. For example, 0:s:0 " "Which stream/track in the video file to use as reference, "
"uses the first subtitle track; 0:a:3 would use the third audio track. " "formatted according to ffmpeg conventions. For example, 0:s:0 "
"You can also drop the leading `0:`; i.e. use s:0 or a:3, respectively. " "uses the first subtitle track; 0:a:3 would use the third audio track. "
"Example: `ffs ref.mkv -i in.srt -o out.srt --reference-stream s:2`", "You can also drop the leading `0:`; i.e. use s:0 or a:3, respectively. "
"Example: `ffs ref.mkv -i in.srt -o out.srt --reference-stream s:2`"
),
) )
@ -574,7 +578,10 @@ def add_cli_only_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument( parser.add_argument(
"--overwrite-input", "--overwrite-input",
action="store_true", action="store_true",
help="If specified, will overwrite the input srt instead of writing the output to a new file.", help=(
"If specified, will overwrite the input srt "
"instead of writing the output to a new file."
),
) )
parser.add_argument( parser.add_argument(
"--encoding", "--encoding",
@ -642,7 +649,14 @@ def add_cli_only_args(parser: argparse.ArgumentParser) -> None:
) )
parser.add_argument( parser.add_argument(
"--vad", "--vad",
choices=["subs_then_webrtc", "webrtc", "subs_then_auditok", "auditok"], choices=[
"subs_then_webrtc",
"webrtc",
"subs_then_auditok",
"auditok",
"subs_then_silero",
"silero",
],
default=None, default=None,
help="Which voice activity detector to use for speech extraction " help="Which voice activity detector to use for speech extraction "
"(if using video / audio as a reference, default={}).".format(DEFAULT_VAD), "(if using video / audio as a reference, default={}).".format(DEFAULT_VAD),
@ -680,7 +694,10 @@ def add_cli_only_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument( parser.add_argument(
"--log-dir-path", "--log-dir-path",
default=None, default=None,
help="If provided, will save log file ffsubsync.log to this path (must be an existing directory).", help=(
"If provided, will save log file ffsubsync.log to this path "
"(must be an existing directory)."
),
) )
parser.add_argument( parser.add_argument(
"--gss", "--gss",
@ -688,6 +705,11 @@ def add_cli_only_args(parser: argparse.ArgumentParser) -> None:
help="If specified, use golden-section search to try to find" help="If specified, use golden-section search to try to find"
"the optimal framerate ratio between video and subtitles.", "the optimal framerate ratio between video and subtitles.",
) )
parser.add_argument(
"--strict",
action="store_true",
help="If specified, refuse to parse srt files with formatting issues.",
)
parser.add_argument("--vlc-mode", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--vlc-mode", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--gui-mode", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--gui-mode", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--skip-sync", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--skip-sync", action="store_true", help=argparse.SUPPRESS)

@ -64,7 +64,11 @@ _menu = [
def make_parser(): def make_parser():
description = DESCRIPTION description = DESCRIPTION
if update_available(): if update_available():
description += '\nUpdate available! Please go to "File" -> "Download latest release" to update FFsubsync.' description += (
"\nUpdate available! Please go to "
'"File" -> "Download latest release"'
" to update FFsubsync."
)
parser = GooeyParser(description=description) parser = GooeyParser(description=description)
main_group = parser.add_argument_group("Basic") main_group = parser.add_argument_group("Basic")
main_group.add_argument( main_group.add_argument(

@ -4,7 +4,37 @@ This module borrows and adapts `Pipeline` from `sklearn.pipeline` and
`TransformerMixin` from `sklearn.base` in the scikit-learn framework `TransformerMixin` from `sklearn.base` in the scikit-learn framework
(commit hash d205638475ca542dc46862652e3bb0be663a8eac) to be precise). (commit hash d205638475ca542dc46862652e3bb0be663a8eac) to be precise).
Both are BSD licensed and allow for this sort of thing; attribution Both are BSD licensed and allow for this sort of thing; attribution
is given as a comment above each class. is given as a comment above each class. License reproduced below:
BSD 3-Clause License
Copyright (c) 2007-2022 The scikit-learn developers.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
""" """
from collections import defaultdict from collections import defaultdict
from itertools import islice from itertools import islice
@ -14,7 +44,7 @@ from typing_extensions import Protocol
class TransformerProtocol(Protocol): class TransformerProtocol(Protocol):
fit: Callable[..., "TransformerProtocol"] fit: Callable[..., "TransformerProtocol"]
transform: Callable[["TransformerProtocol", Any], Any] transform: Callable[[Any], Any]
# Author: Gael Varoquaux <gael.varoquaux@normalesup.org> # Author: Gael Varoquaux <gael.varoquaux@normalesup.org>
@ -176,7 +206,7 @@ class Pipeline:
) )
step, param = pname.split("__", 1) step, param = pname.split("__", 1)
fit_params_steps[step][param] = pval fit_params_steps[step][param] = pval
for (step_idx, name, transformer) in self._iter( for step_idx, name, transformer in self._iter(
with_final=False, filter_passthrough=False with_final=False, filter_passthrough=False
): ):
if transformer is None or transformer == "passthrough": if transformer is None or transformer == "passthrough":

@ -1,17 +1,24 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import os
from contextlib import contextmanager from contextlib import contextmanager
import logging import logging
import io import io
import subprocess import subprocess
import sys import sys
from datetime import timedelta from datetime import timedelta
from typing import cast, Callable, Dict, Optional, Union from typing import cast, Callable, Dict, List, Optional, Union
import ffmpeg import ffmpeg
import numpy as np import numpy as np
import tqdm import tqdm
from ffsubsync.constants import * from ffsubsync.constants import (
DEFAULT_ENCODING,
DEFAULT_MAX_SUBTITLE_SECONDS,
DEFAULT_SCALE_FACTOR,
DEFAULT_START_SECONDS,
SAMPLE_RATE,
)
from ffsubsync.ffmpeg_utils import ffmpeg_bin_path, subprocess_args from ffsubsync.ffmpeg_utils import ffmpeg_bin_path, subprocess_args
from ffsubsync.generic_subtitles import GenericSubtitle from ffsubsync.generic_subtitles import GenericSubtitle
from ffsubsync.sklearn_shim import TransformerMixin from ffsubsync.sklearn_shim import TransformerMixin
@ -144,7 +151,7 @@ def _make_webrtcvad_detector(
asegment[start * bytes_per_frame : stop * bytes_per_frame], asegment[start * bytes_per_frame : stop * bytes_per_frame],
sample_rate=frame_rate, sample_rate=frame_rate,
) )
except: except Exception:
is_speech = False is_speech = False
failures += 1 failures += 1
# webrtcvad has low recall on mode 3, so treat non-speech as "not sure" # webrtcvad has low recall on mode 3, so treat non-speech as "not sure"
@ -154,6 +161,49 @@ def _make_webrtcvad_detector(
return _detect return _detect
def _make_silero_detector(
sample_rate: int, frame_rate: int, non_speech_label: float
) -> Callable[[bytes], np.ndarray]:
import torch
window_duration = 1.0 / sample_rate # duration in seconds
frames_per_window = int(window_duration * frame_rate + 0.5)
bytes_per_frame = 1
model, _ = torch.hub.load(
repo_or_dir="snakers4/silero-vad",
model="silero_vad",
force_reload=False,
onnx=False,
)
exception_logged = False
def _detect(asegment) -> np.ndarray:
asegment = np.frombuffer(asegment, np.int16).astype(np.float32) / (1 << 15)
asegment = torch.FloatTensor(asegment)
media_bstring = []
failures = 0
for start in range(0, len(asegment) // bytes_per_frame, frames_per_window):
stop = min(start + frames_per_window, len(asegment))
try:
speech_prob = model(
asegment[start * bytes_per_frame : stop * bytes_per_frame],
frame_rate,
).item()
except Exception:
nonlocal exception_logged
if not exception_logged:
exception_logged = True
logger.exception("exception occurred during speech detection")
speech_prob = 0.0
failures += 1
media_bstring.append(1.0 - (1.0 - speech_prob) * (1.0 - non_speech_label))
return np.array(media_bstring)
return _detect
class ComputeSpeechFrameBoundariesMixin: class ComputeSpeechFrameBoundariesMixin:
def __init__(self) -> None: def __init__(self) -> None:
self.start_frame_: Optional[int] = None self.start_frame_: Optional[int] = None
@ -170,8 +220,8 @@ class ComputeSpeechFrameBoundariesMixin:
) -> "ComputeSpeechFrameBoundariesMixin": ) -> "ComputeSpeechFrameBoundariesMixin":
nz = np.nonzero(speech_frames > 0.5)[0] nz = np.nonzero(speech_frames > 0.5)[0]
if len(nz) > 0: if len(nz) > 0:
self.start_frame_ = np.min(nz) self.start_frame_ = int(np.min(nz))
self.end_frame_ = np.max(nz) self.end_frame_ = int(np.max(nz))
return self return self
@ -287,9 +337,13 @@ class VideoSpeechTransformer(TransformerMixin):
detector = _make_auditok_detector( detector = _make_auditok_detector(
self.sample_rate, self.frame_rate, self._non_speech_label self.sample_rate, self.frame_rate, self._non_speech_label
) )
elif "silero" in self.vad:
detector = _make_silero_detector(
self.sample_rate, self.frame_rate, self._non_speech_label
)
else: else:
raise ValueError("unknown vad: %s" % self.vad) raise ValueError("unknown vad: %s" % self.vad)
media_bstring = [] media_bstring: List[np.ndarray] = []
ffmpeg_args = [ ffmpeg_args = [
ffmpeg_bin_path( ffmpeg_bin_path(
"ffmpeg", self.gui_mode, ffmpeg_resources_path=self.ffmpeg_path "ffmpeg", self.gui_mode, ffmpeg_resources_path=self.ffmpeg_path
@ -324,10 +378,7 @@ class VideoSpeechTransformer(TransformerMixin):
windows_per_buffer = 10000 windows_per_buffer = 10000
simple_progress = 0.0 simple_progress = 0.0
@contextmanager redirect_stderr = None
def redirect_stderr(enter_result=None):
yield enter_result
tqdm_extra_args = {} tqdm_extra_args = {}
should_print_redirected_stderr = self.gui_mode should_print_redirected_stderr = self.gui_mode
if self.gui_mode: if self.gui_mode:
@ -337,6 +388,13 @@ class VideoSpeechTransformer(TransformerMixin):
tqdm_extra_args["file"] = sys.stdout tqdm_extra_args["file"] = sys.stdout
except ImportError: except ImportError:
should_print_redirected_stderr = False should_print_redirected_stderr = False
if redirect_stderr is None:
@contextmanager
def redirect_stderr(enter_result=None):
yield enter_result
assert redirect_stderr is not None
pbar_output = io.StringIO() pbar_output = io.StringIO()
with redirect_stderr(pbar_output): with redirect_stderr(pbar_output):
with tqdm.tqdm( with tqdm.tqdm(
@ -363,13 +421,17 @@ class VideoSpeechTransformer(TransformerMixin):
assert self.gui_mode assert self.gui_mode
# no need to flush since we pass -u to do unbuffered output for gui mode # no need to flush since we pass -u to do unbuffered output for gui mode
print(pbar_output.read()) print(pbar_output.read())
in_bytes = np.frombuffer(in_bytes, np.uint8) if "silero" not in self.vad:
in_bytes = np.frombuffer(in_bytes, np.uint8)
media_bstring.append(detector(in_bytes)) media_bstring.append(detector(in_bytes))
process.wait()
if len(media_bstring) == 0: if len(media_bstring) == 0:
raise ValueError( raise ValueError(
"Unable to detect speech. Perhaps try specifying a different stream / track, or a different vad." "Unable to detect speech. "
"Perhaps try specifying a different stream / track, or a different vad."
) )
self.video_speech_results_ = np.concatenate(media_bstring) self.video_speech_results_ = np.concatenate(media_bstring)
logger.info("total of speech segments: %s", np.sum(self.video_speech_results_))
return self return self
def transform(self, *_) -> np.ndarray: def transform(self, *_) -> np.ndarray:

@ -1,17 +1,29 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any, Optional from typing import Any, cast, List, Optional
try: try:
import cchardet as chardet import cchardet
except ImportError: except: # noqa: E722
import chardet # type: ignore cchardet = None
try:
import chardet
except: # noqa: E722
chardet = None
try:
import charset_normalizer
except: # noqa: E722
charset_normalizer = None
import pysubs2 import pysubs2
from ffsubsync.sklearn_shim import TransformerMixin from ffsubsync.sklearn_shim import TransformerMixin
import srt import srt
from ffsubsync.constants import * from ffsubsync.constants import (
DEFAULT_ENCODING,
DEFAULT_MAX_SUBTITLE_SECONDS,
DEFAULT_START_SECONDS,
)
from ffsubsync.file_utils import open_file from ffsubsync.file_utils import open_file
from ffsubsync.generic_subtitles import GenericSubtitle, GenericSubtitlesFile, SubsMixin from ffsubsync.generic_subtitles import GenericSubtitle, GenericSubtitlesFile, SubsMixin
@ -61,6 +73,7 @@ class GenericSubtitleParser(SubsMixin, TransformerMixin):
max_subtitle_seconds: Optional[int] = None, max_subtitle_seconds: Optional[int] = None,
start_seconds: int = 0, start_seconds: int = 0,
skip_ssa_info: bool = False, skip_ssa_info: bool = False,
strict: bool = False,
) -> None: ) -> None:
super(self.__class__, self).__init__() super(self.__class__, self).__init__()
self.sub_format: str = fmt self.sub_format: str = fmt
@ -72,6 +85,7 @@ class GenericSubtitleParser(SubsMixin, TransformerMixin):
self.start_seconds: int = start_seconds self.start_seconds: int = start_seconds
# FIXME: hack to get tests to pass; remove # FIXME: hack to get tests to pass; remove
self._skip_ssa_info: bool = skip_ssa_info self._skip_ssa_info: bool = skip_ssa_info
self._strict: bool = strict
def fit(self, fname: str, *_) -> "GenericSubtitleParser": def fit(self, fname: str, *_) -> "GenericSubtitleParser":
if self.caching and self.fit_fname == ("<stdin>" if fname is None else fname): if self.caching and self.fit_fname == ("<stdin>" if fname is None else fname):
@ -80,15 +94,28 @@ class GenericSubtitleParser(SubsMixin, TransformerMixin):
with open_file(fname, "rb") as f: with open_file(fname, "rb") as f:
subs = f.read() subs = f.read()
if self.encoding == "infer": if self.encoding == "infer":
encodings_to_try = (chardet.detect(subs)["encoding"],) for chardet_lib in (cchardet, charset_normalizer, chardet):
self.detected_encoding_ = encodings_to_try[0] if chardet_lib is not None:
try:
detected_encoding = cast(
Optional[str], chardet_lib.detect(subs)["encoding"]
)
except: # noqa: E722
continue
if detected_encoding is not None:
self.detected_encoding_ = detected_encoding
encodings_to_try = (detected_encoding,)
break
assert self.detected_encoding_ is not None
logger.info("detected encoding: %s" % self.detected_encoding_) logger.info("detected encoding: %s" % self.detected_encoding_)
exc = None exc = None
for encoding in encodings_to_try: for encoding in encodings_to_try:
try: try:
decoded_subs = subs.decode(encoding, errors="replace").strip() decoded_subs = subs.decode(encoding, errors="replace").strip()
if self.sub_format == "srt": if self.sub_format == "srt":
parsed_subs = srt.parse(decoded_subs) parsed_subs = srt.parse(
decoded_subs, ignore_errors=not self._strict
)
elif self.sub_format in ("ass", "ssa", "sub"): elif self.sub_format in ("ass", "ssa", "sub"):
parsed_subs = pysubs2.SSAFile.from_string(decoded_subs) parsed_subs = pysubs2.SSAFile.from_string(decoded_subs)
else: else:
@ -144,4 +171,5 @@ def make_subtitle_parser(
max_subtitle_seconds=max_subtitle_seconds, max_subtitle_seconds=max_subtitle_seconds,
start_seconds=start_seconds, start_seconds=start_seconds,
skip_ssa_info=kwargs.get("skip_ssa_info", False), skip_ssa_info=kwargs.get("skip_ssa_info", False),
strict=kwargs.get("strict", False),
) )

@ -10,7 +10,7 @@ deep-translator==1.9.1
dogpile.cache==1.1.8 dogpile.cache==1.1.8
dynaconf==3.1.12 dynaconf==3.1.12
fese==0.1.2 fese==0.1.2
ffsubsync==0.4.20 ffsubsync==0.4.25
Flask-Compress==1.13 # modified to import brotli only if required Flask-Compress==1.13 # modified to import brotli only if required
flask-cors==3.0.10 flask-cors==3.0.10
flask-migrate==4.0.4 flask-migrate==4.0.4

Loading…
Cancel
Save