From 77bf409c5d519650daccc3859df65bae76b5ce8c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Wed, 26 Aug 2026 12:54:47 +0100 Subject: [PATCH] fix(arr): supersede stale import failure --- ...29ed40a9e72fe57be4f407e132233d2820a1.json} | 4 +- crates/arr-api/src/series.rs | 56 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) rename .sqlx/{query-3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe.json => query-d870deeac972fd856a304b61131829ed40a9e72fe57be4f407e132233d2820a1.json} (71%) diff --git a/.sqlx/query-3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe.json b/.sqlx/query-d870deeac972fd856a304b61131829ed40a9e72fe57be4f407e132233d2820a1.json similarity index 71% rename from .sqlx/query-3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe.json rename to .sqlx/query-d870deeac972fd856a304b61131829ed40a9e72fe57be4f407e132233d2820a1.json index ec49475..832140c 100644 --- a/.sqlx/query-3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe.json +++ b/.sqlx/query-d870deeac972fd856a304b61131829ed40a9e72fe57be4f407e132233d2820a1.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT g.target_id AS \"season_id!: i64\",\n r.name AS \"name!: String\",\n g.infohash AS \"infohash!: String\",\n g.failed_at,\n g.grabbed_at AS \"grabbed_at!: String\"\n FROM grabs g\n JOIN releases r ON r.id = g.release_id\n JOIN seasons s ON s.id = g.target_id\n WHERE g.target_kind = 'season'\n AND g.state = 'failed'\n AND s.series_id = ?\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = s.id AND e.wanted\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n )\n ORDER BY coalesce(g.failed_at, g.grabbed_at), g.id", + "query": "SELECT g.target_id AS \"season_id!: i64\",\n r.name AS \"name!: String\",\n g.infohash AS \"infohash!: String\",\n g.failed_at,\n g.grabbed_at AS \"grabbed_at!: String\"\n FROM grabs g\n JOIN releases r ON r.id = g.release_id\n JOIN seasons s ON s.id = g.target_id\n WHERE g.target_kind = 'season'\n AND g.state = 'failed'\n AND s.series_id = ?\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = s.id AND e.wanted\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n )\n -- A later attempt owns the headline while it is still in\n -- flight. Keep the failed grab recorded; it is simply no\n -- longer the current explanation for the gap.\n AND NOT EXISTS (\n SELECT 1 FROM grabs newer\n WHERE newer.target_kind = 'season'\n AND newer.target_id = g.target_id\n AND newer.state != 'failed'\n AND (newer.grabbed_at > coalesce(g.failed_at, g.grabbed_at)\n OR (newer.grabbed_at = coalesce(g.failed_at, g.grabbed_at)\n AND newer.id > g.id))\n )\n ORDER BY coalesce(g.failed_at, g.grabbed_at), g.id", "describe": { "columns": [ { @@ -70,5 +70,5 @@ false ] }, - "hash": "3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe" + "hash": "d870deeac972fd856a304b61131829ed40a9e72fe57be4f407e132233d2820a1" } diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 1c05b23..becf180 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -1698,6 +1698,18 @@ async fn season_import_failures( WHERE f.owner_kind = 'episode' AND f.owner_id = e.id ) ) + -- A later attempt owns the headline while it is still in + -- flight. Keep the failed grab recorded; it is simply no + -- longer the current explanation for the gap. + AND NOT EXISTS ( + SELECT 1 FROM grabs newer + WHERE newer.target_kind = 'season' + AND newer.target_id = g.target_id + AND newer.state != 'failed' + AND (newer.grabbed_at > coalesce(g.failed_at, g.grabbed_at) + OR (newer.grabbed_at = coalesce(g.failed_at, g.grabbed_at) + AND newer.id > g.id)) + ) ORDER BY coalesce(g.failed_at, g.grabbed_at), g.id"#, series_id ) @@ -2103,6 +2115,7 @@ mod tests { /// season read `0/10` with nothing anywhere saying a grab had been tried. /// The season row, its deck and the deck's blacklisted release now each /// carry the failure and the reason it failed on. + #[allow(clippy::too_many_lines)] #[tokio::test] async fn an_abandoned_pack_is_visible_on_the_season_and_its_deck() { let (_dir, state, base) = application().await; @@ -2161,6 +2174,49 @@ mod tests { .await .expect("failed pack grab"); + // A newer attempt supersedes the old failure while it is in flight. + let newer_name = "Rick.And.Morty.S08.720p.WEB-DL.x264-NEW"; + let newer_release_id = sqlx::query_scalar::<_, i64>( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) + VALUES (7, 'new-pack', ?, 1000, 'url', ?, 'eligible') RETURNING id", + ) + .bind(newer_name) + .bind(serde_json::to_string(&parsed).expect("parsed json")) + .fetch_one(pool) + .await + .expect("newer release"); + sqlx::query( + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at) + VALUES (?, 'season', ?, 'new123', 'sent', '2026-01-03T00:00:00.000Z')", + ) + .bind(newer_release_id) + .bind(season_id) + .execute(pool) + .await + .expect("newer in-flight grab"); + + let seasons = seasons_json(&base, series_id).await; + assert!( + seasons[0]["import_failure"].is_null(), + "a newer in-flight grab supersedes the old failure" + ); + let pack_state: serde_json::Value = reqwest::get(format!( + "{base}/api/series/{series_id}/seasons/8/pack-state" + )) + .await + .expect("pack state") + .json() + .await + .expect("pack state json"); + assert!( + pack_state["import_failure"].is_null(), + "the pack-state endpoint uses the same supersession rule" + ); + sqlx::query("DELETE FROM grabs WHERE infohash = 'new123'") + .execute(pool) + .await + .expect("remove test attempt"); + // The failure with no blacklist row yet: still a failure, and the // reason is simply not known. Rows written before the blacklist // carried one read this way and must keep rendering.