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:
@@ -541,6 +541,19 @@
|
||||
<button type="button" class="control" id="tv-releases-sweep">re-search</button>
|
||||
</header>
|
||||
<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>
|
||||
</main>
|
||||
|
||||
|
||||
+117
-13
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user