Merge #140: name episodes in the attention queue

Closes #140
This commit is contained in:
Miguel Palhas
2026-08-23 19:52:48 +01:00
8 changed files with 262 additions and 139 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+105 -33
View File
@@ -100,6 +100,22 @@ pub struct AttentionQueues {
pub tv_needs_decision: Vec<SeriesAttention>,
}
/// 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<i64>,
/// Episodes that put the series in this queue.
pub episodes: Vec<i64>,
pub episodes: Vec<QueuedEpisode>,
/// Seasons whose pack grab failed and fell back to per-episode grabbing.
pub seasons: Vec<i64>,
pub seasons: Vec<QueuedSeason>,
}
#[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<SeriesAttention>,
series_id: i64,
tmdb_id: i64,
title: &str,
year: Option<i64>,
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<SeriesAttention>,
series_id: i64,
tmdb_id: i64,
title: String,
title: &str,
year: Option<i64>,
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<i64> =
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.
+22 -14
View File
@@ -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);
+15 -2
View File
@@ -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 {