feat: ntfy notifications for imported, needs-decision, broken (#96)
This commit was merged in pull request #96.
This commit is contained in:
+267
-46
@@ -26,6 +26,7 @@ use arr_dl::TransmissionClient;
|
||||
use arr_probe::Prober;
|
||||
|
||||
use crate::jellyfin::JellyfinClient;
|
||||
use crate::notify::Notifier;
|
||||
use crate::reconcile::{Action, ActionFuture, Outcome};
|
||||
|
||||
/// A failure during one import tick.
|
||||
@@ -75,6 +76,15 @@ pub struct ImportAction {
|
||||
transmission: TransmissionClient,
|
||||
prober: Prober,
|
||||
jellyfin: JellyfinClient,
|
||||
notifier: Notifier,
|
||||
/// The operator's ntfy topic (DESIGN.md §9.5), for the *broken*
|
||||
/// notification a disk-full hardlink/copy failure raises. `None` when
|
||||
/// unconfigured: the failure is still logged, just not notified.
|
||||
operator_topic: Option<String>,
|
||||
/// Debounces the disk-full *broken* notification so a stuck-full disk
|
||||
/// notifies once, not every tick. Transient: a restart re-arms it, same
|
||||
/// as the probe cache above.
|
||||
disk_full_notified: std::sync::Arc<tokio::sync::Mutex<bool>>,
|
||||
/// Probe results by path, kept across ticks. The reconcile lane cancels
|
||||
/// the whole action after its 25 s budget while one probe alone may take
|
||||
/// up to 60 s, so without this a large multi-file torrent would restart
|
||||
@@ -101,11 +111,20 @@ struct PendingImport {
|
||||
|
||||
impl ImportAction {
|
||||
#[must_use]
|
||||
pub fn new(transmission: TransmissionClient, prober: Prober, jellyfin: JellyfinClient) -> Self {
|
||||
pub fn new(
|
||||
transmission: TransmissionClient,
|
||||
prober: Prober,
|
||||
jellyfin: JellyfinClient,
|
||||
notifier: Notifier,
|
||||
operator_topic: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
transmission,
|
||||
prober,
|
||||
jellyfin,
|
||||
notifier,
|
||||
operator_topic,
|
||||
disk_full_notified: std::sync::Arc::new(tokio::sync::Mutex::new(false)),
|
||||
probed: std::sync::Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
@@ -170,33 +189,77 @@ impl ImportAction {
|
||||
let mut outcomes = Vec::new();
|
||||
for pending in pending_imports(database).await? {
|
||||
match self.import_one(database, &pending).await {
|
||||
Ok(Some(outcome)) => outcomes.push(outcome),
|
||||
Ok(Some(outcome)) => {
|
||||
self.clear_disk_full().await;
|
||||
outcomes.push(outcome);
|
||||
}
|
||||
Ok(None) => {}
|
||||
// One grab's failure must not cost the rest of the tick.
|
||||
Err(error) => tracing::error!(
|
||||
grab_id = pending.grab_id,
|
||||
movie_id = pending.movie_id,
|
||||
title = pending.title,
|
||||
%error,
|
||||
"import failed"
|
||||
),
|
||||
Err(error) => {
|
||||
self.notify_if_disk_full(&error).await;
|
||||
tracing::error!(
|
||||
grab_id = pending.grab_id,
|
||||
movie_id = pending.movie_id,
|
||||
title = pending.title,
|
||||
%error,
|
||||
"import failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for pending in pending_tv_imports(database).await? {
|
||||
match self.import_tv_one(database, &pending).await {
|
||||
Ok(Some(outcome)) => outcomes.push(outcome),
|
||||
Ok(Some(outcome)) => {
|
||||
self.clear_disk_full().await;
|
||||
outcomes.push(outcome);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => tracing::error!(
|
||||
grab_id = pending.grab_id,
|
||||
series = pending.series_title,
|
||||
%error,
|
||||
"tv import failed"
|
||||
),
|
||||
Err(error) => {
|
||||
self.notify_if_disk_full(&error).await;
|
||||
tracing::error!(
|
||||
grab_id = pending.grab_id,
|
||||
series = pending.series_title,
|
||||
%error,
|
||||
"tv import failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(outcomes)
|
||||
}
|
||||
|
||||
/// §9.5 *broken*: a hardlink or copy that failed because the target
|
||||
/// filesystem is full. Debounced so a disk that stays full notifies once,
|
||||
/// not every tick, and silently re-arms once space frees up.
|
||||
async fn notify_if_disk_full(&self, error: &ImportError) {
|
||||
let ImportError::Io { source, .. } = error else {
|
||||
return;
|
||||
};
|
||||
if source.kind() != io::ErrorKind::StorageFull {
|
||||
return;
|
||||
}
|
||||
let Some(topic) = &self.operator_topic else {
|
||||
return;
|
||||
};
|
||||
let mut notified = self.disk_full_notified.lock().await;
|
||||
if *notified {
|
||||
return;
|
||||
}
|
||||
if let Err(notify_error) = self
|
||||
.notifier
|
||||
.send(topic, "arr: disk full", &error.to_string())
|
||||
.await
|
||||
{
|
||||
tracing::warn!(%notify_error, "broken notification failed");
|
||||
return;
|
||||
}
|
||||
*notified = true;
|
||||
}
|
||||
|
||||
async fn clear_disk_full(&self) {
|
||||
*self.disk_full_notified.lock().await = false;
|
||||
}
|
||||
|
||||
async fn import_one(
|
||||
&self,
|
||||
database: &Db,
|
||||
@@ -292,12 +355,37 @@ impl ImportAction {
|
||||
waived = waiver.is_some(),
|
||||
"imported"
|
||||
);
|
||||
self.notify_imported(
|
||||
database,
|
||||
"movie",
|
||||
pending.movie_id,
|
||||
&title_with_year(&pending.title, pending.year),
|
||||
)
|
||||
.await;
|
||||
Ok(Some(Outcome::new(
|
||||
format!("grab {} downloaded, not imported", pending.grab_id),
|
||||
format!("imported {path_text}"),
|
||||
)))
|
||||
}
|
||||
|
||||
/// §9.5 *imported*: the only good-news notification, sent to the title's
|
||||
/// owners alone. A failure to reach ntfy must not fail the import, which
|
||||
/// has already succeeded.
|
||||
async fn notify_imported(&self, database: &Db, title_kind: &str, title_id: i64, title: &str) {
|
||||
let topics = match owner_topics(database, title_kind, title_id).await {
|
||||
Ok(topics) => topics,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "could not load owners for imported notification");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for topic in topics {
|
||||
if let Err(error) = self.notifier.send(&topic, title, "imported").await {
|
||||
tracing::warn!(%error, topic, "imported notification failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The torrent's files as safe local paths, or `None` when Transmission
|
||||
/// no longer has the torrent.
|
||||
///
|
||||
@@ -423,15 +511,7 @@ impl ImportAction {
|
||||
if imports.is_empty() {
|
||||
// Everything the pack holds is already on disk. Nothing to
|
||||
// place; the grab is settled.
|
||||
sqlx::query!(
|
||||
"UPDATE grabs
|
||||
SET state = 'imported',
|
||||
imported_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
pending.grab_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
mark_grab_imported(database, pending.grab_id).await?;
|
||||
self.forget_probes(&paths).await;
|
||||
return Ok(Some(Outcome::new(
|
||||
format!("grab {} downloaded, not imported", pending.grab_id),
|
||||
@@ -443,17 +523,16 @@ impl ImportAction {
|
||||
.place_episodes(database, pending, &loaded.root_path, imports)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE grabs
|
||||
SET state = 'imported',
|
||||
imported_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
pending.grab_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
mark_grab_imported(database, pending.grab_id).await?;
|
||||
self.forget_probes(&paths).await;
|
||||
self.refresh_jellyfin().await;
|
||||
self.notify_imported(
|
||||
database,
|
||||
"series",
|
||||
pending.series_id,
|
||||
&title_with_year(&pending.series_title, pending.series_year),
|
||||
)
|
||||
.await;
|
||||
Ok(Some(Outcome::new(
|
||||
format!("grab {} downloaded, not imported", pending.grab_id),
|
||||
format!(
|
||||
@@ -646,6 +725,47 @@ impl Action for ImportAction {
|
||||
}
|
||||
}
|
||||
|
||||
/// Settle a TV grab as imported, whether or not any file was placed — a
|
||||
/// pack entirely already on disk still needs its grab marked done.
|
||||
async fn mark_grab_imported(database: &Db, grab_id: i64) -> Result<(), ImportError> {
|
||||
sqlx::query!(
|
||||
"UPDATE grabs
|
||||
SET state = 'imported',
|
||||
imported_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
grab_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A notification title: the bare title, or with the release year appended.
|
||||
fn title_with_year(title: &str, year: Option<i64>) -> String {
|
||||
match year {
|
||||
Some(year) => format!("{title} ({year})"),
|
||||
None => title.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The ntfy topics of a title's owners (§4.3, §9.5), movie or series alike.
|
||||
async fn owner_topics(
|
||||
database: &Db,
|
||||
title_kind: &str,
|
||||
title_id: i64,
|
||||
) -> Result<Vec<String>, ImportError> {
|
||||
Ok(sqlx::query_scalar!(
|
||||
r#"SELECT o.ntfy_topic AS "ntfy_topic!: String"
|
||||
FROM owners o
|
||||
JOIN title_owners t ON t.owner_id = o.id
|
||||
WHERE t.title_kind = ? AND t.title_id = ?"#,
|
||||
title_kind,
|
||||
title_id
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Settle a placed file into the rows: the `media_files` record (§4), the
|
||||
/// grab and the movie. The upsert on path is the crash seam — a re-run after
|
||||
/// a death between the link and here converges instead of erroring.
|
||||
@@ -745,6 +865,7 @@ struct PendingTvImport {
|
||||
episode_id: Option<i64>,
|
||||
season_id: i64,
|
||||
season_number: i64,
|
||||
series_id: i64,
|
||||
series_tmdb_id: i64,
|
||||
series_title: String,
|
||||
series_year: Option<i64>,
|
||||
@@ -780,6 +901,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
|
||||
e.id AS "episode_id!: i64",
|
||||
se.id AS "season_id!: i64",
|
||||
se.number AS "season_number!: i64",
|
||||
s.id AS "series_id!: i64",
|
||||
s.tmdb_id AS "series_tmdb_id!: i64",
|
||||
s.title AS "series_title!: String",
|
||||
s.year AS "series_year",
|
||||
@@ -802,6 +924,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
|
||||
episode_id: Some(row.episode_id),
|
||||
season_id: row.season_id,
|
||||
season_number: row.season_number,
|
||||
series_id: row.series_id,
|
||||
series_tmdb_id: row.series_tmdb_id,
|
||||
series_title: row.series_title,
|
||||
series_year: row.series_year,
|
||||
@@ -815,6 +938,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
|
||||
g.infohash AS "infohash!: String",
|
||||
se.id AS "season_id!: i64",
|
||||
se.number AS "season_number!: i64",
|
||||
s.id AS "series_id!: i64",
|
||||
s.tmdb_id AS "series_tmdb_id!: i64",
|
||||
s.title AS "series_title!: String",
|
||||
s.year AS "series_year",
|
||||
@@ -836,6 +960,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
|
||||
episode_id: None,
|
||||
season_id: row.season_id,
|
||||
season_number: row.season_number,
|
||||
series_id: row.series_id,
|
||||
series_tmdb_id: row.series_tmdb_id,
|
||||
series_title: row.series_title,
|
||||
series_year: row.series_year,
|
||||
@@ -1114,6 +1239,44 @@ mod tests {
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// Tags a title with one owner, so a test can assert an *imported*
|
||||
/// notification reaches that owner's topic alone (§9.5).
|
||||
async fn insert_owner(database: &Db, title_kind: &str, title_id: i64, name: &str, topic: &str) {
|
||||
let owner_id = sqlx::query("INSERT INTO owners (name, ntfy_topic) VALUES (?, ?)")
|
||||
.bind(name)
|
||||
.bind(topic)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap()
|
||||
.last_insert_rowid();
|
||||
sqlx::query("INSERT INTO title_owners (title_kind, title_id, owner_id) VALUES (?, ?, ?)")
|
||||
.bind(title_kind)
|
||||
.bind(title_id)
|
||||
.bind(owner_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn start_ntfy_server() -> MockServer {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(&server)
|
||||
.await;
|
||||
server
|
||||
}
|
||||
|
||||
async fn start_jellyfin_server() -> MockServer {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/Library/Refresh"))
|
||||
.respond_with(ResponseTemplate::new(204))
|
||||
.mount(&server)
|
||||
.await;
|
||||
server
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
_dir: tempfile::TempDir,
|
||||
database: Db,
|
||||
@@ -1122,6 +1285,7 @@ mod tests {
|
||||
action: ImportAction,
|
||||
_server: MockServer,
|
||||
jellyfin_server: MockServer,
|
||||
ntfy_server: MockServer,
|
||||
}
|
||||
|
||||
/// An `ffprobe` stand-in: canned JSON for media, a `tty` document for the
|
||||
@@ -1192,6 +1356,7 @@ mod tests {
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
insert_owner(&database, "movie", 1, "Alice", "alice-topic").await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
@@ -1206,19 +1371,18 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let jellyfin_server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/Library/Refresh"))
|
||||
.respond_with(ResponseTemplate::new(204))
|
||||
.mount(&jellyfin_server)
|
||||
.await;
|
||||
let jellyfin_server = start_jellyfin_server().await;
|
||||
let ntfy_server = start_ntfy_server().await;
|
||||
|
||||
let prober = Prober::new().with_binary(fake_ffprobe(dir.path(), media_json));
|
||||
let jellyfin = JellyfinClient::new(jellyfin_server.uri(), None).unwrap();
|
||||
let notifier = Notifier::new(ntfy_server.uri()).unwrap();
|
||||
let action = ImportAction::new(
|
||||
TransmissionClient::new(&server.uri()).unwrap(),
|
||||
prober,
|
||||
jellyfin,
|
||||
notifier,
|
||||
Some("operator-topic".to_string()),
|
||||
);
|
||||
|
||||
Harness {
|
||||
@@ -1229,6 +1393,7 @@ mod tests {
|
||||
action,
|
||||
_server: server,
|
||||
jellyfin_server,
|
||||
ntfy_server,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1283,6 +1448,14 @@ mod tests {
|
||||
1,
|
||||
"§7.5: one refresh call at the end of a successful import"
|
||||
);
|
||||
|
||||
// The issue's acceptance case: an import notifies only that title's
|
||||
// owners (§9.5), never the operator topic.
|
||||
let notifications = h.ntfy_server.received_requests().await.unwrap();
|
||||
assert_eq!(notifications.len(), 1);
|
||||
assert_eq!(notifications[0].url.path(), "/alice-topic");
|
||||
let body = String::from_utf8(notifications[0].body.clone()).unwrap();
|
||||
assert!(body.contains("Dune: Part Two (2024)"), "{body}");
|
||||
}
|
||||
|
||||
/// §5.3 through §5.7: Profile 5 is a hard fail — blacklisted, grab
|
||||
@@ -1322,6 +1495,38 @@ mod tests {
|
||||
movie_state, "missing",
|
||||
"the gap reopens for the next candidate"
|
||||
);
|
||||
|
||||
assert!(
|
||||
h.ntfy_server.received_requests().await.unwrap().is_empty(),
|
||||
"§9.5: a hard fail is not notified"
|
||||
);
|
||||
}
|
||||
|
||||
/// §9.5 *broken*: a full disk notifies the operator once, not every
|
||||
/// tick, and re-arms once space frees up.
|
||||
#[tokio::test]
|
||||
async fn disk_full_notifies_the_operator_once_until_it_clears() {
|
||||
let h = harness(HDR10_PROBE).await;
|
||||
let error = ImportError::Io {
|
||||
action: "hardlink into",
|
||||
path: PathBuf::from("/mnt/media/x.mkv"),
|
||||
source: io::Error::from(io::ErrorKind::StorageFull),
|
||||
};
|
||||
|
||||
h.action.notify_if_disk_full(&error).await;
|
||||
h.action.notify_if_disk_full(&error).await;
|
||||
|
||||
let notifications = h.ntfy_server.received_requests().await.unwrap();
|
||||
assert_eq!(notifications.len(), 1, "debounced while still full");
|
||||
assert_eq!(notifications[0].url.path(), "/operator-topic");
|
||||
|
||||
h.action.clear_disk_full().await;
|
||||
h.action.notify_if_disk_full(&error).await;
|
||||
assert_eq!(
|
||||
h.ntfy_server.received_requests().await.unwrap().len(),
|
||||
2,
|
||||
"re-arms once space frees up"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5.7 soft fail: watchable but not what was asked. It imports, and the
|
||||
@@ -1507,6 +1712,8 @@ mod tests {
|
||||
TransmissionClient::new("http://127.0.0.1:1").unwrap(),
|
||||
Prober::new().with_binary(&script),
|
||||
JellyfinClient::new("http://127.0.0.1:1", None).unwrap(),
|
||||
Notifier::new("http://127.0.0.1:1").unwrap(),
|
||||
None,
|
||||
);
|
||||
let paths = vec![media];
|
||||
|
||||
@@ -1552,6 +1759,8 @@ mod tests {
|
||||
TransmissionClient::new("http://127.0.0.1:1").unwrap(),
|
||||
Prober::new().with_binary(&script),
|
||||
JellyfinClient::new("http://127.0.0.1:1", None).unwrap(),
|
||||
Notifier::new("http://127.0.0.1:1").unwrap(),
|
||||
None,
|
||||
);
|
||||
let paths = vec![media];
|
||||
|
||||
@@ -1604,6 +1813,7 @@ mod tests {
|
||||
library: PathBuf,
|
||||
action: ImportAction,
|
||||
_server: MockServer,
|
||||
ntfy_server: MockServer,
|
||||
}
|
||||
|
||||
/// A downloaded season-pack grab for Fallout S01E01-E02, its two files
|
||||
@@ -1670,6 +1880,7 @@ mod tests {
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
insert_owner(&database, "series", 1, "Bob", "bob-topic").await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
@@ -1686,19 +1897,18 @@ mod tests {
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let jellyfin_server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/Library/Refresh"))
|
||||
.respond_with(ResponseTemplate::new(204))
|
||||
.mount(&jellyfin_server)
|
||||
.await;
|
||||
let jellyfin_server = start_jellyfin_server().await;
|
||||
let ntfy_server = start_ntfy_server().await;
|
||||
|
||||
let prober = Prober::new().with_binary(fake_ffprobe(dir.path(), media_json));
|
||||
let jellyfin = JellyfinClient::new(jellyfin_server.uri(), None).unwrap();
|
||||
let notifier = Notifier::new(ntfy_server.uri()).unwrap();
|
||||
let action = ImportAction::new(
|
||||
TransmissionClient::new(&server.uri()).unwrap(),
|
||||
prober,
|
||||
jellyfin,
|
||||
notifier,
|
||||
Some("operator-topic".to_string()),
|
||||
);
|
||||
|
||||
TvHarness {
|
||||
@@ -1708,6 +1918,7 @@ mod tests {
|
||||
library,
|
||||
action,
|
||||
_server: server,
|
||||
ntfy_server,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1746,6 +1957,16 @@ mod tests {
|
||||
h.downloads.join("Fallout.S01/Fallout.S01E01.mkv").is_file(),
|
||||
"§7.3: the torrent keeps seeding"
|
||||
);
|
||||
|
||||
let notifications = h.ntfy_server.received_requests().await.unwrap();
|
||||
assert_eq!(
|
||||
notifications.len(),
|
||||
1,
|
||||
"one notification per grab, not per episode"
|
||||
);
|
||||
assert_eq!(notifications[0].url.path(), "/bob-topic");
|
||||
let body = String::from_utf8(notifications[0].body.clone()).unwrap();
|
||||
assert!(body.contains("Fallout (2024)"), "{body}");
|
||||
}
|
||||
|
||||
/// The fourth acceptance case: a pack containing an episode already on
|
||||
|
||||
Reference in New Issue
Block a user