feat(desktop): supprimer tous les torrents en une action
Ajoute api_torrents_delete_all sur l'Api librqbit : itere sur tous les torrents de la session, les supprime avec ou sans les donnees selon remove_files, collecte les erreurs sans interrompre la boucle et ne renvoie une 500 qu'a la fin si au moins une suppression a echoue. Cote desktop, la commande Tauri torrents_delete_all expose l'appel, et la WebUI ajoute un bouton corbeille dans RootContent avec une modale de confirmation DeleteAllTorrentsModal. Limite connue : aucune route HTTP /torrents/delete_all n'est enregistree dans http_api/handlers. Le chemin Tauri fonctionne (desktop/src/api.tsx passe par invokeAPI), mais la WebUI servie en mode navigateur appellera POST /torrents/delete_all et recevra un 404. La route reste a cabler pour que le bouton fonctionne hors application desktop. Ce correctif tournait depuis des semaines uniquement dans l'arbre de travail, non versionne, alors qu'il etait deja compile dans les paquets rqbit et rqbit-desktop installes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
99219f90bc
commit
5d68aaf352
9 changed files with 233 additions and 0 deletions
60
Cargo.lock
generated
60
Cargo.lock
generated
|
|
@ -7409,3 +7409,63 @@ dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.101",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[patch.unused]]
|
||||||
|
name = "dpi"
|
||||||
|
version = "0.1.2"
|
||||||
|
|
||||||
|
[[patch.unused]]
|
||||||
|
name = "winit"
|
||||||
|
version = "0.31.0-beta.2"
|
||||||
|
|
||||||
|
[[patch.unused]]
|
||||||
|
name = "winit-android"
|
||||||
|
version = "0.31.0-beta.2"
|
||||||
|
|
||||||
|
[[patch.unused]]
|
||||||
|
name = "winit-appkit"
|
||||||
|
version = "0.31.0-beta.2"
|
||||||
|
|
||||||
|
[[patch.unused]]
|
||||||
|
name = "winit-common"
|
||||||
|
version = "0.31.0-beta.2"
|
||||||
|
|
||||||
|
[[patch.unused]]
|
||||||
|
name = "winit-core"
|
||||||
|
version = "0.31.0-beta.2"
|
||||||
|
|
||||||
|
[[patch.unused]]
|
||||||
|
name = "winit-orbital"
|
||||||
|
version = "0.31.0-beta.2"
|
||||||
|
|
||||||
|
[[patch.unused]]
|
||||||
|
name = "winit-uikit"
|
||||||
|
version = "0.31.0-beta.2"
|
||||||
|
|
||||||
|
[[patch.unused]]
|
||||||
|
name = "winit-wayland"
|
||||||
|
version = "0.31.0-beta.2"
|
||||||
|
|
||||||
|
[[patch.unused]]
|
||||||
|
name = "winit-web"
|
||||||
|
version = "0.31.0-beta.2"
|
||||||
|
|
||||||
|
[[patch.unused]]
|
||||||
|
name = "winit-win32"
|
||||||
|
version = "0.31.0-beta.2"
|
||||||
|
|
||||||
|
[[patch.unused]]
|
||||||
|
name = "winit-x11"
|
||||||
|
version = "0.31.0-beta.2"
|
||||||
|
|
||||||
|
[[patch.unused]]
|
||||||
|
name = "softbuffer"
|
||||||
|
version = "0.4.1"
|
||||||
|
|
||||||
|
[[patch.unused]]
|
||||||
|
name = "cosmic-freedesktop-icons"
|
||||||
|
version = "0.4.0"
|
||||||
|
|
||||||
|
[[patch.unused]]
|
||||||
|
name = "smithay-clipboard"
|
||||||
|
version = "0.8.0"
|
||||||
|
|
|
||||||
|
|
@ -325,6 +325,48 @@ impl Api {
|
||||||
Ok(Default::default())
|
Ok(Default::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete all torrents, optionally keeping the downloaded data.
|
||||||
|
pub async fn api_torrents_delete_all(
|
||||||
|
&self,
|
||||||
|
remove_files: bool,
|
||||||
|
) -> Result<EmptyJsonResponse> {
|
||||||
|
let ids: Vec<usize> = {
|
||||||
|
let g = self.session.with_torrents(|torrents| {
|
||||||
|
torrents.map(|(id, _)| id).collect()
|
||||||
|
});
|
||||||
|
g
|
||||||
|
};
|
||||||
|
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(Default::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect errors but continue deleting all torrents
|
||||||
|
let mut errors: Vec<String> = Vec::new();
|
||||||
|
for id in ids {
|
||||||
|
if let Err(e) = self
|
||||||
|
.session
|
||||||
|
.delete(TorrentIdOrHash::Id(id), remove_files)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(id, error=?e, "error deleting torrent");
|
||||||
|
errors.push(format!("Torrent {}: {:?}", id, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.is_empty() {
|
||||||
|
return Err(ApiError::new_from_anyhow(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Failed to delete some torrents:\n{}",
|
||||||
|
errors.join("\n")
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Default::default())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn api_torrent_action_update_only_files(
|
pub async fn api_torrent_action_update_only_files(
|
||||||
&self,
|
&self,
|
||||||
idx: TorrentIdOrHash,
|
idx: TorrentIdOrHash,
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,7 @@ export interface RqbitAPI {
|
||||||
start: (index: number) => Promise<void>;
|
start: (index: number) => Promise<void>;
|
||||||
forget: (index: number) => Promise<void>;
|
forget: (index: number) => Promise<void>;
|
||||||
delete: (index: number) => Promise<void>;
|
delete: (index: number) => Promise<void>;
|
||||||
|
deleteAll: (removeFiles?: boolean) => Promise<void>;
|
||||||
openOutputFolder?: (path: string) => Promise<void>;
|
openOutputFolder?: (path: string) => Promise<void>;
|
||||||
stats: () => Promise<SessionStats>;
|
stats: () => Promise<SessionStats>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
|
import { useState } from "react";
|
||||||
import { TorrentsList } from "./TorrentsList";
|
import { TorrentsList } from "./TorrentsList";
|
||||||
import { ErrorComponent } from "./ErrorComponent";
|
import { ErrorComponent } from "./ErrorComponent";
|
||||||
import { useTorrentStore } from "../stores/torrentStore";
|
import { useTorrentStore } from "../stores/torrentStore";
|
||||||
import { useErrorStore } from "../stores/errorStore";
|
import { useErrorStore } from "../stores/errorStore";
|
||||||
|
import { IconButton } from "./buttons/IconButton";
|
||||||
|
import { FiTrash2 } from "react-icons/fi";
|
||||||
|
import { DeleteAllTorrentsModal } from "./modal/DeleteAllTorrentsModal";
|
||||||
|
|
||||||
export const RootContent = (props: {}) => {
|
export const RootContent = (props: {}) => {
|
||||||
let closeableError = useErrorStore((state) => state.closeableError);
|
let closeableError = useErrorStore((state) => state.closeableError);
|
||||||
|
|
@ -11,6 +15,7 @@ export const RootContent = (props: {}) => {
|
||||||
let torrentsInitiallyLoading = useTorrentStore(
|
let torrentsInitiallyLoading = useTorrentStore(
|
||||||
(state) => state.torrentsInitiallyLoading
|
(state) => state.torrentsInitiallyLoading
|
||||||
);
|
);
|
||||||
|
const [deleteAllOpened, setDeleteAllOpened] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto">
|
||||||
|
|
@ -19,7 +24,18 @@ export const RootContent = (props: {}) => {
|
||||||
remove={() => setCloseableError(null)}
|
remove={() => setCloseableError(null)}
|
||||||
/>
|
/>
|
||||||
<ErrorComponent error={otherError} />
|
<ErrorComponent error={otherError} />
|
||||||
|
{torrents && torrents.length > 0 && (
|
||||||
|
<div className="flex justify-end px-4 py-2">
|
||||||
|
<IconButton onClick={() => setDeleteAllOpened(true)}>
|
||||||
|
<FiTrash2 className="hover:text-red-500" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<TorrentsList torrents={torrents} loading={torrentsInitiallyLoading} />
|
<TorrentsList torrents={torrents} loading={torrentsInitiallyLoading} />
|
||||||
|
<DeleteAllTorrentsModal
|
||||||
|
show={deleteAllOpened}
|
||||||
|
onHide={() => setDeleteAllOpened(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { useContext, useState } from "react";
|
||||||
|
import { APIContext } from "../../context";
|
||||||
|
import { ErrorWithLabel } from "../../rqbit-web";
|
||||||
|
import { ErrorComponent } from "../ErrorComponent";
|
||||||
|
import { Spinner } from "../Spinner";
|
||||||
|
import { Modal } from "./Modal";
|
||||||
|
import { ModalBody } from "./ModalBody";
|
||||||
|
import { ModalFooter } from "./ModalFooter";
|
||||||
|
import { Button } from "../buttons/Button";
|
||||||
|
import { useTorrentStore } from "../../stores/torrentStore";
|
||||||
|
|
||||||
|
export const DeleteAllTorrentsModal: React.FC<{
|
||||||
|
show: boolean;
|
||||||
|
onHide: () => void;
|
||||||
|
}> = ({ show, onHide }) => {
|
||||||
|
if (!show) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const [deleteFiles, setDeleteFiles] = useState(false);
|
||||||
|
const [error, setError] = useState<ErrorWithLabel | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const API = useContext(APIContext);
|
||||||
|
const refreshTorrents = useTorrentStore((state) => state.refreshTorrents);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setDeleteFiles(false);
|
||||||
|
setError(null);
|
||||||
|
setDeleting(false);
|
||||||
|
onHide();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAllTorrents = () => {
|
||||||
|
setDeleting(true);
|
||||||
|
|
||||||
|
API.deleteAll(deleteFiles)
|
||||||
|
.then(() => {
|
||||||
|
refreshTorrents();
|
||||||
|
close();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setError({
|
||||||
|
text: "Error deleting all torrents",
|
||||||
|
details: e,
|
||||||
|
});
|
||||||
|
setDeleting(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={show} onClose={onHide} title="Delete all torrents">
|
||||||
|
<ModalBody>
|
||||||
|
<p className="text-gray-700 dark:text-slate-300">
|
||||||
|
Are you sure you want to delete <strong>all</strong> torrents? This
|
||||||
|
action cannot be undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="deleteFilesAll"
|
||||||
|
className="form-checkbox h-4 w-4 text-blue-500"
|
||||||
|
onChange={() => setDeleteFiles(!deleteFiles)}
|
||||||
|
checked={deleteFiles}
|
||||||
|
placeholder="Also delete files"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="deleteFilesAll"
|
||||||
|
className="ml-2 text-gray-700 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
Also delete downloaded files
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <ErrorComponent error={error} />}
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
{deleting && <Spinner />}
|
||||||
|
<Button variant="cancel" onClick={close}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={deleteAllTorrents} disabled={deleting}>
|
||||||
|
Delete All
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -29,6 +29,9 @@ export const APIContext = createContext<RqbitAPI>({
|
||||||
delete: () => {
|
delete: () => {
|
||||||
throw new Error("Function not implemented.");
|
throw new Error("Function not implemented.");
|
||||||
},
|
},
|
||||||
|
deleteAll: () => {
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
},
|
||||||
getTorrentStreamUrl: () => {
|
getTorrentStreamUrl: () => {
|
||||||
throw new Error("Function not implemented.");
|
throw new Error("Function not implemented.");
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,13 @@ export const API: RqbitAPI & { getVersion: () => Promise<string> } = {
|
||||||
delete: (index: number): Promise<void> => {
|
delete: (index: number): Promise<void> => {
|
||||||
return makeRequest("POST", `/torrents/${index}/delete`);
|
return makeRequest("POST", `/torrents/${index}/delete`);
|
||||||
},
|
},
|
||||||
|
deleteAll: (removeFiles: boolean = false): Promise<void> => {
|
||||||
|
let url = "/torrents/delete_all";
|
||||||
|
if (removeFiles) {
|
||||||
|
url += "?remove_files=true";
|
||||||
|
}
|
||||||
|
return makeRequest("POST", url);
|
||||||
|
},
|
||||||
getVersion: async (): Promise<string> => {
|
getVersion: async (): Promise<string> => {
|
||||||
const r = await makeRequest("GET", "/");
|
const r = await makeRequest("GET", "/");
|
||||||
return r.version;
|
return r.version;
|
||||||
|
|
|
||||||
|
|
@ -560,6 +560,17 @@ async fn torrent_action_delete(
|
||||||
state.api()?.api_torrent_action_delete(id).await
|
state.api()?.api_torrent_action_delete(id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn torrents_delete_all(
|
||||||
|
state: tauri::State<'_, State>,
|
||||||
|
remove_files: bool,
|
||||||
|
) -> Result<EmptyJsonResponse, ApiError> {
|
||||||
|
state
|
||||||
|
.api()?
|
||||||
|
.api_torrents_delete_all(remove_files)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn torrent_action_pause(
|
async fn torrent_action_pause(
|
||||||
state: tauri::State<'_, State>,
|
state: tauri::State<'_, State>,
|
||||||
|
|
@ -692,6 +703,7 @@ async fn start() {
|
||||||
torrent_stats,
|
torrent_stats,
|
||||||
torrent_create_from_url,
|
torrent_create_from_url,
|
||||||
torrent_action_delete,
|
torrent_action_delete,
|
||||||
|
torrents_delete_all,
|
||||||
torrent_action_pause,
|
torrent_action_pause,
|
||||||
torrent_action_forget,
|
torrent_action_forget,
|
||||||
torrent_action_start,
|
torrent_action_start,
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,9 @@ export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => {
|
||||||
delete: function (id: number): Promise<void> {
|
delete: function (id: number): Promise<void> {
|
||||||
return invokeAPI<void>("torrent_action_delete", { id });
|
return invokeAPI<void>("torrent_action_delete", { id });
|
||||||
},
|
},
|
||||||
|
deleteAll: function (removeFiles: boolean = false): Promise<void> {
|
||||||
|
return invokeAPI<void>("torrents_delete_all", { removeFiles });
|
||||||
|
},
|
||||||
openOutputFolder: function (path: string): Promise<void> {
|
openOutputFolder: function (path: string): Promise<void> {
|
||||||
return invokeAPI<void>("open_output_folder", { path });
|
return invokeAPI<void>("open_output_folder", { path });
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue