diff --git a/crates/librtbit/src/http_api/handlers/qbit_compat.rs b/crates/librtbit/src/http_api/handlers/qbit_compat.rs index 44deb7c..f447d05 100644 --- a/crates/librtbit/src/http_api/handlers/qbit_compat.rs +++ b/crates/librtbit/src/http_api/handlers/qbit_compat.rs @@ -7,6 +7,7 @@ use std::{ collections::HashMap, num::NonZeroU16, + path::Path, sync::Arc, time::{Instant, SystemTime, UNIX_EPOCH}, }; @@ -189,6 +190,44 @@ struct QbitFileInfo { availability: f64, } +fn qbit_save_path(handle: &crate::torrent_state::ManagedTorrentHandle) -> String { + handle + .shared() + .options + .output_folder_root + .as_ref() + .unwrap_or(&handle.shared().options.output_folder) + .to_string_lossy() + .into_owned() +} + +fn qbit_content_path(handle: &crate::torrent_state::ManagedTorrentHandle, name: &str) -> String { + Path::new(&qbit_save_path(handle)) + .join(name) + .to_string_lossy() + .into_owned() +} + +fn qbit_file_name( + torrent_name: Option<&str>, + file_name: &str, + is_multi_file: bool, + has_save_path_root: bool, +) -> String { + if is_multi_file && has_save_path_root { + torrent_name + .map(|root| { + Path::new(root) + .join(file_name) + .to_string_lossy() + .into_owned() + }) + .unwrap_or_else(|| file_name.to_owned()) + } else { + file_name.to_owned() + } +} + #[derive(Serialize)] struct QbitBuildInfo { qt: &'static str, @@ -477,13 +516,8 @@ async fn h_torrents_info( let stats = handle.stats(); let name = handle.name().unwrap_or_else(|| format!("torrent_{id}")); - let output_folder = handle - .shared() - .options - .output_folder - .to_string_lossy() - .into_owned(); - let content_path = format!("{}/{}", output_folder, name); + 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(); @@ -699,12 +733,7 @@ async fn h_torrents_properties( }; let stats = handle.stats(); - let output_folder = handle - .shared() - .options - .output_folder - .to_string_lossy() - .into_owned(); + let output_folder = qbit_save_path(&handle); let now = now_unix(); let added_on = handle.shared().added_on; let completion_on = handle @@ -839,9 +868,11 @@ async fn h_torrents_files( let stats = handle.stats(); let is_seed = stats.finished; - let files: Vec = details - .files - .unwrap_or_default() + let details_name = details.name.clone(); + let details_files = details.files.unwrap_or_default(); + let qbit_root = handle.shared().options.output_folder_root.is_some(); + let is_multi_file = details_files.len() > 1; + let files: Vec = details_files .iter() .enumerate() .map(|(i, f)| { @@ -851,9 +882,10 @@ async fn h_torrents_files( } else { 1.0 }; + let name = qbit_file_name(details_name.as_deref(), &f.name, is_multi_file, qbit_root); QbitFileInfo { index: i, - name: f.name.clone(), + name, size: f.length, progress, priority: if f.included { 1 } else { 0 }, @@ -936,7 +968,7 @@ async fn h_torrents_add( let opts = AddTorrentOptions { paused, overwrite: true, - output_folder: savepath.clone(), + output_folder_root: savepath.clone(), category: category.clone(), ..Default::default() }; @@ -953,7 +985,7 @@ async fn h_torrents_add( let opts = AddTorrentOptions { paused, overwrite: true, - output_folder: savepath.clone(), + output_folder_root: savepath.clone(), category: category.clone(), ..Default::default() }; @@ -1263,7 +1295,7 @@ pub(crate) fn make_qbit_router(api_state: ApiState) -> Router { #[cfg(test)] mod tests { - use std::{net::Ipv4Addr, sync::Arc}; + use std::{net::Ipv4Addr, path::Path, sync::Arc}; use axum::response::IntoResponse; use bytes::Bytes; @@ -1277,6 +1309,7 @@ mod tests { use super::{ QbitSessions, QbitState, h_app_preferences, h_app_set_preferences, matches_category, + qbit_file_name, }; async fn qbit_state() -> (Arc, Arc, tempfile::TempDir) { @@ -1385,6 +1418,22 @@ mod tests { assert!(!matches_category("linux isos", "Linux ISOs")); } + #[test] + fn qbit_multi_file_names_include_torrent_root_for_save_path_imports() { + assert_eq!( + qbit_file_name(Some("release"), "disc/file.bin", true, true), + Path::new("release").join("disc/file.bin").to_string_lossy() + ); + assert_eq!( + qbit_file_name(Some("release"), "single.bin", false, true), + "single.bin" + ); + assert_eq!( + qbit_file_name(Some("release"), "disc/file.bin", true, false), + "disc/file.bin" + ); + } + #[test] fn test_eta_overflow_safety() { // Simulate: very large remaining bytes / very small download speed diff --git a/crates/librtbit/src/session/mod.rs b/crates/librtbit/src/session/mod.rs index b7c6caf..0419e7d 100644 --- a/crates/librtbit/src/session/mod.rs +++ b/crates/librtbit/src/session/mod.rs @@ -867,19 +867,33 @@ impl Session { let default_output_folder = expand_folder_template(&self.output_folder.read().clone(), opts.category.as_deref()); - let output_folder = match (opts.output_folder, opts.sub_folder) { - (None, None) => default_output_folder.join( + let output_folder_root = opts + .output_folder_root + .as_ref() + .map(|root| expand_folder_template(&PathBuf::from(root), opts.category.as_deref())); + let output_folder = match ( + opts.output_folder, + opts.sub_folder, + output_folder_root.as_ref(), + ) { + (None, None, None) => default_output_folder.join( self.get_default_subfolder_for_torrent(&metadata.info, name.as_deref())? .unwrap_or_default(), ), - (Some(o), None) => expand_folder_template(&PathBuf::from(o), opts.category.as_deref()), - (Some(_), Some(_)) => { - bail!("you can't provide both output_folder and sub_folder") + (Some(o), None, None) => { + expand_folder_template(&PathBuf::from(o), opts.category.as_deref()) } - (None, Some(s)) => default_output_folder.join(expand_folder_template( + (None, Some(s), None) => default_output_folder.join(expand_folder_template( &PathBuf::from(s), opts.category.as_deref(), )), + (None, None, Some(root)) => root.join( + self.get_default_subfolder_for_torrent(&metadata.info, name.as_deref())? + .unwrap_or_default(), + ), + _ => { + bail!("output_folder, output_folder_root, and sub_folder are mutually exclusive") + } }; if opts.list_only { @@ -938,6 +952,7 @@ impl Session { peer_read_write_timeout: peer_opts.read_write_timeout, allow_overwrite: opts.overwrite, output_folder, + output_folder_root, ratelimits: opts.ratelimits, initial_peers: opts.initial_peers.clone().unwrap_or_default(), peer_limit: opts.peer_limit.or(self.peer_limit), diff --git a/crates/librtbit/src/session/types.rs b/crates/librtbit/src/session/types.rs index 5f81d52..20405bf 100644 --- a/crates/librtbit/src/session/types.rs +++ b/crates/librtbit/src/session/types.rs @@ -110,6 +110,11 @@ pub struct AddTorrentOptions { pub list_only: bool, /// The output folder for the torrent. If not set, the session's default one will be used. pub output_folder: Option, + /// A root output folder under which multi-file torrents receive their + /// normal torrent-name subfolder. This matches qBittorrent's `savepath` + /// semantics and is mutually exclusive with `output_folder` and + /// `sub_folder`. + pub output_folder_root: Option, /// Sub-folder within session's default output folder. Will error if "output_folder" if also set. /// By default, multi-torrent files are downloaded to a sub-folder. pub sub_folder: Option, diff --git a/crates/librtbit/src/session_persistence/json.rs b/crates/librtbit/src/session_persistence/json.rs index 6aa58cb..b142bca 100644 --- a/crates/librtbit/src/session_persistence/json.rs +++ b/crates/librtbit/src/session_persistence/json.rs @@ -155,6 +155,7 @@ impl JsonSessionPersistenceStore { only_files: torrent.only_files().clone(), is_paused: torrent.is_paused(), output_folder: torrent.shared().options.output_folder.clone(), + output_folder_root: torrent.shared().options.output_folder_root.clone(), category: torrent.shared().category.read().clone(), added_on: Some(torrent.shared().added_on), completion_on: match torrent diff --git a/crates/librtbit/src/session_persistence/mod.rs b/crates/librtbit/src/session_persistence/mod.rs index 20a1f3b..4fcc27a 100644 --- a/crates/librtbit/src/session_persistence/mod.rs +++ b/crates/librtbit/src/session_persistence/mod.rs @@ -36,6 +36,8 @@ pub struct SerializedTorrent { torrent_bytes: Bytes, trackers: HashSet, output_folder: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + output_folder_root: Option, only_files: Option>, is_paused: bool, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -75,14 +77,26 @@ impl SerializedTorrent { AddTorrent::from_url(magnet) }; - let opts = AddTorrentOptions { - paused: self.is_paused, - output_folder: Some( + let output_folder_root = self + .output_folder_root + .as_ref() + .map(|path| path.to_str().context("broken root path")) + .transpose()? + .map(str::to_owned); + let output_folder = if output_folder_root.is_some() { + None + } else { + Some( self.output_folder .to_str() .context("broken path")? .to_owned(), - ), + ) + }; + let opts = AddTorrentOptions { + paused: self.is_paused, + output_folder, + output_folder_root, only_files: self.only_files, overwrite: true, category, diff --git a/crates/librtbit/src/tests/read_only_storage.rs b/crates/librtbit/src/tests/read_only_storage.rs index a18d16b..29b2bc4 100644 --- a/crates/librtbit/src/tests/read_only_storage.rs +++ b/crates/librtbit/src/tests/read_only_storage.rs @@ -5,7 +5,8 @@ use tempfile::TempDir; use tokio::time::timeout; use crate::{ - AddTorrent, AddTorrentOptions, CreateTorrentOptions, Session, SessionOptions, create_torrent, + AddTorrent, AddTorrentOptions, CreateTorrentOptions, Session, SessionOptions, + SessionPersistenceConfig, create_torrent, spawn_utils::BlockingSpawner, storage::{StorageFactoryExt, filesystem::FilesystemStorageFactory}, }; @@ -39,6 +40,19 @@ async fn create_single_file_torrent(payload_dir: &std::path::Path) -> anyhow::Re Ok(torrent.as_bytes()?.to_vec()) } +async fn create_multi_file_torrent(payload_dir: &std::path::Path) -> anyhow::Result> { + let torrent = create_torrent( + payload_dir, + CreateTorrentOptions { + piece_length: Some(16_384), + ..Default::default() + }, + &BlockingSpawner::new(1), + ) + .await?; + Ok(torrent.as_bytes()?.to_vec()) +} + #[tokio::test(flavor = "multi_thread")] async fn read_only_session_verifies_existing_payload_without_changing_it() -> anyhow::Result<()> { let payload_dir = TempDir::with_prefix("rtbit_read_only_existing")?; @@ -113,3 +127,79 @@ async fn read_only_session_does_not_create_missing_payload() -> anyhow::Result<( assert!(!payload_path.exists()); Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn read_only_session_verifies_qbittorrent_multi_file_save_path() -> anyhow::Result<()> { + let payload_root = TempDir::with_prefix("rtbit_qbit_multi_root")?; + let torrent_root = payload_root.path().join("multi-file-payload"); + std::fs::create_dir(&torrent_root)?; + std::fs::write(torrent_root.join("first.bin"), vec![0x12; 48 * 1024])?; + std::fs::write(torrent_root.join("second.bin"), vec![0x34; 32 * 1024])?; + let torrent = create_multi_file_torrent(&torrent_root).await?; + + let persistence_dir = TempDir::with_prefix("rtbit_qbit_multi_session")?; + let new_session = || { + Session::new_with_opts( + payload_root.path().to_owned(), + SessionOptions { + disable_dht: true, + disable_trackers: true, + disable_local_service_discovery: true, + default_storage_factory: Some(FilesystemStorageFactory::read_only().boxed()), + persistence: Some(SessionPersistenceConfig::Json { + folder: Some(persistence_dir.path().to_owned()), + }), + ..Default::default() + }, + ) + }; + + let session = new_session().await?; + let handle = session + .add_torrent( + AddTorrent::from_bytes(torrent), + Some(AddTorrentOptions { + paused: true, + overwrite: true, + output_folder_root: Some(payload_root.path().to_string_lossy().into_owned()), + ..Default::default() + }), + ) + .await? + .into_handle() + .context("expected a torrent handle")?; + timeout(Duration::from_secs(10), handle.wait_until_initialized()) + .await + .context("timed out waiting for qBittorrent-layout initialization")??; + assert!(handle.stats().finished); + assert_eq!(handle.shared().options.output_folder, torrent_root); + assert_eq!( + handle.shared().options.output_folder_root.as_deref(), + Some(payload_root.path()) + ); + + let info_hash = handle.info_hash(); + drop(handle); + drop(session); + let restored = new_session().await?; + let restored_handle = restored + .get(info_hash.into()) + .context("expected persisted torrent")?; + timeout( + Duration::from_secs(10), + restored_handle.wait_until_initialized(), + ) + .await + .context("timed out waiting for restored qBittorrent-layout initialization")??; + assert!(restored_handle.stats().finished); + assert_eq!(restored_handle.shared().options.output_folder, torrent_root); + assert_eq!( + restored_handle + .shared() + .options + .output_folder_root + .as_deref(), + Some(payload_root.path()) + ); + Ok(()) +} diff --git a/crates/librtbit/src/torrent_state/mod.rs b/crates/librtbit/src/torrent_state/mod.rs index ca23170..1258cc8 100644 --- a/crates/librtbit/src/torrent_state/mod.rs +++ b/crates/librtbit/src/torrent_state/mod.rs @@ -114,6 +114,7 @@ pub(crate) struct ManagedTorrentOptions { pub peer_read_write_timeout: Option, pub allow_overwrite: bool, pub output_folder: PathBuf, + pub output_folder_root: Option, pub ratelimits: LimitsConfig, pub initial_peers: Vec, pub peer_limit: Option,