Improved subtitles synchronisation settings and added a manual sync modal

This commit is contained in:
morpheus65535 2024-01-10 23:07:42 -05:00 committed by GitHub
parent 0807bd99b9
commit 0e648b5588
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 932 additions and 226 deletions

View file

@ -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

View file

@ -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),

View file

@ -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)

View file

@ -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()

View file

@ -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:

View file

@ -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,

View file

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

View file

@ -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 }
);
}

View file

@ -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,

View file

@ -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",

View file

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

View file

@ -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>
);
};

View file

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

View file

@ -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"

View file

@ -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,
},
];

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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";

View file

@ -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

View file

@ -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

View file

@ -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]

View file

@ -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)

View file

@ -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(

View file

@ -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":

View file

@ -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:

View file

@ -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),
)

View file

@ -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