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)