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:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user