diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index ec5f3b6..3da4d44 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -91,7 +91,9 @@ jobs: curl -fsSL https://deb.nodesource.com/setup_22.x | bash - apt-get install -y --no-install-recommends nodejs corepack enable - corepack prepare pnpm@latest --activate + # Prepare the exact version package.json pins; corepack refuses to + # run a mismatched version under a packageManager pin. + corepack prepare "$(node -p "require('./web/package.json').packageManager")" --activate - name: Cache pnpm store if: steps.probe.outputs.present == 'true' diff --git a/.gitignore b/.gitignore index 1884ec6..2da8cb7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ result result-* /worktrees +*.tsbuildinfo diff --git a/.impeccable/design.json b/.impeccable/design.json new file mode 100644 index 0000000..9b7812e --- /dev/null +++ b/.impeccable/design.json @@ -0,0 +1,192 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-08-22T00:00:00.000Z", + "title": "Design System: arr — Master Control", + "extensions": { + "colorMeta": { + "ground": { + "role": "neutral", + "displayName": "Console Charcoal", + "canonical": "oklch(0.14 0.01 250)", + "tonalRamp": [ + "oklch(0.14 0.01 250)", + "oklch(0.18 0.012 250)", + "oklch(0.21 0.014 250)", + "oklch(0.3 0.015 250)", + "oklch(0.42 0.02 250)", + "oklch(0.58 0.015 250)", + "oklch(0.72 0.015 250)", + "oklch(0.93 0.008 250)" + ] + }, + "signal-ok": { + "role": "secondary", + "displayName": "Signal 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)"] + }, + "signal-warn": { + "role": "tertiary", + "displayName": "Caution 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)"] + }, + "signal-fault": { + "role": "secondary", + "displayName": "Fault Red", + "canonical": "oklch(0.68 0.21 25)", + "tonalRamp": ["oklch(0.42 0.15 25)", "oklch(0.55 0.19 25)", "oklch(0.68 0.21 25)", "oklch(0.8 0.14 25)"] + }, + "accent": { + "role": "primary", + "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)"] + } + }, + "typographyMeta": { + "display": { + "displayName": "Display (Rajdhani)", + "purpose": "Rajdhani 700 — wordmark and module names only. Machined, condensed, uppercase with 0.1-0.12em tracking. Self-hosted woff2 in web/public/fonts (no CDN: the SPA is embedded in the binary and must work offline)." + }, + "label": { + "displayName": "Label (system sans)", + "purpose": "system-ui 600 uppercase, 0.6875rem, 0.12em tracking — engraved micro-labels (module roles). Never a kicker above a heading." + }, + "readout": { + "displayName": "Readout (mono)", + "purpose": "ui-monospace stack — every value the operator reads: status words, details, version, rtt. Mono is for measurement, never costume." + } + }, + "shadows": [ + { + "name": "panel-highlight", + "value": "inset 0 1px oklch(1 0 0 / 4%)", + "purpose": "The machined top edge of every panel and control. The only non-lamp shadow in the system." + }, + { + "name": "lamp-glow", + "value": "0 0 6px var(--lamp), 0 0 14px oklch(from var(--lamp) l c h / 35%)", + "purpose": "A lamp's own light. Reserved for .lamp; never decoration on panels or text." + } + ], + "motion": [ + { + "name": "ease-out", + "value": "cubic-bezier(0.25, 1, 0.5, 1)", + "purpose": "Default easing for state transitions." + }, + { + "name": "duration-state", + "value": "150-300ms", + "purpose": "Hover/focus 150ms, lamp and trace state changes 300ms." + }, + { + "name": "power-up strike", + "value": "lamp-strike 420ms, staggered 120ms upstream→downstream, once", + "purpose": "The one authored moment: the board lights in signal order on first verdict. Guarded by prefers-reduced-motion." + } + ], + "breakpoints": [{ "name": "stack", "value": "46rem — the chain turns vertical, one junctioned trace" }], + "themes": { + "strategy": "committed dark only — a control room is lit by its lamps; the operator uses this on a homelab desk at night. color-scheme: dark is declared in the shell.", + "dark": { + "bg": "oklch(0.14 0.01 250)", + "ink": "oklch(0.93 0.008 250)", + "primary": "oklch(0.8 0.12 220)" + } + } + }, + "components": [ + { + "name": "Lamp", + "kind": "custom", + "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; }" + }, + { + "name": "Module (channel strip)", + "kind": "card", + "refersTo": "ground", + "description": "One unit on the chain: lamp + Rajdhani name + mono status word, role label, mono detail. Panel with hairline border and 1px inset highlight; 4px machined radius, never a soft card.", + "html": "

prowlarr

ok

indexers in

torznab search and rss

