diff --git a/.sqlx/query-29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708.json b/.sqlx/query-29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708.json new file mode 100644 index 0000000..92132e9 --- /dev/null +++ b/.sqlx/query-29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ", + "describe": { + "columns": [ + { + "name": "series_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tmdb_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "episode_id!: i64", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "season_number!: i64", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "episode_number!: i64", + "ordinal": 6, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708" +} diff --git a/.sqlx/query-cd09d4c34f0596319fa2ade088ea9009fa24e453f669bcc2357fdcd999b06193.json b/.sqlx/query-7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51.json similarity index 59% rename from .sqlx/query-cd09d4c34f0596319fa2ade088ea9009fa24e453f669bcc2357fdcd999b06193.json rename to .sqlx/query-7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51.json index 2dba66a..bdd7c6c 100644 --- a/.sqlx/query-cd09d4c34f0596319fa2ade088ea9009fa24e453f669bcc2357fdcd999b06193.json +++ b/.sqlx/query-7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id\n ", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n ", "describe": { "columns": [ { @@ -27,6 +27,11 @@ "name": "season_id!: i64", "ordinal": 4, "type_info": "Integer" + }, + { + "name": "season_number!: i64", + "ordinal": 5, + "type_info": "Integer" } ], "parameters": { @@ -37,8 +42,9 @@ false, false, true, + false, false ] }, - "hash": "cd09d4c34f0596319fa2ade088ea9009fa24e453f669bcc2357fdcd999b06193" + "hash": "7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51" } diff --git a/.sqlx/query-a36a611d7c587f1c1880113e334861867891d55dc8bb7fe79b1dc42e36054379.json b/.sqlx/query-a36a611d7c587f1c1880113e334861867891d55dc8bb7fe79b1dc42e36054379.json deleted file mode 100644 index 37c4946..0000000 --- a/.sqlx/query-a36a611d7c587f1c1880113e334861867891d55dc8bb7fe79b1dc42e36054379.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, e.id AS \"episode_id!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ", - "describe": { - "columns": [ - { - "name": "series_id!: i64", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "tmdb_id!: i64", - "ordinal": 1, - "type_info": "Integer" - }, - { - "name": "title!: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "year", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "episode_id!: i64", - "ordinal": 4, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false, - false, - false, - true, - true - ] - }, - "hash": "a36a611d7c587f1c1880113e334861867891d55dc8bb7fe79b1dc42e36054379" -} diff --git a/.sqlx/query-aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702.json b/.sqlx/query-aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702.json new file mode 100644 index 0000000..b730f17 --- /dev/null +++ b/.sqlx/query-aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ", + "describe": { + "columns": [ + { + "name": "series_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tmdb_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "episode_id!: i64", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "season_number!: i64", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "episode_number!: i64", + "ordinal": 6, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false + ] + }, + "hash": "aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702" +} diff --git a/.sqlx/query-b4b58a625ce7b57ba423d39296cb310c6b90c3feb283885b3573ee7666b1725b.json b/.sqlx/query-b4b58a625ce7b57ba423d39296cb310c6b90c3feb283885b3573ee7666b1725b.json deleted file mode 100644 index 654e1a8..0000000 --- a/.sqlx/query-b4b58a625ce7b57ba423d39296cb310c6b90c3feb283885b3573ee7666b1725b.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ", - "describe": { - "columns": [ - { - "name": "series_id!: i64", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "tmdb_id!: i64", - "ordinal": 1, - "type_info": "Integer" - }, - { - "name": "title!: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "year", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "episode_id!: i64", - "ordinal": 4, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false, - false, - false, - true, - false - ] - }, - "hash": "b4b58a625ce7b57ba423d39296cb310c6b90c3feb283885b3573ee7666b1725b" -} diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 599b030..676ee55 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -100,6 +100,22 @@ pub struct AttentionQueues { pub tv_needs_decision: Vec, } +/// An episode that put its series in a TV attention queue, named for the +/// operator (`SxxEyy` comes from season and episode numbers, #140). +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct QueuedEpisode { + pub id: i64, + pub season_number: i64, + pub episode_number: i64, +} + +/// A season whose failed pack put its series in a TV attention queue. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct QueuedSeason { + pub id: i64, + pub number: i64, +} + /// A series in a TV attention queue, with what put it there. One row per /// series — the notification rolls up the same way (§9.5). #[derive(Debug, Clone, Serialize, ToSchema)] @@ -109,9 +125,9 @@ pub struct SeriesAttention { pub title: String, pub year: Option, /// Episodes that put the series in this queue. - pub episodes: Vec, + pub episodes: Vec, /// Seasons whose pack grab failed and fell back to per-episode grabbing. - pub seasons: Vec, + pub seasons: Vec, } #[derive(Debug, Clone, Serialize, ToSchema)] @@ -667,7 +683,9 @@ async fn tv_attention( let no_pt_rows = sqlx::query!( r#" SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", - s.title AS "title!: String", s.year, e.id AS "episode_id!: i64" + s.title AS "title!: String", s.year, + e.id AS "episode_id!: i64", + se.number AS "season_number!: i64", e.number AS "episode_number!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id @@ -688,13 +706,15 @@ async fn tv_attention( let episode_hard_fails = sqlx::query!( r#" SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", - s.title AS "title!: String", s.year, g.target_id AS "episode_id!: i64" + s.title AS "title!: String", s.year, + g.target_id AS "episode_id!: i64", + se.number AS "season_number!: i64", e.number AS "episode_number!: i64" FROM grabs g JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE g.state = 'failed' - GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id + GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number HAVING count(DISTINCT g.release_id) >= 2 "# ) @@ -703,12 +723,13 @@ async fn tv_attention( let season_pack_fails = sqlx::query!( r#" SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", - s.title AS "title!: String", s.year, g.target_id AS "season_id!: i64" + s.title AS "title!: String", s.year, + g.target_id AS "season_id!: i64", se.number AS "season_number!: i64" FROM grabs g JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id JOIN series s ON s.id = se.series_id WHERE g.state = 'failed' - GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id + GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number "# ) .fetch_all(database) @@ -720,9 +741,11 @@ async fn tv_attention( &mut tv_no_pt_source, row.series_id, row.tmdb_id, - row.title, + &row.title, row.year, row.episode_id, + row.season_number, + row.episode_number, ); } @@ -732,54 +755,93 @@ async fn tv_attention( &mut tv_needs_decision, row.series_id, row.tmdb_id, - row.title, + &row.title, row.year, row.episode_id, + row.season_number, + row.episode_number, ); } for row in season_pack_fails { - match tv_needs_decision - .iter_mut() - .find(|entry| entry.series_id == row.series_id) - { - Some(entry) => entry.seasons.push(row.season_id), - None => tv_needs_decision.push(SeriesAttention { - series_id: row.series_id, - tmdb_id: row.tmdb_id, - title: row.title, - year: row.year, - episodes: Vec::new(), - seasons: vec![row.season_id], - }), - } + merge_season( + &mut tv_needs_decision, + row.series_id, + row.tmdb_id, + &row.title, + row.year, + row.season_id, + row.season_number, + ); } Ok((tv_no_pt_source, tv_needs_decision)) } +/// One more qualifying season for its series, creating the series' entry on +/// first sight. +#[allow(clippy::too_many_arguments)] +fn merge_season( + entries: &mut Vec, + series_id: i64, + tmdb_id: i64, + title: &str, + year: Option, + season_id: i64, + season_number: i64, +) { + match entries + .iter_mut() + .find(|entry| entry.series_id == series_id) + { + Some(entry) => entry.seasons.push(QueuedSeason { + id: season_id, + number: season_number, + }), + None => entries.push(SeriesAttention { + series_id, + tmdb_id, + title: title.to_owned(), + year, + episodes: Vec::new(), + seasons: vec![QueuedSeason { + id: season_id, + number: season_number, + }], + }), + } +} + /// One more qualifying episode for its series, creating the series' entry on /// first sight. +#[allow(clippy::too_many_arguments)] fn merge_episode( entries: &mut Vec, series_id: i64, tmdb_id: i64, - title: String, + title: &str, year: Option, episode_id: i64, + season_number: i64, + episode_number: i64, ) { + let episode = QueuedEpisode { + id: episode_id, + season_number, + episode_number, + }; if let Some(entry) = entries .iter_mut() .find(|entry| entry.series_id == series_id) { - entry.episodes.push(episode_id); + entry.episodes.push(episode); return; } entries.push(SeriesAttention { series_id, tmdb_id, - title, + title: title.to_owned(), year, - episodes: vec![episode_id], + episodes: vec![episode], seasons: Vec::new(), }); } @@ -1350,21 +1412,31 @@ mod tests { .json() .await .expect("queues json"); + let episode_ids: Vec = + sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? ORDER BY number") + .bind(season_id) + .fetch_all(pool) + .await + .expect("episode ids"); + assert_eq!(queues["tv_no_pt_source"][0]["series_id"], series_id); assert_eq!( - queues["tv_no_pt_source"][0]["episodes"] - .as_array() - .map(Vec::len), - Some(2) + queues["tv_no_pt_source"][0]["episodes"], + serde_json::json!([ + { "id": episode_ids[0], "season_number": 1, "episode_number": 1 }, + { "id": episode_ids[1], "season_number": 1, "episode_number": 2 }, + ]) ); assert_eq!(queues["tv_needs_decision"][0]["series_id"], series_id); assert_eq!( queues["tv_needs_decision"][0]["episodes"], - serde_json::json!([episode_id]) + serde_json::json!([ + { "id": episode_id, "season_number": 1, "episode_number": 3 }, + ]) ); assert_eq!( queues["tv_needs_decision"][0]["seasons"], - serde_json::json!([season_id]) + serde_json::json!([{ "id": season_id, "number": 1 }]) ); // Blocking the series empties its no-PT-source lane. diff --git a/web/src/main.ts b/web/src/main.ts index 1638353..719eb78 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -575,6 +575,18 @@ function rowTitle(title: string, year: number | null): HTMLElement { return wrap; } +function seasonTag(seasonNumber: number): string { + return `S${two(seasonNumber)}`; +} + +function episodeTag(seasonNumber: number, episodeNumber: number): string { + return `${seasonTag(seasonNumber)}E${two(episodeNumber)}`; +} + +function two(value: number): string { + return value < 10 ? `0${value}` : String(value); +} + function libraryRow( movie: LibraryMovie, roots: Root[], @@ -1922,9 +1934,10 @@ function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableV } /** - * One TV lane entry: the series plus chips counting the episodes or - * seasons that put it there. The API carries internal row ids, not SxxEyy - * numbers, so counts are what can be named honestly. Inert — see above. + * One TV lane entry: the series plus one chip per episode or season that put + * it there, named `SxxEyy` so the operator knows what to act on. + * Inert — the release deck and overrides arrive with the series detail view + * (issues 129 and 39). */ function seriesAttentionRow(entry: SeriesAttention): HTMLLIElement { const item = document.createElement("li"); @@ -1932,21 +1945,16 @@ function seriesAttentionRow(entry: SeriesAttention): HTMLLIElement { row.className = "row"; const chips = document.createElement("span"); chips.className = "row-chips"; - if (entry.episodes.length > 0) { - const n = entry.episodes.length; + for (const episode of entry.episodes) { + const tag = episodeTag(episode.season_number, episode.episode_number); chips.append( - chip(`${n} ${n === 1 ? "episode" : "episodes"}`, (span) => { - span.setAttribute("aria-label", `${n} ${n === 1 ? "episode" : "episodes"} waiting`); + chip(tag, (span) => { + span.setAttribute("aria-label", `${tag} waiting`); }), ); } - if (entry.seasons.length > 0) { - const n = entry.seasons.length; - chips.append( - chip(`${n} ${n === 1 ? "season" : "seasons"}`, (span) => { - span.setAttribute("aria-label", `${n} ${n === 1 ? "season" : "seasons"} waiting`); - }), - ); + for (const season of entry.seasons) { + chips.append(chip(seasonTag(season.number))); } row.append(rowTitle(entry.title, entry.year), chips); item.append(row); diff --git a/web/src/queues.ts b/web/src/queues.ts index f14bda0..b0f190e 100644 --- a/web/src/queues.ts +++ b/web/src/queues.ts @@ -11,14 +11,27 @@ export interface AttentionMovie extends LibraryMovie { last_searched_at: string | null; } +/** An episode that put its series here, named for SxxEyy rendering. */ +export interface QueuedEpisode { + id: number; + season_number: number; + episode_number: number; +} + +/** A season whose failed pack put its series here. */ +export interface QueuedSeason { + id: number; + number: number; +} + /** A series in a TV attention queue, with what put it there (§9.5). */ export interface SeriesAttention { series_id: number; tmdb_id: number; title: string; year: number | null; - episodes: number[]; - seasons: number[]; + episodes: QueuedEpisode[]; + seasons: QueuedSeason[]; } export interface AttentionQueues {