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"); + } }