Hard/soft fail handling and release blacklist (#87)
This commit was merged in pull request #87.
This commit is contained in:
+176
-25
@@ -13,13 +13,13 @@
|
||||
//! - the `grabs` row is written from that response, so a crash between the add
|
||||
//! and the insert heals on the next tick instead of leaving an orphan.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use arr_core::policy::{evaluate, Candidate};
|
||||
use arr_core::{score::score, Language, Policy, TitleOverrides, Verdict};
|
||||
use arr_db::{Db, MoviePolicy};
|
||||
use arr_db::{blacklist, Blacklist, Db, MoviePolicy};
|
||||
use arr_dl::{AddTorrent, TorrentSource, TransmissionClient};
|
||||
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
|
||||
|
||||
@@ -167,7 +167,7 @@ impl GrabAction {
|
||||
for grab in sent {
|
||||
let Some(progress) = torrents.get(&grab.infohash.to_ascii_lowercase()) else {
|
||||
// Gone from Transmission. Deciding whether that is a failure
|
||||
// or a manual removal is issue #24's; leaving the row alone
|
||||
// or a manual removal is issue #86's; leaving the row alone
|
||||
// keeps this tick from re-grabbing behind the operator.
|
||||
continue;
|
||||
};
|
||||
@@ -252,6 +252,7 @@ impl GrabAction {
|
||||
indexers: &[i64],
|
||||
loaded: &MoviePolicy,
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
) -> Result<Vec<Eligible>, GrabError> {
|
||||
let request = SearchRequest::Text {
|
||||
query: search_query(movie),
|
||||
@@ -274,6 +275,7 @@ impl GrabAction {
|
||||
&loaded.policy,
|
||||
&loaded.overrides,
|
||||
original_language,
|
||||
blacklist,
|
||||
)
|
||||
.await?;
|
||||
if let Some(candidate) = stored {
|
||||
@@ -314,17 +316,24 @@ impl GrabAction {
|
||||
};
|
||||
let original_language = arr_db::policy::language(original_language);
|
||||
|
||||
let candidates = self
|
||||
.search(database, movie, indexers, &loaded, &original_language)
|
||||
.await?;
|
||||
// §6.3: anything that hard-failed post-ffprobe is never grabbed
|
||||
// again. The same release reappears under new infohashes, so the key
|
||||
// is the normalised name.
|
||||
let blacklisted = blacklisted_names(database).await?;
|
||||
let Some(winner) = candidates
|
||||
.into_iter()
|
||||
.find(|candidate| !blacklisted.contains(&arr_parse::normalise(&candidate.name)))
|
||||
else {
|
||||
// again. `store_release` already rejects a blacklisted candidate as
|
||||
// it classifies it, so this second pass only catches a row that was
|
||||
// classified before the blacklist entry existed.
|
||||
let blacklist = Blacklist::load(database.pool()).await?;
|
||||
let candidates = self
|
||||
.search(
|
||||
database,
|
||||
movie,
|
||||
indexers,
|
||||
&loaded,
|
||||
&original_language,
|
||||
&blacklist,
|
||||
)
|
||||
.await?;
|
||||
let Some(winner) = candidates.into_iter().find(|candidate| {
|
||||
!blacklist.blocks_candidate(&candidate.name, &candidate.download_url)
|
||||
}) else {
|
||||
tracing::info!(
|
||||
movie_id = movie.id,
|
||||
title = movie.title,
|
||||
@@ -345,6 +354,15 @@ impl GrabAction {
|
||||
.await?;
|
||||
let infohash = added.hash.to_ascii_lowercase();
|
||||
|
||||
// §6.3's second key. A `.torrent` link hides its infohash until
|
||||
// Transmission has fetched it, so the same blacklisted torrent can
|
||||
// reach here under a new name.
|
||||
if blacklist.blocks_infohash(&infohash) {
|
||||
self.drop_blacklisted_torrent(database, movie, &winner, &added)
|
||||
.await?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// A duplicate here is the restart case: the torrent was added before
|
||||
// the process died. `DO NOTHING` keeps the original row.
|
||||
let inserted = sqlx::query!(
|
||||
@@ -390,6 +408,60 @@ impl GrabAction {
|
||||
format!("grabbed {} as grab {}", winner.name, inserted.id),
|
||||
)))
|
||||
}
|
||||
|
||||
/// Undo a grab whose infohash turned out to be blacklisted (§6.3).
|
||||
///
|
||||
/// The name is added to the blacklist so the next tick stops at the cheap
|
||||
/// check instead of paying Transmission again, and no `grabs` row is
|
||||
/// written, which leaves the title a gap for the next candidate.
|
||||
async fn drop_blacklisted_torrent(
|
||||
&self,
|
||||
database: &Db,
|
||||
movie: &PendingMovie,
|
||||
winner: &Eligible,
|
||||
added: &arr_dl::AddedTorrent,
|
||||
) -> Result<(), GrabError> {
|
||||
let release_name = &winner.name;
|
||||
blacklist::add(
|
||||
database.pool(),
|
||||
None,
|
||||
release_name,
|
||||
"blacklisted infohash under a new name",
|
||||
)
|
||||
.await?;
|
||||
// `store_release` classified this row before the infohash was known,
|
||||
// so it still reads eligible. Correct it here rather than waiting for
|
||||
// the next search to overwrite it: until then §9.3's manual view
|
||||
// would keep offering a release this tick just refused.
|
||||
sqlx::query!(
|
||||
"UPDATE releases SET verdict = 'rejected', rejected_rule = ? WHERE id = ?",
|
||||
blacklist::RULE,
|
||||
winner.id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
if added.was_duplicate {
|
||||
// The earlier grab's torrent, still working off its seeding
|
||||
// obligation (§7.3). Nothing here deletes a torrent.
|
||||
tracing::warn!(
|
||||
movie_id = movie.id,
|
||||
release = release_name,
|
||||
infohash = added.hash,
|
||||
"blacklisted torrent re-listed under a new name; left seeding"
|
||||
);
|
||||
} else {
|
||||
// This tick added it seconds ago, so it carries no seeding
|
||||
// obligation and has nothing on disk worth keeping.
|
||||
self.transmission.remove_torrent(added.id, true).await?;
|
||||
tracing::warn!(
|
||||
movie_id = movie.id,
|
||||
release = release_name,
|
||||
infohash = added.hash,
|
||||
"blacklisted torrent re-listed under a new name; removed"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for GrabAction {
|
||||
@@ -471,6 +543,11 @@ async fn pending_movies(database: &Db) -> Result<Vec<PendingMovie>, GrabError> {
|
||||
///
|
||||
/// Returns the candidate only when the release is eligible: automatic
|
||||
/// selection never takes a waiver (§9.3).
|
||||
///
|
||||
/// The blacklist is applied here rather than at selection so it reaches every
|
||||
/// trigger (§6.3: "including by RSS") and so the stored row says why — a
|
||||
/// blacklisted release is rejected under the `blacklisted` rule, which is
|
||||
/// what stops §9.3's manual view from offering it as a clean match.
|
||||
async fn store_release(
|
||||
database: &Db,
|
||||
movie_id: i64,
|
||||
@@ -478,6 +555,7 @@ async fn store_release(
|
||||
policy: &Policy,
|
||||
overrides: &TitleOverrides,
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
) -> Result<Option<Eligible>, GrabError> {
|
||||
let parsed = arr_parse::parse(&release.name);
|
||||
let evaluation = evaluate(
|
||||
@@ -501,7 +579,11 @@ async fn store_release(
|
||||
} else {
|
||||
scored.source.saturating_add(scored.seeders)
|
||||
};
|
||||
let (verdict, rule) = verdict_columns(&evaluation.verdict);
|
||||
let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) {
|
||||
("rejected", Some(blacklist::RULE.to_owned()))
|
||||
} else {
|
||||
verdict_columns(&evaluation.verdict)
|
||||
};
|
||||
let parsed_json = serde_json::to_string(&parsed).map_err(|source| GrabError::Parsed {
|
||||
name: release.name.clone(),
|
||||
source,
|
||||
@@ -565,17 +647,6 @@ async fn store_release(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Every blacklist key (§6.3), for filtering candidates. Household scale: a
|
||||
/// handful of rows, refetched per title rather than cached.
|
||||
async fn blacklisted_names(database: &Db) -> Result<HashSet<String>, GrabError> {
|
||||
let names = sqlx::query_scalar!(
|
||||
r#"SELECT normalised_name AS "normalised_name!: String" FROM blacklist"#
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
Ok(names.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Record that the title was searched, so the next tick takes a different one.
|
||||
async fn record_search(database: &Db, movie_id: i64) -> Result<(), GrabError> {
|
||||
sqlx::query!(
|
||||
@@ -730,6 +801,17 @@ mod tests {
|
||||
.collect();
|
||||
success(&json!({"torrents": torrents}))
|
||||
}
|
||||
"torrent-remove" => {
|
||||
let removed: Vec<i64> = arguments["ids"]
|
||||
.as_array()
|
||||
.map(|ids| ids.iter().filter_map(serde_json::Value::as_i64).collect())
|
||||
.unwrap_or_default();
|
||||
self.torrents
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|torrent| !removed.contains(&torrent.id));
|
||||
success(&json!({}))
|
||||
}
|
||||
_ => success(&json!({})),
|
||||
}
|
||||
}
|
||||
@@ -1022,6 +1104,75 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// §6.3 reaches every trigger, so the exclusion is applied where a
|
||||
/// release is classified rather than where one is picked: the cached row
|
||||
/// says `blacklisted`, which is also what stops §9.3's manual view from
|
||||
/// offering it as a clean match.
|
||||
#[tokio::test]
|
||||
async fn a_blacklisted_release_is_cached_as_rejected() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
blacklist::add(
|
||||
database.pool(),
|
||||
Some("ffff"),
|
||||
"Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.Atmos",
|
||||
"dolby_vision_profile",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
let (verdict, rule): (String, Option<String>) =
|
||||
sqlx::query_as("SELECT verdict, rejected_rule FROM releases WHERE guid = 'good'")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(verdict, "rejected");
|
||||
assert_eq!(rule.as_deref(), Some("blacklisted"));
|
||||
}
|
||||
|
||||
/// §6.3's second key. A `.torrent` link hides its infohash until
|
||||
/// Transmission has fetched it, so the blacklisted torrent is only
|
||||
/// recognised after the add — and must not leave a grab behind.
|
||||
#[tokio::test]
|
||||
async fn a_blacklisted_infohash_never_becomes_a_grab() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
// What the fake hands back for the first torrent it accepts.
|
||||
blacklist::add(
|
||||
database.pool(),
|
||||
Some(&format!("{:040x}", 7)),
|
||||
"Some.Older.Name.Of.The.Same.Torrent",
|
||||
"required_audio",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
assert!(grabs(&database).await.is_empty());
|
||||
assert!(
|
||||
fake.torrents().is_empty(),
|
||||
"a torrent added this tick and then found blacklisted is removed"
|
||||
);
|
||||
// Recorded under its new name, so the next tick stops before paying
|
||||
// Transmission again.
|
||||
let blacklist = Blacklist::load(database.pool()).await.unwrap();
|
||||
assert!(blacklist.blocks_name("Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.Atmos"));
|
||||
// And the cached row stops reading eligible straight away, so §9.3's
|
||||
// manual view never offers what this tick just refused.
|
||||
let (verdict, rule): (String, Option<String>) =
|
||||
sqlx::query_as("SELECT verdict, rejected_rule FROM releases WHERE guid = 'good'")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(verdict, "rejected");
|
||||
assert_eq!(rule.as_deref(), Some("blacklisted"));
|
||||
}
|
||||
|
||||
/// §6.3: `blocked` stops targeted search for a title.
|
||||
#[tokio::test]
|
||||
async fn a_blocked_title_is_not_searched() {
|
||||
|
||||
Reference in New Issue
Block a user