diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index aedcecb..76d04d0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1158,12 +1158,13 @@ pub async fn download_video( } } - let mut child = state - .children - .lock() - .await - .remove(&video_id) - .ok_or_else(|| "Download was cancelled.".to_string())?; + // Gone from the map means it was cancelled: the state is already recorded + // and the event already sent. Stopping something is a normal outcome, not + // a failure to report — a rejection here would raise a dialog per download + // the moment you press Stop all. + let Some(mut child) = state.children.lock().await.remove(&video_id) else { + return Ok(()); + }; let status = child .wait() @@ -1442,6 +1443,45 @@ pub async fn fetch_subtitles( Ok(read_vtt_dir(&dir).await) } +/// Stops everything downloading or waiting to download. +/// +/// Kills the running processes, then marks every row the database still calls +/// queued or running as cancelled — which covers downloads parked on a slot, +/// and any left behind by a crash that no process backs any more. +#[tauri::command] +pub async fn cancel_all_downloads( + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + // Drain first and kill after, so no child is killed while the map is held. + let children: Vec<(String, tokio::process::Child)> = + state.children.lock().await.drain().collect(); + for (_, mut child) in children { + let _ = child.kill().await; + } + + let ids = state.db.lock().await.active_downloads()?; + let library = state.library.lock().await.clone(); + for id in &ids { + state + .db + .lock() + .await + .set_download_state(id, DownloadState::Cancelled, None)?; + cleanup_partials(library.clone(), id).await; + let _ = app.emit( + "download:state", + DownloadStateEvent { + video_id: id.clone(), + state: DownloadState::Cancelled, + error: None, + path: None, + }, + ); + } + Ok(ids.len()) +} + /// Removes every download and the files behind them, including subtitles. #[tauri::command] pub async fn delete_all_downloads(state: State<'_, AppState>) -> Result { diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 0c09c86..47e5295 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -603,6 +603,19 @@ impl Db { rows.collect::, _>>().map_err(|e| e.to_string()) } + /// Videos the database still considers in flight. Includes any left + /// "running" by a crash, which no live process backs any more. + pub fn active_downloads(&self) -> Result, String> { + let mut stmt = self + .conn + .prepare("SELECT video_id FROM downloads WHERE state IN ('queued','running')") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |r| r.get::<_, String>(0)) + .map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string()) + } + pub fn clear_all_downloads(&self) -> Result { let n = self .conn @@ -1020,6 +1033,21 @@ mod tests { ); } + #[test] + fn active_downloads_are_the_queued_and_running_ones() { + let db = seeded(); + db.set_download_state("a", DownloadState::Running, None).unwrap(); + db.set_download_state("b", DownloadState::Queued, None).unwrap(); + db.set_download_state("c", DownloadState::Done, None).unwrap(); + let mut active = db.active_downloads().unwrap(); + active.sort(); + assert_eq!(active, vec!["a".to_string(), "b".to_string()]); + // Stopping them takes them out of flight without deleting anything. + db.set_download_state("a", DownloadState::Cancelled, None).unwrap(); + db.set_download_state("b", DownloadState::Cancelled, None).unwrap(); + assert!(db.active_downloads().unwrap().is_empty()); + } + #[test] fn clearing_a_download_makes_it_undownloaded_again() { let db = seeded(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fa3cfe0..8b0adba 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -157,6 +157,7 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result> { commands::refresh_feeds, commands::download_video, commands::cancel_download, + commands::cancel_all_downloads, commands::delete_download, commands::delete_all_downloads, commands::list_subtitles, diff --git a/src/App.tsx b/src/App.tsx index 8ad3876..6c3c478 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - cancelDownload, deleteAllDownloads, deleteDownload, downloadVideo, + cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteDownload, downloadVideo, fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource, } from "./api"; import Player from "./components/Player"; @@ -253,6 +253,29 @@ export default function App() { return () => clearInterval(id); }, [online, refreshing, playingIndex, doRefresh]); + // Anything in flight, whether or not it is currently listed — a download + // started on one channel keeps running while you look at another. + const activeDownloads = useMemo(() => { + const ids = new Set(); + for (const [id, l] of Object.entries(live)) { + if (l.state === "queued" || l.state === "running") ids.add(id); + } + for (const i of items) { + const state = live[i.id]?.state ?? i.state; + if (state === "queued" || state === "running") ids.add(i.id); + } + return ids.size; + }, [live, items]); + + const stopAll = useCallback(() => { + cancelAllDownloads() + .then((n) => { + reload(); + say(n === 1 ? "Stopped 1 download" : `Stopped ${n} downloads`); + }) + .catch((e) => setFailure(String(e))); + }, [reload, say]); + // Everything listed that is not already here or on its way. const pendingDownloads = useMemo( () => @@ -420,6 +443,8 @@ export default function App() { onDownloadAll={online && bulkTargets.length > 0 ? downloadAll : undefined} downloadAllCount={bulkTargets.length} downloadAllTotal={pendingDownloads.length} + onStopAll={activeDownloads > 0 ? stopAll : undefined} + stopAllCount={activeDownloads} sidebarHidden={sidebarHidden} onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }} titleBarInset={titleBarInset} diff --git a/src/api.ts b/src/api.ts index 1550bf0..c09ded2 100644 --- a/src/api.ts +++ b/src/api.ts @@ -28,6 +28,9 @@ export const refreshFeeds = () => invoke("refresh_feeds"); export const downloadVideo = (videoId: string, quality: Quality, subLangs: string) => invoke("download_video", { videoId, quality, subLangs }); +/** Stops everything downloading or waiting to. Returns how many were stopped. */ +export const cancelAllDownloads = () => invoke("cancel_all_downloads"); + export const deleteAllDownloads = () => invoke("delete_all_downloads"); /** Fills in missing video lengths, a batch at a time. Returns how many. */ diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 708b65c..7eaa19a 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -23,6 +23,9 @@ interface Props { onDeleteAll?: () => void; /** Present only on a channel page with videos still to fetch. */ onDownloadAll?: () => void; + /** Present only while something is downloading or waiting to. */ + onStopAll?: () => void; + stopAllCount?: number; /** How many this press would queue, and how many are listed in all. */ downloadAllCount?: number; downloadAllTotal?: number; @@ -64,7 +67,7 @@ export default function TopBar({ online, reachable, forcedOffline, onToggleForcedOffline, onRefresh, refreshing, refreshProgress, resultCount, view, onView, sidebarHidden, onShowSidebar, onDeleteAll, onDownloadAll, downloadAllCount = 0, - downloadAllTotal = 0, titleBarInset, + downloadAllTotal = 0, onStopAll, stopAllCount = 0, titleBarInset, }: Props) { const pct = refreshProgress && refreshProgress.total > 0 ? (refreshProgress.done / refreshProgress.total) * 100 @@ -111,6 +114,25 @@ export default function TopBar({ Local + {onStopAll && ( + + )} + {onDownloadAll && (