//! 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, infohashes: HashSet, } impl Blacklist { /// Read every blacklist row. /// /// # Errors /// /// If the query fails. pub async fn load(pool: &SqlitePool) -> Result { 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 { 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); } }