Fix qBittorrent WebUI API compat and season-folder placement in library scan

qBittorrent's newer WebUI API returns 204 (not 200 "Ok.") on login success,
and a JSON success/failure summary (not plain "Ok."/"Fails." text) from
torrents/add — both broke against the currently deployed version. Also had
scan_tv_root move already-tracked episode files into their Season NN
subfolder instead of just recording wherever they already sat on disk.
This commit is contained in:
Breadway 2026-07-21 19:17:42 +08:00
parent 9d8a59e3a8
commit 54818f5f05
3 changed files with 61 additions and 12 deletions

View file

@ -18,6 +18,7 @@ pub struct ScanReport {
pub unmatched: Vec<String>, pub unmatched: Vec<String>,
pub files_linked: usize, pub files_linked: usize,
pub files_renamed: usize, pub files_renamed: usize,
pub files_reorganized: usize,
} }
fn get_episode_title(conn: &Connection, episode_id: i64) -> Result<Option<String>> { fn get_episode_title(conn: &Connection, episode_id: i64) -> Result<Option<String>> {
@ -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(); let size = std::fs::metadata(&final_path)?.len();
conn.execute( conn.execute(
"INSERT INTO episode_file (episode_id, path, size_bytes, subtitle_status) VALUES (?1, ?2, ?3, 'none')", "INSERT INTO episode_file (episode_id, path, size_bytes, subtitle_status) VALUES (?1, ?2, ?3, 'none')",

View file

@ -927,11 +927,12 @@ async fn debug_scan_tv(config: &Config, path: &str) -> Result<()> {
.await?; .await?;
println!( println!(
"matched={} unmatched={} files_linked={} files_renamed={}", "matched={} unmatched={} files_linked={} files_renamed={} files_reorganized={}",
report.matched.len(), report.matched.len(),
report.unmatched.len(), report.unmatched.len(),
report.files_linked, report.files_linked,
report.files_renamed report.files_renamed,
report.files_reorganized
); );
if !report.unmatched.is_empty() { if !report.unmatched.is_empty() {
println!("unmatched:"); println!("unmatched:");
@ -974,11 +975,12 @@ async fn debug_scan_movies(config: &Config, path: &str) -> Result<()> {
.await?; .await?;
println!( println!(
"matched={} unmatched={} files_linked={} files_renamed={}", "matched={} unmatched={} files_linked={} files_renamed={} files_reorganized={}",
report.matched.len(), report.matched.len(),
report.unmatched.len(), report.unmatched.len(),
report.files_linked, report.files_linked,
report.files_renamed report.files_renamed,
report.files_reorganized
); );
if !report.unmatched.is_empty() { if !report.unmatched.is_empty() {
println!("unmatched:"); println!("unmatched:");

View file

@ -33,6 +33,15 @@ impl std::fmt::Display for MagnetRejected {
impl std::error::Error 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 { pub struct QbitClient {
base_url: String, base_url: String,
client: reqwest::Client, client: reqwest::Client,
@ -101,7 +110,12 @@ impl QbitClient {
let status = resp.status(); let status = resp.status();
let body = resp.text().await.unwrap_or_default(); 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:?}"); bail!("qbit login failed: status={status} body={body:?}");
} }
Ok(()) Ok(())
@ -142,13 +156,21 @@ impl QbitClient {
} }
// qBittorrent's add-torrent endpoint returns HTTP 200 even when // qBittorrent's add-torrent endpoint returns HTTP 200 even when
// it rejects the magnet outright (a dead/malformed hash, one it // it rejects the magnet outright (a dead/malformed hash, one it
// already knows is unreachable) — the *only* signal is the // already knows is unreachable) — the response body is the only
// response body text ("Ok." vs "Fails."). Without this check a // signal. Older qBittorrent versions returned plain text ("Ok."
// rejected magnet looks identical to a real success: the caller // vs "Fails."); newer ones return a JSON summary with
// records a `release` row as grabbed and nothing ever // success_count/failure_count instead (verified live against
// downloads, silently and permanently (verified live — this // the currently deployed version). Without checking whichever
// happened for a real release). // shape is actually in play, a rejected magnet looks identical
if body.trim() != "Ok." { // 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::<AddTorrentResponse>(&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 Err(anyhow::Error::new(MagnetRejected { body }));
} }
return Ok(()); return Ok(());