metube/app/dl_formats.py
TonyBlu 36b397ae93 fix(captions): add language variant fallback + error on no files
Two fixes for captions downloads:

1. Language variant fallback: When user requests 'en' but the video only
   has 'en-GB' (or other regional variants), yt-dlp silently skips
   subtitle extraction because it uses exact language matching. Now the
   subtitleslangs list includes common regional variants (e.g. en-US,
   en-GB, en-AU, etc.) so yt-dlp can match the first available variant.

2. Error on no files: When yt-dlp completes successfully (ret=0) but
   produces no subtitle files (e.g. no matching language at all), the
   download was incorrectly shown as 'completed'. Now it's marked as
   'error' with a descriptive message like 'No subtitles found for
   language "en"'. Same treatment for thumbnail downloads.
2026-05-25 21:16:10 +08:00

195 lines
6.8 KiB
Python

import copy
AUDIO_FORMATS = ("m4a", "mp3", "opus", "wav", "flac")
CAPTION_MODES = ("auto_only", "manual_only", "prefer_manual", "prefer_auto")
CODEC_FILTER_MAP = {
'h264': "[vcodec~='^(h264|avc)']",
'h265': "[vcodec~='^(h265|hevc)']",
'av1': "[vcodec~='^av0?1']",
'vp9': "[vcodec~='^vp0?9']",
}
# Common language variant suffixes that yt-dlp may encounter on YouTube and
# other platforms. When a user requests "en" we also try "en-GB", "en-US",
# etc. so that a request isn't silently skipped when only a regional variant
# exists. yt-dlp processes the list in order and picks the first match.
_LANGUAGE_REGION_VARIANTS = {
"en": ("US", "GB", "AU", "CA", "IN", "NZ", "IE", "ZA"),
"zh": ("CN", "TW", "HK", "SG"),
"pt": ("BR", "PT"),
"es": ("ES", "MX", "AR", "CO", "CL", "PE", "VE"),
"fr": ("FR", "CA", "BE", "CH"),
"de": ("DE", "AT", "CH"),
"ar": ("SA", "AE", "EG", "MA", "DZ"),
"nl": ("NL", "BE"),
"ro": ("RO", "MD"),
"ms": ("MY", "BN", "SG"),
}
def _subtitle_lang_list(language: str, include_orig: bool = False) -> list[str]:
"""Build a prioritised list of subtitle language codes for yt-dlp.
Starts with the exact language, then appends common regional variants
(e.g. ``en`` → ``en-US, en-GB, en-AU, …``). ``include_orig`` adds the
``<lang>-orig`` tag that YouTube uses for original-language auto-captions.
"""
langs = [language]
variants = _LANGUAGE_REGION_VARIANTS.get(language)
if variants:
langs.extend(f"{language}-{region}" for region in variants)
if include_orig:
langs.append(f"{language}-orig")
return langs
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(download_type: str, codec: str, format: str, quality: str) -> str:
"""
Returns yt-dlp format selector.
Args:
download_type (str): selected content type (video, audio, captions, thumbnail)
codec (str): selected video codec (auto, h264, h265, av1, vp9)
format (str): selected output format/profile for type
quality (str): selected quality
Raises:
Exception: unknown type/format
Returns:
str: yt-dlp format selector
"""
download_type = (download_type or "video").strip().lower()
format = (format or "any").strip().lower()
codec = (codec or "auto").strip().lower()
quality = (quality or "best").strip().lower()
if format.startswith("custom:"):
return format[7:]
if download_type == "thumbnail":
return "bestaudio/best"
if download_type == "captions":
return "bestaudio/best"
if download_type == "audio":
if format not in AUDIO_FORMATS:
raise ValueError(f"Unknown audio format {format}")
return f"bestaudio[ext={format}]/bestaudio/best"
if download_type == "video":
if format not in ("any", "mp4", "ios"):
raise ValueError(f"Unknown video format {format}")
vfmt, afmt = ("[ext=mp4]", "[ext=m4a]") if format in ("mp4", "ios") else ("", "")
vres = f"[height<={quality}]" if quality not in ("best", "worst") else ""
vcombo = vres + vfmt
codec_filter = CODEC_FILTER_MAP.get(codec, "")
if format == "ios":
return f"bestvideo[vcodec~='^((he|a)vc|h26[45])']{vres}+bestaudio[acodec=aac]/bestvideo[vcodec~='^((he|a)vc|h26[45])']{vres}+bestaudio{afmt}/bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}"
if codec_filter:
return f"bestvideo{codec_filter}{vcombo}+bestaudio{afmt}/bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}"
return f"bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}"
raise ValueError(f"Unknown download_type {download_type}")
def get_opts(
download_type: str,
_codec: str,
format: str,
quality: str,
ytdl_opts: dict,
subtitle_language: str = "en",
subtitle_mode: str = "prefer_manual",
) -> dict:
"""
Returns extra yt-dlp options/postprocessors.
Args:
download_type (str): selected content type
codec (str): selected codec (unused currently, kept for API consistency)
format (str): selected format/profile
quality (str): selected quality
ytdl_opts (dict): current options selected
Returns:
dict: extended options
"""
download_type = (download_type or "video").strip().lower()
format = (format or "any").strip().lower()
opts = copy.deepcopy(ytdl_opts)
postprocessors = []
if download_type == "audio":
postprocessors.append(
{
"key": "FFmpegExtractAudio",
"preferredcodec": format,
"preferredquality": 0 if quality == "best" else quality,
}
)
if format != "wav" and "writethumbnail" not in opts:
opts["writethumbnail"] = True
postprocessors.append(
{
"key": "FFmpegThumbnailsConvertor",
"format": "jpg",
"when": "before_dl",
}
)
postprocessors.append({"key": "FFmpegMetadata"})
postprocessors.append({"key": "EmbedThumbnail"})
if download_type == "thumbnail":
opts["skip_download"] = True
opts["writethumbnail"] = True
postprocessors.append(
{"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"}
)
if download_type == "captions":
mode = _normalize_caption_mode(subtitle_mode)
language = _normalize_subtitle_language(subtitle_language)
opts["skip_download"] = True
requested_subtitle_format = (format or "srt").lower()
if requested_subtitle_format == "txt":
requested_subtitle_format = "srt"
opts["subtitlesformat"] = requested_subtitle_format
if mode == "manual_only":
opts["writesubtitles"] = True
opts["writeautomaticsub"] = False
opts["subtitleslangs"] = _subtitle_lang_list(language)
elif mode == "auto_only":
opts["writesubtitles"] = False
opts["writeautomaticsub"] = True
opts["subtitleslangs"] = _subtitle_lang_list(language, include_orig=True)
elif mode == "prefer_auto":
opts["writesubtitles"] = True
opts["writeautomaticsub"] = True
opts["subtitleslangs"] = _subtitle_lang_list(language, include_orig=True)
else:
# prefer_manual (default)
opts["writesubtitles"] = True
opts["writeautomaticsub"] = True
opts["subtitleslangs"] = _subtitle_lang_list(language, include_orig=True)
opts["postprocessors"] = postprocessors + (
opts["postprocessors"] if "postprocessors" in opts else []
)
return opts