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
+117 -13
View File
@@ -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<HTMLElement>("#tv-releases-sub");
const sweep = must<HTMLButtonElement>("#tv-releases-sweep");
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"));
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) {
+65
View File
@@ -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<string> {
try {
const body = (await response.json()) as { error?: string };
+7
View File
@@ -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[];
}
+104
View File
@@ -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);