Skip to content

Commit 70e12c8

Browse files
committed
perf: Re-architecture save_path to avoid mismatches during rsync and replace with a fixed value that is guaranteed to exist
1 parent 5208d2e commit 70e12c8

7 files changed

Lines changed: 129 additions & 19 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,4 @@ dotenv = "0.15"
4141
url = "2.5"
4242
regex = "1.12"
4343
rusqlite = { version = "0.39", features = ["bundled"] }
44+
dirs = "6.0.0"

src/api.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::{constant, settings};
1+
use crate::{constant, savepath, settings};
22
use crate::{database, qb};
33

44
use actix_web::{web, HttpRequest, HttpResponse, Responder};
@@ -310,13 +310,12 @@ pub async fn put_torrent(
310310
.collect();
311311

312312
let mut response: Vec<HashMap<String, String>> = Vec::new();
313-
for item in resolve_payload(&body.into_inner()) {
313+
for mut item in resolve_payload(&body.into_inner()) {
314314
let tag = Uuid::new_v4().to_string();
315315
let url = item.url.to_string();
316316
let name = item.name.as_ref().unwrap().to_string();
317317
let hash = item.hash.as_ref().unwrap().to_uppercase().to_string();
318318
let trackers = item.trackers.as_ref().unwrap().to_vec();
319-
let save_path = item.save_path.to_string();
320319

321320
if hashes.contains(&hash) {
322321
response.push(HashMap::from([(name, 409.to_string())]));
@@ -331,9 +330,11 @@ pub async fn put_torrent(
331330
);
332331

333332
let mut params = vec![("urls", &url), ("tags", &tag)];
334-
if !save_path.is_empty() {
335-
params.push(("savepath", &save_path));
333+
if item.save_path.is_empty() {
334+
item.save_path = savepath::get_default_save_path(&client, &config, &name).await;
336335
}
336+
log::info!("Destination for '{}': {}", name, item.save_path);
337+
params.push(("savepath", &item.save_path));
337338

338339
let resp = client
339340
.post(format!("{}/api/v2/torrents/add", config.qbit_url))

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ mod ntfy;
1414
mod parser;
1515
mod qb;
1616
mod rsync;
17+
mod savepath;
1718
mod settings;
1819
mod squire;
1920
mod swagger;

src/rsync.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ pub async fn run(
5050
state: settings::SharedState,
5151
hash: String,
5252
name: String,
53-
source: String,
5453
put_item: settings::PutItem,
5554
) {
5655
log::info!("Starting rsync for {}", name);
@@ -59,14 +58,15 @@ pub async fn run(
5958
"{}@{}:{}",
6059
put_item.remote_username, put_item.remote_host, put_item.remote_path
6160
);
61+
log::info!("{} -> {}", &put_item.save_path, &remote);
6262

6363
let child_result = Command::new("rsync")
6464
.args([
6565
"-az",
6666
"--progress",
6767
"--partial",
6868
"--inplace",
69-
&source,
69+
&put_item.save_path,
7070
&remote,
7171
])
7272
.stdout(std::process::Stdio::piped())

src/savepath.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
use reqwest::Client;
2+
use serde_json::Value;
3+
use std::path::Path;
4+
5+
use crate::{settings, squire};
6+
7+
/// Constructs a default "Downloads" directory in the HOME folder.
8+
///
9+
/// # Arguments
10+
///
11+
/// * `child_dir` - Child directory that has to be appended to the default "Downloads" folder.
12+
///
13+
/// # Returns
14+
///
15+
/// Returns the constructed "Downloads" directory.
16+
fn default_download_path(child_dir: &str) -> String {
17+
match dirs::home_dir() {
18+
Some(home) => {
19+
let path = home.join("Downloads").join(child_dir);
20+
match path.to_str() {
21+
Some(path_str) => path_str.to_string(),
22+
None => {
23+
log::warn!("Downloads path contains invalid UTF-8, falling back to /tmp");
24+
format!("/tmp/{}", child_dir)
25+
}
26+
}
27+
}
28+
None => {
29+
log::info!("Could not determine HOME directory, falling back to /tmp");
30+
format!("/tmp/{}", child_dir)
31+
}
32+
}
33+
}
34+
35+
/// Fetches the default save path configured in qBittorrent.
36+
///
37+
/// # Arguments
38+
///
39+
/// * `client` - Authenticated HTTP client for qBittorrent API requests.
40+
/// * `config` - Application configuration containing the qBittorrent URL.
41+
///
42+
/// # Returns
43+
///
44+
/// Returns the default save path as a `String`, or a fallback path if the
45+
/// request fails or the field is absent.
46+
pub async fn get_default_save_path(
47+
client: &Client,
48+
config: &settings::Config,
49+
child_dir: &String,
50+
) -> String {
51+
// 1. Check environment variable override
52+
let default_save_env = squire::get_env_var("save_path", None);
53+
if !default_save_env.is_empty() {
54+
match std::fs::create_dir_all(&default_save_env) {
55+
Ok(_) => {
56+
log::debug!(
57+
"Verified save_path environment directory exists: {}",
58+
default_save_env
59+
);
60+
}
61+
Err(err) => {
62+
log::warn!(
63+
"Failed to create save_path (from env) directory '{}': {}",
64+
default_save_env,
65+
err
66+
);
67+
}
68+
}
69+
let joined = Path::new(&default_save_env).join(child_dir);
70+
return joined.to_string_lossy().into_owned();
71+
}
72+
73+
// 2. Fetch qBittorrent preferences
74+
let response = match client
75+
.get(format!("{}/api/v2/app/preferences", config.qbit_url))
76+
.send()
77+
.await
78+
{
79+
Ok(resp) => resp,
80+
Err(err) => {
81+
log::error!("Failed to fetch qBittorrent preferences: {}", err);
82+
return default_download_path(child_dir);
83+
}
84+
};
85+
86+
// 3. Parse JSON response
87+
let resp_json: Value = match response.json().await {
88+
Ok(json) => json,
89+
Err(err) => {
90+
log::error!("Failed to parse qBittorrent preferences JSON: {}", err);
91+
return default_download_path(child_dir);
92+
}
93+
};
94+
95+
// 4. Extract save_path
96+
match resp_json["save_path"].as_str() {
97+
Some(path) if !path.is_empty() => {
98+
log::info!("Using qBittorrent save path: {}", path);
99+
Path::new(path)
100+
.join(child_dir)
101+
.to_string_lossy()
102+
.into_owned()
103+
}
104+
Some(_) => {
105+
log::info!("qBittorrent save_path is empty, using fallback");
106+
default_download_path(child_dir)
107+
}
108+
None => {
109+
log::info!("qBittorrent preferences missing save_path, using fallback");
110+
default_download_path(child_dir)
111+
}
112+
}
113+
}

src/settings.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ fn default_path() -> String {
262262

263263
/// Gets the default save path from the `save_path` environment variable.
264264
fn default_save_path() -> String {
265-
squire::get_env_var("save_path", None)
265+
String::new()
266266
}
267267

268268
/// Determines whether files should be deleted after copying.

src/squire.rs

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,6 @@ pub fn spawn_worker(
221221
for t in arr {
222222
let hash = t["hash"].as_str().unwrap_or("").to_string();
223223
let progress = t["progress"].as_f64().unwrap_or(0.0);
224-
let content_path = t["content_path"].as_str().unwrap_or("").to_string();
225224

226225
let Some(entry) = db.get_mut(&hash) else {
227226
continue;
@@ -268,8 +267,9 @@ pub fn spawn_worker(
268267
.await;
269268
if let Err(e) = qb::handle_response(resp, "DELETE torrent").await {
270269
log::error!("Failed to delete torrent: {}", e.status());
271-
if std::path::Path::new(&content_path).exists()
272-
&& let Err(err) = std::fs::remove_dir_all(content_path)
270+
if std::path::Path::new(&entry.put_item.save_path).exists()
271+
&& let Err(err) =
272+
std::fs::remove_dir_all(&entry.put_item.save_path)
273273
{
274274
log::error!("Failed to delete files: {}", err);
275275
notifier(
@@ -297,14 +297,8 @@ pub fn spawn_worker(
297297
let put_item_clone = entry.put_item.clone();
298298
// Kick off transfer in the background
299299
tokio::spawn(async move {
300-
rsync::run(
301-
state_clone,
302-
hash_clone,
303-
name_clone,
304-
content_path,
305-
put_item_clone,
306-
)
307-
.await;
300+
rsync::run(state_clone, hash_clone, name_clone, put_item_clone)
301+
.await;
308302
});
309303
// Kick off download complete notification in the background
310304
let config_cloned = config.clone();

0 commit comments

Comments
 (0)