Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 69 additions & 20 deletions crates/librtbit/src/http_api/handlers/qbit_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use std::{
collections::HashMap,
num::NonZeroU16,
path::Path,
sync::Arc,
time::{Instant, SystemTime, UNIX_EPOCH},
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -839,9 +868,11 @@ async fn h_torrents_files(
let stats = handle.stats();
let is_seed = stats.finished;

let files: Vec<QbitFileInfo> = 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<QbitFileInfo> = details_files
.iter()
.enumerate()
.map(|(i, f)| {
Expand All @@ -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 },
Expand Down Expand Up @@ -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()
};
Expand All @@ -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()
};
Expand Down Expand Up @@ -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;
Expand All @@ -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<QbitState>, Arc<Session>, tempfile::TempDir) {
Expand Down Expand Up @@ -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
Expand Down
27 changes: 21 additions & 6 deletions crates/librtbit/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down
5 changes: 5 additions & 0 deletions crates/librtbit/src/session/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
/// 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<String>,
Expand Down
1 change: 1 addition & 0 deletions crates/librtbit/src/session_persistence/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 18 additions & 4 deletions crates/librtbit/src/session_persistence/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ pub struct SerializedTorrent {
torrent_bytes: Bytes,
trackers: HashSet<String>,
output_folder: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
output_folder_root: Option<PathBuf>,
only_files: Option<Vec<usize>>,
is_paused: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -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,
Expand Down
92 changes: 91 additions & 1 deletion crates/librtbit/src/tests/read_only_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
Expand Down Expand Up @@ -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<Vec<u8>> {
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")?;
Expand Down Expand Up @@ -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(())
}
1 change: 1 addition & 0 deletions crates/librtbit/src/torrent_state/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ pub(crate) struct ManagedTorrentOptions {
pub peer_read_write_timeout: Option<Duration>,
pub allow_overwrite: bool,
pub output_folder: PathBuf,
pub output_folder_root: Option<PathBuf>,
pub ratelimits: LimitsConfig,
pub initial_peers: Vec<SocketAddr>,
pub peer_limit: Option<usize>,
Expand Down
Loading