diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 3da4d44..65396b2 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -115,3 +115,7 @@ jobs: - name: typecheck if: steps.probe.outputs.present == 'true' run: pnpm -C web exec tsc -b --noEmit + + - name: design tokens + if: steps.probe.outputs.present == 'true' + run: pnpm -C web run check-tokens diff --git a/.gitea/workflows/e2e.yml b/.gitea/workflows/e2e.yml index 6a6275b..7175982 100644 --- a/.gitea/workflows/e2e.yml +++ b/.gitea/workflows/e2e.yml @@ -11,7 +11,9 @@ on: - 'crates/arr-e2e/**' - 'crates/arr-dl/**' - 'crates/arr-indexer/**' + - 'crates/arr-meta/**' - 'crates/arr-probe/**' + - 'crates/arr-api/**' - 'crates/arr-daemon/**' - '.gitea/workflows/e2e.yml' @@ -68,6 +70,11 @@ jobs: curl -LsSf https://get.nexte.st/latest/linux \ | tar zxf - -C "$HOME/.cargo/bin" + - name: build daemon + # The harness spawns this binary; building it here keeps the compile + # out of the first test's boot window. + run: cargo build -p arr-daemon --bin arr + - name: e2e env: TRANSMISSION_RPC_URL: http://transmission:9091/transmission/rpc diff --git a/.impeccable/design.json b/.impeccable/design.json index 9b7812e..4e738ce 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -42,6 +42,30 @@ "displayName": "Phosphor Cyan", "canonical": "oklch(0.8 0.12 220)", "tonalRamp": ["oklch(0.5 0.1 220)", "oklch(0.65 0.11 220)", "oklch(0.8 0.12 220)", "oklch(0.9 0.08 220)"] + }, + "verdict-eligible": { + "role": "secondary", + "displayName": "Eligible Green", + "canonical": "oklch(0.8 0.17 150)", + "tonalRamp": ["oklch(0.45 0.1 150)", "oklch(0.62 0.14 150)", "oklch(0.8 0.17 150)", "oklch(0.88 0.12 150)"] + }, + "verdict-waived": { + "role": "tertiary", + "displayName": "Waiver Amber", + "canonical": "oklch(0.82 0.14 80)", + "tonalRamp": ["oklch(0.5 0.1 80)", "oklch(0.66 0.13 80)", "oklch(0.82 0.14 80)", "oklch(0.9 0.1 80)"] + }, + "verdict-rejected": { + "role": "neutral", + "displayName": "Rejected Slate", + "canonical": "oklch(0.68 0.02 250)", + "tonalRamp": ["oklch(0.42 0.02 250)", "oklch(0.55 0.02 250)", "oklch(0.68 0.02 250)", "oklch(0.8 0.015 250)"] + }, + "status-channel": { + "role": "tertiary", + "displayName": "Channel Violet", + "canonical": "oklch(0.78 0.14 305)", + "tonalRamp": ["oklch(0.64 0.045 305)", "oklch(0.72 0.09 305)", "oklch(0.78 0.14 305)", "oklch(0.86 0.09 305)"] } }, "typographyMeta": { @@ -56,6 +80,10 @@ "readout": { "displayName": "Readout (mono)", "purpose": "ui-monospace stack — every value the operator reads: status words, details, version, rtt. Mono is for measurement, never costume." + }, + "scale": { + "displayName": "Type scale", + "purpose": "Seven fixed steps as tokens: --text-2xs 0.6875rem (micro-labels), --text-xs 0.75rem (details, chips), --text-sm 0.8125rem (readouts, controls), --text-base 1rem (body), --text-md 1.0625rem (module names), --text-lg 1.3125rem (master module), --text-xl 1.625rem (wordmark). Components never carry a literal font-size." } }, "shadows": [ @@ -104,7 +132,31 @@ "refersTo": "signal-ok", "description": "Status lamp. Hue by state (ok/degraded/unconfigured/unreachable), glow is its own light. data-state=\"off\" is dead air: dark, no glow, no motion. Always paired with a status word — remove color and the state must survive.", "html": "", - "css": ".lamp { --lamp: var(--signal-idle); width: 0.625rem; height: 0.625rem; border-radius: 50%; background: var(--lamp); box-shadow: 0 0 6px var(--lamp), 0 0 14px oklch(from var(--lamp) l c h / 35%); } .lamp[data-state=\"ok\"] { --lamp: var(--signal-ok); } .lamp[data-state=\"off\"] { --lamp: oklch(0.3 0.012 250); box-shadow: none; }" + "css": ".lamp { --lamp: var(--signal-idle); width: 0.625rem; height: 0.625rem; border-radius: 50%; background: var(--lamp); box-shadow: 0 0 6px var(--lamp), 0 0 14px oklch(from var(--lamp) l c h / 35%); } .lamp[data-state=\"ok\"] { --lamp: var(--signal-ok); } .lamp[data-state=\"off\"] { --lamp: var(--lamp-off); box-shadow: none; }" + }, + { + "name": "Chip", + "kind": "custom", + "refersTo": "ground", + "description": "The §9.3 column unit: a small machined panel holding one mono value (score, resolution, source, HDR, audio, size, seeders). Fixed rhythm so columns align; never a soft pill.", + "html": "2160p", + "css": ".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); }" + }, + { + "name": "Verdict chip", + "kind": "custom", + "refersTo": "verdict-eligible", + "description": "Release classification (§9.3): eligible / waived / rejected. Hue + border treatment + the word, so the three buckets survive with colour removed — eligible is a solid tinted border, waived a dashed border (the visible mark of a relaxed rule), rejected is quiet slate and always carries the killing rule's name. Rejected is the policy working, never fault red.", + "html": "waived · pt missing", + "css": ".chip[data-verdict=\"eligible\"] { color: var(--verdict-eligible); border-color: oklch(from var(--verdict-eligible) l c h / 45%); } .chip[data-verdict=\"waived\"] { color: var(--verdict-waived); border-style: dashed; border-color: oklch(from var(--verdict-waived) l c h / 55%); } .chip[data-verdict=\"rejected\"] { color: var(--verdict-rejected); }" + }, + { + "name": "Status chip", + "kind": "custom", + "refersTo": "status-channel", + "description": "Library status (§4.2): airing / incomplete / waiting / complete / ended. Informational, not severity — colour marks activity in one channel-violet family (airing brightest, incomplete, waiting dimmest), satisfaction is neutral (complete bright ink, ended faint). The mono word is always present.", + "html": "airing", + "css": ".chip[data-status=\"airing\"] { color: var(--status-airing); border-color: oklch(from var(--status-airing) l c h / 45%); } .chip[data-status=\"incomplete\"] { color: var(--status-incomplete); } .chip[data-status=\"waiting\"] { color: var(--status-waiting); } .chip[data-status=\"complete\"] { color: var(--status-complete); } .chip[data-status=\"ended\"] { color: var(--status-ended); }" }, { "name": "Module (channel strip)", @@ -170,6 +222,21 @@ "body": "Depth is one inset 1px highlight on a hairline-bordered panel. No drop shadows, no glass, no gradients. The only glow on the board is a lamp's own light.", "section": "elevation" }, + { + "name": "The Verdict Rule", + "body": "Release verdicts are eligible green, waiver amber, rejected slate. Rejected is the policy engine doing its job, so it is quiet neutral — fault red stays reserved for the daemon's own failures. Each bucket also carries a non-colour mark: solid tinted border (eligible), dashed border (waived), the killing rule's name (rejected).", + "section": "colors" + }, + { + "name": "The Status-Is-Not-Severity Rule", + "body": "The five library statuses are informational, never an error/warning/success ramp. Activity lives in one channel-violet family (airing > incomplete > waiting, by lightness and chroma); satisfaction is neutral (complete bright ink, ended faint). Complete is never green; incomplete is never amber.", + "section": "colors" + }, + { + "name": "The Token Rule", + "body": "Components consume the custom properties declared in :root of web/src/style.css — never a literal colour, margin, padding or gap. web/scripts/check-tokens.mjs enforces this in CI; relative-colour derivation from a token (oklch(from var(--x) …)) counts as consuming the token.", + "section": "colors" + }, { "name": "The Dead-Air Rule", "body": "When nothing is flowing, nothing animates. Pulse means probing; data-state=\"off\" is dark and still. Motion that misreports activity is a truth bug.", @@ -180,9 +247,12 @@ "Do lay future surfaces (manual search buckets, queues) out as panels and readouts consuming these tokens; chips in §9.3 are small panels with mono values.", "Do keep every control ≥44px, focus rings cyan and visible, and reduced-motion honored.", "Do keep muted text at or above 4.5:1 on panel ground (--ink-muted floor).", - "Do self-host every asset; the SPA is embedded in the binary and must work with no network." + "Do self-host every asset; the SPA is embedded in the binary and must work with no network.", + "Do take every colour, spacing and font-size from the :root tokens; the token check (web/scripts/check-tokens.mjs) fails CI on literals." ], "donts": [ + "Don't colour `complete` green or `incomplete` amber — library status is informational, not a severity ramp.", + "Don't use fault red for a rejected release; red means the daemon itself is broken.", "Don't introduce a second interactive hue or use cyan for a status.", "Don't box content in soft-shadowed rounded cards or add glass/blur.", "Don't put a kicker/eyebrow label above a heading.", diff --git a/.sqlx/query-139c1c790f1fd1c09ae2edcffb5568e3e48c0b7a8f593063286c8d8c68159105.json b/.sqlx/query-139c1c790f1fd1c09ae2edcffb5568e3e48c0b7a8f593063286c8d8c68159105.json new file mode 100644 index 0000000..a90671c --- /dev/null +++ b/.sqlx/query-139c1c790f1fd1c09ae2edcffb5568e3e48c0b7a8f593063286c8d8c68159105.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at FROM movies WHERE title LIKE ? ESCAPE '\\' ORDER BY title, year, id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tmdb_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "original_language", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "root_id!: i64", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "wanted!: bool", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "overrides!: serde_json::Value", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "state!: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "blocked!: bool", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "search_attempts!: i64", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "last_searched_at", + "ordinal": 11, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "139c1c790f1fd1c09ae2edcffb5568e3e48c0b7a8f593063286c8d8c68159105" +} diff --git a/.sqlx/query-8617bd44229736609d860935f22413cc21e26c9cace9a0e2180cca42219cfce3.json b/.sqlx/query-8617bd44229736609d860935f22413cc21e26c9cace9a0e2180cca42219cfce3.json new file mode 100644 index 0000000..14d3224 --- /dev/null +++ b/.sqlx/query-8617bd44229736609d860935f22413cc21e26c9cace9a0e2180cca42219cfce3.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at FROM movies WHERE tmdb_id = ? OR title LIKE ? ESCAPE '\\' ORDER BY title, year, id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tmdb_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "original_language", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "root_id!: i64", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "wanted!: bool", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "overrides!: serde_json::Value", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "state!: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "blocked!: bool", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "search_attempts!: i64", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "last_searched_at", + "ordinal": 11, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "8617bd44229736609d860935f22413cc21e26c9cace9a0e2180cca42219cfce3" +} diff --git a/.sqlx/query-b78bef6253289702123c03afcab624d5d50df897af4a477a9cf3dac196b52ecc.json b/.sqlx/query-b78bef6253289702123c03afcab624d5d50df897af4a477a9cf3dac196b52ecc.json new file mode 100644 index 0000000..04ae29f --- /dev/null +++ b/.sqlx/query-b78bef6253289702123c03afcab624d5d50df897af4a477a9cf3dac196b52ecc.json @@ -0,0 +1,86 @@ +{ + "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 = ?", + "describe": { + "columns": [ + { + "name": "title!: String", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "tmdb_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "original_language", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "overrides!: serde_json::Value", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "policy_id!: i64", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "policy_name!: String", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "required_audio!: String", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "dub_blacklist!: String", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "hdr_rules!: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "size_bands!: String", + "ordinal": 9, + "type_info": "Text" + }, + { + "name": "resolution_pref!: String", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "source_weights!: String", + "ordinal": 11, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "b78bef6253289702123c03afcab624d5d50df897af4a477a9cf3dac196b52ecc" +} diff --git a/.sqlx/query-cba9bc8a941c3830d5fdfa3c76bbf336dfd13920d5439a4a77b92220c62f9526.json b/.sqlx/query-cba9bc8a941c3830d5fdfa3c76bbf336dfd13920d5439a4a77b92220c62f9526.json new file mode 100644 index 0000000..a693b15 --- /dev/null +++ b/.sqlx/query-cba9bc8a941c3830d5fdfa3c76bbf336dfd13920d5439a4a77b92220c62f9526.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at FROM movies WHERE tmdb_id = ?", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tmdb_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "original_language", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "root_id!: i64", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "wanted!: bool", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "overrides!: serde_json::Value", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "state!: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "blocked!: bool", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "search_attempts!: i64", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "last_searched_at", + "ordinal": 11, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "cba9bc8a941c3830d5fdfa3c76bbf336dfd13920d5439a4a77b92220c62f9526" +} diff --git a/Cargo.lock b/Cargo.lock index c1b9880..17849bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,8 +30,13 @@ dependencies = [ name = "arr-api" version = "0.1.0" dependencies = [ + "arr-core", "arr-db", + "arr-indexer", + "arr-meta", + "arr-parse", "axum", + "chrono", "reqwest", "serde", "serde_json", @@ -120,8 +125,15 @@ name = "arr-e2e" version = "0.1.0" dependencies = [ "arr-dl", + "arr-indexer", + "arr-meta", + "chrono", + "reqwest", + "serde_json", + "tempfile", "tokio", "uuid", + "wiremock", ] [[package]] diff --git a/Justfile b/Justfile index cc041a1..3aec3cc 100644 --- a/Justfile +++ b/Justfile @@ -27,10 +27,21 @@ deps: test: cargo nextest run --workspace --exclude arr-e2e -# End-to-end tests. Needs a Transmission container; not part of the push gate. +# End-to-end tests. Needs a Transmission container (`just e2e-up`); not part +# of the push gate. Never point this at the production Transmission LXC. e2e: cargo nextest run -p arr-e2e +# Throwaway local Transmission for `just e2e`, mirroring the CI service. +e2e-up: + docker run --rm -d --name arr-e2e-transmission \ + -e PUID=1000 -e PGID=1000 -p 9091:9091 \ + linuxserver/transmission:latest + +# Stop and discard the local Transmission container. +e2e-down: + docker stop arr-e2e-transmission + # Frontend lint and typecheck. No-op until the SPA exists (issue #6). Probes # for package.json, not the directory: `just gen-client` writes into web/ and # must not turn the gate on before there is a project to check. @@ -41,6 +52,7 @@ web-check: pnpm -C web install --frozen-lockfile pnpm -C web exec biome ci . pnpm -C web exec tsc -b --noEmit + pnpm -C web run check-tokens # Build the SPA and the binary that embeds it. build: diff --git a/crates/arr-api/Cargo.toml b/crates/arr-api/Cargo.toml index da35873..686ed94 100644 --- a/crates/arr-api/Cargo.toml +++ b/crates/arr-api/Cargo.toml @@ -7,8 +7,13 @@ repository.workspace = true publish = false [dependencies] +arr-core = { workspace = true } arr-db = { workspace = true } +arr-indexer = { workspace = true } +arr-meta = { workspace = true } +arr-parse = { workspace = true } axum = { workspace = true } +chrono = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index f8dcbdf..6650e2f 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -8,6 +8,7 @@ mod health; mod movies; +mod search; mod state; use axum::routing::get; @@ -19,6 +20,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 search::{ClassifiedRelease, SearchResponse}; pub use state::{AppState, MovieCommand, Upstreams, DEFAULT_TMDB_URL}; /// Where the generated document is served, and where `just gen-client` reads @@ -39,7 +41,8 @@ pub const DOCS_PATH: &str = "/api/docs"; ), tags( (name = "system", description = "Service health and metadata"), - (name = "movies", description = "Movie library and actions") + (name = "movies", description = "Movie library and actions"), + (name = "search", description = "Unified title and release search") ), )] struct ApiDoc; @@ -54,6 +57,8 @@ fn api_router() -> OpenApiRouter { .routes(routes!(movies::releases)) .routes(routes!(movies::grab)) .routes(routes!(movies::attention)) + .routes(routes!(search::search)) + .routes(routes!(search::releases)) } /// The generated `OpenAPI` document. diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs new file mode 100644 index 0000000..36cf87f --- /dev/null +++ b/crates/arr-api/src/search.rs @@ -0,0 +1,763 @@ +use std::collections::BTreeMap; +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, +}; +use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest}; +use axum::extract::{Query, State}; +use axum::Json; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use crate::movies::{ApiError, ErrorBody, Movie}; +use crate::state::AppState; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct SearchQuery { + q: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct ReleasesQuery { + movie_id: i64, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SearchResponse { + pub kind: SearchInputKind, + pub library: Vec, + pub tmdb: Vec, + pub manual: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum SearchInputKind { + Text, + TmdbId, + ImdbId, + Magnet, + TorrentUrl, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct TmdbMovie { + pub tmdb_id: u32, + pub title: String, + pub original_title: String, + pub original_language: String, + pub year: Option, + pub overview: Option, + pub poster_path: Option, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ClassifiedRelease { + pub indexer_id: i64, + pub guid: String, + pub name: String, + pub size: Option, + pub seeders: Option, + pub publish_date: Option, + pub download_url: String, + pub parsed: serde_json::Value, + pub score: i64, + pub verdict: String, + pub rule: Option, +} + +#[derive(Debug, Deserialize)] +struct PolicyRow { + policy_id: i64, + policy_name: String, + required_audio: String, + dub_blacklist: String, + hdr_rules: String, + size_bands: String, + resolution_pref: String, + source_weights: String, +} + +#[derive(Debug, Deserialize)] +struct RequiredAudioJson { + require: String, + #[serde(default)] + langs: Vec, +} + +#[derive(Debug, Deserialize)] +struct HdrRulesJson { + #[serde(default)] + dv_profile_reject: Vec, +} + +#[derive(Debug, Deserialize)] +struct SizeBandJson { + floor_gib: u64, + target_gib: u64, + penalty_points_per_gib_over: i32, +} + +#[derive(Debug, Deserialize)] +struct OverridesJson { + #[serde(default)] + only_4k: bool, + #[serde(default)] + allow_english_audio: bool, +} + +#[utoipa::path( + get, path = "/api/search", tag = "search", params(SearchQuery), + responses( + (status = 200, body = SearchResponse), + (status = 422, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn search( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let input = query.q.trim(); + if input.is_empty() { + return Err(ApiError::Invalid("q must not be empty".into())); + } + + let kind = input_kind(input); + if matches!(kind, SearchInputKind::Magnet | SearchInputKind::TorrentUrl) { + return Ok(Json(SearchResponse { + kind, + library: Vec::new(), + tmdb: Vec::new(), + manual: Some(input.to_owned()), + })); + } + + let database = state.database().ok_or(ApiError::Unavailable)?; + let mut library = if matches!(kind, SearchInputKind::TmdbId) { + let tmdb_id = input + .strip_prefix("tmdb:") + .unwrap_or(input) + .trim() + .parse::() + .map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?; + let pattern = format!("%{}%", escape_like(input)); + sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at FROM movies WHERE tmdb_id = ? OR title LIKE ? ESCAPE '\' ORDER BY title, year, id"#, tmdb_id, pattern) + .fetch_all(database.pool()).await? + } else { + let pattern = format!("%{}%", escape_like(input)); + sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at FROM movies WHERE title LIKE ? ESCAPE '\' ORDER BY title, year, id"#, pattern) + .fetch_all(database.pool()).await? + }; + + let tmdb = tmdb_client(&state)?; + let results = search_tmdb(&tmdb, kind, input).await?; + if matches!(kind, SearchInputKind::ImdbId) { + for result in &results { + let tmdb_id = i64::from(result.tmdb_id); + if let Some(movie) = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at FROM movies WHERE tmdb_id = ?"#, tmdb_id) + .fetch_optional(database.pool()).await? + { + library.push(movie); + } + } + } + + Ok(Json(SearchResponse { + kind, + library, + tmdb: results, + manual: None, + })) +} + +async fn search_tmdb( + tmdb: &arr_meta::TmdbClient, + kind: SearchInputKind, + input: &str, +) -> Result, ApiError> { + let results = match kind { + SearchInputKind::TmdbId => { + let id = input + .strip_prefix("tmdb:") + .unwrap_or(input) + .trim() + .parse::() + .map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?; + let mut matches = match tmdb.movie(id).await { + Ok(movie) => { + let year = movie.year(); + vec![TmdbMovie { + tmdb_id: movie.tmdb_id, + title: movie.title, + original_title: movie.original_title, + original_language: movie.original_language, + year, + overview: movie.overview, + poster_path: movie.poster_path, + }] + } + Err(arr_meta::Error::NotFound { .. }) => Vec::new(), + Err(error) => return Err(upstream_error(&error)), + }; + if !input.starts_with("tmdb:") { + for movie in tmdb + .search_movies(input, None) + .await + .map_err(|error| upstream_error(&error))? + .into_iter() + .map(TmdbMovie::from) + { + if !matches + .iter() + .any(|candidate| candidate.tmdb_id == movie.tmdb_id) + { + matches.push(movie); + } + } + } + matches + } + SearchInputKind::ImdbId => match tmdb.find_movie_by_imdb(input).await { + Ok(movies) => movies.into_iter().map(TmdbMovie::from).collect(), + Err(arr_meta::Error::NotFound { .. }) => Vec::new(), + Err(error) => return Err(upstream_error(&error)), + }, + SearchInputKind::Text => tmdb + .search_movies(input, None) + .await + .map_err(|error| upstream_error(&error))? + .into_iter() + .map(TmdbMovie::from) + .collect(), + SearchInputKind::Magnet | SearchInputKind::TorrentUrl => Vec::new(), + }; + Ok(results) +} + +#[utoipa::path( + get, path = "/api/releases", tag = "search", params(ReleasesQuery), + responses( + (status = 200, body = [ClassifiedRelease]), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn releases( + State(state): State, + 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) + .fetch_optional(database.pool()).await?.ok_or(ApiError::NotFound)?; + + let tmdb = tmdb_client(&state)? + .movie( + u32::try_from(movie.tmdb_id) + .map_err(|_| ApiError::Invalid("movie has invalid TMDB id".into()))?, + ) + .await + .map_err(|error| upstream_error(&error))?; + let request = tmdb.imdb_id.map_or_else( + || SearchRequest::Text { + query: movie.title.clone(), + }, + |imdb_id| SearchRequest::Movie { imdb_id }, + ); + let upstreams = state.upstreams(); + let api_key = upstreams + .prowlarr_api_key + .clone() + .ok_or(ApiError::Unavailable)?; + let prowlarr = ProwlarrClient::new(upstreams.prowlarr_url.clone(), api_key) + .map_err(|_| ApiError::Unavailable)?; + let indexers = prowlarr + .indexers() + .await + .map_err(|_| ApiError::Unavailable)?; + let policy = policy_from_row(PolicyRow { + policy_id: movie.policy_id, + policy_name: movie.policy_name, + required_audio: movie.required_audio, + dub_blacklist: movie.dub_blacklist, + hdr_rules: movie.hdr_rules, + size_bands: movie.size_bands, + resolution_pref: movie.resolution_pref, + source_weights: movie.source_weights, + })?; + let overrides: OverridesJson = serde_json::from_value(movie.overrides) + .map_err(|error| ApiError::Database(error.to_string()))?; + let overrides = MovieOverrides { + only_4k: overrides.only_4k, + allow_english_audio: overrides.allow_english_audio, + }; + let original_language = title_language( + movie + .original_language + .as_deref() + .unwrap_or(&tmdb.original_language), + &tmdb.origin_countries, + ); + + let mut classified = Vec::new(); + for indexer in indexers { + let indexer_request = match &request { + SearchRequest::Movie { .. } + if indexer.capabilities.movie.available + && indexer.capabilities.movie.supports_parameter("imdbid") => + { + request.clone() + } + SearchRequest::Movie { .. } if indexer.capabilities.search.available => { + SearchRequest::Text { + query: movie.title.clone(), + } + } + SearchRequest::Text { .. } if indexer.capabilities.search.available => request.clone(), + _ => continue, + }; + match prowlarr.search_indexer(indexer.id, &indexer_request).await { + Ok(releases) => { + for release in releases { + classified.push(classify(release, &policy, &overrides, &original_language)?); + } + } + Err(error) => { + tracing::warn!(indexer_id = indexer.id, %error, "manual release search failed"); + } + } + } + classified.sort_by_key(|release| (bucket(&release.verdict), -release.score)); + Ok(Json(classified)) +} + +impl From for TmdbMovie { + fn from(movie: arr_meta::MovieSearchResult) -> Self { + let year = movie.year(); + Self { + tmdb_id: movie.tmdb_id, + title: movie.title, + original_title: movie.original_title, + original_language: movie.original_language, + year, + overview: movie.overview, + poster_path: movie.poster_path, + } + } +} + +fn input_kind(input: &str) -> SearchInputKind { + let lower = input.to_ascii_lowercase(); + if lower.starts_with("magnet:?") { + SearchInputKind::Magnet + } else if lower.ends_with(".torrent") + && (lower.starts_with("http://") || lower.starts_with("https://")) + { + SearchInputKind::TorrentUrl + } else if lower.starts_with("tt") && lower[2..].chars().all(|c| c.is_ascii_digit()) { + SearchInputKind::ImdbId + } else if lower + .strip_prefix("tmdb:") + .is_some_and(|id| id.trim().chars().all(|c| c.is_ascii_digit())) + || lower.chars().all(|c| c.is_ascii_digit()) + { + SearchInputKind::TmdbId + } else { + SearchInputKind::Text + } +} + +fn escape_like(input: &str) -> String { + input + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") +} + +fn tmdb_client(state: &AppState) -> Result { + let upstreams = state.upstreams(); + let key = upstreams + .tmdb_api_key + .clone() + .ok_or(ApiError::Unavailable)?; + arr_meta::TmdbClient::builder(key) + .base_url(&upstreams.tmdb_url) + .build() + .map_err(|_| ApiError::Unavailable) +} + +fn upstream_error(error: &arr_meta::Error) -> ApiError { + match error { + arr_meta::Error::NotFound { .. } => ApiError::NotFound, + _ => ApiError::Unavailable, + } +} + +fn policy_from_row(row: PolicyRow) -> Result { + let required: RequiredAudioJson = json(&row.required_audio)?; + let hdr: HdrRulesJson = json(&row.hdr_rules)?; + let bands: BTreeMap = json(&row.size_bands)?; + let resolutions: Vec = json(&row.resolution_pref)?; + let weights: BTreeMap = json(&row.source_weights)?; + Ok(Policy { + id: PolicyId(row.policy_id), + name: row.policy_name, + required_audio: if required.require == "original_language" { + RequiredAudio::OriginalLanguage + } else { + RequiredAudio::AnyOf(required.langs.iter().map(|value| language(value)).collect()) + }, + dub_blacklist: json::>(&row.dub_blacklist)? + .iter() + .map(|value| language(value)) + .collect(), + hdr_rules: HdrRules { + rejected_dolby_vision_profiles: hdr + .dv_profile_reject + .iter() + .filter_map(|value| value.parse().ok()) + .map(|profile| DolbyVisionProfile { + profile, + compatibility_id: None, + }) + .collect(), + }, + size_bands: bands + .into_iter() + .filter_map(|(resolution, band)| { + resolution_value(&resolution).map(|resolution| { + ( + resolution, + SizeBand { + floor_bytes: gib(band.floor_gib), + target_bytes: gib(band.target_gib), + penalty_points_per_gib_over: band.penalty_points_per_gib_over, + }, + ) + }) + }) + .collect(), + resolution_preference: resolutions + .iter() + .filter_map(|value| resolution_value(value)) + .collect(), + source_weights: weights + .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(), + }) +} + +fn classify( + release: SearchRelease, + policy: &Policy, + overrides: &MovieOverrides, + original_language: &Language, +) -> Result { + let parsed = arr_parse::parse(&release.name); + let evaluation = evaluate( + policy, + overrides, + original_language, + Candidate::PreGrab(&parsed), + release.size, + ); + let (verdict, rule) = verdict(&evaluation.verdict); + let score = score(policy, &parsed, release.size, release.seeders); + Ok(ClassifiedRelease { + indexer_id: release.indexer_id, + guid: release.guid, + name: release.name, + size: release.size, + seeders: release.seeders, + publish_date: release + .publish_date + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .and_then(|duration| { + DateTime::::from_timestamp(i64::try_from(duration.as_secs()).ok()?, 0) + }) + .map(|date| date.to_rfc3339()), + download_url: release.download_url, + parsed: serde_json::to_value(parsed) + .map_err(|error| ApiError::Database(error.to_string()))?, + score, + verdict: verdict.to_owned(), + rule, + }) +} + +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), + Verdict::Waived(rule) => ("waived", Some(rule_name(rule))), + Verdict::Rejected(rule) => ("rejected", Some(rule_name(rule))), + } +} + +fn rule_name(rule: &Rule) -> String { + match rule { + Rule::RequiredAudio => "required_audio".into(), + Rule::DubBlacklist(_) => "dub_blacklist".into(), + Rule::PortugueseUnverified => "portuguese_unverified".into(), + Rule::DolbyVisionProfile(_) => "dolby_vision_profile".into(), + Rule::Resolution(_) => "resolution".into(), + Rule::Source(_) => "source".into(), + Rule::Size => "size".into(), + Rule::Other(name) => name.clone(), + } +} + +fn bucket(verdict: &str) -> u8 { + match verdict { + "eligible" => 0, + "waived" => 1, + _ => 2, + } +} +fn gib(value: u64) -> u64 { + value.saturating_mul(1024 * 1024 * 1024) +} +fn language(value: &str) -> Language { + match value { + "pt-PT" => Language::PortuguesePortugal, + "pt-BR" => Language::PortugueseBrazil, + "pt" | "por-unverified" => Language::PortugueseUnverified, + other => Language::Other(other.to_owned()), + } +} +fn title_language(value: &str, origin_countries: &[String]) -> Language { + if value == "pt" { + if origin_countries.iter().any(|country| country == "BR") { + return Language::PortugueseBrazil; + } + if origin_countries.iter().any(|country| country == "PT") { + return Language::PortuguesePortugal; + } + } + language(value) +} +fn resolution_value(value: &str) -> Option { + match value { + "2160p" => Some(Resolution::R2160p), + "1080p" => Some(Resolution::R1080p), + "720p" => Some(Resolution::R720p), + _ => None, + } +} +fn source_value(value: &str) -> Option { + match value { + "Remux" => Some(Source::Remux), + "BluRay" => Some(Source::BluRay), + "WEB-DL" => Some(Source::WebDl), + "WEBRip" => Some(Source::WebRip), + "HDTV" => Some(Source::Hdtv), + _ => None, + } +} +fn json(value: &str) -> Result { + serde_json::from_str(value).map_err(|error| ApiError::Database(error.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{router, Upstreams}; + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + async fn application( + tmdb: &MockServer, + prowlarr: &MockServer, + ) -> (tempfile::TempDir, AppState, String) { + 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(prowlarr.uri(), "http://127.0.0.1:1".into()) + .with_prowlarr_api_key(Some("prowlarr-key".into())) + .with_tmdb_url(tmdb.uri()) + .with_tmdb_api_key(Some("tmdb-key".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.clone()); + tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); + (dir, state, format!("http://{address}")) + } + + #[tokio::test] + async fn unified_search_groups_library_before_tmdb() { + let tmdb = MockServer::start().await; + let prowlarr = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/search/movie")) + .and(query_param("query", "Dune")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[{ + "id": 693_134, "title": "Dune: Part Two", "original_title": "Dune: Part Two", + "original_language": "en", "release_date": "2024-02-27" + }]})), + ) + .mount(&tmdb) + .await; + let (_dir, state, base) = application(&tmdb, &prowlarr).await; + sqlx::query("INSERT INTO movies (tmdb_id, title, year, original_language, root_id) VALUES (438631, 'Dune', 2021, 'en', 2)") + .execute(state.database().expect("database").pool()).await.expect("movie"); + + let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=Dune")) + .await + .expect("search") + .json() + .await + .expect("json"); + assert_eq!(response["kind"], "text"); + assert_eq!(response["library"][0]["title"], "Dune"); + assert_eq!(response["tmdb"][0]["tmdb_id"], 693_134); + } + + #[tokio::test] + async fn numeric_titles_survive_a_missing_tmdb_id() { + let tmdb = MockServer::start().await; + let prowlarr = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/movie/1917")) + .respond_with(ResponseTemplate::new(404)) + .mount(&tmdb) + .await; + Mock::given(method("GET")) + .and(path("/search/movie")) + .and(query_param("query", "1917")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": [{"id": 530_915, "title": "1917", "original_title": "1917", + "original_language": "en", "release_date": "2019-12-25"}] + }))) + .mount(&tmdb) + .await; + let (_dir, state, base) = application(&tmdb, &prowlarr).await; + sqlx::query("INSERT INTO movies (tmdb_id, title, year, original_language, root_id) VALUES (530915, '1917', 2019, 'en', 2)") + .execute(state.database().expect("database").pool()).await.expect("movie"); + + let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=1917")) + .await + .expect("search") + .json() + .await + .expect("json"); + assert_eq!(response["library"][0]["title"], "1917"); + assert_eq!(response["tmdb"][0]["title"], "1917"); + } + + #[tokio::test] + async fn manual_releases_are_classified_and_name_rejection_rules() { + let tmdb = MockServer::start().await; + let prowlarr = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/movie/693134")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id":693_134, "imdb_id":"tt15239678", "title":"Dune Part Two", + "original_title":"Dune Part Two", "original_language":"en", "status":"Released" + }))) + .mount(&tmdb) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/indexer")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!([{"id":7,"name":"tracker","enable":true}])), + ) + .mount(&prowlarr) + .await; + Mock::given(method("GET")).and(path("/7/api")).and(query_param("t", "caps")) + .respond_with(ResponseTemplate::new(200).set_body_string("")) + .mount(&prowlarr).await; + Mock::given(method("GET")).and(path("/7/api")).and(query_param("t", "movie")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"Dune.Part.Two.2024.1080p.CAMbadhttps://tracker/bad4000000000Dune.Part.Two.2024.2160p.WEB-DLgoodhttps://tracker/good22000000000"#)) + .mount(&prowlarr).await; + 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"); + + let response = reqwest::get(format!("{base}/api/releases?movie_id=1")) + .await + .expect("releases"); + assert_eq!(response.status(), 200); + let releases: Vec = response.json().await.expect("json"); + assert_eq!(releases.len(), 2); + assert!(releases + .iter() + .all(|release| release["verdict"].is_string())); + let rejected = releases + .iter() + .find(|release| release["verdict"] == "rejected") + .expect("rejected"); + assert_eq!(rejected["rule"], "source"); + assert!(releases[0]["score"].as_i64().is_some()); + } + + #[test] + fn manual_inputs_skip_title_upstreams() { + assert!(matches!( + input_kind("magnet:?xt=urn:btih:abc"), + SearchInputKind::Magnet + )); + assert!(matches!( + input_kind("https://example.test/a.torrent"), + SearchInputKind::TorrentUrl + )); + assert!(matches!(input_kind("tt15239678"), SearchInputKind::ImdbId)); + assert!(matches!(input_kind("tmdb:693134"), SearchInputKind::TmdbId)); + } +} diff --git a/crates/arr-api/src/state.rs b/crates/arr-api/src/state.rs index 0030cd5..76ea5fd 100644 --- a/crates/arr-api/src/state.rs +++ b/crates/arr-api/src/state.rs @@ -52,7 +52,8 @@ impl Upstreams { self } - /// Point TMDB somewhere other than the real API. Tests only. + /// Point TMDB somewhere other than the real API. Tests and the + /// `ARR_TMDB_URL` e2e seam only. #[must_use] pub fn with_tmdb_url(mut self, url: String) -> Self { self.tmdb_url = url; diff --git a/crates/arr-daemon/src/config.rs b/crates/arr-daemon/src/config.rs index afceee6..3b4c1ab 100644 --- a/crates/arr-daemon/src/config.rs +++ b/crates/arr-daemon/src/config.rs @@ -21,6 +21,7 @@ pub const ENV_PROWLARR_URL: &str = "ARR_PROWLARR_URL"; pub const ENV_PROWLARR_API_KEY: &str = "ARR_PROWLARR_API_KEY"; pub const ENV_TRANSMISSION_URL: &str = "ARR_TRANSMISSION_URL"; pub const ENV_TMDB_API_KEY: &str = "ARR_TMDB_API_KEY"; +pub const ENV_TMDB_URL: &str = "ARR_TMDB_URL"; pub const ENV_JELLYFIN_URL: &str = "ARR_JELLYFIN_URL"; pub const ENV_JELLYFIN_API_KEY: &str = "ARR_JELLYFIN_API_KEY"; pub const ENV_NTFY_URL: &str = "ARR_NTFY_URL"; @@ -89,6 +90,7 @@ pub struct EnvOverrides { pub prowlarr_api_key: Option, pub transmission_url: Option, pub tmdb_api_key: Option, + pub tmdb_url: Option, pub jellyfin_url: Option, pub jellyfin_api_key: Option, pub ntfy_url: Option, @@ -105,6 +107,7 @@ impl EnvOverrides { prowlarr_api_key: std::env::var(ENV_PROWLARR_API_KEY).ok(), transmission_url: std::env::var(ENV_TRANSMISSION_URL).ok(), tmdb_api_key: std::env::var(ENV_TMDB_API_KEY).ok(), + tmdb_url: std::env::var(ENV_TMDB_URL).ok(), jellyfin_url: std::env::var(ENV_JELLYFIN_URL).ok(), jellyfin_api_key: std::env::var(ENV_JELLYFIN_API_KEY).ok(), ntfy_url: std::env::var(ENV_NTFY_URL).ok(), @@ -122,6 +125,9 @@ pub struct Config { pub prowlarr_api_key: Option, pub transmission_url: String, pub tmdb_api_key: Option, + /// E2E seam only, env-only. `None` means the client's built-in TMDB + /// address; DESIGN.md §10 keeps the real URL out of configuration. + pub tmdb_url: Option, pub jellyfin_url: String, pub jellyfin_api_key: Option, pub ntfy_url: String, @@ -170,6 +176,7 @@ impl Config { .or(file.transmission_url) .unwrap_or_else(|| DEFAULT_TRANSMISSION_URL.to_string()), tmdb_api_key: env.tmdb_api_key, + tmdb_url: env.tmdb_url, jellyfin_url: env .jellyfin_url .or(file.jellyfin_url) @@ -277,6 +284,31 @@ prowlarr_url = "http://prowlarr.internal:9696" assert_eq!(config.jellyfin_api_key.as_deref(), Some("secret-3")); } + #[test] + fn tmdb_url_is_an_env_only_seam() { + let config = Config::resolve(EnvOverrides::default()).unwrap(); + assert_eq!(config.tmdb_url, None); + + let env = EnvOverrides { + tmdb_url: Some("http://127.0.0.1:9/3".into()), + ..EnvOverrides::default() + }; + let config = Config::resolve(env).unwrap(); + assert_eq!(config.tmdb_url.as_deref(), Some("http://127.0.0.1:9/3")); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("arr.toml"); + std::fs::write(&path, "tmdb_url = \"http://127.0.0.1:9/3\"\n").unwrap(); + let env = EnvOverrides { + config_file: Some(path.to_string_lossy().into_owned()), + ..EnvOverrides::default() + }; + assert!(matches!( + Config::resolve(env), + Err(ConfigError::TomlDecode(_)) + )); + } + #[test] fn a_secret_in_the_config_file_is_a_parse_error() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/arr-daemon/src/main.rs b/crates/arr-daemon/src/main.rs index cc1a571..2758bd5 100644 --- a/crates/arr-daemon/src/main.rs +++ b/crates/arr-daemon/src/main.rs @@ -88,15 +88,20 @@ async fn run() -> Result<(), Error> { // needs its own TMDB client for `movie/lookup`. let mut compat = CompatState::new(database.clone()); if let Some(key) = &config.tmdb_api_key { - compat = compat.with_tmdb(Arc::new(TmdbClient::new(key)?)); + let mut tmdb = TmdbClient::builder(key.clone()); + if let Some(tmdb_url) = &config.tmdb_url { + tmdb = tmdb.base_url(tmdb_url.clone()); + } + compat = compat.with_tmdb(Arc::new(tmdb.build()?)); } - let state = AppState::new( - Upstreams::new(config.prowlarr_url, config.transmission_url) - .with_prowlarr_api_key(config.prowlarr_api_key) - .with_tmdb_api_key(config.tmdb_api_key), - )? - .with_database(database); + let mut upstreams = Upstreams::new(config.prowlarr_url, config.transmission_url) + .with_prowlarr_api_key(config.prowlarr_api_key) + .with_tmdb_api_key(config.tmdb_api_key); + if let Some(tmdb_url) = config.tmdb_url { + upstreams = upstreams.with_tmdb_url(tmdb_url); + } + let state = AppState::new(upstreams)?.with_database(database); let app = arr_api::router(state) .merge(arr_compat::router(compat)) diff --git a/crates/arr-e2e/Cargo.toml b/crates/arr-e2e/Cargo.toml index 3f2fe6d..aab3fac 100644 --- a/crates/arr-e2e/Cargo.toml +++ b/crates/arr-e2e/Cargo.toml @@ -7,10 +7,17 @@ repository.workspace = true publish = false [dependencies] +reqwest = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true } +wiremock = { workspace = true } [dev-dependencies] arr-dl = { workspace = true } -tokio = { workspace = true } +arr-indexer = { workspace = true } +arr-meta = { workspace = true } +chrono = { workspace = true } uuid = { workspace = true } [lints] diff --git a/crates/arr-e2e/fixtures/prowlarr-caps.xml b/crates/arr-e2e/fixtures/prowlarr-caps.xml new file mode 100644 index 0000000..07a9bc7 --- /dev/null +++ b/crates/arr-e2e/fixtures/prowlarr-caps.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/arr-e2e/fixtures/prowlarr-indexers.json b/crates/arr-e2e/fixtures/prowlarr-indexers.json new file mode 100644 index 0000000..6f9c609 --- /dev/null +++ b/crates/arr-e2e/fixtures/prowlarr-indexers.json @@ -0,0 +1,4 @@ +[ + { "id": 3, "name": "Alpha Tracker", "enable": true }, + { "id": 42, "name": "Disabled Tracker", "enable": false } +] diff --git a/crates/arr-e2e/fixtures/prowlarr-movie-search.xml b/crates/arr-e2e/fixtures/prowlarr-movie-search.xml new file mode 100644 index 0000000..9a54a08 --- /dev/null +++ b/crates/arr-e2e/fixtures/prowlarr-movie-search.xml @@ -0,0 +1,24 @@ + + + + Alpha Tracker + + <![CDATA[Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX]]> + alpha-redacted-1 + https://indexer.invalid/download/alpha-redacted-1 + Sat, 16 Mar 2024 12:30:00 +0000 + + + + + + <![CDATA[Dune.Part.Two.2024.1080p.WEB-DL.DDP5.1.H.264-FLUX]]> + alpha-redacted-2 + https://indexer.invalid/download/alpha-redacted-2 + Sat, 16 Mar 2024 12:28:00 +0000 + + + + + + diff --git a/crates/arr-e2e/fixtures/tmdb-configuration.json b/crates/arr-e2e/fixtures/tmdb-configuration.json new file mode 100644 index 0000000..d76f61e --- /dev/null +++ b/crates/arr-e2e/fixtures/tmdb-configuration.json @@ -0,0 +1,8 @@ +{ + "images": { + "base_url": "http://image.tmdb.org/t/p/", + "secure_base_url": "https://image.tmdb.org/t/p/", + "poster_sizes": ["w92", "w154", "w185", "w342", "w500", "w780", "original"] + }, + "change_keys": ["adult", "air_date", "also_known_as"] +} diff --git a/crates/arr-e2e/fixtures/tmdb-movie-dune.json b/crates/arr-e2e/fixtures/tmdb-movie-dune.json new file mode 100644 index 0000000..2d98628 --- /dev/null +++ b/crates/arr-e2e/fixtures/tmdb-movie-dune.json @@ -0,0 +1,83 @@ +{ + "adult": false, + "backdrop_path": "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg", + "budget": 190000000, + "genres": [{ "id": 878, "name": "Science Fiction" }, { "id": 12, "name": "Adventure" }], + "homepage": "https://www.dunemovie.com", + "id": 693134, + "imdb_id": "tt15239678", + "origin_country": ["US"], + "original_language": "en", + "original_title": "Dune: Part Two", + "overview": "Follow the mythic journey of Paul Atreides as he unites with Chani and the Fremen while on a path of revenge against the conspirators who destroyed his family.", + "popularity": 234.53, + "poster_path": "/1pdfLvkbY9ohJlCjQH2CZjjYVvJ.jpg", + "production_countries": [ + { "iso_3166_1": "US", "name": "United States of America" }, + { "iso_3166_1": "CA", "name": "Canada" } + ], + "release_date": "2024-02-27", + "revenue": 711844358, + "runtime": 167, + "spoken_languages": [{ "english_name": "English", "iso_639_1": "en", "name": "English" }], + "status": "Released", + "tagline": "Long live the fighters.", + "title": "Dune: Part Two", + "video": false, + "vote_average": 8.157, + "vote_count": 6104, + "release_dates": { + "results": [ + { + "iso_3166_1": "US", + "release_dates": [ + { + "certification": "PG-13", + "descriptors": [], + "iso_639_1": "", + "note": "", + "release_date": "2024-03-01T00:00:00.000Z", + "type": 3 + }, + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "", + "release_date": "2024-04-16T00:00:00.000Z", + "type": 4 + }, + { + "certification": "PG-13", + "descriptors": [], + "iso_639_1": "", + "note": "", + "release_date": "2024-05-14T00:00:00.000Z", + "type": 5 + } + ] + }, + { + "iso_3166_1": "PT", + "release_dates": [ + { + "certification": "M/12", + "descriptors": [], + "iso_639_1": "", + "note": "", + "release_date": "2024-02-28T00:00:00.000Z", + "type": 3 + }, + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "", + "release_date": "2024-04-10T00:00:00.000Z", + "type": 4 + } + ] + } + ] + } +} diff --git a/crates/arr-e2e/src/lib.rs b/crates/arr-e2e/src/lib.rs index f509fc0..1f12eff 100644 --- a/crates/arr-e2e/src/lib.rs +++ b/crates/arr-e2e/src/lib.rs @@ -1,65 +1,315 @@ -//! arr-e2e — cross-process integration tests. See DESIGN.md §12. +//! arr-e2e — cross-process integration test harness. See DESIGN.md §12. //! -//! Tests here talk to a real Transmission container and to `wiremock` stubs -//! standing in for Prowlarr and TMDB. Never a live tracker. +//! Tests built on this harness talk to a real Transmission container and to +//! `wiremock` fakes serving recorded responses for Prowlarr and TMDB. Never a +//! live tracker: trackers rate-limit and it would leak credentials into CI. +//! +//! The harness boots the actual `arr` binary as a child process, so a test +//! crosses the same process boundary production does: bootstrap config from +//! the environment, migrations against a fresh database, the HTTP API over a +//! real socket. +// Dev-dependencies belong to the integration tests; the lib's own test build +// links them without using them. Same per-target quirk as arr-meta. #[cfg(test)] -mod tests { - use std::path::PathBuf; +use {arr_dl as _, arr_indexer as _, arr_meta as _, chrono as _, uuid as _}; - use arr_dl::{AddTorrent, TorrentSource, TransmissionClient}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; - #[tokio::test] - async fn transmission_add_list_and_remove() { - let endpoint = std::env::var("TRANSMISSION_RPC_URL") - .unwrap_or_else(|_| "http://127.0.0.1:9091/transmission/rpc".into()); - let client = TransmissionClient::new(&endpoint).expect("valid endpoint"); - let name = format!("arr-e2e-{}", uuid::Uuid::new_v4()); - let metainfo = torrent_with_name(&name); - let download_dir = PathBuf::from("/tmp/arr-e2e"); - let request = || AddTorrent { - source: TorrentSource::Metainfo(metainfo.clone()), - label: "movies-main".into(), - download_dir: download_dir.clone(), - seed_ratio_limit: 1.5, - seed_idle_limit_minutes: 60, - }; +use wiremock::matchers::{header, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; - let first = client.add_torrent(request()).await.expect("add torrent"); - assert!(!first.was_duplicate); +/// The API key both fakes expect, and the one [`Daemon::spawn`] configures. +pub const API_KEY: &str = "arr-e2e-key"; - let torrents = client.list_torrents().await.expect("list torrents"); - let listed = torrents - .iter() - .find(|torrent| torrent.id == first.id) - .expect("added torrent is authoritative in list"); - assert_eq!(listed.name, name); - assert_eq!(listed.hash, first.hash); - assert_eq!(listed.download_dir, download_dir); - assert_eq!(listed.labels, ["movies-main"]); - assert!((0.0..=1.0).contains(&listed.progress)); +/// How long the daemon has to answer its first health check. +const BOOT_TIMEOUT: Duration = Duration::from_secs(30); - client - .remove_torrent(first.id, false) - .await - .expect("remove without data"); +/// Recorded responses served by the fakes, kept in `fixtures/` so a captured +/// real response can replace one without touching code. +pub mod fixtures { + pub const PROWLARR_INDEXERS: &str = include_str!("../fixtures/prowlarr-indexers.json"); + pub const PROWLARR_CAPS: &str = include_str!("../fixtures/prowlarr-caps.xml"); + pub const PROWLARR_MOVIE_SEARCH: &str = include_str!("../fixtures/prowlarr-movie-search.xml"); + pub const TMDB_CONFIGURATION: &str = include_str!("../fixtures/tmdb-configuration.json"); + pub const TMDB_MOVIE_DUNE: &str = include_str!("../fixtures/tmdb-movie-dune.json"); +} - let second = client.add_torrent(request()).await.expect("add again"); - client - .remove_torrent(second.id, true) - .await - .expect("remove with data"); +/// The enabled indexer in [`fixtures::PROWLARR_INDEXERS`]. +pub const INDEXER_ID: i64 = 3; - let torrents = client.list_torrents().await.expect("list after remove"); - assert!(torrents.iter().all(|torrent| torrent.id != second.id)); +/// The movie in [`fixtures::TMDB_MOVIE_DUNE`]. +pub const TMDB_MOVIE_ID: u32 = 693_134; + +/// The Transmission RPC endpoint tests should use: `TRANSMISSION_RPC_URL` +/// when set (CI points it at the service container), a local container's +/// default port otherwise. Never the production LXC. +#[must_use] +pub fn transmission_url() -> String { + std::env::var("TRANSMISSION_RPC_URL") + .unwrap_or_else(|_| "http://127.0.0.1:9091/transmission/rpc".into()) +} + +/// A `wiremock` Prowlarr: the REST indexer enumeration plus one enabled +/// indexer's Torznab endpoint, all serving recorded fixtures. +#[derive(Debug)] +pub struct FakeProwlarr { + server: MockServer, +} + +impl FakeProwlarr { + /// Start the fake and mount every recorded route. + /// + /// # Panics + /// + /// Panics when the mock server cannot bind a local port. + pub async fn start() -> Self { + let server = MockServer::start().await; + + // Health probe. Prowlarr answers /ping without a key. + Mock::given(method("GET")) + .and(path("/ping")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + // REST enumeration, keyed like the real thing. + Mock::given(method("GET")) + .and(path("/api/v1/indexer")) + .and(header("X-Api-Key", API_KEY)) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw(fixtures::PROWLARR_INDEXERS, "application/json"), + ) + .mount(&server) + .await; + + // The enabled indexer's Torznab endpoint. + let torznab = format!("/{INDEXER_ID}/api"); + Mock::given(method("GET")) + .and(path(torznab.clone())) + .and(query_param("apikey", API_KEY)) + .and(query_param("t", "caps")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(fixtures::PROWLARR_CAPS, "application/xml"), + ) + .mount(&server) + .await; + for operation in ["movie", "search"] { + Mock::given(method("GET")) + .and(path(torznab.clone())) + .and(query_param("apikey", API_KEY)) + .and(query_param("t", operation)) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw(fixtures::PROWLARR_MOVIE_SEARCH, "application/xml"), + ) + .mount(&server) + .await; + } + + Self { server } } - fn torrent_with_name(name: &str) -> Vec { - let piece_hash = [0_u8; 20]; - let mut bytes = format!("d4:infod6:lengthi1e4:name{}:{name}", name.len()).into_bytes(); - bytes.extend_from_slice(b"12:piece lengthi16384e6:pieces20:"); - bytes.extend_from_slice(&piece_hash); - bytes.extend_from_slice(b"ee"); - bytes + /// Base URL, in the shape `ARR_PROWLARR_URL` and `ProwlarrClient` expect. + #[must_use] + pub fn url(&self) -> String { + self.server.uri() } } + +/// A `wiremock` TMDB serving recorded fixtures under the real `/3` prefix. +#[derive(Debug)] +pub struct FakeTmdb { + server: MockServer, +} + +impl FakeTmdb { + /// Start the fake and mount every recorded route. + /// + /// # Panics + /// + /// Panics when the mock server cannot bind a local port. + pub async fn start() -> Self { + let server = MockServer::start().await; + + // Health probe. + Mock::given(method("GET")) + .and(path("/3/configuration")) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw(fixtures::TMDB_CONFIGURATION, "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path(format!("/3/movie/{TMDB_MOVIE_ID}"))) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw(fixtures::TMDB_MOVIE_DUNE, "application/json"), + ) + .mount(&server) + .await; + + Self { server } + } + + /// Base URL including the `/3` prefix, in the shape `ARR_TMDB_URL` and + /// `TmdbClient` expect. + #[must_use] + pub fn url(&self) -> String { + format!("{}/3", self.server.uri()) + } +} + +/// The `arr` binary running as a child process against a fresh temporary +/// database and media root. Killed on drop. +#[derive(Debug)] +pub struct Daemon { + child: Child, + base_url: String, + // Owns the database and media root for the child's lifetime. + _dir: tempfile::TempDir, +} + +impl Daemon { + /// Build and boot the daemon against the given upstreams, waiting until + /// its health endpoint answers. + /// + /// # Panics + /// + /// Panics when the binary cannot be built or spawned, or when the health + /// endpoint does not answer within the boot timeout. + pub async fn spawn(prowlarr_url: &str, tmdb_url: &str, transmission_url: &str) -> Self { + let dir = tempfile::tempdir().expect("create daemon tempdir"); + let media_root = dir.path().join("media"); + std::fs::create_dir(&media_root).expect("create media root"); + let port = free_port(); + let base_url = format!("http://127.0.0.1:{port}"); + + let child = Command::new(daemon_binary()) + .env_remove("ARR_CONFIG_FILE") + .env("ARR_BIND_ADDR", format!("127.0.0.1:{port}")) + .env("ARR_DATABASE_PATH", dir.path().join("arr.db")) + .env("ARR_MEDIA_ROOT", &media_root) + .env("ARR_PROWLARR_URL", prowlarr_url) + .env("ARR_PROWLARR_API_KEY", API_KEY) + .env("ARR_TMDB_URL", tmdb_url) + .env("ARR_TMDB_API_KEY", API_KEY) + .env("ARR_TRANSMISSION_URL", transmission_url) + .stdin(Stdio::null()) + .spawn() + .expect("spawn arr daemon"); + + let mut daemon = Self { + child, + base_url, + _dir: dir, + }; + daemon.wait_until_healthy().await; + daemon + } + + /// The daemon's API base URL, such as `http://127.0.0.1:41234`. + #[must_use] + pub fn base_url(&self) -> &str { + &self.base_url + } + + /// Fetch `/api/health` and return the parsed report. + /// + /// # Panics + /// + /// Panics when the request fails or the body is not JSON. + pub async fn health(&self) -> serde_json::Value { + reqwest::get(format!("{}/api/health", self.base_url)) + .await + .expect("health request") + .json() + .await + .expect("health body is JSON") + } + + async fn wait_until_healthy(&mut self) { + let deadline = Instant::now() + BOOT_TIMEOUT; + let url = format!("{}/api/health", self.base_url); + loop { + if let Some(status) = self.child.try_wait().expect("poll daemon") { + panic!("arr daemon exited during boot: {status}"); + } + if let Ok(response) = reqwest::get(&url).await { + if response.status().is_success() { + return; + } + } + assert!( + Instant::now() < deadline, + "arr daemon did not become healthy within {BOOT_TIMEOUT:?}" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } +} + +impl Drop for Daemon { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Build the `arr` binary once per test run and return its path. +/// +/// `cargo nextest run -p arr-e2e` compiles only this crate's test binaries, +/// so the daemon is built here, and cargo itself reports where the executable +/// landed — no guessing at the target layout the test runner uses. Cargo's +/// own file locking serialises this against concurrent test processes, and a +/// warm target directory makes it a no-op. +fn daemon_binary() -> &'static PathBuf { + static BINARY: OnceLock = OnceLock::new(); + BINARY.get_or_init(|| { + let mut build = Command::new(env!("CARGO")); + build + .args(["build", "-p", "arr-daemon", "--bin", "arr"]) + .args(["--message-format", "json-render-diagnostics"]) + .stderr(Stdio::inherit()); + // The test process carries the per-crate vars cargo set for *this* + // crate (CARGO_MANIFEST_DIR, CARGO_PKG_*). Inherited into a nested + // cargo they poison its fingerprints, so every shell build and every + // test run rebuild the world from each other. Strip them. + for (key, _) in std::env::vars_os() { + let poisoned = key.to_str().is_some_and(|key| { + key.starts_with("CARGO_PKG_") || key.starts_with("CARGO_MANIFEST_") + }); + if poisoned { + build.env_remove(key); + } + } + let output = build.output().expect("run cargo build"); + assert!(output.status.success(), "cargo build -p arr-daemon failed"); + + let stdout = String::from_utf8(output.stdout).expect("cargo build output is UTF-8"); + stdout + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|message| { + message["reason"] == "compiler-artifact" && message["target"]["name"] == "arr" + }) + .and_then(|message| message["executable"].as_str().map(PathBuf::from)) + .expect("cargo build reported the arr executable") + }) +} + +/// A port the daemon can bind. Released before the child starts, so a +/// collision is possible but vanishingly unlikely within one test run. +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .expect("bind an ephemeral port") + .local_addr() + .expect("read the bound address") + .port() +} diff --git a/crates/arr-e2e/tests/e2e.rs b/crates/arr-e2e/tests/e2e.rs new file mode 100644 index 0000000..ba135ba --- /dev/null +++ b/crates/arr-e2e/tests/e2e.rs @@ -0,0 +1,172 @@ +//! Cross-process end-to-end tests over the DESIGN.md §12 seams: the real +//! `arr` binary, a real Transmission container, and `wiremock` fakes serving +//! recorded Prowlarr and TMDB responses. Never a live tracker. +//! +//! The full add-to-imported run lands here once the reconcile loop (#21), +//! grab pipeline (#22) and import pipeline (#23) exist; today these prove the +//! harness itself end to end. + +// The harness library links these; this test target does not use them all +// directly. Same per-target quirk as arr-meta's tests. +use {tempfile as _, wiremock as _}; + +use std::path::PathBuf; + +use arr_dl::{AddTorrent, TorrentSource, TransmissionClient}; +use arr_e2e::{ + transmission_url, Daemon, FakeProwlarr, FakeTmdb, API_KEY, INDEXER_ID, TMDB_MOVIE_ID, +}; +use arr_indexer::{ProwlarrClient, SearchRequest}; +use arr_meta::TmdbClient; +use chrono::NaiveDate; + +/// The daemon boots as its own process against faked Prowlarr and TMDB plus a +/// real Transmission, reports every upstream healthy, and serves the movie +/// API over the wire. +#[tokio::test] +async fn daemon_runs_against_fakes_and_a_real_transmission() { + let prowlarr = FakeProwlarr::start().await; + let tmdb = FakeTmdb::start().await; + let daemon = Daemon::spawn(&prowlarr.url(), &tmdb.url(), &transmission_url()).await; + let base = daemon.base_url(); + + let health = daemon.health().await; + assert_eq!(health["status"], "ok", "health report: {health}"); + assert_eq!(health["prowlarr"]["status"], "ok"); + assert_eq!(health["transmission"]["status"], "ok"); + assert_eq!(health["tmdb"]["status"], "ok"); + + // Movie CRUD across the process boundary, against the migrated seed data. + let movie: serde_json::Value = reqwest::Client::new() + .post(format!("{base}/api/movies")) + .json(&serde_json::json!({ + "tmdb_id": TMDB_MOVIE_ID, "title": "Dune: Part Two", "year": 2024, + "original_language": "en", "root_id": 1 + })) + .send() + .await + .expect("create movie") + .json() + .await + .expect("movie json"); + let movie_id = movie["id"].as_i64().expect("movie id"); + assert_eq!(movie["wanted"], true); + + // The manual-search trigger is accepted; its consumer is the reconcile + // loop (#21), so no releases materialise yet. + let search = reqwest::Client::new() + .post(format!("{base}/api/movies/{movie_id}/search")) + .send() + .await + .expect("trigger search"); + assert_eq!(search.status(), reqwest::StatusCode::ACCEPTED); + + let releases: Vec = + reqwest::get(format!("{base}/api/movies/{movie_id}/releases")) + .await + .expect("list releases") + .json() + .await + .expect("releases json"); + assert!(releases.is_empty()); +} + +/// The recorded fixtures satisfy the real Prowlarr and TMDB clients, so a +/// pipeline wired to the fakes exercises the same parsing production does. +#[tokio::test] +async fn recorded_fixtures_satisfy_the_real_clients() { + let prowlarr = FakeProwlarr::start().await; + let client = ProwlarrClient::new(prowlarr.url(), API_KEY).expect("prowlarr client"); + + let indexers = client.indexers().await.expect("enumerate indexers"); + assert_eq!(indexers.len(), 1, "disabled indexers are filtered out"); + let indexer = &indexers[0]; + assert_eq!(indexer.id, INDEXER_ID); + assert!(indexer.capabilities_error.is_none()); + assert!(indexer.capabilities.movie.supports_id_search()); + + let releases = client + .search_indexer( + INDEXER_ID, + &SearchRequest::Movie { + imdb_id: "tt15239678".into(), + }, + ) + .await + .expect("movie search"); + assert_eq!(releases.len(), 2); + assert_eq!( + releases[0].name, + "Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX" + ); + assert_eq!(releases[0].size, Some(23_622_320_128)); + assert_eq!(releases[0].seeders, Some(128)); + + let tmdb = FakeTmdb::start().await; + let movie = TmdbClient::builder(API_KEY) + .base_url(tmdb.url()) + .build() + .expect("tmdb client") + .movie(TMDB_MOVIE_ID) + .await + .expect("movie lookup"); + assert_eq!(movie.title, "Dune: Part Two"); + assert_eq!(movie.original_language, "en"); + assert_eq!(movie.imdb_id.as_deref(), Some("tt15239678")); + let after_digital = NaiveDate::from_ymd_opt(2024, 5, 1).expect("valid date"); + assert!(movie.is_digitally_released(after_digital)); +} + +/// Torrent lifecycle against the real Transmission container: RPC semantics +/// are the seam most likely to surprise (DESIGN.md §12). +#[tokio::test] +async fn transmission_add_list_and_remove() { + let client = TransmissionClient::new(&transmission_url()).expect("valid endpoint"); + let name = format!("arr-e2e-{}", uuid::Uuid::new_v4()); + let metainfo = torrent_with_name(&name); + let download_dir = PathBuf::from("/tmp/arr-e2e"); + let request = || AddTorrent { + source: TorrentSource::Metainfo(metainfo.clone()), + label: "movies-main".into(), + download_dir: download_dir.clone(), + seed_ratio_limit: 1.5, + seed_idle_limit_minutes: 60, + }; + + let first = client.add_torrent(request()).await.expect("add torrent"); + assert!(!first.was_duplicate); + + let torrents = client.list_torrents().await.expect("list torrents"); + let listed = torrents + .iter() + .find(|torrent| torrent.id == first.id) + .expect("added torrent is authoritative in list"); + assert_eq!(listed.name, name); + assert_eq!(listed.hash, first.hash); + assert_eq!(listed.download_dir, download_dir); + assert_eq!(listed.labels, ["movies-main"]); + assert!((0.0..=1.0).contains(&listed.progress)); + + client + .remove_torrent(first.id, false) + .await + .expect("remove without data"); + + let second = client.add_torrent(request()).await.expect("add again"); + client + .remove_torrent(second.id, true) + .await + .expect("remove with data"); + + let torrents = client.list_torrents().await.expect("list after remove"); + assert!(torrents.iter().all(|torrent| torrent.id != second.id)); +} + +fn torrent_with_name(name: &str) -> Vec { + let piece_hash = [0_u8; 20]; + let mut bytes = format!("d4:infod6:lengthi1e4:name{}:{name}", name.len()).into_bytes(); + bytes.extend_from_slice(b"12:piece lengthi16384e6:pieces20:"); + bytes.extend_from_slice(&piece_hash); + bytes.extend_from_slice(b"ee"); + bytes +} diff --git a/crates/arr-meta/src/client.rs b/crates/arr-meta/src/client.rs index 93550e7..a4544e2 100644 --- a/crates/arr-meta/src/client.rs +++ b/crates/arr-meta/src/client.rs @@ -8,7 +8,7 @@ use serde::de::DeserializeOwned; use crate::cache::Cache; use crate::error::{Error, Result}; -use crate::model::{Movie, MovieSearchResult, RawMovie, RawSearchPage}; +use crate::model::{Movie, MovieSearchResult, RawFindPage, RawMovie, RawSearchPage}; /// TMDB's v3 API root. pub const DEFAULT_BASE_URL: &str = "https://api.themoviedb.org/3/"; @@ -89,6 +89,18 @@ impl TmdbClient { Ok(page.results.into_iter().map(Into::into).collect()) } + /// Resolve an `IMDb` title id through TMDB's external-id index. + /// + /// # Errors + /// + /// Any of [`Error`]; see its variants for what callers should distinguish. + pub async fn find_movie_by_imdb(&self, imdb_id: &str) -> Result> { + let path = format!("find/{}", imdb_id.trim()); + let params = [("external_source", "imdb_id".to_owned())]; + let page: RawFindPage = self.get_json(&path, ¶ms).await?; + Ok(page.movie_results.into_iter().map(Into::into).collect()) + } + /// Full detail for one movie, including the digital release date. /// /// One HTTP call: release dates come back appended to the same response diff --git a/crates/arr-meta/src/model.rs b/crates/arr-meta/src/model.rs index a1d8cd9..1df921c 100644 --- a/crates/arr-meta/src/model.rs +++ b/crates/arr-meta/src/model.rs @@ -107,6 +107,12 @@ pub(crate) struct RawSearchPage { pub(crate) results: Vec, } +#[derive(Debug, Deserialize)] +pub(crate) struct RawFindPage { + #[serde(default)] + pub(crate) movie_results: Vec, +} + #[derive(Debug, Deserialize)] pub(crate) struct RawSearchResult { id: u32, diff --git a/web/package.json b/web/package.json index e0a11e9..df5f22c 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,8 @@ "build": "tsc -b --noEmit && vite build", "preview": "vite preview", "typecheck": "tsc -b --noEmit", - "lint": "biome ci ." + "lint": "biome ci .", + "check-tokens": "node scripts/check-tokens.mjs" }, "devDependencies": { "@biomejs/biome": "^2.5.8", diff --git a/web/scripts/check-tokens.mjs b/web/scripts/check-tokens.mjs new file mode 100644 index 0000000..a28ee17 --- /dev/null +++ b/web/scripts/check-tokens.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +// Design-token gate (issue #45): components consume tokens from :root in +// web/src/style.css, never literal colour or spacing values. +// +// Allowed colour literals: inside a `:root` block (token definitions) and +// inside `@font-face`. Relative colour derived from a token — +// `oklch(from var(--x) …)` — counts as consuming the token and is allowed +// anywhere. Spacing properties (margin/padding/gap/inset) may only use +// var(), 0, auto and percentages; font-size only a --text-* token. + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import process from "node:process"; + +const webRoot = new URL("..", import.meta.url).pathname; +const SKIP_DIRS = new Set(["node_modules", "dist", "api"]); + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + if (!SKIP_DIRS.has(name)) { + walk(path, out); + } + } else if (/\.(css|ts|html)$/.test(name)) { + out.push(path); + } + } + return out; +} + +const COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|\b(?:oklch|oklab|lab|lch|rgba?|hsla?|hwb|color)\(/; +const RELATIVE_FROM_TOKEN = /\b(?:oklch|oklab|lab|lch|rgb|hsl|hwb|color)\(\s*from\s+var\(--/g; +const SPACING_PROP = /^(?:margin|padding|gap|row-gap|column-gap|inset)(?:-[a-z-]+)?$/; +const SPACING_VALUE = /^(?:var\(--[\w-]+\)|0|auto|-?[\d.]+%)$/; +const FONT_SIZE_VALUE = /^var\(--text-[\w-]+\)$/; + +const errors = []; + +function checkDeclaration(declaration, rel, line) { + // Relative-colour-from-token is token consumption, not a literal. + const neutral = declaration.replace(RELATIVE_FROM_TOKEN, "from-token("); + if (COLOR_LITERAL.test(neutral)) { + errors.push(`${rel}:${line}: literal colour — use a token from :root`); + } + const colon = declaration.indexOf(":"); + if (colon === -1) { + return; + } + const property = declaration.slice(0, colon).trim(); + const value = declaration.slice(colon + 1).trim(); + if (property === "font-size") { + if (!FONT_SIZE_VALUE.test(value)) { + errors.push(`${rel}:${line}: literal font-size "${value}" — use a --text-* token`); + } + return; + } + if (!SPACING_PROP.test(property)) { + return; + } + const bad = value.split(/\s+/).filter((part) => part && !SPACING_VALUE.test(part)); + if (bad.length > 0) { + errors.push(`${rel}:${line}: literal spacing "${bad.join(" ")}" — use a --space-* token`); + } +} + +function checkCss(source, rel) { + const stripped = source.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, " ")); + const blockStack = []; + let buffer = ""; + let bufferLine = 1; + let line = 1; + for (const char of stripped) { + if (char === "\n") { + line += 1; + } + if (char === "{") { + blockStack.push(buffer.trim() || "(anonymous)"); + buffer = ""; + bufferLine = line; + } else if (char === ";" || char === "}") { + const declaration = buffer.trim(); + const allowed = blockStack.some((sel) => /(^|,)\s*:root\b|@font-face/.test(sel)); + if (declaration && !allowed) { + checkDeclaration(declaration, rel, bufferLine); + } + if (char === "}") { + blockStack.pop(); + } + buffer = ""; + bufferLine = line; + } else { + if (buffer.trim() === "") { + bufferLine = line; + } + buffer += char; + } + } +} + +function checkPlain(source, rel) { + source.split("\n").forEach((rawLine, index) => { + const neutral = rawLine.replace(RELATIVE_FROM_TOKEN, "from-token("); + if (COLOR_LITERAL.test(neutral)) { + errors.push(`${rel}:${index + 1}: literal colour outside the token block`); + } + }); +} + +const files = [join(webRoot, "index.html"), ...walk(join(webRoot, "src"))]; +for (const file of files) { + const rel = relative(webRoot, file); + const source = readFileSync(file, "utf8"); + if (file.endsWith(".css")) { + checkCss(source, rel); + } else { + checkPlain(source, rel); + } +} + +if (errors.length > 0) { + console.error("check-tokens: components must consume design tokens:\n"); + for (const error of errors) { + console.error(` ${error}`); + } + process.exit(1); +} +console.log(`check-tokens: ${files.length} files clean`); diff --git a/web/src/style.css b/web/src/style.css index 6ce5847..c916b47 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -40,6 +40,24 @@ /* the one interactive hue */ --accent: oklch(0.8 0.12 220); + --accent-bright: oklch(0.88 0.12 220); + + /* dead lamp and pressed-control lowlight */ + --lamp-off: oklch(0.3 0.012 250); + --lowlight: oklch(0 0 0 / 30%); + + /* release verdicts (§9.3) — rejected is quiet slate, never fault red */ + --verdict-eligible: oklch(0.8 0.17 150); + --verdict-waived: oklch(0.82 0.14 80); + --verdict-rejected: oklch(0.68 0.02 250); + + /* library status (§4.2) — informational, never a severity ramp: + activity in one violet family, satisfaction in neutrals */ + --status-airing: oklch(0.78 0.14 305); + --status-incomplete: oklch(0.72 0.09 305); + --status-waiting: oklch(0.64 0.045 305); + --status-complete: oklch(0.85 0.01 250); + --status-ended: oklch(0.62 0.015 250); /* type */ --font-label: system-ui, "Segoe UI", sans-serif; @@ -47,6 +65,15 @@ ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, monospace; --font-display: "Rajdhani", "Avenir Next Condensed", "Arial Narrow", sans-serif; + /* type scale */ + --text-2xs: 0.6875rem; + --text-xs: 0.75rem; + --text-sm: 0.8125rem; + --text-base: 1rem; + --text-md: 1.0625rem; + --text-lg: 1.3125rem; + --text-xl: 1.625rem; + /* rhythm */ --space-1: 0.25rem; --space-2: 0.5rem; @@ -77,13 +104,13 @@ body { background: var(--ground); color: var(--ink); font-family: var(--font-label); - font-size: 1rem; + font-size: var(--text-base); line-height: 1.5; } .readout { font-family: var(--font-readout); - font-size: 0.8125rem; + font-size: var(--text-sm); } .dim { @@ -107,7 +134,7 @@ body { border-bottom: 0; border-top: 1px solid var(--line); justify-content: space-between; - font-size: 0.75rem; + font-size: var(--text-xs); } .rail-id { @@ -119,7 +146,7 @@ body { .wordmark { margin: 0; font-family: var(--font-display); - font-size: 1.625rem; + font-size: var(--text-xl); font-weight: 700; letter-spacing: 0.1em; line-height: 1; @@ -193,7 +220,7 @@ body { /* dead air: dark, no glow, no motion */ .lamp[data-state="off"] { - --lamp: oklch(0.3 0.012 250); + --lamp: var(--lamp-off); box-shadow: none; } @@ -318,7 +345,7 @@ body { .module-name { margin: 0; font-family: var(--font-display); - font-size: 1.0625rem; + font-size: var(--text-md); font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; @@ -326,7 +353,7 @@ body { } .module-master .module-name { - font-size: 1.3125rem; + font-size: var(--text-lg); } .module-status { @@ -349,7 +376,7 @@ body { .module-role { margin: var(--space-2) 0 0; - font-size: 0.6875rem; + font-size: var(--text-2xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.12em; @@ -358,7 +385,7 @@ body { .module-detail { margin: var(--space-1) 0 0; - font-size: 0.75rem; + font-size: var(--text-xs); color: var(--ink-muted); min-height: 1.2em; } @@ -406,7 +433,7 @@ body { min-height: 2.75rem; padding: 0 var(--space-4); font-family: var(--font-readout); - font-size: 0.8125rem; + font-size: var(--text-sm); text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); @@ -422,11 +449,11 @@ body { .control:hover { border-color: var(--accent); - color: oklch(from var(--accent) calc(l + 0.08) c h); + color: var(--accent-bright); } .control:active { - box-shadow: inset 0 1px oklch(0 0 0 / 30%); + box-shadow: inset 0 1px var(--lowlight); } .control:disabled {