diff --git a/.impeccable/design.json b/.impeccable/design.json index 5735401..0364f4f 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -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": "2160p", - "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": "airing", - "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": "", - "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": "

arr

all signals nominal

", - "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": "

arr

", + "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.", diff --git a/.sqlx/query-cf040af066e35e9bcb969c84ac180784d5bc9a2bf0630cd6fef2bb8f4cb467b5.json b/.sqlx/query-3b518839d694b872f83005ca059d45d048a8c44efd78b75e08e74299fed072ea.json similarity index 87% rename from .sqlx/query-cf040af066e35e9bcb969c84ac180784d5bc9a2bf0630cd6fef2bb8f4cb467b5.json rename to .sqlx/query-3b518839d694b872f83005ca059d45d048a8c44efd78b75e08e74299fed072ea.json index 9906276..ec46c2b 100644 --- a/.sqlx/query-cf040af066e35e9bcb969c84ac180784d5bc9a2bf0630cd6fef2bb8f4cb467b5.json +++ b/.sqlx/query-3b518839d694b872f83005ca059d45d048a8c44efd78b75e08e74299fed072ea.json @@ -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" } diff --git a/.sqlx/query-e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187.json b/.sqlx/query-580e3bf81062d253fd08c9a4e1a879a0e57d6357ab9b278503cdf8fb5252886a.json similarity index 92% rename from .sqlx/query-e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187.json rename to .sqlx/query-580e3bf81062d253fd08c9a4e1a879a0e57d6357ab9b278503cdf8fb5252886a.json index e22fd02..ac2fc80 100644 --- a/.sqlx/query-e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187.json +++ b/.sqlx/query-580e3bf81062d253fd08c9a4e1a879a0e57d6357ab9b278503cdf8fb5252886a.json @@ -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" } diff --git a/.sqlx/query-2bc048c0fca4822eb38befbe3091f44f961fafb79432d4771aa48e36d32bf2a2.json b/.sqlx/query-80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9.json similarity index 83% rename from .sqlx/query-2bc048c0fca4822eb38befbe3091f44f961fafb79432d4771aa48e36d32bf2a2.json rename to .sqlx/query-80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9.json index c0b913e..bccef76 100644 --- a/.sqlx/query-2bc048c0fca4822eb38befbe3091f44f961fafb79432d4771aa48e36d32bf2a2.json +++ b/.sqlx/query-80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9.json @@ -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" } diff --git a/.sqlx/query-9f0164d699ffd66f31e7916827c81e01d283f35bb91ec2f34f63240792c01152.json b/.sqlx/query-9755322057875ad786326cf533bff5d6b78e7844476731b309a3859287ff8c2f.json similarity index 82% rename from .sqlx/query-9f0164d699ffd66f31e7916827c81e01d283f35bb91ec2f34f63240792c01152.json rename to .sqlx/query-9755322057875ad786326cf533bff5d6b78e7844476731b309a3859287ff8c2f.json index 3822d6e..1596a41 100644 --- a/.sqlx/query-9f0164d699ffd66f31e7916827c81e01d283f35bb91ec2f34f63240792c01152.json +++ b/.sqlx/query-9755322057875ad786326cf533bff5d6b78e7844476731b309a3859287ff8c2f.json @@ -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" } diff --git a/.sqlx/query-583b48cc8a0c618294361f606a9c8a4b3a42000c96343ff9a5490bb518700f81.json b/.sqlx/query-f52980596624a03ae4c704926a443c5665c865f9d3d3699105790fe09d8b3fc9.json similarity index 89% rename from .sqlx/query-583b48cc8a0c618294361f606a9c8a4b3a42000c96343ff9a5490bb518700f81.json rename to .sqlx/query-f52980596624a03ae4c704926a443c5665c865f9d3d3699105790fe09d8b3fc9.json index 52f33f0..68a8d8c 100644 --- a/.sqlx/query-583b48cc8a0c618294361f606a9c8a4b3a42000c96343ff9a5490bb518700f81.json +++ b/.sqlx/query-f52980596624a03ae4c704926a443c5665c865f9d3d3699105790fe09d8b3fc9.json @@ -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" } diff --git a/.sqlx/query-ab536da7864e712754f1742e1f1f67d1d5cef884dafcaae1ae9ce498fc852e32.json b/.sqlx/query-fd340665e0593bf692f0ffbe60ba4bebbc1a3d80c1304ed1fb790ccbfc6cc420.json similarity index 87% rename from .sqlx/query-ab536da7864e712754f1742e1f1f67d1d5cef884dafcaae1ae9ce498fc852e32.json rename to .sqlx/query-fd340665e0593bf692f0ffbe60ba4bebbc1a3d80c1304ed1fb790ccbfc6cc420.json index 3c135a7..d375fa0 100644 --- a/.sqlx/query-ab536da7864e712754f1742e1f1f67d1d5cef884dafcaae1ae9ce498fc852e32.json +++ b/.sqlx/query-fd340665e0593bf692f0ffbe60ba4bebbc1a3d80c1304ed1fb790ccbfc6cc420.json @@ -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" } diff --git a/DESIGN.md b/DESIGN.md index 3079579..4187cd3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index c085011..31a2eb3 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -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, }; diff --git a/crates/arr-api/src/metadata.rs b/crates/arr-api/src/metadata.rs index 07999cf..900482c 100644 --- a/crates/arr-api/src/metadata.rs +++ b/crates/arr-api/src/metadata.rs @@ -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, -} - /// 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, /// §9.6 links out to `IMDb` for movies. pub imdb_id: Option, - /// Top 10 billed, ordered by TMDB's own cast order. - pub cast: Vec, pub trailer: Option, } @@ -75,8 +61,6 @@ pub struct SeriesMetadata { pub homepage: Option, /// §9.6 links out to TVDB for series. pub tvdb_id: Option, - /// Top 10 billed, ordered by TMDB's own cast order. - pub cast: Vec, pub trailer: Option, } @@ -95,7 +79,6 @@ impl From 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 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 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 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; } diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index f38b36c..70c6803 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -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] diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index fa480c0..bace102 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -208,6 +208,9 @@ struct SeriesRow { poster_path: Option, /// TMDB's rating, out of 10; `null` when TMDB has no votes for it. vote_average: Option, + /// 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, } struct EpisodeRow { @@ -353,7 +356,7 @@ async fn tv_by_series(state: &AppState) -> Result Result { - 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, ) -> Result>, 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, 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, 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 = 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 { + 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; diff --git a/crates/arr-core/src/tracking.rs b/crates/arr-core/src/tracking.rs index aeb9793..e6513ec 100644 --- a/crates/arr-core/src/tracking.rs +++ b/crates/arr-core/src/tracking.rs @@ -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)); diff --git a/crates/arr-daemon/src/series_refresh.rs b/crates/arr-daemon/src/series_refresh.rs index 1bb5f9f..8a7880a 100644 --- a/crates/arr-daemon/src/series_refresh.rs +++ b/crates/arr-daemon/src/series_refresh.rs @@ -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 = 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 = 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: diff --git a/web/index.html b/web/index.html index 4523f83..8891f4c 100644 --- a/web/index.html +++ b/web/index.html @@ -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. -->
-
- -

