Files
arr/crates/arr-daemon/src/reaper.rs
naps62-yolo e01505bd53
ci / web (push) Successful in 25s
e2e / e2e (push) Successful in 46s
ci / rust (push) Successful in 2m9s
Targeted-search backoff and release-date gating (#90)
2026-08-23 00:42:13 +01:00

149 lines
5.0 KiB
Rust

//! Removes arr torrents only after Transmission says their seeding obligation
//! is complete. The library/import state is deliberately not consulted (§7.3).
use std::collections::HashSet;
use arr_db::Db;
use arr_dl::TransmissionClient;
use crate::grab::label_for_root;
use crate::reconcile::{Action, ActionFuture, Outcome};
#[derive(Debug)]
pub struct ReaperAction {
transmission: TransmissionClient,
}
impl ReaperAction {
#[must_use]
pub fn new(transmission: TransmissionClient) -> Self {
Self { transmission }
}
async fn tick(&self, labels: &HashSet<String>) -> Result<Vec<Outcome>, arr_dl::Error> {
let torrents = self.transmission.list_torrents().await?;
let mut outcomes = Vec::new();
for torrent in torrents {
if !torrent.is_finished || !torrent.labels.iter().any(|label| labels.contains(label)) {
continue;
}
self.transmission.remove_torrent(torrent.id, true).await?;
outcomes.push(Outcome::new(
format!("torrent {} finished seeding", torrent.hash),
format!("removed torrent {} and its download data", torrent.id),
));
}
Ok(outcomes)
}
}
impl Action for ReaperAction {
fn name(&self) -> &'static str {
"torrent-reaper"
}
fn run<'a>(&'a self, database: &'a Db) -> ActionFuture<'a> {
Box::pin(async move {
let labels = sqlx::query_as::<_, (String, String)>("SELECT kind, audience FROM roots")
.fetch_all(database.pool())
.await
.map_err(|error| Box::new(error) as crate::reconcile::ActionError)?
.into_iter()
.map(|(kind, audience)| label_for_root(&kind, &audience))
.collect::<HashSet<_>>();
self.tick(&labels)
.await
.map_err(|error| Box::new(error) as crate::reconcile::ActionError)
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::sync::{Arc, Mutex};
use serde_json::{json, Value};
use wiremock::matchers::any;
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
use super::*;
#[derive(Clone)]
struct Transmission {
torrents: Value,
removed: Arc<Mutex<Vec<Value>>>,
}
impl Respond for Transmission {
fn respond(&self, request: &Request) -> ResponseTemplate {
let body: Value = serde_json::from_slice(&request.body).unwrap();
let arguments = match body["method"].as_str().unwrap() {
"torrent-get" => json!({"torrents": self.torrents}),
"torrent-remove" => {
self.removed.lock().unwrap().push(body["arguments"].clone());
json!({})
}
method => panic!("unexpected method {method}"),
};
ResponseTemplate::new(200)
.insert_header("x-transmission-session-id", "session")
.set_body_json(json!({"result": "success", "arguments": arguments}))
}
}
fn torrent(id: i64, label: &str, finished: bool) -> Value {
json!({
"id": id, "name": format!("torrent-{id}"), "hashString": format!("hash-{id}"),
"status": if finished { 0 } else { 6 }, "percentDone": 1.0,
"downloadDir": "/downloads", "labels": [label], "isFinished": finished
})
}
#[tokio::test]
async fn removes_only_finished_arr_torrents_with_data() {
let server = MockServer::start().await;
let removed = Arc::new(Mutex::new(Vec::new()));
Mock::given(any())
.respond_with(Transmission {
torrents: json!([
torrent(1, "movies-main", false),
torrent(2, "movies-main", true),
torrent(3, "radarr", true)
]),
removed: Arc::clone(&removed),
})
.mount(&server)
.await;
let action = ReaperAction::new(TransmissionClient::new(&server.uri()).unwrap());
let labels = HashSet::from(["movies-main".to_owned(), "movies-kids".to_owned()]);
let outcomes = action.tick(&labels).await.unwrap();
assert_eq!(outcomes.len(), 1);
assert_eq!(
*removed.lock().unwrap(),
[json!({"ids": [2], "delete-local-data": true})]
);
}
#[tokio::test]
async fn finished_torrent_does_not_need_a_grab_or_import_row() {
let server = MockServer::start().await;
let removed = Arc::new(Mutex::new(Vec::new()));
Mock::given(any())
.respond_with(Transmission {
torrents: json!([torrent(9, "movies-kids", true)]),
removed: Arc::clone(&removed),
})
.mount(&server)
.await;
let action = ReaperAction::new(TransmissionClient::new(&server.uri()).unwrap());
let labels = HashSet::from(["movies-kids".to_owned()]);
action.tick(&labels).await.unwrap();
assert_eq!(removed.lock().unwrap().len(), 1);
}
}