From 528aadf59cc93a5b804b19efdf3177d437fd2abe Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 11:05:59 +0100 Subject: [PATCH] fix(daemon): measure attention window from failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap #239 describes: §5.7's 30-day window was filtered on grabbed_at, so a torrent stalling past the window before hard-failing at import never surfaced in the needs-a-decision queue. grabs gains failed_at (migration 0030, backfilled from grabbed_at for existing failed rows), the import tick stamps it on hard fail, and every window query in the daemon notifier and the attention endpoint reads it. §5.7 now states the anchor explicitly. §6.2's pack backoff stays on grabbed_at deliberately; noted on the issue. Co-Authored-By: Claude Fable 5 --- ...fa96cc595454d8803b3c4307074ab273eb1d.json} | 4 +- ...03e9414398ce9d0b716e5c8e367003625a46.json} | 4 +- ...613790d8c88ed4cabd46b33f67eddc47f9c0.json} | 4 +- ...57365f0ced5fbf77c49f25049492658998db.json} | 4 +- ...b071e9720132a42e837a0e9bb8b2d955a9e8.json} | 4 +- ...fa379d232b045498310debab5ddd97f039294.json | 12 --- ...eed66904f2847ee45a699c96fd53d5d9e777.json} | 4 +- ...9cc4ed55dd24352a3d57a78a49e07604003c1.json | 12 +++ DESIGN.md | 7 ++ crates/arr-api/src/movies.rs | 24 +++-- crates/arr-daemon/src/attention.rs | 92 +++++++++++++------ crates/arr-daemon/src/import.rs | 36 ++++++-- crates/arr-daemon/src/tv_grab.rs | 3 +- .../arr-db/migrations/0030_grab_failed_at.sql | 8 ++ crates/arr-db/src/lib.rs | 6 +- 15 files changed, 147 insertions(+), 77 deletions(-) rename .sqlx/{query-d903535e39c0957aae78c40eacb76a65ad77d0ab7f1bccda7e28e7972243b5e1.json => query-0c97b43e22b1c83c5a7131ce9a07fa96cc595454d8803b3c4307074ab273eb1d.json} (74%) rename .sqlx/{query-ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be.json => query-35d381e3acde2f3e49f8559da15d03e9414398ce9d0b716e5c8e367003625a46.json} (85%) rename .sqlx/{query-55b41a8ac2ce6eb3224e669653b2e98a87296da17205e0175414ccc5aeab4bb5.json => query-3ebb77103e3b3ec01b3de47c35ef613790d8c88ed4cabd46b33f67eddc47f9c0.json} (95%) rename .sqlx/{query-b8d707635737b155b2c0318aca5ee4a511f744f46bb21b072be1a03a88866c9b.json => query-4f9e8728ca5f0bd70c40155b05a857365f0ced5fbf77c49f25049492658998db.json} (86%) rename .sqlx/{query-edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86.json => query-6faa1fc2c2015becebb83843b031b071e9720132a42e837a0e9bb8b2d955a9e8.json} (82%) delete mode 100644 .sqlx/query-c66f29b6edc4bca3a751e85e133fa379d232b045498310debab5ddd97f039294.json rename .sqlx/{query-b5b8f0ff18d9554a63b24d9693e4e07dc4d0bdb89dd8eced880e5a36eb596d37.json => query-dba46c4901a633fc1bda1b2adf75eed66904f2847ee45a699c96fd53d5d9e777.json} (79%) create mode 100644 .sqlx/query-fefda8c73b3b4a4dac4ae6551ed9cc4ed55dd24352a3d57a78a49e07604003c1.json create mode 100644 crates/arr-db/migrations/0030_grab_failed_at.sql diff --git a/.sqlx/query-d903535e39c0957aae78c40eacb76a65ad77d0ab7f1bccda7e28e7972243b5e1.json b/.sqlx/query-0c97b43e22b1c83c5a7131ce9a07fa96cc595454d8803b3c4307074ab273eb1d.json similarity index 74% rename from .sqlx/query-d903535e39c0957aae78c40eacb76a65ad77d0ab7f1bccda7e28e7972243b5e1.json rename to .sqlx/query-0c97b43e22b1c83c5a7131ce9a07fa96cc595454d8803b3c4307074ab273eb1d.json index 6f5f146..f7e3de3 100644 --- a/.sqlx/query-d903535e39c0957aae78c40eacb76a65ad77d0ab7f1bccda7e28e7972243b5e1.json +++ b/.sqlx/query-0c97b43e22b1c83c5a7131ce9a07fa96cc595454d8803b3c4307074ab273eb1d.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 AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.title, s.year, se.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 \"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.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.title, s.year, se.id\n HAVING count(DISTINCT g.release_id) >= 2\n ", "describe": { "columns": [ { @@ -58,5 +58,5 @@ false ] }, - "hash": "d903535e39c0957aae78c40eacb76a65ad77d0ab7f1bccda7e28e7972243b5e1" + "hash": "0c97b43e22b1c83c5a7131ce9a07fa96cc595454d8803b3c4307074ab273eb1d" } diff --git a/.sqlx/query-ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be.json b/.sqlx/query-35d381e3acde2f3e49f8559da15d03e9414398ce9d0b716e5c8e367003625a46.json similarity index 85% rename from .sqlx/query-ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be.json rename to .sqlx/query-35d381e3acde2f3e49f8559da15d03e9414398ce9d0b716e5c8e367003625a46.json index b982458..0309f43 100644 --- a/.sqlx/query-ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be.json +++ b/.sqlx/query-35d381e3acde2f3e49f8559da15d03e9414398ce9d0b716e5c8e367003625a46.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'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 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.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2\n ", "describe": { "columns": [ { @@ -46,5 +46,5 @@ true ] }, - "hash": "ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be" + "hash": "35d381e3acde2f3e49f8559da15d03e9414398ce9d0b716e5c8e367003625a46" } diff --git a/.sqlx/query-55b41a8ac2ce6eb3224e669653b2e98a87296da17205e0175414ccc5aeab4bb5.json b/.sqlx/query-3ebb77103e3b3ec01b3de47c35ef613790d8c88ed4cabd46b33f67eddc47f9c0.json similarity index 95% rename from .sqlx/query-55b41a8ac2ce6eb3224e669653b2e98a87296da17205e0175414ccc5aeab4bb5.json rename to .sqlx/query-3ebb77103e3b3ec01b3de47c35ef613790d8c88ed4cabd46b33f67eddc47f9c0.json index 00c69f2..fc3dc47 100644 --- a/.sqlx/query-55b41a8ac2ce6eb3224e669653b2e98a87296da17205e0175414ccc5aeab4bb5.json +++ b/.sqlx/query-3ebb77103e3b3ec01b3de47c35ef613790d8c88ed4cabd46b33f67eddc47f9c0.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 movies.wanted = 1 AND movies.state != 'available' 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' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 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 movies.wanted = 1 AND movies.state != 'available' 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' AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title", "describe": { "columns": [ { @@ -190,5 +190,5 @@ true ] }, - "hash": "55b41a8ac2ce6eb3224e669653b2e98a87296da17205e0175414ccc5aeab4bb5" + "hash": "3ebb77103e3b3ec01b3de47c35ef613790d8c88ed4cabd46b33f67eddc47f9c0" } diff --git a/.sqlx/query-b8d707635737b155b2c0318aca5ee4a511f744f46bb21b072be1a03a88866c9b.json b/.sqlx/query-4f9e8728ca5f0bd70c40155b05a857365f0ced5fbf77c49f25049492658998db.json similarity index 86% rename from .sqlx/query-b8d707635737b155b2c0318aca5ee4a511f744f46bb21b072be1a03a88866c9b.json rename to .sqlx/query-4f9e8728ca5f0bd70c40155b05a857365f0ced5fbf77c49f25049492658998db.json index 21c01af..0d77e5f 100644 --- a/.sqlx/query-b8d707635737b155b2c0318aca5ee4a511f744f46bb21b072be1a03a88866c9b.json +++ b/.sqlx/query-4f9e8728ca5f0bd70c40155b05a857365f0ced5fbf77c49f25049492658998db.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 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.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.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\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": [ { @@ -94,5 +94,5 @@ false ] }, - "hash": "b8d707635737b155b2c0318aca5ee4a511f744f46bb21b072be1a03a88866c9b" + "hash": "4f9e8728ca5f0bd70c40155b05a857365f0ced5fbf77c49f25049492658998db" } diff --git a/.sqlx/query-edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86.json b/.sqlx/query-6faa1fc2c2015becebb83843b031b071e9720132a42e837a0e9bb8b2d955a9e8.json similarity index 82% rename from .sqlx/query-edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86.json rename to .sqlx/query-6faa1fc2c2015becebb83843b031b071e9720132a42e837a0e9bb8b2d955a9e8.json index 3b0c337..14e71f6 100644 --- a/.sqlx/query-edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86.json +++ b/.sqlx/query-6faa1fc2c2015becebb83843b031b071e9720132a42e837a0e9bb8b2d955a9e8.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 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 ", + "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.failed_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": [ { @@ -58,5 +58,5 @@ false ] }, - "hash": "edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86" + "hash": "6faa1fc2c2015becebb83843b031b071e9720132a42e837a0e9bb8b2d955a9e8" } diff --git a/.sqlx/query-c66f29b6edc4bca3a751e85e133fa379d232b045498310debab5ddd97f039294.json b/.sqlx/query-c66f29b6edc4bca3a751e85e133fa379d232b045498310debab5ddd97f039294.json deleted file mode 100644 index e3636bf..0000000 --- a/.sqlx/query-c66f29b6edc4bca3a751e85e133fa379d232b045498310debab5ddd97f039294.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE grabs SET state = 'failed' WHERE id = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "c66f29b6edc4bca3a751e85e133fa379d232b045498310debab5ddd97f039294" -} diff --git a/.sqlx/query-b5b8f0ff18d9554a63b24d9693e4e07dc4d0bdb89dd8eced880e5a36eb596d37.json b/.sqlx/query-dba46c4901a633fc1bda1b2adf75eed66904f2847ee45a699c96fd53d5d9e777.json similarity index 79% rename from .sqlx/query-b5b8f0ff18d9554a63b24d9693e4e07dc4d0bdb89dd8eced880e5a36eb596d37.json rename to .sqlx/query-dba46c4901a633fc1bda1b2adf75eed66904f2847ee45a699c96fd53d5d9e777.json index a1d783c..25b72e8 100644 --- a/.sqlx/query-b5b8f0ff18d9554a63b24d9693e4e07dc4d0bdb89dd8eced880e5a36eb596d37.json +++ b/.sqlx/query-dba46c4901a633fc1bda1b2adf75eed66904f2847ee45a699c96fd53d5d9e777.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 AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\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 ", + "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.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\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": [ { @@ -82,5 +82,5 @@ false ] }, - "hash": "b5b8f0ff18d9554a63b24d9693e4e07dc4d0bdb89dd8eced880e5a36eb596d37" + "hash": "dba46c4901a633fc1bda1b2adf75eed66904f2847ee45a699c96fd53d5d9e777" } diff --git a/.sqlx/query-fefda8c73b3b4a4dac4ae6551ed9cc4ed55dd24352a3d57a78a49e07604003c1.json b/.sqlx/query-fefda8c73b3b4a4dac4ae6551ed9cc4ed55dd24352a3d57a78a49e07604003c1.json new file mode 100644 index 0000000..2cdd72a --- /dev/null +++ b/.sqlx/query-fefda8c73b3b4a4dac4ae6551ed9cc4ed55dd24352a3d57a78a49e07604003c1.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE grabs\n SET state = 'failed',\n failed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "fefda8c73b3b4a4dac4ae6551ed9cc4ed55dd24352a3d57a78a49e07604003c1" +} diff --git a/DESIGN.md b/DESIGN.md index 8f36486..d6c46b2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -385,6 +385,13 @@ 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 runs from the failure, not the grab. The two are usually minutes +apart, but a torrent can sit stalling on a dead swarm for five weeks before +`ffprobe` finally condemns it — and that failure is fresh evidence the target +is broken now, not history. Measured from the grab it would be born outside +the window and a genuinely broken target could never surface. So `grabs` +records `failed_at` alongside `grabbed_at`, and the window reads it. + 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 diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 7266b21..1be1218 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -788,7 +788,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 movies.wanted = 1 AND movies.state != 'available' 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' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title"#, arr_db::ATTENTION_WINDOW) + 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 movies.wanted = 1 AND movies.state != 'available' 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' AND g.failed_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?; @@ -884,7 +884,7 @@ async fn tv_hard_fail_lane(database: &sqlx::SqlitePool) -> Result= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) + AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) AND e.wanted = 1 AND e.state != 'available' GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number HAVING count(DISTINCT g.release_id) >= 2 @@ -902,7 +902,7 @@ async fn tv_hard_fail_lane(database: &sqlx::SqlitePool) -> Result= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) + AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) AND EXISTS ( SELECT 1 FROM episodes e WHERE e.season_id = se.id @@ -1913,7 +1913,7 @@ mod tests { .expect("release") .last_insert_rowid(); release_ids.push(release_id); - sqlx::query("INSERT INTO grabs (release_id, target_kind, target_id, infohash, state) VALUES (?, 'movie', ?, ?, 'failed')") + sqlx::query("INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, failed_at) VALUES (?, 'movie', ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))") .bind(release_id) .bind(movie_id) .bind(format!("hash-{suffix}")) @@ -2088,8 +2088,9 @@ mod tests { .get(0) } - /// A hard-failed grab, stamped `age_days` in the past so the §5.7 window - /// can be exercised without waiting a month. + /// A hard-failed grab, its failure stamped `age_days` in the past so the + /// §5.7 window — which runs from the failure — can be exercised without + /// waiting a month. async fn insert_failed_grab( pool: &sqlx::SqlitePool, release_id: i64, @@ -2099,14 +2100,17 @@ mod tests { 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', ?))", + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at, failed_at) + VALUES (?, ?, ?, ?, 'failed', + strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))", ) .bind(release_id) .bind(target_kind) .bind(target_id) .bind(infohash) .bind(format!("-{age_days} days")) + .bind(format!("-{age_days} days")) .execute(pool) .await .expect("failed grab"); @@ -2426,8 +2430,8 @@ mod tests { .expect("release") .get(0); sqlx::query( - "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state) - VALUES (?, ?, ?, ?, 'failed')", + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, failed_at) + VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", ) .bind(release_id) .bind(kind) diff --git a/crates/arr-daemon/src/attention.rs b/crates/arr-daemon/src/attention.rs index 7ba6ade..ec67e69 100644 --- a/crates/arr-daemon/src/attention.rs +++ b/crates/arr-daemon/src/attention.rs @@ -144,7 +144,7 @@ impl AttentionAction { 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 + AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 "#, arr_db::ATTENTION_WINDOW ) @@ -266,7 +266,7 @@ 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 g.failed_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 @@ -289,7 +289,7 @@ 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', ?) + AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) AND EXISTS ( SELECT 1 FROM episodes e WHERE e.season_id = se.id @@ -424,14 +424,16 @@ mod tests { } /// 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( + /// grabbed `grab_age_days` ago and failed `fail_age_days` ago, so §5.7's + /// window — which runs from the failure — can be exercised without + /// waiting a month. + async fn insert_dated_failed_grab( database: &Db, target_kind: &str, target_id: i64, release_guid: &str, - age_days: i64, + grab_age_days: i64, + fail_age_days: i64, ) { let release_id: i64 = sqlx::query_scalar( "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) @@ -443,19 +445,42 @@ mod tests { .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', ?))", + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at, failed_at) + VALUES (?, ?, ?, ?, 'failed', + strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?), + 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")) + .bind(format!("-{grab_age_days} days")) + .bind(format!("-{fail_age_days} days")) .execute(database.pool()) .await .unwrap(); } + /// A failed grab by `release_guid` against `target_kind`/`target_id`, + /// failed `age_days` in the past. + async fn insert_aged_failed_grab( + database: &Db, + target_kind: &str, + target_id: i64, + release_guid: &str, + age_days: i64, + ) { + insert_dated_failed_grab( + database, + target_kind, + target_id, + release_guid, + age_days, + age_days, + ) + .await; + } + /// 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( @@ -464,26 +489,7 @@ mod tests { target_id: i64, release_guid: &str, ) { - 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) - VALUES (?, ?, ?, ?, 'failed')", - ) - .bind(release_id) - .bind(target_kind) - .bind(target_id) - .bind(format!("hash-{release_guid}")) - .execute(database.pool()) - .await - .unwrap(); + insert_dated_failed_grab(database, target_kind, target_id, release_guid, 0, 0).await; } async fn action(server: &MockServer) -> AttentionAction { @@ -834,4 +840,30 @@ mod tests { "still breaking: back in the queue" ); } + + /// §5.7/#239: the window runs from the failure, not the grab. A torrent + /// that stalls on a slow swarm for five weeks and then hard-fails at + /// import is fresh evidence the target is broken, however old the grab. + #[tokio::test] + async fn a_grab_stalled_past_the_window_before_failing_still_counts() { + 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_wanted_episode(&database, season_id, 1).await; + // Both grabbed 40 days ago — outside the window — but failed today. + insert_dated_failed_grab(&database, "season", season_id, "stalled-one", 40, 0).await; + insert_dated_failed_grab(&database, "season", season_id, "stalled-two", 40, 0).await; + + let server = MockServer::start().await; + let action = action(&server).await; + + assert_eq!( + action.tick(&database).await.unwrap().len(), + 1, + "grab age is irrelevant: two fresh failures queue the target" + ); + } } diff --git a/crates/arr-daemon/src/import.rs b/crates/arr-daemon/src/import.rs index ebc6109..cfd3b9e 100644 --- a/crates/arr-daemon/src/import.rs +++ b/crates/arr-daemon/src/import.rs @@ -621,7 +621,10 @@ impl ImportAction { ) .await?; sqlx::query!( - "UPDATE grabs SET state = 'failed' WHERE id = ?", + "UPDATE grabs + SET state = 'failed', + failed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ?", pending.grab_id ) .execute(database.pool()) @@ -759,7 +762,10 @@ impl ImportAction { ) .await?; sqlx::query!( - "UPDATE grabs SET state = 'failed' WHERE id = ?", + "UPDATE grabs + SET state = 'failed', + failed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ?", pending.grab_id ) .execute(database.pool()) @@ -1564,11 +1570,16 @@ mod tests { assert_eq!(normalised, arr_parse::normalise(RELEASE_NAME)); assert_eq!(reason, "dolby_vision_profile"); - let grab_state: String = sqlx::query_scalar("SELECT state FROM grabs") - .fetch_one(h.database.pool()) - .await - .unwrap(); + let (grab_state, failed_at): (String, Option) = + sqlx::query_as("SELECT state, failed_at FROM grabs") + .fetch_one(h.database.pool()) + .await + .unwrap(); assert_eq!(grab_state, "failed"); + assert!( + failed_at.is_some(), + "§5.7's window runs from the failure, so the failure is stamped" + ); let movie_state: String = sqlx::query_scalar("SELECT state FROM movies WHERE id = 1") .fetch_one(h.database.pool()) .await @@ -2202,11 +2213,16 @@ mod tests { )], "only the release is blacklisted, never the season" ); - let grab_state: String = sqlx::query_scalar("SELECT state FROM grabs") - .fetch_one(h.database.pool()) - .await - .unwrap(); + let (grab_state, failed_at): (String, Option) = + sqlx::query_as("SELECT state, failed_at FROM grabs") + .fetch_one(h.database.pool()) + .await + .unwrap(); assert_eq!(grab_state, "failed"); + assert!( + failed_at.is_some(), + "§5.7's window runs from the failure, so the failure is stamped" + ); let states: Vec<(String, bool)> = sqlx::query_as("SELECT state, wanted FROM episodes ORDER BY number") .fetch_all(h.database.pool()) diff --git a/crates/arr-daemon/src/tv_grab.rs b/crates/arr-daemon/src/tv_grab.rs index e3fc68b..1f01aa2 100644 --- a/crates/arr-daemon/src/tv_grab.rs +++ b/crates/arr-daemon/src/tv_grab.rs @@ -1139,7 +1139,8 @@ async fn record_pack_search(database: &Db, season_id: i64) -> Result<(), GrabErr /// Whether failed season-pack grabs still hold this season off the pack /// lane. §6.2: a failure quiets the pack search on the shared backoff curve /// (each failed grab is one attempt), it never disables it. Anchored on the -/// latest failed grab's `grabbed_at` — failure time itself is not recorded. +/// latest failed grab's `grabbed_at`, not `failed_at` — moving §6.2's retry +/// cadence to failure time is its own decision, not #239's. async fn pack_backoff_active(database: &Db, season_id: i64) -> Result { let row = sqlx::query!( r#"SELECT count(*) AS "failures!: i64", diff --git a/crates/arr-db/migrations/0030_grab_failed_at.sql b/crates/arr-db/migrations/0030_grab_failed_at.sql new file mode 100644 index 0000000..ec926bf --- /dev/null +++ b/crates/arr-db/migrations/0030_grab_failed_at.sql @@ -0,0 +1,8 @@ +-- #239: §5.7's 30-day attention window runs from the failure, but only +-- grabbed_at existed, so a torrent that stalled past the window and then +-- hard-failed at import was born outside it. failed_at records the moment +-- the grab entered 'failed'. Existing failed rows get grabbed_at as the +-- best available approximation. +ALTER TABLE grabs ADD COLUMN failed_at TEXT; + +UPDATE grabs SET failed_at = grabbed_at WHERE state = 'failed'; diff --git a/crates/arr-db/src/lib.rs b/crates/arr-db/src/lib.rs index 4ee13a9..647b8a8 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -18,11 +18,13 @@ use sqlx::{migrate::MigrateError, SqlitePool}; 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. +/// queue, as a SQLite time modifier. Measured from `failed_at` — the moment +/// the grab hard-failed, not the moment it was grabbed — so a torrent that +/// stalls past the window and then fails still counts (#239). /// /// 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: +/// Callers pair it with the `failed_at` format: /// `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ATTENTION_WINDOW)`. pub const ATTENTION_WINDOW: &str = "-30 days";