", + "css": ".module { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: inset 0 1px var(--highlight); padding: var(--space-4) var(--space-4) var(--space-3); }" + }, + { + "name": "Bus trace", + "kind": "custom", + "refersTo": "ground", + "description": "Signal path between modules: hairline + arrowhead SVG, tethered to its source module's row. Lit with the source's signal hue via data-tone; inert gray when nothing flows.", + "html": "", + "css": ".bus-trace { --trace: var(--line-strong); } .bus-trace line { stroke: var(--trace); } .bus-arrow { fill: var(--trace); } .bus-trace[data-tone=\"ok\"] { --trace: var(--signal-ok); }" + }, + { + "name": "Control", + "kind": "button", + "refersTo": "accent", + "description": "The interactive element: mono uppercase, phosphor-cyan text, panel ground, ≥44px hit target. Cyan belongs to interaction only — never to status.", + "html": "", + "css": ".control { min-height: 2.75rem; padding: 0 var(--space-4); font-family: var(--font-readout); text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); background: var(--panel-raised); border: 1px solid var(--line-strong); border-radius: var(--radius); }" + }, + { + "name": "Rail", + "kind": "nav", + "refersTo": "ground", + "description": "Full-width panel strip framing the board top (identity + verdict + controls) and bottom (readouts). Hairline-ruled, panel ground, inset highlight.", + "html": "

arr

all signals nominal

", + "css": ".rail { display: flex; align-items: center; gap: var(--space-6); padding: var(--space-3) var(--space-6); border-bottom: 1px solid var(--line); background: var(--panel); box-shadow: inset 0 1px var(--highlight); }" + } + ], + "narrative": { + "northStar": "Master Control", + "overview": "arr's UI is a broadcast master-control panel: the daemon is a signal chain (TMDB and Prowlarr feed in, Transmission is driven out) and every surface reads as lamps, readouts and traces on that chain. Near-black charcoal ground, machined panels with hairline borders and a 1px inset highlight, Rajdhani display names, monospace readouts, three semantic lamp hues plus one phosphor-cyan interactive hue. Committed dark. Built for the operator's one-glance question: can arr do its job, and if not, which upstream is down.", + "keyCharacteristics": [ + "Signal chain, not dashboard: topology mirrors DESIGN.md §3; no sidebar, no stat cards, no hero metric.", + "Lamps carry state, words carry proof — every hue is paired with a mono status word.", + "Rajdhani 700 uppercase for names, system sans for micro-labels, mono for every value.", + "Panels are machined: hairline border, inset highlight, 4px radius; never soft shadows or glass.", + "One authored motion: the power-up strike, upstream to downstream, once." + ], + "rules": [ + { + "name": "The Lamp-Hue Rule", + "body": "Green/amber/red belong to state and to nothing else. Interaction is phosphor-cyan. A saturated color that is neither a state nor an interactive affordance is a bug.", + "section": "colors" + }, + { + "name": "The Word-Beside-The-Lamp Rule", + "body": "Every lamp and lit trace is paired with a monospace status word. Remove all color and the board must still read.", + "section": "colors" + }, + { + "name": "The Readout Rule", + "body": "Monospace is for values the operator reads — statuses, sizes, latencies, versions. Prose and labels are never mono; names are never sans.", + "section": "typography" + }, + { + "name": "The Machined-Panel Rule", + "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 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.", + "section": "motion" + } + ], + "dos": [ + "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." + ], + "donts": [ + "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.", + "Don't animate idle or dead elements; the strike runs once, on real data." + ] + } +} diff --git a/.sqlx/query-0ace60e25bb38dd1075f0ab119547b12f719b90a3b1d3adc0995982a508ae7bc.json b/.sqlx/query-0ace60e25bb38dd1075f0ab119547b12f719b90a3b1d3adc0995982a508ae7bc.json new file mode 100644 index 0000000..75a643f --- /dev/null +++ b/.sqlx/query-0ace60e25bb38dd1075f0ab119547b12f719b90a3b1d3adc0995982a508ae7bc.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR IGNORE INTO title_owners (title_kind, title_id, owner_id) SELECT 'movie', ?, id FROM owners WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "0ace60e25bb38dd1075f0ab119547b12f719b90a3b1d3adc0995982a508ae7bc" +} diff --git a/.sqlx/query-3adb45309bbd3f1de7d968abd8e5a65d4c6a2b890a54af9d5d2d956cca783892.json b/.sqlx/query-3adb45309bbd3f1de7d968abd8e5a65d4c6a2b890a54af9d5d2d956cca783892.json new file mode 100644 index 0000000..a9d0770 --- /dev/null +++ b/.sqlx/query-3adb45309bbd3f1de7d968abd8e5a65d4c6a2b890a54af9d5d2d956cca783892.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT m.id AS \"id!: i64\",\n m.tmdb_id AS \"tmdb_id!: i64\",\n m.title AS \"title!: String\",\n m.year,\n m.wanted AS \"wanted!: bool\",\n m.root_id AS \"root_id!: i64\",\n r.path AS \"root_path!: String\",\n m.created_at AS \"created_at!: String\",\n (SELECT count(*) FROM media_files f\n WHERE f.owner_kind = 'movie' AND f.owner_id = m.id)\n AS \"file_count!: i64\",\n (SELECT coalesce(sum(f.size), 0) FROM media_files f\n WHERE f.owner_kind = 'movie' AND f.owner_id = m.id)\n AS \"size_on_disk!: i64\"\n FROM movies m\n JOIN roots r ON r.id = m.root_id\n WHERE (?1 IS NULL OR m.id = ?1)\n AND (?2 IS NULL OR m.tmdb_id = ?2)\n ORDER BY m.title, m.year, m.id\n ", + "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": "wanted!: bool", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "root_id!: i64", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "root_path!: String", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "created_at!: String", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "file_count!: i64", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "size_on_disk!: i64", + "ordinal": 9, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "3adb45309bbd3f1de7d968abd8e5a65d4c6a2b890a54af9d5d2d956cca783892" +} diff --git a/.sqlx/query-500cfed4016c5956bc73f2c9bd14f36e1c32672bd171009fb164e6593f98ec31.json b/.sqlx/query-500cfed4016c5956bc73f2c9bd14f36e1c32672bd171009fb164e6593f98ec31.json new file mode 100644 index 0000000..f9c6de9 --- /dev/null +++ b/.sqlx/query-500cfed4016c5956bc73f2c9bd14f36e1c32672bd171009fb164e6593f98ec31.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT title_id AS \"title_id!: i64\", owner_id AS \"owner_id!: i64\"\n FROM title_owners WHERE title_kind = 'movie' ORDER BY owner_id", + "describe": { + "columns": [ + { + "name": "title_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "owner_id!: i64", + "ordinal": 1, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false + ] + }, + "hash": "500cfed4016c5956bc73f2c9bd14f36e1c32672bd171009fb164e6593f98ec31" +} diff --git a/.sqlx/query-6d6796a0f35b6ee3e94c3ff2b9575c2912f03aae8d8fcf67b7fc354d41c15ede.json b/.sqlx/query-6d6796a0f35b6ee3e94c3ff2b9575c2912f03aae8d8fcf67b7fc354d41c15ede.json new file mode 100644 index 0000000..8deed9e --- /dev/null +++ b/.sqlx/query-6d6796a0f35b6ee3e94c3ff2b9575c2912f03aae8d8fcf67b7fc354d41c15ede.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO movies (tmdb_id, title, year, original_language, root_id, wanted) VALUES (?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "6d6796a0f35b6ee3e94c3ff2b9575c2912f03aae8d8fcf67b7fc354d41c15ede" +} diff --git a/.sqlx/query-b5aed94114cf4f1764ff5d6110196527b87bb0c61a7c692a42b2fada7e1ed8e8.json b/.sqlx/query-b5aed94114cf4f1764ff5d6110196527b87bb0c61a7c692a42b2fada7e1ed8e8.json new file mode 100644 index 0000000..35c070f --- /dev/null +++ b/.sqlx/query-b5aed94114cf4f1764ff5d6110196527b87bb0c61a7c692a42b2fada7e1ed8e8.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\", name AS \"label!: String\" FROM owners ORDER BY name", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "label!: String", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + false + ] + }, + "hash": "b5aed94114cf4f1764ff5d6110196527b87bb0c61a7c692a42b2fada7e1ed8e8" +} diff --git a/Cargo.lock b/Cargo.lock index 142105b..7f753f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,6 +48,19 @@ dependencies = [ [[package]] name = "arr-compat" version = "0.1.0" +dependencies = [ + "arr-db", + "arr-meta", + "axum", + "reqwest", + "serde", + "serde_json", + "sqlx", + "tempfile", + "tokio", + "tracing", + "wiremock", +] [[package]] name = "arr-core" @@ -61,8 +74,12 @@ name = "arr-daemon" version = "0.1.0" dependencies = [ "arr-api", + "arr-compat", "arr-db", + "arr-meta", "axum", + "include_dir", + "mime_guess", "reqwest", "serde", "sqlx", @@ -1014,6 +1031,25 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1163,6 +1199,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.2.2" @@ -2412,6 +2458,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/Justfile b/Justfile index cfc8177..3268982 100644 --- a/Justfile +++ b/Justfile @@ -49,8 +49,8 @@ web-check: #!/usr/bin/env bash set -euo pipefail if [ ! -f web/package.json ]; then echo "web/ not present yet, skipping"; exit 0; fi - biome ci web/ pnpm -C web install --frozen-lockfile + pnpm -C web exec biome ci . pnpm -C web exec tsc -b --noEmit # Build the SPA and the binary that embeds it. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..27ebeef --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,87 @@ +# Product + + + +All facts below are drawn from DESIGN.md and the issue tracker; none were +confirmed in an interview (the driving session mandated autonomous operation). +Items marked *(inferred)* are best-effort readings, not user-approved. + +## Platform + +web + +## Stack + +Vite + TypeScript SPA, no framework *(fixed by DESIGN.md §11: `web/` is a +Vite + TypeScript SPA embedded in the Rust binary via `include_dir`; the +reference project `~/tea/maestro` is framework-free vanilla TS)*. + +## Users + +A single household's operator (the person who runs the media stack) and, via +filtered views, a small set of family members. Used on a self-hosted LAN/VPN, +mostly desktop, sometimes phone. The operator's job: decide what media is +wanted, watch it get sourced, and resolve the queue items that need a human +call (no-PT-source queue, waivers, manual grabs). + +## Product Purpose + +One service replacing Radarr, Sonarr and later Bazarr: decide what you want, +find it, fetch it, put it somewhere Jellyfin can read. Success is a 20-40 MB +single binary doing what five .NET services do at 600-900 MB — with a UI whose +manual-search view is actually usable. + +## Positioning + +Policy engine classifies every release candidate before the human sees it. +The UI shows decisions (eligible / waived / rejected, with the killing rule +named), not raw release-name dumps. Radarr cannot truthfully claim this; its +manual search buries the decision under the release name column. + +## Operating Context + +- Self-hosted behind VPN + Authelia; the app itself has no auth layer. +- API-first: the SPA is one client of the OpenAPI-described HTTP API, no + privileged path. +- Coexists with Prowlarr (indexers), Transmission (downloads), Jellyfin + (playback), Jellyseerr (requests), ntfy (notifications). + +## Capabilities and Constraints + +- Phase 1 (now): skeleton only — health endpoint, embedded empty SPA shell + with one page rendering `GET /api/health`. +- Phase 5 brings the real UI: unified search (§9.2), manual search buckets + (§9.3), library views, queues. +- The manual-search view is the reason the UI exists; §9.3 fixes chip-based + columns, three buckets, secondary release names. Future tokens/components + must serve dense, scannable, chip-heavy tabular data. +- CI gate: `biome ci web/` and `tsc -b --noEmit`; target under 5 min total. +- Bundle is embedded at compile time; no CDN, no external assets at runtime + *(inferred from the include_dir + single-binary requirement)*. + +## Brand Commitments + +Name: `arr`, lowercase. No logo, no committed palette or typography yet +*(inferred: nothing visual exists in the repo)*. Voice of DESIGN.md is terse, +technical, anti-noise — the UI should read the same way. + +## Evidence on Hand + +- DESIGN.md — the contract, including §9.2/§9.3 UI specs. +- `~/tea/maestro` — reference for repo/web mechanics, not visual identity. +- No screenshots, no user testimonials, no existing UI. Nothing to fabricate. + +## Product Principles + +- Decisions over data: surface the classification, keep the raw evidence one + expand away. +- Quiet by default: default views show only what needs attention; everything + satisfied collapses (mirrors §4.2 and §9.5's notification restraint). +- Density with fixed structure: chips and fixed-width columns, never + horizontal scroll. +- One surface of truth: the API; the UI never has a privileged path. + +## Accessibility & Inclusion + +No product-specific requirement established. Household includes a pre-reader +child, but the child consumes Jellyfin, not this UI *(inferred)*. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..145abb6 --- /dev/null +++ b/biome.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json", + "files": { + "includes": ["web/**", "!web/src/api/**", "!web/dist/**", "!web/node_modules/**"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/crates/arr-compat/Cargo.toml b/crates/arr-compat/Cargo.toml index 4dcdca8..19b54cc 100644 --- a/crates/arr-compat/Cargo.toml +++ b/crates/arr-compat/Cargo.toml @@ -7,6 +7,19 @@ repository.workspace = true publish = false [dependencies] +arr-db = { workspace = true } +arr-meta = { workspace = true } +axum = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +reqwest = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true } +wiremock = { workspace = true } [lints] workspace = true diff --git a/crates/arr-compat/src/error.rs b/crates/arr-compat/src/error.rs new file mode 100644 index 0000000..f175696 --- /dev/null +++ b/crates/arr-compat/src/error.rs @@ -0,0 +1,100 @@ +//! Failures, in the shapes Radarr produces them. +//! +//! Jellyseerr surfaces the response body verbatim in its own logs and settings +//! UI, so a validation failure that comes back in Radarr's shape is readable +//! there without anyone knowing this is not Radarr. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Serialize; + +/// Everything the shim can answer with instead of a result. +#[derive(Debug)] +pub enum CompatError { + /// The request was well-formed but names something that does not exist + /// here — usually a root folder path Jellyseerr was configured with by + /// hand. `property` is Radarr's field name, `PascalCase` as it sends it. + Validation { + /// Radarr's name for the offending field, e.g. `RootFolderPath`. + property: &'static str, + /// What is wrong with it, in a sentence. + message: String, + }, + /// No such movie. + NotFound, + /// TMDB is unconfigured or would not answer. + Upstream(String), + /// The database would not answer. + Database(String), +} + +/// One entry of Radarr's validation-failure body. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ValidationFailure { + property_name: &'static str, + error_message: String, + severity: &'static str, +} + +/// Radarr's shape for everything that is not a validation failure. +#[derive(Debug, Serialize)] +struct Message { + message: String, +} + +impl IntoResponse for CompatError { + fn into_response(self) -> Response { + match self { + // A list, not an object: Radarr's model binder reports every + // failing field at once and Jellyseerr's client reads index 0. + Self::Validation { property, message } => ( + StatusCode::BAD_REQUEST, + Json(vec![ValidationFailure { + property_name: property, + error_message: message, + severity: "error", + }]), + ) + .into_response(), + Self::NotFound => ( + StatusCode::NOT_FOUND, + Json(Message { + message: "NotFound".into(), + }), + ) + .into_response(), + Self::Upstream(detail) => { + tracing::warn!(%detail, "compat shim upstream failure"); + (StatusCode::BAD_GATEWAY, Json(Message { message: detail })).into_response() + } + Self::Database(detail) => { + tracing::error!(%detail, "compat shim database error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(Message { + message: "database error".into(), + }), + ) + .into_response() + } + } + } +} + +impl From for CompatError { + fn from(error: sqlx::Error) -> Self { + if matches!(error, sqlx::Error::RowNotFound) { + Self::NotFound + } else { + Self::Database(error.to_string()) + } + } +} + +impl From for CompatError { + fn from(error: arr_meta::Error) -> Self { + Self::Upstream(error.to_string()) + } +} diff --git a/crates/arr-compat/src/lib.rs b/crates/arr-compat/src/lib.rs index ac13547..5d63197 100644 --- a/crates/arr-compat/src/lib.rs +++ b/crates/arr-compat/src/lib.rs @@ -1 +1,118 @@ -//! arr-compat — see DESIGN.md. +//! arr-compat — the Radarr v3 slice Jellyseerr calls. See DESIGN.md §9.4. +//! +//! Jellyseerr already does Jellyfin user auth, discovery and request approval, +//! none of which this project is trying to solve. So it stays, and it keeps +//! talking to what it believes is a Radarr. This crate is the translation +//! layer and nothing else: +//! +//! - It maps onto the real domain (`roots`, `movies`, `owners`) and holds no +//! state of its own. +//! - It never reaches into `arr-core`, and `arr-core` never learns what a +//! "quality profile" is. Radarr's vocabulary stops at this crate's edge. +//! - Where Radarr's model and this one disagree, this one wins and the +//! difference is absorbed here. Policy attaches to a root (§5.1), so the +//! quality profile Jellyseerr sends is read, acknowledged and dropped; the +//! root folder path is what decides which policy a request lands under. +//! +//! What is deliberately not here: everything Jellyseerr does not call. This is +//! not an attempt at Radarr compatibility in general. + +mod error; +mod model; +mod movies; +mod system; + +use std::sync::Arc; + +use arr_db::Db; +use arr_meta::TmdbClient; +use axum::routing::get; +use axum::Router; + +pub use error::CompatError; +pub use model::{ + AddMovie, AddOptions, MovieResource, QualityProfile, RootFolder, SystemStatus, Tag, +}; + +/// Everything here hangs off `/api/v3`, the prefix Radarr v3 serves and the +/// one Jellyseerr appends to whatever URL an operator types into it. +pub const API_PREFIX: &str = "/api/v3"; + +/// What the shim answers `system/status` with. +/// +/// Jellyseerr branches on the major version to decide which Radarr dialect to +/// speak, so this is not decoration: it has to name a real release that serves +/// `/api/v3`, and it has to stay put. +pub const RADARR_VERSION: &str = "5.14.0.9383"; + +/// What the shim needs to answer: the real database, and TMDB for `movie/lookup`. +/// +/// Cheap to clone — [`Db`] is a pool handle and the TMDB client is shared +/// behind an [`Arc`] so its response cache is not duplicated per clone. +#[derive(Debug, Clone)] +pub struct CompatState { + database: Db, + tmdb: Option>, +} + +impl CompatState { + /// The shim over a migrated database, with no TMDB client. + /// + /// Without one `movie/lookup` is the only endpoint that stops working, and + /// it fails loudly rather than answering an empty list — an empty lookup + /// looks to Jellyseerr like "TMDB has never heard of this film". + #[must_use] + pub fn new(database: Db) -> Self { + Self { + database, + tmdb: None, + } + } + + /// Attach the TMDB client `movie/lookup` searches through. + #[must_use] + pub fn with_tmdb(mut self, tmdb: Arc) -> Self { + self.tmdb = Some(tmdb); + self + } + + pub(crate) fn pool(&self) -> &sqlx::SqlitePool { + self.database.pool() + } + + pub(crate) fn database(&self) -> &Db { + &self.database + } + + pub(crate) fn tmdb(&self) -> Result<&TmdbClient, CompatError> { + self.tmdb + .as_deref() + .ok_or_else(|| CompatError::Upstream("TMDB is not configured".into())) + } +} + +/// The shim, mounted under [`API_PREFIX`], ready to `merge` into the app. +pub fn router(state: CompatState) -> Router { + Router::new().nest(API_PREFIX, endpoints().with_state(state)) +} + +/// Radarr runs on ASP.NET, whose routing is case-insensitive, and Jellyseerr +/// takes advantage: it asks for `qualityProfile` while the documentation says +/// `qualityprofile`. `axum` matches exactly, so both spellings are registered +/// rather than lower-casing every incoming path and losing the ability to see +/// what was actually requested in a trace. +fn endpoints() -> Router { + Router::new() + .route("/system/status", get(system::status)) + .route("/rootfolder", get(system::root_folders)) + .route("/rootFolder", get(system::root_folders)) + .route("/qualityprofile", get(system::quality_profiles)) + .route("/qualityProfile", get(system::quality_profiles)) + .route("/tag", get(system::tags)) + .route("/movie", get(movies::list).post(movies::add)) + .route("/movie/lookup", get(movies::lookup)) + .route("/movie/{movie_id}", get(movies::get)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/arr-compat/src/model.rs b/crates/arr-compat/src/model.rs new file mode 100644 index 0000000..effa6ff --- /dev/null +++ b/crates/arr-compat/src/model.rs @@ -0,0 +1,378 @@ +//! Radarr's wire vocabulary. Nothing in here is a domain type — these are the +//! shapes Jellyseerr parses, and they exist so that no other crate has to know +//! what `qualityProfileId` means. +//! +//! Field names are Radarr's, camelCase, and the field *set* is the subset +//! Jellyseerr reads plus what makes a response look coherent to a human +//! reading it in a browser. Radarr sends a great deal more. + +use serde::{Deserialize, Serialize}; + +use crate::RADARR_VERSION; + +/// Where TMDB serves the images whose paths its API returns. +const TMDB_IMAGE_BASE: &str = "https://image.tmdb.org/t/p/original"; + +/// `GET /api/v3/system/status`. +/// +/// Jellyseerr reads `version` and `appName`; the rest is what a Radarr answers +/// and costs nothing to keep truthful about the process actually serving it. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +// Radarr's own payload is a flat run of feature flags. Reshaping it into +// something clippy likes would mean not being Radarr. +#[allow(clippy::struct_excessive_bools)] +pub struct SystemStatus { + pub app_name: &'static str, + pub instance_name: &'static str, + pub version: &'static str, + pub build_time: &'static str, + pub is_debug: bool, + pub is_production: bool, + pub is_admin: bool, + pub is_user_interactive: bool, + pub startup_path: &'static str, + pub app_data: &'static str, + pub os_name: &'static str, + pub is_net_core: bool, + pub is_linux: bool, + pub is_osx: bool, + pub is_windows: bool, + pub is_docker: bool, + pub mode: &'static str, + pub branch: &'static str, + /// DESIGN.md §2: the perimeter is the VPN. Radarr's own word for that is + /// `none`, and saying so is more honest than pretending to hold a key. + pub authentication: &'static str, + pub url_base: &'static str, + pub runtime_version: &'static str, + pub package_version: &'static str, +} + +impl Default for SystemStatus { + fn default() -> Self { + Self { + app_name: "Radarr", + instance_name: "arr", + version: RADARR_VERSION, + build_time: "2025-01-01T00:00:00Z", + is_debug: false, + is_production: true, + is_admin: false, + is_user_interactive: false, + startup_path: "/", + app_data: "/config", + os_name: "linux", + is_net_core: true, + is_linux: true, + is_osx: false, + is_windows: false, + is_docker: false, + mode: "console", + branch: "master", + authentication: "none", + url_base: "", + runtime_version: "8.0.0", + package_version: concat!("arr ", env!("CARGO_PKG_VERSION")), + } + } +} + +/// `GET /api/v3/rootfolder`. One entry per real movie root (§5.1). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RootFolder { + /// The real root's row id, so a Jellyseerr default folder survives. + pub id: i64, + pub path: String, + pub accessible: bool, + /// Always zero. Nothing here decides anything on free space, and reporting + /// a number would mean stat-ing a filesystem on every settings page load. + pub free_space: i64, + pub unmapped_folders: Vec, +} + +/// `GET /api/v3/qualityprofile`. One fake profile per root (§5.1). +/// +/// The profile is a fiction that exists because Jellyseerr insists on picking +/// one per request. It carries the root's policy name so that the operator +/// choosing in Jellyseerr's dropdown is choosing something meaningful, but +/// whichever id comes back on the request is ignored — the root folder path +/// decides. `id` is the root's id, so the two dropdowns line up. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QualityProfile { + pub id: i64, + pub name: String, + /// There is no upgrade loop here at all (§5.4). + pub upgrade_allowed: bool, + pub cutoff: i64, + /// Empty on purpose: the real rules are policy rows, not a quality list, + /// and inventing a plausible-looking one would be a second place for a + /// reader to look for the truth. + pub items: Vec, + pub min_format_score: i64, + pub cutoff_format_score: i64, + pub format_items: Vec, +} + +/// `GET /api/v3/tag`. Radarr's tags are this project's owners (§4.3). +#[derive(Debug, Clone, Serialize)] +pub struct Tag { + pub id: i64, + pub label: String, +} + +/// A poster, in the shape Radarr reports one. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Image { + pub cover_type: &'static str, + pub remote_url: String, + pub url: String, +} + +impl Image { + /// TMDB returns a path fragment, not a URL. + fn poster(poster_path: &str) -> Self { + let url = format!("{TMDB_IMAGE_BASE}{poster_path}"); + Self { + cover_type: "poster", + remote_url: url.clone(), + url, + } + } +} + +/// A movie, as Radarr describes one. +/// +/// `id` is the load-bearing field: Jellyseerr treats a non-zero `id` on a +/// lookup result as "already in the library" and skips the add. So a lookup +/// hit that matches a local row must carry that row's id, and one that does +/// not must carry zero. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(clippy::struct_excessive_bools)] +pub struct MovieResource { + pub id: i64, + pub title: String, + pub sort_title: String, + pub year: i64, + pub tmdb_id: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub imdb_id: Option, + pub title_slug: String, + pub folder_name: String, + pub path: String, + pub root_folder_path: String, + /// Radarr's `monitored` is this project's `wanted` (§4.1). + pub monitored: bool, + pub has_file: bool, + /// Radarr's older name for `has_file`. Jellyseerr reads this one. + pub downloaded: bool, + pub is_available: bool, + pub size_on_disk: i64, + pub status: &'static str, + pub minimum_availability: &'static str, + pub quality_profile_id: i64, + /// Radarr's older name for `quality_profile_id`. Jellyseerr reads both. + pub profile_id: i64, + pub added: String, + pub tags: Vec, + pub images: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub overview: Option, + pub runtime: i64, +} + +impl MovieResource { + /// The fields every mapping shares, with everything library-side empty. + fn blank(tmdb_id: i64, title: &str, year: Option) -> Self { + Self { + id: 0, + title: title.to_owned(), + sort_title: title.to_lowercase(), + year: year.unwrap_or_default(), + tmdb_id, + imdb_id: None, + title_slug: title_slug(title, tmdb_id), + folder_name: String::new(), + path: String::new(), + root_folder_path: String::new(), + monitored: false, + has_file: false, + downloaded: false, + // Everything reachable on an indexer is available to request; the + // §6.2 digital-release gate is the reconcile loop's business, not + // a reason to hide a title from the person asking for it. + is_available: true, + size_on_disk: 0, + status: "released", + minimum_availability: "released", + quality_profile_id: 0, + profile_id: 0, + added: String::new(), + tags: Vec::new(), + images: Vec::new(), + overview: None, + runtime: 0, + } + } + + /// A row of the real `movies` table, dressed as a Radarr movie. + pub(crate) fn from_library(row: &crate::movies::MovieRow, tags: Vec) -> Self { + let folder = folder_name(&row.title, row.year, row.tmdb_id); + Self { + id: row.id, + folder_name: format!("{}/{folder}", row.root_path), + path: format!("{}/{folder}", row.root_path), + root_folder_path: row.root_path.clone(), + monitored: row.wanted, + has_file: row.file_count > 0, + downloaded: row.file_count > 0, + size_on_disk: row.size_on_disk, + quality_profile_id: row.root_id, + profile_id: row.root_id, + added: row.created_at.clone(), + tags, + ..Self::blank(row.tmdb_id, &row.title, row.year) + } + } + + /// A TMDB search hit, dressed as a Radarr movie. + pub(crate) fn from_search(hit: &arr_meta::MovieSearchResult) -> Self { + Self { + images: hit + .poster_path + .as_deref() + .map(Image::poster) + .into_iter() + .collect(), + overview: hit.overview.clone(), + ..Self::blank( + i64::from(hit.tmdb_id), + &hit.title, + hit.year().map(i64::from), + ) + } + } + + /// A fully detailed TMDB movie, dressed as a Radarr movie. + pub(crate) fn from_tmdb(movie: &arr_meta::Movie) -> Self { + Self { + imdb_id: movie.imdb_id.clone(), + images: movie + .poster_path + .as_deref() + .map(Image::poster) + .into_iter() + .collect(), + overview: movie.overview.clone(), + runtime: movie.runtime.map_or(0, i64::from), + ..Self::blank( + i64::from(movie.tmdb_id), + &movie.title, + movie.year().map(i64::from), + ) + } + } + + /// Overlay what the library knows about a title a lookup just found. + /// + /// Everything descriptive stays TMDB's — it is fresher, and the local row + /// only stores a title and a year — but the identity and intent fields + /// come from the row, because those are what Jellyseerr acts on. + pub(crate) fn with_library(mut self, row: &crate::movies::MovieRow) -> Self { + let folder = folder_name(&row.title, row.year, row.tmdb_id); + self.id = row.id; + self.folder_name = format!("{}/{folder}", row.root_path); + self.path.clone_from(&self.folder_name); + self.root_folder_path.clone_from(&row.root_path); + self.monitored = row.wanted; + self.has_file = row.file_count > 0; + self.downloaded = self.has_file; + self.size_on_disk = row.size_on_disk; + self.quality_profile_id = row.root_id; + self.profile_id = row.root_id; + self.added.clone_from(&row.created_at); + self + } +} + +/// `POST /api/v3/movie`. Jellyseerr sends a good deal more than this; the +/// fields absent here are absent because nothing is done with them. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddMovie { + pub tmdb_id: i64, + #[serde(default)] + pub title: String, + #[serde(default)] + pub year: Option, + /// Which root, and so which policy (§5.1). The only routing input. + #[serde(default)] + pub root_folder_path: Option, + /// Read and dropped. Kept in the struct so the reason is visible here + /// rather than being an absence someone has to notice. + #[serde(default)] + pub quality_profile_id: Option, + #[serde(default = "default_true")] + pub monitored: bool, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub add_options: Option, +} + +/// Radarr's per-request flags. `search_for_movie` is read and dropped: the +/// reconcile loop searches everything wanted (§8), so asking is redundant and +/// obeying it would be a second search path to keep correct. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddOptions { + #[serde(default)] + pub search_for_movie: bool, +} + +fn default_true() -> bool { + true +} + +/// Radarr's slug: lower-case, punctuation dropped, spaces hyphenated, id +/// appended. Jellyseerr echoes it back on add and never parses it. +fn title_slug(title: &str, tmdb_id: i64) -> String { + let mut slug = String::with_capacity(title.len() + 8); + let mut pending_dash = false; + for character in title.chars() { + if character.is_ascii_alphanumeric() { + if pending_dash && !slug.is_empty() { + slug.push('-'); + } + pending_dash = false; + slug.extend(character.to_lowercase()); + } else { + pending_dash = true; + } + } + if slug.is_empty() { + return tmdb_id.to_string(); + } + format!("{slug}-{tmdb_id}") +} + +/// The §7.4 folder: `Title (Year) [tmdbid-N]`. The provider id is what turns +/// Jellyfin's matching from string guessing into a lookup. +/// +/// Path separators are stripped rather than escaped — a title containing one +/// would otherwise name a directory that is not the one meant. +pub(crate) fn folder_name(title: &str, year: Option, tmdb_id: i64) -> String { + let safe: String = title + .chars() + .filter(|character| !matches!(character, '/' | '\\') && !character.is_control()) + .collect(); + let safe = safe.trim(); + match year { + Some(year) => format!("{safe} ({year}) [tmdbid-{tmdb_id}]"), + None => format!("{safe} [tmdbid-{tmdb_id}]"), + } +} diff --git a/crates/arr-compat/src/movies.rs b/crates/arr-compat/src/movies.rs new file mode 100644 index 0000000..e5f08d3 --- /dev/null +++ b/crates/arr-compat/src/movies.rs @@ -0,0 +1,322 @@ +//! `movie`, `movie/{id}` and `movie/lookup`. +//! +//! Two of Radarr's ideas have to be absorbed here rather than passed on: +//! +//! - **The quality profile.** Policy attaches to a root (§5.1), so the profile +//! id on an add is read and dropped and `rootFolderPath` decides everything. +//! - **`searchForMovie`.** The reconcile loop searches whatever is wanted +//! (§8). Acting on the flag would be a second search path to keep correct, +//! so it is dropped too. + +use std::collections::HashMap; + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::Json; +use serde::Deserialize; + +use crate::error::CompatError; +use crate::model::{AddMovie, MovieResource}; +use crate::CompatState; + +/// A row of `movies` with everything a Radarr movie needs joined on. +#[derive(Debug, Clone)] +pub(crate) struct MovieRow { + pub(crate) id: i64, + pub(crate) tmdb_id: i64, + pub(crate) title: String, + pub(crate) year: Option, + pub(crate) wanted: bool, + pub(crate) root_id: i64, + pub(crate) root_path: String, + pub(crate) created_at: String, + pub(crate) file_count: i64, + pub(crate) size_on_disk: i64, +} + +/// `?tmdbId=` on the list endpoint, and nothing else Radarr accepts there. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ListQuery { + #[serde(default)] + tmdb_id: Option, +} + +/// `?term=` on lookup. +#[derive(Debug, Deserialize)] +pub(crate) struct LookupQuery { + #[serde(default)] + term: String, +} + +/// `GET /api/v3/movie`, optionally narrowed to one TMDB id — Jellyseerr's +/// existence check. +pub(crate) async fn list( + State(state): State, + Query(query): Query, +) -> Result>, CompatError> { + let rows = load_movies(&state, None, query.tmdb_id).await?; + let mut tags = load_tags(&state).await?; + Ok(Json( + rows.iter() + .map(|row| MovieResource::from_library(row, tags.remove(&row.id).unwrap_or_default())) + .collect(), + )) +} + +/// `GET /api/v3/movie/{id}`. +pub(crate) async fn get( + State(state): State, + Path(id): Path, +) -> Result, CompatError> { + let row = load_movies(&state, Some(id), None) + .await? + .into_iter() + .next() + .ok_or(CompatError::NotFound)?; + let tags = load_tags(&state).await?.remove(&row.id).unwrap_or_default(); + Ok(Json(MovieResource::from_library(&row, tags))) +} + +/// `POST /api/v3/movie` — the request Jellyseerr approves turning into a +/// wanted movie under the root the operator picked. +pub(crate) async fn add( + State(state): State, + Json(input): Json, +) -> Result<(StatusCode, Json), CompatError> { + if input.tmdb_id <= 0 { + return Err(CompatError::Validation { + property: "TmdbId", + message: "a TMDB id is required".into(), + }); + } + let title = input.title.trim(); + if title.is_empty() { + return Err(CompatError::Validation { + property: "Title", + message: "a title is required".into(), + }); + } + let root = resolve_root(&state, input.root_folder_path.as_deref()).await?; + + if let Some(existing) = load_movies(&state, None, Some(input.tmdb_id)) + .await? + .first() + { + return Err(CompatError::Validation { + property: "TmdbId", + message: format!("{} has already been added", existing.title), + }); + } + + // §5.2 is expressed against the title's original language, and nothing + // else fills it in yet, so take it while a TMDB client is already at hand. + // Best effort: TMDB being down is not a reason to refuse a request. + let original_language = original_language(&state, input.tmdb_id).await; + + let inserted = sqlx::query!( + "INSERT INTO movies (tmdb_id, title, year, original_language, root_id, wanted) \ + VALUES (?, ?, ?, ?, ?, ?)", + input.tmdb_id, + title, + input.year, + original_language, + root, + input.monitored, + ) + .execute(state.pool()) + .await? + .last_insert_rowid(); + + attach_owners(&state, inserted, &input.tags).await?; + + let row = load_movies(&state, Some(inserted), None) + .await? + .into_iter() + .next() + .ok_or(CompatError::NotFound)?; + let tags = load_tags(&state).await?.remove(&row.id).unwrap_or_default(); + Ok(( + StatusCode::CREATED, + Json(MovieResource::from_library(&row, tags)), + )) +} + +/// `GET /api/v3/movie/lookup?term=` — TMDB search, with the library's own id +/// stamped onto anything already added. +pub(crate) async fn lookup( + State(state): State, + Query(query): Query, +) -> Result>, CompatError> { + let term = query.term.trim(); + if term.is_empty() { + return Ok(Json(Vec::new())); + } + let tmdb = state.tmdb()?; + + if let Some(id) = term.strip_prefix("tmdb:") { + let Ok(id) = id.trim().parse::() else { + return Ok(Json(Vec::new())); + }; + return match tmdb.movie(id).await { + Ok(movie) => { + let resource = MovieResource::from_tmdb(&movie); + let existing = load_movies(&state, None, Some(i64::from(movie.tmdb_id))).await?; + Ok(Json(vec![match existing.first() { + Some(row) => resource.with_library(row), + None => resource, + }])) + } + // Radarr answers an unknown id with an empty list, not a 404, and + // Jellyseerr reads that as "not a film" rather than as an outage. + Err(arr_meta::Error::NotFound { .. }) => Ok(Json(Vec::new())), + Err(error) => Err(error.into()), + }; + } + + // `imdb:` is the other prefix Radarr accepts. TMDB's find-by-external-id + // endpoint is not wired up here, and guessing from the digits would return + // the wrong film, so it answers empty until there is a reason to add it. + if term.starts_with("imdb:") { + tracing::debug!(term, "compat lookup by IMDb id is not supported"); + return Ok(Json(Vec::new())); + } + + let hits = tmdb.search_movies(term, None).await?; + let library: HashMap = load_movies(&state, None, None) + .await? + .into_iter() + .map(|row| (row.tmdb_id, row)) + .collect(); + + Ok(Json( + hits.iter() + .map(|hit| { + let resource = MovieResource::from_search(hit); + match library.get(&i64::from(hit.tmdb_id)) { + Some(row) => resource.with_library(row), + None => resource, + } + }) + .collect(), + )) +} + +/// Every movie, or the one matching an id or a TMDB id. +/// +/// One query rather than three near-identical ones: a `NULL` filter matches +/// everything, so the shape of the row mapping stays in a single place. +async fn load_movies( + state: &CompatState, + id: Option, + tmdb_id: Option, +) -> Result, CompatError> { + Ok(sqlx::query_as!( + MovieRow, + r#" + SELECT m.id AS "id!: i64", + m.tmdb_id AS "tmdb_id!: i64", + m.title AS "title!: String", + m.year, + m.wanted AS "wanted!: bool", + m.root_id AS "root_id!: i64", + r.path AS "root_path!: String", + m.created_at AS "created_at!: String", + (SELECT count(*) FROM media_files f + WHERE f.owner_kind = 'movie' AND f.owner_id = m.id) + AS "file_count!: i64", + (SELECT coalesce(sum(f.size), 0) FROM media_files f + WHERE f.owner_kind = 'movie' AND f.owner_id = m.id) + AS "size_on_disk!: i64" + FROM movies m + JOIN roots r ON r.id = m.root_id + WHERE (?1 IS NULL OR m.id = ?1) + AND (?2 IS NULL OR m.tmdb_id = ?2) + ORDER BY m.title, m.year, m.id + "#, + id, + tmdb_id, + ) + .fetch_all(state.pool()) + .await?) +} + +/// Owner ids per movie, in one query. The library is small enough that +/// fetching the whole mapping beats a query per row. +async fn load_tags(state: &CompatState) -> Result>, CompatError> { + let rows = sqlx::query!( + r#"SELECT title_id AS "title_id!: i64", owner_id AS "owner_id!: i64" + FROM title_owners WHERE title_kind = 'movie' ORDER BY owner_id"# + ) + .fetch_all(state.pool()) + .await?; + let mut tags: HashMap> = HashMap::new(); + for row in rows { + tags.entry(row.title_id).or_default().push(row.owner_id); + } + Ok(tags) +} + +/// Which root a `rootFolderPath` names, rejecting anything else. +/// +/// No default and no fallback to the quality profile: guessing here files a +/// film under the wrong policy, and for the `kids` root that means the wrong +/// audio in front of a child (§5.2). +async fn resolve_root(state: &CompatState, path: Option<&str>) -> Result { + let Some(path) = path.map(normalise_path).filter(|path| !path.is_empty()) else { + return Err(CompatError::Validation { + property: "RootFolderPath", + message: "a root folder path is required".into(), + }); + }; + state + .database() + .list_roots() + .await? + .into_iter() + .find(|root| root.kind == "movie" && normalise_path(&root.path) == path) + .map(|root| root.id) + .ok_or(CompatError::Validation { + property: "RootFolderPath", + message: format!("{path} is not a configured movie root"), + }) +} + +/// Trailing separators are cosmetic; Jellyseerr stores whatever was typed. +fn normalise_path(path: &str) -> String { + path.trim().trim_end_matches('/').to_owned() +} + +/// The title's original language from TMDB (§5.2), or nothing. +async fn original_language(state: &CompatState, tmdb_id: i64) -> Option { + let id = u32::try_from(tmdb_id).ok()?; + match state.tmdb().ok()?.movie(id).await { + Ok(movie) if !movie.original_language.is_empty() => Some(movie.original_language), + Ok(_) => None, + Err(error) => { + tracing::warn!(%error, tmdb_id, "compat add could not read the original language"); + None + } + } +} + +/// Radarr's tags are owners (§4.3). Ids that name no owner are dropped rather +/// than failing the add: Jellyseerr's tag list can outlive an owner row, and +/// losing a notification recipient is better than losing the request. +async fn attach_owners( + state: &CompatState, + movie_id: i64, + tags: &[i64], +) -> Result<(), CompatError> { + for tag in tags { + sqlx::query!( + "INSERT OR IGNORE INTO title_owners (title_kind, title_id, owner_id) \ + SELECT 'movie', ?, id FROM owners WHERE id = ?", + movie_id, + tag, + ) + .execute(state.pool()) + .await?; + } + Ok(()) +} diff --git a/crates/arr-compat/src/system.rs b/crates/arr-compat/src/system.rs new file mode 100644 index 0000000..d5d4678 --- /dev/null +++ b/crates/arr-compat/src/system.rs @@ -0,0 +1,80 @@ +//! The four endpoints Jellyseerr calls before it will save a server: status, +//! root folders, quality profiles and tags. + +use axum::extract::State; +use axum::Json; + +use crate::error::CompatError; +use crate::model::{QualityProfile, RootFolder, SystemStatus, Tag}; +use crate::CompatState; + +/// `GET /api/v3/system/status` — Jellyseerr's "Test" button. +pub(crate) async fn status() -> Json { + Json(SystemStatus::default()) +} + +/// `GET /api/v3/rootfolder` — the real movie roots (§5.1). +/// +/// TV roots are excluded rather than merged: they belong to the `series` +/// half of the shim, and a Radarr offered a TV root would file films into it. +pub(crate) async fn root_folders( + State(state): State, +) -> Result>, CompatError> { + let roots = state.database().list_roots().await?; + Ok(Json( + roots + .into_iter() + .filter(|root| root.kind == "movie") + .map(|root| RootFolder { + id: root.id, + path: root.path, + accessible: true, + free_space: 0, + unmapped_folders: Vec::new(), + }) + .collect(), + )) +} + +/// `GET /api/v3/qualityprofile` — one fake profile per movie root (§5.1). +pub(crate) async fn quality_profiles( + State(state): State, +) -> Result>, CompatError> { + let roots = state.database().list_roots().await?; + Ok(Json( + roots + .into_iter() + .filter(|root| root.kind == "movie") + .map(|root| QualityProfile { + id: root.id, + name: root.policy_name, + upgrade_allowed: false, + cutoff: root.id, + items: Vec::new(), + min_format_score: 0, + cutoff_format_score: 0, + format_items: Vec::new(), + }) + .collect(), + )) +} + +/// `GET /api/v3/tag` — the owners from §4.3, which is what Radarr's tags map +/// onto here. Jellyseerr can then attach one to a request and the notification +/// routing follows. +pub(crate) async fn tags(State(state): State) -> Result>, CompatError> { + let owners = sqlx::query!( + r#"SELECT id AS "id!: i64", name AS "label!: String" FROM owners ORDER BY name"# + ) + .fetch_all(state.pool()) + .await?; + Ok(Json( + owners + .into_iter() + .map(|owner| Tag { + id: owner.id, + label: owner.label, + }) + .collect(), + )) +} diff --git a/crates/arr-compat/src/tests.rs b/crates/arr-compat/src/tests.rs new file mode 100644 index 0000000..6672acd --- /dev/null +++ b/crates/arr-compat/src/tests.rs @@ -0,0 +1,384 @@ +//! The shim is only worth anything if Jellyseerr's actual call sequence works, +//! so the tests follow that sequence rather than the module layout: connect, +//! read the folders and profiles, look a film up, add it. + +use std::sync::Arc; + +use arr_db::Db; +use arr_meta::TmdbClient; +use serde_json::{json, Value}; +use wiremock::matchers::{method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use super::{router, CompatState}; + +/// A migrated database, the shim serving it, and its base URL. The temporary +/// directory has to outlive the test, so it comes back with the rest. +async fn shim() -> (tempfile::TempDir, Db, String) { + let dir = tempfile::tempdir().expect("tempdir"); + let database = Db::connect(dir.path().join("arr.db")) + .await + .expect("connect"); + database.migrate().await.expect("migrate"); + let base = serve(CompatState::new(database.clone())).await; + (dir, database, base) +} + +async fn serve(state: CompatState) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let address = listener.local_addr().expect("address"); + tokio::spawn(async move { + axum::serve(listener, router(state)).await.expect("serve"); + }); + format!("http://{address}") +} + +async fn get_json(url: String) -> Value { + reqwest::get(url) + .await + .expect("request") + .json() + .await + .expect("json body") +} + +/// A TMDB that knows one film, wired the way `arr-meta` calls it. +async fn tmdb_with_dune() -> MockServer { + let server = MockServer::start().await; + let movie = json!({ + "id": 693_134, + "imdb_id": "tt15239678", + "title": "Dune: Part Two", + "original_title": "Dune: Part Two", + "original_language": "en", + "origin_country": ["US"], + "release_date": "2024-02-27", + "runtime": 167, + "status": "Released", + "overview": "Paul Atreides unites with Chani.", + "poster_path": "/dune.jpg", + "release_dates": { "results": [] } + }); + Mock::given(method("GET")) + .and(path("/3/movie/693134")) + .respond_with(ResponseTemplate::new(200).set_body_json(movie)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/3/search/movie")) + .and(query_param("query", "dune")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [{ + "id": 693_134, + "title": "Dune: Part Two", + "original_title": "Dune: Part Two", + "original_language": "en", + "release_date": "2024-02-27", + "overview": "Paul Atreides unites with Chani.", + "poster_path": "/dune.jpg" + }] + }))) + .mount(&server) + .await; + server +} + +fn client(server: &MockServer) -> Arc { + Arc::new( + TmdbClient::builder("key") + .base_url(format!("{}/3/", server.uri())) + .build() + .expect("tmdb client"), + ) +} + +#[tokio::test] +async fn status_names_a_radarr_that_serves_v3() { + let (_dir, _database, base) = shim().await; + let body = get_json(format!("{base}/api/v3/system/status")).await; + assert_eq!(body["appName"], "Radarr"); + assert_eq!(body["version"], super::RADARR_VERSION); + assert!( + body["version"] + .as_str() + .is_some_and(|version| version.starts_with('5')), + "Jellyseerr branches on the major version: {body}" + ); + assert_eq!(body["authentication"], "none"); +} + +#[tokio::test] +async fn root_folders_and_profiles_are_the_real_roots() { + let (_dir, _database, base) = shim().await; + + let folders = get_json(format!("{base}/api/v3/rootfolder")).await; + let folders = folders.as_array().expect("array"); + assert_eq!(folders.len(), 2, "§5.1: two movie roots"); + let paths: Vec<&str> = folders + .iter() + .filter_map(|folder| folder["path"].as_str()) + .collect(); + assert!(paths.contains(&"/mnt/media/movies/main")); + assert!(paths.contains(&"/mnt/media/movies/kids")); + + // §5.1: one fake profile per root, and the ids line up so that the two + // dropdowns in Jellyseerr cannot be set to disagree. + let profiles = get_json(format!("{base}/api/v3/qualityProfile")).await; + let profiles = profiles.as_array().expect("array"); + assert_eq!(profiles.len(), folders.len()); + for (profile, folder) in profiles.iter().zip(folders) { + assert_eq!(profile["id"], folder["id"]); + } + assert!(profiles + .iter() + .any(|profile| profile["name"] == "Movies — main")); +} + +#[tokio::test] +async fn tags_are_owners() { + let (_dir, database, base) = shim().await; + sqlx::query("INSERT INTO owners (name, ntfy_topic) VALUES ('miguel', 'arr-miguel')") + .execute(database.pool()) + .await + .expect("owner"); + + let tags = get_json(format!("{base}/api/v3/tag")).await; + assert_eq!(tags[0]["label"], "miguel"); +} + +#[tokio::test] +async fn a_request_creates_a_wanted_movie_in_the_named_root() { + let (_dir, database, _unused) = shim().await; + let tmdb = tmdb_with_dune().await; + let base = serve(CompatState::new(database.clone()).with_tmdb(client(&tmdb))).await; + + let owner_id: i64 = sqlx::query_scalar( + "INSERT INTO owners (name, ntfy_topic) VALUES ('kid', 't') RETURNING id", + ) + .fetch_one(database.pool()) + .await + .expect("owner"); + + let response = reqwest::Client::new() + .post(format!("{base}/api/v3/movie")) + .json(&json!({ + "tmdbId": 693_134, + "title": "Dune: Part Two", + "year": 2024, + // Trailing separator, because Jellyseerr stores what was typed. + "rootFolderPath": "/mnt/media/movies/kids/", + "qualityProfileId": 9999, + "monitored": true, + "tags": [owner_id], + "addOptions": { "searchForMovie": true } + })) + .send() + .await + .expect("add"); + assert_eq!(response.status(), 201); + let created: Value = response.json().await.expect("json"); + + assert!(created["id"].as_i64().is_some_and(|id| id > 0)); + assert_eq!(created["monitored"], true); + assert_eq!(created["rootFolderPath"], "/mnt/media/movies/kids"); + assert_eq!( + created["path"], "/mnt/media/movies/kids/Dune: Part Two (2024) [tmdbid-693134]", + "§7.4 layout, provider id included" + ); + assert_eq!(created["tags"][0], owner_id); + + let row = sqlx::query_as::<_, (i64, bool, String, Option)>( + "SELECT m.root_id, m.wanted, r.audience, m.original_language + FROM movies m JOIN roots r ON r.id = m.root_id WHERE m.tmdb_id = 693134", + ) + .fetch_one(database.pool()) + .await + .expect("movie row"); + assert!(row.1, "§4.1: monitored maps onto wanted"); + assert_eq!(row.2, "kids", "the path decided the root, not the profile"); + assert_eq!( + row.3.as_deref(), + Some("en"), + "§5.2 needs the original language" + ); + + // The quality profile Jellyseerr sent named nothing here and was ignored; + // what comes back is the root's own profile id (§5.1). + assert_eq!(created["qualityProfileId"], row.0); +} + +#[tokio::test] +async fn an_unknown_root_is_a_radarr_validation_failure() { + let (_dir, _database, base) = shim().await; + let response = reqwest::Client::new() + .post(format!("{base}/api/v3/movie")) + .json(&json!({ + "tmdbId": 1, + "title": "Somewhere Else", + "rootFolderPath": "/mnt/media/movies/nope" + })) + .send() + .await + .expect("add"); + assert_eq!(response.status(), 400); + let body: Value = response.json().await.expect("json"); + assert_eq!(body[0]["propertyName"], "RootFolderPath"); + assert_eq!(body[0]["severity"], "error"); +} + +#[tokio::test] +async fn adding_the_same_film_twice_is_rejected_the_way_radarr_rejects_it() { + let (_dir, _database, base) = shim().await; + let request = json!({ + "tmdbId": 693_134, + "title": "Dune: Part Two", + "year": 2024, + "rootFolderPath": "/mnt/media/movies/main" + }); + let first = reqwest::Client::new() + .post(format!("{base}/api/v3/movie")) + .json(&request) + .send() + .await + .expect("first add"); + assert_eq!(first.status(), 201); + + let second = reqwest::Client::new() + .post(format!("{base}/api/v3/movie")) + .json(&request) + .send() + .await + .expect("second add"); + assert_eq!(second.status(), 400); + let body: Value = second.json().await.expect("json"); + assert_eq!(body[0]["propertyName"], "TmdbId"); +} + +#[tokio::test] +async fn the_library_answers_the_existence_check() { + let (_dir, _database, base) = shim().await; + reqwest::Client::new() + .post(format!("{base}/api/v3/movie")) + .json(&json!({ + "tmdbId": 693_134, "title": "Dune: Part Two", "year": 2024, + "rootFolderPath": "/mnt/media/movies/main" + })) + .send() + .await + .expect("add"); + + let all = get_json(format!("{base}/api/v3/movie")).await; + assert_eq!(all.as_array().map(Vec::len), Some(1)); + let id = all[0]["id"].as_i64().expect("id"); + + let filtered = get_json(format!("{base}/api/v3/movie?tmdbId=693134")).await; + assert_eq!(filtered.as_array().map(Vec::len), Some(1)); + let missing = get_json(format!("{base}/api/v3/movie?tmdbId=1")).await; + assert_eq!(missing.as_array().map(Vec::len), Some(0)); + + let one = get_json(format!("{base}/api/v3/movie/{id}")).await; + assert_eq!(one["tmdbId"], 693_134); + let absent = reqwest::get(format!("{base}/api/v3/movie/424242")) + .await + .expect("request"); + assert_eq!(absent.status(), 404); +} + +#[tokio::test] +async fn lookup_stamps_the_library_id_onto_a_film_already_added() { + let (_dir, database, _unused) = shim().await; + let tmdb = tmdb_with_dune().await; + let base = serve(CompatState::new(database.clone()).with_tmdb(client(&tmdb))).await; + + // Not yet in the library: id 0 is what tells Jellyseerr to go on and add. + let before = get_json(format!("{base}/api/v3/movie/lookup?term=tmdb:693134")).await; + assert_eq!(before.as_array().map(Vec::len), Some(1)); + assert_eq!(before[0]["id"], 0); + assert_eq!(before[0]["tmdbId"], 693_134); + assert_eq!(before[0]["imdbId"], "tt15239678"); + assert_eq!(before[0]["monitored"], false); + + let created: Value = reqwest::Client::new() + .post(format!("{base}/api/v3/movie")) + .json(&json!({ + "tmdbId": 693_134, "title": "Dune: Part Two", "year": 2024, + "rootFolderPath": "/mnt/media/movies/main" + })) + .send() + .await + .expect("add") + .json() + .await + .expect("json"); + + let after = get_json(format!("{base}/api/v3/movie/lookup?term=tmdb:693134")).await; + assert_eq!(after[0]["id"], created["id"]); + assert_eq!(after[0]["monitored"], true); + assert_eq!(after[0]["hasFile"], false); + + // A free-text search takes the same overlay. + let searched = get_json(format!("{base}/api/v3/movie/lookup?term=dune")).await; + assert_eq!(searched[0]["id"], created["id"]); +} + +#[tokio::test] +async fn an_unknown_tmdb_id_looks_up_empty_rather_than_failing() { + let (_dir, database, _unused) = shim().await; + let tmdb = tmdb_with_dune().await; + Mock::given(method("GET")) + .and(path("/3/movie/1")) + .respond_with(ResponseTemplate::new(404)) + .mount(&tmdb) + .await; + let base = serve(CompatState::new(database).with_tmdb(client(&tmdb))).await; + + let body = get_json(format!("{base}/api/v3/movie/lookup?term=tmdb:1")).await; + assert_eq!(body.as_array().map(Vec::len), Some(0)); + let by_imdb = get_json(format!("{base}/api/v3/movie/lookup?term=imdb:tt15239678")).await; + assert_eq!(by_imdb.as_array().map(Vec::len), Some(0)); +} + +#[tokio::test] +async fn lookup_without_tmdb_fails_loudly_instead_of_answering_empty() { + let (_dir, _database, base) = shim().await; + let response = reqwest::get(format!("{base}/api/v3/movie/lookup?term=dune")) + .await + .expect("request"); + assert_eq!( + response.status(), + 502, + "an empty list would read as 'no such film'" + ); +} + +#[tokio::test] +async fn adding_survives_tmdb_being_down() { + let (_dir, database, _unused) = shim().await; + let tmdb = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/3/movie/693134")) + .respond_with(ResponseTemplate::new(500)) + .mount(&tmdb) + .await; + let base = serve(CompatState::new(database.clone()).with_tmdb(client(&tmdb))).await; + + let response = reqwest::Client::new() + .post(format!("{base}/api/v3/movie")) + .json(&json!({ + "tmdbId": 693_134, "title": "Dune: Part Two", "year": 2024, + "rootFolderPath": "/mnt/media/movies/main" + })) + .send() + .await + .expect("add"); + assert_eq!(response.status(), 201); + + let language: Option = + sqlx::query_scalar("SELECT original_language FROM movies WHERE tmdb_id = 693134") + .fetch_one(database.pool()) + .await + .expect("row"); + assert_eq!(language, None, "left for the metadata refresh to fill"); +} diff --git a/crates/arr-daemon/Cargo.toml b/crates/arr-daemon/Cargo.toml index 83cd807..edda77f 100644 --- a/crates/arr-daemon/Cargo.toml +++ b/crates/arr-daemon/Cargo.toml @@ -12,8 +12,12 @@ path = "src/main.rs" [dependencies] arr-api = { workspace = true } +arr-compat = { workspace = true } arr-db = { workspace = true } +arr-meta = { workspace = true } axum = { workspace = true } +include_dir = { workspace = true } +mime_guess = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } sqlx = { workspace = true } diff --git a/crates/arr-daemon/build.rs b/crates/arr-daemon/build.rs new file mode 100644 index 0000000..21bdd32 --- /dev/null +++ b/crates/arr-daemon/build.rs @@ -0,0 +1,127 @@ +//! Resolve the SPA bundle embedded by `web.rs` via `include_dir!`. +//! +//! Three modes, in order: +//! +//! * `ARR_WEB_DIST` set — a prebuilt bundle (Nix, CI, release pipeline) is +//! embedded as-is. Missing or empty means a broken pipeline: fail. +//! * pnpm available — the default developer path: build `web/` and embed +//! `web/dist`. Failures in the build itself fail loudly. +//! * pnpm missing, debug profile — embed a generated placeholder page and +//! warn. This keeps `cargo clippy`/`cargo test` working on runners with no +//! node toolchain (the CI rust job, DESIGN.md §12). +//! +//! A release build never gets the placeholder: shipping a binary whose UI is +//! an apology defeats the single-binary acceptance test, so that combination +//! panics instead. + +use std::env; +use std::path::PathBuf; +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-env-changed=ARR_WEB_DIST"); + + let dist = match env::var_os("ARR_WEB_DIST") { + Some(prebuilt) => PathBuf::from(prebuilt), + None => build_or_placeholder(), + }; + + let index = dist.join("index.html"); + let bundled = std::fs::metadata(&index).is_ok_and(|m| m.is_file() && m.len() > 0); + assert!( + bundled, + "no SPA bundle at {} (missing or empty index.html) — check the vite \ + outDir or the ARR_WEB_DIST override", + index.display() + ); + + // `include_dir!` expands `$VAR` against the env rustc is invoked with, + // which is what `cargo:rustc-env` sets. + println!("cargo:rustc-env=ARR_WEB_BUNDLE_DIR={}", dist.display()); +} + +fn build_or_placeholder() -> PathBuf { + let manifest_dir = + PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR")); + let web_dir = manifest_dir.join("../../web"); + + // Rebuild only when a frontend input changes, not on unrelated Rust + // recompiles. The dist watch matters too: the inputs decide whether this + // script reruns, but `include_dir!` reads dist, and without this watch an + // incremental build can regenerate dist yet keep the old bytes embedded. + for rel in [ + "src", + "index.html", + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "vite.config.ts", + "tsconfig.json", + "dist", + ] { + println!("cargo:rerun-if-changed={}", web_dir.join(rel).display()); + } + + if !tool_works("pnpm") { + let profile = env::var("PROFILE").unwrap_or_default(); + assert!( + profile != "release", + "arr embeds the web SPA at build time and needs pnpm + node for a \ + release build. Install them, or point ARR_WEB_DIST at a prebuilt \ + bundle." + ); + println!( + "cargo:warning=pnpm not found; embedding a placeholder page instead of \ + the web SPA (debug builds only)" + ); + return write_placeholder(); + } + + run( + Command::new("pnpm") + .args(["install", "--frozen-lockfile"]) + .current_dir(&web_dir), + "pnpm install --frozen-lockfile", + ); + run( + Command::new("pnpm").arg("build").current_dir(&web_dir), + "pnpm build", + ); + + web_dir.join("dist") +} + +fn write_placeholder() -> PathBuf { + let out = PathBuf::from(env::var("OUT_DIR").expect("cargo sets OUT_DIR")).join("placeholder"); + std::fs::create_dir_all(&out).expect("create placeholder dir"); + std::fs::write(out.join("index.html"), PLACEHOLDER_HTML).expect("write placeholder"); + out +} + +fn tool_works(tool: &str) -> bool { + Command::new(tool) + .arg("--version") + .output() + .is_ok_and(|o| o.status.success()) +} + +fn run(cmd: &mut Command, label: &str) { + let status = cmd + .status() + .unwrap_or_else(|e| panic!("failed to spawn `{label}`: {e}")); + assert!(status.success(), "`{label}` failed with {status}"); +} + +const PLACEHOLDER_HTML: &str = r#" + +arr — web bundle not built + +

