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..f4d1ccc --- /dev/null +++ b/crates/arr-api/src/roots.rs @@ -0,0 +1,80 @@ +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"); + // Two movie roots and, since #71, two TV roots. + assert_eq!(roots.len(), 4, "every seeded root: {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/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index f2414aa..2d6154b 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -1,7 +1,8 @@ use std::time::UNIX_EPOCH; use arr_core::policy::{evaluate, Candidate}; -use arr_core::{Language, MovieOverrides, Policy, Resolution, Rule, Source, Verdict}; +use arr_core::score::score; +use arr_core::{Language, Policy, Rule, TitleOverrides, Verdict}; use arr_db::policy::language; use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest}; use axum::extract::{Query, State}; @@ -350,7 +351,7 @@ fn upstream_error(error: &arr_meta::Error) -> ApiError { fn classify( release: SearchRelease, policy: &Policy, - overrides: &MovieOverrides, + overrides: &TitleOverrides, original_language: &Language, ) -> Result { let parsed = arr_parse::parse(&release.name); @@ -362,7 +363,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, @@ -385,44 +396,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), @@ -465,6 +438,8 @@ fn title_language(value: &str, origin_countries: &[String]) -> Language { #[cfg(test)] mod tests { + use arr_core::{HdrRules, PolicyId, RequiredAudio, Resolution, ScoreWeights, SizeBand, Source}; + use super::*; use crate::{router, Upstreams}; use wiremock::matchers::{method, path, query_param}; @@ -587,6 +562,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 @@ -602,7 +583,60 @@ 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 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: 8 << 30, + target_bytes: 22 << 30, + 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, + &TitleOverrides::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] 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-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs index a7fd016..63928aa 100644 --- a/crates/arr-daemon/src/grab.rs +++ b/crates/arr-daemon/src/grab.rs @@ -15,10 +15,10 @@ use std::collections::HashMap; use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use arr_core::policy::{evaluate, Candidate}; -use arr_core::{score::score, Language, MovieOverrides, Policy, Rule, Verdict}; +use arr_core::{score::score, Language, Policy, Rule, TitleOverrides, Verdict}; use arr_db::{Db, MoviePolicy}; use arr_dl::{AddTorrent, TorrentSource, TransmissionClient}; use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest}; @@ -31,6 +31,16 @@ use crate::reconcile::{Action, ActionFuture, Outcome}; /// and release-date gating are issue #26. const MOVIES_PER_TICK: i64 = 5; +/// How long a discovered indexer list is reused. Prowlarr enumerates one +/// indexer per call plus a `t=caps` probe each, all sequential, so paying for +/// it every 30 s would leave the tick no room to search. +const INDEXER_CACHE_TTL: Duration = Duration::from_mins(15); + +/// The share of the reconcile lane's 25 s budget discovery may spend. One +/// unresponsive tracker must not cancel the whole action before a single +/// targeted search has run; past this the last known list is used instead. +const INDEXER_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(8); + /// Seeding obligations, per tracker in principle (§7.3) and per install in /// practice until issue #25 gives them a home. Both are set on the torrent at /// add time and enforced by Transmission. @@ -56,6 +66,15 @@ pub enum GrabError { name: String, source: serde_json::Error, }, + #[error("indexer discovery timed out after {0:?} and none is known yet")] + IndexerDiscoveryTimeout(Duration), +} + +/// The searchable indexer ids, and when they were last discovered. +#[derive(Debug, Default)] +struct IndexerCache { + ids: Vec, + refreshed_at: Option, } /// Sends the best eligible release for every wanted movie that has neither a @@ -66,6 +85,10 @@ pub struct GrabAction { transmission: TransmissionClient, download_dir: PathBuf, seeding: SeedingLimits, + indexers: tokio::sync::RwLock, + /// [`INDEXER_DISCOVERY_TIMEOUT`], overridden by tests that cannot wait + /// out the real one. Mirrors `ReconcileLoop`'s action timeout override. + discovery_timeout: Duration, } impl GrabAction { @@ -81,6 +104,8 @@ impl GrabAction { transmission, download_dir, seeding, + indexers: tokio::sync::RwLock::new(IndexerCache::default()), + discovery_timeout: INDEXER_DISCOVERY_TIMEOUT, } } @@ -91,12 +116,7 @@ impl GrabAction { return Ok(outcomes); } - let indexers = self.prowlarr.indexers().await?; - let searchable: Vec = indexers - .iter() - .filter(|indexer| indexer.capabilities.search.available) - .map(|indexer| indexer.id) - .collect(); + let searchable = self.searchable_indexers().await?; if searchable.is_empty() { tracing::warn!("no indexer advertises a text search; nothing can be grabbed"); return Ok(outcomes); @@ -173,6 +193,51 @@ impl GrabAction { Ok(outcomes) } + /// The indexers that accept a text search, cached across ticks. + /// + /// Discovery is bounded and its result is reused, because Prowlarr probes + /// capabilities one indexer at a time: a single slow tracker would + /// otherwise burn the whole reconcile budget before any search runs. A + /// refresh that fails or times out keeps the previous list rather than + /// stopping the tick, and only an empty cache turns that into an error. + async fn searchable_indexers(&self) -> Result, GrabError> { + { + let cache = self.indexers.read().await; + if cache + .refreshed_at + .is_some_and(|at| at.elapsed() < INDEXER_CACHE_TTL) + { + return Ok(cache.ids.clone()); + } + } + + let discovered = + tokio::time::timeout(self.discovery_timeout, self.prowlarr.indexers()).await; + let mut cache = self.indexers.write().await; + match discovered { + Ok(Ok(indexers)) => { + cache.ids = indexers + .iter() + .filter(|indexer| indexer.capabilities.search.available) + .map(|indexer| indexer.id) + .collect(); + cache.refreshed_at = Some(Instant::now()); + } + Ok(Err(error)) if cache.ids.is_empty() => return Err(error.into()), + Err(_) if cache.ids.is_empty() => { + return Err(GrabError::IndexerDiscoveryTimeout(self.discovery_timeout)) + } + Ok(Err(error)) => { + tracing::warn!(%error, "indexer discovery failed; using the last known list"); + } + Err(_) => tracing::warn!( + timeout_seconds = self.discovery_timeout.as_secs(), + "indexer discovery timed out; using the last known list" + ), + } + Ok(cache.ids.clone()) + } + /// Search every indexer for one title, cache each candidate with its /// verdict and score (§9.3), and return the eligible ones best first. /// @@ -404,7 +469,7 @@ async fn store_release( movie_id: i64, release: &SearchRelease, policy: &Policy, - overrides: &MovieOverrides, + overrides: &TitleOverrides, original_language: &Language, ) -> Result, GrabError> { let parsed = arr_parse::parse(&release.name); @@ -415,13 +480,20 @@ async fn store_release( Candidate::PreGrab(&parsed), release.size, ); - let score = score( + let scored = score( policy, Candidate::PreGrab(&parsed), - release.size.unwrap_or(0), - release.seeders.unwrap_or(0), - ) - .total; + release.size.unwrap_or_default(), + release.seeders.unwrap_or_default(), + ); + // A release that did not say its size is not a tiny one: scoring it + // against the band's floor would bury it. Same treatment as the manual + // search view, so the ranking the operator sees is the one that picks. + let score = if release.size.is_some() { + scored.total + } else { + scored.source.saturating_add(scored.seeders) + }; let (verdict, rule) = verdict_columns(&evaluation.verdict); let parsed_json = serde_json::to_string(&parsed).map_err(|source| GrabError::Parsed { name: release.name.clone(), @@ -936,6 +1008,57 @@ mod tests { assert!(grabs(&database).await.is_empty()); } + /// Prowlarr probes capabilities one indexer at a time, so paying for + /// discovery every 30 s would leave the tick no room to search. + #[tokio::test] + async fn indexer_discovery_is_cached_across_ticks() { + let (_dir, database) = wanted_movie().await; + let indexer = prowlarr().await; + let (downloader, _fake) = transmission().await; + let action = action(&indexer, &downloader); + + action.tick(&database).await.unwrap(); + action.tick(&database).await.unwrap(); + + let enumerations = indexer + .received_requests() + .await + .unwrap() + .into_iter() + .filter(|request| request.url.path() == "/api/v1/indexer") + .count(); + assert_eq!(enumerations, 1); + } + + /// One unresponsive tracker must not cancel the action before a single + /// search has run. + #[tokio::test] + async fn a_stalled_discovery_gives_up_instead_of_hanging() { + let (_dir, database) = wanted_movie().await; + let indexer = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/indexer")) + .respond_with( + ResponseTemplate::new(200) + .set_delay(Duration::from_secs(30)) + .set_body_json(json!([])), + ) + .mount(&indexer) + .await; + let (downloader, fake) = transmission().await; + + let mut action = action(&indexer, &downloader); + action.discovery_timeout = Duration::from_millis(50); + + let error = action + .tick(&database) + .await + .expect_err("discovery cannot complete"); + + assert!(matches!(error, GrabError::IndexerDiscoveryTimeout(_))); + assert!(fake.torrents().is_empty()); + } + #[test] fn indexer_links_and_magnets_both_reach_transmission() { assert_eq!( 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 3f8525e..139f2e2 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -160,13 +160,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] @@ -204,7 +204,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); @@ -251,4 +251,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"); + } } diff --git a/crates/arr-db/src/policy.rs b/crates/arr-db/src/policy.rs index 4d88dcc..9111d0d 100644 --- a/crates/arr-db/src/policy.rs +++ b/crates/arr-db/src/policy.rs @@ -8,8 +8,8 @@ use std::collections::BTreeMap; use arr_core::{ - DolbyVisionProfile, HdrRules, Language, MovieOverrides, Policy, PolicyId, RequiredAudio, - Resolution, ScoreWeights, SizeBand, Source, + DolbyVisionProfile, HdrRules, Language, Policy, PolicyId, RequiredAudio, Resolution, + ScoreWeights, SizeBand, Source, TitleOverrides, }; use serde::Deserialize; @@ -36,7 +36,7 @@ pub enum PolicyError { pub struct MoviePolicy { pub policy: Policy, /// Per-title relaxations and tightenings of the root policy (§5.1). - pub overrides: MovieOverrides, + pub overrides: TitleOverrides, pub root_id: i64, /// `movie` or `tv`. pub root_kind: String, @@ -186,7 +186,7 @@ impl Db { Ok(Some(MoviePolicy { policy, - overrides: MovieOverrides { + overrides: TitleOverrides { only_4k: overrides.only_4k, allow_english_audio: overrides.allow_english_audio, }, @@ -294,7 +294,8 @@ mod tests { db.migrate().await.unwrap(); sqlx::query( "INSERT INTO movies (tmdb_id, title, root_id) - SELECT 693134, 'Dune Part Two', id FROM roots WHERE audience = 'main'", + SELECT 693134, 'Dune Part Two', id + FROM roots WHERE kind = 'movie' AND audience = 'main'", ) .execute(db.pool()) .await @@ -322,7 +323,7 @@ mod tests { loaded.policy.resolution_preference, vec![Resolution::R2160p, Resolution::R1080p] ); - assert_eq!(loaded.overrides, MovieOverrides::default()); + assert_eq!(loaded.overrides, TitleOverrides::default()); } /// §5.5: the scoring numbers are policy rows, so the loader reads them @@ -362,6 +363,37 @@ mod tests { assert!(!loaded.overrides.allow_english_audio); } + /// The mapping is usable without a database, which is how the API's + /// classifier and the daemon's selection stay on the same numbers. + #[test] + fn columns_map_without_a_row() { + let policy = PolicyColumns { + id: 1, + 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(), + } + .to_policy() + .unwrap(); + + assert_eq!( + policy.score_weights, + ScoreWeights { + size_at_target: 2000, + source_tier: 7, + seeder_doubling: 11, + } + ); + assert_eq!(policy.source_weights[&Source::WebDl], 2); + } + #[tokio::test] async fn an_unknown_movie_is_not_an_error() { let (_dir, db) = seeded().await; 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); }