Merge remote-tracking branch 'origin/main' into issue/21-reconcile-loop
ci / web (pull_request) Successful in 1m3s
ci / rust (pull_request) Successful in 1m47s
e2e / e2e (pull_request) Successful in 1m50s

This commit is contained in:
Miguel Palhas
2026-08-22 21:32:19 +01:00
28 changed files with 2069 additions and 78 deletions
+4
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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.",
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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
View File
@@ -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]]
+13 -1
View File
@@ -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:
+5
View File
@@ -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 }
+6 -1
View File
@@ -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.
+763
View File
@@ -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));
}
}
+2 -1
View File
@@ -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;
+32
View File
@@ -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();
+12 -7
View File
@@ -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))
+8 -1
View File
@@ -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
View File
@@ -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()
}
+172
View File
@@ -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
}
+13 -1
View File
@@ -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, &params).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
+6
View File
@@ -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
View File
@@ -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",
+128
View File
@@ -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
View File
@@ -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 {