feat: ntfy notifications for imported, needs-decision, broken (#96)
ci / web (push) Successful in 1m0s
e2e / e2e (push) Successful in 3m41s
ci / rust (push) Successful in 5m42s

This commit was merged in pull request #96.
This commit is contained in:
2026-08-23 02:04:36 +01:00
parent 9f812fac11
commit ad0eb0d9d3
11 changed files with 1006 additions and 62 deletions
+227
View File
@@ -0,0 +1,227 @@
//! §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).
//!
//! 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.
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};
#[derive(Debug)]
pub struct AttentionAction {
notifier: Notifier,
operator_topic: String,
notified: Arc<Mutex<HashSet<i64>>>,
}
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<i64, (String, &'static str)> = 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(
row.id,
(
title_with_year(&row.title, row.year),
"no Portuguese-audio source found",
),
);
}
let needs_decision = sqlx::query!(
r#"
SELECT id AS "id!: i64", title AS "title!: String", year
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
"#
)
.fetch_all(database.pool())
.await?;
for row in needs_decision {
queued.entry(row.id).or_insert_with(|| {
(
title_with_year(&row.title, row.year),
"hard-failed twice on different releases",
)
});
}
let mut notified = self.notified.lock().await;
let mut outcomes = Vec::new();
for (&movie_id, (title, reason)) in &queued {
if !notified.insert(movie_id) {
continue;
}
match self
.notifier
.send(&self.operator_topic, title, reason)
.await
{
Ok(()) => outcomes.push(Outcome::new(
format!("movie {movie_id} needs a decision"),
format!("notified operator: {reason}"),
)),
Err(error) => {
tracing::warn!(%error, movie_id, "needs-decision notification failed");
}
}
}
notified.retain(|movie_id| queued.contains_key(movie_id));
Ok(outcomes)
}
}
fn title_with_year(title: &str, year: Option<i64>) -> String {
match year {
Some(year) => format!("{title} ({year})"),
None => title.to_string(),
}
}
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()
}
#[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);
}
}