From 591cf27dc52a9e60595b85fc42c5c7c6473200ba Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 12:11:46 +0100 Subject: [PATCH] feat(web): say what a pack was abandoned for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...fd4137966a469b134fbeb51fb91f67951cbbe.json | 74 ++++++ ...51bb65ce9702e3bb1d26b51b93c5fdefa79f.json} | 11 +- ...9e0967206e8186b2157bb4b4d11e9a136759.json} | 11 +- ...cae6e6e327709dc9e5a2073092b75137e13c.json} | 18 +- ...9c02d0a070277a9ddf4d5749545315746389.json} | 11 +- crates/arr-api/src/movies.rs | 52 +++- crates/arr-api/src/search.rs | 10 + crates/arr-api/src/series.rs | 234 +++++++++++++++++- crates/arr-db/src/blacklist.rs | 115 ++++++++- web/index.html | 13 + web/src/main.ts | 130 +++++++++- web/src/releases.ts | 65 +++++ web/src/series.ts | 7 + web/src/style.css | 104 ++++++++ 14 files changed, 820 insertions(+), 35 deletions(-) create mode 100644 .sqlx/query-3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe.json rename .sqlx/{query-aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e.json => query-7aa154a1bd54ec84412a1be6d7db51bb65ce9702e3bb1d26b51b93c5fdefa79f.json} (86%) rename .sqlx/{query-f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba.json => query-8b2aa810e679ddde423a5dc5a1a89e0967206e8186b2157bb4b4d11e9a136759.json} (86%) rename .sqlx/{query-071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97.json => query-939252a81cdd0103b2aaa3cf19d5cae6e6e327709dc9e5a2073092b75137e13c.json} (58%) rename .sqlx/{query-aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f.json => query-9cb6a575aa3e0ff377551a324f839c02d0a070277a9ddf4d5749545315746389.json} (86%) diff --git a/.sqlx/query-3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe.json b/.sqlx/query-3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe.json new file mode 100644 index 0000000..ec49475 --- /dev/null +++ b/.sqlx/query-3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "SELECT g.target_id AS \"season_id!: i64\",\n r.name AS \"name!: String\",\n g.infohash AS \"infohash!: String\",\n g.failed_at,\n g.grabbed_at AS \"grabbed_at!: String\"\n FROM grabs g\n JOIN releases r ON r.id = g.release_id\n JOIN seasons s ON s.id = g.target_id\n WHERE g.target_kind = 'season'\n AND g.state = 'failed'\n AND s.series_id = ?\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = s.id AND e.wanted\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n )\n ORDER BY coalesce(g.failed_at, g.grabbed_at), g.id", + "describe": { + "columns": [ + { + "name": "season_id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "grabs", + "name": "target_id" + } + } + }, + { + "name": "name!: String", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "releases", + "name": "name" + } + } + }, + { + "name": "infohash!: String", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "grabs", + "name": "infohash" + } + } + }, + { + "name": "failed_at", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "grabs", + "name": "failed_at" + } + } + }, + { + "name": "grabbed_at!: String", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "grabs", + "name": "grabbed_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + false + ] + }, + "hash": "3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe" +} diff --git a/.sqlx/query-aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e.json b/.sqlx/query-7aa154a1bd54ec84412a1be6d7db51bb65ce9702e3bb1d26b51b93c5fdefa79f.json similarity index 86% rename from .sqlx/query-aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e.json rename to .sqlx/query-7aa154a1bd54ec84412a1be6d7db51bb65ce9702e3bb1d26b51b93c5fdefa79f.json index 8b93c58..0082174 100644 --- a/.sqlx/query-aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e.json +++ b/.sqlx/query-7aa154a1bd54ec84412a1be6d7db51bb65ce9702e3bb1d26b51b93c5fdefa79f.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id", + "query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule, NULL AS \"blacklist_reason?: String\" FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id", "describe": { "columns": [ { @@ -134,6 +134,12 @@ "name": "rejected_rule" } } + }, + { + "name": "blacklist_reason?: String", + "ordinal": 12, + "type_info": "Null", + "origin": "Expression" } ], "parameters": { @@ -151,8 +157,9 @@ false, true, true, + true, true ] }, - "hash": "aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e" + "hash": "7aa154a1bd54ec84412a1be6d7db51bb65ce9702e3bb1d26b51b93c5fdefa79f" } diff --git a/.sqlx/query-f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba.json b/.sqlx/query-8b2aa810e679ddde423a5dc5a1a89e0967206e8186b2157bb4b4d11e9a136759.json similarity index 86% rename from .sqlx/query-f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba.json rename to .sqlx/query-8b2aa810e679ddde423a5dc5a1a89e0967206e8186b2157bb4b4d11e9a136759.json index 6e0ebcd..90e1265 100644 --- a/.sqlx/query-f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba.json +++ b/.sqlx/query-8b2aa810e679ddde423a5dc5a1a89e0967206e8186b2157bb4b4d11e9a136759.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id", + "query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule, NULL AS \"blacklist_reason?: String\" FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id", "describe": { "columns": [ { @@ -134,6 +134,12 @@ "name": "rejected_rule" } } + }, + { + "name": "blacklist_reason?: String", + "ordinal": 12, + "type_info": "Null", + "origin": "Expression" } ], "parameters": { @@ -151,8 +157,9 @@ false, true, true, + true, true ] }, - "hash": "f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba" + "hash": "8b2aa810e679ddde423a5dc5a1a89e0967206e8186b2157bb4b4d11e9a136759" } diff --git a/.sqlx/query-071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97.json b/.sqlx/query-939252a81cdd0103b2aaa3cf19d5cae6e6e327709dc9e5a2073092b75137e13c.json similarity index 58% rename from .sqlx/query-071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97.json rename to .sqlx/query-939252a81cdd0103b2aaa3cf19d5cae6e6e327709dc9e5a2073092b75137e13c.json index a849773..152b0c0 100644 --- a/.sqlx/query-071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97.json +++ b/.sqlx/query-939252a81cdd0103b2aaa3cf19d5cae6e6e327709dc9e5a2073092b75137e13c.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT normalised_name AS \"normalised_name!: String\", infohash FROM blacklist", + "query": "SELECT normalised_name AS \"normalised_name!: String\",\n infohash,\n reason AS \"reason!: String\"\n FROM blacklist\n ORDER BY id", "describe": { "columns": [ { @@ -24,6 +24,17 @@ "name": "infohash" } } + }, + { + "name": "reason!: String", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "blacklist", + "name": "reason" + } + } } ], "parameters": { @@ -31,8 +42,9 @@ }, "nullable": [ false, - true + true, + false ] }, - "hash": "071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97" + "hash": "939252a81cdd0103b2aaa3cf19d5cae6e6e327709dc9e5a2073092b75137e13c" } diff --git a/.sqlx/query-aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f.json b/.sqlx/query-9cb6a575aa3e0ff377551a324f839c02d0a070277a9ddf4d5749545315746389.json similarity index 86% rename from .sqlx/query-aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f.json rename to .sqlx/query-9cb6a575aa3e0ff377551a324f839c02d0a070277a9ddf4d5749545315746389.json index 6deb1c5..395b36b 100644 --- a/.sqlx/query-aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f.json +++ b/.sqlx/query-9cb6a575aa3e0ff377551a324f839c02d0a070277a9ddf4d5749545315746389.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id", + "query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule, NULL AS \"blacklist_reason?: String\" FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id", "describe": { "columns": [ { @@ -134,6 +134,12 @@ "name": "rejected_rule" } } + }, + { + "name": "blacklist_reason?: String", + "ordinal": 12, + "type_info": "Null", + "origin": "Expression" } ], "parameters": { @@ -151,8 +157,9 @@ false, true, true, + true, true ] }, - "hash": "aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f" + "hash": "9cb6a575aa3e0ff377551a324f839c02d0a070277a9ddf4d5749545315746389" } diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 53a5923..7537d3f 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -83,6 +83,35 @@ pub struct Release { // OpenAPI descriptions and would put the generated client in web/ out of // date, which #227 and #232 own. pub rejected_rule: Option, + // #227: what the blacklist recorded this release as failing on, when + // `rejected_rule` is `blacklisted`. Null on every other row, and on a + // blacklisted row whose blacklist entry has since gone. A size rejection + // is a policy opinion the operator can relax; a corrupt or mismatched + // release is not, and a bare `blacklisted` reads the same for both. + // Plain comment for the same reason as the field above. + pub blacklist_reason: Option, +} + +/// Fill in [`Release::blacklist_reason`] for every deck row the blacklist +/// holds (#227, §6.3). +/// +/// The blacklist is keyed on the *normalised* name, which SQL cannot compute, +/// so the match happens here over the whole table — a handful of rows, the +/// same reasoning as [`arr_db::blacklist::Blacklist`] itself. +pub(crate) async fn attach_blacklist_reasons( + pool: &sqlx::SqlitePool, + releases: &mut [Release], +) -> Result<(), ApiError> { + if releases.is_empty() { + return Ok(()); + } + let blacklist = arr_db::blacklist::Blacklist::load(pool).await?; + for release in releases.iter_mut() { + release.blacklist_reason = blacklist + .reason_for_candidate(&release.name, &release.download_url) + .map(str::to_owned); + } + Ok(()) } /// A library file and what it cost to accept it (`DESIGN.md` §5.7). @@ -650,7 +679,7 @@ pub async fn releases( Path(id): Path, ) -> Result>, ApiError> { load_movie(&state, id).await?; - let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id) + let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule, NULL AS "blacklist_reason?: String" FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id) .fetch_all(pool(&state)?) .await?; let policy = state @@ -662,6 +691,7 @@ pub async fn releases( .ok_or(ApiError::NotFound)? .policy; rescore(&mut releases, &policy, None, 0)?; + attach_blacklist_reasons(pool(&state)?, &mut releases).await?; Ok(Json(releases)) } @@ -1423,6 +1453,26 @@ mod tests { .expect("releases json"); assert_eq!(releases[0]["verdict"], "rejected"); assert_eq!(releases[0]["rejected_rule"], "blacklisted"); + // #227: a row the blacklist has no entry for keeps rendering — the + // rule is all the record holds, and no reason is invented for it. + assert_eq!(releases[0]["blacklist_reason"], serde_json::Value::Null); + + // With the entry, the row says what it was blacklisted for: a + // corrupt file is not the same decision as a size rejection. + arr_db::blacklist::add(pool, None, name, "no original-language audio") + .await + .expect("blacklist"); + let releases: Vec = + reqwest::get(format!("{base}/api/movies/{movie_id}/releases")) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + assert_eq!( + releases[0]["blacklist_reason"], + "no original-language audio" + ); } #[tokio::test] diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 2fc2c6d..b12f1d4 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -156,6 +156,10 @@ pub struct ClassifiedRelease { pub score_terms: ScoreTerms, pub verdict: String, pub rule: Option, + /// What the blacklist recorded this release as failing on (#227, §6.3), + /// when `rule` is `blacklisted`. `None` on every other row. A policy + /// rejection and a bad release both read as `blacklisted` without it. + pub blacklist_reason: Option, } #[utoipa::path( @@ -730,6 +734,11 @@ fn classify( episodes, runtime_minutes, ); + // #227: the reason the blacklist holds is what tells a policy rejection + // the operator can relax from a release that should never be retried. + let blacklist_reason = blacklist + .reason_for_candidate(&release.name, &release.download_url) + .map(str::to_owned); let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) { ("rejected", Some(blacklist::RULE.to_owned())) } else { @@ -781,6 +790,7 @@ fn classify( score_terms: terms, verdict: verdict.to_owned(), rule, + blacklist_reason, }) } diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 12904f8..dacbffe 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -29,7 +29,9 @@ use chrono::{DateTime, NaiveDate, NaiveTime, Utc}; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; -use crate::movies::{pool, rescore, Accepted, ApiError, ErrorBody, Release}; +use crate::movies::{ + attach_blacklist_reasons, pool, rescore, Accepted, ApiError, ErrorBody, Release, +}; use crate::owners::Owner; use crate::search::tmdb_client; use crate::state::{AppState, EpisodeCommand, MetadataCommand, SeasonCommand}; @@ -110,6 +112,10 @@ pub struct Season { /// which is why the row still exists. A conflict for the operator to /// resolve; nothing was deleted from disk. pub vanished: bool, + /// #227. The last season pack that downloaded in full and was condemned + /// at import, while the season is still waiting for a file. `None` when + /// no pack was abandoned, or when the gap has since been filled. + pub import_failure: Option, pub episodes: Vec, } @@ -1039,6 +1045,8 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result, A .fetch_all(pool(state)?) .await?; + let mut failures = season_import_failures(state, series_id).await?; + Ok(seasons .into_iter() .map(|season| Season { @@ -1047,6 +1055,7 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result, A number: season.number, tracked: season.tracked, vanished: season.vanished, + import_failure: failures.remove(&season.id), episodes: episodes .iter() .filter(|episode| episode.season_id == season.id) @@ -1416,7 +1425,7 @@ pub async fn episode_releases( Path(id): Path, ) -> Result>, ApiError> { let episode = load_episode(&state, id).await?; - let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id) + let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule, NULL AS "blacklist_reason?: String" FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id) .fetch_all(pool(&state)?) .await?; let policy = state @@ -1430,6 +1439,7 @@ pub async fn episode_releases( let lengths = season_lengths(&state, episode.series_id).await?; let runtime = series_runtime(&state, episode.series_id).await?; rescore(&mut releases, &policy, Some(&lengths), runtime)?; + attach_blacklist_reasons(pool(&state)?, &mut releases).await?; Ok(Json(releases)) } @@ -1571,7 +1581,7 @@ pub async fn season_releases( ) -> Result>, ApiError> { load_series_row(&state, series_id).await?; let season_id = load_season_id(&state, series_id, number).await?; - let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, season_id) + let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule, NULL AS "blacklist_reason?: String" FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, season_id) .fetch_all(pool(&state)?) .await?; let policy = state @@ -1585,9 +1595,92 @@ pub async fn season_releases( let lengths = season_lengths(&state, series_id).await?; let runtime = series_runtime(&state, series_id).await?; rescore(&mut releases, &policy, Some(&lengths), runtime)?; + attach_blacklist_reasons(pool(&state)?, &mut releases).await?; Ok(Json(releases)) } +/// A grab for this season that downloaded in full and was then condemned at +/// import (#227, §5.7). +/// +/// The torrent stays at 100% in Transmission — §7.3 leaves that lifecycle to +/// the reaper — the release is blacklisted and every episode it was covering +/// reopens as a gap. Nothing on screen connected the two, so the season read +/// `0/10` as though no grab had ever been tried. Every fact is already +/// recorded; this is the row that carries them out. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ImportFailure { + /// The blacklisted release name, as the indexer spelled it. + pub release: String, + /// What the blacklist recorded it as failing on — a policy rule name the + /// operator can relax, or a sentence about the release itself. `None` + /// when no blacklist row answers to either key, which is possible after a + /// blacklist row is cleared by hand; the failure is still true. + pub reason: Option, + /// When the grab entered `failed`, RFC3339. `None` on a row that failed + /// before migration 0030 gave the column a value. + pub failed_at: Option, +} + +/// The most recent abandoned pack per season of one series (#227). +/// +/// Only seasons still waiting for a file are answered: once the gap is +/// filled, the failure is history and the season has nothing to explain. The +/// blacklist reason is matched in memory because its name key is normalised, +/// which SQL cannot compute. +async fn season_import_failures( + state: &AppState, + series_id: i64, +) -> Result, ApiError> { + let pool = pool(state)?; + let rows = sqlx::query!( + r#"SELECT g.target_id AS "season_id!: i64", + r.name AS "name!: String", + g.infohash AS "infohash!: String", + g.failed_at, + g.grabbed_at AS "grabbed_at!: String" + FROM grabs g + JOIN releases r ON r.id = g.release_id + JOIN seasons s ON s.id = g.target_id + WHERE g.target_kind = 'season' + AND g.state = 'failed' + AND s.series_id = ? + AND EXISTS ( + SELECT 1 FROM episodes e + WHERE e.season_id = s.id AND e.wanted + AND NOT EXISTS ( + SELECT 1 FROM media_files f + WHERE f.owner_kind = 'episode' AND f.owner_id = e.id + ) + ) + ORDER BY coalesce(g.failed_at, g.grabbed_at), g.id"#, + series_id + ) + .fetch_all(pool) + .await?; + if rows.is_empty() { + return Ok(HashMap::new()); + } + let blacklist = arr_db::blacklist::Blacklist::load(pool).await?; + // Ascending order, so the last row written for a season wins. + Ok(rows + .into_iter() + .map(|row| { + let reason = blacklist + .reason_for_infohash(&row.infohash) + .or_else(|| blacklist.reason_for_name(&row.name)) + .map(str::to_owned); + ( + row.season_id, + ImportFailure { + release: row.name, + reason, + failed_at: row.failed_at, + }, + ) + }) + .collect()) +} + /// Which lane a season's missing episodes take (#182, §6.2). #[derive(Debug, Clone, Copy, Serialize, ToSchema)] #[serde(rename_all = "snake_case")] @@ -1629,6 +1722,11 @@ pub struct SeasonPackState { /// When a pack sweep for this season last completed. `None` means no /// pack search has ever run, so an empty deck is pending, not settled. pub last_pack_search_at: Option, + /// #227. The last pack that downloaded in full and was then condemned at + /// import, while the season is still waiting for a file. A deck that + /// cannot say this leaves the season reading as though nothing was ever + /// tried, with the torrent still sitting at 100% in Transmission. + pub import_failure: Option, } #[utoipa::path( @@ -1720,6 +1818,9 @@ pub async fn season_pack_state( pack_failures: failed.failures, pack_retry_at: pack_retry_at.map(|at| at.to_rfc3339()), last_pack_search_at, + import_failure: season_import_failures(&state, series_id) + .await? + .remove(&season_id), })) } @@ -1939,6 +2040,133 @@ mod tests { response.json().await.expect("season json") } + async fn seasons_json(base: &str, series_id: i64) -> Vec { + reqwest::get(format!("{base}/api/series/{series_id}/seasons")) + .await + .expect("seasons") + .json() + .await + .expect("seasons json") + } + + /// #227, the operator's own report: a pack downloaded in full, §5.7 + /// condemned it at import, every episode went back to `missing`, and the + /// season read `0/10` with nothing anywhere saying a grab had been tried. + /// The season row, its deck and the deck's blacklisted release now each + /// carry the failure and the reason it failed on. + #[tokio::test] + async fn an_abandoned_pack_is_visible_on_the_season_and_its_deck() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, false).await; + let series_id = series["id"].as_i64().expect("id"); + let episodes: Vec = (1..=10) + .map(|number| { + serde_json::json!({ + "number": number, "title": format!("Episode {number}"), + "air_date": "2025-01-01" + }) + }) + .collect(); + let season = add_season(&base, series_id, 8, serde_json::json!(episodes)).await; + let season_id = season["id"].as_i64().expect("season id"); + assert!( + season["import_failure"].is_null(), + "nothing has been grabbed yet" + ); + // §4.1: tracking the season is what makes its episodes wanted, and a + // season with no intent has no gap to explain. + let tracked = reqwest::Client::new() + .patch(format!("{base}/api/series/{series_id}/seasons/8")) + .json(&serde_json::json!({"tracked": true})) + .send() + .await + .expect("track the season"); + assert_eq!(tracked.status(), StatusCode::OK); + + let pool = state.database().expect("database").pool(); + let name = "Rick.And.Morty.S08.1080p.WEB-DL.x264-GROUP"; + let parsed = arr_parse::parse(name); + let release_id = sqlx::query_scalar::<_, i64>( + "INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict) + VALUES (7, 'pack', ?, 1000, 50, 'url', ?, 0, 'eligible') RETURNING id", + ) + .bind(name) + .bind(serde_json::to_string(&parsed).expect("parsed json")) + .fetch_one(pool) + .await + .expect("release"); + sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)") + .bind(season_id) + .bind(release_id) + .execute(pool) + .await + .expect("association"); + sqlx::query( + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at, failed_at) + VALUES (?, 'season', ?, 'abc123', 'failed', '2026-01-01T00:00:00.000Z', '2026-01-02T00:00:00.000Z')", + ) + .bind(release_id) + .bind(season_id) + .execute(pool) + .await + .expect("failed pack grab"); + + // The failure with no blacklist row yet: still a failure, and the + // reason is simply not known. Rows written before the blacklist + // carried one read this way and must keep rendering. + let seasons = seasons_json(&base, series_id).await; + let row = &seasons[0]["import_failure"]; + assert_eq!(row["release"], name); + assert_eq!(row["reason"], serde_json::Value::Null); + assert_eq!(row["failed_at"], "2026-01-02T00:00:00.000Z"); + + arr_db::blacklist::add(pool, Some("ABC123"), name, "size") + .await + .expect("blacklist"); + + let seasons = seasons_json(&base, series_id).await; + assert_eq!(seasons[0]["import_failure"]["reason"], "size"); + + // The deck the season row leads to says the same thing. + let pack_state: serde_json::Value = reqwest::get(format!( + "{base}/api/series/{series_id}/seasons/8/pack-state" + )) + .await + .expect("pack state") + .json() + .await + .expect("pack state json"); + assert_eq!(pack_state["import_failure"]["release"], name); + assert_eq!(pack_state["import_failure"]["reason"], "size"); + + // §6.3: the release itself is rejected in the deck, and now names + // what it was blacklisted for — a size rejection the operator can + // relax, not a corrupt file they should leave alone. + let releases: Vec = + reqwest::get(format!("{base}/api/series/{series_id}/seasons/8/releases")) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + assert_eq!(releases.len(), 1); + assert_eq!(releases[0]["blacklist_reason"], "size"); + + // Once the gap is filled the failure is history: the season has + // nothing left to explain and stops saying it. + for episode in seasons[0]["episodes"].as_array().expect("episodes") { + sqlx::query("INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 1)") + .bind(episode["id"].as_i64().expect("episode id")) + .bind(format!("/library/e{}.mkv", episode["number"])) + .execute(pool) + .await + .expect("file on disk"); + } + let seasons = seasons_json(&base, series_id).await; + assert!(seasons[0]["import_failure"].is_null()); + } + /// The production case behind #210: every pack of a season is under /// §5.5's per-episode floor, so the deck holds three candidates and /// nothing is grabbable. Writing `allow_below_floor` turns the diff --git a/crates/arr-db/src/blacklist.rs b/crates/arr-db/src/blacklist.rs index 989ab04..bc5843f 100644 --- a/crates/arr-db/src/blacklist.rs +++ b/crates/arr-db/src/blacklist.rs @@ -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, - infohashes: HashSet, + names: HashMap, + infohashes: HashMap, } impl Blacklist { @@ -39,17 +45,30 @@ impl Blacklist { /// /// If the query fails. pub async fn load(pool: &SqlitePool) -> Result { + // 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; diff --git a/web/index.html b/web/index.html index add0cda..ffd5f61 100644 --- a/web/index.html +++ b/web/index.html @@ -541,6 +541,19 @@ + +
diff --git a/web/src/main.ts b/web/src/main.ts index e5aac0c..1f976d3 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -34,6 +34,9 @@ import { } from "./queues"; import { type ActionOutcome, + blacklistAdvice, + blacklistClass, + blacklistReasonLabel, bucketOf, type FilesOutcome, formatAudio, @@ -46,12 +49,14 @@ import { formatSource, formatSweepAge, grabRelease, + type ImportFailure, libraryFolder, type MovieRelease, movieFiles, movieReleases, movieSearchState, overridable, + type PackStateOutcome, probedAttributeTags, queueSearch, removeMovie, @@ -2237,7 +2242,14 @@ function paintBuckets(dom: BucketsDom, releases: MovieRelease[], actions: Releas // §9.3: over-strict filters must be visible, not silently absent — and // where a rule can be waived, the count says so rather than leaving the // way out folded inside a collapsed bucket. - const forceable = releases.filter(overridable).length; + // + // Only a rejected row needs forcing. A row below policy is offered + // already, one click, whatever its rule — and since #211 gave it a rule + // at all, counting every overridable row here claimed the whole + // collapsed deck had to be forced. + const forceable = releases.filter( + (release) => bucketOf(release) === "rejected" && overridable(release), + ).length; const none = document.createElement("li"); none.className = "rel rel-none readout dim"; none.textContent = @@ -2379,20 +2391,36 @@ function releaseRow( }), ); } + // #227: a blacklisted release was grabbed, downloaded and condemned at + // import. Which of the two things happened decides what the operator does + // next, and `blacklisted` alone reads the same for both. + const blacklisted = + bucket === "rejected" && release.rejected_rule === "blacklisted" + ? blacklistClass(release.blacklist_reason) + : null; if (bucket !== "eligible") { - // A rejected row always names its rule; a waived one cannot — the - // `releases` CHECK allows `rejected_rule` only on a rejection. So a - // waived row says the plainer thing the operator can act on, "below - // policy", rather than the name the record keeps for it. + // §9.3: every row that is not eligible names the rule behind it, waived + // and rejected alike — three waivers for three different reasons read + // identically otherwise, and reading release names to tell them apart is + // the Radarr defect this view exists to fix. #211 gave a waived row the + // rule it relaxed; a row written before it still has none, and says the + // plainer thing alone. const verdict = bucket === "waived" - ? "below policy" - : release.rejected_rule - ? `rejected · ${ruleLabel(release.rejected_rule)}` - : "rejected"; + ? release.rejected_rule + ? `below policy · ${ruleLabel(release.rejected_rule)}` + : "below policy" + : blacklisted + ? `blacklisted · ${blacklistReasonLabel(release.blacklist_reason)}` + : release.rejected_rule + ? `rejected · ${ruleLabel(release.rejected_rule)}` + : "rejected"; line.append( chip(verdict, (span) => { span.dataset.verdict = bucket; + if (blacklisted) { + span.dataset.blacklist = blacklisted; + } }), ); } @@ -2401,6 +2429,13 @@ function releaseRow( name.className = "rel-name readout"; name.textContent = release.name; line.append(name); + if (blacklisted) { + const why = document.createElement("span"); + why.className = "rel-why readout"; + why.dataset.blacklist = blacklisted; + why.textContent = blacklistAdvice(release.blacklist_reason); + line.append(why); + } item.append(line); const note = document.createElement("span"); @@ -2884,6 +2919,10 @@ function tvReleasesMain(): TvReleasesView { const sub = must("#tv-releases-sub"); const sweep = must("#tv-releases-sweep"); const statusEl = must("#tv-releases-status"); + const failureEl = must("#tv-releases-failure"); + const failureRelease = must("#tv-failure-release"); + const failureWhat = must("#tv-failure-what"); + const failureNext = must("#tv-failure-next"); const dom = buildBucketDom(must("#tv-buckets")); let request: TvDeckRequest | null = null; @@ -2926,6 +2965,36 @@ function tvReleasesMain(): TvReleasesView { statusEl.dataset.action = ""; } + /** + * #227: the season's own history, above the candidates. A pack that + * downloaded in full and was condemned at import (§5.7) blacklists the + * release and puts every episode back to `missing`, which leaves the season + * reading `0/10` as though nothing had ever been tried. It sits outside the + * status line because it is a fact about the season, not about the request + * in flight, and it has to survive a sweep that repaints the status. + */ + function paintFailure(failure: ImportFailure | null) { + if (failure === null) { + failureEl.hidden = true; + return; + } + failureEl.dataset.blacklist = blacklistClass(failure.reason); + failureEl.hidden = false; + failureRelease.textContent = + failure.failed_at === null + ? failure.release + : `${failure.release} · ${formatSweepAge(failure.failed_at)}`; + // A rule name reads as a preposition — "condemned on size"; a reason + // written as a sentence has to be quoted, not conjugated. + const on = + blacklistClass(failure.reason) === "policy" + ? ` on ${blacklistReasonLabel(failure.reason)}` + : ""; + const said = blacklistClass(failure.reason) === "release" ? `${failure.reason}. ` : ""; + failureWhat.textContent = `downloaded in full, then condemned at import${on} — ${said}every episode went back to missing and the release is blacklisted.`; + failureNext.textContent = `${blacklistAdvice(failure.reason)}.`; + } + const actions: ReleaseActions = { reload: () => load(), notify: (text, tone) => setStatus(text, tone), @@ -2969,9 +3038,17 @@ function tvReleasesMain(): TvReleasesView { setStatus(`releases unavailable — ${outcome.detail}`, "fault"); return; } - if (!paintBuckets(dom, outcome.releases, actions)) { + const painted = paintBuckets(dom, outcome.releases, actions); + // One read of the season's state, shared by the notice and the empty + // verdict below — they answer two questions from the same row. + const pack = await current.target.packState?.(); + if (ticket !== sequence || request !== current) { + return; + } + paintFailure(pack?.kind === "state" ? pack.state.import_failure : null); + if (!painted) { clearBuckets(dom); - await emptyVerdict(current, ticket, sweepIfEmpty); + await emptyVerdict(current, ticket, sweepIfEmpty, pack); return; } setStatus(null); @@ -2985,8 +3062,13 @@ function tvReleasesMain(): TvReleasesView { * answers the third; `last_pack_search_at` separates the first two, the * same way a movie's `last_searched_at` does (#177). */ - async function emptyVerdict(current: TvDeckRequest, ticket: number, sweepIfEmpty: boolean) { - const outcome = await current.target.packState?.(); + async function emptyVerdict( + current: TvDeckRequest, + ticket: number, + sweepIfEmpty: boolean, + known?: PackStateOutcome, + ) { + const outcome = known ?? (await current.target.packState?.()); if (ticket !== sequence || request !== current) { return; } @@ -3078,6 +3160,9 @@ function tvReleasesMain(): TvReleasesView { if (ticket !== sequence || request !== current) { return; } + if (state?.kind === "state") { + paintFailure(state.state.import_failure); + } if (state?.kind === "state" && state.state.last_pack_search_at !== baseline) { sweep.disabled = false; await emptyVerdict(current, ticket, false); @@ -3150,6 +3235,7 @@ function tvReleasesMain(): TvReleasesView { next.returnTo.hidden = true; view.hidden = false; clearBuckets(dom); + paintFailure(null); sweep.disabled = false; back.focus(); void load(true); @@ -3554,6 +3640,24 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { }); line.append(disclose, name, track, seasonCountsChip(season)); + // #227: the season the operator's report is about read `0/10` with + // nothing saying a pack had been grabbed, downloaded in full and thrown + // out at import. The counts chip beside this one is exactly the number + // that looked like nothing was ever tried. + const failure = season.import_failure; + if (failure) { + const kind = blacklistClass(failure.reason); + const when = failure.failed_at === null ? "" : ` ${formatSweepAge(failure.failed_at)}`; + const told = `a pack for this season downloaded in full and failed at import${when} — ${failure.release} · ${blacklistAdvice(failure.reason)}`; + line.append( + chip(`import failed · ${blacklistReasonLabel(failure.reason)}`, (span) => { + span.dataset.flag = "import-failed"; + span.dataset.blacklist = kind; + span.title = told; + span.setAttribute("aria-label", told); + }), + ); + } // The season-level twin of the episode flag above: gone upstream while // files under it remained. if (season.vanished) { diff --git a/web/src/releases.ts b/web/src/releases.ts index c066abb..92873e3 100644 --- a/web/src/releases.ts +++ b/web/src/releases.ts @@ -26,6 +26,12 @@ export interface MovieRelease { score: number | null; verdict: string | null; rejected_rule: string | null; + /** + * What the blacklist recorded this release as failing on (#227, §6.3). + * Null on every row the blacklist does not hold, and on a blacklisted row + * whose entry has since gone — `blacklisted` is then all the record has. + */ + blacklist_reason: string | null; } export type ReleasesOutcome = @@ -113,6 +119,23 @@ export interface SeasonPackState { pack_failures: number; pack_retry_at: string | null; last_pack_search_at: string | null; + import_failure: ImportFailure | null; +} + +/** + * A pack that downloaded in full and was condemned at import (#227, §5.7). + * + * The torrent stays where it is — §7.3 hands that lifecycle to the reaper — + * the release is blacklisted and every episode it covered reopens as a gap. + * Nothing on screen joined those facts, so the season read `0/10` as though + * no grab had ever been tried and the operator found out by opening + * Transmission. + */ +export interface ImportFailure { + release: string; + /** A policy rule name, or a sentence about the release. See `blacklistClass`. */ + reason: string | null; + failed_at: string | null; } export type PackStateOutcome = @@ -467,6 +490,48 @@ export function ruleLabel(rule: string | null): string { return RULE_LABEL[rule] ?? rule.replaceAll("_", " "); } +/** + * What a blacklisting was: the policy turning a file down, or the release + * itself failing (#227). + * + * The blacklist reason is either a policy rule name — the same vocabulary + * `rejected_rule` uses — or a sentence about the release, written where the + * import gave up before any rule was consulted. The two demand opposite + * decisions: a size rejection is the operator's own floor and they can relax + * it, a corrupt or mismatched pack is not theirs to argue with. `unknown` is + * a row the blacklist no longer answers for; nothing is claimed about it. + */ +export type BlacklistClass = "policy" | "release" | "unknown"; + +export function blacklistClass(reason: string | null): BlacklistClass { + if (reason === null) { + return "unknown"; + } + return reason in RULE_LABEL ? "policy" : "release"; +} + +/** The reason as a chip word: a rule's short label, or the sentence itself. */ +export function blacklistReasonLabel(reason: string | null): string { + return reason === null ? "reason not recorded" : (RULE_LABEL[reason] ?? reason); +} + +/** + * What the operator does about it, which is the whole difference between the + * two classes — and the sentence #227 exists to put on screen. + */ +export function blacklistAdvice(reason: string | null): string { + switch (blacklistClass(reason)) { + case "policy": + return waiverOverride(reason) === null + ? "policy rejected the file — that rule has no per-title relaxation" + : `policy rejected the file — relax ${ruleLabel(reason)} for this title and the next candidate can pass`; + case "release": + return "the release itself failed at import — a retry downloads the same files"; + default: + return "blacklisted before the reason was recorded"; + } +} + export async function errorDetail(response: Response): Promise { try { const body = (await response.json()) as { error?: string }; diff --git a/web/src/series.ts b/web/src/series.ts index 0c5d63c..1e0e58e 100644 --- a/web/src/series.ts +++ b/web/src/series.ts @@ -5,6 +5,7 @@ import type { MetadataTrailer } from "./movie"; import type { ActionOutcome, + ImportFailure, MovieRelease, PackStateOutcome, ReleasesOutcome, @@ -49,6 +50,12 @@ export interface ApiSeason { tracked: boolean; /** Gone upstream while a file under it remained — a conflict, not a state. */ vanished: boolean; + /** + * #227: the last pack that downloaded in full and was condemned at import, + * while the season is still waiting for a file. Null once the gap is + * filled — a season with nothing missing has nothing to explain. + */ + import_failure: ImportFailure | null; episodes: ApiEpisode[]; } diff --git a/web/src/style.css b/web/src/style.css index 6bc612b..4980295 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -617,6 +617,48 @@ body { vertical-align: baseline; } +/* #227: a pack abandoned at import, above the candidates. A quiet panel, not + an alert: the failure is history the season owes an explanation for, and + the operator opened this deck to grab something, not to be shouted at. The + tone follows the same reading as the row chips — amber for their own policy + floor, red for a release that failed on its own. */ +.deck-notice { + margin: 0 0 var(--space-6); + padding: var(--space-3) var(--space-4) var(--space-4); + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +.deck-notice[data-blacklist="policy"] { + background: oklch(from var(--signal-warn) l c h / 7%); + border-color: oklch(from var(--signal-warn) l c h / 40%); +} + +.deck-notice[data-blacklist="release"] { + background: oklch(from var(--signal-fault) l c h / 7%); + border-color: oklch(from var(--signal-fault) l c h / 40%); +} + +/* the release name is evidence, in the readout face like every other one */ +.notice-release { + margin: 0 0 var(--space-2); + overflow-wrap: anywhere; + font-size: var(--text-xs); + color: var(--ink); +} + +.notice-line { + margin: 0; + max-width: 68ch; + font-size: var(--text-sm); + color: var(--ink-muted); +} + +.notice-line + .notice-line { + margin-top: var(--space-2); +} + .deck-group { margin: 0 0 var(--space-8); } @@ -644,6 +686,18 @@ body { color: var(--ink-faint); } +.deck-notice .deck-label { + margin-bottom: var(--space-2); +} + +.deck-notice[data-blacklist="policy"] .deck-label { + color: var(--signal-warn); +} + +.deck-notice[data-blacklist="release"] .deck-label { + color: var(--signal-fault); +} + .deck-rows { margin: 0; padding: 0; @@ -719,6 +773,35 @@ body { color: var(--verdict-rejected); } +/* #227: a blacklisted release was grabbed, downloaded in full and thrown out + at import, and the two ways that happens want opposite decisions. The + policy turning a file down is the operator's own floor — amber, the hue + this app already gives a gap they can act on. The release itself failing is + the one case in the deck that is genuinely broken, so it takes fault red; + the "never fault red" rule above is about a rejection, and this is not one. + A row whose blacklist entry is gone claims nothing and stays slate. */ +.chip[data-blacklist="policy"] { + color: var(--signal-warn); + border-color: oklch(from var(--signal-warn) l c h / 55%); +} + +.chip[data-blacklist="release"] { + color: var(--signal-fault); + border-color: oklch(from var(--signal-fault) l c h / 55%); +} + +/* a reason written as a sentence is longer than any rule name, and the chips + around it are fixed-width columns that must not be pushed off the line + (§9.3: no horizontal scroll at any viewport). This one chip wraps instead. */ +.chip[data-blacklist], +.chip[data-flag="import-failed"] { + min-width: 0; + max-width: 100%; + padding-top: var(--space-1); + padding-bottom: var(--space-1); + overflow-wrap: anywhere; +} + /* media state ramp (§4.2): green on disk, violet downloading, amber wanted and still missing. Unwanted-missing and parked are nothing-happening and stay neutral; `parked` exists so a vanished grab never reads as a gap. */ @@ -1199,6 +1282,20 @@ body { border-bottom: 1px solid oklch(from var(--line) l c h / 45%); } +/* #227: what the blacklisting means for the next move, on its own line under + the chips. Only a blacklisted row carries it, so the dense list stays dense + everywhere else. */ +.rel-why { + flex-basis: 100%; + min-width: 0; + font-size: var(--text-xs); + color: var(--ink-muted); +} + +.rel-why[data-blacklist="release"] { + color: oklch(from var(--signal-fault) 0.78 0.1 h); +} + .rel-note { font-size: var(--text-xs); color: var(--ink-muted); @@ -1519,6 +1616,13 @@ body { font-size: var(--text-xs); } +/* #227: the season the operator's own report was about read `0/10` beside + this chip's absence. It sits next to the counts because that number is what + looked like nothing had ever been tried. */ +.chip[data-flag="import-failed"] { + cursor: help; +} + /* issue 122: gone upstream while its file remained — a conflict, amber dashed */ .chip[data-flag="vanished"] { color: var(--signal-warn);