From ac6ed8b7984467b5084088ce831fb2e0f8f0d14a Mon Sep 17 00:00:00 2001 From: "naps62-yolo (agent)" Date: Sat, 22 Aug 2026 22:04:27 +0100 Subject: [PATCH 1/3] feat(db): add series, season and episode model (#71) --- crates/arr-api/src/search.rs | 8 +- crates/arr-core/src/lib.rs | 46 +++++++- crates/arr-core/src/policy.rs | 52 ++++----- crates/arr-core/src/score.rs | 6 +- crates/arr-db/migrations/0005_series.sql | 119 ++++++++++++++++++++ crates/arr-db/src/lib.rs | 136 +++++++++++++++++++++-- 6 files changed, 322 insertions(+), 45 deletions(-) create mode 100644 crates/arr-db/migrations/0005_series.sql 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 c6b335f..3449d2d 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); @@ -155,13 +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, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum MovieState { +pub enum MediaState { Missing, Downloading, Available, @@ -176,13 +179,48 @@ 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, } +#[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: TitleOverrides, + pub upstream_ended: bool, + 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, 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: MediaState, + pub search_attempts: u32, + pub last_searched_at: Option, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct AudioTrack { pub language: Language, 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 new file mode 100644 index 0000000..5490879 --- /dev/null +++ b/crates/arr-db/migrations/0005_series.sql @@ -0,0 +1,119 @@ +-- 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, + 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)), + -- §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')) +) STRICT; + +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') +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 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 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 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, + 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)), + -- 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, + 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 +) 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"]'), + -- §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}') +); + +INSERT INTO policies ( + name, required_audio, dub_blacklist, hdr_rules, size_bands, + resolution_pref, source_weights, score_weights +) 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'; + +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..665fc2d 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,124 @@ 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" + ); + + 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("existing movie schema remains writable"); + } + + #[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"); + } + + #[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 MediaState"); + sqlx::query("UPDATE series SET upstream_ended = 2 WHERE id = ?") + .bind(series_id) + .execute(db.pool()) + .await + .expect_err("upstream completion is boolean"); + } } From bf526b9198ded6e2d2cf0cc28252834df1706958 Mon Sep 17 00:00:00 2001 From: "naps62-yolo (agent)" Date: Sat, 22 Aug 2026 22:08:32 +0100 Subject: [PATCH 2/3] Unified search box (#30) (#75) --- .impeccable/surfaces/web-src-main-ts.md | 26 + ...46450066ac2997437175e30c60d6364701dcf.json | 50 ++ crates/arr-api/src/lib.rs | 6 +- crates/arr-api/src/roots.rs | 79 +++ web/index.html | 46 ++ web/src/main.ts | 462 ++++++++++++++++++ web/src/search.ts | 146 ++++++ web/src/style.css | 299 ++++++++++++ 8 files changed, 1113 insertions(+), 1 deletion(-) create mode 100644 .impeccable/surfaces/web-src-main-ts.md create mode 100644 .sqlx/query-fa84977e249cb6f3b95d5f3486346450066ac2997437175e30c60d6364701dcf.json create mode 100644 crates/arr-api/src/roots.rs create mode 100644 web/src/search.ts diff --git a/.impeccable/surfaces/web-src-main-ts.md b/.impeccable/surfaces/web-src-main-ts.md new file mode 100644 index 0000000..5e82744 --- /dev/null +++ b/.impeccable/surfaces/web-src-main-ts.md @@ -0,0 +1,26 @@ +--- +version: 1 +slug: "web-src-main-ts" +primary_target: "web/src/main.ts" +related_targets: ["web/index.html","web/src/search.ts","web/src/style.css"] +--- + +# Surface: unified search (web/src/main.ts, §9.2) + +Mode: Operate. The operator finds or adds a title without knowing which of +the two they are doing. + +- Audience: the household operator; desktop mostly, phone sometimes. +- Task: one rail-mounted command input. Typing swaps the chain board for the + search deck; clearing or Esc returns to the board. "/" focuses from anywhere. +- Structure (pinned by DESIGN.md §9.2): IN LIBRARY group first, ON TMDB below, + engraved micro-label headings, machined rows, chips on the right. +- Add flow: inline panel under a TMDB row — root radio cards (audience name, + policy name, path), main pre-selected, one confirm. Never a separate page. +- Manual intake: magnet / .torrent input routes to a MANUAL GRAB panel showing + parsed display name and btih; grab wiring lands with the release buckets + (issue #31/#22), and the panel says so honestly. +- Constraints: 250 ms debounce, AbortController cancellation, token-gated CSS, + cyan for interaction only, dead-air rule (no idle animation). +- Unresolved: library rows are informational until the library view (#32) + gives them a destination; TMDB search is movie-only until series land (#39). diff --git a/.sqlx/query-fa84977e249cb6f3b95d5f3486346450066ac2997437175e30c60d6364701dcf.json b/.sqlx/query-fa84977e249cb6f3b95d5f3486346450066ac2997437175e30c60d6364701dcf.json new file mode 100644 index 0000000..b66fc84 --- /dev/null +++ b/.sqlx/query-fa84977e249cb6f3b95d5f3486346450066ac2997437175e30c60d6364701dcf.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "SELECT r.id AS \"id!: i64\", r.kind AS \"kind!: String\", r.audience AS \"audience!: String\", r.path AS \"path!: String\", p.id AS \"policy_id!: i64\", p.name AS \"policy_name!: String\" FROM roots r JOIN policies p ON p.id = r.policy_id ORDER BY r.id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "kind!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "audience!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "path!: String", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "policy_id!: i64", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "policy_name!: String", + "ordinal": 5, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "fa84977e249cb6f3b95d5f3486346450066ac2997437175e30c60d6364701dcf" +} diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 062b2b1..f565f46 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -9,6 +9,7 @@ mod health; mod movies; mod owners; +mod roots; mod search; mod state; @@ -22,6 +23,7 @@ use utoipa_scalar::{Scalar, Servable}; pub use health::{Check, Health, HealthReport, Status}; pub use movies::{Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, Release, UpdateMovie}; pub use owners::{CreateOwner, Owner, UpdateOwner}; +pub use roots::Root; pub use search::{ClassifiedRelease, SearchResponse}; pub use state::{AppState, MovieCommand, Upstreams, DEFAULT_TMDB_URL}; @@ -45,7 +47,8 @@ pub const DOCS_PATH: &str = "/api/docs"; (name = "system", description = "Service health and metadata"), (name = "movies", description = "Movie library and actions"), (name = "owners", description = "Owner tags and filtered views (DESIGN.md §4.3)"), - (name = "search", description = "Unified title and release search") + (name = "search", description = "Unified title and release search"), + (name = "roots", description = "Root folders and their policies") ), )] struct ApiDoc; @@ -66,6 +69,7 @@ fn api_router() -> OpenApiRouter { .routes(routes!(owners::get, owners::update, owners::delete)) .routes(routes!(search::search)) .routes(routes!(search::releases)) + .routes(routes!(roots::list)) } /// The generated `OpenAPI` document. diff --git a/crates/arr-api/src/roots.rs b/crates/arr-api/src/roots.rs new file mode 100644 index 0000000..7f86586 --- /dev/null +++ b/crates/arr-api/src/roots.rs @@ -0,0 +1,79 @@ +use axum::extract::State; +use axum::Json; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::movies::{ApiError, ErrorBody}; +use crate::state::AppState; + +/// A root folder joined with its policy, enough for the add flow to pre-fill +/// root and policy (DESIGN.md §9.2) without a second request. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct Root { + pub id: i64, + pub kind: String, + pub audience: String, + pub path: String, + pub policy_id: i64, + pub policy_name: String, +} + +#[utoipa::path( + get, path = "/api/roots", tag = "roots", + responses( + (status = 200, body = [Root]), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn list(State(state): State) -> Result>, ApiError> { + let database = state.database().ok_or(ApiError::Unavailable)?; + let roots = sqlx::query_as!( + Root, + r#"SELECT r.id AS "id!: i64", r.kind AS "kind!: String", r.audience AS "audience!: String", r.path AS "path!: String", p.id AS "policy_id!: i64", p.name AS "policy_name!: String" FROM roots r JOIN policies p ON p.id = r.policy_id ORDER BY r.id"# + ) + .fetch_all(database.pool()) + .await?; + Ok(Json(roots)) +} + +#[cfg(test)] +mod tests { + use crate::state::Upstreams; + use crate::{router, AppState}; + + #[tokio::test] + async fn roots_carry_their_policy_name() { + let dir = tempfile::tempdir().expect("tempdir"); + let database = arr_db::Db::connect(dir.path().join("arr.db")) + .await + .expect("database"); + database.migrate().await.expect("migrate"); + let state = AppState::new(Upstreams::new( + "http://127.0.0.1:1".into(), + "http://127.0.0.1:1".into(), + )) + .expect("state") + .with_database(database); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let address = listener.local_addr().expect("address"); + let app = router(state); + tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); + + let roots: Vec = reqwest::get(format!("http://{address}/api/roots")) + .await + .expect("roots") + .json() + .await + .expect("json"); + assert_eq!(roots.len(), 2, "the two seeded movie roots: {roots:?}"); + assert_eq!(roots[0]["audience"], "main"); + assert_eq!(roots[0]["policy_name"], "Movies — main"); + assert_eq!(roots[1]["audience"], "kids"); + assert!(roots[1]["path"] + .as_str() + .is_some_and(|p| p.contains("kids"))); + } +} diff --git a/web/index.html b/web/index.html index ac7c7f0..dcd8d88 100644 --- a/web/index.html +++ b/web/index.html @@ -28,12 +28,31 @@ output in this environment). FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md. + SEARCH (§9.2, issue #30): one command input on the rail — the board's + command line. Typing swaps the chain for the search deck: IN LIBRARY + first, ON TMDB below, engraved group labels, machined rows. Enter on a + TMDB row expands an inline add panel with root and policy pre-filled; + a pasted magnet or .torrent routes to a manual-intake panel. There is + no separate add page, ever. Esc returns to the board; "/" focuses the + box from anywhere. -->

arr

+

probing chain…

v — @@ -41,6 +60,33 @@
+
+ + + + + + + +
+
diff --git a/web/src/main.ts b/web/src/main.ts index 7a664f5..458283d 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,4 +1,14 @@ import { type CheckStatus, type Probe, probeHealth } from "./health"; +import { + addMovie, + type LibraryMovie, + movieRoots, + parseManualInput, + type Root, + type SearchResponse, + searchTitles, + type TmdbMovie, +} from "./search"; import "./style.css"; const POLL_MS = 15_000; @@ -154,6 +164,458 @@ function main() { void probe(); }, POLL_MS); void probe(); + + searchMain(board); +} + +const DEBOUNCE_MS = 250; + +interface DeckRefs { + deck: HTMLElement; + status: HTMLElement; + groups: { + library: { section: HTMLElement; count: HTMLElement; rows: HTMLUListElement }; + tmdb: { section: HTMLElement; count: HTMLElement; rows: HTMLUListElement }; + }; + manualSection: HTMLElement; + manualIntake: HTMLElement; +} + +function searchMain(board: HTMLElement) { + const input = must("#search"); + const hint = must("#search-hint"); + const refs: DeckRefs = { + deck: must("#deck"), + status: must("#deck-status"), + groups: { + library: { + section: must("#group-library"), + count: must("#count-library"), + rows: must("#rows-library"), + }, + tmdb: { + section: must("#group-tmdb"), + count: must("#count-tmdb"), + rows: must("#rows-tmdb"), + }, + }, + manualSection: must("#group-manual"), + manualIntake: must("#manual-intake"), + }; + + // The audience chip on a library row needs id → root. movieRoots() caches + // success, so a failed boot fetch is retried whenever an add panel opens. + let roots: Root[] = []; + const fetchRoots = () => + movieRoots().then( + (fetched) => { + roots = fetched; + return fetched; + }, + () => roots, + ); + void fetchRoots(); + + let timer: number | undefined; + let controller: AbortController | null = null; + + function showBoard() { + controller?.abort(); + controller = null; + refs.deck.hidden = true; + board.hidden = false; + } + + function showDeck() { + board.hidden = true; + refs.deck.hidden = false; + } + + function setStatus(text: string | null, tone?: "fault") { + refs.status.hidden = text === null; + refs.status.textContent = text ?? ""; + if (tone) { + refs.status.dataset.tone = tone; + } else { + delete refs.status.dataset.tone; + } + } + + function clearGroups() { + for (const group of [refs.groups.library, refs.groups.tmdb]) { + group.section.hidden = true; + group.rows.replaceChildren(); + } + refs.manualSection.hidden = true; + refs.manualIntake.replaceChildren(); + } + + async function run(query: string) { + controller?.abort(); + controller = new AbortController(); + showDeck(); + setStatus("searching…"); + const outcome = await searchTitles(query, controller.signal); + if (outcome.kind === "aborted") { + return; + } + if (outcome.kind === "error") { + clearGroups(); + setStatus(`search failed — ${outcome.detail}`, "fault"); + return; + } + render(outcome.response); + } + + function render(response: SearchResponse) { + clearGroups(); + if ( + response.manual !== null && + (response.kind === "magnet" || response.kind === "torrent_url") + ) { + setStatus(null); + refs.manualSection.hidden = false; + renderManual(refs.manualIntake, response.kind, response.manual); + return; + } + + const inLibrary = new Set(response.library.map((movie) => movie.tmdb_id)); + if (response.library.length === 0 && response.tmdb.length === 0) { + setStatus("no matches in library or on tmdb"); + return; + } + setStatus(null); + + if (response.library.length > 0) { + refs.groups.library.section.hidden = false; + refs.groups.library.count.textContent = String(response.library.length); + for (const movie of response.library) { + refs.groups.library.rows.append(libraryRow(movie, roots)); + } + } + if (response.tmdb.length > 0) { + refs.groups.tmdb.section.hidden = false; + refs.groups.tmdb.count.textContent = String(response.tmdb.length); + for (const movie of response.tmdb) { + refs.groups.tmdb.rows.append(tmdbRow(movie, inLibrary.has(movie.tmdb_id), fetchRoots)); + } + } + } + + input.addEventListener("input", () => { + window.clearTimeout(timer); + const query = input.value.trim(); + if (query === "") { + showBoard(); + clearGroups(); + setStatus(null); + return; + } + timer = window.setTimeout(() => { + void run(query); + }, DEBOUNCE_MS); + }); + + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + window.clearTimeout(timer); + const query = input.value.trim(); + if (query !== "") { + void run(query); + } + } else if (event.key === "ArrowDown") { + const first = refs.deck.querySelector(".row-tmdb:not(:disabled)"); + if (first) { + event.preventDefault(); + first.focus(); + } + } else if (event.key === "Escape") { + input.value = ""; + showBoard(); + clearGroups(); + setStatus(null); + } + }); + + input.addEventListener("focus", () => { + hint.hidden = true; + }); + input.addEventListener("blur", () => { + hint.hidden = false; + }); + + window.addEventListener("keydown", (event) => { + if ( + event.key === "/" && + document.activeElement !== input && + !(document.activeElement instanceof HTMLInputElement) && + !(document.activeElement instanceof HTMLTextAreaElement) + ) { + event.preventDefault(); + input.focus(); + input.select(); + } else if (event.key === "Escape" && !refs.deck.hidden) { + input.value = ""; + showBoard(); + clearGroups(); + setStatus(null); + input.focus(); + } + }); + + refs.deck.addEventListener("keydown", (event) => { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") { + return; + } + const target = event.target; + if (!(target instanceof HTMLElement) || !target.classList.contains("row-tmdb")) { + return; + } + event.preventDefault(); + const rows = [...refs.deck.querySelectorAll(".row-tmdb:not(:disabled)")]; + const index = rows.indexOf(target); + if (event.key === "ArrowUp" && index === 0) { + input.focus(); + return; + } + rows[index + (event.key === "ArrowDown" ? 1 : -1)]?.focus(); + }); +} + +function chip(text: string, extra?: (chip: HTMLSpanElement) => void): HTMLSpanElement { + const span = document.createElement("span"); + span.className = "chip readout"; + span.textContent = text; + extra?.(span); + return span; +} + +function rowTitle(title: string, year: number | null): HTMLElement { + const wrap = document.createElement("span"); + wrap.className = "row-id"; + const name = document.createElement("span"); + name.className = "row-title"; + name.textContent = title; + const when = document.createElement("span"); + when.className = "row-year readout dim"; + when.textContent = year === null ? "—" : String(year); + wrap.append(name, when); + return wrap; +} + +function libraryRow(movie: LibraryMovie, roots: Root[]): HTMLLIElement { + const item = document.createElement("li"); + item.className = "row"; + const chips = document.createElement("span"); + chips.className = "row-chips"; + const root = roots.find((candidate) => candidate.id === movie.root_id); + chips.append(chip(root ? root.audience : `root ${movie.root_id}`)); + chips.append( + chip(movie.state, (span) => { + span.dataset.movieState = movie.state; + }), + ); + if (!movie.wanted) { + chips.append(chip("not wanted")); + } + if (movie.blocked) { + chips.append(chip("blocked")); + } + item.append(rowTitle(movie.title, movie.year), chips); + return item; +} + +function tmdbRow( + movie: TmdbMovie, + inLibrary: boolean, + fetchRoots: () => Promise, +): HTMLLIElement { + const item = document.createElement("li"); + + const row = document.createElement("button"); + row.type = "button"; + row.className = "row row-tmdb"; + const chips = document.createElement("span"); + chips.className = "row-chips"; + chips.append(chip(movie.original_language)); + if (inLibrary) { + chips.append( + chip("in library", (span) => { + span.dataset.verdict = "eligible"; + }), + ); + } else { + const add = document.createElement("span"); + add.className = "row-add readout"; + add.textContent = "add"; + chips.append(add); + } + row.append(rowTitle(movie.title, movie.year), chips); + if (movie.overview) { + const overview = document.createElement("span"); + overview.className = "row-overview"; + overview.textContent = movie.overview; + row.append(overview); + } + item.append(row); + + if (inLibrary) { + row.disabled = true; + return item; + } + + row.setAttribute("aria-expanded", "false"); + row.addEventListener("click", () => { + const open = item.querySelector(".add-panel"); + if (open) { + open.remove(); + row.setAttribute("aria-expanded", "false"); + row.focus(); + return; + } + void fetchRoots().then((available) => { + if (item.querySelector(".add-panel")) { + return; + } + const panel = addPanel(movie, available, row); + item.append(panel); + row.setAttribute("aria-expanded", "true"); + panel.querySelector(".control")?.focus(); + }); + }); + return item; +} + +/** The §9.2 add flow: inline, root and policy pre-filled, one confirm. */ +function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTMLElement { + const panel = document.createElement("div"); + panel.className = "add-panel"; + + let selected = roots.find((root) => root.audience === "main") ?? roots[0] ?? null; + + const options = document.createElement("div"); + options.className = "add-roots"; + options.setAttribute("role", "group"); + options.setAttribute("aria-label", "root folder"); + + const optionButtons: HTMLButtonElement[] = []; + for (const root of roots) { + const option = document.createElement("button"); + option.type = "button"; + option.className = "root-option"; + option.setAttribute("aria-pressed", String(root === selected)); + + const audience = document.createElement("span"); + audience.className = "root-audience"; + audience.textContent = root.audience; + const policy = document.createElement("span"); + policy.className = "root-policy readout dim"; + policy.textContent = root.policy_name; + const path = document.createElement("span"); + path.className = "root-path readout dim"; + path.textContent = root.path; + option.append(audience, policy, path); + + option.addEventListener("click", () => { + selected = root; + for (const other of optionButtons) { + other.setAttribute("aria-pressed", String(other === option)); + } + }); + optionButtons.push(option); + options.append(option); + } + + const confirm = document.createElement("button"); + confirm.type = "button"; + confirm.className = "control"; + confirm.textContent = "add to library"; + + const note = document.createElement("p"); + note.className = "add-note readout"; + note.setAttribute("role", "status"); + note.hidden = true; + + if (roots.length === 0) { + confirm.disabled = true; + note.hidden = false; + note.textContent = "roots unavailable — daemon down or database missing"; + note.dataset.tone = "fault"; + } + + confirm.addEventListener("click", () => { + const root = selected; + if (!root) { + return; + } + confirm.disabled = true; + note.hidden = false; + delete note.dataset.tone; + note.textContent = "adding…"; + void addMovie(movie, root.id).then((outcome) => { + if (outcome.kind === "error") { + confirm.disabled = false; + note.textContent = `add failed — ${outcome.detail}`; + note.dataset.tone = "fault"; + return; + } + const already = outcome.kind === "conflict"; + const done = document.createElement("p"); + done.className = "add-done readout"; + done.setAttribute("role", "status"); + done.tabIndex = -1; + done.textContent = already + ? "already in library" + : `added to ${root.audience} — wanted, search follows`; + panel.replaceChildren(done); + done.focus(); + row.disabled = true; + row.setAttribute("aria-expanded", "true"); + row.querySelector(".row-add")?.replaceWith( + chip("in library", (span) => { + span.dataset.verdict = "eligible"; + }), + ); + }); + }); + + const actions = document.createElement("div"); + actions.className = "add-actions"; + actions.append(confirm, note); + panel.append(options, actions); + return panel; +} + +function renderManual(intake: HTMLElement, kind: "magnet" | "torrent_url", raw: string) { + const parsed = parseManualInput(kind, raw); + intake.replaceChildren(); + + const head = document.createElement("header"); + head.className = "module-head"; + const name = document.createElement("h3"); + name.className = "module-name"; + name.textContent = kind === "magnet" ? "magnet" : "torrent"; + const status = document.createElement("span"); + status.className = "module-status readout"; + status.textContent = "recognized"; + head.append(name, status); + + const detail = document.createElement("p"); + detail.className = "module-detail readout"; + detail.textContent = parsed.name ?? parsed.raw; + + intake.append(head, detail); + if (parsed.infohash) { + const hash = document.createElement("p"); + hash.className = "module-detail readout dim"; + hash.textContent = `btih ${parsed.infohash}`; + intake.append(hash); + } + + const note = document.createElement("p"); + note.className = "module-detail readout dim"; + note.textContent = "manual grab lands with the release buckets — nothing sent yet"; + intake.append(note); } main(); diff --git a/web/src/search.ts b/web/src/search.ts new file mode 100644 index 0000000..82068d1 --- /dev/null +++ b/web/src/search.ts @@ -0,0 +1,146 @@ +// Hand-written mirror of arr-api's /api/search, /api/roots and POST +// /api/movies schemas — same reasoning as health.ts: the generated client +// (src/api/) is uncommitted, so CI's tsc cannot see it. + +export type SearchInputKind = "text" | "tmdb_id" | "imdb_id" | "magnet" | "torrent_url"; + +export interface LibraryMovie { + id: number; + tmdb_id: number; + title: string; + year: number | null; + original_language: string | null; + root_id: number; + wanted: boolean; + state: "missing" | "grabbed" | "imported"; + blocked: boolean; +} + +export interface TmdbMovie { + tmdb_id: number; + title: string; + original_title: string; + original_language: string; + year: number | null; + overview: string | null; +} + +export interface SearchResponse { + kind: SearchInputKind; + library: LibraryMovie[]; + tmdb: TmdbMovie[]; + manual: string | null; +} + +export interface Root { + id: number; + kind: string; + audience: string; + path: string; + policy_id: number; + policy_name: string; +} + +export type SearchOutcome = + | { kind: "results"; response: SearchResponse } + | { kind: "error"; detail: string } + | { kind: "aborted" }; + +export async function searchTitles(query: string, signal: AbortSignal): Promise { + try { + const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal }); + if (!response.ok) { + const detail = await errorDetail(response); + return { kind: "error", detail }; + } + return { kind: "results", response: (await response.json()) as SearchResponse }; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return { kind: "aborted" }; + } + return { kind: "error", detail: "daemon unreachable" }; + } +} + +let rootsCache: Root[] | null = null; + +/** The movie roots, fetched once. The add flow pre-fills from these. */ +export async function movieRoots(): Promise { + if (rootsCache) { + return rootsCache; + } + const response = await fetch("/api/roots"); + if (!response.ok) { + throw new Error(await errorDetail(response)); + } + const roots = (await response.json()) as Root[]; + rootsCache = roots.filter((root) => root.kind === "movie"); + return rootsCache; +} + +export type AddOutcome = + | { kind: "added"; movie: LibraryMovie } + | { kind: "conflict" } + | { kind: "error"; detail: string }; + +export async function addMovie(movie: TmdbMovie, rootId: number): Promise { + try { + const response = await fetch("/api/movies", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + tmdb_id: movie.tmdb_id, + title: movie.title, + year: movie.year, + original_language: movie.original_language, + root_id: rootId, + }), + }); + if (response.status === 409) { + return { kind: "conflict" }; + } + if (!response.ok) { + return { kind: "error", detail: await errorDetail(response) }; + } + return { kind: "added", movie: (await response.json()) as LibraryMovie }; + } catch { + return { kind: "error", detail: "daemon unreachable" }; + } +} + +async function errorDetail(response: Response): Promise { + try { + const body = (await response.json()) as { error?: string }; + return body.error ?? `http ${response.status}`; + } catch { + return `http ${response.status}`; + } +} + +export interface ManualIntake { + kind: "magnet" | "torrent_url"; + /** The display name a magnet advertises, when it carries one. */ + name: string | null; + /** The btih infohash, when the magnet carries one. */ + infohash: string | null; + raw: string; +} + +/** Best-effort read of a pasted magnet or .torrent URL, for display only. */ +export function parseManualInput(kind: "magnet" | "torrent_url", raw: string): ManualIntake { + if (kind === "torrent_url") { + return { kind, name: raw.split("/").pop() ?? null, infohash: null, raw }; + } + let name: string | null = null; + let infohash: string | null = null; + try { + const params = new URLSearchParams(raw.slice(raw.indexOf("?") + 1)); + name = params.get("dn"); + const urn = params.get("xt") ?? ""; + const match = /^urn:btih:([0-9a-zA-Z]+)$/.exec(urn); + infohash = match?.[1]?.toLowerCase() ?? null; + } catch { + // display-only parsing; a malformed magnet still shows raw + } + return { kind, name, infohash, raw }; +} diff --git a/web/src/style.css b/web/src/style.css index c916b47..a89308c 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -277,6 +277,11 @@ body { align-items: center; } +/* the deck swaps in over the board; flex would beat [hidden] otherwise */ +.board[hidden] { + display: none; +} + .chain { width: 100%; } @@ -467,6 +472,285 @@ body { outline-offset: 2px; } +/* ---- unified search (§9.2) ------------------------------------------- */ + +.rail-search { + position: relative; + flex: 1; + max-width: 30rem; + display: flex; + align-items: center; +} + +.search-input { + width: 100%; + min-height: 2.75rem; + padding: 0 var(--space-8) 0 var(--space-3); + color: var(--ink); + background: var(--ground); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: inset 0 1px var(--lowlight); + caret-color: var(--accent); + transition: border-color 150ms var(--ease-out); +} + +.search-input::placeholder { + color: var(--ink-faint); + opacity: 1; +} + +.search-input:hover { + border-color: var(--line-strong); +} + +.search-input:focus { + border-color: var(--accent); + outline: none; +} + +.search-hint { + position: absolute; + right: var(--space-3); + padding: 0 var(--space-1); + font-size: var(--text-2xs); + color: var(--ink-faint); + border: 1px solid var(--line); + border-radius: var(--radius); + pointer-events: none; +} + +/* ---- search deck ------------------------------------------------------ */ + +.deck { + width: 100%; + max-width: 68rem; + margin: 0 auto; + padding: var(--space-8) var(--space-6) var(--space-12); + flex: 1; +} + +.deck-status { + margin: 0 0 var(--space-4); + color: var(--ink-muted); +} + +.deck-status[data-tone="fault"] { + color: var(--signal-fault); +} + +.deck-group { + margin: 0 0 var(--space-8); +} + +.deck-head { + display: flex; + align-items: baseline; + gap: var(--space-3); + padding: 0 var(--space-1) var(--space-2); + border-bottom: 1px solid var(--line); +} + +/* the engraved micro-label is the heading itself, not a kicker */ +.deck-label { + margin: 0; + font-size: var(--text-2xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--ink-muted); +} + +.deck-count { + font-size: var(--text-xs); + color: var(--ink-faint); +} + +.deck-rows { + margin: 0; + padding: 0; + list-style: none; +} + +.row { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: var(--space-1) var(--space-3); + width: 100%; + padding: var(--space-2) var(--space-1); + border-bottom: 1px solid oklch(from var(--line) l c h / 45%); + text-align: left; +} + +.row-id { + display: inline-flex; + align-items: baseline; + gap: var(--space-2); + min-width: 0; +} + +/* titles are names: the display face, per the Readout Rule in design.json */ +.row-title { + font-family: var(--font-display); + font-size: var(--text-md); + font-weight: 600; + letter-spacing: 0.02em; + color: var(--ink); +} + +.row-year { + font-size: var(--text-xs); +} + +.row-chips { + display: inline-flex; + align-items: center; + gap: var(--space-1); + margin-left: auto; +} + +.chip { + display: inline-flex; + align-items: center; + gap: var(--space-1); + min-height: 1.375rem; + padding: 0 var(--space-2); + background: var(--panel-raised); + border: 1px solid var(--line); + border-radius: var(--radius); + font-family: var(--font-readout); + font-size: var(--text-xs); + color: var(--ink-muted); +} + +.chip[data-verdict="eligible"] { + color: var(--verdict-eligible); + border-color: oklch(from var(--verdict-eligible) l c h / 45%); +} + +.chip[data-movie-state="imported"] { + color: var(--ink); +} + +/* a TMDB row is the affordance: the whole row opens the add flow */ +.row-tmdb { + font: inherit; + background: none; + border: 0; + border-bottom: 1px solid oklch(from var(--line) l c h / 45%); + cursor: pointer; + transition: background 150ms var(--ease-out); +} + +.row-tmdb:hover:not(:disabled), +.row-tmdb:focus-visible { + background: var(--panel); +} + +.row-tmdb:disabled { + cursor: default; +} + +.row-add { + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent); +} + +.row-overview { + flex-basis: 100%; + overflow: hidden; + font-size: var(--text-xs); + color: var(--ink-faint); + white-space: nowrap; + text-overflow: ellipsis; +} + +/* ---- add flow (inline, root and policy pre-filled) -------------------- */ + +.add-panel { + margin: 0 0 var(--space-2); + padding: var(--space-3) var(--space-3) var(--space-3); + background: var(--panel); + border: 1px solid var(--line); + border-top: 0; + border-radius: 0 0 var(--radius) var(--radius); + box-shadow: inset 0 1px var(--highlight); +} + +.add-roots { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: var(--space-2); +} + +.root-option { + display: grid; + gap: var(--space-1); + padding: var(--space-2) var(--space-3); + font: inherit; + text-align: left; + color: var(--ink); + background: var(--panel-raised); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: inset 0 1px var(--highlight); + cursor: pointer; + transition: border-color 150ms var(--ease-out); +} + +.root-option:hover { + border-color: var(--line-strong); +} + +.root-option[aria-pressed="true"] { + border-color: var(--accent); +} + +.root-audience { + font-family: var(--font-display); + font-size: var(--text-md); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + line-height: 1; +} + +.root-policy, +.root-path { + font-size: var(--text-xs); +} + +.add-actions { + display: flex; + align-items: center; + gap: var(--space-3); + margin: var(--space-3) 0 0; +} + +.add-note { + margin: 0; + font-size: var(--text-xs); + color: var(--ink-muted); +} + +.add-note[data-tone="fault"] { + color: var(--signal-fault); +} + +.add-done { + margin: 0; + color: var(--verdict-eligible); +} + +/* ---- manual intake ---------------------------------------------------- */ + +.intake .module-detail { + overflow-wrap: anywhere; +} + /* ---- small screens --------------------------------------------------- */ @media (max-width: 46rem) { @@ -480,6 +764,21 @@ body { order: 3; } + .rail-search { + order: 4; + flex-basis: 100%; + max-width: none; + } + + .deck { + padding: var(--space-6) var(--space-4) var(--space-8); + } + + .row-chips { + margin-left: 0; + flex-basis: 100%; + } + .board { padding: var(--space-6) var(--space-4); } From b5413eef467f2d853e704be1df7eefba1e22aa9c Mon Sep 17 00:00:00 2001 From: "naps62-yolo (agent)" Date: Sat, 22 Aug 2026 22:12:17 +0100 Subject: [PATCH 3/3] Use policy scoring weights in release search (#74) --- ...11ab88e1fdcc28c432da4c3debf1202eee7a.json} | 10 +- crates/arr-api/src/search.rs | 159 +++++++++++++----- 2 files changed, 123 insertions(+), 46 deletions(-) rename .sqlx/{query-b78bef6253289702123c03afcab624d5d50df897af4a477a9cf3dac196b52ecc.json => query-6e988f7e8d8ac2167c589a35626b11ab88e1fdcc28c432da4c3debf1202eee7a.json} (83%) diff --git a/.sqlx/query-b78bef6253289702123c03afcab624d5d50df897af4a477a9cf3dac196b52ecc.json b/.sqlx/query-6e988f7e8d8ac2167c589a35626b11ab88e1fdcc28c432da4c3debf1202eee7a.json similarity index 83% rename from .sqlx/query-b78bef6253289702123c03afcab624d5d50df897af4a477a9cf3dac196b52ecc.json rename to .sqlx/query-6e988f7e8d8ac2167c589a35626b11ab88e1fdcc28c432da4c3debf1202eee7a.json index 04ae29f..f613aaf 100644 --- a/.sqlx/query-b78bef6253289702123c03afcab624d5d50df897af4a477a9cf3dac196b52ecc.json +++ b/.sqlx/query-6e988f7e8d8ac2167c589a35626b11ab88e1fdcc28c432da4c3debf1202eee7a.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT m.title AS \"title!: String\", m.tmdb_id AS \"tmdb_id!: i64\", m.original_language, m.overrides AS \"overrides!: serde_json::Value\", 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\" FROM movies m JOIN roots r ON r.id = m.root_id JOIN policies p ON p.id = r.policy_id WHERE m.id = ?", + "query": "SELECT m.title AS \"title!: String\", m.tmdb_id AS \"tmdb_id!: i64\", m.original_language, m.overrides AS \"overrides!: serde_json::Value\", 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 movies m JOIN roots r ON r.id = m.root_id JOIN policies p ON p.id = r.policy_id WHERE m.id = ?", "describe": { "columns": [ { @@ -62,6 +62,11 @@ "name": "source_weights!: String", "ordinal": 11, "type_info": "Text" + }, + { + "name": "score_weights!: String", + "ordinal": 12, + "type_info": "Text" } ], "parameters": { @@ -79,8 +84,9 @@ false, false, false, + false, false ] }, - "hash": "b78bef6253289702123c03afcab624d5d50df897af4a477a9cf3dac196b52ecc" + "hash": "6e988f7e8d8ac2167c589a35626b11ab88e1fdcc28c432da4c3debf1202eee7a" } diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 8745d56..e681ecf 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use std::time::UNIX_EPOCH; use arr_core::policy::{evaluate, Candidate}; +use arr_core::score::score; use arr_core::{ DolbyVisionProfile, HdrRules, Language, Policy, PolicyId, RequiredAudio, Resolution, Rule, ScoreWeights, SizeBand, Source, TitleOverrides, Verdict, @@ -80,6 +81,7 @@ struct PolicyRow { size_bands: String, resolution_pref: String, source_weights: String, + score_weights: String, } #[derive(Debug, Deserialize)] @@ -102,6 +104,13 @@ struct SizeBandJson { penalty_points_per_gib_over: i32, } +#[derive(Debug, Deserialize)] +struct ScoreWeightsJson { + size_at_target: i32, + source_tier: i32, + seeder_doubling: i32, +} + #[derive(Debug, Deserialize)] struct OverridesJson { #[serde(default)] @@ -254,7 +263,7 @@ pub async fn releases( Query(query): Query, ) -> Result>, ApiError> { let database = state.database().ok_or(ApiError::Unavailable)?; - let movie = sqlx::query!(r#"SELECT m.title AS "title!: String", m.tmdb_id AS "tmdb_id!: i64", m.original_language, m.overrides AS "overrides!: serde_json::Value", 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" FROM movies m JOIN roots r ON r.id = m.root_id JOIN policies p ON p.id = r.policy_id WHERE m.id = ?"#, query.movie_id) + let movie = sqlx::query!(r#"SELECT m.title AS "title!: String", m.tmdb_id AS "tmdb_id!: i64", m.original_language, m.overrides AS "overrides!: serde_json::Value", 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 movies m JOIN roots r ON r.id = m.root_id JOIN policies p ON p.id = r.policy_id WHERE m.id = ?"#, query.movie_id) .fetch_optional(database.pool()).await?.ok_or(ApiError::NotFound)?; let tmdb = tmdb_client(&state)? @@ -290,6 +299,7 @@ pub async fn releases( size_bands: movie.size_bands, resolution_pref: movie.resolution_pref, source_weights: movie.source_weights, + score_weights: movie.score_weights, })?; let overrides: OverridesJson = serde_json::from_value(movie.overrides) .map_err(|error| ApiError::Database(error.to_string()))?; @@ -405,6 +415,7 @@ fn policy_from_row(row: PolicyRow) -> Result { let bands: BTreeMap = json(&row.size_bands)?; let resolutions: Vec = json(&row.resolution_pref)?; let weights: BTreeMap = json(&row.source_weights)?; + let score_weights: ScoreWeightsJson = json(&row.score_weights)?; Ok(Policy { id: PolicyId(row.policy_id), name: row.policy_name, @@ -451,9 +462,11 @@ fn policy_from_row(row: PolicyRow) -> Result { .into_iter() .filter_map(|(source, weight)| source_value(&source).map(|source| (source, weight))) .collect(), - // The row's score_weights column is not selected here yet; the seeded - // values match these defaults. Wiring the column through is #68. - score_weights: ScoreWeights::default(), + score_weights: ScoreWeights { + size_at_target: score_weights.size_at_target, + source_tier: score_weights.source_tier, + seeder_doubling: score_weights.seeder_doubling, + }, }) } @@ -472,7 +485,17 @@ fn classify( release.size, ); let (verdict, rule) = verdict(&evaluation.verdict); - let score = score(policy, &parsed, release.size, release.seeders); + let score = score( + policy, + Candidate::PreGrab(&parsed), + release.size.unwrap_or_default(), + release.seeders.unwrap_or_default(), + ); + let score = if release.size.is_some() { + score.total + } else { + score.source.saturating_add(score.seeders) + }; Ok(ClassifiedRelease { indexer_id: release.indexer_id, guid: release.guid, @@ -495,44 +518,6 @@ fn classify( }) } -fn score( - policy: &Policy, - parsed: &arr_parse::NameClaims, - size: Option, - seeders: Option, -) -> i64 { - let resolution = parsed.resolution.map(Resolution::from); - let resolution_score = resolution - .and_then(|value| { - policy - .resolution_preference - .iter() - .position(|candidate| *candidate == value) - }) - .map_or(0, |position| { - 10_000 - i64::try_from(position).unwrap_or(0) * 5_000 - }); - let size_score = resolution - .and_then(|value| policy.size_bands.get(&value)) - .zip(size) - .map_or(0, |(band, bytes)| { - if bytes < band.floor_bytes { - -i64::try_from((band.floor_bytes - bytes) / 100_000_000).unwrap_or(i64::MAX) - } else { - -i64::try_from(bytes.abs_diff(band.target_bytes) / 100_000_000).unwrap_or(i64::MAX) - } - }); - let source_score = i64::from( - parsed - .source - .map(Source::from) - .and_then(|source| policy.source_weights.get(&source).copied()) - .unwrap_or(0), - ) * 5; - let seeder_score = seeders.map_or(0, |count| i64::from(count.saturating_add(1).ilog2()) * 2); - resolution_score + size_score + source_score + seeder_score -} - fn verdict(verdict: &Verdict) -> (&'static str, Option) { match verdict { Verdict::Eligible => ("eligible", None), @@ -729,6 +714,12 @@ mod tests { let (_dir, state, base) = application(&tmdb, &prowlarr).await; sqlx::query("INSERT INTO movies (tmdb_id, title, year, original_language, root_id) VALUES (693134, 'Dune Part Two', 2024, 'en', 2)") .execute(state.database().expect("database").pool()).await.expect("movie"); + sqlx::query( + "UPDATE policies SET score_weights = '{\"size_at_target\":0,\"source_tier\":0,\"seeder_doubling\":0}'", + ) + .execute(state.database().expect("database").pool()) + .await + .expect("score weights"); let response = reqwest::get(format!("{base}/api/releases?movie_id=1")) .await @@ -744,7 +735,87 @@ mod tests { .find(|release| release["verdict"] == "rejected") .expect("rejected"); assert_eq!(rejected["rule"], "source"); - assert!(releases[0]["score"].as_i64().is_some()); + let eligible = releases + .iter() + .find(|release| release["guid"] == "good") + .expect("eligible"); + assert_eq!(eligible["score"], 0); + } + + #[test] + fn policy_row_uses_persisted_score_weights() { + let policy = policy_from_row(PolicyRow { + policy_id: 1, + policy_name: "test".into(), + required_audio: r#"{"require":"original_language"}"#.into(), + dub_blacklist: "[]".into(), + hdr_rules: "{}".into(), + size_bands: + r#"{"2160p":{"floor_gib":8,"target_gib":22,"penalty_points_per_gib_over":60}}"# + .into(), + resolution_pref: r#"["2160p"]"#.into(), + source_weights: r#"{"WEB-DL":2}"#.into(), + score_weights: r#"{"size_at_target":2000,"source_tier":7,"seeder_doubling":11}"#.into(), + }) + .expect("policy row"); + + assert_eq!( + policy.score_weights, + ScoreWeights { + size_at_target: 2000, + source_tier: 7, + seeder_doubling: 11, + } + ); + } + + #[test] + fn releases_without_sizes_skip_the_size_score() { + let policy = Policy { + id: PolicyId(1), + name: "test".into(), + required_audio: RequiredAudio::OriginalLanguage, + dub_blacklist: Vec::new(), + hdr_rules: HdrRules { + rejected_dolby_vision_profiles: Vec::new(), + }, + size_bands: std::collections::BTreeMap::from([( + Resolution::R2160p, + SizeBand { + floor_bytes: gib(8), + target_bytes: gib(22), + penalty_points_per_gib_over: 60, + }, + )]), + resolution_preference: vec![Resolution::R2160p], + source_weights: std::collections::BTreeMap::from([(Source::WebDl, 2)]), + score_weights: ScoreWeights::default(), + }; + let release = SearchRelease { + indexer_id: 1, + guid: "release".into(), + name: "Dune.Part.Two.2024.2160p.WEB-DL".into(), + size: None, + seeders: Some(8), + publish_date: None, + download_url: "https://tracker/release".into(), + }; + let parsed = arr_parse::parse(&release.name); + let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8); + + let classified = classify( + release, + &policy, + &MovieOverrides::default(), + &Language::Other("en".into()), + ) + .expect("classified release"); + + assert_eq!( + classified.score, + core_score.source.saturating_add(core_score.seeders) + ); + assert_ne!(classified.score, core_score.total); } #[test]