From bd52941a6d88f567e5fed644c6fa2c793c8f19bd Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 21:53:32 +0100 Subject: [PATCH 01/27] docs: size bands scale with episode runtime A band's floor and target now read as a rate against a 45-minute reference runtime, scaled by the series' per-episode runtime from TMDB. Missing or zero runtime falls back to the reference, keeping today's behaviour. Movies are explicitly unscaled. Closes the axis question in #208; #209 implements it. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/DESIGN.md b/DESIGN.md index 09e4f0e..b269a9c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -307,6 +307,26 @@ measured by its full size. That is the behaviour today, and it makes a pack look oversized rather than undersized — it fails toward rejecting a good pack rather than grabbing a bad one, and the next search after a refresh has the real count. +**A band describes a rate, not a fixed size per episode.** The shipped values +are read against a **reference runtime of 45 minutes**: a 1080p target of 2 GiB +means 2 GiB per 45 minutes of episode. Before the per-episode figure is compared +against them, a band's floor and target are both scaled by +`runtime / 45`, where `runtime` is the series' minutes-per-episode from TMDB +metadata. For a typical drama around 45 minutes the shipped numbers keep exactly +their current meaning; a 22-minute show is judged against roughly half the floor +and half the target instead of being rejected for weighing half of what an hour +of video weighs. The shipped band values themselves do not change — the +reference runtime is chosen so they do not have to. + +**A missing or zero runtime is the reference runtime.** When metadata carries no +per-episode runtime, or carries zero, the scale factor is 1 and the band applies +unscaled — exactly today's behaviour. The rule does not guess a duration, for +the same reason the unknown episode count does not guess a number. + +**Movies are not scaled.** This applies to episodes only. A movie's bands are +already tuned against feature length, so its floor and target keep their current +meaning regardless of the movie's own runtime. + Source tier (`Remux > BluRay > WEB-DL > WEBRip > HDTV`) survives as a small tiebreaker. Seeders are log-scaled and small: enough to complete, past that it does not matter. Telesync, CAM and screener are **hard filters**, not low From 9fee07f0800cd47e0ef790bd0d1d36dc8fc4986b Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 22:15:01 +0100 Subject: [PATCH 02/27] feat: let a size rejection be waived MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A release below §5.5's floor was rejected with no way through, so a policy wrong about one title left three Rick and Morty S09 packs visible and none grabbable. `allow_below_floor` relaxes the floor for one title into a soft fail, never a pass: the release is waived, so automatic grabbing still skips it and the import records a §5.7 waiver. The deck offers the one click on a rejected row where the rule has an override, which is exactly what §9.3's override is for. Stored verdicts are re-derived when a title's overrides change — the deck and the daemon's grab gate both read that column, so without it the row the operator just acted on would keep reading `rejected`. Closes #210 Co-Authored-By: Claude Opus 5 --- .impeccable/surfaces/web-src-main-ts.md | 12 +- ...f8ee0f4aeb615e1d29c9cae03a589c7a6bdd4.json | 182 +++++++++++++++ ...c58997079821e2521a196174f4182e41531ad.json | 74 ++++++ ...af062e73659ee89ab4387e0a28b7d546f5be4.json | 26 +++ ...e4af24a93f62b5c04b1a67ad8704f31c39d62.json | 12 + ...b1618554de74b444f28a89af92cde6abb47e5.json | 74 ++++++ crates/arr-api/src/lib.rs | 1 + crates/arr-api/src/movies.rs | 120 +++++++++- crates/arr-api/src/reclassify.rs | 210 ++++++++++++++++++ crates/arr-api/src/search.rs | 4 +- crates/arr-api/src/series.rs | 135 ++++++++++- crates/arr-core/src/lib.rs | 8 + crates/arr-core/src/policy.rs | 141 ++++++++++++ crates/arr-db/src/policy.rs | 73 ++++++ web/src/main.ts | 34 ++- web/src/releases.ts | 12 + 16 files changed, 1098 insertions(+), 20 deletions(-) create mode 100644 .sqlx/query-1f67ae5af28115d22ad5e2fde1df8ee0f4aeb615e1d29c9cae03a589c7a6bdd4.json create mode 100644 .sqlx/query-7fe60916664f3552cd111e45586c58997079821e2521a196174f4182e41531ad.json create mode 100644 .sqlx/query-869e17a01bcf8710bb533dbe428af062e73659ee89ab4387e0a28b7d546f5be4.json create mode 100644 .sqlx/query-ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62.json create mode 100644 .sqlx/query-e5b93151521f2d49a1f9e3c5b7ab1618554de74b444f28a89af92cde6abb47e5.json create mode 100644 crates/arr-api/src/reclassify.rs diff --git a/.impeccable/surfaces/web-src-main-ts.md b/.impeccable/surfaces/web-src-main-ts.md index e54011a..1e1b68e 100644 --- a/.impeccable/surfaces/web-src-main-ts.md +++ b/.impeccable/surfaces/web-src-main-ts.md @@ -29,9 +29,15 @@ reads the library as derived status, not flags. counts behind cyan show/hide toggles. Release name is a secondary truncated line, full string plus indexer/date/bytes on row expand. Every waived/rejected row carries a verdict chip naming the rule. Grab is its - own control per row; on a waived row it is "waive + grab" and writes the - per-title override (rule→override mapping in releases.ts — bookkeeping, - never policy; verdicts always come from the API). Esc steps back one + own control per row; where the rule that failed has an override it reads + "waive + grab" and writes the per-title override (rule→override mapping in + releases.ts — bookkeeping, never policy; verdicts always come from the + API). A rejected row offers it too — §9.3's one click exists precisely for + the rule the operator disagrees with, and a size rejection (#210) is only + ever rejected. What the click produces stays a waiver: the row re-reads as + a dashed WAIVED, never eligible. When ELIGIBLE is empty and something is + waivable, its "none" line carries the count, so the way out is not folded + inside a collapsed bucket. Esc steps back one layer: deck → search results → board. - Removal (§7.4, issue 104): REMOVE is a quiet control in the deck head, never a row affordance — a dense list is the wrong place for a delete. diff --git a/.sqlx/query-1f67ae5af28115d22ad5e2fde1df8ee0f4aeb615e1d29c9cae03a589c7a6bdd4.json b/.sqlx/query-1f67ae5af28115d22ad5e2fde1df8ee0f4aeb615e1d29c9cae03a589c7a6bdd4.json new file mode 100644 index 0000000..adecf74 --- /dev/null +++ b/.sqlx/query-1f67ae5af28115d22ad5e2fde1df8ee0f4aeb615e1d29c9cae03a589c7a6bdd4.json @@ -0,0 +1,182 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT s.overrides AS \"overrides!: String\",\n r.id AS \"root_id!: i64\",\n r.kind AS \"root_kind!: String\",\n r.audience AS \"root_audience!: String\",\n r.path AS \"root_path!: String\",\n p.id AS \"policy_id!: i64\",\n p.name AS \"policy_name!: String\",\n p.required_audio AS \"required_audio!: String\",\n p.dub_blacklist AS \"dub_blacklist!: String\",\n p.hdr_rules AS \"hdr_rules!: String\",\n p.size_bands AS \"size_bands!: String\",\n p.resolution_pref AS \"resolution_pref!: String\",\n p.source_weights AS \"source_weights!: String\",\n p.score_weights AS \"score_weights!: String\"\n FROM series s\n JOIN roots r ON r.id = s.root_id\n JOIN policies p ON p.id = r.policy_id\n WHERE s.id = ?\n ", + "describe": { + "columns": [ + { + "name": "overrides!: String", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "series", + "name": "overrides" + } + } + }, + { + "name": "root_id!: i64", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "roots", + "name": "id" + } + } + }, + { + "name": "root_kind!: String", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "roots", + "name": "kind" + } + } + }, + { + "name": "root_audience!: String", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "roots", + "name": "audience" + } + } + }, + { + "name": "root_path!: String", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "roots", + "name": "path" + } + } + }, + { + "name": "policy_id!: i64", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "policies", + "name": "id" + } + } + }, + { + "name": "policy_name!: String", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "policies", + "name": "name" + } + } + }, + { + "name": "required_audio!: String", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "policies", + "name": "required_audio" + } + } + }, + { + "name": "dub_blacklist!: String", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "policies", + "name": "dub_blacklist" + } + } + }, + { + "name": "hdr_rules!: String", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "policies", + "name": "hdr_rules" + } + } + }, + { + "name": "size_bands!: String", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "policies", + "name": "size_bands" + } + } + }, + { + "name": "resolution_pref!: String", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "policies", + "name": "resolution_pref" + } + } + }, + { + "name": "source_weights!: String", + "ordinal": 12, + "type_info": "Text", + "origin": { + "Table": { + "table": "policies", + "name": "source_weights" + } + } + }, + { + "name": "score_weights!: String", + "ordinal": 13, + "type_info": "Text", + "origin": { + "Table": { + "table": "policies", + "name": "score_weights" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "1f67ae5af28115d22ad5e2fde1df8ee0f4aeb615e1d29c9cae03a589c7a6bdd4" +} diff --git a/.sqlx/query-7fe60916664f3552cd111e45586c58997079821e2521a196174f4182e41531ad.json b/.sqlx/query-7fe60916664f3552cd111e45586c58997079821e2521a196174f4182e41531ad.json new file mode 100644 index 0000000..41f7f09 --- /dev/null +++ b/.sqlx/query-7fe60916664f3552cd111e45586c58997079821e2521a196174f4182e41531ad.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT r.id AS \"id!: i64\",\n r.size AS \"size!: i64\",\n r.parsed AS \"parsed!: serde_json::Value\",\n r.verdict,\n r.rejected_rule\n FROM releases r\n WHERE r.id IN (\n SELECT er.release_id\n FROM episode_releases er\n JOIN episodes e ON e.id = er.episode_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?\n UNION\n SELECT sr.release_id\n FROM season_releases sr\n JOIN seasons se ON se.id = sr.season_id\n WHERE se.series_id = ?\n )\n ", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "releases", + "name": "id" + } + } + }, + { + "name": "size!: i64", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "releases", + "name": "size" + } + } + }, + { + "name": "parsed!: serde_json::Value", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "releases", + "name": "parsed" + } + } + }, + { + "name": "verdict", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "releases", + "name": "verdict" + } + } + }, + { + "name": "rejected_rule", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "releases", + "name": "rejected_rule" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "7fe60916664f3552cd111e45586c58997079821e2521a196174f4182e41531ad" +} diff --git a/.sqlx/query-869e17a01bcf8710bb533dbe428af062e73659ee89ab4387e0a28b7d546f5be4.json b/.sqlx/query-869e17a01bcf8710bb533dbe428af062e73659ee89ab4387e0a28b7d546f5be4.json new file mode 100644 index 0000000..91528f3 --- /dev/null +++ b/.sqlx/query-869e17a01bcf8710bb533dbe428af062e73659ee89ab4387e0a28b7d546f5be4.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT original_language FROM series WHERE id = ?", + "describe": { + "columns": [ + { + "name": "original_language", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "series", + "name": "original_language" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true + ] + }, + "hash": "869e17a01bcf8710bb533dbe428af062e73659ee89ab4387e0a28b7d546f5be4" +} diff --git a/.sqlx/query-ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62.json b/.sqlx/query-ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62.json new file mode 100644 index 0000000..1a98b0c --- /dev/null +++ b/.sqlx/query-ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE releases SET verdict = ?, rejected_rule = ? WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62" +} diff --git a/.sqlx/query-e5b93151521f2d49a1f9e3c5b7ab1618554de74b444f28a89af92cde6abb47e5.json b/.sqlx/query-e5b93151521f2d49a1f9e3c5b7ab1618554de74b444f28a89af92cde6abb47e5.json new file mode 100644 index 0000000..4dd5fd6 --- /dev/null +++ b/.sqlx/query-e5b93151521f2d49a1f9e3c5b7ab1618554de74b444f28a89af92cde6abb47e5.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT r.id AS \"id!: i64\",\n r.size AS \"size!: i64\",\n r.parsed AS \"parsed!: serde_json::Value\",\n r.verdict,\n r.rejected_rule\n FROM releases r\n JOIN movie_releases mr ON mr.release_id = r.id\n WHERE mr.movie_id = ?\n ", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "releases", + "name": "id" + } + } + }, + { + "name": "size!: i64", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "releases", + "name": "size" + } + } + }, + { + "name": "parsed!: serde_json::Value", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "releases", + "name": "parsed" + } + } + }, + { + "name": "verdict", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "releases", + "name": "verdict" + } + } + }, + { + "name": "rejected_rule", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "releases", + "name": "rejected_rule" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "e5b93151521f2d49a1f9e3c5b7ab1618554de74b444f28a89af92cde6abb47e5" +} diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 1fd1f75..38187b4 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -11,6 +11,7 @@ mod metadata; mod movies; mod owners; mod policies; +mod reclassify; mod roots; mod search; mod series; diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 351b1e5..04e8184 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -240,10 +240,10 @@ fn validate_overrides(value: &serde_json::Value) -> Result<(), ApiError> { }; if object .keys() - .any(|key| key != "only_4k" && key != "allow_english_audio") + .any(|key| key != "only_4k" && key != "allow_english_audio" && key != "allow_below_floor") { return Err(ApiError::Invalid( - "overrides supports only only_4k and allow_english_audio".into(), + "overrides supports only only_4k, allow_english_audio and allow_below_floor".into(), )); } if object.values().any(|value| !value.is_boolean()) { @@ -406,8 +406,11 @@ pub async fn update( if title.trim().is_empty() { return Err(ApiError::Invalid("title cannot be empty".into())); } - let overrides = input.overrides.unwrap_or(current.overrides); + let overrides = input.overrides.unwrap_or(current.overrides.clone()); validate_overrides(&overrides)?; + // Verdicts already stored for this title were reached under the old + // overrides; §9.3's deck and the grab gate both read them (`reclassify`). + let overrides_changed = overrides != current.overrides; let overrides = serde_json::to_string(&overrides).map_err(|error| ApiError::Invalid(error.to_string()))?; let title = title.trim(); @@ -430,6 +433,9 @@ pub async fn update( sqlx::query!("UPDATE movies SET title = ?, year = ?, original_language = ?, root_id = ?, wanted = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, wanted, blocked, overrides, id) .execute(pool(&state)?) .await?; + if overrides_changed { + crate::reclassify::movie(&state, id).await?; + } Ok(Json(load_movie(&state, id).await?)) } @@ -1135,6 +1141,114 @@ mod tests { assert_eq!(files[0]["probed"]["resolution"], "1080p"); } + /// #210 on the movie deck: relaxing §5.5's floor for one title moves its + /// size rejections into the waived bucket and no further — a waiver is + /// never picked automatically (§9.3), only offered. + #[tokio::test] + async fn allow_below_floor_moves_a_size_rejection_to_waived_not_eligible() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let created: serde_json::Value = reqwest::Client::new() + .post(format!("{base}/api/movies")) + .json(&serde_json::json!({ + "tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024, + "original_language": "en", "root_id": 1, "overrides": {} + })) + .send() + .await + .expect("create movie") + .json() + .await + .expect("movie json"); + let movie_id = created["id"].as_i64().expect("id"); + + // 2 GiB at 1080p, under the movie policy's 3 GiB floor. + let name = "Dune Part Two 2024 1080p WEB-DL x264-GROUP"; + let parsed = arr_parse::parse(name); + let size = 2_i64 * (1 << 30); + let release_id = sqlx::query_scalar::<_, i64>( + "INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict, rejected_rule) + VALUES (7, 'g', ?, ?, 40, 'url', ?, 0, 'rejected', 'size') RETURNING id", + ) + .bind(name) + .bind(size) + .bind(serde_json::to_string(&parsed).expect("parsed json")) + .fetch_one(pool) + .await + .expect("release"); + sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)") + .bind(movie_id) + .bind(release_id) + .execute(pool) + .await + .expect("association"); + + let response = reqwest::Client::new() + .patch(format!("{base}/api/movies/{movie_id}")) + .json(&serde_json::json!({"overrides": {"allow_below_floor": true}})) + .send() + .await + .expect("override"); + assert_eq!(response.status(), StatusCode::OK); + + let releases: Vec = + reqwest::get(format!("{base}/api/movies/{movie_id}/releases")) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + assert_eq!(releases.len(), 1); + assert_eq!(releases[0]["verdict"], "waived"); + } + + /// A blacklisted release (§6.3) is not a policy opinion, so no override + /// re-opens it. + #[tokio::test] + async fn an_override_never_revives_a_blacklisted_release() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let movie = add_movie(&base, 693_134, 1).await; + let movie_id = movie["id"].as_i64().expect("id"); + + let name = "Dune Part Two 2024 1080p WEB-DL x264-GROUP"; + let parsed = arr_parse::parse(name); + let size = 8_i64 * (1 << 30); + let release_id = sqlx::query_scalar::<_, i64>( + "INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict, rejected_rule) + VALUES (7, 'g', ?, ?, 40, 'url', ?, 0, 'rejected', 'blacklisted') RETURNING id", + ) + .bind(name) + .bind(size) + .bind(serde_json::to_string(&parsed).expect("parsed json")) + .fetch_one(pool) + .await + .expect("release"); + sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)") + .bind(movie_id) + .bind(release_id) + .execute(pool) + .await + .expect("association"); + + reqwest::Client::new() + .patch(format!("{base}/api/movies/{movie_id}")) + .json(&serde_json::json!({"overrides": {"allow_below_floor": true, "only_4k": false}})) + .send() + .await + .expect("override"); + + let releases: Vec = + reqwest::get(format!("{base}/api/movies/{movie_id}/releases")) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + assert_eq!(releases[0]["verdict"], "rejected"); + assert_eq!(releases[0]["rejected_rule"], "blacklisted"); + } + #[tokio::test] async fn crud_preserves_intent_and_overrides() { let (_dir, _state, base) = application().await; diff --git a/crates/arr-api/src/reclassify.rs b/crates/arr-api/src/reclassify.rs new file mode 100644 index 0000000..1a8fb28 --- /dev/null +++ b/crates/arr-api/src/reclassify.rs @@ -0,0 +1,210 @@ +//! Stored verdicts, re-derived when a title's overrides change (§9.3). +//! +//! A release's verdict is stamped once, by the search that found it. Both the +//! deck and the daemon's manual-grab gate read that stored column, so an +//! override written from the deck's one-click waive would change nothing +//! until the next sweep — the row the operator just acted on would keep +//! reading `rejected` and the grab would be refused. +//! +//! So the rules run again here, over the releases already attached to the +//! title. This is the same correction the daemon makes when a grab turns out +//! to be blacklisted: fix the row now rather than leave §9.3 offering, or +//! refusing, something that is no longer true. +//! +//! The blacklist (§6.3) is not a policy opinion, so a row rejected by it is +//! left exactly as it is. + +use std::collections::BTreeMap; + +use arr_core::policy::{evaluate, Candidate}; +use arr_core::score::claimed_episode_count; +use arr_core::{Language, ParsedRelease, Policy, TitleOverrides}; +use arr_db::blacklist; + +use crate::movies::{pool, ApiError}; +use crate::search::verdict_columns; +use crate::state::AppState; + +/// One stored release, as much of it as re-evaluation needs. +struct Stored { + id: i64, + size: i64, + parsed: serde_json::Value, + verdict: Option, + rejected_rule: Option, +} + +/// Re-evaluate every release attached to one movie. +/// +/// A movie with no stored original language is left alone, the same rule the +/// daemon's search applies: without it the required-audio rule has nothing to +/// compare, and guessing would move verdicts on worse evidence than the ones +/// already there. +pub(crate) async fn movie(state: &AppState, movie_id: i64) -> Result<(), ApiError> { + let Some(language) = movie_language(state, movie_id).await? else { + return Ok(()); + }; + let Some(loaded) = state + .database() + .ok_or(ApiError::Unavailable)? + .movie_policy(movie_id) + .await + .map_err(|error| ApiError::Database(error.to_string()))? + else { + return Ok(()); + }; + let releases = sqlx::query_as!( + Stored, + r#" + SELECT r.id AS "id!: i64", + r.size AS "size!: i64", + r.parsed AS "parsed!: serde_json::Value", + r.verdict, + r.rejected_rule + FROM releases r + JOIN movie_releases mr ON mr.release_id = r.id + WHERE mr.movie_id = ? + "#, + movie_id + ) + .fetch_all(pool(state)?) + .await?; + + apply( + state, + &releases, + &loaded.policy, + &loaded.overrides, + &language, + None, + ) + .await +} + +/// Re-evaluate every release attached to one series, episode decks and season +/// decks alike — overrides sit on the series (§5.1), so one change moves both. +pub(crate) async fn series(state: &AppState, series_id: i64) -> Result<(), ApiError> { + let Some(language) = series_language(state, series_id).await? else { + return Ok(()); + }; + let Some(loaded) = state + .database() + .ok_or(ApiError::Unavailable)? + .series_policy(series_id) + .await + .map_err(|error| ApiError::Database(error.to_string()))? + else { + return Ok(()); + }; + let releases = sqlx::query_as!( + Stored, + r#" + SELECT r.id AS "id!: i64", + r.size AS "size!: i64", + r.parsed AS "parsed!: serde_json::Value", + r.verdict, + r.rejected_rule + FROM releases r + WHERE r.id IN ( + SELECT er.release_id + FROM episode_releases er + JOIN episodes e ON e.id = er.episode_id + JOIN seasons se ON se.id = e.season_id + WHERE se.series_id = ? + UNION + SELECT sr.release_id + FROM season_releases sr + JOIN seasons se ON se.id = sr.season_id + WHERE se.series_id = ? + ) + "#, + series_id, + series_id + ) + .fetch_all(pool(state)?) + .await?; + + // A size band describes one episode (§5.5), so a pack's verdict needs the + // same divisor the deck scores it with. + let lengths = crate::series::season_lengths(state, series_id).await?; + apply( + state, + &releases, + &loaded.policy, + &loaded.overrides, + &language, + Some(&lengths), + ) + .await +} + +async fn apply( + state: &AppState, + releases: &[Stored], + policy: &Policy, + overrides: &TitleOverrides, + original_language: &Language, + season_lengths: Option<&BTreeMap>, +) -> Result<(), ApiError> { + for release in releases { + if release.rejected_rule.as_deref() == Some(blacklist::RULE) { + continue; + } + let parsed: ParsedRelease = serde_json::from_value(release.parsed.clone()) + .map_err(|error| ApiError::Database(error.to_string()))?; + let episodes = season_lengths.map_or(1, |lengths| { + claimed_episode_count(parsed.episode.as_ref(), lengths) + }); + // A stored zero is a release the indexer gave no size for, and a + // release with no size has nothing to say about its band — the same + // reading the deck's rescore takes. + let size = u64::try_from(release.size).ok().filter(|size| *size > 0); + let evaluation = evaluate( + policy, + overrides, + original_language, + Candidate::PreGrab(&parsed), + size, + episodes, + ); + // `releases` allows a rule name only on a rejected row + // (`CHECK ((verdict = 'rejected') = (rejected_rule IS NOT NULL))`), + // which is also how the daemon writes a waiver. + let (verdict, rule) = verdict_columns(&evaluation.verdict); + let rule = if verdict == "rejected" { rule } else { None }; + if release.verdict.as_deref() == Some(verdict) && release.rejected_rule == rule { + continue; + } + sqlx::query!( + "UPDATE releases SET verdict = ?, rejected_rule = ? WHERE id = ?", + verdict, + rule, + release.id + ) + .execute(pool(state)?) + .await?; + } + Ok(()) +} + +async fn movie_language(state: &AppState, movie_id: i64) -> Result, ApiError> { + let stored = sqlx::query_scalar!( + "SELECT original_language FROM movies WHERE id = ?", + movie_id + ) + .fetch_optional(pool(state)?) + .await? + .flatten(); + Ok(stored.map(|value| arr_db::policy::language(&value))) +} + +async fn series_language(state: &AppState, series_id: i64) -> Result, ApiError> { + let stored = sqlx::query_scalar!( + "SELECT original_language FROM series WHERE id = ?", + series_id + ) + .fetch_optional(pool(state)?) + .await? + .flatten(); + Ok(stored.map(|value| arr_db::policy::language(&value))) +} diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 0c178b4..2e73d73 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -728,7 +728,7 @@ fn classify( let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) { ("rejected", Some(blacklist::RULE.to_owned())) } else { - verdict(&evaluation.verdict) + verdict_columns(&evaluation.verdict) }; let score = score( policy, @@ -778,7 +778,7 @@ fn classify( }) } -fn verdict(verdict: &Verdict) -> (&'static str, Option) { +pub(crate) fn verdict_columns(verdict: &Verdict) -> (&'static str, Option) { match verdict { Verdict::Eligible => ("eligible", None), Verdict::Waived(rule) => ("waived", Some(rule_name(rule))), diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index cf78ba1..6c85afa 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -184,10 +184,10 @@ fn validate_overrides(value: &serde_json::Value) -> Result<(), ApiError> { }; if object .keys() - .any(|key| key != "only_4k" && key != "allow_english_audio") + .any(|key| key != "only_4k" && key != "allow_english_audio" && key != "allow_below_floor") { return Err(ApiError::Invalid( - "overrides supports only only_4k and allow_english_audio".into(), + "overrides supports only only_4k, allow_english_audio and allow_below_floor".into(), )); } if object.values().any(|value| !value.is_boolean()) { @@ -561,8 +561,11 @@ pub async fn update( if title.trim().is_empty() { return Err(ApiError::Invalid("title cannot be empty".into())); } - let overrides = input.overrides.unwrap_or(current.overrides); + let overrides = input.overrides.unwrap_or(current.overrides.clone()); validate_overrides(&overrides)?; + // Verdicts already stored for this series were reached under the old + // overrides; §9.3's deck and the grab gate both read them (`reclassify`). + let overrides_changed = overrides != current.overrides; let overrides = serde_json::to_string(&overrides).map_err(|error| ApiError::Invalid(error.to_string()))?; let title = title.trim(); @@ -578,6 +581,9 @@ pub async fn update( sqlx::query!("UPDATE series SET title = ?, year = ?, original_language = ?, root_id = ?, auto_track = ?, upstream_ended = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, id) .execute(pool(&state)?) .await?; + if overrides_changed { + crate::reclassify::series(&state, id).await?; + } Ok(Json(load_series(&state, id).await?)) } @@ -1869,6 +1875,129 @@ mod tests { response.json().await.expect("season json") } + /// The production case behind #210: every pack of a season is under + /// §5.5's per-episode floor, so the deck holds three candidates and + /// nothing is grabbable. Writing `allow_below_floor` turns the + /// rejections into waivers — offered, never automatic. + #[tokio::test] + async fn allow_below_floor_turns_a_size_rejected_pack_into_a_waiver() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, false).await; + let series_id = series["id"].as_i64().expect("id"); + let episodes: Vec = (1..=10) + .map(|number| { + serde_json::json!({ + "number": number, "title": format!("Episode {number}"), + "air_date": "2025-01-01" + }) + }) + .collect(); + let season = add_season(&base, series_id, 9, serde_json::json!(episodes)).await; + let season_id = season["id"].as_i64().expect("season id"); + + // Ten episodes in 8 GiB: 0.8 GiB each, under the 1 GiB 1080p floor. + let pool = state.database().expect("database").pool(); + let name = "Bluey S09 1080p WEB-DL x264-GROUP"; + let parsed = arr_parse::parse(name); + let size = 8_i64 * (1 << 30); + let release_id = sqlx::query_scalar::<_, i64>( + "INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict, rejected_rule) + VALUES (7, 'pack', ?, ?, 50, 'url', ?, 0, 'rejected', 'size') RETURNING id", + ) + .bind(name) + .bind(size) + .bind(serde_json::to_string(&parsed).expect("parsed json")) + .fetch_one(pool) + .await + .expect("release"); + sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)") + .bind(season_id) + .bind(release_id) + .execute(pool) + .await + .expect("association"); + + let response = reqwest::Client::new() + .patch(format!("{base}/api/series/{series_id}")) + .json(&serde_json::json!({"overrides": {"allow_below_floor": true}})) + .send() + .await + .expect("override"); + assert_eq!( + response.status(), + StatusCode::OK, + "{:?}", + response.text().await + ); + let updated: serde_json::Value = reqwest::get(format!("{base}/api/series/{series_id}")) + .await + .expect("series") + .json() + .await + .expect("series json"); + assert_eq!(updated["overrides"]["allow_below_floor"], true); + + let releases: Vec = + reqwest::get(format!("{base}/api/series/{series_id}/seasons/9/releases")) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + assert_eq!(releases.len(), 1); + assert_eq!( + releases[0]["verdict"], "waived", + "a waived grab stays a waiver; nothing here makes it eligible" + ); + // The row's rule name goes with the rejection; what survives is the + // dashed `waived` verdict the deck reads (§9.3). + assert!(releases[0]["rejected_rule"].is_null()); + + // The grab the deck's one click sends is now accepted. + let response = reqwest::Client::new() + .post(format!( + "{base}/api/series/{series_id}/seasons/9/releases/{release_id}/grab" + )) + .send() + .await + .expect("grab"); + assert_eq!(response.status(), StatusCode::ACCEPTED); + + // And clearing the override puts the floor back. + reqwest::Client::new() + .patch(format!("{base}/api/series/{series_id}")) + .json(&serde_json::json!({"overrides": {}})) + .send() + .await + .expect("clear override"); + let releases: Vec = + reqwest::get(format!("{base}/api/series/{series_id}/seasons/9/releases")) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + assert_eq!(releases[0]["verdict"], "rejected"); + assert_eq!(releases[0]["rejected_rule"], "size"); + } + + #[tokio::test] + async fn overrides_reject_a_key_the_policy_engine_has_no_rule_for() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, false).await; + let series_id = series["id"].as_i64().expect("id"); + + let response = reqwest::Client::new() + .patch(format!("{base}/api/series/{series_id}")) + .json(&serde_json::json!({"overrides": {"allow_anything": true}})) + .send() + .await + .expect("override"); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + /// §9.6: the three stored artwork fields come off the series detail /// response at add time — the same call that used to fetch only the /// TVDB id — so a series added today has a poster before tomorrow's diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 4421f0f..584da12 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -237,6 +237,14 @@ pub struct Policy { pub struct TitleOverrides { pub only_4k: bool, pub allow_english_audio: bool, + /// Take a release below its size band's floor on this title (§5.5). + /// + /// No band is right for every title, and the floor is a hard reject, so + /// a title the band is wrong about has nothing grabbable at all. This + /// relaxes the floor to a soft fail rather than removing it: the release + /// is waived, never eligible, so it stays a deliberate manual grab and + /// imports on the record as a §5.7 waiver. + pub allow_below_floor: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs index f9ab8bb..033b396 100644 --- a/crates/arr-core/src/policy.rs +++ b/crates/arr-core/src/policy.rs @@ -224,6 +224,12 @@ impl PolicyRule for SourceRule { /// "smaller is better" selects a 3 GB 4K encode that looks like mud. How far /// a candidate sits from the band's *target* is [`crate::score`]'s question, /// not this rule's. +/// +/// `allow_below_floor` softens the floor for one title rather than lifting +/// it: a below-floor release becomes a waiver, so it is never picked +/// automatically and its import is recorded as a §5.7 waiver. This mirrors +/// [`ResolutionRule`], where an override moves a failure between hard and +/// soft and never makes the rule stop applying. #[derive(Clone, Copy, Debug, Default)] pub struct SizeRule; @@ -236,6 +242,9 @@ impl PolicyRule for SizeRule { match crate::score::is_below_floor(context.policy, resolution, size, context.episode_count) { None => RuleEvaluation::Unknown(RuleKind::Size), + Some(true) if context.overrides.allow_below_floor => { + RuleEvaluation::SoftFail(Rule::Size) + } Some(true) => RuleEvaluation::HardFail(Rule::Size), Some(false) => RuleEvaluation::Pass(RuleKind::Size), } @@ -655,6 +664,138 @@ mod tests { ); } + /// A policy with one 1080p band, so the floor has something to say. + fn banded_policy() -> Policy { + Policy { + size_bands: BTreeMap::from([( + Resolution::R1080p, + crate::SizeBand { + floor_bytes: 2 << 30, + target_bytes: 4 << 30, + penalty_points_per_gib_over: 600, + }, + )]), + resolution_preference: vec![Resolution::R1080p], + ..policy() + } + } + + #[test] + fn a_below_floor_release_is_rejected_without_the_override() { + let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl)); + let evaluation = evaluate( + &banded_policy(), + &TitleOverrides::default(), + &en(), + Candidate::PreGrab(&claims), + Some(1 << 30), + 1, + ); + + assert_eq!(evaluation.verdict, Verdict::Rejected(Rule::Size)); + } + + #[test] + fn allow_below_floor_waives_the_floor_rather_than_lifting_it() { + let policy = banded_policy(); + let overrides = TitleOverrides { + allow_below_floor: true, + ..TitleOverrides::default() + }; + let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl)); + + // Pre-grab the deck offers it, and only as a waiver: `waived` is + // never picked automatically (§9.3), so the operator still decides. + assert_eq!( + evaluate( + &policy, + &overrides, + &en(), + Candidate::PreGrab(&claims), + Some(1 << 30), + 1, + ) + .verdict, + Verdict::Waived(Rule::Size) + ); + + // And the import records the waiver rather than hard-failing (§5.7). + let media = probed(Resolution::R1080p, Some(Source::WebDl)); + assert_eq!( + evaluate( + &policy, + &overrides, + &en(), + Candidate::PostDownload(&media), + Some(1 << 30), + 1, + ) + .verdict, + Verdict::Waived(Rule::Size) + ); + } + + #[test] + fn allow_below_floor_says_nothing_about_a_release_that_clears_the_floor() { + let overrides = TitleOverrides { + allow_below_floor: true, + ..TitleOverrides::default() + }; + let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl)); + + assert_eq!( + evaluate( + &banded_policy(), + &overrides, + &en(), + Candidate::PreGrab(&claims), + Some(4 << 30), + 1, + ) + .verdict, + Verdict::Eligible + ); + } + + /// §5.5: the floor takes the per-episode figure, so the override that + /// relaxes it has to travel the same divisor. + #[test] + fn allow_below_floor_waives_a_pack_measured_per_episode() { + let policy = banded_policy(); + let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl)); + // Ten episodes at 1 GiB each: over the floor in total, under it per + // episode, which is the figure the floor compares. + let size = Some(10 << 30); + + assert_eq!( + evaluate( + &policy, + &TitleOverrides::default(), + &en(), + Candidate::PreGrab(&claims), + size, + 10, + ) + .verdict, + Verdict::Rejected(Rule::Size) + ); + assert_eq!( + evaluate( + &policy, + &TitleOverrides { + allow_below_floor: true, + ..TitleOverrides::default() + }, + &en(), + Candidate::PreGrab(&claims), + size, + 10, + ) + .verdict, + Verdict::Waived(Rule::Size) + ); + } + #[test] fn every_unsafe_source_hard_fails_in_both_phases() { let policy = policy(); diff --git a/crates/arr-db/src/policy.rs b/crates/arr-db/src/policy.rs index 354d183..d33701d 100644 --- a/crates/arr-db/src/policy.rs +++ b/crates/arr-db/src/policy.rs @@ -193,6 +193,7 @@ impl Db { overrides: TitleOverrides { only_4k: overrides.only_4k, allow_english_audio: overrides.allow_english_audio, + allow_below_floor: overrides.allow_below_floor, }, root_id: row.root_id, root_kind: row.root_kind, @@ -268,6 +269,75 @@ impl Db { overrides: TitleOverrides { only_4k: overrides.only_4k, allow_english_audio: overrides.allow_english_audio, + allow_below_floor: overrides.allow_below_floor, + }, + root_id: row.root_id, + root_kind: row.root_kind, + root_audience: row.root_audience, + root_path: row.root_path, + })) + } + + /// The policy attached to one series' root, with the series' own + /// overrides (§5.1). + /// + /// `None` when the series does not exist. + /// + /// # Errors + /// + /// If the query fails, or a policy column does not hold the JSON its + /// migration promises. + pub async fn series_policy(&self, series_id: i64) -> Result, PolicyError> { + let row = sqlx::query!( + r#" + SELECT s.overrides AS "overrides!: String", + r.id AS "root_id!: i64", + r.kind AS "root_kind!: String", + r.audience AS "root_audience!: String", + r.path AS "root_path!: String", + p.id AS "policy_id!: i64", + p.name AS "policy_name!: String", + p.required_audio AS "required_audio!: String", + p.dub_blacklist AS "dub_blacklist!: String", + p.hdr_rules AS "hdr_rules!: String", + p.size_bands AS "size_bands!: String", + p.resolution_pref AS "resolution_pref!: String", + p.source_weights AS "source_weights!: String", + p.score_weights AS "score_weights!: String" + FROM series s + JOIN roots r ON r.id = s.root_id + JOIN policies p ON p.id = r.policy_id + WHERE s.id = ? + "#, + series_id + ) + .fetch_optional(self.pool()) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + + let overrides: OverridesJson = json("overrides", &row.overrides)?; + let policy = PolicyColumns { + id: row.policy_id, + name: row.policy_name, + required_audio: row.required_audio, + dub_blacklist: row.dub_blacklist, + hdr_rules: row.hdr_rules, + size_bands: row.size_bands, + resolution_pref: row.resolution_pref, + source_weights: row.source_weights, + score_weights: row.score_weights, + } + .to_policy()?; + + Ok(Some(TitlePolicy { + policy, + overrides: TitleOverrides { + only_4k: overrides.only_4k, + allow_english_audio: overrides.allow_english_audio, + allow_below_floor: overrides.allow_below_floor, }, root_id: row.root_id, root_kind: row.root_kind, @@ -337,6 +407,7 @@ impl Db { overrides: TitleOverrides { only_4k: overrides.only_4k, allow_english_audio: overrides.allow_english_audio, + allow_below_floor: overrides.allow_below_floor, }, root_id: row.root_id, root_kind: row.root_kind, @@ -428,6 +499,8 @@ struct OverridesJson { only_4k: bool, #[serde(default)] allow_english_audio: bool, + #[serde(default)] + allow_below_floor: bool, } fn gib(value: u64) -> u64 { diff --git a/web/src/main.ts b/web/src/main.ts index cb3714b..410aa33 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -50,6 +50,7 @@ import { movieFiles, movieReleases, movieSearchState, + overridable, probedAttributeTags, queueSearch, removeMovie, @@ -59,7 +60,6 @@ import { totalSize, type WaiveOutcome, waiveAndGrab, - waiverOverride, } from "./releases"; import { currentRoute, navigate, type Route } from "./router"; import { @@ -1352,7 +1352,7 @@ function movieMain(views: HideableView[]): MovieView { if (!movie) { return { kind: "error", detail: "page closed", overrideWritten: false }; } - if (bucket === "waived") { + if (bucket !== "eligible") { return waiveAndGrab(movie.id, release.id, release.rejected_rule); } const outcome = await grabRelease(movie.id, release.id); @@ -2091,7 +2091,8 @@ function removePanel(subject: RemoveSubject, actions: RemoveActions): HTMLElemen interface ReleaseActions { reload: () => Promise; notify: (text: string, tone?: "fault") => void; - /** One click on grab — a plain grab, or waive-then-grab on a waived row. */ + /** One click on grab — a plain grab, or waive-then-grab where the rule + * that failed has an override (§9.3). */ grab: ( release: MovieRelease, bucket: "eligible" | "waived" | "rejected", @@ -2155,10 +2156,16 @@ function paintBuckets(dom: BucketsDom, releases: MovieRelease[], actions: Releas dom.eligible.rows.append(releaseRow(release, "eligible", scoreStop, actions)); } } else { - // §9.3: over-strict filters must be visible, not silently absent + // §9.3: over-strict filters must be visible, not silently absent — and + // where a rule can be waived, the count says so rather than leaving the + // way out folded inside a collapsed bucket. + const waivable = releases.filter(overridable).length; const none = document.createElement("li"); none.className = "rel rel-none readout dim"; - none.textContent = "none — every candidate was waived or rejected by policy"; + none.textContent = + waivable > 0 + ? `none — every candidate was waived or rejected by policy; ${waivable} can be waived` + : "none — every candidate was waived or rejected by policy"; dom.eligible.rows.append(none); } for (const name of ["waived", "rejected"] as const) { @@ -2295,8 +2302,14 @@ function releaseRow( ); } if (bucket !== "eligible") { + // A rejected row always names its rule; a waived one cannot — the + // `releases` CHECK allows `rejected_rule` only on a rejection. Say + // `waived` plainly rather than calling a classified row unclassified. + const verdict = release.rejected_rule + ? `${bucket} · ${ruleLabel(release.rejected_rule)}` + : bucket; line.append( - chip(`${bucket} · ${ruleLabel(release.rejected_rule)}`, (span) => { + chip(verdict, (span) => { span.dataset.verdict = bucket; }), ); @@ -2313,11 +2326,14 @@ function releaseRow( note.setAttribute("role", "status"); note.hidden = true; - if (bucket !== "rejected") { + // A rejected row is normally inert, but §9.3's one click is exactly for + // the rule the operator disagrees with: where an override exists, the row + // offers it and the grab that follows stays a waiver. + const writesOverride = bucket !== "eligible" && overridable(release); + if (bucket !== "rejected" || writesOverride) { const grab = document.createElement("button"); grab.type = "button"; grab.className = "control rel-grab"; - const writesOverride = bucket === "waived" && waiverOverride(release.rejected_rule) !== null; grab.textContent = writesOverride ? "waive + grab" : "grab"; grab.addEventListener("click", () => { grab.disabled = true; @@ -2829,7 +2845,7 @@ function tvReleasesMain(): TvReleasesView { if (!current) { return Promise.resolve({ kind: "error", detail: "deck closed", overrideWritten: false }); } - if (bucket === "waived") { + if (bucket !== "eligible") { return waiveAndGrabTv(current.seriesId, current.target, release.id, release.rejected_rule); } return current.target diff --git a/web/src/releases.ts b/web/src/releases.ts index 527bc48..c066abb 100644 --- a/web/src/releases.ts +++ b/web/src/releases.ts @@ -257,6 +257,11 @@ export async function grabRelease(movieId: number, releaseId: number): Promise | null { switch (rule) { @@ -264,11 +269,18 @@ export function waiverOverride(rule: string | null): Record | n return { allow_english_audio: true }; case "resolution": return { only_4k: false }; + case "size": + return { allow_below_floor: true }; default: return null; } } +/** Whether one click on this row has an override to write (§9.3). */ +export function overridable(release: { rejected_rule: string | null }): boolean { + return waiverOverride(release.rejected_rule) !== null; +} + export type WaiveOutcome = | { kind: "done"; overrideWritten: boolean } | { kind: "error"; detail: string; overrideWritten: boolean }; From 917aa4fa7646aad1071e2236851aee05a8cb8281 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 22:45:55 +0100 Subject: [PATCH 03/27] feat: scale size bands by episode runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #209 per §5.5 as amended by #208: a band's floor and target are rates against a 45-minute reference runtime, scaled by the series' minutes per episode. A missing or zero runtime applies the bands unscaled, and movies are never scaled. The runtime is stored on the series row (new migration), filled on add and by the metadata refresh, which never blanks a known value against TMDB's frequently-empty episode_run_time. Composes with #210: allow_below_floor waives against the scaled floor. Co-Authored-By: Claude Fable 5 --- ...38ad03e58f95ec9636a79ef0c06b24635a67.json} | 18 +- ...413135a87ccbd8ec3f73e21ef9c73e8ba80c.json} | 6 +- ...70a43cde5f8b79ea02483047626fc11bc377.json} | 16 +- ...b96ce79164dc551f388180c554034dec007b.json} | 18 +- ...ea7777979f43b63d4e7c00fa8358431e6b3d9.json | 26 +++ ...2aeed6f3feaf13492244fdc9e0eab2af579b.json} | 16 +- ...037693173cf966e2d91f8f7f84d7f0e579b16.json | 12 + ...e0e9113ce6c60498772b1fc056ffa037d1166.json | 26 +++ crates/arr-api/src/movies.rs | 20 +- crates/arr-api/src/reclassify.rs | 6 + crates/arr-api/src/search.rs | 10 +- crates/arr-api/src/series.rs | 27 ++- crates/arr-core/src/policy.rs | 75 +++++- crates/arr-core/src/score.rs | 215 ++++++++++++++++-- crates/arr-daemon/src/grab.rs | 31 +++ crates/arr-daemon/src/import.rs | 14 ++ crates/arr-daemon/src/series_refresh.rs | 68 +++++- .../arr-db/migrations/0024_series_runtime.sql | 6 + crates/arr-db/src/policy.rs | 3 +- crates/arr-meta/src/model.rs | 10 + 20 files changed, 573 insertions(+), 50 deletions(-) rename .sqlx/{query-7963cce11a588f8b8697e596da24802e4a5556c388b84fe7a6102444a887c2cc.json => query-1fa97a49b40502e95b7618ac8fed38ad03e58f95ec9636a79ef0c06b24635a67.json} (80%) rename .sqlx/{query-9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331.json => query-3cc5524f3ac253e86de8618970a5413135a87ccbd8ec3f73e21ef9c73e8ba80c.json} (52%) rename .sqlx/{query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json => query-4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377.json} (90%) rename .sqlx/{query-7f94d0bad8dd346c5e930c2606c8643e732850b0a1ec331e216c3aedf6987ad4.json => query-81361374c5c84d4c12ac33fc103ab96ce79164dc551f388180c554034dec007b.json} (80%) create mode 100644 .sqlx/query-b23e7c444df0fa99f891a59f834ea7777979f43b63d4e7c00fa8358431e6b3d9.json rename .sqlx/{query-6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644.json => query-ee34b26d3145587c48d8777542082aeed6f3feaf13492244fdc9e0eab2af579b.json} (88%) create mode 100644 .sqlx/query-f6b954dcaaeb797161952cd4697037693173cf966e2d91f8f7f84d7f0e579b16.json create mode 100644 .sqlx/query-fffd6c2609b3389aa94d41cdde6e0e9113ce6c60498772b1fc056ffa037d1166.json create mode 100644 crates/arr-db/migrations/0024_series_runtime.sql diff --git a/.sqlx/query-7963cce11a588f8b8697e596da24802e4a5556c388b84fe7a6102444a887c2cc.json b/.sqlx/query-1fa97a49b40502e95b7618ac8fed38ad03e58f95ec9636a79ef0c06b24635a67.json similarity index 80% rename from .sqlx/query-7963cce11a588f8b8697e596da24802e4a5556c388b84fe7a6102444a887c2cc.json rename to .sqlx/query-1fa97a49b40502e95b7618ac8fed38ad03e58f95ec9636a79ef0c06b24635a67.json index 7559915..d34c95a 100644 --- a/.sqlx/query-7963cce11a588f8b8697e596da24802e4a5556c388b84fe7a6102444a887c2cc.json +++ b/.sqlx/query-1fa97a49b40502e95b7618ac8fed38ad03e58f95ec9636a79ef0c06b24635a67.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN seasons se ON se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'season'\n ORDER BY g.id\n ", + "query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n s.runtime_minutes,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN seasons se ON se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'season'\n ORDER BY g.id\n ", "describe": { "columns": [ { @@ -103,8 +103,19 @@ } }, { - "name": "release_name!: String", + "name": "runtime_minutes", "ordinal": 9, + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "runtime_minutes" + } + } + }, + { + "name": "release_name!: String", + "ordinal": 10, "type_info": "Text", "origin": { "Table": { @@ -127,8 +138,9 @@ false, true, true, + true, false ] }, - "hash": "7963cce11a588f8b8697e596da24802e4a5556c388b84fe7a6102444a887c2cc" + "hash": "1fa97a49b40502e95b7618ac8fed38ad03e58f95ec9636a79ef0c06b24635a67" } diff --git a/.sqlx/query-9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331.json b/.sqlx/query-3cc5524f3ac253e86de8618970a5413135a87ccbd8ec3f73e21ef9c73e8ba80c.json similarity index 52% rename from .sqlx/query-9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331.json rename to .sqlx/query-3cc5524f3ac253e86de8618970a5413135a87ccbd8ec3f73e21ef9c73e8ba80c.json index b3fa0d0..413eb80 100644 --- a/.sqlx/query-9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331.json +++ b/.sqlx/query-3cc5524f3ac253e86de8618970a5413135a87ccbd8ec3f73e21ef9c73e8ba80c.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average, runtime_minutes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "describe": { "columns": [], "parameters": { - "Right": 13 + "Right": 14 }, "nullable": [] }, - "hash": "9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331" + "hash": "3cc5524f3ac253e86de8618970a5413135a87ccbd8ec3f73e21ef9c73e8ba80c" } diff --git a/.sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json b/.sqlx/query-4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377.json similarity index 90% rename from .sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json rename to .sqlx/query-4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377.json index a440463..54d5718 100644 --- a/.sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json +++ b/.sqlx/query-4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average\n FROM series WHERE id = ?", + "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average, runtime_minutes\n FROM series WHERE id = ?", "describe": { "columns": [ { @@ -145,6 +145,17 @@ "name": "vote_average" } } + }, + { + "name": "runtime_minutes", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "runtime_minutes" + } + } } ], "parameters": { @@ -163,8 +174,9 @@ true, true, true, + true, true ] }, - "hash": "e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10" + "hash": "4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377" } diff --git a/.sqlx/query-7f94d0bad8dd346c5e930c2606c8643e732850b0a1ec331e216c3aedf6987ad4.json b/.sqlx/query-81361374c5c84d4c12ac33fc103ab96ce79164dc551f388180c554034dec007b.json similarity index 80% rename from .sqlx/query-7f94d0bad8dd346c5e930c2606c8643e732850b0a1ec331e216c3aedf6987ad4.json rename to .sqlx/query-81361374c5c84d4c12ac33fc103ab96ce79164dc551f388180c554034dec007b.json index 70f41db..bb05272 100644 --- a/.sqlx/query-7f94d0bad8dd346c5e930c2606c8643e732850b0a1ec331e216c3aedf6987ad4.json +++ b/.sqlx/query-81361374c5c84d4c12ac33fc103ab96ce79164dc551f388180c554034dec007b.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n e.id AS \"episode_id!: i64\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN episodes e ON 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 JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'episode'\n ORDER BY g.id\n ", + "query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n e.id AS \"episode_id!: i64\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n s.runtime_minutes,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN episodes e ON 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 JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'episode'\n ORDER BY g.id\n ", "describe": { "columns": [ { @@ -114,8 +114,19 @@ } }, { - "name": "release_name!: String", + "name": "runtime_minutes", "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "runtime_minutes" + } + } + }, + { + "name": "release_name!: String", + "ordinal": 11, "type_info": "Text", "origin": { "Table": { @@ -139,8 +150,9 @@ false, true, true, + true, false ] }, - "hash": "7f94d0bad8dd346c5e930c2606c8643e732850b0a1ec331e216c3aedf6987ad4" + "hash": "81361374c5c84d4c12ac33fc103ab96ce79164dc551f388180c554034dec007b" } diff --git a/.sqlx/query-b23e7c444df0fa99f891a59f834ea7777979f43b63d4e7c00fa8358431e6b3d9.json b/.sqlx/query-b23e7c444df0fa99f891a59f834ea7777979f43b63d4e7c00fa8358431e6b3d9.json new file mode 100644 index 0000000..2f69b71 --- /dev/null +++ b/.sqlx/query-b23e7c444df0fa99f891a59f834ea7777979f43b63d4e7c00fa8358431e6b3d9.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT runtime_minutes FROM series WHERE id = ?", + "describe": { + "columns": [ + { + "name": "runtime_minutes", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "runtime_minutes" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true + ] + }, + "hash": "b23e7c444df0fa99f891a59f834ea7777979f43b63d4e7c00fa8358431e6b3d9" +} diff --git a/.sqlx/query-6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644.json b/.sqlx/query-ee34b26d3145587c48d8777542082aeed6f3feaf13492244fdc9e0eab2af579b.json similarity index 88% rename from .sqlx/query-6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644.json rename to .sqlx/query-ee34b26d3145587c48d8777542082aeed6f3feaf13492244fdc9e0eab2af579b.json index 509e530..910129e 100644 --- a/.sqlx/query-6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644.json +++ b/.sqlx/query-ee34b26d3145587c48d8777542082aeed6f3feaf13492244fdc9e0eab2af579b.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id", + "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average, runtime_minutes\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id", "describe": { "columns": [ { @@ -145,6 +145,17 @@ "name": "vote_average" } } + }, + { + "name": "runtime_minutes", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "runtime_minutes" + } + } } ], "parameters": { @@ -163,8 +174,9 @@ true, true, true, + true, true ] }, - "hash": "6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644" + "hash": "ee34b26d3145587c48d8777542082aeed6f3feaf13492244fdc9e0eab2af579b" } diff --git a/.sqlx/query-f6b954dcaaeb797161952cd4697037693173cf966e2d91f8f7f84d7f0e579b16.json b/.sqlx/query-f6b954dcaaeb797161952cd4697037693173cf966e2d91f8f7f84d7f0e579b16.json new file mode 100644 index 0000000..d65ea71 --- /dev/null +++ b/.sqlx/query-f6b954dcaaeb797161952cd4697037693173cf966e2d91f8f7f84d7f0e579b16.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE series SET runtime_minutes = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "f6b954dcaaeb797161952cd4697037693173cf966e2d91f8f7f84d7f0e579b16" +} diff --git a/.sqlx/query-fffd6c2609b3389aa94d41cdde6e0e9113ce6c60498772b1fc056ffa037d1166.json b/.sqlx/query-fffd6c2609b3389aa94d41cdde6e0e9113ce6c60498772b1fc056ffa037d1166.json new file mode 100644 index 0000000..55302ed --- /dev/null +++ b/.sqlx/query-fffd6c2609b3389aa94d41cdde6e0e9113ce6c60498772b1fc056ffa037d1166.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT s.runtime_minutes FROM series s\n WHERE s.id = (SELECT s2.series_id FROM episodes e\n JOIN seasons s2 ON s2.id = e.season_id\n WHERE e.id = ?)", + "describe": { + "columns": [ + { + "name": "runtime_minutes", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "runtime_minutes" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true + ] + }, + "hash": "fffd6c2609b3389aa94d41cdde6e0e9113ce6c60498772b1fc056ffa037d1166" +} diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 04e8184..d7d13ac 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -610,7 +610,7 @@ pub async fn releases( .map_err(|error| ApiError::Database(error.to_string()))? .ok_or(ApiError::NotFound)? .policy; - rescore(&mut releases, &policy, None)?; + rescore(&mut releases, &policy, None, 0)?; Ok(Json(releases)) } @@ -624,6 +624,10 @@ pub async fn releases( /// many it covers. `None` is the movie decks: one release is one film, /// whatever episode-shaped noise its name parses to. /// +/// `runtime_minutes` scales the bands by the series' minutes per episode +/// (§5.5). Movies are never scaled, so the movie decks pass zero — the same +/// zero a series with no known runtime gets. +/// /// Score magnitudes stay far below `f64`'s 52-bit mantissa (they are sums of /// policy weights in the thousands), so the `i64` -> `f64` cast into the /// column's storage type is exact. @@ -632,6 +636,7 @@ pub(crate) fn rescore( releases: &mut [Release], policy: &Policy, season_lengths: Option<&BTreeMap>, + runtime_minutes: u32, ) -> Result<(), ApiError> { let mut totals = Vec::with_capacity(releases.len()); for release in releases.iter() { @@ -642,7 +647,17 @@ pub(crate) fn rescore( let episodes = season_lengths.map_or(1, |lengths| { claimed_episode_count(parsed.episode.as_ref(), lengths) }); - totals.push(score(policy, Candidate::PreGrab(&parsed), size, seeders, episodes).total); + totals.push( + score( + policy, + Candidate::PreGrab(&parsed), + size, + seeders, + episodes, + runtime_minutes, + ) + .total, + ); } let mut indices: Vec = (0..releases.len()).collect(); indices.sort_by_key(|&i| (bucket(releases[i].verdict.as_deref()), -totals[i])); @@ -1559,6 +1574,7 @@ mod tests { size_bytes, seeders, 1, + 0, ) .total; #[allow(clippy::cast_precision_loss)] diff --git a/crates/arr-api/src/reclassify.rs b/crates/arr-api/src/reclassify.rs index 1a8fb28..70fd9a8 100644 --- a/crates/arr-api/src/reclassify.rs +++ b/crates/arr-api/src/reclassify.rs @@ -77,6 +77,7 @@ pub(crate) async fn movie(state: &AppState, movie_id: i64) -> Result<(), ApiErro &loaded.overrides, &language, None, + 0, ) .await } @@ -127,6 +128,7 @@ pub(crate) async fn series(state: &AppState, series_id: i64) -> Result<(), ApiEr // A size band describes one episode (§5.5), so a pack's verdict needs the // same divisor the deck scores it with. let lengths = crate::series::season_lengths(state, series_id).await?; + let runtime = crate::series::series_runtime(state, series_id).await?; apply( state, &releases, @@ -134,10 +136,12 @@ pub(crate) async fn series(state: &AppState, series_id: i64) -> Result<(), ApiEr &loaded.overrides, &language, Some(&lengths), + runtime, ) .await } +#[allow(clippy::too_many_arguments)] async fn apply( state: &AppState, releases: &[Stored], @@ -145,6 +149,7 @@ async fn apply( overrides: &TitleOverrides, original_language: &Language, season_lengths: Option<&BTreeMap>, + runtime_minutes: u32, ) -> Result<(), ApiError> { for release in releases { if release.rejected_rule.as_deref() == Some(blacklist::RULE) { @@ -166,6 +171,7 @@ async fn apply( Candidate::PreGrab(&parsed), size, episodes, + runtime_minutes, ); // `releases` allows a rule name only on a rejected row // (`CHECK ((verdict = 'rejected') = (rejected_rule IS NOT NULL))`), diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 2e73d73..2fc2c6d 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -490,6 +490,7 @@ async fn movie_releases( &original_language, &blacklist, None, + 0, )?); } } @@ -525,6 +526,7 @@ async fn episode_releases( .await? .ok_or(ApiError::EpisodeNotFound)?; let season_lengths = crate::series::season_lengths(state, episode.series_id).await?; + let runtime_minutes = crate::series::series_runtime(state, episode.series_id).await?; let loaded = database .episode_policy(episode_id) .await @@ -568,6 +570,7 @@ async fn episode_releases( &original_language, &blacklist, Some(&season_lengths), + runtime_minutes, )?); } } @@ -709,6 +712,7 @@ fn classify( original_language: &Language, blacklist: &Blacklist, season_lengths: Option<&BTreeMap>, + runtime_minutes: u32, ) -> Result { let parsed = arr_parse::parse(&release.name); // A size band describes one episode (`DESIGN.md` §5.5): a pack's size is @@ -724,6 +728,7 @@ fn classify( Candidate::PreGrab(&parsed), release.size, episodes, + runtime_minutes, ); let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) { ("rejected", Some(blacklist::RULE.to_owned())) @@ -736,6 +741,7 @@ fn classify( release.size.unwrap_or_default(), release.seeders.unwrap_or_default(), episodes, + runtime_minutes, ); // A release with no size has nothing to say about its size band, so that // term is dropped rather than scored as if it were at the floor. Every @@ -1465,6 +1471,7 @@ mod tests { &Language::Other("en".into()), &Blacklist::default(), None, + 0, ) .expect("classified release") }; @@ -1558,7 +1565,7 @@ mod tests { imdb_id: None, }; let parsed = arr_parse::parse(&release.name); - let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8, 1); + let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8, 1, 0); let classified = classify( release, @@ -1567,6 +1574,7 @@ mod tests { &Language::Other("en".into()), &Blacklist::default(), None, + 0, ) .expect("classified release"); diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 6c85afa..a8c48f3 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -468,11 +468,15 @@ pub async fn create( let poster_path = tmdb_series.as_ref().and_then(|s| s.poster_path.clone()); let backdrop_path = tmdb_series.as_ref().and_then(|s| s.backdrop_path.clone()); let vote_average = tmdb_series.as_ref().and_then(|s| s.vote_average); + let runtime_minutes = tmdb_series + .as_ref() + .and_then(|s| s.episode_runtime) + .map(i64::from); let result = sqlx::query!( - "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average, runtime_minutes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", input.tmdb_id, tvdb_id, title, input.year, input.original_language, input.root_id, input.auto_track, input.upstream_ended, input.blocked, overrides, - poster_path, backdrop_path, vote_average, + poster_path, backdrop_path, vote_average, runtime_minutes, ) .execute(pool(&state)?) .await?; @@ -1379,7 +1383,8 @@ pub async fn episode_releases( .ok_or(ApiError::EpisodeNotFound)? .policy; let lengths = season_lengths(&state, episode.series_id).await?; - rescore(&mut releases, &policy, Some(&lengths))?; + let runtime = series_runtime(&state, episode.series_id).await?; + rescore(&mut releases, &policy, Some(&lengths), runtime)?; Ok(Json(releases)) } @@ -1418,6 +1423,19 @@ pub async fn grab_episode( /// per-episode size normalisation (`DESIGN.md` §5.5). A season with no /// revealed episodes counts zero, which `claimed_episode_count` treats as /// unknown. +/// The series' minutes per episode (`DESIGN.md` §5.5): the scale factor +/// behind runtime-scaled size bands. Zero when unknown, which applies the +/// bands unscaled. +pub(crate) async fn series_runtime(state: &AppState, series_id: i64) -> Result { + let minutes = sqlx::query_scalar!("SELECT runtime_minutes FROM series WHERE id = ?", series_id) + .fetch_optional(pool(state)?) + .await? + .flatten(); + Ok(minutes + .and_then(|minutes| u32::try_from(minutes).ok()) + .unwrap_or(0)) +} + pub(crate) async fn season_lengths( state: &AppState, series_id: i64, @@ -1520,7 +1538,8 @@ pub async fn season_releases( .ok_or(ApiError::SeasonNotFound)? .policy; let lengths = season_lengths(&state, series_id).await?; - rescore(&mut releases, &policy, Some(&lengths))?; + let runtime = series_runtime(&state, series_id).await?; + rescore(&mut releases, &policy, Some(&lengths), runtime)?; Ok(Json(releases)) } diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs index 033b396..ea828f8 100644 --- a/crates/arr-core/src/policy.rs +++ b/crates/arr-core/src/policy.rs @@ -68,6 +68,11 @@ pub struct EvaluationContext<'a> { /// describes one episode, so the size rule divides by this. One for a /// movie or an unknown count; zero is treated as one. pub episode_count: u32, + /// The series' minutes per episode (`DESIGN.md` §5.5) — a size band is a + /// rate against 45 minutes, so the size rule scales its floor by + /// `runtime / 45`. Zero is a missing runtime and applies the band + /// unscaled; movies are never scaled and pass zero. + pub runtime_minutes: u32, } /// A rule's identity when no violation exists to carry concrete evidence. @@ -112,6 +117,7 @@ pub fn evaluate( candidate: Candidate<'_>, size_bytes: Option, episode_count: u32, + runtime_minutes: u32, ) -> Evaluation { let context = EvaluationContext { policy, @@ -120,6 +126,7 @@ pub fn evaluate( candidate, size_bytes, episode_count, + runtime_minutes, }; let rules: [&dyn PolicyRule; 6] = [ &ResolutionRule, @@ -239,8 +246,13 @@ impl PolicyRule for SizeRule { else { return RuleEvaluation::Unknown(RuleKind::Size); }; - match crate::score::is_below_floor(context.policy, resolution, size, context.episode_count) - { + match crate::score::is_below_floor( + context.policy, + resolution, + size, + context.episode_count, + context.runtime_minutes, + ) { None => RuleEvaluation::Unknown(RuleKind::Size), Some(true) if context.overrides.allow_below_floor => { RuleEvaluation::SoftFail(Rule::Size) @@ -531,7 +543,7 @@ mod tests { } fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict { - evaluate(policy, overrides, &en(), candidate, None, 1).verdict + evaluate(policy, overrides, &en(), candidate, None, 1, 0).verdict } fn verdict_for( @@ -546,6 +558,7 @@ mod tests { candidate, None, 1, + 0, ) .verdict } @@ -561,6 +574,7 @@ mod tests { Candidate::PreGrab(&claims), None, 1, + 0, ); assert_eq!(report.verdict, Verdict::Eligible); @@ -690,6 +704,7 @@ mod tests { Candidate::PreGrab(&claims), Some(1 << 30), 1, + 0, ); assert_eq!(evaluation.verdict, Verdict::Rejected(Rule::Size)); @@ -714,6 +729,7 @@ mod tests { Candidate::PreGrab(&claims), Some(1 << 30), 1, + 0, ) .verdict, Verdict::Waived(Rule::Size) @@ -729,6 +745,7 @@ mod tests { Candidate::PostDownload(&media), Some(1 << 30), 1, + 0, ) .verdict, Verdict::Waived(Rule::Size) @@ -751,6 +768,7 @@ mod tests { Candidate::PreGrab(&claims), Some(4 << 30), 1, + 0, ) .verdict, Verdict::Eligible @@ -775,6 +793,7 @@ mod tests { Candidate::PreGrab(&claims), size, 10, + 0, ) .verdict, Verdict::Rejected(Rule::Size) @@ -790,12 +809,48 @@ mod tests { Candidate::PreGrab(&claims), size, 10, + 0, ) .verdict, Verdict::Waived(Rule::Size) ); } + /// §5.5 scaling composes with the #210 waiver: the runtime moves the + /// floor, and `allow_below_floor` still only softens what remains below + /// it — it never bypasses the scaled comparison. + #[test] + fn allow_below_floor_waives_against_the_scaled_floor() { + let policy = banded_policy(); + let waive = TitleOverrides { + allow_below_floor: true, + ..TitleOverrides::default() + }; + let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl)); + // The 2 GiB floor at 22 minutes is ~0.98 GiB. 1.5 GiB clears it, so + // the override has nothing to waive; 0.5 GiB is below even the + // scaled floor and stays a waiver rather than eligible. + let at = |size_bytes, overrides| { + evaluate( + &policy, + overrides, + &en(), + Candidate::PreGrab(&claims), + Some(size_bytes), + 1, + 22, + ) + .verdict + }; + + assert_eq!(at(3 << 29, &waive), Verdict::Eligible); + assert_eq!(at(1 << 29, &waive), Verdict::Waived(Rule::Size)); + assert_eq!( + at(1 << 29, &TitleOverrides::default()), + Verdict::Rejected(Rule::Size) + ); + } + #[test] fn every_unsafe_source_hard_fails_in_both_phases() { let policy = policy(); @@ -876,6 +931,7 @@ mod tests { Candidate::PostDownload(&media), None, 1, + 0, ); assert_eq!(report.verdict, Verdict::Eligible); @@ -933,6 +989,7 @@ mod tests { Candidate::PostDownload(&media), None, 1, + 0, ); let expected = if rejected { Verdict::Rejected(Rule::DolbyVisionProfile(profile)) @@ -953,6 +1010,7 @@ mod tests { Candidate::PreGrab(&claims), None, 1, + 0, ); assert_eq!(report.verdict, Verdict::Eligible); @@ -986,6 +1044,7 @@ mod tests { candidate: Candidate::PreGrab(&claims), size_bytes: None, episode_count: 1, + runtime_minutes: 0, }; let soft = FixedRule { evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())), @@ -1015,6 +1074,7 @@ mod tests { candidate: Candidate::PreGrab(&claims), size_bytes: None, episode_count: 1, + runtime_minutes: 0, }; let first = FixedRule { evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())), @@ -1151,7 +1211,8 @@ mod tests { &en(), Candidate::PreGrab(&claims), None, - 1 + 1, + 0, ) .verdict, Verdict::Eligible @@ -1165,7 +1226,8 @@ mod tests { &en(), Candidate::PostDownload(&media), None, - 1 + 1, + 0, ) .verdict, Verdict::Waived(Rule::RequiredAudio) @@ -1240,6 +1302,7 @@ mod tests { Candidate::PostDownload(&media), None, 1, + 0, ); assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified)); @@ -1252,6 +1315,7 @@ mod tests { Candidate::PostDownload(&media), None, 1, + 0, ); assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified)); } @@ -1266,6 +1330,7 @@ mod tests { Candidate::PreGrab(&claims), None, 1, + 0, ); assert_eq!(report.verdict, Verdict::Eligible); assert_eq!( diff --git a/crates/arr-core/src/score.rs b/crates/arr-core/src/score.rs index 3cfa5b4..d569358 100644 --- a/crates/arr-core/src/score.rs +++ b/crates/arr-core/src/score.rs @@ -27,6 +27,11 @@ use crate::{policy::Candidate, Policy, Release, Resolution, SizeBand, Source}; const BYTES_PER_GIB: i64 = 1 << 30; +/// The reference runtime (`DESIGN.md` §5.5): a band's shipped values are a +/// rate against a 45-minute episode, and both floor and target scale by +/// `runtime / 45` before a per-episode size is compared to them. +pub const REFERENCE_RUNTIME_MINUTES: u32 = 45; + /// How much each scoring term is worth. Policy data, not constants in the /// code, for the same reason the size bands are. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -94,6 +99,13 @@ pub struct Score { /// band is compared against. The caller supplies it — `arr-core` has no IO, /// and a `Season` claim names a season, not a length. A movie, and any release /// whose count is unknown, is one episode; zero is treated as one. +/// +/// A band also describes a rate against [`REFERENCE_RUNTIME_MINUTES`], so +/// `runtime_minutes` — the series' minutes per episode, caller-supplied the +/// same way — scales its floor and target before the comparison. Zero is a +/// missing runtime and applies the band unscaled, exactly the pre-scaling +/// behaviour. Movies are never scaled: their bands are already tuned against +/// feature length, so a movie caller passes zero. #[must_use] pub fn score( policy: &Policy, @@ -101,13 +113,16 @@ pub fn score( size_bytes: u64, seeders: u32, episode_count: u32, + runtime_minutes: u32, ) -> Score { let weights = &policy.score_weights; let claimed = candidate.resolution(); let per_episode = per_episode_size(size_bytes, episode_count); let size = claimed .and_then(|resolution| policy.size_bands.get(&resolution)) - .map_or(0, |band| size_points(band, weights, per_episode)); + .map_or(0, |band| { + size_points(&scaled_band(band, runtime_minutes), weights, per_episode) + }); let source = candidate .source() .map_or(0, |source| source_points(policy, source)); @@ -130,13 +145,19 @@ pub fn score( /// /// Pre-grab, the name is all there is (`DESIGN.md` §5.6). #[must_use] -pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) -> Score { +pub fn score_release( + policy: &Policy, + release: &Release, + episode_count: u32, + runtime_minutes: u32, +) -> Score { score( policy, Candidate::PreGrab(&release.parsed), release.size, release.seeders, episode_count, + runtime_minutes, ) } @@ -146,6 +167,9 @@ pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) -> /// (`DESIGN.md` §5.5): comparing a pack's total against an episode-sized floor /// would let every pack through untested. Zero `episode_count` is one episode. /// +/// The floor is also scaled by `runtime_minutes / 45` the way [`score`] +/// scales it: zero runtime means unscaled, and a movie caller passes zero. +/// /// `None` when the policy carries no band for that resolution: no band is no /// opinion, not a rejection. #[must_use] @@ -154,11 +178,11 @@ pub fn is_below_floor( resolution: Resolution, size_bytes: u64, episode_count: u32, + runtime_minutes: u32, ) -> Option { - policy - .size_bands - .get(&resolution) - .map(|band| per_episode_size(size_bytes, episode_count) < band.floor_bytes) + policy.size_bands.get(&resolution).map(|band| { + per_episode_size(size_bytes, episode_count) < scaled_band(band, runtime_minutes).floor_bytes + }) } /// How many episodes a release's size covers (`DESIGN.md` §5.5): the divisor @@ -195,6 +219,26 @@ fn per_episode_size(size_bytes: u64, episode_count: u32) -> u64 { size_bytes / u64::from(episode_count.max(1)) } +/// A band read at a runtime (`DESIGN.md` §5.5): floor and target scale by +/// `runtime / 45`, the penalty rate stays per gibibyte over. Zero runtime is +/// the reference runtime — the band applies unscaled. +fn scaled_band(band: &SizeBand, runtime_minutes: u32) -> SizeBand { + if runtime_minutes == 0 || runtime_minutes == REFERENCE_RUNTIME_MINUTES { + return *band; + } + SizeBand { + floor_bytes: scale_by_runtime(band.floor_bytes, runtime_minutes), + target_bytes: scale_by_runtime(band.target_bytes, runtime_minutes), + penalty_points_per_gib_over: band.penalty_points_per_gib_over, + } +} + +fn scale_by_runtime(bytes: u64, runtime_minutes: u32) -> u64 { + let scaled = + u128::from(bytes) * u128::from(runtime_minutes) / u128::from(REFERENCE_RUNTIME_MINUTES); + u64::try_from(scaled).unwrap_or(u64::MAX) +} + /// The size term: a ramp from the floor up to the target, then a penalty that /// grows with every gigabyte above it. fn size_points(band: &SizeBand, weights: &ScoreWeights, size_bytes: u64) -> i64 { @@ -329,6 +373,7 @@ mod tests { size_bytes, seeders, 1, + 0, ) } @@ -350,6 +395,7 @@ mod tests { size_bytes, seeders, 1, + 0, ) } @@ -358,6 +404,10 @@ mod tests { } fn size_rule_for(size_bytes: u64, episode_count: u32) -> RuleEvaluation { + size_rule_at(size_bytes, episode_count, 0) + } + + fn size_rule_at(size_bytes: u64, episode_count: u32, runtime_minutes: u32) -> RuleEvaluation { let policy = policy(); let overrides = TitleOverrides::default(); let language = Language::Other("en".to_owned()); @@ -369,6 +419,7 @@ mod tests { candidate: Candidate::PreGrab(&claims), size_bytes: Some(size_bytes), episode_count, + runtime_minutes, }) } @@ -400,6 +451,7 @@ mod tests { Candidate::PreGrab(&claims), Some(gib(60)), 1, + 0, ); // A bad score, but a score: nothing filters it out, so a selection @@ -466,11 +518,11 @@ mod tests { assert_eq!(size_rule(gib(3)), RuleEvaluation::HardFail(Rule::Size)); assert!(matches!(size_rule(gib(9)), RuleEvaluation::Pass(_))); assert_eq!( - is_below_floor(&policy(), Resolution::R2160p, gib(3), 1), + is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0), Some(true) ); assert_eq!( - is_below_floor(&policy(), Resolution::R720p, gib(3), 1), + is_below_floor(&policy(), Resolution::R720p, gib(3), 1, 0), None ); } @@ -481,7 +533,7 @@ mod tests { source: Some(ClaimedSource::WebDl), ..NameClaims::default() }; - let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1); + let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1, 0); assert_eq!(score.size, 0); assert_eq!(score.total, score.source + score.seeders); @@ -499,8 +551,8 @@ mod tests { }, ); let claims = claims(ClaimedSource::WebDl); - let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1); - let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1); + let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1, 0); + let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1, 0); assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target)); assert_eq!(under.size, 0); @@ -548,7 +600,7 @@ mod tests { resolution: Some(resolution), ..NameClaims::default() }; - score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1).resolution + score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1, 0).resolution }; assert_eq!(at(ClaimedResolution::P2160), 2 * step); @@ -565,7 +617,7 @@ mod tests { source: Some(ClaimedSource::WebDl), ..NameClaims::default() }; - let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1); + let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1, 0); assert_eq!(unranked.resolution, 0); assert_eq!(unclaimed.resolution, 0); @@ -617,7 +669,7 @@ mod tests { fn a_pack_scores_the_same_size_term_as_one_episode_of_its_per_episode_size() { let episode = scored(ClaimedSource::WebDl, gib(22), 10); let claims = claims(ClaimedSource::WebDl); - let pack = score(&policy(), Candidate::PreGrab(&claims), gib(220), 10, 10); + let pack = score(&policy(), Candidate::PreGrab(&claims), gib(220), 10, 10, 0); assert_eq!(pack.size, episode.size); assert_eq!(pack.total, episode.total); @@ -628,7 +680,7 @@ mod tests { // 30 GiB across ten episodes is 3 GiB each, under the 8 GiB 4K floor // — a pack of mud-quality encodes fails as plainly as one of them. assert_eq!( - is_below_floor(&policy(), Resolution::R2160p, gib(30), 10), + is_below_floor(&policy(), Resolution::R2160p, gib(30), 10, 0), Some(true) ); assert_eq!( @@ -638,22 +690,141 @@ mod tests { // The same total over three episodes is 10 GiB each and passes. assert_eq!( - is_below_floor(&policy(), Resolution::R2160p, gib(30), 3), + is_below_floor(&policy(), Resolution::R2160p, gib(30), 3, 0), Some(false) ); assert!(matches!(size_rule_for(gib(30), 3), RuleEvaluation::Pass(_))); } + /// The corrected acceptance criterion from issue #209: a series whose + /// runtime is known and short is judged against a proportionally scaled + /// floor and target, at both 22 and 45 minutes. + #[test] + fn a_known_short_runtime_scales_the_floor_at_22_and_45_minutes() { + // 4K floor is 8 GiB per 45 minutes; at 22 minutes it is ~3.91 GiB. + // 5 GiB fails the unscaled floor and clears the 22-minute one. + assert_eq!( + is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 45), + Some(true) + ); + assert_eq!( + is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 22), + Some(false) + ); + // Genuinely thin stays rejected even scaled: 3 GiB < 3.91 GiB. + assert_eq!( + is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 22), + Some(true) + ); + // 45 minutes is the reference runtime: identical to no scaling. + assert_eq!( + is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 45), + is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 0) + ); + + // The size rule takes the same scaled floor. + assert_eq!( + size_rule_at(gib(5), 1, 0), + RuleEvaluation::HardFail(Rule::Size) + ); + assert_eq!( + size_rule_at(gib(5), 1, 45), + RuleEvaluation::HardFail(Rule::Size) + ); + assert!(matches!( + size_rule_at(gib(5), 1, 22), + RuleEvaluation::Pass(_) + )); + assert_eq!( + size_rule_at(gib(3), 1, 22), + RuleEvaluation::HardFail(Rule::Size) + ); + } + + #[test] + fn the_target_scales_with_runtime_so_equal_bitrates_score_equally() { + let claims = claims(ClaimedSource::WebDl); + let scored_at_runtime = |size, runtime| { + score(&policy(), Candidate::PreGrab(&claims), size, 10, 1, runtime).size + }; + + // At-target bitrate: 22 GiB per 45 minutes is 22 GiB × 22/45 at 22 + // minutes, and both sit at the top of the size term. + let at_target = i64::from(ScoreWeights::default().size_at_target); + assert_eq!(scored_at_runtime(gib(22), 45), at_target); + assert_eq!(scored_at_runtime(gib(22) * 22 / 45, 22), at_target); + + // A below-target bitrate lands on the same point of the ramp at any + // runtime, give or take integer rounding. + let half_way_45 = scored_at_runtime(gib(15), 45); + let half_way_22 = scored_at_runtime(gib(15) * 22 / 45, 22); + assert!((half_way_45 - half_way_22).abs() <= 1); + } + + /// The correction on issue #209: the Rick and Morty S09 packs are 0.19, + /// 0.24 and 0.32 GiB per 22-minute episode against a 1 GiB 1080p floor. + /// The scaled floor is ~0.489 GiB, they are genuinely low-bitrate, and + /// scaling must not let them through. + #[test] + fn the_rick_and_morty_s09_packs_stay_below_the_scaled_floor() { + let mut policy = policy(); + policy.size_bands.insert( + Resolution::R1080p, + SizeBand { + floor_bytes: gib(1), + target_bytes: gib(2), + penalty_points_per_gib_over: 60, + }, + ); + let episodes = 10; + for per_episode_gib in [19, 24, 32] { + let pack = per_episode_gib * GIB / 100 * u64::from(episodes); + assert_eq!( + is_below_floor(&policy, Resolution::R1080p, pack, episodes, 22), + Some(true) + ); + } + // Half a GiB per episode clears the scaled floor: the floor still + // discriminates rather than rejecting every 22-minute release. + assert_eq!( + is_below_floor(&policy, Resolution::R1080p, gib(5), episodes, 22), + Some(false) + ); + } + + /// A missing runtime is the reference runtime (`DESIGN.md` §5.5): zero + /// reproduces the pre-scaling score exactly, pinned to literals the same + /// way #180 pinned movie scoring. + #[test] + fn a_missing_runtime_reproduces_the_unscaled_score() { + let claims = claims(ClaimedSource::WebDl); + let missing = score(&policy(), Candidate::PreGrab(&claims), gib(30), 40, 1, 0); + assert_eq!( + missing, + Score { + total: 918, + size: 520, + source: 50, + seeders: 48, + resolution: 300, + } + ); + assert_eq!( + missing, + score(&policy(), Candidate::PreGrab(&claims), gib(30), 40, 1, 45) + ); + } + #[test] fn an_unknown_episode_count_falls_back_to_one_episode() { let single = scored(ClaimedSource::WebDl, gib(22), 10); let claims = claims(ClaimedSource::WebDl); - let zero = score(&policy(), Candidate::PreGrab(&claims), gib(22), 10, 0); + let zero = score(&policy(), Candidate::PreGrab(&claims), gib(22), 10, 0, 0); assert_eq!(zero, single); assert_eq!( - is_below_floor(&policy(), Resolution::R2160p, gib(3), 0), - is_below_floor(&policy(), Resolution::R2160p, gib(3), 1) + is_below_floor(&policy(), Resolution::R2160p, gib(3), 0, 0), + is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0) ); } @@ -733,18 +904,18 @@ mod tests { source: Some(ClaimedSource::WebDl), ..NameClaims::default() }; - score(&policy, Candidate::PreGrab(&claims), size, 20, episodes) + score(&policy, Candidate::PreGrab(&claims), size, 20, episodes, 0) }; let hd = scored(ClaimedResolution::P1080, hd_pack); let uhd = scored(ClaimedResolution::P2160, uhd_pack); // Neither pack trips the floor per episode, so the ranking decides. assert_eq!( - is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes), + is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes, 0), Some(false) ); assert_eq!( - is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes), + is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes, 0), Some(false) ); assert!(uhd.total > hd.total); diff --git a/crates/arr-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs index d462a77..b38d37d 100644 --- a/crates/arr-daemon/src/grab.rs +++ b/crates/arr-daemon/src/grab.rs @@ -1192,6 +1192,7 @@ pub(crate) async fn store_release( original_language: &Language, blacklist: &Blacklist, ) -> Result, GrabError> { + // A movie is one episode's worth and is never runtime-scaled (§5.5). let (release_id, eligible) = classify_and_store( database, release, @@ -1200,6 +1201,7 @@ pub(crate) async fn store_release( original_language, blacklist, 1, + 0, ) .await?; sqlx::query!( @@ -1241,6 +1243,11 @@ pub(crate) async fn store_episode_release( _ => BTreeMap::new(), }; let episode_count = claimed_episode_count(claim.as_ref(), &season_lengths); + // §5.5: the size bands scale by the series' minutes per episode. + let runtime_minutes = match episode_ids.first() { + Some(&episode_id) => series_runtime_of(database, episode_id).await?, + None => 0, + }; let (release_id, eligible) = classify_and_store( database, release, @@ -1249,6 +1256,7 @@ pub(crate) async fn store_episode_release( original_language, blacklist, episode_count, + runtime_minutes, ) .await?; for episode_id in episode_ids { @@ -1293,6 +1301,26 @@ async fn season_lengths_of( .collect()) } +/// The minutes-per-episode of the series one covered episode belongs to +/// (`DESIGN.md` §5.5): the scale factor for its size bands. Zero when the +/// series has no known runtime, which applies the bands unscaled. +async fn series_runtime_of(database: &Db, episode_id: i64) -> Result { + let minutes = sqlx::query_scalar!( + r#"SELECT s.runtime_minutes FROM series s + WHERE s.id = (SELECT s2.series_id FROM episodes e + JOIN seasons s2 ON s2.id = e.season_id + WHERE e.id = ?)"#, + episode_id + ) + .fetch_optional(database.pool()) + .await? + .flatten(); + Ok(minutes + .and_then(|minutes| u32::try_from(minutes).ok()) + .unwrap_or(0)) +} + +#[allow(clippy::too_many_arguments)] async fn classify_and_store( database: &Db, release: &SearchRelease, @@ -1301,6 +1329,7 @@ async fn classify_and_store( original_language: &Language, blacklist: &Blacklist, episode_count: u32, + runtime_minutes: u32, ) -> Result<(i64, Option), GrabError> { let parsed = arr_parse::parse(&release.name); let evaluation = evaluate( @@ -1310,6 +1339,7 @@ async fn classify_and_store( Candidate::PreGrab(&parsed), release.size, episode_count, + runtime_minutes, ); let scored = score( policy, @@ -1317,6 +1347,7 @@ async fn classify_and_store( release.size.unwrap_or_default(), release.seeders.unwrap_or_default(), episode_count, + runtime_minutes, ); // A release that did not say its size is not a tiny one: scoring it // against the band's floor would bury it. Same treatment as the manual diff --git a/crates/arr-daemon/src/import.rs b/crates/arr-daemon/src/import.rs index c26dcd0..80db79a 100644 --- a/crates/arr-daemon/src/import.rs +++ b/crates/arr-daemon/src/import.rs @@ -308,6 +308,7 @@ impl ImportAction { Candidate::PostDownload(&feature.media), Some(feature.size), 1, + 0, ); let waiver: Option = match evaluation.verdict { Verdict::Rejected(rule) => { @@ -477,6 +478,10 @@ impl ImportAction { // §5.6 second phase of truth, over every file that would be // imported, before anything is placed: one hard failure condemns // the whole release (§5.7), not the episodes. + let runtime_minutes = pending + .runtime_minutes + .and_then(|minutes| u32::try_from(minutes).ok()) + .unwrap_or(0); let mut imports = Vec::new(); for assignment in assignments { if assignment.episode.has_file { @@ -496,6 +501,7 @@ impl ImportAction { Candidate::PostDownload(&assignment.file.media), Some(assignment.file.size), 1, + runtime_minutes, ); let waiver = match evaluation.verdict { Verdict::Rejected(rule) => { @@ -938,6 +944,10 @@ struct PendingTvImport { series_title: String, series_year: Option, original_language: Option, + /// §5.5: the series' minutes per episode, scaling the size bands the + /// same way the pre-grab verdict scaled them. `None` applies them + /// unscaled. + runtime_minutes: Option, release_name: String, } @@ -974,6 +984,7 @@ async fn pending_tv_imports(database: &Db) -> Result, Impor s.title AS "series_title!: String", s.year AS "series_year", s.original_language, + s.runtime_minutes, r.name AS "release_name!: String" FROM grabs g JOIN episodes e ON e.id = g.target_id @@ -997,6 +1008,7 @@ async fn pending_tv_imports(database: &Db) -> Result, Impor series_title: row.series_title, series_year: row.series_year, original_language: row.original_language, + runtime_minutes: row.runtime_minutes, release_name: row.release_name, })); @@ -1011,6 +1023,7 @@ async fn pending_tv_imports(database: &Db) -> Result, Impor s.title AS "series_title!: String", s.year AS "series_year", s.original_language, + s.runtime_minutes, r.name AS "release_name!: String" FROM grabs g JOIN seasons se ON se.id = g.target_id @@ -1033,6 +1046,7 @@ async fn pending_tv_imports(database: &Db) -> Result, Impor series_title: row.series_title, series_year: row.series_year, original_language: row.original_language, + runtime_minutes: row.runtime_minutes, release_name: row.release_name, })); diff --git a/crates/arr-daemon/src/series_refresh.rs b/crates/arr-daemon/src/series_refresh.rs index 46d7471..391c47e 100644 --- a/crates/arr-daemon/src/series_refresh.rs +++ b/crates/arr-daemon/src/series_refresh.rs @@ -75,7 +75,7 @@ impl SeriesRefreshAction { title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at, - poster_path, backdrop_path, vote_average + poster_path, backdrop_path, vote_average, runtime_minutes FROM series ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id"# ) @@ -118,7 +118,7 @@ impl SeriesRefreshAction { title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at, - poster_path, backdrop_path, vote_average + poster_path, backdrop_path, vote_average, runtime_minutes FROM series WHERE id = ?"#, series_id ) @@ -250,6 +250,20 @@ impl SeriesRefreshAction { changed = true; } } + // §5.5: the size bands scale by this. TMDB's `episode_run_time` is + // frequently empty; a known value is never overwritten by a missing + // one, so a series keeps its runtime across TMDB's blank spells. + let runtime = metadata.episode_runtime.map(i64::from); + if runtime.is_some() && runtime != stale.runtime_minutes { + sqlx::query!( + "UPDATE series SET runtime_minutes = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", + runtime, + stale.id + ) + .execute(&mut *executor) + .await?; + changed = true; + } let ended = is_upstream_ended(&metadata.status); if ended != stale.upstream_ended { sqlx::query!( @@ -547,6 +561,7 @@ struct DueSeries { poster_path: Option, backdrop_path: Option, vote_average: Option, + runtime_minutes: Option, } /// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`, /// STRICT). A number past `u16::MAX` cannot match anything real and would @@ -817,6 +832,55 @@ mod tests { assert_eq!(vote, Some(8.417)); } + /// §5.5: the refresh stores the minutes-per-episode the size bands scale + /// by, and a later refresh with TMDB's frequently-empty + /// `episode_run_time` never blanks a known value. + #[tokio::test] + async fn refresh_stores_the_episode_runtime_and_keeps_it_over_blanks() { + let (_dir, database) = seeded_series(false).await; + let server = MockServer::start().await; + let body = |episode_run_time: serde_json::Value| { + json!({ + "id": 82_728, + "name": "Bluey", + "original_language": "en", + "first_air_date": "2018-10-01", + "status": "Returning Series", + "episode_run_time": episode_run_time, + "seasons": [] + }) + }; + Mock::given(method("GET")) + .and(path("/tv/82728")) + .respond_with(ResponseTemplate::new(200).set_body_json(body(json!([7])))) + .mount(&server) + .await; + + action(&server).tick(&database).await.unwrap(); + let runtime: Option = + sqlx::query_scalar("SELECT runtime_minutes FROM series WHERE tmdb_id = 82728") + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(runtime, Some(7)); + + server.reset().await; + Mock::given(method("GET")) + .and(path("/tv/82728")) + .respond_with(ResponseTemplate::new(200).set_body_json(body(json!([])))) + .mount(&server) + .await; + expire_refresh(&database).await; + + action(&server).tick(&database).await.unwrap(); + let runtime: Option = + sqlx::query_scalar("SELECT runtime_minutes FROM series WHERE tmdb_id = 82728") + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(runtime, Some(7)); + } + /// #160. A series' first refresh reveals its back catalogue, but §4.1 /// never tracks what was already there at add time: nothing is tracked, /// nothing arrives wanted. diff --git a/crates/arr-db/migrations/0024_series_runtime.sql b/crates/arr-db/migrations/0024_series_runtime.sql new file mode 100644 index 0000000..a121900 --- /dev/null +++ b/crates/arr-db/migrations/0024_series_runtime.sql @@ -0,0 +1,6 @@ +-- §5.5 as amended by #187/#208: size bands are rates against a 45-minute +-- reference runtime, scaled by the series' minutes per episode. NULL is a +-- missing runtime — TMDB's episode_run_time is frequently empty — and means +-- the bands apply unscaled. +ALTER TABLE series ADD COLUMN runtime_minutes INTEGER + CHECK (runtime_minutes IS NULL OR runtime_minutes > 0); diff --git a/crates/arr-db/src/policy.rs b/crates/arr-db/src/policy.rs index d33701d..5701a71 100644 --- a/crates/arr-db/src/policy.rs +++ b/crates/arr-db/src/policy.rs @@ -696,7 +696,8 @@ mod tests { &loaded.policy, Resolution::R1080p, one_and_a_half_gib, - 1 + 1, + 0 ), Some(false) ); diff --git a/crates/arr-meta/src/model.rs b/crates/arr-meta/src/model.rs index 05d8125..e3a6f71 100644 --- a/crates/arr-meta/src/model.rs +++ b/crates/arr-meta/src/model.rs @@ -156,6 +156,10 @@ pub struct Series { pub vote_average: Option, /// How many votes the rating rests on. pub vote_count: u32, + /// Minutes per episode, the first non-zero entry of TMDB's + /// `episode_run_time`. Frequently empty for returning series — §5.5 + /// treats a missing runtime as the reference runtime. + pub episode_runtime: Option, pub seasons: Vec, } @@ -368,6 +372,8 @@ pub(crate) struct RawSeries { #[serde(default)] vote_count: u32, #[serde(default)] + episode_run_time: Vec, + #[serde(default)] seasons: Vec, #[serde(default)] external_ids: Option, @@ -406,6 +412,10 @@ impl From for Series { backdrop_path: non_empty(raw.backdrop_path), vote_average: rating(raw.vote_average), vote_count: raw.vote_count, + episode_runtime: raw + .episode_run_time + .into_iter() + .find(|&minutes| minutes > 0), seasons: raw .seasons .into_iter() From bb2708f7eeaa574c9017d2bc91240698731fe5b3 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 22:47:18 +0100 Subject: [PATCH 04/27] chore(db): renumber the runtime migration to 0026 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The concurrent subtitles milestone carries 0024_subtitles.sql and 0025_subtitle_settings.sql. Two migrations claiming version 24 do not conflict in git — the filenames differ — so both would land on main and sqlx would see a duplicate version. Renumbering here is the half that does not depend on the other milestone acting. Co-Authored-By: Claude Opus 5 (1M context) --- .../{0024_series_runtime.sql => 0026_series_runtime.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/arr-db/migrations/{0024_series_runtime.sql => 0026_series_runtime.sql} (100%) diff --git a/crates/arr-db/migrations/0024_series_runtime.sql b/crates/arr-db/migrations/0026_series_runtime.sql similarity index 100% rename from crates/arr-db/migrations/0024_series_runtime.sql rename to crates/arr-db/migrations/0026_series_runtime.sql From c64c572781af0e60fe624fb4738c0793a60cc137 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 01:31:54 +0100 Subject: [PATCH 05/27] refactor(arr): give arr-api its own jellyfin client arr-daemon depends on arr-api, so a handler in arr-api can never reach the daemon's private JellyfinClient. Move it into arr-api and attach an instance to AppState, so a manual subtitle write can ask for the same refresh import already does (#195). --- Cargo.lock | 1 + crates/arr-api/Cargo.toml | 1 + .../{arr-daemon => arr-api}/src/jellyfin.rs | 16 +++++++++---- crates/arr-api/src/lib.rs | 1 + crates/arr-api/src/state.rs | 16 +++++++++++++ crates/arr-daemon/src/import.rs | 2 +- crates/arr-daemon/src/main.rs | 23 +++++++++++++------ 7 files changed, 48 insertions(+), 12 deletions(-) rename crates/{arr-daemon => arr-api}/src/jellyfin.rs (85%) diff --git a/Cargo.lock b/Cargo.lock index 1042d70..78bdfe6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,6 +42,7 @@ dependencies = [ "serde_json", "sqlx", "tempfile", + "thiserror", "tokio", "tracing", "utoipa", diff --git a/crates/arr-api/Cargo.toml b/crates/arr-api/Cargo.toml index 686ed94..b5da491 100644 --- a/crates/arr-api/Cargo.toml +++ b/crates/arr-api/Cargo.toml @@ -18,6 +18,7 @@ reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sqlx = { workspace = true } +thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } utoipa = { workspace = true } diff --git a/crates/arr-daemon/src/jellyfin.rs b/crates/arr-api/src/jellyfin.rs similarity index 85% rename from crates/arr-daemon/src/jellyfin.rs rename to crates/arr-api/src/jellyfin.rs index dde3f47..e064d9a 100644 --- a/crates/arr-daemon/src/jellyfin.rs +++ b/crates/arr-api/src/jellyfin.rs @@ -1,8 +1,9 @@ -//! Jellyfin library refresh after import. See DESIGN.md §7.5. +//! Jellyfin library refresh after import or a subtitle write. See DESIGN.md +//! §7.5 and §15. //! -//! Jellyfin's own filesystem watcher misses the hardlinked file, so import -//! asks it to rescan directly. A refresh failure must not fail the import — -//! the caller logs and continues (§7.5). +//! Jellyfin's own filesystem watcher misses a hardlinked or sidecar file, so +//! callers ask it to rescan directly. A refresh failure must not fail the +//! caller — it logs and continues (§7.5). use std::time::Duration; @@ -27,6 +28,9 @@ pub struct JellyfinClient { } impl JellyfinClient { + /// # Errors + /// + /// If the underlying HTTP client cannot be built. pub fn new(base_url: impl Into, api_key: Option) -> Result { let client = Client::builder().timeout(REQUEST_TIMEOUT).build()?; Ok(Self { @@ -38,6 +42,10 @@ impl JellyfinClient { /// Trigger a full library scan. Jellyfin exposes no per-library refresh /// without knowing that library's ID, which this app never learns. + /// + /// # Errors + /// + /// If the request fails, or Jellyfin answers with a non-success status. pub async fn refresh(&self) -> Result<(), Error> { let url = format!("{}/Library/Refresh", self.base_url.trim_end_matches('/')); let mut request = self.client.post(url); diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 38187b4..d42a89b 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -7,6 +7,7 @@ //! to compile, and the gate in DESIGN.md §12 fails with it. mod health; +pub mod jellyfin; mod metadata; mod movies; mod owners; diff --git a/crates/arr-api/src/state.rs b/crates/arr-api/src/state.rs index de8c4c0..e534120 100644 --- a/crates/arr-api/src/state.rs +++ b/crates/arr-api/src/state.rs @@ -7,6 +7,8 @@ use std::time::Duration; use arr_db::Db; use tokio::sync::mpsc; +use crate::jellyfin::JellyfinClient; + /// The TMDB API root. Not a bootstrap setting (DESIGN.md §10) — only the key /// is configurable, so this is a constant that tests point elsewhere. pub const DEFAULT_TMDB_URL: &str = "https://api.themoviedb.org/3"; @@ -76,6 +78,7 @@ pub struct AppState { pending_season_commands: Arc>>, metadata_commands: mpsc::Sender, pending_metadata_commands: Arc>>, + jellyfin: Option, } /// Work explicitly requested through the movie API. @@ -146,6 +149,7 @@ impl AppState { pending_season_commands: Arc::new(tokio::sync::Mutex::new(pending_season_commands)), metadata_commands, pending_metadata_commands: Arc::new(tokio::sync::Mutex::new(pending_metadata_commands)), + jellyfin: None, }) } @@ -156,6 +160,18 @@ impl AppState { self } + /// Attach the Jellyfin client, so a manual subtitle grab or translation + /// can trigger the same library refresh import does (§7.5, §15). + #[must_use] + pub fn with_jellyfin(mut self, jellyfin: JellyfinClient) -> Self { + self.jellyfin = Some(jellyfin); + self + } + + pub(crate) fn jellyfin(&self) -> Option<&JellyfinClient> { + self.jellyfin.as_ref() + } + /// Wait for the next manual movie action in the daemon's reconcile loop. /// /// # Errors diff --git a/crates/arr-daemon/src/import.rs b/crates/arr-daemon/src/import.rs index 80db79a..ebc6109 100644 --- a/crates/arr-daemon/src/import.rs +++ b/crates/arr-daemon/src/import.rs @@ -25,9 +25,9 @@ use arr_db::Db; use arr_dl::TransmissionClient; use arr_probe::Prober; -use crate::jellyfin::JellyfinClient; use crate::notify::Notifier; use crate::reconcile::{Action, ActionFuture, Outcome}; +use arr_api::jellyfin::JellyfinClient; /// A failure during one import tick. #[derive(Debug, thiserror::Error)] diff --git a/crates/arr-daemon/src/main.rs b/crates/arr-daemon/src/main.rs index 34c45c9..5264b36 100644 --- a/crates/arr-daemon/src/main.rs +++ b/crates/arr-daemon/src/main.rs @@ -6,7 +6,6 @@ mod config; mod grab; mod import; mod indexers; -mod jellyfin; mod manual; mod metadata; mod notify; @@ -94,7 +93,7 @@ enum Error { #[error("transmission client: {0}")] Transmission(#[from] arr_dl::Error), #[error("jellyfin client: {0}")] - Jellyfin(#[from] jellyfin::Error), + Jellyfin(#[from] arr_api::jellyfin::Error), #[error("ntfy client: {0}")] Notify(#[from] notify::NotifyError), #[error("bind {addr}: {source}")] @@ -124,6 +123,7 @@ async fn run() -> Result<(), Error> { None }; let notifier = Notifier::new(config.ntfy_url.clone())?; + let api_jellyfin = jellyfin_client(&config)?; let (reconcile, manual_grab, manual_tv) = reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), ¬ifier)?; // Issue #176: the on-demand half of the metadata lane needs its own @@ -144,7 +144,9 @@ async fn run() -> Result<(), Error> { if let Some(tmdb_url) = config.tmdb_url { upstreams = upstreams.with_tmdb_url(tmdb_url); } - let state = AppState::new(upstreams)?.with_database(database.clone()); + let state = AppState::new(upstreams)? + .with_database(database.clone()) + .with_jellyfin(api_jellyfin); let app = arr_api::router(state.clone()) .merge(arr_compat::router(compat)) @@ -294,10 +296,7 @@ fn reconcile_loop( } else { tracing::warn!("TMDB is not configured: series metadata refresh is disabled"); } - let jellyfin = jellyfin::JellyfinClient::new( - config.jellyfin_url.clone(), - config.jellyfin_api_key.clone(), - )?; + let jellyfin = jellyfin_client(config)?; // Grab before import, so a download that completes on this tick is // imported on this tick. reconcile = reconcile.register( @@ -443,3 +442,13 @@ async fn shutdown() { tracing::info!("shutting down"); } + +/// The Jellyfin client, built fresh for each of its two independent callers: +/// import's own reconcile action, and the subtitle API's manual grab and +/// translate handlers (§7.5, §15). +fn jellyfin_client(config: &Config) -> Result { + Ok(arr_api::jellyfin::JellyfinClient::new( + config.jellyfin_url.clone(), + config.jellyfin_api_key.clone(), + )?) +} From bce3d3823dcaf2666f827687a3665f85a7ac6358 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 09:55:08 +0100 Subject: [PATCH 06/27] feat(api): move title files on root change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing a movie's or series' root previously rewrote root_id and left the files behind, so the §7.4 layout stopped describing the disk and the root's policy applied to a library the files were not in. Series had no root control at all. All roots share one ZFS dataset, so the move is a rename of the title folder into the new root, never a copy — hardlinks and the seeding torrent survive it (§7.3). Disk first, row second: a destination that already holds the folder is a 409, a failed rename leaves the row unchanged, and a title with nothing on disk moves with no filesystem work. media_files rows are rewritten in the same transaction as the root_id, and a successful move triggers the §7.5 Jellyfin refresh. Issue #228 Co-Authored-By: Claude Fable 5 --- ...3621c69971e6dc0784f3d2c011c8e90a001d6.json | 12 + ...d95d2949fe2a0f1f59d55d6272cca1bd431aa.json | 38 +++ ...9ab5455e7a2d06fc86291e2c6c9696fd08953.json | 26 ++ ...a50df414a77bc191755d515e5d5905a651adc.json | 38 +++ DESIGN.md | 2 + crates/arr-api/src/lib.rs | 1 + crates/arr-api/src/movies.rs | 239 +++++++++++++++- crates/arr-api/src/relocate.rs | 259 ++++++++++++++++++ crates/arr-api/src/series.rs | 161 ++++++++++- 9 files changed, 770 insertions(+), 6 deletions(-) create mode 100644 .sqlx/query-20dd51ed5a7e54bb156d9a5eaf83621c69971e6dc0784f3d2c011c8e90a001d6.json create mode 100644 .sqlx/query-3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa.json create mode 100644 .sqlx/query-6ef45d7e6fc113b578fab91148f9ab5455e7a2d06fc86291e2c6c9696fd08953.json create mode 100644 .sqlx/query-89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc.json create mode 100644 crates/arr-api/src/relocate.rs diff --git a/.sqlx/query-20dd51ed5a7e54bb156d9a5eaf83621c69971e6dc0784f3d2c011c8e90a001d6.json b/.sqlx/query-20dd51ed5a7e54bb156d9a5eaf83621c69971e6dc0784f3d2c011c8e90a001d6.json new file mode 100644 index 0000000..9b13e30 --- /dev/null +++ b/.sqlx/query-20dd51ed5a7e54bb156d9a5eaf83621c69971e6dc0784f3d2c011c8e90a001d6.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE media_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "20dd51ed5a7e54bb156d9a5eaf83621c69971e6dc0784f3d2c011c8e90a001d6" +} diff --git a/.sqlx/query-3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa.json b/.sqlx/query-3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa.json new file mode 100644 index 0000000..d962efc --- /dev/null +++ b/.sqlx/query-3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\", path AS \"path!: String\"\n FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "media_files", + "name": "id" + } + } + }, + { + "name": "path!: String", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "media_files", + "name": "path" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa" +} diff --git a/.sqlx/query-6ef45d7e6fc113b578fab91148f9ab5455e7a2d06fc86291e2c6c9696fd08953.json b/.sqlx/query-6ef45d7e6fc113b578fab91148f9ab5455e7a2d06fc86291e2c6c9696fd08953.json new file mode 100644 index 0000000..5262844 --- /dev/null +++ b/.sqlx/query-6ef45d7e6fc113b578fab91148f9ab5455e7a2d06fc86291e2c6c9696fd08953.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT path AS \"path!: String\" FROM roots WHERE id = ?", + "describe": { + "columns": [ + { + "name": "path!: String", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "roots", + "name": "path" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "6ef45d7e6fc113b578fab91148f9ab5455e7a2d06fc86291e2c6c9696fd08953" +} diff --git a/.sqlx/query-89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc.json b/.sqlx/query-89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc.json new file mode 100644 index 0000000..ade8330 --- /dev/null +++ b/.sqlx/query-89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "media_files", + "name": "id" + } + } + }, + { + "name": "path!: String", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "media_files", + "name": "path" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc" +} diff --git a/DESIGN.md b/DESIGN.md index b269a9c..8d16fd3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -488,6 +488,8 @@ Media kind first, hard audience boundary second, people nowhere. - **Release group is deliberately absent.** It is not a selection criterion and it makes filenames long enough to break a terminal. +Changing a title's root relocates its title folder into the new root; roots are assumed to share one filesystem, so the move is a rename, never a copy. + During transition, write into the existing roots so Jellyfin needs no reconfiguration and new content appears immediately. Radarr will not touch a folder it has no record of. diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index d42a89b..c912e04 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -13,6 +13,7 @@ mod movies; mod owners; mod policies; mod reclassify; +mod relocate; mod roots; mod search; mod series; diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index d7d13ac..96cdf48 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -430,9 +430,51 @@ pub async fn update( } let wanted = input.wanted.unwrap_or(current.wanted); let blocked = input.blocked.unwrap_or(current.blocked); - sqlx::query!("UPDATE movies SET title = ?, year = ?, original_language = ?, root_id = ?, wanted = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, wanted, blocked, overrides, id) - .execute(pool(&state)?) - .await?; + // A root change moves the §7.4 title folder with the row (issue #228). + // Disk first, row second: a failed rename leaves the row alone, so the + // operator sees the title where its files actually are and can retry — + // the same ordering `remove_library_files` documents. + let relocation = if root_id == current.root_id { + None + } else { + Some( + crate::relocate::relocate_title( + &state, + crate::relocate::TitleKind::Movie, + id, + current.root_id, + root_id, + ) + .await?, + ) + }; + let mut transaction = pool(&state)?.begin().await?; + let written: Result<(), sqlx::Error> = async { + sqlx::query!("UPDATE movies SET title = ?, year = ?, original_language = ?, root_id = ?, wanted = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, wanted, blocked, overrides, id) + .execute(&mut *transaction) + .await?; + if let Some(relocation) = &relocation { + relocation.rewrite_rows(&mut transaction).await?; + } + Ok(()) + } + .await; + let committed = match written { + Ok(()) => transaction.commit().await.map_err(ApiError::from), + Err(error) => Err(ApiError::from(error)), + }; + if let Err(error) = committed { + if let Some(relocation) = &relocation { + relocation.undo().await; + } + return Err(error); + } + if relocation + .as_ref() + .is_some_and(crate::relocate::Relocation::moved_files) + { + crate::relocate::refresh_jellyfin(&state).await; + } if overrides_changed { crate::reclassify::movie(&state, id).await?; } @@ -1417,6 +1459,197 @@ mod tests { ); } + /// Point one of the seeded roots at a real directory for the duration of + /// a test. + async fn point_root_at(state: &AppState, root_id: i64, path: &std::path::Path) { + sqlx::query("UPDATE roots SET path = ? WHERE id = ?") + .bind(path.to_str().expect("utf-8 root")) + .bind(root_id) + .execute(state.database().expect("database").pool()) + .await + .expect("point the root at the tempdir"); + } + + /// Issue #228: changing a movie's root renames its §7.4 folder into the + /// new root — sidecars ride along inside it — and the `media_files` rows + /// follow in the same write. + #[tokio::test] + async fn changing_root_moves_the_title_folder_and_its_rows() { + let (_dir, state, base) = application().await; + let movie = add_movie(&base, 693_134, 1).await; + let id = movie["id"].as_i64().expect("id"); + let source = tempfile::tempdir().expect("source root"); + let destination = tempfile::tempdir().expect("destination root"); + let folder = library_on_disk(&state, id, source.path()).await; + point_root_at(&state, 2, destination.path()).await; + + // The wrong kind of root is still rejected, before anything moves. + let rejected = reqwest::Client::new() + .patch(format!("{base}/api/movies/{id}")) + .json(&serde_json::json!({"root_id": 3})) + .send() + .await + .expect("move to a tv root"); + assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let updated: serde_json::Value = reqwest::Client::new() + .patch(format!("{base}/api/movies/{id}")) + .json(&serde_json::json!({"root_id": 2})) + .send() + .await + .expect("move root") + .json() + .await + .expect("updated json"); + assert_eq!(updated["root_id"], 2); + + assert!(!folder.exists(), "the folder left the old root"); + let moved = destination + .path() + .join("Dune Part Two (2024) [tmdbid-693134]"); + assert!( + moved + .join("Dune Part Two (2024) [tmdbid-693134] - [2160p].mkv") + .exists(), + "the feature arrived in the new root" + ); + assert!( + moved.join("dune.pt.srt").exists(), + "sidecars travel inside the folder" + ); + + let path: String = sqlx::query_scalar( + "SELECT path FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?", + ) + .bind(id) + .fetch_one(state.database().expect("database").pool()) + .await + .expect("media file row"); + assert!( + std::path::Path::new(&path).starts_with(destination.path()), + "the row follows the file: {path}" + ); + assert!( + std::path::Path::new(&path).exists(), + "the rewritten path describes the disk" + ); + } + + /// Issue #228: a title with nothing on disk changes root with no + /// filesystem work at all — the seeded root paths do not even exist. + #[tokio::test] + async fn a_movie_with_no_files_changes_root_cleanly() { + let (_dir, state, base) = application().await; + let movie = add_movie(&base, 693_134, 1).await; + let id = movie["id"].as_i64().expect("id"); + + let updated: serde_json::Value = reqwest::Client::new() + .patch(format!("{base}/api/movies/{id}")) + .json(&serde_json::json!({"root_id": 2})) + .send() + .await + .expect("move root") + .json() + .await + .expect("updated json"); + assert_eq!(updated["root_id"], 2); + + let root_id: i64 = sqlx::query_scalar("SELECT root_id FROM movies WHERE id = ?") + .bind(id) + .fetch_one(state.database().expect("database").pool()) + .await + .expect("movie row"); + assert_eq!(root_id, 2); + } + + /// Issue #228: a destination already holding a folder of that name is a + /// conflict, not an overwrite — and the row stays where the files are. + #[tokio::test] + async fn a_destination_collision_is_refused_and_the_row_unchanged() { + let (_dir, state, base) = application().await; + let movie = add_movie(&base, 693_134, 1).await; + let id = movie["id"].as_i64().expect("id"); + let source = tempfile::tempdir().expect("source root"); + let destination = tempfile::tempdir().expect("destination root"); + let folder = library_on_disk(&state, id, source.path()).await; + point_root_at(&state, 2, destination.path()).await; + let squatter = destination + .path() + .join("Dune Part Two (2024) [tmdbid-693134]"); + tokio::fs::create_dir_all(&squatter) + .await + .expect("pre-existing folder"); + tokio::fs::write(squatter.join("theirs.mkv"), b"not ours") + .await + .expect("write squatter file"); + + let response = reqwest::Client::new() + .patch(format!("{base}/api/movies/{id}")) + .json(&serde_json::json!({"root_id": 2})) + .send() + .await + .expect("move root"); + assert_eq!(response.status(), StatusCode::CONFLICT); + + assert!(folder.exists(), "the folder stayed in the old root"); + assert!( + squatter.join("theirs.mkv").exists(), + "the occupant was not overwritten" + ); + let (root_id, path): (i64, String) = sqlx::query_as( + "SELECT m.root_id, f.path FROM movies m + JOIN media_files f ON f.owner_kind = 'movie' AND f.owner_id = m.id + WHERE m.id = ?", + ) + .bind(id) + .fetch_one(state.database().expect("database").pool()) + .await + .expect("movie row"); + assert_eq!(root_id, 1, "the row is unchanged"); + assert!(std::path::Path::new(&path).starts_with(source.path())); + } + + /// Issue #228: if the rename fails, the row must not change — the + /// operator sees the title where its files actually are and can retry. + #[tokio::test] + async fn a_failed_rename_leaves_the_row_alone() { + let (_dir, state, base) = application().await; + let movie = add_movie(&base, 693_134, 1).await; + let id = movie["id"].as_i64().expect("id"); + let source = tempfile::tempdir().expect("source root"); + let destination = tempfile::tempdir().expect("destination root"); + let folder = library_on_disk(&state, id, source.path()).await; + // A destination whose parent does not exist makes the rename itself + // fail while the collision pre-check still passes. + point_root_at( + &state, + 2, + &destination.path().join("missing").join("library"), + ) + .await; + + let response = reqwest::Client::new() + .patch(format!("{base}/api/movies/{id}")) + .json(&serde_json::json!({"root_id": 2})) + .send() + .await + .expect("move root"); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + + assert!(folder.exists(), "the folder never left the old root"); + let (root_id, path): (i64, String) = sqlx::query_as( + "SELECT m.root_id, f.path FROM movies m + JOIN media_files f ON f.owner_kind = 'movie' AND f.owner_id = m.id + WHERE m.id = ?", + ) + .bind(id) + .fetch_one(state.database().expect("database").pool()) + .await + .expect("movie row"); + assert_eq!(root_id, 1, "the row is unchanged"); + assert!(std::path::Path::new(&path).starts_with(source.path())); + } + #[test] fn a_title_target_is_the_folder_directly_under_the_root() { let root = "/mnt/media/movies/main"; diff --git a/crates/arr-api/src/relocate.rs b/crates/arr-api/src/relocate.rs new file mode 100644 index 0000000..f1d3f6f --- /dev/null +++ b/crates/arr-api/src/relocate.rs @@ -0,0 +1,259 @@ +//! Moving a title between roots (issue #228). Changing a title's `root_id` +//! renames its §7.4 folder into the new root and rewrites the `media_files` +//! rows to match, so the layout keeps describing the disk and the new root's +//! policy (§5.1) applies to a library the files are actually in. +//! +//! Every root shares one filesystem — one ZFS dataset, bind-mounted — so this +//! is a directory rename, never a copy. Hardlinked files keep their inodes +//! and the torrent keeps seeding against them (§7.3). +//! +//! Ordering mirrors `remove_library_files`: the disk is touched before the +//! row changes, so a failed rename leaves the title where its files actually +//! are and the operator can retry. + +use std::path::PathBuf; + +use crate::movies::{pool, title_target, ApiError}; +use crate::state::AppState; + +/// Which table owns the moving title's files. +#[derive(Debug, Clone, Copy)] +pub(crate) enum TitleKind { + Movie, + Series, +} + +/// One rename from the old root into the new one: a §7.4 title folder, or a +/// loose file sitting straight in the root. +#[derive(Debug)] +struct PlannedRename { + source: PathBuf, + destination: PathBuf, +} + +/// The renames already performed on disk and the row rewrites they imply. +/// The database half is the caller's transaction; [`Self::undo`] is for when +/// that transaction fails after the disk already changed. +#[derive(Debug)] +pub(crate) struct Relocation { + performed: Vec, + rewrites: Vec<(i64, String)>, +} + +/// Rename the title's folders into the new root. Called before the row is +/// written, and only when the root actually changes. +/// +/// A title with nothing on disk — no `media_files` rows, or rows whose +/// targets are already gone — changes root with no filesystem work at all. +/// +/// # Errors +/// +/// [`ApiError::Conflict`] when the destination already holds an entry of the +/// same name — a conflict, never an overwrite. [`ApiError::Filesystem`] when +/// a rename fails; whatever had already been renamed is moved back first, so +/// the row the caller then leaves unchanged still describes the disk. +pub(crate) async fn relocate_title( + state: &AppState, + kind: TitleKind, + title_id: i64, + old_root_id: i64, + new_root_id: i64, +) -> Result { + let old_root = root_path(state, old_root_id).await?; + let new_root = root_path(state, new_root_id).await?; + let files = title_files(state, kind, title_id).await?; + + let mut renames: Vec = Vec::new(); + let mut rewrites: Vec<(i64, String)> = Vec::new(); + for (file_id, path) in &files { + let Some(source) = title_target(&old_root, path) else { + // Outside its own root: not ours to move, and the row keeps + // pointing at where the file really is. + tracing::warn!(%path, %old_root, "media file is outside its root, not moved"); + continue; + }; + let Some(name) = source.file_name() else { + continue; + }; + let destination = std::path::Path::new(&new_root).join(name); + if !renames.iter().any(|rename| rename.source == source) { + renames.push(PlannedRename { + source, + destination, + }); + } + let relative = std::path::Path::new(path) + .strip_prefix(std::path::Path::new(&old_root)) + .map_err(|error| ApiError::Filesystem(error.to_string()))?; + let rewritten = std::path::Path::new(&new_root).join(relative); + let Some(rewritten) = rewritten.to_str() else { + return Err(ApiError::Filesystem(format!( + "non-UTF-8 path under {new_root}" + ))); + }; + rewrites.push((*file_id, rewritten.to_owned())); + } + + // Every destination is checked before anything is renamed, so a conflict + // never leaves a half-moved title behind. + for rename in &renames { + match tokio::fs::symlink_metadata(&rename.destination).await { + Ok(_) => { + return Err(ApiError::Conflict(format!( + "the destination root already has '{}'", + rename.destination.display() + ))) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(ApiError::Filesystem(error.to_string())), + } + } + + let mut performed: Vec = Vec::new(); + for rename in renames { + // A recorded file with nothing on disk: the rows still follow the + // title, the same way a delete treats already-gone as done. Checked + // on the source, so a missing *destination* parent stays an error. + match tokio::fs::symlink_metadata(&rename.source).await { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + tracing::warn!( + source = %rename.source.display(), + "nothing on disk to move for this title" + ); + continue; + } + Err(error) => { + let failed = ApiError::Filesystem(error.to_string()); + Relocation { + performed, + rewrites: Vec::new(), + } + .undo() + .await; + return Err(failed); + } + } + match tokio::fs::rename(&rename.source, &rename.destination).await { + Ok(()) => { + tracing::info!( + source = %rename.source.display(), + destination = %rename.destination.display(), + "moved title folder between roots" + ); + performed.push(rename); + } + Err(error) => { + let failed = ApiError::Filesystem(error.to_string()); + Relocation { + performed, + rewrites: Vec::new(), + } + .undo() + .await; + return Err(failed); + } + } + } + + Ok(Relocation { + performed, + rewrites, + }) +} + +impl Relocation { + /// Whether anything on disk actually moved — the trigger for the same + /// single Jellyfin refresh import performs (§7.5). + pub(crate) fn moved_files(&self) -> bool { + !self.performed.is_empty() + } + + /// Point the `media_files` rows at the new root, inside the caller's + /// transaction so they land together with the `root_id` change or not at + /// all. + pub(crate) async fn rewrite_rows( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + ) -> Result<(), sqlx::Error> { + for (file_id, path) in &self.rewrites { + sqlx::query!( + "UPDATE media_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", + path, + file_id + ) + .execute(&mut **transaction) + .await?; + } + Ok(()) + } + + /// Best-effort reversal of the renames, for when the disk moved but the + /// database write failed. A reversal that itself fails is logged: at that + /// point the operator's retry is the recovery path. + pub(crate) async fn undo(&self) { + for rename in self.performed.iter().rev() { + if let Err(error) = tokio::fs::rename(&rename.destination, &rename.source).await { + tracing::error!( + source = %rename.source.display(), + destination = %rename.destination.display(), + %error, + "could not move the title folder back after a failed root change" + ); + } + } + } +} + +/// §7.5 after a move: the same single best-effort refresh import performs. +/// Failure logs and never fails the write that already committed. +pub(crate) async fn refresh_jellyfin(state: &AppState) { + if let Some(jellyfin) = state.jellyfin() { + if let Err(error) = jellyfin.refresh().await { + tracing::warn!(%error, "jellyfin refresh after a root change failed"); + } + } +} + +async fn root_path(state: &AppState, root_id: i64) -> Result { + Ok(sqlx::query_scalar!( + r#"SELECT path AS "path!: String" FROM roots WHERE id = ?"#, + root_id + ) + .fetch_one(pool(state)?) + .await?) +} + +/// Every file the service recorded for the title: a movie's own rows, or the +/// rows of every episode below a series. +async fn title_files( + state: &AppState, + kind: TitleKind, + title_id: i64, +) -> Result, ApiError> { + Ok(match kind { + TitleKind::Movie => sqlx::query!( + r#"SELECT id AS "id!: i64", path AS "path!: String" + FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?"#, + title_id + ) + .fetch_all(pool(state)?) + .await? + .into_iter() + .map(|row| (row.id, row.path)) + .collect(), + TitleKind::Series => sqlx::query!( + r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String" + FROM media_files mf + JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id + JOIN seasons se ON se.id = e.season_id + WHERE se.series_id = ?"#, + title_id + ) + .fetch_all(pool(state)?) + .await? + .into_iter() + .map(|row| (row.id, row.path)) + .collect(), + }) +} diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index a8c48f3..453f989 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -582,9 +582,51 @@ pub async fn update( let auto_track = input.auto_track.unwrap_or(current.auto_track); let upstream_ended = input.upstream_ended.unwrap_or(current.upstream_ended); let blocked = input.blocked.unwrap_or(current.blocked); - sqlx::query!("UPDATE series SET title = ?, year = ?, original_language = ?, root_id = ?, auto_track = ?, upstream_ended = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, id) - .execute(pool(&state)?) - .await?; + // A root change moves the §7.4 title folder with the row (issue #228). + // Disk first, row second: a failed rename leaves the row alone, so the + // operator sees the title where its files actually are and can retry — + // the same ordering `remove_library_files` documents. + let relocation = if root_id == current.root_id { + None + } else { + Some( + crate::relocate::relocate_title( + &state, + crate::relocate::TitleKind::Series, + id, + current.root_id, + root_id, + ) + .await?, + ) + }; + let mut transaction = pool(&state)?.begin().await?; + let written: Result<(), sqlx::Error> = async { + sqlx::query!("UPDATE series SET title = ?, year = ?, original_language = ?, root_id = ?, auto_track = ?, upstream_ended = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, id) + .execute(&mut *transaction) + .await?; + if let Some(relocation) = &relocation { + relocation.rewrite_rows(&mut transaction).await?; + } + Ok(()) + } + .await; + let committed = match written { + Ok(()) => transaction.commit().await.map_err(ApiError::from), + Err(error) => Err(ApiError::from(error)), + }; + if let Err(error) = committed { + if let Some(relocation) = &relocation { + relocation.undo().await; + } + return Err(error); + } + if relocation + .as_ref() + .is_some_and(crate::relocate::Relocation::moved_files) + { + crate::relocate::refresh_jellyfin(&state).await; + } if overrides_changed { crate::reclassify::series(&state, id).await?; } @@ -3654,6 +3696,119 @@ mod tests { ); } + /// Issue #228: changing a series' root renames its §7.4 title folder — + /// season subfolders inside it — into the new root, and every episode's + /// `media_files` row follows. + #[tokio::test] + async fn changing_root_moves_the_series_folder_and_its_rows() { + let (_dir, state, base) = application().await; + let main_root = tv_root(&state, "main").await; + let kids_root = tv_root(&state, "kids").await; + let series = add_series(&base, main_root, false).await; + let series_id = series["id"].as_i64().expect("id"); + let season = add_season( + &base, + series_id, + 1, + serde_json::json!([{ "number": 1, "title": "Magic Xylophone" }]), + ) + .await; + let season_id = season["id"].as_i64().expect("season id"); + let episode_id: i64 = + sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1") + .bind(season_id) + .fetch_one(state.database().expect("database").pool()) + .await + .expect("episode id"); + + let source = tempfile::tempdir().expect("source root"); + let destination = tempfile::tempdir().expect("destination root"); + let folder = source.path().join("Bluey (2018) [tmdbid-82728]"); + let episode_file = folder.join("Season 01").join("Bluey (2018) - S01E01.mkv"); + tokio::fs::create_dir_all(folder.join("Season 01")) + .await + .expect("create season folder"); + tokio::fs::write(&episode_file, b"episode") + .await + .expect("write episode"); + let pool = state.database().expect("database").pool(); + for (root, path) in [(main_root, source.path()), (kids_root, destination.path())] { + sqlx::query("UPDATE roots SET path = ? WHERE id = ?") + .bind(path.to_str().expect("utf-8 root")) + .bind(root) + .execute(pool) + .await + .expect("point the root at the tempdir"); + } + sqlx::query( + "INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 7)", + ) + .bind(episode_id) + .bind(episode_file.to_str().expect("utf-8 path")) + .execute(pool) + .await + .expect("media file"); + + // The wrong kind of root is still rejected, before anything moves. + let rejected = reqwest::Client::new() + .patch(format!("{base}/api/series/{series_id}")) + .json(&serde_json::json!({"root_id": 1})) + .send() + .await + .expect("move to a movie root"); + assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let updated: serde_json::Value = reqwest::Client::new() + .patch(format!("{base}/api/series/{series_id}")) + .json(&serde_json::json!({"root_id": kids_root})) + .send() + .await + .expect("move root") + .json() + .await + .expect("updated json"); + assert_eq!(updated["root_id"], kids_root); + + assert!(!folder.exists(), "the folder left the old root"); + let moved = destination + .path() + .join("Bluey (2018) [tmdbid-82728]") + .join("Season 01") + .join("Bluey (2018) - S01E01.mkv"); + assert!(moved.exists(), "the episode arrived, season folder intact"); + + let path: String = sqlx::query_scalar( + "SELECT path FROM media_files WHERE owner_kind = 'episode' AND owner_id = ?", + ) + .bind(episode_id) + .fetch_one(pool) + .await + .expect("media file row"); + assert_eq!(path, moved.to_str().expect("utf-8 path").to_owned()); + } + + /// Issue #228: a series with nothing on disk changes root with no + /// filesystem work at all. + #[tokio::test] + async fn a_series_with_no_files_changes_root_cleanly() { + let (_dir, state, base) = application().await; + let main_root = tv_root(&state, "main").await; + let kids_root = tv_root(&state, "kids").await; + let series = add_series(&base, main_root, false).await; + let series_id = series["id"].as_i64().expect("id"); + + let updated: serde_json::Value = reqwest::Client::new() + .patch(format!("{base}/api/series/{series_id}")) + .json(&serde_json::json!({"root_id": kids_root})) + .send() + .await + .expect("move root") + .json() + .await + .expect("updated json"); + assert_eq!(updated["root_id"], kids_root); + } + #[test] fn air_dates_parse_as_dates_and_as_timestamps() { assert_eq!( From ac0e80c0448622c196498693aee125089f7740ec Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 10:09:07 +0100 Subject: [PATCH 07/27] feat: require two recent failures to queue a season MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The season branch of the attention queue listed a season on one failed grab of any age, so `GET /api/queues/attention` returned Rick and Morty with every season it has and buried the one that needed attention. Two changes, both stated in DESIGN.md §5.7: - The season branch now enforces the same bar the episode branch does: two grabs that hard-failed on *different* releases. - A failed grab counts toward the queue for 30 days (`arr_db::ATTENTION_WINDOW`). Nothing clears a `grabs` row, so without a window the queue only grows and can never be emptied. #181 gave the pack guard a backoff curve for the same reason; this is the queue's version of §6.2's "it never gives up entirely, it goes quiet". A season the operator dealt with stops failing and drops out; one still breaking keeps failing (the pack guard retries at worst weekly) and stays. The window applies to all three hard-fail lanes — movie, episode and season — because DESIGN.md states one rule for the queue, and to the daemon's needs-a-decision notifier as well as the API, since both read the same queue and a season-per-failure notification is the same noise on a different channel. No schema change: `grabs.grabbed_at` already carries the timestamp. Gate: `just ci` green (486 tests). --- ...151629f0b0870cad7a4462f21013ac43606e.json} | 6 +- ...32bfed6d33927db2f0992e2e1b793ee99b42.json} | 6 +- ...2a4ef2613b8b57366fc7f2fbc56be29ce72c.json} | 6 +- ...331f602ea73b2e860a623b79ae435beca614.json} | 6 +- ...5c1434a65467e2ab70efddb0423cde1829be.json} | 6 +- ...0d14dab165130719a46bd371cc3b9b135d86.json} | 6 +- DESIGN.md | 17 ++ crates/arr-api/src/movies.rs | 177 +++++++++++++++++- crates/arr-daemon/src/attention.rs | 131 +++++++++++-- crates/arr-db/src/lib.rs | 9 + 10 files changed, 335 insertions(+), 35 deletions(-) rename .sqlx/{query-4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e.json => query-1a2660bb8b6ac22352a2c8262423151629f0b0870cad7a4462f21013ac43606e.json} (82%) rename .sqlx/{query-7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51.json => query-44d8376cc9cdf66afb89de1374a332bfed6d33927db2f0992e2e1b793ee99b42.json} (85%) rename .sqlx/{query-aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702.json => query-a8beee4a6c6f00a299cb6c6ec1bb2a4ef2613b8b57366fc7f2fbc56be29ce72c.json} (87%) rename .sqlx/{query-9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2.json => query-b35fe903d45f1c3ec7a963aac599331f602ea73b2e860a623b79ae435beca614.json} (94%) rename .sqlx/{query-91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d.json => query-ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be.json} (84%) rename .sqlx/{query-1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6.json => query-edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86.json} (81%) diff --git a/.sqlx/query-4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e.json b/.sqlx/query-1a2660bb8b6ac22352a2c8262423151629f0b0870cad7a4462f21013ac43606e.json similarity index 82% rename from .sqlx/query-4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e.json rename to .sqlx/query-1a2660bb8b6ac22352a2c8262423151629f0b0870cad7a4462f21013ac43606e.json index a1a303a..58d5fb1 100644 --- a/.sqlx/query-4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e.json +++ b/.sqlx/query-1a2660bb8b6ac22352a2c8262423151629f0b0870cad7a4462f21013ac43606e.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, se.id\n ", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n GROUP BY s.id, s.title, s.year, se.id\n HAVING count(DISTINCT g.release_id) >= 2\n ", "describe": { "columns": [ { @@ -49,7 +49,7 @@ } ], "parameters": { - "Right": 0 + "Right": 1 }, "nullable": [ false, @@ -58,5 +58,5 @@ false ] }, - "hash": "4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e" + "hash": "1a2660bb8b6ac22352a2c8262423151629f0b0870cad7a4462f21013ac43606e" } diff --git a/.sqlx/query-7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51.json b/.sqlx/query-44d8376cc9cdf66afb89de1374a332bfed6d33927db2f0992e2e1b793ee99b42.json similarity index 85% rename from .sqlx/query-7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51.json rename to .sqlx/query-44d8376cc9cdf66afb89de1374a332bfed6d33927db2f0992e2e1b793ee99b42.json index feed8c1..8356895 100644 --- a/.sqlx/query-7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51.json +++ b/.sqlx/query-44d8376cc9cdf66afb89de1374a332bfed6d33927db2f0992e2e1b793ee99b42.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n ", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n HAVING count(DISTINCT g.release_id) >= 2\n ", "describe": { "columns": [ { @@ -71,7 +71,7 @@ } ], "parameters": { - "Right": 0 + "Right": 1 }, "nullable": [ false, @@ -82,5 +82,5 @@ false ] }, - "hash": "7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51" + "hash": "44d8376cc9cdf66afb89de1374a332bfed6d33927db2f0992e2e1b793ee99b42" } diff --git a/.sqlx/query-aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702.json b/.sqlx/query-a8beee4a6c6f00a299cb6c6ec1bb2a4ef2613b8b57366fc7f2fbc56be29ce72c.json similarity index 87% rename from .sqlx/query-aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702.json rename to .sqlx/query-a8beee4a6c6f00a299cb6c6ec1bb2a4ef2613b8b57366fc7f2fbc56be29ce72c.json index bc48f31..79089ad 100644 --- a/.sqlx/query-aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702.json +++ b/.sqlx/query-a8beee4a6c6f00a299cb6c6ec1bb2a4ef2613b8b57366fc7f2fbc56be29ce72c.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ", "describe": { "columns": [ { @@ -82,7 +82,7 @@ } ], "parameters": { - "Right": 0 + "Right": 1 }, "nullable": [ false, @@ -94,5 +94,5 @@ false ] }, - "hash": "aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702" + "hash": "a8beee4a6c6f00a299cb6c6ec1bb2a4ef2613b8b57366fc7f2fbc56be29ce72c" } diff --git a/.sqlx/query-9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2.json b/.sqlx/query-b35fe903d45f1c3ec7a963aac599331f602ea73b2e860a623b79ae435beca614.json similarity index 94% rename from .sqlx/query-9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2.json rename to .sqlx/query-b35fe903d45f1c3ec7a963aac599331f602ea73b2e860a623b79ae435beca614.json index d9e9698..0377826 100644 --- a/.sqlx/query-9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2.json +++ b/.sqlx/query-b35fe903d45f1c3ec7a963aac599331f602ea73b2e860a623b79ae435beca614.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title", + "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title", "describe": { "columns": [ { @@ -170,7 +170,7 @@ } ], "parameters": { - "Right": 0 + "Right": 1 }, "nullable": [ false, @@ -190,5 +190,5 @@ true ] }, - "hash": "9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2" + "hash": "b35fe903d45f1c3ec7a963aac599331f602ea73b2e860a623b79ae435beca614" } diff --git a/.sqlx/query-91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d.json b/.sqlx/query-ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be.json similarity index 84% rename from .sqlx/query-91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d.json rename to .sqlx/query-ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be.json index ae097a4..b982458 100644 --- a/.sqlx/query-91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d.json +++ b/.sqlx/query-ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed') >= 2\n ", + "query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2\n ", "describe": { "columns": [ { @@ -38,7 +38,7 @@ } ], "parameters": { - "Right": 0 + "Right": 1 }, "nullable": [ false, @@ -46,5 +46,5 @@ true ] }, - "hash": "91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d" + "hash": "ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be" } diff --git a/.sqlx/query-1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6.json b/.sqlx/query-edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86.json similarity index 81% rename from .sqlx/query-1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6.json rename to .sqlx/query-edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86.json index bcd58e4..3b0c337 100644 --- a/.sqlx/query-1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6.json +++ b/.sqlx/query-edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ", "describe": { "columns": [ { @@ -49,7 +49,7 @@ } ], "parameters": { - "Right": 0 + "Right": 1 }, "nullable": [ false, @@ -58,5 +58,5 @@ false ] }, - "hash": "1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6" + "hash": "edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86" } diff --git a/DESIGN.md b/DESIGN.md index b269a9c..6392a36 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -378,6 +378,23 @@ A policy violation found by `ffprobe` is not one thing. Neither deletes the torrent. See §7.3. +**Two hard failures make a decision, and only for 30 days.** A movie, an +episode or a season enters the needs-a-decision queue (§9.5) when two grabs +against *different* releases hard-failed on it, and both of those failures +happened within the last 30 days. One bad torrent is not a decision — a +release that hard-failed is blacklisted (§6.3) and the next candidate is +grabbed, which is the system working. + +The window is what lets the queue be emptied. Nothing clears a `grabs` row, so +without it the queue only ever grows and the one season that wants attention +sits behind eight that were dealt with months ago. It is the queue's version of +§6.2's "it never gives up entirely, it goes quiet": a target the operator has +dealt with stops producing failures and drops out once the last one ages past +30 days, while a target that is still broken keeps producing them — the pack +guard retries at worst weekly (§6.2) — and stays queued for exactly as long as +it is genuinely broken. Nothing is dismissed by hand and no acknowledgement +state is stored, so there is no second thing to keep correct. + ## 6. Sourcing ### 6.1 Prowlarr, per-indexer Torznab diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index d7d13ac..c232a93 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -740,7 +740,7 @@ pub async fn attention(State(state): State) -> Result 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#) .fetch_all(pool(&state)?) .await?; - let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title"#) + let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title"#, arr_db::ATTENTION_WINDOW) .fetch_all(pool(&state)?) .await?; let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?; @@ -756,6 +756,11 @@ pub async fn attention(State(state): State) -> Result Result<(Vec, Vec), ApiError> { @@ -795,9 +800,11 @@ async fn tv_attention( JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE g.state = 'failed' + AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number HAVING count(DISTINCT g.release_id) >= 2 - "# + "#, + arr_db::ATTENTION_WINDOW ) .fetch_all(database) .await?; @@ -810,8 +817,11 @@ async fn tv_attention( JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id JOIN series s ON s.id = se.series_id WHERE g.state = 'failed' + AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number - "# + HAVING count(DISTINCT g.release_id) >= 2 + "#, + arr_db::ATTENTION_WINDOW ) .fetch_all(database) .await?; @@ -1763,10 +1773,166 @@ mod tests { ); } + /// A series with one empty season, for exercising the season lane on its + /// own. + async fn seed_bare_season(pool: &sqlx::SqlitePool, tmdb_id: i64) -> (i64, i64) { + let root_id: i64 = + sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'") + .fetch_one(pool) + .await + .expect("kids tv root"); + let series_id: i64 = sqlx::query( + "INSERT INTO series (tmdb_id, title, year, root_id) VALUES (?, 'Rick and Morty', 2013, ?) + RETURNING id", + ) + .bind(tmdb_id) + .bind(root_id) + .fetch_one(pool) + .await + .expect("series") + .get(0); + let season_id: i64 = sqlx::query_scalar( + "INSERT INTO seasons (series_id, number) VALUES (?, 8) RETURNING id", + ) + .bind(series_id) + .fetch_one(pool) + .await + .expect("season"); + (series_id, season_id) + } + + async fn insert_release(pool: &sqlx::SqlitePool, guid: &str) -> i64 { + sqlx::query( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) + VALUES (1, ?, 'release', 1, 'url', '{}', 'eligible') RETURNING id", + ) + .bind(guid) + .fetch_one(pool) + .await + .expect("release") + .get(0) + } + + /// A hard-failed grab, stamped `age_days` in the past so the §5.7 window + /// can be exercised without waiting a month. + async fn insert_failed_grab( + pool: &sqlx::SqlitePool, + release_id: i64, + target_kind: &str, + target_id: i64, + infohash: &str, + age_days: i64, + ) { + sqlx::query( + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at) + VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))", + ) + .bind(release_id) + .bind(target_kind) + .bind(target_id) + .bind(infohash) + .bind(format!("-{age_days} days")) + .execute(pool) + .await + .expect("failed grab"); + } + + /// The seasons `GET /api/queues/attention` currently reports for a series. + async fn queued_seasons(base: &str, series_id: i64) -> Vec { + let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention")) + .await + .expect("queues") + .json() + .await + .expect("queues json"); + queues["tv_needs_decision"] + .as_array() + .expect("tv lane") + .iter() + .filter(|entry| entry["series_id"] == series_id) + .flat_map(|entry| { + entry["seasons"] + .as_array() + .expect("seasons") + .iter() + .map(|season| season["id"].as_i64().expect("season id")) + }) + .collect() + } + + /// §5.7: one failed pack is the blacklist working, not a decision. The + /// season lane holds to the same two-distinct-releases bar the episode + /// lane does (#226). + #[tokio::test] + async fn a_season_queues_only_on_two_distinct_release_failures() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let (series_id, season_id) = seed_bare_season(pool, 1).await; + + let first = insert_release(pool, "pack-one").await; + insert_failed_grab(pool, first, "season", season_id, "hash-one", 0).await; + assert!( + queued_seasons(&base, series_id).await.is_empty(), + "one failed pack is not a decision" + ); + + // A second failure on the *same* release is still one release. + insert_failed_grab(pool, first, "season", season_id, "hash-one-again", 0).await; + assert!( + queued_seasons(&base, series_id).await.is_empty(), + "two grabs of one release are not two releases" + ); + + let second = insert_release(pool, "pack-two").await; + insert_failed_grab(pool, second, "season", season_id, "hash-two", 0).await; + assert_eq!( + queued_seasons(&base, series_id).await, + vec![season_id], + "two distinct releases hard-failed: the operator decides" + ); + } + + /// §5.7: a failure counts for 30 days. A season the operator has dealt + /// with stops failing and leaves the queue; one still breaking keeps + /// producing failures and stays (#226). + #[tokio::test] + async fn a_season_failure_ages_out_of_the_attention_queue() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let (series_id, season_id) = seed_bare_season(pool, 1).await; + + for (guid, hash, age) in [ + ("old-one", "hash-old-one", 40), + ("old-two", "hash-old-two", 35), + ] { + let release_id = insert_release(pool, guid).await; + insert_failed_grab(pool, release_id, "season", season_id, hash, age).await; + } + assert!( + queued_seasons(&base, series_id).await.is_empty(), + "failures older than the window are history, not attention" + ); + + let fresh = insert_release(pool, "new-one").await; + insert_failed_grab(pool, fresh, "season", season_id, "hash-new-one", 0).await; + assert!( + queued_seasons(&base, series_id).await.is_empty(), + "one recent failure does not revive two stale ones" + ); + + let fresher = insert_release(pool, "new-two").await; + insert_failed_grab(pool, fresher, "season", season_id, "hash-new-two", 0).await; + assert_eq!( + queued_seasons(&base, series_id).await, + vec![season_id], + "still breaking: back in the queue" + ); + } + /// One series hitting all three §9.5 TV entry conditions: two wanted, /// searched episodes whose every candidate was rejected for language; a - /// season pack that hard-failed; and an episode two different releases - /// hard-failed on. + /// season two different packs hard-failed on; and an episode two different + /// releases hard-failed on. async fn seed_queued_series(pool: &sqlx::SqlitePool) -> (i64, i64, i64) { let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'") @@ -1818,6 +1984,7 @@ mod tests { .expect("episode id"); for (kind, guid, suffix) in [ ("season", "pack", "pack"), + ("season", "pack-two", "pack2"), ("episode", "first", "a"), ("episode", "second", "b"), ] { diff --git a/crates/arr-daemon/src/attention.rs b/crates/arr-daemon/src/attention.rs index 9012f30..b0fc8cb 100644 --- a/crates/arr-daemon/src/attention.rs +++ b/crates/arr-daemon/src/attention.rs @@ -2,6 +2,10 @@ //! the no-PT-source queue, or hard-failed twice on different releases (the //! same queues `GET /api/queues/attention` reports, §9.3). //! +//! §5.7 sets the bar for the hard-fail side: two failures on *different* +//! releases, both inside `arr_db::ATTENTION_WINDOW`. One bad torrent is not a +//! decision, and a failure already dealt with ages out (#226). +//! //! Edge-triggered per title: it notifies once when the title enters either //! queue, and is forgotten once it leaves both, so a future re-entry notifies //! again. A series notifies as its series, never per episode — a broken @@ -34,8 +38,8 @@ struct TvEntry { no_pt_source: Vec, /// Episodes two different releases hard-failed post-probe (§5.7). hard_failed_episodes: Vec, - /// Seasons whose pack grab hard-failed, sending the season back to - /// per-episode grabbing. + /// Seasons two different pack releases hard-failed on (§5.7), sending the + /// season back to per-episode grabbing. failed_season_packs: Vec, } @@ -59,8 +63,8 @@ impl TvEntry { if !self.failed_season_packs.is_empty() { parts.push(plural( self.failed_season_packs.len(), - "season pack hard-failed", - "season packs hard-failed", + "season hard-failed twice on different packs", + "seasons hard-failed twice on different packs", )); } parts.join("; ") @@ -134,8 +138,10 @@ impl AttentionAction { AND (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id - AND g.state = 'failed') >= 2 - "# + AND g.state = 'failed' + AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 + "#, + arr_db::ATTENTION_WINDOW ) .fetch_all(database.pool()) .await?; @@ -209,8 +215,8 @@ fn tv_entry( /// TV roll-up (§9.5): every queued series with what put it there — wanted /// episodes whose every candidate was rejected for language, episodes two -/// different releases hard-failed post-probe, and seasons whose pack grab -/// hard-failed. One entry per series, so the notification can be one per +/// different releases hard-failed post-probe, and seasons two different packs +/// hard-failed on. One entry per series, so the notification can be one per /// series however long the broken season is. async fn queue_tv(database: &Db) -> Result, TvEntry)>, sqlx::Error> { let mut tv = HashMap::new(); @@ -250,10 +256,12 @@ async fn queue_tv(database: &Db) -> Result, TvEntr JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE g.state = 'failed' + AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) AND e.wanted = 1 AND e.state != 'available' GROUP BY s.id, s.title, s.year, e.id HAVING count(DISTINCT g.release_id) >= 2 - "# + "#, + arr_db::ATTENTION_WINDOW ) .fetch_all(database.pool()) .await?; @@ -271,8 +279,11 @@ async fn queue_tv(database: &Db) -> Result, TvEntr JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id JOIN series s ON s.id = se.series_id WHERE g.state = 'failed' + AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) GROUP BY s.id, s.title, s.year, se.id - "# + HAVING count(DISTINCT g.release_id) >= 2 + "#, + arr_db::ATTENTION_WINDOW ) .fetch_all(database.pool()) .await?; @@ -382,6 +393,39 @@ mod tests { series_id } + /// A failed grab by `release_guid` against `target_kind`/`target_id`, + /// stamped `age_days` in the past, so §5.7's window can be exercised + /// without waiting a month. + async fn insert_aged_failed_grab( + database: &Db, + target_kind: &str, + target_id: i64, + release_guid: &str, + age_days: i64, + ) { + let release_id: i64 = sqlx::query_scalar( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) + VALUES (7, ?, 'release', 10737418240, 'https://tracker/x.torrent', '{}', 'eligible') + RETURNING id", + ) + .bind(release_guid) + .fetch_one(database.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at) + VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))", + ) + .bind(release_id) + .bind(target_kind) + .bind(target_id) + .bind(format!("hash-{release_guid}")) + .bind(format!("-{age_days} days")) + .execute(database.pool()) + .await + .unwrap(); + } + /// A failed grab by `release_guid` against `target_kind`/`target_id`, /// standing in for what the import tick leaves behind post-probe. async fn insert_failed_grab( @@ -548,9 +592,10 @@ mod tests { .unwrap(); insert_failed_grab(&database, "episode", episode_id, "first").await; insert_failed_grab(&database, "episode", episode_id, "second").await; - // The pack's failure sent this season back to per-episode grabbing; - // it queues the same series, so it must not double the message. + // The packs' failures sent this season back to per-episode grabbing; + // they queue the same series, so it must not double the message. insert_failed_grab(&database, "season", season_id, "pack").await; + insert_failed_grab(&database, "season", season_id, "pack-two").await; let server = MockServer::start().await; let action = action(&server).await; @@ -631,4 +676,66 @@ mod tests { assert_eq!(second.len(), 0, "leaves the queue once imported"); assert_eq!(server.received_requests().await.unwrap().len(), 1); } + + /// §5.7: the season lane holds to the same two-distinct-releases bar the + /// episode lane does, so one bad pack does not notify (#226). + #[tokio::test] + async fn one_failed_season_pack_does_not_notify() { + let (_dir, database) = seeded_database().await; + insert_no_pt_source_series(&database, 1, 0).await; + let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1") + .fetch_one(database.pool()) + .await + .unwrap(); + insert_failed_grab(&database, "season", season_id, "pack").await; + + let server = MockServer::start().await; + let action = action(&server).await; + + assert_eq!( + action.tick(&database).await.unwrap().len(), + 0, + "one failed pack is the blacklist working, not a decision" + ); + + insert_failed_grab(&database, "season", season_id, "pack-two").await; + + assert_eq!( + action.tick(&database).await.unwrap().len(), + 1, + "two distinct packs hard-failed: the operator decides" + ); + } + + /// §5.7: a failure counts for 30 days, so a season dealt with leaves the + /// queue instead of sitting in it forever (#226). + #[tokio::test] + async fn season_failures_older_than_the_window_do_not_notify() { + let (_dir, database) = seeded_database().await; + insert_no_pt_source_series(&database, 1, 0).await; + let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1") + .fetch_one(database.pool()) + .await + .unwrap(); + insert_aged_failed_grab(&database, "season", season_id, "old-one", 40).await; + insert_aged_failed_grab(&database, "season", season_id, "old-two", 35).await; + + let server = MockServer::start().await; + let action = action(&server).await; + + assert_eq!( + action.tick(&database).await.unwrap().len(), + 0, + "failures older than the window are history, not attention" + ); + + insert_aged_failed_grab(&database, "season", season_id, "new-one", 0).await; + insert_aged_failed_grab(&database, "season", season_id, "new-two", 0).await; + + assert_eq!( + action.tick(&database).await.unwrap().len(), + 1, + "still breaking: back in the queue" + ); + } } diff --git a/crates/arr-db/src/lib.rs b/crates/arr-db/src/lib.rs index 697edba..4ee13a9 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -17,6 +17,15 @@ use sqlx::{migrate::MigrateError, SqlitePool}; /// The migrations embedded in the binary, so a deploy is one file. pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); +/// §5.7: how long a failed grab keeps counting toward the needs-a-decision +/// queue, as a SQLite time modifier. +/// +/// Nothing ever clears a `grabs` row, so without a bound the queue only grows +/// and the one season that wants attention sits behind the ones that do not. +/// Callers pair it with the `grabbed_at` format: +/// `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ATTENTION_WINDOW)`. +pub const ATTENTION_WINDOW: &str = "-30 days"; + /// How long a writer waits for the write lock before giving up. const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); From efb47d64e7a5de5b34c9afcfbf2af3cc96ed6bba Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 10:20:37 +0100 Subject: [PATCH 08/27] feat(api): move title folders on root path change Changing a root's path rewrote the row and moved nothing, so every title under it was mislocated at once. It now reuses the #228 mover: plan every rename, refuse a destination that already exists, rewrite the media_files rows in the same transaction as the row change. The move is all or nothing. A root row carries one path, so a half-moved library would have to describe both places; instead one folder that cannot move puts back the ones that already did and leaves the root's path alone, and the same request is the retry. just ci ran clean through the gate: 498 tests passed. --- ...c2d970ad55a8fd62b12aadd68ee6a261ed41.json} | 4 +- ...69f08914d6faf42d21c1689d7e2872c7fdbaa.json | 38 ++ ...6056f40ae1b47098dffd02047a7b001ad0302.json | 38 ++ ...2a107963ba6ab2bcef2c8cfb9a1efbd905b59.json | 26 + DESIGN.md | 2 +- crates/arr-api/src/relocate.rs | 165 ++++-- crates/arr-api/src/roots.rs | 473 +++++++++++++++++- 7 files changed, 688 insertions(+), 58 deletions(-) rename .sqlx/{query-0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b.json => query-2231fa4a963a5f1a15dcdb835fb7c2d970ad55a8fd62b12aadd68ee6a261ed41.json} (50%) create mode 100644 .sqlx/query-73f978ab92e16183828ed15674f69f08914d6faf42d21c1689d7e2872c7fdbaa.json create mode 100644 .sqlx/query-ba0db4be362417f625fcede07f26056f40ae1b47098dffd02047a7b001ad0302.json create mode 100644 .sqlx/query-fa3d1e4a6cae94780daf8fe20062a107963ba6ab2bcef2c8cfb9a1efbd905b59.json diff --git a/.sqlx/query-0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b.json b/.sqlx/query-2231fa4a963a5f1a15dcdb835fb7c2d970ad55a8fd62b12aadd68ee6a261ed41.json similarity index 50% rename from .sqlx/query-0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b.json rename to .sqlx/query-2231fa4a963a5f1a15dcdb835fb7c2d970ad55a8fd62b12aadd68ee6a261ed41.json index dd8746a..2169d1b 100644 --- a/.sqlx/query-0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b.json +++ b/.sqlx/query-2231fa4a963a5f1a15dcdb835fb7c2d970ad55a8fd62b12aadd68ee6a261ed41.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?", + "query": "UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b" + "hash": "2231fa4a963a5f1a15dcdb835fb7c2d970ad55a8fd62b12aadd68ee6a261ed41" } diff --git a/.sqlx/query-73f978ab92e16183828ed15674f69f08914d6faf42d21c1689d7e2872c7fdbaa.json b/.sqlx/query-73f978ab92e16183828ed15674f69f08914d6faf42d21c1689d7e2872c7fdbaa.json new file mode 100644 index 0000000..280d7c3 --- /dev/null +++ b/.sqlx/query-73f978ab92e16183828ed15674f69f08914d6faf42d21c1689d7e2872c7fdbaa.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE s.root_id = ?\n ORDER BY mf.id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "media_files", + "name": "id" + } + } + }, + { + "name": "path!: String", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "media_files", + "name": "path" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "73f978ab92e16183828ed15674f69f08914d6faf42d21c1689d7e2872c7fdbaa" +} diff --git a/.sqlx/query-ba0db4be362417f625fcede07f26056f40ae1b47098dffd02047a7b001ad0302.json b/.sqlx/query-ba0db4be362417f625fcede07f26056f40ae1b47098dffd02047a7b001ad0302.json new file mode 100644 index 0000000..39977ba --- /dev/null +++ b/.sqlx/query-ba0db4be362417f625fcede07f26056f40ae1b47098dffd02047a7b001ad0302.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id\n WHERE m.root_id = ?\n ORDER BY mf.id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "media_files", + "name": "id" + } + } + }, + { + "name": "path!: String", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "media_files", + "name": "path" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "ba0db4be362417f625fcede07f26056f40ae1b47098dffd02047a7b001ad0302" +} diff --git a/.sqlx/query-fa3d1e4a6cae94780daf8fe20062a107963ba6ab2bcef2c8cfb9a1efbd905b59.json b/.sqlx/query-fa3d1e4a6cae94780daf8fe20062a107963ba6ab2bcef2c8cfb9a1efbd905b59.json new file mode 100644 index 0000000..a757088 --- /dev/null +++ b/.sqlx/query-fa3d1e4a6cae94780daf8fe20062a107963ba6ab2bcef2c8cfb9a1efbd905b59.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT id FROM roots WHERE path = ? AND id <> ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "roots", + "name": "id" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "fa3d1e4a6cae94780daf8fe20062a107963ba6ab2bcef2c8cfb9a1efbd905b59" +} diff --git a/DESIGN.md b/DESIGN.md index 584de2c..024913b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -505,7 +505,7 @@ Media kind first, hard audience boundary second, people nowhere. - **Release group is deliberately absent.** It is not a selection criterion and it makes filenames long enough to break a terminal. -Changing a title's root relocates its title folder into the new root; roots are assumed to share one filesystem, so the move is a rename, never a copy. +Changing a title's root relocates its title folder into the new root; roots are assumed to share one filesystem, so the move is a rename, never a copy. Changing a root's path is the same move over every title under it, and it is all or nothing: one folder that cannot move puts back the ones that already did and leaves the root's path alone, so the stored path always describes the disk. During transition, write into the existing roots so Jellyfin needs no reconfiguration and new content appears immediately. Radarr will not touch a diff --git a/crates/arr-api/src/relocate.rs b/crates/arr-api/src/relocate.rs index f1d3f6f..5f9ad6b 100644 --- a/crates/arr-api/src/relocate.rs +++ b/crates/arr-api/src/relocate.rs @@ -1,7 +1,8 @@ -//! Moving a title between roots (issue #228). Changing a title's `root_id` -//! renames its §7.4 folder into the new root and rewrites the `media_files` -//! rows to match, so the layout keeps describing the disk and the new root's -//! policy (§5.1) applies to a library the files are actually in. +//! Moving library files when the layout under them changes: a title changing +//! its `root_id` (issue #228), and a root changing its `path` (issue #236). +//! Both rename §7.4 folders and rewrite the `media_files` rows to match, so +//! the layout keeps describing the disk and the root's policy (§5.1) applies +//! to a library the files are actually in. //! //! Every root shares one filesystem — one ZFS dataset, bind-mounted — so this //! is a directory rename, never a copy. Hardlinked files keep their inodes @@ -10,6 +11,16 @@ //! Ordering mirrors `remove_library_files`: the disk is touched before the //! row changes, so a failed rename leaves the title where its files actually //! are and the operator can retry. +//! +//! A root path change is the same move repeated over every title under the +//! root, and it is all or nothing. If the seventh of ten folders fails to +//! move, the six already renamed are moved back and the root row is left +//! alone: a root row carries one path, so a half-moved library would have to +//! describe both, and neither the operator nor the next import could tell +//! which titles were where. Refusing leaves one answer — everything is still +//! at the old path — and the retry is the same request again. A retry after +//! an undo that itself failed still converges, because a source that is no +//! longer on disk is skipped while its row is still rewritten. use std::path::PathBuf; @@ -40,6 +51,15 @@ pub(crate) struct Relocation { rewrites: Vec<(i64, String)>, } +/// Whether the destination root is a directory that must already be there. +/// A title moves into another configured root, which exists; a root moving to +/// a new path is moving somewhere that need not exist yet. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Destination { + Existing, + Create, +} + /// Rename the title's folders into the new root. Called before the row is /// written, and only when the root actually changes. /// @@ -62,11 +82,45 @@ pub(crate) async fn relocate_title( let old_root = root_path(state, old_root_id).await?; let new_root = root_path(state, new_root_id).await?; let files = title_files(state, kind, title_id).await?; + relocate_files(&files, &old_root, &new_root, Destination::Existing).await +} +/// Rename every title folder under a root into the root's new path, for a +/// `PUT /api/roots/{id}` that changes `path` (issue #236). +/// +/// A root with no titles under it — or whose titles have nothing on disk — +/// changes path with no filesystem work at all. The new path is created when +/// there is something to move into it, since a root is normally pointed at a +/// directory that does not exist yet. +/// +/// # Errors +/// +/// The same two as [`relocate_title`], with the whole root's move treated as +/// one unit: one folder that cannot move takes the entire change down and +/// moves back whatever had already moved. +pub(crate) async fn relocate_root( + state: &AppState, + root_id: i64, + old_path: &str, + new_path: &str, +) -> Result { + let files = root_files(state, root_id).await?; + relocate_files(&files, old_path, new_path, Destination::Create).await +} + +/// The one mover both callers share: plan every rename, refuse every +/// destination that already exists, then perform them, undoing what was +/// performed if one fails. +async fn relocate_files( + files: &[(i64, String)], + old_root: &str, + new_root: &str, + destination: Destination, +) -> Result { let mut renames: Vec = Vec::new(); let mut rewrites: Vec<(i64, String)> = Vec::new(); - for (file_id, path) in &files { - let Some(source) = title_target(&old_root, path) else { + for (file_id, path) in files { + let Some(source) = title_target(old_root, path) else { // Outside its own root: not ours to move, and the row keeps // pointing at where the file really is. tracing::warn!(%path, %old_root, "media file is outside its root, not moved"); @@ -75,7 +129,7 @@ pub(crate) async fn relocate_title( let Some(name) = source.file_name() else { continue; }; - let destination = std::path::Path::new(&new_root).join(name); + let destination = std::path::Path::new(new_root).join(name); if !renames.iter().any(|rename| rename.source == source) { renames.push(PlannedRename { source, @@ -83,9 +137,9 @@ pub(crate) async fn relocate_title( }); } let relative = std::path::Path::new(path) - .strip_prefix(std::path::Path::new(&old_root)) + .strip_prefix(std::path::Path::new(old_root)) .map_err(|error| ApiError::Filesystem(error.to_string()))?; - let rewritten = std::path::Path::new(&new_root).join(relative); + let rewritten = std::path::Path::new(new_root).join(relative); let Some(rewritten) = rewritten.to_str() else { return Err(ApiError::Filesystem(format!( "non-UTF-8 path under {new_root}" @@ -95,12 +149,12 @@ pub(crate) async fn relocate_title( } // Every destination is checked before anything is renamed, so a conflict - // never leaves a half-moved title behind. + // never leaves a half-moved library behind. for rename in &renames { match tokio::fs::symlink_metadata(&rename.destination).await { Ok(_) => { return Err(ApiError::Conflict(format!( - "the destination root already has '{}'", + "the destination already has '{}'", rename.destination.display() ))) } @@ -109,6 +163,14 @@ pub(crate) async fn relocate_title( } } + if destination == Destination::Create && !renames.is_empty() { + if let Err(error) = tokio::fs::create_dir_all(new_root).await { + return Err(ApiError::Filesystem(format!( + "could not create '{new_root}': {error}" + ))); + } + } + let mut performed: Vec = Vec::new(); for rename in renames { // A recorded file with nothing on disk: the rows still follow the @@ -123,36 +185,18 @@ pub(crate) async fn relocate_title( ); continue; } - Err(error) => { - let failed = ApiError::Filesystem(error.to_string()); - Relocation { - performed, - rewrites: Vec::new(), - } - .undo() - .await; - return Err(failed); - } + Err(error) => return Err(failed(&rename, &error, performed).await), } match tokio::fs::rename(&rename.source, &rename.destination).await { Ok(()) => { tracing::info!( source = %rename.source.display(), destination = %rename.destination.display(), - "moved title folder between roots" + "moved a title folder" ); performed.push(rename); } - Err(error) => { - let failed = ApiError::Filesystem(error.to_string()); - Relocation { - performed, - rewrites: Vec::new(), - } - .undo() - .await; - return Err(failed); - } + Err(error) => return Err(failed(&rename, &error, performed).await), } } @@ -162,6 +206,26 @@ pub(crate) async fn relocate_title( }) } +/// One rename failed: move back everything that had already moved and name +/// the folder that stopped the move, so the operator knows which title to +/// look at before retrying. +async fn failed( + rename: &PlannedRename, + error: &std::io::Error, + performed: Vec, +) -> ApiError { + Relocation { + performed, + rewrites: Vec::new(), + } + .undo() + .await; + ApiError::Filesystem(format!( + "could not move '{}': {error}", + rename.source.display() + )) +} + impl Relocation { /// Whether anything on disk actually moved — the trigger for the same /// single Jellyfin refresh import performs (§7.5). @@ -224,6 +288,43 @@ async fn root_path(state: &AppState, root_id: i64) -> Result { .await?) } +/// Every file the service recorded under a root: the rows of every movie in +/// it, and the rows of every episode of every series in it. Ordered so the +/// renames happen in a stable order, which is what makes a failure part-way +/// through reproducible. +async fn root_files(state: &AppState, root_id: i64) -> Result, ApiError> { + let mut files: Vec<(i64, String)> = sqlx::query!( + r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String" + FROM media_files mf + JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id + WHERE m.root_id = ? + ORDER BY mf.id"#, + root_id + ) + .fetch_all(pool(state)?) + .await? + .into_iter() + .map(|row| (row.id, row.path)) + .collect(); + files.extend( + sqlx::query!( + r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String" + FROM media_files mf + JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id + JOIN seasons se ON se.id = e.season_id + JOIN series s ON s.id = se.series_id + WHERE s.root_id = ? + ORDER BY mf.id"#, + root_id + ) + .fetch_all(pool(state)?) + .await? + .into_iter() + .map(|row| (row.id, row.path)), + ); + Ok(files) +} + /// Every file the service recorded for the title: a movie's own rows, or the /// rows of every episode below a series. async fn title_files( diff --git a/crates/arr-api/src/roots.rs b/crates/arr-api/src/roots.rs index d193843..8ce47e8 100644 --- a/crates/arr-api/src/roots.rs +++ b/crates/arr-api/src/roots.rs @@ -172,26 +172,74 @@ pub async fn update( let input = parsed(body)?; input.validate().map_err(ApiError::Invalid)?; input.policy_exists(&state).await?; + let current = load_root(&state, id).await?; let path = input.path.trim().to_owned(); - let result = sqlx::query!( - r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?, - updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') - WHERE id = ?"#, - input.kind, - input.audience, - path, - input.policy_id, - id, - ) - .execute(pool(&state)?) - .await - .map_err(root_conflict)?; - if result.rows_affected() == 0 { - return Err(ApiError::RootNotFound); + // A path change moves every §7.4 title folder under this root with the + // row (issue #236), the same way changing a title's root moves one + // (#228). Disk first, row second: a failed rename leaves the root row + // alone, so the operator sees the library where its files actually are + // and can retry. A path already taken is refused before any of it, since + // the write would fail afterwards anyway. + let relocation = if path == current.path { + None + } else { + path_is_free(&state, id, &path).await?; + Some(crate::relocate::relocate_root(&state, id, ¤t.path, &path).await?) + }; + let mut transaction = pool(&state)?.begin().await?; + let written: Result<(), sqlx::Error> = async { + sqlx::query!( + r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ?"#, + input.kind, + input.audience, + path, + input.policy_id, + id, + ) + .execute(&mut *transaction) + .await?; + if let Some(relocation) = &relocation { + relocation.rewrite_rows(&mut transaction).await?; + } + Ok(()) + } + .await; + let committed = match written { + Ok(()) => transaction.commit().await.map_err(root_conflict), + Err(error) => Err(root_conflict(error)), + }; + if let Err(error) = committed { + if let Some(relocation) = &relocation { + relocation.undo().await; + } + return Err(error); + } + if relocation + .as_ref() + .is_some_and(crate::relocate::Relocation::moved_files) + { + crate::relocate::refresh_jellyfin(&state).await; } Ok(Json(load_root(&state, id).await?)) } +/// The unique index on `path` would catch this after the move; catching it +/// first keeps a doomed write from touching the disk at all. +async fn path_is_free(state: &AppState, id: i64, path: &str) -> Result<(), ApiError> { + let taken: Option = + sqlx::query_scalar!("SELECT id FROM roots WHERE path = ? AND id <> ?", path, id) + .fetch_optional(pool(state)?) + .await?; + if taken.is_some() { + return Err(ApiError::Conflict( + "a root with this path already exists".into(), + )); + } + Ok(()) +} + /// A duplicate path or a duplicate (kind, audience) pair is a settings /// mistake the operator can fix, not a server fault. fn root_conflict(error: sqlx::Error) -> ApiError { @@ -258,7 +306,7 @@ mod tests { use crate::{router, AppState, Upstreams}; use axum::http::StatusCode; - async fn application() -> (tempfile::TempDir, String) { + async fn application() -> (tempfile::TempDir, AppState, String) { let dir = tempfile::tempdir().expect("tempdir"); let database = arr_db::Db::connect(dir.path().join("arr.db")) .await @@ -274,14 +322,14 @@ mod tests { .await .expect("bind"); let address = listener.local_addr().expect("address"); - let app = router(state); + let app = router(state.clone()); tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); - (dir, format!("http://{address}")) + (dir, state, format!("http://{address}")) } #[tokio::test] async fn roots_carry_their_policy_name() { - let (_dir, base) = application().await; + let (_dir, _state, base) = application().await; let roots: Vec = reqwest::get(format!("{base}/api/roots")) .await .expect("roots") @@ -325,7 +373,7 @@ mod tests { #[tokio::test] async fn a_root_round_trips_through_create_and_update() { - let (_dir, base) = application().await; + let (_dir, _state, base) = application().await; let policy_ids = first_policy_ids(&base).await; let roots: Vec = reqwest::get(format!("{base}/api/roots")) .await @@ -399,7 +447,7 @@ mod tests { #[tokio::test] async fn an_unknown_kind_or_policy_is_a_422_naming_the_field() { - let (_dir, base) = application().await; + let (_dir, _state, base) = application().await; let policy_ids = first_policy_ids(&base).await; for (mut payload, field) in [ @@ -428,7 +476,7 @@ mod tests { #[tokio::test] async fn a_duplicate_path_or_pair_conflicts() { - let (_dir, base) = application().await; + let (_dir, _state, base) = application().await; let mut path = root_input(1); path["audience"] = serde_json::json!("kids"); path["path"] = serde_json::json!("/mnt/media/movies/kids"); @@ -454,7 +502,7 @@ mod tests { #[tokio::test] async fn a_root_with_titles_refuses_to_die() { - let (_dir, base) = application().await; + let (_dir, _state, base) = application().await; let roots: Vec = reqwest::get(format!("{base}/api/roots")) .await .expect("roots") @@ -495,4 +543,383 @@ mod tests { .expect("delete empty root"); assert_eq!(response.status(), StatusCode::NO_CONTENT); } + + async fn point_root_at(state: &AppState, root_id: i64, path: &std::path::Path) { + sqlx::query("UPDATE roots SET path = ? WHERE id = ?") + .bind(path.to_str().expect("utf-8 root")) + .bind(root_id) + .execute(state.database().expect("database").pool()) + .await + .expect("point the root at the tempdir"); + } + + async fn add_movie(base: &str, tmdb_id: i64, title: &str, root_id: i64) -> i64 { + let created: serde_json::Value = reqwest::Client::new() + .post(format!("{base}/api/movies")) + .json(&serde_json::json!({ + "tmdb_id": tmdb_id, "title": title, "year": 2024, + "original_language": "en", "root_id": root_id, + })) + .send() + .await + .expect("create movie") + .json() + .await + .expect("movie json"); + created["id"].as_i64().expect("movie id") + } + + /// One movie's §7.4 folder on disk with a feature in it, and the + /// `media_files` row that points at the feature. + async fn library_folder( + state: &AppState, + movie_id: i64, + root: &std::path::Path, + folder: &str, + ) -> std::path::PathBuf { + let folder = root.join(folder); + tokio::fs::create_dir_all(&folder) + .await + .expect("create title folder"); + let feature = folder.join("feature.mkv"); + tokio::fs::write(&feature, b"feature").await.expect("write"); + sqlx::query( + "INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('movie', ?, ?, 7)", + ) + .bind(movie_id) + .bind(feature.to_str().expect("utf-8 path")) + .execute(state.database().expect("database").pool()) + .await + .expect("media file"); + folder + } + + async fn root_payload(base: &str, id: i64, path: &str) -> serde_json::Value { + let root: serde_json::Value = reqwest::get(format!("{base}/api/roots/{id}")) + .await + .expect("root") + .json() + .await + .expect("root json"); + serde_json::json!({ + "kind": root["kind"], + "audience": root["audience"], + "path": path, + "policy_id": root["policy_id"], + }) + } + + async fn file_paths(state: &AppState) -> Vec { + sqlx::query_scalar("SELECT path FROM media_files ORDER BY id") + .fetch_all(state.database().expect("database").pool()) + .await + .expect("media files") + } + + async fn stored_path(base: &str, id: i64) -> String { + let root: serde_json::Value = reqwest::get(format!("{base}/api/roots/{id}")) + .await + .expect("root") + .json() + .await + .expect("root json"); + root["path"].as_str().expect("path").to_owned() + } + + /// Issue #236: changing a root's path renames every §7.4 title folder + /// under it into the new path and rewrites the `media_files` rows in the + /// same write. Titles under other roots are not this root's business. + #[tokio::test] + async fn changing_a_root_path_moves_every_title_under_it() { + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let other = tempfile::tempdir().expect("other root"); + point_root_at(&state, 1, old.path()).await; + point_root_at(&state, 2, other.path()).await; + + let mut folders = Vec::new(); + for (tmdb_id, title) in [(100, "Dune"), (101, "Arrival"), (102, "Sicario")] { + let id = add_movie(&base, tmdb_id, title, 1).await; + folders.push(library_folder(&state, id, old.path(), title).await); + } + let elsewhere = add_movie(&base, 200, "Prisoners", 2).await; + let untouched = library_folder(&state, elsewhere, other.path(), "Prisoners").await; + + // The new path need not exist yet — pointing a root somewhere fresh + // is the ordinary case. + let new = old.path().parent().expect("parent").join("relocated-main"); + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await) + .send() + .await + .expect("move the root"); + assert_eq!(response.status(), StatusCode::OK); + + for folder in &folders { + assert!(!folder.exists(), "{} left the old path", folder.display()); + } + for title in ["Dune", "Arrival", "Sicario"] { + assert!( + new.join(title).join("feature.mkv").exists(), + "{title} arrived under the new path" + ); + } + assert!( + untouched.join("feature.mkv").exists(), + "a title under another root is untouched" + ); + + let paths = file_paths(&state).await; + for path in paths.iter().take(3) { + assert!( + std::path::Path::new(path).starts_with(&new), + "the row follows the file: {path}" + ); + assert!( + std::path::Path::new(path).exists(), + "the rewritten path describes the disk: {path}" + ); + } + assert!( + std::path::Path::new(&paths[3]).starts_with(other.path()), + "the other root's row is untouched: {}", + paths[3] + ); + assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8")); + + tokio::fs::remove_dir_all(&new).await.expect("clean up"); + } + + /// Issue #236: a root with nothing under it changes path with no + /// filesystem work at all — the new path is not even created. + #[tokio::test] + async fn an_empty_root_changes_path_with_no_filesystem_work() { + let (_dir, state, base) = application().await; + let home = tempfile::tempdir().expect("home"); + let new = home.path().join("nothing-here"); + + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await) + .send() + .await + .expect("move the root"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8")); + assert!(!new.exists(), "nothing was created on disk"); + assert!(file_paths(&state).await.is_empty()); + } + + /// Issue #236: a destination already holding a folder of that name is a + /// conflict, not an overwrite, and it is caught before anything moves. + #[tokio::test] + async fn a_squatted_destination_refuses_the_whole_move() { + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let new = tempfile::tempdir().expect("new root"); + point_root_at(&state, 1, old.path()).await; + + let first = add_movie(&base, 100, "Dune", 1).await; + let second = add_movie(&base, 101, "Arrival", 1).await; + let dune = library_folder(&state, first, old.path(), "Dune").await; + let arrival = library_folder(&state, second, old.path(), "Arrival").await; + tokio::fs::create_dir_all(new.path().join("Arrival")) + .await + .expect("squatter"); + + let before = file_paths(&state).await; + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await) + .send() + .await + .expect("move the root"); + assert_eq!(response.status(), StatusCode::CONFLICT); + + assert!(dune.exists(), "not even the first folder moved"); + assert!(arrival.exists()); + assert_eq!(file_paths(&state).await, before, "the rows are untouched"); + assert_eq!( + stored_path(&base, 1).await, + old.path().to_str().expect("utf-8"), + "the root still points where the files are" + ); + } + + /// Issue #236: the move is all or nothing. A folder that cannot be + /// renamed part-way through takes the whole change down: what had already + /// moved is moved back, the root row keeps the old path, and the rows + /// still describe the disk, so the operator can fix the folder and send + /// the same request again. + #[cfg(unix)] + #[tokio::test] + async fn one_folder_that_cannot_move_puts_the_others_back() { + use std::os::unix::fs::PermissionsExt; + + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let home = tempfile::tempdir().expect("home of the new path"); + let new = home.path().join("relocated-main"); + point_root_at(&state, 1, old.path()).await; + + let mut folders = Vec::new(); + for (tmdb_id, title) in [(100, "Dune"), (101, "Arrival"), (102, "Sicario")] { + let id = add_movie(&base, tmdb_id, title, 1).await; + folders.push(library_folder(&state, id, old.path(), title).await); + } + // Moving a directory to another parent rewrites its `..`, which needs + // write permission on the directory itself: the second title cannot + // move, the first already has. + let stuck = folders[1].clone(); + tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555)) + .await + .expect("freeze the second title folder"); + + let before = file_paths(&state).await; + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await) + .send() + .await + .expect("move the root"); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let body: serde_json::Value = response.json().await.expect("error body"); + assert!( + body["error"] + .as_str() + .expect("text") + .contains(stuck.to_str().expect("utf-8")), + "the error names the folder that stopped the move: {body}" + ); + + tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755)) + .await + .expect("thaw the second title folder"); + for folder in &folders { + assert!( + folder.join("feature.mkv").exists(), + "{} is back where the row says it is", + folder.display() + ); + } + assert!( + new.exists(), + "the move reached the disk: the new path was created for it" + ); + assert!( + !new.join("Dune").exists(), + "the folder that had already moved was moved back" + ); + assert_eq!(file_paths(&state).await, before, "the rows are untouched"); + assert_eq!( + stored_path(&base, 1).await, + old.path().to_str().expect("utf-8"), + "the root still points where the files are" + ); + + // The same request again, with the folder fixed, is the retry path. + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await) + .send() + .await + .expect("retry the move"); + assert_eq!(response.status(), StatusCode::OK); + for title in ["Dune", "Arrival", "Sicario"] { + assert!(new.join(title).join("feature.mkv").exists()); + } + } + + /// Issue #236: a TV root carries series folders, whose files hang off + /// episodes rather than off the title row. They move with the root too. + #[tokio::test] + async fn a_tv_root_moves_its_series_folders() { + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let home = tempfile::tempdir().expect("home of the new path"); + let new = home.path().join("relocated-tv"); + point_root_at(&state, 3, old.path()).await; + + let pool = state.database().expect("database").pool(); + sqlx::query( + "INSERT INTO series (id, tmdb_id, title, year, original_language, root_id) + VALUES (1, 82728, 'Bluey', 2018, 'en', 3)", + ) + .execute(pool) + .await + .expect("series"); + sqlx::query("INSERT INTO seasons (id, series_id, number) VALUES (1, 1, 1)") + .execute(pool) + .await + .expect("season"); + sqlx::query( + "INSERT INTO episodes (id, season_id, number, title) VALUES (1, 1, 2, 'Hospital')", + ) + .execute(pool) + .await + .expect("episode"); + let season = old + .path() + .join("Bluey (2018) [tmdbid-82728]") + .join("Season 01"); + tokio::fs::create_dir_all(&season) + .await + .expect("create season folder"); + let episode = season.join("Bluey - S01E02.mkv"); + tokio::fs::write(&episode, b"episode").await.expect("write"); + sqlx::query( + "INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', 1, ?, 9)", + ) + .bind(episode.to_str().expect("utf-8 path")) + .execute(pool) + .await + .expect("media file"); + + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/3")) + .json(&root_payload(&base, 3, new.to_str().expect("utf-8")).await) + .send() + .await + .expect("move the root"); + assert_eq!(response.status(), StatusCode::OK); + + assert!( + new.join("Bluey (2018) [tmdbid-82728]") + .join("Season 01") + .join("Bluey - S01E02.mkv") + .exists(), + "the whole series folder moved, season layout intact" + ); + let paths = file_paths(&state).await; + assert!( + std::path::Path::new(&paths[0]).starts_with(&new) + && std::path::Path::new(&paths[0]).exists(), + "the episode row follows the file: {}", + paths[0] + ); + } + + /// Issue #236: a path another root already holds is refused before the + /// disk is touched at all. + #[tokio::test] + async fn a_path_another_root_holds_is_refused_before_any_move() { + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let taken = tempfile::tempdir().expect("taken root"); + point_root_at(&state, 1, old.path()).await; + point_root_at(&state, 2, taken.path()).await; + let id = add_movie(&base, 100, "Dune", 1).await; + let folder = library_folder(&state, id, old.path(), "Dune").await; + + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, taken.path().to_str().expect("utf-8")).await) + .send() + .await + .expect("move the root"); + assert_eq!(response.status(), StatusCode::CONFLICT); + assert!(folder.exists(), "nothing moved"); + assert!(!taken.path().join("Dune").exists()); + } } From 690eaeda5cc5932cff42a772c6b4180381ec56ff Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 10:29:38 +0100 Subject: [PATCH 09/27] fix(api): let a stranded folder be retried, not refused Two findings from the integration review of this milestone, both caused by two sessions editing the same code without seeing each other. The retry that relocate.rs documents did not converge. The conflict pre-check ran over every planned rename, including renames whose source was already gone, and the skip for a missing source came after it. An undo is best-effort, so a failed move can leave one folder at the destination with its row still naming the source; every later attempt then 409'd against the operator's own half-moved library and the only way out was moving the folder back by hand. The pre-check now skips a rename whose source is absent, which is what the perform loop already did. Verified: the new test returns 409 without the change and 200 with. ApiError::Filesystem rendered as "files not removed: {error}". That was written for the delete lane; #228 and #236 then returned the same variant for move failures, so a root path change with one unwritable folder reported "files not removed" after an operation that removed nothing. The variant now renders the caller's message and the two delete lanes carry their own context. --- crates/arr-api/src/movies.rs | 20 +++++++++---- crates/arr-api/src/relocate.rs | 12 +++++++- crates/arr-api/src/roots.rs | 53 ++++++++++++++++++++++++++++++++++ crates/arr-api/src/series.rs | 12 ++++++-- 4 files changed, 88 insertions(+), 9 deletions(-) diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 3b12803..70e8012 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -199,12 +199,12 @@ impl IntoResponse for ApiError { tracing::error!(%error, "API database error"); (StatusCode::INTERNAL_SERVER_ERROR, "database error".into()) } + // The message is the caller's: this variant is returned by the + // delete lane and by the relocate lane, and "files not removed" + // is a lie about a move that failed. Self::Filesystem(error) => { tracing::error!(%error, "API filesystem error"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("files not removed: {error}"), - ) + (StatusCode::INTERNAL_SERVER_ERROR, error.clone()) } }; (status, Json(ErrorBody { error })).into_response() @@ -570,7 +570,11 @@ 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(error.to_string())), + Err(error) => { + return Err(ApiError::Filesystem(format!( + "files not removed: {error}" + ))) + } }; let removed = if metadata.is_dir() { tokio::fs::remove_dir_all(&target).await @@ -580,7 +584,11 @@ 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(error.to_string())), + Err(error) => { + return Err(ApiError::Filesystem(format!( + "files not removed: {error}" + ))) + } } } Ok(()) diff --git a/crates/arr-api/src/relocate.rs b/crates/arr-api/src/relocate.rs index 5f9ad6b..f59eda0 100644 --- a/crates/arr-api/src/relocate.rs +++ b/crates/arr-api/src/relocate.rs @@ -149,8 +149,18 @@ async fn relocate_files( } // Every destination is checked before anything is renamed, so a conflict - // never leaves a half-moved library behind. + // never leaves a half-moved library behind. A rename whose source is + // already gone is skipped here rather than refused: the perform loop below + // skips it too, and refusing it would make the documented retry + // impossible. After an undo that itself failed, the folder sits at the + // destination while the row still names the source, and every later + // attempt would 409 on a conflict with the operator's own half-moved + // library. for rename in &renames { + match tokio::fs::symlink_metadata(&rename.source).await { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + _ => {} + } match tokio::fs::symlink_metadata(&rename.destination).await { Ok(_) => { return Err(ApiError::Conflict(format!( diff --git a/crates/arr-api/src/roots.rs b/crates/arr-api/src/roots.rs index 8ce47e8..ede08e6 100644 --- a/crates/arr-api/src/roots.rs +++ b/crates/arr-api/src/roots.rs @@ -747,6 +747,59 @@ mod tests { ); } + /// Integration review of the Feedback pass 2 blitz: the retry the module + /// documents has to actually converge. An undo is best-effort, so a move + /// can fail and leave one folder at the destination with its row still + /// naming the source. Every later attempt used to 409 against the + /// operator's own half-moved library, and the only way out was moving the + /// folder back by hand. + #[tokio::test] + async fn a_folder_left_at_the_destination_by_a_failed_undo_does_not_block_the_retry() { + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let new = tempfile::tempdir().expect("new root"); + point_root_at(&state, 1, old.path()).await; + + let first = add_movie(&base, 100, "Dune", 1).await; + let second = add_movie(&base, 101, "Arrival", 1).await; + let dune = library_folder(&state, first, old.path(), "Dune").await; + let arrival = library_folder(&state, second, old.path(), "Arrival").await; + + // What a failed undo leaves behind: Arrival is already at the new + // path, its row still points at the old one, and the root row was + // never changed. + tokio::fs::create_dir_all(new.path()) + .await + .expect("the new root"); + tokio::fs::rename(&arrival, new.path().join("Arrival")) + .await + .expect("strand the folder"); + assert!(!arrival.exists()); + + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await) + .send() + .await + .expect("retry the move"); + assert_eq!( + response.status(), + StatusCode::OK, + "the stranded folder is the state we wanted, not a conflict" + ); + + assert!(!dune.exists(), "the folder still on disk moved"); + assert!(new.path().join("Dune").exists()); + assert!(new.path().join("Arrival").exists(), "left where it was"); + let paths = file_paths(&state).await; + let root = new.path().to_str().expect("utf-8"); + assert!( + paths.iter().all(|path| path.starts_with(root)), + "every row follows the root, the stranded one included: {paths:?}" + ); + assert_eq!(stored_path(&base, 1).await, root); + } + /// Issue #236: the move is all or nothing. A folder that cannot be /// renamed part-way through takes the whole change down: what had already /// moved is moved back, the root row keeps the old path, and the rows diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 453f989..00cd8b5 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -734,7 +734,11 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(), 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(error.to_string())), + Err(error) => { + return Err(ApiError::Filesystem(format!( + "files not removed: {error}" + ))) + } }; let removed = if metadata.is_dir() { tokio::fs::remove_dir_all(&target).await @@ -744,7 +748,11 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(), 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(error.to_string())), + Err(error) => { + return Err(ApiError::Filesystem(format!( + "files not removed: {error}" + ))) + } } } Ok(()) From 815a072ef0e86c80163d1c8bbeca0a1c3bc99dd1 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 10:40:37 +0100 Subject: [PATCH 10/27] fix(web): drop back button from movie and series pages (#229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail, browser back, and Esc already cover navigation — the back button was a third way to do what two other things already do, and it cost the page its first line. - Remove #movie-back and #series-back buttons and their event listeners - Focus lands on the title element (tabindex=-1) instead of the removed button on open - TV releases back button is untouched - Hero banner now meets the rail with zero top padding on movie/series - Esc and parent-route behaviour unchanged - Deep link fallback for TV releases uses #nav-library instead of the removed #series-back --- web/index.html | 10 ++-------- web/src/main.ts | 18 +++++++----------- web/src/style.css | 6 ++++++ 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/web/index.html b/web/index.html index dd45e56..4a56b13 100644 --- a/web/index.html +++ b/web/index.html @@ -218,10 +218,7 @@ -->
-
- - -
+
@@ -470,10 +467,7 @@ -->
-
- - -
+
diff --git a/web/src/main.ts b/web/src/main.ts index 410aa33..4dc735c 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -393,14 +393,14 @@ function main() { must("#library"), { kind: "library" }, ); - // no origin click to restore focus to on a deep link — the series - // view's back control is the closest stand-in + // no origin click to restore focus to on a deep link — the library + // rail button is the closest stand-in tvDeck.open({ title, sub, seriesId, target, - origin: must("#series-back"), + origin: must("#nav-library"), returnTo: must("#series"), parentRoute: { kind: "series", seriesId }, }); @@ -1294,7 +1294,6 @@ function externalLink(label: string, href: string): HTMLAnchorElement { function movieMain(views: HideableView[]): MovieView { const view = must("#movie"); const deckEl = must("#deck"); - const back = must("#movie-back"); const statusEl = must("#movie-status"); const hero = must("#movie-hero"); const poster = must("#movie-poster"); @@ -1906,7 +1905,8 @@ function movieMain(views: HideableView[]): MovieView { filesSection.hidden = true; diskRows.replaceChildren(); sweep.disabled = false; - back.focus(); + titleEl.setAttribute("tabindex", "-1"); + titleEl.focus(); await load(); } @@ -1929,8 +1929,6 @@ function movieMain(views: HideableView[]): MovieView { target?.focus(); } - back.addEventListener("click", close); - // capture + stopImmediatePropagation: one Escape steps back one layer — // the library and search decks also listen for Escape on this window window.addEventListener( @@ -3125,7 +3123,6 @@ const SERIES_REFRESH_WAIT_MS = 30_000; function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { const view = must("#series"); const deckEl = must("#deck"); - const back = must("#series-back"); const hero = must("#series-hero"); const poster = must("#series-poster"); const titleEl = must("#series-title"); @@ -3793,7 +3790,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { view.hidden = false; clearRemove(); clearRichDetail(); - back.focus(); + titleEl.setAttribute("tabindex", "-1"); + titleEl.focus(); await load(); } @@ -3814,8 +3812,6 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { origin?.focus(); } - back.addEventListener("click", close); - // capture, like every other layer: the tv deck's listener is registered // first, so one Esc steps back one layer window.addEventListener( diff --git a/web/src/style.css b/web/src/style.css index 8a0ca95..efe15b9 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -528,6 +528,12 @@ body { flex: 1; } +/* movie and series: the hero banner meets the rail — no top padding (#229) */ +.deck#movie, +.deck#series { + padding-top: 0; +} + .deck-status { margin: 0 0 var(--space-4); color: var(--ink-muted); From 50056a2bd91c2aafa9572b1f5c1c3d1c0b987e09 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 10:48:56 +0100 Subject: [PATCH 11/27] style(api): reformat two error arms Pre-existing rustfmt drift on the branch base; `just ci` fails on it before reaching anything else. --- crates/arr-api/src/series.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 00cd8b5..6ee9020 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -734,11 +734,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(), 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 @@ -748,11 +744,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(), 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(()) From 6847d25cf501be9a813b894ca301b308785f88bc Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 10:49:02 +0100 Subject: [PATCH 12/27] 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; From 6fa88b8f1fdf23f119ac5f5de590cce8dde15e3c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 10:56:06 +0100 Subject: [PATCH 13/27] fix(api): trailing-slash self-conflict and stray relocation dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A root path differing only by a trailing separator now normalises to the same value on create and update, so PUT no longer treats a no-op edit as a relocation whose destinations conflict with their own sources. A failed root-path move now removes the new root directory it created for that move, but only when it created it — a directory that already existed at the destination is left alone, matching the retry guarantee relocate.rs documents. Refs #243 --- crates/arr-api/src/relocate.rs | 39 ++++++++-- crates/arr-api/src/roots.rs | 129 +++++++++++++++++++++++++++++++-- 2 files changed, 155 insertions(+), 13 deletions(-) diff --git a/crates/arr-api/src/relocate.rs b/crates/arr-api/src/relocate.rs index f59eda0..3641efa 100644 --- a/crates/arr-api/src/relocate.rs +++ b/crates/arr-api/src/relocate.rs @@ -49,6 +49,11 @@ struct PlannedRename { pub(crate) struct Relocation { performed: Vec, rewrites: Vec<(i64, String)>, + /// The new root directory, set only when this request created it (it did + /// not already exist). [`Self::undo`] removes it, so it is cleaned up + /// exactly when the move it was created for does not complete; a move + /// that commits never calls `undo` and the directory stays. + created_root: Option, } /// Whether the destination root is a directory that must already be there. @@ -173,12 +178,20 @@ async fn relocate_files( } } + // Tracked only when this call is the one that created the directory, so + // a failed move can remove it again without ever touching a root path + // that already existed on disk. + let mut created_root: Option = None; if destination == Destination::Create && !renames.is_empty() { + let already_there = tokio::fs::symlink_metadata(new_root).await.is_ok(); if let Err(error) = tokio::fs::create_dir_all(new_root).await { return Err(ApiError::Filesystem(format!( "could not create '{new_root}': {error}" ))); } + if !already_there { + created_root = Some(PathBuf::from(new_root)); + } } let mut performed: Vec = Vec::new(); @@ -195,7 +208,7 @@ async fn relocate_files( ); continue; } - Err(error) => return Err(failed(&rename, &error, performed).await), + Err(error) => return Err(failed(&rename, &error, performed, created_root).await), } match tokio::fs::rename(&rename.source, &rename.destination).await { Ok(()) => { @@ -206,27 +219,31 @@ async fn relocate_files( ); performed.push(rename); } - Err(error) => return Err(failed(&rename, &error, performed).await), + Err(error) => return Err(failed(&rename, &error, performed, created_root).await), } } Ok(Relocation { performed, rewrites, + created_root, }) } -/// One rename failed: move back everything that had already moved and name -/// the folder that stopped the move, so the operator knows which title to -/// look at before retrying. +/// One rename failed: move back everything that had already moved, remove +/// the new root if this request is the one that created it, and name the +/// folder that stopped the move so the operator knows which title to look at +/// before retrying. async fn failed( rename: &PlannedRename, error: &std::io::Error, performed: Vec, + created_root: Option, ) -> ApiError { Relocation { performed, rewrites: Vec::new(), + created_root, } .undo() .await; @@ -276,6 +293,18 @@ impl Relocation { ); } } + // `remove_dir` rather than `remove_dir_all`: it only succeeds on an + // empty directory, so anything unexpected left inside it — this + // request's own undo failing, say — is a reason to leave it alone. + if let Some(root) = &self.created_root { + if let Err(error) = tokio::fs::remove_dir(root).await { + tracing::warn!( + path = %root.display(), + %error, + "could not remove the directory created for a move that did not complete" + ); + } + } } } diff --git a/crates/arr-api/src/roots.rs b/crates/arr-api/src/roots.rs index ede08e6..758bd27 100644 --- a/crates/arr-api/src/roots.rs +++ b/crates/arr-api/src/roots.rs @@ -67,6 +67,20 @@ impl RootInput { } } +/// Strips a trailing separator so `/mnt/media` and `/mnt/media/` name the +/// same root — otherwise a `PUT` that only adds or drops a trailing slash +/// takes the relocation branch and every planned destination conflicts with +/// its own source. Leaves a bare `/` alone. +fn normalize_path(path: &str) -> String { + let trimmed = path.trim(); + let stripped = trimmed.trim_end_matches('/'); + if stripped.is_empty() { + "/".to_owned() + } else { + stripped.to_owned() + } +} + async fn load_root(state: &AppState, id: i64) -> Result { sqlx::query_as!( Root, @@ -137,7 +151,7 @@ pub async fn create( let input = parsed(body)?; input.validate().map_err(ApiError::Invalid)?; input.policy_exists(&state).await?; - let path = input.path.trim().to_owned(); + let path = normalize_path(&input.path); let result = sqlx::query!( "INSERT INTO roots (kind, audience, path, policy_id) VALUES (?, ?, ?, ?)", input.kind, @@ -173,7 +187,7 @@ pub async fn update( input.validate().map_err(ApiError::Invalid)?; input.policy_exists(&state).await?; let current = load_root(&state, id).await?; - let path = input.path.trim().to_owned(); + let path = normalize_path(&input.path); // A path change moves every §7.4 title folder under this root with the // row (issue #236), the same way changing a title's root moves one // (#228). Disk first, row second: a failed rename leaves the root row @@ -857,12 +871,8 @@ mod tests { ); } assert!( - new.exists(), - "the move reached the disk: the new path was created for it" - ); - assert!( - !new.join("Dune").exists(), - "the folder that had already moved was moved back" + !new.exists(), + "the directory created for the failed move was removed, since it never held anything" ); assert_eq!(file_paths(&state).await, before, "the rows are untouched"); assert_eq!( @@ -884,6 +894,109 @@ mod tests { } } + /// Issue #243: a directory a failed move did not create is not this + /// request's to remove, even though it sits at the same new root path. + #[cfg(unix)] + #[tokio::test] + async fn a_new_root_that_already_existed_survives_a_failed_move() { + use std::os::unix::fs::PermissionsExt; + + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let new = tempfile::tempdir().expect("new root, already there"); + point_root_at(&state, 1, old.path()).await; + + let id = add_movie(&base, 100, "Dune", 1).await; + let stuck = library_folder(&state, id, old.path(), "Dune").await; + tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555)) + .await + .expect("freeze the title folder"); + + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await) + .send() + .await + .expect("move the root"); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + + tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755)) + .await + .expect("thaw the title folder"); + assert!( + new.path().exists(), + "a directory this request did not create is left alone" + ); + } + + /// Issue #243: a path differing only by a trailing separator is the same + /// root, not a self-conflict — `PUT` with it is a no-op. + #[tokio::test] + async fn a_trailing_separator_is_not_a_path_change() { + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + point_root_at(&state, 1, old.path()).await; + let id = add_movie(&base, 100, "Dune", 1).await; + let folder = library_folder(&state, id, old.path(), "Dune").await; + + let mut trailing = old.path().to_str().expect("utf-8").to_owned(); + trailing.push('/'); + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, &trailing).await) + .send() + .await + .expect("update root with a trailing separator"); + assert_eq!(response.status(), StatusCode::OK); + + assert!(folder.exists(), "nothing on disk moved"); + assert_eq!( + stored_path(&base, 1).await, + old.path().to_str().expect("utf-8"), + "the stored path keeps no trailing separator" + ); + } + + /// Issue #243: `create` normalises the same way, so a trailing separator + /// cannot dodge the duplicate-path check. + #[tokio::test] + async fn create_normalises_a_trailing_separator_before_checking_for_conflicts() { + let (_dir, _state, base) = application().await; + let policy_ids = first_policy_ids(&base).await; + let roots: Vec = reqwest::get(format!("{base}/api/roots")) + .await + .expect("roots") + .json() + .await + .expect("roots json"); + let existing_path = roots[0]["path"].as_str().expect("path").to_owned(); + + // Free the (movie, kids) pair so the conflict below is the path + // check alone, not the (kind, audience) uniqueness check. + let kids_root = roots[1]["id"].as_i64().expect("movie kids root id"); + let freed = reqwest::Client::new() + .delete(format!("{base}/api/roots/{kids_root}")) + .send() + .await + .expect("free the seeded movie kids root"); + assert_eq!(freed.status(), StatusCode::NO_CONTENT); + + let mut payload = root_input(policy_ids[0]); + payload["audience"] = serde_json::json!("kids"); + payload["path"] = serde_json::json!(format!("{existing_path}/")); + let response = reqwest::Client::new() + .post(format!("{base}/api/roots")) + .json(&payload) + .send() + .await + .expect("create root with a trailing separator"); + assert_eq!( + response.status(), + StatusCode::CONFLICT, + "a trailing separator does not dodge the duplicate-path check" + ); + } + /// Issue #236: a TV root carries series folders, whose files hang off /// episodes rather than off the title row. They move with the root too. #[tokio::test] From 0b4ded3fe94837992286595d20ea735a12ff9d3f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 10:58:07 +0100 Subject: [PATCH 14/27] feat(web): icon-only controls and readouts (#230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trash replaces the remove labels on title pages and season and episode rows, a magnifier replaces the deck control, the season on-disk readout carries a drive glyph before its 0/10, and the TMDB, TVDB, IMDb and Rotten Tomatoes links carry drawn marks shipped inline (wordmark badges plus a tomato), never remote images. Every icon-only control keeps an aria-label naming the action and the row or title it acts on, and the arm-then-confirm delete speaks a visible amber "confirm" while armed, so the first hit never destroys. Shared CSS lives in one block in style.css headed "icon-only controls (#230)" — .icon, .control-icon, .icon-mark — for #231 to consume for the settings rows. Co-Authored-By: Claude Fable 5 --- web/index.html | 22 +++- web/src/main.ts | 249 +++++++++++++++++++++++++++++++++++++--------- web/src/style.css | 56 +++++++++++ 3 files changed, 275 insertions(+), 52 deletions(-) diff --git a/web/index.html b/web/index.html index 4a56b13..67b6572 100644 --- a/web/index.html +++ b/web/index.html @@ -263,12 +263,19 @@
@@ -500,12 +507,19 @@
diff --git a/web/src/main.ts b/web/src/main.ts index 4dc735c..4def99e 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -103,7 +103,7 @@ import { type TvTarget, waiveAndGrabTv, } from "./series"; -import { armedDelete, settingsMain } from "./settings"; +import { settingsMain } from "./settings"; import "./style.css"; const POLL_MS = 15_000; @@ -1263,6 +1263,118 @@ function wireCollapsedToggle(bucket: CollapsedBucketDom) { }); } +/* ---- icon-only controls (#230) ----------------------------------------- */ + +const SVG_NS = "http://www.w3.org/2000/svg"; + +const ICON_PATHS = { + trash: [ + "M2.8 4.3h10.4", + "M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2", + "M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5", + "M6.6 7v4.2", + "M9.4 7v4.2", + ], + search: ["M7.2 2.6a4.4 4.4 0 1 1 0 8.8 4.4 4.4 0 0 1 0-8.8Z", "M10.4 10.4l3.2 3.2"], + disk: [ + "M2.5 9.8l1.6-5c.2-.7.8-1.2 1.5-1.2h4.8c.7 0 1.3.5 1.5 1.2l1.6 5", + "M2.5 9.8h11v2.5c0 .7-.5 1.2-1.2 1.2H3.7c-.7 0-1.2-.5-1.2-1.2z", + "M10.9 11.6h.01", + ], +} as const; + +/** Stroke glyph on the shared 16-grid; the control's aria-label carries the meaning. */ +function icon(name: keyof typeof ICON_PATHS): SVGSVGElement { + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("viewBox", "0 0 16 16"); + svg.setAttribute("class", "icon"); + svg.setAttribute("aria-hidden", "true"); + for (const d of ICON_PATHS[name]) { + const path = document.createElementNS(SVG_NS, "path"); + path.setAttribute("d", d); + svg.append(path); + } + return svg; +} + +/** §9.6 provider marks, drawn and shipped inline — never a remote image. */ +function providerMark(name: "tmdb" | "tvdb" | "imdb" | "rt"): SVGSVGElement { + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("class", "icon icon-mark"); + svg.setAttribute("aria-hidden", "true"); + if (name === "rt") { + svg.setAttribute("viewBox", "0 0 20 20"); + const paths = [ + "M10 6.6c4.1 0 6.8 2.1 6.8 5.2 0 3.3-3 5.7-6.8 5.7s-6.8-2.4-6.8-5.7c0-3.1 2.7-5.2 6.8-5.2Z", + "M10 6.6C8.6 7.1 7 6.8 5.9 5.8", + "M10 6.6c1.4.5 3 .2 4.1-.8", + "M10 6.6c-.3-1.2 0-2.3.9-3.2", + ]; + for (const d of paths) { + const path = document.createElementNS(SVG_NS, "path"); + path.setAttribute("d", d); + svg.append(path); + } + return svg; + } + svg.setAttribute("viewBox", "0 0 44 20"); + const box = document.createElementNS(SVG_NS, "rect"); + box.setAttribute("x", "1"); + box.setAttribute("y", "1.5"); + box.setAttribute("width", "42"); + box.setAttribute("height", "17"); + box.setAttribute("rx", "4"); + const letters = document.createElementNS(SVG_NS, "text"); + letters.setAttribute("x", "22"); + letters.setAttribute("y", "13.2"); + letters.setAttribute("text-anchor", "middle"); + letters.setAttribute("font-size", "9"); + letters.textContent = { tmdb: "TMDB", tvdb: "TVDB", imdb: "IMDb" }[name]; + svg.append(box, letters); + return svg; +} + +/** + * The settings arm-then-confirm, icon-only: a trash glyph resting, the + * visible word "confirm" while armed, so an accidental hit never destroys. + */ +function armedDeleteIcon(label: string, execute: () => void): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "control control-quiet control-icon"; + button.setAttribute("aria-label", label); + button.title = label; + button.replaceChildren(icon("trash")); + let armed = false; + let resetTimer: number | undefined; + const disarm = () => { + armed = false; + window.clearTimeout(resetTimer); + delete button.dataset.armed; + button.setAttribute("aria-label", label); + button.replaceChildren(icon("trash")); + }; + button.addEventListener("click", () => { + if (armed) { + disarm(); + button.disabled = true; + execute(); + return; + } + armed = true; + button.dataset.armed = "true"; + button.textContent = "confirm"; + button.setAttribute("aria-label", `confirm — ${label}`); + resetTimer = window.setTimeout(disarm, 4000); + }); + button.addEventListener("blur", () => { + if (armed) { + disarm(); + } + }); + return button; +} + /* ---- §9.6 painters shared by both detail pages ------------------------- */ /** The drawn star before a TMDB rating — no glyph standing in for an icon. */ @@ -1281,13 +1393,15 @@ function starIcon(): SVGSVGElement { } /** External links read as quiet controls; they leave the app entirely. */ -function externalLink(label: string, href: string): HTMLAnchorElement { +function externalLink(mark: SVGSVGElement, label: string, href: string): HTMLAnchorElement { const link = document.createElement("a"); - link.className = "control control-quiet"; + link.className = "control control-quiet control-icon"; link.href = href; link.target = "_blank"; link.rel = "noreferrer"; - link.textContent = label; + link.setAttribute("aria-label", label); + link.title = label; + link.append(mark); return link; } @@ -1413,6 +1527,8 @@ function movieMain(views: HideableView[]): MovieView { } titleEl.textContent = movie.title; yearEl.textContent = movie.year === null ? "" : String(movie.year); + remove.setAttribute("aria-label", `remove ${movie.title} from the library`); + remove.title = `remove ${movie.title} from the library`; chipsEl.replaceChildren(); const root = roots.find((candidate) => candidate.id === movie.root_id); chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`)); @@ -1605,13 +1721,26 @@ function movieMain(views: HideableView[]): MovieView { trailer.textContent = "trailer"; actionsEl.append(trailer); } - actionsEl.append(externalLink("tmdb", tmdbMovieLink(detail.tmdb_id))); + const linkTitle = current?.title ?? "this movie"; + actionsEl.append( + externalLink( + providerMark("tmdb"), + `open ${linkTitle} on TMDB`, + tmdbMovieLink(detail.tmdb_id), + ), + ); if (detail.imdb_id !== null) { - actionsEl.append(externalLink("imdb", imdbLink(detail.imdb_id))); + actionsEl.append( + externalLink(providerMark("imdb"), `open ${linkTitle} on IMDb`, imdbLink(detail.imdb_id)), + ); } if (current !== null) { actionsEl.append( - externalLink("rotten tomatoes", rottenTomatoesSearch(current.title, current.year)), + externalLink( + providerMark("rt"), + `search Rotten Tomatoes for ${current.title}`, + rottenTomatoesSearch(current.title, current.year), + ), ); } } @@ -3227,6 +3356,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { } titleEl.textContent = current.title; yearEl.textContent = current.year === null ? "" : String(current.year); + remove.setAttribute("aria-label", `remove ${current.title} from the library`); + remove.title = `remove ${current.title} from the library`; chipsEl.replaceChildren(); const root = roots.find((candidate) => candidate.id === current.root_id); chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`)); @@ -3329,26 +3460,41 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { trailer.textContent = "trailer"; actionsEl.append(trailer); } - actionsEl.append(externalLink("tmdb", tmdbSeriesLink(detail.tmdb_id))); + const linkTitle = series?.title ?? "this series"; + actionsEl.append( + externalLink( + providerMark("tmdb"), + `open ${linkTitle} on TMDB`, + tmdbSeriesLink(detail.tmdb_id), + ), + ); // a series has no imdb_id in this app; TVDB is its second id (§9.6) if (detail.tvdb_id !== null) { - actionsEl.append(externalLink("tvdb", tvdbLink(detail.tvdb_id))); + actionsEl.append( + externalLink(providerMark("tvdb"), `open ${linkTitle} on TVDB`, tvdbLink(detail.tvdb_id)), + ); } if (series !== null) { actionsEl.append( - externalLink("rotten tomatoes", rottenTomatoesSearch(series.title, series.year)), + externalLink( + providerMark("rt"), + `search Rotten Tomatoes for ${series.title}`, + rottenTomatoesSearch(series.title, series.year), + ), ); } } function seasonCountsChip(season: ApiSeason): HTMLSpanElement { const counts = seasonCounts(season); - return countsChip( - `${counts.available}/${counts.wanted} on disk`, + const span = countsChip( + `${counts.available}/${counts.wanted}`, counts.available, counts.wanted, `${counts.available} of ${counts.wanted} wanted episodes on disk`, ); + span.prepend(icon("disk")); + return span; } function seasonRow(season: ApiSeason): HTMLLIElement { @@ -3415,8 +3561,14 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { const deckBtn = document.createElement("button"); deckBtn.type = "button"; - deckBtn.className = "control"; - deckBtn.textContent = "deck"; + deckBtn.className = "control control-icon"; + const deckLabel = + season.number === 0 + ? "open the specials release deck" + : `open the season ${PAD_TWO(season.number)} release deck`; + deckBtn.setAttribute("aria-label", deckLabel); + deckBtn.title = deckLabel; + deckBtn.append(icon("search")); deckBtn.addEventListener("click", () => { const currentId = seriesId; const currentTitle = series?.title ?? ""; @@ -3449,26 +3601,25 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { // #174: files go, episodes stop being wanted, the season stays listed. // Only offered with files on disk — intent alone is the tracked toggle. if (season.episodes.some((episode) => filesByEpisode.has(episode.id))) { - const clear = armedDelete("remove files", () => { - const currentId = seriesId; - if (currentId === null) { - return; - } - void removeSeasonFiles(currentId, season.number).then((outcome) => { - if (outcome.kind === "error") { - clear.disabled = false; - setStatus(`remove failed — ${outcome.detail}`, "fault"); + const clear = armedDeleteIcon( + `remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`, + () => { + const currentId = seriesId; + if (currentId === null) { return; } - // the control itself disappears with the files; the tracked - // toggle is the season's control that survives the repaint - focusKey = `track-${season.number}`; - void load(); - }); - }); - clear.setAttribute( - "aria-label", - `remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`, + void removeSeasonFiles(currentId, season.number).then((outcome) => { + if (outcome.kind === "error") { + clear.disabled = false; + setStatus(`remove failed — ${outcome.detail}`, "fault"); + return; + } + // the control itself disappears with the files; the tracked + // toggle is the season's control that survives the repaint + focusKey = `track-${season.number}`; + void load(); + }); + }, ); line.append(clear); } @@ -3536,21 +3687,20 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { if (aired && onDisk) { // #174: the file goes and the episode stops being wanted; the row // stays listed. Same arm-then-confirm as a settings row. - const clear = armedDelete("remove file", () => { - void removeEpisodeFiles(episode.id).then((outcome) => { - if (outcome.kind === "error") { - clear.disabled = false; - setStatus(`remove failed — ${outcome.detail}`, "fault"); - return; - } - // once missing, the row's want control is what remains to focus - focusKey = `want-${episode.id}`; - void load(); - }); - }); - clear.setAttribute( - "aria-label", + const clear = armedDeleteIcon( `remove the ${episodeTag(seasonNumber, episode.number)} file from disk and stop wanting the episode — it stays listed`, + () => { + void removeEpisodeFiles(episode.id).then((outcome) => { + if (outcome.kind === "error") { + clear.disabled = false; + setStatus(`remove failed — ${outcome.detail}`, "fault"); + return; + } + // once missing, the row's want control is what remains to focus + focusKey = `want-${episode.id}`; + void load(); + }); + }, ); actions.append(clear); } else if (!aired) { @@ -3581,8 +3731,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { const deckBtn = document.createElement("button"); deckBtn.type = "button"; - deckBtn.className = "control"; - deckBtn.textContent = "deck"; + deckBtn.className = "control control-icon"; + const deckLabel = `open the ${episodeTag(seasonNumber, episode.number)} release deck`; + deckBtn.setAttribute("aria-label", deckLabel); + deckBtn.title = deckLabel; + deckBtn.append(icon("search")); deckBtn.addEventListener("click", () => { const currentId = seriesId; const currentTitle = series?.title ?? ""; diff --git a/web/src/style.css b/web/src/style.css index efe15b9..41bdf3e 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -470,6 +470,62 @@ body { outline-offset: 2px; } +/* ---- icon-only controls (#230) ---------------------------------------- */ + +/* One block, shared: title pages, decks and readouts here, the settings + rows via #231. The glyph carries the action, the aria-label carries the + meaning, and the geometry stays on the --control-h rail with the chips. */ + +.icon { + display: block; + flex: none; + height: 0.875rem; + width: auto; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.control-icon { + min-width: var(--control-h); + padding: 0 var(--space-2); +} + +/* provider wordmark badges (§9.6): the mark is drawn and shipped inline, + monochrome on currentColor — never a remote image */ +.icon-mark { + height: 1.125rem; +} + +.icon-mark rect { + stroke-width: 1.6; +} + +/* the mark's letters live in viewBox units, so the size rides the SVG + scale as an attribute; only voice and weight come from the tokens */ +.icon-mark text { + fill: currentColor; + stroke: none; + font-family: var(--font-readout); + font-weight: 700; + letter-spacing: 0.06em; +} + +/* a readout chip's glyph sits beside its figure, sized to the readout */ +.chip .icon { + height: 0.75rem; + stroke-width: 1.7; +} + +/* rows widen every .control's padding; an icon control stays near-square */ +.season-line .control-icon, +.ep-actions .control-icon, +.movie-controls .control-icon { + padding: 0 var(--space-2); +} + /* ---- unified search (§9.2) ------------------------------------------- */ .rail-search { From 528aadf59cc93a5b804b19efdf3177d437fd2abe Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 11:05:59 +0100 Subject: [PATCH 15/27] 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"; From 43e65514ed77deba5931c07441c97daeb7f717df Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 11:17:33 +0100 Subject: [PATCH 16/27] fix(api): reclassify on root and policy changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving a title to a root with a different policy, and pointing a root at a different policy via PUT /api/roots/{id}, both changed the effective policy without re-deriving stored verdicts — which §9.3's deck and the daemon's manual-grab gate read. Both now run the same reclassify the overrides path uses, inline in the request; §5.1 states the contract, and relocate.rs no longer claims the move alone makes the policy apply. PUT /api/policies/{id} has the same gap one level up; noted on #241 for its own issue. Closes #241 Co-Authored-By: Claude Fable 5 --- ...0839c26fbbea836aca6a55a43d75210ae4368.json | 26 +++ ...232fc8a45e31c1a74ae487198b2d286b81e96.json | 26 +++ DESIGN.md | 11 + crates/arr-api/src/movies.rs | 68 ++++++- crates/arr-api/src/reclassify.rs | 39 +++- crates/arr-api/src/relocate.rs | 5 +- crates/arr-api/src/roots.rs | 191 ++++++++++++++++++ crates/arr-api/src/series.rs | 69 ++++++- 8 files changed, 429 insertions(+), 6 deletions(-) create mode 100644 .sqlx/query-2bb8e5739bd8d16bc7c5a08f5530839c26fbbea836aca6a55a43d75210ae4368.json create mode 100644 .sqlx/query-cc5686ba4766a79d333e98ddcce232fc8a45e31c1a74ae487198b2d286b81e96.json diff --git a/.sqlx/query-2bb8e5739bd8d16bc7c5a08f5530839c26fbbea836aca6a55a43d75210ae4368.json b/.sqlx/query-2bb8e5739bd8d16bc7c5a08f5530839c26fbbea836aca6a55a43d75210ae4368.json new file mode 100644 index 0000000..26ddc1e --- /dev/null +++ b/.sqlx/query-2bb8e5739bd8d16bc7c5a08f5530839c26fbbea836aca6a55a43d75210ae4368.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\" FROM movies WHERE root_id = ?", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "movies", + "name": "id" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "2bb8e5739bd8d16bc7c5a08f5530839c26fbbea836aca6a55a43d75210ae4368" +} diff --git a/.sqlx/query-cc5686ba4766a79d333e98ddcce232fc8a45e31c1a74ae487198b2d286b81e96.json b/.sqlx/query-cc5686ba4766a79d333e98ddcce232fc8a45e31c1a74ae487198b2d286b81e96.json new file mode 100644 index 0000000..9afc9d2 --- /dev/null +++ b/.sqlx/query-cc5686ba4766a79d333e98ddcce232fc8a45e31c1a74ae487198b2d286b81e96.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\" FROM series WHERE root_id = ?", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "id" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "cc5686ba4766a79d333e98ddcce232fc8a45e31c1a74ae487198b2d286b81e96" +} diff --git a/DESIGN.md b/DESIGN.md index d6c46b2..b730786 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -199,6 +199,17 @@ fake profile per root and ignoring whatever it sends. Per-title `overrides` relax the root policy for one title. Same mechanism in both directions — `only_4k` tightens, `allow_english_audio` loosens. +**Stored verdicts follow the effective policy.** A release's verdict is +stamped by the search that found it, and both §9.3's deck and the daemon's +manual-grab gate read that stored column. So the three operator actions that +change a title's effective policy — editing its overrides, moving it to a +root with a different policy, and pointing a root at a different policy — +re-derive the stored verdicts of everything they touch, inline in the same +request. The operator is never left reading a verdict computed under a +policy that no longer applies. Inline is affordable even root-wide: +re-evaluation is pure and in-memory, and a row whose verdict does not move +is not rewritten, so a library-sized repoint costs reads, not writes. + ### 5.2 Language Requires a concept the release name does not carry: the **original language of diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 1be1218..fabc819 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -475,7 +475,10 @@ pub async fn update( { crate::relocate::refresh_jellyfin(&state).await; } - if overrides_changed { + // A root change swaps the effective policy the same way an overrides + // edit does (§5.1), so both re-derive; `relocation` is `Some` exactly + // when the root changed. + if overrides_changed || relocation.is_some() { crate::reclassify::movie(&state, id).await?; } Ok(Json(load_movie(&state, id).await?)) @@ -1302,6 +1305,69 @@ mod tests { assert_eq!(releases[0]["verdict"], "waived"); } + /// #241: moving a title to a root with a different policy re-derives its + /// stored verdicts (§5.1) — an English-audio release a `main` root found + /// eligible is only a waiver under the `kids` root it moved to. + #[tokio::test] + async fn moving_a_movie_to_another_root_rederives_its_verdicts() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let created: serde_json::Value = reqwest::Client::new() + .post(format!("{base}/api/movies")) + .json(&serde_json::json!({ + "tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024, + "original_language": "en", "root_id": 1, "overrides": {} + })) + .send() + .await + .expect("create movie") + .json() + .await + .expect("movie json"); + let movie_id = created["id"].as_i64().expect("id"); + + let name = "Dune Part Two 2024 1080p WEB-DL ENGLISH x264-GROUP"; + let parsed = arr_parse::parse(name); + let size = 6_i64 * (1 << 30); + let release_id = sqlx::query_scalar::<_, i64>( + "INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict) + VALUES (7, 'g', ?, ?, 40, 'url', ?, 0, 'eligible') RETURNING id", + ) + .bind(name) + .bind(size) + .bind(serde_json::to_string(&parsed).expect("parsed json")) + .fetch_one(pool) + .await + .expect("release"); + sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)") + .bind(movie_id) + .bind(release_id) + .execute(pool) + .await + .expect("association"); + + let response = reqwest::Client::new() + .patch(format!("{base}/api/movies/{movie_id}")) + .json(&serde_json::json!({"root_id": 2})) + .send() + .await + .expect("move root"); + assert_eq!(response.status(), StatusCode::OK); + + let releases: Vec = + reqwest::get(format!("{base}/api/movies/{movie_id}/releases")) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + assert_eq!(releases.len(), 1); + assert_eq!( + releases[0]["verdict"], "waived", + "kids requires pt-PT, English is a soft fail: {releases:?}" + ); + } + /// A blacklisted release (§6.3) is not a policy opinion, so no override /// re-opens it. #[tokio::test] diff --git a/crates/arr-api/src/reclassify.rs b/crates/arr-api/src/reclassify.rs index 70fd9a8..e95d265 100644 --- a/crates/arr-api/src/reclassify.rs +++ b/crates/arr-api/src/reclassify.rs @@ -1,10 +1,15 @@ -//! Stored verdicts, re-derived when a title's overrides change (§9.3). +//! Stored verdicts, re-derived when a title's effective policy changes: +//! an overrides edit (§9.3), a move to a root with a different policy, or a +//! root pointed at a different policy (§5.1). //! //! A release's verdict is stamped once, by the search that found it. Both the //! deck and the daemon's manual-grab gate read that stored column, so an //! override written from the deck's one-click waive would change nothing //! until the next sweep — the row the operator just acted on would keep -//! reading `rejected` and the grab would be refused. +//! reading `rejected` and the grab would be refused. A root move and a +//! policy repoint invalidate the column the same way, just wider: nothing +//! else re-reads it, so the write that changed the effective policy is the +//! only place the correction can happen. //! //! So the rules run again here, over the releases already attached to the //! title. This is the same correction the daemon makes when a grab turns out @@ -141,6 +146,36 @@ pub(crate) async fn series(state: &AppState, series_id: i64) -> Result<(), ApiEr .await } +/// Re-evaluate every release of every title under one root, for a +/// `PUT /api/roots/{id}` that pointed the root at a different policy (§5.1). +/// +/// Title by title through [`movie`] and [`series`], so the skip rules — no +/// stored original language, the blacklist (§6.3) — stay in one place. Rows +/// whose verdict the new policy does not move are left unwritten, so the +/// usual case (most of a library re-evaluates to the same answer) costs +/// reads, not writes. +pub(crate) async fn root(state: &AppState, root_id: i64) -> Result<(), ApiError> { + let movie_ids = sqlx::query_scalar!( + r#"SELECT id AS "id!: i64" FROM movies WHERE root_id = ?"#, + root_id + ) + .fetch_all(pool(state)?) + .await?; + for movie_id in movie_ids { + movie(state, movie_id).await?; + } + let series_ids = sqlx::query_scalar!( + r#"SELECT id AS "id!: i64" FROM series WHERE root_id = ?"#, + root_id + ) + .fetch_all(pool(state)?) + .await?; + for series_id in series_ids { + series(state, series_id).await?; + } + Ok(()) +} + #[allow(clippy::too_many_arguments)] async fn apply( state: &AppState, diff --git a/crates/arr-api/src/relocate.rs b/crates/arr-api/src/relocate.rs index 3641efa..459b387 100644 --- a/crates/arr-api/src/relocate.rs +++ b/crates/arr-api/src/relocate.rs @@ -1,8 +1,9 @@ //! Moving library files when the layout under them changes: a title changing //! its `root_id` (issue #228), and a root changing its `path` (issue #236). //! Both rename §7.4 folders and rewrite the `media_files` rows to match, so -//! the layout keeps describing the disk and the root's policy (§5.1) applies -//! to a library the files are actually in. +//! the layout keeps describing the disk. This module only moves things: the +//! stored verdicts a root change invalidates (§5.1) are re-derived by the +//! calling handler through `reclassify`, after the row commits. //! //! Every root shares one filesystem — one ZFS dataset, bind-mounted — so this //! is a directory rename, never a copy. Hardlinked files keep their inodes diff --git a/crates/arr-api/src/roots.rs b/crates/arr-api/src/roots.rs index 758bd27..71ead45 100644 --- a/crates/arr-api/src/roots.rs +++ b/crates/arr-api/src/roots.rs @@ -236,6 +236,11 @@ pub async fn update( { crate::relocate::refresh_jellyfin(&state).await; } + // Verdicts already stored under this root were reached under the old + // policy; §9.3's deck and the grab gate both read them (`reclassify`). + if input.policy_id != current.policy_id { + crate::reclassify::root(&state, id).await?; + } Ok(Json(load_root(&state, id).await?)) } @@ -385,6 +390,192 @@ mod tests { .collect() } + async fn policy_id_named(base: &str, name: &str) -> i64 { + let policies: Vec = reqwest::get(format!("{base}/api/policies")) + .await + .expect("policies") + .json() + .await + .expect("policies json"); + policies + .iter() + .find(|policy| policy["name"] == name) + .and_then(|policy| policy["id"].as_i64()) + .expect("policy id") + } + + async fn stamped_eligible_release(state: &AppState, name: &str, size: i64) -> i64 { + let parsed = arr_parse::parse(name); + sqlx::query_scalar::<_, i64>( + "INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict) + VALUES (7, ?, ?, ?, 40, 'url', ?, 0, 'eligible') RETURNING id", + ) + .bind(name) + .bind(name) + .bind(size) + .bind(serde_json::to_string(&parsed).expect("parsed json")) + .fetch_one(state.database().expect("database").pool()) + .await + .expect("release") + } + + /// A movie under the movie main root, its release stamped eligible. + async fn movie_with_eligible_release(state: &AppState, base: &str) -> i64 { + let movie: serde_json::Value = reqwest::Client::new() + .post(format!("{base}/api/movies")) + .json(&serde_json::json!({ + "tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024, + "original_language": "en", "root_id": 1, "overrides": {} + })) + .send() + .await + .expect("create movie") + .json() + .await + .expect("movie json"); + let movie_id = movie["id"].as_i64().expect("movie id"); + let release_id = stamped_eligible_release( + state, + "Dune Part Two 2024 1080p WEB-DL ENGLISH x264-GROUP", + 6_i64 * (1 << 30), + ) + .await; + sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)") + .bind(movie_id) + .bind(release_id) + .execute(state.database().expect("database").pool()) + .await + .expect("movie association"); + movie_id + } + + /// A series under the given TV root, with a season 9 pack stamped + /// eligible. Ten episodes in 15 GiB: 1.5 GiB each, inside the 1080p band. + async fn series_with_eligible_pack(state: &AppState, base: &str, root_id: i64) -> i64 { + let client = reqwest::Client::new(); + let series: serde_json::Value = client + .post(format!("{base}/api/series")) + .json(&serde_json::json!({ + "tmdb_id": 82_728, "title": "Bluey", "year": 2018, + "original_language": "en", "root_id": root_id, + "auto_track": false + })) + .send() + .await + .expect("create series") + .json() + .await + .expect("series json"); + let series_id = series["id"].as_i64().expect("series id"); + let episodes: Vec = (1..=10) + .map(|number| { + serde_json::json!({ + "number": number, "title": format!("Episode {number}"), + "air_date": "2025-01-01" + }) + }) + .collect(); + let season: serde_json::Value = client + .post(format!("{base}/api/series/{series_id}/seasons")) + .json(&serde_json::json!({"number": 9, "episodes": episodes})) + .send() + .await + .expect("create season") + .json() + .await + .expect("season json"); + let season_id = season["id"].as_i64().expect("season id"); + let release_id = stamped_eligible_release( + state, + "Bluey S09 1080p WEB-DL ENGLISH x264-GROUP", + 15_i64 * (1 << 30), + ) + .await; + sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)") + .bind(season_id) + .bind(release_id) + .execute(state.database().expect("database").pool()) + .await + .expect("season association"); + series_id + } + + async fn verdict_of(url: String) -> serde_json::Value { + let releases: Vec = reqwest::get(url) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + releases[0]["verdict"].clone() + } + + /// #241: `PUT /api/roots/{id}` writing a different `policy_id` + /// re-derives the stored verdicts of every title under the root (§5.1) + /// — and of nothing outside it. + #[tokio::test] + async fn repointing_a_root_at_another_policy_rederives_its_library() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let client = reqwest::Client::new(); + let movie_id = movie_with_eligible_release(&state, &base).await; + let tv_main: i64 = + sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'") + .fetch_one(pool) + .await + .expect("tv root"); + let series_id = series_with_eligible_pack(&state, &base, tv_main).await; + + // Point the movie main root at the kids policy; the path stays, so + // nothing on disk is touched. + let kids_policy = policy_id_named(&base, "Movies — kids").await; + let response = client + .put(format!("{base}/api/roots/1")) + .json(&serde_json::json!({ + "kind": "movie", "audience": "main", + "path": "/mnt/media/movies/main", "policy_id": kids_policy + })) + .send() + .await + .expect("repoint movie root"); + assert_eq!(response.status(), StatusCode::OK); + + assert_eq!( + verdict_of(format!("{base}/api/movies/{movie_id}/releases")).await, + "waived", + "kids requires pt-PT, English is a soft fail" + ); + // The TV root was not repointed, so its library is untouched. + assert_eq!( + verdict_of(format!("{base}/api/series/{series_id}/seasons/9/releases")).await, + "eligible" + ); + + // Now the TV root; the series' pack re-derives too. + let tv_kids_policy = policy_id_named(&base, "TV — kids").await; + let response = client + .put(format!("{base}/api/roots/{tv_main}")) + .json(&serde_json::json!({ + "kind": "tv", "audience": "main", + "path": "/mnt/media/tv/main", "policy_id": tv_kids_policy + })) + .send() + .await + .expect("repoint tv root"); + assert_eq!( + response.status(), + StatusCode::OK, + "{:?}", + response.text().await + ); + + assert_eq!( + verdict_of(format!("{base}/api/series/{series_id}/seasons/9/releases")).await, + "waived", + "kids requires pt-PT, English is a soft fail" + ); + } + #[tokio::test] async fn a_root_round_trips_through_create_and_update() { let (_dir, _state, base) = application().await; diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 6ee9020..de95833 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -627,7 +627,10 @@ pub async fn update( { crate::relocate::refresh_jellyfin(&state).await; } - if overrides_changed { + // A root change swaps the effective policy the same way an overrides + // edit does (§5.1), so both re-derive; `relocation` is `Some` exactly + // when the root changed. + if overrides_changed || relocation.is_some() { crate::reclassify::series(&state, id).await?; } Ok(Json(load_series(&state, id).await?)) @@ -2043,6 +2046,70 @@ mod tests { assert_eq!(releases[0]["rejected_rule"], "size"); } + /// #241: moving a series to a root with a different policy re-derives + /// the stored verdicts of its whole deck (§5.1), season packs included. + #[tokio::test] + async fn moving_a_series_to_another_root_rederives_its_verdicts() { + let (_dir, state, base) = application().await; + let main_root = tv_root(&state, "main").await; + let kids_root = tv_root(&state, "kids").await; + let series = add_series(&base, main_root, false).await; + let series_id = series["id"].as_i64().expect("id"); + let episodes: Vec = (1..=10) + .map(|number| { + serde_json::json!({ + "number": number, "title": format!("Episode {number}"), + "air_date": "2025-01-01" + }) + }) + .collect(); + let season = add_season(&base, series_id, 9, serde_json::json!(episodes)).await; + let season_id = season["id"].as_i64().expect("season id"); + + // Ten episodes in 15 GiB: 1.5 GiB each, inside the 1080p band. + let pool = state.database().expect("database").pool(); + let name = "Bluey S09 1080p WEB-DL ENGLISH x264-GROUP"; + let parsed = arr_parse::parse(name); + let size = 15_i64 * (1 << 30); + let release_id = sqlx::query_scalar::<_, i64>( + "INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict) + VALUES (7, 'pack', ?, ?, 50, 'url', ?, 0, 'eligible') RETURNING id", + ) + .bind(name) + .bind(size) + .bind(serde_json::to_string(&parsed).expect("parsed json")) + .fetch_one(pool) + .await + .expect("release"); + sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)") + .bind(season_id) + .bind(release_id) + .execute(pool) + .await + .expect("association"); + + let response = reqwest::Client::new() + .patch(format!("{base}/api/series/{series_id}")) + .json(&serde_json::json!({"root_id": kids_root})) + .send() + .await + .expect("move root"); + assert_eq!(response.status(), StatusCode::OK); + + let releases: Vec = + reqwest::get(format!("{base}/api/series/{series_id}/seasons/9/releases")) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + assert_eq!(releases.len(), 1); + assert_eq!( + releases[0]["verdict"], "waived", + "kids requires pt-PT, English is a soft fail: {releases:?}" + ); + } + #[tokio::test] async fn overrides_reject_a_key_the_policy_engine_has_no_rule_for() { let (_dir, state, base) = application().await; From df1af383a389b115b27193a8efeb4fe32a3855d2 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 11:19:05 +0100 Subject: [PATCH 17/27] refactor(web): extract shared icon controls to module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #230 icon block (glyph set, icon(), armedDeleteIcon()) moves from main.ts to icons.ts so the settings rows (#231) can consume it without an import cycle. The shared set is extended with one pencil glyph for the rows' edit action — extension of the shared block, not a fork. Co-Authored-By: Claude Fable 5 --- web/src/icons.ts | 80 ++++++++++++++++++++++++++++++++++++++++++++++++ web/src/main.ts | 75 ++------------------------------------------- 2 files changed, 83 insertions(+), 72 deletions(-) create mode 100644 web/src/icons.ts diff --git a/web/src/icons.ts b/web/src/icons.ts new file mode 100644 index 0000000..45786e2 --- /dev/null +++ b/web/src/icons.ts @@ -0,0 +1,80 @@ +// The shared icon-only control block (#230), consumed by the title pages, +// decks and readouts in main.ts and the settings rows (#231). One glyph set, +// one arm-then-confirm idiom — never a second implementation per view. + +export const SVG_NS = "http://www.w3.org/2000/svg"; + +const ICON_PATHS = { + trash: [ + "M2.8 4.3h10.4", + "M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2", + "M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5", + "M6.6 7v4.2", + "M9.4 7v4.2", + ], + search: ["M7.2 2.6a4.4 4.4 0 1 1 0 8.8 4.4 4.4 0 0 1 0-8.8Z", "M10.4 10.4l3.2 3.2"], + disk: [ + "M2.5 9.8l1.6-5c.2-.7.8-1.2 1.5-1.2h4.8c.7 0 1.3.5 1.5 1.2l1.6 5", + "M2.5 9.8h11v2.5c0 .7-.5 1.2-1.2 1.2H3.7c-.7 0-1.2-.5-1.2-1.2z", + "M10.9 11.6h.01", + ], + pencil: [ + "M11.2 2.4a1.5 1.5 0 0 1 2.1 0l.3.3a1.5 1.5 0 0 1 0 2.1L5.9 12.5l-3.2.8.8-3.2z", + "M10.1 3.5l2.4 2.4", + ], +} as const; + +/** Stroke glyph on the shared 16-grid; the control's aria-label carries the meaning. */ +export function icon(name: keyof typeof ICON_PATHS): SVGSVGElement { + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("viewBox", "0 0 16 16"); + svg.setAttribute("class", "icon"); + svg.setAttribute("aria-hidden", "true"); + for (const d of ICON_PATHS[name]) { + const path = document.createElementNS(SVG_NS, "path"); + path.setAttribute("d", d); + svg.append(path); + } + return svg; +} + +/** + * The settings arm-then-confirm, icon-only: a trash glyph resting, the + * visible word "confirm" while armed, so an accidental hit never destroys. + */ +export function armedDeleteIcon(label: string, execute: () => void): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "control control-quiet control-icon"; + button.setAttribute("aria-label", label); + button.title = label; + button.replaceChildren(icon("trash")); + let armed = false; + let resetTimer: number | undefined; + const disarm = () => { + armed = false; + window.clearTimeout(resetTimer); + delete button.dataset.armed; + button.setAttribute("aria-label", label); + button.replaceChildren(icon("trash")); + }; + button.addEventListener("click", () => { + if (armed) { + disarm(); + button.disabled = true; + execute(); + return; + } + armed = true; + button.dataset.armed = "true"; + button.textContent = "confirm"; + button.setAttribute("aria-label", `confirm — ${label}`); + resetTimer = window.setTimeout(disarm, 4000); + }); + button.addEventListener("blur", () => { + if (armed) { + disarm(); + } + }); + return button; +} diff --git a/web/src/main.ts b/web/src/main.ts index 4def99e..9aabc27 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,4 +1,5 @@ import { type CheckStatus, type Probe, probeHealth } from "./health"; +import { armedDeleteIcon, icon, SVG_NS } from "./icons"; import { fetchLibrary, type LibrarySeries, @@ -1265,37 +1266,8 @@ function wireCollapsedToggle(bucket: CollapsedBucketDom) { /* ---- icon-only controls (#230) ----------------------------------------- */ -const SVG_NS = "http://www.w3.org/2000/svg"; - -const ICON_PATHS = { - trash: [ - "M2.8 4.3h10.4", - "M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2", - "M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5", - "M6.6 7v4.2", - "M9.4 7v4.2", - ], - search: ["M7.2 2.6a4.4 4.4 0 1 1 0 8.8 4.4 4.4 0 0 1 0-8.8Z", "M10.4 10.4l3.2 3.2"], - disk: [ - "M2.5 9.8l1.6-5c.2-.7.8-1.2 1.5-1.2h4.8c.7 0 1.3.5 1.5 1.2l1.6 5", - "M2.5 9.8h11v2.5c0 .7-.5 1.2-1.2 1.2H3.7c-.7 0-1.2-.5-1.2-1.2z", - "M10.9 11.6h.01", - ], -} as const; - -/** Stroke glyph on the shared 16-grid; the control's aria-label carries the meaning. */ -function icon(name: keyof typeof ICON_PATHS): SVGSVGElement { - const svg = document.createElementNS(SVG_NS, "svg"); - svg.setAttribute("viewBox", "0 0 16 16"); - svg.setAttribute("class", "icon"); - svg.setAttribute("aria-hidden", "true"); - for (const d of ICON_PATHS[name]) { - const path = document.createElementNS(SVG_NS, "path"); - path.setAttribute("d", d); - svg.append(path); - } - return svg; -} +// icon() and armedDeleteIcon() live in icons.ts, shared with the settings +// rows (#231); the §9.6 painters below stay here as their only consumer. /** §9.6 provider marks, drawn and shipped inline — never a remote image. */ function providerMark(name: "tmdb" | "tvdb" | "imdb" | "rt"): SVGSVGElement { @@ -1334,47 +1306,6 @@ function providerMark(name: "tmdb" | "tvdb" | "imdb" | "rt"): SVGSVGElement { return svg; } -/** - * The settings arm-then-confirm, icon-only: a trash glyph resting, the - * visible word "confirm" while armed, so an accidental hit never destroys. - */ -function armedDeleteIcon(label: string, execute: () => void): HTMLButtonElement { - const button = document.createElement("button"); - button.type = "button"; - button.className = "control control-quiet control-icon"; - button.setAttribute("aria-label", label); - button.title = label; - button.replaceChildren(icon("trash")); - let armed = false; - let resetTimer: number | undefined; - const disarm = () => { - armed = false; - window.clearTimeout(resetTimer); - delete button.dataset.armed; - button.setAttribute("aria-label", label); - button.replaceChildren(icon("trash")); - }; - button.addEventListener("click", () => { - if (armed) { - disarm(); - button.disabled = true; - execute(); - return; - } - armed = true; - button.dataset.armed = "true"; - button.textContent = "confirm"; - button.setAttribute("aria-label", `confirm — ${label}`); - resetTimer = window.setTimeout(disarm, 4000); - }); - button.addEventListener("blur", () => { - if (armed) { - disarm(); - } - }); - return button; -} - /* ---- §9.6 painters shared by both detail pages ------------------------- */ /** The drawn star before a TMDB rating — no glyph standing in for an icon. */ From 955a0f309cbecf82f9c9cbcdff343d3ef8b5d6b8 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 11:19:05 +0100 Subject: [PATCH 18/27] feat(web): settings rows on one line with icon actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each root and policy row is one line: identity left, chips at the right edge, then pencil/trash icon controls — the episode-row idiom instead of a two-line group. Every icon action's aria-label names the action and the row (edit root /mnt/media/tv/main). Delete keeps the arm-then-confirm behaviour via the shared armedDeleteIcon; the local text-button armedDelete is gone with it. Under 46rem the row wraps like an episode row: identity, chips full-width, actions keeping the right edge. Closes #231 Co-Authored-By: Claude Fable 5 --- web/src/settings.ts | 76 +++++++++++++-------------------------------- web/src/style.css | 25 +++++++++++++-- 2 files changed, 44 insertions(+), 57 deletions(-) diff --git a/web/src/settings.ts b/web/src/settings.ts index f0736d4..1e5bdbf 100644 --- a/web/src/settings.ts +++ b/web/src/settings.ts @@ -2,6 +2,7 @@ // plus the settings view over them — same reasoning as search.ts: the // generated client (src/api/) is uncommitted, so CI's tsc cannot see it. +import { armedDeleteIcon, icon } from "./icons"; import { navigate } from "./router"; import type { Root } from "./search"; @@ -185,40 +186,14 @@ function listValue(input: HTMLInputElement): string[] { .filter((part) => part !== ""); } -/** - * First click arms the destructive action, second confirms. The armed state - * clears on blur or after a few seconds, so an accidental double click - * never deletes. Shared with the series detail rows — one confirmation - * idiom for row-level destruction, not one per page. - */ -export function armedDelete(label: string, execute: () => void): HTMLButtonElement { - const button = el("button", "control control-quiet readout", label); +/** Icon-only edit toggle for one row; the label names the row it opens. */ +function editIcon(label: string): HTMLButtonElement { + const button = el("button", "control control-quiet control-icon"); button.type = "button"; - let armed = false; - let resetTimer: number | undefined; - const disarm = () => { - armed = false; - window.clearTimeout(resetTimer); - delete button.dataset.armed; - button.textContent = label; - }; - button.addEventListener("click", () => { - if (armed) { - disarm(); - button.disabled = true; - execute(); - return; - } - armed = true; - button.dataset.armed = "true"; - button.textContent = `confirm ${label}`; - resetTimer = window.setTimeout(disarm, 4000); - }); - button.addEventListener("blur", () => { - if (armed) { - disarm(); - } - }); + button.setAttribute("aria-label", label); + button.title = label; + button.setAttribute("aria-expanded", "false"); + button.append(icon("pencil")); return button; } @@ -300,31 +275,29 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void): /* -- roots -------------------------------------------------------------- */ - /** A delete/edit action pair sharing the row's feedback line. */ - function rowActions( + /** The row's line: identity, chips right, the icon action pair, feedback. */ + function rowLine( item: HTMLLIElement, + title: string, + chips: HTMLSpanElement, del: HTMLButtonElement, edit: HTMLButtonElement, ): HTMLParagraphElement { const actions = el("div", "settings-row-actions"); actions.append(edit, del); - item.append(actions); const note = el("p", "add-note readout"); note.setAttribute("role", "status"); note.hidden = true; - item.append(note); + item.append(rowTitle(title), chips, actions, note); return note; } function rootRow(root: Root): HTMLLIElement { - const item = el("li", "queue-item"); - const row = el("div", "row"); + const item = el("li", "queue-item settings-row"); const chips = el("span", "row-chips"); chips.append(chip(root.kind), chip(root.audience), chip(root.policy_name)); - row.append(rowTitle(root.path), chips); - item.append(row); - const del = armedDelete("delete", () => { + const del = armedDeleteIcon(`delete root ${root.path}`, () => { void deleteRow("roots", root.id).then((detail) => { if (detail !== null) { note.hidden = false; @@ -336,9 +309,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void): }); }); - const edit = el("button", "control control-quiet readout", "edit"); - edit.type = "button"; - edit.setAttribute("aria-expanded", "false"); + const edit = editIcon(`edit root ${root.path}`); edit.addEventListener("click", () => { const existing = item.querySelector(".edit-panel"); if (existing) { @@ -350,7 +321,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void): edit.setAttribute("aria-expanded", "true"); }); - const note = rowActions(item, del, edit); + const note = rowLine(item, root.path, chips, del, edit); return item; } @@ -432,17 +403,14 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void): } function policyRow(policy: PolicyDoc): HTMLLIElement { - const item = el("li", "queue-item"); - const row = el("div", "row"); + const item = el("li", "queue-item settings-row"); const chips = el("span", "row-chips"); chips.append(chip(policy.resolution_pref.join(" › ") || "no resolutions")); chips.append(chip(requiredAudioLabel(policy))); const bandCount = Object.keys(policy.size_bands).length; chips.append(chip(`${bandCount} band${bandCount === 1 ? "" : "s"}`)); - row.append(rowTitle(policy.name), chips); - item.append(row); - const del = armedDelete("delete", () => { + const del = armedDeleteIcon(`delete policy ${policy.name}`, () => { void deleteRow("policies", policy.id).then((detail) => { if (detail !== null) { note.hidden = false; @@ -454,9 +422,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void): }); }); - const edit = el("button", "control control-quiet readout", "edit"); - edit.type = "button"; - edit.setAttribute("aria-expanded", "false"); + const edit = editIcon(`edit policy ${policy.name}`); edit.addEventListener("click", () => { const existing = item.querySelector(".edit-panel"); if (existing) { @@ -468,7 +434,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void): edit.setAttribute("aria-expanded", "true"); }); - const note = rowActions(item, del, edit); + const note = rowLine(item, policy.name, chips, del, edit); return item; } diff --git a/web/src/style.css b/web/src/style.css index 41bdf3e..ab58117 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1957,11 +1957,32 @@ body { /* ---- settings (issue #116) -------------------------------------------- */ -/* rows follow the queues pattern: line, then an action pair */ +/* one machined line per row (#231): identity, chips at the right edge, + then the icon action pair — the episode-row idiom, not a two-line group */ .settings-row-actions { display: flex; + align-items: center; gap: var(--space-2); - margin: var(--space-1) 0 var(--space-2); + margin-left: var(--space-3); +} + +.settings-row .row-title { + overflow-wrap: anywhere; +} + +/* feedback and the edit panel drop below the line, full width */ +.settings-row .add-note, +.settings-row .edit-panel { + flex-basis: 100%; +} + +/* narrow, the row wraps like an episode row: identity first, chips + full-width (the shared .row-chips rule), the icon pair keeps the right + edge. Lives here, after the base rule, so the margin actually flips. */ +@media (max-width: 46rem) { + .settings-row-actions { + margin-left: auto; + } } .form-grid { From c4d4ade4daa54bb58359a79aa66ed1c90aa2f557 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 11:28:35 +0100 Subject: [PATCH 19/27] fix(api): normalise stored root paths #243 normalised the incoming path but compared it against the value read raw from the database, so a root stored with a trailing separator never compared equal. Every edit of it -- a policy change included -- took the relocation branch, where each planned destination is its own source and the pre-check refuses. That root could not be edited at all. `update` now normalises both sides, and hands `relocate_root` the normalised stored value. `path_is_free` normalises the stored side in SQL and `create` goes through it too, so `/mnt/x` and `/mnt/x/` cannot be two roots for one directory -- the unique index compares raw strings and cannot see that. Migration 0031 strips the separator from rows already written. It skips any row whose stripped form another row would also hold, rather than tripping the unique index: a migration that cannot apply stops the daemon booting, which is worse than two roots naming one directory. Also from the same review: `undo` recorded only the leaf directory, so a failed move into `/mnt/media-v2/tv/kids` left `tv` behind. It now records every level `create_dir_all` materialised, deepest first, and still never touches one that was already on disk. Refs #244. Co-Authored-By: Claude Opus 5 --- ...088f1b6ae12ad95f972724a417711ad37fa1.json} | 8 +- crates/arr-api/src/relocate.rs | 71 ++++-- crates/arr-api/src/roots.rs | 237 +++++++++++++++++- .../migrations/0031_normalise_root_paths.sql | 34 +++ crates/arr-db/src/lib.rs | 87 +++++++ 5 files changed, 399 insertions(+), 38 deletions(-) rename .sqlx/{query-fa3d1e4a6cae94780daf8fe20062a107963ba6ab2bcef2c8cfb9a1efbd905b59.json => query-bda8991590c009ca7084fed36fae088f1b6ae12ad95f972724a417711ad37fa1.json} (51%) create mode 100644 crates/arr-db/migrations/0031_normalise_root_paths.sql diff --git a/.sqlx/query-fa3d1e4a6cae94780daf8fe20062a107963ba6ab2bcef2c8cfb9a1efbd905b59.json b/.sqlx/query-bda8991590c009ca7084fed36fae088f1b6ae12ad95f972724a417711ad37fa1.json similarity index 51% rename from .sqlx/query-fa3d1e4a6cae94780daf8fe20062a107963ba6ab2bcef2c8cfb9a1efbd905b59.json rename to .sqlx/query-bda8991590c009ca7084fed36fae088f1b6ae12ad95f972724a417711ad37fa1.json index a757088..e31f5fb 100644 --- a/.sqlx/query-fa3d1e4a6cae94780daf8fe20062a107963ba6ab2bcef2c8cfb9a1efbd905b59.json +++ b/.sqlx/query-bda8991590c009ca7084fed36fae088f1b6ae12ad95f972724a417711ad37fa1.json @@ -1,10 +1,10 @@ { "db_name": "SQLite", - "query": "SELECT id FROM roots WHERE path = ? AND id <> ?", + "query": "SELECT id AS \"id!: i64\" FROM roots\n WHERE CASE WHEN rtrim(path, '/') = '' THEN '/' ELSE rtrim(path, '/') END = ?\n AND (? IS NULL OR id <> ?)", "describe": { "columns": [ { - "name": "id", + "name": "id!: i64", "ordinal": 0, "type_info": "Integer", "origin": { @@ -16,11 +16,11 @@ } ], "parameters": { - "Right": 2 + "Right": 3 }, "nullable": [ false ] }, - "hash": "fa3d1e4a6cae94780daf8fe20062a107963ba6ab2bcef2c8cfb9a1efbd905b59" + "hash": "bda8991590c009ca7084fed36fae088f1b6ae12ad95f972724a417711ad37fa1" } diff --git a/crates/arr-api/src/relocate.rs b/crates/arr-api/src/relocate.rs index 459b387..e88d843 100644 --- a/crates/arr-api/src/relocate.rs +++ b/crates/arr-api/src/relocate.rs @@ -50,11 +50,14 @@ struct PlannedRename { pub(crate) struct Relocation { performed: Vec, rewrites: Vec<(i64, String)>, - /// The new root directory, set only when this request created it (it did - /// not already exist). [`Self::undo`] removes it, so it is cleaned up - /// exactly when the move it was created for does not complete; a move - /// that commits never calls `undo` and the directory stays. - created_root: Option, + /// Every directory level this request materialised for the new root, + /// deepest first. `create_dir_all` can make more than one — moving a + /// root to `/mnt/media-v2/tv/kids` when `/mnt/media-v2` is all that + /// exists creates both `tv` and `kids` — and [`Self::undo`] removes all + /// of them, so a failed move leaves nothing behind (#244). Levels that + /// were already on disk are never in this list and are never touched. A + /// move that commits never calls `undo` and the directories stay. + created_dirs: Vec, } /// Whether the destination root is a directory that must already be there. @@ -179,20 +182,18 @@ async fn relocate_files( } } - // Tracked only when this call is the one that created the directory, so - // a failed move can remove it again without ever touching a root path - // that already existed on disk. - let mut created_root: Option = None; + // Tracked only for the levels this call is the one to create, so a + // failed move can remove them again without ever touching a directory + // that already existed on disk. Recorded before `create_dir_all`, since + // afterwards there is no way to tell which levels it made. + let mut created_dirs: Vec = Vec::new(); if destination == Destination::Create && !renames.is_empty() { - let already_there = tokio::fs::symlink_metadata(new_root).await.is_ok(); + created_dirs = missing_levels(std::path::Path::new(new_root)).await; if let Err(error) = tokio::fs::create_dir_all(new_root).await { return Err(ApiError::Filesystem(format!( "could not create '{new_root}': {error}" ))); } - if !already_there { - created_root = Some(PathBuf::from(new_root)); - } } let mut performed: Vec = Vec::new(); @@ -209,7 +210,7 @@ async fn relocate_files( ); continue; } - Err(error) => return Err(failed(&rename, &error, performed, created_root).await), + Err(error) => return Err(failed(&rename, &error, performed, created_dirs).await), } match tokio::fs::rename(&rename.source, &rename.destination).await { Ok(()) => { @@ -220,31 +221,47 @@ async fn relocate_files( ); performed.push(rename); } - Err(error) => return Err(failed(&rename, &error, performed, created_root).await), + Err(error) => return Err(failed(&rename, &error, performed, created_dirs).await), } } Ok(Relocation { performed, rewrites, - created_root, + created_dirs, }) } +/// The levels of `path` that are not on disk, deepest first — exactly what a +/// following `create_dir_all` will materialise. The walk stops at the first +/// ancestor that exists, so nothing already there is ever listed. +async fn missing_levels(path: &std::path::Path) -> Vec { + let mut missing = Vec::new(); + for ancestor in path.ancestors() { + // `ancestors` ends in an empty path for a relative input; there is + // no level above that to create. + if ancestor.as_os_str().is_empty() || tokio::fs::symlink_metadata(ancestor).await.is_ok() { + break; + } + missing.push(ancestor.to_path_buf()); + } + missing +} + /// One rename failed: move back everything that had already moved, remove -/// the new root if this request is the one that created it, and name the -/// folder that stopped the move so the operator knows which title to look at -/// before retrying. +/// every directory level this request created, and name the folder that +/// stopped the move so the operator knows which title to look at before +/// retrying. async fn failed( rename: &PlannedRename, error: &std::io::Error, performed: Vec, - created_root: Option, + created_dirs: Vec, ) -> ApiError { Relocation { performed, rewrites: Vec::new(), - created_root, + created_dirs, } .undo() .await; @@ -297,13 +314,17 @@ impl Relocation { // `remove_dir` rather than `remove_dir_all`: it only succeeds on an // empty directory, so anything unexpected left inside it — this // request's own undo failing, say — is a reason to leave it alone. - if let Some(root) = &self.created_root { - if let Err(error) = tokio::fs::remove_dir(root).await { + // Deepest first, since a parent cannot go while its child is there; + // the first level that will not go stops the walk, because every + // level above it now has content and refusing is the right answer. + for directory in &self.created_dirs { + if let Err(error) = tokio::fs::remove_dir(directory).await { tracing::warn!( - path = %root.display(), + path = %directory.display(), %error, - "could not remove the directory created for a move that did not complete" + "could not remove a directory created for a move that did not complete" ); + break; } } } diff --git a/crates/arr-api/src/roots.rs b/crates/arr-api/src/roots.rs index 71ead45..df9ce47 100644 --- a/crates/arr-api/src/roots.rs +++ b/crates/arr-api/src/roots.rs @@ -152,6 +152,7 @@ pub async fn create( input.validate().map_err(ApiError::Invalid)?; input.policy_exists(&state).await?; let path = normalize_path(&input.path); + path_is_free(&state, None, &path).await?; let result = sqlx::query!( "INSERT INTO roots (kind, audience, path, policy_id) VALUES (?, ?, ?, ?)", input.kind, @@ -188,17 +189,25 @@ pub async fn update( input.policy_exists(&state).await?; let current = load_root(&state, id).await?; let path = normalize_path(&input.path); + // #244: rows written before #243 can hold a trailing separator, so the + // stored value is normalised too. Comparing a normalised payload against + // a raw stored value means the row can never compare equal: every edit, + // policy changes included, takes the relocation branch, and there each + // planned destination is its own source. The normalised value is what + // `relocate_root` gets as well, so no planned path carries a doubled + // separator. + let current_path = normalize_path(¤t.path); // A path change moves every §7.4 title folder under this root with the // row (issue #236), the same way changing a title's root moves one // (#228). Disk first, row second: a failed rename leaves the root row // alone, so the operator sees the library where its files actually are // and can retry. A path already taken is refused before any of it, since // the write would fail afterwards anyway. - let relocation = if path == current.path { + let relocation = if path == current_path { None } else { - path_is_free(&state, id, &path).await?; - Some(crate::relocate::relocate_root(&state, id, ¤t.path, &path).await?) + path_is_free(&state, Some(id), &path).await?; + Some(crate::relocate::relocate_root(&state, id, ¤t_path, &path).await?) }; let mut transaction = pool(&state)?.begin().await?; let written: Result<(), sqlx::Error> = async { @@ -245,12 +254,25 @@ pub async fn update( } /// The unique index on `path` would catch this after the move; catching it -/// first keeps a doomed write from touching the disk at all. -async fn path_is_free(state: &AppState, id: i64, path: &str) -> Result<(), ApiError> { - let taken: Option = - sqlx::query_scalar!("SELECT id FROM roots WHERE path = ? AND id <> ?", path, id) - .fetch_optional(pool(state)?) - .await?; +/// first keeps a doomed write from touching the disk at all. `except` is the +/// row being updated, or `None` when creating. +/// +/// The stored side is normalised in SQL, mirroring [`normalize_path`], so +/// `/mnt/media/x` and `/mnt/media/x/` cannot be two roots for one directory +/// (#244). The unique index cannot see that — it compares the raw strings — +/// and migration 0031 leaves any pair that already collides alone rather +/// than failing to apply, so such a row can still be on disk. +async fn path_is_free(state: &AppState, except: Option, path: &str) -> Result<(), ApiError> { + let taken: Option = sqlx::query_scalar!( + r#"SELECT id AS "id!: i64" FROM roots + WHERE CASE WHEN rtrim(path, '/') = '' THEN '/' ELSE rtrim(path, '/') END = ? + AND (? IS NULL OR id <> ?)"#, + path, + except, + except + ) + .fetch_optional(pool(state)?) + .await?; if taken.is_some() { return Err(ApiError::Conflict( "a root with this path already exists".into(), @@ -749,6 +771,17 @@ mod tests { assert_eq!(response.status(), StatusCode::NO_CONTENT); } + /// A path written straight into the row, no normalisation — how a root + /// created before #243 could end up holding a trailing separator. + async fn point_root_at_raw(state: &AppState, root_id: i64, path: &str) { + sqlx::query("UPDATE roots SET path = ? WHERE id = ?") + .bind(path) + .bind(root_id) + .execute(state.database().expect("database").pool()) + .await + .expect("store the raw path"); + } + async fn point_root_at(state: &AppState, root_id: i64, path: &std::path::Path) { sqlx::query("UPDATE roots SET path = ? WHERE id = ?") .bind(path.to_str().expect("utf-8 root")) @@ -1257,6 +1290,192 @@ mod tests { ); } + /// Issue #244: a root *stored* with a trailing separator — creatable + /// through the API at any point before #243 — could not be edited at + /// all. The payload was normalised and the stored value was not, so no + /// payload compared equal: every edit took the relocation branch, where + /// every planned destination is its own source and the pre-check 409s. + #[tokio::test] + async fn a_root_stored_with_a_trailing_separator_can_still_be_edited() { + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let unslashed = old.path().to_str().expect("utf-8").to_owned(); + point_root_at_raw(&state, 1, &format!("{unslashed}/")).await; + let id = add_movie(&base, 100, "Dune", 1).await; + let folder = library_folder(&state, id, old.path(), "Dune").await; + + // What the settings view sends back: the path exactly as stored, + // separator included, with only the policy changed. + let kids_policy = policy_id_named(&base, "Movies — kids").await; + let mut payload = root_payload(&base, 1, &format!("{unslashed}/")).await; + payload["policy_id"] = kids_policy.into(); + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&payload) + .send() + .await + .expect("edit the root"); + assert_eq!( + response.status(), + StatusCode::OK, + "{:?}", + response.text().await + ); + + assert!(folder.join("feature.mkv").exists(), "nothing on disk moved"); + assert_eq!( + stored_path(&base, 1).await, + unslashed, + "the row is left normalised, so the next edit compares equal too" + ); + + // And the same payload without the separator is not a relocation + // either. + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, &unslashed).await) + .send() + .await + .expect("edit the root again"); + assert_eq!(response.status(), StatusCode::OK); + assert!(folder.join("feature.mkv").exists(), "still nothing moved"); + } + + /// Issue #244: a real path change from a root stored with a trailing + /// separator plans from the normalised value, so neither a destination + /// nor a rewritten row carries a doubled separator. + #[tokio::test] + async fn relocating_a_slash_stored_root_plans_no_doubled_separator() { + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let home = tempfile::tempdir().expect("home of the new path"); + let new = home.path().join("relocated-main"); + let unslashed = old.path().to_str().expect("utf-8").to_owned(); + point_root_at_raw(&state, 1, &format!("{unslashed}/")).await; + let id = add_movie(&base, 100, "Dune", 1).await; + let folder = library_folder(&state, id, old.path(), "Dune").await; + + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await) + .send() + .await + .expect("move the root"); + assert_eq!( + response.status(), + StatusCode::OK, + "{:?}", + response.text().await + ); + + assert!(!folder.exists(), "the folder left the old path"); + assert!(new.join("Dune").join("feature.mkv").exists()); + let paths = file_paths(&state).await; + assert!( + !paths[0].contains("//"), + "no doubled separator in the rewritten row: {}", + paths[0] + ); + assert!( + std::path::Path::new(&paths[0]).exists(), + "the rewritten path describes the disk: {}", + paths[0] + ); + assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8")); + } + + /// Issue #244: `create_dir_all` can materialise more than one level for + /// a root path pointed somewhere fresh. A failed move removes every + /// level it created, not only the leaf — and still nothing that was + /// already on disk. + #[cfg(unix)] + #[tokio::test] + async fn a_failed_move_removes_every_level_it_created() { + use std::os::unix::fs::PermissionsExt; + + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let home = tempfile::tempdir().expect("home of the new path"); + // Three levels below a directory that is already there. + let top = home.path().join("media-v2"); + let new = top.join("tv").join("kids"); + point_root_at(&state, 1, old.path()).await; + + let id = add_movie(&base, 100, "Dune", 1).await; + let stuck = library_folder(&state, id, old.path(), "Dune").await; + // Moving a directory to another parent rewrites its `..`, which + // needs write permission on the directory itself. + tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555)) + .await + .expect("freeze the title folder"); + + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await) + .send() + .await + .expect("move the root"); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + + tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755)) + .await + .expect("thaw the title folder"); + assert!( + !top.exists(), + "every level the failed move created is gone, not only the leaf" + ); + assert!( + home.path().exists(), + "the level that was already there is left alone" + ); + assert!( + stuck.join("feature.mkv").exists(), + "the folder is still where the row says it is" + ); + assert_eq!( + stored_path(&base, 1).await, + old.path().to_str().expect("utf-8") + ); + } + + /// Issue #244: two roots naming one directory, differing only by a + /// trailing separator, are not two roots. The unique index compares raw + /// strings and cannot see it, so the check normalises both sides. + #[tokio::test] + async fn a_slash_stored_path_is_not_free_for_another_root() { + let (_dir, state, base) = application().await; + point_root_at_raw(&state, 2, "/mnt/media/movies/archive/").await; + + // An update onto the stripped form of a path another root holds. + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, "/mnt/media/movies/archive").await) + .send() + .await + .expect("update onto the other root's path"); + assert_eq!(response.status(), StatusCode::CONFLICT); + + // And a create, which the unique index would have let through. + let policy_ids = first_policy_ids(&base).await; + let mut payload = root_input(policy_ids[0]); + payload["kind"] = serde_json::json!("tv"); + payload["audience"] = serde_json::json!("main"); + payload["path"] = serde_json::json!("/mnt/media/movies/archive"); + let deleted = reqwest::Client::new() + .delete(format!("{base}/api/roots/3")) + .send() + .await + .expect("free the (tv, main) pair"); + assert_eq!(deleted.status(), StatusCode::NO_CONTENT); + let response = reqwest::Client::new() + .post(format!("{base}/api/roots")) + .json(&payload) + .send() + .await + .expect("create onto the other root's path"); + assert_eq!(response.status(), StatusCode::CONFLICT); + } + /// Issue #236: a path another root already holds is refused before the /// disk is touched at all. #[tokio::test] diff --git a/crates/arr-db/migrations/0031_normalise_root_paths.sql b/crates/arr-db/migrations/0031_normalise_root_paths.sql new file mode 100644 index 0000000..c67840f --- /dev/null +++ b/crates/arr-db/migrations/0031_normalise_root_paths.sql @@ -0,0 +1,34 @@ +-- Issue #244. A root's path could be stored with a trailing separator until +-- #243 normalised the incoming value. `roots::update` normalises the payload +-- and compares it against the stored value, so such a row never compares +-- equal: every edit, a policy change included, takes the relocation branch, +-- and there every planned destination is its own source. Normalising the +-- payload alone fixed the half that cannot bite; this is the other half. +-- +-- `rtrim` strips every trailing separator at once, so '/mnt/x//' normalises +-- in one pass. A bare '/' rtrims to the empty string and is put back, which +-- is what `normalize_path` in arr-api does. +-- +-- Guarded, because `roots.path` is UNIQUE and a migration that cannot apply +-- stops the daemon booting -- worse than the bug it fixes. A row is +-- normalised only when no other row shares its normalised path: neither a +-- row already holding the stripped value, nor another trailing-separator row +-- that would strip to the same thing. Every row in such a group is left +-- exactly as it is. That leaves two roots naming one directory, which is a +-- settings mistake for the operator to resolve by hand, not a reason to +-- refuse to boot. +-- +-- This cannot introduce a collision either. An updated row's new value is a +-- normalised path no other row normalises to, and a row left alone whose raw +-- path equalled that value would have had the same normalised path, which is +-- the case the guard excludes. +UPDATE roots +SET path = CASE WHEN rtrim(path, '/') = '' THEN '/' ELSE rtrim(path, '/') END +WHERE path <> CASE WHEN rtrim(path, '/') = '' THEN '/' ELSE rtrim(path, '/') END + AND NOT EXISTS ( + SELECT 1 + FROM roots AS other + WHERE other.id <> roots.id + AND CASE WHEN rtrim(other.path, '/') = '' THEN '/' ELSE rtrim(other.path, '/') END + = CASE WHEN rtrim(roots.path, '/') = '' THEN '/' ELSE rtrim(roots.path, '/') END + ); diff --git a/crates/arr-db/src/lib.rs b/crates/arr-db/src/lib.rs index 647b8a8..0d4b5ae 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -347,6 +347,93 @@ mod tests { assert_eq!(renamed_from_imported, "available"); } + /// The four seeded roots, up to but not including migration 0031, with + /// the given legacy paths written straight into the rows. + async fn roots_before_normalisation(paths: &[(i64, &str)]) -> (tempfile::TempDir, Db) { + let dir = tempfile::tempdir().expect("tempdir"); + let db = Db::connect(dir.path().join("arr.db")) + .await + .expect("connect"); + MIGRATOR + .run_to(30, db.pool()) + .await + .expect("migrations before #244"); + for (id, path) in paths { + sqlx::query("UPDATE roots SET path = ? WHERE id = ?") + .bind(path) + .bind(id) + .execute(db.pool()) + .await + .expect("legacy path, valid under the unique index"); + } + (dir, db) + } + + async fn root_path(db: &Db, id: i64) -> String { + sqlx::query_scalar("SELECT path FROM roots WHERE id = ?") + .bind(id) + .fetch_one(db.pool()) + .await + .expect("root path") + } + + /// #244: a root's path could be stored with a trailing separator until + /// #243 normalised the incoming value, and `roots::update` compares a + /// normalised payload against the stored value — so such a row could + /// never be edited again. Migration 0031 strips the separator, and skips + /// a row whose stripped form another row already holds rather than + /// tripping the unique index and refusing to apply. + #[tokio::test] + async fn root_paths_are_normalised_but_never_onto_a_path_in_use() { + let (_dir, db) = roots_before_normalisation(&[ + (1, "/mnt/media/movies/main/"), + (2, "/mnt/collide"), + (3, "/mnt/collide/"), + (4, "/mnt/media/tv/kids///"), + ]) + .await; + + db.migrate() + .await + .expect("0031 applies with a collision present"); + + assert_eq!(root_path(&db, 1).await, "/mnt/media/movies/main"); + assert_eq!( + root_path(&db, 4).await, + "/mnt/media/tv/kids", + "every trailing separator goes in one pass" + ); + assert_eq!(root_path(&db, 2).await, "/mnt/collide"); + assert_eq!( + root_path(&db, 3).await, + "/mnt/collide/", + "left exactly as it is: normalising it would collide with root 2" + ); + } + + /// #244: the harder half of the same guard. Two rows that strip to the + /// same path, *neither* of which already holds the stripped value, still + /// have to be left alone — normalising them would collide with each + /// other, and a migration that cannot apply stops the daemon booting. + #[tokio::test] + async fn two_rows_that_would_collide_with_each_other_stop_nothing() { + let (_dir, db) = + roots_before_normalisation(&[(1, "/mnt/one/"), (2, "/mnt/dup/"), (3, "/mnt/dup//")]) + .await; + + db.migrate() + .await + .expect("0031 applies with a mutually colliding pair present"); + + assert_eq!(root_path(&db, 2).await, "/mnt/dup/"); + assert_eq!(root_path(&db, 3).await, "/mnt/dup//"); + assert_eq!( + root_path(&db, 1).await, + "/mnt/one", + "the rest of the table is still normalised" + ); + } + /// #155: the table-rebuild migrations (0007, 0014, 0021) run outside a /// transaction so `PRAGMA foreign_keys = OFF` holds and dropping the old /// tables does not cascade-delete `movie_releases`/`episode_releases`. From bbb6d2f4a45054cef5b3a0be5623a9834ffa2d9f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 11:40:32 +0100 Subject: [PATCH 20/27] feat(api): re-derive verdicts on a policy edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUT /api/policies/{id} changed the rule every title under every root pointing at the policy is judged by, and re-derived nothing, so §9.3's deck and the daemon's grab gate kept reading verdicts computed under rules that no longer existed. Drives #241's walker from a policy id: root by root through reclassify::root, so the skip rules and the leave-unchanged-rows-alone rule stay in one place. A rename touches no rule and walks nothing. Inline still holds at this width. Measured on a release build over 2000 titles and 10 000 stored releases across two roots sharing one policy: 0.36 s when no verdict moves, 2.7 s when all 10 000 do. DESIGN.md §5.1 now names four actions and carries those numbers. just ci passed through the gate. Co-Authored-By: Claude Opus 5 --- ...492efe0df0123d9def212d84b88e57aaa0484.json | 98 +++++ ...3e15c746702c36a0d4068fa70d28b950e7562.json | 26 ++ DESIGN.md | 23 +- crates/arr-api/src/policies.rs | 341 +++++++++++++++++- crates/arr-api/src/reclassify.rs | 32 +- 5 files changed, 501 insertions(+), 19 deletions(-) create mode 100644 .sqlx/query-100e5e9297a9a917eb107c673ac492efe0df0123d9def212d84b88e57aaa0484.json create mode 100644 .sqlx/query-a28caf97dfb6a7e6b69543df0693e15c746702c36a0d4068fa70d28b950e7562.json diff --git a/.sqlx/query-100e5e9297a9a917eb107c673ac492efe0df0123d9def212d84b88e57aaa0484.json b/.sqlx/query-100e5e9297a9a917eb107c673ac492efe0df0123d9def212d84b88e57aaa0484.json new file mode 100644 index 0000000..8d96b35 --- /dev/null +++ b/.sqlx/query-100e5e9297a9a917eb107c673ac492efe0df0123d9def212d84b88e57aaa0484.json @@ -0,0 +1,98 @@ +{ + "db_name": "SQLite", + "query": "SELECT required_audio AS \"required_audio!: String\",\n dub_blacklist AS \"dub_blacklist!: String\",\n hdr_rules AS \"hdr_rules!: String\",\n size_bands AS \"size_bands!: String\",\n resolution_pref AS \"resolution_pref!: String\",\n source_weights AS \"source_weights!: String\",\n score_weights AS \"score_weights!: String\"\n FROM policies WHERE id = ?", + "describe": { + "columns": [ + { + "name": "required_audio!: String", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "policies", + "name": "required_audio" + } + } + }, + { + "name": "dub_blacklist!: String", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "policies", + "name": "dub_blacklist" + } + } + }, + { + "name": "hdr_rules!: String", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "policies", + "name": "hdr_rules" + } + } + }, + { + "name": "size_bands!: String", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "policies", + "name": "size_bands" + } + } + }, + { + "name": "resolution_pref!: String", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "policies", + "name": "resolution_pref" + } + } + }, + { + "name": "source_weights!: String", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "policies", + "name": "source_weights" + } + } + }, + { + "name": "score_weights!: String", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "policies", + "name": "score_weights" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "100e5e9297a9a917eb107c673ac492efe0df0123d9def212d84b88e57aaa0484" +} diff --git a/.sqlx/query-a28caf97dfb6a7e6b69543df0693e15c746702c36a0d4068fa70d28b950e7562.json b/.sqlx/query-a28caf97dfb6a7e6b69543df0693e15c746702c36a0d4068fa70d28b950e7562.json new file mode 100644 index 0000000..5bdcdb9 --- /dev/null +++ b/.sqlx/query-a28caf97dfb6a7e6b69543df0693e15c746702c36a0d4068fa70d28b950e7562.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\" FROM roots WHERE policy_id = ?", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "roots", + "name": "id" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "a28caf97dfb6a7e6b69543df0693e15c746702c36a0d4068fa70d28b950e7562" +} diff --git a/DESIGN.md b/DESIGN.md index b730786..fa9f0bd 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -201,14 +201,23 @@ both directions — `only_4k` tightens, `allow_english_audio` loosens. **Stored verdicts follow the effective policy.** A release's verdict is stamped by the search that found it, and both §9.3's deck and the daemon's -manual-grab gate read that stored column. So the three operator actions that +manual-grab gate read that stored column. So the four operator actions that change a title's effective policy — editing its overrides, moving it to a -root with a different policy, and pointing a root at a different policy — -re-derive the stored verdicts of everything they touch, inline in the same -request. The operator is never left reading a verdict computed under a -policy that no longer applies. Inline is affordable even root-wide: -re-evaluation is pure and in-memory, and a row whose verdict does not move -is not rewritten, so a library-sized repoint costs reads, not writes. +root with a different policy, pointing a root at a different policy, and +editing the contents of a policy some root points at — re-derive the stored +verdicts of everything they touch, inline in the same request. The operator +is never left reading a verdict computed under a policy that no longer +applies. + +The fourth is the widest: a repoint moves one library, a policy edit moves +every library sharing the policy. Inline still holds there. Re-evaluation is +pure and in-memory, a row whose verdict does not move is not rewritten, and +the ceiling is the whole database rather than something that grows with the +number of roots — roots partition titles, and a title has exactly one root. +Measured over 2000 titles and 10 000 stored releases split across two roots +sharing one policy: 0.36 s when the edit moves no verdict, 2.7 s in the +pathological case where it moves all 10 000. A rename changes no rule and +re-derives nothing. ### 5.2 Language diff --git a/crates/arr-api/src/policies.rs b/crates/arr-api/src/policies.rs index a7a07d0..6ebb77c 100644 --- a/crates/arr-api/src/policies.rs +++ b/crates/arr-api/src/policies.rs @@ -173,6 +173,33 @@ struct PolicyColumns { score_weights: String, } +/// Everything in a policy row that a verdict depends on — the name is the +/// one column that does not. +#[derive(PartialEq, Eq)] +struct PolicyRules { + required_audio: String, + dub_blacklist: String, + hdr_rules: String, + size_bands: String, + resolution_pref: String, + source_weights: String, + score_weights: String, +} + +impl PolicyColumns { + fn rules(&self) -> PolicyRules { + PolicyRules { + required_audio: self.required_audio.clone(), + dub_blacklist: self.dub_blacklist.clone(), + hdr_rules: self.hdr_rules.clone(), + size_bands: self.size_bands.clone(), + resolution_pref: self.resolution_pref.clone(), + source_weights: self.source_weights.clone(), + score_weights: self.score_weights.clone(), + } + } +} + fn column( column: &'static str, value: &str, @@ -376,6 +403,8 @@ pub async fn update( input.validate().map_err(ApiError::Invalid)?; let name = input.name.trim().to_owned(); let columns = input.into_columns(name)?; + let after = columns.rules(); + let before = stored_rules(&state, id).await?; let result = sqlx::query!( r#"UPDATE policies SET name = ?, required_audio = ?, dub_blacklist = ?, hdr_rules = ?, @@ -405,9 +434,44 @@ pub async fn update( if result.rows_affected() == 0 { return Err(ApiError::PolicyNotFound); } + // Every verdict stored under every root pointing here was reached under + // the rules this write just replaced; §9.3's deck and the grab gate both + // read them (`reclassify`). A rename leaves the rules alone, so it walks + // nothing. + if before.is_none_or(|before| before != after) { + crate::reclassify::policy(&state, id).await?; + } Ok(Json(load_policy(&state, id).await?)) } +/// The rule columns of a policy as they stand, or `None` when there is no +/// such row. Compared against what the write is about to store, so an edit +/// that only moves the name does not re-derive a library. +async fn stored_rules(state: &AppState, id: i64) -> Result, ApiError> { + let row = sqlx::query!( + r#"SELECT required_audio AS "required_audio!: String", + dub_blacklist AS "dub_blacklist!: String", + hdr_rules AS "hdr_rules!: String", + size_bands AS "size_bands!: String", + resolution_pref AS "resolution_pref!: String", + source_weights AS "source_weights!: String", + score_weights AS "score_weights!: String" + FROM policies WHERE id = ?"#, + id + ) + .fetch_optional(pool(state)?) + .await?; + Ok(row.map(|row| PolicyRules { + required_audio: row.required_audio, + dub_blacklist: row.dub_blacklist, + hdr_rules: row.hdr_rules, + size_bands: row.size_bands, + resolution_pref: row.resolution_pref, + source_weights: row.source_weights, + score_weights: row.score_weights, + })) +} + #[utoipa::path( delete, path = "/api/policies/{policy_id}", tag = "policies", params(("policy_id" = i64, Path, description = "Policy row id")), @@ -450,7 +514,7 @@ mod tests { use crate::{router, Upstreams}; use axum::http::StatusCode; - async fn application() -> (tempfile::TempDir, String) { + async fn application() -> (tempfile::TempDir, AppState, String) { let dir = tempfile::tempdir().expect("tempdir"); let database = arr_db::Db::connect(dir.path().join("arr.db")) .await @@ -466,9 +530,9 @@ mod tests { .await .expect("bind"); let address = listener.local_addr().expect("address"); - let app = router(state); + let app = router(state.clone()); tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); - (dir, format!("http://{address}")) + (dir, state, format!("http://{address}")) } fn valid_input(name: &str) -> serde_json::Value { @@ -501,7 +565,7 @@ mod tests { #[tokio::test] async fn crud_round_trips_a_policy() { - let (_dir, base) = application().await; + let (_dir, _state, base) = application().await; let created: serde_json::Value = create(&base, valid_input("test policy")) .await @@ -557,7 +621,7 @@ mod tests { #[tokio::test] async fn a_referenced_policy_refuses_to_die() { - let (_dir, base) = application().await; + let (_dir, _state, base) = application().await; let roots: Vec = reqwest::get(format!("{base}/api/roots")) .await .expect("roots") @@ -582,7 +646,7 @@ mod tests { #[tokio::test] async fn an_unknown_resolution_is_a_422_naming_the_field() { - let (_dir, base) = application().await; + let (_dir, _state, base) = application().await; let mut payload = valid_input("bad bands"); payload["size_bands"]["1440p"] = serde_json::json!({ "floor_gib": 2, "target_gib": 6, "penalty_points_per_gib_over": 60 }); @@ -596,7 +660,7 @@ mod tests { #[tokio::test] async fn every_field_validates_by_name() { - let (_dir, base) = application().await; + let (_dir, _state, base) = application().await; let with = |patch: &dyn Fn(&mut serde_json::Value)| { let mut payload = valid_input("validation probe"); patch(&mut payload); @@ -645,7 +709,7 @@ mod tests { #[tokio::test] async fn malformed_json_is_422_not_400_or_500() { - let (_dir, base) = application().await; + let (_dir, _state, base) = application().await; let client = reqwest::Client::new(); let response = client .post(format!("{base}/api/policies")) @@ -667,8 +731,267 @@ mod tests { #[tokio::test] async fn a_duplicate_name_conflicts() { - let (_dir, base) = application().await; + let (_dir, _state, base) = application().await; let response = create(&base, valid_input("Movies — main")).await; assert_eq!(response.status(), StatusCode::CONFLICT); } + + async fn policy_id_named(base: &str, name: &str) -> i64 { + let policies: Vec = reqwest::get(format!("{base}/api/policies")) + .await + .expect("policies") + .json() + .await + .expect("policies json"); + policies + .iter() + .find(|policy| policy["name"] == name) + .and_then(|policy| policy["id"].as_i64()) + .expect("policy id") + } + + /// A movie under `root_id`, with one English 1080p release stamped + /// `verdict` as a search would have stamped it. + async fn movie_with_release( + state: &AppState, + base: &str, + root_id: i64, + tmdb_id: i64, + verdict: &str, + ) -> i64 { + let movie: serde_json::Value = reqwest::Client::new() + .post(format!("{base}/api/movies")) + .json(&serde_json::json!({ + "tmdb_id": tmdb_id, "title": format!("Title {tmdb_id}"), "year": 2024, + "original_language": "en", "root_id": root_id, "overrides": {} + })) + .send() + .await + .expect("create movie") + .json() + .await + .expect("movie json"); + let movie_id = movie["id"].as_i64().expect("movie id"); + let name = format!("Title {tmdb_id} 2024 1080p WEB-DL ENGLISH x264-GROUP"); + let release_id = stamped_release(state, &name, 6_i64 * (1 << 30), verdict).await; + sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)") + .bind(movie_id) + .bind(release_id) + .execute(state.database().expect("database").pool()) + .await + .expect("movie association"); + movie_id + } + + /// One release row, verdict stamped by hand. `waived` carries no rule, + /// which the `releases` CHECK allows — only `rejected` needs one. + async fn stamped_release(state: &AppState, name: &str, size: i64, verdict: &str) -> i64 { + let parsed = arr_parse::parse(name); + sqlx::query_scalar::<_, i64>( + "INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict) + VALUES (7, ?, ?, ?, 40, 'url', ?, 0, ?) RETURNING id", + ) + .bind(name) + .bind(name) + .bind(size) + .bind(serde_json::to_string(&parsed).expect("parsed json")) + .bind(verdict) + .fetch_one(state.database().expect("database").pool()) + .await + .expect("release") + } + + /// A series under `root_id` with a ten-episode season 9 pack stamped + /// `waived`. Under its own policy the pack is eligible, so the stamp + /// only survives if nothing re-derived it. + async fn series_with_waived_pack(state: &AppState, base: &str, root_id: i64) -> i64 { + let client = reqwest::Client::new(); + let series: serde_json::Value = client + .post(format!("{base}/api/series")) + .json(&serde_json::json!({ + "tmdb_id": 82_728, "title": "Bluey", "year": 2018, + "original_language": "en", "root_id": root_id, + "auto_track": false + })) + .send() + .await + .expect("create series") + .json() + .await + .expect("series json"); + let series_id = series["id"].as_i64().expect("series id"); + let episodes: Vec = (1..=10) + .map(|number| { + serde_json::json!({ + "number": number, "title": format!("Episode {number}"), + "air_date": "2025-01-01" + }) + }) + .collect(); + let season: serde_json::Value = client + .post(format!("{base}/api/series/{series_id}/seasons")) + .json(&serde_json::json!({"number": 9, "episodes": episodes})) + .send() + .await + .expect("create season") + .json() + .await + .expect("season json"); + let season_id = season["id"].as_i64().expect("season id"); + // Ten episodes in 15 GiB: 1.5 GiB each, inside the 1080p band. + let release_id = stamped_release( + state, + "Bluey S09 1080p WEB-DL ENGLISH x264-GROUP", + 15_i64 * (1 << 30), + "waived", + ) + .await; + sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)") + .bind(season_id) + .bind(release_id) + .execute(state.database().expect("database").pool()) + .await + .expect("season association"); + series_id + } + + async fn verdict_at(url: String) -> serde_json::Value { + let releases: Vec = reqwest::get(url) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + releases[0]["verdict"].clone() + } + + async fn movie_verdict(base: &str, movie_id: i64) -> serde_json::Value { + verdict_at(format!("{base}/api/movies/{movie_id}/releases")).await + } + + async fn root_id_of(state: &AppState, kind: &str, audience: &str) -> i64 { + sqlx::query_scalar("SELECT id FROM roots WHERE kind = ? AND audience = ?") + .bind(kind) + .bind(audience) + .fetch_one(state.database().expect("database").pool()) + .await + .expect("root id") + } + + /// The same policy document with a different required-audio rule, so an + /// English release that was eligible becomes a waiver. + fn requires_portuguese(name: &str) -> serde_json::Value { + let mut payload = valid_input(name); + payload["required_audio"] = serde_json::json!({ "require": "any_of", "langs": ["pt-PT"] }); + payload + } + + /// #246: editing a policy re-derives the stored verdicts of every title + /// under every root pointing at it (§5.1) — both libraries when two + /// roots share it, and nothing under a root that does not. + #[tokio::test] + async fn editing_a_policy_rederives_every_root_that_points_at_it() { + let (_dir, state, base) = application().await; + let client = reqwest::Client::new(); + let shared = policy_id_named(&base, "Movies — main").await; + let main_root = root_id_of(&state, "movie", "main").await; + let kids_root = root_id_of(&state, "movie", "kids").await; + + // Two roots on one policy: the edit has two libraries to reach + // rather than one. The path stays, so nothing on disk moves. + let response = client + .put(format!("{base}/api/roots/{kids_root}")) + .json(&serde_json::json!({ + "kind": "movie", "audience": "kids", + "path": "/mnt/media/movies/kids", "policy_id": shared, + })) + .send() + .await + .expect("repoint kids root"); + assert_eq!( + response.status(), + StatusCode::OK, + "{:?}", + response.text().await + ); + + let here = movie_with_release(&state, &base, main_root, 693_134, "eligible").await; + let there = movie_with_release(&state, &base, kids_root, 27_205, "eligible").await; + // On the TV main root, a different policy, so the edit must not + // reach it. Stamped against its own policy's answer, so a walk that + // did reach it would show. + let tv_root = root_id_of(&state, "tv", "main").await; + let series_id = series_with_waived_pack(&state, &base, tv_root).await; + + let response = client + .put(format!("{base}/api/policies/{shared}")) + .json(&requires_portuguese("Movies — main")) + .send() + .await + .expect("edit policy"); + assert_eq!( + response.status(), + StatusCode::OK, + "{:?}", + response.text().await + ); + + assert_eq!( + movie_verdict(&base, here).await, + "waived", + "the first root's library re-derives" + ); + assert_eq!( + movie_verdict(&base, there).await, + "waived", + "and so does the second root's, sharing the policy" + ); + assert_eq!( + verdict_at(format!("{base}/api/series/{series_id}/seasons/9/releases")).await, + "waived", + "a root on another policy keeps the verdict it was stamped with" + ); + } + + /// Renaming a policy changes no rule, so it re-derives nothing — the + /// stamped verdict survives even though the rules would not produce it. + #[tokio::test] + async fn renaming_a_policy_leaves_verdicts_alone() { + let (_dir, state, base) = application().await; + let client = reqwest::Client::new(); + let kids = policy_id_named(&base, "Movies — kids").await; + let kids_root = root_id_of(&state, "movie", "kids").await; + + // Write the rules through the API once, so the rename that follows + // stores byte-identical rule columns and the only change is the name. + let response = client + .put(format!("{base}/api/policies/{kids}")) + .json(&valid_input("Movies — kids")) + .send() + .await + .expect("normalise policy"); + assert_eq!(response.status(), StatusCode::OK); + + // Deliberately the wrong answer: these rules make an English release + // a waiver, so a re-derivation would move this row. + let movie = movie_with_release(&state, &base, kids_root, 157_336, "eligible").await; + + let response = client + .put(format!("{base}/api/policies/{kids}")) + .json(&valid_input("Movies — children")) + .send() + .await + .expect("rename policy"); + assert_eq!( + response.status(), + StatusCode::OK, + "{:?}", + response.text().await + ); + assert_eq!( + movie_verdict(&base, movie).await, + "eligible", + "a rename touches no rule, so it walks nothing" + ); + } } diff --git a/crates/arr-api/src/reclassify.rs b/crates/arr-api/src/reclassify.rs index e95d265..d4fe79a 100644 --- a/crates/arr-api/src/reclassify.rs +++ b/crates/arr-api/src/reclassify.rs @@ -1,6 +1,7 @@ //! Stored verdicts, re-derived when a title's effective policy changes: -//! an overrides edit (§9.3), a move to a root with a different policy, or a -//! root pointed at a different policy (§5.1). +//! an overrides edit (§9.3), a move to a root with a different policy, a +//! root pointed at a different policy, or an edit to the contents of a +//! policy some root points at (§5.1). //! //! A release's verdict is stamped once, by the search that found it. Both the //! deck and the daemon's manual-grab gate read that stored column, so an @@ -9,7 +10,8 @@ //! reading `rejected` and the grab would be refused. A root move and a //! policy repoint invalidate the column the same way, just wider: nothing //! else re-reads it, so the write that changed the effective policy is the -//! only place the correction can happen. +//! only place the correction can happen. A policy edit invalidates it wider +//! still: every root pointing at that policy, not just one. //! //! So the rules run again here, over the releases already attached to the //! title. This is the same correction the daemon makes when a grab turns out @@ -176,6 +178,30 @@ pub(crate) async fn root(state: &AppState, root_id: i64) -> Result<(), ApiError> Ok(()) } +/// Re-evaluate every release of every title under every root that points at +/// one policy, for a `PUT /api/policies/{id}` that changed its rules (§5.1). +/// +/// Root by root through [`root`], which is title by title through [`movie`] +/// and [`series`]: one walker, one set of skip rules, one place that decides +/// a row does not need rewriting. +/// +/// This is the widest of the four re-derivations — a root repoint moves one +/// library, a policy edit moves every library sharing the policy — but the +/// ceiling is the whole database rather than something that grows with it, +/// since roots partition titles and a title has exactly one root. +pub(crate) async fn policy(state: &AppState, policy_id: i64) -> Result<(), ApiError> { + let root_ids = sqlx::query_scalar!( + r#"SELECT id AS "id!: i64" FROM roots WHERE policy_id = ?"#, + policy_id + ) + .fetch_all(pool(state)?) + .await?; + for root_id in root_ids { + root(state, root_id).await?; + } + Ok(()) +} + #[allow(clippy::too_many_arguments)] async fn apply( state: &AppState, From a6720f4c067bbab41e96eb1f27cb5a26887d8b8e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 11:43:14 +0100 Subject: [PATCH 21/27] feat(web): plainer words for grab, blocked, deck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three words the operator reads change; nothing underneath does. Waivers are still written, recorded and served under their existing names, the `blocked` column and flag keep theirs, and the release deck keeps its name in DESIGN.md §9.3 and in the code. - `waive + grab` reads `force grab`, and its accessible name says which rule the click relaxes. The `waived` bucket reads `below policy`, and so does the verdict chip on its rows — an operator can act on "below policy" and cannot act on the name the record keeps. - A waived import already said what was relaxed for two rules; `size` joins them and the fallback names the rule rather than badging it `waived`. - `blocked` reads `no targeted search`, everywhere the flag surfaces. §6.3 gives it one effect and a bare toggle hid it: the accessible name carries the RSS half at rest, and a note under the controls spells it out while the flag is on. - The season and episode controls are already the #230 search icon; only their labels still said "deck". They now say what the click does. `just ci` passes through the gate: 509 tests, biome, tsc, tokens. Verified in a real browser (agent-browser) on the library, movie detail, series detail, the episode release view, queues and settings, at 1440 and 390 wide — no horizontal scroll at either. Refs #232 --- web/index.html | 9 ++++-- web/src/library.ts | 7 +++- web/src/main.ts | 81 +++++++++++++++++++++++++++++++++------------- web/src/style.css | 11 +++++++ 4 files changed, 81 insertions(+), 27 deletions(-) diff --git a/web/index.html b/web/index.html index 67b6572..add0cda 100644 --- a/web/index.html +++ b/web/index.html @@ -252,7 +252,7 @@ id="movie-blocked" aria-pressed="false" > - blocked + no targeted search
@@ -314,7 +317,7 @@ diff --git a/web/src/library.ts b/web/src/library.ts index ad6531f..517ba94 100644 --- a/web/src/library.ts +++ b/web/src/library.ts @@ -70,6 +70,9 @@ export function movieNeedsAttention(movie: LibraryMovie): boolean { * `allow_english_audio` reads as "english, no dub", not as a clean match. * The import pipeline records the relaxed rule; both the bare rule name and * an object carrying one are accepted. + * + * Every arm names what was relaxed, never what the record calls it — the + * operator can act on "english, no dub" and cannot act on "waived". */ export function waiverLabel(waiver: unknown): string | null { const rule = waiverRule(waiver); @@ -81,8 +84,10 @@ export function waiverLabel(waiver: unknown): string | null { return "english, no dub"; case "resolution": return "below wanted resolution"; + case "size": + return "below the size floor"; default: - return `waived · ${rule.replaceAll("_", " ")}`; + return `${rule.replaceAll("_", " ")} relaxed`; } } diff --git a/web/src/main.ts b/web/src/main.ts index 9aabc27..e5aac0c 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -720,6 +720,18 @@ function searchMain( return { showIdle, restore }; } +/** + * What the operator reads for the stored `blocked` flag. §6.3 gives it one + * effect — it stops the targeted-search lane and leaves RSS matching on — + * and "blocked" reads as "block this title outright", which is the wrong + * promise. The flag, the column and the API keep their name; only the words + * on screen say what it does. [`BLOCKED_EFFECT`] carries the RSS half. + */ +const NO_TARGETED_SEARCH = "no targeted search"; + +/** The half a three-word label cannot hold, for titles and accessible names. */ +const BLOCKED_EFFECT = "arr stops searching for this title; RSS still matches it"; + function chip(text: string, extra?: (chip: HTMLSpanElement) => void): HTMLSpanElement { const span = document.createElement("span"); span.className = "chip readout"; @@ -931,7 +943,7 @@ function libraryRow( chips.append(chip("not wanted")); } if (movie.blocked) { - chips.append(chip("blocked")); + chips.append(chip(NO_TARGETED_SEARCH)); } const rating = ratingChip(movie.vote_average); if (rating !== null) { @@ -964,7 +976,7 @@ function librarySeriesRow( const root = roots.find((candidate) => candidate.id === series.root_id); chips.append(chip(root ? root.audience : `root ${series.root_id}`)); if (series.blocked) { - chips.append(chip("blocked")); + chips.append(chip(NO_TARGETED_SEARCH)); } const rating = ratingChip(series.vote_average); if (rating !== null) { @@ -1352,6 +1364,10 @@ function movieMain(views: HideableView[]): MovieView { const actionsEl = must("#movie-actions"); const wanted = must("#movie-wanted"); const blocked = must("#movie-blocked"); + const blockedNote = must("#movie-blocked-note"); + // the accessible name carries what three words cannot, at rest and on + blocked.setAttribute("aria-label", `${NO_TARGETED_SEARCH} — ${BLOCKED_EFFECT}`); + blocked.title = BLOCKED_EFFECT; const rootSelect = must("#movie-root"); const sweep = must("#movie-sweep"); const remove = must("#movie-remove"); @@ -1474,7 +1490,7 @@ function movieMain(views: HideableView[]): MovieView { ); } if (movie.blocked) { - chipsEl.append(chip("blocked")); + chipsEl.append(chip(NO_TARGETED_SEARCH)); } } @@ -1495,6 +1511,10 @@ function movieMain(views: HideableView[]): MovieView { } wanted.setAttribute("aria-pressed", String(movie.wanted)); blocked.setAttribute("aria-pressed", String(movie.blocked)); + // §6.3 is the whole point of the flag and a bare toggle hides it: the + // note appears when the flag is on, which is when the RSS half is the + // thing that surprises. At rest the label and the title carry it. + blockedNote.hidden = !movie.blocked; paintRootOptions(); // a root list that does not carry the title's own root still shows it — // a select lying by omission would make the next change move it blindly @@ -1549,7 +1569,7 @@ function movieMain(views: HideableView[]): MovieView { void updateMovie(movie.id, { blocked: !movie.blocked }).then((outcome) => { if (outcome.kind === "error") { blocked.disabled = false; - setStatus(`blocked failed — ${outcome.detail}`, "fault"); + setStatus(`targeted search change failed — ${outcome.detail}`, "fault"); return; } void refreshMovie().then(() => { @@ -2217,13 +2237,13 @@ function paintBuckets(dom: BucketsDom, releases: MovieRelease[], actions: Releas // §9.3: over-strict filters must be visible, not silently absent — and // where a rule can be waived, the count says so rather than leaving the // way out folded inside a collapsed bucket. - const waivable = releases.filter(overridable).length; + const forceable = releases.filter(overridable).length; const none = document.createElement("li"); none.className = "rel rel-none readout dim"; none.textContent = - waivable > 0 - ? `none — every candidate was waived or rejected by policy; ${waivable} can be waived` - : "none — every candidate was waived or rejected by policy"; + forceable > 0 + ? `none — every candidate is below policy or rejected; ${forceable} can be force grabbed` + : "none — every candidate is below policy or rejected"; dom.eligible.rows.append(none); } for (const name of ["waived", "rejected"] as const) { @@ -2310,7 +2330,7 @@ function buildBucketDom(root: HTMLElement): BucketsDom { const dom: BucketsDom = { eligible: { section: eligibleSection, count: eligibleCount, rows: eligibleRows }, - waived: collapsed("waived"), + waived: collapsed("below policy"), rejected: collapsed("rejected"), }; root.append(dom.eligible.section, dom.waived.section, dom.rejected.section); @@ -2361,11 +2381,15 @@ function releaseRow( } if (bucket !== "eligible") { // A rejected row always names its rule; a waived one cannot — the - // `releases` CHECK allows `rejected_rule` only on a rejection. Say - // `waived` plainly rather than calling a classified row unclassified. - const verdict = release.rejected_rule - ? `${bucket} · ${ruleLabel(release.rejected_rule)}` - : bucket; + // `releases` CHECK allows `rejected_rule` only on a rejection. So a + // waived row says the plainer thing the operator can act on, "below + // policy", rather than the name the record keeps for it. + const verdict = + bucket === "waived" + ? "below policy" + : release.rejected_rule + ? `rejected · ${ruleLabel(release.rejected_rule)}` + : "rejected"; line.append( chip(verdict, (span) => { span.dataset.verdict = bucket; @@ -2392,7 +2416,14 @@ function releaseRow( const grab = document.createElement("button"); grab.type = "button"; grab.className = "control rel-grab"; - grab.textContent = writesOverride ? "waive + grab" : "grab"; + // "force grab" says what the click does; the accessible name says what + // it forces past, since the rule is the reason the row is not eligible. + grab.textContent = writesOverride ? "force grab" : "grab"; + if (writesOverride) { + const forced = `force grab — relaxes ${ruleLabel(release.rejected_rule)} for this title and grabs anyway`; + grab.setAttribute("aria-label", forced); + grab.title = forced; + } grab.addEventListener("click", () => { grab.disabled = true; note.hidden = false; @@ -2665,7 +2696,7 @@ function seriesRow( ); } if (series.blocked) { - chips.append(chip("blocked")); + chips.append(chip(NO_TARGETED_SEARCH)); } chips.append( chip(series.status, (span) => { @@ -2777,7 +2808,7 @@ function seriesCard( ); } if (series.blocked) { - chips.push(chip("blocked")); + chips.push(chip(NO_TARGETED_SEARCH)); } chips.push( chip(series.status, (span) => { @@ -2814,7 +2845,7 @@ function movieCard( chips.push(chip("not wanted")); } if (movie.blocked) { - chips.push(chip("blocked")); + chips.push(chip(NO_TARGETED_SEARCH)); } const rating = ratingChip(movie.vote_average); if (rating !== null) { @@ -2901,7 +2932,11 @@ function tvReleasesMain(): TvReleasesView { grab: (release, bucket) => { const current = request; if (!current) { - return Promise.resolve({ kind: "error", detail: "deck closed", overrideWritten: false }); + return Promise.resolve({ + kind: "error", + detail: "releases closed", + overrideWritten: false, + }); } if (bucket !== "eligible") { return waiveAndGrabTv(current.seriesId, current.target, release.id, release.rejected_rule); @@ -3306,7 +3341,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { }), ); if (current.blocked) { - chipsEl.append(chip("blocked")); + chipsEl.append(chip(NO_TARGETED_SEARCH)); } } @@ -3495,8 +3530,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { deckBtn.className = "control control-icon"; const deckLabel = season.number === 0 - ? "open the specials release deck" - : `open the season ${PAD_TWO(season.number)} release deck`; + ? "search releases for specials" + : `search releases for season ${PAD_TWO(season.number)}`; deckBtn.setAttribute("aria-label", deckLabel); deckBtn.title = deckLabel; deckBtn.append(icon("search")); @@ -3663,7 +3698,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { const deckBtn = document.createElement("button"); deckBtn.type = "button"; deckBtn.className = "control control-icon"; - const deckLabel = `open the ${episodeTag(seasonNumber, episode.number)} release deck`; + const deckLabel = `search releases for ${episodeTag(seasonNumber, episode.number)}`; deckBtn.setAttribute("aria-label", deckLabel); deckBtn.title = deckLabel; deckBtn.append(icon("search")); diff --git a/web/src/style.css b/web/src/style.css index ab58117..6bc612b 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1748,6 +1748,17 @@ body { padding: 0 var(--space-3); } +/* The one control whose effect is narrower than its name suggests (§6.3) + says so under the row while it is on: three words on the button, the + half they cannot hold on the line below. Aligned with the row's own + padding so it reads as a note on the controls, not a new section. */ +.control-note { + margin: 0; + padding: 0 var(--space-1) var(--space-2); + color: var(--ink-muted); + font-size: var(--text-xs); +} + /* wanted and blocked read as rules, pressed = on — the tracked-toggle idiom */ .movie-controls .control[aria-pressed="true"] { color: var(--accent-bright); From a0dc07f0856a438aafe4d56e25eac8056a360eeb Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 11:42:59 +0100 Subject: [PATCH 22/27] feat(db): let a waiver name the rule it relaxed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `releases` forbade a rule name on anything but a rejection, so §9.3's deck showed a bare `waived` beside rejections that each named their own, and §5.7's "watchable but not what was asked" lost the half that says what was not asked for. Since #210 that is the ordinary outcome of waiving a size rejection, not a rare one. 0032 rebuilds the table with `CHECK (verdict != 'rejected' OR rejected_rule IS NOT NULL)`, and the daemon and arr-api's reclassification both store the waived rule. Existing rows keep NULL and read as they do today. `releases` is a parent — `grabs`, `movie_releases`, `episode_releases` and `season_releases` point at it, three ON DELETE CASCADE — so the rebuild runs `-- no-transaction` with foreign keys off around one explicit transaction, per SQLite's own procedure. Verified against a real database: the pre-0032 binary created and populated it, this build migrated a copy, and every release row, child row and created_at came through byte-identical with `PRAGMA foreign_key_check` clean. Refs #211 --- crates/arr-api/src/movies.rs | 10 + crates/arr-api/src/reclassify.rs | 8 +- crates/arr-api/src/series.rs | 7 +- crates/arr-daemon/src/grab.rs | 25 ++- crates/arr-db/migrations/0032_waived_rule.sql | 68 +++++++ crates/arr-db/src/lib.rs | 171 ++++++++++++++++++ 6 files changed, 281 insertions(+), 8 deletions(-) create mode 100644 crates/arr-db/migrations/0032_waived_rule.sql diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index fabc819..53a5923 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -76,6 +76,12 @@ pub struct Release { pub parsed: serde_json::Value, pub score: Option, pub verdict: Option, + // The rule behind the verdict: the one that killed a `rejected` row, or + // the one a `waived` row relaxed (#211). Null on an `eligible` row, and + // on a `waived` row stored before migration 0032, which could not record + // it. A plain comment, not a doc comment: doc comments here become + // OpenAPI descriptions and would put the generated client in web/ out of + // date, which #227 and #232 own. pub rejected_rule: Option, } @@ -1303,6 +1309,10 @@ mod tests { .expect("releases json"); assert_eq!(releases.len(), 1); assert_eq!(releases[0]["verdict"], "waived"); + // #211: and it says which rule the waiver relaxed, rather than + // sitting in the deck as a bare `waived` beside rejections that each + // name their own. + assert_eq!(releases[0]["rejected_rule"], "size"); } /// #241: moving a title to a root with a different policy re-derives its diff --git a/crates/arr-api/src/reclassify.rs b/crates/arr-api/src/reclassify.rs index e95d265..b01beb7 100644 --- a/crates/arr-api/src/reclassify.rs +++ b/crates/arr-api/src/reclassify.rs @@ -208,11 +208,11 @@ async fn apply( episodes, runtime_minutes, ); - // `releases` allows a rule name only on a rejected row - // (`CHECK ((verdict = 'rejected') = (rejected_rule IS NOT NULL))`), - // which is also how the daemon writes a waiver. + // A waiver records the rule it relaxed, the same as a rejection + // (#211). Migration 0032 relaxed `releases` to + // `CHECK (verdict != 'rejected' OR rejected_rule IS NOT NULL)` so it + // can, and the daemon writes waivers the same way. let (verdict, rule) = verdict_columns(&evaluation.verdict); - let rule = if verdict == "rejected" { rule } else { None }; if release.verdict.as_deref() == Some(verdict) && release.rejected_rule == rule { continue; } diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index de95833..12904f8 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -2014,9 +2014,10 @@ mod tests { releases[0]["verdict"], "waived", "a waived grab stays a waiver; nothing here makes it eligible" ); - // The row's rule name goes with the rejection; what survives is the - // dashed `waived` verdict the deck reads (§9.3). - assert!(releases[0]["rejected_rule"].is_null()); + // #211: the waiver keeps the rule it relaxed, so the deck names it + // the way it names a rejection (§9.3) instead of showing a bare + // `waived`. + assert_eq!(releases[0]["rejected_rule"], "size"); // The grab the deck's one click sends is now accepted. let response = reqwest::Client::new() diff --git a/crates/arr-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs index b38d37d..4a404ca 100644 --- a/crates/arr-daemon/src/grab.rs +++ b/crates/arr-daemon/src/grab.rs @@ -1534,10 +1534,17 @@ fn search_query(movie: &PendingMovie) -> String { ) } +/// The `verdict` and `rejected_rule` columns for a verdict. +/// +/// A waiver names the rule it relaxed (#211). §5.7 calls a soft fail +/// "watchable but not what was asked", and which rule was relaxed is the +/// whole content of that sentence, so §9.3's deck can name it the way it +/// names a rejection. Rows written before 0032 hold `NULL` there and stay +/// readable. fn verdict_columns(verdict: &Verdict) -> (&'static str, Option) { match verdict { Verdict::Eligible => ("eligible", None), - Verdict::Waived(_) => ("waived", None), + Verdict::Waived(rule) => ("waived", Some(rule.name())), Verdict::Rejected(rule) => ("rejected", Some(rule.name())), } } @@ -2029,6 +2036,22 @@ mod tests { ); } + /// #211: a waiver names the rule it relaxed, the same as a rejection, so + /// §9.3's deck shows what was given up instead of a bare `waived`. + /// Migration 0032 relaxed the constraint that forbade it. + #[test] + fn a_waiver_records_the_rule_it_relaxed() { + assert_eq!( + verdict_columns(&Verdict::Waived(arr_core::Rule::Size)), + ("waived", Some("size".to_owned())) + ); + assert_eq!(verdict_columns(&Verdict::Eligible), ("eligible", None)); + assert_eq!( + verdict_columns(&Verdict::Rejected(arr_core::Rule::RequiredAudio)), + ("rejected", Some("required_audio".to_owned())) + ); + } + /// Every candidate is cached with its verdict, which is what the manual /// search view and the attention queues read (§9.3). #[tokio::test] diff --git a/crates/arr-db/migrations/0032_waived_rule.sql b/crates/arr-db/migrations/0032_waived_rule.sql new file mode 100644 index 0000000..3a18906 --- /dev/null +++ b/crates/arr-db/migrations/0032_waived_rule.sql @@ -0,0 +1,68 @@ +-- no-transaction +-- #211: a waived release could not record which rule it relaxed. The old +-- constraint made the rule name an exact synonym for rejection: +-- +-- CHECK ((verdict = 'rejected') = (rejected_rule IS NOT NULL)) +-- +-- so §9.3's deck showed a bare `waived` beside rejected rows that each named +-- their own rule, and §5.7's "watchable but not what was asked" lost the half +-- that says what was not asked for. The relaxed form still demands a rule on +-- a rejection and stops demanding its absence elsewhere. +-- +-- SQLite cannot alter a CHECK, so the table is rebuilt (see 0014). Unlike the +-- rebuilds there, `releases` is a parent: `grabs`, `movie_releases`, +-- `episode_releases` and `season_releases` all point at it, three of them +-- ON DELETE CASCADE. Dropping the old table with foreign keys enforced would +-- delete those children (or, for `grabs`, refuse outright), so this follows +-- SQLite's own procedure — foreign keys off, the rebuild in one transaction, +-- foreign keys back on. `PRAGMA foreign_keys` is a no-op inside a +-- transaction, which is why the file opens `-- no-transaction` and manages +-- its own; the migration is still all-or-nothing. +-- +-- Rows are copied verbatim. Every existing `waived` row keeps its NULL and +-- goes on reading as it does today; only rows written after this migration +-- carry a waived rule. +PRAGMA foreign_keys = OFF; + +BEGIN; + +CREATE TABLE releases_new ( + id INTEGER PRIMARY KEY, + -- Prowlarr's indexer id. Not a foreign key: indexers live in Prowlarr. + indexer_id INTEGER NOT NULL, + guid TEXT NOT NULL, + name TEXT NOT NULL, + size INTEGER NOT NULL, + seeders INTEGER, + publish_date TEXT, + download_url TEXT NOT NULL, + -- §5.6. What the release name claims, before anything is downloaded. + parsed TEXT NOT NULL CHECK (json_valid(parsed)), + score REAL, + -- §9.3. Three buckets. `rejected_rule` names the rule that killed it so + -- an over-strict filter is visible without reading release names, and + -- §5.7's waiver names the rule it relaxed for the same reason. + verdict TEXT CHECK (verdict IN ('eligible', 'waived', 'rejected')), + rejected_rule TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE (indexer_id, guid), + CHECK (verdict != 'rejected' OR rejected_rule IS NOT NULL) +) STRICT; + +INSERT INTO releases_new ( + id, indexer_id, guid, name, size, seeders, publish_date, download_url, + parsed, score, verdict, rejected_rule, created_at +) +SELECT + id, indexer_id, guid, name, size, seeders, publish_date, download_url, + parsed, score, verdict, rejected_rule, created_at +FROM releases; + +DROP TABLE releases; +ALTER TABLE releases_new RENAME TO releases; + +CREATE INDEX releases_verdict ON releases (verdict, score); + +COMMIT; + +PRAGMA foreign_keys = ON; diff --git a/crates/arr-db/src/lib.rs b/crates/arr-db/src/lib.rs index 0d4b5ae..d28eb37 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -517,6 +517,177 @@ mod tests { ); } + /// #211: 0032 rebuilds `releases`, which is a parent — `grabs`, + /// `movie_releases`, `episode_releases` and `season_releases` all point + /// at it, three of them ON DELETE CASCADE. The rebuild runs with foreign + /// keys off so dropping the old table neither cascades those children + /// away nor is refused by `grabs`. + #[tokio::test] + async fn the_releases_rebuild_preserves_children_and_rows() { + let (_dir, db) = a_deck_with_every_child_row().await; + + db.migrate().await.expect("remaining migrations"); + + for (label, query, expected) in [ + ("releases", "SELECT count(*) FROM releases", 3), + ("movie_releases", "SELECT count(*) FROM movie_releases", 1), + ( + "episode_releases", + "SELECT count(*) FROM episode_releases", + 1, + ), + ("season_releases", "SELECT count(*) FROM season_releases", 1), + ("grabs", "SELECT count(*) FROM grabs", 1), + ] { + let rows: i64 = sqlx::query_scalar(query) + .fetch_one(db.pool()) + .await + .expect("count"); + assert_eq!(rows, expected, "{label} survives the releases rebuild"); + } + + // An existing waived row keeps its NULL and reads as it did before. + let waived: Option = + sqlx::query_scalar("SELECT rejected_rule FROM releases WHERE guid = 'waived-guid'") + .fetch_one(db.pool()) + .await + .expect("waived row"); + assert_eq!(waived, None); + let rejected: Option = + sqlx::query_scalar("SELECT rejected_rule FROM releases WHERE guid = 'rejected-guid'") + .fetch_one(db.pool()) + .await + .expect("rejected row"); + assert_eq!(rejected.as_deref(), Some("size")); + + // Foreign keys are back on for the connection the migration used. + let foreign_keys: i64 = sqlx::query_scalar("PRAGMA foreign_keys") + .fetch_one(db.pool()) + .await + .expect("foreign_keys"); + assert_eq!(foreign_keys, 1); + } + + /// A database migrated to just before 0032, holding one release per + /// verdict and one row in every table that points at `releases`. + async fn a_deck_with_every_child_row() -> (tempfile::TempDir, Db) { + let dir = tempfile::tempdir().expect("tempdir"); + let db = Db::connect(dir.path().join("arr.db")) + .await + .expect("connect"); + + MIGRATOR + .run_to(31, db.pool()) + .await + .expect("migrations before the releases rebuild"); + + let movie_id = sqlx::query( + "INSERT INTO movies (tmdb_id, title, root_id) + SELECT 693134, 'Dune Part Two', id FROM roots WHERE kind = 'movie' LIMIT 1", + ) + .execute(db.pool()) + .await + .expect("movie") + .last_insert_rowid(); + let series_id = sqlx::query( + "INSERT INTO series (tmdb_id, title, root_id) + SELECT 82728, 'Bluey', id FROM roots WHERE kind = 'tv' LIMIT 1", + ) + .execute(db.pool()) + .await + .expect("series") + .last_insert_rowid(); + let season_id = sqlx::query("INSERT INTO seasons (series_id, number) VALUES (?, 1)") + .bind(series_id) + .execute(db.pool()) + .await + .expect("season") + .last_insert_rowid(); + let episode_id = + sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 1, 'x')") + .bind(season_id) + .execute(db.pool()) + .await + .expect("episode") + .last_insert_rowid(); + // One row per verdict, including the waived row this issue is about, + // which under the old constraint could only hold NULL. + for (guid, verdict, rule) in [ + ("eligible-guid", "eligible", None), + ("waived-guid", "waived", None), + ("rejected-guid", "rejected", Some("size")), + ] { + sqlx::query( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict, rejected_rule) + VALUES (1, ?, ?, 1024, 'http://x', '{}', ?, ?)", + ) + .bind(guid) + .bind(guid) + .bind(verdict) + .bind(rule) + .execute(db.pool()) + .await + .expect("release"); + } + let release_id: i64 = + sqlx::query_scalar("SELECT id FROM releases WHERE guid = 'eligible-guid'") + .fetch_one(db.pool()) + .await + .expect("release id"); + sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)") + .bind(movie_id) + .bind(release_id) + .execute(db.pool()) + .await + .expect("movie link"); + sqlx::query("INSERT INTO episode_releases (episode_id, release_id) VALUES (?, ?)") + .bind(episode_id) + .bind(release_id) + .execute(db.pool()) + .await + .expect("episode link"); + sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)") + .bind(season_id) + .bind(release_id) + .execute(db.pool()) + .await + .expect("season link"); + sqlx::query( + "INSERT INTO grabs (release_id, target_kind, target_id, infohash) + VALUES (?, 'movie', ?, 'infohash-1')", + ) + .bind(release_id) + .bind(movie_id) + .execute(db.pool()) + .await + .expect("grab"); + + (dir, db) + } + + /// The relaxed constraint (#211) admits a rule on a waiver and still + /// refuses a rejection without one. + #[tokio::test] + async fn a_waiver_may_name_the_rule_it_relaxed() { + let (_dir, db) = fresh().await; + + sqlx::query( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict, rejected_rule) + VALUES (1, 'waived', 'Some.Release', 1024, 'http://x', '{}', 'waived', 'size')", + ) + .execute(db.pool()) + .await + .expect("a waiver names its rule"); + + sqlx::query( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) + VALUES (1, 'rejected', 'Some.Release', 1024, 'http://x', '{}', 'rejected')", + ) + .execute(db.pool()) + .await + .expect_err("a rejection still has to name its rule"); + } + #[tokio::test] async fn seeds_two_tv_roots_with_distinct_policies() { let (_dir, db) = fresh().await; From c78c73ec6c1b444e9f166977f0f33b360b1b0115 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 11:49:34 +0100 Subject: [PATCH 23/27] =?UTF-8?q?docs:=20reconcile=20design=20coherence=20?= =?UTF-8?q?issues=20in=20=C2=A75.7=20and=20=C2=A79.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §9.5 restated the pre-#226 decision-bar rule without the 30-day window, giving the document two versions of the same rule. Make it defer to §5.7 instead. §5.7 used two phrasings for the season-queuing predicate in one paragraph (existence-of-file vs. state check) though both readers implement the state check; picked the state phrasing throughout. Also fixed the arithmetically confusing "seen twice ... third face" sentence, and rewrapped the single unwrapped ~450-character line in §7.4 to the document's ~78-column width. Ref #240. Ran full `just ci` through the gate (517 tests, exit 0). Co-Authored-By: Claude Sonnet 5 --- DESIGN.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index fa9f0bd..a4b21c9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -422,13 +422,13 @@ 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 +**Only a target still waiting for a file is queued.** The count and the +window already express one rule — bounded attention — and this is another +face of it: 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 not `available`. 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. @@ -548,7 +548,12 @@ Media kind first, hard audience boundary second, people nowhere. - **Release group is deliberately absent.** It is not a selection criterion and it makes filenames long enough to break a terminal. -Changing a title's root relocates its title folder into the new root; roots are assumed to share one filesystem, so the move is a rename, never a copy. Changing a root's path is the same move over every title under it, and it is all or nothing: one folder that cannot move puts back the ones that already did and leaves the root's path alone, so the stored path always describes the disk. +Changing a title's root relocates its title folder into the new root; roots +are assumed to share one filesystem, so the move is a rename, never a copy. +Changing a root's path is the same move over every title under it, and it +is all or nothing: one folder that cannot move puts back the ones that +already did and leaves the root's path alone, so the stored path always +describes the disk. During transition, write into the existing roots so Jellyfin needs no reconfiguration and new content appears immediately. Radarr will not touch a @@ -658,7 +663,7 @@ notifying on everything and being muted within a week. - **Imported** → to the title's owners. The only good-news notification. - **Needs a decision** → to the operator alone. Entered the no-PT-source queue, - or hard-failed twice on different releases. + or the needs-a-decision queue (§5.7). - **Broken** → to the operator alone. Prowlarr, Transmission or TMDB unreachable, disk full. From 55373d228c10e3e26054bdb2de1aa4715a16fdd0 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 11:49:44 +0100 Subject: [PATCH 24/27] fix: pack backoff runs from the failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #239 moved §5.7's attention window to `failed_at` and left §6.2's pack ladder on `grabbed_at`. A torrent that stalls for weeks before ffprobe condemns it at import has elapsed the whole ladder the moment it fails, so the pack lane retried a source that had just failed — the one thing the backoff exists to prevent. The ladder now measures from the failure, the same anchor and the same column §5.7 reads, with `grabbed_at` as the fallback for rows written before the column existed. All three sites read `max(coalesce(failed_at, grabbed_at))`, so the `last_failed_at` alias holds what its name says — including the one the season deck feeds into `reopens_at` and `pack_retry_at`, which was showing a grab time under a name §5.7 had redefined. DESIGN.md §6.2 states the anchor the way §5.7 states its own. Tests cover a pack grabbed 35 days ago and failed 10 minutes ago on the targeted lane, the RSS lane and the season deck. `just ci` through the gate: 519/519 tests pass, web checks clean. Co-Authored-By: Claude Opus 5 --- ...6c4fad280f56905b6a85035a142dd592dbec.json} | 4 +- ...862bba0dd2d708e948c32fd74f013cc4cda5.json} | 4 +- DESIGN.md | 16 +++++ crates/arr-api/src/series.rs | 34 +++++++++-- crates/arr-daemon/src/rss.rs | 60 ++++++++++++++++++- crates/arr-daemon/src/tv_grab.rs | 59 +++++++++++++++--- 6 files changed, 159 insertions(+), 18 deletions(-) rename .sqlx/{query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json => query-3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec.json} (55%) rename .sqlx/{query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json => query-c73422a1c9d28742f200d1744ba0862bba0dd2d708e948c32fd74f013cc4cda5.json} (55%) diff --git a/.sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json b/.sqlx/query-3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec.json similarity index 55% rename from .sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json rename to .sqlx/query-3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec.json index 207bd32..0c19f57 100644 --- a/.sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json +++ b/.sqlx/query-3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'", + "query": "SELECT count(*) AS \"failures!: i64\",\n max(coalesce(failed_at, grabbed_at)) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'", "describe": { "columns": [ { @@ -24,5 +24,5 @@ true ] }, - "hash": "ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019" + "hash": "3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec" } diff --git a/.sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json b/.sqlx/query-c73422a1c9d28742f200d1744ba0862bba0dd2d708e948c32fd74f013cc4cda5.json similarity index 55% rename from .sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json rename to .sqlx/query-c73422a1c9d28742f200d1744ba0862bba0dd2d708e948c32fd74f013cc4cda5.json index d9045ff..ed923e9 100644 --- a/.sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json +++ b/.sqlx/query-c73422a1c9d28742f200d1744ba0862bba0dd2d708e948c32fd74f013cc4cda5.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'", + "query": "SELECT count(*) AS \"failures!: i64\",\n max(coalesce(failed_at, grabbed_at)) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'", "describe": { "columns": [ { @@ -24,5 +24,5 @@ true ] }, - "hash": "0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162" + "hash": "c73422a1c9d28742f200d1744ba0862bba0dd2d708e948c32fd74f013cc4cda5" } diff --git a/DESIGN.md b/DESIGN.md index fa9f0bd..82dad57 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -480,6 +480,22 @@ its own, the escape hatch is the season release deck, not a lane exception. Targeted search backs off `1h → 6h → 1d → 3d`, capped at 7d, reset when the title's metadata changes. It never gives up entirely, it goes quiet. +**The ladder runs from the failure, not the grab.** A failed season-pack grab +quiets the pack lane on that same curve, and the rung is measured from the +moment the grab entered `failed` — `grabs.failed_at`, the column §5.7's +attention window reads — not from when it was sent. The two are usually +minutes apart, but a torrent can stall on a dead swarm for five weeks before +`ffprobe` condemns it at import. Measured from the grab, the whole ladder has +already elapsed by the time the failure lands, so the lane retries the source +that just failed at once, which is the one thing the backoff exists to +prevent. The ladder's job is to stay off a source that has recently failed, +and "recently" can only mean recently failed. + +One anchor covers both features. §5.7's window and this ladder ask the same +question of the same event and read the same column; a target still broken +keeps producing fresh failures, and each one both re-arms this backoff and +holds the target in the attention queue. + **Do not search before the release exists.** TMDB carries release dates; a movie with no digital release date gets zero targeted searches. This is the single largest source of wasted queries in Radarr and it is free to avoid. diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index de95833..35fdf3a 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -1672,9 +1672,12 @@ pub async fn season_pack_state( .await?; // The same failed-pack tally the grab lane backs off on (#181), read // here so the deck can name the window instead of guessing at one. + // `last_failed_at` really is the failure time (#245): `reopens_at` and + // `pack_retry_at` below hand it to the deck, so an alias holding a grab + // time would put a grab under a name §5.7 gave to something else. let failed = sqlx::query!( - r#"SELECT count(*) AS "failures!: i64", - max(grabbed_at) AS "last_failed_at?: String" + r#"SELECT count(*) AS "failures!: i64", + max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String" FROM grabs WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#, season_id @@ -2851,8 +2854,11 @@ mod tests { .await .expect("release"); sqlx::query( - "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at) - VALUES (?, 'season', ?, 'hash', '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 (?, 'season', ?, 'hash', 'failed', + strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", ) .bind(release_id) .bind(season_id) @@ -2868,6 +2874,26 @@ mod tests { "the deck offers a date, not just a closed door" ); + // §6.2/#245: push the grab five weeks back and leave the failure + // where it is. The deck reads the failure, so the window it names is + // unmoved — anchored on the grab it would have expired long ago and + // the deck would claim the lane was open. + sqlx::query( + "UPDATE grabs SET grabbed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-35 days') + WHERE target_kind = 'season' AND target_id = ?", + ) + .bind(season_id) + .execute(pool) + .await + .expect("age the grab"); + let stalled = state_of(url.clone()).await; + assert_eq!(stalled["lane"], "per_episode"); + assert_eq!(stalled["reason"], "pack_backoff"); + assert_eq!( + stalled["pack_retry_at"], quiet["pack_retry_at"], + "the window is anchored on the failure, so aging the grab moves nothing" + ); + // §14 outranks it: a pack would re-import what is on disk, so // clearing the failure would not earn a pack anyway. sqlx::query("INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, '/library/e01.mkv', 1)") diff --git a/crates/arr-daemon/src/rss.rs b/crates/arr-daemon/src/rss.rs index 1116b96..2b353c7 100644 --- a/crates/arr-daemon/src/rss.rs +++ b/crates/arr-daemon/src/rss.rs @@ -625,9 +625,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result ) .fetch_all(database.pool()) .await?; + // §6.2's ladder runs from the failure, not the grab (#245), with + // `grabbed_at` as the fallback for rows older than #239's column. let failed_packs = sqlx::query!( - r#"SELECT count(*) AS "failures!: i64", - max(grabbed_at) AS "last_failed_at?: String" + r#"SELECT count(*) AS "failures!: i64", + max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String" FROM grabs WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#, season_id @@ -1164,6 +1166,60 @@ mod tests { ); } + /// §6.2, issue #245: the RSS lane reads the same ladder, anchored on the + /// failure. A pack sent five weeks ago and condemned at import ten + /// minutes ago holds the lane shut, where anchoring on the grab would + /// have handed it the very release class that just failed. + #[tokio::test] + async fn a_pack_that_stalled_for_weeks_stays_backed_off_on_rss() { + let (_dir, database) = wanted(&[]).await; + let (season_id, episodes) = + wanted_series(&database, &["2024-04-11", "2024-04-18", "2024-04-25"]).await; + let release_id: i64 = sqlx::query_scalar( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) + VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920, + 'https://tracker/oldpack.torrent', '{}', 'eligible') + RETURNING id", + ) + .fetch_one(database.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, + grabbed_at, failed_at) + VALUES (?, 'season', ?, 'dead', 'failed', + strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-35 days'), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-10 minutes'))", + ) + .bind(release_id) + .bind(season_id) + .execute(database.pool()) + .await + .unwrap(); + let indexer = prowlarr(TV_FEED).await; + let (downloader, _fake) = transmission().await; + + action(&indexer, &downloader).tick(&database).await.unwrap(); + + let sent: Vec<(String, i64, String)> = tv_grabs(&database) + .await + .into_iter() + .filter(|(_, _, state)| state == "sent") + .collect(); + assert!( + !sent.iter().any(|(kind, _, _)| kind == "season"), + "the failure is ten minutes old, so the pack lane is shut: {sent:?}" + ); + assert_eq!( + sent, + vec![ + ("episode".to_owned(), episodes[0], "sent".to_owned()), + ("episode".to_owned(), episodes[1], "sent".to_owned()), + ("episode".to_owned(), episodes[2], "sent".to_owned()), + ] + ); + } + /// §6.2 with #117's guard: an episode already on disk keeps the season /// per-episode here too — the pack is skipped and the open gaps take /// their singles. diff --git a/crates/arr-daemon/src/tv_grab.rs b/crates/arr-daemon/src/tv_grab.rs index 1f01aa2..706facf 100644 --- a/crates/arr-daemon/src/tv_grab.rs +++ b/crates/arr-daemon/src/tv_grab.rs @@ -1139,12 +1139,15 @@ 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`, not `failed_at` — moving §6.2's retry -/// cadence to failure time is its own decision, not #239's. +/// latest `failed_at` (#245), the same anchor §5.7's window uses: a torrent +/// can stall for weeks before `ffprobe` condemns it, and measured from the +/// grab the whole ladder would already have elapsed when the failure lands. +/// `grabbed_at` is the fallback for rows written before #239 added the +/// column. async fn pack_backoff_active(database: &Db, season_id: i64) -> Result { let row = sqlx::query!( - r#"SELECT count(*) AS "failures!: i64", - max(grabbed_at) AS "last_failed_at?: String" + r#"SELECT count(*) AS "failures!: i64", + max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String" FROM grabs WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#, season_id @@ -1497,8 +1500,17 @@ mod tests { /// release on the blacklist and the season falls back to per-episode — /// the pack is not tried again and the episodes are not written off. /// Seed what the import tick leaves behind after a pack fails: one - /// `failed` season grab per (infohash, age) pair. + /// `failed` season grab per (infohash, age) pair, grabbed and failed at + /// the same age, which is the usual case — the two are minutes apart. async fn failed_packs(database: &Db, season_id: i64, ages: &[&str]) { + let pairs: Vec<(&str, &str)> = ages.iter().map(|age| (*age, *age)).collect(); + stalled_failed_packs(database, season_id, &pairs).await; + } + + /// The same seed, but with the grab and the failure at different ages — + /// the #245 case, where a torrent stalls for weeks before `ffprobe` + /// condemns it at import. + async fn stalled_failed_packs(database: &Db, season_id: i64, ages: &[(&str, &str)]) { let release_id: i64 = sqlx::query_scalar( "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920, @@ -1508,16 +1520,19 @@ mod tests { .fetch_one(database.pool()) .await .unwrap(); - for (index, age) in ages.iter().enumerate() { + for (index, (grabbed_age, failed_age)) in ages.iter().enumerate() { sqlx::query( - "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at) + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, + grabbed_at, failed_at) VALUES (?, 'season', ?, ?, 'failed', + strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?), strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))", ) .bind(release_id) .bind(season_id) .bind(format!("dead{index}")) - .bind(age) + .bind(grabbed_age) + .bind(failed_age) .execute(database.pool()) .await .unwrap(); @@ -1576,6 +1591,34 @@ mod tests { ); } + /// §6.2, issue #245: the ladder runs from the failure, not the grab. A + /// pack sent five weeks ago and condemned by `ffprobe` ten minutes ago + /// is one minute into a 1h window, not five weeks past it — the lane + /// stays quiet and the episodes carry the season instead. + #[tokio::test] + async fn a_pack_that_stalled_for_weeks_backs_off_from_the_failure() { + let (_dir, database, season_id) = + wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + stalled_failed_packs(&database, season_id, &[("-35 days", "-10 minutes")]).await; + let indexer = prowlarr().await; + let (downloader, fake) = transmission().await; + + action(&indexer, &downloader).tick(&database).await.unwrap(); + + let sources: Vec = fake + .torrents() + .into_iter() + .map(|torrent| torrent.source) + .collect(); + assert!( + sources + .iter() + .all(|source| !source.ends_with("pack.torrent")), + "the grab is five weeks old but the failure is ten minutes old: {sources:?}" + ); + assert_eq!(sources.len(), 3, "{sources:?}"); + } + /// Repeated failures ride the capped curve: five failed packs mean a 7d /// window — still closed at 6d, open at 8d. Quiet, never off. #[tokio::test] From 591cf27dc52a9e60595b85fc42c5c7c6473200ba Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 12:11:46 +0100 Subject: [PATCH 25/27] feat(web): say what a pack was abandoned for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pack that hard-failed at import blacklisted its release, put every episode back to missing and left the season reading 0/10, with nothing on screen joining the two. Every fact was already recorded. The blacklist now carries its reason out of the database: deck rows read `blacklisted · size` instead of a bare `blacklisted`, and say whether the policy turned the file down — relaxable for this title — or the release itself failed, which a retry only repeats. A season whose pack was abandoned says so on its row and above its deck, with the release name, when it failed, and what it failed on. A row the blacklist no longer answers for keeps rendering and claims no reason. Two defects from the integration review of #211 sit in the same code and are fixed here: a waived row threw away the rule it now carries and read a bare `below policy`, and the empty-eligible count called every waived row force-grabbable, since #211 gave those rows the rule `overridable` reads. Verified against a real browser: series detail, both season decks and their buckets, at 1280 and 390 px. Refs #227, #211 --- ...fd4137966a469b134fbeb51fb91f67951cbbe.json | 74 ++++++ ...51bb65ce9702e3bb1d26b51b93c5fdefa79f.json} | 11 +- ...9e0967206e8186b2157bb4b4d11e9a136759.json} | 11 +- ...cae6e6e327709dc9e5a2073092b75137e13c.json} | 18 +- ...9c02d0a070277a9ddf4d5749545315746389.json} | 11 +- crates/arr-api/src/movies.rs | 52 +++- crates/arr-api/src/search.rs | 10 + crates/arr-api/src/series.rs | 234 +++++++++++++++++- crates/arr-db/src/blacklist.rs | 115 ++++++++- web/index.html | 13 + web/src/main.ts | 130 +++++++++- web/src/releases.ts | 65 +++++ web/src/series.ts | 7 + web/src/style.css | 104 ++++++++ 14 files changed, 820 insertions(+), 35 deletions(-) create mode 100644 .sqlx/query-3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe.json rename .sqlx/{query-aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e.json => query-7aa154a1bd54ec84412a1be6d7db51bb65ce9702e3bb1d26b51b93c5fdefa79f.json} (86%) rename .sqlx/{query-f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba.json => query-8b2aa810e679ddde423a5dc5a1a89e0967206e8186b2157bb4b4d11e9a136759.json} (86%) rename .sqlx/{query-071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97.json => query-939252a81cdd0103b2aaa3cf19d5cae6e6e327709dc9e5a2073092b75137e13c.json} (58%) rename .sqlx/{query-aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f.json => query-9cb6a575aa3e0ff377551a324f839c02d0a070277a9ddf4d5749545315746389.json} (86%) diff --git a/.sqlx/query-3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe.json b/.sqlx/query-3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe.json new file mode 100644 index 0000000..ec49475 --- /dev/null +++ b/.sqlx/query-3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "SELECT g.target_id AS \"season_id!: i64\",\n r.name AS \"name!: String\",\n g.infohash AS \"infohash!: String\",\n g.failed_at,\n g.grabbed_at AS \"grabbed_at!: String\"\n FROM grabs g\n JOIN releases r ON r.id = g.release_id\n JOIN seasons s ON s.id = g.target_id\n WHERE g.target_kind = 'season'\n AND g.state = 'failed'\n AND s.series_id = ?\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = s.id AND e.wanted\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n )\n ORDER BY coalesce(g.failed_at, g.grabbed_at), g.id", + "describe": { + "columns": [ + { + "name": "season_id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "grabs", + "name": "target_id" + } + } + }, + { + "name": "name!: String", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "releases", + "name": "name" + } + } + }, + { + "name": "infohash!: String", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "grabs", + "name": "infohash" + } + } + }, + { + "name": "failed_at", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "grabs", + "name": "failed_at" + } + } + }, + { + "name": "grabbed_at!: String", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "grabs", + "name": "grabbed_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + false + ] + }, + "hash": "3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe" +} diff --git a/.sqlx/query-aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e.json b/.sqlx/query-7aa154a1bd54ec84412a1be6d7db51bb65ce9702e3bb1d26b51b93c5fdefa79f.json similarity index 86% rename from .sqlx/query-aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e.json rename to .sqlx/query-7aa154a1bd54ec84412a1be6d7db51bb65ce9702e3bb1d26b51b93c5fdefa79f.json index 8b93c58..0082174 100644 --- a/.sqlx/query-aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e.json +++ b/.sqlx/query-7aa154a1bd54ec84412a1be6d7db51bb65ce9702e3bb1d26b51b93c5fdefa79f.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id", + "query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule, NULL AS \"blacklist_reason?: String\" FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id", "describe": { "columns": [ { @@ -134,6 +134,12 @@ "name": "rejected_rule" } } + }, + { + "name": "blacklist_reason?: String", + "ordinal": 12, + "type_info": "Null", + "origin": "Expression" } ], "parameters": { @@ -151,8 +157,9 @@ false, true, true, + true, true ] }, - "hash": "aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e" + "hash": "7aa154a1bd54ec84412a1be6d7db51bb65ce9702e3bb1d26b51b93c5fdefa79f" } diff --git a/.sqlx/query-f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba.json b/.sqlx/query-8b2aa810e679ddde423a5dc5a1a89e0967206e8186b2157bb4b4d11e9a136759.json similarity index 86% rename from .sqlx/query-f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba.json rename to .sqlx/query-8b2aa810e679ddde423a5dc5a1a89e0967206e8186b2157bb4b4d11e9a136759.json index 6e0ebcd..90e1265 100644 --- a/.sqlx/query-f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba.json +++ b/.sqlx/query-8b2aa810e679ddde423a5dc5a1a89e0967206e8186b2157bb4b4d11e9a136759.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id", + "query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule, NULL AS \"blacklist_reason?: String\" FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id", "describe": { "columns": [ { @@ -134,6 +134,12 @@ "name": "rejected_rule" } } + }, + { + "name": "blacklist_reason?: String", + "ordinal": 12, + "type_info": "Null", + "origin": "Expression" } ], "parameters": { @@ -151,8 +157,9 @@ false, true, true, + true, true ] }, - "hash": "f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba" + "hash": "8b2aa810e679ddde423a5dc5a1a89e0967206e8186b2157bb4b4d11e9a136759" } diff --git a/.sqlx/query-071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97.json b/.sqlx/query-939252a81cdd0103b2aaa3cf19d5cae6e6e327709dc9e5a2073092b75137e13c.json similarity index 58% rename from .sqlx/query-071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97.json rename to .sqlx/query-939252a81cdd0103b2aaa3cf19d5cae6e6e327709dc9e5a2073092b75137e13c.json index a849773..152b0c0 100644 --- a/.sqlx/query-071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97.json +++ b/.sqlx/query-939252a81cdd0103b2aaa3cf19d5cae6e6e327709dc9e5a2073092b75137e13c.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT normalised_name AS \"normalised_name!: String\", infohash FROM blacklist", + "query": "SELECT normalised_name AS \"normalised_name!: String\",\n infohash,\n reason AS \"reason!: String\"\n FROM blacklist\n ORDER BY id", "describe": { "columns": [ { @@ -24,6 +24,17 @@ "name": "infohash" } } + }, + { + "name": "reason!: String", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "blacklist", + "name": "reason" + } + } } ], "parameters": { @@ -31,8 +42,9 @@ }, "nullable": [ false, - true + true, + false ] }, - "hash": "071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97" + "hash": "939252a81cdd0103b2aaa3cf19d5cae6e6e327709dc9e5a2073092b75137e13c" } diff --git a/.sqlx/query-aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f.json b/.sqlx/query-9cb6a575aa3e0ff377551a324f839c02d0a070277a9ddf4d5749545315746389.json similarity index 86% rename from .sqlx/query-aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f.json rename to .sqlx/query-9cb6a575aa3e0ff377551a324f839c02d0a070277a9ddf4d5749545315746389.json index 6deb1c5..395b36b 100644 --- a/.sqlx/query-aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f.json +++ b/.sqlx/query-9cb6a575aa3e0ff377551a324f839c02d0a070277a9ddf4d5749545315746389.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id", + "query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule, NULL AS \"blacklist_reason?: String\" FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id", "describe": { "columns": [ { @@ -134,6 +134,12 @@ "name": "rejected_rule" } } + }, + { + "name": "blacklist_reason?: String", + "ordinal": 12, + "type_info": "Null", + "origin": "Expression" } ], "parameters": { @@ -151,8 +157,9 @@ false, true, true, + true, true ] }, - "hash": "aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f" + "hash": "9cb6a575aa3e0ff377551a324f839c02d0a070277a9ddf4d5749545315746389" } diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 53a5923..7537d3f 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -83,6 +83,35 @@ pub struct Release { // OpenAPI descriptions and would put the generated client in web/ out of // date, which #227 and #232 own. pub rejected_rule: Option, + // #227: what the blacklist recorded this release as failing on, when + // `rejected_rule` is `blacklisted`. Null on every other row, and on a + // blacklisted row whose blacklist entry has since gone. A size rejection + // is a policy opinion the operator can relax; a corrupt or mismatched + // release is not, and a bare `blacklisted` reads the same for both. + // Plain comment for the same reason as the field above. + pub blacklist_reason: Option, +} + +/// Fill in [`Release::blacklist_reason`] for every deck row the blacklist +/// holds (#227, §6.3). +/// +/// The blacklist is keyed on the *normalised* name, which SQL cannot compute, +/// so the match happens here over the whole table — a handful of rows, the +/// same reasoning as [`arr_db::blacklist::Blacklist`] itself. +pub(crate) async fn attach_blacklist_reasons( + pool: &sqlx::SqlitePool, + releases: &mut [Release], +) -> Result<(), ApiError> { + if releases.is_empty() { + return Ok(()); + } + let blacklist = arr_db::blacklist::Blacklist::load(pool).await?; + for release in releases.iter_mut() { + release.blacklist_reason = blacklist + .reason_for_candidate(&release.name, &release.download_url) + .map(str::to_owned); + } + Ok(()) } /// A library file and what it cost to accept it (`DESIGN.md` §5.7). @@ -650,7 +679,7 @@ pub async fn releases( Path(id): Path, ) -> Result>, ApiError> { load_movie(&state, id).await?; - let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id) + let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule, NULL AS "blacklist_reason?: String" FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id) .fetch_all(pool(&state)?) .await?; let policy = state @@ -662,6 +691,7 @@ pub async fn releases( .ok_or(ApiError::NotFound)? .policy; rescore(&mut releases, &policy, None, 0)?; + attach_blacklist_reasons(pool(&state)?, &mut releases).await?; Ok(Json(releases)) } @@ -1423,6 +1453,26 @@ mod tests { .expect("releases json"); assert_eq!(releases[0]["verdict"], "rejected"); assert_eq!(releases[0]["rejected_rule"], "blacklisted"); + // #227: a row the blacklist has no entry for keeps rendering — the + // rule is all the record holds, and no reason is invented for it. + assert_eq!(releases[0]["blacklist_reason"], serde_json::Value::Null); + + // With the entry, the row says what it was blacklisted for: a + // corrupt file is not the same decision as a size rejection. + arr_db::blacklist::add(pool, None, name, "no original-language audio") + .await + .expect("blacklist"); + let releases: Vec = + reqwest::get(format!("{base}/api/movies/{movie_id}/releases")) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + assert_eq!( + releases[0]["blacklist_reason"], + "no original-language audio" + ); } #[tokio::test] diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 2fc2c6d..b12f1d4 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -156,6 +156,10 @@ pub struct ClassifiedRelease { pub score_terms: ScoreTerms, pub verdict: String, pub rule: Option, + /// What the blacklist recorded this release as failing on (#227, §6.3), + /// when `rule` is `blacklisted`. `None` on every other row. A policy + /// rejection and a bad release both read as `blacklisted` without it. + pub blacklist_reason: Option, } #[utoipa::path( @@ -730,6 +734,11 @@ fn classify( episodes, runtime_minutes, ); + // #227: the reason the blacklist holds is what tells a policy rejection + // the operator can relax from a release that should never be retried. + let blacklist_reason = blacklist + .reason_for_candidate(&release.name, &release.download_url) + .map(str::to_owned); let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) { ("rejected", Some(blacklist::RULE.to_owned())) } else { @@ -781,6 +790,7 @@ fn classify( score_terms: terms, verdict: verdict.to_owned(), rule, + blacklist_reason, }) } diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 12904f8..dacbffe 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -29,7 +29,9 @@ use chrono::{DateTime, NaiveDate, NaiveTime, Utc}; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; -use crate::movies::{pool, rescore, Accepted, ApiError, ErrorBody, Release}; +use crate::movies::{ + attach_blacklist_reasons, pool, rescore, Accepted, ApiError, ErrorBody, Release, +}; use crate::owners::Owner; use crate::search::tmdb_client; use crate::state::{AppState, EpisodeCommand, MetadataCommand, SeasonCommand}; @@ -110,6 +112,10 @@ pub struct Season { /// which is why the row still exists. A conflict for the operator to /// resolve; nothing was deleted from disk. pub vanished: bool, + /// #227. The last season pack that downloaded in full and was condemned + /// at import, while the season is still waiting for a file. `None` when + /// no pack was abandoned, or when the gap has since been filled. + pub import_failure: Option, pub episodes: Vec, } @@ -1039,6 +1045,8 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result, A .fetch_all(pool(state)?) .await?; + let mut failures = season_import_failures(state, series_id).await?; + Ok(seasons .into_iter() .map(|season| Season { @@ -1047,6 +1055,7 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result, A number: season.number, tracked: season.tracked, vanished: season.vanished, + import_failure: failures.remove(&season.id), episodes: episodes .iter() .filter(|episode| episode.season_id == season.id) @@ -1416,7 +1425,7 @@ pub async fn episode_releases( Path(id): Path, ) -> Result>, ApiError> { let episode = load_episode(&state, id).await?; - let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id) + let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule, NULL AS "blacklist_reason?: String" FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id) .fetch_all(pool(&state)?) .await?; let policy = state @@ -1430,6 +1439,7 @@ pub async fn episode_releases( let lengths = season_lengths(&state, episode.series_id).await?; let runtime = series_runtime(&state, episode.series_id).await?; rescore(&mut releases, &policy, Some(&lengths), runtime)?; + attach_blacklist_reasons(pool(&state)?, &mut releases).await?; Ok(Json(releases)) } @@ -1571,7 +1581,7 @@ pub async fn season_releases( ) -> Result>, ApiError> { load_series_row(&state, series_id).await?; let season_id = load_season_id(&state, series_id, number).await?; - let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, season_id) + let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule, NULL AS "blacklist_reason?: String" FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, season_id) .fetch_all(pool(&state)?) .await?; let policy = state @@ -1585,9 +1595,92 @@ pub async fn season_releases( let lengths = season_lengths(&state, series_id).await?; let runtime = series_runtime(&state, series_id).await?; rescore(&mut releases, &policy, Some(&lengths), runtime)?; + attach_blacklist_reasons(pool(&state)?, &mut releases).await?; Ok(Json(releases)) } +/// A grab for this season that downloaded in full and was then condemned at +/// import (#227, §5.7). +/// +/// The torrent stays at 100% in Transmission — §7.3 leaves that lifecycle to +/// the reaper — the release is blacklisted and every episode it was covering +/// reopens as a gap. Nothing on screen connected the two, so the season read +/// `0/10` as though no grab had ever been tried. Every fact is already +/// recorded; this is the row that carries them out. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ImportFailure { + /// The blacklisted release name, as the indexer spelled it. + pub release: String, + /// What the blacklist recorded it as failing on — a policy rule name the + /// operator can relax, or a sentence about the release itself. `None` + /// when no blacklist row answers to either key, which is possible after a + /// blacklist row is cleared by hand; the failure is still true. + pub reason: Option, + /// When the grab entered `failed`, RFC3339. `None` on a row that failed + /// before migration 0030 gave the column a value. + pub failed_at: Option, +} + +/// The most recent abandoned pack per season of one series (#227). +/// +/// Only seasons still waiting for a file are answered: once the gap is +/// filled, the failure is history and the season has nothing to explain. The +/// blacklist reason is matched in memory because its name key is normalised, +/// which SQL cannot compute. +async fn season_import_failures( + state: &AppState, + series_id: i64, +) -> Result, ApiError> { + let pool = pool(state)?; + let rows = sqlx::query!( + r#"SELECT g.target_id AS "season_id!: i64", + r.name AS "name!: String", + g.infohash AS "infohash!: String", + g.failed_at, + g.grabbed_at AS "grabbed_at!: String" + FROM grabs g + JOIN releases r ON r.id = g.release_id + JOIN seasons s ON s.id = g.target_id + WHERE g.target_kind = 'season' + AND g.state = 'failed' + AND s.series_id = ? + AND EXISTS ( + SELECT 1 FROM episodes e + WHERE e.season_id = s.id AND e.wanted + AND NOT EXISTS ( + SELECT 1 FROM media_files f + WHERE f.owner_kind = 'episode' AND f.owner_id = e.id + ) + ) + ORDER BY coalesce(g.failed_at, g.grabbed_at), g.id"#, + series_id + ) + .fetch_all(pool) + .await?; + if rows.is_empty() { + return Ok(HashMap::new()); + } + let blacklist = arr_db::blacklist::Blacklist::load(pool).await?; + // Ascending order, so the last row written for a season wins. + Ok(rows + .into_iter() + .map(|row| { + let reason = blacklist + .reason_for_infohash(&row.infohash) + .or_else(|| blacklist.reason_for_name(&row.name)) + .map(str::to_owned); + ( + row.season_id, + ImportFailure { + release: row.name, + reason, + failed_at: row.failed_at, + }, + ) + }) + .collect()) +} + /// Which lane a season's missing episodes take (#182, §6.2). #[derive(Debug, Clone, Copy, Serialize, ToSchema)] #[serde(rename_all = "snake_case")] @@ -1629,6 +1722,11 @@ pub struct SeasonPackState { /// When a pack sweep for this season last completed. `None` means no /// pack search has ever run, so an empty deck is pending, not settled. pub last_pack_search_at: Option, + /// #227. The last pack that downloaded in full and was then condemned at + /// import, while the season is still waiting for a file. A deck that + /// cannot say this leaves the season reading as though nothing was ever + /// tried, with the torrent still sitting at 100% in Transmission. + pub import_failure: Option, } #[utoipa::path( @@ -1720,6 +1818,9 @@ pub async fn season_pack_state( pack_failures: failed.failures, pack_retry_at: pack_retry_at.map(|at| at.to_rfc3339()), last_pack_search_at, + import_failure: season_import_failures(&state, series_id) + .await? + .remove(&season_id), })) } @@ -1939,6 +2040,133 @@ mod tests { response.json().await.expect("season json") } + async fn seasons_json(base: &str, series_id: i64) -> Vec { + reqwest::get(format!("{base}/api/series/{series_id}/seasons")) + .await + .expect("seasons") + .json() + .await + .expect("seasons json") + } + + /// #227, the operator's own report: a pack downloaded in full, §5.7 + /// condemned it at import, every episode went back to `missing`, and the + /// season read `0/10` with nothing anywhere saying a grab had been tried. + /// The season row, its deck and the deck's blacklisted release now each + /// carry the failure and the reason it failed on. + #[tokio::test] + async fn an_abandoned_pack_is_visible_on_the_season_and_its_deck() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, false).await; + let series_id = series["id"].as_i64().expect("id"); + let episodes: Vec = (1..=10) + .map(|number| { + serde_json::json!({ + "number": number, "title": format!("Episode {number}"), + "air_date": "2025-01-01" + }) + }) + .collect(); + let season = add_season(&base, series_id, 8, serde_json::json!(episodes)).await; + let season_id = season["id"].as_i64().expect("season id"); + assert!( + season["import_failure"].is_null(), + "nothing has been grabbed yet" + ); + // §4.1: tracking the season is what makes its episodes wanted, and a + // season with no intent has no gap to explain. + let tracked = reqwest::Client::new() + .patch(format!("{base}/api/series/{series_id}/seasons/8")) + .json(&serde_json::json!({"tracked": true})) + .send() + .await + .expect("track the season"); + assert_eq!(tracked.status(), StatusCode::OK); + + let pool = state.database().expect("database").pool(); + let name = "Rick.And.Morty.S08.1080p.WEB-DL.x264-GROUP"; + let parsed = arr_parse::parse(name); + let release_id = sqlx::query_scalar::<_, i64>( + "INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict) + VALUES (7, 'pack', ?, 1000, 50, 'url', ?, 0, 'eligible') RETURNING id", + ) + .bind(name) + .bind(serde_json::to_string(&parsed).expect("parsed json")) + .fetch_one(pool) + .await + .expect("release"); + sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)") + .bind(season_id) + .bind(release_id) + .execute(pool) + .await + .expect("association"); + sqlx::query( + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at, failed_at) + VALUES (?, 'season', ?, 'abc123', 'failed', '2026-01-01T00:00:00.000Z', '2026-01-02T00:00:00.000Z')", + ) + .bind(release_id) + .bind(season_id) + .execute(pool) + .await + .expect("failed pack grab"); + + // The failure with no blacklist row yet: still a failure, and the + // reason is simply not known. Rows written before the blacklist + // carried one read this way and must keep rendering. + let seasons = seasons_json(&base, series_id).await; + let row = &seasons[0]["import_failure"]; + assert_eq!(row["release"], name); + assert_eq!(row["reason"], serde_json::Value::Null); + assert_eq!(row["failed_at"], "2026-01-02T00:00:00.000Z"); + + arr_db::blacklist::add(pool, Some("ABC123"), name, "size") + .await + .expect("blacklist"); + + let seasons = seasons_json(&base, series_id).await; + assert_eq!(seasons[0]["import_failure"]["reason"], "size"); + + // The deck the season row leads to says the same thing. + let pack_state: serde_json::Value = reqwest::get(format!( + "{base}/api/series/{series_id}/seasons/8/pack-state" + )) + .await + .expect("pack state") + .json() + .await + .expect("pack state json"); + assert_eq!(pack_state["import_failure"]["release"], name); + assert_eq!(pack_state["import_failure"]["reason"], "size"); + + // §6.3: the release itself is rejected in the deck, and now names + // what it was blacklisted for — a size rejection the operator can + // relax, not a corrupt file they should leave alone. + let releases: Vec = + reqwest::get(format!("{base}/api/series/{series_id}/seasons/8/releases")) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + assert_eq!(releases.len(), 1); + assert_eq!(releases[0]["blacklist_reason"], "size"); + + // Once the gap is filled the failure is history: the season has + // nothing left to explain and stops saying it. + for episode in seasons[0]["episodes"].as_array().expect("episodes") { + sqlx::query("INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 1)") + .bind(episode["id"].as_i64().expect("episode id")) + .bind(format!("/library/e{}.mkv", episode["number"])) + .execute(pool) + .await + .expect("file on disk"); + } + let seasons = seasons_json(&base, series_id).await; + assert!(seasons[0]["import_failure"].is_null()); + } + /// The production case behind #210: every pack of a season is under /// §5.5's per-episode floor, so the deck holds three candidates and /// nothing is grabbable. Writing `allow_below_floor` turns the diff --git a/crates/arr-db/src/blacklist.rs b/crates/arr-db/src/blacklist.rs index 989ab04..bc5843f 100644 --- a/crates/arr-db/src/blacklist.rs +++ b/crates/arr-db/src/blacklist.rs @@ -12,7 +12,7 @@ //! infohash, and //! - **infohash**, because the same torrent is re-listed under a new name. -use std::collections::HashSet; +use std::collections::HashMap; use sqlx::SqlitePool; @@ -21,15 +21,21 @@ use sqlx::SqlitePool; /// rejected row names the rule that killed it" reads the same everywhere. pub const RULE: &str = "blacklisted"; -/// Every blacklist key, loaded once per tick or request. +/// Every blacklist key and the reason it was written under, loaded once per +/// tick or request. /// /// Household scale: a handful of rows. Loading it whole and matching in /// memory keeps the check identical for a release that has a database row and /// one that has only just arrived from an indexer. +/// +/// The reason travels with the key because §9.3's deck has to say what a row +/// was blacklisted for (#227): a release the policy rejected on size is one +/// the operator can relax and try again, and a corrupt or mismatched one is +/// not. A bare `blacklisted` makes those two read the same. #[derive(Debug, Clone, Default)] pub struct Blacklist { - names: HashSet, - infohashes: HashSet, + names: HashMap, + infohashes: HashMap, } impl Blacklist { @@ -39,17 +45,30 @@ impl Blacklist { /// /// If the query fails. pub async fn load(pool: &SqlitePool) -> Result { + // Oldest first, so a key that hard-failed twice under different + // reasons keeps the first one — the same rule [`add`] applies when it + // refuses to write the second row. let rows = sqlx::query!( - r#"SELECT normalised_name AS "normalised_name!: String", infohash FROM blacklist"# + r#"SELECT normalised_name AS "normalised_name!: String", + infohash, + reason AS "reason!: String" + FROM blacklist + ORDER BY id"# ) .fetch_all(pool) .await?; let mut blacklist = Self::default(); for row in rows { - blacklist.names.insert(row.normalised_name); + blacklist + .names + .entry(row.normalised_name) + .or_insert_with(|| row.reason.clone()); if let Some(infohash) = row.infohash { - blacklist.infohashes.insert(infohash.to_ascii_lowercase()); + blacklist + .infohashes + .entry(infohash.to_ascii_lowercase()) + .or_insert(row.reason); } } Ok(blacklist) @@ -58,14 +77,44 @@ impl Blacklist { /// Whether this release name has been blacklisted, under any spelling. #[must_use] pub fn blocks_name(&self, release_name: &str) -> bool { - self.names.contains(&arr_parse::normalise(release_name)) + self.names.contains_key(&arr_parse::normalise(release_name)) } /// Whether this infohash has been blacklisted. Case-insensitive: /// Transmission and Torznab disagree on the hex casing. #[must_use] pub fn blocks_infohash(&self, infohash: &str) -> bool { - self.infohashes.contains(&infohash.to_ascii_lowercase()) + self.infohashes.contains_key(&infohash.to_ascii_lowercase()) + } + + /// What this release name was blacklisted for, or `None` if it was not. + #[must_use] + pub fn reason_for_name(&self, release_name: &str) -> Option<&str> { + self.names + .get(&arr_parse::normalise(release_name)) + .map(String::as_str) + } + + /// What this infohash was blacklisted for, or `None` if it was not. + #[must_use] + pub fn reason_for_infohash(&self, infohash: &str) -> Option<&str> { + self.infohashes + .get(&infohash.to_ascii_lowercase()) + .map(String::as_str) + } + + /// What a candidate was blacklisted for, under either key (#227). + /// + /// The name is asked first: it is the key every candidate has, and a + /// `.torrent` URL hides its infohash until the download client fetches + /// it, exactly as [`Blacklist::blocks_candidate`] describes. + #[must_use] + pub fn reason_for_candidate(&self, release_name: &str, download_url: &str) -> Option<&str> { + self.reason_for_name(release_name).or_else(|| { + magnet_infohash(download_url) + .and_then(|hash| self.infohashes.get(&hash)) + .map(String::as_str) + }) } /// Whether a candidate is blacklisted before anything is sent to the @@ -175,6 +224,54 @@ mod tests { assert!(!blacklist.blocks_name("Dune Part Two 2024 1080p WEB-DL")); } + #[tokio::test] + async fn a_key_carries_the_reason_it_was_blacklisted_for() { + let (database, _dir) = database().await; + add( + database.pool(), + Some(HASH), + "Rick.And.Morty.S08.1080p", + "size", + ) + .await + .unwrap(); + add( + database.pool(), + None, + "Some.Other.Pack.S01", + "no file matches a wanted episode", + ) + .await + .unwrap(); + + let blacklist = Blacklist::load(database.pool()).await.unwrap(); + // #227: the deck has to tell a policy rejection from a bad release, + // and the reason is the only thing that says which. + assert_eq!( + blacklist.reason_for_name("Rick And Morty S08 1080p"), + Some("size") + ); + assert_eq!( + blacklist.reason_for_infohash(&HASH.to_ascii_uppercase()), + Some("size") + ); + assert_eq!( + blacklist.reason_for_name("Some.Other.Pack.S01"), + Some("no file matches a wanted episode") + ); + assert_eq!(blacklist.reason_for_name("Never.Failed.S01"), None); + + let magnet = format!("magnet:?xt=urn:btih:{HASH}&dn=Renamed.Pack"); + assert_eq!( + blacklist.reason_for_candidate("Renamed.Pack", &magnet), + Some("size") + ); + assert_eq!( + blacklist.reason_for_candidate("Renamed.Pack", "https://tracker/x.torrent"), + None + ); + } + #[tokio::test] async fn a_second_hard_fail_of_the_same_torrent_adds_no_row() { let (database, _dir) = database().await; diff --git a/web/index.html b/web/index.html index add0cda..ffd5f61 100644 --- a/web/index.html +++ b/web/index.html @@ -541,6 +541,19 @@ + +
diff --git a/web/src/main.ts b/web/src/main.ts index e5aac0c..1f976d3 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -34,6 +34,9 @@ import { } from "./queues"; import { type ActionOutcome, + blacklistAdvice, + blacklistClass, + blacklistReasonLabel, bucketOf, type FilesOutcome, formatAudio, @@ -46,12 +49,14 @@ import { formatSource, formatSweepAge, grabRelease, + type ImportFailure, libraryFolder, type MovieRelease, movieFiles, movieReleases, movieSearchState, overridable, + type PackStateOutcome, probedAttributeTags, queueSearch, removeMovie, @@ -2237,7 +2242,14 @@ function paintBuckets(dom: BucketsDom, releases: MovieRelease[], actions: Releas // §9.3: over-strict filters must be visible, not silently absent — and // where a rule can be waived, the count says so rather than leaving the // way out folded inside a collapsed bucket. - const forceable = releases.filter(overridable).length; + // + // Only a rejected row needs forcing. A row below policy is offered + // already, one click, whatever its rule — and since #211 gave it a rule + // at all, counting every overridable row here claimed the whole + // collapsed deck had to be forced. + const forceable = releases.filter( + (release) => bucketOf(release) === "rejected" && overridable(release), + ).length; const none = document.createElement("li"); none.className = "rel rel-none readout dim"; none.textContent = @@ -2379,20 +2391,36 @@ function releaseRow( }), ); } + // #227: a blacklisted release was grabbed, downloaded and condemned at + // import. Which of the two things happened decides what the operator does + // next, and `blacklisted` alone reads the same for both. + const blacklisted = + bucket === "rejected" && release.rejected_rule === "blacklisted" + ? blacklistClass(release.blacklist_reason) + : null; if (bucket !== "eligible") { - // A rejected row always names its rule; a waived one cannot — the - // `releases` CHECK allows `rejected_rule` only on a rejection. So a - // waived row says the plainer thing the operator can act on, "below - // policy", rather than the name the record keeps for it. + // §9.3: every row that is not eligible names the rule behind it, waived + // and rejected alike — three waivers for three different reasons read + // identically otherwise, and reading release names to tell them apart is + // the Radarr defect this view exists to fix. #211 gave a waived row the + // rule it relaxed; a row written before it still has none, and says the + // plainer thing alone. const verdict = bucket === "waived" - ? "below policy" - : release.rejected_rule - ? `rejected · ${ruleLabel(release.rejected_rule)}` - : "rejected"; + ? release.rejected_rule + ? `below policy · ${ruleLabel(release.rejected_rule)}` + : "below policy" + : blacklisted + ? `blacklisted · ${blacklistReasonLabel(release.blacklist_reason)}` + : release.rejected_rule + ? `rejected · ${ruleLabel(release.rejected_rule)}` + : "rejected"; line.append( chip(verdict, (span) => { span.dataset.verdict = bucket; + if (blacklisted) { + span.dataset.blacklist = blacklisted; + } }), ); } @@ -2401,6 +2429,13 @@ function releaseRow( name.className = "rel-name readout"; name.textContent = release.name; line.append(name); + if (blacklisted) { + const why = document.createElement("span"); + why.className = "rel-why readout"; + why.dataset.blacklist = blacklisted; + why.textContent = blacklistAdvice(release.blacklist_reason); + line.append(why); + } item.append(line); const note = document.createElement("span"); @@ -2884,6 +2919,10 @@ function tvReleasesMain(): TvReleasesView { const sub = must("#tv-releases-sub"); const sweep = must("#tv-releases-sweep"); const statusEl = must("#tv-releases-status"); + const failureEl = must("#tv-releases-failure"); + const failureRelease = must("#tv-failure-release"); + const failureWhat = must("#tv-failure-what"); + const failureNext = must("#tv-failure-next"); const dom = buildBucketDom(must("#tv-buckets")); let request: TvDeckRequest | null = null; @@ -2926,6 +2965,36 @@ function tvReleasesMain(): TvReleasesView { statusEl.dataset.action = ""; } + /** + * #227: the season's own history, above the candidates. A pack that + * downloaded in full and was condemned at import (§5.7) blacklists the + * release and puts every episode back to `missing`, which leaves the season + * reading `0/10` as though nothing had ever been tried. It sits outside the + * status line because it is a fact about the season, not about the request + * in flight, and it has to survive a sweep that repaints the status. + */ + function paintFailure(failure: ImportFailure | null) { + if (failure === null) { + failureEl.hidden = true; + return; + } + failureEl.dataset.blacklist = blacklistClass(failure.reason); + failureEl.hidden = false; + failureRelease.textContent = + failure.failed_at === null + ? failure.release + : `${failure.release} · ${formatSweepAge(failure.failed_at)}`; + // A rule name reads as a preposition — "condemned on size"; a reason + // written as a sentence has to be quoted, not conjugated. + const on = + blacklistClass(failure.reason) === "policy" + ? ` on ${blacklistReasonLabel(failure.reason)}` + : ""; + const said = blacklistClass(failure.reason) === "release" ? `${failure.reason}. ` : ""; + failureWhat.textContent = `downloaded in full, then condemned at import${on} — ${said}every episode went back to missing and the release is blacklisted.`; + failureNext.textContent = `${blacklistAdvice(failure.reason)}.`; + } + const actions: ReleaseActions = { reload: () => load(), notify: (text, tone) => setStatus(text, tone), @@ -2969,9 +3038,17 @@ function tvReleasesMain(): TvReleasesView { setStatus(`releases unavailable — ${outcome.detail}`, "fault"); return; } - if (!paintBuckets(dom, outcome.releases, actions)) { + const painted = paintBuckets(dom, outcome.releases, actions); + // One read of the season's state, shared by the notice and the empty + // verdict below — they answer two questions from the same row. + const pack = await current.target.packState?.(); + if (ticket !== sequence || request !== current) { + return; + } + paintFailure(pack?.kind === "state" ? pack.state.import_failure : null); + if (!painted) { clearBuckets(dom); - await emptyVerdict(current, ticket, sweepIfEmpty); + await emptyVerdict(current, ticket, sweepIfEmpty, pack); return; } setStatus(null); @@ -2985,8 +3062,13 @@ function tvReleasesMain(): TvReleasesView { * answers the third; `last_pack_search_at` separates the first two, the * same way a movie's `last_searched_at` does (#177). */ - async function emptyVerdict(current: TvDeckRequest, ticket: number, sweepIfEmpty: boolean) { - const outcome = await current.target.packState?.(); + async function emptyVerdict( + current: TvDeckRequest, + ticket: number, + sweepIfEmpty: boolean, + known?: PackStateOutcome, + ) { + const outcome = known ?? (await current.target.packState?.()); if (ticket !== sequence || request !== current) { return; } @@ -3078,6 +3160,9 @@ function tvReleasesMain(): TvReleasesView { if (ticket !== sequence || request !== current) { return; } + if (state?.kind === "state") { + paintFailure(state.state.import_failure); + } if (state?.kind === "state" && state.state.last_pack_search_at !== baseline) { sweep.disabled = false; await emptyVerdict(current, ticket, false); @@ -3150,6 +3235,7 @@ function tvReleasesMain(): TvReleasesView { next.returnTo.hidden = true; view.hidden = false; clearBuckets(dom); + paintFailure(null); sweep.disabled = false; back.focus(); void load(true); @@ -3554,6 +3640,24 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { }); line.append(disclose, name, track, seasonCountsChip(season)); + // #227: the season the operator's report is about read `0/10` with + // nothing saying a pack had been grabbed, downloaded in full and thrown + // out at import. The counts chip beside this one is exactly the number + // that looked like nothing was ever tried. + const failure = season.import_failure; + if (failure) { + const kind = blacklistClass(failure.reason); + const when = failure.failed_at === null ? "" : ` ${formatSweepAge(failure.failed_at)}`; + const told = `a pack for this season downloaded in full and failed at import${when} — ${failure.release} · ${blacklistAdvice(failure.reason)}`; + line.append( + chip(`import failed · ${blacklistReasonLabel(failure.reason)}`, (span) => { + span.dataset.flag = "import-failed"; + span.dataset.blacklist = kind; + span.title = told; + span.setAttribute("aria-label", told); + }), + ); + } // The season-level twin of the episode flag above: gone upstream while // files under it remained. if (season.vanished) { diff --git a/web/src/releases.ts b/web/src/releases.ts index c066abb..92873e3 100644 --- a/web/src/releases.ts +++ b/web/src/releases.ts @@ -26,6 +26,12 @@ export interface MovieRelease { score: number | null; verdict: string | null; rejected_rule: string | null; + /** + * What the blacklist recorded this release as failing on (#227, §6.3). + * Null on every row the blacklist does not hold, and on a blacklisted row + * whose entry has since gone — `blacklisted` is then all the record has. + */ + blacklist_reason: string | null; } export type ReleasesOutcome = @@ -113,6 +119,23 @@ export interface SeasonPackState { pack_failures: number; pack_retry_at: string | null; last_pack_search_at: string | null; + import_failure: ImportFailure | null; +} + +/** + * A pack that downloaded in full and was condemned at import (#227, §5.7). + * + * The torrent stays where it is — §7.3 hands that lifecycle to the reaper — + * the release is blacklisted and every episode it covered reopens as a gap. + * Nothing on screen joined those facts, so the season read `0/10` as though + * no grab had ever been tried and the operator found out by opening + * Transmission. + */ +export interface ImportFailure { + release: string; + /** A policy rule name, or a sentence about the release. See `blacklistClass`. */ + reason: string | null; + failed_at: string | null; } export type PackStateOutcome = @@ -467,6 +490,48 @@ export function ruleLabel(rule: string | null): string { return RULE_LABEL[rule] ?? rule.replaceAll("_", " "); } +/** + * What a blacklisting was: the policy turning a file down, or the release + * itself failing (#227). + * + * The blacklist reason is either a policy rule name — the same vocabulary + * `rejected_rule` uses — or a sentence about the release, written where the + * import gave up before any rule was consulted. The two demand opposite + * decisions: a size rejection is the operator's own floor and they can relax + * it, a corrupt or mismatched pack is not theirs to argue with. `unknown` is + * a row the blacklist no longer answers for; nothing is claimed about it. + */ +export type BlacklistClass = "policy" | "release" | "unknown"; + +export function blacklistClass(reason: string | null): BlacklistClass { + if (reason === null) { + return "unknown"; + } + return reason in RULE_LABEL ? "policy" : "release"; +} + +/** The reason as a chip word: a rule's short label, or the sentence itself. */ +export function blacklistReasonLabel(reason: string | null): string { + return reason === null ? "reason not recorded" : (RULE_LABEL[reason] ?? reason); +} + +/** + * What the operator does about it, which is the whole difference between the + * two classes — and the sentence #227 exists to put on screen. + */ +export function blacklistAdvice(reason: string | null): string { + switch (blacklistClass(reason)) { + case "policy": + return waiverOverride(reason) === null + ? "policy rejected the file — that rule has no per-title relaxation" + : `policy rejected the file — relax ${ruleLabel(reason)} for this title and the next candidate can pass`; + case "release": + return "the release itself failed at import — a retry downloads the same files"; + default: + return "blacklisted before the reason was recorded"; + } +} + export async function errorDetail(response: Response): Promise { try { const body = (await response.json()) as { error?: string }; diff --git a/web/src/series.ts b/web/src/series.ts index 0c5d63c..1e0e58e 100644 --- a/web/src/series.ts +++ b/web/src/series.ts @@ -5,6 +5,7 @@ import type { MetadataTrailer } from "./movie"; import type { ActionOutcome, + ImportFailure, MovieRelease, PackStateOutcome, ReleasesOutcome, @@ -49,6 +50,12 @@ export interface ApiSeason { tracked: boolean; /** Gone upstream while a file under it remained — a conflict, not a state. */ vanished: boolean; + /** + * #227: the last pack that downloaded in full and was condemned at import, + * while the season is still waiting for a file. Null once the gap is + * filled — a season with nothing missing has nothing to explain. + */ + import_failure: ImportFailure | null; episodes: ApiEpisode[]; } diff --git a/web/src/style.css b/web/src/style.css index 6bc612b..4980295 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -617,6 +617,48 @@ body { vertical-align: baseline; } +/* #227: a pack abandoned at import, above the candidates. A quiet panel, not + an alert: the failure is history the season owes an explanation for, and + the operator opened this deck to grab something, not to be shouted at. The + tone follows the same reading as the row chips — amber for their own policy + floor, red for a release that failed on its own. */ +.deck-notice { + margin: 0 0 var(--space-6); + padding: var(--space-3) var(--space-4) var(--space-4); + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +.deck-notice[data-blacklist="policy"] { + background: oklch(from var(--signal-warn) l c h / 7%); + border-color: oklch(from var(--signal-warn) l c h / 40%); +} + +.deck-notice[data-blacklist="release"] { + background: oklch(from var(--signal-fault) l c h / 7%); + border-color: oklch(from var(--signal-fault) l c h / 40%); +} + +/* the release name is evidence, in the readout face like every other one */ +.notice-release { + margin: 0 0 var(--space-2); + overflow-wrap: anywhere; + font-size: var(--text-xs); + color: var(--ink); +} + +.notice-line { + margin: 0; + max-width: 68ch; + font-size: var(--text-sm); + color: var(--ink-muted); +} + +.notice-line + .notice-line { + margin-top: var(--space-2); +} + .deck-group { margin: 0 0 var(--space-8); } @@ -644,6 +686,18 @@ body { color: var(--ink-faint); } +.deck-notice .deck-label { + margin-bottom: var(--space-2); +} + +.deck-notice[data-blacklist="policy"] .deck-label { + color: var(--signal-warn); +} + +.deck-notice[data-blacklist="release"] .deck-label { + color: var(--signal-fault); +} + .deck-rows { margin: 0; padding: 0; @@ -719,6 +773,35 @@ body { color: var(--verdict-rejected); } +/* #227: a blacklisted release was grabbed, downloaded in full and thrown out + at import, and the two ways that happens want opposite decisions. The + policy turning a file down is the operator's own floor — amber, the hue + this app already gives a gap they can act on. The release itself failing is + the one case in the deck that is genuinely broken, so it takes fault red; + the "never fault red" rule above is about a rejection, and this is not one. + A row whose blacklist entry is gone claims nothing and stays slate. */ +.chip[data-blacklist="policy"] { + color: var(--signal-warn); + border-color: oklch(from var(--signal-warn) l c h / 55%); +} + +.chip[data-blacklist="release"] { + color: var(--signal-fault); + border-color: oklch(from var(--signal-fault) l c h / 55%); +} + +/* a reason written as a sentence is longer than any rule name, and the chips + around it are fixed-width columns that must not be pushed off the line + (§9.3: no horizontal scroll at any viewport). This one chip wraps instead. */ +.chip[data-blacklist], +.chip[data-flag="import-failed"] { + min-width: 0; + max-width: 100%; + padding-top: var(--space-1); + padding-bottom: var(--space-1); + overflow-wrap: anywhere; +} + /* media state ramp (§4.2): green on disk, violet downloading, amber wanted and still missing. Unwanted-missing and parked are nothing-happening and stay neutral; `parked` exists so a vanished grab never reads as a gap. */ @@ -1199,6 +1282,20 @@ body { border-bottom: 1px solid oklch(from var(--line) l c h / 45%); } +/* #227: what the blacklisting means for the next move, on its own line under + the chips. Only a blacklisted row carries it, so the dense list stays dense + everywhere else. */ +.rel-why { + flex-basis: 100%; + min-width: 0; + font-size: var(--text-xs); + color: var(--ink-muted); +} + +.rel-why[data-blacklist="release"] { + color: oklch(from var(--signal-fault) 0.78 0.1 h); +} + .rel-note { font-size: var(--text-xs); color: var(--ink-muted); @@ -1519,6 +1616,13 @@ body { font-size: var(--text-xs); } +/* #227: the season the operator's own report was about read `0/10` beside + this chip's absence. It sits next to the counts because that number is what + looked like nothing had ever been tried. */ +.chip[data-flag="import-failed"] { + cursor: help; +} + /* issue 122: gone upstream while its file remained — a conflict, amber dashed */ .chip[data-flag="vanished"] { color: var(--signal-warn); From bd5b00b14a22e1183a0f543259a4d27eaa7efbdc Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 13:01:16 +0100 Subject: [PATCH 26/27] fix(api): create title move destination --- crates/arr-api/src/movies.rs | 78 ++++++++++++++++++++++++++++++---- crates/arr-api/src/relocate.rs | 27 ++++++------ 2 files changed, 82 insertions(+), 23 deletions(-) diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 7537d3f..6441778 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -1704,6 +1704,62 @@ mod tests { ); } + /// Reported in production: moving a title into a root whose directory has + /// never been written to failed with + /// `could not move '...The Batman...': No such file or directory`, naming + /// a folder that was sitting exactly where the operator left it. A root is + /// a database row and nothing makes its directory exist; `rename` reports + /// a missing destination parent as the same `NotFound` as a missing + /// source. Every earlier test pointed the destination at a `tempdir`, + /// which is why the assumption was never exercised. + #[tokio::test] + async fn a_root_whose_directory_does_not_exist_yet_still_receives_the_title() { + let (_dir, state, base) = application().await; + let movie = add_movie(&base, 693_134, 1).await; + let id = movie["id"].as_i64().expect("id"); + let source = tempfile::tempdir().expect("source root"); + let parent = tempfile::tempdir().expect("media tree"); + let folder = library_on_disk(&state, id, source.path()).await; + + // Configured, but never written to: the row exists, the folder does not. + let destination = parent.path().join("movies").join("kids"); + assert!(!destination.exists()); + point_root_at(&state, 2, &destination).await; + + let response = reqwest::Client::new() + .patch(format!("{base}/api/movies/{id}")) + .json(&serde_json::json!({"root_id": 2})) + .send() + .await + .expect("move root"); + assert_eq!( + response.status(), + StatusCode::OK, + "a root that has no folder yet is not a reason to refuse the move" + ); + + assert!(!folder.exists(), "the folder left the old root"); + assert!( + destination + .join("Dune Part Two (2024) [tmdbid-693134]") + .join("Dune Part Two (2024) [tmdbid-693134] - [2160p].mkv") + .exists(), + "the feature arrived in a root that had to be created for it" + ); + + let path: String = sqlx::query_scalar( + "SELECT path FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?", + ) + .bind(id) + .fetch_one(state.database().expect("database").pool()) + .await + .expect("media file row"); + assert!( + std::path::Path::new(&path).starts_with(&destination), + "the row follows the file: {path}" + ); + } + /// Issue #228: a title with nothing on disk changes root with no /// filesystem work at all — the seeded root paths do not even exist. #[tokio::test] @@ -1780,22 +1836,21 @@ mod tests { /// Issue #228: if the rename fails, the row must not change — the /// operator sees the title where its files actually are and can retry. + #[cfg(unix)] #[tokio::test] async fn a_failed_rename_leaves_the_row_alone() { + use std::os::unix::fs::PermissionsExt; + let (_dir, state, base) = application().await; let movie = add_movie(&base, 693_134, 1).await; let id = movie["id"].as_i64().expect("id"); let source = tempfile::tempdir().expect("source root"); let destination = tempfile::tempdir().expect("destination root"); let folder = library_on_disk(&state, id, source.path()).await; - // A destination whose parent does not exist makes the rename itself - // fail while the collision pre-check still passes. - point_root_at( - &state, - 2, - &destination.path().join("missing").join("library"), - ) - .await; + point_root_at(&state, 2, destination.path()).await; + tokio::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o555)) + .await + .expect("freeze the title folder"); let response = reqwest::Client::new() .patch(format!("{base}/api/movies/{id}")) @@ -1804,7 +1859,14 @@ mod tests { .await .expect("move root"); assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let body: serde_json::Value = response.json().await.expect("error body"); + let error = body["error"].as_str().expect("error text"); + assert!(error.contains(folder.to_str().expect("utf-8 source"))); + assert!(error.contains(destination.path().to_str().expect("utf-8 destination"))); + tokio::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o755)) + .await + .expect("thaw the title folder"); assert!(folder.exists(), "the folder never left the old root"); let (root_id, path): (i64, String) = sqlx::query_as( "SELECT m.root_id, f.path FROM movies m diff --git a/crates/arr-api/src/relocate.rs b/crates/arr-api/src/relocate.rs index e88d843..6ae9af7 100644 --- a/crates/arr-api/src/relocate.rs +++ b/crates/arr-api/src/relocate.rs @@ -60,15 +60,6 @@ pub(crate) struct Relocation { created_dirs: Vec, } -/// Whether the destination root is a directory that must already be there. -/// A title moves into another configured root, which exists; a root moving to -/// a new path is moving somewhere that need not exist yet. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Destination { - Existing, - Create, -} - /// Rename the title's folders into the new root. Called before the row is /// written, and only when the root actually changes. /// @@ -91,7 +82,7 @@ pub(crate) async fn relocate_title( let old_root = root_path(state, old_root_id).await?; let new_root = root_path(state, new_root_id).await?; let files = title_files(state, kind, title_id).await?; - relocate_files(&files, &old_root, &new_root, Destination::Existing).await + relocate_files(&files, &old_root, &new_root).await } /// Rename every title folder under a root into the root's new path, for a @@ -114,7 +105,7 @@ pub(crate) async fn relocate_root( new_path: &str, ) -> Result { let files = root_files(state, root_id).await?; - relocate_files(&files, old_path, new_path, Destination::Create).await + relocate_files(&files, old_path, new_path).await } /// The one mover both callers share: plan every rename, refuse every @@ -124,7 +115,6 @@ async fn relocate_files( files: &[(i64, String)], old_root: &str, new_root: &str, - destination: Destination, ) -> Result { let mut renames: Vec = Vec::new(); let mut rewrites: Vec<(i64, String)> = Vec::new(); @@ -187,7 +177,10 @@ async fn relocate_files( // that already existed on disk. Recorded before `create_dir_all`, since // afterwards there is no way to tell which levels it made. let mut created_dirs: Vec = Vec::new(); - if destination == Destination::Create && !renames.is_empty() { + // A configured root need not exist on disk. Without creating it here, + // `rename` reports its missing parent as the same `NotFound` as a missing + // source and sends the operator looking at the wrong end of the move. + if !renames.is_empty() { created_dirs = missing_levels(std::path::Path::new(new_root)).await; if let Err(error) = tokio::fs::create_dir_all(new_root).await { return Err(ApiError::Filesystem(format!( @@ -265,9 +258,13 @@ async fn failed( } .undo() .await; + // Name both ends. `rename` returns NotFound for a missing destination + // parent as readily as for a missing source, and naming only the source + // sent an operator looking for a file that was sitting where they left it. ApiError::Filesystem(format!( - "could not move '{}': {error}", - rename.source.display() + "could not move '{}' to '{}': {error}", + rename.source.display(), + rename.destination.display() )) } From 8947febbff06c918b2b2e4ea512f86e34a607db3 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 14:24:30 +0100 Subject: [PATCH 27/27] fix(web): add series library selector --- web/index.html | 12 +++++++++--- web/src/main.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++++ web/src/series.ts | 20 ++++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/web/index.html b/web/index.html index ffd5f61..d915b03 100644 --- a/web/index.html +++ b/web/index.html @@ -499,14 +499,20 @@ -

library

+