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