diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 76d04d0..7b001ef 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1026,7 +1026,7 @@ async fn cache_thumbnails(state: &State<'_, AppState>) { pub async fn download_video( video_id: String, quality: String, - sub_langs: String, + sub_lang: String, app: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { @@ -1038,6 +1038,7 @@ pub async fn download_video( { let db = state.db.lock().await; db.set_download_state(&video_id, DownloadState::Queued, None)?; + db.set_download_request(&video_id, &quality, &sub_lang)?; } let _ = app.emit( "download:state", @@ -1070,6 +1071,7 @@ pub async fn download_video( .join(downloader::OUTPUT_TEMPLATE) .to_string_lossy() .to_string(); + let sub_langs = downloader::embed_sub_langs_for(&sub_lang); let mut args = downloader::build_args(&video_id, &out_template, &quality, &sub_langs); // Without this yt-dlp looks for ffmpeg on PATH, which a bundled app has no // reason to have. Merging video and audio would fail on a clean machine. @@ -1443,6 +1445,18 @@ pub async fn fetch_subtitles( Ok(read_vtt_dir(&dir).await) } +/// Downloads that were still going when the app last closed. +/// +/// Killing the app kills yt-dlp with it, leaving rows queued or running that no +/// process backs. The front end hands these straight back to `download_video`, +/// so they rejoin the same queue rather than needing a second code path. +#[tauri::command] +pub async fn interrupted_downloads( + state: State<'_, AppState>, +) -> Result, String> { + state.db.lock().await.interrupted_downloads() +} + /// Stops everything downloading or waiting to download. /// /// Kills the running processes, then marks every row the database still calls diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 47e5295..b06011b 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -92,6 +92,9 @@ impl Db { let _ = conn.execute("ALTER TABLE videos ADD COLUMN duration INTEGER", []); let _ = conn.execute("ALTER TABLE channels ADD COLUMN last_error TEXT", []); let _ = conn.execute("ALTER TABLE channels ADD COLUMN last_checked INTEGER", []); + // What a download was asked for, so it can be resumed as requested. + let _ = conn.execute("ALTER TABLE downloads ADD COLUMN quality TEXT", []); + let _ = conn.execute("ALTER TABLE downloads ADD COLUMN sub_lang TEXT", []); Ok(Db { conn }) } @@ -508,6 +511,40 @@ impl Db { Ok(()) } + /// The quality and subtitle language a download was started with, so an + /// interrupted one resumes as it was asked for rather than as the settings + /// happen to read now. + pub fn set_download_request( + &self, + video_id: &str, + quality: &str, + sub_lang: &str, + ) -> Result<(), String> { + self.conn + .execute( + "UPDATE downloads SET quality = ?2, sub_lang = ?3 WHERE video_id = ?1", + params![video_id, quality, sub_lang], + ) + .map_err(|e| e.to_string())?; + Ok(()) + } + + /// Downloads left unfinished, as (video_id, quality, sub_lang). Killing the + /// app leaves rows queued or running with no process behind them. + pub fn interrupted_downloads(&self) -> Result, String> { + let mut stmt = self + .conn + .prepare( + "SELECT video_id, COALESCE(quality, ''), COALESCE(sub_lang, '') + FROM downloads WHERE state IN ('queued','running')", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))) + .map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string()) + } + pub fn set_download_state( &self, video_id: &str, diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index 238d496..23525e4 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -111,21 +111,28 @@ pub fn build_args( ]; if !sub_langs.is_empty() { - // Subtitles come along for offline use, including YouTube's - // auto-generated ones. WebVTT beside the video rather than muxed in: - // WebKit reads a reliably and largely ignores subtitle streams - // inside an MP4. + // Muxed into the file as a soft subtitle track, not written beside it + // and not burned into the picture. Without --write-subs, yt-dlp fetches + // the captions, embeds them, and removes the WebVTT files, so a + // download is one self-contained file. + // + // Auto-generated captions are included: on most videos they are the + // only ones there are. // // The languages are named exactly. A wildcard like "en.*" also matches // every machine-translated variant YouTube offers — en-en-US, en-de and // dozens more — and asking for all of them earns an HTTP 429. args.extend([ - "--write-subs".into(), "--write-auto-subs".into(), + "--embed-subs".into(), "--sub-format".into(), "vtt".into(), + // Converted to SubRip on the way in. YouTube's WebVTT pins every + // cue to the left edge and fills it with karaoke timing tags; + // SubRip carries neither, so the embedded track is plain centred + // text rather than something clinging to the side of the picture. "--convert-subs".into(), - "vtt".into(), + "srt".into(), "--sub-langs".into(), sub_langs.to_string(), ]); @@ -161,10 +168,12 @@ pub fn subs_only_args(video_id: &str, out_template: &str, sub_langs: &str) -> Ve ] } -/// The subtitle languages to request for a preference, or empty for none. +/// The subtitle languages to fetch for a preference, or empty for none. /// -/// Only the language itself and YouTube's "-orig" variant; anything broader -/// pulls in machine translations by the dozen. +/// The language itself and YouTube's "-orig" variant, which is the original +/// rather than a machine translation. Anything broader pulls in translations by +/// the dozen. The two are usually byte-identical, so whoever reads them back +/// collapses duplicates. pub fn sub_langs_for(pref: &str) -> String { if pref.is_empty() || pref == "off" { String::new() @@ -173,6 +182,18 @@ pub fn sub_langs_for(pref: &str) -> String { } } +/// The languages to embed in a download: just the one. +/// +/// Nothing downstream can collapse duplicates once they are muxed in, and +/// asking for "en,en-orig" gives a file with the same captions on two tracks. +pub fn embed_sub_langs_for(pref: &str) -> String { + if pref.is_empty() || pref == "off" { + String::new() + } else { + pref.to_string() + } +} + /// yt-dlp reports the final path via `--print after_move:`, which is more /// reliable than guessing the extension after a merge. pub fn parse_final_path(line: &str) -> Option { @@ -270,24 +291,36 @@ mod tests { } #[test] - fn subtitles_are_requested_including_auto_generated() { - let args = build_args("abc", "/tmp/o.%(ext)s", "best", "en,en-orig"); - assert!(args.contains(&"--write-subs".to_string())); + fn subtitles_are_embedded_not_written_beside_the_video() { + let args = build_args("abc", "/tmp/o.%(ext)s", "best", "en"); + assert!(args.contains(&"--embed-subs".to_string())); // The auto-generated track is the only one many videos have. assert!(args.contains(&"--write-auto-subs".to_string())); - assert!(args.contains(&"en,en-orig".to_string())); - // WebVTT, because that is what a element can load. - assert!(args.contains(&"vtt".to_string())); + assert!(args.contains(&"en".to_string())); + // Without --write-subs, yt-dlp removes the WebVTT files after + // embedding, leaving one self-contained file. + assert!(!args.contains(&"--write-subs".to_string())); + // SubRip, so YouTube's edge-pinned cue positioning does not come with. + assert!(args.contains(&"srt".to_string())); } #[test] fn no_preference_means_no_subtitle_requests_at_all() { let args = build_args("abc", "/tmp/o.%(ext)s", "best", ""); - assert!(!args.contains(&"--write-subs".to_string())); + assert!(!args.contains(&"--embed-subs".to_string())); assert!(!args.contains(&"--write-auto-subs".to_string())); assert!(!args.contains(&"--sub-langs".to_string())); } + #[test] + fn embedding_asks_for_one_language_and_fetching_asks_for_both() { + // Two identical tracks cannot be collapsed once they are muxed in. + assert_eq!(embed_sub_langs_for("en"), "en"); + assert_eq!(embed_sub_langs_for("off"), ""); + // Reading them back can collapse duplicates, so breadth is free there. + assert_eq!(sub_langs_for("en"), "en,en-orig"); + } + #[test] fn a_subtitle_failure_must_not_abort_the_video() { // yt-dlp aborts the whole job on the first error without this. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8b0adba..ce28b3e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -158,6 +158,7 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result> { commands::download_video, commands::cancel_download, commands::cancel_all_downloads, + commands::interrupted_downloads, commands::delete_download, commands::delete_all_downloads, commands::list_subtitles, diff --git a/src/App.tsx b/src/App.tsx index a9f1e91..c85eb15 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteDownload, downloadVideo, + interruptedDownloads, fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource, } from "./api"; import Player from "./components/Player"; @@ -148,6 +149,11 @@ export default function App() { }, [view, quality, bulkLimit, streamQuality, subLang, browser, downloadedOnly, hideShorts, sidebarHidden]); + // The language a download embeds. Like the player's fetch, this does not + // depend on the on/off preference: that says what is shown, and a file + // downloaded without a subtitle track can never gain one offline. + const embedLang = subLang === "off" ? DEFAULT_SUB_LANG : subLang; + // Offline, the only videos that can be played are the ones already on disk, // so the feed collapses to those regardless of the toggle. const effectiveDownloadedOnly = downloadedOnly || !online; @@ -191,7 +197,6 @@ export default function App() { // Named exactly, never as a wildcard: "en.*" also matches every // machine-translated variant YouTube offers, and asking for all of them // earns an HTTP 429. Off means no subtitle requests at all. - const subLangArg = subLang === "off" ? "" : `${subLang},${subLang}-orig`; const doRefresh = useCallback(async () => { setRefreshing(true); @@ -275,6 +280,33 @@ export default function App() { return ids.size; }, [live, items]); + // Closing the app kills yt-dlp, so anything in flight is left unfinished. + // Pick it up on the next launch, at the quality and language it was asked + // for rather than whatever the settings happen to say now. + useEffect(() => { + let cancelled = false; + interruptedDownloads() + .then((rows) => { + if (cancelled || rows.length === 0) return; + say( + rows.length === 1 + ? "Resuming 1 unfinished download" + : `Resuming ${rows.length} unfinished downloads`, + ); + for (const [id, q, lang] of rows) { + downloadVideo(id, (q || "best") as Quality, lang).catch(() => {}); + } + }) + .catch(() => { + /* nothing to resume is the normal case */ + }); + return () => { + cancelled = true; + }; + // Once, on launch. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const stopAll = useCallback(() => { cancelAllDownloads() .then((n) => { @@ -311,9 +343,9 @@ export default function App() { : `Queued ${bulkTargets.length} video${bulkTargets.length === 1 ? "" : "s"}`, ); for (const i of bulkTargets) { - downloadVideo(i.id, quality, subLangArg).catch(() => {}); + downloadVideo(i.id, quality, embedLang).catch(() => {}); } - }, [bulkTargets, pendingDownloads.length, quality, subLangArg, say]); + }, [bulkTargets, pendingDownloads.length, quality, embedLang, say]); // Ids on screen still lacking a length, newest first. Joined into a string // so the effect below only re-runs when the set actually changes. @@ -502,7 +534,7 @@ export default function App() { onOpen: () => openIndex(idx), onOpenChannel: () => { setChannelId(item.channel_id); setSearch(""); }, onDownload: () => - downloadVideo(item.id, quality, subLangArg).catch((e) => setFailure(String(e))), + downloadVideo(item.id, quality, embedLang).catch((e) => setFailure(String(e))), onCancel: () => cancelDownload(item.id).catch((e) => setFailure(String(e))), onDelete: () => @@ -546,7 +578,7 @@ export default function App() { onDownload={ playing.path === null ? () => { - downloadVideo(playing.item.id, quality, subLangArg).catch((e) => setFailure(String(e))); + downloadVideo(playing.item.id, quality, embedLang).catch((e) => setFailure(String(e))); say("Download started"); } : undefined diff --git a/src/api.ts b/src/api.ts index c09ded2..1fbfc4e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -25,8 +25,12 @@ export const listFeed = (filter: FeedFilter) => export const refreshFeeds = () => invoke("refresh_feeds"); -export const downloadVideo = (videoId: string, quality: Quality, subLangs: string) => - invoke("download_video", { videoId, quality, subLangs }); +export const downloadVideo = (videoId: string, quality: Quality, subLang: string) => + invoke("download_video", { videoId, quality, subLang }); + +/** Downloads still unfinished from a previous run, as (id, quality, subLang). */ +export const interruptedDownloads = () => + invoke>("interrupted_downloads"); /** Stops everything downloading or waiting to. Returns how many were stopped. */ export const cancelAllDownloads = () => invoke("cancel_all_downloads"); diff --git a/src/components/Player.tsx b/src/components/Player.tsx index 8e2241a..61d21a1 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -143,6 +143,21 @@ export default function Player({ setTracks([]); setFetchingSubs(false); + // A download made since subtitles became embedded carries them inside the + // file, where the element exposes them itself. Give it until the metadata + // is parsed to say so before going to the network for something already on + // disk — which would also fail offline. + const hasEmbedded = async () => { + const v = videoRef.current; + if (!v) return false; + for (let i = 0, settled = 0; i < 20 && settled < 3; i++) { + if (v.textTracks.length > 0) return true; + if (v.readyState >= 1) settled++; + await new Promise((r) => setTimeout(r, 150)); + } + return (videoRef.current?.textTracks.length ?? 0) > 0; + }; + const load = async () => { if (path) { const local = await listSubtitles(item.id).catch(() => []); @@ -151,6 +166,7 @@ export default function Player({ setTracks(local.map(([lang, file]) => [lang, fileUrl(file)])); return; } + if (await hasEmbedded()) return; } if (cancelled) return; setFetchingSubs(true); diff --git a/src/components/PlayerControls.tsx b/src/components/PlayerControls.tsx index f1bd839..3ba321f 100644 --- a/src/components/PlayerControls.tsx +++ b/src/components/PlayerControls.tsx @@ -1,25 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; -/** WebKit exposes HLS alternate audio renditions here; the DOM lib omits it. */ -interface AudioTrackLike { - id: string; - label: string; - language: string; - enabled: boolean; -} -interface AudioTrackListLike { - length: number; - [index: number]: AudioTrackLike; - addEventListener?: (t: string, fn: () => void) => void; - removeEventListener?: (t: string, fn: () => void) => void; -} -type VideoWithTracks = HTMLVideoElement & { audioTracks?: AudioTrackListLike }; - -function listAudio(v: HTMLVideoElement | null): AudioTrackLike[] { - const list = (v as VideoWithTracks | null)?.audioTracks; - if (!list) return []; - return Array.from({ length: list.length }, (_, i) => list[i]); -} +import { subtitleLabel } from "./format"; function listSubs(v: HTMLVideoElement | null): TextTrack[] { if (!v) return []; @@ -76,21 +57,17 @@ export default function PlayerControls({ const [pip, setPip] = useState(false); const [full, setFull] = useState(false); const [menu, setMenu] = useState(false); - const [audio, setAudio] = useState([]); const [subs, setSubs] = useState([]); const [, bump] = useState(0); const menuRef = useRef(null); - // How many text tracks the preference was last applied to. - const applied = useRef(-1); + // Which video, and how many tracks, the preference was last applied to. + const applied = useRef(""); // Tracks arrive with the manifest, after metadata rather than on mount. useEffect(() => { const v = videoRef.current; if (!v) return; - const read = () => { - setAudio(listAudio(v)); - setSubs(listSubs(v)); - }; + const read = () => setSubs(listSubs(v)); read(); // Subtitles follow the language chosen in Settings and nothing else. @@ -98,11 +75,12 @@ export default function PlayerControls({ // which is the same unwanted auto-selection as a dubbed audio track. // Applied once per set of tracks. Re-applying on every poll would undo a // choice made in the menu a second after it was made. - applied.current = -1; + applied.current = ""; const applyPreference = () => { const tracks = Array.from(v.textTracks); - if (tracks.length === applied.current) return; - applied.current = tracks.length; + const key = `${v.currentSrc}|${tracks.length}`; + if (key === applied.current) return; + applied.current = key; const wanted = subLang === "off" ? undefined @@ -118,8 +96,6 @@ export default function PlayerControls({ v.textTracks.addEventListener?.("addtrack", applyPreference); v.addEventListener("loadedmetadata", read); - const at = (v as VideoWithTracks).audioTracks; - at?.addEventListener?.("addtrack", read); v.textTracks.addEventListener?.("addtrack", read); // Manifests can take a moment to surface renditions. const id = setInterval(read, 1000); @@ -128,7 +104,6 @@ export default function PlayerControls({ v.removeEventListener("loadedmetadata", applyPreference); v.textTracks.removeEventListener?.("addtrack", applyPreference); v.removeEventListener("loadedmetadata", read); - at?.removeEventListener?.("addtrack", read); v.textTracks.removeEventListener?.("addtrack", read); clearInterval(id); clearTimeout(stop); @@ -145,16 +120,6 @@ export default function PlayerControls({ return () => document.removeEventListener("mousedown", onDown); }, [menu]); - const chooseAudio = (i: number) => { - const v = videoRef.current; - const list = (v as VideoWithTracks | null)?.audioTracks; - if (!list) return; - // Exactly one enabled, or WebKit mixes them. - for (let k = 0; k < list.length; k++) list[k].enabled = k === i; - bump((n) => n + 1); - onActivity?.(); - }; - const chooseSub = (track: TextTrack | null) => { const v = videoRef.current; if (!v) return; @@ -340,12 +305,12 @@ export default function PlayerControls({ }} /> - {(audio.length > 1 || subs.length > 0 || subsLoading) && ( + {(subs.length > 0 || subsLoading) && (
- ))} - - )} - {subs.length === 0 && subsLoading && ( <>
@@ -419,7 +363,11 @@ export default function PlayerControls({ {t.mode === "showing" ? "✓" : ""} - {t.label || t.language || `Subtitles ${i + 1}`} + + {t.language + ? subtitleLabel(t.language) + : t.label || `Subtitles ${i + 1}`} + ))} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 56ea877..f95b9ce 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -280,8 +280,8 @@ export default function Settings({ captions — on most videos those are the only ones there are. Downloads keep them beside the file for offline use; streams fetch them separately, since YouTube's live manifest carries no subtitles at all. Off only means - nothing comes on by itself: whatever a video has is still listed in the - player's subtitle menu. + nothing comes on by itself: subtitles are still embedded in downloads and + still listed in the player's subtitle menu.

diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 7eaa19a..7589ea5 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -162,13 +162,17 @@ export default function TopBar({ )}