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
+77 -1
View File
@@ -14,7 +14,7 @@ use crate::thumbs;
use futures::stream::StreamExt;
use serde::Serialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager, State};
use tokio::io::{AsyncBufReadExt, BufReader};
@@ -1369,6 +1369,79 @@ pub async fn list_subtitles(
Ok(out)
}
/// Reads the WebVTT files in a directory as (language, text), newest naming
/// convention `<id>.<lang>.vtt`. Identical texts are collapsed: YouTube serves
/// the same auto-generated captions under both `en` and `en-orig`, and offering
/// the same track twice is just noise in the menu.
async fn read_vtt_dir(dir: &Path) -> Vec<(String, String)> {
let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
return Vec::new();
};
let mut out: Vec<(String, String)> = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
let Some(stem) = name.strip_suffix(".vtt") else { continue };
let Some((_, lang)) = stem.rsplit_once('.') else { continue };
let Ok(text) = tokio::fs::read_to_string(entry.path()).await else {
continue;
};
out.push((lang.to_string(), tidy_vtt(&text)));
}
// Sorting first makes the survivor of a duplicate the plainer code: "en"
// rather than "en-orig".
out.sort();
let mut seen: Vec<String> = Vec::new();
out.retain(|(_, text)| {
if seen.iter().any(|t| t == text) {
false
} else {
seen.push(text.clone());
true
}
});
out
}
/// Subtitles for a video, as (language, WebVTT text).
///
/// Streaming has no other source: YouTube's HLS manifest lists a dozen audio
/// renditions and no subtitles at all. A download saved before subtitles were
/// switched on has none beside it either, and this fills those in without
/// fetching the video again. Results are cached per video and language, so
/// replaying something costs nothing and works offline.
#[tauri::command]
pub async fn fetch_subtitles(
video_id: String,
lang: String,
state: State<'_, AppState>,
) -> Result<Vec<(String, String)>, String> {
let sub_langs = downloader::sub_langs_for(&lang);
if sub_langs.is_empty() {
return Ok(Vec::new());
}
let dir = state.app_data.join("subs").join(&video_id).join(&lang);
let cached = read_vtt_dir(&dir).await;
if !cached.is_empty() {
return Ok(cached);
}
tokio::fs::create_dir_all(&dir)
.await
.map_err(|e| format!("Cannot create subtitle cache: {e}"))?;
let template = dir.join("%(id)s.%(ext)s").to_string_lossy().to_string();
let mut args = downloader::subs_only_args(&video_id, &template, &sub_langs);
args.push("--ffmpeg-location".into());
args.push(bin("ffmpeg"));
// A video with no captions in this language is an ordinary outcome, not a
// failure worth reporting, so the exit status is not consulted: whatever
// landed on disk is the answer.
let _ = state.yt_dlp().await.args(&args).output().await;
Ok(read_vtt_dir(&dir).await)
}
/// Removes every download and the files behind them, including subtitles.
#[tauri::command]
pub async fn delete_all_downloads(state: State<'_, AppState>) -> Result<usize, String> {
@@ -1386,6 +1459,9 @@ pub async fn delete_all_downloads(state: State<'_, AppState>) -> Result<usize, S
}
}
}
// Fetched captions live in the cache rather than beside the video, so they
// would otherwise survive a wipe.
let _ = tokio::fs::remove_dir_all(state.app_data.join("subs")).await;
state.db.lock().await.clear_all_downloads()
}
+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();
+1
View File
@@ -160,6 +160,7 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
commands::delete_download,
commands::delete_all_downloads,
commands::list_subtitles,
commands::fetch_subtitles,
commands::fetch_durations,
commands::list_browsers,
commands::set_cookie_source,