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 @@
-
-
-
cast
-
-
-
-
on disk
@@ -428,13 +421,6 @@
-
-
-
cast
-
-
-
-
@@ -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);
}