feat: subtitles while streaming, and fetched when a download lacks them

YouTube's HLS manifest carries a dozen audio renditions and no
subtitles whatsoever — checked against a live master playlist: 16
EXT-X-MEDIA entries, all TYPE=AUDIO, zero SUBTITLES. So the track menu
could only ever list audio while streaming, which is what it did.

Subtitles are now fetched separately with yt-dlp, tidied through the
existing VTT cleanup, and cached per video and language. They reach the
player as blob URLs, which share the document's origin — a file:// or
127.0.0.1 track would be cross-origin to the page and need CORS the
media pipeline cannot supply. The same path fills in a download saved
before subtitles were switched on, without fetching the video again.

YouTube serves identical auto-generated captions under both "en" and
"en-orig", so byte-identical texts collapse to one entry rather than
offering the same track twice, and tracks are labelled "English"
rather than "en".

The subtitle preference now defaults to English. "None" is a poor
default for a setting whose whole purpose is captions: it silently
means no subtitles are downloaded, fetched, or offered anywhere, and
the Settings text now says so.
This commit is contained in:
vincent
2026-08-29 16:25:26 +02:00
parent 66cb80a044
commit 5b2be50852
10 changed files with 221 additions and 12 deletions
+49
View File
@@ -135,6 +135,32 @@ pub fn build_args(
args
}
/// Arguments for fetching only the subtitles of a video.
///
/// YouTube's HLS manifest carries audio renditions but no subtitles whatsoever,
/// so a streamed video has nothing to show unless the captions are fetched
/// separately. Auto-generated captions are included: on most videos they are
/// the only ones there are.
pub fn subs_only_args(video_id: &str, out_template: &str, sub_langs: &str) -> Vec<String> {
vec![
"--skip-download".into(),
"--no-playlist".into(),
"--no-colors".into(),
"--ignore-errors".into(),
"--write-subs".into(),
"--write-auto-subs".into(),
"--sub-format".into(),
"vtt".into(),
"--convert-subs".into(),
"vtt".into(),
"--sub-langs".into(),
sub_langs.to_string(),
"-o".into(),
out_template.into(),
format!("https://www.youtube.com/watch?v={video_id}"),
]
}
/// The subtitle languages to request for a preference, or empty for none.
///
/// Only the language itself and YouTube's "-orig" variant; anything broader
@@ -160,6 +186,29 @@ pub fn parse_final_path(line: &str) -> Option<String> {
mod tests {
use super::*;
#[test]
fn subs_only_args_take_auto_generated_captions() {
let args = subs_only_args("abc", "/tmp/%(id)s.%(ext)s", "en,en-orig");
// Most videos have no hand-written captions at all; without this the
// fetch comes back empty.
assert!(args.iter().any(|a| a == "--write-auto-subs"));
assert!(args.iter().any(|a| a == "--write-subs"));
assert!(args.iter().any(|a| a == "--skip-download"));
// Named exactly: a wildcard drags in dozens of machine translations.
let langs = args.iter().position(|a| a == "--sub-langs").unwrap();
assert_eq!(args[langs + 1], "en,en-orig");
assert_eq!(args.last().unwrap(), "https://www.youtube.com/watch?v=abc");
}
#[test]
fn subs_only_args_are_written_where_asked() {
let args = subs_only_args("abc", "/cache/%(id)s.%(ext)s", "nl,nl-orig");
let out = args.iter().position(|a| a == "-o").unwrap();
assert_eq!(args[out + 1], "/cache/%(id)s.%(ext)s");
}
use super::*;
#[test]
fn parses_complete_line() {
let p = parse_progress_line("FTPROG 1048576 10485760 524288.0 18").unwrap();