diff --git a/CLAUDE.md b/CLAUDE.md index 5e6fcaf..bcd3340 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,18 @@ BitTorrent client written in Rust with a web UI. (Service Access table): `GIT_AUTH_TOKEN`/`FORGEJO_TOKEN`/`GITHUB_PAT` live in the `cicd` project, env `prod`. Never print token values. +## Coding conventions + +- **Do not reference the upstream qBittorrent project by name in code.** The + WebUI API v2 compatibility layer must not spell the brand name in Rust + identifiers, struct/field names, log messages, or response strings. Use the + neutral `qbit` abbreviation (already the established prefix, e.g. `qbit_compat`, + `QbitTorrentInfo`) or phrasings like "WebUI API v2 compat". Protocol-level + constants the layer must emit for wire compatibility (state strings such as + `stoppedDL`, the advertised `webapiVersion`, endpoint paths) are not brand + references and are fine. Doc comments may mention the upstream project where + needed to explain a compatibility decision, but keep it out of code proper. + ## Codesight Auto-generated codebase context map: `.codesight/CODESIGHT.md` — routes, schema, components, dependencies, and hot files. Regenerate with `npx codesight`. diff --git a/Cargo.lock b/Cargo.lock index e0fc8fe..035af18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6040,6 +6040,7 @@ dependencies = [ "tokio-stream", "tokio-test", "tokio-util", + "tower", "tower-http", "tracing", "tracing-subscriber", diff --git a/crates/librtbit/Cargo.toml b/crates/librtbit/Cargo.toml index d4ad095..652a5e2 100644 --- a/crates/librtbit/Cargo.toml +++ b/crates/librtbit/Cargo.toml @@ -142,6 +142,7 @@ windows = { version = "0.62", features = ["Win32", "Win32_System", "Win32_System anyhow = "1" [dev-dependencies] +tower = { version = "0.5", features = ["util"] } tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "std"] } tokio-test = "0.4" tempfile = "3" diff --git a/crates/librtbit/src/api.rs b/crates/librtbit/src/api.rs index bf00ee9..6a6aa88 100644 --- a/crates/librtbit/src/api.rs +++ b/crates/librtbit/src/api.rs @@ -243,12 +243,7 @@ impl Api { id: Some(id), info_hash: mgr.shared().info_hash.as_string(), name: mgr.name(), - output_folder: mgr - .shared() - .options - .output_folder - .to_string_lossy() - .into_owned(), + output_folder: mgr.output_folder().to_string_lossy().into_owned(), total_pieces, // These will be filled in /details and /stats endpoints @@ -283,13 +278,7 @@ impl Api { let info_hash = handle.shared().info_hash; let only_files = handle.only_files(); let category = handle.shared().category.read().clone(); - let output_folder = handle - .shared() - .options - .output_folder - .to_string_lossy() - .into_owned() - .to_string(); + let output_folder = handle.output_folder().to_string_lossy().into_owned(); make_torrent_details( Some(handle.id()), &info_hash, diff --git a/crates/librtbit/src/http_api/handlers/mod.rs b/crates/librtbit/src/http_api/handlers/mod.rs index 6bd407e..6a9f603 100644 --- a/crates/librtbit/src/http_api/handlers/mod.rs +++ b/crates/librtbit/src/http_api/handlers/mod.rs @@ -5,6 +5,8 @@ pub(crate) mod logging; pub(crate) mod other; pub(crate) mod playlist; pub(crate) mod qbit_compat; +#[cfg(test)] +mod qbit_parity; pub(crate) mod rss; pub(crate) mod speed; pub(crate) mod streaming; diff --git a/crates/librtbit/src/http_api/handlers/qbit_compat.rs b/crates/librtbit/src/http_api/handlers/qbit_compat.rs index a2482a9..3a63183 100644 --- a/crates/librtbit/src/http_api/handlers/qbit_compat.rs +++ b/crates/librtbit/src/http_api/handlers/qbit_compat.rs @@ -5,9 +5,10 @@ //! pretending to be qBittorrent. use std::{ - collections::HashMap, - num::NonZeroU16, + collections::{BTreeSet, HashMap, HashSet}, + num::{NonZeroU16, NonZeroU32}, path::Path, + path::PathBuf, sync::Arc, time::{Instant, SystemTime, UNIX_EPOCH}, }; @@ -25,8 +26,11 @@ use serde::{Deserialize, Serialize}; use tracing::warn; use crate::{ - AddTorrent, AddTorrentOptions, + AddTorrent, AddTorrentOptions, CreateTorrentOptions, api::{Api, TorrentIdOrHash}, + create_torrent, + limits::LimitsConfig, + spawn_utils::BlockingSpawner, torrent_state::stats::TorrentStatsState, }; @@ -71,6 +75,212 @@ impl QbitSessions { } } +/// In-memory tag store for the compat layer. rtbit has no native tag concept, +/// so tags live here (a global set plus a per-torrent, info-hash-keyed set) and +/// are not persisted across restarts. +#[derive(Default)] +struct QbitTags { + all: RwLock>, + per_torrent: RwLock>>, +} + +impl QbitTags { + fn all_tags(&self) -> Vec { + self.all.read().iter().cloned().collect() + } + + fn create(&self, tags: &[String]) { + let mut all = self.all.write(); + all.extend(tags.iter().cloned()); + } + + fn delete(&self, tags: &[String]) { + let mut all = self.all.write(); + let mut per = self.per_torrent.write(); + for tag in tags { + all.remove(tag); + } + for set in per.values_mut() { + for tag in tags { + set.remove(tag); + } + } + } + + fn add_to(&self, hashes: &[String], tags: &[String]) { + if tags.is_empty() { + return; + } + self.create(tags); + let mut per = self.per_torrent.write(); + for hash in hashes { + per.entry(hash.clone()) + .or_default() + .extend(tags.iter().cloned()); + } + } + + fn remove_from(&self, hashes: &[String], tags: &[String]) { + let mut per = self.per_torrent.write(); + for hash in hashes { + if let Some(set) = per.get_mut(hash) { + // An empty tag list clears every tag, matching qBittorrent. + if tags.is_empty() { + set.clear(); + } else { + for tag in tags { + set.remove(tag); + } + } + } + } + } + + fn set(&self, hashes: &[String], tags: &[String]) { + self.create(tags); + let mut per = self.per_torrent.write(); + for hash in hashes { + per.insert(hash.clone(), tags.iter().cloned().collect()); + } + } + + fn tags_for(&self, hash: &str) -> String { + self.per_torrent + .read() + .get(hash) + .map(|set| set.iter().cloned().collect::>().join(", ")) + .unwrap_or_default() + } + + fn has_tag(&self, hash: &str, tag: &str) -> bool { + self.per_torrent + .read() + .get(hash) + .is_some_and(|set| set.contains(tag)) + } +} + +// --------------------------------------------------------------------------- +// Torrent-creator task store (bridges the native create_torrent) +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Serialize)] +enum CreatorStatus { + Running, + Finished, + Failed, +} + +#[derive(Clone)] +struct CreatorTask { + status: CreatorStatus, + source_path: String, + format: String, + error: Option, + torrent_bytes: Option, + info_hash: Option, +} + +/// In-memory registry of torrent-creation tasks. rtbit creates torrents +/// synchronously via `create_torrent`; each qBittorrent "task" runs that on a +/// spawned future and records the result here. Not persisted across restarts. +#[derive(Default)] +struct QbitTorrentCreator { + tasks: RwLock>, +} + +impl QbitTorrentCreator { + fn new_task(&self, source_path: String, format: String) -> String { + let id: String = (0..16) + .map(|_| format!("{:02x}", rand::random::())) + .collect(); + self.tasks.write().insert( + id.clone(), + CreatorTask { + status: CreatorStatus::Running, + source_path, + format, + error: None, + torrent_bytes: None, + info_hash: None, + }, + ); + id + } + + fn finish(&self, id: &str, torrent_bytes: Option, info_hash: String) { + if let Some(task) = self.tasks.write().get_mut(id) { + task.status = CreatorStatus::Finished; + task.torrent_bytes = torrent_bytes; + task.info_hash = Some(info_hash); + } + } + + fn fail(&self, id: &str, error: String) { + if let Some(task) = self.tasks.write().get_mut(id) { + task.status = CreatorStatus::Failed; + task.error = Some(error); + } + } + + fn get(&self, id: &str) -> Option { + self.tasks.read().get(id).cloned() + } + + fn all(&self) -> Vec<(String, CreatorTask)> { + self.tasks + .read() + .iter() + .map(|(id, task)| (id.clone(), task.clone())) + .collect() + } + + fn remove(&self, id: &str) -> bool { + self.tasks.write().remove(id).is_some() + } +} + +/// In-memory per-torrent share limits (max ratio + max seeding minutes). rtbit +/// does not auto-enforce these; they are stored so the values round-trip and are +/// surfaced in `torrents/info` for clients that enforce removal themselves +/// (e.g. Sonarr/Radarr). Not persisted across restarts. +#[derive(Default)] +struct QbitShareLimits { + limits: RwLock>, +} + +impl QbitShareLimits { + fn set(&self, hashes: &[String], ratio_limit: f64, seeding_time_limit: i64) { + let mut limits = self.limits.write(); + for hash in hashes { + limits.insert(hash.clone(), (ratio_limit, seeding_time_limit)); + } + } + + fn get(&self, hash: &str) -> Option<(f64, i64)> { + self.limits.read().get(hash).copied() + } +} + +/// Overlay stored share limits onto a torrent info view (reporting only). +fn apply_share_limits(info: &mut QbitTorrentInfo, store: &QbitShareLimits) { + if let Some((ratio_limit, seeding_time_limit)) = store.get(&info.hash) { + info.ratio_limit = ratio_limit; + info.max_ratio = ratio_limit; + let minutes = i32::try_from(seeding_time_limit).unwrap_or(-1); + info.seeding_time_limit = minutes; + info.max_seeding_time = minutes; + } +} + +fn parse_tags(raw: &str) -> Vec { + raw.split(',') + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(str::to_owned) + .collect() +} + #[derive(Clone, Serialize)] struct QbitCategory { name: String, @@ -82,6 +292,9 @@ struct QbitCategory { pub(crate) struct QbitState { api_state: ApiState, sessions: QbitSessions, + tags: QbitTags, + creator: QbitTorrentCreator, + share_limits: QbitShareLimits, } // --------------------------------------------------------------------------- @@ -111,7 +324,7 @@ struct QbitTorrentInfo { infohash_v2: String, last_activity: u64, magnet_uri: String, - max_ratio: i32, + max_ratio: f64, max_seeding_time: i32, name: String, num_complete: u32, @@ -121,7 +334,7 @@ struct QbitTorrentInfo { priority: u32, progress: f64, ratio: f64, - ratio_limit: i32, + ratio_limit: f64, save_path: String, seeding_time: u64, seeding_time_limit: i32, @@ -191,12 +404,19 @@ struct QbitFileInfo { } fn qbit_save_path(handle: &crate::torrent_state::ManagedTorrentHandle) -> String { - handle - .shared() + let shared = handle.shared(); + // After a relocation the override diverges from the add-time output_folder; + // report the current root. Otherwise keep the qbit-savepath (output_folder_root) + // behaviour used for multi-file name prefixing. + let current_root = shared.output_folder_override.read(); + if *current_root != shared.options.output_folder { + return current_root.to_string_lossy().into_owned(); + } + shared .options .output_folder_root .as_ref() - .unwrap_or(&handle.shared().options.output_folder) + .unwrap_or(&shared.options.output_folder) .to_string_lossy() .into_owned() } @@ -343,6 +563,10 @@ async fn h_app_webapi_version() -> &'static str { "2.11.3" } +async fn h_app_default_save_path(State(state): State>) -> String { + state.api_state.api.api_output_folder() +} + async fn h_app_build_info() -> impl IntoResponse { axum::Json(QbitBuildInfo { qt: "N/A", @@ -417,18 +641,124 @@ async fn h_app_set_preferences( async fn h_transfer_info(State(state): State>) -> impl IntoResponse { let session_stats = state.api_state.api.api_session_stats(); + let config = state.api_state.api.session().ratelimits.get_config(); axum::Json(QbitTransferInfo { dl_info_speed: session_stats.download_speed.as_bytes(), dl_info_data: session_stats.counters.fetched_bytes, up_info_speed: session_stats.upload_speed.as_bytes(), up_info_data: session_stats.counters.uploaded_bytes, - dl_rate_limit: 0, - up_rate_limit: 0, + dl_rate_limit: bps_to_u64(config.download_bps), + up_rate_limit: bps_to_u64(config.upload_bps), dht_nodes: 0, connection_status: "connected", }) } +fn bps_to_u64(bps: Option) -> u64 { + bps.map_or(0, |v| u64::from(v.get())) +} + +/// Parse a qBittorrent byte-rate limit (0 means unlimited). +fn limit_to_bps(limit: u64) -> Option { + NonZeroU32::new(u32::try_from(limit).unwrap_or(u32::MAX)) +} + +#[derive(Deserialize, Default)] +struct LimitForm { + #[serde(default)] + limit: u64, +} + +#[derive(Deserialize, Default)] +struct ModeForm { + #[serde(default)] + mode: u8, +} + +async fn h_transfer_download_limit(State(state): State>) -> impl IntoResponse { + let config = state.api_state.api.session().ratelimits.get_config(); + axum::Json(bps_to_u64(config.download_bps)) +} + +async fn h_transfer_upload_limit(State(state): State>) -> impl IntoResponse { + let config = state.api_state.api.session().ratelimits.get_config(); + axum::Json(bps_to_u64(config.upload_bps)) +} + +async fn h_transfer_set_download_limit( + State(state): State>, + body: Bytes, +) -> &'static str { + let form: LimitForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let session = state.api_state.api.session(); + let upload = session.ratelimits.get_config().upload_bps; + session.set_normal_rate_limits(limit_to_bps(form.limit), upload); + "Ok." +} + +async fn h_transfer_set_upload_limit( + State(state): State>, + body: Bytes, +) -> &'static str { + let form: LimitForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let session = state.api_state.api.session(); + let download = session.ratelimits.get_config().download_bps; + session.set_normal_rate_limits(download, limit_to_bps(form.limit)); + "Ok." +} + +/// qBittorrent alternative-speed mode maps onto our alt-speed toggle: `1` when +/// alternative limits are active, `0` otherwise. +async fn h_transfer_speed_limits_mode(State(state): State>) -> &'static str { + if state.api_state.api.session().alt_speed_enabled() { + "1" + } else { + "0" + } +} + +async fn h_transfer_toggle_speed_limits_mode(State(state): State>) -> &'static str { + let session = state.api_state.api.session(); + session.set_alt_speed_enabled(!session.alt_speed_enabled()); + "Ok." +} + +async fn h_transfer_set_speed_limits_mode( + State(state): State>, + body: Bytes, +) -> &'static str { + let form: ModeForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + state + .api_state + .api + .session() + .set_alt_speed_enabled(form.mode != 0); + "Ok." +} + +/// `transfer/pauseSession` — qBittorrent pauses the whole session; we have no +/// global pause, so we pause every torrent. +async fn h_transfer_pause_session(State(state): State>) -> &'static str { + let api = &state.api_state.api; + for idx in resolve_hashes(api, "all") { + if let Err(error) = api.api_torrent_action_pause(idx).await { + warn!(%error, "qbit compat: error pausing session torrent"); + } + } + "Ok." +} + +/// `transfer/resumeSession` — resume every torrent (see pauseSession). +async fn h_transfer_resume_session(State(state): State>) -> &'static str { + let api = &state.api_state.api; + for idx in resolve_hashes(api, "all") { + if let Err(error) = api.api_torrent_action_start(idx).await { + warn!(%error, "qbit compat: error resuming session torrent"); + } + } + "Ok." +} + // --------------------------------------------------------------------------- // Torrent management endpoints // --------------------------------------------------------------------------- @@ -441,13 +771,17 @@ fn now_unix() -> u64 { } /// Map rtbit torrent state to qBittorrent state string. +/// +/// We advertise WebAPI 2.11.3, so we emit the post-2.11 `stoppedDL`/`stoppedUP` +/// names (renamed from `pausedDL`/`pausedUP` in 2.11); clients that switch on +/// the advertised version expect these. fn map_state(state: TorrentStatsState, finished: bool) -> &'static str { match (state, finished) { (TorrentStatsState::Initializing, _) => "metaDL", (TorrentStatsState::Live, false) => "downloading", (TorrentStatsState::Live, true) => "uploading", - (TorrentStatsState::Paused, false) => "pausedDL", - (TorrentStatsState::Paused, true) => "pausedUP", + (TorrentStatsState::Paused, false) => "stoppedDL", + (TorrentStatsState::Paused, true) => "stoppedUP", (TorrentStatsState::Error, _) => "error", } } @@ -456,6 +790,7 @@ fn map_state(state: TorrentStatsState, finished: bool) -> &'static str { struct TorrentsInfoQuery { filter: Option, category: Option, + tag: Option, hashes: Option, sort: Option, reverse: Option, @@ -474,10 +809,12 @@ fn matches_filter( "downloading" => qbit_state == "downloading" || qbit_state == "metaDL", "seeding" => qbit_state == "uploading", "completed" => stats.finished, - "paused" => qbit_state == "pausedDL" || qbit_state == "pausedUP", + // `paused` was renamed to `stopped` in 2.11; accept both spellings. + "paused" | "stopped" => qbit_state == "stoppedDL" || qbit_state == "stoppedUP", "active" => qbit_state == "downloading" || qbit_state == "uploading", "inactive" => qbit_state != "downloading" && qbit_state != "uploading", - "resumed" => qbit_state != "pausedDL" && qbit_state != "pausedUP", + // `resumed` was renamed to `running` in 2.11; accept both spellings. + "resumed" | "running" => qbit_state != "stoppedDL" && qbit_state != "stoppedUP", "stalled" | "stalled_uploading" | "stalled_downloading" => { matches!(stats.state, TorrentStatsState::Live) && stats @@ -491,6 +828,164 @@ fn matches_filter( } } +/// Build the qBittorrent `torrents/info` view of a single torrent. Shared by +/// `torrents/info` and `sync/maindata`. +fn build_torrent_info( + handle: &crate::torrent_state::ManagedTorrentHandle, + stats: &crate::torrent_state::stats::TorrentStats, + now: u64, +) -> QbitTorrentInfo { + let info_hash = handle.shared().info_hash.as_string(); + let name = handle + .name() + .unwrap_or_else(|| format!("torrent_{}", handle.id())); + let output_folder = qbit_save_path(handle); + let content_path = qbit_content_path(handle, &name); + let qbit_state = map_state(stats.state, stats.finished); + let category = handle.shared().category.read().clone().unwrap_or_default(); + + let dl_speed = stats + .live + .as_ref() + .map(|l| l.download_speed.as_bytes()) + .unwrap_or(0); + let up_speed = stats + .live + .as_ref() + .map(|l| l.upload_speed.as_bytes()) + .unwrap_or(0); + + let progress = if stats.total_bytes > 0 { + stats.progress_bytes as f64 / stats.total_bytes as f64 + } else { + 0.0 + }; + + let eta = stats + .total_bytes + .saturating_sub(stats.progress_bytes) + .checked_div(dl_speed) + .map(|seconds| i64::try_from(seconds.min(8_640_000)).unwrap_or(8_640_000)) + .unwrap_or(8_640_000); + + let num_seeds = stats + .live + .as_ref() + .map(|l| l.snapshot.connected_seeders) + .unwrap_or(0); + let num_leechs = stats + .live + .as_ref() + .map(|l| l.snapshot.connected_leechers) + .unwrap_or(0); + let added_on = handle.shared().added_on; + let completion_on = handle + .shared() + .completion_on + .load(std::sync::atomic::Ordering::Relaxed); + let ratio = if stats.progress_bytes == 0 { + 0.0 + } else { + stats.uploaded_bytes as f64 / stats.progress_bytes as f64 + }; + let time_active = now.saturating_sub(added_on); + let seeding_time = completion_on + .checked_sub(added_on) + .map_or(0, |_| now.saturating_sub(completion_on)); + + let tracker = handle + .shared() + .trackers + .read() + .iter() + .next() + .map(|u| u.to_string()) + .unwrap_or_default(); + + let trackers_count = handle.shared().trackers.read().len(); + + QbitTorrentInfo { + added_on, + amount_left: stats.total_bytes.saturating_sub(stats.progress_bytes), + auto_tmm: false, + availability: -1, + category, + completed: stats.progress_bytes, + completion_on: if completion_on > 0 { + i64::try_from(completion_on).unwrap_or(i64::MAX) + } else { + -1 + }, + content_path, + dl_limit: -1, + dlspeed: dl_speed, + download_path: String::new(), + downloaded: stats.progress_bytes, + downloaded_session: 0, + eta, + f_l_piece_prio: false, + force_start: false, + hash: info_hash.clone(), + infohash_v1: info_hash, + infohash_v2: String::new(), + last_activity: if completion_on > 0 { + completion_on + } else { + added_on + }, + magnet_uri: String::new(), + max_ratio: -1.0, + max_seeding_time: -1, + name, + num_complete: num_seeds, + num_incomplete: num_leechs, + num_leechs, + num_seeds, + priority: 0, + progress, + ratio, + ratio_limit: -1.0, + save_path: output_folder, + seeding_time, + seeding_time_limit: -1, + seen_complete: if completion_on > 0 { + i64::try_from(completion_on).unwrap_or(i64::MAX) + } else { + -1 + }, + seq_dl: false, + size: stats.total_bytes, + state: qbit_state.to_string(), + super_seeding: false, + tags: String::new(), + time_active, + total_size: stats.total_bytes, + tracker, + trackers_count, + up_limit: -1, + uploaded: stats.uploaded_bytes, + uploaded_session: stats.uploaded_bytes, + upspeed: up_speed, + } +} + +/// Apply qBittorrent `offset`/`limit` pagination. An offset at or past the end +/// yields an empty list (not the whole list); a `limit` of 0 means "no limit". +fn apply_offset_limit(mut items: Vec, offset: usize, limit: Option) -> Vec { + if offset >= items.len() { + return Vec::new(); + } + if offset > 0 { + items = items.split_off(offset); + } + if let Some(limit) = limit + && limit > 0 + { + items.truncate(limit); + } + items +} + async fn h_torrents_info( State(state): State>, Query(query): Query, @@ -504,162 +999,43 @@ async fn h_torrents_info( .map(|h| h.split('|').map(|s| s.to_lowercase()).collect()); let mut torrents: Vec = api.session().with_torrents(|iter| { - iter.filter_map(|(id, handle)| { - let info_hash = handle.shared().info_hash.as_string(); + iter.filter_map(|(_id, handle)| { + let stats = handle.stats(); + let mut info = build_torrent_info(handle, &stats, now); + info.tags = state.tags.tags_for(&info.hash); + apply_share_limits(&mut info, &state.share_limits); - // Filter by hash if specified + // Filter by hash if specified. if let Some(ref hashes) = hash_filter - && !hashes.contains(&info_hash) + && !hashes.contains(&info.hash) { return None; } - let stats = handle.stats(); - let name = handle.name().unwrap_or_else(|| format!("torrent_{id}")); - let output_folder = qbit_save_path(handle); - let content_path = qbit_content_path(handle, &name); - - let qbit_state = map_state(stats.state, stats.finished); - let category = handle.shared().category.read().clone().unwrap_or_default(); - // qBittorrent treats an empty category and "uncategorized" as the // uncategorized bucket; otherwise category matching is exact. if let Some(ref requested) = query.category - && !matches_category(requested, &category) + && !matches_category(requested, &info.category) { return None; } - // Apply filter - if let Some(ref filter) = query.filter - && !matches_filter(filter, qbit_state, &stats) + // A non-empty tag query filters to torrents carrying that tag. + if let Some(ref tag) = query.tag + && !tag.is_empty() + && !state.tags.has_tag(&info.hash, tag) { return None; } - let dl_speed = stats - .live - .as_ref() - .map(|l| l.download_speed.as_bytes()) - .unwrap_or(0); - let up_speed = stats - .live - .as_ref() - .map(|l| l.upload_speed.as_bytes()) - .unwrap_or(0); - - let progress = if stats.total_bytes > 0 { - stats.progress_bytes as f64 / stats.total_bytes as f64 - } else { - 0.0 - }; - - let eta = stats - .total_bytes - .saturating_sub(stats.progress_bytes) - .checked_div(dl_speed) - .map(|seconds| i64::try_from(seconds.min(8_640_000)).unwrap_or(8_640_000)) - .unwrap_or(8_640_000); - - let num_seeds = stats - .live - .as_ref() - .map(|l| l.snapshot.connected_seeders) - .unwrap_or(0); - let num_leechs = stats - .live - .as_ref() - .map(|l| l.snapshot.connected_leechers) - .unwrap_or(0); - let added_on = handle.shared().added_on; - let completion_on = handle - .shared() - .completion_on - .load(std::sync::atomic::Ordering::Relaxed); - let ratio = if stats.progress_bytes == 0 { - 0.0 - } else { - stats.uploaded_bytes as f64 / stats.progress_bytes as f64 - }; - let time_active = now.saturating_sub(added_on); - let seeding_time = completion_on - .checked_sub(added_on) - .map_or(0, |_| now.saturating_sub(completion_on)); - - let tracker = handle - .shared() - .trackers - .read() - .iter() - .next() - .map(|u| u.to_string()) - .unwrap_or_default(); + // Apply the state filter. + if let Some(ref filter) = query.filter + && !matches_filter(filter, &info.state, &stats) + { + return None; + } - let trackers_count = handle.shared().trackers.read().len(); - - Some(QbitTorrentInfo { - added_on, - amount_left: stats.total_bytes.saturating_sub(stats.progress_bytes), - auto_tmm: false, - availability: -1, - category, - completed: stats.progress_bytes, - completion_on: if completion_on > 0 { - i64::try_from(completion_on).unwrap_or(i64::MAX) - } else { - -1 - }, - content_path, - dl_limit: -1, - dlspeed: dl_speed, - download_path: String::new(), - downloaded: stats.progress_bytes, - downloaded_session: 0, - eta, - f_l_piece_prio: false, - force_start: false, - hash: info_hash.clone(), - infohash_v1: info_hash, - infohash_v2: String::new(), - last_activity: if completion_on > 0 { - completion_on - } else { - added_on - }, - magnet_uri: String::new(), - max_ratio: -1, - max_seeding_time: -1, - name, - num_complete: num_seeds, - num_incomplete: num_leechs, - num_leechs, - num_seeds, - priority: 0, - progress, - ratio, - ratio_limit: -1, - save_path: output_folder, - seeding_time, - seeding_time_limit: -1, - seen_complete: if completion_on > 0 { - i64::try_from(completion_on).unwrap_or(i64::MAX) - } else { - -1 - }, - seq_dl: false, - size: stats.total_bytes, - state: qbit_state.to_string(), - super_seeding: false, - tags: String::new(), - time_active, - total_size: stats.total_bytes, - tracker, - trackers_count, - up_limit: -1, - uploaded: stats.uploaded_bytes, - uploaded_session: stats.uploaded_bytes, - upspeed: up_speed, - }) + Some(info) }) .collect() }); @@ -693,15 +1069,7 @@ async fn h_torrents_info( }); } - // Offset and limit - let offset = query.offset.unwrap_or(0); - if offset > 0 && offset < torrents.len() { - torrents = torrents.split_off(offset); - } - if let Some(limit) = query.limit { - torrents.truncate(limit); - } - + let torrents = apply_offset_limit(torrents, query.offset.unwrap_or(0), query.limit); axum::Json(torrents) } @@ -875,6 +1243,19 @@ async fn h_torrents_files( let is_multi_file = handle .with_metadata(|metadata| metadata.info.info().files.is_some()) .unwrap_or(false); + // Source per-file names from file_infos (which reflects renames) when its + // count lines up with the details file list; otherwise fall back to the + // metadata names to avoid any index skew (e.g. padding files). + let rel_names: Vec = handle + .with_metadata(|metadata| { + metadata + .file_infos + .iter() + .map(|fi| fi.relative_filename.to_string_lossy().into_owned()) + .collect::>() + }) + .unwrap_or_default(); + let use_rel_names = rel_names.len() == details_files.len(); let files: Vec = details_files .iter() .enumerate() @@ -885,7 +1266,12 @@ async fn h_torrents_files( } else { 1.0 }; - let name = qbit_file_name(details_name.as_deref(), &f.name, is_multi_file, qbit_root); + let base_name = if use_rel_names { + rel_names[i].as_str() + } else { + f.name.as_str() + }; + let name = qbit_file_name(details_name.as_deref(), base_name, is_multi_file, qbit_root); QbitFileInfo { index: i, name, @@ -902,23 +1288,196 @@ async fn h_torrents_files( axum::Json(files).into_response() } -// --------------------------------------------------------------------------- -// Torrent actions (add, pause, resume, delete) -// --------------------------------------------------------------------------- +#[derive(Serialize)] +struct QbitTrackerInfo { + url: String, + /// qBittorrent tracker status: 0 disabled, 1 not contacted, 2 working, + /// 3 updating, 4 not working. + status: u8, + tier: i32, + num_peers: i64, + num_seeds: i64, + num_leeches: i64, + num_downloaded: i64, + msg: String, +} -async fn h_torrents_add( +/// Map our tracker announce state to qBittorrent's integer status code. +fn qbit_tracker_status(state: tracker_comms::TrackerAnnounceState) -> u8 { + use tracker_comms::TrackerAnnounceState::*; + match state { + Disabled => 0, + NotContacted => 1, + Working => 2, + Updating => 3, + Error => 4, + } +} + +async fn h_torrents_trackers( State(state): State>, - mut multipart: Multipart, + Query(query): Query, ) -> impl IntoResponse { - let mut urls: Vec = Vec::new(); - let mut torrent_bytes: Vec = Vec::new(); - let mut savepath: Option = None; - let mut category: Option = None; - let mut paused = false; + let api = &state.api_state.api; + let idx = match TorrentIdOrHash::parse(&query.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let response = match api.api_tracker_status(idx) { + Ok(r) => r, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; - while let Ok(Some(field)) = multipart.next_field().await { - let field_name = field.name().unwrap_or("").to_string(); - match field_name.as_str() { + let trackers: Vec = response + .trackers + .into_iter() + .map(|t| QbitTrackerInfo { + status: qbit_tracker_status(t.state), + tier: 0, + num_peers: t.peers_returned.map_or(-1, i64::from), + num_seeds: t.seeders.map_or(-1, i64::from), + num_leeches: t.leechers.map_or(-1, i64::from), + num_downloaded: -1, + msg: t.last_error.unwrap_or_default(), + url: t.url, + }) + .collect(); + axum::Json(trackers).into_response() +} + +async fn h_torrents_piece_states( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let api = &state.api_state.api; + let idx = match TorrentIdOrHash::parse(&query.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let (bf, len) = match api.api_dump_haves(idx) { + Ok(v) => v, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + // qBittorrent: 0 not downloaded, 1 downloading (requested), 2 downloaded. + // We only distinguish have/not-have, so emit 0 or 2. + let states: Vec = bf + .iter() + .take(len as usize) + .map(|b| if *b { 2 } else { 0 }) + .collect(); + axum::Json(states).into_response() +} + +async fn h_torrents_piece_hashes( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let api = &state.api_state.api; + let idx = match TorrentIdOrHash::parse(&query.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let handle = match api.mgr_handle(idx) { + Ok(h) => h, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let hashes: Option> = handle + .with_metadata(|m| { + let info = m.info.info(); + let total = m.info.lengths().total_pieces(); + (0..total) + .map(|p| { + info.get_hash(p).map(|h| { + h.iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + }) + }) + .collect::>>() + }) + .ok() + .flatten(); + match hashes { + Some(hashes) => axum::Json(hashes).into_response(), + // Metadata not yet available (magnet still resolving) or v2-only torrent. + None => axum::Json(Vec::::new()).into_response(), + } +} + +async fn h_torrents_count(State(state): State>) -> impl IntoResponse { + let count = state + .api_state + .api + .session() + .with_torrents(|iter| iter.count()); + axum::Json(count) +} + +/// `torrents/export` — return the raw `.torrent` file bytes for one torrent. +async fn h_torrents_export( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let api = &state.api_state.api; + let idx = match TorrentIdOrHash::parse(&query.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let handle = match api.mgr_handle(idx) { + Ok(h) => h, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + match handle.with_metadata(|meta| meta.torrent_bytes.clone()) { + Ok(bytes) => ([("content-type", "application/x-bittorrent")], bytes).into_response(), + // Metadata not resolved yet (magnet) — no file to export. + Err(_) => (StatusCode::CONFLICT, "Metadata not available").into_response(), + } +} + +#[derive(Serialize)] +struct QbitWebSeed { + url: String, +} + +async fn h_torrents_webseeds( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let api = &state.api_state.api; + let idx = match TorrentIdOrHash::parse(&query.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let handle = match api.mgr_handle(idx) { + Ok(h) => h, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let seeds: Vec = handle + .shared() + .web_seed_urls + .iter() + .map(|url| QbitWebSeed { url: url.clone() }) + .collect(); + axum::Json(seeds).into_response() +} + +// --------------------------------------------------------------------------- +// Torrent actions (add, pause, resume, delete) +// --------------------------------------------------------------------------- + +async fn h_torrents_add( + State(state): State>, + mut multipart: Multipart, +) -> impl IntoResponse { + let mut urls: Vec = Vec::new(); + let mut torrent_bytes: Vec = Vec::new(); + let mut savepath: Option = None; + let mut category: Option = None; + let mut paused = false; + + while let Ok(Some(field)) = multipart.next_field().await { + let field_name = field.name().unwrap_or("").to_string(); + match field_name.as_str() { "urls" => { if let Ok(text) = field.text().await { for line in text.lines() { @@ -953,9 +1512,12 @@ async fn h_torrents_add( savepath = Some(text); } } - "paused" => { - if let Ok(text) = field.text().await { - paused = text.eq_ignore_ascii_case("true"); + // `paused` was renamed to `stopped` in WebAPI 2.11; accept both. + "paused" | "stopped" => { + if let Ok(text) = field.text().await + && text.eq_ignore_ascii_case("true") + { + paused = true; } } _ => { @@ -1063,6 +1625,17 @@ async fn h_torrents_resume(State(state): State>, body: Bytes) -> "Ok." } +/// `torrents/stop` — the WebAPI 2.11+ name for `torrents/pause`. We advertise +/// 2.11.3, so modern clients (qbittorrent-api, newer *arr) call this. +async fn h_torrents_stop(state: State>, body: Bytes) -> &'static str { + h_torrents_pause(state, body).await +} + +/// `torrents/start` — the WebAPI 2.11+ name for `torrents/resume`. +async fn h_torrents_start(state: State>, body: Bytes) -> &'static str { + h_torrents_resume(state, body).await +} + async fn h_torrents_recheck(State(state): State>, body: Bytes) -> &'static str { let form: HashesForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); let api = &state.api_state.api; @@ -1074,6 +1647,145 @@ async fn h_torrents_recheck(State(state): State>, body: Bytes) -> "Ok." } +async fn h_torrents_reannounce(State(state): State>, body: Bytes) -> &'static str { + let form: HashesForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + for idx in resolve_hashes(api, &form.hashes) { + if let Ok(handle) = api.mgr_handle(idx) { + handle.reannounce(); + } + } + "Ok." +} + +/// Relative paths of every file in the torrent, indexed by file id. +fn torrent_file_paths(handle: &crate::torrent_state::ManagedTorrentHandle) -> Vec { + handle + .with_metadata(|metadata| { + metadata + .file_infos + .iter() + .map(|fi| fi.relative_filename.clone()) + .collect::>() + }) + .unwrap_or_default() +} + +/// Map a rename engine error onto qBittorrent's 409 Conflict (used for +/// "torrent must be stopped", path collisions, invalid paths, etc.). +fn rename_conflict(error: anyhow::Error) -> axum::response::Response { + (StatusCode::CONFLICT, format!("{error:#}")).into_response() +} + +#[derive(Deserialize, Default)] +struct RenamePathForm { + #[serde(default)] + hash: String, + #[serde(default, alias = "oldPath")] + old_path: String, + #[serde(default, alias = "newPath")] + new_path: String, +} + +async fn h_torrents_rename_file( + State(state): State>, + body: Bytes, +) -> axum::response::Response { + let form: RenamePathForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + if form.hash.is_empty() { + return (StatusCode::BAD_REQUEST, "hash is required").into_response(); + } + if form.old_path.is_empty() || form.new_path.is_empty() { + return (StatusCode::BAD_REQUEST, "oldPath and newPath are required").into_response(); + } + let idx = match TorrentIdOrHash::parse(&form.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let handle = match api.mgr_handle(idx) { + Ok(h) => h, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let paths = torrent_file_paths(&handle); + let old = PathBuf::from(&form.old_path); + let file_id = match paths.iter().position(|p| *p == old) { + Some(id) => id, + None => return (StatusCode::CONFLICT, "oldPath does not exist").into_response(), + }; + match handle.rename_files(&[(file_id, PathBuf::from(&form.new_path))]) { + Ok(()) => (StatusCode::OK, "Ok.").into_response(), + Err(error) => rename_conflict(error), + } +} + +async fn h_torrents_rename_folder( + State(state): State>, + body: Bytes, +) -> axum::response::Response { + let form: RenamePathForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + if form.hash.is_empty() { + return (StatusCode::BAD_REQUEST, "hash is required").into_response(); + } + if form.old_path.is_empty() || form.new_path.is_empty() { + return (StatusCode::BAD_REQUEST, "oldPath and newPath are required").into_response(); + } + let idx = match TorrentIdOrHash::parse(&form.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let handle = match api.mgr_handle(idx) { + Ok(h) => h, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + // Rename every file whose path is under the old folder prefix. + let old_prefix = PathBuf::from(&form.old_path); + let new_prefix = PathBuf::from(&form.new_path); + let mut renames: Vec<(usize, PathBuf)> = Vec::new(); + for (id, path) in torrent_file_paths(&handle).into_iter().enumerate() { + if let Ok(rest) = path.strip_prefix(&old_prefix) { + renames.push((id, new_prefix.join(rest))); + } + } + if renames.is_empty() { + return (StatusCode::CONFLICT, "no files under oldPath").into_response(); + } + match handle.rename_files(&renames) { + Ok(()) => (StatusCode::OK, "Ok.").into_response(), + Err(error) => rename_conflict(error), + } +} + +#[derive(Deserialize, Default)] +struct RenameForm { + #[serde(default)] + hash: String, + #[serde(default)] + name: String, +} + +async fn h_torrents_rename(State(state): State>, body: Bytes) -> impl IntoResponse { + let form: RenameForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + if form.hash.is_empty() { + return (StatusCode::BAD_REQUEST, "hash is required"); + } + if form.name.trim().is_empty() { + return (StatusCode::CONFLICT, "name must not be empty"); + } + let idx = match TorrentIdOrHash::parse(&form.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found"), + }; + let handle = match api.mgr_handle(idx) { + Ok(h) => h, + Err(_) => return (StatusCode::NOT_FOUND, "Not found"), + }; + handle.set_display_name(Some(form.name)); + (StatusCode::OK, "Ok.") +} + #[derive(Deserialize, Default)] struct DeleteForm { #[serde(default)] @@ -1125,6 +1837,456 @@ async fn h_torrents_set_category(State(state): State>, body: Byte "Ok." } +#[derive(Deserialize, Default)] +struct AddTrackersForm { + #[serde(default)] + hash: String, + #[serde(default)] + urls: String, +} + +async fn h_torrents_add_trackers( + State(state): State>, + body: Bytes, +) -> impl IntoResponse { + let form: AddTrackersForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + if form.hash.is_empty() { + return (StatusCode::BAD_REQUEST, "hash is required"); + } + let idx = match TorrentIdOrHash::parse(&form.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found"), + }; + let trackers: Vec = form + .urls + .lines() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); + if trackers.is_empty() { + return (StatusCode::OK, "Ok."); + } + match api.api_torrent_action_add_trackers(idx, trackers).await { + Ok(_) => (StatusCode::OK, "Ok."), + Err(error) => { + warn!(%error, "qbit compat: error adding trackers"); + (StatusCode::NOT_FOUND, "Not found") + } + } +} + +#[derive(Deserialize, Default)] +struct RemoveTrackersForm { + #[serde(default)] + hash: String, + /// Pipe-separated tracker URLs. + #[serde(default)] + urls: String, +} + +async fn h_torrents_remove_trackers( + State(state): State>, + body: Bytes, +) -> impl IntoResponse { + let form: RemoveTrackersForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + if form.hash.is_empty() { + return (StatusCode::BAD_REQUEST, "hash is required").into_response(); + } + let idx = match TorrentIdOrHash::parse(&form.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let handle = match api.mgr_handle(idx) { + Ok(h) => h, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let urls: Vec = form + .urls + .split('|') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); + if let Err(error) = api.session().remove_trackers(&handle, &urls).await { + warn!(%error, "qbit compat: error removing trackers"); + } + (StatusCode::OK, "Ok.").into_response() +} + +#[derive(Deserialize, Default)] +struct EditTrackerForm { + #[serde(default)] + hash: String, + #[serde(default, alias = "origUrl")] + orig_url: String, + #[serde(default, alias = "newUrl")] + new_url: String, +} + +async fn h_torrents_edit_tracker( + State(state): State>, + body: Bytes, +) -> impl IntoResponse { + let form: EditTrackerForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + if form.hash.is_empty() { + return (StatusCode::BAD_REQUEST, "hash is required").into_response(); + } + let idx = match TorrentIdOrHash::parse(&form.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let handle = match api.mgr_handle(idx) { + Ok(h) => h, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + // Validate newUrl *before* removing origUrl so a bad newUrl can't leave the + // torrent with neither tracker. + let valid_new = url::Url::parse(&form.new_url) + .ok() + .is_some_and(|u| matches!(u.scheme(), "http" | "https" | "udp")); + if !valid_new { + return (StatusCode::BAD_REQUEST, "invalid newUrl").into_response(); + } + let removed = api + .session() + .remove_trackers(&handle, std::slice::from_ref(&form.orig_url)) + .await + .unwrap_or(0); + if removed == 0 { + return (StatusCode::CONFLICT, "origUrl not found").into_response(); + } + match api + .api_torrent_action_add_trackers(idx, vec![form.new_url]) + .await + { + Ok(_) => (StatusCode::OK, "Ok.").into_response(), + Err(error) => { + warn!(%error, "qbit compat: error editing tracker"); + (StatusCode::CONFLICT, "Failed").into_response() + } + } +} + +#[derive(Deserialize, Default)] +struct AddPeersForm { + #[serde(default)] + hashes: String, + #[serde(default)] + peers: String, +} + +async fn h_torrents_add_peers(State(state): State>, body: Bytes) -> &'static str { + let form: AddPeersForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + let peers: Vec = form + .peers + .split('|') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + for idx in resolve_hashes(api, &form.hashes) { + let Ok(handle) = api.mgr_handle(idx) else { + continue; + }; + let Some(live) = handle.live() else { + continue; + }; + for addr in &peers { + let _ = live.add_peer_if_not_seen(*addr); + } + } + "Ok." +} + +#[derive(Deserialize, Default)] +struct FilePrioForm { + #[serde(default)] + hash: String, + /// Pipe-separated file indices. + #[serde(default)] + id: String, + #[serde(default)] + priority: u8, +} + +async fn h_torrents_file_prio( + State(state): State>, + body: Bytes, +) -> impl IntoResponse { + let form: FilePrioForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + if form.hash.is_empty() { + return (StatusCode::BAD_REQUEST, "hash is required"); + } + let idx = match TorrentIdOrHash::parse(&form.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found"), + }; + let handle = match api.mgr_handle(idx) { + Ok(h) => h, + Err(_) => return (StatusCode::NOT_FOUND, "Not found"), + }; + let num_files = match api.api_torrent_details(idx) { + Ok(details) => details.files.map(|f| f.len()).unwrap_or(0), + Err(_) => return (StatusCode::NOT_FOUND, "Not found"), + }; + + // qBittorrent priority 0 means "do not download"; anything else downloads. + // We only model an include/exclude selection, so map onto only_files. + let mut included: HashSet = match handle.only_files() { + Some(files) => files.into_iter().collect(), + None => (0..num_files).collect(), + }; + let download = form.priority != 0; + for id in form + .id + .split('|') + .filter_map(|s| s.trim().parse::().ok()) + { + if id >= num_files { + return (StatusCode::CONFLICT, "Invalid file id"); + } + if download { + included.insert(id); + } else { + included.remove(&id); + } + } + match api + .api_torrent_action_update_only_files(idx, &included) + .await + { + Ok(_) => (StatusCode::OK, "Ok."), + Err(error) => { + warn!(%error, "qbit compat: error setting file priority"); + (StatusCode::CONFLICT, "Failed") + } + } +} + +// --------------------------------------------------------------------------- +// Per-torrent speed limits +// --------------------------------------------------------------------------- + +#[derive(Deserialize, Default)] +struct HashesQuery { + #[serde(default)] + hashes: String, +} + +#[derive(Deserialize, Default)] +struct SetTorrentLimitForm { + #[serde(default)] + hashes: String, + /// Bytes/s; <= 0 means unlimited. + #[serde(default)] + limit: i64, +} + +fn torrent_limit_map( + api: &Api, + hashes: &str, + pick: impl Fn(LimitsConfig) -> Option, +) -> HashMap { + let mut map = HashMap::new(); + for idx in resolve_hashes(api, hashes) { + if let Ok(handle) = api.mgr_handle(idx) { + let hash = handle.shared().info_hash.as_string(); + map.insert(hash, bps_to_u64(pick(handle.rate_limits()))); + } + } + map +} + +fn parse_torrent_limit(limit: i64) -> Option { + if limit <= 0 { + None + } else { + limit_to_bps(limit as u64) + } +} + +async fn h_torrents_download_limit( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let map = torrent_limit_map(&state.api_state.api, &query.hashes, |c| c.download_bps); + axum::Json(map) +} + +async fn h_torrents_upload_limit( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let map = torrent_limit_map(&state.api_state.api, &query.hashes, |c| c.upload_bps); + axum::Json(map) +} + +async fn h_torrents_set_download_limit( + State(state): State>, + body: Bytes, +) -> &'static str { + let form: SetTorrentLimitForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + let bps = parse_torrent_limit(form.limit); + for idx in resolve_hashes(api, &form.hashes) { + if let Ok(handle) = api.mgr_handle(idx) { + handle.set_download_limit(bps); + } + } + "Ok." +} + +async fn h_torrents_set_upload_limit( + State(state): State>, + body: Bytes, +) -> &'static str { + let form: SetTorrentLimitForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + let bps = parse_torrent_limit(form.limit); + for idx in resolve_hashes(api, &form.hashes) { + if let Ok(handle) = api.mgr_handle(idx) { + handle.set_upload_limit(bps); + } + } + "Ok." +} + +fn default_ratio_limit() -> f64 { + -1.0 +} + +fn default_seeding_time_limit() -> i64 { + -1 +} + +#[derive(Deserialize)] +struct SetShareLimitsForm { + #[serde(default)] + hashes: String, + #[serde(default = "default_ratio_limit", alias = "ratioLimit")] + ratio_limit: f64, + #[serde(default = "default_seeding_time_limit", alias = "seedingTimeLimit")] + seeding_time_limit: i64, +} + +/// `torrents/setShareLimits` — store per-torrent ratio / seeding-time limits so +/// they round-trip and appear in `torrents/info`. rtbit does not auto-enforce +/// them (clients like Sonarr/Radarr apply their own removal policy). +async fn h_torrents_set_share_limits( + State(state): State>, + body: Bytes, +) -> &'static str { + let form: SetShareLimitsForm = match serde_urlencoded::from_bytes(&body) { + Ok(form) => form, + Err(_) => return "Ok.", + }; + let hashes = resolve_info_hashes(&state.api_state.api, &form.hashes); + state + .share_limits + .set(&hashes, form.ratio_limit, form.seeding_time_limit); + "Ok." +} + +// --------------------------------------------------------------------------- +// Tag endpoints (backed by the in-memory QbitTags store) +// --------------------------------------------------------------------------- + +/// Resolve hash(es) to canonical (lowercase hex) info-hash strings, used as the +/// key space for the tag store. Skips torrents that cannot be resolved. +fn resolve_info_hashes(api: &Api, hashes_str: &str) -> Vec { + resolve_hashes(api, hashes_str) + .into_iter() + .filter_map(|idx| { + api.mgr_handle(idx) + .ok() + .map(|handle| handle.shared().info_hash.as_string()) + }) + .collect() +} + +#[derive(Deserialize, Default)] +struct TagsForm { + #[serde(default)] + hashes: String, + #[serde(default)] + tags: String, +} + +async fn h_torrents_tags(State(state): State>) -> impl IntoResponse { + axum::Json(state.tags.all_tags()) +} + +async fn h_torrents_create_tags(State(state): State>, body: Bytes) -> &'static str { + let form: TagsForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + state.tags.create(&parse_tags(&form.tags)); + "Ok." +} + +async fn h_torrents_delete_tags(State(state): State>, body: Bytes) -> &'static str { + let form: TagsForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + state.tags.delete(&parse_tags(&form.tags)); + "Ok." +} + +async fn h_torrents_add_tags(State(state): State>, body: Bytes) -> &'static str { + let form: TagsForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let hashes = resolve_info_hashes(&state.api_state.api, &form.hashes); + state.tags.add_to(&hashes, &parse_tags(&form.tags)); + "Ok." +} + +async fn h_torrents_remove_tags(State(state): State>, body: Bytes) -> &'static str { + let form: TagsForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let hashes = resolve_info_hashes(&state.api_state.api, &form.hashes); + state.tags.remove_from(&hashes, &parse_tags(&form.tags)); + "Ok." +} + +async fn h_torrents_set_tags(State(state): State>, body: Bytes) -> &'static str { + let form: TagsForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let hashes = resolve_info_hashes(&state.api_state.api, &form.hashes); + state.tags.set(&hashes, &parse_tags(&form.tags)); + "Ok." +} + +#[derive(Deserialize, Default)] +struct SetLocationForm { + // setLocation uses `hashes`/`location`; setSavePath uses `id`/`path`. + #[serde(default, alias = "id")] + hashes: String, + #[serde(default, alias = "path")] + location: String, +} + +async fn h_torrents_set_location( + State(state): State>, + body: Bytes, +) -> axum::response::Response { + let form: SetLocationForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + let location = form.location.trim(); + if location.is_empty() { + return (StatusCode::BAD_REQUEST, "location is required").into_response(); + } + let new_root = PathBuf::from(location); + let mut last_error = None; + for idx in resolve_hashes(api, &form.hashes) { + if let Ok(handle) = api.mgr_handle(idx) + && let Err(error) = handle.set_location(new_root.clone()) + { + last_error = Some(error); + } + } + match last_error { + Some(error) => rename_conflict(error), + None => (StatusCode::OK, "Ok.").into_response(), + } +} + // --------------------------------------------------------------------------- // Category endpoints // --------------------------------------------------------------------------- @@ -1143,67 +2305,335 @@ async fn h_categories(State(state): State>) -> impl IntoResponse (name.clone(), QbitCategory { name, save_path }) }) .collect(); - axum::Json(serde_json::to_value(&map).unwrap_or_default()) + axum::Json(serde_json::to_value(&map).unwrap_or_default()) +} + +#[derive(Deserialize, Default)] +struct CreateCategoryForm { + #[serde(default)] + category: String, + #[serde(default, alias = "savePath")] + save_path: String, +} + +async fn h_create_category(State(state): State>, body: Bytes) -> impl IntoResponse { + let form: CreateCategoryForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + if form.category.is_empty() { + return (StatusCode::BAD_REQUEST, "Category name required").into_response(); + } + let save_path = (!form.save_path.is_empty()).then(|| form.save_path.into()); + match state + .api_state + .api + .api_create_or_edit_category(form.category, save_path) + .await + { + Ok(_) => (StatusCode::OK, "Ok.").into_response(), + Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), + } +} + +async fn h_edit_category(State(state): State>, body: Bytes) -> impl IntoResponse { + let form: CreateCategoryForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + if form.category.is_empty() { + return (StatusCode::BAD_REQUEST, "Category name required").into_response(); + } + let save_path = (!form.save_path.is_empty()).then(|| form.save_path.into()); + match state + .api_state + .api + .api_create_or_edit_category(form.category, save_path) + .await + { + Ok(_) => (StatusCode::OK, "Ok.").into_response(), + Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), + } +} + +#[derive(Deserialize, Default)] +struct RemoveCategoriesForm { + #[serde(default)] + categories: String, +} + +async fn h_remove_categories(State(state): State>, body: Bytes) -> &'static str { + let form: RemoveCategoriesForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + for name in form.categories.split('\n') { + let name = name.trim(); + if !name.is_empty() + && let Err(error) = state.api_state.api.api_remove_category(name).await + { + warn!(%error, %name, "qbit compat: error removing category"); + } + } + "Ok." +} + +// --------------------------------------------------------------------------- +// Sync (main polling endpoint) +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct QbitServerState { + connection_status: &'static str, + dht_nodes: u64, + dl_info_data: u64, + dl_info_speed: u64, + dl_rate_limit: u64, + up_info_data: u64, + up_info_speed: u64, + up_rate_limit: u64, + queueing: bool, + use_alt_speed_limits: bool, + refresh_interval: u64, + free_space_on_disk: u64, + global_ratio: String, +} + +#[derive(Serialize)] +struct QbitMainData { + rid: u64, + full_update: bool, + torrents: HashMap, + categories: HashMap, + tags: Vec, + server_state: QbitServerState, +} + +#[derive(Deserialize, Default)] +struct SyncQuery { + #[serde(default)] + rid: u64, +} + +/// `sync/maindata` — the primary polling endpoint for WebUI frontends and many +/// integrations. We do not track per-client deltas, so every response is a +/// `full_update` snapshot (a valid, if chattier, mode of the protocol); the +/// `rid` is echoed back incremented so clients keep polling. +async fn h_sync_maindata( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let api = &state.api_state.api; + let now = now_unix(); + + let torrents: HashMap = api.session().with_torrents(|iter| { + iter.map(|(_id, handle)| { + let stats = handle.stats(); + let mut info = build_torrent_info(handle, &stats, now); + info.tags = state.tags.tags_for(&info.hash); + apply_share_limits(&mut info, &state.share_limits); + (info.hash.clone(), info) + }) + .collect() + }); + + let categories: HashMap = api + .api_list_categories() + .into_iter() + .map(|(name, category)| { + let save_path = category + .save_path + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_default(); + (name.clone(), QbitCategory { name, save_path }) + }) + .collect(); + + let session_stats = api.api_session_stats(); + let config = api.session().ratelimits.get_config(); + let downloaded = session_stats.counters.fetched_bytes; + let uploaded = session_stats.counters.uploaded_bytes; + let global_ratio = if downloaded == 0 { + "0.00".to_string() + } else { + format!("{:.2}", uploaded as f64 / downloaded as f64) + }; + + let server_state = QbitServerState { + connection_status: "connected", + dht_nodes: 0, + dl_info_data: downloaded, + dl_info_speed: session_stats.download_speed.as_bytes(), + dl_rate_limit: bps_to_u64(config.download_bps), + up_info_data: uploaded, + up_info_speed: session_stats.upload_speed.as_bytes(), + up_rate_limit: bps_to_u64(config.upload_bps), + queueing: false, + use_alt_speed_limits: api.session().alt_speed_enabled(), + refresh_interval: 1500, + free_space_on_disk: 0, + global_ratio, + }; + + axum::Json(QbitMainData { + rid: query.rid.saturating_add(1), + full_update: true, + torrents, + categories, + tags: state.tags.all_tags(), + server_state, + }) } +// --------------------------------------------------------------------------- +// Torrent creator endpoints +// --------------------------------------------------------------------------- + #[derive(Deserialize, Default)] -struct CreateCategoryForm { +struct AddTaskForm { + #[serde(default, alias = "sourcePath")] + source_path: String, #[serde(default)] - category: String, - #[serde(default, alias = "savePath")] - save_path: String, + format: String, + #[serde(default)] + trackers: String, + #[serde(default, alias = "pieceSize")] + piece_size: u32, + #[serde(default, alias = "torrentName", alias = "name")] + name: String, } -async fn h_create_category(State(state): State>, body: Bytes) -> impl IntoResponse { - let form: CreateCategoryForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); - if form.category.is_empty() { - return (StatusCode::BAD_REQUEST, "Category name required").into_response(); +async fn h_torrentcreator_add_task( + State(state): State>, + body: Bytes, +) -> axum::response::Response { + if !state.api_state.opts.allow_create { + return ( + StatusCode::FORBIDDEN, + "creating torrents is not enabled on this server", + ) + .into_response(); } - let save_path = (!form.save_path.is_empty()).then(|| form.save_path.into()); - match state - .api_state - .api - .api_create_or_edit_category(form.category, save_path) - .await - { - Ok(_) => (StatusCode::OK, "Ok.").into_response(), - Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), + let form: AddTaskForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + if form.source_path.trim().is_empty() { + return (StatusCode::BAD_REQUEST, "sourcePath is required").into_response(); } + + let format = if form.format.is_empty() { + "v1".to_string() + } else { + form.format.clone() + }; + let task_id = state.creator.new_task(form.source_path.clone(), format); + + // Run the (synchronous) creation off the request path and record the result. + let trackers: Vec = form + .trackers + .lines() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); + let name = (!form.name.trim().is_empty()).then(|| form.name.clone()); + let piece_length = (form.piece_size > 0).then_some(form.piece_size); + let source = form.source_path.clone(); + let state = state.clone(); + let spawned_id = task_id.clone(); + tokio::spawn(async move { + let spawner = BlockingSpawner::new(1); + let options = CreateTorrentOptions { + name: name.as_deref(), + trackers, + piece_length, + }; + match create_torrent(std::path::Path::new(&source), options, &spawner).await { + Ok(result) => { + let bytes = result.as_bytes().ok(); + let info_hash = result.info_hash().as_string(); + state.creator.finish(&spawned_id, bytes, info_hash); + } + Err(error) => state.creator.fail(&spawned_id, format!("{error:#}")), + } + }); + + axum::Json(serde_json::json!({ "taskID": task_id })).into_response() } -async fn h_edit_category(State(state): State>, body: Bytes) -> impl IntoResponse { - let form: CreateCategoryForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); - if form.category.is_empty() { - return (StatusCode::BAD_REQUEST, "Category name required").into_response(); - } - let save_path = (!form.save_path.is_empty()).then(|| form.save_path.into()); - match state - .api_state - .api - .api_create_or_edit_category(form.category, save_path) - .await - { - Ok(_) => (StatusCode::OK, "Ok.").into_response(), - Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CreatorTaskStatus { + task_id: String, + source_path: String, + format: String, + status: CreatorStatus, + error_message: String, + progress: f64, +} + +fn creator_task_status(id: String, task: CreatorTask) -> CreatorTaskStatus { + CreatorTaskStatus { + progress: match task.status { + CreatorStatus::Finished => 1.0, + _ => 0.0, + }, + error_message: task.error.unwrap_or_default(), + task_id: id, + source_path: task.source_path, + format: task.format, + status: task.status, } } #[derive(Deserialize, Default)] -struct RemoveCategoriesForm { - #[serde(default)] - categories: String, +struct OptionalTaskIdQuery { + #[serde(default, alias = "taskID")] + task_id: String, } -async fn h_remove_categories(State(state): State>, body: Bytes) -> &'static str { - let form: RemoveCategoriesForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); - for name in form.categories.split('\n') { - let name = name.trim(); - if !name.is_empty() - && let Err(error) = state.api_state.api.api_remove_category(name).await - { - warn!(%error, %name, "qbit compat: error removing category"); - } +async fn h_torrentcreator_status( + State(state): State>, + Query(query): Query, +) -> axum::response::Response { + if !query.task_id.is_empty() { + return match state.creator.get(&query.task_id) { + Some(task) => { + axum::Json(vec![creator_task_status(query.task_id, task)]).into_response() + } + None => (StatusCode::NOT_FOUND, "task not found").into_response(), + }; + } + let all: Vec = state + .creator + .all() + .into_iter() + .map(|(id, task)| creator_task_status(id, task)) + .collect(); + axum::Json(all).into_response() +} + +#[derive(Deserialize)] +struct RequiredTaskIdQuery { + #[serde(alias = "taskID")] + task_id: String, +} + +async fn h_torrentcreator_torrent_file( + State(state): State>, + Query(query): Query, +) -> axum::response::Response { + match state.creator.get(&query.task_id) { + Some(task) if matches!(task.status, CreatorStatus::Finished) => match task.torrent_bytes { + Some(bytes) => ([("content-type", "application/x-bittorrent")], bytes).into_response(), + None => (StatusCode::INTERNAL_SERVER_ERROR, "no torrent bytes").into_response(), + }, + Some(_) => (StatusCode::CONFLICT, "task has not finished").into_response(), + None => (StatusCode::NOT_FOUND, "task not found").into_response(), } +} + +#[derive(Deserialize, Default)] +struct DeleteTaskForm { + #[serde(default, alias = "taskID")] + task_id: String, +} + +async fn h_torrentcreator_delete_task( + State(state): State>, + body: Bytes, +) -> &'static str { + let form: DeleteTaskForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + state.creator.remove(&form.task_id); "Ok." } @@ -1232,6 +2662,9 @@ pub(crate) fn make_qbit_router(api_state: ApiState) -> Router { let qbit_state = Arc::new(QbitState { api_state: api_state.clone(), sessions: QbitSessions::new(), + tags: QbitTags::default(), + creator: QbitTorrentCreator::default(), + share_limits: QbitShareLimits::default(), }); // Auth endpoints (no auth required to reach these) @@ -1244,32 +2677,92 @@ pub(crate) fn make_qbit_router(api_state: ApiState) -> Router { .route("/version", get(h_app_version)) .route("/webapiVersion", get(h_app_webapi_version)) .route("/buildInfo", get(h_app_build_info)) + .route("/defaultSavePath", get(h_app_default_save_path)) .route("/preferences", get(h_app_preferences)) .route("/setPreferences", post(h_app_set_preferences)); // Torrent endpoints let torrents_router = Router::new() .route("/info", get(h_torrents_info)) + .route("/count", get(h_torrents_count)) .route("/properties", get(h_torrents_properties)) .route("/files", get(h_torrents_files)) + .route("/trackers", get(h_torrents_trackers)) + .route("/webseeds", get(h_torrents_webseeds)) + .route("/export", get(h_torrents_export)) + .route("/pieceStates", get(h_torrents_piece_states)) + .route("/pieceHashes", get(h_torrents_piece_hashes)) .route("/add", post(h_torrents_add)) .route("/pause", post(h_torrents_pause)) .route("/resume", post(h_torrents_resume)) + .route("/stop", post(h_torrents_stop)) + .route("/start", post(h_torrents_start)) .route("/recheck", post(h_torrents_recheck)) + .route("/reannounce", post(h_torrents_reannounce)) + .route("/rename", post(h_torrents_rename)) + .route("/renameFile", post(h_torrents_rename_file)) + .route("/renameFolder", post(h_torrents_rename_folder)) .route("/delete", post(h_torrents_delete)) + .route("/addTrackers", post(h_torrents_add_trackers)) + .route("/removeTrackers", post(h_torrents_remove_trackers)) + .route("/editTracker", post(h_torrents_edit_tracker)) + .route("/addPeers", post(h_torrents_add_peers)) + .route("/filePrio", post(h_torrents_file_prio)) + .route("/downloadLimit", get(h_torrents_download_limit)) + .route("/uploadLimit", get(h_torrents_upload_limit)) + .route("/setDownloadLimit", post(h_torrents_set_download_limit)) + .route("/setUploadLimit", post(h_torrents_set_upload_limit)) + .route("/setShareLimits", post(h_torrents_set_share_limits)) + .route("/setLocation", post(h_torrents_set_location)) + .route("/setSavePath", post(h_torrents_set_location)) .route("/setCategory", post(h_torrents_set_category)) + .route("/tags", get(h_torrents_tags)) + .route("/createTags", post(h_torrents_create_tags)) + .route("/deleteTags", post(h_torrents_delete_tags)) + .route("/addTags", post(h_torrents_add_tags)) + .route("/removeTags", post(h_torrents_remove_tags)) + .route("/setTags", post(h_torrents_set_tags)) .route("/categories", get(h_categories)) .route("/createCategory", post(h_create_category)) .route("/editCategory", post(h_edit_category)) .route("/removeCategories", post(h_remove_categories)); - // Transfer info - let transfer_router = Router::new().route("/info", get(h_transfer_info)); + // Transfer info + session speed limits + let transfer_router = Router::new() + .route("/info", get(h_transfer_info)) + .route("/downloadLimit", get(h_transfer_download_limit)) + .route("/uploadLimit", get(h_transfer_upload_limit)) + .route("/setDownloadLimit", post(h_transfer_set_download_limit)) + .route("/setUploadLimit", post(h_transfer_set_upload_limit)) + .route("/speedLimitsMode", get(h_transfer_speed_limits_mode)) + .route( + "/toggleSpeedLimitsMode", + post(h_transfer_toggle_speed_limits_mode), + ) + .route( + "/setSpeedLimitsMode", + post(h_transfer_set_speed_limits_mode), + ) + .route("/pauseSession", post(h_transfer_pause_session)) + .route("/resumeSession", post(h_transfer_resume_session)); + + // Sync (main polling endpoint). Must stay nested inside protected_router so + // it runs behind the SID-cookie auth layer. + let sync_router = Router::new().route("/maindata", get(h_sync_maindata)); + + // Torrent creator task API (also behind the auth layer). + let torrentcreator_router = Router::new() + .route("/addTask", post(h_torrentcreator_add_task)) + .route("/status", get(h_torrentcreator_status)) + .route("/torrentFile", get(h_torrentcreator_torrent_file)) + .route("/deleteTask", post(h_torrentcreator_delete_task)); let protected_router = Router::new() .nest("/app", app_router) .nest("/torrents", torrents_router) .nest("/transfer", transfer_router) + .nest("/sync", sync_router) + .nest("/torrentcreator", torrentcreator_router) .route_layer({ let qbit_state_for_layer = qbit_state.clone(); axum::middleware::from_fn( @@ -1326,7 +2819,8 @@ mod tests { }; use super::{ - QbitSessions, QbitState, h_app_preferences, h_app_set_preferences, h_torrents_recheck, + CreatorStatus, QbitSessions, QbitShareLimits, QbitState, QbitTags, QbitTorrentCreator, + h_app_preferences, h_app_set_preferences, h_torrentcreator_add_task, h_torrents_recheck, matches_category, qbit_file_name, }; @@ -1358,6 +2852,7 @@ mod tests { api, Some(HttpApiOptions { web_ui_port: Some(3031), + allow_create: true, ..Default::default() }), )); @@ -1365,6 +2860,9 @@ mod tests { Arc::new(QbitState { api_state, sessions: QbitSessions::new(), + tags: QbitTags::default(), + creator: QbitTorrentCreator::default(), + share_limits: QbitShareLimits::default(), }), session, output, @@ -1426,7 +2924,7 @@ mod tests { } #[tokio::test] - async fn recheck_endpoint_accepts_qbittorrent_hash_form() { + async fn recheck_endpoint_accepts_qbit_hash_form() { let (state, session, output) = qbit_state().await; std::fs::write(output.path().join("payload.bin"), vec![0x71; 32 * 1024]).unwrap(); let torrent = create_torrent( @@ -1471,8 +2969,189 @@ mod tests { assert!(matches!(handle.stats().state, TorrentStatsState::Paused)); } + #[tokio::test] + async fn per_torrent_rate_limits_round_trip_on_handle() { + use std::num::NonZeroU32; + + let (_state, session, output) = qbit_state().await; + std::fs::write(output.path().join("payload.bin"), vec![0x71; 32 * 1024]).unwrap(); + let torrent = create_torrent( + output.path(), + CreateTorrentOptions { + piece_length: Some(16_384), + ..Default::default() + }, + &BlockingSpawner::new(1), + ) + .await + .unwrap() + .as_bytes() + .unwrap() + .to_vec(); + let handle = session + .add_torrent( + AddTorrent::from_bytes(torrent), + Some(AddTorrentOptions { + paused: true, + overwrite: true, + output_folder: Some(output.path().to_string_lossy().into_owned()), + ..Default::default() + }), + ) + .await + .unwrap() + .into_handle() + .unwrap(); + handle.wait_until_initialized().await.unwrap(); + + // Defaults to unlimited. + assert_eq!(handle.rate_limits().download_bps, None); + assert_eq!(handle.rate_limits().upload_bps, None); + + // Each direction is set independently and persists on the override + // (this torrent is paused, so there is no live limiter to update). + handle.set_download_limit(NonZeroU32::new(4096)); + handle.set_upload_limit(NonZeroU32::new(8192)); + assert_eq!(handle.rate_limits().download_bps, NonZeroU32::new(4096)); + assert_eq!(handle.rate_limits().upload_bps, NonZeroU32::new(8192)); + + // Setting one leaves the other untouched. + handle.set_download_limit(NonZeroU32::new(1024)); + assert_eq!(handle.rate_limits().download_bps, NonZeroU32::new(1024)); + assert_eq!(handle.rate_limits().upload_bps, NonZeroU32::new(8192)); + + // None clears the limit. + handle.set_download_limit(None); + assert_eq!(handle.rate_limits().download_bps, None); + } + + #[tokio::test] + async fn rename_file_moves_on_disk_and_updates_metadata_when_paused() { + let (_state, session, output) = qbit_state().await; + std::fs::write(output.path().join("payload.bin"), vec![0x71; 32 * 1024]).unwrap(); + let torrent = create_torrent( + output.path(), + CreateTorrentOptions { + piece_length: Some(16_384), + ..Default::default() + }, + &BlockingSpawner::new(1), + ) + .await + .unwrap() + .as_bytes() + .unwrap() + .to_vec(); + let handle = session + .add_torrent( + AddTorrent::from_bytes(torrent), + Some(AddTorrentOptions { + paused: true, + overwrite: true, + output_folder: Some(output.path().to_string_lossy().into_owned()), + ..Default::default() + }), + ) + .await + .unwrap() + .into_handle() + .unwrap(); + handle.wait_until_initialized().await.unwrap(); + + // Discover file 0's real on-disk location from the metadata. + let old_rel = handle + .with_metadata(|m| m.file_infos[0].relative_filename.clone()) + .unwrap(); + let old_abs = output.path().join(&old_rel); + assert!(old_abs.exists(), "expected file at {old_abs:?}"); + + // A path escaping the root is rejected before anything moves. + assert!( + handle + .rename_files(&[(0, std::path::PathBuf::from("../escape.bin"))]) + .is_err() + ); + assert!(old_abs.exists(), "rejected rename must not move anything"); + + // A valid rename moves the file on disk and updates file_infos. + let new_rel = std::path::PathBuf::from("renamed_dir/renamed.bin"); + handle.rename_files(&[(0, new_rel.clone())]).unwrap(); + assert_eq!( + handle + .with_metadata(|m| m.file_infos[0].relative_filename.clone()) + .unwrap(), + new_rel + ); + assert!(!old_abs.exists(), "old path should be gone"); + assert!( + output.path().join(&new_rel).exists(), + "new path should exist" + ); + + // The display-name override is independent of file renames. + assert!(handle.name().is_some()); + handle.set_display_name(Some("Custom Name".to_string())); + assert_eq!(handle.name().as_deref(), Some("Custom Name")); + handle.set_display_name(Some(" ".to_string())); + assert_ne!( + handle.name().as_deref(), + Some(" "), + "blank name clears override" + ); + } + + #[tokio::test] + async fn set_location_relocates_torrent_files_when_paused() { + let (_state, session, output) = qbit_state().await; + std::fs::write(output.path().join("payload.bin"), vec![0x71; 32 * 1024]).unwrap(); + let torrent = create_torrent( + output.path(), + CreateTorrentOptions { + piece_length: Some(16_384), + ..Default::default() + }, + &BlockingSpawner::new(1), + ) + .await + .unwrap() + .as_bytes() + .unwrap() + .to_vec(); + let handle = session + .add_torrent( + AddTorrent::from_bytes(torrent), + Some(AddTorrentOptions { + paused: true, + overwrite: true, + output_folder: Some(output.path().to_string_lossy().into_owned()), + ..Default::default() + }), + ) + .await + .unwrap() + .into_handle() + .unwrap(); + handle.wait_until_initialized().await.unwrap(); + + let rel = handle + .with_metadata(|m| m.file_infos[0].relative_filename.clone()) + .unwrap(); + let old_abs = output.path().join(&rel); + assert!(old_abs.exists(), "expected file at {old_abs:?}"); + + // Relocate to a sibling directory on the same filesystem. + let new_root = output.path().join("relocated"); + handle.set_location(new_root.clone()).unwrap(); + + assert!(!old_abs.exists(), "old location should be gone"); + assert!( + new_root.join(&rel).exists(), + "file should now live under the new root" + ); + } + #[test] - fn category_filter_supports_qbittorrent_special_values() { + fn category_filter_supports_qbit_special_values() { assert!(matches_category("all", "Linux ISOs")); assert!(matches_category("all", "")); assert!(matches_category("uncategorized", "")); @@ -1543,4 +3222,188 @@ mod tests { let result = i64::try_from(now).unwrap_or(i64::MAX); assert_eq!(result, 1_700_000_000i64); } + + #[test] + fn parse_tags_trims_splits_and_drops_empties() { + assert_eq!( + super::parse_tags("tv, , radarr ,,sonarr"), + vec!["tv".to_string(), "radarr".to_string(), "sonarr".to_string()] + ); + assert!(super::parse_tags("").is_empty()); + assert!(super::parse_tags(" , ,").is_empty()); + } + + #[test] + fn tag_store_add_remove_delete_and_set() { + let tags = QbitTags::default(); + let a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(); + let b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(); + + // createTags adds to the global set without touching any torrent. + tags.create(&["tv".to_string(), "hd".to_string()]); + assert_eq!(tags.all_tags(), vec!["hd".to_string(), "tv".to_string()]); + assert_eq!(tags.tags_for(&a), ""); + + // addTags associates tags with torrents (and registers new ones). + tags.add_to( + &[a.clone(), b.clone()], + &["tv".to_string(), "new".to_string()], + ); + assert!(tags.has_tag(&a, "tv")); + assert!(tags.has_tag(&b, "new")); + assert!(tags.all_tags().contains(&"new".to_string())); + + // removeTags with a specific tag only drops it from the given torrent. + tags.remove_from(&[a.clone()], &["tv".to_string()]); + assert!(!tags.has_tag(&a, "tv")); + assert!(tags.has_tag(&a, "new")); + assert!(tags.has_tag(&b, "tv")); + + // setTags replaces the whole tag set of a torrent. + tags.set(&[b.clone()], &["only".to_string()]); + assert_eq!(tags.tags_for(&b), "only"); + + // deleteTags removes a tag globally and from every torrent. + tags.delete(&["new".to_string()]); + assert!(!tags.has_tag(&a, "new")); + assert!(!tags.all_tags().contains(&"new".to_string())); + + // removeTags with an empty list clears all tags on the torrent. + tags.add_to(&[a.clone()], &["x".to_string(), "y".to_string()]); + tags.remove_from(&[a.clone()], &[]); + assert_eq!(tags.tags_for(&a), ""); + } + + #[test] + fn offset_limit_pagination_matches_qbittorrent_semantics() { + use super::apply_offset_limit; + let v = || vec![0, 1, 2, 3, 4]; + // Normal window. + assert_eq!(apply_offset_limit(v(), 1, Some(2)), vec![1, 2]); + // Offset past the end -> empty (not the whole list). + assert_eq!(apply_offset_limit(v(), 5, None), Vec::::new()); + assert_eq!(apply_offset_limit(v(), 99, Some(3)), Vec::::new()); + // Offset exactly at the end -> empty. + assert_eq!(apply_offset_limit(v(), 5, None), Vec::::new()); + // limit == 0 means no limit. + assert_eq!(apply_offset_limit(v(), 0, Some(0)), vec![0, 1, 2, 3, 4]); + // No offset, no limit. + assert_eq!(apply_offset_limit(v(), 0, None), vec![0, 1, 2, 3, 4]); + } + + #[test] + fn share_limits_store_set_and_get() { + let store = super::QbitShareLimits::default(); + assert_eq!(store.get("h1"), None); + store.set(&["h1".to_string(), "h2".to_string()], 2.0, 120); + assert_eq!(store.get("h1"), Some((2.0, 120))); + assert_eq!(store.get("h2"), Some((2.0, 120))); + // Re-setting overwrites, and -1 encodes "no limit". + store.set(&["h1".to_string()], -1.0, -1); + assert_eq!(store.get("h1"), Some((-1.0, -1))); + assert_eq!(store.get("h2"), Some((2.0, 120))); + } + + #[test] + fn torrent_creator_task_store_lifecycle() { + let creator = QbitTorrentCreator::default(); + let id = creator.new_task("/data/movie".to_string(), "v1".to_string()); + let task = creator.get(&id).unwrap(); + assert!(matches!(task.status, CreatorStatus::Running)); + assert_eq!(task.source_path, "/data/movie"); + assert_eq!(creator.all().len(), 1); + + creator.finish( + &id, + Some(bytes::Bytes::from_static(b"torrent")), + "abc".to_string(), + ); + let task = creator.get(&id).unwrap(); + assert!(matches!(task.status, CreatorStatus::Finished)); + assert_eq!(task.info_hash.as_deref(), Some("abc")); + assert_eq!(task.torrent_bytes.as_deref(), Some(&b"torrent"[..])); + + let failing = creator.new_task("/data/x".to_string(), "v2".to_string()); + creator.fail(&failing, "boom".to_string()); + let task = creator.get(&failing).unwrap(); + assert!(matches!(task.status, CreatorStatus::Failed)); + assert_eq!(task.error.as_deref(), Some("boom")); + + assert!(creator.remove(&id)); + assert!(!creator.remove(&id)); + assert!(creator.get(&id).is_none()); + assert_eq!(creator.all().len(), 1); + } + + #[tokio::test] + async fn torrentcreator_add_task_produces_a_torrent_file() { + let (state, _session, output) = qbit_state().await; + std::fs::write(output.path().join("data.bin"), vec![7u8; 4096]).unwrap(); + + let body = Bytes::from(format!("sourcePath={}", output.path().to_string_lossy())); + let response = h_torrentcreator_add_task(axum::extract::State(state.clone()), body) + .await + .into_response(); + assert_eq!(response.status(), StatusCode::OK); + + // The task is registered synchronously; poll it to completion. + let all = state.creator.all(); + assert_eq!(all.len(), 1); + let (id, _) = all.into_iter().next().unwrap(); + for _ in 0..200 { + if !matches!( + state.creator.get(&id).unwrap().status, + CreatorStatus::Running + ) { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + let task = state.creator.get(&id).unwrap(); + assert!( + matches!(task.status, CreatorStatus::Finished), + "creation did not finish: {:?}", + task.error + ); + assert!( + task.torrent_bytes.is_some(), + "expected generated torrent bytes" + ); + assert!(task.info_hash.is_some()); + } + + #[test] + fn tracker_status_maps_to_qbit_codes() { + use super::qbit_tracker_status; + use tracker_comms::TrackerAnnounceState::*; + assert_eq!(qbit_tracker_status(Disabled), 0); + assert_eq!(qbit_tracker_status(NotContacted), 1); + assert_eq!(qbit_tracker_status(Working), 2); + assert_eq!(qbit_tracker_status(Updating), 3); + assert_eq!(qbit_tracker_status(Error), 4); + } + + #[test] + fn rate_limit_conversions_round_trip_and_treat_zero_as_unlimited() { + use std::num::NonZeroU32; + assert_eq!(super::limit_to_bps(0), None); + assert_eq!(super::bps_to_u64(None), 0); + assert_eq!(super::limit_to_bps(1024), NonZeroU32::new(1024)); + assert_eq!(super::bps_to_u64(NonZeroU32::new(1024)), 1024); + // Values beyond u32 saturate rather than overflow. + assert_eq!(super::limit_to_bps(u64::MAX), NonZeroU32::new(u32::MAX)); + } + + #[test] + fn per_torrent_limit_treats_non_positive_as_unlimited() { + use std::num::NonZeroU32; + assert_eq!(super::parse_torrent_limit(0), None); + assert_eq!(super::parse_torrent_limit(-1), None); + assert_eq!(super::parse_torrent_limit(-999), None); + assert_eq!(super::parse_torrent_limit(2048), NonZeroU32::new(2048)); + assert_eq!( + super::parse_torrent_limit(i64::MAX), + NonZeroU32::new(u32::MAX) + ); + } } diff --git a/crates/librtbit/src/http_api/handlers/qbit_parity.rs b/crates/librtbit/src/http_api/handlers/qbit_parity.rs new file mode 100644 index 0000000..dde92ff --- /dev/null +++ b/crates/librtbit/src/http_api/handlers/qbit_parity.rs @@ -0,0 +1,230 @@ +//! qBittorrent WebUI API parity checker. +//! +//! `qbit_parity_spec.json` is a machine-readable inventory of the upstream +//! qBittorrent WebUI API v2 surface (extracted from the qBittorrent sources), +//! annotated with the parity status of each endpoint in our compat layer +//! (`qbit_compat.rs`). These tests keep the spec and the actual router from +//! drifting apart: +//! +//! - every endpoint marked `full` or `partial` must be routed; +//! - every endpoint marked `missing` or `out_of_scope` must return 404; +//! - every route literal in `qbit_compat.rs` must be tracked in the spec. +//! +//! When implementing a new compat endpoint, flip its `status` in the spec +//! (and drop a note about any semantic gaps). To refresh the upstream +//! inventory against a newer qBittorrent checkout, run +//! `scripts/refresh-qbit-parity-spec.py `. + +use std::{collections::HashSet, net::Ipv4Addr, sync::Arc}; + +use http::StatusCode; +use serde::Deserialize; +use tower::ServiceExt; + +use crate::{ + Api, ListenerMode, Session, SessionOptions, + http_api::{HttpApi, HttpApiOptions}, + listen::ListenerOptions, +}; + +const SPEC_JSON: &str = include_str!("qbit_parity_spec.json"); +const COMPAT_SOURCE: &str = include_str!("qbit_compat.rs"); + +#[derive(Deserialize)] +struct Spec { + upstream: UpstreamInfo, + endpoints: Vec, +} + +#[derive(Deserialize)] +struct UpstreamInfo { + webapi_version: String, +} + +#[derive(Deserialize)] +struct Endpoint { + /// `controller/action`, e.g. `torrents/info`. + endpoint: String, + method: String, + status: Status, + /// False for legacy endpoints we serve that upstream has since removed. + #[serde(default = "default_true")] + upstream: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(Deserialize, Clone, Copy, PartialEq, Eq, Debug)] +#[serde(rename_all = "snake_case")] +enum Status { + /// Routed, semantics match qBittorrent closely enough for real clients. + Full, + /// Routed, but with stubbed fields or semantic gaps (see notes). + Partial, + /// Not routed; candidate for future parity work. + Missing, + /// Not routed by explicit decision. + OutOfScope, +} + +impl Status { + fn is_routed(self) -> bool { + matches!(self, Status::Full | Status::Partial) + } +} + +fn parse_spec() -> Spec { + serde_json::from_str(SPEC_JSON).expect("qbit_parity_spec.json must be valid JSON") +} + +async fn make_router() -> (axum::Router, Arc, tempfile::TempDir) { + let output = tempfile::TempDir::with_prefix("qbit_parity").unwrap(); + let session = Session::new_with_opts( + output.path().to_owned(), + SessionOptions { + disable_dht: true, + disable_local_service_discovery: true, + listen: Some(ListenerOptions { + mode: ListenerMode::TcpOnly, + listen_addr: (Ipv4Addr::LOCALHOST, 0).into(), + ..Default::default() + }), + ..Default::default() + }, + ) + .await + .unwrap(); + let api = Api::new( + session.clone(), + None, + #[cfg(feature = "tracing-subscriber-utils")] + None, + ); + let api_state = Arc::new(HttpApi::new(api, Some(HttpApiOptions::default()))); + ( + super::qbit_compat::make_qbit_router(api_state), + session, + output, + ) +} + +#[test] +fn spec_is_well_formed() { + let spec = parse_spec(); + assert!(!spec.upstream.webapi_version.is_empty()); + let mut seen = HashSet::new(); + for ep in &spec.endpoints { + assert!( + seen.insert(ep.endpoint.as_str()), + "duplicate spec entry: {}", + ep.endpoint + ); + assert!( + matches!(ep.method.as_str(), "GET" | "POST"), + "{}: unexpected method {}", + ep.endpoint, + ep.method + ); + assert!( + ep.endpoint.split('/').count() == 2, + "{}: endpoint must be controller/action", + ep.endpoint + ); + if !ep.upstream { + assert!( + ep.status.is_routed(), + "{}: non-upstream (legacy) entries only make sense if we route them", + ep.endpoint + ); + } + } +} + +/// Probes every spec endpoint against the real compat router and fails on any +/// mismatch between the declared parity status and what is actually routed. +#[tokio::test] +async fn spec_matches_router() { + let spec = parse_spec(); + let (router, _session, _output) = make_router().await; + + let mut violations = Vec::new(); + for ep in &spec.endpoints { + let request = http::Request::builder() + .method(ep.method.as_str()) + .uri(format!("/{}", ep.endpoint)) + .header("content-type", "application/x-www-form-urlencoded") + .body(axum::body::Body::empty()) + .unwrap(); + let status = router.clone().oneshot(request).await.unwrap().status(); + // Anything but 404/405 (including 400 for probes lacking required + // params) proves the route exists with the expected method. + let routed = status != StatusCode::NOT_FOUND && status != StatusCode::METHOD_NOT_ALLOWED; + if routed != ep.status.is_routed() { + violations.push(format!( + "{} {} is marked {:?} in qbit_parity_spec.json but the router returned {status}", + ep.method, ep.endpoint, ep.status + )); + } + } + + assert!( + violations.is_empty(), + "qbit compat router and qbit_parity_spec.json disagree; \ + update the spec status (or the router) for:\n{}", + violations.join("\n") + ); +} + +/// Every `.route("...")` literal in qbit_compat.rs must correspond to a spec +/// entry marked as routed, so new compat routes can't land untracked. +#[test] +fn every_compat_route_is_tracked_in_spec() { + let spec = parse_spec(); + let routed_actions: HashSet<&str> = spec + .endpoints + .iter() + .filter(|ep| ep.status.is_routed()) + .filter_map(|ep| ep.endpoint.split_once('/').map(|(_, action)| action)) + .collect(); + + let mut untracked = Vec::new(); + for (idx, _) in COMPAT_SOURCE.match_indices(".route(\"/") { + let start = idx + ".route(\"/".len(); + let action = COMPAT_SOURCE[start..] + .split('"') + .next() + .expect("unterminated route literal"); + if !routed_actions.contains(action) { + untracked.push(action); + } + } + + assert!( + untracked.is_empty(), + "routes in qbit_compat.rs with no full/partial entry in qbit_parity_spec.json: {untracked:?}" + ); +} + +/// Not an assertion — prints the parity scoreboard (visible with +/// `cargo test -p swarmforge qbit_parity -- --nocapture`). +#[test] +fn parity_summary() { + let spec = parse_spec(); + let upstream: Vec<&Endpoint> = spec.endpoints.iter().filter(|ep| ep.upstream).collect(); + let count = |status: Status| upstream.iter().filter(|ep| ep.status == status).count(); + let (full, partial, missing, oos) = ( + count(Status::Full), + count(Status::Partial), + count(Status::Missing), + count(Status::OutOfScope), + ); + let implemented = full + partial; + eprintln!( + "qBittorrent WebUI API v{} parity: {implemented}/{} endpoints routed \ + ({full} full, {partial} partial, {missing} missing, {oos} out of scope)", + spec.upstream.webapi_version, + upstream.len(), + ); +} diff --git a/crates/librtbit/src/http_api/handlers/qbit_parity_spec.json b/crates/librtbit/src/http_api/handlers/qbit_parity_spec.json new file mode 100644 index 0000000..5fd05ad --- /dev/null +++ b/crates/librtbit/src/http_api/handlers/qbit_parity_spec.json @@ -0,0 +1,758 @@ +{ + "upstream": { + "webapi_version": "2.16.2", + "source_commit": "fe4506e8c6af67cd49720b8254d7b97fe5504a69", + "extracted_on": "2026-08-30" + }, + "endpoints": [ + { + "endpoint": "app/buildInfo", + "method": "GET", + "status": "partial", + "notes": "Placeholder values for qt/libtorrent/boost/openssl." + }, + { + "endpoint": "app/cookies", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent-internal app feature (2026-08-30 descoping decision)." + }, + { + "endpoint": "app/defaultSavePath", + "method": "GET", + "status": "full" + }, + { + "endpoint": "app/deleteAPIKey", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent-internal app feature (2026-08-30 descoping decision)." + }, + { + "endpoint": "app/getDirectoryContent", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent-internal app feature (2026-08-30 descoping decision)." + }, + { + "endpoint": "app/getFreeSpaceAtPath", + "method": "GET", + "status": "missing" + }, + { + "endpoint": "app/networkInterfaceAddressList", + "method": "GET", + "status": "missing" + }, + { + "endpoint": "app/networkInterfaceList", + "method": "GET", + "status": "missing" + }, + { + "endpoint": "app/preferences", + "method": "GET", + "status": "partial", + "notes": "Small subset of preference fields." + }, + { + "endpoint": "app/processInfo", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent-internal app feature (2026-08-30 descoping decision)." + }, + { + "endpoint": "app/rotateAPIKey", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent-internal app feature (2026-08-30 descoping decision)." + }, + { + "endpoint": "app/sendTestEmail", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent-internal app feature (2026-08-30 descoping decision)." + }, + { + "endpoint": "app/setCookies", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent-internal app feature (2026-08-30 descoping decision)." + }, + { + "endpoint": "app/setPreferences", + "method": "POST", + "status": "partial", + "notes": "Only announce_port is settable; other fields rejected." + }, + { + "endpoint": "app/shutdown", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "app/version", + "method": "GET", + "status": "partial", + "notes": "Returns a hardcoded version string, not a real qBittorrent version." + }, + { + "endpoint": "app/webapiVersion", + "method": "GET", + "status": "full" + }, + { + "endpoint": "auth/login", + "method": "POST", + "status": "full" + }, + { + "endpoint": "auth/logout", + "method": "POST", + "status": "full" + }, + { + "endpoint": "clientdata/load", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: WebUI client-state KV store; not needed for targeted clients." + }, + { + "endpoint": "clientdata/store", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: WebUI client-state KV store; not needed for targeted clients." + }, + { + "endpoint": "log/main", + "method": "GET", + "status": "missing", + "notes": "No persistent log ring buffer; native API streams logs instead." + }, + { + "endpoint": "log/peers", + "method": "GET", + "status": "missing" + }, + { + "endpoint": "rss/addFeed", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/addFolder", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/cloneRule", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/items", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/markAsRead", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/matchingArticles", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/moveItem", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/refreshItem", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/removeItem", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/removeRule", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/renameRule", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/rules", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/setFeedRefreshInterval", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/setFeedURL", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/setRule", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "search/delete", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/downloadTorrent", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/enablePlugin", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/installPlugin", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/plugins", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/results", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/start", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/status", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/stop", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/uninstallPlugin", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/updatePlugins", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "sync/maindata", + "method": "GET", + "status": "partial", + "notes": "Always returns a full_update snapshot (no per-client rid deltas); free_space_on_disk and dht_nodes stubbed." + }, + { + "endpoint": "sync/torrentPeers", + "method": "GET", + "status": "missing" + }, + { + "endpoint": "torrentcreator/addTask", + "method": "POST", + "status": "partial", + "notes": "Bridges native create_torrent (sourcePath/trackers/pieceSize/name); other options ignored. Requires server create to be enabled. Tasks not persisted across restarts." + }, + { + "endpoint": "torrentcreator/deleteTask", + "method": "POST", + "status": "full", + "notes": "Removes a task from the in-memory store." + }, + { + "endpoint": "torrentcreator/status", + "method": "GET", + "status": "full", + "notes": "In-memory task status (Running/Finished/Failed)." + }, + { + "endpoint": "torrentcreator/torrentFile", + "method": "GET", + "status": "full", + "notes": "Returns the generated .torrent bytes for a finished task." + }, + { + "endpoint": "torrents/SSLParameters", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: per-torrent SSL certificates not planned." + }, + { + "endpoint": "torrents/add", + "method": "POST", + "status": "partial", + "notes": "Supports urls/torrents/savepath/category/paused; other form fields ignored." + }, + { + "endpoint": "torrents/addPeers", + "method": "POST", + "status": "partial", + "notes": "Adds peers to live torrents; silently skips torrents that are not yet live." + }, + { + "endpoint": "torrents/addTags", + "method": "POST", + "status": "partial", + "notes": "In-memory tag store (not persisted across restarts); rtbit has no native tags." + }, + { + "endpoint": "torrents/addTrackers", + "method": "POST", + "status": "full" + }, + { + "endpoint": "torrents/addWebSeeds", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/bottomPrio", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/categories", + "method": "GET", + "status": "full" + }, + { + "endpoint": "torrents/count", + "method": "GET", + "status": "full" + }, + { + "endpoint": "torrents/createCategory", + "method": "POST", + "status": "full" + }, + { + "endpoint": "torrents/createTags", + "method": "POST", + "status": "partial", + "notes": "In-memory tag store (not persisted across restarts); rtbit has no native tags." + }, + { + "endpoint": "torrents/decreasePrio", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/delete", + "method": "POST", + "status": "full" + }, + { + "endpoint": "torrents/deleteTags", + "method": "POST", + "status": "partial", + "notes": "In-memory tag store (not persisted across restarts); rtbit has no native tags." + }, + { + "endpoint": "torrents/downloadFile", + "method": "GET", + "status": "missing" + }, + { + "endpoint": "torrents/downloadLimit", + "method": "GET", + "status": "partial", + "notes": "Per-torrent limit persisted on the torrent and enforced while live (resets are avoided across pause/unpause)." + }, + { + "endpoint": "torrents/editCategory", + "method": "POST", + "status": "full" + }, + { + "endpoint": "torrents/editTracker", + "method": "POST", + "status": "partial", + "notes": "Replaces origUrl with newUrl (remove + add); validates newUrl before removing so a bad newUrl is non-destructive." + }, + { + "endpoint": "torrents/editWebSeed", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/export", + "method": "GET", + "status": "partial", + "notes": "Returns raw .torrent bytes; 409 while a magnet is still resolving." + }, + { + "endpoint": "torrents/fetchMetadata", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/filePrio", + "method": "POST", + "status": "partial", + "notes": "Maps priority 0 to file exclusion and any non-zero priority to inclusion (no per-file priority tiers)." + }, + { + "endpoint": "torrents/files", + "method": "GET", + "status": "partial", + "notes": "piece_range is stubbed as [0, 0]." + }, + { + "endpoint": "torrents/increasePrio", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/info", + "method": "GET", + "status": "partial", + "notes": "Core fields real; several fields stubbed (tags, limits, magnet_uri, priority)." + }, + { + "endpoint": "torrents/parseMetadata", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/pause", + "method": "POST", + "status": "full", + "upstream": false, + "notes": "Legacy pre-2.11 name kept for old clients; upstream replaced it with torrents/stop." + }, + { + "endpoint": "torrents/pieceAvailability", + "method": "GET", + "status": "missing" + }, + { + "endpoint": "torrents/pieceHashes", + "method": "GET", + "status": "partial", + "notes": "v1 piece hashes from metadata; empty array while a magnet is still resolving or for v2-only torrents." + }, + { + "endpoint": "torrents/pieceStates", + "method": "GET", + "status": "partial", + "notes": "Emits 0 (not downloaded) / 2 (downloaded) from the have bitfield; the in-flight state 1 is not distinguished." + }, + { + "endpoint": "torrents/properties", + "method": "GET", + "status": "partial", + "notes": "Some fields stubbed (creation_date, comment, wasted, reannounce)." + }, + { + "endpoint": "torrents/reannounce", + "method": "POST", + "status": "partial", + "notes": "Signals a fresh tracker announce / peer re-discovery on live torrents; a no-op for paused or finished-idle torrents." + }, + { + "endpoint": "torrents/recheck", + "method": "POST", + "status": "full" + }, + { + "endpoint": "torrents/removeCategories", + "method": "POST", + "status": "full" + }, + { + "endpoint": "torrents/removeTags", + "method": "POST", + "status": "partial", + "notes": "In-memory tag store (not persisted across restarts); rtbit has no native tags." + }, + { + "endpoint": "torrents/removeTrackers", + "method": "POST", + "status": "partial", + "notes": "Removes URLs from the tracker set (persisted); an in-flight announce loop for a removed tracker stops at the next re-discovery." + }, + { + "endpoint": "torrents/removeWebSeeds", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/rename", + "method": "POST", + "status": "full", + "notes": "Sets the torrent's display name (name override); surfaced in torrents/info and sync/maindata. Not persisted across restarts." + }, + { + "endpoint": "torrents/renameFile", + "method": "POST", + "status": "partial", + "notes": "Moves the file on disk and updates metadata; requires the torrent stopped (409 if running). Not persisted across restarts." + }, + { + "endpoint": "torrents/renameFolder", + "method": "POST", + "status": "partial", + "notes": "Moves every file under the folder prefix; requires the torrent stopped (409 if running). Not persisted across restarts." + }, + { + "endpoint": "torrents/resume", + "method": "POST", + "status": "full", + "upstream": false, + "notes": "Legacy pre-2.11 name kept for old clients; upstream replaced it with torrents/start." + }, + { + "endpoint": "torrents/saveMetadata", + "method": "GET", + "status": "missing" + }, + { + "endpoint": "torrents/setAutoManagement", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/setCategory", + "method": "POST", + "status": "partial", + "notes": "Does not return 409 for unknown categories like qBittorrent does." + }, + { + "endpoint": "torrents/setComment", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/setDownloadLimit", + "method": "POST", + "status": "partial", + "notes": "Per-torrent limit persisted on the torrent and enforced while live (resets are avoided across pause/unpause)." + }, + { + "endpoint": "torrents/setDownloadPath", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/setForceStart", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/setLocation", + "method": "POST", + "status": "partial", + "notes": "Moves all files to a new root; stopped-only (409 if running) and same-filesystem only (cross-device rejected). Not persisted across restarts." + }, + { + "endpoint": "torrents/setSSLParameters", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: per-torrent SSL certificates not planned." + }, + { + "endpoint": "torrents/setSavePath", + "method": "POST", + "status": "partial", + "notes": "Alias of setLocation (id/path form): stopped-only, same-filesystem move. Not persisted across restarts." + }, + { + "endpoint": "torrents/setShareLimits", + "method": "POST", + "status": "partial", + "notes": "Stores ratio/seeding-time limits and reports them in torrents/info; not auto-enforced (clients apply their own removal). Not persisted across restarts." + }, + { + "endpoint": "torrents/setSuperSeeding", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/setTags", + "method": "POST", + "status": "partial", + "notes": "In-memory tag store (not persisted across restarts); rtbit has no native tags." + }, + { + "endpoint": "torrents/setUploadLimit", + "method": "POST", + "status": "partial", + "notes": "Per-torrent limit persisted on the torrent and enforced while live (resets are avoided across pause/unpause)." + }, + { + "endpoint": "torrents/start", + "method": "POST", + "status": "full", + "notes": "WebAPI 2.11+ name for torrents/resume; shares the resume handler." + }, + { + "endpoint": "torrents/stop", + "method": "POST", + "status": "full", + "notes": "WebAPI 2.11+ name for torrents/pause; shares the pause handler." + }, + { + "endpoint": "torrents/tags", + "method": "GET", + "status": "partial", + "notes": "In-memory tag store (not persisted across restarts); rtbit has no native tags." + }, + { + "endpoint": "torrents/toggleFirstLastPiecePrio", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/toggleSequentialDownload", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/topPrio", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/trackers", + "method": "GET", + "status": "partial", + "notes": "Real per-tracker announce state; tier always 0 and num_downloaded always -1 (not tracked)." + }, + { + "endpoint": "torrents/uploadLimit", + "method": "GET", + "status": "partial", + "notes": "Per-torrent limit persisted on the torrent and enforced while live (resets are avoided across pause/unpause)." + }, + { + "endpoint": "torrents/webseeds", + "method": "GET", + "status": "full" + }, + { + "endpoint": "transfer/banPeers", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "transfer/downloadLimit", + "method": "GET", + "status": "full" + }, + { + "endpoint": "transfer/getSpeedLimits", + "method": "GET", + "status": "missing" + }, + { + "endpoint": "transfer/info", + "method": "GET", + "status": "partial", + "notes": "Real session rate limits; dht_nodes stubbed and connection_status always connected." + }, + { + "endpoint": "transfer/pauseSession", + "method": "POST", + "status": "partial", + "notes": "Pauses every torrent (no global session pause)." + }, + { + "endpoint": "transfer/resumeSession", + "method": "POST", + "status": "partial", + "notes": "Resumes every torrent (no global session pause)." + }, + { + "endpoint": "transfer/setDownloadLimit", + "method": "POST", + "status": "full" + }, + { + "endpoint": "transfer/setSpeedLimits", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "transfer/setSpeedLimitsMode", + "method": "POST", + "status": "full", + "notes": "Sets the alt-speed mode (native /speed/alt)." + }, + { + "endpoint": "transfer/setUploadLimit", + "method": "POST", + "status": "full" + }, + { + "endpoint": "transfer/speedLimitsMode", + "method": "GET", + "status": "full", + "notes": "Reflects the alt-speed toggle (native /speed/alt)." + }, + { + "endpoint": "transfer/toggleSpeedLimitsMode", + "method": "POST", + "status": "full", + "notes": "Toggles the alt-speed mode (native /speed/alt)." + }, + { + "endpoint": "transfer/uploadLimit", + "method": "GET", + "status": "full" + } + ] +} diff --git a/crates/librtbit/src/session/mod.rs b/crates/librtbit/src/session/mod.rs index 9cf751b..61c6d35 100644 --- a/crates/librtbit/src/session/mod.rs +++ b/crates/librtbit/src/session/mod.rs @@ -952,9 +952,8 @@ impl Session { peer_connect_timeout: peer_opts.connect_timeout, peer_read_write_timeout: peer_opts.read_write_timeout, allow_overwrite: opts.overwrite, - output_folder, + output_folder: output_folder.clone(), output_folder_root, - ratelimits: opts.ratelimits, initial_peers: opts.initial_peers.clone().unwrap_or_default(), peer_limit: opts.peer_limit.or(self.peer_limit), #[cfg(feature = "disable-upload")] @@ -965,6 +964,9 @@ impl Session { magnet_name: name, web_seed_urls, category: RwLock::new(opts.category.clone()), + ratelimit_override: RwLock::new(opts.ratelimits), + name_override: RwLock::new(None), + output_folder_override: RwLock::new(output_folder), tracker_status, added_on: opts.added_on.unwrap_or_else(|| { std::time::SystemTime::now() @@ -1103,7 +1105,7 @@ impl Session { (Ok(storage), true) => { debug!("will delete files"); remove_files_and_dirs(&metadata.file_infos, &storage); - if removed.shared().options.output_folder != *self.output_folder.read() + if removed.output_folder() != *self.output_folder.read() && let Err(e) = storage.remove_directory_if_empty(Path::new("")) { warn!( @@ -1212,6 +1214,38 @@ impl Session { Ok(()) } + /// Remove tracker URLs from a torrent's configured set. Returns the number + /// of trackers actually removed. Any in-flight announce loop for a removed + /// tracker continues until the next re-discovery; this only changes the set + /// used for future announces (and persists the change). + pub async fn remove_trackers( + self: &Arc, + handle: &ManagedTorrentHandle, + trackers: &[String], + ) -> anyhow::Result { + // Match on parsed URL where possible so callers can pass either the + // exact stored string or an equivalent spelling; fall back to string. + let removed = { + let mut configured = handle.shared().trackers.write(); + let mut removed = 0; + for tracker in trackers { + let matched = url::Url::parse(tracker) + .ok() + .map(|url| configured.remove(&url)) + .unwrap_or(false); + if matched { + removed += 1; + } + } + removed + }; + + if removed > 0 { + self.try_update_persistence_metadata(handle).await; + } + Ok(removed) + } + pub async fn update_only_files( self: &Arc, handle: &ManagedTorrentHandle, @@ -1325,7 +1359,9 @@ impl Session { handle: &ManagedTorrentHandle, target_folder: PathBuf, ) -> anyhow::Result<()> { - let current_output = handle.shared().options.output_folder.clone(); + // Use the torrent's current root so a relocated torrent moves from + // where its files actually are, not the stale add-time folder. + let current_output = handle.output_folder(); // Don't move if already in the target folder if current_output == target_folder { diff --git a/crates/librtbit/src/storage/filesystem/fs.rs b/crates/librtbit/src/storage/filesystem/fs.rs index 601e493..7f151e3 100644 --- a/crates/librtbit/src/storage/filesystem/fs.rs +++ b/crates/librtbit/src/storage/filesystem/fs.rs @@ -5,6 +5,7 @@ use std::{ }; use anyhow::Context; +use parking_lot::RwLock; use tracing::warn; use crate::{ @@ -38,7 +39,7 @@ impl StorageFactory for FilesystemStorageFactory { _metadata: &TorrentMetadata, ) -> anyhow::Result { Ok(FilesystemStorage { - output_folder: shared.options.output_folder.clone(), + output_folder: RwLock::new(shared.output_folder_override.read().clone()), opened_files: Default::default(), read_only: self.read_only, }) @@ -50,7 +51,9 @@ impl StorageFactory for FilesystemStorageFactory { } pub struct FilesystemStorage { - pub(super) output_folder: PathBuf, + /// Storage root. Mutable so a whole-torrent relocation (`move_root`) can + /// re-anchor the storage; only cold paths (init/remove/rename/move) read it. + pub(super) output_folder: RwLock, pub(super) opened_files: Vec, read_only: bool, } @@ -70,7 +73,7 @@ impl FilesystemStorage { .iter() .map(|f| f.take_clone()) .collect::>>()?, - output_folder: self.output_folder.clone(), + output_folder: RwLock::new(self.output_folder.read().clone()), read_only: self.read_only, }) } @@ -110,7 +113,43 @@ impl TorrentStorage for FilesystemStorage { fn remove_file(&self, _file_id: usize, filename: &Path) -> anyhow::Result<()> { self.require_writable()?; - Ok(std::fs::remove_file(self.output_folder.join(filename))?) + Ok(std::fs::remove_file( + self.output_folder.read().join(filename), + )?) + } + + fn rename_file(&self, file_id: usize, new_relative: &Path) -> anyhow::Result<()> { + self.require_writable()?; + let of = self.opened_files.get(file_id).context("no such file")?; + let new_full = self.output_folder.read().join(new_relative); + of.rename_to(&new_full) + } + + fn move_root(&self, new_root: &Path) -> anyhow::Result<()> { + self.require_writable()?; + let old_root = self.output_folder.read().clone(); + if old_root == new_root { + return Ok(()); + } + // Move each file to the same relative location under the new root. On + // any failure (including a cross-filesystem EXDEV rename), roll the + // already-moved files back before returning. + for (moved, of) in self.opened_files.iter().enumerate() { + if let Err(error) = of.rebase(&old_root, new_root) { + for prev in self.opened_files[..moved].iter().rev() { + if let Err(rollback_error) = prev.rebase(new_root, &old_root) { + warn!( + %rollback_error, + "error rolling back a file after a failed relocation; \ + storage may be left inconsistent for this torrent" + ); + } + } + return Err(error).context("error relocating torrent; rolled back"); + } + } + *self.output_folder.write() = new_root.to_owned(); + Ok(()) } fn ensure_file_length(&self, file_id: usize, len: u64) -> anyhow::Result<()> { @@ -136,14 +175,14 @@ impl TorrentStorage for FilesystemStorage { .iter() .map(|f| f.take_clone()) .collect::>>()?, - output_folder: self.output_folder.clone(), + output_folder: RwLock::new(self.output_folder.read().clone()), read_only: self.read_only, })) } fn remove_directory_if_empty(&self, path: &Path) -> anyhow::Result<()> { self.require_writable()?; - let path = self.output_folder.join(path); + let path = self.output_folder.read().join(path); if !path.is_dir() { anyhow::bail!("cannot remove dir: {path:?} is not a directory") } @@ -165,8 +204,9 @@ impl TorrentStorage for FilesystemStorage { metadata: &TorrentMetadata, ) -> anyhow::Result<()> { let mut files = Vec::::new(); + let output_folder = self.output_folder.read().clone(); for file_details in metadata.file_infos.iter() { - let mut full_path = self.output_folder.clone(); + let mut full_path = output_folder.clone(); let relative_path = &file_details.relative_filename; full_path.push(relative_path); @@ -242,7 +282,7 @@ mod tests { opened_files.push(OpenedFile::new(path, f)); } let storage = FilesystemStorage { - output_folder: dir.path().to_owned(), + output_folder: RwLock::new(dir.path().to_owned()), opened_files, read_only: false, }; @@ -388,6 +428,122 @@ mod tests { assert!(file1_path.exists()); } + #[test] + fn test_storage_rename_file_moves_and_keeps_handle_usable() { + let (storage, dir) = make_test_storage(1); + storage.ensure_file_length(0, 64).unwrap(); + storage.pwrite_all(0, 0, b"payload").unwrap(); + + // Rename into a fresh subdirectory (must be created). + storage + .rename_file(0, Path::new("sub/renamed.dat")) + .unwrap(); + + // Old path is gone, new path exists with the data. + assert!(!dir.path().join("file_0.dat").exists()); + let new_path = dir.path().join("sub/renamed.dat"); + assert!(new_path.exists()); + assert_eq!(&std::fs::read(&new_path).unwrap()[..7], b"payload"); + + // The cached handle still reads/writes at the new location. + let mut buf = vec![0u8; 7]; + storage.pread_exact(0, 0, &mut buf).unwrap(); + assert_eq!(&buf, b"payload"); + storage.pwrite_all(0, 0, b"updated").unwrap(); + assert_eq!(&std::fs::read(&new_path).unwrap()[..7], b"updated"); + } + + #[test] + fn test_storage_rename_file_refuses_to_clobber_existing_destination() { + let (storage, dir) = make_test_storage(1); + storage.ensure_file_length(0, 8).unwrap(); + storage.pwrite_all(0, 0, b"torrent!").unwrap(); + + // An unrelated file already sits at the target path. + let victim = dir.path().join("unrelated.dat"); + std::fs::write(&victim, b"precious").unwrap(); + + // The rename must be refused, and neither file may be touched. + assert!(storage.rename_file(0, Path::new("unrelated.dat")).is_err()); + assert_eq!(std::fs::read(&victim).unwrap(), b"precious"); + assert!(dir.path().join("file_0.dat").exists()); + // The torrent file's handle is still usable at its original location. + let mut buf = vec![0u8; 8]; + storage.pread_exact(0, 0, &mut buf).unwrap(); + assert_eq!(&buf, b"torrent!"); + } + + #[test] + fn test_storage_move_root_relocates_all_files_and_keeps_handles() { + let (storage, dir) = make_test_storage(2); + storage.ensure_file_length(0, 16).unwrap(); + storage.ensure_file_length(1, 16).unwrap(); + storage.pwrite_all(0, 0, b"aaaa").unwrap(); + storage.pwrite_all(1, 0, b"bbbb").unwrap(); + + let new_root = dir.path().join("moved"); + storage.move_root(&new_root).unwrap(); + + assert!(!dir.path().join("file_0.dat").exists()); + assert!(new_root.join("file_0.dat").exists()); + assert!(new_root.join("file_1.dat").exists()); + + // Handles still read at the new location, and a write lands there. + let mut buf = vec![0u8; 4]; + storage.pread_exact(0, 0, &mut buf).unwrap(); + assert_eq!(&buf, b"aaaa"); + storage.pwrite_all(1, 0, b"cccc").unwrap(); + assert_eq!( + &std::fs::read(new_root.join("file_1.dat")).unwrap()[..4], + b"cccc" + ); + + // The anchor moved: path-based ops now resolve under the new root. + storage.remove_file(0, Path::new("file_0.dat")).unwrap(); + assert!(!new_root.join("file_0.dat").exists()); + } + + #[test] + fn test_storage_move_root_rolls_back_on_partial_failure() { + let (storage, dir) = make_test_storage(2); + storage.ensure_file_length(0, 8).unwrap(); + storage.ensure_file_length(1, 8).unwrap(); + storage.pwrite_all(0, 0, b"keepme").unwrap(); + + let new_root = dir.path().join("dest"); + std::fs::create_dir_all(&new_root).unwrap(); + // Block the second file's destination so the relocation fails midway. + std::fs::write(new_root.join("file_1.dat"), b"blocker").unwrap(); + + assert!(storage.move_root(&new_root).is_err()); + + // File 0 was rolled back to the original root and is still usable. + assert!(dir.path().join("file_0.dat").exists()); + assert!(!new_root.join("file_0.dat").exists()); + let mut buf = vec![0u8; 6]; + storage.pread_exact(0, 0, &mut buf).unwrap(); + assert_eq!(&buf, b"keepme"); + // The unrelated blocker file is untouched. + assert_eq!( + std::fs::read(new_root.join("file_1.dat")).unwrap(), + b"blocker" + ); + } + + #[test] + fn test_storage_rename_file_rejected_when_read_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("payload.dat"); + std::fs::write(&path, b"x").unwrap(); + let file = OpenOptions::new().read(true).open(&path).unwrap(); + let storage = FilesystemStorage { + output_folder: RwLock::new(dir.path().to_owned()), + opened_files: vec![OpenedFile::new(path, file)], + read_only: true, + }; + assert!(storage.rename_file(0, Path::new("other.dat")).is_err()); + } + #[test] fn test_storage_take() { let (storage, _dir) = make_test_storage(1); @@ -454,7 +610,7 @@ mod tests { std::fs::write(&path, original).unwrap(); let file = OpenOptions::new().read(true).open(&path).unwrap(); let storage = FilesystemStorage { - output_folder: dir.path().to_owned(), + output_folder: RwLock::new(dir.path().to_owned()), opened_files: vec![OpenedFile::new(path.clone(), file)], read_only: true, }; diff --git a/crates/librtbit/src/storage/filesystem/mmap.rs b/crates/librtbit/src/storage/filesystem/mmap.rs index 4a77151..ffb75d3 100644 --- a/crates/librtbit/src/storage/filesystem/mmap.rs +++ b/crates/librtbit/src/storage/filesystem/mmap.rs @@ -78,6 +78,18 @@ impl TorrentStorage for MmapFilesystemStorage { self.fs.remove_file(file_id, filename) } + fn rename_file(&self, file_id: usize, new_relative: &Path) -> anyhow::Result<()> { + // The mmap stays valid across a rename on Unix (it is bound to the + // inode, not the name); the fs layer moves the file and reopens its fd. + self.fs.rename_file(file_id, new_relative) + } + + fn move_root(&self, new_root: &Path) -> anyhow::Result<()> { + // Same inode-based reasoning as rename_file: the mappings survive the + // move on Unix; the fs layer relocates the files and reopens the fds. + self.fs.move_root(new_root) + } + fn remove_directory_if_empty(&self, path: &Path) -> anyhow::Result<()> { self.fs.remove_directory_if_empty(path) } diff --git a/crates/librtbit/src/storage/filesystem/opened_file.rs b/crates/librtbit/src/storage/filesystem/opened_file.rs index 3bd49e3..526b7af 100644 --- a/crates/librtbit/src/storage/filesystem/opened_file.rs +++ b/crates/librtbit/src/storage/filesystem/opened_file.rs @@ -125,6 +125,42 @@ impl DerefMut for OpenedFileLocked { } } +/// Move the file behind an already-write-locked handle to `new_full` and +/// re-point the handle at it. Never clobbers (refuses an existing destination), +/// creates missing parent directories, and fails on a cross-filesystem move +/// rather than copying. A dummy/not-present file just records the new path. +fn rename_locked(g: &mut OpenedFileLocked, new_full: &std::path::Path) -> anyhow::Result<()> { + use std::fs::OpenOptions; + if g.fd.is_none() { + g.path = new_full.to_owned(); + return Ok(()); + } + if new_full == g.path { + return Ok(()); + } + if new_full.try_exists().unwrap_or(true) { + anyhow::bail!("cannot move to {new_full:?}: destination already exists"); + } + if let Some(parent) = new_full.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("error creating parent directory for {new_full:?}"))?; + } + std::fs::rename(&g.path, new_full) + .with_context(|| format!("error renaming {:?} -> {new_full:?}", g.path))?; + let f = OpenOptions::new() + .read(true) + .write(true) + .open(new_full) + .with_context(|| format!("error reopening {new_full:?} after rename"))?; + g.fd = Some(f); + #[cfg(windows)] + { + g.tried_marking_sparse = false; + } + g.path = new_full.to_owned(); + Ok(()) +} + #[derive(Debug)] pub(crate) struct OpenedFile { file: RwLock, @@ -155,6 +191,32 @@ impl OpenedFile { }) } + /// Move this file on disk to `new_full` and re-point the cached handle at + /// the new location. A dummy (padding / not-present) file just records the + /// new path. Takes the write lock, so it serialises against in-flight I/O. + pub fn rename_to(&self, new_full: &std::path::Path) -> anyhow::Result<()> { + let mut g = self.file.write(); + rename_locked(&mut g, new_full) + } + + /// Move this file from under `old_root` to the same relative location under + /// `new_root` (whole-torrent relocation). Fails on a cross-filesystem move + /// (the underlying rename returns an error) rather than copying. + pub fn rebase( + &self, + old_root: &std::path::Path, + new_root: &std::path::Path, + ) -> anyhow::Result<()> { + let mut g = self.file.write(); + let relative = g + .path + .strip_prefix(old_root) + .with_context(|| format!("file {:?} is not under root {old_root:?}", g.path))? + .to_owned(); + let new_full = new_root.join(&relative); + rename_locked(&mut g, &new_full) + } + pub fn lock_read(&self) -> crate::Result> { RwLockReadGuard::try_map(self.file.read(), |f| f.as_ref()) .ok() diff --git a/crates/librtbit/src/storage/middleware/slow.rs b/crates/librtbit/src/storage/middleware/slow.rs index e8c8571..539df92 100644 --- a/crates/librtbit/src/storage/middleware/slow.rs +++ b/crates/librtbit/src/storage/middleware/slow.rs @@ -109,6 +109,14 @@ impl TorrentStorage for SlowStorage { self.underlying.remove_file(file_id, filename) } + fn rename_file(&self, file_id: usize, new_relative: &std::path::Path) -> anyhow::Result<()> { + self.underlying.rename_file(file_id, new_relative) + } + + fn move_root(&self, new_root: &std::path::Path) -> anyhow::Result<()> { + self.underlying.move_root(new_root) + } + fn ensure_file_length(&self, file_id: usize, length: u64) -> anyhow::Result<()> { self.underlying.ensure_file_length(file_id, length) } diff --git a/crates/librtbit/src/storage/middleware/timing.rs b/crates/librtbit/src/storage/middleware/timing.rs index e71e490..5c5dd99 100644 --- a/crates/librtbit/src/storage/middleware/timing.rs +++ b/crates/librtbit/src/storage/middleware/timing.rs @@ -94,6 +94,14 @@ impl TorrentStorage for TimingStorage { self.underlying.remove_file(file_id, filename) } + fn rename_file(&self, file_id: usize, new_relative: &std::path::Path) -> anyhow::Result<()> { + self.underlying.rename_file(file_id, new_relative) + } + + fn move_root(&self, new_root: &std::path::Path) -> anyhow::Result<()> { + self.underlying.move_root(new_root) + } + fn ensure_file_length(&self, file_id: usize, length: u64) -> anyhow::Result<()> { self.underlying.ensure_file_length(file_id, length) } diff --git a/crates/librtbit/src/storage/middleware/write_through_cache.rs b/crates/librtbit/src/storage/middleware/write_through_cache.rs index 11cf401..c6993bb 100644 --- a/crates/librtbit/src/storage/middleware/write_through_cache.rs +++ b/crates/librtbit/src/storage/middleware/write_through_cache.rs @@ -107,6 +107,14 @@ impl TorrentStorage for WriteThroughCacheStorage { self.underlying.remove_file(file_id, filename) } + fn rename_file(&self, file_id: usize, new_relative: &std::path::Path) -> anyhow::Result<()> { + self.underlying.rename_file(file_id, new_relative) + } + + fn move_root(&self, new_root: &std::path::Path) -> anyhow::Result<()> { + self.underlying.move_root(new_root) + } + fn ensure_file_length(&self, file_id: usize, length: u64) -> anyhow::Result<()> { self.underlying.ensure_file_length(file_id, length) } diff --git a/crates/librtbit/src/storage/mod.rs b/crates/librtbit/src/storage/mod.rs index 30e3745..8e5ca28 100644 --- a/crates/librtbit/src/storage/mod.rs +++ b/crates/librtbit/src/storage/mod.rs @@ -154,6 +154,22 @@ pub trait TorrentStorage: Send + Sync { /// Remove a file from the storage. If not supported, or it doesn't matter, just return Ok(()) fn remove_file(&self, file_id: usize, filename: &Path) -> anyhow::Result<()>; + /// Move the file identified by `file_id` to `new_relative` (a path relative + /// to the storage root), keeping any cached handle valid. Backends that + /// cannot rename should return an error (the default). Callers are + /// responsible for keeping torrent metadata (`FileInfo.relative_filename`) + /// in sync. + fn rename_file(&self, _file_id: usize, _new_relative: &Path) -> anyhow::Result<()> { + anyhow::bail!("this storage backend does not support renaming files") + } + + /// Relocate every file to the same relative path under a new storage root, + /// keeping cached handles valid. Backends that cannot relocate should return + /// an error (the default). Callers keep the torrent's root anchor in sync. + fn move_root(&self, _new_root: &Path) -> anyhow::Result<()> { + anyhow::bail!("this storage backend does not support relocation") + } + fn remove_directory_if_empty(&self, path: &Path) -> anyhow::Result<()>; /// E.g. for filesystem backend ensure that the file has a certain length, and grow/shrink as needed. @@ -183,6 +199,14 @@ impl TorrentStorage for Box { (**self).remove_file(file_id, filename) } + fn rename_file(&self, file_id: usize, new_relative: &Path) -> anyhow::Result<()> { + (**self).rename_file(file_id, new_relative) + } + + fn move_root(&self, new_root: &Path) -> anyhow::Result<()> { + (**self).move_root(new_root) + } + fn ensure_file_length(&self, file_id: usize, length: u64) -> anyhow::Result<()> { (**self).ensure_file_length(file_id, length) } diff --git a/crates/librtbit/src/torrent_state/live/mod.rs b/crates/librtbit/src/torrent_state/live/mod.rs index 5c4618f..7207231 100644 --- a/crates/librtbit/src/torrent_state/live/mod.rs +++ b/crates/librtbit/src/torrent_state/live/mod.rs @@ -270,7 +270,9 @@ impl TorrentStateLive { tokio::sync::mpsc::UnboundedSender, ChunkInfo, )>(); - let ratelimits = Limits::new(paused.shared.options.ratelimits); + // Read the runtime override (seeded from options.ratelimits) so a + // per-torrent limit set while paused takes effect on this live state. + let ratelimits = Limits::new(*paused.shared.ratelimit_override.read()); let state = Arc::new(TorrentStateLive { shared: paused.shared.clone(), diff --git a/crates/librtbit/src/torrent_state/mod.rs b/crates/librtbit/src/torrent_state/mod.rs index 5c8c74e..09853d4 100644 --- a/crates/librtbit/src/torrent_state/mod.rs +++ b/crates/librtbit/src/torrent_state/mod.rs @@ -115,7 +115,6 @@ pub(crate) struct ManagedTorrentOptions { pub allow_overwrite: bool, pub output_folder: PathBuf, pub output_folder_root: Option, - pub ratelimits: LimitsConfig, pub initial_peers: Vec, pub peer_limit: Option, #[cfg(feature = "disable-upload")] @@ -172,6 +171,71 @@ impl TorrentMetadata { pub fn lengths(&self) -> &Lengths { self.info.lengths() } + + /// Return a copy of this metadata with the `relative_filename` of the given + /// files replaced. `renames` is a list of `(file_id, new_relative_path)`. + /// Only paths change — offsets, lengths and piece ranges are preserved, so + /// the chunk tracker stays valid. + pub(crate) fn with_renamed_files(&self, renames: &[(usize, std::path::PathBuf)]) -> Self { + let mut file_infos = self.file_infos.clone(); + for (file_id, new_path) in renames { + if let Some(fi) = file_infos.get_mut(*file_id) { + fi.relative_filename = new_path.clone(); + } + } + Self { + info: self.info.clone(), + torrent_bytes: self.torrent_bytes.clone(), + info_bytes: self.info_bytes.clone(), + file_infos, + } + } +} + +/// Validate a batch of file renames against the current file set: each id must +/// exist, each new path must be relative and free of `.`/`..`/root components, +/// and the resulting set of paths must stay unique (no collisions). +fn validate_renames( + file_infos: &crate::type_aliases::FileInfos, + renames: &[(usize, std::path::PathBuf)], +) -> anyhow::Result<()> { + use std::path::Component; + + if renames.is_empty() { + bail!("no files to rename"); + } + + let mut paths: Vec = file_infos + .iter() + .map(|f| f.relative_filename.clone()) + .collect(); + + for (file_id, new_relative) in renames { + if *file_id >= file_infos.len() { + bail!("no such file id {file_id}"); + } + if new_relative.as_os_str().is_empty() { + bail!("new path for file {file_id} is empty"); + } + if new_relative.is_absolute() { + bail!("new path {new_relative:?} must be relative to the torrent root"); + } + for component in new_relative.components() { + if !matches!(component, Component::Normal(_)) { + bail!("new path {new_relative:?} must not contain '.', '..', or a root"); + } + } + paths[*file_id] = new_relative.clone(); + } + + let mut seen = std::collections::HashSet::with_capacity(paths.len()); + for path in &paths { + if !seen.insert(path) { + bail!("rename would create two files at the same path: {path:?}"); + } + } + + Ok(()) } /// Common information about torrent shared among all possible states. @@ -200,6 +264,22 @@ pub struct ManagedTorrentShared { /// Category assigned to this torrent. pub category: RwLock>, + /// Per-torrent speed limits, mutable at runtime. Seeded from the add-time + /// options; read when (re)constructing the live limiter so a limit set + /// while paused (or before a pause/unpause) survives. `None` bps = no limit. + pub(crate) ratelimit_override: RwLock, + + /// Display-name override set via the compat rename endpoint; takes + /// precedence over the metadata/magnet name. `None` = use the torrent's + /// own name. Not persisted across restarts. + pub(crate) name_override: RwLock>, + + /// Current storage root, mutable at runtime so a whole-torrent relocation + /// (`set_location`) can re-anchor storage. Seeded from + /// `options.output_folder`; read by the storage factory's `create`. Not + /// persisted across restarts. + pub(crate) output_folder_override: RwLock, + /// Live per-tracker announce status (seeds/peers per tracker etc). pub tracker_status: Arc, @@ -250,6 +330,9 @@ impl ManagedTorrent { } pub fn name(&self) -> Option { + if let Some(name) = self.shared.name_override.read().clone() { + return Some(name); + } if let Some(m) = &*self.metadata.load() { return m .info @@ -260,6 +343,105 @@ impl ManagedTorrent { self.shared.magnet_name.clone() } + /// The torrent's current storage root. Reflects a `set_location` + /// relocation, unlike the add-time `options.output_folder`. + pub fn output_folder(&self) -> std::path::PathBuf { + self.shared.output_folder_override.read().clone() + } + + /// Set (or clear, with `None`) the display-name override for this torrent. + /// An empty/whitespace name clears the override. + pub fn set_display_name(&self, name: Option) { + let name = name.filter(|n| !n.trim().is_empty()); + *self.shared.name_override.write() = name; + } + + /// Rename one or more files within the torrent. Only supported while the + /// torrent is stopped (paused); moves the files on disk, keeps the storage + /// handles valid, and updates the torrent metadata. All-or-nothing: a + /// mid-move failure rolls back the renames already applied. + /// + /// `renames` is a list of `(file_id, new_relative_path)`. A rename whose + /// destination already exists on disk is refused (never clobbers), which + /// also makes a colliding/cyclic batch fail safely. Not persisted across + /// restarts. + pub fn rename_files(&self, renames: &[(usize, std::path::PathBuf)]) -> anyhow::Result<()> { + let mut g = self.locked.write(); + let paused = match &mut g.state { + ManagedTorrentState::Paused(paused) => paused, + ManagedTorrentState::Live(_) => { + bail!("torrent must be stopped before renaming files") + } + _ => bail!("torrent is not in a renamable state (must be stopped)"), + }; + + validate_renames(&paused.metadata.file_infos, renames)?; + + // Capture old relative paths (for rollback and directory pruning) + // before we start mutating the storage. + let old_paths: Vec<(usize, std::path::PathBuf)> = renames + .iter() + .map(|(file_id, _)| { + ( + *file_id, + paused.metadata.file_infos[*file_id] + .relative_filename + .clone(), + ) + }) + .collect(); + + for (applied, (file_id, new_relative)) in renames.iter().enumerate() { + if let Err(error) = paused.files.rename_file(*file_id, new_relative) { + // Roll back the renames already applied, in reverse order. + for (rollback_id, old_relative) in old_paths[..applied].iter().rev() { + let _ = paused.files.rename_file(*rollback_id, old_relative); + } + return Err(error).context("failed to rename file on disk; rolled back"); + } + } + + // Update the metadata (source of truth for deletion, display and any + // future storage re-init). + let new_metadata = Arc::new(paused.metadata.with_renamed_files(renames)); + paused.metadata = new_metadata.clone(); + self.metadata.store(Some(new_metadata)); + + // Best-effort prune of source directories left empty by the move. + for (_, old_relative) in &old_paths { + if let Some(parent) = old_relative.parent() + && !parent.as_os_str().is_empty() + { + let _ = paused.files.remove_directory_if_empty(parent); + } + } + + Ok(()) + } + + /// Relocate the torrent's files to a new root directory. Only supported + /// while the torrent is stopped (paused): moves every file to the same + /// relative path under `new_root`, keeps the storage handles valid, and + /// re-anchors the storage root. Same-filesystem only — a cross-device move + /// is refused (and rolled back) rather than copied. Not persisted across + /// restarts. + pub fn set_location(&self, new_root: std::path::PathBuf) -> anyhow::Result<()> { + let mut g = self.locked.write(); + let paused = match &mut g.state { + ManagedTorrentState::Paused(paused) => paused, + ManagedTorrentState::Live(_) => { + bail!("torrent must be stopped before changing its location") + } + _ => bail!("torrent is not in a relocatable state (must be stopped)"), + }; + + std::fs::create_dir_all(&new_root) + .with_context(|| format!("error creating destination directory {new_root:?}"))?; + paused.files.move_root(&new_root)?; + *self.shared.output_folder_override.write() = new_root; + Ok(()) + } + pub fn shared(&self) -> &ManagedTorrentShared { &self.shared } @@ -281,6 +463,43 @@ impl ManagedTorrent { self.locked.read().only_files.clone() } + /// Current per-torrent speed limits (the runtime override). `None` bps means + /// unlimited in that direction. + pub fn rate_limits(&self) -> LimitsConfig { + *self.shared.ratelimit_override.read() + } + + /// Set the per-torrent download limit (`None` = unlimited). Persisted on the + /// runtime override and applied to the live limiter immediately if live. + pub fn set_download_limit(&self, bps: Option) { + self.shared.ratelimit_override.write().download_bps = bps; + if let Some(live) = self.live() { + live.ratelimits.set_download_bps(bps); + } + } + + /// Set the per-torrent upload limit (`None` = unlimited). See + /// [`Self::set_download_limit`]. + pub fn set_upload_limit(&self, bps: Option) { + self.shared.ratelimit_override.write().upload_bps = bps; + if let Some(live) = self.live() { + live.ratelimits.set_upload_bps(bps); + } + } + + /// Force an immediate re-announce to trackers (and a fresh peer discovery + /// from DHT/trackers). Returns false if the torrent is not live, in which + /// case there is no announce loop to signal. + pub fn reannounce(&self) -> bool { + match self.live() { + Some(live) => { + live.rediscovery_notify.notify_one(); + true + } + None => false, + } + } + pub fn with_state(&self, f: impl FnOnce(&ManagedTorrentState) -> R) -> R { f(&self.locked.read().state) } diff --git a/docs/QBIT-API-PARITY.md b/docs/QBIT-API-PARITY.md new file mode 100644 index 0000000..80d4607 --- /dev/null +++ b/docs/QBIT-API-PARITY.md @@ -0,0 +1,227 @@ +# qBittorrent WebUI API parity + +rustTorrent ships a qBittorrent WebUI API v2 compatibility layer +(`crates/librtbit/src/http_api/handlers/qbit_compat.rs`, mounted at +`/api/v2`) so that *arr apps and other qBittorrent integrations can talk to +it. This document describes how parity with upstream qBittorrent is measured +and enforced, and where we currently stand. + +## Current standing (upstream WebAPI 2.16.2, 2026-08-30) + +**64 of 93 in-scope endpoints routed (~69%)** — 26 full, 38 partial — plus 2 +legacy aliases (`torrents/pause`, `torrents/resume`) that upstream removed in +WebAPI 2.11. 37 of the 130 upstream endpoints were descoped on 2026-08-30 +(marked `out_of_scope`, enforced as unrouted): `search/*` (Indexarr covers +discovery), `rss/*` (native `/rss` + Indexarr are the RSS story), `clientdata/*`, +SSL parameters, and qBittorrent-internal app misc (email, cookies, API keys, +processInfo, getDirectoryContent). + +Decisions of record: advertised `webapiVersion` stays **2.11.3** and the +rename-family bug is now fixed *forward* (see below); the remaining heavy engine +items (queueing, per-torrent rate limits, move-storage, reannounce, rename) +**will be built**, not stubbed — they account for most of the 46 still-`missing` +in-scope endpoints. + +| Controller | Implemented / in scope | Notes | +|---|---|---| +| `auth` | 2 / 2 | login/logout with SID cookies | +| `app` | 6 / 10 | version info, `defaultSavePath`, minimal preferences (7 descoped) | +| `torrents` | 41 / 60 | lifecycle, categories, tags, trackers (add/remove/edit), reannounce, rename (name/file/folder), setLocation/setSavePath, pieces, file prio, per-torrent + share limits, export, webseeds | +| `transfer` | 10 / 13 | info (real session limits) + session rate limits + alt-speed mode + session pause/resume | +| `sync` | 1 / 2 | `maindata` (full-update snapshots; no per-client rid deltas) | +| `torrentcreator` | 4 / 4 | task API over the native `create_torrent` (in-memory tasks) | +| `log` | 0 / 2 | no persistent log ring buffer | +| `rss`, `search`, `clientdata` | — | descoped entirely | + +### Rename-family bug (fixed 2026-08-30) + +We advertise `webapiVersion` **2.11.3**, so modern clients (qbittorrent-api, +newer *arr releases) use the post-2.11 vocabulary. This is now served: + +- `torrents/stop` / `torrents/start` are routed (sharing the pause/resume + handlers); the pre-2.11 `torrents/pause` / `torrents/resume` remain as aliases. +- `torrents/info` emits the 2.11 state strings `stoppedDL` / `stoppedUP`. +- `matches_filter` accepts `stopped` / `running` (and still the old + `paused` / `resumed`), so `filter=stopped` no longer returns every torrent. +- `torrents/add` reads the 2.11 `stopped` form field as well as `paused`. + +### Conflict audit (2026-08-30) + +No hard route conflicts: the native API owns `/` and the compat layer owns +`/api/v2`; nothing native is mounted under `/api/*`, so all 111 missing +endpoints have free paths. Auth layering is also sound — the main +Bearer/Basic middleware is applied before the qbit router is nested, so +`/api/v2` correctly runs its own SID-cookie auth against the same credential +store. **Trap**: future qbit sub-routers (`sync`, `rss`, `log`, …) must be +nested inside `protected_router` in `make_qbit_router`, or they ship +unauthenticated. + +The version-skew conflicts (items 1–5) were the 2.11 pause→stop rename family +and the rate-limit state disagreement; all are now resolved: + +1. ~~**`torrents/stop`/`start` missing**~~ — routed (see the rename-family + section above). +2. ~~**State strings** `pausedDL`/`pausedUP`~~ — `torrents/info` now emits + `stoppedDL`/`stoppedUP`. +3. ~~**Filter values** `stopped`/`running`~~ — `matches_filter` now recognises + both the 2.11 and pre-2.11 spellings, so `filter=stopped` no longer returns + every torrent. +4. ~~**`torrents/add` `stopped` field**~~ — now read alongside `paused`. +5. ~~**Rate-limit state disagreement**~~ — `transfer/downloadLimit`/ + `uploadLimit`/`setDownloadLimit`/`setUploadLimit` now read and write the same + session limiter as the native `/torrents/limits` API, and + `transfer/speedLimitsMode`/`toggleSpeedLimitsMode`/`setSpeedLimitsMode` + reflect the native `/speed/alt` toggle. `transfer/info` now also reports the + real session `dl_rate_limit`/`up_rate_limit` (was hardcoded to 0). + +Still open: + +6. **`app/setPreferences` rejects instead of ignoring**: it uses + `deny_unknown_fields`, returning 400 for any field other than + `announce_port`. Real qBittorrent ignores unknown fields and applies the + rest, so any client that round-trips preferences (get → modify → set) + fails hard. +7. **RSS data-model gap** (affects future bridging): the native store is flat + (feeds keyed by name, rules with one feed + one regex); qBittorrent has + folder hierarchies (`Folder\Feed` paths) and rules keyed by name with + `mustContain`/`mustNotContain`/`affectedFeeds[]`. A bridge needs flat-folder + emulation and a rule-shape mapping; `rss/addFolder`/`rss/moveItem` have no + clean mapping. + +Hygiene note (adjacent): `QbitSessions` never purges expired SIDs except on +explicit logout, so the session map grows unboundedly with clients that +re-login frequently (Sonarr does). + +### Suggested parity tiers + +Tier 1 (high value) and the thin backend bridges from tier 2 are now largely +done: `torrents/stop`/`start`, `sync/maindata`, `app/defaultSavePath`, +`torrents/tags` + tag CRUD, `torrents/trackers`, `torrents/addTrackers`, +`torrents/addPeers`, `torrents/filePrio`, `torrents/pieceStates`/`pieceHashes`, +`torrents/export`, `torrents/webseeds`, `torrents/count`, the `transfer/*Limit*` ++ speed-limits-mode family, and `transfer/pauseSession`/`resumeSession`. + +Remaining work, roughly by cost: + +1. **Needs a new engine method** (the `will be built` items): queueing + (`topPrio` / `bottomPrio` / `increasePrio` / `decreasePrio`), + `setSuperSeeding`, `toggleSequentialDownload`, `setForceStart`, + `setAutoManagement`, `setDownloadPath` (incomplete-file path). And the + **auto-enforcement** half of share limits (`setShareLimits` currently stores + and reports the limits but does not auto-pause/remove at the threshold — a + periodic session task is the v2). Done since: per-torrent rate limits (`ratelimit_override` on + `ManagedTorrentShared`, enforced by the live limiter), `reannounce` (signals + the live re-discovery notify), **file/folder/display rename**, and + **`setLocation`/`setSavePath`** (whole-torrent relocation — see below). + +### Relocation (setLocation, v1, 2026-08-31) + +`torrents/setLocation` and `torrents/setSavePath` move every file to a new root: + +- New `TorrentStorage::move_root(new_root)` primitive: the filesystem backend + moves each file to the same relative path under the new root (reusing the + no-clobber rename core) and re-anchors its (now `RwLock`-wrapped) root; mmap + forwards. All-or-nothing with rollback. +- The persistent anchor is a new `output_folder_override` on + `ManagedTorrentShared` (seeded from `options.output_folder`, read by the + storage factory's `create`), rather than unfreezing the immutable `options`. +- `ManagedTorrent::set_location()` is **stopped-only** (409 when live) and + **same-filesystem only** — a cross-device `rename` (EXDEV) is refused and + rolled back, not copied. `qbit_save_path` reports the new root after a move. +- **v2**: cross-filesystem relocation (async copy+delete, à la + `move_completed_torrent`), live relocation, and persistence across restart. + +> **⚠️ Restart caveat (rename and relocation both).** These overrides are +> in-memory only. Persistence still records the add-time `output_folder` and +> derives filenames from the immutable `.torrent` info, so after a +> rename/relocation **and a restart** the torrent re-adds at its original root +> with original names while the data sits at the new location — a recheck finds +> nothing and re-downloads, orphaning the moved copy. Until v2 persistence +> lands, treat rename/relocation as effective only within the running session. + +### File rename (v1, 2026-08-31) + +`torrents/renameFile`, `renameFolder`, and `rename` (display name) are +implemented: + +- New `TorrentStorage::rename_file(file_id, new_relative)` primitive: the + filesystem backend moves the file on disk and re-points its cached open + handle at the new path (mmap forwards; other backends default to an error). +- `ManagedTorrent::rename_files()` is **stopped-only** (returns 409 when live, + which sidesteps live-handle / Windows-open-file / mmap-remap hazards): it + validates the batch (relative paths, no `.`/`..`/root, no collisions), + moves each file, then swaps in rebuilt `TorrentMetadata` with updated + `file_infos` and prunes emptied source dirs. All-or-nothing with rollback. +- `torrents/rename` sets a `name_override` on `ManagedTorrentShared`, surfaced + in `torrents/info` / `sync/maindata` via `ManagedTorrent::name()`. +- **v1 limitations**: renames are not persisted across restarts (re-derived + from the immutable `.torrent` info on load), and require the torrent stopped. + Live rename is the documented v2, gated behind the differential-test harness. +2. **Thin bridges still open**: `torrents/setComment`, web-seed mutation + (`addWebSeeds` / `editWebSeed` / `removeWebSeeds` — `web_seed_urls` is + currently immutable). (`torrentcreator/*` is now done — an in-memory task + store over the native `create_torrent`; tasks are not persisted.) +3. **Probably out of scope**: `search/*` (plugin system), + `app/sendTestEmail`, `app/processInfo`, `clientdata/*`, + `torrents/SSLParameters`, `log/*`. + +## The parity checker framework + +The source of truth is +`crates/librtbit/src/http_api/handlers/qbit_parity_spec.json`: one entry per +upstream endpoint (`controller/action`), with its HTTP method and a parity +status: + +- `full` — routed, semantics close enough for real clients +- `partial` — routed, but with stubbed fields or gaps (see `notes`) +- `missing` — not routed; candidate for future work +- `out_of_scope` — not routed by explicit decision + +Entries with `"upstream": false` are legacy endpoints we serve that upstream +has removed. + +Tests in `crates/librtbit/src/http_api/handlers/qbit_parity.rs` enforce the +spec against the real router (they run in the normal +`cargo test --workspace` CI job): + +- `spec_matches_router` — probes every spec entry against + `make_qbit_router()`; `full`/`partial` must be routed, `missing`/ + `out_of_scope` must 404. Implementing or removing an endpoint without + flipping its spec status fails CI. +- `every_compat_route_is_tracked_in_spec` — scans the route literals in + `qbit_compat.rs` so no compat route can land untracked. +- `parity_summary` — prints the scoreboard + (`cargo test -p swarmforge qbit_parity -- --nocapture`). + +### Testing scope (the parity checker is tier 0, not the whole story) + +The tests above enforce *surface* parity only — that an endpoint is routed, +not that its response is correct. Full durable testing is scoped as a ladder +(tracked as WI-25/WI-26): + +1. **Shape conformance** (WI-25, mandatory before the endpoint wave): each + `full`/`partial` spec entry gains a response schema; the parity tests probe + endpoints with a live in-process torrent and validate JSON field + sets/types. +2. **Behavioral lifecycle** (WI-25): add→stop→start→recheck→delete suites + through the compat router against a real session. +3. **Client-replay fixtures** (WI-26): recorded Sonarr / qbittorrent-api + request sequences replayed against the router. +4. **Differential harness** (WI-26, nightly): identical requests against a + real qBittorrent container and rustTorrent, responses diffed field-by-field + with an allowlist for intentionally-stubbed fields. + +Definition of done for every endpoint: route + spec status flip + schema + +lifecycle coverage. + +### Workflow + +- **Implementing an endpoint**: add the route, flip the spec entry to + `full`/`partial`, note any semantic gaps in `notes`. +- **Declaring non-goals**: set status to `out_of_scope` (kept enforced as + unrouted). +- **Tracking upstream**: re-clone qBittorrent and run + `scripts/refresh-qbit-parity-spec.py ` — it re-extracts the + endpoint inventory and POST allowlist from the sources, preserves our + statuses/notes, adds new upstream endpoints as `missing`, and flags + upstream-removed endpoints. diff --git a/scripts/refresh-qbit-parity-spec.py b/scripts/refresh-qbit-parity-spec.py new file mode 100755 index 0000000..945fd14 --- /dev/null +++ b/scripts/refresh-qbit-parity-spec.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Refresh crates/librtbit/src/http_api/handlers/qbit_parity_spec.json from a +qBittorrent checkout. + +Usage: + git clone --depth 1 https://github.com/qbittorrent/qBittorrent.git /tmp/qbittorrent + scripts/refresh-qbit-parity-spec.py /tmp/qbittorrent + +Re-extracts the upstream WebUI API endpoint inventory (every `Action()` +in src/webui/api/*controller.h, with methods from the POST allowlist in +webapplication.h) and merges it into the existing spec: + +- existing entries keep their status/notes; +- new upstream endpoints are added with status "missing"; +- entries that disappeared upstream are kept but flagged upstream: false if we + route them (legacy aliases), or dropped with a warning if we don't. + +The parity tests in crates/librtbit/src/http_api/handlers/qbit_parity.rs +enforce that the spec matches the actual compat router. +""" + +import datetime +import json +import re +import subprocess +import sys +from pathlib import Path + +SPEC_PATH = ( + Path(__file__).resolve().parent.parent + / "crates/librtbit/src/http_api/handlers/qbit_parity_spec.json" +) + +# controller header -> URL scope (must match registerAPIController names in +# qBittorrent's webapplication.cpp). +SCOPES = { + "appcontroller.h": "app", + "authcontroller.h": "auth", + "clientdatacontroller.h": "clientdata", + "logcontroller.h": "log", + "rsscontroller.h": "rss", + "searchcontroller.h": "search", + "synccontroller.h": "sync", + "torrentcreatorcontroller.h": "torrentcreator", + "torrentscontroller.h": "torrents", + "transfercontroller.h": "transfer", +} + + +def main() -> int: + if len(sys.argv) != 2: + print(__doc__, file=sys.stderr) + return 2 + qbit = Path(sys.argv[1]) + api_dir = qbit / "src/webui/api" + if not api_dir.is_dir(): + print(f"error: {api_dir} not found; not a qBittorrent checkout?", file=sys.stderr) + return 2 + + upstream_endpoints = [] + for header, scope in SCOPES.items(): + text = (api_dir / header).read_text() + for m in re.finditer(r"void (\w+)Action\(\)", text): + upstream_endpoints.append(f"{scope}/{m.group(1)}") + + webapp_h = (qbit / "src/webui/webapplication.h").read_text() + post_set = { + f"{m.group(1)}/{m.group(2)}" + for m in re.finditer( + r'\{\{u"(\w+)"_s, u"(\w+)"_s\}, Http::HEADER_REQUEST_METHOD_POST\}', + webapp_h, + ) + } + version_match = re.search(r"API_VERSION \{(\d+), (\d+), (\d+)\}", webapp_h) + if not version_match: + print("error: could not find API_VERSION in webapplication.h", file=sys.stderr) + return 1 + version = ".".join(version_match.groups()) + commit = subprocess.check_output( + ["git", "-C", str(qbit), "rev-parse", "HEAD"], text=True + ).strip() + + spec = json.loads(SPEC_PATH.read_text()) + existing = {row["endpoint"]: row for row in spec["endpoints"]} + upstream_set = set(upstream_endpoints) + + rows = [] + added, flagged_legacy, dropped = [], [], [] + for ep in sorted(upstream_set): + row = existing.get(ep) + if row is None: + row = {"endpoint": ep, "status": "missing"} + added.append(ep) + row["method"] = "POST" if ep in post_set else "GET" + row.pop("upstream", None) + rows.append(row) + + for ep, row in existing.items(): + if ep in upstream_set: + continue + if row["status"] in ("full", "partial"): + if row.get("upstream", True): + flagged_legacy.append(ep) + row["upstream"] = False + rows.append(row) + else: + dropped.append(ep) + + rows.sort(key=lambda r: r["endpoint"]) + spec["upstream"] = { + "webapi_version": version, + "source_commit": commit, + "extracted_on": datetime.date.today().isoformat(), + } + spec["endpoints"] = rows + SPEC_PATH.write_text(json.dumps(spec, indent=2) + "\n") + + counts = {} + for row in rows: + counts[row["status"]] = counts.get(row["status"], 0) + 1 + print(f"upstream WebAPI v{version} @ {commit[:12]}: {len(rows)} entries {counts}") + for label, items in (("added (new upstream)", added), + ("newly flagged legacy (removed upstream, still routed)", flagged_legacy), + ("dropped (removed upstream, never implemented)", dropped)): + if items: + print(f" {label}:") + for ep in items: + print(f" {ep}") + print("run `cargo test -p swarmforge qbit_parity` to validate against the router") + return 0 + + +if __name__ == "__main__": + sys.exit(main())