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).
This commit is contained in:
TonyBlu 2026-05-25 21:48:27 +08:00
parent b31f4e2fea
commit 6d890021a6
6 changed files with 76 additions and 6 deletions

View file

@ -25,6 +25,27 @@ _LANGUAGE_REGION_VARIANTS = {
"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"),
}
@ -34,13 +55,27 @@ def _subtitle_lang_list(language: str, include_orig: bool = False) -> list[str]:
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]
variants = _LANGUAGE_REGION_VARIANTS.get(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:
langs.extend(f"{language}-{region}" for region in variants)
for region in variants:
variant = f"{base}-{region}"
if variant not in langs:
langs.append(variant)
if include_orig:
langs.append(f"{language}-orig")
orig_tag = f"{base}-orig"
if orig_tag not in langs:
langs.append(orig_tag)
return langs

View file

@ -114,7 +114,7 @@ class DlFormatsTests(unittest.TestCase):
opts = get_opts(
"captions", "auto", "srt", "best", {}, subtitle_language="it", subtitle_mode="prefer_manual"
)
self.assertEqual(opts["subtitleslangs"], ["it", "it-orig"])
self.assertEqual(opts["subtitleslangs"], ["it", "it-IT", "it-SM", "it-orig"])
def test_get_opts_captions_txt_maps_to_srt_format(self):
opts = get_opts("captions", "auto", "txt", "best", {})

View file

@ -210,6 +210,7 @@ class DownloadInfo:
self.size = None
self.timestamp = time.time_ns()
self.error = error
self.filename = None
# Strip non-pickleable values (generators, iterators, locks, etc.) for shelve
self.entry = _sanitize_entry_for_pickle(entry) if entry is not None else None
self.playlist_item_limit = playlist_item_limit
@ -221,6 +222,7 @@ class DownloadInfo:
self.ytdl_options_overrides = dict(ytdl_options_overrides or {})
self.clip_start = clip_start
self.clip_end = clip_end
self.chapter_files = []
self.subtitle_files = []
def __setstate__(self, state):
@ -289,6 +291,8 @@ class DownloadInfo:
self.subtitle_files = []
if not hasattr(self, "chapter_files"):
self.chapter_files = []
if not hasattr(self, "filename"):
self.filename = None
if not hasattr(self, "clip_start"):
self.clip_start = None
if not hasattr(self, "clip_end"):

View file

@ -904,7 +904,36 @@
</td>
</tr>
}
}
}
@if (entry[1].subtitle_files && entry[1].subtitle_files.length > 0) {
@for (subtitleFile of entry[1].subtitle_files; track subtitleFile.filename) {
<tr [class.disabled]='entry[1].deleting'>
<td></td>
<td>
<div style="padding-left: 2rem;">
<fa-icon [icon]="faClosedCaptioning" class="text-info me-2" />
<a href="{{buildChapterDownloadLink(entry[1], subtitleFile.filename)}}" target="_blank" [attr.aria-label]="'Open subtitle file ' + getChapterFileName(subtitleFile.filename)">{{
getChapterFileName(subtitleFile.filename) }}</a>
</div>
</td>
<td></td>
<td></td>
<td></td>
<td>
@if (subtitleFile.size) {
<span>{{ subtitleFile.size | fileSize }}</span>
}
</td>
<td></td>
<td>
<div class="d-flex">
<a href="{{buildChapterDownloadLink(entry[1], subtitleFile.filename)}}" download [attr.aria-label]="'Download subtitle file ' + getChapterFileName(subtitleFile.filename)"
class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
</div>
</td>
</tr>
}
}
}
</tbody>
</table>

View file

@ -7,7 +7,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { NgSelectModule } from '@ng-select/ng-select';
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faChevronDown, faUpload, faPause, faPlay } from '@fortawesome/free-solid-svg-icons';
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faChevronDown, faUpload, faPause, faPlay, faClosedCaptioning } from '@fortawesome/free-solid-svg-icons';
import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { CookieService } from 'ngx-cookie-service';
import { AddDownloadPayload, DownloadsService } from './services/downloads.service';
@ -188,6 +188,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
faUpload = faUpload;
faPause = faPause;
faPlay = faPlay;
faClosedCaptioning = faClosedCaptioning;
subtitleLanguages = [
{ id: 'en', text: 'English' },
{ id: 'ar', text: 'Arabic' },

View file

@ -31,4 +31,5 @@ export interface Download {
error?: string;
deleting?: boolean;
chapter_files?: { filename: string, size: number }[];
subtitle_files?: { filename: string, size: number }[];
}