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()
}