Merge milestone 'Feedback pass'
Nine issues from a hands-on pass over the deployed build. Behaviour: adding a series no longer tracks its whole back catalogue (auto_track applies from the second refresh onward); a series-title query no longer returns every episode of that series; seasons and episodes come back newest-first. UI: cast comes off title detail; buttons and chips share one 2rem geometry; state chips carry the ramp (green satisfied, amber gap, violet active, neutral idle); the header drops its verdict line and sits on the content width; season and episode rows lose their search button and the deck sweeps itself when opened empty. DESIGN.md 4.1, 4.2, 9.2 and 9.6 amended first; every other issue implements against the amended text. Closes #159, #160, #161, #162, #163, #164, #165, #166, #167
This commit is contained in:
@@ -123,7 +123,7 @@
|
||||
},
|
||||
"scale": {
|
||||
"displayName": "Type scale",
|
||||
"purpose": "Seven fixed steps as tokens: --text-2xs 0.6875rem (micro-labels), --text-xs 0.75rem (details, chips), --text-sm 0.8125rem (readouts, controls), --text-base 1rem (body), --text-md 1.0625rem (module names), --text-lg 1.3125rem (master module), --text-xl 1.625rem (wordmark). Components never carry a literal font-size."
|
||||
"purpose": "Seven fixed steps as tokens: --text-2xs 0.6875rem (micro-labels), --text-xs 0.75rem (details, chips, controls), --text-sm 0.8125rem (readouts), --text-base 1rem (body), --text-md 1.0625rem (module names), --text-lg 1.3125rem (master module), --text-xl 1.625rem (wordmark). Components never carry a literal font-size. One control geometry: buttons and chips share --control-h 2rem and --text-xs, so a season line reads as one system."
|
||||
}
|
||||
},
|
||||
"shadows": [
|
||||
@@ -189,7 +189,7 @@
|
||||
"refersTo": "ground",
|
||||
"description": "The §9.3 column unit: a small machined panel holding one mono value (score, resolution, source, HDR, audio, size, seeders). Fixed rhythm so columns align; never a soft pill.",
|
||||
"html": "<span class=\"chip readout\">2160p</span>",
|
||||
"css": ".chip { display: inline-flex; align-items: center; gap: var(--space-1); min-height: 1.375rem; padding: 0 var(--space-2); background: var(--panel-raised); border: 1px solid var(--line); border-radius: var(--radius); font-family: var(--font-readout); font-size: var(--text-xs); color: var(--ink-muted); }"
|
||||
"css": ".chip { display: inline-flex; align-items: center; gap: var(--space-1); min-height: var(--control-h); padding: 0 var(--space-2); background: var(--panel-raised); border: 1px solid var(--line); border-radius: var(--radius); font-family: var(--font-readout); font-size: var(--text-xs); color: var(--ink-muted); }"
|
||||
},
|
||||
{
|
||||
"name": "Verdict chip",
|
||||
@@ -205,7 +205,7 @@
|
||||
"refersTo": "status-channel",
|
||||
"description": "Library status (§4.2): airing / incomplete / waiting / complete / ended. Informational, not severity — colour marks activity in one channel-violet family (airing brightest, incomplete, waiting dimmest), satisfaction is neutral (complete bright ink, ended faint). The mono word is always present.",
|
||||
"html": "<span class=\"chip readout\" data-status=\"airing\">airing</span>",
|
||||
"css": ".chip[data-status=\"airing\"] { color: var(--status-airing); border-color: oklch(from var(--status-airing) l c h / 45%); } .chip[data-status=\"incomplete\"] { color: var(--status-incomplete); } .chip[data-status=\"waiting\"] { color: var(--status-waiting); } .chip[data-status=\"complete\"] { color: var(--status-complete); } .chip[data-status=\"ended\"] { color: var(--status-ended); }"
|
||||
"css": ".chip[data-status=\"airing\"] { color: var(--status-airing); border-color: oklch(from var(--status-airing) l c h / 45%); } .chip[data-status=\"incomplete\"] { color: var(--signal-warn); border-color: oklch(from var(--signal-warn) l c h / 55%); } .chip[data-status=\"complete\"], .chip[data-status=\"ended\"] { color: var(--signal-ok); border-color: oklch(from var(--signal-ok) l c h / 45%); } .chip[data-counts=\"complete\"] { color: var(--signal-ok); } .chip[data-counts=\"partial\"] { color: var(--signal-warn); }"
|
||||
},
|
||||
{
|
||||
"name": "Module (channel strip)",
|
||||
@@ -229,15 +229,15 @@
|
||||
"refersTo": "accent",
|
||||
"description": "The interactive element: mono uppercase, phosphor-cyan text, panel ground, ≥44px hit target. Cyan belongs to interaction only — never to status.",
|
||||
"html": "<button type=\"button\" class=\"control\">probe</button>",
|
||||
"css": ".control { min-height: 2.75rem; padding: 0 var(--space-4); font-family: var(--font-readout); text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); background: var(--panel-raised); border: 1px solid var(--line-strong); border-radius: var(--radius); }"
|
||||
"css": ".control { display: inline-flex; align-items: center; justify-content: center; min-height: var(--control-h); padding: 0 var(--space-4); font-family: var(--font-readout); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); background: var(--panel-raised); border: 1px solid var(--line-strong); border-radius: var(--radius); }"
|
||||
},
|
||||
{
|
||||
"name": "Rail",
|
||||
"kind": "nav",
|
||||
"refersTo": "ground",
|
||||
"description": "Full-width panel strip framing the board top (identity + verdict + controls) and bottom (readouts). Hairline-ruled, panel ground, inset highlight.",
|
||||
"html": "<header class=\"rail\"><div class=\"rail-id\"><span class=\"lamp\" data-state=\"ok\" aria-hidden=\"true\"></span><h1 class=\"wordmark\">arr</h1></div><p class=\"rail-verdict readout\" data-tone=\"ok\">all signals nominal</p></header>",
|
||||
"css": ".rail { display: flex; align-items: center; gap: var(--space-6); padding: var(--space-3) var(--space-6); border-bottom: 1px solid var(--line); background: var(--panel); box-shadow: inset 0 1px var(--highlight); }"
|
||||
"description": "Full-width panel strip framing the board top (identity + controls) and bottom (readouts). Hairline-ruled, panel ground, inset highlight. Contents sit in a .rail-inner column capped at 68rem, centred — the panel stays full-bleed. The lamp beside the wordmark is the only header status signal.",
|
||||
"html": "<header class=\"rail\"><div class=\"rail-inner\"><div class=\"rail-id\"><span class=\"lamp\" data-state=\"ok\" aria-hidden=\"true\"></span><h1 class=\"wordmark\">arr</h1></div></div></header>",
|
||||
"css": ".rail { padding: var(--space-3) var(--space-6); border-bottom: 1px solid var(--line); background: var(--panel); box-shadow: inset 0 1px var(--highlight); } .rail-inner { display: flex; align-items: center; gap: var(--space-6); width: 100%; max-width: 68rem; margin-inline: auto; }"
|
||||
}
|
||||
],
|
||||
"narrative": {
|
||||
@@ -300,7 +300,7 @@
|
||||
"Do take every colour, spacing and font-size from the :root tokens; the token check (web/scripts/check-tokens.mjs) fails CI on literals."
|
||||
],
|
||||
"donts": [
|
||||
"Don't colour `complete` green or `incomplete` amber — library status is informational, not a severity ramp.",
|
||||
"Colour state chips on the ramp — `complete` and `ended` green, `incomplete` and a partial count amber, `airing` and `downloading` violet, `waiting` and `untracked` neutral. The word stays in every chip, so state survives with colour removed.",
|
||||
"Don't use fault red for a rejected release; red means the daemon itself is broken.",
|
||||
"Don't introduce a second interactive hue or use cyan for a status.",
|
||||
"Don't box content in soft-shadowed rounded cards or add glass/blur.",
|
||||
|
||||
+8
-2
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
+8
-2
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+8
-2
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -13,18 +13,6 @@ use crate::movies::{pool, ApiError, ErrorBody};
|
||||
use crate::search::{tmdb_client, upstream_error};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// One of the top-billed cast members on a detail page.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct MetadataCastMember {
|
||||
/// The person's TMDB id, for the link out to tmdb.org (§9.6).
|
||||
pub tmdb_id: u32,
|
||||
pub name: String,
|
||||
pub character: String,
|
||||
/// Path fragment exactly as TMDB sends it (§9.6): the browser composes
|
||||
/// the URL and picks the size.
|
||||
pub profile_path: Option<String>,
|
||||
}
|
||||
|
||||
/// The one trailer worth showing, resolved by the §9.6 rule inside `arr-meta`.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct MetadataTrailer {
|
||||
@@ -52,8 +40,6 @@ pub struct MovieMetadata {
|
||||
pub homepage: Option<String>,
|
||||
/// §9.6 links out to `IMDb` for movies.
|
||||
pub imdb_id: Option<String>,
|
||||
/// Top 10 billed, ordered by TMDB's own cast order.
|
||||
pub cast: Vec<MetadataCastMember>,
|
||||
pub trailer: Option<MetadataTrailer>,
|
||||
}
|
||||
|
||||
@@ -75,8 +61,6 @@ pub struct SeriesMetadata {
|
||||
pub homepage: Option<String>,
|
||||
/// §9.6 links out to TVDB for series.
|
||||
pub tvdb_id: Option<u32>,
|
||||
/// Top 10 billed, ordered by TMDB's own cast order.
|
||||
pub cast: Vec<MetadataCastMember>,
|
||||
pub trailer: Option<MetadataTrailer>,
|
||||
}
|
||||
|
||||
@@ -95,7 +79,6 @@ impl From<MovieDetail> for MovieMetadata {
|
||||
vote_count: detail.vote_count,
|
||||
homepage: detail.homepage,
|
||||
imdb_id: detail.imdb_id,
|
||||
cast: detail.cast.into_iter().map(Into::into).collect(),
|
||||
trailer: detail.trailer.map(Into::into),
|
||||
}
|
||||
}
|
||||
@@ -116,23 +99,11 @@ impl From<SeriesDetail> for SeriesMetadata {
|
||||
vote_count: detail.vote_count,
|
||||
homepage: detail.homepage,
|
||||
tvdb_id: detail.tvdb_id,
|
||||
cast: detail.cast.into_iter().map(Into::into).collect(),
|
||||
trailer: detail.trailer.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<arr_meta::CastMember> for MetadataCastMember {
|
||||
fn from(member: arr_meta::CastMember) -> Self {
|
||||
Self {
|
||||
tmdb_id: member.tmdb_id,
|
||||
name: member.name,
|
||||
character: member.character,
|
||||
profile_path: member.profile_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<arr_meta::Video> for MetadataTrailer {
|
||||
fn from(video: arr_meta::Video) -> Self {
|
||||
Self {
|
||||
@@ -347,11 +318,6 @@ mod tests {
|
||||
// §9.6: path fragments exactly as TMDB sends them, never composed URLs.
|
||||
assert_eq!(body["poster_path"], "/1pdfLlvXA5qYN3ikFm4QDe7EJ19.jpg");
|
||||
assert_eq!(body["backdrop_path"], "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg");
|
||||
assert_eq!(
|
||||
body["cast"][0]["profile_path"],
|
||||
"/tzLq0VAm3myi2lzNpdfhVGjXAdB.jpg"
|
||||
);
|
||||
assert_eq!(body["cast"][0]["character"], "Paul Atreides");
|
||||
assert_eq!(body["trailer"]["youtube_key"], "Way9Dexny3w");
|
||||
tmdb.verify().await;
|
||||
}
|
||||
|
||||
@@ -233,9 +233,11 @@ pub async fn search(
|
||||
library.extend(series_rows.into_iter().map(LibraryResult::Series));
|
||||
|
||||
// §9.2 names the TV case explicitly: `bluey hospital` finds the episode.
|
||||
// Tokens may split across the series and episode titles, so the haystack
|
||||
// is both joined and every token must land somewhere in it.
|
||||
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String", s.poster_path, s.vote_average, s.tmdb_id AS "series_tmdb_id!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens)
|
||||
// Tokens may split across the series and episode titles, so every token
|
||||
// must land in the concatenation — and at least one must match the
|
||||
// episode title on its own, or a series-title-only query would list
|
||||
// every episode the series has.
|
||||
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String", s.poster_path, s.vote_average, s.tmdb_id AS "series_tmdb_id!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') AND EXISTS (SELECT 1 FROM json_each(?) token WHERE e.title LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens, tokens)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
library.extend(episode_rows.into_iter().map(LibraryResult::Episode));
|
||||
@@ -956,15 +958,32 @@ mod tests {
|
||||
assert_eq!(response["tmdb"][0]["kind"], "series");
|
||||
assert_eq!(response["tmdb"][0]["title"], "Bluey");
|
||||
|
||||
// §9.2, amended: an episode row surfaces only when the query matches
|
||||
// something beyond the series title. `bluey` alone returns the series
|
||||
// row and no episode below it.
|
||||
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=bluey"))
|
||||
.await
|
||||
.expect("search")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
assert_eq!(response["library"][0]["kind"], "series");
|
||||
assert_eq!(response["library"][0]["title"], "Bluey");
|
||||
assert_eq!(response["library"][0]["tmdb_id"], 82_728);
|
||||
let library = response["library"].as_array().expect("library");
|
||||
assert_eq!(library.len(), 1);
|
||||
assert_eq!(library[0]["kind"], "series");
|
||||
assert_eq!(library[0]["title"], "Bluey");
|
||||
assert_eq!(library[0]["tmdb_id"], 82_728);
|
||||
|
||||
// An episode-title-only query still finds the episode.
|
||||
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=hospital"))
|
||||
.await
|
||||
.expect("search")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
let library = response["library"].as_array().expect("library");
|
||||
assert_eq!(library.len(), 1);
|
||||
assert_eq!(library[0]["kind"], "episode");
|
||||
assert_eq!(library[0]["title"], "Hospital");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+125
-10
@@ -208,6 +208,9 @@ struct SeriesRow {
|
||||
poster_path: Option<String>,
|
||||
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
|
||||
vote_average: Option<f64>,
|
||||
/// NULL until a metadata refresh has stamped it (#160): the fact the
|
||||
/// auto-track rule needs to tell a seeding refresh from a later one.
|
||||
metadata_refreshed_at: Option<String>,
|
||||
}
|
||||
|
||||
struct EpisodeRow {
|
||||
@@ -353,7 +356,7 @@ async fn tv_by_series(state: &AppState) -> Result<HashMap<i64, Vec<arr_core::Epi
|
||||
}
|
||||
|
||||
async fn load_series_row(state: &AppState, id: i64) -> Result<SeriesRow, ApiError> {
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average FROM series WHERE id = ?"#, id)
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average, metadata_refreshed_at FROM series WHERE id = ?"#, id)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?
|
||||
.ok_or(ApiError::SeriesNotFound)
|
||||
@@ -407,11 +410,11 @@ pub async fn list(
|
||||
Query(query): Query<ListSeriesQuery>,
|
||||
) -> Result<Json<Vec<Series>>, ApiError> {
|
||||
let rows = if let Some(owner_id) = query.owner_id {
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.tvdb_id, s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.auto_track AS "auto_track!: bool", s.overrides AS "overrides!: serde_json::Value", s.upstream_ended AS "upstream_ended!: bool", s.blocked AS "blocked!: bool", poster_path, vote_average FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id"#, owner_id)
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.tvdb_id, s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.auto_track AS "auto_track!: bool", s.overrides AS "overrides!: serde_json::Value", s.upstream_ended AS "upstream_ended!: bool", s.blocked AS "blocked!: bool", poster_path, vote_average, s.metadata_refreshed_at FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id"#, owner_id)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average FROM series ORDER BY title, year, id"#)
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average, metadata_refreshed_at FROM series ORDER BY title, year, id"#)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?
|
||||
};
|
||||
@@ -652,7 +655,9 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
|
||||
|
||||
async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, ApiError> {
|
||||
let seasons = sqlx::query!(
|
||||
r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64", tracked AS "tracked!: bool", vanished AS "vanished!: bool" FROM seasons WHERE series_id = ? ORDER BY number"#,
|
||||
// §9.6: newest-first, so season 0 lands last under plain numeric
|
||||
// descending order — exactly where the design puts it.
|
||||
r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64", tracked AS "tracked!: bool", vanished AS "vanished!: bool" FROM seasons WHERE series_id = ? ORDER BY number DESC"#,
|
||||
series_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
@@ -660,7 +665,7 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
|
||||
let episodes = sqlx::query_as!(
|
||||
EpisodeRow,
|
||||
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
|
||||
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number"#,
|
||||
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number DESC, e.number DESC"#,
|
||||
series_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
@@ -765,6 +770,9 @@ pub async fn create_season(
|
||||
|
||||
// §4.1. The request does not say whether the episodes are wanted; the
|
||||
// series' auto_track rule does, through the one function that owns it.
|
||||
// #160: whether a refresh has happened yet rides in from the row — a
|
||||
// hand-added season on a never-refreshed series is still seeding, and
|
||||
// §4.1 does not track what was there at add time.
|
||||
let mut revealed = [RefreshedSeason {
|
||||
season: arr_core::Season {
|
||||
id: SeasonId(0),
|
||||
@@ -790,7 +798,11 @@ pub async fn create_season(
|
||||
.collect(),
|
||||
is_new: true,
|
||||
}];
|
||||
apply_auto_track(&core_series(&series), &mut revealed);
|
||||
apply_auto_track(
|
||||
&core_series(&series),
|
||||
series.metadata_refreshed_at.is_some(),
|
||||
&mut revealed,
|
||||
);
|
||||
let [revealed] = revealed;
|
||||
|
||||
// One transaction: a rejected episode must not leave the season behind,
|
||||
@@ -1457,15 +1469,21 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
/// #160. A season added before the series' first metadata refresh is part
|
||||
/// of the back catalogue at add time, so the rule does not track it. Once
|
||||
/// a refresh has happened, a hand-revealed season is genuinely new and
|
||||
/// arrives tracked with its episodes wanted.
|
||||
#[tokio::test]
|
||||
async fn auto_track_decides_whether_a_new_season_arrives_wanted() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "kids").await;
|
||||
|
||||
let tracked = add_series(&base, root_id, true).await;
|
||||
let tracked_id = tracked["id"].as_i64().expect("id");
|
||||
// Seeding: no refresh has ever stamped this series.
|
||||
let season = add_season(
|
||||
&base,
|
||||
tracked["id"].as_i64().expect("id"),
|
||||
tracked_id,
|
||||
1,
|
||||
serde_json::json!([
|
||||
{"number": 1, "title": "The Magic Xylophone", "air_date": "2018-10-01"},
|
||||
@@ -1473,6 +1491,33 @@ mod tests {
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(season["tracked"], false);
|
||||
assert!(
|
||||
season["episodes"]
|
||||
.as_array()
|
||||
.expect("episodes")
|
||||
.iter()
|
||||
.all(|episode| episode["wanted"] == false),
|
||||
"§4.1: the back catalogue at add time is never auto-tracked"
|
||||
);
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE series SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(tracked_id)
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("stamp refreshed");
|
||||
let season = add_season(
|
||||
&base,
|
||||
tracked_id,
|
||||
2,
|
||||
serde_json::json!([
|
||||
{"number": 1, "title": "Dance Mode", "air_date": "2019-04-01"}
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(season["tracked"], true);
|
||||
assert!(
|
||||
season["episodes"]
|
||||
@@ -1480,7 +1525,7 @@ mod tests {
|
||||
.expect("episodes")
|
||||
.iter()
|
||||
.all(|episode| episode["wanted"] == true),
|
||||
"§4.1: auto_track marks the episodes of a revealed season wanted"
|
||||
"§4.1: from the second refresh onward the rule tracks what is new"
|
||||
);
|
||||
|
||||
let untracked_id = sqlx::query_scalar::<_, i64>(
|
||||
@@ -1594,13 +1639,83 @@ mod tests {
|
||||
.await
|
||||
.expect("seasons json");
|
||||
let seasons = seasons.as_array().expect("seasons array");
|
||||
assert_eq!(seasons[0]["vanished"], false);
|
||||
let vanished = |number: i64| {
|
||||
seasons
|
||||
.iter()
|
||||
.find(|season| season["number"] == number)
|
||||
.expect("season")["vanished"]
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(vanished(1), false);
|
||||
assert_eq!(
|
||||
seasons[1]["vanished"], true,
|
||||
vanished(2),
|
||||
true,
|
||||
"#141: the conflict flag is not stopped at the database"
|
||||
);
|
||||
}
|
||||
|
||||
/// §9.6: seasons come back newest-first within a series and episodes
|
||||
/// newest-first within each season, with season 0 wherever descending
|
||||
/// numeric order puts it — last. The SPA renders this order as given,
|
||||
/// so it is the API's to get right (#162).
|
||||
#[tokio::test]
|
||||
async fn seasons_and_episodes_are_returned_newest_first() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, false).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
for (season, episodes) in [
|
||||
(
|
||||
0,
|
||||
serde_json::json!([
|
||||
{"number": 1, "title": "Special one"},
|
||||
{"number": 2, "title": "Special two"}
|
||||
]),
|
||||
),
|
||||
(1, serde_json::json!([{"number": 1, "title": "Pilot"}])),
|
||||
(
|
||||
2,
|
||||
serde_json::json!([
|
||||
{"number": 1, "title": "One"},
|
||||
{"number": 2, "title": "Two"},
|
||||
{"number": 3, "title": "Three"}
|
||||
]),
|
||||
),
|
||||
] {
|
||||
add_season(&base, series_id, season, episodes).await;
|
||||
}
|
||||
|
||||
let seasons: serde_json::Value =
|
||||
reqwest::get(format!("{base}/api/series/{series_id}/seasons"))
|
||||
.await
|
||||
.expect("list seasons")
|
||||
.json()
|
||||
.await
|
||||
.expect("seasons json");
|
||||
let seasons = seasons.as_array().expect("seasons array");
|
||||
let numbers: Vec<i64> = seasons
|
||||
.iter()
|
||||
.map(|season| season["number"].as_i64().expect("number"))
|
||||
.collect();
|
||||
assert_eq!(numbers, [2, 1, 0], "§9.6: newest-first within the series");
|
||||
|
||||
let episode_numbers = |index: usize| -> Vec<i64> {
|
||||
seasons[index]["episodes"]
|
||||
.as_array()
|
||||
.expect("episodes")
|
||||
.iter()
|
||||
.map(|episode| episode["number"].as_i64().expect("episode number"))
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
episode_numbers(0),
|
||||
[3, 2, 1],
|
||||
"§9.6: newest-first in a season"
|
||||
);
|
||||
assert_eq!(episode_numbers(1), [1]);
|
||||
assert_eq!(episode_numbers(2), [2, 1]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn intent_is_set_on_seasons_and_on_single_episodes() {
|
||||
let (_dir, state, base) = application().await;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -168,7 +168,13 @@ impl SeriesRefreshAction {
|
||||
});
|
||||
}
|
||||
}
|
||||
apply_auto_track(&core_series(stale), &mut revealed);
|
||||
// #160: the fact of a previous refresh rides in from the row, so the
|
||||
// seeding refresh never tracks the back catalogue it reveals.
|
||||
apply_auto_track(
|
||||
&core_series(stale),
|
||||
stale.metadata_refreshed_at.is_some(),
|
||||
&mut revealed,
|
||||
);
|
||||
for refreshed in revealed {
|
||||
self.insert_revealed_season(&mut transaction, stale.id, refreshed)
|
||||
.await?;
|
||||
@@ -779,8 +785,11 @@ mod tests {
|
||||
assert_eq!(vote, Some(8.417));
|
||||
}
|
||||
|
||||
/// #160. A series' first refresh reveals its back catalogue, but §4.1
|
||||
/// never tracks what was already there at add time: nothing is tracked,
|
||||
/// nothing arrives wanted.
|
||||
#[tokio::test]
|
||||
async fn first_refresh_reveals_seasons_and_applies_auto_track() {
|
||||
async fn the_seeding_refresh_reveals_but_tracks_nothing() {
|
||||
let (_dir, database) = seeded_series(true).await;
|
||||
let server = tmdb("Returning Series", season_one_body(&two_episodes())).await;
|
||||
|
||||
@@ -792,14 +801,14 @@ mod tests {
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, vec![(1, true), (2, true)]);
|
||||
assert_eq!(rows, vec![(1, false), (2, false)]);
|
||||
let wanted: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM episodes WHERE wanted = 1 AND state = 'missing'",
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(wanted, 3);
|
||||
assert_eq!(wanted, 0);
|
||||
// #120 backfill: the series was added before it had an id on file.
|
||||
let tvdb_id: Option<i64> =
|
||||
sqlx::query_scalar("SELECT tvdb_id FROM series WHERE tmdb_id = 82728")
|
||||
@@ -816,6 +825,50 @@ mod tests {
|
||||
assert_eq!(stamped, 1);
|
||||
}
|
||||
|
||||
/// #160. Once the series has been refreshed before, a season TMDB reveals
|
||||
/// that the library has never seen genuinely appeared after add time, so
|
||||
/// it is tracked and its episodes arrive wanted.
|
||||
#[tokio::test]
|
||||
async fn a_later_refresh_tracks_a_genuinely_new_season() {
|
||||
let (_dir, database) = seeded_series(true).await;
|
||||
let server = MockServer::start().await;
|
||||
mount_with(&server, "Returning Series", &[1]).await;
|
||||
|
||||
action(&server).tick(&database).await.unwrap();
|
||||
let tracked: i64 = sqlx::query_scalar("SELECT count(*) FROM seasons WHERE tracked = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tracked, 0, "the seeding refresh tracks nothing");
|
||||
|
||||
expire_refresh(&database).await;
|
||||
server.reset().await;
|
||||
mount(
|
||||
&server,
|
||||
"Returning Series",
|
||||
season_one_body(&two_episodes()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let outcomes = action(&server).tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(outcomes.len(), 1);
|
||||
let rows: Vec<(i64, bool)> =
|
||||
sqlx::query_as("SELECT number, tracked FROM seasons ORDER BY number")
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, vec![(1, false), (2, true)]);
|
||||
let wanted: Vec<i64> = sqlx::query_scalar(
|
||||
"SELECT e.number FROM episodes e JOIN seasons s ON s.id = e.season_id
|
||||
WHERE s.number = 2 AND e.wanted = 1",
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(wanted, vec![1]);
|
||||
}
|
||||
|
||||
/// `auto_track` is a rule about reveals, not intent: without it nothing
|
||||
/// arrives wanted, even though everything is revealed.
|
||||
#[tokio::test]
|
||||
@@ -877,13 +930,13 @@ mod tests {
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, vec![(1, true), (2, true)]);
|
||||
assert_eq!(rows, vec![(1, false), (2, false)]);
|
||||
let counts: (i64, i64) =
|
||||
sqlx::query_as("SELECT count(*), coalesce(sum(wanted), 0) FROM episodes")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(counts, (3, 3));
|
||||
assert_eq!(counts, (3, 0));
|
||||
}
|
||||
|
||||
/// Episodes revealed into an existing season follow that season's rule:
|
||||
|
||||
+34
-48
@@ -18,7 +18,7 @@
|
||||
fault red) with a soft glow, phosphor-cyan for the one interactive control.
|
||||
STORY: the operator opens the board, reads the master lamp, and knows in a
|
||||
second whether arr can do its job — and which upstream is down, by name.
|
||||
FIRST VIEWPORT: a top rail (wordmark + master lamp + verdict + version),
|
||||
FIRST VIEWPORT: a top rail (wordmark + master lamp + version),
|
||||
then the chain: TMDB and PROWLARR modules on the left feeding the central
|
||||
arr module, TRANSMISSION driven on the right, joined by bus hairlines with
|
||||
direction arrows. On a phone the chain stacks vertically.
|
||||
@@ -65,36 +65,37 @@
|
||||
interaction hue.
|
||||
-->
|
||||
<header class="rail">
|
||||
<div class="rail-id">
|
||||
<span class="lamp" id="master-lamp" data-state="probing" aria-hidden="true"></span>
|
||||
<h1 class="wordmark">arr</h1>
|
||||
</div>
|
||||
<nav class="rail-nav" aria-label="views">
|
||||
<button type="button" class="control" id="nav-library" aria-pressed="false">
|
||||
library
|
||||
</button>
|
||||
<button type="button" class="control" id="nav-queues" aria-pressed="false">
|
||||
queues<span class="nav-count readout" id="queues-count" hidden></span>
|
||||
</button>
|
||||
<button type="button" class="control" id="nav-settings" aria-pressed="false">
|
||||
settings
|
||||
</button>
|
||||
</nav>
|
||||
<div class="rail-search">
|
||||
<input
|
||||
id="search"
|
||||
class="search-input readout"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
placeholder="search · title / tmdb id / tt… / magnet"
|
||||
aria-label="unified search — titles, TMDB or IMDb ids, magnets and .torrent links"
|
||||
/>
|
||||
<span class="search-hint readout" id="search-hint" aria-hidden="true">/</span>
|
||||
</div>
|
||||
<p class="rail-verdict readout" id="master-verdict" role="status">probing chain…</p>
|
||||
<div class="rail-meta">
|
||||
<span class="readout dim" id="version" data-testid="version">v —</span>
|
||||
<div class="rail-inner">
|
||||
<div class="rail-id">
|
||||
<span class="lamp" id="master-lamp" data-state="probing" aria-hidden="true"></span>
|
||||
<h1 class="wordmark">arr</h1>
|
||||
</div>
|
||||
<nav class="rail-nav" aria-label="views">
|
||||
<button type="button" class="control" id="nav-library" aria-pressed="false">
|
||||
library
|
||||
</button>
|
||||
<button type="button" class="control" id="nav-queues" aria-pressed="false">
|
||||
queues<span class="nav-count readout" id="queues-count" hidden></span>
|
||||
</button>
|
||||
<button type="button" class="control" id="nav-settings" aria-pressed="false">
|
||||
settings
|
||||
</button>
|
||||
</nav>
|
||||
<div class="rail-search">
|
||||
<input
|
||||
id="search"
|
||||
class="search-input readout"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
placeholder="search · title / tmdb id / tt… / magnet"
|
||||
aria-label="unified search — titles, TMDB or IMDb ids, magnets and .torrent links"
|
||||
/>
|
||||
<span class="search-hint readout" id="search-hint" aria-hidden="true">/</span>
|
||||
</div>
|
||||
<div class="rail-meta">
|
||||
<span class="readout dim" id="version" data-testid="version">v —</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -203,8 +204,7 @@
|
||||
metadata response's own key (no second call); TMDB, IMDb and Rotten
|
||||
Tomatoes sit beside it — RT is a search URL built here, never a resolved
|
||||
page. LIBRARY controls follow on their own row: wanted, blocked, root,
|
||||
then search indexers and remove. CAST is the top 10 billed, each photo
|
||||
linking out to that person's TMDB page. ON DISK lists imported files
|
||||
then search indexers and remove. ON DISK lists imported files
|
||||
with ffprobe attribute tags and any §5.7 waiver, worded honestly. The
|
||||
§9.3 deck closes the page unchanged — buckets, chips, score heat and
|
||||
rejection rules untouched; /movies/{id}/releases lands here with the
|
||||
@@ -271,13 +271,6 @@
|
||||
<div class="remove-panel" id="remove-panel" hidden></div>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="movie-cast" hidden aria-labelledby="label-cast">
|
||||
<header class="deck-head">
|
||||
<h3 class="deck-label" id="label-cast">cast</h3>
|
||||
</header>
|
||||
<ul class="cast-grid" id="rows-cast"></ul>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="movie-files" hidden aria-labelledby="label-disk">
|
||||
<header class="deck-head">
|
||||
<h3 class="deck-label" id="label-disk">on disk</h3>
|
||||
@@ -428,13 +421,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="series-cast" hidden aria-labelledby="label-series-cast">
|
||||
<header class="deck-head">
|
||||
<h3 class="deck-label" id="label-series-cast">cast</h3>
|
||||
</header>
|
||||
<ul class="cast-grid" id="rows-series-cast"></ul>
|
||||
</section>
|
||||
|
||||
<ul class="deck-rows seasons" id="rows-seasons"></ul>
|
||||
</main>
|
||||
|
||||
@@ -445,7 +431,7 @@
|
||||
<h2 class="releases-title" id="tv-releases-title">—</h2>
|
||||
<span class="releases-year readout dim" id="tv-releases-sub"></span>
|
||||
</div>
|
||||
<button type="button" class="control" id="tv-releases-sweep">search indexers</button>
|
||||
<button type="button" class="control" id="tv-releases-sweep">re-search</button>
|
||||
</header>
|
||||
<p class="deck-status readout" id="tv-releases-status" role="status" hidden></p>
|
||||
<div id="tv-buckets"></div>
|
||||
|
||||
+80
-160
@@ -12,13 +12,11 @@ import {
|
||||
formatRating,
|
||||
formatVoteCount,
|
||||
imdbLink,
|
||||
type MetadataCastMember,
|
||||
movieMetadata,
|
||||
resolveTrailer,
|
||||
rottenTomatoesSearch,
|
||||
tmdbImage,
|
||||
tmdbMovieLink,
|
||||
tmdbPersonLink,
|
||||
tmdbSeriesLink,
|
||||
tvdbLink,
|
||||
updateMovie,
|
||||
@@ -148,7 +146,6 @@ function must<T extends Element>(selector: string): T {
|
||||
function main() {
|
||||
const board = must<HTMLElement>(".board");
|
||||
const masterLamp = must<HTMLElement>("#master-lamp");
|
||||
const verdict = must<HTMLElement>("#master-verdict");
|
||||
const version = must<HTMLElement>("#version");
|
||||
|
||||
const master = moduleRefs("master");
|
||||
@@ -193,10 +190,9 @@ function main() {
|
||||
|
||||
if (probe.kind === "unreachable") {
|
||||
masterLamp.dataset.state = "unreachable";
|
||||
verdict.dataset.tone = "fault";
|
||||
verdict.textContent = `daemon unreachable — ${probe.detail}`;
|
||||
version.textContent = "v —";
|
||||
setModule(master, "unreachable", "fault", "down");
|
||||
// the outage detail lives on the board's arr module — the header has no verdict line
|
||||
setModule(master, "unreachable", "fault", "down", probe.detail);
|
||||
for (const refs of Object.values(checks)) {
|
||||
setModule(refs, "off", "idle", "no signal");
|
||||
}
|
||||
@@ -210,8 +206,6 @@ function main() {
|
||||
const degraded = report.status === "degraded";
|
||||
|
||||
masterLamp.dataset.state = degraded ? "degraded" : "ok";
|
||||
verdict.dataset.tone = degraded ? "warn" : "ok";
|
||||
verdict.textContent = degraded ? "chain degraded" : "all signals nominal";
|
||||
version.textContent = `v${report.version}`;
|
||||
|
||||
setModule(master, degraded ? "degraded" : "ok", degraded ? "warn" : "ok", report.status);
|
||||
@@ -717,6 +711,37 @@ function chip(text: string, extra?: (chip: HTMLSpanElement) => void): HTMLSpanEl
|
||||
return span;
|
||||
}
|
||||
|
||||
/** §4.2 state ramp on a media-state chip; the word carries, colour accelerates. */
|
||||
function mediaStateChip(state: string, wanted: boolean): HTMLSpanElement {
|
||||
return chip(state, (span) => {
|
||||
span.dataset.movieState = state;
|
||||
span.dataset.wanted = String(wanted);
|
||||
});
|
||||
}
|
||||
|
||||
/** The ramp read off N/M itself: green when full, amber while a gap remains. */
|
||||
function countsTone(available: number, wanted: number): "complete" | "partial" | null {
|
||||
if (wanted === 0) {
|
||||
return null;
|
||||
}
|
||||
return available >= wanted ? "complete" : "partial";
|
||||
}
|
||||
|
||||
function countsChip(
|
||||
label: string,
|
||||
available: number,
|
||||
wanted: number,
|
||||
ariaLabel: string,
|
||||
): HTMLSpanElement {
|
||||
return chip(label, (span) => {
|
||||
const tone = countsTone(available, wanted);
|
||||
if (tone !== null) {
|
||||
span.dataset.counts = tone;
|
||||
}
|
||||
span.setAttribute("aria-label", ariaLabel);
|
||||
});
|
||||
}
|
||||
|
||||
function rowTitle(title: string, year: number | null): HTMLElement {
|
||||
const wrap = document.createElement("span");
|
||||
wrap.className = "row-id";
|
||||
@@ -875,11 +900,7 @@ function libraryRow(
|
||||
const { item, row, body, chips } = richRow();
|
||||
const root = roots.find((candidate) => candidate.id === movie.root_id);
|
||||
chips.append(chip(root ? root.audience : `root ${movie.root_id}`));
|
||||
chips.append(
|
||||
chip(movie.state, (span) => {
|
||||
span.dataset.movieState = movie.state;
|
||||
}),
|
||||
);
|
||||
chips.append(mediaStateChip(movie.state, movie.wanted));
|
||||
// §5.7 honesty: a waived import is never presented as a clean match
|
||||
const waiver = waiverLabel(movie.waiver);
|
||||
if (waiver !== null) {
|
||||
@@ -1288,46 +1309,6 @@ function externalLink(label: string, href: string): HTMLAnchorElement {
|
||||
return link;
|
||||
}
|
||||
|
||||
/** One top-billed cast member, linking out to their TMDB page (§9.6). */
|
||||
function castItem(member: MetadataCastMember): HTMLLIElement {
|
||||
const item = document.createElement("li");
|
||||
item.className = "cast-item";
|
||||
const link = document.createElement("a");
|
||||
link.className = "cast-link";
|
||||
link.href = tmdbPersonLink(member.tmdb_id);
|
||||
link.target = "_blank";
|
||||
link.rel = "noreferrer";
|
||||
const photo = document.createElement("span");
|
||||
photo.className = "cast-photo";
|
||||
const image = tmdbImage(member.profile_path, "w185");
|
||||
if (image !== null) {
|
||||
const img = document.createElement("img");
|
||||
img.src = image;
|
||||
img.alt = "";
|
||||
img.loading = "lazy";
|
||||
// hotlinked art can 404 (§9.6); fall back to the same empty frame a
|
||||
// person with no photo gets, rather than a broken-image glyph.
|
||||
img.addEventListener("error", () => {
|
||||
img.remove();
|
||||
photo.classList.add("cast-photo-none");
|
||||
});
|
||||
photo.append(img);
|
||||
} else {
|
||||
photo.classList.add("cast-photo-none");
|
||||
}
|
||||
const name = document.createElement("span");
|
||||
name.className = "cast-name";
|
||||
name.textContent = member.name;
|
||||
const character = document.createElement("span");
|
||||
character.className = "cast-character readout dim";
|
||||
character.textContent = member.character;
|
||||
link.setAttribute("aria-label", `${member.name} as ${member.character} — on TMDB`);
|
||||
link.append(photo, name, character);
|
||||
link.title = `${member.name} — ${member.character}`;
|
||||
item.append(link);
|
||||
return item;
|
||||
}
|
||||
|
||||
function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
|
||||
const view = must<HTMLElement>("#movie");
|
||||
const deckEl = must<HTMLElement>("#deck");
|
||||
@@ -1349,8 +1330,6 @@ function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
|
||||
const sweep = must<HTMLButtonElement>("#movie-sweep");
|
||||
const remove = must<HTMLButtonElement>("#movie-remove");
|
||||
const removeWrap = must<HTMLElement>("#remove-panel");
|
||||
const castSection = must<HTMLElement>("#movie-cast");
|
||||
const castRows = must<HTMLUListElement>("#rows-cast");
|
||||
const filesSection = must<HTMLElement>("#movie-files");
|
||||
const diskCount = must<HTMLElement>("#count-disk");
|
||||
const diskRows = must<HTMLUListElement>("#rows-disk");
|
||||
@@ -1456,11 +1435,7 @@ function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
|
||||
chipsEl.replaceChildren();
|
||||
const root = roots.find((candidate) => candidate.id === movie.root_id);
|
||||
chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`));
|
||||
chipsEl.append(
|
||||
chip(movie.state, (span) => {
|
||||
span.dataset.movieState = movie.state;
|
||||
}),
|
||||
);
|
||||
chipsEl.append(mediaStateChip(movie.state, movie.wanted));
|
||||
// §5.7 honesty: a waived import is never presented as a clean match
|
||||
const waiver = waiverLabel(movie.waiver);
|
||||
if (waiver !== null) {
|
||||
@@ -1589,8 +1564,6 @@ function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
|
||||
taglineEl.hidden = true;
|
||||
overviewEl.hidden = true;
|
||||
actionsEl.replaceChildren();
|
||||
castSection.hidden = true;
|
||||
castRows.replaceChildren();
|
||||
}
|
||||
|
||||
async function loadMetadata(id: number, ticket: number) {
|
||||
@@ -1660,12 +1633,6 @@ function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
|
||||
externalLink("rotten tomatoes", rottenTomatoesSearch(current.title, current.year)),
|
||||
);
|
||||
}
|
||||
if (detail.cast.length > 0 && current !== null) {
|
||||
castSection.hidden = false;
|
||||
for (const member of detail.cast) {
|
||||
castRows.append(castItem(member));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- on disk: ffprobe truth and honest waivers (§5.6, §5.7) ---- */
|
||||
@@ -2625,12 +2592,12 @@ function seriesRow(
|
||||
chips.append(chip(root ? root.audience : `root ${series.root_id}`));
|
||||
if (series.wanted_episodes > 0) {
|
||||
chips.append(
|
||||
chip(`${series.available_episodes}/${series.wanted_episodes} eps`, (span) => {
|
||||
span.setAttribute(
|
||||
"aria-label",
|
||||
`${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`,
|
||||
);
|
||||
}),
|
||||
countsChip(
|
||||
`${series.available_episodes}/${series.wanted_episodes} eps`,
|
||||
series.available_episodes,
|
||||
series.wanted_episodes,
|
||||
`${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (series.blocked) {
|
||||
@@ -2737,12 +2704,12 @@ function seriesCard(
|
||||
const chips = [chip(root ? root.audience : `root ${series.root_id}`)];
|
||||
if (series.wanted_episodes > 0) {
|
||||
chips.push(
|
||||
chip(`${series.available_episodes}/${series.wanted_episodes} eps`, (span) => {
|
||||
span.setAttribute(
|
||||
"aria-label",
|
||||
`${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`,
|
||||
);
|
||||
}),
|
||||
countsChip(
|
||||
`${series.available_episodes}/${series.wanted_episodes} eps`,
|
||||
series.available_episodes,
|
||||
series.wanted_episodes,
|
||||
`${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (series.blocked) {
|
||||
@@ -2769,11 +2736,7 @@ function movieCard(
|
||||
): HTMLLIElement {
|
||||
const root = roots.find((candidate) => candidate.id === movie.root_id);
|
||||
const chips = [chip(root ? root.audience : `root ${movie.root_id}`)];
|
||||
chips.push(
|
||||
chip(movie.state, (span) => {
|
||||
span.dataset.movieState = movie.state;
|
||||
}),
|
||||
);
|
||||
chips.push(mediaStateChip(movie.state, movie.wanted));
|
||||
// §5.7 honesty: a waived import is never presented as a clean match
|
||||
const waiver = waiverLabel(movie.waiver);
|
||||
if (waiver !== null) {
|
||||
@@ -2871,7 +2834,7 @@ function tvReleasesMain(): TvReleasesView {
|
||||
},
|
||||
};
|
||||
|
||||
async function load() {
|
||||
async function load(sweepIfEmpty = false) {
|
||||
const current = request;
|
||||
if (!current) {
|
||||
return;
|
||||
@@ -2890,7 +2853,12 @@ function tvReleasesMain(): TvReleasesView {
|
||||
return;
|
||||
}
|
||||
if (!paintBuckets(dom, outcome.releases, actions)) {
|
||||
setStatus("no releases indexed yet — search indexers queues a targeted sweep");
|
||||
// issue 167: an empty deck sweeps on open instead of describing a sweep
|
||||
if (sweepIfEmpty) {
|
||||
startSweep(current);
|
||||
} else {
|
||||
setStatus("no releases indexed yet — re-search queues a targeted sweep");
|
||||
}
|
||||
return;
|
||||
}
|
||||
setStatus(null);
|
||||
@@ -2935,11 +2903,8 @@ function tvReleasesMain(): TvReleasesView {
|
||||
}, TV_SWEEP_POLL_MS);
|
||||
}
|
||||
|
||||
sweep.addEventListener("click", () => {
|
||||
const current = request;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
/** Queue a targeted sweep, then watch for its releases to land (§6.2). */
|
||||
function startSweep(current: TvDeckRequest) {
|
||||
sweep.disabled = true;
|
||||
void (async () => {
|
||||
const outcome = await current.target.search();
|
||||
@@ -2955,6 +2920,14 @@ function tvReleasesMain(): TvReleasesView {
|
||||
window.clearTimeout(pollTimer);
|
||||
watchSweep(current, sequence);
|
||||
})();
|
||||
}
|
||||
|
||||
sweep.addEventListener("click", () => {
|
||||
const current = request;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
startSweep(current);
|
||||
});
|
||||
|
||||
function open(next: TvDeckRequest) {
|
||||
@@ -2966,7 +2939,7 @@ function tvReleasesMain(): TvReleasesView {
|
||||
clearBuckets(dom);
|
||||
sweep.disabled = false;
|
||||
back.focus();
|
||||
void load();
|
||||
void load(true);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
@@ -3033,8 +3006,6 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
const taglineEl = must<HTMLElement>("#series-tagline");
|
||||
const overviewEl = must<HTMLElement>("#series-overview");
|
||||
const actionsEl = must<HTMLElement>("#series-actions");
|
||||
const castSection = must<HTMLElement>("#series-cast");
|
||||
const castRows = must<HTMLUListElement>("#rows-series-cast");
|
||||
const statusEl = must<HTMLElement>("#series-status");
|
||||
const seasonsList = must<HTMLUListElement>("#rows-seasons");
|
||||
|
||||
@@ -3074,12 +3045,12 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
const root = roots.find((candidate) => candidate.id === current.root_id);
|
||||
chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`));
|
||||
chipsEl.append(
|
||||
chip(`${current.available_episodes}/${current.wanted_episodes} eps`, (span) => {
|
||||
span.setAttribute(
|
||||
"aria-label",
|
||||
`${current.available_episodes} of ${current.wanted_episodes} wanted episodes on disk`,
|
||||
);
|
||||
}),
|
||||
countsChip(
|
||||
`${current.available_episodes}/${current.wanted_episodes} eps`,
|
||||
current.available_episodes,
|
||||
current.wanted_episodes,
|
||||
`${current.available_episodes} of ${current.wanted_episodes} wanted episodes on disk`,
|
||||
),
|
||||
);
|
||||
chipsEl.append(
|
||||
chip(current.status, (span) => {
|
||||
@@ -3104,8 +3075,6 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
taglineEl.hidden = true;
|
||||
overviewEl.hidden = true;
|
||||
actionsEl.replaceChildren();
|
||||
castSection.hidden = true;
|
||||
castRows.replaceChildren();
|
||||
}
|
||||
|
||||
async function loadMetadata(id: number, ticket: number) {
|
||||
@@ -3184,22 +3153,16 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
externalLink("rotten tomatoes", rottenTomatoesSearch(series.title, series.year)),
|
||||
);
|
||||
}
|
||||
if (detail.cast.length > 0) {
|
||||
castSection.hidden = false;
|
||||
for (const member of detail.cast) {
|
||||
castRows.append(castItem(member));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
|
||||
const counts = seasonCounts(season);
|
||||
return chip(`${counts.available}/${counts.wanted} on disk`, (span) => {
|
||||
span.setAttribute(
|
||||
"aria-label",
|
||||
`${counts.available} of ${counts.wanted} wanted episodes on disk`,
|
||||
);
|
||||
});
|
||||
return countsChip(
|
||||
`${counts.available}/${counts.wanted} on disk`,
|
||||
counts.available,
|
||||
counts.wanted,
|
||||
`${counts.available} of ${counts.wanted} wanted episodes on disk`,
|
||||
);
|
||||
}
|
||||
|
||||
function seasonRow(season: ApiSeason): HTMLLIElement {
|
||||
@@ -3264,27 +3227,6 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
const space = document.createElement("span");
|
||||
space.className = "season-space";
|
||||
|
||||
const searchBtn = document.createElement("button");
|
||||
searchBtn.type = "button";
|
||||
searchBtn.className = "control";
|
||||
searchBtn.textContent = "search";
|
||||
searchBtn.addEventListener("click", () => {
|
||||
const currentId = seriesId;
|
||||
if (currentId === null) {
|
||||
return;
|
||||
}
|
||||
searchBtn.disabled = true;
|
||||
void (async () => {
|
||||
const outcome = await seasonTarget(currentId, season.number).search();
|
||||
searchBtn.disabled = false;
|
||||
if (outcome.kind === "error") {
|
||||
setStatus(`search failed — ${outcome.detail}`, "fault");
|
||||
return;
|
||||
}
|
||||
setStatus(`season ${PAD_TWO(season.number)} sweep queued — results land in its deck`);
|
||||
})();
|
||||
});
|
||||
|
||||
const deckBtn = document.createElement("button");
|
||||
deckBtn.type = "button";
|
||||
deckBtn.className = "control";
|
||||
@@ -3317,7 +3259,7 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
}),
|
||||
);
|
||||
}
|
||||
line.append(space, searchBtn, deckBtn);
|
||||
line.append(space, deckBtn);
|
||||
item.append(line);
|
||||
|
||||
const episodes = document.createElement("ul");
|
||||
@@ -3349,11 +3291,7 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
|
||||
const chips = document.createElement("span");
|
||||
chips.className = "row-chips ep-chips";
|
||||
chips.append(
|
||||
chip(episode.state, (span) => {
|
||||
span.dataset.movieState = episode.state;
|
||||
}),
|
||||
);
|
||||
chips.append(mediaStateChip(episode.state, episode.wanted));
|
||||
// issue 122: gone upstream while its file remained — a conflict, not a state
|
||||
if (episode.vanished) {
|
||||
chips.append(
|
||||
@@ -3411,24 +3349,6 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
});
|
||||
actions.append(want);
|
||||
|
||||
const searchBtn = document.createElement("button");
|
||||
searchBtn.type = "button";
|
||||
searchBtn.className = "control";
|
||||
searchBtn.textContent = "search";
|
||||
searchBtn.addEventListener("click", () => {
|
||||
searchBtn.disabled = true;
|
||||
void (async () => {
|
||||
const outcome = await episodeTarget(episode.id).search();
|
||||
searchBtn.disabled = false;
|
||||
if (outcome.kind === "error") {
|
||||
setStatus(`search failed — ${outcome.detail}`, "fault");
|
||||
return;
|
||||
}
|
||||
setStatus(`S${PAD_TWO(seasonNumber)}E${PAD_TWO(episode.number)} sweep queued`);
|
||||
})();
|
||||
});
|
||||
actions.append(searchBtn);
|
||||
|
||||
const deckBtn = document.createElement("button");
|
||||
deckBtn.type = "button";
|
||||
deckBtn.className = "control";
|
||||
|
||||
@@ -4,15 +4,6 @@
|
||||
|
||||
import { type ActionOutcome, errorDetail } from "./releases";
|
||||
|
||||
/** One of the top-billed cast members (§9.6). */
|
||||
export interface MetadataCastMember {
|
||||
tmdb_id: number;
|
||||
name: string;
|
||||
character: string;
|
||||
/** Path fragment exactly as TMDB sends it; the browser composes the URL. */
|
||||
profile_path: string | null;
|
||||
}
|
||||
|
||||
/** The one trailer worth showing, resolved by the §9.6 rule in arr-meta. */
|
||||
export interface MetadataTrailer {
|
||||
youtube_key: string;
|
||||
@@ -35,7 +26,6 @@ export interface MovieMetadata {
|
||||
vote_count: number;
|
||||
homepage: string | null;
|
||||
imdb_id: string | null;
|
||||
cast: MetadataCastMember[];
|
||||
trailer: MetadataTrailer | null;
|
||||
}
|
||||
|
||||
@@ -129,10 +119,6 @@ export function tvdbLink(tvdbId: number): string {
|
||||
return `https://www.thetvdb.com/dereferrer/series/${tvdbId}`;
|
||||
}
|
||||
|
||||
export function tmdbPersonLink(tmdbId: number): string {
|
||||
return `https://www.themoviedb.org/person/${tmdbId}`;
|
||||
}
|
||||
|
||||
export function imdbLink(imdbId: string): string {
|
||||
return `https://www.imdb.com/title/${imdbId}/`;
|
||||
}
|
||||
|
||||
+1
-2
@@ -2,7 +2,7 @@
|
||||
// release schemas — same reasoning as search.ts: the generated client
|
||||
// (src/api/) is uncommitted, so CI's tsc cannot see it.
|
||||
|
||||
import type { MetadataCastMember, MetadataTrailer } from "./movie";
|
||||
import type { MetadataTrailer } from "./movie";
|
||||
import type { ActionOutcome, MovieRelease, ReleasesOutcome, WaiveOutcome } from "./releases";
|
||||
import { errorDetail, probedAttributeTags, waiverOverride } from "./releases";
|
||||
|
||||
@@ -259,7 +259,6 @@ export interface SeriesMetadata {
|
||||
homepage: string | null;
|
||||
/** §9.6 links out to TVDB for series; absent when the id is unknown. */
|
||||
tvdb_id: number | null;
|
||||
cast: MetadataCastMember[];
|
||||
trailer: MetadataTrailer | null;
|
||||
}
|
||||
|
||||
|
||||
+64
-133
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user