From 0f6fa543649219d05e6056a767b25d1c2507d7c2 Mon Sep 17 00:00:00 2001 From: bugrax Date: Sat, 18 Jul 2026 14:06:09 +0300 Subject: [PATCH 1/2] feat: add keep_downloads option and prune completed TODO items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `keep_downloads` config option (default false). When true, the locally downloaded files are kept in `download_directory` after the *arr imports them instead of being deleted — useful when the *arr copies imports and you want to keep the original. put.io transfers/files are still cleaned up as usual. Also prunes the README TODO list of items that are done or no longer relevant: - "Better error handling and retry behavior" — addressed across the download resume/retry, request/stream timeouts, and worker-resilience work (#21, #30, #32, #34). - "The session ID provided is hard coded. Not sure if it matters." — it doesn't; sonarr/radarr/whisparr work fine against the fixed session id. - "Add option to not delete downloads" — implemented here. --- README.md | 8 ++++--- src/download_system/orchestration.rs | 33 +++++++++++++++++----------- src/main.rs | 9 ++++++++ src/utils.rs | 5 +++++ 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 0319d93..2d411bc 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,11 @@ orchestration_workers = 10 # Optional number of download workers, default 4. This controls how many downloads we run in parallel. download_workers = 4 +# Optional. When false (default), locally downloaded files are deleted after +# sonarr/radarr/whisparr imports them. Set to true to keep them in +# download_directory instead (put.io transfers/files are still cleaned up). +keep_downloads = false + [putio] # Required. Putio API key. You can generate one using `putioarr get-token` api_key = "MYPUTIOKEY" @@ -180,9 +185,6 @@ To prevent Sonarr/Radarr/Whisparr from seeing each other's downloads, you can co This feature ensures each *arr application only sees and processes its own downloads. ## TODO: -- Better Error handling and retry behavior -- The session ID provided is hard coded. Not sure if it matters. -- (Add option to not delete downloads) - Figure out a better way to map a transfer to a completed import. Since a transfer can contain multiple files (e.g. a whole season) we currently check if all video files have been imported. Most of the time this is fine, except when there are sample videos. sonarr/radarr/whisparr will not import samples, but will make no mention of the fact that the sample was skipped. Right now we check against the `skip_directories` list, which works, but might be tedious. - Automatically pick the right putio proxy based on speed diff --git a/src/download_system/orchestration.rs b/src/download_system/orchestration.rs index 1968976..878d00d 100644 --- a/src/download_system/orchestration.rs +++ b/src/download_system/orchestration.rs @@ -157,19 +157,26 @@ async fn watch_for_import( info!("{}: imported", transfer); let top_level_target = transfer.get_top_level(); - match metadata(&top_level_target.to).await { - Ok(m) if m.is_dir() => { - fs::remove_dir_all(&top_level_target.to).unwrap(); - info!("{}: deleted", &top_level_target); - } - Ok(m) if m.is_file() => { - fs::remove_file(&top_level_target.to).unwrap(); - info!("{}: deleted", &top_level_target); - } - Ok(_) | Err(_) => { - panic!("{}: no idea how to handle", &top_level_target) - } - }; + // Remove the local copy now that it's imported, unless the user + // opted to keep downloads (e.g. the *arr copies imports and they + // want to keep the original). + if app_data.config.keep_downloads { + info!("{}: keeping local download (keep_downloads)", &top_level_target); + } else { + match metadata(&top_level_target.to).await { + Ok(m) if m.is_dir() => { + fs::remove_dir_all(&top_level_target.to).unwrap(); + info!("{}: deleted", &top_level_target); + } + Ok(m) if m.is_file() => { + fs::remove_file(&top_level_target.to).unwrap(); + info!("{}: deleted", &top_level_target); + } + Ok(_) | Err(_) => { + panic!("{}: no idea how to handle", &top_level_target) + } + }; + } // An orphan has no put.io transfer to remove or seed, so finish it // here directly instead of routing an Imported message through a // worker (which may be busy downloading and never pick it up), diff --git a/src/main.rs b/src/main.rs index 362cf46..db7c2ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -82,6 +82,14 @@ pub struct Config { /// lets the put.io account be shared with manual downloads. #[serde(default)] download_unmanaged: bool, + /// When true, keep the locally downloaded files in `download_directory` + /// after the *arr imports them, instead of deleting them. Off by default, + /// which preserves the normal behaviour of removing a download once its + /// import is confirmed. Useful when the *arr copies (rather than + /// hardlinks/moves) imports and you want to keep the original. Only affects + /// the local files; put.io transfers/files are still cleaned up as usual. + #[serde(default)] + keep_downloads: bool, /// put.io folder ids to additionally scan for *orphaned* completed files: /// files that were downloaded but whose transfer record no longer exists /// (e.g. put.io's "clear completed transfers" removes the transfer while @@ -184,6 +192,7 @@ async fn main() -> Result<()> { .join(Serialized::default("port", 9091)) .join(Serialized::default("uid", 1000)) .join(Serialized::default("download_unmanaged", false)) + .join(Serialized::default("keep_downloads", false)) .join(Serialized::default( "skip_directories", vec!["sample", "extras"], diff --git a/src/utils.rs b/src/utils.rs index 243a9da..1bf473a 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -44,6 +44,11 @@ skip_directories = ["sample", "extras"] # transfers. Set to true to download every transfer on the account. download_unmanaged = false +# Optional. When false (default), locally downloaded files are deleted after the +# *arr imports them. Set to true to keep them in download_directory instead (put.io +# transfers/files are still cleaned up as usual). +keep_downloads = false + # Optional. put.io folder ids to scan for *orphaned* completed files: files that # were downloaded but whose transfer record no longer exists (e.g. put.io's # "clear completed transfers" removes the transfer but leaves the file). Such From 934cb1f1a5c1a6c0833ae8dc9e145abf7fe4ea7c Mon Sep 17 00:00:00 2001 From: bugrax Date: Sat, 18 Jul 2026 14:14:46 +0300 Subject: [PATCH 2/2] Address review: non-panicking async delete, accurate *arr doc - orchestration: delete the imported local copy with async fs and log failures instead of blocking std::fs + unwrap/panic, so a failed delete (permissions, concurrent removal, path already gone) can't take the process down or stall the runtime. - README: the keep_downloads note referred to sonarr/radarr/whisparr; use "*arr" since lidarr and [arrs.*] instances are supported too. --- README.md | 4 ++-- src/download_system/orchestration.rs | 28 ++++++++++++++-------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 2d411bc..7aaccaa 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,8 @@ orchestration_workers = 10 download_workers = 4 # Optional. When false (default), locally downloaded files are deleted after -# sonarr/radarr/whisparr imports them. Set to true to keep them in -# download_directory instead (put.io transfers/files are still cleaned up). +# the *arr imports them. Set to true to keep them in download_directory instead +# (put.io transfers/files are still cleaned up). keep_downloads = false [putio] diff --git a/src/download_system/orchestration.rs b/src/download_system/orchestration.rs index 878d00d..63d3181 100644 --- a/src/download_system/orchestration.rs +++ b/src/download_system/orchestration.rs @@ -11,10 +11,7 @@ use anyhow::Result; use async_channel::{Receiver, Sender}; use colored::*; use log::{info, warn}; -use std::{ - fs, - time::{Duration, Instant}, -}; +use std::time::{Duration, Instant}; use tokio::{fs::metadata, time::sleep}; use super::transfer::TransferMessage; @@ -163,19 +160,22 @@ async fn watch_for_import( if app_data.config.keep_downloads { info!("{}: keeping local download (keep_downloads)", &top_level_target); } else { - match metadata(&top_level_target.to).await { + // Use async fs and log failures instead of unwrap/panic: a failed + // delete (permissions, a concurrent removal, the path already gone) + // shouldn't take the process down, and blocking fs here could stall + // the runtime under load. + let result = match metadata(&top_level_target.to).await { Ok(m) if m.is_dir() => { - fs::remove_dir_all(&top_level_target.to).unwrap(); - info!("{}: deleted", &top_level_target); - } - Ok(m) if m.is_file() => { - fs::remove_file(&top_level_target.to).unwrap(); - info!("{}: deleted", &top_level_target); - } - Ok(_) | Err(_) => { - panic!("{}: no idea how to handle", &top_level_target) + tokio::fs::remove_dir_all(&top_level_target.to).await } + Ok(m) if m.is_file() => tokio::fs::remove_file(&top_level_target.to).await, + // Neither a file nor a dir (e.g. already removed): nothing to do. + Ok(_) | Err(_) => Ok(()), }; + match result { + Ok(_) => info!("{}: deleted", &top_level_target), + Err(e) => warn!("{}: failed to delete local copy: {}", &top_level_target, e), + } } // An orphan has no put.io transfer to remove or seed, so finish it // here directly instead of routing an Imported message through a