226 lines
7.8 KiB
Rust
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);
|
|
}
|
|
}
|