From 6847d25cf501be9a813b894ca301b308785f88bc Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 10:49:02 +0100 Subject: [PATCH] feat: queue only targets still waiting for a file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The needs-a-decision queue had no liveness condition on the season lane and none at all in the API reader, so a season pack that hard-failed twice, fell back to per-episode grabbing exactly as §6.2 intends, and was then fully acquired kept notifying for 30 days, and `GET /api/queues/attention` listed titles the daemon never notified on. DESIGN.md §5.7 now states the third face of the same rule alongside the count and the window: a movie or an episode is queued while `wanted` and not `available`; a season, holding no intent of its own (§4.1), while at least one of its episodes is. Both readers apply it on all three lanes. `just ci` passed through the gate. --- ...630b89ab665e0c4323d8c1aa6521c200b86d.json} | 4 +- ...e98a87296da17205e0175414ccc5aeab4bb5.json} | 4 +- ...e07dc4d0bdb89dd8eced880e5a36eb596d37.json} | 4 +- ...e4a511f744f46bb21b072be1a03a88866c9b.json} | 4 +- ...6a65ad77d0ab7f1bccda7e28e7972243b5e1.json} | 4 +- DESIGN.md | 16 + crates/arr-api/src/movies.rs | 295 ++++++++++++++---- crates/arr-daemon/src/attention.rs | 100 +++++- 8 files changed, 365 insertions(+), 66 deletions(-) rename .sqlx/{query-29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708.json => query-06eca0d86be94dc4615cfaa1f75f630b89ab665e0c4323d8c1aa6521c200b86d.json} (63%) rename .sqlx/{query-b35fe903d45f1c3ec7a963aac599331f602ea73b2e860a623b79ae435beca614.json => query-55b41a8ac2ce6eb3224e669653b2e98a87296da17205e0175414ccc5aeab4bb5.json} (91%) rename .sqlx/{query-44d8376cc9cdf66afb89de1374a332bfed6d33927db2f0992e2e1b793ee99b42.json => query-b5b8f0ff18d9554a63b24d9693e4e07dc4d0bdb89dd8eced880e5a36eb596d37.json} (81%) rename .sqlx/{query-a8beee4a6c6f00a299cb6c6ec1bb2a4ef2613b8b57366fc7f2fbc56be29ce72c.json => query-b8d707635737b155b2c0318aca5ee4a511f744f46bb21b072be1a03a88866c9b.json} (89%) rename .sqlx/{query-1a2660bb8b6ac22352a2c8262423151629f0b0870cad7a4462f21013ac43606e.json => query-d903535e39c0957aae78c40eacb76a65ad77d0ab7f1bccda7e28e7972243b5e1.json} (78%) diff --git a/.sqlx/query-29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708.json b/.sqlx/query-06eca0d86be94dc4615cfaa1f75f630b89ab665e0c4323d8c1aa6521c200b86d.json similarity index 63% rename from .sqlx/query-29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708.json rename to .sqlx/query-06eca0d86be94dc4615cfaa1f75f630b89ab665e0c4323d8c1aa6521c200b86d.json index 361c234..b39a803 100644 --- a/.sqlx/query-29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708.json +++ b/.sqlx/query-06eca0d86be94dc4615cfaa1f75f630b89ab665e0c4323d8c1aa6521c200b86d.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 e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.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 e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ", "describe": { "columns": [ { @@ -94,5 +94,5 @@ false ] }, - "hash": "29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708" + "hash": "06eca0d86be94dc4615cfaa1f75f630b89ab665e0c4323d8c1aa6521c200b86d" } diff --git a/.sqlx/query-b35fe903d45f1c3ec7a963aac599331f602ea73b2e860a623b79ae435beca614.json b/.sqlx/query-55b41a8ac2ce6eb3224e669653b2e98a87296da17205e0175414ccc5aeab4bb5.json similarity index 91% rename from .sqlx/query-b35fe903d45f1c3ec7a963aac599331f602ea73b2e860a623b79ae435beca614.json rename to .sqlx/query-55b41a8ac2ce6eb3224e669653b2e98a87296da17205e0175414ccc5aeab4bb5.json index 0377826..00c69f2 100644 --- a/.sqlx/query-b35fe903d45f1c3ec7a963aac599331f602ea73b2e860a623b79ae435beca614.json +++ b/.sqlx/query-55b41a8ac2ce6eb3224e669653b2e98a87296da17205e0175414ccc5aeab4bb5.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' 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.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title", "describe": { "columns": [ { @@ -190,5 +190,5 @@ true ] }, - "hash": "b35fe903d45f1c3ec7a963aac599331f602ea73b2e860a623b79ae435beca614" + "hash": "55b41a8ac2ce6eb3224e669653b2e98a87296da17205e0175414ccc5aeab4bb5" } diff --git a/.sqlx/query-44d8376cc9cdf66afb89de1374a332bfed6d33927db2f0992e2e1b793ee99b42.json b/.sqlx/query-b5b8f0ff18d9554a63b24d9693e4e07dc4d0bdb89dd8eced880e5a36eb596d37.json similarity index 81% rename from .sqlx/query-44d8376cc9cdf66afb89de1374a332bfed6d33927db2f0992e2e1b793ee99b42.json rename to .sqlx/query-b5b8f0ff18d9554a63b24d9693e4e07dc4d0bdb89dd8eced880e5a36eb596d37.json index 8356895..a1d783c 100644 --- a/.sqlx/query-44d8376cc9cdf66afb89de1374a332bfed6d33927db2f0992e2e1b793ee99b42.json +++ b/.sqlx/query-b5b8f0ff18d9554a63b24d9693e4e07dc4d0bdb89dd8eced880e5a36eb596d37.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 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.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 ", "describe": { "columns": [ { @@ -82,5 +82,5 @@ false ] }, - "hash": "44d8376cc9cdf66afb89de1374a332bfed6d33927db2f0992e2e1b793ee99b42" + "hash": "b5b8f0ff18d9554a63b24d9693e4e07dc4d0bdb89dd8eced880e5a36eb596d37" } diff --git a/.sqlx/query-a8beee4a6c6f00a299cb6c6ec1bb2a4ef2613b8b57366fc7f2fbc56be29ce72c.json b/.sqlx/query-b8d707635737b155b2c0318aca5ee4a511f744f46bb21b072be1a03a88866c9b.json similarity index 89% rename from .sqlx/query-a8beee4a6c6f00a299cb6c6ec1bb2a4ef2613b8b57366fc7f2fbc56be29ce72c.json rename to .sqlx/query-b8d707635737b155b2c0318aca5ee4a511f744f46bb21b072be1a03a88866c9b.json index 79089ad..21c01af 100644 --- a/.sqlx/query-a8beee4a6c6f00a299cb6c6ec1bb2a4ef2613b8b57366fc7f2fbc56be29ce72c.json +++ b/.sqlx/query-b8d707635737b155b2c0318aca5ee4a511f744f46bb21b072be1a03a88866c9b.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 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 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": "a8beee4a6c6f00a299cb6c6ec1bb2a4ef2613b8b57366fc7f2fbc56be29ce72c" + "hash": "b8d707635737b155b2c0318aca5ee4a511f744f46bb21b072be1a03a88866c9b" } diff --git a/.sqlx/query-1a2660bb8b6ac22352a2c8262423151629f0b0870cad7a4462f21013ac43606e.json b/.sqlx/query-d903535e39c0957aae78c40eacb76a65ad77d0ab7f1bccda7e28e7972243b5e1.json similarity index 78% rename from .sqlx/query-1a2660bb8b6ac22352a2c8262423151629f0b0870cad7a4462f21013ac43606e.json rename to .sqlx/query-d903535e39c0957aae78c40eacb76a65ad77d0ab7f1bccda7e28e7972243b5e1.json index 58d5fb1..6f5f146 100644 --- a/.sqlx/query-1a2660bb8b6ac22352a2c8262423151629f0b0870cad7a4462f21013ac43606e.json +++ b/.sqlx/query-d903535e39c0957aae78c40eacb76a65ad77d0ab7f1bccda7e28e7972243b5e1.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 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.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 ", "describe": { "columns": [ { @@ -58,5 +58,5 @@ false ] }, - "hash": "1a2660bb8b6ac22352a2c8262423151629f0b0870cad7a4462f21013ac43606e" + "hash": "d903535e39c0957aae78c40eacb76a65ad77d0ab7f1bccda7e28e7972243b5e1" } diff --git a/DESIGN.md b/DESIGN.md index 024913b..8f36486 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -395,6 +395,22 @@ guard retries at worst weekly (§6.2) — and stays queued for exactly as long a it is genuinely broken. Nothing is dismissed by hand and no acknowledgement state is stored, so there is no second thing to keep correct. +**Only a target still waiting for a file is queued.** The count and the window +are one rule seen twice — the queue is meant to be visited and emptied — and +this is its third face: a failure history queues a target only while that +target still has a gap to fill. A movie or an episode is queued while it is +`wanted` and not `available`. A season holds no intent of its own (§4.1), so it +is queued while at least one of its episodes is still wanted and still without +a file. A season pack that hard-failed twice, fell back to per-episode grabbing +exactly as §6.2 says it should, and was then fully acquired leaves at once +rather than waiting out the 30 days — that is the system working, not a +decision. A target that is still broken keeps producing failures and stays. + +The same rule applies on all three lanes and in both readers. +`GET /api/queues/attention` (§9.3) and the `ntfy` notification (§9.5) are two +views of one queue; filtering differently tells the operator two different +stories on two channels. + ## 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 70e8012..7266b21 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -570,11 +570,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError> Ok(metadata) => metadata, // Already gone is the state we wanted. Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => { - return Err(ApiError::Filesystem(format!( - "files not removed: {error}" - ))) - } + Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))), }; let removed = if metadata.is_dir() { tokio::fs::remove_dir_all(&target).await @@ -584,11 +580,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError> match removed { Ok(()) => tracing::info!(target = %target.display(), "removed library files"), Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(ApiError::Filesystem(format!( - "files not removed: {error}" - ))) - } + Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))), } } Ok(()) @@ -778,6 +770,12 @@ pub async fn grab( Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true }))) } +// The four attention lanes (§9.5). The hard-fail lanes carry §5.7's bar in +// full — two failures on *different* releases, both inside +// `ATTENTION_WINDOW`, against a target still waiting for a file — so this +// endpoint and the daemon's notifier report the same queue. Deliberately not +// a doc comment: utoipa would fold it into the OpenAPI description and drift +// the committed document. #[utoipa::path( get, path = "/api/queues/attention", tag = "movies", responses( @@ -790,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 (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.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?; @@ -808,37 +806,73 @@ pub async fn attention(State(state): State) -> Result Result<(Vec, Vec), ApiError> { let database = pool(state)?; + Ok(( + tv_no_pt_source_lane(database).await?, + tv_hard_fail_lane(database).await?, + )) +} - let no_pt_rows = sqlx::query!( +/// §5.2's no-PT-source lane: wanted, searched episodes on a `kids` root whose +/// every candidate release was rejected for language. +async fn tv_no_pt_source_lane( + database: &sqlx::SqlitePool, +) -> Result, ApiError> { + let rows = sqlx::query!( r#" - SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", - s.title AS "title!: String", s.year, - e.id AS "episode_id!: i64", - se.number AS "season_number!: i64", e.number AS "episode_number!: i64" - FROM episodes e - JOIN seasons se ON se.id = e.season_id - JOIN series s ON s.id = se.series_id - JOIN roots root ON root.id = s.root_id - WHERE root.audience = 'kids' - AND s.blocked = 0 - AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0 - AND NOT EXISTS ( - SELECT 1 FROM episode_releases er - JOIN releases r ON r.id = er.release_id - WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived') - ) - ORDER BY se.number, e.number - "# + SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", + s.title AS "title!: String", s.year, + e.id AS "episode_id!: i64", + se.number AS "season_number!: i64", e.number AS "episode_number!: i64" + FROM episodes e + JOIN seasons se ON se.id = e.season_id + JOIN series s ON s.id = se.series_id + JOIN roots root ON root.id = s.root_id + WHERE root.audience = 'kids' + AND s.blocked = 0 + AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0 + AND NOT EXISTS ( + SELECT 1 FROM episode_releases er + JOIN releases r ON r.id = er.release_id + WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived') + ) + ORDER BY se.number, e.number + "# ) .fetch_all(database) .await?; + + let mut entries: Vec = Vec::new(); + for row in rows { + merge_episode( + &mut entries, + row.series_id, + row.tmdb_id, + &row.title, + row.year, + row.episode_id, + row.season_number, + row.episode_number, + ); + } + Ok(entries) +} + +/// §5.7's hard-fail lane: episodes and seasons two *different* releases failed +/// on inside `ATTENTION_WINDOW`, restricted to targets still waiting for a +/// file. The season half reads that last condition off its episodes, which is +/// where intent lives (§4.1). Both halves merge into one entry per series. +async fn tv_hard_fail_lane(database: &sqlx::SqlitePool) -> Result, ApiError> { let episode_hard_fails = sqlx::query!( r#" SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", @@ -851,6 +885,7 @@ async fn tv_attention( 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.tmdb_id, s.title, s.year, e.id, se.number, e.number HAVING count(DISTINCT g.release_id) >= 2 "#, @@ -868,6 +903,11 @@ async fn tv_attention( 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 EXISTS ( + SELECT 1 FROM episodes e + WHERE e.season_id = se.id + AND e.wanted = 1 AND e.state != 'available' + ) GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number HAVING count(DISTINCT g.release_id) >= 2 "#, @@ -876,24 +916,10 @@ async fn tv_attention( .fetch_all(database) .await?; - let mut tv_no_pt_source: Vec = Vec::new(); - for row in no_pt_rows { - merge_episode( - &mut tv_no_pt_source, - row.series_id, - row.tmdb_id, - &row.title, - row.year, - row.episode_id, - row.season_number, - row.episode_number, - ); - } - - let mut tv_needs_decision: Vec = Vec::new(); + let mut entries: Vec = Vec::new(); for row in episode_hard_fails { merge_episode( - &mut tv_needs_decision, + &mut entries, row.series_id, row.tmdb_id, &row.title, @@ -905,7 +931,7 @@ async fn tv_attention( } for row in season_pack_fails { merge_season( - &mut tv_needs_decision, + &mut entries, row.series_id, row.tmdb_id, &row.title, @@ -914,8 +940,7 @@ async fn tv_attention( row.season_number, ); } - - Ok((tv_no_pt_source, tv_needs_decision)) + Ok(entries) } /// One more qualifying season for its series, creating the series' entry on @@ -2014,8 +2039,9 @@ mod tests { ); } - /// A series with one empty season, for exercising the season lane on its - /// own. + /// A series with one season holding a single wanted, missing episode — the + /// least that satisfies §5.7's liveness condition — 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'") @@ -2039,6 +2065,14 @@ mod tests { .fetch_one(pool) .await .expect("season"); + sqlx::query( + "INSERT INTO episodes (season_id, number, title, wanted, state) + VALUES (?, 1, 'Episode 1', 1, 'missing')", + ) + .bind(season_id) + .execute(pool) + .await + .expect("episode"); (series_id, season_id) } @@ -2170,6 +2204,159 @@ mod tests { ); } + /// §5.7: the queue only holds targets still waiting for a file. A season + /// whose pack failed twice, fell back to per-episode grabbing (§6.2) and + /// was then fully acquired is the system working, so it drops out at once + /// instead of sitting there for 30 days (#238). + #[tokio::test] + async fn a_fully_acquired_season_leaves_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; + sqlx::query( + "INSERT INTO episodes (season_id, number, title, wanted, state) + VALUES (?, 2, 'Episode 2', 1, 'missing')", + ) + .bind(season_id) + .execute(pool) + .await + .expect("second episode"); + + for (guid, hash) in [("pack-one", "hash-one"), ("pack-two", "hash-two")] { + let release_id = insert_release(pool, guid).await; + insert_failed_grab(pool, release_id, "season", season_id, hash, 0).await; + } + assert_eq!( + queued_seasons(&base, series_id).await, + vec![season_id], + "two packs failed and episodes are still missing" + ); + + // Per-episode grabbing got one of the two. Still a gap, still queued. + sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ? AND number = 1") + .bind(season_id) + .execute(pool) + .await + .expect("first episode imported"); + assert_eq!( + queued_seasons(&base, series_id).await, + vec![season_id], + "one episode still wanted and missing: still broken, still queued" + ); + + sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ?") + .bind(season_id) + .execute(pool) + .await + .expect("season imported"); + assert!( + queued_seasons(&base, series_id).await.is_empty(), + "every episode acquired: the fallback worked, no decision to make" + ); + } + + /// §5.7: the same liveness condition on the movie and episode lanes, so + /// `GET /api/queues/attention` reports exactly what the daemon notifies + /// on (#238). + #[tokio::test] + async fn an_acquired_movie_or_episode_leaves_the_attention_queue() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE audience = 'kids'") + .fetch_one(pool) + .await + .expect("kids root"); + let movie = add_movie(&base, 82728, root_id).await; + let movie_id = movie["id"].as_i64().expect("id"); + for (guid, hash) in [("movie-one", "hash-m1"), ("movie-two", "hash-m2")] { + let release_id = insert_release(pool, guid).await; + insert_failed_grab(pool, release_id, "movie", movie_id, hash, 0).await; + } + + let (series_id, season_id) = seed_bare_season(pool, 99).await; + let episode_id: i64 = + sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1") + .bind(season_id) + .fetch_one(pool) + .await + .expect("episode id"); + for (guid, hash) in [("ep-one", "hash-e1"), ("ep-two", "hash-e2")] { + let release_id = insert_release(pool, guid).await; + insert_failed_grab(pool, release_id, "episode", episode_id, hash, 0).await; + } + + let queues = attention_queues(&base).await; + assert_eq!(queues["needs_decision"][0]["id"], movie_id); + assert_eq!( + queued_episodes(&queues, series_id), + vec![episode_id], + "still wanted and missing: queued" + ); + + sqlx::query("UPDATE movies SET state = 'available' WHERE id = ?") + .bind(movie_id) + .execute(pool) + .await + .expect("movie imported"); + sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?") + .bind(episode_id) + .execute(pool) + .await + .expect("episode imported"); + + let queues = attention_queues(&base).await; + assert_eq!( + queues["needs_decision"].as_array().map(Vec::len), + Some(0), + "imported from a third release: no decision to make" + ); + assert!( + queued_episodes(&queues, series_id).is_empty(), + "imported from a third release: no decision to make" + ); + + // Withdrawing intent empties the lane just the same. + sqlx::query("UPDATE movies SET state = 'missing', wanted = 0 WHERE id = ?") + .bind(movie_id) + .execute(pool) + .await + .expect("movie unwanted"); + assert_eq!( + attention_queues(&base).await["needs_decision"] + .as_array() + .map(Vec::len), + Some(0), + "nothing is waiting for a file" + ); + } + + /// The whole attention payload. + async fn attention_queues(base: &str) -> serde_json::Value { + reqwest::get(format!("{base}/api/queues/attention")) + .await + .expect("queues") + .json() + .await + .expect("queues json") + } + + /// The episodes a series contributes to the hard-fail TV lane. + fn queued_episodes(queues: &serde_json::Value, series_id: i64) -> Vec { + queues["tv_needs_decision"] + .as_array() + .expect("tv lane") + .iter() + .filter(|entry| entry["series_id"] == series_id) + .flat_map(|entry| { + entry["episodes"] + .as_array() + .expect("episodes") + .iter() + .map(|episode| episode["id"].as_i64().expect("episode id")) + }) + .collect() + } + /// One series hitting all three §9.5 TV entry conditions: two wanted, /// searched episodes whose every candidate was rejected for language; a /// season two different packs hard-failed on; and an episode two different diff --git a/crates/arr-daemon/src/attention.rs b/crates/arr-daemon/src/attention.rs index b0fc8cb..7ba6ade 100644 --- a/crates/arr-daemon/src/attention.rs +++ b/crates/arr-daemon/src/attention.rs @@ -3,8 +3,13 @@ //! 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). +//! releases, both inside `arr_db::ATTENTION_WINDOW`, against a target still +//! waiting for a file. One bad torrent is not a decision, a failure already +//! dealt with ages out (#226), and a target that has since been acquired +//! leaves at once (#238). The season lane reads that last condition off its +//! episodes, which is where intent lives (§4.1). `GET /api/queues/attention` +//! filters identically, or the two channels tell the operator different +//! stories. //! //! 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 @@ -218,6 +223,11 @@ fn tv_entry( /// 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. +/// +/// Both hard-fail lanes carry §5.7's liveness condition: an episode is queued +/// only while `wanted` and not `available`, and a season only while at least +/// one of its episodes is. A season pack that failed twice and then fell back +/// to per-episode grabbing (§6.2) drops out as those episodes land. async fn queue_tv(database: &Db) -> Result, TvEntry)>, sqlx::Error> { let mut tv = HashMap::new(); @@ -280,6 +290,11 @@ async fn queue_tv(database: &Db) -> Result, TvEntr 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 EXISTS ( + SELECT 1 FROM episodes e + WHERE e.season_id = se.id + AND e.wanted = 1 AND e.state != 'available' + ) GROUP BY s.id, s.title, s.year, se.id HAVING count(DISTINCT g.release_id) >= 2 "#, @@ -393,6 +408,21 @@ mod tests { series_id } + /// A wanted, missing episode: the least that keeps its season live for + /// §5.7's liveness condition. + async fn insert_wanted_episode(database: &Db, season_id: i64, number: i64) -> i64 { + sqlx::query_scalar( + "INSERT INTO episodes (season_id, number, title, wanted, state) + VALUES (?, ?, ?, 1, 'missing') RETURNING id", + ) + .bind(season_id) + .bind(number) + .bind(format!("Episode {number}")) + .fetch_one(database.pool()) + .await + .unwrap() + } + /// 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. @@ -687,6 +717,7 @@ mod tests { .fetch_one(database.pool()) .await .unwrap(); + insert_wanted_episode(&database, season_id, 1).await; insert_failed_grab(&database, "season", season_id, "pack").await; let server = MockServer::start().await; @@ -707,6 +738,70 @@ mod tests { ); } + /// §5.7: the queue only holds targets still waiting for a file. A season + /// whose packs both hard-failed falls back to per-episode grabbing (§6.2); + /// once every episode has landed the system worked, so the season leaves + /// the queue at once rather than notifying for 30 days (#238). + #[tokio::test] + async fn a_fully_acquired_season_leaves_the_queue() { + 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; + insert_wanted_episode(&database, season_id, 2).await; + 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; + + assert_eq!( + action.tick(&database).await.unwrap().len(), + 1, + "two packs failed and the season still has episodes missing" + ); + + // Per-episode grabbing got the first one. Still a gap, still queued. + sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ? AND number = 1") + .bind(season_id) + .execute(database.pool()) + .await + .unwrap(); + assert_eq!( + action.tick(&database).await.unwrap().len(), + 0, + "already notified, and still queued" + ); + + sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ?") + .bind(season_id) + .execute(database.pool()) + .await + .unwrap(); + assert_eq!( + action.tick(&database).await.unwrap().len(), + 0, + "every episode acquired: nothing left to decide" + ); + + // Proof it actually left rather than merely staying quiet: a season + // still queued would not notify again on re-entry. + sqlx::query("UPDATE episodes SET state = 'missing' WHERE season_id = ? AND number = 2") + .bind(season_id) + .execute(database.pool()) + .await + .unwrap(); + assert_eq!( + action.tick(&database).await.unwrap().len(), + 1, + "broken again: re-enters the queue and notifies" + ); + assert_eq!(server.received_requests().await.unwrap().len(), 2); + } + /// §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] @@ -717,6 +812,7 @@ mod tests { .fetch_one(database.pool()) .await .unwrap(); + insert_wanted_episode(&database, season_id, 1).await; insert_aged_failed_grab(&database, "season", season_id, "old-one", 40).await; insert_aged_failed_grab(&database, "season", season_id, "old-two", 35).await;