Merge milestone 'Feedback pass'
ci / web (push) Successful in 36s
e2e / e2e (push) Successful in 1m10s
ci / rust (push) Successful in 1m19s

Nine issues from a hands-on pass over the deployed build.

Behaviour: adding a series no longer tracks its whole back catalogue
(auto_track applies from the second refresh onward); a series-title
query no longer returns every episode of that series; seasons and
episodes come back newest-first.

UI: cast comes off title detail; buttons and chips share one 2rem
geometry; state chips carry the ramp (green satisfied, amber gap,
violet active, neutral idle); the header drops its verdict line and
sits on the content width; season and episode rows lose their search
button and the deck sweeps itself when opened empty.

DESIGN.md 4.1, 4.2, 9.2 and 9.6 amended first; every other issue
implements against the amended text.

Closes #159, #160, #161, #162, #163, #164, #165, #166, #167
This commit is contained in:
Miguel Palhas
2026-08-24 15:26:12 +01:00
19 changed files with 495 additions and 451 deletions
+8 -8
View File
@@ -123,7 +123,7 @@
},
"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."
"purpose": "Seven fixed steps as tokens: --text-2xs 0.6875rem (micro-labels), --text-xs 0.75rem (details, chips, controls), --text-sm 0.8125rem (readouts), --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. One control geometry: buttons and chips share --control-h 2rem and --text-xs, so a season line reads as one system."
}
},
"shadows": [
@@ -189,7 +189,7 @@
"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); }"
"css": ".chip { display: inline-flex; align-items: center; gap: var(--space-1); min-height: var(--control-h); 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",
@@ -205,7 +205,7 @@
"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); }"
"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(--signal-warn); border-color: oklch(from var(--signal-warn) l c h / 55%); } .chip[data-status=\"complete\"], .chip[data-status=\"ended\"] { color: var(--signal-ok); border-color: oklch(from var(--signal-ok) l c h / 45%); } .chip[data-counts=\"complete\"] { color: var(--signal-ok); } .chip[data-counts=\"partial\"] { color: var(--signal-warn); }"
},
{
"name": "Module (channel strip)",
@@ -229,15 +229,15 @@
"refersTo": "accent",
"description": "The interactive element: mono uppercase, phosphor-cyan text, panel ground, ≥44px hit target. Cyan belongs to interaction only — never to status.",
"html": "<button type=\"button\" class=\"control\">probe</button>",
"css": ".control { min-height: 2.75rem; padding: 0 var(--space-4); font-family: var(--font-readout); text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); background: var(--panel-raised); border: 1px solid var(--line-strong); border-radius: var(--radius); }"
"css": ".control { display: inline-flex; align-items: center; justify-content: center; min-height: var(--control-h); padding: 0 var(--space-4); font-family: var(--font-readout); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); background: var(--panel-raised); border: 1px solid var(--line-strong); border-radius: var(--radius); }"
},
{
"name": "Rail",
"kind": "nav",
"refersTo": "ground",
"description": "Full-width panel strip framing the board top (identity + verdict + controls) and bottom (readouts). Hairline-ruled, panel ground, inset highlight.",
"html": "<header class=\"rail\"><div class=\"rail-id\"><span class=\"lamp\" data-state=\"ok\" aria-hidden=\"true\"></span><h1 class=\"wordmark\">arr</h1></div><p class=\"rail-verdict readout\" data-tone=\"ok\">all signals nominal</p></header>",
"css": ".rail { display: flex; align-items: center; gap: var(--space-6); padding: var(--space-3) var(--space-6); border-bottom: 1px solid var(--line); background: var(--panel); box-shadow: inset 0 1px var(--highlight); }"
"description": "Full-width panel strip framing the board top (identity + controls) and bottom (readouts). Hairline-ruled, panel ground, inset highlight. Contents sit in a .rail-inner column capped at 68rem, centred — the panel stays full-bleed. The lamp beside the wordmark is the only header status signal.",
"html": "<header class=\"rail\"><div class=\"rail-inner\"><div class=\"rail-id\"><span class=\"lamp\" data-state=\"ok\" aria-hidden=\"true\"></span><h1 class=\"wordmark\">arr</h1></div></div></header>",
"css": ".rail { padding: var(--space-3) var(--space-6); border-bottom: 1px solid var(--line); background: var(--panel); box-shadow: inset 0 1px var(--highlight); } .rail-inner { display: flex; align-items: center; gap: var(--space-6); width: 100%; max-width: 68rem; margin-inline: auto; }"
}
],
"narrative": {
@@ -300,7 +300,7 @@
"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.",
"Colour state chips on the ramp — `complete` and `ended` green, `incomplete` and a partial count amber, `airing` and `downloading` violet, `waiting` and `untracked` neutral. The word stays in every chip, so state survives with colour removed.",
"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.",
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\", poster_path, vote_average FROM series ORDER BY title, year, id",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\", poster_path, vote_average, metadata_refreshed_at FROM series ORDER BY title, year, id",
"describe": {
"columns": [
{
@@ -67,6 +67,11 @@
"name": "vote_average",
"ordinal": 12,
"type_info": "Float"
},
{
"name": "metadata_refreshed_at",
"ordinal": 13,
"type_info": "Text"
}
],
"parameters": {
@@ -85,8 +90,9 @@
false,
false,
true,
true,
true
]
},
"hash": "cf040af066e35e9bcb969c84ac180784d5bc9a2bf0630cd6fef2bb8f4cb467b5"
"hash": "3b518839d694b872f83005ca059d45d048a8c44efd78b75e08e74299fed072ea"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number DESC, e.number DESC",
"describe": {
"columns": [
{
@@ -82,5 +82,5 @@
true
]
},
"hash": "e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187"
"hash": "580e3bf81062d253fd08c9a4e1a879a0e57d6357ab9b278503cdf8fb5252886a"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\", s.poster_path, s.vote_average, s.tmdb_id AS \"series_tmdb_id!: i64\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id",
"query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\", s.poster_path, s.vote_average, s.tmdb_id AS \"series_tmdb_id!: i64\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') AND EXISTS (SELECT 1 FROM json_each(?) token WHERE e.title LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id",
"describe": {
"columns": [
{
@@ -45,7 +45,7 @@
}
],
"parameters": {
"Right": 1
"Right": 2
},
"nullable": [
true,
@@ -58,5 +58,5 @@
false
]
},
"hash": "2bc048c0fca4822eb38befbe3091f44f961fafb79432d4771aa48e36d32bf2a2"
"hash": "80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.tvdb_id, s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.auto_track AS \"auto_track!: bool\", s.overrides AS \"overrides!: serde_json::Value\", s.upstream_ended AS \"upstream_ended!: bool\", s.blocked AS \"blocked!: bool\", poster_path, vote_average FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id",
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.tvdb_id, s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.auto_track AS \"auto_track!: bool\", s.overrides AS \"overrides!: serde_json::Value\", s.upstream_ended AS \"upstream_ended!: bool\", s.blocked AS \"blocked!: bool\", poster_path, vote_average, s.metadata_refreshed_at FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id",
"describe": {
"columns": [
{
@@ -67,6 +67,11 @@
"name": "vote_average",
"ordinal": 12,
"type_info": "Float"
},
{
"name": "metadata_refreshed_at",
"ordinal": 13,
"type_info": "Text"
}
],
"parameters": {
@@ -85,8 +90,9 @@
false,
false,
true,
true,
true
]
},
"hash": "9f0164d699ffd66f31e7916827c81e01d283f35bb91ec2f34f63240792c01152"
"hash": "9755322057875ad786326cf533bff5d6b78e7844476731b309a3859287ff8c2f"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\", tracked AS \"tracked!: bool\", vanished AS \"vanished!: bool\" FROM seasons WHERE series_id = ? ORDER BY number",
"query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\", tracked AS \"tracked!: bool\", vanished AS \"vanished!: bool\" FROM seasons WHERE series_id = ? ORDER BY number DESC",
"describe": {
"columns": [
{
@@ -40,5 +40,5 @@
false
]
},
"hash": "583b48cc8a0c618294361f606a9c8a4b3a42000c96343ff9a5490bb518700f81"
"hash": "f52980596624a03ae4c704926a443c5665c865f9d3d3699105790fe09d8b3fc9"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\", poster_path, vote_average FROM series WHERE id = ?",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\", poster_path, vote_average, metadata_refreshed_at FROM series WHERE id = ?",
"describe": {
"columns": [
{
@@ -67,6 +67,11 @@
"name": "vote_average",
"ordinal": 12,
"type_info": "Float"
},
{
"name": "metadata_refreshed_at",
"ordinal": 13,
"type_info": "Text"
}
],
"parameters": {
@@ -85,8 +90,9 @@
false,
false,
true,
true,
true
]
},
"hash": "ab536da7864e712754f1742e1f1f67d1d5cef884dafcaae1ae9ce498fc852e32"
"hash": "fd340665e0593bf692f0ffbe60ba4bebbc1a3d80c1304ed1fb790ccbfc6cc420"
}
+23 -11
View File
@@ -130,7 +130,11 @@ anything.
Here, intent is `Episode.wanted` and `Movie.wanted` only.
`Series.auto_track` is not intent — it is a rule one level up: when metadata
reveals a new season, that season becomes tracked. An untracked series where you
reveals a new season, that season becomes tracked. The rule applies from the
second refresh onward: seasons revealed by a series' first refresh — the whole
back catalogue present at add time — are never tracked by it. Only seasons that
appear after the series was added are covered; back-catalogue seasons are picked
by hand. An untracked series where you
manually marked S02 needs no special case: three wanted episodes, nothing else.
`Season.tracked` is also a rule, not intent. Turning tracking on marks every
@@ -160,6 +164,12 @@ behind one toggle. A one-off season grab therefore disappears from the default
view by itself once satisfied, and a tracked show reappears by itself when a new
season is announced. Nothing to remember to flip.
Status chips carry state colour, not a single informational hue: green
(`--signal-ok`) for on disk or complete, amber (`--signal-warn`) for wanted but
not yet found, violet (`--status-airing`) for downloading, neutral for
untracked. The word stays present in every chip, so state survives with colour
removed.
Season 0 is invisible to all of it: derived status ignores season 0 episodes
entirely, so a manually wanted special cannot pin a series at `incomplete` or
hold back `ended`. The accepted consequence is that specials are visible and
@@ -477,7 +487,10 @@ lower-priority crate** (§9.4), not the primary shape.
One box. Two grouped result sets: **in library** first (title match, and for TV
also episode title, so `bluey hospital` finds the episode), **on TMDB** below.
Enter on a TMDB result opens the add flow with root and policy pre-filled.
An episode row surfaces only when the query matches something beyond the series
title: a query satisfied by the series title alone returns the series row and
nothing below it. Enter on a TMDB result opens the add flow with root and
policy pre-filled.
The same box accepts a raw TMDB or IMDb ID, and a pasted magnet or `.torrent`,
which skips to the manual-grab flow.
@@ -549,12 +562,16 @@ Not notified: grabs, searches, downloads starting or finishing, soft fails.
One detail surface per kind: `/movies/{id}` and `/series/{id}` (#129). For a
movie the release deck becomes a section of the page, and `/movies/{id}/releases`
keeps resolving; series keeps its season-and-episode shape.
keeps resolving; series keeps its season-and-episode shape, seasons ordered
newest-first within the series and episodes newest-first within each season —
the seasons you are deciding about now are at the top, not after nine rows of
back catalogue.
**TMDB is the only metadata source.** The rating shown anywhere is TMDB's
`vote_average` with its `vote_count`. No OMDb, no IMDb or Rotten Tomatoes
scores — each would need a second upstream, a second key and a second thing
that can be down.
`vote_average` with its `vote_count`, rendered as amber stars (`--signal-warn`
— the same state colour a wanted chip carries). No OMDb, no IMDb or Rotten
Tomatoes scores — each would need a second upstream, a second key and a second
thing that can be down.
**Images are hotlinked** from `image.tmdb.org`. The API returns TMDB path
fragments, never URLs; the browser composes the URL and chooses the size. No
@@ -565,11 +582,6 @@ image proxy and no image cache in the service.
and `vote_average`, stored on `movies` and `series` and written by the daily
metadata refresh (§8), so library views render without a TMDB call.
**Cast** is the top 10 billed — profile photo, actor name, character name —
linking out to that person's TMDB page. This does not contradict §2: nothing
about a person is stored, tracked or searched on. The non-goal is following
people, not naming them.
**External links** are TMDB always, IMDb for movies, TVDB for series — all from
ids the app already holds — plus a Rotten Tomatoes *search* link, which is a
query URL, not a resolved title page.
+1 -1
View File
@@ -25,7 +25,7 @@ use utoipa_axum::routes;
use utoipa_scalar::{Scalar, Servable};
pub use health::{Check, Health, HealthReport, Status};
pub use metadata::{MetadataCastMember, MetadataTrailer, MovieMetadata, SeriesMetadata};
pub use metadata::{MetadataTrailer, MovieMetadata, SeriesMetadata};
pub use movies::{
Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, MovieFile, Release, UpdateMovie,
};
-34
View File
@@ -13,18 +13,6 @@ use crate::movies::{pool, ApiError, ErrorBody};
use crate::search::{tmdb_client, upstream_error};
use crate::state::AppState;
/// One of the top-billed cast members on a detail page.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct MetadataCastMember {
/// The person's TMDB id, for the link out to tmdb.org (§9.6).
pub tmdb_id: u32,
pub name: String,
pub character: String,
/// Path fragment exactly as TMDB sends it (§9.6): the browser composes
/// the URL and picks the size.
pub profile_path: Option<String>,
}
/// The one trailer worth showing, resolved by the §9.6 rule inside `arr-meta`.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct MetadataTrailer {
@@ -52,8 +40,6 @@ pub struct MovieMetadata {
pub homepage: Option<String>,
/// §9.6 links out to `IMDb` for movies.
pub imdb_id: Option<String>,
/// Top 10 billed, ordered by TMDB's own cast order.
pub cast: Vec<MetadataCastMember>,
pub trailer: Option<MetadataTrailer>,
}
@@ -75,8 +61,6 @@ pub struct SeriesMetadata {
pub homepage: Option<String>,
/// §9.6 links out to TVDB for series.
pub tvdb_id: Option<u32>,
/// Top 10 billed, ordered by TMDB's own cast order.
pub cast: Vec<MetadataCastMember>,
pub trailer: Option<MetadataTrailer>,
}
@@ -95,7 +79,6 @@ impl From<MovieDetail> for MovieMetadata {
vote_count: detail.vote_count,
homepage: detail.homepage,
imdb_id: detail.imdb_id,
cast: detail.cast.into_iter().map(Into::into).collect(),
trailer: detail.trailer.map(Into::into),
}
}
@@ -116,23 +99,11 @@ impl From<SeriesDetail> for SeriesMetadata {
vote_count: detail.vote_count,
homepage: detail.homepage,
tvdb_id: detail.tvdb_id,
cast: detail.cast.into_iter().map(Into::into).collect(),
trailer: detail.trailer.map(Into::into),
}
}
}
impl From<arr_meta::CastMember> for MetadataCastMember {
fn from(member: arr_meta::CastMember) -> Self {
Self {
tmdb_id: member.tmdb_id,
name: member.name,
character: member.character,
profile_path: member.profile_path,
}
}
}
impl From<arr_meta::Video> for MetadataTrailer {
fn from(video: arr_meta::Video) -> Self {
Self {
@@ -347,11 +318,6 @@ mod tests {
// §9.6: path fragments exactly as TMDB sends them, never composed URLs.
assert_eq!(body["poster_path"], "/1pdfLlvXA5qYN3ikFm4QDe7EJ19.jpg");
assert_eq!(body["backdrop_path"], "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg");
assert_eq!(
body["cast"][0]["profile_path"],
"/tzLq0VAm3myi2lzNpdfhVGjXAdB.jpg"
);
assert_eq!(body["cast"][0]["character"], "Paul Atreides");
assert_eq!(body["trailer"]["youtube_key"], "Way9Dexny3w");
tmdb.verify().await;
}
+25 -6
View File
@@ -233,9 +233,11 @@ pub async fn search(
library.extend(series_rows.into_iter().map(LibraryResult::Series));
// §9.2 names the TV case explicitly: `bluey hospital` finds the episode.
// Tokens may split across the series and episode titles, so the haystack
// is both joined and every token must land somewhere in it.
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String", s.poster_path, s.vote_average, s.tmdb_id AS "series_tmdb_id!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens)
// Tokens may split across the series and episode titles, so every token
// must land in the concatenation — and at least one must match the
// episode title on its own, or a series-title-only query would list
// every episode the series has.
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String", s.poster_path, s.vote_average, s.tmdb_id AS "series_tmdb_id!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') AND EXISTS (SELECT 1 FROM json_each(?) token WHERE e.title LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens, tokens)
.fetch_all(database.pool())
.await?;
library.extend(episode_rows.into_iter().map(LibraryResult::Episode));
@@ -956,15 +958,32 @@ mod tests {
assert_eq!(response["tmdb"][0]["kind"], "series");
assert_eq!(response["tmdb"][0]["title"], "Bluey");
// §9.2, amended: an episode row surfaces only when the query matches
// something beyond the series title. `bluey` alone returns the series
// row and no episode below it.
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=bluey"))
.await
.expect("search")
.json()
.await
.expect("json");
assert_eq!(response["library"][0]["kind"], "series");
assert_eq!(response["library"][0]["title"], "Bluey");
assert_eq!(response["library"][0]["tmdb_id"], 82_728);
let library = response["library"].as_array().expect("library");
assert_eq!(library.len(), 1);
assert_eq!(library[0]["kind"], "series");
assert_eq!(library[0]["title"], "Bluey");
assert_eq!(library[0]["tmdb_id"], 82_728);
// An episode-title-only query still finds the episode.
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=hospital"))
.await
.expect("search")
.json()
.await
.expect("json");
let library = response["library"].as_array().expect("library");
assert_eq!(library.len(), 1);
assert_eq!(library[0]["kind"], "episode");
assert_eq!(library[0]["title"], "Hospital");
}
#[tokio::test]
+125 -10
View File
@@ -208,6 +208,9 @@ struct SeriesRow {
poster_path: Option<String>,
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
vote_average: Option<f64>,
/// NULL until a metadata refresh has stamped it (#160): the fact the
/// auto-track rule needs to tell a seeding refresh from a later one.
metadata_refreshed_at: Option<String>,
}
struct EpisodeRow {
@@ -353,7 +356,7 @@ async fn tv_by_series(state: &AppState) -> Result<HashMap<i64, Vec<arr_core::Epi
}
async fn load_series_row(state: &AppState, id: i64) -> Result<SeriesRow, ApiError> {
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average FROM series WHERE id = ?"#, id)
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average, metadata_refreshed_at FROM series WHERE id = ?"#, id)
.fetch_optional(pool(state)?)
.await?
.ok_or(ApiError::SeriesNotFound)
@@ -407,11 +410,11 @@ pub async fn list(
Query(query): Query<ListSeriesQuery>,
) -> Result<Json<Vec<Series>>, ApiError> {
let rows = if let Some(owner_id) = query.owner_id {
sqlx::query_as!(SeriesRow, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.tvdb_id, s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.auto_track AS "auto_track!: bool", s.overrides AS "overrides!: serde_json::Value", s.upstream_ended AS "upstream_ended!: bool", s.blocked AS "blocked!: bool", poster_path, vote_average FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id"#, owner_id)
sqlx::query_as!(SeriesRow, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.tvdb_id, s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.auto_track AS "auto_track!: bool", s.overrides AS "overrides!: serde_json::Value", s.upstream_ended AS "upstream_ended!: bool", s.blocked AS "blocked!: bool", poster_path, vote_average, s.metadata_refreshed_at FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id"#, owner_id)
.fetch_all(pool(&state)?)
.await?
} else {
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average FROM series ORDER BY title, year, id"#)
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average, metadata_refreshed_at FROM series ORDER BY title, year, id"#)
.fetch_all(pool(&state)?)
.await?
};
@@ -652,7 +655,9 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, ApiError> {
let seasons = sqlx::query!(
r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64", tracked AS "tracked!: bool", vanished AS "vanished!: bool" FROM seasons WHERE series_id = ? ORDER BY number"#,
// §9.6: newest-first, so season 0 lands last under plain numeric
// descending order — exactly where the design puts it.
r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64", tracked AS "tracked!: bool", vanished AS "vanished!: bool" FROM seasons WHERE series_id = ? ORDER BY number DESC"#,
series_id
)
.fetch_all(pool(state)?)
@@ -660,7 +665,7 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
let episodes = sqlx::query_as!(
EpisodeRow,
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number"#,
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number DESC, e.number DESC"#,
series_id
)
.fetch_all(pool(state)?)
@@ -765,6 +770,9 @@ pub async fn create_season(
// §4.1. The request does not say whether the episodes are wanted; the
// series' auto_track rule does, through the one function that owns it.
// #160: whether a refresh has happened yet rides in from the row — a
// hand-added season on a never-refreshed series is still seeding, and
// §4.1 does not track what was there at add time.
let mut revealed = [RefreshedSeason {
season: arr_core::Season {
id: SeasonId(0),
@@ -790,7 +798,11 @@ pub async fn create_season(
.collect(),
is_new: true,
}];
apply_auto_track(&core_series(&series), &mut revealed);
apply_auto_track(
&core_series(&series),
series.metadata_refreshed_at.is_some(),
&mut revealed,
);
let [revealed] = revealed;
// One transaction: a rejected episode must not leave the season behind,
@@ -1457,15 +1469,21 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
/// #160. A season added before the series' first metadata refresh is part
/// of the back catalogue at add time, so the rule does not track it. Once
/// a refresh has happened, a hand-revealed season is genuinely new and
/// arrives tracked with its episodes wanted.
#[tokio::test]
async fn auto_track_decides_whether_a_new_season_arrives_wanted() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "kids").await;
let tracked = add_series(&base, root_id, true).await;
let tracked_id = tracked["id"].as_i64().expect("id");
// Seeding: no refresh has ever stamped this series.
let season = add_season(
&base,
tracked["id"].as_i64().expect("id"),
tracked_id,
1,
serde_json::json!([
{"number": 1, "title": "The Magic Xylophone", "air_date": "2018-10-01"},
@@ -1473,6 +1491,33 @@ mod tests {
]),
)
.await;
assert_eq!(season["tracked"], false);
assert!(
season["episodes"]
.as_array()
.expect("episodes")
.iter()
.all(|episode| episode["wanted"] == false),
"§4.1: the back catalogue at add time is never auto-tracked"
);
sqlx::query(
"UPDATE series SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?",
)
.bind(tracked_id)
.execute(state.database().expect("database").pool())
.await
.expect("stamp refreshed");
let season = add_season(
&base,
tracked_id,
2,
serde_json::json!([
{"number": 1, "title": "Dance Mode", "air_date": "2019-04-01"}
]),
)
.await;
assert_eq!(season["tracked"], true);
assert!(
season["episodes"]
@@ -1480,7 +1525,7 @@ mod tests {
.expect("episodes")
.iter()
.all(|episode| episode["wanted"] == true),
"§4.1: auto_track marks the episodes of a revealed season wanted"
"§4.1: from the second refresh onward the rule tracks what is new"
);
let untracked_id = sqlx::query_scalar::<_, i64>(
@@ -1594,13 +1639,83 @@ mod tests {
.await
.expect("seasons json");
let seasons = seasons.as_array().expect("seasons array");
assert_eq!(seasons[0]["vanished"], false);
let vanished = |number: i64| {
seasons
.iter()
.find(|season| season["number"] == number)
.expect("season")["vanished"]
.clone()
};
assert_eq!(vanished(1), false);
assert_eq!(
seasons[1]["vanished"], true,
vanished(2),
true,
"#141: the conflict flag is not stopped at the database"
);
}
/// §9.6: seasons come back newest-first within a series and episodes
/// newest-first within each season, with season 0 wherever descending
/// numeric order puts it — last. The SPA renders this order as given,
/// so it is the API's to get right (#162).
#[tokio::test]
async fn seasons_and_episodes_are_returned_newest_first() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, false).await;
let series_id = series["id"].as_i64().expect("id");
for (season, episodes) in [
(
0,
serde_json::json!([
{"number": 1, "title": "Special one"},
{"number": 2, "title": "Special two"}
]),
),
(1, serde_json::json!([{"number": 1, "title": "Pilot"}])),
(
2,
serde_json::json!([
{"number": 1, "title": "One"},
{"number": 2, "title": "Two"},
{"number": 3, "title": "Three"}
]),
),
] {
add_season(&base, series_id, season, episodes).await;
}
let seasons: serde_json::Value =
reqwest::get(format!("{base}/api/series/{series_id}/seasons"))
.await
.expect("list seasons")
.json()
.await
.expect("seasons json");
let seasons = seasons.as_array().expect("seasons array");
let numbers: Vec<i64> = seasons
.iter()
.map(|season| season["number"].as_i64().expect("number"))
.collect();
assert_eq!(numbers, [2, 1, 0], "§9.6: newest-first within the series");
let episode_numbers = |index: usize| -> Vec<i64> {
seasons[index]["episodes"]
.as_array()
.expect("episodes")
.iter()
.map(|episode| episode["number"].as_i64().expect("episode number"))
.collect()
};
assert_eq!(
episode_numbers(0),
[3, 2, 1],
"§9.6: newest-first in a season"
);
assert_eq!(episode_numbers(1), [1]);
assert_eq!(episode_numbers(2), [2, 1]);
}
#[tokio::test]
async fn intent_is_set_on_seasons_and_on_single_episodes() {
let (_dir, state, base) = application().await;
+44 -5
View File
@@ -13,8 +13,14 @@ pub struct RefreshedSeason {
/// Existing seasons keep their tracking rule and leaf-level intent unchanged.
/// Season 0 is never tracked by the rule (`DESIGN.md` §4.1); an operator may
/// still track it by hand.
pub fn apply_auto_track(series: &Series, seasons: &mut [RefreshedSeason]) {
if !series.auto_track {
///
/// `ever_refreshed` says whether the series has had a metadata refresh yet
/// (`metadata_refreshed_at IS NOT NULL`). The caller supplies the fact because
/// `arr-core` has no IO; seasons revealed by a series' first refresh are its
/// back catalogue as it stood at add time, and §4.1 never tracks those — the
/// rule applies from the second refresh onward.
pub fn apply_auto_track(series: &Series, ever_refreshed: bool, seasons: &mut [RefreshedSeason]) {
if !series.auto_track || !ever_refreshed {
return;
}
@@ -114,7 +120,7 @@ mod tests {
refreshed_season(3, true, &[false, false]),
];
apply_auto_track(&series(true), &mut refresh);
apply_auto_track(&series(true), true, &mut refresh);
assert!(!refresh[0].season.tracked);
assert!(refresh[0].episodes.iter().all(|episode| !episode.wanted));
@@ -122,6 +128,39 @@ mod tests {
assert!(refresh[1].episodes.iter().all(|episode| episode.wanted));
}
/// #160. The first refresh of a newly added series reveals the whole back
/// catalogue at once; §4.1 never tracks those.
#[test]
fn the_seeding_refresh_tracks_nothing() {
let mut refresh = [
refreshed_season(1, true, &[false, false]),
refreshed_season(2, true, &[false]),
];
apply_auto_track(&series(true), false, &mut refresh);
assert!(refresh.iter().all(|refreshed| !refreshed.season.tracked));
assert!(refresh
.iter()
.all(|refreshed| refreshed.episodes.iter().all(|episode| !episode.wanted)));
}
/// #160. From the second refresh onward a season that genuinely did not
/// exist when the series was added is tracked, and its episodes arrive
/// wanted — while the seasons already on record stay as they were.
#[test]
fn a_later_refresh_tracks_a_genuinely_new_season() {
let mut refresh = [
refreshed_season(1, false, &[true, true]),
refreshed_season(3, true, &[false]),
];
apply_auto_track(&series(true), true, &mut refresh);
assert!(refresh[1].season.tracked);
assert!(refresh[1].episodes.iter().all(|episode| episode.wanted));
}
#[test]
fn untracked_series_keeps_manual_leaf_intent_only() {
let mut refresh = [
@@ -129,7 +168,7 @@ mod tests {
refreshed_season(3, true, &[false, false]),
];
apply_auto_track(&series(false), &mut refresh);
apply_auto_track(&series(false), true, &mut refresh);
assert!(refresh[0].episodes.iter().all(|episode| episode.wanted));
assert!(!refresh[0].season.tracked);
@@ -141,7 +180,7 @@ mod tests {
fn auto_track_skips_season_zero() {
let mut refresh = [refreshed_season(0, true, &[false])];
apply_auto_track(&series(true), &mut refresh);
apply_auto_track(&series(true), true, &mut refresh);
assert!(!refresh[0].season.tracked);
assert!(refresh[0].episodes.iter().all(|episode| !episode.wanted));
+59 -6
View File
@@ -168,7 +168,13 @@ impl SeriesRefreshAction {
});
}
}
apply_auto_track(&core_series(stale), &mut revealed);
// #160: the fact of a previous refresh rides in from the row, so the
// seeding refresh never tracks the back catalogue it reveals.
apply_auto_track(
&core_series(stale),
stale.metadata_refreshed_at.is_some(),
&mut revealed,
);
for refreshed in revealed {
self.insert_revealed_season(&mut transaction, stale.id, refreshed)
.await?;
@@ -779,8 +785,11 @@ mod tests {
assert_eq!(vote, Some(8.417));
}
/// #160. A series' first refresh reveals its back catalogue, but §4.1
/// never tracks what was already there at add time: nothing is tracked,
/// nothing arrives wanted.
#[tokio::test]
async fn first_refresh_reveals_seasons_and_applies_auto_track() {
async fn the_seeding_refresh_reveals_but_tracks_nothing() {
let (_dir, database) = seeded_series(true).await;
let server = tmdb("Returning Series", season_one_body(&two_episodes())).await;
@@ -792,14 +801,14 @@ mod tests {
.fetch_all(database.pool())
.await
.unwrap();
assert_eq!(rows, vec![(1, true), (2, true)]);
assert_eq!(rows, vec![(1, false), (2, false)]);
let wanted: i64 = sqlx::query_scalar(
"SELECT count(*) FROM episodes WHERE wanted = 1 AND state = 'missing'",
)
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(wanted, 3);
assert_eq!(wanted, 0);
// #120 backfill: the series was added before it had an id on file.
let tvdb_id: Option<i64> =
sqlx::query_scalar("SELECT tvdb_id FROM series WHERE tmdb_id = 82728")
@@ -816,6 +825,50 @@ mod tests {
assert_eq!(stamped, 1);
}
/// #160. Once the series has been refreshed before, a season TMDB reveals
/// that the library has never seen genuinely appeared after add time, so
/// it is tracked and its episodes arrive wanted.
#[tokio::test]
async fn a_later_refresh_tracks_a_genuinely_new_season() {
let (_dir, database) = seeded_series(true).await;
let server = MockServer::start().await;
mount_with(&server, "Returning Series", &[1]).await;
action(&server).tick(&database).await.unwrap();
let tracked: i64 = sqlx::query_scalar("SELECT count(*) FROM seasons WHERE tracked = 1")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(tracked, 0, "the seeding refresh tracks nothing");
expire_refresh(&database).await;
server.reset().await;
mount(
&server,
"Returning Series",
season_one_body(&two_episodes()),
)
.await;
let outcomes = action(&server).tick(&database).await.unwrap();
assert_eq!(outcomes.len(), 1);
let rows: Vec<(i64, bool)> =
sqlx::query_as("SELECT number, tracked FROM seasons ORDER BY number")
.fetch_all(database.pool())
.await
.unwrap();
assert_eq!(rows, vec![(1, false), (2, true)]);
let wanted: Vec<i64> = sqlx::query_scalar(
"SELECT e.number FROM episodes e JOIN seasons s ON s.id = e.season_id
WHERE s.number = 2 AND e.wanted = 1",
)
.fetch_all(database.pool())
.await
.unwrap();
assert_eq!(wanted, vec![1]);
}
/// `auto_track` is a rule about reveals, not intent: without it nothing
/// arrives wanted, even though everything is revealed.
#[tokio::test]
@@ -877,13 +930,13 @@ mod tests {
.fetch_all(database.pool())
.await
.unwrap();
assert_eq!(rows, vec![(1, true), (2, true)]);
assert_eq!(rows, vec![(1, false), (2, false)]);
let counts: (i64, i64) =
sqlx::query_as("SELECT count(*), coalesce(sum(wanted), 0) FROM episodes")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(counts, (3, 3));
assert_eq!(counts, (3, 0));
}
/// Episodes revealed into an existing season follow that season's rule:
+34 -48
View File
@@ -18,7 +18,7 @@
fault red) with a soft glow, phosphor-cyan for the one interactive control.
STORY: the operator opens the board, reads the master lamp, and knows in a
second whether arr can do its job — and which upstream is down, by name.
FIRST VIEWPORT: a top rail (wordmark + master lamp + verdict + version),
FIRST VIEWPORT: a top rail (wordmark + master lamp + version),
then the chain: TMDB and PROWLARR modules on the left feeding the central
arr module, TRANSMISSION driven on the right, joined by bus hairlines with
direction arrows. On a phone the chain stacks vertically.
@@ -65,36 +65,37 @@
interaction hue.
-->
<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>
<nav class="rail-nav" aria-label="views">
<button type="button" class="control" id="nav-library" aria-pressed="false">
library
</button>
<button type="button" class="control" id="nav-queues" aria-pressed="false">
queues<span class="nav-count readout" id="queues-count" hidden></span>
</button>
<button type="button" class="control" id="nav-settings" aria-pressed="false">
settings
</button>
</nav>
<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>
<div class="rail-inner">
<div class="rail-id">
<span class="lamp" id="master-lamp" data-state="probing" aria-hidden="true"></span>
<h1 class="wordmark">arr</h1>
</div>
<nav class="rail-nav" aria-label="views">
<button type="button" class="control" id="nav-library" aria-pressed="false">
library
</button>
<button type="button" class="control" id="nav-queues" aria-pressed="false">
queues<span class="nav-count readout" id="queues-count" hidden></span>
</button>
<button type="button" class="control" id="nav-settings" aria-pressed="false">
settings
</button>
</nav>
<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>
<div class="rail-meta">
<span class="readout dim" id="version" data-testid="version">v —</span>
</div>
</div>
</header>
@@ -203,8 +204,7 @@
metadata response's own key (no second call); TMDB, IMDb and Rotten
Tomatoes sit beside it — RT is a search URL built here, never a resolved
page. LIBRARY controls follow on their own row: wanted, blocked, root,
then search indexers and remove. CAST is the top 10 billed, each photo
linking out to that person's TMDB page. ON DISK lists imported files
then search indexers and remove. ON DISK lists imported files
with ffprobe attribute tags and any §5.7 waiver, worded honestly. The
§9.3 deck closes the page unchanged — buckets, chips, score heat and
rejection rules untouched; /movies/{id}/releases lands here with the
@@ -271,13 +271,6 @@
<div class="remove-panel" id="remove-panel" hidden></div>
</section>
<section class="deck-group" id="movie-cast" hidden aria-labelledby="label-cast">
<header class="deck-head">
<h3 class="deck-label" id="label-cast">cast</h3>
</header>
<ul class="cast-grid" id="rows-cast"></ul>
</section>
<section class="deck-group" id="movie-files" hidden aria-labelledby="label-disk">
<header class="deck-head">
<h3 class="deck-label" id="label-disk">on disk</h3>
@@ -428,13 +421,6 @@
</div>
</section>
<section class="deck-group" id="series-cast" hidden aria-labelledby="label-series-cast">
<header class="deck-head">
<h3 class="deck-label" id="label-series-cast">cast</h3>
</header>
<ul class="cast-grid" id="rows-series-cast"></ul>
</section>
<ul class="deck-rows seasons" id="rows-seasons"></ul>
</main>
@@ -445,7 +431,7 @@
<h2 class="releases-title" id="tv-releases-title">—</h2>
<span class="releases-year readout dim" id="tv-releases-sub"></span>
</div>
<button type="button" class="control" id="tv-releases-sweep">search indexers</button>
<button type="button" class="control" id="tv-releases-sweep">re-search</button>
</header>
<p class="deck-status readout" id="tv-releases-status" role="status" hidden></p>
<div id="tv-buckets"></div>
+80 -160
View File
@@ -12,13 +12,11 @@ import {
formatRating,
formatVoteCount,
imdbLink,
type MetadataCastMember,
movieMetadata,
resolveTrailer,
rottenTomatoesSearch,
tmdbImage,
tmdbMovieLink,
tmdbPersonLink,
tmdbSeriesLink,
tvdbLink,
updateMovie,
@@ -148,7 +146,6 @@ function must<T extends Element>(selector: string): T {
function main() {
const board = must<HTMLElement>(".board");
const masterLamp = must<HTMLElement>("#master-lamp");
const verdict = must<HTMLElement>("#master-verdict");
const version = must<HTMLElement>("#version");
const master = moduleRefs("master");
@@ -193,10 +190,9 @@ function main() {
if (probe.kind === "unreachable") {
masterLamp.dataset.state = "unreachable";
verdict.dataset.tone = "fault";
verdict.textContent = `daemon unreachable — ${probe.detail}`;
version.textContent = "v —";
setModule(master, "unreachable", "fault", "down");
// the outage detail lives on the board's arr module — the header has no verdict line
setModule(master, "unreachable", "fault", "down", probe.detail);
for (const refs of Object.values(checks)) {
setModule(refs, "off", "idle", "no signal");
}
@@ -210,8 +206,6 @@ function main() {
const degraded = report.status === "degraded";
masterLamp.dataset.state = degraded ? "degraded" : "ok";
verdict.dataset.tone = degraded ? "warn" : "ok";
verdict.textContent = degraded ? "chain degraded" : "all signals nominal";
version.textContent = `v${report.version}`;
setModule(master, degraded ? "degraded" : "ok", degraded ? "warn" : "ok", report.status);
@@ -717,6 +711,37 @@ function chip(text: string, extra?: (chip: HTMLSpanElement) => void): HTMLSpanEl
return span;
}
/** §4.2 state ramp on a media-state chip; the word carries, colour accelerates. */
function mediaStateChip(state: string, wanted: boolean): HTMLSpanElement {
return chip(state, (span) => {
span.dataset.movieState = state;
span.dataset.wanted = String(wanted);
});
}
/** The ramp read off N/M itself: green when full, amber while a gap remains. */
function countsTone(available: number, wanted: number): "complete" | "partial" | null {
if (wanted === 0) {
return null;
}
return available >= wanted ? "complete" : "partial";
}
function countsChip(
label: string,
available: number,
wanted: number,
ariaLabel: string,
): HTMLSpanElement {
return chip(label, (span) => {
const tone = countsTone(available, wanted);
if (tone !== null) {
span.dataset.counts = tone;
}
span.setAttribute("aria-label", ariaLabel);
});
}
function rowTitle(title: string, year: number | null): HTMLElement {
const wrap = document.createElement("span");
wrap.className = "row-id";
@@ -875,11 +900,7 @@ function libraryRow(
const { item, row, body, chips } = richRow();
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;
}),
);
chips.append(mediaStateChip(movie.state, movie.wanted));
// §5.7 honesty: a waived import is never presented as a clean match
const waiver = waiverLabel(movie.waiver);
if (waiver !== null) {
@@ -1288,46 +1309,6 @@ function externalLink(label: string, href: string): HTMLAnchorElement {
return link;
}
/** One top-billed cast member, linking out to their TMDB page (§9.6). */
function castItem(member: MetadataCastMember): HTMLLIElement {
const item = document.createElement("li");
item.className = "cast-item";
const link = document.createElement("a");
link.className = "cast-link";
link.href = tmdbPersonLink(member.tmdb_id);
link.target = "_blank";
link.rel = "noreferrer";
const photo = document.createElement("span");
photo.className = "cast-photo";
const image = tmdbImage(member.profile_path, "w185");
if (image !== null) {
const img = document.createElement("img");
img.src = image;
img.alt = "";
img.loading = "lazy";
// hotlinked art can 404 (§9.6); fall back to the same empty frame a
// person with no photo gets, rather than a broken-image glyph.
img.addEventListener("error", () => {
img.remove();
photo.classList.add("cast-photo-none");
});
photo.append(img);
} else {
photo.classList.add("cast-photo-none");
}
const name = document.createElement("span");
name.className = "cast-name";
name.textContent = member.name;
const character = document.createElement("span");
character.className = "cast-character readout dim";
character.textContent = member.character;
link.setAttribute("aria-label", `${member.name} as ${member.character} — on TMDB`);
link.append(photo, name, character);
link.title = `${member.name} — ${member.character}`;
item.append(link);
return item;
}
function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
const view = must<HTMLElement>("#movie");
const deckEl = must<HTMLElement>("#deck");
@@ -1349,8 +1330,6 @@ function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
const sweep = must<HTMLButtonElement>("#movie-sweep");
const remove = must<HTMLButtonElement>("#movie-remove");
const removeWrap = must<HTMLElement>("#remove-panel");
const castSection = must<HTMLElement>("#movie-cast");
const castRows = must<HTMLUListElement>("#rows-cast");
const filesSection = must<HTMLElement>("#movie-files");
const diskCount = must<HTMLElement>("#count-disk");
const diskRows = must<HTMLUListElement>("#rows-disk");
@@ -1456,11 +1435,7 @@ function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
chipsEl.replaceChildren();
const root = roots.find((candidate) => candidate.id === movie.root_id);
chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`));
chipsEl.append(
chip(movie.state, (span) => {
span.dataset.movieState = movie.state;
}),
);
chipsEl.append(mediaStateChip(movie.state, movie.wanted));
// §5.7 honesty: a waived import is never presented as a clean match
const waiver = waiverLabel(movie.waiver);
if (waiver !== null) {
@@ -1589,8 +1564,6 @@ function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
taglineEl.hidden = true;
overviewEl.hidden = true;
actionsEl.replaceChildren();
castSection.hidden = true;
castRows.replaceChildren();
}
async function loadMetadata(id: number, ticket: number) {
@@ -1660,12 +1633,6 @@ function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
externalLink("rotten tomatoes", rottenTomatoesSearch(current.title, current.year)),
);
}
if (detail.cast.length > 0 && current !== null) {
castSection.hidden = false;
for (const member of detail.cast) {
castRows.append(castItem(member));
}
}
}
/* ---- on disk: ffprobe truth and honest waivers (§5.6, §5.7) ---- */
@@ -2625,12 +2592,12 @@ function seriesRow(
chips.append(chip(root ? root.audience : `root ${series.root_id}`));
if (series.wanted_episodes > 0) {
chips.append(
chip(`${series.available_episodes}/${series.wanted_episodes} eps`, (span) => {
span.setAttribute(
"aria-label",
`${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`,
);
}),
countsChip(
`${series.available_episodes}/${series.wanted_episodes} eps`,
series.available_episodes,
series.wanted_episodes,
`${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`,
),
);
}
if (series.blocked) {
@@ -2737,12 +2704,12 @@ function seriesCard(
const chips = [chip(root ? root.audience : `root ${series.root_id}`)];
if (series.wanted_episodes > 0) {
chips.push(
chip(`${series.available_episodes}/${series.wanted_episodes} eps`, (span) => {
span.setAttribute(
"aria-label",
`${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`,
);
}),
countsChip(
`${series.available_episodes}/${series.wanted_episodes} eps`,
series.available_episodes,
series.wanted_episodes,
`${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`,
),
);
}
if (series.blocked) {
@@ -2769,11 +2736,7 @@ function movieCard(
): HTMLLIElement {
const root = roots.find((candidate) => candidate.id === movie.root_id);
const chips = [chip(root ? root.audience : `root ${movie.root_id}`)];
chips.push(
chip(movie.state, (span) => {
span.dataset.movieState = movie.state;
}),
);
chips.push(mediaStateChip(movie.state, movie.wanted));
// §5.7 honesty: a waived import is never presented as a clean match
const waiver = waiverLabel(movie.waiver);
if (waiver !== null) {
@@ -2871,7 +2834,7 @@ function tvReleasesMain(): TvReleasesView {
},
};
async function load() {
async function load(sweepIfEmpty = false) {
const current = request;
if (!current) {
return;
@@ -2890,7 +2853,12 @@ function tvReleasesMain(): TvReleasesView {
return;
}
if (!paintBuckets(dom, outcome.releases, actions)) {
setStatus("no releases indexed yet — search indexers queues a targeted sweep");
// issue 167: an empty deck sweeps on open instead of describing a sweep
if (sweepIfEmpty) {
startSweep(current);
} else {
setStatus("no releases indexed yet — re-search queues a targeted sweep");
}
return;
}
setStatus(null);
@@ -2935,11 +2903,8 @@ function tvReleasesMain(): TvReleasesView {
}, TV_SWEEP_POLL_MS);
}
sweep.addEventListener("click", () => {
const current = request;
if (!current) {
return;
}
/** Queue a targeted sweep, then watch for its releases to land (§6.2). */
function startSweep(current: TvDeckRequest) {
sweep.disabled = true;
void (async () => {
const outcome = await current.target.search();
@@ -2955,6 +2920,14 @@ function tvReleasesMain(): TvReleasesView {
window.clearTimeout(pollTimer);
watchSweep(current, sequence);
})();
}
sweep.addEventListener("click", () => {
const current = request;
if (!current) {
return;
}
startSweep(current);
});
function open(next: TvDeckRequest) {
@@ -2966,7 +2939,7 @@ function tvReleasesMain(): TvReleasesView {
clearBuckets(dom);
sweep.disabled = false;
back.focus();
void load();
void load(true);
}
function hide() {
@@ -3033,8 +3006,6 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
const taglineEl = must<HTMLElement>("#series-tagline");
const overviewEl = must<HTMLElement>("#series-overview");
const actionsEl = must<HTMLElement>("#series-actions");
const castSection = must<HTMLElement>("#series-cast");
const castRows = must<HTMLUListElement>("#rows-series-cast");
const statusEl = must<HTMLElement>("#series-status");
const seasonsList = must<HTMLUListElement>("#rows-seasons");
@@ -3074,12 +3045,12 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
const root = roots.find((candidate) => candidate.id === current.root_id);
chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`));
chipsEl.append(
chip(`${current.available_episodes}/${current.wanted_episodes} eps`, (span) => {
span.setAttribute(
"aria-label",
`${current.available_episodes} of ${current.wanted_episodes} wanted episodes on disk`,
);
}),
countsChip(
`${current.available_episodes}/${current.wanted_episodes} eps`,
current.available_episodes,
current.wanted_episodes,
`${current.available_episodes} of ${current.wanted_episodes} wanted episodes on disk`,
),
);
chipsEl.append(
chip(current.status, (span) => {
@@ -3104,8 +3075,6 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
taglineEl.hidden = true;
overviewEl.hidden = true;
actionsEl.replaceChildren();
castSection.hidden = true;
castRows.replaceChildren();
}
async function loadMetadata(id: number, ticket: number) {
@@ -3184,22 +3153,16 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
externalLink("rotten tomatoes", rottenTomatoesSearch(series.title, series.year)),
);
}
if (detail.cast.length > 0) {
castSection.hidden = false;
for (const member of detail.cast) {
castRows.append(castItem(member));
}
}
}
function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
const counts = seasonCounts(season);
return chip(`${counts.available}/${counts.wanted} on disk`, (span) => {
span.setAttribute(
"aria-label",
`${counts.available} of ${counts.wanted} wanted episodes on disk`,
);
});
return countsChip(
`${counts.available}/${counts.wanted} on disk`,
counts.available,
counts.wanted,
`${counts.available} of ${counts.wanted} wanted episodes on disk`,
);
}
function seasonRow(season: ApiSeason): HTMLLIElement {
@@ -3264,27 +3227,6 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
const space = document.createElement("span");
space.className = "season-space";
const searchBtn = document.createElement("button");
searchBtn.type = "button";
searchBtn.className = "control";
searchBtn.textContent = "search";
searchBtn.addEventListener("click", () => {
const currentId = seriesId;
if (currentId === null) {
return;
}
searchBtn.disabled = true;
void (async () => {
const outcome = await seasonTarget(currentId, season.number).search();
searchBtn.disabled = false;
if (outcome.kind === "error") {
setStatus(`search failed — ${outcome.detail}`, "fault");
return;
}
setStatus(`season ${PAD_TWO(season.number)} sweep queued — results land in its deck`);
})();
});
const deckBtn = document.createElement("button");
deckBtn.type = "button";
deckBtn.className = "control";
@@ -3317,7 +3259,7 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
}),
);
}
line.append(space, searchBtn, deckBtn);
line.append(space, deckBtn);
item.append(line);
const episodes = document.createElement("ul");
@@ -3349,11 +3291,7 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
const chips = document.createElement("span");
chips.className = "row-chips ep-chips";
chips.append(
chip(episode.state, (span) => {
span.dataset.movieState = episode.state;
}),
);
chips.append(mediaStateChip(episode.state, episode.wanted));
// issue 122: gone upstream while its file remained — a conflict, not a state
if (episode.vanished) {
chips.append(
@@ -3411,24 +3349,6 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
});
actions.append(want);
const searchBtn = document.createElement("button");
searchBtn.type = "button";
searchBtn.className = "control";
searchBtn.textContent = "search";
searchBtn.addEventListener("click", () => {
searchBtn.disabled = true;
void (async () => {
const outcome = await episodeTarget(episode.id).search();
searchBtn.disabled = false;
if (outcome.kind === "error") {
setStatus(`search failed — ${outcome.detail}`, "fault");
return;
}
setStatus(`S${PAD_TWO(seasonNumber)}E${PAD_TWO(episode.number)} sweep queued`);
})();
});
actions.append(searchBtn);
const deckBtn = document.createElement("button");
deckBtn.type = "button";
deckBtn.className = "control";
-14
View File
@@ -4,15 +4,6 @@
import { type ActionOutcome, errorDetail } from "./releases";
/** One of the top-billed cast members (§9.6). */
export interface MetadataCastMember {
tmdb_id: number;
name: string;
character: string;
/** Path fragment exactly as TMDB sends it; the browser composes the URL. */
profile_path: string | null;
}
/** The one trailer worth showing, resolved by the §9.6 rule in arr-meta. */
export interface MetadataTrailer {
youtube_key: string;
@@ -35,7 +26,6 @@ export interface MovieMetadata {
vote_count: number;
homepage: string | null;
imdb_id: string | null;
cast: MetadataCastMember[];
trailer: MetadataTrailer | null;
}
@@ -129,10 +119,6 @@ export function tvdbLink(tvdbId: number): string {
return `https://www.thetvdb.com/dereferrer/series/${tvdbId}`;
}
export function tmdbPersonLink(tmdbId: number): string {
return `https://www.themoviedb.org/person/${tmdbId}`;
}
export function imdbLink(imdbId: string): string {
return `https://www.imdb.com/title/${imdbId}/`;
}
+1 -2
View File
@@ -2,7 +2,7 @@
// release schemas — same reasoning as search.ts: the generated client
// (src/api/) is uncommitted, so CI's tsc cannot see it.
import type { MetadataCastMember, MetadataTrailer } from "./movie";
import type { MetadataTrailer } from "./movie";
import type { ActionOutcome, MovieRelease, ReleasesOutcome, WaiveOutcome } from "./releases";
import { errorDetail, probedAttributeTags, waiverOverride } from "./releases";
@@ -259,7 +259,6 @@ export interface SeriesMetadata {
homepage: string | null;
/** §9.6 links out to TVDB for series; absent when the id is unknown. */
tvdb_id: number | null;
cast: MetadataCastMember[];
trailer: MetadataTrailer | null;
}
+64 -133
View File
@@ -61,13 +61,11 @@
--score-5: oklch(0.79 0.165 129);
--score-6: oklch(0.8 0.17 150);
/* library status (§4.2) — informational, never a severity ramp:
activity in one violet family, satisfaction in neutrals */
/* library state ramp (§4.2): green satisfied, amber gap, violet active,
neutral nothing-happening. Satisfaction reuses --signal-ok and a gap
--signal-warn, so the only hue this family still owns is the active one,
which doubles as the downloading colour. */
--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;
@@ -94,6 +92,7 @@
--space-12: 3rem;
--radius: 4px;
--control-h: 2rem;
--ease-out: cubic-bezier(0.25, 1, 0.5, 1);
}
@@ -135,15 +134,22 @@ body {
/* ---- rails ---------------------------------------------------------- */
.rail {
display: flex;
align-items: center;
gap: var(--space-6);
padding: var(--space-3) var(--space-6);
border-bottom: 1px solid var(--line);
background: var(--panel);
box-shadow: inset 0 1px var(--highlight);
}
/* contents share the deck's column; the panel itself stays full-bleed */
.rail-inner {
display: flex;
align-items: center;
gap: var(--space-6);
width: 100%;
max-width: 68rem;
margin-inline: auto;
}
.rail-id {
display: flex;
align-items: center;
@@ -159,28 +165,13 @@ body {
line-height: 1;
}
.rail-verdict {
margin: 0;
flex: 1;
color: var(--ink-muted);
}
.rail-verdict[data-tone="ok"] {
color: var(--signal-ok);
}
.rail-verdict[data-tone="warn"] {
color: var(--signal-warn);
}
.rail-verdict[data-tone="fault"] {
color: var(--signal-fault);
}
.rail-meta {
display: flex;
align-items: center;
gap: var(--space-4);
/* the verdict line used to absorb the slack; without it the readout
needs to claim the right edge itself */
margin-left: auto;
}
/* ---- lamps ---------------------------------------------------------- */
@@ -442,10 +433,13 @@ body {
/* ---- controls -------------------------------------------------------- */
.control {
min-height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--control-h);
padding: 0 var(--space-4);
font-family: var(--font-readout);
font-size: var(--text-sm);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
@@ -497,7 +491,7 @@ body {
.search-input {
width: 100%;
min-height: 2.75rem;
min-height: var(--control-h);
padding: 0 var(--space-8) 0 var(--space-3);
color: var(--ink);
background: var(--ground);
@@ -634,7 +628,7 @@ body {
display: inline-flex;
align-items: center;
gap: var(--space-1);
min-height: 1.375rem;
min-height: var(--control-h);
padding: 0 var(--space-2);
background: var(--panel-raised);
border: 1px solid var(--line);
@@ -661,32 +655,52 @@ body {
color: var(--verdict-rejected);
}
/* media state ramp (§4.2): green on disk, violet downloading, amber wanted
and still missing. Unwanted-missing and parked are nothing-happening and
stay neutral; `parked` exists so a vanished grab never reads as a gap. */
.chip[data-movie-state="available"] {
color: var(--ink);
color: var(--signal-ok);
border-color: oklch(from var(--signal-ok) l c h / 45%);
}
/* library status (§4.2): informational, never a severity ramp — activity in
the channel-violet family, satisfaction in neutrals; the word is always
present, so the state survives with colour removed */
.chip[data-movie-state="downloading"] {
color: var(--status-airing);
border-color: oklch(from var(--status-airing) l c h / 45%);
}
.chip[data-movie-state="missing"][data-wanted="true"] {
color: var(--signal-warn);
border-color: oklch(from var(--signal-warn) l c h / 55%);
}
/* library status ramp (§4.2): violet while activity is live, green once
everything wanted is on disk, amber for a grabbable gap; waiting is
between seasons with nothing to do, so it stays neutral */
.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);
color: var(--signal-warn);
border-color: oklch(from var(--signal-warn) l c h / 55%);
}
.chip[data-status="complete"],
.chip[data-status="ended"] {
color: var(--status-ended);
color: var(--signal-ok);
border-color: oklch(from var(--signal-ok) l c h / 45%);
}
/* season/series counts chip (§4.2): the same ramp read off N/M itself */
.chip[data-counts="complete"] {
color: var(--signal-ok);
border-color: oklch(from var(--signal-ok) l c h / 45%);
}
.chip[data-counts="partial"] {
color: var(--signal-warn);
border-color: oklch(from var(--signal-warn) l c h / 55%);
}
/* a TMDB row is the affordance: the whole row opens the add flow */
@@ -996,7 +1010,7 @@ body {
.bucket-toggle {
margin-left: auto;
min-height: 2.75rem;
min-height: var(--control-h);
padding: 0 var(--space-2);
font-size: var(--text-xs);
text-transform: uppercase;
@@ -1175,7 +1189,7 @@ body {
}
.view-btn {
min-height: 1.75rem;
min-height: var(--control-h);
padding: 0 var(--space-2);
font-family: var(--font-readout);
font-size: var(--text-xs);
@@ -1373,7 +1387,6 @@ body {
/* the tracked toggle reads as the rule it is; pressed = on */
.season-line .control,
.ep-actions .control {
min-height: 2.25rem;
padding: 0 var(--space-3);
}
@@ -1595,10 +1608,11 @@ body {
color: var(--ink);
}
/* the star is amber (§4.2): rating reads at a glance, the number stays ink */
.star {
width: 0.75rem;
height: 0.75rem;
fill: var(--ink-muted);
fill: var(--signal-warn);
}
/* a row chip is smaller than the detail page's rating readout */
@@ -1654,7 +1668,6 @@ body {
}
.movie-controls .control {
min-height: 2.25rem;
padding: 0 var(--space-3);
}
@@ -1679,79 +1692,6 @@ body {
flex: 1;
}
/* cast: top billed, each linking out to TMDB (§9.6) */
.cast-grid {
margin: 0;
padding: var(--space-3) var(--space-1) 0;
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
gap: var(--space-4) var(--space-2);
}
.cast-link {
display: grid;
gap: var(--space-1);
justify-items: start;
color: inherit;
text-decoration: none;
min-width: 0;
}
.cast-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.cast-photo {
width: 100%;
aspect-ratio: 2 / 3;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel-raised);
transition: border-color 150ms var(--ease-out);
}
.cast-photo img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* no profile photo: dead air, not a broken frame */
.cast-photo-none::before {
content: "";
display: block;
width: 40%;
aspect-ratio: 1;
margin: 30% auto 0;
border-radius: 50%;
border: 1px solid var(--line);
}
.cast-link:hover .cast-photo,
.cast-link:focus-visible .cast-photo {
border-color: var(--accent);
}
.cast-name {
font-size: var(--text-xs);
color: var(--ink);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
}
.cast-character {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
}
/* on-disk rows share the deck's rel grammar: name leads, chips trail */
.disk-name {
min-width: 0;
@@ -1768,7 +1708,7 @@ body {
/* ---- small screens --------------------------------------------------- */
@media (max-width: 46rem) {
.rail {
.rail-inner {
flex-wrap: wrap;
gap: var(--space-3) var(--space-4);
}
@@ -1786,11 +1726,6 @@ body {
white-space: nowrap;
}
.rail-verdict {
flex-basis: 100%;
order: 3;
}
.rail-search {
order: 4;
flex-basis: 100%;
@@ -1876,10 +1811,6 @@ body {
flex: 1;
}
.cast-grid {
grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr));
}
.board {
padding: var(--space-6) var(--space-4);
}