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
@@ -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"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -134,6 +134,12 @@
"name": "rejected_rule" "name": "rejected_rule"
} }
} }
},
{
"name": "blacklist_reason?: String",
"ordinal": 12,
"type_info": "Null",
"origin": "Expression"
} }
], ],
"parameters": { "parameters": {
@@ -151,8 +157,9 @@
false, false,
true, true,
true, true,
true,
true true
] ]
}, },
"hash": "aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e" "hash": "7aa154a1bd54ec84412a1be6d7db51bb65ce9702e3bb1d26b51b93c5fdefa79f"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -134,6 +134,12 @@
"name": "rejected_rule" "name": "rejected_rule"
} }
} }
},
{
"name": "blacklist_reason?: String",
"ordinal": 12,
"type_info": "Null",
"origin": "Expression"
} }
], ],
"parameters": { "parameters": {
@@ -151,8 +157,9 @@
false, false,
true, true,
true, true,
true,
true true
] ]
}, },
"hash": "f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba" "hash": "8b2aa810e679ddde423a5dc5a1a89e0967206e8186b2157bb4b4d11e9a136759"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -24,6 +24,17 @@
"name": "infohash" "name": "infohash"
} }
} }
},
{
"name": "reason!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "blacklist",
"name": "reason"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -31,8 +42,9 @@
}, },
"nullable": [ "nullable": [
false, false,
true true,
false
] ]
}, },
"hash": "071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97" "hash": "939252a81cdd0103b2aaa3cf19d5cae6e6e327709dc9e5a2073092b75137e13c"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -134,6 +134,12 @@
"name": "rejected_rule" "name": "rejected_rule"
} }
} }
},
{
"name": "blacklist_reason?: String",
"ordinal": 12,
"type_info": "Null",
"origin": "Expression"
} }
], ],
"parameters": { "parameters": {
@@ -151,8 +157,9 @@
false, false,
true, true,
true, true,
true,
true true
] ]
}, },
"hash": "aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f" "hash": "9cb6a575aa3e0ff377551a324f839c02d0a070277a9ddf4d5749545315746389"
} }
+51 -1
View File
@@ -83,6 +83,35 @@ pub struct Release {
// OpenAPI descriptions and would put the generated client in web/ out of // OpenAPI descriptions and would put the generated client in web/ out of
// date, which #227 and #232 own. // date, which #227 and #232 own.
pub rejected_rule: Option<String>, pub rejected_rule: Option<String>,
// #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<String>,
}
/// 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). /// 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<i64>, Path(id): Path<i64>,
) -> Result<Json<Vec<Release>>, ApiError> { ) -> Result<Json<Vec<Release>>, ApiError> {
load_movie(&state, id).await?; 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)?) .fetch_all(pool(&state)?)
.await?; .await?;
let policy = state let policy = state
@@ -662,6 +691,7 @@ pub async fn releases(
.ok_or(ApiError::NotFound)? .ok_or(ApiError::NotFound)?
.policy; .policy;
rescore(&mut releases, &policy, None, 0)?; rescore(&mut releases, &policy, None, 0)?;
attach_blacklist_reasons(pool(&state)?, &mut releases).await?;
Ok(Json(releases)) Ok(Json(releases))
} }
@@ -1423,6 +1453,26 @@ mod tests {
.expect("releases json"); .expect("releases json");
assert_eq!(releases[0]["verdict"], "rejected"); assert_eq!(releases[0]["verdict"], "rejected");
assert_eq!(releases[0]["rejected_rule"], "blacklisted"); 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<serde_json::Value> =
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] #[tokio::test]
+10
View File
@@ -156,6 +156,10 @@ pub struct ClassifiedRelease {
pub score_terms: ScoreTerms, pub score_terms: ScoreTerms,
pub verdict: String, pub verdict: String,
pub rule: Option<String>, pub rule: Option<String>,
/// 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<String>,
} }
#[utoipa::path( #[utoipa::path(
@@ -730,6 +734,11 @@ fn classify(
episodes, episodes,
runtime_minutes, 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) { let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) {
("rejected", Some(blacklist::RULE.to_owned())) ("rejected", Some(blacklist::RULE.to_owned()))
} else { } else {
@@ -781,6 +790,7 @@ fn classify(
score_terms: terms, score_terms: terms,
verdict: verdict.to_owned(), verdict: verdict.to_owned(),
rule, rule,
blacklist_reason,
}) })
} }
+231 -3
View File
@@ -29,7 +29,9 @@ use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema}; 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::owners::Owner;
use crate::search::tmdb_client; use crate::search::tmdb_client;
use crate::state::{AppState, EpisodeCommand, MetadataCommand, SeasonCommand}; 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 /// which is why the row still exists. A conflict for the operator to
/// resolve; nothing was deleted from disk. /// resolve; nothing was deleted from disk.
pub vanished: bool, 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<ImportFailure>,
pub episodes: Vec<Episode>, pub episodes: Vec<Episode>,
} }
@@ -1039,6 +1045,8 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
.fetch_all(pool(state)?) .fetch_all(pool(state)?)
.await?; .await?;
let mut failures = season_import_failures(state, series_id).await?;
Ok(seasons Ok(seasons
.into_iter() .into_iter()
.map(|season| Season { .map(|season| Season {
@@ -1047,6 +1055,7 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
number: season.number, number: season.number,
tracked: season.tracked, tracked: season.tracked,
vanished: season.vanished, vanished: season.vanished,
import_failure: failures.remove(&season.id),
episodes: episodes episodes: episodes
.iter() .iter()
.filter(|episode| episode.season_id == season.id) .filter(|episode| episode.season_id == season.id)
@@ -1416,7 +1425,7 @@ pub async fn episode_releases(
Path(id): Path<i64>, Path(id): Path<i64>,
) -> Result<Json<Vec<Release>>, ApiError> { ) -> Result<Json<Vec<Release>>, ApiError> {
let episode = load_episode(&state, id).await?; 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)?) .fetch_all(pool(&state)?)
.await?; .await?;
let policy = state let policy = state
@@ -1430,6 +1439,7 @@ pub async fn episode_releases(
let lengths = season_lengths(&state, episode.series_id).await?; let lengths = season_lengths(&state, episode.series_id).await?;
let runtime = series_runtime(&state, episode.series_id).await?; let runtime = series_runtime(&state, episode.series_id).await?;
rescore(&mut releases, &policy, Some(&lengths), runtime)?; rescore(&mut releases, &policy, Some(&lengths), runtime)?;
attach_blacklist_reasons(pool(&state)?, &mut releases).await?;
Ok(Json(releases)) Ok(Json(releases))
} }
@@ -1571,7 +1581,7 @@ pub async fn season_releases(
) -> Result<Json<Vec<Release>>, ApiError> { ) -> Result<Json<Vec<Release>>, ApiError> {
load_series_row(&state, series_id).await?; load_series_row(&state, series_id).await?;
let season_id = load_season_id(&state, series_id, number).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)?) .fetch_all(pool(&state)?)
.await?; .await?;
let policy = state let policy = state
@@ -1585,9 +1595,92 @@ pub async fn season_releases(
let lengths = season_lengths(&state, series_id).await?; let lengths = season_lengths(&state, series_id).await?;
let runtime = series_runtime(&state, series_id).await?; let runtime = series_runtime(&state, series_id).await?;
rescore(&mut releases, &policy, Some(&lengths), runtime)?; rescore(&mut releases, &policy, Some(&lengths), runtime)?;
attach_blacklist_reasons(pool(&state)?, &mut releases).await?;
Ok(Json(releases)) 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<String>,
/// When the grab entered `failed`, RFC3339. `None` on a row that failed
/// before migration 0030 gave the column a value.
pub failed_at: Option<String>,
}
/// 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<HashMap<i64, ImportFailure>, 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). /// Which lane a season's missing episodes take (#182, §6.2).
#[derive(Debug, Clone, Copy, Serialize, ToSchema)] #[derive(Debug, Clone, Copy, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -1629,6 +1722,11 @@ pub struct SeasonPackState {
/// When a pack sweep for this season last completed. `None` means no /// 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. /// pack search has ever run, so an empty deck is pending, not settled.
pub last_pack_search_at: Option<String>, pub last_pack_search_at: Option<String>,
/// #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<ImportFailure>,
} }
#[utoipa::path( #[utoipa::path(
@@ -1720,6 +1818,9 @@ pub async fn season_pack_state(
pack_failures: failed.failures, pack_failures: failed.failures,
pack_retry_at: pack_retry_at.map(|at| at.to_rfc3339()), pack_retry_at: pack_retry_at.map(|at| at.to_rfc3339()),
last_pack_search_at, 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") response.json().await.expect("season json")
} }
async fn seasons_json(base: &str, series_id: i64) -> Vec<serde_json::Value> {
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<serde_json::Value> = (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<serde_json::Value> =
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 /// 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 /// §5.5's per-episode floor, so the deck holds three candidates and
/// nothing is grabbable. Writing `allow_below_floor` turns the /// nothing is grabbable. Writing `allow_below_floor` turns the
+106 -9
View File
@@ -12,7 +12,7 @@
//! infohash, and //! infohash, and
//! - **infohash**, because the same torrent is re-listed under a new name. //! - **infohash**, because the same torrent is re-listed under a new name.
use std::collections::HashSet; use std::collections::HashMap;
use sqlx::SqlitePool; use sqlx::SqlitePool;
@@ -21,15 +21,21 @@ use sqlx::SqlitePool;
/// rejected row names the rule that killed it" reads the same everywhere. /// rejected row names the rule that killed it" reads the same everywhere.
pub const RULE: &str = "blacklisted"; 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 /// 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 /// memory keeps the check identical for a release that has a database row and
/// one that has only just arrived from an indexer. /// 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)] #[derive(Debug, Clone, Default)]
pub struct Blacklist { pub struct Blacklist {
names: HashSet<String>, names: HashMap<String, String>,
infohashes: HashSet<String>, infohashes: HashMap<String, String>,
} }
impl Blacklist { impl Blacklist {
@@ -39,17 +45,30 @@ impl Blacklist {
/// ///
/// If the query fails. /// If the query fails.
pub async fn load(pool: &SqlitePool) -> Result<Self, sqlx::Error> { 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!( 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) .fetch_all(pool)
.await?; .await?;
let mut blacklist = Self::default(); let mut blacklist = Self::default();
for row in rows { 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 { 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) Ok(blacklist)
@@ -58,14 +77,44 @@ impl Blacklist {
/// Whether this release name has been blacklisted, under any spelling. /// Whether this release name has been blacklisted, under any spelling.
#[must_use] #[must_use]
pub fn blocks_name(&self, release_name: &str) -> bool { 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: /// Whether this infohash has been blacklisted. Case-insensitive:
/// Transmission and Torznab disagree on the hex casing. /// Transmission and Torznab disagree on the hex casing.
#[must_use] #[must_use]
pub fn blocks_infohash(&self, infohash: &str) -> bool { 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 /// 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")); 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] #[tokio::test]
async fn a_second_hard_fail_of_the_same_torrent_adds_no_row() { async fn a_second_hard_fail_of_the_same_torrent_adds_no_row() {
let (database, _dir) = database().await; let (database, _dir) = database().await;
+13
View File
@@ -541,6 +541,19 @@
<button type="button" class="control" id="tv-releases-sweep">re-search</button> <button type="button" class="control" id="tv-releases-sweep">re-search</button>
</header> </header>
<p class="deck-status readout" id="tv-releases-status" role="status" hidden></p> <p class="deck-status readout" id="tv-releases-status" role="status" hidden></p>
<!-- issue 227: a pack that downloaded in full and was condemned at
import (§5.7). It outlives the status line because it is a fact
about the season, not about the request in flight. -->
<section class="deck-notice" id="tv-releases-failure" hidden aria-labelledby="tv-failure-label">
<h3 class="deck-label" id="tv-failure-label">pack abandoned at import</h3>
<p class="notice-release readout" id="tv-failure-release"></p>
<p class="notice-line" id="tv-failure-what"></p>
<p class="notice-line" id="tv-failure-next"></p>
<p class="notice-line dim">
nothing was imported, and the torrent was left where it is: nothing is
deleted early to satisfy the library (§7.3).
</p>
</section>
<div id="tv-buckets"></div> <div id="tv-buckets"></div>
</main> </main>
+117 -13
View File
@@ -34,6 +34,9 @@ import {
} from "./queues"; } from "./queues";
import { import {
type ActionOutcome, type ActionOutcome,
blacklistAdvice,
blacklistClass,
blacklistReasonLabel,
bucketOf, bucketOf,
type FilesOutcome, type FilesOutcome,
formatAudio, formatAudio,
@@ -46,12 +49,14 @@ import {
formatSource, formatSource,
formatSweepAge, formatSweepAge,
grabRelease, grabRelease,
type ImportFailure,
libraryFolder, libraryFolder,
type MovieRelease, type MovieRelease,
movieFiles, movieFiles,
movieReleases, movieReleases,
movieSearchState, movieSearchState,
overridable, overridable,
type PackStateOutcome,
probedAttributeTags, probedAttributeTags,
queueSearch, queueSearch,
removeMovie, 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 // §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 // where a rule can be waived, the count says so rather than leaving the
// way out folded inside a collapsed bucket. // 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"); const none = document.createElement("li");
none.className = "rel rel-none readout dim"; none.className = "rel rel-none readout dim";
none.textContent = 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") { if (bucket !== "eligible") {
// A rejected row always names its rule; a waived one cannot — the // §9.3: every row that is not eligible names the rule behind it, waived
// `releases` CHECK allows `rejected_rule` only on a rejection. So a // and rejected alike — three waivers for three different reasons read
// waived row says the plainer thing the operator can act on, "below // identically otherwise, and reading release names to tell them apart is
// policy", rather than the name the record keeps for it. // 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 = const verdict =
bucket === "waived" bucket === "waived"
? "below policy" ? release.rejected_rule
: release.rejected_rule ? `below policy · ${ruleLabel(release.rejected_rule)}`
? `rejected · ${ruleLabel(release.rejected_rule)}` : "below policy"
: "rejected"; : blacklisted
? `blacklisted · ${blacklistReasonLabel(release.blacklist_reason)}`
: release.rejected_rule
? `rejected · ${ruleLabel(release.rejected_rule)}`
: "rejected";
line.append( line.append(
chip(verdict, (span) => { chip(verdict, (span) => {
span.dataset.verdict = bucket; span.dataset.verdict = bucket;
if (blacklisted) {
span.dataset.blacklist = blacklisted;
}
}), }),
); );
} }
@@ -2401,6 +2429,13 @@ function releaseRow(
name.className = "rel-name readout"; name.className = "rel-name readout";
name.textContent = release.name; name.textContent = release.name;
line.append(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); item.append(line);
const note = document.createElement("span"); const note = document.createElement("span");
@@ -2884,6 +2919,10 @@ function tvReleasesMain(): TvReleasesView {
const sub = must<HTMLElement>("#tv-releases-sub"); const sub = must<HTMLElement>("#tv-releases-sub");
const sweep = must<HTMLButtonElement>("#tv-releases-sweep"); const sweep = must<HTMLButtonElement>("#tv-releases-sweep");
const statusEl = must<HTMLElement>("#tv-releases-status"); const statusEl = must<HTMLElement>("#tv-releases-status");
const failureEl = must<HTMLElement>("#tv-releases-failure");
const failureRelease = must<HTMLElement>("#tv-failure-release");
const failureWhat = must<HTMLElement>("#tv-failure-what");
const failureNext = must<HTMLElement>("#tv-failure-next");
const dom = buildBucketDom(must<HTMLElement>("#tv-buckets")); const dom = buildBucketDom(must<HTMLElement>("#tv-buckets"));
let request: TvDeckRequest | null = null; let request: TvDeckRequest | null = null;
@@ -2926,6 +2965,36 @@ function tvReleasesMain(): TvReleasesView {
statusEl.dataset.action = ""; 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 = { const actions: ReleaseActions = {
reload: () => load(), reload: () => load(),
notify: (text, tone) => setStatus(text, tone), notify: (text, tone) => setStatus(text, tone),
@@ -2969,9 +3038,17 @@ function tvReleasesMain(): TvReleasesView {
setStatus(`releases unavailable — ${outcome.detail}`, "fault"); setStatus(`releases unavailable — ${outcome.detail}`, "fault");
return; 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); clearBuckets(dom);
await emptyVerdict(current, ticket, sweepIfEmpty); await emptyVerdict(current, ticket, sweepIfEmpty, pack);
return; return;
} }
setStatus(null); setStatus(null);
@@ -2985,8 +3062,13 @@ function tvReleasesMain(): TvReleasesView {
* answers the third; `last_pack_search_at` separates the first two, the * answers the third; `last_pack_search_at` separates the first two, the
* same way a movie's `last_searched_at` does (#177). * same way a movie's `last_searched_at` does (#177).
*/ */
async function emptyVerdict(current: TvDeckRequest, ticket: number, sweepIfEmpty: boolean) { async function emptyVerdict(
const outcome = await current.target.packState?.(); current: TvDeckRequest,
ticket: number,
sweepIfEmpty: boolean,
known?: PackStateOutcome,
) {
const outcome = known ?? (await current.target.packState?.());
if (ticket !== sequence || request !== current) { if (ticket !== sequence || request !== current) {
return; return;
} }
@@ -3078,6 +3160,9 @@ function tvReleasesMain(): TvReleasesView {
if (ticket !== sequence || request !== current) { if (ticket !== sequence || request !== current) {
return; return;
} }
if (state?.kind === "state") {
paintFailure(state.state.import_failure);
}
if (state?.kind === "state" && state.state.last_pack_search_at !== baseline) { if (state?.kind === "state" && state.state.last_pack_search_at !== baseline) {
sweep.disabled = false; sweep.disabled = false;
await emptyVerdict(current, ticket, false); await emptyVerdict(current, ticket, false);
@@ -3150,6 +3235,7 @@ function tvReleasesMain(): TvReleasesView {
next.returnTo.hidden = true; next.returnTo.hidden = true;
view.hidden = false; view.hidden = false;
clearBuckets(dom); clearBuckets(dom);
paintFailure(null);
sweep.disabled = false; sweep.disabled = false;
back.focus(); back.focus();
void load(true); void load(true);
@@ -3554,6 +3640,24 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
}); });
line.append(disclose, name, track, seasonCountsChip(season)); 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 // The season-level twin of the episode flag above: gone upstream while
// files under it remained. // files under it remained.
if (season.vanished) { if (season.vanished) {
+65
View File
@@ -26,6 +26,12 @@ export interface MovieRelease {
score: number | null; score: number | null;
verdict: string | null; verdict: string | null;
rejected_rule: 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 = export type ReleasesOutcome =
@@ -113,6 +119,23 @@ export interface SeasonPackState {
pack_failures: number; pack_failures: number;
pack_retry_at: string | null; pack_retry_at: string | null;
last_pack_search_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 = export type PackStateOutcome =
@@ -467,6 +490,48 @@ export function ruleLabel(rule: string | null): string {
return RULE_LABEL[rule] ?? rule.replaceAll("_", " "); 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<string> { export async function errorDetail(response: Response): Promise<string> {
try { try {
const body = (await response.json()) as { error?: string }; const body = (await response.json()) as { error?: string };
+7
View File
@@ -5,6 +5,7 @@
import type { MetadataTrailer } from "./movie"; import type { MetadataTrailer } from "./movie";
import type { import type {
ActionOutcome, ActionOutcome,
ImportFailure,
MovieRelease, MovieRelease,
PackStateOutcome, PackStateOutcome,
ReleasesOutcome, ReleasesOutcome,
@@ -49,6 +50,12 @@ export interface ApiSeason {
tracked: boolean; tracked: boolean;
/** Gone upstream while a file under it remained — a conflict, not a state. */ /** Gone upstream while a file under it remained — a conflict, not a state. */
vanished: boolean; 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[]; episodes: ApiEpisode[];
} }
+104
View File
@@ -617,6 +617,48 @@ body {
vertical-align: baseline; 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 { .deck-group {
margin: 0 0 var(--space-8); margin: 0 0 var(--space-8);
} }
@@ -644,6 +686,18 @@ body {
color: var(--ink-faint); 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 { .deck-rows {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -719,6 +773,35 @@ body {
color: var(--verdict-rejected); 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 /* media state ramp (§4.2): green on disk, violet downloading, amber wanted
and still missing. Unwanted-missing and parked are nothing-happening and and still missing. Unwanted-missing and parked are nothing-happening and
stay neutral; `parked` exists so a vanished grab never reads as a gap. */ 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%); 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 { .rel-note {
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--ink-muted); color: var(--ink-muted);
@@ -1519,6 +1616,13 @@ body {
font-size: var(--text-xs); 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 */ /* issue 122: gone upstream while its file remained — a conflict, amber dashed */
.chip[data-flag="vanished"] { .chip[data-flag="vanished"] {
color: var(--signal-warn); color: var(--signal-warn);