arr

-
- - -

probing chain…

-
- v — +
+
+ +

arr

+
+ + +
+ v — +
@@ -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 @@ - - - -
    @@ -445,7 +431,7 @@

    —

    - +
    diff --git a/web/src/main.ts b/web/src/main.ts index 6f8bdb1..38bc21b 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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(selector: string): T { function main() { const board = must(".board"); const masterLamp = must("#master-lamp"); - const verdict = must("#master-verdict"); const version = must("#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("#movie"); const deckEl = must("#deck"); @@ -1349,8 +1330,6 @@ function movieMain(board: HTMLElement, views: HideableView[]): MovieView { const sweep = must("#movie-sweep"); const remove = must("#movie-remove"); const removeWrap = must("#remove-panel"); - const castSection = must("#movie-cast"); - const castRows = must("#rows-cast"); const filesSection = must("#movie-files"); const diskCount = must("#count-disk"); const diskRows = must("#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("#series-tagline"); const overviewEl = must("#series-overview"); const actionsEl = must("#series-actions"); - const castSection = must("#series-cast"); - const castRows = must("#rows-series-cast"); const statusEl = must("#series-status"); const seasonsList = must("#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"; diff --git a/web/src/movie.ts b/web/src/movie.ts index 945febe..2fb3f65 100644 --- a/web/src/movie.ts +++ b/web/src/movie.ts @@ -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}/`; } diff --git a/web/src/series.ts b/web/src/series.ts index eb48246..7fb7b2d 100644 --- a/web/src/series.ts +++ b/web/src/series.ts @@ -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; } diff --git a/web/src/style.css b/web/src/style.css index fd954c3..93e5fc6 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -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); }