metube/app/dl_formats.py
TonyBlu 6d890021a6 feat(captions): P1/P2 improvements - region variant fallback, DownloadInfo init, subtitle_files UI
P1 fixes:
- _subtitle_lang_list now extracts base language from qualified tags
  (e.g. zh-Hans → zh base) so regional variants are still added as
  fallbacks. Original input is preserved as first priority.
- DownloadInfo.__init__ now explicitly initializes filename=None and
  chapter_files=[] to avoid AttributeError on dynamic attribute access.
- __setstate__ adds filename=None migration for old persistent data.
- Frontend Download interface gains subtitle_files field; completed
  downloads show each subtitle file with download link and CC icon.

P2 improvements:
- _LANGUAGE_REGION_VARIANTS expanded from 10 to 30 languages covering
  it/ja/ko/hi/th/vi/id/pl/uk/ru/cs/sv/da/no/fi/tr/el/he/hu/bn/ta.
- Languages with no regional variants use empty tuple (they still get
  -orig suffix when include_orig=True).
2026-05-25 21:48:27 +08:00

230 lines
7.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"),
"it": ("IT", "SM"),
"ja": (),
"ko": (),
"hi": (),
"th": (),
"vi": (),
"id": (),
"pl": (),
"uk": (),
"ru": ("RU", "BY", "KZ", "UA"),
"cs": (),
"sv": ("SE", "FI"),
"da": ("DK",),
"no": ("NO", "NB", "NN"),
"fi": (),
"tr": (),
"el": ("GR", "CY"),
"he": (),
"hu": (),
"bn": ("BD", "IN"),
"ta": ("IN", "SG", "LK", "MY"),
}
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.
Handles user input that already contains a region or script subtag (e.g.
``zh-Hans``, ``pt-BR``): extracts the base language (``zh``, ``pt``) so
regional variants for that base language are still included as fallbacks.
"""
langs = [language]
# Extract base language (e.g. "zh-Hans" → "zh", "en-GB" → "en")
base = language.split('-')[0]
# If user provided a qualified tag, also include the base form as fallback
if base != language:
langs.append(base)
variants = _LANGUAGE_REGION_VARIANTS.get(base)
if variants:
for region in variants:
variant = f"{base}-{region}"
if variant not in langs:
langs.append(variant)
if include_orig:
orig_tag = f"{base}-orig"
if orig_tag not in langs:
langs.append(orig_tag)
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