Merge remote-tracking branch 'origin/main' into issue/22-grab-pipeline
# Conflicts: # .sqlx/query-6e988f7e8d8ac2167c589a35626b11ab88e1fdcc28c432da4c3debf1202eee7a.json # crates/arr-api/src/search.rs
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
---
|
||||
version: 1
|
||||
slug: "web-src-main-ts"
|
||||
primary_target: "web/src/main.ts"
|
||||
related_targets: ["web/index.html","web/src/search.ts","web/src/style.css"]
|
||||
---
|
||||
|
||||
# Surface: unified search (web/src/main.ts, §9.2)
|
||||
|
||||
Mode: Operate. The operator finds or adds a title without knowing which of
|
||||
the two they are doing.
|
||||
|
||||
- Audience: the household operator; desktop mostly, phone sometimes.
|
||||
- Task: one rail-mounted command input. Typing swaps the chain board for the
|
||||
search deck; clearing or Esc returns to the board. "/" focuses from anywhere.
|
||||
- Structure (pinned by DESIGN.md §9.2): IN LIBRARY group first, ON TMDB below,
|
||||
engraved micro-label headings, machined rows, chips on the right.
|
||||
- Add flow: inline panel under a TMDB row — root radio cards (audience name,
|
||||
policy name, path), main pre-selected, one confirm. Never a separate page.
|
||||
- Manual intake: magnet / .torrent input routes to a MANUAL GRAB panel showing
|
||||
parsed display name and btih; grab wiring lands with the release buckets
|
||||
(issue #31/#22), and the panel says so honestly.
|
||||
- Constraints: 250 ms debounce, AbortController cancellation, token-gated CSS,
|
||||
cyan for interaction only, dead-air rule (no idle animation).
|
||||
- Unresolved: library rows are informational until the library view (#32)
|
||||
gives them a destination; TMDB search is movie-only until series land (#39).
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT r.id AS \"id!: i64\", r.kind AS \"kind!: String\", r.audience AS \"audience!: String\", r.path AS \"path!: String\", p.id AS \"policy_id!: i64\", p.name AS \"policy_name!: String\" FROM roots r JOIN policies p ON p.id = r.policy_id ORDER BY r.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "kind!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "audience!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "policy_id!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "policy_name!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fa84977e249cb6f3b95d5f3486346450066ac2997437175e30c60d6364701dcf"
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
mod health;
|
||||
mod movies;
|
||||
mod owners;
|
||||
mod roots;
|
||||
mod search;
|
||||
mod state;
|
||||
|
||||
@@ -22,6 +23,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 owners::{CreateOwner, Owner, UpdateOwner};
|
||||
pub use roots::Root;
|
||||
pub use search::{ClassifiedRelease, SearchResponse};
|
||||
pub use state::{AppState, MovieCommand, Upstreams, DEFAULT_TMDB_URL};
|
||||
|
||||
@@ -45,7 +47,8 @@ pub const DOCS_PATH: &str = "/api/docs";
|
||||
(name = "system", description = "Service health and metadata"),
|
||||
(name = "movies", description = "Movie library and actions"),
|
||||
(name = "owners", description = "Owner tags and filtered views (DESIGN.md §4.3)"),
|
||||
(name = "search", description = "Unified title and release search")
|
||||
(name = "search", description = "Unified title and release search"),
|
||||
(name = "roots", description = "Root folders and their policies")
|
||||
),
|
||||
)]
|
||||
struct ApiDoc;
|
||||
@@ -66,6 +69,7 @@ fn api_router() -> OpenApiRouter<AppState> {
|
||||
.routes(routes!(owners::get, owners::update, owners::delete))
|
||||
.routes(routes!(search::search))
|
||||
.routes(routes!(search::releases))
|
||||
.routes(routes!(roots::list))
|
||||
}
|
||||
|
||||
/// The generated `OpenAPI` document.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::movies::{ApiError, ErrorBody};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// A root folder joined with its policy, enough for the add flow to pre-fill
|
||||
/// root and policy (DESIGN.md §9.2) without a second request.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct Root {
|
||||
pub id: i64,
|
||||
pub kind: String,
|
||||
pub audience: String,
|
||||
pub path: String,
|
||||
pub policy_id: i64,
|
||||
pub policy_name: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/roots", tag = "roots",
|
||||
responses(
|
||||
(status = 200, body = [Root]),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn list(State(state): State<AppState>) -> Result<Json<Vec<Root>>, ApiError> {
|
||||
let database = state.database().ok_or(ApiError::Unavailable)?;
|
||||
let roots = sqlx::query_as!(
|
||||
Root,
|
||||
r#"SELECT r.id AS "id!: i64", r.kind AS "kind!: String", r.audience AS "audience!: String", r.path AS "path!: String", p.id AS "policy_id!: i64", p.name AS "policy_name!: String" FROM roots r JOIN policies p ON p.id = r.policy_id ORDER BY r.id"#
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
Ok(Json(roots))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::state::Upstreams;
|
||||
use crate::{router, AppState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn roots_carry_their_policy_name() {
|
||||
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(
|
||||
"http://127.0.0.1:1".into(),
|
||||
"http://127.0.0.1:1".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);
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
|
||||
|
||||
let roots: Vec<serde_json::Value> = reqwest::get(format!("http://{address}/api/roots"))
|
||||
.await
|
||||
.expect("roots")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
// Two movie roots and, since #71, two TV roots.
|
||||
assert_eq!(roots.len(), 4, "every seeded root: {roots:?}");
|
||||
assert_eq!(roots[0]["audience"], "main");
|
||||
assert_eq!(roots[0]["policy_name"], "Movies — main");
|
||||
assert_eq!(roots[1]["audience"], "kids");
|
||||
assert!(roots[1]["path"]
|
||||
.as_str()
|
||||
.is_some_and(|p| p.contains("kids")));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use arr_core::policy::{evaluate, Candidate};
|
||||
use arr_core::{Language, MovieOverrides, Policy, Resolution, Rule, Source, Verdict};
|
||||
use arr_core::score::score;
|
||||
use arr_core::{Language, Policy, Rule, TitleOverrides, Verdict};
|
||||
use arr_db::policy::language;
|
||||
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
|
||||
use axum::extract::{Query, State};
|
||||
@@ -350,7 +351,7 @@ fn upstream_error(error: &arr_meta::Error) -> ApiError {
|
||||
fn classify(
|
||||
release: SearchRelease,
|
||||
policy: &Policy,
|
||||
overrides: &MovieOverrides,
|
||||
overrides: &TitleOverrides,
|
||||
original_language: &Language,
|
||||
) -> Result<ClassifiedRelease, ApiError> {
|
||||
let parsed = arr_parse::parse(&release.name);
|
||||
@@ -362,7 +363,17 @@ fn classify(
|
||||
release.size,
|
||||
);
|
||||
let (verdict, rule) = verdict(&evaluation.verdict);
|
||||
let score = score(policy, &parsed, release.size, release.seeders);
|
||||
let score = score(
|
||||
policy,
|
||||
Candidate::PreGrab(&parsed),
|
||||
release.size.unwrap_or_default(),
|
||||
release.seeders.unwrap_or_default(),
|
||||
);
|
||||
let score = if release.size.is_some() {
|
||||
score.total
|
||||
} else {
|
||||
score.source.saturating_add(score.seeders)
|
||||
};
|
||||
Ok(ClassifiedRelease {
|
||||
indexer_id: release.indexer_id,
|
||||
guid: release.guid,
|
||||
@@ -385,44 +396,6 @@ fn classify(
|
||||
})
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -465,6 +438,8 @@ fn title_language(value: &str, origin_countries: &[String]) -> Language {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use arr_core::{HdrRules, PolicyId, RequiredAudio, Resolution, ScoreWeights, SizeBand, Source};
|
||||
|
||||
use super::*;
|
||||
use crate::{router, Upstreams};
|
||||
use wiremock::matchers::{method, path, query_param};
|
||||
@@ -587,6 +562,12 @@ mod tests {
|
||||
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");
|
||||
sqlx::query(
|
||||
"UPDATE policies SET score_weights = '{\"size_at_target\":0,\"source_tier\":0,\"seeder_doubling\":0}'",
|
||||
)
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("score weights");
|
||||
|
||||
let response = reqwest::get(format!("{base}/api/releases?movie_id=1"))
|
||||
.await
|
||||
@@ -602,7 +583,60 @@ mod tests {
|
||||
.find(|release| release["verdict"] == "rejected")
|
||||
.expect("rejected");
|
||||
assert_eq!(rejected["rule"], "source");
|
||||
assert!(releases[0]["score"].as_i64().is_some());
|
||||
let eligible = releases
|
||||
.iter()
|
||||
.find(|release| release["guid"] == "good")
|
||||
.expect("eligible");
|
||||
assert_eq!(eligible["score"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releases_without_sizes_skip_the_size_score() {
|
||||
let policy = Policy {
|
||||
id: PolicyId(1),
|
||||
name: "test".into(),
|
||||
required_audio: RequiredAudio::OriginalLanguage,
|
||||
dub_blacklist: Vec::new(),
|
||||
hdr_rules: HdrRules {
|
||||
rejected_dolby_vision_profiles: Vec::new(),
|
||||
},
|
||||
size_bands: std::collections::BTreeMap::from([(
|
||||
Resolution::R2160p,
|
||||
SizeBand {
|
||||
floor_bytes: 8 << 30,
|
||||
target_bytes: 22 << 30,
|
||||
penalty_points_per_gib_over: 60,
|
||||
},
|
||||
)]),
|
||||
resolution_preference: vec![Resolution::R2160p],
|
||||
source_weights: std::collections::BTreeMap::from([(Source::WebDl, 2)]),
|
||||
score_weights: ScoreWeights::default(),
|
||||
};
|
||||
let release = SearchRelease {
|
||||
indexer_id: 1,
|
||||
guid: "release".into(),
|
||||
name: "Dune.Part.Two.2024.2160p.WEB-DL".into(),
|
||||
size: None,
|
||||
seeders: Some(8),
|
||||
publish_date: None,
|
||||
download_url: "https://tracker/release".into(),
|
||||
};
|
||||
let parsed = arr_parse::parse(&release.name);
|
||||
let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8);
|
||||
|
||||
let classified = classify(
|
||||
release,
|
||||
&policy,
|
||||
&TitleOverrides::default(),
|
||||
&Language::Other("en".into()),
|
||||
)
|
||||
.expect("classified release");
|
||||
|
||||
assert_eq!(
|
||||
classified.score,
|
||||
core_score.source.saturating_add(core_score.seeders)
|
||||
);
|
||||
assert_ne!(classified.score, core_score.total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -21,6 +21,9 @@ macro_rules! id_type {
|
||||
id_type!(RootId);
|
||||
id_type!(PolicyId);
|
||||
id_type!(MovieId);
|
||||
id_type!(SeriesId);
|
||||
id_type!(SeasonId);
|
||||
id_type!(EpisodeId);
|
||||
id_type!(MediaFileId);
|
||||
id_type!(ReleaseId);
|
||||
id_type!(GrabId);
|
||||
@@ -155,13 +158,13 @@ pub struct Policy {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct MovieOverrides {
|
||||
pub struct TitleOverrides {
|
||||
pub only_4k: bool,
|
||||
pub allow_english_audio: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MovieState {
|
||||
pub enum MediaState {
|
||||
Missing,
|
||||
Downloading,
|
||||
Available,
|
||||
@@ -176,13 +179,48 @@ pub struct Movie {
|
||||
pub original_language: Language,
|
||||
pub root_id: RootId,
|
||||
pub wanted: bool,
|
||||
pub overrides: MovieOverrides,
|
||||
pub state: MovieState,
|
||||
pub overrides: TitleOverrides,
|
||||
pub state: MediaState,
|
||||
pub blocked: bool,
|
||||
pub search_attempts: u32,
|
||||
pub last_searched_at: Option<SystemTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Series {
|
||||
pub id: SeriesId,
|
||||
pub tmdb_id: u64,
|
||||
pub title: String,
|
||||
pub year: u16,
|
||||
pub original_language: Language,
|
||||
pub root_id: RootId,
|
||||
pub auto_track: bool,
|
||||
pub overrides: TitleOverrides,
|
||||
pub upstream_ended: bool,
|
||||
pub blocked: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Season {
|
||||
pub id: SeasonId,
|
||||
pub series_id: SeriesId,
|
||||
pub number: u16,
|
||||
pub tracked: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Episode {
|
||||
pub id: EpisodeId,
|
||||
pub season_id: SeasonId,
|
||||
pub number: u16,
|
||||
pub title: String,
|
||||
pub air_date: Option<SystemTime>,
|
||||
pub wanted: bool,
|
||||
pub state: MediaState,
|
||||
pub search_attempts: u32,
|
||||
pub last_searched_at: Option<SystemTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct AudioTrack {
|
||||
pub language: Language,
|
||||
|
||||
@@ -2,8 +2,8 @@ use arr_parse::{LanguageMarker, NameClaims};
|
||||
|
||||
use crate::{
|
||||
lang::{is_resolved_portuguese, language_of_marker},
|
||||
HdrFormat, Language, MovieOverrides, Policy, ProbedMedia, RequiredAudio, Resolution, Rule,
|
||||
Source, Verdict,
|
||||
HdrFormat, Language, Policy, ProbedMedia, RequiredAudio, Resolution, Rule, Source,
|
||||
TitleOverrides, Verdict,
|
||||
};
|
||||
|
||||
/// The evidence available while evaluating a candidate.
|
||||
@@ -55,7 +55,7 @@ pub enum EvaluationPhase {
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct EvaluationContext<'a> {
|
||||
pub policy: &'a Policy,
|
||||
pub overrides: &'a MovieOverrides,
|
||||
pub overrides: &'a TitleOverrides,
|
||||
/// The title's original language from TMDB (`DESIGN.md` §5.2) — a
|
||||
/// distinct input from any track's language.
|
||||
pub original_language: &'a Language,
|
||||
@@ -103,7 +103,7 @@ pub struct Evaluation {
|
||||
#[must_use]
|
||||
pub fn evaluate(
|
||||
policy: &Policy,
|
||||
overrides: &MovieOverrides,
|
||||
overrides: &TitleOverrides,
|
||||
original_language: &Language,
|
||||
candidate: Candidate<'_>,
|
||||
size_bytes: Option<u64>,
|
||||
@@ -514,7 +514,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn verdict(policy: &Policy, overrides: &MovieOverrides, candidate: Candidate<'_>) -> Verdict {
|
||||
fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict {
|
||||
evaluate(policy, overrides, &en(), candidate, None).verdict
|
||||
}
|
||||
|
||||
@@ -525,7 +525,7 @@ mod tests {
|
||||
) -> Verdict {
|
||||
evaluate(
|
||||
policy,
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
original_language,
|
||||
candidate,
|
||||
None,
|
||||
@@ -539,7 +539,7 @@ mod tests {
|
||||
let claims = claims(None, None);
|
||||
let report = evaluate(
|
||||
&policy,
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
@@ -562,7 +562,7 @@ mod tests {
|
||||
#[test]
|
||||
fn preferred_resolutions_pass_in_both_phases() {
|
||||
let policy = policy();
|
||||
let overrides = MovieOverrides::default();
|
||||
let overrides = TitleOverrides::default();
|
||||
|
||||
for claimed in [ClaimedResolution::P2160, ClaimedResolution::P1080] {
|
||||
let claims = claims(Some(claimed), Some(ClaimedSource::WebDl));
|
||||
@@ -583,7 +583,7 @@ mod tests {
|
||||
#[test]
|
||||
fn resolution_outside_root_policy_hard_fails_in_both_phases() {
|
||||
let policy = policy();
|
||||
let overrides = MovieOverrides::default();
|
||||
let overrides = TitleOverrides::default();
|
||||
let cases = [
|
||||
(ClaimedResolution::P480, Resolution::Other(480)),
|
||||
(ClaimedResolution::P576, Resolution::Other(576)),
|
||||
@@ -607,9 +607,9 @@ mod tests {
|
||||
#[test]
|
||||
fn only_4k_tightens_the_root_policy_in_both_phases() {
|
||||
let policy = policy();
|
||||
let overrides = MovieOverrides {
|
||||
let overrides = TitleOverrides {
|
||||
only_4k: true,
|
||||
..MovieOverrides::default()
|
||||
..TitleOverrides::default()
|
||||
};
|
||||
let claims_1080 = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
|
||||
let media = probed(Resolution::R1080p, Some(Source::WebDl));
|
||||
@@ -634,9 +634,9 @@ mod tests {
|
||||
fn only_4k_never_expands_the_root_policy() {
|
||||
let mut policy = policy();
|
||||
policy.resolution_preference = vec![Resolution::R1080p];
|
||||
let overrides = MovieOverrides {
|
||||
let overrides = TitleOverrides {
|
||||
only_4k: true,
|
||||
..MovieOverrides::default()
|
||||
..TitleOverrides::default()
|
||||
};
|
||||
let claims = claims(Some(ClaimedResolution::P2160), Some(ClaimedSource::WebDl));
|
||||
|
||||
@@ -649,7 +649,7 @@ mod tests {
|
||||
#[test]
|
||||
fn every_unsafe_source_hard_fails_in_both_phases() {
|
||||
let policy = policy();
|
||||
let overrides = MovieOverrides::default();
|
||||
let overrides = TitleOverrides::default();
|
||||
let cases = [
|
||||
(ClaimedSource::Cam, Source::Cam),
|
||||
(ClaimedSource::Telesync, Source::Telesync),
|
||||
@@ -679,7 +679,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
verdict(
|
||||
&policy,
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
Candidate::PreGrab(&claims),
|
||||
),
|
||||
Verdict::Rejected(Rule::Source(Source::Telesync))
|
||||
@@ -689,7 +689,7 @@ mod tests {
|
||||
#[test]
|
||||
fn every_safe_source_passes_in_both_phases() {
|
||||
let policy = policy();
|
||||
let overrides = MovieOverrides::default();
|
||||
let overrides = TitleOverrides::default();
|
||||
let cases = [
|
||||
(ClaimedSource::Dvd, Source::Dvd),
|
||||
(ClaimedSource::Telecine, Source::Telecine),
|
||||
@@ -721,7 +721,7 @@ mod tests {
|
||||
let media = probed(Resolution::R2160p, source);
|
||||
let report = evaluate(
|
||||
&policy,
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
@@ -777,7 +777,7 @@ mod tests {
|
||||
|
||||
let report = evaluate(
|
||||
policy,
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
@@ -796,7 +796,7 @@ mod tests {
|
||||
let claims = arr_parse::parse("Movie.2024.2160p.WEB-DL.DV-GROUP");
|
||||
let report = evaluate(
|
||||
&policy(),
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
@@ -823,7 +823,7 @@ mod tests {
|
||||
#[test]
|
||||
fn hard_failure_wins_over_an_earlier_soft_failure() {
|
||||
let policy = policy();
|
||||
let overrides = MovieOverrides::default();
|
||||
let overrides = TitleOverrides::default();
|
||||
let original = en();
|
||||
let claims = claims(None, None);
|
||||
let context = EvaluationContext {
|
||||
@@ -851,7 +851,7 @@ mod tests {
|
||||
#[test]
|
||||
fn first_failure_of_the_deciding_kind_is_stable() {
|
||||
let policy = policy();
|
||||
let overrides = MovieOverrides::default();
|
||||
let overrides = TitleOverrides::default();
|
||||
let original = en();
|
||||
let claims = claims(None, None);
|
||||
let context = EvaluationContext {
|
||||
@@ -984,9 +984,9 @@ mod tests {
|
||||
#[test]
|
||||
fn kids_allow_english_audio_grabs_and_imports_with_a_waiver() {
|
||||
let policy = kids_policy();
|
||||
let overrides = MovieOverrides {
|
||||
let overrides = TitleOverrides {
|
||||
allow_english_audio: true,
|
||||
..MovieOverrides::default()
|
||||
..TitleOverrides::default()
|
||||
};
|
||||
let claims = name_claims(&[LanguageMarker::English]);
|
||||
assert_eq!(
|
||||
@@ -1078,7 +1078,7 @@ mod tests {
|
||||
// kids: could be the required pt-PT — must not reject as a dub.
|
||||
let report = evaluate(
|
||||
&kids_policy(),
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
@@ -1089,7 +1089,7 @@ mod tests {
|
||||
// as verified either.
|
||||
let report = evaluate(
|
||||
&policy(),
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
&Language::PortugueseBrazil,
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
@@ -1102,7 +1102,7 @@ mod tests {
|
||||
let claims = name_claims(&[LanguageMarker::Portuguese]);
|
||||
let report = evaluate(
|
||||
&kids_policy(),
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
|
||||
@@ -156,7 +156,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
policy::{evaluate, EvaluationContext, PolicyRule, RuleEvaluation, SizeRule},
|
||||
HdrRules, Language, MovieOverrides, PolicyId, RequiredAudio, Rule, Verdict,
|
||||
HdrRules, Language, PolicyId, RequiredAudio, Rule, TitleOverrides, Verdict,
|
||||
};
|
||||
|
||||
const GIB: u64 = 1 << 30;
|
||||
@@ -221,7 +221,7 @@ mod tests {
|
||||
|
||||
fn size_rule(size_bytes: u64) -> RuleEvaluation {
|
||||
let policy = policy();
|
||||
let overrides = MovieOverrides::default();
|
||||
let overrides = TitleOverrides::default();
|
||||
let language = Language::Other("en".to_owned());
|
||||
let claims = claims(ClaimedSource::WebDl);
|
||||
SizeRule.evaluate(&EvaluationContext {
|
||||
@@ -256,7 +256,7 @@ mod tests {
|
||||
let claims = claims(ClaimedSource::Remux);
|
||||
let evaluation = evaluate(
|
||||
&policy,
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
&Language::Other("en".to_owned()),
|
||||
Candidate::PreGrab(&claims),
|
||||
Some(gib(60)),
|
||||
|
||||
+137
-14
@@ -15,10 +15,10 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use arr_core::policy::{evaluate, Candidate};
|
||||
use arr_core::{score::score, Language, MovieOverrides, Policy, Rule, Verdict};
|
||||
use arr_core::{score::score, Language, Policy, Rule, TitleOverrides, Verdict};
|
||||
use arr_db::{Db, MoviePolicy};
|
||||
use arr_dl::{AddTorrent, TorrentSource, TransmissionClient};
|
||||
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
|
||||
@@ -31,6 +31,16 @@ use crate::reconcile::{Action, ActionFuture, Outcome};
|
||||
/// and release-date gating are issue #26.
|
||||
const MOVIES_PER_TICK: i64 = 5;
|
||||
|
||||
/// How long a discovered indexer list is reused. Prowlarr enumerates one
|
||||
/// indexer per call plus a `t=caps` probe each, all sequential, so paying for
|
||||
/// it every 30 s would leave the tick no room to search.
|
||||
const INDEXER_CACHE_TTL: Duration = Duration::from_mins(15);
|
||||
|
||||
/// The share of the reconcile lane's 25 s budget discovery may spend. One
|
||||
/// unresponsive tracker must not cancel the whole action before a single
|
||||
/// targeted search has run; past this the last known list is used instead.
|
||||
const INDEXER_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
|
||||
/// Seeding obligations, per tracker in principle (§7.3) and per install in
|
||||
/// practice until issue #25 gives them a home. Both are set on the torrent at
|
||||
/// add time and enforced by Transmission.
|
||||
@@ -56,6 +66,15 @@ pub enum GrabError {
|
||||
name: String,
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("indexer discovery timed out after {0:?} and none is known yet")]
|
||||
IndexerDiscoveryTimeout(Duration),
|
||||
}
|
||||
|
||||
/// The searchable indexer ids, and when they were last discovered.
|
||||
#[derive(Debug, Default)]
|
||||
struct IndexerCache {
|
||||
ids: Vec<i64>,
|
||||
refreshed_at: Option<Instant>,
|
||||
}
|
||||
|
||||
/// Sends the best eligible release for every wanted movie that has neither a
|
||||
@@ -66,6 +85,10 @@ pub struct GrabAction {
|
||||
transmission: TransmissionClient,
|
||||
download_dir: PathBuf,
|
||||
seeding: SeedingLimits,
|
||||
indexers: tokio::sync::RwLock<IndexerCache>,
|
||||
/// [`INDEXER_DISCOVERY_TIMEOUT`], overridden by tests that cannot wait
|
||||
/// out the real one. Mirrors `ReconcileLoop`'s action timeout override.
|
||||
discovery_timeout: Duration,
|
||||
}
|
||||
|
||||
impl GrabAction {
|
||||
@@ -81,6 +104,8 @@ impl GrabAction {
|
||||
transmission,
|
||||
download_dir,
|
||||
seeding,
|
||||
indexers: tokio::sync::RwLock::new(IndexerCache::default()),
|
||||
discovery_timeout: INDEXER_DISCOVERY_TIMEOUT,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,12 +116,7 @@ impl GrabAction {
|
||||
return Ok(outcomes);
|
||||
}
|
||||
|
||||
let indexers = self.prowlarr.indexers().await?;
|
||||
let searchable: Vec<i64> = indexers
|
||||
.iter()
|
||||
.filter(|indexer| indexer.capabilities.search.available)
|
||||
.map(|indexer| indexer.id)
|
||||
.collect();
|
||||
let searchable = self.searchable_indexers().await?;
|
||||
if searchable.is_empty() {
|
||||
tracing::warn!("no indexer advertises a text search; nothing can be grabbed");
|
||||
return Ok(outcomes);
|
||||
@@ -173,6 +193,51 @@ impl GrabAction {
|
||||
Ok(outcomes)
|
||||
}
|
||||
|
||||
/// The indexers that accept a text search, cached across ticks.
|
||||
///
|
||||
/// Discovery is bounded and its result is reused, because Prowlarr probes
|
||||
/// capabilities one indexer at a time: a single slow tracker would
|
||||
/// otherwise burn the whole reconcile budget before any search runs. A
|
||||
/// refresh that fails or times out keeps the previous list rather than
|
||||
/// stopping the tick, and only an empty cache turns that into an error.
|
||||
async fn searchable_indexers(&self) -> Result<Vec<i64>, GrabError> {
|
||||
{
|
||||
let cache = self.indexers.read().await;
|
||||
if cache
|
||||
.refreshed_at
|
||||
.is_some_and(|at| at.elapsed() < INDEXER_CACHE_TTL)
|
||||
{
|
||||
return Ok(cache.ids.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let discovered =
|
||||
tokio::time::timeout(self.discovery_timeout, self.prowlarr.indexers()).await;
|
||||
let mut cache = self.indexers.write().await;
|
||||
match discovered {
|
||||
Ok(Ok(indexers)) => {
|
||||
cache.ids = indexers
|
||||
.iter()
|
||||
.filter(|indexer| indexer.capabilities.search.available)
|
||||
.map(|indexer| indexer.id)
|
||||
.collect();
|
||||
cache.refreshed_at = Some(Instant::now());
|
||||
}
|
||||
Ok(Err(error)) if cache.ids.is_empty() => return Err(error.into()),
|
||||
Err(_) if cache.ids.is_empty() => {
|
||||
return Err(GrabError::IndexerDiscoveryTimeout(self.discovery_timeout))
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
tracing::warn!(%error, "indexer discovery failed; using the last known list");
|
||||
}
|
||||
Err(_) => tracing::warn!(
|
||||
timeout_seconds = self.discovery_timeout.as_secs(),
|
||||
"indexer discovery timed out; using the last known list"
|
||||
),
|
||||
}
|
||||
Ok(cache.ids.clone())
|
||||
}
|
||||
|
||||
/// Search every indexer for one title, cache each candidate with its
|
||||
/// verdict and score (§9.3), and return the eligible ones best first.
|
||||
///
|
||||
@@ -404,7 +469,7 @@ async fn store_release(
|
||||
movie_id: i64,
|
||||
release: &SearchRelease,
|
||||
policy: &Policy,
|
||||
overrides: &MovieOverrides,
|
||||
overrides: &TitleOverrides,
|
||||
original_language: &Language,
|
||||
) -> Result<Option<Eligible>, GrabError> {
|
||||
let parsed = arr_parse::parse(&release.name);
|
||||
@@ -415,13 +480,20 @@ async fn store_release(
|
||||
Candidate::PreGrab(&parsed),
|
||||
release.size,
|
||||
);
|
||||
let score = score(
|
||||
let scored = score(
|
||||
policy,
|
||||
Candidate::PreGrab(&parsed),
|
||||
release.size.unwrap_or(0),
|
||||
release.seeders.unwrap_or(0),
|
||||
)
|
||||
.total;
|
||||
release.size.unwrap_or_default(),
|
||||
release.seeders.unwrap_or_default(),
|
||||
);
|
||||
// A release that did not say its size is not a tiny one: scoring it
|
||||
// against the band's floor would bury it. Same treatment as the manual
|
||||
// search view, so the ranking the operator sees is the one that picks.
|
||||
let score = if release.size.is_some() {
|
||||
scored.total
|
||||
} else {
|
||||
scored.source.saturating_add(scored.seeders)
|
||||
};
|
||||
let (verdict, rule) = verdict_columns(&evaluation.verdict);
|
||||
let parsed_json = serde_json::to_string(&parsed).map_err(|source| GrabError::Parsed {
|
||||
name: release.name.clone(),
|
||||
@@ -936,6 +1008,57 @@ mod tests {
|
||||
assert!(grabs(&database).await.is_empty());
|
||||
}
|
||||
|
||||
/// Prowlarr probes capabilities one indexer at a time, so paying for
|
||||
/// discovery every 30 s would leave the tick no room to search.
|
||||
#[tokio::test]
|
||||
async fn indexer_discovery_is_cached_across_ticks() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
let action = action(&indexer, &downloader);
|
||||
|
||||
action.tick(&database).await.unwrap();
|
||||
action.tick(&database).await.unwrap();
|
||||
|
||||
let enumerations = indexer
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter(|request| request.url.path() == "/api/v1/indexer")
|
||||
.count();
|
||||
assert_eq!(enumerations, 1);
|
||||
}
|
||||
|
||||
/// One unresponsive tracker must not cancel the action before a single
|
||||
/// search has run.
|
||||
#[tokio::test]
|
||||
async fn a_stalled_discovery_gives_up_instead_of_hanging() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/indexer"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_delay(Duration::from_secs(30))
|
||||
.set_body_json(json!([])),
|
||||
)
|
||||
.mount(&indexer)
|
||||
.await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
let mut action = action(&indexer, &downloader);
|
||||
action.discovery_timeout = Duration::from_millis(50);
|
||||
|
||||
let error = action
|
||||
.tick(&database)
|
||||
.await
|
||||
.expect_err("discovery cannot complete");
|
||||
|
||||
assert!(matches!(error, GrabError::IndexerDiscoveryTimeout(_)));
|
||||
assert!(fake.torrents().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexer_links_and_magnets_both_reach_transmission() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
-- TV side of the separate aggregates in DESIGN.md §4. This migration is
|
||||
-- additive: the movie tables stay concrete and unchanged.
|
||||
CREATE TABLE series (
|
||||
id INTEGER PRIMARY KEY,
|
||||
tmdb_id INTEGER NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
year INTEGER,
|
||||
original_language TEXT,
|
||||
root_id INTEGER NOT NULL REFERENCES roots (id),
|
||||
auto_track INTEGER NOT NULL DEFAULT 0 CHECK (auto_track IN (0, 1)),
|
||||
overrides TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(overrides)),
|
||||
-- §4.2 needs upstream completion to derive `ended` rather than store it.
|
||||
upstream_ended INTEGER NOT NULL DEFAULT 0 CHECK (upstream_ended IN (0, 1)),
|
||||
blocked INTEGER NOT NULL DEFAULT 0 CHECK (blocked IN (0, 1)),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX series_root ON series (root_id);
|
||||
|
||||
-- Before TV roots existed, the roots foreign key was sufficient to keep a
|
||||
-- movie on a movie root. Preserve that invariant now that both kinds exist.
|
||||
CREATE TRIGGER movies_require_movie_root
|
||||
BEFORE INSERT ON movies
|
||||
WHEN EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind != 'movie')
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'movies require a movie root');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER movies_require_movie_root_on_update
|
||||
BEFORE UPDATE OF root_id ON movies
|
||||
WHEN EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind != 'movie')
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'movies require a movie root');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER series_require_tv_root
|
||||
BEFORE INSERT ON series
|
||||
WHEN EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind != 'tv')
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'series require a TV root');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER series_require_tv_root_on_update
|
||||
BEFORE UPDATE OF root_id ON series
|
||||
WHEN EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind != 'tv')
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'series require a TV root');
|
||||
END;
|
||||
|
||||
CREATE TABLE seasons (
|
||||
id INTEGER PRIMARY KEY,
|
||||
series_id INTEGER NOT NULL REFERENCES series (id) ON DELETE CASCADE,
|
||||
number INTEGER NOT NULL CHECK (number >= 0),
|
||||
tracked INTEGER NOT NULL DEFAULT 0 CHECK (tracked IN (0, 1)),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
UNIQUE (series_id, number)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE episodes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
season_id INTEGER NOT NULL REFERENCES seasons (id) ON DELETE CASCADE,
|
||||
number INTEGER NOT NULL CHECK (number >= 0),
|
||||
title TEXT NOT NULL,
|
||||
air_date TEXT,
|
||||
wanted INTEGER NOT NULL DEFAULT 0 CHECK (wanted IN (0, 1)),
|
||||
-- Mirrors arr-core's canonical MediaState vocabulary. The older movies
|
||||
-- table uses legacy names; its additive correction is tracked in #73.
|
||||
state TEXT NOT NULL DEFAULT 'missing'
|
||||
CHECK (state IN ('missing', 'downloading', 'available')),
|
||||
search_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_searched_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
UNIQUE (season_id, number)
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX episodes_pending_search
|
||||
ON episodes (last_searched_at)
|
||||
WHERE wanted = 1 AND state = 'missing';
|
||||
|
||||
CREATE INDEX episodes_state ON episodes (state);
|
||||
|
||||
INSERT INTO policies (
|
||||
name, required_audio, dub_blacklist, hdr_rules, size_bands,
|
||||
resolution_pref, source_weights, score_weights
|
||||
) VALUES (
|
||||
'TV — main',
|
||||
json('{"require":"original_language","allow_english_fallback":false}'),
|
||||
json('["pt-BR"]'),
|
||||
json('{"dv_profile_allow":["8.1"],"dv_profile_reject":["5","7"],"allow_hdr10":true,"allow_hdr10plus":true,"allow_sdr":true}'),
|
||||
json('{"2160p":{"floor_gib":8,"target_gib":22,"penalty_points_per_gib_over":60},"1080p":{"floor_gib":3,"target_gib":8,"penalty_points_per_gib_over":60}}'),
|
||||
json('["2160p","1080p"]'),
|
||||
-- §5.5 deliberately keeps source tier as Remux > BluRay > WEB-DL.
|
||||
json('{"Remux":4,"BluRay":3,"WEB-DL":2,"WEBRip":1,"HDTV":0}'),
|
||||
json('{"size_at_target":1000,"source_tier":25,"seeder_doubling":8}')
|
||||
);
|
||||
|
||||
INSERT INTO policies (
|
||||
name, required_audio, dub_blacklist, hdr_rules, size_bands,
|
||||
resolution_pref, source_weights, score_weights
|
||||
) VALUES (
|
||||
'TV — kids',
|
||||
json('{"require":"any_of","langs":["pt-PT"],"or_original_when_portuguese":true,"allow_english_fallback":false}'),
|
||||
json('["pt-BR"]'),
|
||||
json('{"dv_profile_allow":["8.1"],"dv_profile_reject":["5","7"],"allow_hdr10":true,"allow_hdr10plus":true,"allow_sdr":true}'),
|
||||
json('{"2160p":{"floor_gib":8,"target_gib":22,"penalty_points_per_gib_over":60},"1080p":{"floor_gib":3,"target_gib":8,"penalty_points_per_gib_over":60}}'),
|
||||
json('["2160p","1080p"]'),
|
||||
-- §5.2: pt-PT dubs are concentrated in streaming releases.
|
||||
json('{"WEB-DL":4,"WEBRip":2,"BluRay":1,"Remux":1,"HDTV":0}'),
|
||||
json('{"size_at_target":1000,"source_tier":25,"seeder_doubling":8}')
|
||||
);
|
||||
|
||||
INSERT INTO roots (kind, audience, path, policy_id)
|
||||
SELECT 'tv', 'main', '/mnt/media/tv/main', id FROM policies WHERE name = 'TV — main';
|
||||
|
||||
INSERT INTO roots (kind, audience, path, policy_id)
|
||||
SELECT 'tv', 'kids', '/mnt/media/tv/kids', id FROM policies WHERE name = 'TV — kids';
|
||||
+128
-8
@@ -160,13 +160,13 @@ mod tests {
|
||||
|
||||
let roots = db.list_roots().await.expect("list roots");
|
||||
|
||||
assert_eq!(roots.len(), 2, "§5.1: two movie roots, TV comes with #34");
|
||||
assert!(roots.iter().all(|r| r.kind == "movie"));
|
||||
assert_eq!(roots[0].audience, "kids");
|
||||
assert_eq!(roots[0].path, "/mnt/media/movies/kids");
|
||||
assert_eq!(roots[1].audience, "main");
|
||||
assert_eq!(roots[1].path, "/mnt/media/movies/main");
|
||||
assert_ne!(roots[0].policy_id, roots[1].policy_id);
|
||||
let movie_roots: Vec<_> = roots.iter().filter(|root| root.kind == "movie").collect();
|
||||
assert_eq!(movie_roots.len(), 2);
|
||||
assert_eq!(movie_roots[0].audience, "kids");
|
||||
assert_eq!(movie_roots[0].path, "/mnt/media/movies/kids");
|
||||
assert_eq!(movie_roots[1].audience, "main");
|
||||
assert_eq!(movie_roots[1].path, "/mnt/media/movies/main");
|
||||
assert_ne!(movie_roots[0].policy_id, movie_roots[1].policy_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -204,7 +204,7 @@ mod tests {
|
||||
.await
|
||||
.expect("policies");
|
||||
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows.len(), 4);
|
||||
for row in rows {
|
||||
let size_bands: String = row.get(0);
|
||||
let score_weights: String = row.get(1);
|
||||
@@ -251,4 +251,124 @@ mod tests {
|
||||
.await
|
||||
.expect_err("state is a closed set");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seeds_two_tv_roots_with_distinct_policies() {
|
||||
let (_dir, db) = fresh().await;
|
||||
let roots = db.list_roots().await.expect("list roots");
|
||||
let tv_roots: Vec<_> = roots.iter().filter(|root| root.kind == "tv").collect();
|
||||
|
||||
assert_eq!(tv_roots.len(), 2);
|
||||
assert_eq!(tv_roots[0].audience, "kids");
|
||||
assert_eq!(tv_roots[0].path, "/mnt/media/tv/kids");
|
||||
assert_eq!(tv_roots[1].audience, "main");
|
||||
assert_eq!(tv_roots[1].path, "/mnt/media/tv/main");
|
||||
assert_ne!(tv_roots[0].policy_id, tv_roots[1].policy_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn episode_wanted_is_independent_of_tracking_rules() {
|
||||
let (_dir, db) = fresh().await;
|
||||
let root_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("TV root");
|
||||
let series_id = sqlx::query("INSERT INTO series (tmdb_id, title, root_id, auto_track) VALUES (82728, 'Bluey', ?, 1)")
|
||||
.bind(root_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("series")
|
||||
.last_insert_rowid();
|
||||
let season_id =
|
||||
sqlx::query("INSERT INTO seasons (series_id, number, tracked) VALUES (?, 1, 1)")
|
||||
.bind(series_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("season")
|
||||
.last_insert_rowid();
|
||||
sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 2, 'Hospital')")
|
||||
.bind(season_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("episode");
|
||||
|
||||
let wanted: bool = sqlx::query_scalar("SELECT wanted FROM episodes")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("wanted");
|
||||
assert!(
|
||||
!wanted,
|
||||
"auto_track and tracked are rules, not episode intent"
|
||||
);
|
||||
|
||||
sqlx::query("INSERT INTO movies (tmdb_id, title, root_id) SELECT 693134, 'Dune Part Two', id FROM roots WHERE kind = 'movie' AND audience = 'main'")
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("existing movie schema remains writable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aggregates_require_roots_of_their_own_kind() {
|
||||
let (_dir, db) = fresh().await;
|
||||
let movie_root: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'movie' LIMIT 1")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("movie root");
|
||||
let tv_root: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' LIMIT 1")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("TV root");
|
||||
|
||||
sqlx::query("INSERT INTO movies (tmdb_id, title, root_id) VALUES (1, 'x', ?)")
|
||||
.bind(tv_root)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect_err("movie cannot use TV root");
|
||||
sqlx::query("INSERT INTO series (tmdb_id, title, root_id) VALUES (2, 'x', ?)")
|
||||
.bind(movie_root)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect_err("series cannot use movie root");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn episode_state_and_upstream_completion_are_constrained() {
|
||||
let (_dir, db) = fresh().await;
|
||||
let root_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("TV root");
|
||||
let series_id = sqlx::query(
|
||||
"INSERT INTO series (tmdb_id, title, root_id, upstream_ended)
|
||||
VALUES (82728, 'Bluey', ?, 1)",
|
||||
)
|
||||
.bind(root_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("ended series")
|
||||
.last_insert_rowid();
|
||||
let season_id = sqlx::query("INSERT INTO seasons (series_id, number) VALUES (?, 1)")
|
||||
.bind(series_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("season")
|
||||
.last_insert_rowid();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO episodes (season_id, number, title, state)
|
||||
VALUES (?, 1, 'Magic Xylophone', 'grabbed')",
|
||||
)
|
||||
.bind(season_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect_err("database mirrors MediaState");
|
||||
sqlx::query("UPDATE series SET upstream_ended = 2 WHERE id = ?")
|
||||
.bind(series_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect_err("upstream completion is boolean");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use arr_core::{
|
||||
DolbyVisionProfile, HdrRules, Language, MovieOverrides, Policy, PolicyId, RequiredAudio,
|
||||
Resolution, ScoreWeights, SizeBand, Source,
|
||||
DolbyVisionProfile, HdrRules, Language, Policy, PolicyId, RequiredAudio, Resolution,
|
||||
ScoreWeights, SizeBand, Source, TitleOverrides,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -36,7 +36,7 @@ pub enum PolicyError {
|
||||
pub struct MoviePolicy {
|
||||
pub policy: Policy,
|
||||
/// Per-title relaxations and tightenings of the root policy (§5.1).
|
||||
pub overrides: MovieOverrides,
|
||||
pub overrides: TitleOverrides,
|
||||
pub root_id: i64,
|
||||
/// `movie` or `tv`.
|
||||
pub root_kind: String,
|
||||
@@ -186,7 +186,7 @@ impl Db {
|
||||
|
||||
Ok(Some(MoviePolicy {
|
||||
policy,
|
||||
overrides: MovieOverrides {
|
||||
overrides: TitleOverrides {
|
||||
only_4k: overrides.only_4k,
|
||||
allow_english_audio: overrides.allow_english_audio,
|
||||
},
|
||||
@@ -294,7 +294,8 @@ mod tests {
|
||||
db.migrate().await.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO movies (tmdb_id, title, root_id)
|
||||
SELECT 693134, 'Dune Part Two', id FROM roots WHERE audience = 'main'",
|
||||
SELECT 693134, 'Dune Part Two', id
|
||||
FROM roots WHERE kind = 'movie' AND audience = 'main'",
|
||||
)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
@@ -322,7 +323,7 @@ mod tests {
|
||||
loaded.policy.resolution_preference,
|
||||
vec![Resolution::R2160p, Resolution::R1080p]
|
||||
);
|
||||
assert_eq!(loaded.overrides, MovieOverrides::default());
|
||||
assert_eq!(loaded.overrides, TitleOverrides::default());
|
||||
}
|
||||
|
||||
/// §5.5: the scoring numbers are policy rows, so the loader reads them
|
||||
@@ -362,6 +363,37 @@ mod tests {
|
||||
assert!(!loaded.overrides.allow_english_audio);
|
||||
}
|
||||
|
||||
/// The mapping is usable without a database, which is how the API's
|
||||
/// classifier and the daemon's selection stay on the same numbers.
|
||||
#[test]
|
||||
fn columns_map_without_a_row() {
|
||||
let policy = PolicyColumns {
|
||||
id: 1,
|
||||
name: "test".into(),
|
||||
required_audio: r#"{"require":"original_language"}"#.into(),
|
||||
dub_blacklist: "[]".into(),
|
||||
hdr_rules: "{}".into(),
|
||||
size_bands:
|
||||
r#"{"2160p":{"floor_gib":8,"target_gib":22,"penalty_points_per_gib_over":60}}"#
|
||||
.into(),
|
||||
resolution_pref: r#"["2160p"]"#.into(),
|
||||
source_weights: r#"{"WEB-DL":2}"#.into(),
|
||||
score_weights: r#"{"size_at_target":2000,"source_tier":7,"seeder_doubling":11}"#.into(),
|
||||
}
|
||||
.to_policy()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
policy.score_weights,
|
||||
ScoreWeights {
|
||||
size_at_target: 2000,
|
||||
source_tier: 7,
|
||||
seeder_doubling: 11,
|
||||
}
|
||||
);
|
||||
assert_eq!(policy.source_weights[&Source::WebDl], 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unknown_movie_is_not_an_error() {
|
||||
let (_dir, db) = seeded().await;
|
||||
|
||||
@@ -28,12 +28,31 @@
|
||||
output in this environment).
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the
|
||||
finish review, the verdict, and DESIGN.md.
|
||||
SEARCH (§9.2, issue #30): one command input on the rail — the board's
|
||||
command line. Typing swaps the chain for the search deck: IN LIBRARY
|
||||
first, ON TMDB below, engraved group labels, machined rows. Enter on a
|
||||
TMDB row expands an inline add panel with root and policy pre-filled;
|
||||
a pasted magnet or .torrent routes to a manual-intake panel. There is
|
||||
no separate add page, ever. Esc returns to the board; "/" focuses the
|
||||
box from anywhere.
|
||||
-->
|
||||
<header class="rail">
|
||||
<div class="rail-id">
|
||||
<span class="lamp" id="master-lamp" data-state="probing" aria-hidden="true"></span>
|
||||
<h1 class="wordmark">arr</h1>
|
||||
</div>
|
||||
<div class="rail-search">
|
||||
<input
|
||||
id="search"
|
||||
class="search-input readout"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
placeholder="search · title / tmdb id / tt… / magnet"
|
||||
aria-label="unified search — titles, TMDB or IMDb ids, magnets and .torrent links"
|
||||
/>
|
||||
<span class="search-hint readout" id="search-hint" aria-hidden="true">/</span>
|
||||
</div>
|
||||
<p class="rail-verdict readout" id="master-verdict" role="status">probing chain…</p>
|
||||
<div class="rail-meta">
|
||||
<span class="readout dim" id="version" data-testid="version">v —</span>
|
||||
@@ -41,6 +60,33 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="deck" id="deck" hidden>
|
||||
<p class="deck-status readout" id="deck-status" role="status" hidden></p>
|
||||
|
||||
<section class="deck-group" id="group-library" hidden aria-labelledby="label-library">
|
||||
<header class="deck-head">
|
||||
<h2 class="deck-label" id="label-library">in library</h2>
|
||||
<span class="deck-count readout" id="count-library"></span>
|
||||
</header>
|
||||
<ul class="deck-rows" id="rows-library"></ul>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="group-tmdb" hidden aria-labelledby="label-tmdb">
|
||||
<header class="deck-head">
|
||||
<h2 class="deck-label" id="label-tmdb">on tmdb</h2>
|
||||
<span class="deck-count readout" id="count-tmdb"></span>
|
||||
</header>
|
||||
<ul class="deck-rows" id="rows-tmdb"></ul>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="group-manual" hidden aria-labelledby="label-manual">
|
||||
<header class="deck-head">
|
||||
<h2 class="deck-label" id="label-manual">manual grab</h2>
|
||||
</header>
|
||||
<article class="module intake" id="manual-intake"></article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main class="board">
|
||||
<section class="chain" aria-label="signal chain">
|
||||
<article class="module area-tmdb" data-check="tmdb">
|
||||
|
||||
+462
@@ -1,4 +1,14 @@
|
||||
import { type CheckStatus, type Probe, probeHealth } from "./health";
|
||||
import {
|
||||
addMovie,
|
||||
type LibraryMovie,
|
||||
movieRoots,
|
||||
parseManualInput,
|
||||
type Root,
|
||||
type SearchResponse,
|
||||
searchTitles,
|
||||
type TmdbMovie,
|
||||
} from "./search";
|
||||
import "./style.css";
|
||||
|
||||
const POLL_MS = 15_000;
|
||||
@@ -154,6 +164,458 @@ function main() {
|
||||
void probe();
|
||||
}, POLL_MS);
|
||||
void probe();
|
||||
|
||||
searchMain(board);
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
interface DeckRefs {
|
||||
deck: HTMLElement;
|
||||
status: HTMLElement;
|
||||
groups: {
|
||||
library: { section: HTMLElement; count: HTMLElement; rows: HTMLUListElement };
|
||||
tmdb: { section: HTMLElement; count: HTMLElement; rows: HTMLUListElement };
|
||||
};
|
||||
manualSection: HTMLElement;
|
||||
manualIntake: HTMLElement;
|
||||
}
|
||||
|
||||
function searchMain(board: HTMLElement) {
|
||||
const input = must<HTMLInputElement>("#search");
|
||||
const hint = must<HTMLElement>("#search-hint");
|
||||
const refs: DeckRefs = {
|
||||
deck: must<HTMLElement>("#deck"),
|
||||
status: must<HTMLElement>("#deck-status"),
|
||||
groups: {
|
||||
library: {
|
||||
section: must<HTMLElement>("#group-library"),
|
||||
count: must<HTMLElement>("#count-library"),
|
||||
rows: must<HTMLUListElement>("#rows-library"),
|
||||
},
|
||||
tmdb: {
|
||||
section: must<HTMLElement>("#group-tmdb"),
|
||||
count: must<HTMLElement>("#count-tmdb"),
|
||||
rows: must<HTMLUListElement>("#rows-tmdb"),
|
||||
},
|
||||
},
|
||||
manualSection: must<HTMLElement>("#group-manual"),
|
||||
manualIntake: must<HTMLElement>("#manual-intake"),
|
||||
};
|
||||
|
||||
// The audience chip on a library row needs id → root. movieRoots() caches
|
||||
// success, so a failed boot fetch is retried whenever an add panel opens.
|
||||
let roots: Root[] = [];
|
||||
const fetchRoots = () =>
|
||||
movieRoots().then(
|
||||
(fetched) => {
|
||||
roots = fetched;
|
||||
return fetched;
|
||||
},
|
||||
() => roots,
|
||||
);
|
||||
void fetchRoots();
|
||||
|
||||
let timer: number | undefined;
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
function showBoard() {
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
refs.deck.hidden = true;
|
||||
board.hidden = false;
|
||||
}
|
||||
|
||||
function showDeck() {
|
||||
board.hidden = true;
|
||||
refs.deck.hidden = false;
|
||||
}
|
||||
|
||||
function setStatus(text: string | null, tone?: "fault") {
|
||||
refs.status.hidden = text === null;
|
||||
refs.status.textContent = text ?? "";
|
||||
if (tone) {
|
||||
refs.status.dataset.tone = tone;
|
||||
} else {
|
||||
delete refs.status.dataset.tone;
|
||||
}
|
||||
}
|
||||
|
||||
function clearGroups() {
|
||||
for (const group of [refs.groups.library, refs.groups.tmdb]) {
|
||||
group.section.hidden = true;
|
||||
group.rows.replaceChildren();
|
||||
}
|
||||
refs.manualSection.hidden = true;
|
||||
refs.manualIntake.replaceChildren();
|
||||
}
|
||||
|
||||
async function run(query: string) {
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
showDeck();
|
||||
setStatus("searching…");
|
||||
const outcome = await searchTitles(query, controller.signal);
|
||||
if (outcome.kind === "aborted") {
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === "error") {
|
||||
clearGroups();
|
||||
setStatus(`search failed — ${outcome.detail}`, "fault");
|
||||
return;
|
||||
}
|
||||
render(outcome.response);
|
||||
}
|
||||
|
||||
function render(response: SearchResponse) {
|
||||
clearGroups();
|
||||
if (
|
||||
response.manual !== null &&
|
||||
(response.kind === "magnet" || response.kind === "torrent_url")
|
||||
) {
|
||||
setStatus(null);
|
||||
refs.manualSection.hidden = false;
|
||||
renderManual(refs.manualIntake, response.kind, response.manual);
|
||||
return;
|
||||
}
|
||||
|
||||
const inLibrary = new Set(response.library.map((movie) => movie.tmdb_id));
|
||||
if (response.library.length === 0 && response.tmdb.length === 0) {
|
||||
setStatus("no matches in library or on tmdb");
|
||||
return;
|
||||
}
|
||||
setStatus(null);
|
||||
|
||||
if (response.library.length > 0) {
|
||||
refs.groups.library.section.hidden = false;
|
||||
refs.groups.library.count.textContent = String(response.library.length);
|
||||
for (const movie of response.library) {
|
||||
refs.groups.library.rows.append(libraryRow(movie, roots));
|
||||
}
|
||||
}
|
||||
if (response.tmdb.length > 0) {
|
||||
refs.groups.tmdb.section.hidden = false;
|
||||
refs.groups.tmdb.count.textContent = String(response.tmdb.length);
|
||||
for (const movie of response.tmdb) {
|
||||
refs.groups.tmdb.rows.append(tmdbRow(movie, inLibrary.has(movie.tmdb_id), fetchRoots));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
const query = input.value.trim();
|
||||
if (query === "") {
|
||||
showBoard();
|
||||
clearGroups();
|
||||
setStatus(null);
|
||||
return;
|
||||
}
|
||||
timer = window.setTimeout(() => {
|
||||
void run(query);
|
||||
}, DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
window.clearTimeout(timer);
|
||||
const query = input.value.trim();
|
||||
if (query !== "") {
|
||||
void run(query);
|
||||
}
|
||||
} else if (event.key === "ArrowDown") {
|
||||
const first = refs.deck.querySelector<HTMLElement>(".row-tmdb:not(:disabled)");
|
||||
if (first) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
} else if (event.key === "Escape") {
|
||||
input.value = "";
|
||||
showBoard();
|
||||
clearGroups();
|
||||
setStatus(null);
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener("focus", () => {
|
||||
hint.hidden = true;
|
||||
});
|
||||
input.addEventListener("blur", () => {
|
||||
hint.hidden = false;
|
||||
});
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (
|
||||
event.key === "/" &&
|
||||
document.activeElement !== input &&
|
||||
!(document.activeElement instanceof HTMLInputElement) &&
|
||||
!(document.activeElement instanceof HTMLTextAreaElement)
|
||||
) {
|
||||
event.preventDefault();
|
||||
input.focus();
|
||||
input.select();
|
||||
} else if (event.key === "Escape" && !refs.deck.hidden) {
|
||||
input.value = "";
|
||||
showBoard();
|
||||
clearGroups();
|
||||
setStatus(null);
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
|
||||
refs.deck.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
|
||||
return;
|
||||
}
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement) || !target.classList.contains("row-tmdb")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const rows = [...refs.deck.querySelectorAll<HTMLElement>(".row-tmdb:not(:disabled)")];
|
||||
const index = rows.indexOf(target);
|
||||
if (event.key === "ArrowUp" && index === 0) {
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
rows[index + (event.key === "ArrowDown" ? 1 : -1)]?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function chip(text: string, extra?: (chip: HTMLSpanElement) => void): HTMLSpanElement {
|
||||
const span = document.createElement("span");
|
||||
span.className = "chip readout";
|
||||
span.textContent = text;
|
||||
extra?.(span);
|
||||
return span;
|
||||
}
|
||||
|
||||
function rowTitle(title: string, year: number | null): HTMLElement {
|
||||
const wrap = document.createElement("span");
|
||||
wrap.className = "row-id";
|
||||
const name = document.createElement("span");
|
||||
name.className = "row-title";
|
||||
name.textContent = title;
|
||||
const when = document.createElement("span");
|
||||
when.className = "row-year readout dim";
|
||||
when.textContent = year === null ? "—" : String(year);
|
||||
wrap.append(name, when);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function libraryRow(movie: LibraryMovie, roots: Root[]): HTMLLIElement {
|
||||
const item = document.createElement("li");
|
||||
item.className = "row";
|
||||
const chips = document.createElement("span");
|
||||
chips.className = "row-chips";
|
||||
const root = roots.find((candidate) => candidate.id === movie.root_id);
|
||||
chips.append(chip(root ? root.audience : `root ${movie.root_id}`));
|
||||
chips.append(
|
||||
chip(movie.state, (span) => {
|
||||
span.dataset.movieState = movie.state;
|
||||
}),
|
||||
);
|
||||
if (!movie.wanted) {
|
||||
chips.append(chip("not wanted"));
|
||||
}
|
||||
if (movie.blocked) {
|
||||
chips.append(chip("blocked"));
|
||||
}
|
||||
item.append(rowTitle(movie.title, movie.year), chips);
|
||||
return item;
|
||||
}
|
||||
|
||||
function tmdbRow(
|
||||
movie: TmdbMovie,
|
||||
inLibrary: boolean,
|
||||
fetchRoots: () => Promise<Root[]>,
|
||||
): HTMLLIElement {
|
||||
const item = document.createElement("li");
|
||||
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "row row-tmdb";
|
||||
const chips = document.createElement("span");
|
||||
chips.className = "row-chips";
|
||||
chips.append(chip(movie.original_language));
|
||||
if (inLibrary) {
|
||||
chips.append(
|
||||
chip("in library", (span) => {
|
||||
span.dataset.verdict = "eligible";
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const add = document.createElement("span");
|
||||
add.className = "row-add readout";
|
||||
add.textContent = "add";
|
||||
chips.append(add);
|
||||
}
|
||||
row.append(rowTitle(movie.title, movie.year), chips);
|
||||
if (movie.overview) {
|
||||
const overview = document.createElement("span");
|
||||
overview.className = "row-overview";
|
||||
overview.textContent = movie.overview;
|
||||
row.append(overview);
|
||||
}
|
||||
item.append(row);
|
||||
|
||||
if (inLibrary) {
|
||||
row.disabled = true;
|
||||
return item;
|
||||
}
|
||||
|
||||
row.setAttribute("aria-expanded", "false");
|
||||
row.addEventListener("click", () => {
|
||||
const open = item.querySelector(".add-panel");
|
||||
if (open) {
|
||||
open.remove();
|
||||
row.setAttribute("aria-expanded", "false");
|
||||
row.focus();
|
||||
return;
|
||||
}
|
||||
void fetchRoots().then((available) => {
|
||||
if (item.querySelector(".add-panel")) {
|
||||
return;
|
||||
}
|
||||
const panel = addPanel(movie, available, row);
|
||||
item.append(panel);
|
||||
row.setAttribute("aria-expanded", "true");
|
||||
panel.querySelector<HTMLElement>(".control")?.focus();
|
||||
});
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
/** The §9.2 add flow: inline, root and policy pre-filled, one confirm. */
|
||||
function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTMLElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "add-panel";
|
||||
|
||||
let selected = roots.find((root) => root.audience === "main") ?? roots[0] ?? null;
|
||||
|
||||
const options = document.createElement("div");
|
||||
options.className = "add-roots";
|
||||
options.setAttribute("role", "group");
|
||||
options.setAttribute("aria-label", "root folder");
|
||||
|
||||
const optionButtons: HTMLButtonElement[] = [];
|
||||
for (const root of roots) {
|
||||
const option = document.createElement("button");
|
||||
option.type = "button";
|
||||
option.className = "root-option";
|
||||
option.setAttribute("aria-pressed", String(root === selected));
|
||||
|
||||
const audience = document.createElement("span");
|
||||
audience.className = "root-audience";
|
||||
audience.textContent = root.audience;
|
||||
const policy = document.createElement("span");
|
||||
policy.className = "root-policy readout dim";
|
||||
policy.textContent = root.policy_name;
|
||||
const path = document.createElement("span");
|
||||
path.className = "root-path readout dim";
|
||||
path.textContent = root.path;
|
||||
option.append(audience, policy, path);
|
||||
|
||||
option.addEventListener("click", () => {
|
||||
selected = root;
|
||||
for (const other of optionButtons) {
|
||||
other.setAttribute("aria-pressed", String(other === option));
|
||||
}
|
||||
});
|
||||
optionButtons.push(option);
|
||||
options.append(option);
|
||||
}
|
||||
|
||||
const confirm = document.createElement("button");
|
||||
confirm.type = "button";
|
||||
confirm.className = "control";
|
||||
confirm.textContent = "add to library";
|
||||
|
||||
const note = document.createElement("p");
|
||||
note.className = "add-note readout";
|
||||
note.setAttribute("role", "status");
|
||||
note.hidden = true;
|
||||
|
||||
if (roots.length === 0) {
|
||||
confirm.disabled = true;
|
||||
note.hidden = false;
|
||||
note.textContent = "roots unavailable — daemon down or database missing";
|
||||
note.dataset.tone = "fault";
|
||||
}
|
||||
|
||||
confirm.addEventListener("click", () => {
|
||||
const root = selected;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
confirm.disabled = true;
|
||||
note.hidden = false;
|
||||
delete note.dataset.tone;
|
||||
note.textContent = "adding…";
|
||||
void addMovie(movie, root.id).then((outcome) => {
|
||||
if (outcome.kind === "error") {
|
||||
confirm.disabled = false;
|
||||
note.textContent = `add failed — ${outcome.detail}`;
|
||||
note.dataset.tone = "fault";
|
||||
return;
|
||||
}
|
||||
const already = outcome.kind === "conflict";
|
||||
const done = document.createElement("p");
|
||||
done.className = "add-done readout";
|
||||
done.setAttribute("role", "status");
|
||||
done.tabIndex = -1;
|
||||
done.textContent = already
|
||||
? "already in library"
|
||||
: `added to ${root.audience} — wanted, search follows`;
|
||||
panel.replaceChildren(done);
|
||||
done.focus();
|
||||
row.disabled = true;
|
||||
row.setAttribute("aria-expanded", "true");
|
||||
row.querySelector(".row-add")?.replaceWith(
|
||||
chip("in library", (span) => {
|
||||
span.dataset.verdict = "eligible";
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "add-actions";
|
||||
actions.append(confirm, note);
|
||||
panel.append(options, actions);
|
||||
return panel;
|
||||
}
|
||||
|
||||
function renderManual(intake: HTMLElement, kind: "magnet" | "torrent_url", raw: string) {
|
||||
const parsed = parseManualInput(kind, raw);
|
||||
intake.replaceChildren();
|
||||
|
||||
const head = document.createElement("header");
|
||||
head.className = "module-head";
|
||||
const name = document.createElement("h3");
|
||||
name.className = "module-name";
|
||||
name.textContent = kind === "magnet" ? "magnet" : "torrent";
|
||||
const status = document.createElement("span");
|
||||
status.className = "module-status readout";
|
||||
status.textContent = "recognized";
|
||||
head.append(name, status);
|
||||
|
||||
const detail = document.createElement("p");
|
||||
detail.className = "module-detail readout";
|
||||
detail.textContent = parsed.name ?? parsed.raw;
|
||||
|
||||
intake.append(head, detail);
|
||||
if (parsed.infohash) {
|
||||
const hash = document.createElement("p");
|
||||
hash.className = "module-detail readout dim";
|
||||
hash.textContent = `btih ${parsed.infohash}`;
|
||||
intake.append(hash);
|
||||
}
|
||||
|
||||
const note = document.createElement("p");
|
||||
note.className = "module-detail readout dim";
|
||||
note.textContent = "manual grab lands with the release buckets — nothing sent yet";
|
||||
intake.append(note);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Hand-written mirror of arr-api's /api/search, /api/roots and POST
|
||||
// /api/movies schemas — same reasoning as health.ts: the generated client
|
||||
// (src/api/) is uncommitted, so CI's tsc cannot see it.
|
||||
|
||||
export type SearchInputKind = "text" | "tmdb_id" | "imdb_id" | "magnet" | "torrent_url";
|
||||
|
||||
export interface LibraryMovie {
|
||||
id: number;
|
||||
tmdb_id: number;
|
||||
title: string;
|
||||
year: number | null;
|
||||
original_language: string | null;
|
||||
root_id: number;
|
||||
wanted: boolean;
|
||||
state: "missing" | "grabbed" | "imported";
|
||||
blocked: boolean;
|
||||
}
|
||||
|
||||
export interface TmdbMovie {
|
||||
tmdb_id: number;
|
||||
title: string;
|
||||
original_title: string;
|
||||
original_language: string;
|
||||
year: number | null;
|
||||
overview: string | null;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
kind: SearchInputKind;
|
||||
library: LibraryMovie[];
|
||||
tmdb: TmdbMovie[];
|
||||
manual: string | null;
|
||||
}
|
||||
|
||||
export interface Root {
|
||||
id: number;
|
||||
kind: string;
|
||||
audience: string;
|
||||
path: string;
|
||||
policy_id: number;
|
||||
policy_name: string;
|
||||
}
|
||||
|
||||
export type SearchOutcome =
|
||||
| { kind: "results"; response: SearchResponse }
|
||||
| { kind: "error"; detail: string }
|
||||
| { kind: "aborted" };
|
||||
|
||||
export async function searchTitles(query: string, signal: AbortSignal): Promise<SearchOutcome> {
|
||||
try {
|
||||
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal });
|
||||
if (!response.ok) {
|
||||
const detail = await errorDetail(response);
|
||||
return { kind: "error", detail };
|
||||
}
|
||||
return { kind: "results", response: (await response.json()) as SearchResponse };
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
return { kind: "aborted" };
|
||||
}
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
let rootsCache: Root[] | null = null;
|
||||
|
||||
/** The movie roots, fetched once. The add flow pre-fills from these. */
|
||||
export async function movieRoots(): Promise<Root[]> {
|
||||
if (rootsCache) {
|
||||
return rootsCache;
|
||||
}
|
||||
const response = await fetch("/api/roots");
|
||||
if (!response.ok) {
|
||||
throw new Error(await errorDetail(response));
|
||||
}
|
||||
const roots = (await response.json()) as Root[];
|
||||
rootsCache = roots.filter((root) => root.kind === "movie");
|
||||
return rootsCache;
|
||||
}
|
||||
|
||||
export type AddOutcome =
|
||||
| { kind: "added"; movie: LibraryMovie }
|
||||
| { kind: "conflict" }
|
||||
| { kind: "error"; detail: string };
|
||||
|
||||
export async function addMovie(movie: TmdbMovie, rootId: number): Promise<AddOutcome> {
|
||||
try {
|
||||
const response = await fetch("/api/movies", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
tmdb_id: movie.tmdb_id,
|
||||
title: movie.title,
|
||||
year: movie.year,
|
||||
original_language: movie.original_language,
|
||||
root_id: rootId,
|
||||
}),
|
||||
});
|
||||
if (response.status === 409) {
|
||||
return { kind: "conflict" };
|
||||
}
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "added", movie: (await response.json()) as LibraryMovie };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
async function errorDetail(response: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string };
|
||||
return body.error ?? `http ${response.status}`;
|
||||
} catch {
|
||||
return `http ${response.status}`;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ManualIntake {
|
||||
kind: "magnet" | "torrent_url";
|
||||
/** The display name a magnet advertises, when it carries one. */
|
||||
name: string | null;
|
||||
/** The btih infohash, when the magnet carries one. */
|
||||
infohash: string | null;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/** Best-effort read of a pasted magnet or .torrent URL, for display only. */
|
||||
export function parseManualInput(kind: "magnet" | "torrent_url", raw: string): ManualIntake {
|
||||
if (kind === "torrent_url") {
|
||||
return { kind, name: raw.split("/").pop() ?? null, infohash: null, raw };
|
||||
}
|
||||
let name: string | null = null;
|
||||
let infohash: string | null = null;
|
||||
try {
|
||||
const params = new URLSearchParams(raw.slice(raw.indexOf("?") + 1));
|
||||
name = params.get("dn");
|
||||
const urn = params.get("xt") ?? "";
|
||||
const match = /^urn:btih:([0-9a-zA-Z]+)$/.exec(urn);
|
||||
infohash = match?.[1]?.toLowerCase() ?? null;
|
||||
} catch {
|
||||
// display-only parsing; a malformed magnet still shows raw
|
||||
}
|
||||
return { kind, name, infohash, raw };
|
||||
}
|
||||
@@ -277,6 +277,11 @@ body {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* the deck swaps in over the board; flex would beat [hidden] otherwise */
|
||||
.board[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chain {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -467,6 +472,285 @@ body {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---- unified search (§9.2) ------------------------------------------- */
|
||||
|
||||
.rail-search {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 30rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: 0 var(--space-8) 0 var(--space-3);
|
||||
color: var(--ink);
|
||||
background: var(--ground);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: inset 0 1px var(--lowlight);
|
||||
caret-color: var(--accent);
|
||||
transition: border-color 150ms var(--ease-out);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--ink-faint);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.search-input:hover {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-hint {
|
||||
position: absolute;
|
||||
right: var(--space-3);
|
||||
padding: 0 var(--space-1);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--ink-faint);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- search deck ------------------------------------------------------ */
|
||||
|
||||
.deck {
|
||||
width: 100%;
|
||||
max-width: 68rem;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-8) var(--space-6) var(--space-12);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.deck-status {
|
||||
margin: 0 0 var(--space-4);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.deck-status[data-tone="fault"] {
|
||||
color: var(--signal-fault);
|
||||
}
|
||||
|
||||
.deck-group {
|
||||
margin: 0 0 var(--space-8);
|
||||
}
|
||||
|
||||
.deck-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-1) var(--space-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* the engraved micro-label is the heading itself, not a kicker */
|
||||
.deck-label {
|
||||
margin: 0;
|
||||
font-size: var(--text-2xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.deck-count {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.deck-rows {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-1);
|
||||
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.row-id {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* titles are names: the display face, per the Readout Rule in design.json */
|
||||
.row-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.row-year {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.row-chips {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.chip[data-verdict="eligible"] {
|
||||
color: var(--verdict-eligible);
|
||||
border-color: oklch(from var(--verdict-eligible) l c h / 45%);
|
||||
}
|
||||
|
||||
.chip[data-movie-state="imported"] {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* a TMDB row is the affordance: the whole row opens the add flow */
|
||||
.row-tmdb {
|
||||
font: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
|
||||
cursor: pointer;
|
||||
transition: background 150ms var(--ease-out);
|
||||
}
|
||||
|
||||
.row-tmdb:hover:not(:disabled),
|
||||
.row-tmdb:focus-visible {
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.row-tmdb:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.row-add {
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.row-overview {
|
||||
flex-basis: 100%;
|
||||
overflow: hidden;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---- add flow (inline, root and policy pre-filled) -------------------- */
|
||||
|
||||
.add-panel {
|
||||
margin: 0 0 var(--space-2);
|
||||
padding: var(--space-3) var(--space-3) var(--space-3);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-top: 0;
|
||||
border-radius: 0 0 var(--radius) var(--radius);
|
||||
box-shadow: inset 0 1px var(--highlight);
|
||||
}
|
||||
|
||||
.add-roots {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.root-option {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
color: var(--ink);
|
||||
background: var(--panel-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: inset 0 1px var(--highlight);
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms var(--ease-out);
|
||||
}
|
||||
|
||||
.root-option:hover {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.root-option[aria-pressed="true"] {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.root-audience {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.root-policy,
|
||||
.root-path {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.add-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin: var(--space-3) 0 0;
|
||||
}
|
||||
|
||||
.add-note {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.add-note[data-tone="fault"] {
|
||||
color: var(--signal-fault);
|
||||
}
|
||||
|
||||
.add-done {
|
||||
margin: 0;
|
||||
color: var(--verdict-eligible);
|
||||
}
|
||||
|
||||
/* ---- manual intake ---------------------------------------------------- */
|
||||
|
||||
.intake .module-detail {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ---- small screens --------------------------------------------------- */
|
||||
|
||||
@media (max-width: 46rem) {
|
||||
@@ -480,6 +764,21 @@ body {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.rail-search {
|
||||
order: 4;
|
||||
flex-basis: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.deck {
|
||||
padding: var(--space-6) var(--space-4) var(--space-8);
|
||||
}
|
||||
|
||||
.row-chips {
|
||||
margin-left: 0;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.board {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user