diff --git a/breadarrd/src/library_scan.rs b/breadarrd/src/library_scan.rs index 07db0ff..b5a3c7d 100644 --- a/breadarrd/src/library_scan.rs +++ b/breadarrd/src/library_scan.rs @@ -18,6 +18,7 @@ pub struct ScanReport { pub unmatched: Vec, pub files_linked: usize, pub files_renamed: usize, + pub files_reorganized: usize, } fn get_episode_title(conn: &Connection, episode_id: i64) -> Result> { @@ -525,6 +526,30 @@ pub async fn scan_tv_root( } }; + // Files imported before the season-folder convention existed + // (or moved around by hand) can still be sitting flat in the + // show root — move them under `Season NN` now rather than just + // recording wherever they happen to already be. Same + // filesystem as `series_dir`, so this is a plain rename. + let season_folder = importer::season_dir(&series_dir.to_string_lossy(), season); + let final_path = if final_path.parent() != Some(season_folder.as_path()) { + match std::fs::create_dir_all(&season_folder).and_then(|_| { + let dest = season_folder.join(final_path.file_name().unwrap()); + std::fs::rename(&final_path, &dest).map(|_| dest) + }) { + Ok(dest) => { + report.files_reorganized += 1; + dest + } + Err(e) => { + tracing::warn!(file = %final_path.display(), error = %e, "season-folder move failed, keeping in place"); + final_path + } + } + } else { + final_path + }; + let size = std::fs::metadata(&final_path)?.len(); conn.execute( "INSERT INTO episode_file (episode_id, path, size_bytes, subtitle_status) VALUES (?1, ?2, ?3, 'none')", diff --git a/breadarrd/src/main.rs b/breadarrd/src/main.rs index e2aa04c..b3014dd 100644 --- a/breadarrd/src/main.rs +++ b/breadarrd/src/main.rs @@ -927,11 +927,12 @@ async fn debug_scan_tv(config: &Config, path: &str) -> Result<()> { .await?; println!( - "matched={} unmatched={} files_linked={} files_renamed={}", + "matched={} unmatched={} files_linked={} files_renamed={} files_reorganized={}", report.matched.len(), report.unmatched.len(), report.files_linked, - report.files_renamed + report.files_renamed, + report.files_reorganized ); if !report.unmatched.is_empty() { println!("unmatched:"); @@ -974,11 +975,12 @@ async fn debug_scan_movies(config: &Config, path: &str) -> Result<()> { .await?; println!( - "matched={} unmatched={} files_linked={} files_renamed={}", + "matched={} unmatched={} files_linked={} files_renamed={} files_reorganized={}", report.matched.len(), report.unmatched.len(), report.files_linked, - report.files_renamed + report.files_renamed, + report.files_reorganized ); if !report.unmatched.is_empty() { println!("unmatched:"); diff --git a/breadarrd/src/qbit/mod.rs b/breadarrd/src/qbit/mod.rs index bf46181..bd6618d 100644 --- a/breadarrd/src/qbit/mod.rs +++ b/breadarrd/src/qbit/mod.rs @@ -33,6 +33,15 @@ impl std::fmt::Display for MagnetRejected { impl std::error::Error for MagnetRejected {} +/// Newer qBittorrent WebUI API versions' `torrents/add` JSON response shape. +#[derive(Debug, Deserialize, Default)] +struct AddTorrentResponse { + #[serde(default)] + success_count: u32, + #[serde(default)] + failure_count: u32, +} + pub struct QbitClient { base_url: String, client: reqwest::Client, @@ -101,7 +110,12 @@ impl QbitClient { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - if !status.is_success() || body.trim() != "Ok." { + // Older qBittorrent WebUI API versions return 200 with body "Ok."; + // newer ones return 204 No Content with an empty body instead. Bad + // credentials return a real error status (401), which `is_success` + // already catches — the response body's exact text isn't part of + // the actual success contract, just an artifact of the old version. + if !status.is_success() { bail!("qbit login failed: status={status} body={body:?}"); } Ok(()) @@ -142,13 +156,21 @@ impl QbitClient { } // qBittorrent's add-torrent endpoint returns HTTP 200 even when // it rejects the magnet outright (a dead/malformed hash, one it - // already knows is unreachable) — the *only* signal is the - // response body text ("Ok." vs "Fails."). Without this check a - // rejected magnet looks identical to a real success: the caller - // records a `release` row as grabbed and nothing ever - // downloads, silently and permanently (verified live — this - // happened for a real release). - if body.trim() != "Ok." { + // already knows is unreachable) — the response body is the only + // signal. Older qBittorrent versions returned plain text ("Ok." + // vs "Fails."); newer ones return a JSON summary with + // success_count/failure_count instead (verified live against + // the currently deployed version). Without checking whichever + // shape is actually in play, a rejected magnet looks identical + // to a real success: the caller records a `release` row as + // grabbed and nothing ever downloads, silently and permanently + // (verified live — this happened for a real release under the + // old text-only check). + let rejected = match serde_json::from_str::(&body) { + Ok(r) => r.failure_count > 0 || r.success_count == 0, + Err(_) => body.trim() != "Ok.", + }; + if rejected { return Err(anyhow::Error::new(MagnetRejected { body })); } return Ok(());