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";