Merge #126: TV titles in the needs-a-decision queue

Closes #126
This commit is contained in:
Miguel Palhas
2026-08-23 16:58:08 +01:00
10 changed files with 912 additions and 22 deletions
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n 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.title, s.year, se.id\n ",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "season_id!: i64",
"ordinal": 3,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
false
]
},
"hash": "4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n 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 ",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "episode_id!: i64",
"ordinal": 3,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
true
]
},
"hash": "a071c1bedfdd1347713cac523cb1aa9522dc9a07fb5d681655622971398a0ff4"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n 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.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": "title!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "episode_id!: i64",
"ordinal": 3,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
false
]
},
"hash": "faa9e5cf0cf869161dd4878f6fb8707e378b7d9c6516d602afb49b1e30792207"
}
+1
View File
@@ -343,6 +343,7 @@ mod tests {
"UpdateMovie",
"Release",
"AttentionQueues",
"SeriesAttention",
"Series",
] {
assert!(
+290
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,12 +646,144 @@ 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, tv_needs_decision) = tv_attention(&state).await?;
Ok(Json(AttentionQueues {
no_pt_source,
needs_decision,
tv_no_pt_source,
tv_needs_decision,
}))
}
/// The TV lanes of the attention queues (§9.5): one entry per series with
/// the episodes and seasons that put it there. The two hard-fail conditions
/// share a lane; a series arriving through both is merged into one entry.
async fn tv_attention(
state: &AppState,
) -> Result<(Vec<SeriesAttention>, Vec<SeriesAttention>), ApiError> {
let database = pool(state)?;
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"
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(database)
.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(database)
.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(database)
.await?;
let mut tv_no_pt_source: Vec<SeriesAttention> = Vec::new();
for row in no_pt_rows {
merge_episode(
&mut tv_no_pt_source,
row.series_id,
row.tmdb_id,
row.title,
row.year,
row.episode_id,
);
}
let mut tv_needs_decision: Vec<SeriesAttention> = Vec::new();
for row in episode_hard_fails {
merge_episode(
&mut tv_needs_decision,
row.series_id,
row.tmdb_id,
row.title,
row.year,
row.episode_id,
);
}
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],
}),
}
}
Ok((tv_no_pt_source, tv_needs_decision))
}
/// One more qualifying episode for its series, creating the series' entry on
/// first sight.
fn merge_episode(
entries: &mut Vec<SeriesAttention>,
series_id: i64,
tmdb_id: i64,
title: String,
year: Option<i64>,
episode_id: i64,
) {
if let Some(entry) = entries
.iter_mut()
.find(|entry| entry.series_id == series_id)
{
entry.episodes.push(episode_id);
return;
}
entries.push(SeriesAttention {
series_id,
tmdb_id,
title,
year,
episodes: vec![episode_id],
seasons: Vec::new(),
});
}
#[utoipa::path(
get, path = "/api/movies/{movie_id}/owners", tag = "movies",
params(("movie_id" = i64, Path, description = "Movie row id")),
@@ -724,6 +875,7 @@ pub async fn untag_owner(
mod tests {
use super::*;
use crate::{router, Upstreams};
use sqlx::Row;
async fn application() -> (tempfile::TempDir, AppState, String) {
let dir = tempfile::tempdir().expect("tempdir");
@@ -1183,4 +1335,142 @@ 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 (series_id, season_id, episode_id) = seed_queued_series(pool).await;
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"
);
}
/// One series hitting all three §9.5 TV entry conditions: two wanted,
/// searched episodes whose every candidate was rejected for language; a
/// season pack that hard-failed; and an episode two different releases
/// hard-failed on.
async fn seed_queued_series(pool: &sqlx::SqlitePool) -> (i64, i64, i64) {
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");
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");
}
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");
}
(series_id, season_id, episode_id)
}
}
+358 -21
View File
@@ -1,11 +1,14 @@
//! §9.5 *needs a decision* → the operator alone: a movie entered the
//! no-PT-source queue, or hard-failed twice on different releases (the same
//! two queues `GET /api/queues/attention` reports, §9.3).
//! §9.5 *needs a decision* → the operator alone: a movie or series entered
//! the no-PT-source queue, or hard-failed twice on different releases (the
//! same queues `GET /api/queues/attention` reports, §9.3).
//!
//! Edge-triggered: a movie notifies once when it enters either queue, and is
//! forgotten once it leaves both, so a future re-entry notifies again. The
//! notified set is in-memory and transient — a restart re-notifies whatever
//! is currently queued, same tradeoff `ImportAction`'s probe cache makes.
//! Edge-triggered per title: it notifies once when the title enters either
//! queue, and is forgotten once it leaves both, so a future re-entry notifies
//! again. A series notifies as its series, never per episode — a broken
//! twenty-episode season is one message, which is the restraint §9.5 exists
//! for. The notified set is in-memory and transient — a restart re-notifies
//! whatever is currently queued, same tradeoff `ImportAction`'s probe cache
//! makes.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -16,11 +19,67 @@ use tokio::sync::Mutex;
use crate::notify::Notifier;
use crate::reconcile::{Action, ActionFuture, Outcome};
/// What is queued. Movies and series have independent id sequences, so the
/// kind travels with the id.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Subject {
Movie(i64),
Series(i64),
}
/// One series' standing in the TV queues, rolled up from its episodes.
#[derive(Debug, Default)]
struct TvEntry {
/// Wanted episodes whose every candidate was rejected for language.
no_pt_source: Vec<i64>,
/// Episodes two different releases hard-failed post-probe (§5.7).
hard_failed_episodes: Vec<i64>,
/// Seasons whose pack grab hard-failed, sending the season back to
/// per-episode grabbing.
failed_season_packs: Vec<i64>,
}
impl TvEntry {
fn reason(&self) -> String {
let mut parts = Vec::new();
if !self.no_pt_source.is_empty() {
parts.push(plural(
self.no_pt_source.len(),
"episode found no Portuguese-audio source",
"episodes found no Portuguese-audio source",
));
}
if !self.hard_failed_episodes.is_empty() {
parts.push(plural(
self.hard_failed_episodes.len(),
"episode hard-failed twice on different releases",
"episodes hard-failed twice on different releases",
));
}
if !self.failed_season_packs.is_empty() {
parts.push(plural(
self.failed_season_packs.len(),
"season pack hard-failed",
"season packs hard-failed",
));
}
parts.join("; ")
}
}
fn plural(count: usize, one: &str, many: &str) -> String {
if count == 1 {
format!("1 {one}")
} else {
format!("{count} {many}")
}
}
#[derive(Debug)]
pub struct AttentionAction {
notifier: Notifier,
operator_topic: String,
notified: Arc<Mutex<HashSet<i64>>>,
notified: Arc<Mutex<HashSet<Subject>>>,
}
impl AttentionAction {
@@ -34,7 +93,7 @@ impl AttentionAction {
}
async fn tick(&self, database: &Db) -> Result<Vec<Outcome>, sqlx::Error> {
let mut queued: HashMap<i64, (String, &'static str)> = HashMap::new();
let mut queued: HashMap<Subject, (String, String)> = HashMap::new();
let no_pt_source = sqlx::query!(
r#"
@@ -59,10 +118,10 @@ impl AttentionAction {
.await?;
for row in no_pt_source {
queued.insert(
row.id,
Subject::Movie(row.id),
(
title_with_year(&row.title, row.year),
"no Portuguese-audio source found",
"no Portuguese-audio source found".to_string(),
),
);
}
@@ -80,18 +139,25 @@ impl AttentionAction {
.fetch_all(database.pool())
.await?;
for row in needs_decision {
queued.entry(row.id).or_insert_with(|| {
queued.entry(Subject::Movie(row.id)).or_insert_with(|| {
(
title_with_year(&row.title, row.year),
"hard-failed twice on different releases",
"hard-failed twice on different releases".to_string(),
)
});
}
for (series_id, title, year, entry) in queue_tv(database).await? {
queued.insert(
Subject::Series(series_id),
(title_with_year(&title, year), entry.reason()),
);
}
let mut notified = self.notified.lock().await;
let mut outcomes = Vec::new();
for (&movie_id, (title, reason)) in &queued {
if !notified.insert(movie_id) {
for (subject, (title, reason)) in &queued {
if !notified.insert(subject.clone()) {
continue;
}
match self
@@ -99,16 +165,22 @@ impl AttentionAction {
.send(&self.operator_topic, title, reason)
.await
{
Ok(()) => outcomes.push(Outcome::new(
format!("movie {movie_id} needs a decision"),
format!("notified operator: {reason}"),
)),
Ok(()) => {
let (kind, id) = match subject {
Subject::Movie(id) => ("movie", *id),
Subject::Series(id) => ("series", *id),
};
outcomes.push(Outcome::new(
format!("{kind} {id} needs a decision"),
format!("notified operator: {reason}"),
));
}
Err(error) => {
tracing::warn!(%error, movie_id, "needs-decision notification failed");
tracing::warn!(%error, ?subject, "needs-decision notification failed");
}
}
}
notified.retain(|movie_id| queued.contains_key(movie_id));
notified.retain(|subject| queued.contains_key(subject));
Ok(outcomes)
}
@@ -121,6 +193,99 @@ fn title_with_year(title: &str, year: Option<i64>) -> String {
}
}
/// The series' roll-up entry, created empty on first sight.
fn tv_entry(
tv: &mut HashMap<i64, (String, Option<i64>, TvEntry)>,
series_id: i64,
title: String,
year: Option<i64>,
) -> &mut TvEntry {
&mut tv
.entry(series_id)
.or_insert_with(|| (title, year, TvEntry::default()))
.2
}
/// TV roll-up (§9.5): every queued series with what put it there — wanted
/// episodes whose every candidate was rejected for language, episodes two
/// different releases hard-failed post-probe, and seasons whose pack grab
/// hard-failed. One entry per series, so the notification can be one per
/// series however long the broken season is.
async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntry)>, sqlx::Error> {
let mut tv = HashMap::new();
let tv_no_pt_source = sqlx::query!(
r#"
SELECT s.id AS "series_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')
)
"#
)
.fetch_all(database.pool())
.await?;
for row in tv_no_pt_source {
tv_entry(&mut tv, row.series_id, row.title, row.year)
.no_pt_source
.push(row.episode_id);
}
let episode_hard_fails = sqlx::query!(
r#"
SELECT s.id AS "series_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.title, s.year, e.id
HAVING count(DISTINCT g.release_id) >= 2
"#
)
.fetch_all(database.pool())
.await?;
for row in episode_hard_fails {
tv_entry(&mut tv, row.series_id, row.title, row.year)
.hard_failed_episodes
.push(row.episode_id);
}
let season_pack_fails = sqlx::query!(
r#"
SELECT s.id AS "series_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.title, s.year, se.id
"#
)
.fetch_all(database.pool())
.await?;
for row in season_pack_fails {
tv_entry(&mut tv, row.series_id, row.title, row.year)
.failed_season_packs
.push(row.season_id);
}
Ok(tv
.into_iter()
.map(|(id, (t, y, e))| (id, t, y, e))
.collect())
}
impl Action for AttentionAction {
fn name(&self) -> &'static str {
"attention"
@@ -168,6 +333,94 @@ mod tests {
.unwrap()
}
/// A series on the kids TV root with one season, and `count` wanted,
/// missing episodes that have been searched. No stored releases: every
/// candidate was rejected for language (§5.2's no-PT-source case).
async fn insert_no_pt_source_series(database: &Db, tmdb_id: i64, episode_count: usize) -> i64 {
let root_id: i64 =
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO series (tmdb_id, title, year, root_id) VALUES (?, 'Bluey', 2018, ?)",
)
.bind(tmdb_id)
.bind(root_id)
.execute(database.pool())
.await
.unwrap();
let series_id: i64 = sqlx::query_scalar("SELECT id FROM series WHERE tmdb_id = ?")
.bind(tmdb_id)
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query("INSERT INTO seasons (series_id, number) VALUES (?, 1)")
.bind(series_id)
.execute(database.pool())
.await
.unwrap();
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE series_id = ?")
.bind(series_id)
.fetch_one(database.pool())
.await
.unwrap();
for number in 0..episode_count {
sqlx::query(
"INSERT INTO episodes (season_id, number, title, wanted, state, search_attempts)
VALUES (?, ?, ?, 1, 'missing', 1)",
)
.bind(season_id)
.bind(i64::try_from(number).unwrap_or(0) + 1)
.bind(format!("Episode {number}"))
.execute(database.pool())
.await
.unwrap();
}
series_id
}
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
/// standing in for what the import tick leaves behind post-probe.
async fn insert_failed_grab(
database: &Db,
target_kind: &str,
target_id: i64,
release_guid: &str,
) {
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
VALUES (7, ?, 'release', 10737418240, 'https://tracker/x.torrent', '{}', 'eligible')
RETURNING id",
)
.bind(release_guid)
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
VALUES (?, ?, ?, ?, 'failed')",
)
.bind(release_id)
.bind(target_kind)
.bind(target_id)
.bind(format!("hash-{release_guid}"))
.execute(database.pool())
.await
.unwrap();
}
async fn action(server: &MockServer) -> AttentionAction {
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(server)
.await;
AttentionAction::new(
Notifier::new(server.uri()).unwrap(),
"operator-topic".to_string(),
)
}
#[tokio::test]
async fn a_movie_entering_the_queue_notifies_the_operator_once() {
let (_dir, database) = seeded_database().await;
@@ -224,4 +477,88 @@ mod tests {
assert_eq!(third.len(), 1, "re-enters and notifies again");
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
async fn a_series_entering_the_queue_notifies_once_per_series() {
let (_dir, database) = seeded_database().await;
// Twenty broken episodes are one notification, not twenty (§9.5).
insert_no_pt_source_series(&database, 1, 20).await;
let server = MockServer::start().await;
let action = action(&server).await;
let first = action.tick(&database).await.unwrap();
let second = action.tick(&database).await.unwrap();
assert_eq!(first.len(), 1, "one notification for the whole series");
assert_eq!(second.len(), 0, "does not repeat while still queued");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn a_series_leaving_and_re_entering_the_queue_notifies_again() {
let (_dir, database) = seeded_database().await;
let series_id = insert_no_pt_source_series(&database, 1, 1).await;
let server = MockServer::start().await;
let action = action(&server).await;
action.tick(&database).await.unwrap();
sqlx::query("UPDATE series SET blocked = 1 WHERE id = ?")
.bind(series_id)
.execute(database.pool())
.await
.unwrap();
action.tick(&database).await.unwrap();
sqlx::query("UPDATE series SET blocked = 0 WHERE id = ?")
.bind(series_id)
.execute(database.pool())
.await
.unwrap();
let third = action.tick(&database).await.unwrap();
assert_eq!(third.len(), 1, "re-enters and notifies again");
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
async fn an_episode_hard_failed_twice_notifies_its_series() {
let (_dir, database) = seeded_database().await;
insert_no_pt_source_series(&database, 1, 0).await;
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO episodes (season_id, number, title, wanted, state)
VALUES (?, 1, 'Episode 0', 1, 'missing')",
)
.bind(season_id)
.execute(database.pool())
.await
.unwrap();
let episode_id: i64 =
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1")
.bind(season_id)
.fetch_one(database.pool())
.await
.unwrap();
insert_failed_grab(&database, "episode", episode_id, "first").await;
insert_failed_grab(&database, "episode", episode_id, "second").await;
// The pack's failure sent this season back to per-episode grabbing;
// it queues the same series, so it must not double the message.
insert_failed_grab(&database, "season", season_id, "pack").await;
let server = MockServer::start().await;
let action = action(&server).await;
let outcomes = action.tick(&database).await.unwrap();
assert_eq!(
outcomes.len(),
1,
"both hard-fail conditions roll up to one series notification"
);
}
}
+17 -1
View File
@@ -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 (the backend landed in issue 126).
*/
export function attentionTotal(queues: AttentionQueues): number {
return queues.no_pt_source.length + queues.needs_decision.length;
}