Files
arr/crates/arr-daemon/src/attention.rs
T

635 lines
22 KiB
Rust

//! §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 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;
use arr_db::Db;
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<Subject>>>,
}
impl AttentionAction {
#[must_use]
pub fn new(notifier: Notifier, operator_topic: String) -> Self {
Self {
notifier,
operator_topic,
notified: Arc::new(Mutex::new(HashSet::new())),
}
}
async fn tick(&self, database: &Db) -> Result<Vec<Outcome>, sqlx::Error> {
let mut queued: HashMap<Subject, (String, String)> = HashMap::new();
let no_pt_source = sqlx::query!(
r#"
SELECT id AS "id!: i64", title AS "title!: String", year
FROM movies
WHERE id IN (
SELECT m.id
FROM movies m
JOIN roots root ON root.id = m.root_id
WHERE root.audience = 'kids'
AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing'
AND m.search_attempts > 0
AND NOT EXISTS (
SELECT 1 FROM movie_releases mr
JOIN releases r ON r.id = mr.release_id
WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived')
)
)
"#
)
.fetch_all(database.pool())
.await?;
for row in no_pt_source {
queued.insert(
Subject::Movie(row.id),
(
title_with_year(&row.title, row.year),
"no Portuguese-audio source found".to_string(),
),
);
}
let needs_decision = sqlx::query!(
r#"
SELECT id AS "id!: i64", title AS "title!: String", year
FROM movies
WHERE movies.wanted = 1 AND movies.state != 'available'
AND (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
"#
)
.fetch_all(database.pool())
.await?;
for row in needs_decision {
queued.entry(Subject::Movie(row.id)).or_insert_with(|| {
(
title_with_year(&row.title, row.year),
"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 (subject, (title, reason)) in &queued {
if !notified.insert(subject.clone()) {
continue;
}
match self
.notifier
.send(&self.operator_topic, title, reason)
.await
{
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, ?subject, "needs-decision notification failed");
}
}
}
notified.retain(|subject| queued.contains_key(subject));
Ok(outcomes)
}
}
fn title_with_year(title: &str, year: Option<i64>) -> String {
match year {
Some(year) => format!("{title} ({year})"),
None => title.to_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'
AND e.wanted = 1 AND e.state != 'available'
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"
}
fn run<'a>(&'a self, database: &'a Db) -> ActionFuture<'a> {
Box::pin(async move { self.tick(database).await.map_err(Into::into) })
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
async fn seeded_database() -> (tempfile::TempDir, Db) {
let dir = tempfile::tempdir().unwrap();
let database = Db::connect(dir.path().join("arr.db")).await.unwrap();
database.migrate().await.unwrap();
(dir, database)
}
async fn insert_no_pt_source_movie(database: &Db) -> i64 {
let root_id: i64 =
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'movie' AND audience = 'kids'")
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO movies
(tmdb_id, title, year, root_id, wanted, state, search_attempts)
VALUES (1, 'Encanto', 2021, ?, 1, 'missing', 1)
RETURNING id",
)
.bind(root_id)
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query_scalar("SELECT id FROM movies WHERE tmdb_id = 1")
.fetch_one(database.pool())
.await
.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;
insert_no_pt_source_movie(&database).await;
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let action = AttentionAction::new(
Notifier::new(server.uri()).unwrap(),
"operator-topic".to_string(),
);
let first = action.tick(&database).await.unwrap();
let second = action.tick(&database).await.unwrap();
assert_eq!(first.len(), 1, "notifies on the tick it enters the queue");
assert_eq!(second.len(), 0, "does not repeat while still queued");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn leaving_and_re_entering_the_queue_notifies_again() {
let (_dir, database) = seeded_database().await;
let movie_id = insert_no_pt_source_movie(&database).await;
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let action = AttentionAction::new(
Notifier::new(server.uri()).unwrap(),
"operator-topic".to_string(),
);
action.tick(&database).await.unwrap();
sqlx::query("UPDATE movies SET blocked = 1 WHERE id = ?")
.bind(movie_id)
.execute(database.pool())
.await
.unwrap();
action.tick(&database).await.unwrap();
sqlx::query("UPDATE movies SET blocked = 0 WHERE id = ?")
.bind(movie_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 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"
);
}
#[tokio::test]
async fn a_movie_imported_after_two_hard_fails_leaves_the_queue() {
let (_dir, database) = seeded_database().await;
let movie_id = insert_no_pt_source_movie(&database).await;
insert_failed_grab(&database, "movie", movie_id, "first").await;
insert_failed_grab(&database, "movie", movie_id, "second").await;
let server = MockServer::start().await;
let action = action(&server).await;
let first = action.tick(&database).await.unwrap();
assert_eq!(first.len(), 1, "queued while unsatisfied");
// Imported from a third release: satisfied, no decision needed.
sqlx::query("UPDATE movies SET state = 'available' WHERE id = ?")
.bind(movie_id)
.execute(database.pool())
.await
.unwrap();
let second = action.tick(&database).await.unwrap();
assert_eq!(second.len(), 0, "leaves the queue once imported");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn an_episode_imported_after_two_hard_fails_leaves_the_queue() {
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;
let server = MockServer::start().await;
let action = action(&server).await;
let first = action.tick(&database).await.unwrap();
assert_eq!(first.len(), 1, "queued while unsatisfied");
// Imported from a third release: satisfied, no decision needed.
sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?")
.bind(episode_id)
.execute(database.pool())
.await
.unwrap();
let second = action.tick(&database).await.unwrap();
assert_eq!(second.len(), 0, "leaves the queue once imported");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
}