feat: ntfy notifications for imported, needs-decision, broken (#96)
This commit was merged in pull request #96.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user