diff --git a/crates/librtbit/src/http_api/handlers/qbit_compat.rs b/crates/librtbit/src/http_api/handlers/qbit_compat.rs index f447d05..3be4804 100644 --- a/crates/librtbit/src/http_api/handlers/qbit_compat.rs +++ b/crates/librtbit/src/http_api/handlers/qbit_compat.rs @@ -871,7 +871,9 @@ async fn h_torrents_files( 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 is_multi_file = handle + .with_metadata(|metadata| metadata.info.info().files.is_some()) + .unwrap_or(false); let files: Vec = details_files .iter() .enumerate() @@ -1428,6 +1430,10 @@ mod tests { qbit_file_name(Some("release"), "single.bin", false, true), "single.bin" ); + assert_eq!( + qbit_file_name(Some("release"), "only.bin", true, true), + Path::new("release").join("only.bin").to_string_lossy() + ); assert_eq!( qbit_file_name(Some("release"), "disc/file.bin", true, false), "disc/file.bin" diff --git a/crates/librtbit/src/session/mod.rs b/crates/librtbit/src/session/mod.rs index 0419e7d..a98639c 100644 --- a/crates/librtbit/src/session/mod.rs +++ b/crates/librtbit/src/session/mod.rs @@ -735,13 +735,14 @@ impl Session { info: &ValidatedTorrentMetaV1Info, magnet_name: Option<&str>, ) -> anyhow::Result> { + if info.info().files.is_none() { + return Ok(None); + } + let files = info .iter_file_details() .map(|fd| Ok((fd.filename.to_pathbuf(), fd.len))) .collect::>>()?; - if files.len() < 2 { - return Ok(None); - } fn check_valid(pb: &Path) -> anyhow::Result<()> { if pb.components().any(|x| !matches!(x, Component::Normal(_))) { diff --git a/crates/librtbit/src/session_persistence/postgres.rs b/crates/librtbit/src/session_persistence/postgres.rs index ae7eb22..982689b 100644 --- a/crates/librtbit/src/session_persistence/postgres.rs +++ b/crates/librtbit/src/session_persistence/postgres.rs @@ -24,6 +24,7 @@ struct TorrentsTableRecord { torrent_bytes: Vec, trackers: Vec, output_folder: String, + output_folder_root: Option, only_files: Option>, is_paused: bool, } @@ -37,6 +38,7 @@ impl TorrentsTableRecord { torrent_bytes: self.torrent_bytes.into(), trackers: self.trackers.into_iter().collect(), output_folder: PathBuf::from(self.output_folder), + output_folder_root: self.output_folder_root.map(PathBuf::from), only_files: self .only_files .map(|v| v.into_iter().map(|v| v as usize).collect()), @@ -77,12 +79,14 @@ impl PostgresSessionStorage { torrent_bytes BYTEA NOT NULL, trackers TEXT[] NOT NULL, output_folder TEXT NOT NULL, + output_folder_root TEXT, only_files INTEGER[], is_paused BOOLEAN NOT NULL )" ); exec!("ALTER TABLE torrents ADD COLUMN IF NOT EXISTS have_bitfield BYTEA"); + exec!("ALTER TABLE torrents ADD COLUMN IF NOT EXISTS output_folder_root TEXT"); Ok(Self { pool }) } @@ -105,8 +109,8 @@ impl SessionPersistenceStore for PostgresSessionStorage { .as_ref() .map(|i| i.torrent_bytes.clone()) .unwrap_or_default(); - let q = "INSERT INTO torrents (id, info_hash, torrent_bytes, trackers, output_folder, only_files, is_paused) - VALUES($1, $2, $3, $4, $5, $6, $7) + let q = "INSERT INTO torrents (id, info_hash, torrent_bytes, trackers, output_folder, output_folder_root, only_files, is_paused) + VALUES($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT(id) DO NOTHING"; sqlx::query(q) .bind::(id.try_into()?) @@ -129,6 +133,15 @@ impl SessionPersistenceStore for PostgresSessionStorage { .context("output_folder")? .to_owned(), ) + .bind( + torrent + .shared() + .options + .output_folder_root + .as_ref() + .map(|path| path.to_str().context("output_folder_root")) + .transpose()?, + ) .bind(torrent.only_files().map(|o| { o.into_iter() .filter_map(|o| o.try_into().ok()) diff --git a/crates/librtbit/src/tests/read_only_storage.rs b/crates/librtbit/src/tests/read_only_storage.rs index 29b2bc4..bcb591f 100644 --- a/crates/librtbit/src/tests/read_only_storage.rs +++ b/crates/librtbit/src/tests/read_only_storage.rs @@ -203,3 +203,40 @@ async fn read_only_session_verifies_qbittorrent_multi_file_save_path() -> anyhow ); Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn read_only_session_verifies_single_entry_multi_file_save_path() -> anyhow::Result<()> { + let payload_root = TempDir::with_prefix("rtbit_qbit_single_entry_root")?; + let torrent_root = payload_root.path().join("single-entry-payload"); + std::fs::create_dir(&torrent_root)?; + std::fs::write(torrent_root.join("only.bin"), vec![0x56; 48 * 1024])?; + let torrent = create_multi_file_torrent(&torrent_root).await?; + + let session_dir = TempDir::with_prefix("rtbit_qbit_single_entry_session")?; + let session = create_read_only_session(session_dir.path()).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 single-entry multi-file 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()) + ); + Ok(()) +}