diff --git a/.sqlx/query-4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e.json b/.sqlx/query-1a2660bb8b6ac22352a2c8262423151629f0b0870cad7a4462f21013ac43606e.json similarity index 82% rename from .sqlx/query-4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e.json rename to .sqlx/query-1a2660bb8b6ac22352a2c8262423151629f0b0870cad7a4462f21013ac43606e.json index a1a303a..58d5fb1 100644 --- a/.sqlx/query-4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e.json +++ b/.sqlx/query-1a2660bb8b6ac22352a2c8262423151629f0b0870cad7a4462f21013ac43606e.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, se.id\n ", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n GROUP BY s.id, s.title, s.year, se.id\n HAVING count(DISTINCT g.release_id) >= 2\n ", "describe": { "columns": [ { @@ -49,7 +49,7 @@ } ], "parameters": { - "Right": 0 + "Right": 1 }, "nullable": [ false, @@ -58,5 +58,5 @@ false ] }, - "hash": "4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e" + "hash": "1a2660bb8b6ac22352a2c8262423151629f0b0870cad7a4462f21013ac43606e" } diff --git a/.sqlx/query-7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51.json b/.sqlx/query-44d8376cc9cdf66afb89de1374a332bfed6d33927db2f0992e2e1b793ee99b42.json similarity index 85% rename from .sqlx/query-7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51.json rename to .sqlx/query-44d8376cc9cdf66afb89de1374a332bfed6d33927db2f0992e2e1b793ee99b42.json index feed8c1..8356895 100644 --- a/.sqlx/query-7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51.json +++ b/.sqlx/query-44d8376cc9cdf66afb89de1374a332bfed6d33927db2f0992e2e1b793ee99b42.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n ", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n HAVING count(DISTINCT g.release_id) >= 2\n ", "describe": { "columns": [ { @@ -71,7 +71,7 @@ } ], "parameters": { - "Right": 0 + "Right": 1 }, "nullable": [ false, @@ -82,5 +82,5 @@ false ] }, - "hash": "7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51" + "hash": "44d8376cc9cdf66afb89de1374a332bfed6d33927db2f0992e2e1b793ee99b42" } diff --git a/.sqlx/query-aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702.json b/.sqlx/query-a8beee4a6c6f00a299cb6c6ec1bb2a4ef2613b8b57366fc7f2fbc56be29ce72c.json similarity index 87% rename from .sqlx/query-aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702.json rename to .sqlx/query-a8beee4a6c6f00a299cb6c6ec1bb2a4ef2613b8b57366fc7f2fbc56be29ce72c.json index bc48f31..79089ad 100644 --- a/.sqlx/query-aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702.json +++ b/.sqlx/query-a8beee4a6c6f00a299cb6c6ec1bb2a4ef2613b8b57366fc7f2fbc56be29ce72c.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ", "describe": { "columns": [ { @@ -82,7 +82,7 @@ } ], "parameters": { - "Right": 0 + "Right": 1 }, "nullable": [ false, @@ -94,5 +94,5 @@ false ] }, - "hash": "aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702" + "hash": "a8beee4a6c6f00a299cb6c6ec1bb2a4ef2613b8b57366fc7f2fbc56be29ce72c" } diff --git a/.sqlx/query-9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2.json b/.sqlx/query-b35fe903d45f1c3ec7a963aac599331f602ea73b2e860a623b79ae435beca614.json similarity index 94% rename from .sqlx/query-9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2.json rename to .sqlx/query-b35fe903d45f1c3ec7a963aac599331f602ea73b2e860a623b79ae435beca614.json index d9e9698..0377826 100644 --- a/.sqlx/query-9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2.json +++ b/.sqlx/query-b35fe903d45f1c3ec7a963aac599331f602ea73b2e860a623b79ae435beca614.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title", + "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title", "describe": { "columns": [ { @@ -170,7 +170,7 @@ } ], "parameters": { - "Right": 0 + "Right": 1 }, "nullable": [ false, @@ -190,5 +190,5 @@ true ] }, - "hash": "9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2" + "hash": "b35fe903d45f1c3ec7a963aac599331f602ea73b2e860a623b79ae435beca614" } diff --git a/.sqlx/query-91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d.json b/.sqlx/query-ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be.json similarity index 84% rename from .sqlx/query-91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d.json rename to .sqlx/query-ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be.json index ae097a4..b982458 100644 --- a/.sqlx/query-91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d.json +++ b/.sqlx/query-ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed') >= 2\n ", + "query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2\n ", "describe": { "columns": [ { @@ -38,7 +38,7 @@ } ], "parameters": { - "Right": 0 + "Right": 1 }, "nullable": [ false, @@ -46,5 +46,5 @@ true ] }, - "hash": "91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d" + "hash": "ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be" } diff --git a/.sqlx/query-1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6.json b/.sqlx/query-edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86.json similarity index 81% rename from .sqlx/query-1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6.json rename to .sqlx/query-edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86.json index bcd58e4..3b0c337 100644 --- a/.sqlx/query-1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6.json +++ b/.sqlx/query-edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ", "describe": { "columns": [ { @@ -49,7 +49,7 @@ } ], "parameters": { - "Right": 0 + "Right": 1 }, "nullable": [ false, @@ -58,5 +58,5 @@ false ] }, - "hash": "1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6" + "hash": "edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86" } diff --git a/DESIGN.md b/DESIGN.md index 8d16fd3..584de2c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -378,6 +378,23 @@ A policy violation found by `ffprobe` is not one thing. Neither deletes the torrent. See §7.3. +**Two hard failures make a decision, and only for 30 days.** A movie, an +episode or a season enters the needs-a-decision queue (§9.5) when two grabs +against *different* releases hard-failed on it, and both of those failures +happened within the last 30 days. One bad torrent is not a decision — a +release that hard-failed is blacklisted (§6.3) and the next candidate is +grabbed, which is the system working. + +The window is what lets the queue be emptied. Nothing clears a `grabs` row, so +without it the queue only ever grows and the one season that wants attention +sits behind eight that were dealt with months ago. It is the queue's version of +§6.2's "it never gives up entirely, it goes quiet": a target the operator has +dealt with stops producing failures and drops out once the last one ages past +30 days, while a target that is still broken keeps producing them — the pack +guard retries at worst weekly (§6.2) — and stays queued for exactly as long as +it is genuinely broken. Nothing is dismissed by hand and no acknowledgement +state is stored, so there is no second thing to keep correct. + ## 6. Sourcing ### 6.1 Prowlarr, per-indexer Torznab diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 96cdf48..3b12803 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -782,7 +782,7 @@ pub async fn attention(State(state): State) -> Result 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#) .fetch_all(pool(&state)?) .await?; - let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title"#) + let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title"#, arr_db::ATTENTION_WINDOW) .fetch_all(pool(&state)?) .await?; let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?; @@ -798,6 +798,11 @@ pub async fn attention(State(state): State) -> Result Result<(Vec, Vec), ApiError> { @@ -837,9 +842,11 @@ async fn tv_attention( JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE g.state = 'failed' + AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number HAVING count(DISTINCT g.release_id) >= 2 - "# + "#, + arr_db::ATTENTION_WINDOW ) .fetch_all(database) .await?; @@ -852,8 +859,11 @@ async fn tv_attention( JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id JOIN series s ON s.id = se.series_id WHERE g.state = 'failed' + AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number - "# + HAVING count(DISTINCT g.release_id) >= 2 + "#, + arr_db::ATTENTION_WINDOW ) .fetch_all(database) .await?; @@ -1996,10 +2006,166 @@ mod tests { ); } + /// A series with one empty season, for exercising the season lane on its + /// own. + async fn seed_bare_season(pool: &sqlx::SqlitePool, tmdb_id: i64) -> (i64, i64) { + let root_id: i64 = + sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'") + .fetch_one(pool) + .await + .expect("kids tv root"); + let series_id: i64 = sqlx::query( + "INSERT INTO series (tmdb_id, title, year, root_id) VALUES (?, 'Rick and Morty', 2013, ?) + RETURNING id", + ) + .bind(tmdb_id) + .bind(root_id) + .fetch_one(pool) + .await + .expect("series") + .get(0); + let season_id: i64 = sqlx::query_scalar( + "INSERT INTO seasons (series_id, number) VALUES (?, 8) RETURNING id", + ) + .bind(series_id) + .fetch_one(pool) + .await + .expect("season"); + (series_id, season_id) + } + + async fn insert_release(pool: &sqlx::SqlitePool, guid: &str) -> i64 { + sqlx::query( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) + VALUES (1, ?, 'release', 1, 'url', '{}', 'eligible') RETURNING id", + ) + .bind(guid) + .fetch_one(pool) + .await + .expect("release") + .get(0) + } + + /// A hard-failed grab, stamped `age_days` in the past so the §5.7 window + /// can be exercised without waiting a month. + async fn insert_failed_grab( + pool: &sqlx::SqlitePool, + release_id: i64, + target_kind: &str, + target_id: i64, + infohash: &str, + age_days: i64, + ) { + sqlx::query( + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at) + VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))", + ) + .bind(release_id) + .bind(target_kind) + .bind(target_id) + .bind(infohash) + .bind(format!("-{age_days} days")) + .execute(pool) + .await + .expect("failed grab"); + } + + /// The seasons `GET /api/queues/attention` currently reports for a series. + async fn queued_seasons(base: &str, series_id: i64) -> Vec { + let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention")) + .await + .expect("queues") + .json() + .await + .expect("queues json"); + queues["tv_needs_decision"] + .as_array() + .expect("tv lane") + .iter() + .filter(|entry| entry["series_id"] == series_id) + .flat_map(|entry| { + entry["seasons"] + .as_array() + .expect("seasons") + .iter() + .map(|season| season["id"].as_i64().expect("season id")) + }) + .collect() + } + + /// §5.7: one failed pack is the blacklist working, not a decision. The + /// season lane holds to the same two-distinct-releases bar the episode + /// lane does (#226). + #[tokio::test] + async fn a_season_queues_only_on_two_distinct_release_failures() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let (series_id, season_id) = seed_bare_season(pool, 1).await; + + let first = insert_release(pool, "pack-one").await; + insert_failed_grab(pool, first, "season", season_id, "hash-one", 0).await; + assert!( + queued_seasons(&base, series_id).await.is_empty(), + "one failed pack is not a decision" + ); + + // A second failure on the *same* release is still one release. + insert_failed_grab(pool, first, "season", season_id, "hash-one-again", 0).await; + assert!( + queued_seasons(&base, series_id).await.is_empty(), + "two grabs of one release are not two releases" + ); + + let second = insert_release(pool, "pack-two").await; + insert_failed_grab(pool, second, "season", season_id, "hash-two", 0).await; + assert_eq!( + queued_seasons(&base, series_id).await, + vec![season_id], + "two distinct releases hard-failed: the operator decides" + ); + } + + /// §5.7: a failure counts for 30 days. A season the operator has dealt + /// with stops failing and leaves the queue; one still breaking keeps + /// producing failures and stays (#226). + #[tokio::test] + async fn a_season_failure_ages_out_of_the_attention_queue() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let (series_id, season_id) = seed_bare_season(pool, 1).await; + + for (guid, hash, age) in [ + ("old-one", "hash-old-one", 40), + ("old-two", "hash-old-two", 35), + ] { + let release_id = insert_release(pool, guid).await; + insert_failed_grab(pool, release_id, "season", season_id, hash, age).await; + } + assert!( + queued_seasons(&base, series_id).await.is_empty(), + "failures older than the window are history, not attention" + ); + + let fresh = insert_release(pool, "new-one").await; + insert_failed_grab(pool, fresh, "season", season_id, "hash-new-one", 0).await; + assert!( + queued_seasons(&base, series_id).await.is_empty(), + "one recent failure does not revive two stale ones" + ); + + let fresher = insert_release(pool, "new-two").await; + insert_failed_grab(pool, fresher, "season", season_id, "hash-new-two", 0).await; + assert_eq!( + queued_seasons(&base, series_id).await, + vec![season_id], + "still breaking: back in the queue" + ); + } + /// One series hitting all three §9.5 TV entry conditions: two wanted, /// searched episodes whose every candidate was rejected for language; a - /// season pack that hard-failed; and an episode two different releases - /// hard-failed on. + /// season two different packs hard-failed on; and an episode two different + /// releases hard-failed on. async fn seed_queued_series(pool: &sqlx::SqlitePool) -> (i64, i64, i64) { let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'") @@ -2051,6 +2217,7 @@ mod tests { .expect("episode id"); for (kind, guid, suffix) in [ ("season", "pack", "pack"), + ("season", "pack-two", "pack2"), ("episode", "first", "a"), ("episode", "second", "b"), ] { diff --git a/crates/arr-daemon/src/attention.rs b/crates/arr-daemon/src/attention.rs index 9012f30..b0fc8cb 100644 --- a/crates/arr-daemon/src/attention.rs +++ b/crates/arr-daemon/src/attention.rs @@ -2,6 +2,10 @@ //! the no-PT-source queue, or hard-failed twice on different releases (the //! same queues `GET /api/queues/attention` reports, §9.3). //! +//! §5.7 sets the bar for the hard-fail side: two failures on *different* +//! releases, both inside `arr_db::ATTENTION_WINDOW`. One bad torrent is not a +//! decision, and a failure already dealt with ages out (#226). +//! //! Edge-triggered per title: it notifies once when the title enters either //! queue, and is forgotten once it leaves both, so a future re-entry notifies //! again. A series notifies as its series, never per episode — a broken @@ -34,8 +38,8 @@ struct TvEntry { no_pt_source: Vec, /// Episodes two different releases hard-failed post-probe (§5.7). hard_failed_episodes: Vec, - /// Seasons whose pack grab hard-failed, sending the season back to - /// per-episode grabbing. + /// Seasons two different pack releases hard-failed on (§5.7), sending the + /// season back to per-episode grabbing. failed_season_packs: Vec, } @@ -59,8 +63,8 @@ impl TvEntry { if !self.failed_season_packs.is_empty() { parts.push(plural( self.failed_season_packs.len(), - "season pack hard-failed", - "season packs hard-failed", + "season hard-failed twice on different packs", + "seasons hard-failed twice on different packs", )); } parts.join("; ") @@ -134,8 +138,10 @@ impl AttentionAction { AND (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id - AND g.state = 'failed') >= 2 - "# + AND g.state = 'failed' + AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 + "#, + arr_db::ATTENTION_WINDOW ) .fetch_all(database.pool()) .await?; @@ -209,8 +215,8 @@ fn tv_entry( /// TV roll-up (§9.5): every queued series with what put it there — wanted /// episodes whose every candidate was rejected for language, episodes two -/// different releases hard-failed post-probe, and seasons whose pack grab -/// hard-failed. One entry per series, so the notification can be one per +/// different releases hard-failed post-probe, and seasons two different packs +/// hard-failed on. One entry per series, so the notification can be one per /// series however long the broken season is. async fn queue_tv(database: &Db) -> Result, TvEntry)>, sqlx::Error> { let mut tv = HashMap::new(); @@ -250,10 +256,12 @@ async fn queue_tv(database: &Db) -> Result, TvEntr JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE g.state = 'failed' + AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) AND e.wanted = 1 AND e.state != 'available' GROUP BY s.id, s.title, s.year, e.id HAVING count(DISTINCT g.release_id) >= 2 - "# + "#, + arr_db::ATTENTION_WINDOW ) .fetch_all(database.pool()) .await?; @@ -271,8 +279,11 @@ async fn queue_tv(database: &Db) -> Result, TvEntr JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id JOIN series s ON s.id = se.series_id WHERE g.state = 'failed' + AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) GROUP BY s.id, s.title, s.year, se.id - "# + HAVING count(DISTINCT g.release_id) >= 2 + "#, + arr_db::ATTENTION_WINDOW ) .fetch_all(database.pool()) .await?; @@ -382,6 +393,39 @@ mod tests { series_id } + /// A failed grab by `release_guid` against `target_kind`/`target_id`, + /// stamped `age_days` in the past, so §5.7's window can be exercised + /// without waiting a month. + async fn insert_aged_failed_grab( + database: &Db, + target_kind: &str, + target_id: i64, + release_guid: &str, + age_days: i64, + ) { + let release_id: i64 = sqlx::query_scalar( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) + VALUES (7, ?, 'release', 10737418240, 'https://tracker/x.torrent', '{}', 'eligible') + RETURNING id", + ) + .bind(release_guid) + .fetch_one(database.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at) + VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))", + ) + .bind(release_id) + .bind(target_kind) + .bind(target_id) + .bind(format!("hash-{release_guid}")) + .bind(format!("-{age_days} days")) + .execute(database.pool()) + .await + .unwrap(); + } + /// A failed grab by `release_guid` against `target_kind`/`target_id`, /// standing in for what the import tick leaves behind post-probe. async fn insert_failed_grab( @@ -548,9 +592,10 @@ mod tests { .unwrap(); insert_failed_grab(&database, "episode", episode_id, "first").await; insert_failed_grab(&database, "episode", episode_id, "second").await; - // The pack's failure sent this season back to per-episode grabbing; - // it queues the same series, so it must not double the message. + // The packs' failures sent this season back to per-episode grabbing; + // they queue the same series, so it must not double the message. insert_failed_grab(&database, "season", season_id, "pack").await; + insert_failed_grab(&database, "season", season_id, "pack-two").await; let server = MockServer::start().await; let action = action(&server).await; @@ -631,4 +676,66 @@ mod tests { assert_eq!(second.len(), 0, "leaves the queue once imported"); assert_eq!(server.received_requests().await.unwrap().len(), 1); } + + /// §5.7: the season lane holds to the same two-distinct-releases bar the + /// episode lane does, so one bad pack does not notify (#226). + #[tokio::test] + async fn one_failed_season_pack_does_not_notify() { + let (_dir, database) = seeded_database().await; + insert_no_pt_source_series(&database, 1, 0).await; + let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1") + .fetch_one(database.pool()) + .await + .unwrap(); + insert_failed_grab(&database, "season", season_id, "pack").await; + + let server = MockServer::start().await; + let action = action(&server).await; + + assert_eq!( + action.tick(&database).await.unwrap().len(), + 0, + "one failed pack is the blacklist working, not a decision" + ); + + insert_failed_grab(&database, "season", season_id, "pack-two").await; + + assert_eq!( + action.tick(&database).await.unwrap().len(), + 1, + "two distinct packs hard-failed: the operator decides" + ); + } + + /// §5.7: a failure counts for 30 days, so a season dealt with leaves the + /// queue instead of sitting in it forever (#226). + #[tokio::test] + async fn season_failures_older_than_the_window_do_not_notify() { + let (_dir, database) = seeded_database().await; + insert_no_pt_source_series(&database, 1, 0).await; + let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1") + .fetch_one(database.pool()) + .await + .unwrap(); + insert_aged_failed_grab(&database, "season", season_id, "old-one", 40).await; + insert_aged_failed_grab(&database, "season", season_id, "old-two", 35).await; + + let server = MockServer::start().await; + let action = action(&server).await; + + assert_eq!( + action.tick(&database).await.unwrap().len(), + 0, + "failures older than the window are history, not attention" + ); + + insert_aged_failed_grab(&database, "season", season_id, "new-one", 0).await; + insert_aged_failed_grab(&database, "season", season_id, "new-two", 0).await; + + assert_eq!( + action.tick(&database).await.unwrap().len(), + 1, + "still breaking: back in the queue" + ); + } } diff --git a/crates/arr-db/src/lib.rs b/crates/arr-db/src/lib.rs index 697edba..4ee13a9 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -17,6 +17,15 @@ use sqlx::{migrate::MigrateError, SqlitePool}; /// The migrations embedded in the binary, so a deploy is one file. pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); +/// §5.7: how long a failed grab keeps counting toward the needs-a-decision +/// queue, as a SQLite time modifier. +/// +/// Nothing ever clears a `grabs` row, so without a bound the queue only grows +/// and the one season that wants attention sits behind the ones that do not. +/// Callers pair it with the `grabbed_at` format: +/// `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ATTENTION_WINDOW)`. +pub const ATTENTION_WINDOW: &str = "-30 days"; + /// How long a writer waits for the write lock before giving up. const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);