arr is running

+

but this binary was built without the web bundle (no pnpm on the build +machine). Rebuild with pnpm + node installed, or set ARR_WEB_DIST +to a prebuilt bundle.

+"#; diff --git a/crates/arr-daemon/src/main.rs b/crates/arr-daemon/src/main.rs index 8ab8093..2004fc2 100644 --- a/crates/arr-daemon/src/main.rs +++ b/crates/arr-daemon/src/main.rs @@ -1,11 +1,15 @@ //! arr — reconcile loop and process entry point. See DESIGN.md §8. mod config; +mod web; use std::process::ExitCode; +use std::sync::Arc; use arr_api::{AppState, Upstreams}; +use arr_compat::CompatState; use arr_db::Db; +use arr_meta::TmdbClient; use config::Config; use tower_http::trace::TraceLayer; @@ -59,6 +63,8 @@ enum Error { Database(#[from] sqlx::Error), #[error("database migration: {0}")] Migration(#[from] sqlx::migrate::MigrateError), + #[error("tmdb client: {0}")] + Tmdb(#[from] arr_meta::Error), #[error("bind {addr}: {source}")] Bind { addr: std::net::SocketAddr, @@ -73,6 +79,17 @@ async fn run() -> Result<(), Error> { let database = Db::connect(&config.database_path).await?; database.migrate().await?; + // Jellyseerr's Radarr shim (DESIGN.md §9.4) reads the same database and + // needs its own TMDB client for `movie/lookup`. + let mut compat = CompatState::new(database.clone()); + if let Some(key) = &config.tmdb_api_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 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); @@ -81,7 +98,10 @@ async fn run() -> Result<(), Error> { } let state = AppState::new(upstreams)?.with_database(database); - let app = arr_api::router(state).layer(TraceLayer::new_for_http()); + let app = arr_api::router(state) + .merge(arr_compat::router(compat)) + .fallback(web::serve) + .layer(TraceLayer::new_for_http()); let listener = tokio::net::TcpListener::bind(config.bind_addr) .await diff --git a/crates/arr-daemon/src/web.rs b/crates/arr-daemon/src/web.rs new file mode 100644 index 0000000..2ca285c --- /dev/null +++ b/crates/arr-daemon/src/web.rs @@ -0,0 +1,137 @@ +//! The embedded SPA. See DESIGN.md §11: `web/` is built by Vite and compiled +//! into this binary via `include_dir`, so `cargo run` serves the whole UI +//! with no external files. `build.rs` resolves which directory gets embedded. + +use axum::http::{header, StatusCode, Uri}; +use axum::response::{IntoResponse, Response}; +use include_dir::{include_dir, Dir}; + +static BUNDLE: Dir<'_> = include_dir!("$ARR_WEB_BUNDLE_DIR"); + +/// Fallback handler for everything the API router did not claim. +pub async fn serve(uri: Uri) -> Response { + serve_path(uri.path()) +} + +/// Serve a path from the bundle. +/// +/// Content-hashed files under `assets/` are immutable, so they get a year of +/// `immutable` cache. The shell and everything else is `no-cache`: the shell +/// references the current hashed asset names and a stale cached copy would +/// point at files that no longer exist after a rebuild. +/// +/// A miss falls back to the shell only for extensionless paths (client-side +/// routes). A miss that names a file — a stale `assets/index-OLD.js` — is a +/// real 404: serving HTML where the browser expects JS blanks the page. And +/// `/api/*` never falls through to HTML; an unknown API path is a 404. +fn serve_path(path: &str) -> Response { + let trimmed = path.trim_start_matches('/'); + if trimmed == "api" || trimmed.starts_with("api/") { + return not_found(); + } + if trimmed.is_empty() { + return serve_index(); + } + match BUNDLE.get_file(trimmed) { + Some(file) => { + let mime = mime_guess::from_path(trimmed).first_or_octet_stream(); + let cache = if trimmed.starts_with("assets/") { + "public, max-age=31536000, immutable" + } else { + "no-cache" + }; + ( + [ + (header::CONTENT_TYPE, mime.as_ref()), + (header::CACHE_CONTROL, cache), + ], + file.contents(), + ) + .into_response() + } + None if names_a_file(trimmed) => not_found(), + None => serve_index(), + } +} + +fn serve_index() -> Response { + match BUNDLE.get_file("index.html") { + Some(file) => ( + [ + (header::CONTENT_TYPE, "text/html; charset=utf-8"), + (header::CACHE_CONTROL, "no-cache"), + ], + file.contents(), + ) + .into_response(), + // build.rs guarantees index.html exists in whatever it embedded. + None => not_found(), + } +} + +/// Whether the last segment carries an extension, i.e. names a concrete file +/// rather than a client-side route. +fn names_a_file(path: &str) -> bool { + path.rsplit('/').next().is_some_and(|seg| seg.contains('.')) +} + +fn not_found() -> Response { + ( + StatusCode::NOT_FOUND, + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + "not found", + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn content_type(response: &Response) -> String { + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_owned() + } + + #[test] + fn the_shell_is_embedded_html() { + let response = serve_path("/"); + assert_eq!(response.status(), StatusCode::OK); + assert!(content_type(&response).starts_with("text/html")); + } + + #[test] + fn an_extensionless_route_falls_back_to_the_shell() { + let response = serve_path("/movies/42"); + assert_eq!(response.status(), StatusCode::OK); + assert!(content_type(&response).starts_with("text/html")); + } + + #[test] + fn a_missing_hashed_asset_is_a_404_never_html() { + let response = serve_path("/assets/index-deadbeef.js"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!(!content_type(&response).starts_with("text/html")); + } + + #[test] + fn unknown_api_paths_never_fall_through_to_html() { + let response = serve_path("/api/nope"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!(!content_type(&response).starts_with("text/html")); + } + + #[test] + fn the_shell_is_never_cached() { + let cache = serve_path("/") + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + assert_eq!(cache.as_deref(), Some("no-cache")); + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..ac7c7f0 --- /dev/null +++ b/web/index.html @@ -0,0 +1,114 @@ + + + + + + + arr + + + + +
+
+ +

