From 1084be86cd41407bbbd6efe1dc068a595e463145 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 7 Aug 2026 18:33:18 +0800 Subject: [PATCH] Fix nyaa grabs being silently dropped as false magnet rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add_torrent_response_is_rejected treated success_count == 0 alone as an outright rejection. qBittorrent returns exactly that (with pending_count: 1) for every URL-based add while it fetches the torrent asynchronously — the shape nyaa's RSS feed always uses, since it hands over a .torrent download URL, never a magnet. Every nyaa grab was therefore marked MagnetRejected and silently dropped (no release row, no log line) while the torrent downloaded successfully in the background. With no release row, best_existing_score always saw None, so the same episode got re-grabbed from every new feed entry — the repeated duplicate downloads sitting in /mnt/media/downloads/ (Mushoku Tensei, Tenki no Ko, Code Geass) traced back to this. Root-caused by an Opus 5 investigation. Also stop treating a genuine rejection as silent: process_item now records a failed release row and logs a warning, matching the existing HashCaptureFailed pattern, instead of just marking the item seen and moving on with no trace. --- breadarrd/src/qbit/mod.rs | 86 ++++++++++++++++++++++++++++++-------- breadarrd/src/scheduler.rs | 29 +++++++++++++ 2 files changed, 98 insertions(+), 17 deletions(-) diff --git a/breadarrd/src/qbit/mod.rs b/breadarrd/src/qbit/mod.rs index ec9af4b..7510c7f 100644 --- a/breadarrd/src/qbit/mod.rs +++ b/breadarrd/src/qbit/mod.rs @@ -40,6 +40,40 @@ struct AddTorrentResponse { success_count: u32, #[serde(default)] failure_count: u32, + /// Present when adding by URL rather than by a magnet/`.torrent` blob + /// qBittorrent already has in hand — the torrent itself is fetched + /// asynchronously, so the immediate response has neither succeeded nor + /// failed yet, just queued. nyaa's RSS feed always hands over a + /// `.torrent` download URL (never a magnet), so this is the normal + /// shape for every anime grab, not an edge case. Verified live against + /// the deployed qBittorrent: a URL add returns HTTP 202 with + /// `{"pending_count":1,"success_count":0,"failure_count":0}` — treating + /// `success_count == 0` alone as rejection (the previous check) silently + /// dropped every one of these while the torrent downloaded successfully + /// in the background, with no DB record and no log line. + #[serde(default)] + pending_count: u32, +} + +/// Interprets a `torrents/add` response body — split out from `add_magnet` +/// so it's directly testable without a live qBittorrent server. +/// qBittorrent's add-torrent endpoint returns HTTP 200/202 even when it +/// rejects the magnet outright (a dead/malformed hash, one it 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/pending_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, +/// and separately for every nyaa URL-add under a since-fixed +/// `success_count == 0` check that didn't account for `pending_count`). +fn add_torrent_response_is_rejected(body: &str) -> bool { + match serde_json::from_str::(body) { + Ok(r) => r.failure_count > 0 || (r.success_count == 0 && r.pending_count == 0), + Err(_) => body.trim() != "Ok.", + } } pub struct QbitClient { @@ -157,23 +191,7 @@ impl QbitClient { if !status.is_success() { bail!("qbit add-torrent failed: status={status} body={body:?}"); } - // 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 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 { + if add_torrent_response_is_rejected(&body) { return Err(anyhow::Error::new(MagnetRejected { body })); } return Ok(()); @@ -292,4 +310,38 @@ mod tests { None ); } + + #[test] + fn a_pending_url_add_is_not_treated_as_rejected() { + // Exact shape qBittorrent 5.x returns for a URL add (every nyaa + // grab) — the torrent is queued for an async fetch, not yet + // succeeded or failed. This is the shape that used to be + // misread as an outright rejection. + let body = r#"{"added_torrent_ids":[],"failure_count":0,"pending_count":1,"success_count":0}"#; + assert!(!add_torrent_response_is_rejected(body)); + } + + #[test] + fn a_genuine_failure_is_still_rejected() { + let body = r#"{"failure_count":1,"pending_count":0,"success_count":0}"#; + assert!(add_torrent_response_is_rejected(body)); + } + + #[test] + fn an_in_band_success_is_not_rejected() { + // TPB/1337x magnets: qBittorrent already has the info hash, no + // async fetch needed, so success_count is set immediately. + let body = r#"{"failure_count":0,"pending_count":0,"success_count":1}"#; + assert!(!add_torrent_response_is_rejected(body)); + } + + #[test] + fn the_old_plain_text_ok_response_is_not_rejected() { + assert!(!add_torrent_response_is_rejected("Ok.")); + } + + #[test] + fn the_old_plain_text_fails_response_is_rejected() { + assert!(add_torrent_response_is_rejected("Fails.")); + } } diff --git a/breadarrd/src/scheduler.rs b/breadarrd/src/scheduler.rs index 9cd10a7..1af0718 100644 --- a/breadarrd/src/scheduler.rs +++ b/breadarrd/src/scheduler.rs @@ -613,6 +613,33 @@ async fn process_item( let torrent_hash = match grab_and_capture_hash(qbit, &item.link, qbit_category).await { Ok(hash) => hash, Err(e) if e.downcast_ref::().is_some() => { + // Same reasoning as `HashCaptureFailed` just below: recording + // this as `failed` rather than leaving it with no release row + // at all is what makes a genuine rejection visible and frees + // the episode/movie to be re-searched later with a different + // candidate. A silent `Ok(MagnetRejected)` with nothing written + // used to be indistinguishable from a real success once + // `mark_seen` ran — a since-fixed bug in the response-rejection + // check (see `qbit::add_torrent_response_is_rejected`'s doc + // comment) made every nyaa URL-add hit this path even though + // the torrent was actually downloading, so this arm went + // unnoticed for a long time; logging it now that it only fires + // on real rejections. + tracing::warn!(title = %item.title, guid = %item.guid, "qbittorrent rejected this release's magnet/torrent"); + record_grab( + conn, + media_item.id, + episode_id, + season_pack_number, + source_id, + &item.title, + &item.guid, + release_score, + item.size_bytes, + qbit_category, + None, + "failed", + )?; return Ok(ProcessOutcome::MagnetRejected); } Err(e) => return Err(e), @@ -761,6 +788,7 @@ pub struct GrabCycleStats { pub grabbed: usize, pub errors: usize, pub queued_for_review: usize, + pub magnet_rejected: usize, } pub async fn run_grab_cycle( @@ -811,6 +839,7 @@ pub async fn run_grab_cycle( match outcome { ProcessOutcome::Grabbed { .. } => stats.grabbed += 1, ProcessOutcome::QueuedForReview => stats.queued_for_review += 1, + ProcessOutcome::MagnetRejected => stats.magnet_rejected += 1, _ => {} } }