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
+267 -46
View File
@@ -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