feat: stop every download at once
A stop button appears beside Download all whenever anything is
downloading or waiting to, and disappears when nothing is. It kills the
running processes, marks everything the database still calls queued or
running as cancelled, and clears the part files. Downloads already
finished are untouched.
That also covers two cases a per-video Cancel cannot reach: a download
parked on a slot, which stands down when its turn comes, and a row left
"running" by a crash that no process backs any more.
Cancelling no longer reports itself as a failure. download_video
returned Err("Download was cancelled.") when it found its child gone,
which the caller turned into an error dialog — pressing Stop all would
have raised one per download. Stopping something is a normal outcome:
the state is already recorded and the event already sent, so it returns
cleanly.
This commit is contained in:
@@ -1158,12 +1158,13 @@ pub async fn download_video(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut child = state
|
// Gone from the map means it was cancelled: the state is already recorded
|
||||||
.children
|
// and the event already sent. Stopping something is a normal outcome, not
|
||||||
.lock()
|
// a failure to report — a rejection here would raise a dialog per download
|
||||||
.await
|
// the moment you press Stop all.
|
||||||
.remove(&video_id)
|
let Some(mut child) = state.children.lock().await.remove(&video_id) else {
|
||||||
.ok_or_else(|| "Download was cancelled.".to_string())?;
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
let status = child
|
let status = child
|
||||||
.wait()
|
.wait()
|
||||||
@@ -1442,6 +1443,45 @@ pub async fn fetch_subtitles(
|
|||||||
Ok(read_vtt_dir(&dir).await)
|
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<usize, String> {
|
||||||
|
// 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.
|
/// Removes every download and the files behind them, including subtitles.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_all_downloads(state: State<'_, AppState>) -> Result<usize, String> {
|
pub async fn delete_all_downloads(state: State<'_, AppState>) -> Result<usize, String> {
|
||||||
|
|||||||
@@ -603,6 +603,19 @@ impl Db {
|
|||||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
rows.collect::<Result<Vec<_>, _>>().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<Vec<String>, 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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn clear_all_downloads(&self) -> Result<usize, String> {
|
pub fn clear_all_downloads(&self) -> Result<usize, String> {
|
||||||
let n = self
|
let n = self
|
||||||
.conn
|
.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]
|
#[test]
|
||||||
fn clearing_a_download_makes_it_undownloaded_again() {
|
fn clearing_a_download_makes_it_undownloaded_again() {
|
||||||
let db = seeded();
|
let db = seeded();
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
|
|||||||
commands::refresh_feeds,
|
commands::refresh_feeds,
|
||||||
commands::download_video,
|
commands::download_video,
|
||||||
commands::cancel_download,
|
commands::cancel_download,
|
||||||
|
commands::cancel_all_downloads,
|
||||||
commands::delete_download,
|
commands::delete_download,
|
||||||
commands::delete_all_downloads,
|
commands::delete_all_downloads,
|
||||||
commands::list_subtitles,
|
commands::list_subtitles,
|
||||||
|
|||||||
+26
-1
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
cancelDownload, deleteAllDownloads, deleteDownload, downloadVideo,
|
cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteDownload, downloadVideo,
|
||||||
fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource,
|
fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import Player from "./components/Player";
|
import Player from "./components/Player";
|
||||||
@@ -253,6 +253,29 @@ export default function App() {
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [online, refreshing, playingIndex, doRefresh]);
|
}, [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<string>();
|
||||||
|
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.
|
// Everything listed that is not already here or on its way.
|
||||||
const pendingDownloads = useMemo(
|
const pendingDownloads = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -420,6 +443,8 @@ export default function App() {
|
|||||||
onDownloadAll={online && bulkTargets.length > 0 ? downloadAll : undefined}
|
onDownloadAll={online && bulkTargets.length > 0 ? downloadAll : undefined}
|
||||||
downloadAllCount={bulkTargets.length}
|
downloadAllCount={bulkTargets.length}
|
||||||
downloadAllTotal={pendingDownloads.length}
|
downloadAllTotal={pendingDownloads.length}
|
||||||
|
onStopAll={activeDownloads > 0 ? stopAll : undefined}
|
||||||
|
stopAllCount={activeDownloads}
|
||||||
sidebarHidden={sidebarHidden}
|
sidebarHidden={sidebarHidden}
|
||||||
onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }}
|
onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }}
|
||||||
titleBarInset={titleBarInset}
|
titleBarInset={titleBarInset}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export const refreshFeeds = () => invoke<RefreshSummary>("refresh_feeds");
|
|||||||
export const downloadVideo = (videoId: string, quality: Quality, subLangs: string) =>
|
export const downloadVideo = (videoId: string, quality: Quality, subLangs: string) =>
|
||||||
invoke<void>("download_video", { videoId, quality, subLangs });
|
invoke<void>("download_video", { videoId, quality, subLangs });
|
||||||
|
|
||||||
|
/** Stops everything downloading or waiting to. Returns how many were stopped. */
|
||||||
|
export const cancelAllDownloads = () => invoke<number>("cancel_all_downloads");
|
||||||
|
|
||||||
export const deleteAllDownloads = () => invoke<number>("delete_all_downloads");
|
export const deleteAllDownloads = () => invoke<number>("delete_all_downloads");
|
||||||
|
|
||||||
/** Fills in missing video lengths, a batch at a time. Returns how many. */
|
/** Fills in missing video lengths, a batch at a time. Returns how many. */
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ interface Props {
|
|||||||
onDeleteAll?: () => void;
|
onDeleteAll?: () => void;
|
||||||
/** Present only on a channel page with videos still to fetch. */
|
/** Present only on a channel page with videos still to fetch. */
|
||||||
onDownloadAll?: () => void;
|
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. */
|
/** How many this press would queue, and how many are listed in all. */
|
||||||
downloadAllCount?: number;
|
downloadAllCount?: number;
|
||||||
downloadAllTotal?: number;
|
downloadAllTotal?: number;
|
||||||
@@ -64,7 +67,7 @@ export default function TopBar({
|
|||||||
online, reachable, forcedOffline, onToggleForcedOffline,
|
online, reachable, forcedOffline, onToggleForcedOffline,
|
||||||
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
|
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
|
||||||
sidebarHidden, onShowSidebar, onDeleteAll, onDownloadAll, downloadAllCount = 0,
|
sidebarHidden, onShowSidebar, onDeleteAll, onDownloadAll, downloadAllCount = 0,
|
||||||
downloadAllTotal = 0, titleBarInset,
|
downloadAllTotal = 0, onStopAll, stopAllCount = 0, titleBarInset,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const pct = refreshProgress && refreshProgress.total > 0
|
const pct = refreshProgress && refreshProgress.total > 0
|
||||||
? (refreshProgress.done / refreshProgress.total) * 100
|
? (refreshProgress.done / refreshProgress.total) * 100
|
||||||
@@ -111,6 +114,25 @@ export default function TopBar({
|
|||||||
Local
|
Local
|
||||||
</Toggle>
|
</Toggle>
|
||||||
|
|
||||||
|
{onStopAll && (
|
||||||
|
<button
|
||||||
|
onClick={onStopAll}
|
||||||
|
title={`Stop the ${stopAllCount} download${
|
||||||
|
stopAllCount === 1 ? "" : "s"
|
||||||
|
} in progress — nothing already saved is deleted`}
|
||||||
|
aria-label="Stop all downloads"
|
||||||
|
className={`${ICON_BTN} border border-slate-300 text-slate-500 hover:border-red-500
|
||||||
|
hover:text-red-600 dark:border-slate-700 dark:text-slate-400
|
||||||
|
dark:hover:border-red-500 dark:hover:text-red-400`}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor"
|
||||||
|
strokeWidth="1.8">
|
||||||
|
<circle cx="12" cy="12" r="8.5" />
|
||||||
|
<rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{onDownloadAll && (
|
{onDownloadAll && (
|
||||||
<button
|
<button
|
||||||
onClick={onDownloadAll}
|
onClick={onDownloadAll}
|
||||||
|
|||||||
Reference in New Issue
Block a user