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/bad4000000000
- Dune.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
+ -
+
+ alpha-redacted-1
+ https://indexer.invalid/download/alpha-redacted-1
+ Sat, 16 Mar 2024 12:30:00 +0000
+
+
+
+
+ -
+
+ 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 {