feat(api): tv series in the attention queues endpoint
This commit is contained in:
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"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 ",
|
||||
"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": "season_id!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "cd09d4c34f0596319fa2ade088ea9009fa24e453f669bcc2357fdcd999b06193"
|
||||
}
|
||||
@@ -326,6 +326,7 @@ mod tests {
|
||||
"UpdateMovie",
|
||||
"Release",
|
||||
"AttentionQueues",
|
||||
"SeriesAttention",
|
||||
"Series",
|
||||
] {
|
||||
assert!(
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1
@@ -11,9 +11,21 @@ export interface AttentionMovie extends LibraryMovie {
|
||||
last_searched_at: string | null;
|
||||
}
|
||||
|
||||
/** 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[];
|
||||
}
|
||||
|
||||
export interface AttentionQueues {
|
||||
no_pt_source: AttentionMovie[];
|
||||
needs_decision: AttentionMovie[];
|
||||
tv_no_pt_source: SeriesAttention[];
|
||||
tv_needs_decision: SeriesAttention[];
|
||||
}
|
||||
|
||||
export type AttentionOutcome =
|
||||
@@ -66,7 +78,11 @@ export async function allowEnglishAudio(movieId: number): Promise<AllowEnglishOu
|
||||
return { kind: "done", searchQueued: true };
|
||||
}
|
||||
|
||||
/** Entries across both queues — the rail badge's number. */
|
||||
/** Entries across both queues — the rail badge's number.
|
||||
*
|
||||
* Deliberately movies only for now: the TV lanes are new and the QUEUES
|
||||
* deck does not render them yet (issue #126 covered the backend).
|
||||
*/
|
||||
export function attentionTotal(queues: AttentionQueues): number {
|
||||
return queues.no_pt_source.length + queues.needs_decision.length;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user