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