From 0cceea056a5aecb5d8f6384783fcd069e9aff074 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 21:25:06 +0100 Subject: [PATCH 1/6] feat(core): add TV domain types --- crates/arr-core/src/lib.rs | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index c6b335f..274a6b5 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -21,6 +21,9 @@ macro_rules! id_type { id_type!(RootId); id_type!(PolicyId); id_type!(MovieId); +id_type!(SeriesId); +id_type!(SeasonId); +id_type!(EpisodeId); id_type!(MediaFileId); id_type!(ReleaseId); id_type!(GrabId); @@ -183,6 +186,53 @@ pub struct Movie { pub last_searched_at: Option, } +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SeriesOverrides { + pub only_4k: bool, + pub allow_english_audio: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Series { + pub id: SeriesId, + pub tmdb_id: u64, + pub title: String, + pub year: u16, + pub original_language: Language, + pub root_id: RootId, + pub auto_track: bool, + pub overrides: SeriesOverrides, + pub blocked: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Season { + pub id: SeasonId, + pub series_id: SeriesId, + pub number: u16, + pub tracked: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EpisodeState { + Missing, + Downloading, + Available, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Episode { + pub id: EpisodeId, + pub season_id: SeasonId, + pub number: u16, + pub title: String, + pub air_date: Option, + pub wanted: bool, + pub state: EpisodeState, + pub search_attempts: u32, + pub last_searched_at: Option, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct AudioTrack { pub language: Language, -- 2.47.3 From fe0270d4de1b959a6234cbc29694ec49f93314c9 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 21:25:06 +0100 Subject: [PATCH 2/6] feat(db): add TV schema and roots --- crates/arr-db/migrations/0005_series.sql | 69 ++++++++++++++++++++++ crates/arr-db/src/lib.rs | 73 +++++++++++++++++++++--- 2 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 crates/arr-db/migrations/0005_series.sql diff --git a/crates/arr-db/migrations/0005_series.sql b/crates/arr-db/migrations/0005_series.sql new file mode 100644 index 0000000..3785274 --- /dev/null +++ b/crates/arr-db/migrations/0005_series.sql @@ -0,0 +1,69 @@ +CREATE TABLE series ( + id INTEGER PRIMARY KEY, + tmdb_id INTEGER NOT NULL UNIQUE, + title TEXT NOT NULL, + year INTEGER, + original_language TEXT, + root_id INTEGER NOT NULL REFERENCES roots (id), + auto_track INTEGER NOT NULL DEFAULT 0 CHECK (auto_track IN (0, 1)), + overrides TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(overrides)), + blocked INTEGER NOT NULL DEFAULT 0 CHECK (blocked IN (0, 1)), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +) STRICT; + +CREATE INDEX series_root ON series (root_id); + +CREATE TABLE seasons ( + id INTEGER PRIMARY KEY, + series_id INTEGER NOT NULL REFERENCES series (id) ON DELETE CASCADE, + number INTEGER NOT NULL CHECK (number >= 0), + tracked INTEGER NOT NULL DEFAULT 0 CHECK (tracked IN (0, 1)), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE (series_id, number) +) STRICT; + +CREATE TABLE episodes ( + id INTEGER PRIMARY KEY, + season_id INTEGER NOT NULL REFERENCES seasons (id) ON DELETE CASCADE, + number INTEGER NOT NULL CHECK (number >= 0), + title TEXT NOT NULL, + air_date TEXT, + wanted INTEGER NOT NULL DEFAULT 0 CHECK (wanted IN (0, 1)), + state TEXT NOT NULL DEFAULT 'missing' + CHECK (state IN ('missing', 'grabbed', 'imported')), + search_attempts INTEGER NOT NULL DEFAULT 0, + last_searched_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE (season_id, number) +) STRICT; + +CREATE INDEX episodes_pending_search + ON episodes (last_searched_at) + WHERE wanted = 1 AND state = 'missing'; + +CREATE INDEX episodes_state ON episodes (state); + +INSERT INTO policies ( + name, required_audio, dub_blacklist, hdr_rules, size_bands, + resolution_pref, source_weights, score_weights +) SELECT + 'TV — main', required_audio, dub_blacklist, hdr_rules, size_bands, + resolution_pref, source_weights, score_weights +FROM policies WHERE name = 'Movies — main'; + +INSERT INTO policies ( + name, required_audio, dub_blacklist, hdr_rules, size_bands, + resolution_pref, source_weights, score_weights +) SELECT + 'TV — kids', required_audio, dub_blacklist, hdr_rules, size_bands, + resolution_pref, source_weights, score_weights +FROM policies WHERE name = 'Movies — kids'; + +INSERT INTO roots (kind, audience, path, policy_id) +SELECT 'tv', 'main', '/mnt/media/tv/main', id FROM policies WHERE name = 'TV — main'; + +INSERT INTO roots (kind, audience, path, policy_id) +SELECT 'tv', 'kids', '/mnt/media/tv/kids', id FROM policies WHERE name = 'TV — kids'; diff --git a/crates/arr-db/src/lib.rs b/crates/arr-db/src/lib.rs index 877beed..69784f8 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -156,13 +156,13 @@ mod tests { let roots = db.list_roots().await.expect("list roots"); - assert_eq!(roots.len(), 2, "§5.1: two movie roots, TV comes with #34"); - assert!(roots.iter().all(|r| r.kind == "movie")); - assert_eq!(roots[0].audience, "kids"); - assert_eq!(roots[0].path, "/mnt/media/movies/kids"); - assert_eq!(roots[1].audience, "main"); - assert_eq!(roots[1].path, "/mnt/media/movies/main"); - assert_ne!(roots[0].policy_id, roots[1].policy_id); + let movie_roots: Vec<_> = roots.iter().filter(|root| root.kind == "movie").collect(); + assert_eq!(movie_roots.len(), 2); + assert_eq!(movie_roots[0].audience, "kids"); + assert_eq!(movie_roots[0].path, "/mnt/media/movies/kids"); + assert_eq!(movie_roots[1].audience, "main"); + assert_eq!(movie_roots[1].path, "/mnt/media/movies/main"); + assert_ne!(movie_roots[0].policy_id, movie_roots[1].policy_id); } #[tokio::test] @@ -200,7 +200,7 @@ mod tests { .await .expect("policies"); - assert_eq!(rows.len(), 2); + assert_eq!(rows.len(), 4); for row in rows { let size_bands: String = row.get(0); let score_weights: String = row.get(1); @@ -247,4 +247,61 @@ mod tests { .await .expect_err("state is a closed set"); } + + #[tokio::test] + async fn seeds_two_tv_roots_with_distinct_policies() { + let (_dir, db) = fresh().await; + let roots = db.list_roots().await.expect("list roots"); + let tv_roots: Vec<_> = roots.iter().filter(|root| root.kind == "tv").collect(); + + assert_eq!(tv_roots.len(), 2); + assert_eq!(tv_roots[0].audience, "kids"); + assert_eq!(tv_roots[0].path, "/mnt/media/tv/kids"); + assert_eq!(tv_roots[1].audience, "main"); + assert_eq!(tv_roots[1].path, "/mnt/media/tv/main"); + assert_ne!(tv_roots[0].policy_id, tv_roots[1].policy_id); + } + + #[tokio::test] + async fn episode_wanted_is_independent_of_tracking_rules() { + let (_dir, db) = fresh().await; + let root_id: i64 = + sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'") + .fetch_one(db.pool()) + .await + .expect("TV root"); + let series_id = sqlx::query("INSERT INTO series (tmdb_id, title, root_id, auto_track) VALUES (82728, 'Bluey', ?, 1)") + .bind(root_id) + .execute(db.pool()) + .await + .expect("series") + .last_insert_rowid(); + let season_id = + sqlx::query("INSERT INTO seasons (series_id, number, tracked) VALUES (?, 1, 1)") + .bind(series_id) + .execute(db.pool()) + .await + .expect("season") + .last_insert_rowid(); + sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 2, 'Hospital')") + .bind(season_id) + .execute(db.pool()) + .await + .expect("episode"); + + let wanted: bool = sqlx::query_scalar("SELECT wanted FROM episodes") + .fetch_one(db.pool()) + .await + .expect("wanted"); + assert!( + !wanted, + "auto_track and tracked are rules, not episode intent" + ); + + let movie_count: i64 = sqlx::query_scalar("SELECT count(*) FROM movies") + .fetch_one(db.pool()) + .await + .expect("movie schema remains available"); + assert_eq!(movie_count, 0); + } } -- 2.47.3 From 47e1375434da71b8e5a2632ee0dabad43187f09c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 21:26:40 +0100 Subject: [PATCH 3/6] fix(db): enforce root media kinds --- crates/arr-db/migrations/0005_series.sql | 28 ++++++++++++++++++++++++ crates/arr-db/src/lib.rs | 25 +++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/crates/arr-db/migrations/0005_series.sql b/crates/arr-db/migrations/0005_series.sql index 3785274..bdd4583 100644 --- a/crates/arr-db/migrations/0005_series.sql +++ b/crates/arr-db/migrations/0005_series.sql @@ -14,6 +14,34 @@ CREATE TABLE series ( CREATE INDEX series_root ON series (root_id); +CREATE TRIGGER movies_require_movie_root +BEFORE INSERT ON movies +WHEN NOT EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind = 'movie') +BEGIN + SELECT RAISE(ABORT, 'movies require a movie root'); +END; + +CREATE TRIGGER movies_require_movie_root_on_update +BEFORE UPDATE OF root_id ON movies +WHEN NOT EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind = 'movie') +BEGIN + SELECT RAISE(ABORT, 'movies require a movie root'); +END; + +CREATE TRIGGER series_require_tv_root +BEFORE INSERT ON series +WHEN NOT EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind = 'tv') +BEGIN + SELECT RAISE(ABORT, 'series require a TV root'); +END; + +CREATE TRIGGER series_require_tv_root_on_update +BEFORE UPDATE OF root_id ON series +WHEN NOT EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind = 'tv') +BEGIN + SELECT RAISE(ABORT, 'series require a TV root'); +END; + CREATE TABLE seasons ( id INTEGER PRIMARY KEY, series_id INTEGER NOT NULL REFERENCES series (id) ON DELETE CASCADE, diff --git a/crates/arr-db/src/lib.rs b/crates/arr-db/src/lib.rs index 69784f8..a718a50 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -304,4 +304,29 @@ mod tests { .expect("movie schema remains available"); assert_eq!(movie_count, 0); } + + #[tokio::test] + async fn aggregates_require_roots_of_their_own_kind() { + let (_dir, db) = fresh().await; + let movie_root: i64 = + sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'movie' LIMIT 1") + .fetch_one(db.pool()) + .await + .expect("movie root"); + let tv_root: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' LIMIT 1") + .fetch_one(db.pool()) + .await + .expect("TV root"); + + sqlx::query("INSERT INTO movies (tmdb_id, title, root_id) VALUES (1, 'x', ?)") + .bind(tv_root) + .execute(db.pool()) + .await + .expect_err("movie cannot use TV root"); + sqlx::query("INSERT INTO series (tmdb_id, title, root_id) VALUES (2, 'x', ?)") + .bind(movie_root) + .execute(db.pool()) + .await + .expect_err("series cannot use movie root"); + } } -- 2.47.3 From ca58864f96c65de4f55dfa13be843802ee97f8ff Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 21:26:56 +0100 Subject: [PATCH 4/6] fix(db): preserve foreign key errors --- crates/arr-db/migrations/0005_series.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/arr-db/migrations/0005_series.sql b/crates/arr-db/migrations/0005_series.sql index bdd4583..3182442 100644 --- a/crates/arr-db/migrations/0005_series.sql +++ b/crates/arr-db/migrations/0005_series.sql @@ -16,28 +16,28 @@ CREATE INDEX series_root ON series (root_id); CREATE TRIGGER movies_require_movie_root BEFORE INSERT ON movies -WHEN NOT EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind = 'movie') +WHEN EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind != 'movie') BEGIN SELECT RAISE(ABORT, 'movies require a movie root'); END; CREATE TRIGGER movies_require_movie_root_on_update BEFORE UPDATE OF root_id ON movies -WHEN NOT EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind = 'movie') +WHEN EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind != 'movie') BEGIN SELECT RAISE(ABORT, 'movies require a movie root'); END; CREATE TRIGGER series_require_tv_root BEFORE INSERT ON series -WHEN NOT EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind = 'tv') +WHEN EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind != 'tv') BEGIN SELECT RAISE(ABORT, 'series require a TV root'); END; CREATE TRIGGER series_require_tv_root_on_update BEFORE UPDATE OF root_id ON series -WHEN NOT EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind = 'tv') +WHEN EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind != 'tv') BEGIN SELECT RAISE(ABORT, 'series require a TV root'); END; -- 2.47.3 From b4c6c872bbae061a1eb61591044adc1da556b14e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 21:41:35 +0100 Subject: [PATCH 5/6] fix(db): address TV model review --- crates/arr-core/src/lib.rs | 13 +++---- crates/arr-db/migrations/0005_series.sql | 38 +++++++++++++++----- crates/arr-db/src/lib.rs | 46 +++++++++++++++++++++--- 3 files changed, 76 insertions(+), 21 deletions(-) diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 274a6b5..91bcffe 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -158,11 +158,13 @@ pub struct Policy { } #[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct MovieOverrides { +pub struct TitleOverrides { pub only_4k: bool, pub allow_english_audio: bool, } +pub type MovieOverrides = TitleOverrides; + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MovieState { Missing, @@ -186,12 +188,6 @@ pub struct Movie { pub last_searched_at: Option, } -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct SeriesOverrides { - pub only_4k: bool, - pub allow_english_audio: bool, -} - #[derive(Clone, Debug, Eq, PartialEq)] pub struct Series { pub id: SeriesId, @@ -201,7 +197,8 @@ pub struct Series { pub original_language: Language, pub root_id: RootId, pub auto_track: bool, - pub overrides: SeriesOverrides, + pub overrides: TitleOverrides, + pub upstream_ended: bool, pub blocked: bool, } diff --git a/crates/arr-db/migrations/0005_series.sql b/crates/arr-db/migrations/0005_series.sql index 3182442..55b064b 100644 --- a/crates/arr-db/migrations/0005_series.sql +++ b/crates/arr-db/migrations/0005_series.sql @@ -1,3 +1,5 @@ +-- TV side of the separate aggregates in DESIGN.md §4. This migration is +-- additive: the movie tables stay concrete and unchanged. CREATE TABLE series ( id INTEGER PRIMARY KEY, tmdb_id INTEGER NOT NULL UNIQUE, @@ -7,6 +9,8 @@ CREATE TABLE series ( root_id INTEGER NOT NULL REFERENCES roots (id), auto_track INTEGER NOT NULL DEFAULT 0 CHECK (auto_track IN (0, 1)), overrides TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(overrides)), + -- §4.2 needs upstream completion to derive `ended` rather than store it. + upstream_ended INTEGER NOT NULL DEFAULT 0 CHECK (upstream_ended IN (0, 1)), blocked INTEGER NOT NULL DEFAULT 0 CHECK (blocked IN (0, 1)), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) @@ -14,6 +18,8 @@ CREATE TABLE series ( CREATE INDEX series_root ON series (root_id); +-- Before TV roots existed, the roots foreign key was sufficient to keep a +-- movie on a movie root. Preserve that invariant now that both kinds exist. CREATE TRIGGER movies_require_movie_root BEFORE INSERT ON movies WHEN EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind != 'movie') @@ -59,8 +65,9 @@ CREATE TABLE episodes ( title TEXT NOT NULL, air_date TEXT, wanted INTEGER NOT NULL DEFAULT 0 CHECK (wanted IN (0, 1)), + -- Mirrors arr-core's canonical EpisodeState vocabulary. state TEXT NOT NULL DEFAULT 'missing' - CHECK (state IN ('missing', 'grabbed', 'imported')), + CHECK (state IN ('missing', 'downloading', 'available')), search_attempts INTEGER NOT NULL DEFAULT 0, last_searched_at TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), @@ -77,18 +84,31 @@ CREATE INDEX episodes_state ON episodes (state); INSERT INTO policies ( name, required_audio, dub_blacklist, hdr_rules, size_bands, resolution_pref, source_weights, score_weights -) SELECT - 'TV — main', required_audio, dub_blacklist, hdr_rules, size_bands, - resolution_pref, source_weights, score_weights -FROM policies WHERE name = 'Movies — main'; +) VALUES ( + 'TV — main', + json('{"require":"original_language","allow_english_fallback":false}'), + json('["pt-BR"]'), + json('{"dv_profile_allow":["8.1"],"dv_profile_reject":["5","7"],"allow_hdr10":true,"allow_hdr10plus":true,"allow_sdr":true}'), + json('{"2160p":{"floor_gib":8,"target_gib":22,"penalty_points_per_gib_over":60},"1080p":{"floor_gib":3,"target_gib":8,"penalty_points_per_gib_over":60}}'), + json('["2160p","1080p"]'), + json('{"Remux":4,"BluRay":3,"WEB-DL":2,"WEBRip":1,"HDTV":0}'), + json('{"size_at_target":1000,"source_tier":25,"seeder_doubling":8}') +); INSERT INTO policies ( name, required_audio, dub_blacklist, hdr_rules, size_bands, resolution_pref, source_weights, score_weights -) SELECT - 'TV — kids', required_audio, dub_blacklist, hdr_rules, size_bands, - resolution_pref, source_weights, score_weights -FROM policies WHERE name = 'Movies — kids'; +) VALUES ( + 'TV — kids', + json('{"require":"any_of","langs":["pt-PT"],"or_original_when_portuguese":true,"allow_english_fallback":false}'), + json('["pt-BR"]'), + json('{"dv_profile_allow":["8.1"],"dv_profile_reject":["5","7"],"allow_hdr10":true,"allow_hdr10plus":true,"allow_sdr":true}'), + json('{"2160p":{"floor_gib":8,"target_gib":22,"penalty_points_per_gib_over":60},"1080p":{"floor_gib":3,"target_gib":8,"penalty_points_per_gib_over":60}}'), + json('["2160p","1080p"]'), + -- §5.2: pt-PT dubs are concentrated in streaming releases. + json('{"WEB-DL":4,"WEBRip":2,"BluRay":1,"Remux":1,"HDTV":0}'), + json('{"size_at_target":1000,"source_tier":25,"seeder_doubling":8}') +); INSERT INTO roots (kind, audience, path, policy_id) SELECT 'tv', 'main', '/mnt/media/tv/main', id FROM policies WHERE name = 'TV — main'; diff --git a/crates/arr-db/src/lib.rs b/crates/arr-db/src/lib.rs index a718a50..c7a69a9 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -298,11 +298,10 @@ mod tests { "auto_track and tracked are rules, not episode intent" ); - let movie_count: i64 = sqlx::query_scalar("SELECT count(*) FROM movies") - .fetch_one(db.pool()) + sqlx::query("INSERT INTO movies (tmdb_id, title, root_id) SELECT 693134, 'Dune Part Two', id FROM roots WHERE kind = 'movie' AND audience = 'main'") + .execute(db.pool()) .await - .expect("movie schema remains available"); - assert_eq!(movie_count, 0); + .expect("existing movie schema remains writable"); } #[tokio::test] @@ -329,4 +328,43 @@ mod tests { .await .expect_err("series cannot use movie root"); } + + #[tokio::test] + async fn episode_state_and_upstream_completion_are_constrained() { + let (_dir, db) = fresh().await; + let root_id: i64 = + sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'") + .fetch_one(db.pool()) + .await + .expect("TV root"); + let series_id = sqlx::query( + "INSERT INTO series (tmdb_id, title, root_id, upstream_ended) + VALUES (82728, 'Bluey', ?, 1)", + ) + .bind(root_id) + .execute(db.pool()) + .await + .expect("ended 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(); + + sqlx::query( + "INSERT INTO episodes (season_id, number, title, state) + VALUES (?, 1, 'Magic Xylophone', 'grabbed')", + ) + .bind(season_id) + .execute(db.pool()) + .await + .expect_err("database mirrors EpisodeState"); + sqlx::query("UPDATE series SET upstream_ended = 2 WHERE id = ?") + .bind(series_id) + .execute(db.pool()) + .await + .expect_err("upstream completion is boolean"); + } } -- 2.47.3 From f7a62958fd9f4618e1bf7975da4647970ef24785 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 21:44:59 +0100 Subject: [PATCH 6/6] refactor(core): share title policy types --- crates/arr-api/src/search.rs | 8 ++-- crates/arr-core/src/lib.rs | 17 ++------ crates/arr-core/src/policy.rs | 52 ++++++++++++------------ crates/arr-core/src/score.rs | 6 +-- crates/arr-db/migrations/0005_series.sql | 4 +- crates/arr-db/src/lib.rs | 2 +- 6 files changed, 41 insertions(+), 48 deletions(-) diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 36cf87f..8745d56 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -3,8 +3,8 @@ use std::time::UNIX_EPOCH; use arr_core::policy::{evaluate, Candidate}; use arr_core::{ - DolbyVisionProfile, HdrRules, Language, MovieOverrides, Policy, PolicyId, RequiredAudio, - Resolution, Rule, ScoreWeights, SizeBand, Source, Verdict, + DolbyVisionProfile, HdrRules, Language, Policy, PolicyId, RequiredAudio, Resolution, Rule, + ScoreWeights, SizeBand, Source, TitleOverrides, Verdict, }; use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest}; use axum::extract::{Query, State}; @@ -293,7 +293,7 @@ pub async fn releases( })?; let overrides: OverridesJson = serde_json::from_value(movie.overrides) .map_err(|error| ApiError::Database(error.to_string()))?; - let overrides = MovieOverrides { + let overrides = TitleOverrides { only_4k: overrides.only_4k, allow_english_audio: overrides.allow_english_audio, }; @@ -460,7 +460,7 @@ fn policy_from_row(row: PolicyRow) -> Result { fn classify( release: SearchRelease, policy: &Policy, - overrides: &MovieOverrides, + overrides: &TitleOverrides, original_language: &Language, ) -> Result { let parsed = arr_parse::parse(&release.name); diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 91bcffe..3449d2d 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -163,10 +163,8 @@ pub struct TitleOverrides { pub allow_english_audio: bool, } -pub type MovieOverrides = TitleOverrides; - #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum MovieState { +pub enum MediaState { Missing, Downloading, Available, @@ -181,8 +179,8 @@ pub struct Movie { pub original_language: Language, pub root_id: RootId, pub wanted: bool, - pub overrides: MovieOverrides, - pub state: MovieState, + pub overrides: TitleOverrides, + pub state: MediaState, pub blocked: bool, pub search_attempts: u32, pub last_searched_at: Option, @@ -210,13 +208,6 @@ pub struct Season { pub tracked: bool, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum EpisodeState { - Missing, - Downloading, - Available, -} - #[derive(Clone, Debug, Eq, PartialEq)] pub struct Episode { pub id: EpisodeId, @@ -225,7 +216,7 @@ pub struct Episode { pub title: String, pub air_date: Option, pub wanted: bool, - pub state: EpisodeState, + pub state: MediaState, pub search_attempts: u32, pub last_searched_at: Option, } diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs index 800947d..cd3e2e0 100644 --- a/crates/arr-core/src/policy.rs +++ b/crates/arr-core/src/policy.rs @@ -2,8 +2,8 @@ use arr_parse::{LanguageMarker, NameClaims}; use crate::{ lang::{is_resolved_portuguese, language_of_marker}, - HdrFormat, Language, MovieOverrides, Policy, ProbedMedia, RequiredAudio, Resolution, Rule, - Source, Verdict, + HdrFormat, Language, Policy, ProbedMedia, RequiredAudio, Resolution, Rule, Source, + TitleOverrides, Verdict, }; /// The evidence available while evaluating a candidate. @@ -55,7 +55,7 @@ pub enum EvaluationPhase { #[derive(Clone, Copy, Debug)] pub struct EvaluationContext<'a> { pub policy: &'a Policy, - pub overrides: &'a MovieOverrides, + pub overrides: &'a TitleOverrides, /// The title's original language from TMDB (`DESIGN.md` §5.2) — a /// distinct input from any track's language. pub original_language: &'a Language, @@ -103,7 +103,7 @@ pub struct Evaluation { #[must_use] pub fn evaluate( policy: &Policy, - overrides: &MovieOverrides, + overrides: &TitleOverrides, original_language: &Language, candidate: Candidate<'_>, size_bytes: Option, @@ -514,7 +514,7 @@ mod tests { } } - fn verdict(policy: &Policy, overrides: &MovieOverrides, candidate: Candidate<'_>) -> Verdict { + fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict { evaluate(policy, overrides, &en(), candidate, None).verdict } @@ -525,7 +525,7 @@ mod tests { ) -> Verdict { evaluate( policy, - &MovieOverrides::default(), + &TitleOverrides::default(), original_language, candidate, None, @@ -539,7 +539,7 @@ mod tests { let claims = claims(None, None); let report = evaluate( &policy, - &MovieOverrides::default(), + &TitleOverrides::default(), &en(), Candidate::PreGrab(&claims), None, @@ -562,7 +562,7 @@ mod tests { #[test] fn preferred_resolutions_pass_in_both_phases() { let policy = policy(); - let overrides = MovieOverrides::default(); + let overrides = TitleOverrides::default(); for claimed in [ClaimedResolution::P2160, ClaimedResolution::P1080] { let claims = claims(Some(claimed), Some(ClaimedSource::WebDl)); @@ -583,7 +583,7 @@ mod tests { #[test] fn resolution_outside_root_policy_hard_fails_in_both_phases() { let policy = policy(); - let overrides = MovieOverrides::default(); + let overrides = TitleOverrides::default(); let cases = [ (ClaimedResolution::P480, Resolution::Other(480)), (ClaimedResolution::P576, Resolution::Other(576)), @@ -607,9 +607,9 @@ mod tests { #[test] fn only_4k_tightens_the_root_policy_in_both_phases() { let policy = policy(); - let overrides = MovieOverrides { + let overrides = TitleOverrides { only_4k: true, - ..MovieOverrides::default() + ..TitleOverrides::default() }; let claims_1080 = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl)); let media = probed(Resolution::R1080p, Some(Source::WebDl)); @@ -634,9 +634,9 @@ mod tests { fn only_4k_never_expands_the_root_policy() { let mut policy = policy(); policy.resolution_preference = vec![Resolution::R1080p]; - let overrides = MovieOverrides { + let overrides = TitleOverrides { only_4k: true, - ..MovieOverrides::default() + ..TitleOverrides::default() }; let claims = claims(Some(ClaimedResolution::P2160), Some(ClaimedSource::WebDl)); @@ -649,7 +649,7 @@ mod tests { #[test] fn every_unsafe_source_hard_fails_in_both_phases() { let policy = policy(); - let overrides = MovieOverrides::default(); + let overrides = TitleOverrides::default(); let cases = [ (ClaimedSource::Cam, Source::Cam), (ClaimedSource::Telesync, Source::Telesync), @@ -679,7 +679,7 @@ mod tests { assert_eq!( verdict( &policy, - &MovieOverrides::default(), + &TitleOverrides::default(), Candidate::PreGrab(&claims), ), Verdict::Rejected(Rule::Source(Source::Telesync)) @@ -689,7 +689,7 @@ mod tests { #[test] fn every_safe_source_passes_in_both_phases() { let policy = policy(); - let overrides = MovieOverrides::default(); + let overrides = TitleOverrides::default(); let cases = [ (ClaimedSource::Dvd, Source::Dvd), (ClaimedSource::Telecine, Source::Telecine), @@ -721,7 +721,7 @@ mod tests { let media = probed(Resolution::R2160p, source); let report = evaluate( &policy, - &MovieOverrides::default(), + &TitleOverrides::default(), &en(), Candidate::PostDownload(&media), None, @@ -777,7 +777,7 @@ mod tests { let report = evaluate( policy, - &MovieOverrides::default(), + &TitleOverrides::default(), &en(), Candidate::PostDownload(&media), None, @@ -796,7 +796,7 @@ mod tests { let claims = arr_parse::parse("Movie.2024.2160p.WEB-DL.DV-GROUP"); let report = evaluate( &policy(), - &MovieOverrides::default(), + &TitleOverrides::default(), &en(), Candidate::PreGrab(&claims), None, @@ -823,7 +823,7 @@ mod tests { #[test] fn hard_failure_wins_over_an_earlier_soft_failure() { let policy = policy(); - let overrides = MovieOverrides::default(); + let overrides = TitleOverrides::default(); let original = en(); let claims = claims(None, None); let context = EvaluationContext { @@ -851,7 +851,7 @@ mod tests { #[test] fn first_failure_of_the_deciding_kind_is_stable() { let policy = policy(); - let overrides = MovieOverrides::default(); + let overrides = TitleOverrides::default(); let original = en(); let claims = claims(None, None); let context = EvaluationContext { @@ -984,9 +984,9 @@ mod tests { #[test] fn kids_allow_english_audio_grabs_and_imports_with_a_waiver() { let policy = kids_policy(); - let overrides = MovieOverrides { + let overrides = TitleOverrides { allow_english_audio: true, - ..MovieOverrides::default() + ..TitleOverrides::default() }; let claims = name_claims(&[LanguageMarker::English]); assert_eq!( @@ -1078,7 +1078,7 @@ mod tests { // kids: could be the required pt-PT — must not reject as a dub. let report = evaluate( &kids_policy(), - &MovieOverrides::default(), + &TitleOverrides::default(), &en(), Candidate::PostDownload(&media), None, @@ -1089,7 +1089,7 @@ mod tests { // as verified either. let report = evaluate( &policy(), - &MovieOverrides::default(), + &TitleOverrides::default(), &Language::PortugueseBrazil, Candidate::PostDownload(&media), None, @@ -1102,7 +1102,7 @@ mod tests { let claims = name_claims(&[LanguageMarker::Portuguese]); let report = evaluate( &kids_policy(), - &MovieOverrides::default(), + &TitleOverrides::default(), &en(), Candidate::PreGrab(&claims), None, diff --git a/crates/arr-core/src/score.rs b/crates/arr-core/src/score.rs index e1cb689..2e0e088 100644 --- a/crates/arr-core/src/score.rs +++ b/crates/arr-core/src/score.rs @@ -156,7 +156,7 @@ mod tests { use super::*; use crate::{ policy::{evaluate, EvaluationContext, PolicyRule, RuleEvaluation, SizeRule}, - HdrRules, Language, MovieOverrides, PolicyId, RequiredAudio, Rule, Verdict, + HdrRules, Language, PolicyId, RequiredAudio, Rule, TitleOverrides, Verdict, }; const GIB: u64 = 1 << 30; @@ -221,7 +221,7 @@ mod tests { fn size_rule(size_bytes: u64) -> RuleEvaluation { let policy = policy(); - let overrides = MovieOverrides::default(); + let overrides = TitleOverrides::default(); let language = Language::Other("en".to_owned()); let claims = claims(ClaimedSource::WebDl); SizeRule.evaluate(&EvaluationContext { @@ -256,7 +256,7 @@ mod tests { let claims = claims(ClaimedSource::Remux); let evaluation = evaluate( &policy, - &MovieOverrides::default(), + &TitleOverrides::default(), &Language::Other("en".to_owned()), Candidate::PreGrab(&claims), Some(gib(60)), diff --git a/crates/arr-db/migrations/0005_series.sql b/crates/arr-db/migrations/0005_series.sql index 55b064b..5490879 100644 --- a/crates/arr-db/migrations/0005_series.sql +++ b/crates/arr-db/migrations/0005_series.sql @@ -65,7 +65,8 @@ CREATE TABLE episodes ( title TEXT NOT NULL, air_date TEXT, wanted INTEGER NOT NULL DEFAULT 0 CHECK (wanted IN (0, 1)), - -- Mirrors arr-core's canonical EpisodeState vocabulary. + -- Mirrors arr-core's canonical MediaState vocabulary. The older movies + -- table uses legacy names; its additive correction is tracked in #73. state TEXT NOT NULL DEFAULT 'missing' CHECK (state IN ('missing', 'downloading', 'available')), search_attempts INTEGER NOT NULL DEFAULT 0, @@ -91,6 +92,7 @@ INSERT INTO policies ( json('{"dv_profile_allow":["8.1"],"dv_profile_reject":["5","7"],"allow_hdr10":true,"allow_hdr10plus":true,"allow_sdr":true}'), json('{"2160p":{"floor_gib":8,"target_gib":22,"penalty_points_per_gib_over":60},"1080p":{"floor_gib":3,"target_gib":8,"penalty_points_per_gib_over":60}}'), json('["2160p","1080p"]'), + -- §5.5 deliberately keeps source tier as Remux > BluRay > WEB-DL. json('{"Remux":4,"BluRay":3,"WEB-DL":2,"WEBRip":1,"HDTV":0}'), json('{"size_at_target":1000,"source_tier":25,"seeder_doubling":8}') ); diff --git a/crates/arr-db/src/lib.rs b/crates/arr-db/src/lib.rs index c7a69a9..665fc2d 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -360,7 +360,7 @@ mod tests { .bind(season_id) .execute(db.pool()) .await - .expect_err("database mirrors EpisodeState"); + .expect_err("database mirrors MediaState"); sqlx::query("UPDATE series SET upstream_ended = 2 WHERE id = ?") .bind(series_id) .execute(db.pool()) -- 2.47.3