Fix nyaa grabs being silently dropped as false magnet rejections
Some checks failed
dev release / build (push) Failing after 38s
Some checks failed
dev release / build (push) Failing after 38s
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.
This commit is contained in:
parent
b49a8597e2
commit
1084be86cd
2 changed files with 98 additions and 17 deletions
|
|
@ -40,6 +40,40 @@ struct AddTorrentResponse {
|
||||||
success_count: u32,
|
success_count: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
failure_count: u32,
|
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::<AddTorrentResponse>(body) {
|
||||||
|
Ok(r) => r.failure_count > 0 || (r.success_count == 0 && r.pending_count == 0),
|
||||||
|
Err(_) => body.trim() != "Ok.",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct QbitClient {
|
pub struct QbitClient {
|
||||||
|
|
@ -157,23 +191,7 @@ impl QbitClient {
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
bail!("qbit add-torrent failed: status={status} body={body:?}");
|
bail!("qbit add-torrent failed: status={status} body={body:?}");
|
||||||
}
|
}
|
||||||
// qBittorrent's add-torrent endpoint returns HTTP 200 even when
|
if add_torrent_response_is_rejected(&body) {
|
||||||
// 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::<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(());
|
||||||
|
|
@ -292,4 +310,38 @@ mod tests {
|
||||||
None
|
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."));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -613,6 +613,33 @@ async fn process_item(
|
||||||
let torrent_hash = match grab_and_capture_hash(qbit, &item.link, qbit_category).await {
|
let torrent_hash = match grab_and_capture_hash(qbit, &item.link, qbit_category).await {
|
||||||
Ok(hash) => hash,
|
Ok(hash) => hash,
|
||||||
Err(e) if e.downcast_ref::<crate::qbit::MagnetRejected>().is_some() => {
|
Err(e) if e.downcast_ref::<crate::qbit::MagnetRejected>().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);
|
return Ok(ProcessOutcome::MagnetRejected);
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
|
|
@ -761,6 +788,7 @@ pub struct GrabCycleStats {
|
||||||
pub grabbed: usize,
|
pub grabbed: usize,
|
||||||
pub errors: usize,
|
pub errors: usize,
|
||||||
pub queued_for_review: usize,
|
pub queued_for_review: usize,
|
||||||
|
pub magnet_rejected: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_grab_cycle(
|
pub async fn run_grab_cycle(
|
||||||
|
|
@ -811,6 +839,7 @@ pub async fn run_grab_cycle(
|
||||||
match outcome {
|
match outcome {
|
||||||
ProcessOutcome::Grabbed { .. } => stats.grabbed += 1,
|
ProcessOutcome::Grabbed { .. } => stats.grabbed += 1,
|
||||||
ProcessOutcome::QueuedForReview => stats.queued_for_review += 1,
|
ProcessOutcome::QueuedForReview => stats.queued_for_review += 1,
|
||||||
|
ProcessOutcome::MagnetRejected => stats.magnet_rejected += 1,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue