From 973a87ffc6b2e4d04a79382546043adf59bb318a Mon Sep 17 00:00:00 2001 From: vitaliibudnyi <27superuser@gmail.com> Date: Sat, 21 Feb 2026 19:06:49 +0200 Subject: [PATCH 1/6] add "captions" as download format --- README.md | 1 + app/dl_formats.py | 11 ++++++++++ app/ytdl.py | 35 ++++++++++++++++++++++++++++---- ui/src/app/interfaces/formats.ts | 5 +++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3812b8d..1e04bbf 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Certain values can be set via environment variables, using the `-e` parameter on * __OUTPUT_TEMPLATE_CHANNEL__: The template for the filenames of the downloaded videos when downloaded as a channel. Defaults to `%(channel)s/%(title)s.%(ext)s`. When empty, then `OUTPUT_TEMPLATE` is used. * __YTDL_OPTIONS__: Additional options to pass to yt-dlp in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L222). They roughly correspond to command-line options, though some do not have exact equivalents here. For example, `--recode-video` has to be specified via `postprocessors`. Also note that dashes are replaced with underscores. You may find [this script](https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py) helpful for converting from command-line options to `YTDL_OPTIONS`. * __YTDL_OPTIONS_FILE__: A path to a JSON file that will be loaded and used for populating `YTDL_OPTIONS` above. Please note that if both `YTDL_OPTIONS_FILE` and `YTDL_OPTIONS` are specified, the options in `YTDL_OPTIONS` take precedence. The file will be monitored for changes and reloaded automatically when changes are detected. +* UI format __Captions (EN, VTT)__: Downloads subtitles/captions only (no media) by applying yt-dlp options equivalent to `--skip-download --write-subs --write-auto-subs --sub-langs en --sub-format vtt`. ### 🌐 Web Server & URLs diff --git a/app/dl_formats.py b/app/dl_formats.py index 5640ca4..8f9583d 100644 --- a/app/dl_formats.py +++ b/app/dl_formats.py @@ -26,6 +26,10 @@ def get_format(format: str, quality: str) -> str: # Quality is irrelevant in this case since we skip the download return "bestaudio/best" + if format == "captions": + # Quality is irrelevant in this case since we skip the download + return "bestaudio/best" + if format in AUDIO_FORMATS: # Audio quality needs to be set post-download, set in opts return f"bestaudio[ext={format}]/bestaudio/best" @@ -98,6 +102,13 @@ def get_opts(format: str, quality: str, ytdl_opts: dict) -> dict: {"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"} ) + if format == "captions": + opts["skip_download"] = True + opts["writesubtitles"] = True + opts["writeautomaticsub"] = True + opts["subtitleslangs"] = ["en"] + opts["subtitlesformat"] = "vtt" + opts["postprocessors"] = postprocessors + ( opts["postprocessors"] if "postprocessors" in opts else [] ) diff --git a/app/ytdl.py b/app/ytdl.py index 84ac070..4670f01 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -160,6 +160,14 @@ class Download: else: filename = filepath self.status_queue.put({'status': 'finished', 'filename': filename}) + # For captions-only downloads, yt-dlp may still report a media-like + # filepath in MoveFiles. Capture subtitle outputs explicitly so the + # UI can link to real caption files. + if self.info.format == 'captions': + requested_subtitles = d.get('info_dict', {}).get('requested_subtitles', {}) or {} + for subtitle in requested_subtitles.values(): + if isinstance(subtitle, dict) and subtitle.get('filepath'): + self.status_queue.put({'subtitle_file': subtitle['filepath']}) # Capture all chapter files when SplitChapters finishes elif d.get('postprocessor') == 'SplitChapters' and d.get('status') == 'finished': @@ -260,10 +268,14 @@ class Download: self.tmpfilename = status.get('tmpfilename') if 'filename' in status: fileName = status.get('filename') - self.info.filename = os.path.relpath(fileName, self.download_dir) - self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None - if self.info.format == 'thumbnail': - self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename) + rel_name = os.path.relpath(fileName, self.download_dir) + # For captions mode, ignore media-like placeholders and let subtitle_file + # statuses define the final file shown in the UI. + if not (self.info.format == 'captions' and not rel_name.endswith(('.vtt', '.srt', '.ass', '.ttml'))): + self.info.filename = rel_name + self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None + if self.info.format == 'thumbnail': + self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename) # Handle chapter files log.debug(f"Update status for {self.info.title}: {status}") @@ -280,6 +292,21 @@ class Download: # Skip the rest of status processing for chapter files continue + if 'subtitle_file' in status: + subtitle_file = status.get('subtitle_file') + if not hasattr(self.info, 'subtitle_files'): + self.info.subtitle_files = [] + rel_path = os.path.relpath(subtitle_file, self.download_dir) + file_size = os.path.getsize(subtitle_file) if os.path.exists(subtitle_file) else None + existing = next((sf for sf in self.info.subtitle_files if sf['filename'] == rel_path), None) + if not existing: + self.info.subtitle_files.append({'filename': rel_path, 'size': file_size}) + # Prefer first subtitle file as the primary result link in captions mode. + if self.info.format == 'captions' and (not getattr(self.info, 'filename', None)): + self.info.filename = rel_path + self.info.size = file_size + continue + self.info.status = status['status'] self.info.msg = status.get('msg') if 'downloaded_bytes' in status: diff --git a/ui/src/app/interfaces/formats.ts b/ui/src/app/interfaces/formats.ts index 8cd2201..53ef28f 100644 --- a/ui/src/app/interfaces/formats.ts +++ b/ui/src/app/interfaces/formats.ts @@ -73,4 +73,9 @@ export const Formats: Format[] = [ text: 'Thumbnail', qualities: [{ id: 'best', text: 'Best' }], }, + { + id: 'captions', + text: 'Captions (EN, VTT)', + qualities: [{ id: 'best', text: 'Best' }], + }, ]; From ce9703cd042b30965c083af7607f7eee12131e17 Mon Sep 17 00:00:00 2001 From: vitaliibudnyi <27superuser@gmail.com> Date: Sat, 21 Feb 2026 20:00:09 +0200 Subject: [PATCH 2/6] add advanced options for captions download format --- README.md | 2 +- app/dl_formats.py | 45 +++++++- app/main.py | 27 ++++- app/ytdl.py | 141 +++++++++++++++++++++-- ui/src/app/app.html | 45 ++++++++ ui/src/app/app.ts | 83 ++++++++++++- ui/src/app/interfaces/download.ts | 3 + ui/src/app/interfaces/formats.ts | 2 +- ui/src/app/services/downloads.service.ts | 48 +++++++- 9 files changed, 370 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 1e04bbf..aafaea7 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Certain values can be set via environment variables, using the `-e` parameter on * __OUTPUT_TEMPLATE_CHANNEL__: The template for the filenames of the downloaded videos when downloaded as a channel. Defaults to `%(channel)s/%(title)s.%(ext)s`. When empty, then `OUTPUT_TEMPLATE` is used. * __YTDL_OPTIONS__: Additional options to pass to yt-dlp in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L222). They roughly correspond to command-line options, though some do not have exact equivalents here. For example, `--recode-video` has to be specified via `postprocessors`. Also note that dashes are replaced with underscores. You may find [this script](https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py) helpful for converting from command-line options to `YTDL_OPTIONS`. * __YTDL_OPTIONS_FILE__: A path to a JSON file that will be loaded and used for populating `YTDL_OPTIONS` above. Please note that if both `YTDL_OPTIONS_FILE` and `YTDL_OPTIONS` are specified, the options in `YTDL_OPTIONS` take precedence. The file will be monitored for changes and reloaded automatically when changes are detected. -* UI format __Captions (EN, VTT)__: Downloads subtitles/captions only (no media) by applying yt-dlp options equivalent to `--skip-download --write-subs --write-auto-subs --sub-langs en --sub-format vtt`. +* UI format __Captions__: Downloads subtitles/captions only (no media). Subtitle format, language, and source preference are configurable from Advanced Options (defaults: `ass`, `en`, `prefer_manual`). ### 🌐 Web Server & URLs diff --git a/app/dl_formats.py b/app/dl_formats.py index 8f9583d..43384f0 100644 --- a/app/dl_formats.py +++ b/app/dl_formats.py @@ -1,6 +1,17 @@ import copy AUDIO_FORMATS = ("m4a", "mp3", "opus", "wav", "flac") +CAPTION_MODES = ("auto_only", "manual_only", "prefer_manual", "prefer_auto") + + +def _normalize_caption_mode(mode: str) -> str: + mode = (mode or "").strip() + return mode if mode in CAPTION_MODES else "prefer_manual" + + +def _normalize_subtitle_language(language: str) -> str: + language = (language or "").strip() + return language or "en" def get_format(format: str, quality: str) -> str: @@ -55,7 +66,14 @@ def get_format(format: str, quality: str) -> str: raise Exception(f"Unkown format {format}") -def get_opts(format: str, quality: str, ytdl_opts: dict) -> dict: +def get_opts( + format: str, + quality: str, + ytdl_opts: dict, + subtitle_format: str = "ass", + subtitle_language: str = "en", + subtitle_mode: str = "prefer_manual", +) -> dict: """ Returns extra download options Mostly postprocessing options @@ -103,11 +121,28 @@ def get_opts(format: str, quality: str, ytdl_opts: dict) -> dict: ) if format == "captions": + mode = _normalize_caption_mode(subtitle_mode) + language = _normalize_subtitle_language(subtitle_language) opts["skip_download"] = True - opts["writesubtitles"] = True - opts["writeautomaticsub"] = True - opts["subtitleslangs"] = ["en"] - opts["subtitlesformat"] = "vtt" + opts["subtitlesformat"] = subtitle_format or "ass" + if mode == "manual_only": + opts["writesubtitles"] = True + opts["writeautomaticsub"] = False + opts["subtitleslangs"] = [language] + elif mode == "auto_only": + opts["writesubtitles"] = False + opts["writeautomaticsub"] = True + # `-orig` captures common YouTube auto-sub tags. The plain language + # fallback keeps behavior useful across other extractors. + opts["subtitleslangs"] = [f"{language}-orig", language] + elif mode == "prefer_auto": + opts["writesubtitles"] = True + opts["writeautomaticsub"] = True + opts["subtitleslangs"] = [f"{language}-orig", language] + else: + opts["writesubtitles"] = True + opts["writeautomaticsub"] = True + opts["subtitleslangs"] = [language, f"{language}-orig"] opts["postprocessors"] = postprocessors + ( opts["postprocessors"] if "postprocessors" in opts else [] diff --git a/app/main.py b/app/main.py index fe893d5..16865c6 100644 --- a/app/main.py +++ b/app/main.py @@ -247,6 +247,9 @@ async def add(request): auto_start = post.get('auto_start') split_by_chapters = post.get('split_by_chapters') chapter_template = post.get('chapter_template') + subtitle_format = post.get('subtitle_format') + subtitle_language = post.get('subtitle_language') + subtitle_mode = post.get('subtitle_mode') if custom_name_prefix is None: custom_name_prefix = '' @@ -260,12 +263,34 @@ async def add(request): split_by_chapters = False if chapter_template is None: chapter_template = config.OUTPUT_TEMPLATE_CHAPTER + if subtitle_format is None: + subtitle_format = 'ass' + if subtitle_language is None: + subtitle_language = 'en' + if subtitle_mode is None: + subtitle_mode = 'prefer_manual' if chapter_template and ('..' in chapter_template or chapter_template.startswith('/') or chapter_template.startswith('\\')): raise web.HTTPBadRequest(reason='chapter_template must not contain ".." or start with a path separator') + valid_subtitle_modes = {'auto_only', 'manual_only', 'prefer_manual', 'prefer_auto'} + if subtitle_mode not in valid_subtitle_modes: + raise web.HTTPBadRequest(reason=f'subtitle_mode must be one of {sorted(valid_subtitle_modes)}') playlist_item_limit = int(playlist_item_limit) - status = await dqueue.add(url, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template) + status = await dqueue.add( + url, + quality, + format, + folder, + custom_name_prefix, + playlist_item_limit, + auto_start, + split_by_chapters, + chapter_template, + subtitle_format, + subtitle_language, + subtitle_mode, + ) return web.Response(text=serializer.encode(status)) @routes.post(config.URL_PREFIX + 'delete') diff --git a/app/ytdl.py b/app/ytdl.py index 4670f01..72ee0bc 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -86,7 +86,24 @@ class DownloadQueueNotifier: raise NotImplementedError class DownloadInfo: - def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit, split_by_chapters, chapter_template): + def __init__( + self, + id, + title, + url, + quality, + format, + folder, + custom_name_prefix, + error, + entry, + playlist_item_limit, + split_by_chapters, + chapter_template, + subtitle_format="ass", + subtitle_language="en", + subtitle_mode="prefer_manual", + ): self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}' self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}' self.url = url @@ -104,6 +121,9 @@ class DownloadInfo: self.playlist_item_limit = playlist_item_limit self.split_by_chapters = split_by_chapters self.chapter_template = chapter_template + self.subtitle_format = subtitle_format + self.subtitle_language = subtitle_language + self.subtitle_mode = subtitle_mode class Download: manager = None @@ -113,11 +133,18 @@ class Download: self.temp_dir = temp_dir self.output_template = output_template self.output_template_chapter = output_template_chapter + self.info = info self.format = get_format(format, quality) - self.ytdl_opts = get_opts(format, quality, ytdl_opts) + self.ytdl_opts = get_opts( + format, + quality, + ytdl_opts, + subtitle_format=getattr(info, 'subtitle_format', 'ass'), + subtitle_language=getattr(info, 'subtitle_language', 'en'), + subtitle_mode=getattr(info, 'subtitle_mode', 'prefer_manual'), + ) if "impersonate" in self.ytdl_opts: self.ytdl_opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.ytdl_opts["impersonate"]) - self.info = info self.canceled = False self.tmpfilename = None self.status_queue = None @@ -553,7 +580,22 @@ class DownloadQueue: self.pending.put(download) await self.notifier.added(dl) - async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already): + async def __add_entry( + self, + entry, + quality, + format, + folder, + custom_name_prefix, + playlist_item_limit, + auto_start, + split_by_chapters, + chapter_template, + subtitle_format, + subtitle_language, + subtitle_mode, + already, + ): if not entry: return {'status': 'error', 'msg': "Invalid/empty data was given."} @@ -569,7 +611,21 @@ class DownloadQueue: if etype.startswith('url'): log.debug('Processing as a url') - return await self.add(entry['url'], quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already) + return await self.add( + entry['url'], + quality, + format, + folder, + custom_name_prefix, + playlist_item_limit, + auto_start, + split_by_chapters, + chapter_template, + subtitle_format, + subtitle_language, + subtitle_mode, + already, + ) elif etype == 'playlist' or etype == 'channel': log.debug(f'Processing as a {etype}') entries = entry['entries'] @@ -589,7 +645,23 @@ class DownloadQueue: for property in ("id", "title", "uploader", "uploader_id"): if property in entry: etr[f"{etype}_{property}"] = entry[property] - results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already)) + results.append( + await self.__add_entry( + etr, + quality, + format, + folder, + custom_name_prefix, + playlist_item_limit, + auto_start, + split_by_chapters, + chapter_template, + subtitle_format, + subtitle_language, + subtitle_mode, + already, + ) + ) if any(res['status'] == 'error' for res in results): return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)} return {'status': 'ok'} @@ -597,13 +669,48 @@ class DownloadQueue: log.debug('Processing as a video') key = entry.get('webpage_url') or entry['url'] if not self.queue.exists(key): - dl = DownloadInfo(entry['id'], entry.get('title') or entry['id'], key, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit, split_by_chapters, chapter_template) + dl = DownloadInfo( + entry['id'], + entry.get('title') or entry['id'], + key, + quality, + format, + folder, + custom_name_prefix, + error, + entry, + playlist_item_limit, + split_by_chapters, + chapter_template, + subtitle_format, + subtitle_language, + subtitle_mode, + ) await self.__add_download(dl, auto_start) return {'status': 'ok'} return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'} - async def add(self, url, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start=True, split_by_chapters=False, chapter_template=None, already=None): - log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} {playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=}') + async def add( + self, + url, + quality, + format, + folder, + custom_name_prefix, + playlist_item_limit, + auto_start=True, + split_by_chapters=False, + chapter_template=None, + subtitle_format="ass", + subtitle_language="en", + subtitle_mode="prefer_manual", + already=None, + ): + log.info( + f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} ' + f'{playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=} ' + f'{subtitle_format=} {subtitle_language=} {subtitle_mode=}' + ) already = set() if already is None else already if url in already: log.info('recursion detected, skipping') @@ -614,7 +721,21 @@ class DownloadQueue: entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url) except yt_dlp.utils.YoutubeDLError as exc: return {'status': 'error', 'msg': str(exc)} - return await self.__add_entry(entry, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already) + return await self.__add_entry( + entry, + quality, + format, + folder, + custom_name_prefix, + playlist_item_limit, + auto_start, + split_by_chapters, + chapter_template, + subtitle_format, + subtitle_language, + subtitle_mode, + already, + ) async def start_pending(self, ids): for id in ids: diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 3c55b13..b7da999 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -218,6 +218,51 @@ ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)"> +
+
+ Subtitles + +
+
+
+
+ Language + +
+
+
+
+ Subtitle Source + +
+
diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 2265da8..7fc4a23 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -49,6 +49,9 @@ export class App implements AfterViewInit, OnInit { playlistItemLimit!: number; splitByChapters: boolean; chapterTemplate: string; + subtitleFormat: string; + subtitleLanguage: string; + subtitleMode: string; addInProgress = false; themes: Theme[] = Themes; activeTheme: Theme | undefined; @@ -97,6 +100,31 @@ export class App implements AfterViewInit, OnInit { faGithub = faGithub; faClock = faClock; faTachometerAlt = faTachometerAlt; + subtitleFormats = [ + { id: 'ass', text: 'ASS' }, + { id: 'vtt', text: 'VTT' }, + { id: 'srt', text: 'SRT' }, + { id: 'ttml', text: 'TTML' }, + ]; + subtitleLanguages = [ + { id: 'en', text: 'English' }, + { id: 'es', text: 'Spanish' }, + { id: 'fr', text: 'French' }, + { id: 'de', text: 'German' }, + { id: 'it', text: 'Italian' }, + { id: 'pt', text: 'Portuguese' }, + { id: 'ru', text: 'Russian' }, + { id: 'uk', text: 'Ukrainian' }, + { id: 'ja', text: 'Japanese' }, + { id: 'ko', text: 'Korean' }, + { id: 'zh-Hans', text: 'Chinese (Simplified)' }, + ]; + subtitleModes = [ + { id: 'prefer_manual', text: 'Prefer Manual' }, + { id: 'prefer_auto', text: 'Prefer Auto' }, + { id: 'manual_only', text: 'Manual Only' }, + { id: 'auto_only', text: 'Auto Only' }, + ]; constructor() { this.format = this.cookieService.get('metube_format') || 'any'; @@ -107,6 +135,9 @@ export class App implements AfterViewInit, OnInit { this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true'; // Will be set from backend configuration, use empty string as placeholder this.chapterTemplate = this.cookieService.get('metube_chapter_template') || ''; + this.subtitleFormat = this.cookieService.get('metube_subtitle_format') || 'ass'; + this.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en'; + this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual'; this.activeTheme = this.getPreferredTheme(this.cookieService); @@ -279,6 +310,18 @@ export class App implements AfterViewInit, OnInit { this.cookieService.set('metube_chapter_template', this.chapterTemplate, { expires: 3650 }); } + subtitleFormatChanged() { + this.cookieService.set('metube_subtitle_format', this.subtitleFormat, { expires: 3650 }); + } + + subtitleLanguageChanged() { + this.cookieService.set('metube_subtitle_language', this.subtitleLanguage, { expires: 3650 }); + } + + subtitleModeChanged() { + this.cookieService.set('metube_subtitle_mode', this.subtitleMode, { expires: 3650 }); + } + queueSelectionChanged(checked: number) { this.queueDelSelected().nativeElement.disabled = checked == 0; this.queueDownloadSelected().nativeElement.disabled = checked == 0; @@ -299,7 +342,20 @@ export class App implements AfterViewInit, OnInit { } } - addDownload(url?: string, quality?: string, format?: string, folder?: string, customNamePrefix?: string, playlistItemLimit?: number, autoStart?: boolean, splitByChapters?: boolean, chapterTemplate?: string) { + addDownload( + url?: string, + quality?: string, + format?: string, + folder?: string, + customNamePrefix?: string, + playlistItemLimit?: number, + autoStart?: boolean, + splitByChapters?: boolean, + chapterTemplate?: string, + subtitleFormat?: string, + subtitleLanguage?: string, + subtitleMode?: string, + ) { url = url ?? this.addUrl quality = quality ?? this.quality format = format ?? this.format @@ -309,6 +365,9 @@ export class App implements AfterViewInit, OnInit { autoStart = autoStart ?? this.autoStart splitByChapters = splitByChapters ?? this.splitByChapters chapterTemplate = chapterTemplate ?? this.chapterTemplate + subtitleFormat = subtitleFormat ?? this.subtitleFormat + subtitleLanguage = subtitleLanguage ?? this.subtitleLanguage + subtitleMode = subtitleMode ?? this.subtitleMode // Validate chapter template if chapter splitting is enabled if (splitByChapters && !chapterTemplate.includes('%(section_number)')) { @@ -316,9 +375,9 @@ export class App implements AfterViewInit, OnInit { return; } - console.debug('Downloading: url=' + url + ' quality=' + quality + ' format=' + format + ' folder=' + folder + ' customNamePrefix=' + customNamePrefix + ' playlistItemLimit=' + playlistItemLimit + ' autoStart=' + autoStart + ' splitByChapters=' + splitByChapters + ' chapterTemplate=' + chapterTemplate); + console.debug('Downloading: url=' + url + ' quality=' + quality + ' format=' + format + ' folder=' + folder + ' customNamePrefix=' + customNamePrefix + ' playlistItemLimit=' + playlistItemLimit + ' autoStart=' + autoStart + ' splitByChapters=' + splitByChapters + ' chapterTemplate=' + chapterTemplate + ' subtitleFormat=' + subtitleFormat + ' subtitleLanguage=' + subtitleLanguage + ' subtitleMode=' + subtitleMode); this.addInProgress = true; - this.downloads.add(url, quality, format, folder, customNamePrefix, playlistItemLimit, autoStart, splitByChapters, chapterTemplate).subscribe((status: Status) => { + this.downloads.add(url, quality, format, folder, customNamePrefix, playlistItemLimit, autoStart, splitByChapters, chapterTemplate, subtitleFormat, subtitleLanguage, subtitleMode).subscribe((status: Status) => { if (status.status === 'error') { alert(`Error adding URL: ${status.msg}`); } else { @@ -333,7 +392,20 @@ export class App implements AfterViewInit, OnInit { } retryDownload(key: string, download: Download) { - this.addDownload(download.url, download.quality, download.format, download.folder, download.custom_name_prefix, download.playlist_item_limit, true, download.split_by_chapters, download.chapter_template); + this.addDownload( + download.url, + download.quality, + download.format, + download.folder, + download.custom_name_prefix, + download.playlist_item_limit, + true, + download.split_by_chapters, + download.chapter_template, + download.subtitle_format, + download.subtitle_language, + download.subtitle_mode, + ); this.downloads.delById('done', [key]).subscribe(); } @@ -479,7 +551,8 @@ export class App implements AfterViewInit, OnInit { this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`; // Now pass the selected quality, format, folder, etc. to the add() method this.downloads.add(url, this.quality, this.format, this.folder, this.customNamePrefix, - this.playlistItemLimit, this.autoStart, this.splitByChapters, this.chapterTemplate) + this.playlistItemLimit, this.autoStart, this.splitByChapters, this.chapterTemplate, + this.subtitleFormat, this.subtitleLanguage, this.subtitleMode) .subscribe({ next: (status: Status) => { if (status.status === 'error') { diff --git a/ui/src/app/interfaces/download.ts b/ui/src/app/interfaces/download.ts index b6582ae..6b41625 100644 --- a/ui/src/app/interfaces/download.ts +++ b/ui/src/app/interfaces/download.ts @@ -10,6 +10,9 @@ export interface Download { playlist_item_limit: number; split_by_chapters?: boolean; chapter_template?: string; + subtitle_format?: string; + subtitle_language?: string; + subtitle_mode?: string; status: string; msg: string; percent: number; diff --git a/ui/src/app/interfaces/formats.ts b/ui/src/app/interfaces/formats.ts index 53ef28f..0ea73af 100644 --- a/ui/src/app/interfaces/formats.ts +++ b/ui/src/app/interfaces/formats.ts @@ -75,7 +75,7 @@ export const Formats: Format[] = [ }, { id: 'captions', - text: 'Captions (EN, VTT)', + text: 'Captions', qualities: [{ id: 'best', text: 'Best' }], }, ]; diff --git a/ui/src/app/services/downloads.service.ts b/ui/src/app/services/downloads.service.ts index 7285f39..ac21bc5 100644 --- a/ui/src/app/services/downloads.service.ts +++ b/ui/src/app/services/downloads.service.ts @@ -107,8 +107,34 @@ export class DownloadsService { return of({status: 'error', msg: msg}) } - public add(url: string, quality: string, format: string, folder: string, customNamePrefix: string, playlistItemLimit: number, autoStart: boolean, splitByChapters: boolean, chapterTemplate: string) { - return this.http.post('add', { url: url, quality: quality, format: format, folder: folder, custom_name_prefix: customNamePrefix, playlist_item_limit: playlistItemLimit, auto_start: autoStart, split_by_chapters: splitByChapters, chapter_template: chapterTemplate }).pipe( + public add( + url: string, + quality: string, + format: string, + folder: string, + customNamePrefix: string, + playlistItemLimit: number, + autoStart: boolean, + splitByChapters: boolean, + chapterTemplate: string, + subtitleFormat: string, + subtitleLanguage: string, + subtitleMode: string, + ) { + return this.http.post('add', { + url: url, + quality: quality, + format: format, + folder: folder, + custom_name_prefix: customNamePrefix, + playlist_item_limit: playlistItemLimit, + auto_start: autoStart, + split_by_chapters: splitByChapters, + chapter_template: chapterTemplate, + subtitle_format: subtitleFormat, + subtitle_language: subtitleLanguage, + subtitle_mode: subtitleMode + }).pipe( catchError(this.handleHTTPError) ); } @@ -154,9 +180,25 @@ export class DownloadsService { const defaultAutoStart = true; const defaultSplitByChapters = false; const defaultChapterTemplate = this.configuration['OUTPUT_TEMPLATE_CHAPTER']; + const defaultSubtitleFormat = 'ass'; + const defaultSubtitleLanguage = 'en'; + const defaultSubtitleMode = 'prefer_manual'; return new Promise((resolve, reject) => { - this.add(url, defaultQuality, defaultFormat, defaultFolder, defaultCustomNamePrefix, defaultPlaylistItemLimit, defaultAutoStart, defaultSplitByChapters, defaultChapterTemplate) + this.add( + url, + defaultQuality, + defaultFormat, + defaultFolder, + defaultCustomNamePrefix, + defaultPlaylistItemLimit, + defaultAutoStart, + defaultSplitByChapters, + defaultChapterTemplate, + defaultSubtitleFormat, + defaultSubtitleLanguage, + defaultSubtitleMode, + ) .subscribe({ next: (response) => resolve(response), error: (error) => reject(error) From dd4e05325a50eb74c06f8670d79b0ebfca5a56a4 Mon Sep 17 00:00:00 2001 From: vitaliibudnyi <27superuser@gmail.com> Date: Sat, 21 Feb 2026 20:12:30 +0200 Subject: [PATCH 3/6] change delaut captions type to .srt --- README.md | 2 +- app/dl_formats.py | 4 ++-- app/main.py | 2 +- app/ytdl.py | 6 +++--- ui/src/app/app.ts | 11 +++++++---- ui/src/app/services/downloads.service.ts | 2 +- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index aafaea7..8c79ba6 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Certain values can be set via environment variables, using the `-e` parameter on * __OUTPUT_TEMPLATE_CHANNEL__: The template for the filenames of the downloaded videos when downloaded as a channel. Defaults to `%(channel)s/%(title)s.%(ext)s`. When empty, then `OUTPUT_TEMPLATE` is used. * __YTDL_OPTIONS__: Additional options to pass to yt-dlp in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L222). They roughly correspond to command-line options, though some do not have exact equivalents here. For example, `--recode-video` has to be specified via `postprocessors`. Also note that dashes are replaced with underscores. You may find [this script](https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py) helpful for converting from command-line options to `YTDL_OPTIONS`. * __YTDL_OPTIONS_FILE__: A path to a JSON file that will be loaded and used for populating `YTDL_OPTIONS` above. Please note that if both `YTDL_OPTIONS_FILE` and `YTDL_OPTIONS` are specified, the options in `YTDL_OPTIONS` take precedence. The file will be monitored for changes and reloaded automatically when changes are detected. -* UI format __Captions__: Downloads subtitles/captions only (no media). Subtitle format, language, and source preference are configurable from Advanced Options (defaults: `ass`, `en`, `prefer_manual`). +* UI format __Captions__: Downloads subtitles/captions only (no media). Subtitle format, language, and source preference are configurable from Advanced Options (defaults: `srt`, `en`, `prefer_manual`). ### 🌐 Web Server & URLs diff --git a/app/dl_formats.py b/app/dl_formats.py index 43384f0..867ceee 100644 --- a/app/dl_formats.py +++ b/app/dl_formats.py @@ -70,7 +70,7 @@ def get_opts( format: str, quality: str, ytdl_opts: dict, - subtitle_format: str = "ass", + subtitle_format: str = "srt", subtitle_language: str = "en", subtitle_mode: str = "prefer_manual", ) -> dict: @@ -124,7 +124,7 @@ def get_opts( mode = _normalize_caption_mode(subtitle_mode) language = _normalize_subtitle_language(subtitle_language) opts["skip_download"] = True - opts["subtitlesformat"] = subtitle_format or "ass" + opts["subtitlesformat"] = subtitle_format or "srt" if mode == "manual_only": opts["writesubtitles"] = True opts["writeautomaticsub"] = False diff --git a/app/main.py b/app/main.py index 16865c6..c28c32c 100644 --- a/app/main.py +++ b/app/main.py @@ -264,7 +264,7 @@ async def add(request): if chapter_template is None: chapter_template = config.OUTPUT_TEMPLATE_CHAPTER if subtitle_format is None: - subtitle_format = 'ass' + subtitle_format = 'srt' if subtitle_language is None: subtitle_language = 'en' if subtitle_mode is None: diff --git a/app/ytdl.py b/app/ytdl.py index 72ee0bc..f7924de 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -100,7 +100,7 @@ class DownloadInfo: playlist_item_limit, split_by_chapters, chapter_template, - subtitle_format="ass", + subtitle_format="srt", subtitle_language="en", subtitle_mode="prefer_manual", ): @@ -139,7 +139,7 @@ class Download: format, quality, ytdl_opts, - subtitle_format=getattr(info, 'subtitle_format', 'ass'), + subtitle_format=getattr(info, 'subtitle_format', 'srt'), subtitle_language=getattr(info, 'subtitle_language', 'en'), subtitle_mode=getattr(info, 'subtitle_mode', 'prefer_manual'), ) @@ -701,7 +701,7 @@ class DownloadQueue: auto_start=True, split_by_chapters=False, chapter_template=None, - subtitle_format="ass", + subtitle_format="srt", subtitle_language="en", subtitle_mode="prefer_manual", already=None, diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 7fc4a23..d7651d9 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -101,10 +101,9 @@ export class App implements AfterViewInit, OnInit { faClock = faClock; faTachometerAlt = faTachometerAlt; subtitleFormats = [ - { id: 'ass', text: 'ASS' }, - { id: 'vtt', text: 'VTT' }, { id: 'srt', text: 'SRT' }, - { id: 'ttml', text: 'TTML' }, + { id: 'vtt', text: 'VTT' }, + { id: 'ttml', text: 'TTML' } ]; subtitleLanguages = [ { id: 'en', text: 'English' }, @@ -135,9 +134,13 @@ export class App implements AfterViewInit, OnInit { this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true'; // Will be set from backend configuration, use empty string as placeholder this.chapterTemplate = this.cookieService.get('metube_chapter_template') || ''; - this.subtitleFormat = this.cookieService.get('metube_subtitle_format') || 'ass'; + this.subtitleFormat = this.cookieService.get('metube_subtitle_format') || 'srt'; this.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en'; this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual'; + const allowedSubtitleFormats = new Set(this.subtitleFormats.map(fmt => fmt.id)); + if (!allowedSubtitleFormats.has(this.subtitleFormat)) { + this.subtitleFormat = 'srt'; + } this.activeTheme = this.getPreferredTheme(this.cookieService); diff --git a/ui/src/app/services/downloads.service.ts b/ui/src/app/services/downloads.service.ts index ac21bc5..52690c8 100644 --- a/ui/src/app/services/downloads.service.ts +++ b/ui/src/app/services/downloads.service.ts @@ -180,7 +180,7 @@ export class DownloadsService { const defaultAutoStart = true; const defaultSplitByChapters = false; const defaultChapterTemplate = this.configuration['OUTPUT_TEMPLATE_CHAPTER']; - const defaultSubtitleFormat = 'ass'; + const defaultSubtitleFormat = 'srt'; const defaultSubtitleLanguage = 'en'; const defaultSubtitleMode = 'prefer_manual'; From 8dff6448b28f14b8a3c88cf9748cd4b1cc80aa11 Mon Sep 17 00:00:00 2001 From: vitaliibudnyi <27superuser@gmail.com> Date: Sat, 21 Feb 2026 21:10:40 +0200 Subject: [PATCH 4/6] add "text only" as another advanced option for captions format --- README.md | 2 +- app/dl_formats.py | 6 ++++- app/ytdl.py | 60 ++++++++++++++++++++++++++++++++++++++++++--- ui/src/app/app.html | 3 +++ ui/src/app/app.ts | 1 + 5 files changed, 67 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8c79ba6..91b7e5e 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Certain values can be set via environment variables, using the `-e` parameter on * __OUTPUT_TEMPLATE_CHANNEL__: The template for the filenames of the downloaded videos when downloaded as a channel. Defaults to `%(channel)s/%(title)s.%(ext)s`. When empty, then `OUTPUT_TEMPLATE` is used. * __YTDL_OPTIONS__: Additional options to pass to yt-dlp in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L222). They roughly correspond to command-line options, though some do not have exact equivalents here. For example, `--recode-video` has to be specified via `postprocessors`. Also note that dashes are replaced with underscores. You may find [this script](https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py) helpful for converting from command-line options to `YTDL_OPTIONS`. * __YTDL_OPTIONS_FILE__: A path to a JSON file that will be loaded and used for populating `YTDL_OPTIONS` above. Please note that if both `YTDL_OPTIONS_FILE` and `YTDL_OPTIONS` are specified, the options in `YTDL_OPTIONS` take precedence. The file will be monitored for changes and reloaded automatically when changes are detected. -* UI format __Captions__: Downloads subtitles/captions only (no media). Subtitle format, language, and source preference are configurable from Advanced Options (defaults: `srt`, `en`, `prefer_manual`). +* UI format __Captions__: Downloads subtitles/captions only (no media). Subtitle format, language, and source preference are configurable from Advanced Options (defaults: `srt`, `en`, `prefer_manual`). `txt` is generated from `srt` by stripping timestamps and cue numbers. ### 🌐 Web Server & URLs diff --git a/app/dl_formats.py b/app/dl_formats.py index 867ceee..e94ec9a 100644 --- a/app/dl_formats.py +++ b/app/dl_formats.py @@ -124,7 +124,11 @@ def get_opts( mode = _normalize_caption_mode(subtitle_mode) language = _normalize_subtitle_language(subtitle_language) opts["skip_download"] = True - opts["subtitlesformat"] = subtitle_format or "srt" + requested_subtitle_format = (subtitle_format or "srt").lower() + # txt is a derived, non-timed format produced from SRT after download. + if requested_subtitle_format == "txt": + requested_subtitle_format = "srt" + opts["subtitlesformat"] = requested_subtitle_format if mode == "manual_only": opts["writesubtitles"] = True opts["writeautomaticsub"] = False diff --git a/app/ytdl.py b/app/ytdl.py index f7924de..b1d187d 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -69,6 +69,45 @@ def _convert_generators_to_lists(obj): else: return obj + +def _convert_srt_to_txt_file(subtitle_path: str): + """Convert an SRT subtitle file into plain text by stripping cue numbers/timestamps.""" + txt_path = os.path.splitext(subtitle_path)[0] + ".txt" + try: + with open(subtitle_path, "r", encoding="utf-8", errors="replace") as infile: + content = infile.read() + + # Normalize newlines so cue splitting is consistent across platforms. + content = content.replace("\r\n", "\n").replace("\r", "\n") + cues = [] + for block in re.split(r"\n{2,}", content): + lines = [line.strip() for line in block.split("\n") if line.strip()] + if not lines: + continue + if re.fullmatch(r"\d+", lines[0]): + lines = lines[1:] + if lines and "-->" in lines[0]: + lines = lines[1:] + + text_lines = [] + for line in lines: + if "-->" in line: + continue + clean_line = re.sub(r"<[^>]+>", "", line).strip() + if clean_line: + text_lines.append(clean_line) + if text_lines: + cues.append(" ".join(text_lines)) + + with open(txt_path, "w", encoding="utf-8") as outfile: + if cues: + outfile.write("\n".join(cues)) + outfile.write("\n") + return txt_path + except OSError as exc: + log.warning(f"Failed to convert subtitle file {subtitle_path} to txt: {exc}") + return None + class DownloadQueueNotifier: async def added(self, dl): raise NotImplementedError @@ -298,7 +337,7 @@ class Download: rel_name = os.path.relpath(fileName, self.download_dir) # For captions mode, ignore media-like placeholders and let subtitle_file # statuses define the final file shown in the UI. - if not (self.info.format == 'captions' and not rel_name.endswith(('.vtt', '.srt', '.ass', '.ttml'))): + if not (self.info.format == 'captions' and not rel_name.endswith(('.vtt', '.srt', '.ttml', '.txt'))): self.info.filename = rel_name self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None if self.info.format == 'thumbnail': @@ -321,10 +360,25 @@ class Download: if 'subtitle_file' in status: subtitle_file = status.get('subtitle_file') + if not subtitle_file: + continue + subtitle_output_file = subtitle_file + + # txt mode is derived from SRT by stripping cue metadata. + if self.info.format == 'captions' and str(getattr(self.info, 'subtitle_format', '')).lower() == 'txt': + converted_txt = _convert_srt_to_txt_file(subtitle_file) + if converted_txt: + subtitle_output_file = converted_txt + if converted_txt != subtitle_file: + try: + os.remove(subtitle_file) + except OSError as exc: + log.debug(f"Could not remove temporary SRT file {subtitle_file}: {exc}") + if not hasattr(self.info, 'subtitle_files'): self.info.subtitle_files = [] - rel_path = os.path.relpath(subtitle_file, self.download_dir) - file_size = os.path.getsize(subtitle_file) if os.path.exists(subtitle_file) else None + rel_path = os.path.relpath(subtitle_output_file, self.download_dir) + file_size = os.path.getsize(subtitle_output_file) if os.path.exists(subtitle_output_file) else None existing = next((sf for sf in self.info.subtitle_files if sf['filename'] == rel_path), None) if not existing: self.info.subtitle_files.append({'filename': rel_path, 'size': file_size}) diff --git a/ui/src/app/app.html b/ui/src/app/app.html index b7da999..9149a7f 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -232,6 +232,9 @@ }
+ @if (subtitleFormat === 'txt') { +
TXT is generated from SRT by stripping timestamps and cue numbers.
+ }
diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index d7651d9..525d668 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -102,6 +102,7 @@ export class App implements AfterViewInit, OnInit { faTachometerAlt = faTachometerAlt; subtitleFormats = [ { id: 'srt', text: 'SRT' }, + { id: 'txt', text: 'TXT (Text only)' }, { id: 'vtt', text: 'VTT' }, { id: 'ttml', text: 'TTML' } ]; From 77da359234020563e7bf88bfb39f96dc70cc2801 Mon Sep 17 00:00:00 2001 From: vitaliibudnyi <27superuser@gmail.com> Date: Sat, 21 Feb 2026 21:21:15 +0200 Subject: [PATCH 5/6] fix: for 'text only' subs now download .txt instead of an intermediate .srt --- app/ytdl.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/app/ytdl.py b/app/ytdl.py index b1d187d..dd458a9 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -337,11 +337,15 @@ class Download: rel_name = os.path.relpath(fileName, self.download_dir) # For captions mode, ignore media-like placeholders and let subtitle_file # statuses define the final file shown in the UI. - if not (self.info.format == 'captions' and not rel_name.endswith(('.vtt', '.srt', '.ttml', '.txt'))): - self.info.filename = rel_name - self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None - if self.info.format == 'thumbnail': - self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename) + if self.info.format == 'captions': + requested_subtitle_format = str(getattr(self.info, 'subtitle_format', '')).lower() + allowed_caption_exts = ('.txt',) if requested_subtitle_format == 'txt' else ('.vtt', '.srt', '.sbv', '.scc', '.ttml', '.dfxp') + if not rel_name.lower().endswith(allowed_caption_exts): + continue + self.info.filename = rel_name + self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None + if self.info.format == 'thumbnail': + self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename) # Handle chapter files log.debug(f"Update status for {self.info.title}: {status}") @@ -383,7 +387,10 @@ class Download: if not existing: self.info.subtitle_files.append({'filename': rel_path, 'size': file_size}) # Prefer first subtitle file as the primary result link in captions mode. - if self.info.format == 'captions' and (not getattr(self.info, 'filename', None)): + if self.info.format == 'captions' and ( + not getattr(self.info, 'filename', None) or + str(getattr(self.info, 'subtitle_format', '')).lower() == 'txt' + ): self.info.filename = rel_path self.info.size = file_size continue From 053e41cf5233f99b1462d5aede74e08e4840e8da Mon Sep 17 00:00:00 2001 From: Alex Shnitman Date: Fri, 27 Feb 2026 12:58:50 +0200 Subject: [PATCH 6/6] code review fixes --- README.md | 1 - app/main.py | 15 +++++-- app/ytdl.py | 3 +- ui/src/app/app.html | 96 ++++++++++++++++++++++++--------------------- ui/src/app/app.ts | 36 +++++++++++++++++ 5 files changed, 100 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 91b7e5e..3812b8d 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,6 @@ Certain values can be set via environment variables, using the `-e` parameter on * __OUTPUT_TEMPLATE_CHANNEL__: The template for the filenames of the downloaded videos when downloaded as a channel. Defaults to `%(channel)s/%(title)s.%(ext)s`. When empty, then `OUTPUT_TEMPLATE` is used. * __YTDL_OPTIONS__: Additional options to pass to yt-dlp in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L222). They roughly correspond to command-line options, though some do not have exact equivalents here. For example, `--recode-video` has to be specified via `postprocessors`. Also note that dashes are replaced with underscores. You may find [this script](https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py) helpful for converting from command-line options to `YTDL_OPTIONS`. * __YTDL_OPTIONS_FILE__: A path to a JSON file that will be loaded and used for populating `YTDL_OPTIONS` above. Please note that if both `YTDL_OPTIONS_FILE` and `YTDL_OPTIONS` are specified, the options in `YTDL_OPTIONS` take precedence. The file will be monitored for changes and reloaded automatically when changes are detected. -* UI format __Captions__: Downloads subtitles/captions only (no media). Subtitle format, language, and source preference are configurable from Advanced Options (defaults: `srt`, `en`, `prefer_manual`). `txt` is generated from `srt` by stripping timestamps and cue numbers. ### 🌐 Web Server & URLs diff --git a/app/main.py b/app/main.py index c28c32c..3240744 100644 --- a/app/main.py +++ b/app/main.py @@ -156,6 +156,9 @@ app = web.Application() sio = socketio.AsyncServer(cors_allowed_origins='*') sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io') routes = web.RouteTableDef() +VALID_SUBTITLE_FORMATS = {'srt', 'txt', 'vtt', 'ttml', 'sbv', 'scc', 'dfxp'} +VALID_SUBTITLE_MODES = {'auto_only', 'manual_only', 'prefer_manual', 'prefer_auto'} +SUBTITLE_LANGUAGE_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9-]{0,34}$') class Notifier(DownloadQueueNotifier): async def added(self, dl): @@ -269,11 +272,17 @@ async def add(request): subtitle_language = 'en' if subtitle_mode is None: subtitle_mode = 'prefer_manual' + subtitle_format = str(subtitle_format).strip().lower() + subtitle_language = str(subtitle_language).strip() + subtitle_mode = str(subtitle_mode).strip() if chapter_template and ('..' in chapter_template or chapter_template.startswith('/') or chapter_template.startswith('\\')): raise web.HTTPBadRequest(reason='chapter_template must not contain ".." or start with a path separator') - valid_subtitle_modes = {'auto_only', 'manual_only', 'prefer_manual', 'prefer_auto'} - if subtitle_mode not in valid_subtitle_modes: - raise web.HTTPBadRequest(reason=f'subtitle_mode must be one of {sorted(valid_subtitle_modes)}') + if subtitle_format not in VALID_SUBTITLE_FORMATS: + raise web.HTTPBadRequest(reason=f'subtitle_format must be one of {sorted(VALID_SUBTITLE_FORMATS)}') + if not SUBTITLE_LANGUAGE_RE.fullmatch(subtitle_language): + raise web.HTTPBadRequest(reason='subtitle_language must match pattern [A-Za-z0-9-] and be at most 35 characters') + if subtitle_mode not in VALID_SUBTITLE_MODES: + raise web.HTTPBadRequest(reason=f'subtitle_mode must be one of {sorted(VALID_SUBTITLE_MODES)}') playlist_item_limit = int(playlist_item_limit) diff --git a/app/ytdl.py b/app/ytdl.py index dd458a9..18e47ab 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -163,6 +163,7 @@ class DownloadInfo: self.subtitle_format = subtitle_format self.subtitle_language = subtitle_language self.subtitle_mode = subtitle_mode + self.subtitle_files = [] class Download: manager = None @@ -379,8 +380,6 @@ class Download: except OSError as exc: log.debug(f"Could not remove temporary SRT file {subtitle_file}: {exc}") - if not hasattr(self.info, 'subtitle_files'): - self.info.subtitle_files = [] rel_path = os.path.relpath(subtitle_output_file, self.download_dir) file_size = os.path.getsize(subtitle_output_file) if os.path.exists(subtitle_output_file) else None existing = next((sf for sf in self.info.subtitle_files if sf['filename'] == rel_path), None) diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 9149a7f..8e7741e 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -218,54 +218,60 @@ ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
-
-
- Subtitles - + @if (format === 'captions') { +
+
+ Subtitles + +
+ @if (subtitleFormat === 'txt') { +
TXT is generated from SRT by stripping timestamps and cue numbers.
+ }
- @if (subtitleFormat === 'txt') { -
TXT is generated from SRT by stripping timestamps and cue numbers.
- } -
-
-
- Language - +
+
+ Language + + + @for (lang of subtitleLanguages; track lang.id) { + + } + +
-
-
-
- Subtitle Source - +
+
+ Subtitle Source + +
-
+ }
diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 525d668..e56fd2b 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -108,16 +108,48 @@ export class App implements AfterViewInit, OnInit { ]; subtitleLanguages = [ { id: 'en', text: 'English' }, + { id: 'ar', text: 'Arabic' }, + { id: 'bn', text: 'Bengali' }, + { id: 'bg', text: 'Bulgarian' }, + { id: 'ca', text: 'Catalan' }, + { id: 'cs', text: 'Czech' }, + { id: 'da', text: 'Danish' }, + { id: 'nl', text: 'Dutch' }, { id: 'es', text: 'Spanish' }, + { id: 'et', text: 'Estonian' }, + { id: 'fi', text: 'Finnish' }, { id: 'fr', text: 'French' }, { id: 'de', text: 'German' }, + { id: 'el', text: 'Greek' }, + { id: 'he', text: 'Hebrew' }, + { id: 'hi', text: 'Hindi' }, + { id: 'hu', text: 'Hungarian' }, + { id: 'id', text: 'Indonesian' }, { id: 'it', text: 'Italian' }, + { id: 'lt', text: 'Lithuanian' }, + { id: 'lv', text: 'Latvian' }, + { id: 'ms', text: 'Malay' }, + { id: 'no', text: 'Norwegian' }, + { id: 'pl', text: 'Polish' }, { id: 'pt', text: 'Portuguese' }, + { id: 'pt-BR', text: 'Portuguese (Brazil)' }, + { id: 'ro', text: 'Romanian' }, { id: 'ru', text: 'Russian' }, + { id: 'sk', text: 'Slovak' }, + { id: 'sl', text: 'Slovenian' }, + { id: 'sr', text: 'Serbian' }, + { id: 'sv', text: 'Swedish' }, + { id: 'ta', text: 'Tamil' }, + { id: 'te', text: 'Telugu' }, + { id: 'th', text: 'Thai' }, + { id: 'tr', text: 'Turkish' }, { id: 'uk', text: 'Ukrainian' }, + { id: 'ur', text: 'Urdu' }, + { id: 'vi', text: 'Vietnamese' }, { id: 'ja', text: 'Japanese' }, { id: 'ko', text: 'Korean' }, { id: 'zh-Hans', text: 'Chinese (Simplified)' }, + { id: 'zh-Hant', text: 'Chinese (Traditional)' }, ]; subtitleModes = [ { id: 'prefer_manual', text: 'Prefer Manual' }, @@ -139,9 +171,13 @@ export class App implements AfterViewInit, OnInit { this.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en'; this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual'; const allowedSubtitleFormats = new Set(this.subtitleFormats.map(fmt => fmt.id)); + const allowedSubtitleModes = new Set(this.subtitleModes.map(mode => mode.id)); if (!allowedSubtitleFormats.has(this.subtitleFormat)) { this.subtitleFormat = 'srt'; } + if (!allowedSubtitleModes.has(this.subtitleMode)) { + this.subtitleMode = 'prefer_manual'; + } this.activeTheme = this.getPreferredTheme(this.cookieService);