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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user