From 99219f90bcc6e6e8b38d7703957f9a9bc9c9b08e Mon Sep 17 00:00:00 2001 From: Lionel DARNIS Date: Thu, 9 Jul 2026 14:18:06 +0200 Subject: [PATCH 1/5] feat(desktop): Yoda integration patches on v8.1.1 - macOS-style window controls via gtk-decoration-layout - torrent file preview and context menu improvements - single-instance relay for new torrents/magnet links --- crates/librqbit/webui/src/api-types.ts | 2 + .../librqbit/webui/src/components/Torrent.tsx | 42 ++- .../webui/src/components/TorrentRow.tsx | 1 + .../src/components/buttons/TorrentActions.tsx | 27 +- .../librqbit/webui/src/stores/torrentStore.ts | 13 + desktop/src-tauri/src/main.rs | 295 +++++++++++++++++- desktop/src/api.tsx | 3 + desktop/src/rqbit-desktop.tsx | 35 ++- 8 files changed, 405 insertions(+), 13 deletions(-) diff --git a/crates/librqbit/webui/src/api-types.ts b/crates/librqbit/webui/src/api-types.ts index 58436e7..6580d3e 100644 --- a/crates/librqbit/webui/src/api-types.ts +++ b/crates/librqbit/webui/src/api-types.ts @@ -23,6 +23,7 @@ export interface TorrentFileAttributes { export interface TorrentDetails { name: string | null; info_hash: string; + output_folder?: string; files: Array; } @@ -201,5 +202,6 @@ export interface RqbitAPI { start: (index: number) => Promise; forget: (index: number) => Promise; delete: (index: number) => Promise; + openOutputFolder?: (path: string) => Promise; stats: () => Promise; } diff --git a/crates/librqbit/webui/src/components/Torrent.tsx b/crates/librqbit/webui/src/components/Torrent.tsx index 380b05f..6ed4590 100644 --- a/crates/librqbit/webui/src/components/Torrent.tsx +++ b/crates/librqbit/webui/src/components/Torrent.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { TorrentDetails, TorrentId, @@ -9,6 +9,7 @@ import { import { APIContext, RefreshTorrentStatsContext } from "../context"; import { customSetInterval } from "../helper/customSetInterval"; import { loopUntilSuccess } from "../helper/loopUntilSuccess"; +import { useTorrentStore } from "../stores/torrentStore"; import { TorrentRow } from "./TorrentRow"; export const Torrent: React.FC<{ @@ -21,7 +22,13 @@ export const Torrent: React.FC<{ null, ); const [forceStatsRefresh, setForceStatsRefresh] = useState(0); + const rowRef = useRef(null); const API = useContext(APIContext); + const focusedTorrentId = useTorrentStore((state) => state.focusedTorrentId); + const clearFocusedTorrent = useTorrentStore( + (state) => state.clearFocusedTorrent, + ); + const isFocusedTorrent = focusedTorrentId === id; const forceStatsRefreshCallback = () => { setForceStatsRefresh(forceStatsRefresh + 1); @@ -65,15 +72,38 @@ export const Torrent: React.FC<{ [forceStatsRefresh], ); + useEffect(() => { + if (!isFocusedTorrent) { + return; + } + + rowRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + + const timeout = setTimeout(() => clearFocusedTorrent(id), 3000); + return () => clearTimeout(timeout); + }, [clearFocusedTorrent, id, isFocusedTorrent]); + return ( - +
+ +
); }; diff --git a/crates/librqbit/webui/src/components/TorrentRow.tsx b/crates/librqbit/webui/src/components/TorrentRow.tsx index c7f3e3e..c7815b7 100644 --- a/crates/librqbit/webui/src/components/TorrentRow.tsx +++ b/crates/librqbit/webui/src/components/TorrentRow.tsx @@ -159,6 +159,7 @@ export const TorrentRow: React.FC<{ diff --git a/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx b/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx index cda77d8..749535e 100644 --- a/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx +++ b/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx @@ -5,6 +5,7 @@ import { IconButton } from "./IconButton"; import { DeleteTorrentModal } from "../modal/DeleteTorrentModal"; import { FaCog, + FaFolderOpen, FaPause, FaPlay, FaTrash, @@ -16,24 +17,26 @@ import { ErrorComponent } from "../ErrorComponent"; export const TorrentActions: React.FC<{ id: number; statsResponse: TorrentStats; + outputFolder?: string; extendedView: boolean; setExtendedView: (extendedView: boolean) => void; -}> = ({ id, statsResponse, extendedView, setExtendedView }) => { +}> = ({ id, statsResponse, outputFolder, extendedView, setExtendedView }) => { let state = statsResponse.state; let [disabled, setDisabled] = useState(false); let [deleting, setDeleting] = useState(false); let refreshCtx = useContext(RefreshTorrentStatsContext); + const API = useContext(APIContext); const canPause = state == "live"; const canUnpause = state == "paused" || state == "error"; const canConfigure = state == "paused" || state == "live"; + const canOpenOutputFolder = + statsResponse.finished && !!outputFolder && !!API.openOutputFolder; const setCloseableError = useErrorStore((state) => state.setCloseableError); - const API = useContext(APIContext); - const unpause = () => { setDisabled(true); API.start(id) @@ -116,6 +119,19 @@ export const TorrentActions: React.FC<{ }); }; + const openOutputFolder = () => { + if (!API.openOutputFolder || !outputFolder) { + return; + } + + API.openOutputFolder(outputFolder).catch((e) => { + setCloseableError({ + text: `Error opening output folder for torrent id=${id}`, + details: e, + }); + }); + }; + return (
{canUnpause && ( @@ -136,6 +152,11 @@ export const TorrentActions: React.FC<{ )} + {canOpenOutputFolder && ( + + + + )} diff --git a/crates/librqbit/webui/src/stores/torrentStore.ts b/crates/librqbit/webui/src/stores/torrentStore.ts index 0fd9a71..faf2081 100644 --- a/crates/librqbit/webui/src/stores/torrentStore.ts +++ b/crates/librqbit/webui/src/stores/torrentStore.ts @@ -11,6 +11,10 @@ export interface TorrentStore { refreshTorrents: () => void; setRefreshTorrents: (callback: () => void) => void; + + focusedTorrentId: number | null; + focusTorrent: (id: number) => void; + clearFocusedTorrent: (id: number) => void; } const torrentIdEquals = (t1: TorrentId, t2: TorrentId): boolean => { @@ -51,4 +55,13 @@ export const useTorrentStore = create((set) => ({ }), refreshTorrents: () => {}, setRefreshTorrents: (callback) => set({ refreshTorrents: callback }), + focusedTorrentId: null, + focusTorrent: (id) => set({ focusedTorrentId: id }), + clearFocusedTorrent: (id) => + set((prev) => { + if (prev.focusedTorrentId !== id) { + return {}; + } + return { focusedTorrentId: null }; + }), })); diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index d8bd8ea..9488203 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -4,10 +4,14 @@ mod config; use std::{ + env, fs::{File, OpenOptions}, - io::{BufReader, BufWriter}, - path::Path, + io::{BufReader, BufWriter, Read, Write}, + os::unix::net::{UnixListener, UnixStream}, + path::{Path, PathBuf}, + process::Command, sync::Arc, + thread, }; use anyhow::Context; @@ -26,10 +30,17 @@ use librqbit::{ }; use parking_lot::RwLock; use serde::Serialize; +use tauri::{AppHandle, Emitter}; use tracing::{error, error_span, info, warn}; const ERR_NOT_CONFIGURED: ApiError = ApiError::new_from_text(StatusCode::FAILED_DEPENDENCY, "not configured"); +const TORRENTS_CHANGED_EVENT: &str = "rqbit-desktop-torrents-changed"; + +#[derive(Clone, Serialize)] +struct TorrentsChangedPayload { + id: usize, +} struct StateShared { config: config::RqbitDesktopConfig, @@ -39,6 +50,7 @@ struct StateShared { struct State { config_filename: String, shared: Arc>>, + pending_launch_inputs: Arc>>, init_logging: InitLoggingResult, } @@ -194,6 +206,7 @@ impl State { return Self { config_filename, shared, + pending_launch_inputs: Arc::new(RwLock::new(Vec::new())), init_logging, }; } @@ -202,6 +215,7 @@ impl State { config_filename, init_logging, shared: Arc::new(RwLock::new(None)), + pending_launch_inputs: Arc::new(RwLock::new(Vec::new())), } } @@ -244,6 +258,211 @@ impl State { } } +fn is_torrent_launch_input(input: &str) -> bool { + input.starts_with("magnet:") + || input.starts_with("http://") + || input.starts_with("https://") + || input.starts_with("file://") + || Path::new(input) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("torrent")) +} + +fn collect_launch_inputs() -> Vec { + env::args() + .skip(1) + .filter(|arg| is_torrent_launch_input(arg)) + .collect() +} + +fn desktop_ipc_socket_path() -> PathBuf { + let runtime_dir = env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(env::temp_dir); + let user = env::var("USER").unwrap_or_else(|_| "user".to_string()); + runtime_dir.join(format!("rqbit-desktop-{user}.sock")) +} + +fn forward_inputs_to_existing(socket_path: &Path, inputs: &[String]) -> anyhow::Result<()> { + let mut stream = UnixStream::connect(socket_path)?; + let payload = serde_json::to_vec(inputs)?; + stream.write_all(&payload)?; + Ok(()) +} + +fn bind_ipc_listener(socket_path: &Path) -> std::io::Result { + if UnixStream::connect(socket_path).is_err() { + let _ = std::fs::remove_file(socket_path); + } + + if let Some(parent) = socket_path.parent() { + std::fs::create_dir_all(parent)?; + } + + UnixListener::bind(socket_path) +} + +fn spawn_ipc_listener( + socket_path: PathBuf, + shared: Arc>>, + pending: Arc>>, + app_handle: AppHandle, +) { + let listener = match bind_ipc_listener(&socket_path) { + Ok(listener) => listener, + Err(err) => { + warn!(path=%socket_path.display(), error=%err, "couldn't bind desktop IPC socket"); + return; + } + }; + + let handle = tokio::runtime::Handle::current(); + + thread::spawn(move || { + for stream in listener.incoming() { + let shared = shared.clone(); + let pending = pending.clone(); + let app_handle = app_handle.clone(); + match stream { + Ok(mut stream) => { + let mut payload = String::new(); + if let Err(err) = stream.read_to_string(&mut payload) { + warn!(error=%err, "couldn't read desktop IPC payload"); + continue; + } + + let inputs = match serde_json::from_str::>(&payload) { + Ok(inputs) => inputs, + Err(err) => { + warn!(error=%err, "couldn't parse desktop IPC payload"); + continue; + } + }; + + handle.spawn(async move { + add_launch_inputs(shared, pending, inputs, Some(app_handle)).await; + }); + } + Err(err) => warn!(error=%err, "desktop IPC accept failed"), + } + } + }); +} + +async fn add_launch_inputs( + shared: Arc>>, + pending: Arc>>, + inputs: Vec, + app_handle: Option, +) { + for input in inputs { + match add_launch_input(shared.clone(), pending.clone(), &input).await { + Ok(Some(id)) => { + if let Some(app_handle) = &app_handle { + if let Err(err) = + app_handle.emit(TORRENTS_CHANGED_EVENT, TorrentsChangedPayload { id }) + { + warn!(input=%input, error=%err, "couldn't emit desktop torrent change event"); + } + } + } + Ok(None) => {} + Err(err) => { + warn!(input=%input, error=%err, "couldn't add torrent from desktop launch input"); + } + } + } +} + +async fn add_launch_input( + shared: Arc>>, + pending: Arc>>, + input: &str, +) -> anyhow::Result> { + let api = { + let g = shared.read(); + g.as_ref().and_then(|state| state.api.as_ref()).cloned() + }; + let Some(api) = api else { + pending.write().push(input.to_owned()); + info!(input=%input, "queued desktop launch input until rqbit is configured"); + return Ok(None); + }; + + let opts = AddTorrentOptions { + overwrite: true, + ..Default::default() + }; + + let torrent = if input.starts_with("magnet:") + || input.starts_with("http://") + || input.starts_with("https://") + { + AddTorrent::Url(input.to_string().into()) + } else { + let path = path_from_launch_input(input)?; + let bytes = std::fs::read(&path) + .with_context(|| format!("couldn't read torrent file {}", path.display()))?; + AddTorrent::TorrentFileBytes(bytes.into()) + }; + + api.api_add_torrent(torrent, Some(opts)) + .await + .map(|response| response.id) + .map_err(|err| anyhow::anyhow!("{err:?}")) +} + +async fn drain_pending_launch_inputs( + shared: Arc>>, + pending: Arc>>, + app_handle: Option, +) { + let inputs = { + let mut pending = pending.write(); + if pending.is_empty() { + return; + } + pending.drain(..).collect::>() + }; + + add_launch_inputs(shared, pending, inputs, app_handle).await; +} + +fn path_from_launch_input(input: &str) -> anyhow::Result { + if let Some(uri_path) = input.strip_prefix("file://localhost/") { + return Ok(PathBuf::from(format!("/{}", percent_decode(uri_path)?))); + } + + if let Some(uri_path) = input.strip_prefix("file://") { + return Ok(PathBuf::from(percent_decode(uri_path)?)); + } + + Ok(PathBuf::from(input)) +} + +fn percent_decode(value: &str) -> anyhow::Result { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + + while index < bytes.len() { + if bytes[index] == b'%' { + let Some(hex) = bytes.get(index + 1..index + 3) else { + anyhow::bail!("invalid percent-encoded path"); + }; + let hex = std::str::from_utf8(hex).context("invalid percent-encoded path")?; + decoded.push(u8::from_str_radix(hex, 16).context("invalid percent-encoded path")?); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + + String::from_utf8(decoded).context("invalid UTF-8 path") +} + #[derive(Default, Serialize)] struct CurrentState { config: Option, @@ -269,10 +488,18 @@ fn config_current(state: tauri::State<'_, State>) -> CurrentState { #[tauri::command] async fn config_change( + app_handle: AppHandle, state: tauri::State<'_, State>, config: RqbitDesktopConfig, ) -> Result { - state.configure(config).await.map(|_| EmptyJsonResponse {}) + state.configure(config).await?; + drain_pending_launch_inputs( + state.shared.clone(), + state.pending_launch_inputs.clone(), + Some(app_handle), + ) + .await; + Ok(EmptyJsonResponse {}) } #[tauri::command] @@ -374,6 +601,37 @@ async fn stats(state: tauri::State<'_, State>) -> Result Result { + let path = PathBuf::from(path); + let metadata = std::fs::metadata(&path) + .with_context(|| format!("couldn't access output folder {}", path.display())) + .map_err(|e| ApiError::new_from_anyhow(StatusCode::BAD_REQUEST, e))?; + + if !metadata.is_dir() { + return Err(ApiError::new_from_anyhow( + StatusCode::BAD_REQUEST, + anyhow::anyhow!("output path is not a folder: {}", path.display()), + )); + } + + let status = Command::new("xdg-open") + .arg(&path) + .status() + .or_else(|_| Command::new("gio").arg("open").arg(&path).status()) + .with_context(|| format!("couldn't open output folder {}", path.display())) + .map_err(|e| ApiError::new_from_anyhow(StatusCode::INTERNAL_SERVER_ERROR, e))?; + + if !status.success() { + return Err(ApiError::new_from_anyhow( + StatusCode::INTERNAL_SERVER_ERROR, + anyhow::anyhow!("file manager exited with status {status}"), + )); + } + + Ok(EmptyJsonResponse {}) +} + #[tauri::command] fn get_version() -> &'static str { env!("CARGO_PKG_VERSION") @@ -393,11 +651,41 @@ async fn start() { Err(e) => warn!("failed increasing open file limit: {:#}", e), }; + let launch_inputs = collect_launch_inputs(); + let socket_path = desktop_ipc_socket_path(); + + if !launch_inputs.is_empty() && forward_inputs_to_existing(&socket_path, &launch_inputs).is_ok() + { + return; + } + let state = State::new(init_logging_result).await; + let shared = state.shared.clone(); + let pending = state.pending_launch_inputs.clone(); tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .manage(state) + .setup(move |app| { + let app_handle = app.handle().clone(); + spawn_ipc_listener( + socket_path.clone(), + shared.clone(), + pending.clone(), + app_handle.clone(), + ); + + if !launch_inputs.is_empty() { + tauri::async_runtime::spawn(add_launch_inputs( + shared.clone(), + pending.clone(), + launch_inputs.clone(), + Some(app_handle), + )); + } + + Ok(()) + }) .invoke_handler(tauri::generate_handler![ torrents_list, torrent_details, @@ -409,6 +697,7 @@ async fn start() { torrent_action_start, torrent_action_configure, torrent_create_from_base64_file, + open_output_folder, stats, get_version, config_default, diff --git a/desktop/src/api.tsx b/desktop/src/api.tsx index e92a8aa..7f1d97f 100644 --- a/desktop/src/api.tsx +++ b/desktop/src/api.tsx @@ -133,6 +133,9 @@ export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => { delete: function (id: number): Promise { return invokeAPI("torrent_action_delete", { id }); }, + openOutputFolder: function (path: string): Promise { + return invokeAPI("open_output_folder", { path }); + }, getTorrentStreamUrl: () => { return ""; }, diff --git a/desktop/src/rqbit-desktop.tsx b/desktop/src/rqbit-desktop.tsx index e22ff35..e823cd0 100644 --- a/desktop/src/rqbit-desktop.tsx +++ b/desktop/src/rqbit-desktop.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; import { RqbitWebUI } from "rqbit-webui/src/rqbit-web"; import { CurrentDesktopState, RqbitDesktopConfig } from "./configuration"; import { ConfigModal } from "./configure"; @@ -6,6 +7,13 @@ import { IconButton } from "rqbit-webui/src/components/buttons/IconButton"; import { BsSliders2 } from "react-icons/bs"; import { APIContext } from "rqbit-webui/src/context"; import { makeAPI } from "./api"; +import { useTorrentStore } from "rqbit-webui/src/stores/torrentStore"; + +const TORRENTS_CHANGED_EVENT = "rqbit-desktop-torrents-changed"; + +type TorrentsChangedPayload = { + id?: number; +}; export const RqbitDesktop: React.FC<{ version: string; @@ -18,6 +26,31 @@ export const RqbitDesktop: React.FC<{ ); let [configurationOpened, setConfigurationOpened] = useState(false); + useEffect(() => { + if (!configured) { + return; + } + + let cancelled = false; + const unlisten = listen(TORRENTS_CHANGED_EVENT, (event) => { + if (event.payload.id != null) { + useTorrentStore.getState().focusTorrent(event.payload.id); + } + + setTimeout(() => { + if (cancelled) { + return; + } + useTorrentStore.getState().refreshTorrents(); + }, 250); + }); + + return () => { + cancelled = true; + unlisten.then((cleanup) => cleanup()); + }; + }, [configured]); + const configButton = ( { From 5d68aaf35220fecde955a363b420a2691519ce05 Mon Sep 17 00:00:00 2001 From: Lionel DARNIS Date: Fri, 7 Aug 2026 17:29:33 +0200 Subject: [PATCH 2/5] 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) --- Cargo.lock | 60 +++++++++++++ crates/librqbit/src/api.rs | 42 +++++++++ crates/librqbit/webui/src/api-types.ts | 1 + .../webui/src/components/RootContent.tsx | 16 ++++ .../modal/DeleteAllTorrentsModal.tsx | 89 +++++++++++++++++++ crates/librqbit/webui/src/context.tsx | 3 + crates/librqbit/webui/src/http-api.ts | 7 ++ desktop/src-tauri/src/main.rs | 12 +++ desktop/src/api.tsx | 3 + 9 files changed, 233 insertions(+) create mode 100644 crates/librqbit/webui/src/components/modal/DeleteAllTorrentsModal.tsx diff --git a/Cargo.lock b/Cargo.lock index 226024a..65aa04d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7409,3 +7409,63 @@ dependencies = [ "quote", "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" diff --git a/crates/librqbit/src/api.rs b/crates/librqbit/src/api.rs index cef6b72..1ef76d2 100644 --- a/crates/librqbit/src/api.rs +++ b/crates/librqbit/src/api.rs @@ -325,6 +325,48 @@ impl Api { Ok(Default::default()) } + /// Delete all torrents, optionally keeping the downloaded data. + pub async fn api_torrents_delete_all( + &self, + remove_files: bool, + ) -> Result { + let ids: Vec = { + 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 = 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( &self, idx: TorrentIdOrHash, diff --git a/crates/librqbit/webui/src/api-types.ts b/crates/librqbit/webui/src/api-types.ts index 6580d3e..e7ee454 100644 --- a/crates/librqbit/webui/src/api-types.ts +++ b/crates/librqbit/webui/src/api-types.ts @@ -202,6 +202,7 @@ export interface RqbitAPI { start: (index: number) => Promise; forget: (index: number) => Promise; delete: (index: number) => Promise; + deleteAll: (removeFiles?: boolean) => Promise; openOutputFolder?: (path: string) => Promise; stats: () => Promise; } diff --git a/crates/librqbit/webui/src/components/RootContent.tsx b/crates/librqbit/webui/src/components/RootContent.tsx index f1387e4..f232916 100644 --- a/crates/librqbit/webui/src/components/RootContent.tsx +++ b/crates/librqbit/webui/src/components/RootContent.tsx @@ -1,7 +1,11 @@ +import { useState } from "react"; import { TorrentsList } from "./TorrentsList"; import { ErrorComponent } from "./ErrorComponent"; import { useTorrentStore } from "../stores/torrentStore"; 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: {}) => { let closeableError = useErrorStore((state) => state.closeableError); @@ -11,6 +15,7 @@ export const RootContent = (props: {}) => { let torrentsInitiallyLoading = useTorrentStore( (state) => state.torrentsInitiallyLoading ); + const [deleteAllOpened, setDeleteAllOpened] = useState(false); return (
@@ -19,7 +24,18 @@ export const RootContent = (props: {}) => { remove={() => setCloseableError(null)} /> + {torrents && torrents.length > 0 && ( +
+ setDeleteAllOpened(true)}> + + +
+ )} + setDeleteAllOpened(false)} + />
); }; diff --git a/crates/librqbit/webui/src/components/modal/DeleteAllTorrentsModal.tsx b/crates/librqbit/webui/src/components/modal/DeleteAllTorrentsModal.tsx new file mode 100644 index 0000000..824a352 --- /dev/null +++ b/crates/librqbit/webui/src/components/modal/DeleteAllTorrentsModal.tsx @@ -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(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 ( + + +

+ Are you sure you want to delete all torrents? This + action cannot be undone. +

+ +
+ setDeleteFiles(!deleteFiles)} + checked={deleteFiles} + placeholder="Also delete files" + /> + +
+ + {error && } +
+ + + {deleting && } + + + +
+ ); +}; diff --git a/crates/librqbit/webui/src/context.tsx b/crates/librqbit/webui/src/context.tsx index 7bf10b8..ab7646a 100644 --- a/crates/librqbit/webui/src/context.tsx +++ b/crates/librqbit/webui/src/context.tsx @@ -29,6 +29,9 @@ export const APIContext = createContext({ delete: () => { throw new Error("Function not implemented."); }, + deleteAll: () => { + throw new Error("Function not implemented."); + }, getTorrentStreamUrl: () => { throw new Error("Function not implemented."); }, diff --git a/crates/librqbit/webui/src/http-api.ts b/crates/librqbit/webui/src/http-api.ts index 2e00df3..9094c1d 100644 --- a/crates/librqbit/webui/src/http-api.ts +++ b/crates/librqbit/webui/src/http-api.ts @@ -151,6 +151,13 @@ export const API: RqbitAPI & { getVersion: () => Promise } = { delete: (index: number): Promise => { return makeRequest("POST", `/torrents/${index}/delete`); }, + deleteAll: (removeFiles: boolean = false): Promise => { + let url = "/torrents/delete_all"; + if (removeFiles) { + url += "?remove_files=true"; + } + return makeRequest("POST", url); + }, getVersion: async (): Promise => { const r = await makeRequest("GET", "/"); return r.version; diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 9488203..4380ebc 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -560,6 +560,17 @@ async fn torrent_action_delete( state.api()?.api_torrent_action_delete(id).await } +#[tauri::command] +async fn torrents_delete_all( + state: tauri::State<'_, State>, + remove_files: bool, +) -> Result { + state + .api()? + .api_torrents_delete_all(remove_files) + .await +} + #[tauri::command] async fn torrent_action_pause( state: tauri::State<'_, State>, @@ -692,6 +703,7 @@ async fn start() { torrent_stats, torrent_create_from_url, torrent_action_delete, + torrents_delete_all, torrent_action_pause, torrent_action_forget, torrent_action_start, diff --git a/desktop/src/api.tsx b/desktop/src/api.tsx index 7f1d97f..ae2a468 100644 --- a/desktop/src/api.tsx +++ b/desktop/src/api.tsx @@ -133,6 +133,9 @@ export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => { delete: function (id: number): Promise { return invokeAPI("torrent_action_delete", { id }); }, + deleteAll: function (removeFiles: boolean = false): Promise { + return invokeAPI("torrents_delete_all", { removeFiles }); + }, openOutputFolder: function (path: string): Promise { return invokeAPI("open_output_folder", { path }); }, From fbd052bc0474097c43df0554d59148e93b936f13 Mon Sep 17 00:00:00 2001 From: Lionel DARNIS Date: Fri, 7 Aug 2026 19:25:48 +0200 Subject: [PATCH 3/5] feat(http-api): cabler la route POST /torrents/delete_all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le commit precedent ajoutait api_torrents_delete_all et la commande Tauri, mais aucune route HTTP ne l'exposait : la WebUI servie en mode navigateur appelait POST /torrents/delete_all et recevait un 404. Seul le chemin desktop, qui passe par invokeAPI, fonctionnait. Ajoute h_torrents_delete_all, avec remove_files lu en query param et defaut a false, puis enregistre la route dans le bloc !read_only aux cotes de /torrents/{id}/delete. Segment statique, donc prioritaire sur /torrents/{id} — meme schema que /torrents/resolve_magnet. Co-Authored-By: Claude Opus 5 (1M context) --- crates/librqbit/src/http_api/handlers/mod.rs | 3 +++ .../src/http_api/handlers/torrents.rs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/librqbit/src/http_api/handlers/mod.rs b/crates/librqbit/src/http_api/handlers/mod.rs index ff3b43d..a87d725 100644 --- a/crates/librqbit/src/http_api/handlers/mod.rs +++ b/crates/librqbit/src/http_api/handlers/mod.rs @@ -112,6 +112,9 @@ pub fn make_api_router(state: ApiState) -> Router { "/torrents/{id}/delete", post(torrents::h_torrent_action_delete), ) + // Segment statique, donc prioritaire sur /torrents/{id} — meme + // schema que /torrents/resolve_magnet juste au-dessus. + .route("/torrents/delete_all", post(torrents::h_torrents_delete_all)) .route( "/torrents/{id}/update_only_files", post(torrents::h_torrent_action_update_only_files), diff --git a/crates/librqbit/src/http_api/handlers/torrents.rs b/crates/librqbit/src/http_api/handlers/torrents.rs index 75ffb09..6e3c2f5 100644 --- a/crates/librqbit/src/http_api/handlers/torrents.rs +++ b/crates/librqbit/src/http_api/handlers/torrents.rs @@ -146,6 +146,25 @@ pub async fn h_torrent_action_delete( .map(axum::Json) } +#[derive(Deserialize, Default)] +pub struct DeleteAllQueryParams { + /// Supprime aussi les donnees telechargees. Absent = false, on ne garde + /// que la suppression des torrents. + #[serde(default)] + pub remove_files: bool, +} + +pub async fn h_torrents_delete_all( + State(state): State, + Query(params): Query, +) -> Result { + state + .api + .api_torrents_delete_all(params.remove_files) + .await + .map(axum::Json) +} + #[derive(Deserialize)] pub struct UpdateOnlyFilesRequest { only_files: Vec, From 6ea975935f9b81535d27ca1848027459e1bf2a67 Mon Sep 17 00:00:00 2001 From: Lionel DARNIS Date: Fri, 7 Aug 2026 19:32:49 +0200 Subject: [PATCH 4/5] perf(rqbit): mimalloc comme allocateur global MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declare mimalloc en #[global_allocator] sur le binaire rqbit. L'alloca- teur systeme se comporte mal sur la charge de rqbit, faite de tres nombreuses petites allocations concurrentes sur les buffers de pieces. default-features = false pour ne pas embarquer les variantes secure et les stats de debug, inutiles ici. Ce patch tournait deja dans les paquets installes sans exister dans git, comme la feature « delete all ». Repere par le check hygiene. Co-Authored-By: Claude Opus 5 (1M context) --- crates/rqbit/Cargo.toml | 1 + crates/rqbit/src/main.rs | 3 +++ 2 files changed, 4 insertions(+) diff --git a/crates/rqbit/Cargo.toml b/crates/rqbit/Cargo.toml index 6e651da..4e49a7d 100644 --- a/crates/rqbit/Cargo.toml +++ b/crates/rqbit/Cargo.toml @@ -24,6 +24,7 @@ postgres = ["librqbit/postgres"] disable-upload = ["librqbit/disable-upload"] [dependencies] +mimalloc = { version = "0.1", default-features = false } librqbit = { version = "8.1.1", path = "../librqbit", default-features = false, features = [ "http-api", "http-api-client", diff --git a/crates/rqbit/src/main.rs b/crates/rqbit/src/main.rs index dde6707..d41211a 100644 --- a/crates/rqbit/src/main.rs +++ b/crates/rqbit/src/main.rs @@ -922,3 +922,6 @@ mod tests { } } } + +#[global_allocator] +static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc; From f703bb15b785a76b1c5a96bacd5312f6be618969 Mon Sep 17 00:00:00 2001 From: Lionel DARNIS Date: Fri, 4 Sep 2026 10:48:53 +0200 Subject: [PATCH 5/5] chore: fige Cargo.lock (mimalloc) --- Cargo.lock | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 65aa04d..32cbb3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2781,6 +2781,15 @@ dependencies = [ "windows-targets 0.53.0", ] +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", +] + [[package]] name = "libredox" version = "0.1.3" @@ -3177,6 +3186,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "mime" version = "0.3.17" @@ -4618,6 +4636,7 @@ dependencies = [ "libc", "librqbit", "librqbit-upnp-serve", + "mimalloc", "openssl", "parking_lot", "parse_duration",