Merge #227: say what a pack was abandoned for

Closes #227
This commit is contained in:
Miguel Palhas
2026-08-25 12:12:34 +01:00
14 changed files with 820 additions and 35 deletions
+51 -1
View File
@@ -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<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).
@@ -650,7 +679,7 @@ pub async fn releases(
Path(id): Path<i64>,
) -> Result<Json<Vec<Release>>, 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<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]
+10
View File
@@ -156,6 +156,10 @@ pub struct ClassifiedRelease {
pub score_terms: ScoreTerms,
pub verdict: 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(
@@ -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,
})
}
+231 -3
View File
@@ -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<ImportFailure>,
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)?)
.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<Vec<Season>, 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<i64>,
) -> Result<Json<Vec<Release>>, 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<Json<Vec<Release>>, 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<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).
#[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<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(
@@ -1723,6 +1821,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),
}))
}
@@ -1942,6 +2043,133 @@ mod tests {
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
/// §5.5's per-episode floor, so the deck holds three candidates and
/// nothing is grabbable. Writing `allow_below_floor` turns the