Files
arr/crates/arr-db/src/blacklist.rs
T
naps62-yolo 01af397a40
ci / web (push) Successful in 26s
ci / rust (push) Successful in 1m6s
e2e / e2e (push) Successful in 52s
Hard/soft fail handling and release blacklist (#87)
2026-08-22 23:42:36 +01:00

226 lines
7.8 KiB
Rust

//! The release blacklist (`DESIGN.md` §6.3).
//!
//! Anything that hard-failed post-`ffprobe` (§5.7) is never grabbed again,
//! *including by RSS*. That "including" is why the check lives here rather
//! than inside the grab loop: every path that classifies a release — targeted
//! search, RSS matching, the manual search view — loads the same set and
//! rejects against it, so a new trigger cannot forget the rule.
//!
//! Two keys, because a release escapes either one alone:
//!
//! - **normalised name**, because the same release is re-uploaded under a new
//! infohash, and
//! - **infohash**, because the same torrent is re-listed under a new name.
use std::collections::HashSet;
use sqlx::SqlitePool;
/// The rule name a blacklisted release is rejected under, shared by
/// `releases.rejected_rule` and the manual-search API so §9.3's "every
/// rejected row names the rule that killed it" reads the same everywhere.
pub const RULE: &str = "blacklisted";
/// Every blacklist key, loaded once per tick or request.
///
/// Household scale: a handful of rows. Loading it whole and matching in
/// memory keeps the check identical for a release that has a database row and
/// one that has only just arrived from an indexer.
#[derive(Debug, Clone, Default)]
pub struct Blacklist {
names: HashSet<String>,
infohashes: HashSet<String>,
}
impl Blacklist {
/// Read every blacklist row.
///
/// # Errors
///
/// If the query fails.
pub async fn load(pool: &SqlitePool) -> Result<Self, sqlx::Error> {
let rows = sqlx::query!(
r#"SELECT normalised_name AS "normalised_name!: String", infohash FROM blacklist"#
)
.fetch_all(pool)
.await?;
let mut blacklist = Self::default();
for row in rows {
blacklist.names.insert(row.normalised_name);
if let Some(infohash) = row.infohash {
blacklist.infohashes.insert(infohash.to_ascii_lowercase());
}
}
Ok(blacklist)
}
/// Whether this release name has been blacklisted, under any spelling.
#[must_use]
pub fn blocks_name(&self, release_name: &str) -> bool {
self.names.contains(&arr_parse::normalise(release_name))
}
/// Whether this infohash has been blacklisted. Case-insensitive:
/// Transmission and Torznab disagree on the hex casing.
#[must_use]
pub fn blocks_infohash(&self, infohash: &str) -> bool {
self.infohashes.contains(&infohash.to_ascii_lowercase())
}
/// Whether a candidate is blacklisted before anything is sent to the
/// download client — by name, or by the infohash a magnet link carries.
///
/// A `.torrent` URL hides its infohash until the download client has
/// fetched it, so a non-magnet candidate is judged on its name alone
/// here; [`Blacklist::blocks_infohash`] catches the rest post-add.
#[must_use]
pub fn blocks_candidate(&self, release_name: &str, download_url: &str) -> bool {
self.blocks_name(release_name)
|| magnet_infohash(download_url).is_some_and(|hash| self.blocks_infohash(&hash))
}
}
/// Record a hard fail (§5.7).
///
/// Both keys are written when both are known. The insert is idempotent: a
/// second hard fail of the same torrent keeps the first reason, and a re-run
/// after a crash between the blacklist and the grab update converges.
///
/// # Errors
///
/// If the insert fails.
pub async fn add(
pool: &SqlitePool,
infohash: Option<&str>,
release_name: &str,
reason: &str,
) -> Result<(), sqlx::Error> {
let infohash = infohash.map(str::to_ascii_lowercase);
let normalised = arr_parse::normalise(release_name);
// The name key has no unique index — the same name legitimately arrives
// under several infohashes — so a nameless duplicate is filtered here
// rather than by the schema.
let already = sqlx::query_scalar!(
r#"SELECT count(*) AS "count!: i64" FROM blacklist
WHERE normalised_name = ? AND infohash IS NULL"#,
normalised
)
.fetch_one(pool)
.await?;
if infohash.is_none() && already > 0 {
return Ok(());
}
sqlx::query!(
"INSERT INTO blacklist (infohash, normalised_name, reason)
VALUES (?, ?, ?)
ON CONFLICT (infohash) DO NOTHING",
infohash,
normalised,
reason
)
.execute(pool)
.await?;
Ok(())
}
/// The infohash a magnet link declares, lowercased.
///
/// Only the 40-character hex form is recognised. Base32 `btih` values exist
/// in the wild but Transmission normalises them away, and guessing wrong here
/// would blacklist an unrelated release.
#[must_use]
pub fn magnet_infohash(download_url: &str) -> Option<String> {
if !download_url.starts_with("magnet:") {
return None;
}
download_url
.split(['?', '&'])
.filter_map(|parameter| parameter.strip_prefix("xt=urn:btih:"))
.find(|hash| hash.len() == 40 && hash.chars().all(|c| c.is_ascii_hexdigit()))
.map(str::to_ascii_lowercase)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::{add, magnet_infohash, Blacklist};
use crate::Db;
const HASH: &str = "0123456789abcdef0123456789abcdef01234567";
async fn database() -> (Db, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let database = Db::connect(dir.path().join("test.db")).await.unwrap();
database.migrate().await.unwrap();
(database, dir)
}
#[tokio::test]
async fn both_keys_are_recorded_and_matched() {
let (database, _dir) = database().await;
add(
database.pool(),
Some(&HASH.to_ascii_uppercase()),
"Dune.Part.Two.2024.2160p.WEB-DL.DV",
"dolby_vision_profile",
)
.await
.unwrap();
let blacklist = Blacklist::load(database.pool()).await.unwrap();
// §6.3: the same release under different separators is one key.
assert!(blacklist.blocks_name("Dune Part Two 2024 2160p WEB-DL DV"));
assert!(blacklist.blocks_infohash(HASH));
assert!(!blacklist.blocks_name("Dune Part Two 2024 1080p WEB-DL"));
}
#[tokio::test]
async fn a_second_hard_fail_of_the_same_torrent_adds_no_row() {
let (database, _dir) = database().await;
for _ in 0..2 {
add(database.pool(), Some(HASH), "Some.Release.2024", "size")
.await
.unwrap();
}
add(database.pool(), None, "Some.Release.2024", "size")
.await
.unwrap();
add(database.pool(), None, "Some.Release.2024", "size")
.await
.unwrap();
let count: i64 = sqlx::query_scalar("SELECT count(*) FROM blacklist")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(count, 2, "one row per key, not one per failure");
}
#[tokio::test]
async fn a_magnet_candidate_is_blocked_by_its_infohash_alone() {
let (database, _dir) = database().await;
add(
database.pool(),
Some(HASH),
"Old.Name.2024",
"required_audio",
)
.await
.unwrap();
let blacklist = Blacklist::load(database.pool()).await.unwrap();
let magnet = format!("magnet:?xt=urn:btih:{HASH}&dn=New.Name.2024");
assert!(blacklist.blocks_candidate("New.Name.2024", &magnet));
assert!(!blacklist.blocks_candidate("New.Name.2024", "https://tracker/x.torrent"));
}
#[test]
fn magnet_infohashes_are_read_only_in_the_hex_form() {
let magnet = format!("magnet:?xt=urn:btih:{}&dn=x", HASH.to_ascii_uppercase());
assert_eq!(magnet_infohash(&magnet), Some(HASH.to_owned()));
assert_eq!(magnet_infohash("https://tracker/x.torrent"), None);
assert_eq!(magnet_infohash("magnet:?xt=urn:btih:ZZZZ&dn=x"), None);
}
}