feat(web): say what a pack was abandoned for

A pack that hard-failed at import blacklisted its release, put every
episode back to missing and left the season reading 0/10, with nothing
on screen joining the two. Every fact was already recorded.

The blacklist now carries its reason out of the database: deck rows read
`blacklisted · size` instead of a bare `blacklisted`, and say whether the
policy turned the file down — relaxable for this title — or the release
itself failed, which a retry only repeats. A season whose pack was
abandoned says so on its row and above its deck, with the release name,
when it failed, and what it failed on. A row the blacklist no longer
answers for keeps rendering and claims no reason.

Two defects from the integration review of #211 sit in the same code and
are fixed here: a waived row threw away the rule it now carries and read
a bare `below policy`, and the empty-eligible count called every waived
row force-grabbable, since #211 gave those rows the rule `overridable`
reads.

Verified against a real browser: series detail, both season decks and
their buckets, at 1280 and 390 px.

Refs #227, #211
This commit is contained in:
Miguel Palhas
2026-08-25 12:11:46 +01:00
parent 58a45fc98e
commit 591cf27dc5
14 changed files with 820 additions and 35 deletions
+106 -9
View File
@@ -12,7 +12,7 @@
//! infohash, and
//! - **infohash**, because the same torrent is re-listed under a new name.
use std::collections::HashSet;
use std::collections::HashMap;
use sqlx::SqlitePool;
@@ -21,15 +21,21 @@ use sqlx::SqlitePool;
/// 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.
/// Every blacklist key and the reason it was written under, 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.
///
/// The reason travels with the key because §9.3's deck has to say what a row
/// was blacklisted for (#227): a release the policy rejected on size is one
/// the operator can relax and try again, and a corrupt or mismatched one is
/// not. A bare `blacklisted` makes those two read the same.
#[derive(Debug, Clone, Default)]
pub struct Blacklist {
names: HashSet<String>,
infohashes: HashSet<String>,
names: HashMap<String, String>,
infohashes: HashMap<String, String>,
}
impl Blacklist {
@@ -39,17 +45,30 @@ impl Blacklist {
///
/// If the query fails.
pub async fn load(pool: &SqlitePool) -> Result<Self, sqlx::Error> {
// Oldest first, so a key that hard-failed twice under different
// reasons keeps the first one — the same rule [`add`] applies when it
// refuses to write the second row.
let rows = sqlx::query!(
r#"SELECT normalised_name AS "normalised_name!: String", infohash FROM blacklist"#
r#"SELECT normalised_name AS "normalised_name!: String",
infohash,
reason AS "reason!: String"
FROM blacklist
ORDER BY id"#
)
.fetch_all(pool)
.await?;
let mut blacklist = Self::default();
for row in rows {
blacklist.names.insert(row.normalised_name);
blacklist
.names
.entry(row.normalised_name)
.or_insert_with(|| row.reason.clone());
if let Some(infohash) = row.infohash {
blacklist.infohashes.insert(infohash.to_ascii_lowercase());
blacklist
.infohashes
.entry(infohash.to_ascii_lowercase())
.or_insert(row.reason);
}
}
Ok(blacklist)
@@ -58,14 +77,44 @@ impl 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))
self.names.contains_key(&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())
self.infohashes.contains_key(&infohash.to_ascii_lowercase())
}
/// What this release name was blacklisted for, or `None` if it was not.
#[must_use]
pub fn reason_for_name(&self, release_name: &str) -> Option<&str> {
self.names
.get(&arr_parse::normalise(release_name))
.map(String::as_str)
}
/// What this infohash was blacklisted for, or `None` if it was not.
#[must_use]
pub fn reason_for_infohash(&self, infohash: &str) -> Option<&str> {
self.infohashes
.get(&infohash.to_ascii_lowercase())
.map(String::as_str)
}
/// What a candidate was blacklisted for, under either key (#227).
///
/// The name is asked first: it is the key every candidate has, and a
/// `.torrent` URL hides its infohash until the download client fetches
/// it, exactly as [`Blacklist::blocks_candidate`] describes.
#[must_use]
pub fn reason_for_candidate(&self, release_name: &str, download_url: &str) -> Option<&str> {
self.reason_for_name(release_name).or_else(|| {
magnet_infohash(download_url)
.and_then(|hash| self.infohashes.get(&hash))
.map(String::as_str)
})
}
/// Whether a candidate is blacklisted before anything is sent to the
@@ -175,6 +224,54 @@ mod tests {
assert!(!blacklist.blocks_name("Dune Part Two 2024 1080p WEB-DL"));
}
#[tokio::test]
async fn a_key_carries_the_reason_it_was_blacklisted_for() {
let (database, _dir) = database().await;
add(
database.pool(),
Some(HASH),
"Rick.And.Morty.S08.1080p",
"size",
)
.await
.unwrap();
add(
database.pool(),
None,
"Some.Other.Pack.S01",
"no file matches a wanted episode",
)
.await
.unwrap();
let blacklist = Blacklist::load(database.pool()).await.unwrap();
// #227: the deck has to tell a policy rejection from a bad release,
// and the reason is the only thing that says which.
assert_eq!(
blacklist.reason_for_name("Rick And Morty S08 1080p"),
Some("size")
);
assert_eq!(
blacklist.reason_for_infohash(&HASH.to_ascii_uppercase()),
Some("size")
);
assert_eq!(
blacklist.reason_for_name("Some.Other.Pack.S01"),
Some("no file matches a wanted episode")
);
assert_eq!(blacklist.reason_for_name("Never.Failed.S01"), None);
let magnet = format!("magnet:?xt=urn:btih:{HASH}&dn=Renamed.Pack");
assert_eq!(
blacklist.reason_for_candidate("Renamed.Pack", &magnet),
Some("size")
);
assert_eq!(
blacklist.reason_for_candidate("Renamed.Pack", "https://tracker/x.torrent"),
None
);
}
#[tokio::test]
async fn a_second_hard_fail_of_the_same_torrent_adds_no_row() {
let (database, _dir) = database().await;