Per-tracker seeding rules and torrent reaper (#88)
ci / web (push) Successful in 43s
e2e / e2e (push) Successful in 54s
ci / rust (push) Failing after 2m0s

This commit was merged in pull request #88.
This commit is contained in:
2026-08-23 00:03:30 +01:00
parent 22024d4be2
commit 0585811a0c
6 changed files with 319 additions and 21 deletions
+54 -14
View File
@@ -50,6 +50,26 @@ pub struct SeedingLimits {
pub idle_minutes: u64,
}
#[derive(Debug, Clone)]
pub struct SeedingRules {
default: SeedingLimits,
trackers: HashMap<i64, SeedingLimits>,
}
impl SeedingRules {
#[must_use]
pub fn new(default: SeedingLimits, trackers: HashMap<i64, SeedingLimits>) -> Self {
Self { default, trackers }
}
fn for_indexer(&self, indexer_id: i64) -> SeedingLimits {
self.trackers
.get(&indexer_id)
.copied()
.unwrap_or(self.default)
}
}
/// A failure during one grab tick.
#[derive(Debug, thiserror::Error)]
pub enum GrabError {
@@ -84,7 +104,7 @@ pub struct GrabAction {
prowlarr: ProwlarrClient,
transmission: TransmissionClient,
download_dir: PathBuf,
seeding: SeedingLimits,
seeding: SeedingRules,
indexers: tokio::sync::RwLock<IndexerCache>,
/// [`INDEXER_DISCOVERY_TIMEOUT`], overridden by tests that cannot wait
/// out the real one. Mirrors `ReconcileLoop`'s action timeout override.
@@ -97,7 +117,7 @@ impl GrabAction {
prowlarr: ProwlarrClient,
transmission: TransmissionClient,
download_dir: PathBuf,
seeding: SeedingLimits,
seeding: SeedingRules,
) -> Self {
Self {
prowlarr,
@@ -342,14 +362,15 @@ impl GrabAction {
return Ok(None);
};
let seeding = self.seeding.for_indexer(winner.indexer_id);
let added = self
.transmission
.add_torrent(AddTorrent {
source: torrent_source(&winner.download_url),
label: label(&loaded),
download_dir: self.download_dir.clone(),
seed_ratio_limit: self.seeding.ratio,
seed_idle_limit_minutes: self.seeding.idle_minutes,
seed_ratio_limit: seeding.ratio,
seed_idle_limit_minutes: seeding.idle_minutes,
})
.await?;
let infohash = added.hash.to_ascii_lowercase();
@@ -665,12 +686,12 @@ async fn record_search(database: &Db, movie_id: i64) -> Result<(), GrabError> {
/// `movies-main`, `tv-kids` (§7.1). Distinct from Radarr's own labels, so
/// both stacks can run against one Transmission.
fn label(loaded: &MoviePolicy) -> String {
let kind = if loaded.root_kind == "movie" {
"movies"
} else {
&loaded.root_kind
};
format!("{kind}-{}", loaded.root_audience)
label_for_root(&loaded.root_kind, &loaded.root_audience)
}
pub(crate) fn label_for_root(kind: &str, audience: &str) -> String {
let kind = if kind == "movie" { "movies" } else { kind };
format!("{kind}-{audience}")
}
/// Indexers hand out magnets and `.torrent` links interchangeably;
@@ -901,13 +922,32 @@ mod tests {
ProwlarrClient::new(prowlarr.uri(), "key").unwrap(),
TransmissionClient::new(&transmission.uri()).unwrap(),
PathBuf::from("/mnt/media/transmission/complete"),
SeedingLimits {
ratio: 1.5,
idle_minutes: 60,
},
SeedingRules::new(
SeedingLimits {
ratio: 1.5,
idle_minutes: 60,
},
HashMap::new(),
),
)
}
#[test]
fn seeding_rules_select_by_prowlarr_indexer_id() {
let default = SeedingLimits {
ratio: 1.0,
idle_minutes: 60,
};
let tracker = SeedingLimits {
ratio: 2.5,
idle_minutes: 120,
};
let rules = SeedingRules::new(default, HashMap::from([(7, tracker)]));
assert_eq!(rules.for_indexer(7), tracker);
assert_eq!(rules.for_indexer(8), default);
}
async fn grabs(database: &Db) -> Vec<(i64, String, String)> {
sqlx::query_as::<_, (i64, String, String)>(
"SELECT target_id, infohash, state FROM grabs ORDER BY id",