arr

+
+

probing chain…

+
+ v — + +
+
+ +
+
+
+
+ +

tmdb

+ probing +
+

metadata in

+

titles, languages, release dates

+
+ + + +
+
+ +

prowlarr

+ probing +
+

indexers in

+

torznab search and rss

+
+ + + +
+
+ +

arr

+ probing +
+

reconcile core

+

decide · find · fetch · import

+
+ + + +
+
+ +

transmission

+ probing +
+

torrents out

+

rpc · grab and seed

+
+
+
+ +
+ rtt — + repolls every 15 s +
+ + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..e0a11e9 --- /dev/null +++ b/web/package.json @@ -0,0 +1,20 @@ +{ + "name": "arr-web", + "private": true, + "version": "0.1.0", + "type": "module", + "packageManager": "pnpm@11.21.0", + "scripts": { + "dev": "vite", + "build": "tsc -b --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit", + "lint": "biome ci ." + }, + "devDependencies": { + "@biomejs/biome": "^2.5.8", + "@types/node": "^24.0.0", + "typescript": "^5.9.3", + "vite": "^7.1.0" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..bc48d7a --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,786 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@biomejs/biome': + specifier: ^2.5.8 + version: 2.5.10 + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.1.0 + version: 7.3.6(@types/node@24.13.3) + +packages: + + '@biomejs/biome@2.5.10': + resolution: {integrity: sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.10': + resolution: {integrity: sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.10': + resolution: {integrity: sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.10': + resolution: {integrity: sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.5.10': + resolution: {integrity: sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.5.10': + resolution: {integrity: sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.5.10': + resolution: {integrity: sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.5.10': + resolution: {integrity: sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.10': + resolution: {integrity: sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-android-arm-eabi@4.62.5': + resolution: {integrity: sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.5': + resolution: {integrity: sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.5': + resolution: {integrity: sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.5': + resolution: {integrity: sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.5': + resolution: {integrity: sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.5': + resolution: {integrity: sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.5': + resolution: {integrity: sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.5': + resolution: {integrity: sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.5': + resolution: {integrity: sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.5': + resolution: {integrity: sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.5': + resolution: {integrity: sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.5': + resolution: {integrity: sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.5': + resolution: {integrity: sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.5': + resolution: {integrity: sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.5': + resolution: {integrity: sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.5': + resolution: {integrity: sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.5': + resolution: {integrity: sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.5': + resolution: {integrity: sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.5': + resolution: {integrity: sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.5': + resolution: {integrity: sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.5': + resolution: {integrity: sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.5': + resolution: {integrity: sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.5': + resolution: {integrity: sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.5': + resolution: {integrity: sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.5': + resolution: {integrity: sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.62.5: + resolution: {integrity: sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@biomejs/biome@2.5.10': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.10 + '@biomejs/cli-darwin-x64': 2.5.10 + '@biomejs/cli-linux-arm64': 2.5.10 + '@biomejs/cli-linux-arm64-musl': 2.5.10 + '@biomejs/cli-linux-x64': 2.5.10 + '@biomejs/cli-linux-x64-musl': 2.5.10 + '@biomejs/cli-win32-arm64': 2.5.10 + '@biomejs/cli-win32-x64': 2.5.10 + + '@biomejs/cli-darwin-arm64@2.5.10': + optional: true + + '@biomejs/cli-darwin-x64@2.5.10': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.10': + optional: true + + '@biomejs/cli-linux-arm64@2.5.10': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.10': + optional: true + + '@biomejs/cli-linux-x64@2.5.10': + optional: true + + '@biomejs/cli-win32-arm64@2.5.10': + optional: true + + '@biomejs/cli-win32-x64@2.5.10': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rollup/rollup-android-arm-eabi@4.62.5': + optional: true + + '@rollup/rollup-android-arm64@4.62.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.5': + optional: true + + '@rollup/rollup-darwin-x64@4.62.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.5': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.5': + optional: true + + '@types/estree@1.0.9': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + nanoid@3.3.18: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.62.5: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.5 + '@rollup/rollup-android-arm64': 4.62.5 + '@rollup/rollup-darwin-arm64': 4.62.5 + '@rollup/rollup-darwin-x64': 4.62.5 + '@rollup/rollup-freebsd-arm64': 4.62.5 + '@rollup/rollup-freebsd-x64': 4.62.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.5 + '@rollup/rollup-linux-arm-musleabihf': 4.62.5 + '@rollup/rollup-linux-arm64-gnu': 4.62.5 + '@rollup/rollup-linux-arm64-musl': 4.62.5 + '@rollup/rollup-linux-loong64-gnu': 4.62.5 + '@rollup/rollup-linux-loong64-musl': 4.62.5 + '@rollup/rollup-linux-ppc64-gnu': 4.62.5 + '@rollup/rollup-linux-ppc64-musl': 4.62.5 + '@rollup/rollup-linux-riscv64-gnu': 4.62.5 + '@rollup/rollup-linux-riscv64-musl': 4.62.5 + '@rollup/rollup-linux-s390x-gnu': 4.62.5 + '@rollup/rollup-linux-x64-gnu': 4.62.5 + '@rollup/rollup-linux-x64-musl': 4.62.5 + '@rollup/rollup-openbsd-x64': 4.62.5 + '@rollup/rollup-openharmony-arm64': 4.62.5 + '@rollup/rollup-win32-arm64-msvc': 4.62.5 + '@rollup/rollup-win32-ia32-msvc': 4.62.5 + '@rollup/rollup-win32-x64-gnu': 4.62.5 + '@rollup/rollup-win32-x64-msvc': 4.62.5 + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + typescript@5.9.3: {} + + undici-types@7.18.2: {} + + vite@7.3.6(@types/node@24.13.3): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 0000000..75c06f6 --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +# esbuild's postinstall validates its platform binary; everything else stays +# script-free. +allowBuilds: + esbuild: true diff --git a/web/public/fonts/rajdhani-600-latin.woff2 b/web/public/fonts/rajdhani-600-latin.woff2 new file mode 100644 index 0000000..bc9a946 Binary files /dev/null and b/web/public/fonts/rajdhani-600-latin.woff2 differ diff --git a/web/public/fonts/rajdhani-700-latin.woff2 b/web/public/fonts/rajdhani-700-latin.woff2 new file mode 100644 index 0000000..057c632 Binary files /dev/null and b/web/public/fonts/rajdhani-700-latin.woff2 differ diff --git a/web/src/health.ts b/web/src/health.ts new file mode 100644 index 0000000..f864a94 --- /dev/null +++ b/web/src/health.ts @@ -0,0 +1,42 @@ +// Hand-written mirror of arr-api's /api/health schema. `just gen-client` +// output (src/api/) is uncommitted, so CI's tsc cannot see it — this one +// endpoint stays hand-typed until the generated client is wired in. + +export type CheckStatus = "ok" | "unreachable" | "unconfigured"; + +export interface Check { + status: CheckStatus; + detail?: string; +} + +export interface HealthReport { + status: "ok" | "degraded"; + version: string; + prowlarr: Check; + transmission: Check; + tmdb: Check; +} + +export type Probe = + | { kind: "report"; report: HealthReport; roundtripMs: number } + | { kind: "unreachable"; detail: string }; + +/** One probe of the daemon. The endpoint always answers 200 with a verdict in + * the body; anything else means the daemon itself is the outage. */ +export async function probeHealth(): Promise { + const started = performance.now(); + try { + const response = await fetch("/api/health"); + if (!response.ok) { + return { kind: "unreachable", detail: `http ${response.status}` }; + } + const report = (await response.json()) as HealthReport; + return { + kind: "report", + report, + roundtripMs: Math.round(performance.now() - started), + }; + } catch { + return { kind: "unreachable", detail: "no response" }; + } +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..7a664f5 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,159 @@ +import { type CheckStatus, type Probe, probeHealth } from "./health"; +import "./style.css"; + +const POLL_MS = 15_000; + +type Tone = "ok" | "warn" | "fault" | "idle"; + +const TONE_BY_STATUS: Record = { + ok: "ok", + unconfigured: "warn", + unreachable: "fault", +}; + +interface ModuleRefs { + lamp: HTMLElement; + status: HTMLElement; + detail: HTMLElement; + defaultDetail: string; +} + +function moduleRefs(name: string): ModuleRefs { + const root = document.querySelector(`[data-check="${name}"]`); + const lamp = root?.querySelector(".lamp"); + const status = root?.querySelector('[data-role="status"]'); + const detail = root?.querySelector('[data-role="detail"]'); + if (!lamp || !status || !detail) { + throw new Error(`board is missing the ${name} module`); + } + return { lamp, status, detail, defaultDetail: detail.textContent ?? "" }; +} + +function setModule(refs: ModuleRefs, state: string, tone: Tone, word: string, detail?: string) { + refs.lamp.dataset.state = state; + refs.status.dataset.tone = tone; + refs.status.textContent = word; + refs.detail.textContent = detail ?? refs.defaultDetail; +} + +function must(selector: string): T { + const element = document.querySelector(selector); + if (!element) { + throw new Error(`board markup is missing ${selector}`); + } + return element; +} + +function main() { + const board = must(".board"); + const masterLamp = must("#master-lamp"); + const verdict = must("#master-verdict"); + const version = must("#version"); + const roundtrip = must("#roundtrip"); + const probeButton = must("#probe"); + + const master = moduleRefs("master"); + const checks = { + tmdb: moduleRefs("tmdb"), + prowlarr: moduleRefs("prowlarr"), + transmission: moduleRefs("transmission"), + } as const; + + const traces = { + tmdb: document.querySelector('[data-trace="tmdb"]'), + prowlarr: document.querySelector('[data-trace="prowlarr"]'), + master: document.querySelector('[data-trace="master"]'), + } as const; + + function setTrace(name: keyof typeof traces, tone: Tone | null) { + const trace = traces[name]; + if (!trace) { + return; + } + if (tone === null || tone === "idle") { + delete trace.dataset.tone; + } else { + trace.dataset.tone = tone; + } + } + + // stagger order for the one power-up animation, upstream to downstream + const strikeOrder = [checks.tmdb, checks.prowlarr, master, checks.transmission]; + strikeOrder.forEach((refs, index) => { + refs.lamp.style.setProperty("--strike", String(index)); + }); + masterLamp.style.setProperty("--strike", "0"); + + let powered = false; + + function render(probe: Probe) { + if (!powered) { + powered = true; + board.classList.add("powered"); + } + + if (probe.kind === "unreachable") { + masterLamp.dataset.state = "unreachable"; + verdict.dataset.tone = "fault"; + verdict.textContent = `daemon unreachable — ${probe.detail}`; + version.textContent = "v —"; + setModule(master, "unreachable", "fault", "down"); + for (const refs of Object.values(checks)) { + setModule(refs, "off", "idle", "no signal"); + } + for (const name of ["tmdb", "prowlarr", "master"] as const) { + setTrace(name, null); + } + roundtrip.textContent = "rtt —"; + return; + } + + const { report, roundtripMs } = probe; + const degraded = report.status === "degraded"; + + masterLamp.dataset.state = degraded ? "degraded" : "ok"; + verdict.dataset.tone = degraded ? "warn" : "ok"; + verdict.textContent = degraded ? "chain degraded" : "all signals nominal"; + version.textContent = `v${report.version}`; + roundtrip.textContent = `rtt ${roundtripMs} ms`; + + setModule(master, degraded ? "degraded" : "ok", degraded ? "warn" : "ok", report.status); + setTrace("master", degraded ? "warn" : "ok"); + for (const name of ["tmdb", "prowlarr", "transmission"] as const) { + const check = report[name]; + setModule( + checks[name], + check.status, + TONE_BY_STATUS[check.status], + check.status, + check.detail, + ); + if (name !== "transmission") { + setTrace(name, TONE_BY_STATUS[check.status]); + } + } + } + + let inFlight = false; + + async function probe() { + if (inFlight) { + return; + } + inFlight = true; + probeButton.disabled = true; + render(await probeHealth()); + probeButton.disabled = false; + inFlight = false; + } + + probeButton.addEventListener("click", () => { + void probe(); + }); + window.setInterval(() => { + void probe(); + }, POLL_MS); + void probe(); +} + +main(); diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000..6ce5847 --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,488 @@ +/* arr — master-control board. + Tokens mirror .impeccable/design.json; components consume tokens, never + literals. Committed dark: a control room is lit by its lamps. */ + +@font-face { + font-family: "Rajdhani"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("/fonts/rajdhani-600-latin.woff2") format("woff2"); +} + +@font-face { + font-family: "Rajdhani"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("/fonts/rajdhani-700-latin.woff2") format("woff2"); +} + +:root { + /* ground and panels */ + --ground: oklch(0.14 0.01 250); + --panel: oklch(0.18 0.012 250); + --panel-raised: oklch(0.21 0.014 250); + --line: oklch(0.3 0.015 250); + --line-strong: oklch(0.42 0.02 250); + --highlight: oklch(1 0 0 / 4%); + + /* ink */ + --ink: oklch(0.93 0.008 250); + --ink-muted: oklch(0.72 0.015 250); + --ink-faint: oklch(0.58 0.015 250); + + /* signal lamps */ + --signal-ok: oklch(0.8 0.17 150); + --signal-warn: oklch(0.82 0.14 80); + --signal-fault: oklch(0.68 0.21 25); + --signal-idle: oklch(0.45 0.02 250); + + /* the one interactive hue */ + --accent: oklch(0.8 0.12 220); + + /* type */ + --font-label: system-ui, "Segoe UI", sans-serif; + --font-readout: + ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, monospace; + --font-display: "Rajdhani", "Avenir Next Condensed", "Arial Narrow", sans-serif; + + /* rhythm */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-12: 3rem; + + --radius: 4px; + --ease-out: cubic-bezier(0.25, 1, 0.5, 1); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + min-height: 100dvh; + display: flex; + flex-direction: column; + background: var(--ground); + color: var(--ink); + font-family: var(--font-label); + font-size: 1rem; + line-height: 1.5; +} + +.readout { + font-family: var(--font-readout); + font-size: 0.8125rem; +} + +.dim { + color: var(--ink-muted); +} + +/* ---- rails ---------------------------------------------------------- */ + +.rail { + display: flex; + align-items: center; + gap: var(--space-6); + padding: var(--space-3) var(--space-6); + border-bottom: 1px solid var(--line); + background: var(--panel); + box-shadow: inset 0 1px var(--highlight); +} + +.rail-foot { + margin-top: auto; + border-bottom: 0; + border-top: 1px solid var(--line); + justify-content: space-between; + font-size: 0.75rem; +} + +.rail-id { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.wordmark { + margin: 0; + font-family: var(--font-display); + font-size: 1.625rem; + font-weight: 700; + letter-spacing: 0.1em; + line-height: 1; +} + +.rail-verdict { + margin: 0; + flex: 1; + color: var(--ink-muted); +} + +.rail-verdict[data-tone="ok"] { + color: var(--signal-ok); +} + +.rail-verdict[data-tone="warn"] { + color: var(--signal-warn); +} + +.rail-verdict[data-tone="fault"] { + color: var(--signal-fault); +} + +.rail-meta { + display: flex; + align-items: center; + gap: var(--space-4); +} + +/* ---- lamps ---------------------------------------------------------- */ + +.lamp { + --lamp: var(--signal-idle); + width: 0.625rem; + height: 0.625rem; + flex: none; + border-radius: 50%; + background: var(--lamp); + /* the panel's own device: a lit indicator, not a depth shadow */ + box-shadow: + 0 0 6px var(--lamp), + 0 0 14px oklch(from var(--lamp) l c h / 35%); + transition: background 300ms var(--ease-out); +} + +.lamp-big { + width: 0.875rem; + height: 0.875rem; +} + +.lamp[data-state="ok"] { + --lamp: var(--signal-ok); +} + +.lamp[data-state="unconfigured"] { + --lamp: var(--signal-warn); +} + +.lamp[data-state="degraded"] { + --lamp: var(--signal-warn); +} + +.lamp[data-state="unreachable"] { + --lamp: var(--signal-fault); +} + +.lamp[data-state="probing"] { + --lamp: var(--signal-idle); + animation: lamp-probe 1.2s var(--ease-out) infinite alternate; +} + +/* dead air: dark, no glow, no motion */ +.lamp[data-state="off"] { + --lamp: oklch(0.3 0.012 250); + box-shadow: none; +} + +@keyframes lamp-probe { + from { + opacity: 0.45; + } + to { + opacity: 1; + } +} + +/* power-up: lamps flicker on left to right, once, on first verdict */ +@keyframes lamp-strike { + 0% { + opacity: 0.2; + } + 40% { + opacity: 1; + } + 55% { + opacity: 0.35; + } + 100% { + opacity: 1; + } +} + +.board.powered .lamp { + animation: lamp-strike 420ms var(--ease-out) both; + animation-delay: calc(var(--strike, 0) * 120ms); +} + +.board.powered .lamp[data-state="off"] { + animation: none; +} + +@media (prefers-reduced-motion: reduce) { + .lamp[data-state="probing"], + .board.powered .lamp { + animation: none; + } +} + +/* ---- board and chain ------------------------------------------------- */ + +.board { + width: 100%; + max-width: 68rem; + margin: 0 auto; + padding: var(--space-12) var(--space-6); + flex: 1; + display: flex; + align-items: center; +} + +.chain { + width: 100%; +} + +.chain { + display: grid; + grid-template-columns: 1fr 2.5rem 1.2fr 2.5rem 1fr; + grid-template-areas: + "tmdb t1 master out sink" + "prow t2 master out sink"; + align-items: center; + row-gap: var(--space-6); +} + +.area-tmdb { + grid-area: tmdb; +} + +.area-prowlarr { + grid-area: prow; +} + +.area-t1 { + grid-area: t1; +} + +.area-t2 { + grid-area: t2; +} + +.area-master { + grid-area: master; +} + +.area-out { + grid-area: out; +} + +.area-transmission { + grid-area: sink; +} + +/* ---- modules --------------------------------------------------------- */ + +.module { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: inset 0 1px var(--highlight); + padding: var(--space-4) var(--space-4) var(--space-3); +} + +.module-master { + background: var(--panel-raised); + border-color: var(--line-strong); + padding: var(--space-6) var(--space-4) var(--space-4); +} + +.module-head { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-1) var(--space-2); +} + +.module-name { + margin: 0; + font-family: var(--font-display); + font-size: 1.0625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + line-height: 1; +} + +.module-master .module-name { + font-size: 1.3125rem; +} + +.module-status { + margin-left: auto; + padding-left: var(--space-3); + color: var(--ink-muted); +} + +.module-status[data-tone="ok"] { + color: var(--signal-ok); +} + +.module-status[data-tone="warn"] { + color: var(--signal-warn); +} + +.module-status[data-tone="fault"] { + color: var(--signal-fault); +} + +.module-role { + margin: var(--space-2) 0 0; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--ink-muted); +} + +.module-detail { + margin: var(--space-1) 0 0; + font-size: 0.75rem; + color: var(--ink-muted); + min-height: 1.2em; +} + +/* ---- bus traces ------------------------------------------------------ */ + +.bus { + display: grid; + align-content: center; + justify-items: center; +} + +.bus-trace { + --trace: var(--line-strong); + width: 100%; + height: 0.5rem; + display: block; + transition: filter 300ms var(--ease-out); +} + +.bus-trace line { + stroke: var(--trace); + stroke-width: 1; +} + +.bus-arrow { + fill: var(--trace); +} + +.bus-trace[data-tone="ok"] { + --trace: var(--signal-ok); +} + +.bus-trace[data-tone="warn"] { + --trace: var(--signal-warn); +} + +.bus-trace[data-tone="fault"] { + --trace: var(--signal-fault); +} + +/* ---- controls -------------------------------------------------------- */ + +.control { + min-height: 2.75rem; + padding: 0 var(--space-4); + font-family: var(--font-readout); + font-size: 0.8125rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent); + background: var(--panel-raised); + border: 1px solid var(--line-strong); + border-radius: var(--radius); + box-shadow: inset 0 1px var(--highlight); + cursor: pointer; + transition: + border-color 150ms var(--ease-out), + color 150ms var(--ease-out); +} + +.control:hover { + border-color: var(--accent); + color: oklch(from var(--accent) calc(l + 0.08) c h); +} + +.control:active { + box-shadow: inset 0 1px oklch(0 0 0 / 30%); +} + +.control:disabled { + color: var(--ink-faint); + border-color: var(--line); + cursor: default; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* ---- small screens --------------------------------------------------- */ + +@media (max-width: 46rem) { + .rail { + flex-wrap: wrap; + gap: var(--space-3) var(--space-4); + } + + .rail-verdict { + flex-basis: 100%; + order: 3; + } + + .board { + padding: var(--space-6) var(--space-4); + } + + .chain { + grid-template-columns: 1fr; + grid-template-areas: + "tmdb" + "prow" + "t1" + "master" + "out" + "sink"; + row-gap: var(--space-4); + } + + /* one junctioned trace on the stacked chain; the second is redundant */ + .area-t2 { + display: none; + } + + .bus { + height: 2.25rem; + justify-content: center; + } + + .bus svg { + transform: rotate(90deg); + transform-origin: center; + width: 2.25rem; + height: 0.5rem; + } +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..e03756d --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "isolatedModules": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "useDefineForClassFields": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..522e2e2 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, loadEnv } from "vite"; + +// Dev-mode proxy target: the running daemon. `pnpm dev` gives hot reload while +// every `/api/*` request goes to a real arr. Override with: +// ARR_DAEMON_URL=http://other-host:7878 pnpm dev +const DEFAULT_DAEMON_URL = "http://127.0.0.1:7878"; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const target = (env.ARR_DAEMON_URL ?? DEFAULT_DAEMON_URL).replace(/\/+$/, ""); + return { + build: { + target: "es2022", + outDir: "dist", + emptyOutDir: true, + sourcemap: false, + }, + server: { + host: true, + port: 5173, + proxy: { + "/api": { + target, + changeOrigin: true, + }, + }, + }, + }; +});