feat(api): tv series in the attention queues endpoint

This commit is contained in:
Miguel Palhas
2026-08-23 16:44:16 +01:00
parent a1971ed82c
commit 24f6dab109
6 changed files with 395 additions and 1 deletions
+1
View File
@@ -326,6 +326,7 @@ mod tests {
"UpdateMovie",
"Release",
"AttentionQueues",
"SeriesAttention",
"Series",
] {
assert!(
+245
View File
@@ -93,6 +93,25 @@ pub struct MovieFile {
pub struct AttentionQueues {
pub no_pt_source: Vec<Movie>,
pub needs_decision: Vec<Movie>,
/// TV side (§9.5): series whose wanted episodes found no Portuguese-audio
/// source.
pub tv_no_pt_source: Vec<SeriesAttention>,
/// TV side: series put here by hard failures on episodes or season packs.
pub tv_needs_decision: Vec<SeriesAttention>,
}
/// 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)]
pub struct SeriesAttention {
pub series_id: i64,
pub tmdb_id: i64,
pub title: String,
pub year: Option<i64>,
/// Episodes that put the series in this queue.
pub episodes: Vec<i64>,
/// Seasons whose pack grab failed and fell back to per-episode grabbing.
pub seasons: Vec<i64>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -627,9 +646,107 @@ pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQu
let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title"#)
.fetch_all(pool(&state)?)
.await?;
let tv_no_pt_source =
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"
FROM episodes e
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
JOIN roots root ON root.id = s.root_id
WHERE root.audience = 'kids'
AND s.blocked = 0
AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0
AND NOT EXISTS (
SELECT 1 FROM episode_releases er
JOIN releases r ON r.id = er.release_id
WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')
)
ORDER BY se.number, e.number
"#
)
.fetch_all(pool(&state)?)
.await?;
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"
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
HAVING count(DISTINCT g.release_id) >= 2
"#
)
.fetch_all(pool(&state)?)
.await?;
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"
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
"#
)
.fetch_all(pool(&state)?)
.await?;
let mut tv_no_pt: Vec<SeriesAttention> = Vec::new();
for row in tv_no_pt_source {
match tv_no_pt.iter_mut().find(|entry| entry.series_id == row.series_id) {
Some(entry) => entry.episodes.push(row.episode_id),
None => tv_no_pt.push(SeriesAttention {
series_id: row.series_id,
tmdb_id: row.tmdb_id,
title: row.title,
year: row.year,
episodes: vec![row.episode_id],
seasons: Vec::new(),
}),
}
}
// The two hard-fail lanes share one queue; a series can arrive through
// both and is merged into one entry.
let mut tv_needs: Vec<SeriesAttention> = Vec::new();
for row in episode_hard_fails {
match tv_needs.iter_mut().find(|entry| entry.series_id == row.series_id) {
Some(entry) => entry.episodes.push(row.episode_id),
None => tv_needs.push(SeriesAttention {
series_id: row.series_id,
tmdb_id: row.tmdb_id,
title: row.title,
year: row.year,
episodes: vec![row.episode_id],
seasons: Vec::new(),
}),
}
}
for row in season_pack_fails {
match tv_needs.iter_mut().find(|entry| entry.series_id == row.series_id) {
Some(entry) => entry.seasons.push(row.season_id),
None => tv_needs.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],
}),
}
}
Ok(Json(AttentionQueues {
no_pt_source,
needs_decision,
tv_no_pt_source: tv_no_pt,
tv_needs_decision: tv_needs,
}))
}
@@ -723,6 +840,7 @@ pub async fn untag_owner(
#[cfg(test)]
mod tests {
use super::*;
use sqlx::Row;
use crate::{router, Upstreams};
async fn application() -> (tempfile::TempDir, AppState, String) {
@@ -1183,4 +1301,131 @@ mod tests {
assert_eq!(queues["no_pt_source"].as_array().map(Vec::len), Some(0));
assert_eq!(queues["needs_decision"][0]["id"], movie_id);
}
/// §9.5 TV: the attention queues report series, with the episodes and
/// seasons that put each one there.
#[tokio::test]
async fn attention_queues_report_tv_series() {
let (_dir, state, base) = application().await;
let pool = state.database().expect("database").pool();
let root_id: i64 =
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
.fetch_one(pool)
.await
.expect("kids tv root");
let series_id: i64 = sqlx::query(
"INSERT INTO series (tmdb_id, title, year, root_id) VALUES (1, 'Bluey', 2018, ?)
RETURNING id",
)
.bind(root_id)
.fetch_one(pool)
.await
.expect("series")
.get(0);
let season_id: i64 =
sqlx::query_scalar("INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id")
.bind(series_id)
.fetch_one(pool)
.await
.expect("season");
// Two wanted episodes whose every candidate was rejected for
// language — the §5.2 no-PT-source queue, TV side.
for number in [1, 2] {
sqlx::query(
"INSERT INTO episodes (season_id, number, title, wanted, state, search_attempts)
VALUES (?, ?, ?, 1, 'missing', 1)",
)
.bind(season_id)
.bind(number)
.bind(format!("Episode {number}"))
.execute(pool)
.await
.expect("episode");
}
// A season pack that hard-failed, plus an episode two different
// releases hard-failed on — both land in needs_decision.
sqlx::query(
"INSERT INTO episodes (season_id, number, title, wanted, state)
VALUES (?, 3, 'Episode 3', 1, 'missing')",
)
.bind(season_id)
.execute(pool)
.await
.expect("episode");
let episode_id: i64 =
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 3")
.bind(season_id)
.fetch_one(pool)
.await
.expect("episode id");
for (kind, guid, suffix) in [
("season", "pack", "pack"),
("episode", "first", "a"),
("episode", "second", "b"),
] {
let release_id: i64 = sqlx::query(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
VALUES (1, ?, 'release', 1, 'url', '{}', 'eligible') RETURNING id",
)
.bind(guid)
.fetch_one(pool)
.await
.expect("release")
.get(0);
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
VALUES (?, ?, ?, ?, 'failed')",
)
.bind(release_id)
.bind(kind)
.bind(if kind == "season" { season_id } else { episode_id })
.bind(format!("hash-{suffix}"))
.execute(pool)
.await
.expect("failed grab");
}
let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention"))
.await
.expect("queues")
.json()
.await
.expect("queues json");
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)
);
assert_eq!(queues["tv_needs_decision"][0]["series_id"], series_id);
assert_eq!(
queues["tv_needs_decision"][0]["episodes"],
serde_json::json!([episode_id])
);
assert_eq!(queues["tv_needs_decision"][0]["seasons"], serde_json::json!([season_id]));
// Blocking the series empties its no-PT-source lane.
sqlx::query("UPDATE series SET blocked = 1 WHERE id = ?")
.bind(series_id)
.execute(pool)
.await
.expect("block series");
let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention"))
.await
.expect("blocked queues")
.json()
.await
.expect("blocked queues json");
assert_eq!(
queues["tv_no_pt_source"].as_array().map(Vec::len),
Some(0)
);
assert_eq!(
queues["tv_needs_decision"].as_array().map(Vec::len),
Some(1),
"hard failures queue regardless of blocking"
);
}
}