Merge remote-tracking branch 'origin/main' into issue/44-e2e-harness
ci / web (pull_request) Successful in 45s
ci / rust (pull_request) Successful in 2m1s
e2e / e2e (pull_request) Successful in 2m24s

# Conflicts:
#	crates/arr-daemon/src/main.rs
This commit is contained in:
Miguel Palhas
2026-08-22 21:16:29 +01:00
34 changed files with 3858 additions and 4 deletions
+3 -1
View File
@@ -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'
+1
View File
@@ -10,3 +10,4 @@
result
result-*
/worktrees
*.tsbuildinfo
+192
View File
@@ -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": "<span class=\"lamp\" data-state=\"ok\" aria-hidden=\"true\"></span>",
"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": "<article class=\"module\"><header class=\"module-head\"><span class=\"lamp\" data-state=\"ok\" aria-hidden=\"true\"></span><h2 class=\"module-name\">prowlarr</h2><span class=\"module-status readout\" data-tone=\"ok\">ok</span></header><p class=\"module-role\">indexers in</p><p class=\"module-detail readout\">torznab search and rss</p></article>",
"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": "<svg viewBox=\"0 0 40 8\" preserveAspectRatio=\"none\" class=\"bus-trace\" data-tone=\"ok\" aria-hidden=\"true\"><line x1=\"0\" y1=\"4\" x2=\"32\" y2=\"4\" /><path d=\"M32 0.5 39 4 32 7.5 Z\" class=\"bus-arrow\" /></svg>",
"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": "<button type=\"button\" class=\"control\">probe</button>",
"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": "<header class=\"rail\"><div class=\"rail-id\"><span class=\"lamp\" data-state=\"ok\" aria-hidden=\"true\"></span><h1 class=\"wordmark\">arr</h1></div><p class=\"rail-verdict readout\" data-tone=\"ok\">all signals nominal</p></header>",
"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."
]
}
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
Generated
+52
View File
@@ -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"
+1 -1
View File
@@ -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.
+87
View File
@@ -0,0 +1,87 @@
# Product
<!-- impeccable:product-schema 1 -->
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)*.
+25
View File
@@ -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"
}
}
}
}
+13
View File
@@ -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
+100
View File
@@ -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<sqlx::Error> for CompatError {
fn from(error: sqlx::Error) -> Self {
if matches!(error, sqlx::Error::RowNotFound) {
Self::NotFound
} else {
Self::Database(error.to_string())
}
}
}
impl From<arr_meta::Error> for CompatError {
fn from(error: arr_meta::Error) -> Self {
Self::Upstream(error.to_string())
}
}
+118 -1
View File
@@ -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<Arc<TmdbClient>>,
}
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<TmdbClient>) -> 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<CompatState> {
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;
+378
View File
@@ -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<serde_json::Value>,
}
/// `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<serde_json::Value>,
pub min_format_score: i64,
pub cutoff_format_score: i64,
pub format_items: Vec<serde_json::Value>,
}
/// `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<String>,
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<i64>,
pub images: Vec<Image>,
#[serde(skip_serializing_if = "Option::is_none")]
pub overview: Option<String>,
pub runtime: i64,
}
impl MovieResource {
/// The fields every mapping shares, with everything library-side empty.
fn blank(tmdb_id: i64, title: &str, year: Option<i64>) -> 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<i64>) -> 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<i64>,
/// Which root, and so which policy (§5.1). The only routing input.
#[serde(default)]
pub root_folder_path: Option<String>,
/// 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<i64>,
#[serde(default = "default_true")]
pub monitored: bool,
#[serde(default)]
pub tags: Vec<i64>,
#[serde(default)]
pub add_options: Option<AddOptions>,
}
/// 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<i64>, 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}]"),
}
}
+322
View File
@@ -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<i64>,
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<i64>,
}
/// `?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<CompatState>,
Query(query): Query<ListQuery>,
) -> Result<Json<Vec<MovieResource>>, 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<CompatState>,
Path(id): Path<i64>,
) -> Result<Json<MovieResource>, 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<CompatState>,
Json(input): Json<AddMovie>,
) -> Result<(StatusCode, Json<MovieResource>), 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<CompatState>,
Query(query): Query<LookupQuery>,
) -> Result<Json<Vec<MovieResource>>, 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::<u32>() 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<i64, MovieRow> = 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<i64>,
tmdb_id: Option<i64>,
) -> Result<Vec<MovieRow>, 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<HashMap<i64, Vec<i64>>, 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<i64, Vec<i64>> = 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<i64, CompatError> {
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<String> {
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(())
}
+80
View File
@@ -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<SystemStatus> {
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<CompatState>,
) -> Result<Json<Vec<RootFolder>>, 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<CompatState>,
) -> Result<Json<Vec<QualityProfile>>, 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<CompatState>) -> Result<Json<Vec<Tag>>, 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(),
))
}
+384
View File
@@ -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<TmdbClient> {
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<String>)>(
"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<String> =
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");
}
+4
View File
@@ -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 }
+127
View File
@@ -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#"<!doctype html>
<meta charset="utf-8">
<title>arr — web bundle not built</title>
<style>
body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 40em;
margin: 4em auto; padding: 0 1em; line-height: 1.5; }
code { background: #eee; padding: 0.1em 0.3em; border-radius: 3px; }
</style>
<h1>arr is running</h1>
<p>but this binary was built without the web bundle (no pnpm on the build
machine). Rebuild with pnpm + node installed, or set <code>ARR_WEB_DIST</code>
to a prebuilt bundle.</p>
"#;
+21 -1
View File
@@ -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
+137
View File
@@ -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"));
}
}
+114
View File
@@ -0,0 +1,114 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>arr</title>
<script type="module" src="/src/main.ts"></script>
</head>
<body>
<!--
THESIS: arr is a signal chain — TMDB and Prowlarr feed in, Transmission is
driven out — and health is lamps on that chain, read in one glance. Refuses
the admin-dashboard rut: no sidebar, no stat cards, no hero metric.
OWN-WORLD: broadcast master-control panel. Near-black charcoal ground,
machined panel modules with inset hairline borders, engraved uppercase
micro-labels, monospace readouts, status lamps (signal green / amber /
fault red) with a soft glow, phosphor-cyan for the one interactive control.
STORY: the operator opens the board, reads the master lamp, and knows in a
second whether arr can do its job — and which upstream is down, by name.
FIRST VIEWPORT: a top rail (wordmark + master lamp + verdict + version +
PROBE control), then the chain: TMDB and PROWLARR modules on the left
feeding the central arr module, TRANSMISSION driven on the right, joined by
bus hairlines with direction arrows. Footer rail carries the round-trip
readout. On a phone the chain stacks vertically.
FORM: broadcast master-control panel, #2 of 7 on the ordered list; external
roll (shell RANDOM → 2, witness 57910; concept-seed.mjs degraded to empty
output in this environment).
FINISH: unreviewed and undocumented is unfinished; this build ends with the
finish review, the verdict, and DESIGN.md.
-->
<header class="rail">
<div class="rail-id">
<span class="lamp" id="master-lamp" data-state="probing" aria-hidden="true"></span>
<h1 class="wordmark">arr</h1>
</div>
<p class="rail-verdict readout" id="master-verdict" role="status">probing chain…</p>
<div class="rail-meta">
<span class="readout dim" id="version" data-testid="version">v —</span>
<button type="button" class="control" id="probe">probe</button>
</div>
</header>
<main class="board">
<section class="chain" aria-label="signal chain">
<article class="module area-tmdb" data-check="tmdb">
<header class="module-head">
<span class="lamp" data-state="probing" aria-hidden="true"></span>
<h2 class="module-name">tmdb</h2>
<span class="module-status readout" data-role="status">probing</span>
</header>
<p class="module-role">metadata in</p>
<p class="module-detail readout" data-role="detail">titles, languages, release dates</p>
</article>
<div class="bus area-t1" aria-hidden="true">
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="tmdb" aria-hidden="true">
<line x1="0" y1="4" x2="32" y2="4" />
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
</svg>
</div>
<article class="module area-prowlarr" data-check="prowlarr">
<header class="module-head">
<span class="lamp" data-state="probing" aria-hidden="true"></span>
<h2 class="module-name">prowlarr</h2>
<span class="module-status readout" data-role="status">probing</span>
</header>
<p class="module-role">indexers in</p>
<p class="module-detail readout" data-role="detail">torznab search and rss</p>
</article>
<div class="bus area-t2" aria-hidden="true">
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="prowlarr" aria-hidden="true">
<line x1="0" y1="4" x2="32" y2="4" />
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
</svg>
</div>
<article class="module module-master area-master" data-check="master">
<header class="module-head">
<span class="lamp lamp-big" data-state="probing" aria-hidden="true"></span>
<h2 class="module-name">arr</h2>
<span class="module-status readout" data-role="status">probing</span>
</header>
<p class="module-role">reconcile core</p>
<p class="module-detail readout" data-role="detail">decide · find · fetch · import</p>
</article>
<div class="bus area-out" aria-hidden="true">
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="master" aria-hidden="true">
<line x1="0" y1="4" x2="32" y2="4" />
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
</svg>
</div>
<article class="module area-transmission" data-check="transmission">
<header class="module-head">
<span class="lamp" data-state="probing" aria-hidden="true"></span>
<h2 class="module-name">transmission</h2>
<span class="module-status readout" data-role="status">probing</span>
</header>
<p class="module-role">torrents out</p>
<p class="module-detail readout" data-role="detail">rpc · grab and seed</p>
</article>
</section>
</main>
<footer class="rail rail-foot">
<span class="readout dim" id="roundtrip">rtt —</span>
<span class="readout dim" id="poll-note">repolls every 15 s</span>
</footer>
</body>
</html>
+20
View File
@@ -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"
}
}
+786
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
# esbuild's postinstall validates its platform binary; everything else stays
# script-free.
allowBuilds:
esbuild: true
Binary file not shown.
Binary file not shown.
+42
View File
@@ -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<Probe> {
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" };
}
}
+159
View File
@@ -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<CheckStatus, Tone> = {
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<HTMLElement>(".lamp");
const status = root?.querySelector<HTMLElement>('[data-role="status"]');
const detail = root?.querySelector<HTMLElement>('[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<T extends Element>(selector: string): T {
const element = document.querySelector<T>(selector);
if (!element) {
throw new Error(`board markup is missing ${selector}`);
}
return element;
}
function main() {
const board = must<HTMLElement>(".board");
const masterLamp = must<HTMLElement>("#master-lamp");
const verdict = must<HTMLElement>("#master-verdict");
const version = must<HTMLElement>("#version");
const roundtrip = must<HTMLElement>("#roundtrip");
const probeButton = must<HTMLButtonElement>("#probe");
const master = moduleRefs("master");
const checks = {
tmdb: moduleRefs("tmdb"),
prowlarr: moduleRefs("prowlarr"),
transmission: moduleRefs("transmission"),
} as const;
const traces = {
tmdb: document.querySelector<SVGElement>('[data-trace="tmdb"]'),
prowlarr: document.querySelector<SVGElement>('[data-trace="prowlarr"]'),
master: document.querySelector<SVGElement>('[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();
+488
View File
@@ -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;
}
}
+21
View File
@@ -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"]
}
+29
View File
@@ -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,
},
},
},
};
});