From 24958957172538f3cd610b3b57bf79760c11e85f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:28:05 +0100 Subject: [PATCH 01/11] docs: record five rulings from the deployed build --- DESIGN.md | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) 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. From 8f7373d73dd569a0ab4cb39073bcd123a2a99bd3 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:40:44 +0100 Subject: [PATCH 02/11] fix(core): auto-track skips the seeding refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A series' first metadata refresh reveals its whole back catalogue, and apply_auto_track flagged every season as new — adding Rick and Morty tracked S01-S09 and wanted 91 episodes (#160). Per DESIGN.md 4.1 the rule applies from the second refresh onward. The caller passes whether metadata_refreshed_at is set; arr-core stays IO-free and decides. Wired through the daemon's daily refresh and the API's create-season endpoint. --- ...45d048a8c44efd78b75e08e74299fed072ea.json} | 10 ++- ...f5d6b78e7844476731b309a3859287ff8c2f.json} | 10 ++- ...4bebbc1a3d80c1304ed1fb790ccbfc6cc420.json} | 10 ++- crates/arr-api/src/series.rs | 55 ++++++++++++++-- crates/arr-core/src/tracking.rs | 49 ++++++++++++-- crates/arr-daemon/src/series_refresh.rs | 65 +++++++++++++++++-- 6 files changed, 176 insertions(+), 23 deletions(-) rename .sqlx/{query-cf040af066e35e9bcb969c84ac180784d5bc9a2bf0630cd6fef2bb8f4cb467b5.json => query-3b518839d694b872f83005ca059d45d048a8c44efd78b75e08e74299fed072ea.json} (87%) rename .sqlx/{query-9f0164d699ffd66f31e7916827c81e01d283f35bb91ec2f34f63240792c01152.json => query-9755322057875ad786326cf533bff5d6b78e7844476731b309a3859287ff8c2f.json} (82%) rename .sqlx/{query-ab536da7864e712754f1742e1f1f67d1d5cef884dafcaae1ae9ce498fc852e32.json => query-fd340665e0593bf692f0ffbe60ba4bebbc1a3d80c1304ed1fb790ccbfc6cc420.json} (87%) 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-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-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/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index fa480c0..ea32782 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? }; @@ -765,6 +768,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 +796,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 +1467,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 +1489,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 +1523,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>( 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: From 2c293e7f6003d5295abfd544424dc87f40aa32a3 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:59:18 +0100 Subject: [PATCH 03/11] fix(api): episode rows need a token beyond series title Per amended DESIGN.md 9.2, an episode row now also requires at least one query token to match the episode title on its own, so a series-title-only query lists the series and no episodes. --- ...0a7869d1104dfd80dc3a568ef134c4659ca9.json} | 6 ++-- crates/arr-api/src/search.rs | 31 +++++++++++++++---- 2 files changed, 28 insertions(+), 9 deletions(-) rename .sqlx/{query-2bc048c0fca4822eb38befbe3091f44f961fafb79432d4771aa48e36d32bf2a2.json => query-80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9.json} (83%) 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/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] From 4d5cc120d18903e3d577f34c859d24c6f6a8d08b Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 12:09:52 +0100 Subject: [PATCH 04/11] fix(api): newest-first season and episode order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DESIGN.md §9.6 now puts the current season at the top: seasons descend by number within a series (season 0 lands last under plain numeric descending) and episodes descend within each season. Changed the two queries in load_seasons; the SPA renders this order as given. arr-compat checked and untouched: it reads seasons with its own ORDER BY number query straight from the database, never through arr-api, and SeasonResource carries season_number, which Jellyseerr matches on rather than position. Sonarr's real API also returns seasons ascending, so the shim keeps the contract it emulates. The existing vanished-flag test indexed seasons positionally; it now looks them up by number so it tests the flag, not the order. New test asserts [2, 1, 0] for seasons and descending episodes within each. .sqlx regenerated via just db-prepare. just ci green locally. --- ...79a0e57d6357ab9b278503cdf8fb5252886a.json} | 4 +- ...3c5665c865f9d3d3699105790fe09d8b3fc9.json} | 4 +- crates/arr-api/src/series.rs | 80 ++++++++++++++++++- 3 files changed, 80 insertions(+), 8 deletions(-) rename .sqlx/{query-e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187.json => query-580e3bf81062d253fd08c9a4e1a879a0e57d6357ab9b278503cdf8fb5252886a.json} (92%) rename .sqlx/{query-583b48cc8a0c618294361f606a9c8a4b3a42000c96343ff9a5490bb518700f81.json => query-f52980596624a03ae4c704926a443c5665c865f9d3d3699105790fe09d8b3fc9.json} (89%) 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-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/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index ea32782..bace102 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -655,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)?) @@ -663,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)?) @@ -1637,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; From 1a4394cf0d4965fc9647cbbbd2ff5aab512281fe Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 12:11:08 +0100 Subject: [PATCH 05/11] feat(web): one control geometry for buttons and chips Buttons, chips, toggles and the search input converge on --control-h 2rem with --text-xs shared, and .control becomes an inline-flex so anchor text centres in the title-detail action bar. Chip colour is untouched (#165 owns it). Verified in agent-browser at 1440px, 46rem and 40rem; the release deck keeps its column widths and does not scroll horizontally. --- .impeccable/design.json | 6 +++--- web/src/style.css | 18 ++++++++++-------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.impeccable/design.json b/.impeccable/design.json index 5735401..cc14e9b 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", @@ -229,7 +229,7 @@ "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", diff --git a/web/src/style.css b/web/src/style.css index fd954c3..8f358dc 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -94,6 +94,7 @@ --space-12: 3rem; --radius: 4px; + --control-h: 2rem; --ease-out: cubic-bezier(0.25, 1, 0.5, 1); } @@ -442,10 +443,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 +501,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 +638,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); @@ -996,7 +1000,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 +1179,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 +1377,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); } @@ -1654,7 +1657,6 @@ body { } .movie-controls .control { - min-height: 2.25rem; padding: 0 var(--space-3); } From 7d4ddb7331161fff82528361cc5e1d69330a38e5 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 12:34:00 +0100 Subject: [PATCH 06/11] refactor(web): drop cast from title detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DESIGN.md §9.6 no longer carries cast (#163). Removes the cast sections, the MetadataCastMember schema and field from both metadata responses, and every .cast-* rule. arr-meta keeps CastMember and the credits append; the TMDB call is unchanged. --- crates/arr-api/src/lib.rs | 2 +- crates/arr-api/src/metadata.rs | 34 --------------- web/index.html | 17 +------- web/src/main.ts | 62 --------------------------- web/src/movie.ts | 14 ------- web/src/series.ts | 3 +- web/src/style.css | 77 ---------------------------------- 7 files changed, 3 insertions(+), 206 deletions(-) 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/web/index.html b/web/index.html index 4523f83..4db0642 100644 --- a/web/index.html +++ b/web/index.html @@ -203,8 +203,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 +270,6 @@ - - - -
    diff --git a/web/src/main.ts b/web/src/main.ts index 6f8bdb1..85f90f6 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, @@ -1288,46 +1286,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 +1307,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"); @@ -1589,8 +1545,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 +1614,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) ---- */ @@ -3033,8 +2981,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"); @@ -3104,8 +3050,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,12 +3128,6 @@ 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 { 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..e6c002b 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1679,79 +1679,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; @@ -1876,10 +1803,6 @@ body { flex: 1; } - .cast-grid { - grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr)); - } - .board { padding: var(--space-6) var(--space-4); } From 2dbb0f48f73bfdc43f096cb0856f364eb2c6574e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 13:57:17 +0100 Subject: [PATCH 07/11] feat(web): state ramp on status and counts chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per amended DESIGN.md §4.2: green --signal-ok for on disk/complete, amber --signal-warn for wanted-but-missing, violet --status-airing for downloading, neutral for untracked and waiting. Stars go amber. Words stay in every chip; verdict chips untouched. --- web/src/main.ts | 103 ++++++++++++++++++++++++++-------------------- web/src/style.css | 56 +++++++++++++++++-------- 2 files changed, 98 insertions(+), 61 deletions(-) diff --git a/web/src/main.ts b/web/src/main.ts index 85f90f6..7bac268 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -715,6 +715,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"; @@ -873,11 +904,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) { @@ -1412,11 +1439,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) { @@ -2573,12 +2596,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) { @@ -2685,12 +2708,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) { @@ -2717,11 +2740,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) { @@ -3020,12 +3039,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) => { @@ -3132,12 +3151,12 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV 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 { @@ -3287,11 +3306,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( diff --git a/web/src/style.css b/web/src/style.css index f2cff9a..6f4e96e 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -61,8 +61,9 @@ --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. --status-airing doubles as the downloading + hue; the other four stay declared as the design.json mirror. */ --status-airing: oklch(0.78 0.14 305); --status-incomplete: oklch(0.72 0.09 305); --status-waiting: oklch(0.64 0.045 305); @@ -665,32 +666,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 */ @@ -1598,10 +1619,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 */ From d1d6b4f9a0503eae93f316b32a64e34ab110af7e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 14:36:18 +0100 Subject: [PATCH 08/11] feat(web): lamp-only header on the content width Drops #master-verdict entirely - element, .rail-verdict rules and every line that wrote into it. The lamp is the only header signal; the unreachable detail moves onto the board's arr module so nothing is lost. Rail contents move into .rail-inner capped at the deck's 68rem and centred, leaving the panel full-bleed. Committed by the blitz orchestrator: the authoring session wedged with the work complete on disk and stopped accepting input. --- .impeccable/design.json | 6 ++-- web/index.html | 63 +++++++++++++++++++++-------------------- web/src/main.ts | 8 ++---- web/src/style.css | 38 +++++++------------------ 4 files changed, 48 insertions(+), 67 deletions(-) diff --git a/.impeccable/design.json b/.impeccable/design.json index cc14e9b..ade1c4a 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -235,9 +235,9 @@ "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": { diff --git a/web/index.html b/web/index.html index 4523f83..1d8b2ed 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 — +
    diff --git a/web/src/main.ts b/web/src/main.ts index 6f8bdb1..864913a 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -148,7 +148,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 +192,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 +208,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); diff --git a/web/src/style.css b/web/src/style.css index 8f358dc..1f5d0a3 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -136,15 +136,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; @@ -160,24 +167,6 @@ 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; @@ -1770,7 +1759,7 @@ body { /* ---- small screens --------------------------------------------------- */ @media (max-width: 46rem) { - .rail { + .rail-inner { flex-wrap: wrap; gap: var(--space-3) var(--space-4); } @@ -1788,11 +1777,6 @@ body { white-space: nowrap; } - .rail-verdict { - flex-basis: 100%; - order: 3; - } - .rail-search { order: 4; flex-basis: 100%; From 0cb8111678d9de102d6b2632fbe75b05cc5d49f9 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 14:36:18 +0100 Subject: [PATCH 09/11] fix(web): pin the version readout to the rail edge --- web/src/style.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/src/style.css b/web/src/style.css index 1f5d0a3..2cd2e2b 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -171,6 +171,9 @@ body { 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 ---------------------------------------------------------- */ From fa5d876c0964af2c9d320d9b7958cba416029048 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 14:37:52 +0100 Subject: [PATCH 10/11] chore(web): sync design.json to the state ramp The token system still mirrored the pre-amendment palette and carried the rule 'Don't colour complete green or incomplete amber', which now contradicts DESIGN.md 4.2 and would have had the next session revert the ramp. Drops the four --status-* tokens no rule consumes any more; satisfaction reuses --signal-ok and a gap --signal-warn. Committed by the blitz orchestrator: the authoring session wedged. --- .impeccable/design.json | 4 ++-- web/src/style.css | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.impeccable/design.json b/.impeccable/design.json index cc14e9b..9117aa9 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -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)", @@ -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/web/src/style.css b/web/src/style.css index 6f4e96e..9dc8985 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -62,13 +62,10 @@ --score-6: oklch(0.8 0.17 150); /* library state ramp (§4.2): green satisfied, amber gap, violet active, - neutral nothing-happening. --status-airing doubles as the downloading - hue; the other four stay declared as the design.json mirror. */ + 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; From 8a672c1dcf19c70724cfa78c8dc01232e35bf261 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 14:42:29 +0100 Subject: [PATCH 11/11] feat(web): tv deck sweeps on open when empty Drop the standalone search button from season and episode rows; the deck is the one control. An empty deck fires a targeted sweep on open and reports progress in place; a deck with results never sweeps on open. re-search inside the deck forces a fresh sweep. --- web/index.html | 2 +- web/src/main.ts | 67 ++++++++++++++----------------------------------- 2 files changed, 20 insertions(+), 49 deletions(-) diff --git a/web/index.html b/web/index.html index 2c437a5..8891f4c 100644 --- a/web/index.html +++ b/web/index.html @@ -431,7 +431,7 @@

    —

    - +
    diff --git a/web/src/main.ts b/web/src/main.ts index aaabfa5..38bc21b 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -2834,7 +2834,7 @@ function tvReleasesMain(): TvReleasesView { }, }; - async function load() { + async function load(sweepIfEmpty = false) { const current = request; if (!current) { return; @@ -2853,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); @@ -2898,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(); @@ -2918,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) { @@ -2929,7 +2939,7 @@ function tvReleasesMain(): TvReleasesView { clearBuckets(dom); sweep.disabled = false; back.focus(); - void load(); + void load(true); } function hide() { @@ -3217,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"; @@ -3270,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"); @@ -3360,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";