Merge main into blitz/subtitles

Feedback pass 2 and the size-band work landed on main while this branch
was finishing. Brings them in ahead of the merge back.

# Conflicts:
#	crates/arr-api/src/movies.rs
#	crates/arr-api/src/state.rs
#	crates/arr-daemon/src/main.rs
#	web/src/main.ts
This commit is contained in:
Miguel Palhas
2026-08-25 17:53:18 +01:00
76 changed files with 7184 additions and 416 deletions
+48 -18
View File
@@ -231,10 +231,7 @@
-->
<main class="deck" id="movie" hidden aria-label="movie detail">
<header class="releases-head">
<button type="button" class="control" id="movie-back">back</button>
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
</header>
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
<section class="module movie-hero" id="movie-hero" aria-label="title metadata">
<div class="movie-body">
@@ -268,7 +265,7 @@
id="movie-blocked"
aria-pressed="false"
>
blocked
no targeted search
</button>
<label class="movie-root-field">
<span class="field-label readout dim" id="movie-root-label">root</span>
@@ -279,14 +276,24 @@
<button type="button" class="control" id="movie-sweep">search indexers</button>
<button
type="button"
class="control control-quiet"
class="control control-quiet control-icon"
id="movie-remove"
aria-expanded="false"
aria-controls="remove-panel"
aria-label="remove from the library"
>
remove
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.8 4.3h10.4" />
<path d="M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2" />
<path d="M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5" />
<path d="M6.6 7v4.2" />
<path d="M9.4 7v4.2" />
</svg>
</button>
</div>
<p class="control-note readout dim" id="movie-blocked-note" hidden>
targeted search is off for this title. RSS still matches it, so a release can still arrive.
</p>
<div class="remove-panel" id="remove-panel" hidden></div>
</section>
@@ -323,7 +330,7 @@
<section class="deck-group" id="bucket-waived" hidden aria-labelledby="label-waived">
<header class="deck-head bucket-head">
<h3 class="deck-label" id="label-waived">waived</h3>
<h3 class="deck-label" id="label-waived">below policy</h3>
<button
type="button"
class="bucket-toggle readout"
@@ -466,7 +473,7 @@
</button>
</header>
<p class="queue-note readout dim">
a policy edit rescores search decks immediately — open any title's releases to check it.
a policy edit rescores every title's releases immediately — open any title to check it.
</p>
<ul class="deck-rows" id="rows-policies"></ul>
</section>
@@ -496,10 +503,7 @@
-->
<main class="deck releases" id="series" hidden aria-label="series detail">
<header class="releases-head">
<button type="button" class="control" id="series-back">back</button>
<p class="deck-status readout" id="series-status" role="status" hidden></p>
</header>
<p class="deck-status readout" id="series-status" role="status" hidden></p>
<section class="module movie-hero" id="series-hero" aria-label="title metadata">
<div class="movie-body">
@@ -521,23 +525,36 @@
</div>
</section>
<!-- removal is the one library control a series carries (issue 175):
the same arming panel a movie uses — evidence first, then the
decision — wired to DELETE /api/series/{id} -->
<section class="deck-group" id="series-library" aria-label="library controls">
<header class="deck-head">
<h3 class="deck-label">library</h3>
</header>
<div class="movie-controls">
<label class="movie-root-field">
<span class="field-label readout dim" id="series-root-label">root</span>
<select
class="form-input movie-root"
id="series-root"
data-focus-id="series-root"
aria-labelledby="series-root-label"
></select>
</label>
<span class="movie-controls-space"></span>
<button
type="button"
class="control control-quiet"
class="control control-quiet control-icon"
id="series-remove"
aria-expanded="false"
aria-controls="series-remove-panel"
aria-label="remove from the library"
>
remove
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.8 4.3h10.4" />
<path d="M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2" />
<path d="M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5" />
<path d="M6.6 7v4.2" />
<path d="M9.4 7v4.2" />
</svg>
</button>
</div>
<div class="remove-panel" id="series-remove-panel" hidden></div>
@@ -556,6 +573,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>
+80
View File
@@ -0,0 +1,80 @@
// The shared icon-only control block (#230), consumed by the title pages,
// decks and readouts in main.ts and the settings rows (#231). One glyph set,
// one arm-then-confirm idiom — never a second implementation per view.
export const SVG_NS = "http://www.w3.org/2000/svg";
const ICON_PATHS = {
trash: [
"M2.8 4.3h10.4",
"M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2",
"M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5",
"M6.6 7v4.2",
"M9.4 7v4.2",
],
search: ["M7.2 2.6a4.4 4.4 0 1 1 0 8.8 4.4 4.4 0 0 1 0-8.8Z", "M10.4 10.4l3.2 3.2"],
disk: [
"M2.5 9.8l1.6-5c.2-.7.8-1.2 1.5-1.2h4.8c.7 0 1.3.5 1.5 1.2l1.6 5",
"M2.5 9.8h11v2.5c0 .7-.5 1.2-1.2 1.2H3.7c-.7 0-1.2-.5-1.2-1.2z",
"M10.9 11.6h.01",
],
pencil: [
"M11.2 2.4a1.5 1.5 0 0 1 2.1 0l.3.3a1.5 1.5 0 0 1 0 2.1L5.9 12.5l-3.2.8.8-3.2z",
"M10.1 3.5l2.4 2.4",
],
} as const;
/** Stroke glyph on the shared 16-grid; the control's aria-label carries the meaning. */
export function icon(name: keyof typeof ICON_PATHS): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 16 16");
svg.setAttribute("class", "icon");
svg.setAttribute("aria-hidden", "true");
for (const d of ICON_PATHS[name]) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", d);
svg.append(path);
}
return svg;
}
/**
* The settings arm-then-confirm, icon-only: a trash glyph resting, the
* visible word "confirm" while armed, so an accidental hit never destroys.
*/
export function armedDeleteIcon(label: string, execute: () => void): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "control control-quiet control-icon";
button.setAttribute("aria-label", label);
button.title = label;
button.replaceChildren(icon("trash"));
let armed = false;
let resetTimer: number | undefined;
const disarm = () => {
armed = false;
window.clearTimeout(resetTimer);
delete button.dataset.armed;
button.setAttribute("aria-label", label);
button.replaceChildren(icon("trash"));
};
button.addEventListener("click", () => {
if (armed) {
disarm();
button.disabled = true;
execute();
return;
}
armed = true;
button.dataset.armed = "true";
button.textContent = "confirm";
button.setAttribute("aria-label", `confirm — ${label}`);
resetTimer = window.setTimeout(disarm, 4000);
});
button.addEventListener("blur", () => {
if (armed) {
disarm();
}
});
return button;
}
+6 -1
View File
@@ -70,6 +70,9 @@ export function movieNeedsAttention(movie: LibraryMovie): boolean {
* `allow_english_audio` reads as "english, no dub", not as a clean match.
* The import pipeline records the relaxed rule; both the bare rule name and
* an object carrying one are accepted.
*
* Every arm names what was relaxed, never what the record calls it — the
* operator can act on "english, no dub" and cannot act on "waived".
*/
export function waiverLabel(waiver: unknown): string | null {
const rule = waiverRule(waiver);
@@ -81,8 +84,10 @@ export function waiverLabel(waiver: unknown): string | null {
return "english, no dub";
case "resolution":
return "below wanted resolution";
case "size":
return "below the size floor";
default:
return `waived · ${rule.replaceAll("_", " ")}`;
return `${rule.replaceAll("_", " ")} relaxed`;
}
}
+364 -83
View File
@@ -5,6 +5,7 @@ import {
probeHealth,
type SubtitleHealth,
} from "./health";
import { armedDeleteIcon, icon, SVG_NS } from "./icons";
import {
fetchLibrary,
type LibrarySeries,
@@ -44,6 +45,9 @@ import {
} from "./queues";
import {
type ActionOutcome,
blacklistAdvice,
blacklistClass,
blacklistReasonLabel,
bucketOf,
type FilesOutcome,
formatAudio,
@@ -56,11 +60,14 @@ import {
formatSource,
formatSweepAge,
grabRelease,
type ImportFailure,
libraryFolder,
type MovieRelease,
movieFiles,
movieReleases,
movieSearchState,
overridable,
type PackStateOutcome,
probedAttributeTags,
queueSearch,
removeMovie,
@@ -70,7 +77,6 @@ import {
totalSize,
type WaiveOutcome,
waiveAndGrab,
waiverOverride,
} from "./releases";
import { currentRoute, navigate, type Route } from "./router";
import {
@@ -112,9 +118,10 @@ import {
setEpisodeWanted,
setSeasonTracked,
type TvTarget,
updateSeries,
waiveAndGrabTv,
} from "./series";
import { armedDelete, settingsMain } from "./settings";
import { settingsMain } from "./settings";
import "./style.css";
import {
deleteSubtitle,
@@ -482,14 +489,14 @@ function main() {
must<HTMLElement>("#library"),
{ kind: "library" },
);
// no origin click to restore focus to on a deep link — the series
// view's back control is the closest stand-in
// no origin click to restore focus to on a deep link — the library
// rail button is the closest stand-in
tvDeck.open({
title,
sub,
seriesId,
target,
origin: must<HTMLButtonElement>("#series-back"),
origin: must<HTMLElement>("#nav-library"),
returnTo: must<HTMLElement>("#series"),
parentRoute: { kind: "series", seriesId },
});
@@ -808,6 +815,18 @@ function searchMain(
return { showIdle, restore };
}
/**
* What the operator reads for the stored `blocked` flag. §6.3 gives it one
* effect — it stops the targeted-search lane and leaves RSS matching on —
* and "blocked" reads as "block this title outright", which is the wrong
* promise. The flag, the column and the API keep their name; only the words
* on screen say what it does. [`BLOCKED_EFFECT`] carries the RSS half.
*/
const NO_TARGETED_SEARCH = "no targeted search";
/** The half a three-word label cannot hold, for titles and accessible names. */
const BLOCKED_EFFECT = "arr stops searching for this title; RSS still matches it";
function chip(text: string, extra?: (chip: HTMLSpanElement) => void): HTMLSpanElement {
const span = document.createElement("span");
span.className = "chip readout";
@@ -1750,7 +1769,7 @@ function libraryRow(
chips.append(chip("not wanted"));
}
if (movie.blocked) {
chips.append(chip("blocked"));
chips.append(chip(NO_TARGETED_SEARCH));
}
const rating = ratingChip(movie.vote_average);
if (rating !== null) {
@@ -1783,7 +1802,7 @@ function librarySeriesRow(
const root = roots.find((candidate) => candidate.id === series.root_id);
chips.append(chip(root ? root.audience : `root ${series.root_id}`));
if (series.blocked) {
chips.append(chip("blocked"));
chips.append(chip(NO_TARGETED_SEARCH));
}
const rating = ratingChip(series.vote_average);
if (rating !== null) {
@@ -2083,6 +2102,48 @@ function wireCollapsedToggle(bucket: CollapsedBucketDom) {
});
}
/* ---- icon-only controls (#230) ----------------------------------------- */
// icon() and armedDeleteIcon() live in icons.ts, shared with the settings
// rows (#231); the §9.6 painters below stay here as their only consumer.
/** §9.6 provider marks, drawn and shipped inline — never a remote image. */
function providerMark(name: "tmdb" | "tvdb" | "imdb" | "rt"): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("class", "icon icon-mark");
svg.setAttribute("aria-hidden", "true");
if (name === "rt") {
svg.setAttribute("viewBox", "0 0 20 20");
const paths = [
"M10 6.6c4.1 0 6.8 2.1 6.8 5.2 0 3.3-3 5.7-6.8 5.7s-6.8-2.4-6.8-5.7c0-3.1 2.7-5.2 6.8-5.2Z",
"M10 6.6C8.6 7.1 7 6.8 5.9 5.8",
"M10 6.6c1.4.5 3 .2 4.1-.8",
"M10 6.6c-.3-1.2 0-2.3.9-3.2",
];
for (const d of paths) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", d);
svg.append(path);
}
return svg;
}
svg.setAttribute("viewBox", "0 0 44 20");
const box = document.createElementNS(SVG_NS, "rect");
box.setAttribute("x", "1");
box.setAttribute("y", "1.5");
box.setAttribute("width", "42");
box.setAttribute("height", "17");
box.setAttribute("rx", "4");
const letters = document.createElementNS(SVG_NS, "text");
letters.setAttribute("x", "22");
letters.setAttribute("y", "13.2");
letters.setAttribute("text-anchor", "middle");
letters.setAttribute("font-size", "9");
letters.textContent = { tmdb: "TMDB", tvdb: "TVDB", imdb: "IMDb" }[name];
svg.append(box, letters);
return svg;
}
/* ---- §9.6 painters shared by both detail pages ------------------------- */
/** The drawn star before a TMDB rating — no glyph standing in for an icon. */
@@ -2116,20 +2177,21 @@ function closeIcon(): SVGSVGElement {
}
/** External links read as quiet controls; they leave the app entirely. */
function externalLink(label: string, href: string): HTMLAnchorElement {
function externalLink(mark: SVGSVGElement, label: string, href: string): HTMLAnchorElement {
const link = document.createElement("a");
link.className = "control control-quiet";
link.className = "control control-quiet control-icon";
link.href = href;
link.target = "_blank";
link.rel = "noreferrer";
link.textContent = label;
link.setAttribute("aria-label", label);
link.title = label;
link.append(mark);
return link;
}
function movieMain(views: HideableView[]): MovieView {
const view = must<HTMLElement>("#movie");
const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#movie-back");
const statusEl = must<HTMLElement>("#movie-status");
const hero = must<HTMLElement>("#movie-hero");
const poster = must<HTMLImageElement>("#movie-poster");
@@ -2143,6 +2205,10 @@ function movieMain(views: HideableView[]): MovieView {
const actionsEl = must<HTMLElement>("#movie-actions");
const wanted = must<HTMLButtonElement>("#movie-wanted");
const blocked = must<HTMLButtonElement>("#movie-blocked");
const blockedNote = must<HTMLElement>("#movie-blocked-note");
// the accessible name carries what three words cannot, at rest and on
blocked.setAttribute("aria-label", `${NO_TARGETED_SEARCH}${BLOCKED_EFFECT}`);
blocked.title = BLOCKED_EFFECT;
const rootSelect = must<HTMLSelectElement>("#movie-root");
const sweep = must<HTMLButtonElement>("#movie-sweep");
const remove = must<HTMLButtonElement>("#movie-remove");
@@ -2189,7 +2255,7 @@ function movieMain(views: HideableView[]): MovieView {
if (!movie) {
return { kind: "error", detail: "page closed", overrideWritten: false };
}
if (bucket === "waived") {
if (bucket !== "eligible") {
return waiveAndGrab(movie.id, release.id, release.rejected_rule);
}
const outcome = await grabRelease(movie.id, release.id);
@@ -2251,6 +2317,8 @@ function movieMain(views: HideableView[]): MovieView {
}
titleEl.textContent = movie.title;
yearEl.textContent = movie.year === null ? "" : String(movie.year);
remove.setAttribute("aria-label", `remove ${movie.title} from the library`);
remove.title = `remove ${movie.title} from the library`;
chipsEl.replaceChildren();
const root = roots.find((candidate) => candidate.id === movie.root_id);
chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`));
@@ -2265,7 +2333,7 @@ function movieMain(views: HideableView[]): MovieView {
);
}
if (movie.blocked) {
chipsEl.append(chip("blocked"));
chipsEl.append(chip(NO_TARGETED_SEARCH));
}
}
@@ -2286,6 +2354,10 @@ function movieMain(views: HideableView[]): MovieView {
}
wanted.setAttribute("aria-pressed", String(movie.wanted));
blocked.setAttribute("aria-pressed", String(movie.blocked));
// §6.3 is the whole point of the flag and a bare toggle hides it: the
// note appears when the flag is on, which is when the RSS half is the
// thing that surprises. At rest the label and the title carry it.
blockedNote.hidden = !movie.blocked;
paintRootOptions();
// a root list that does not carry the title's own root still shows it —
// a select lying by omission would make the next change move it blindly
@@ -2340,7 +2412,7 @@ function movieMain(views: HideableView[]): MovieView {
void updateMovie(movie.id, { blocked: !movie.blocked }).then((outcome) => {
if (outcome.kind === "error") {
blocked.disabled = false;
setStatus(`blocked failed — ${outcome.detail}`, "fault");
setStatus(`targeted search change failed — ${outcome.detail}`, "fault");
return;
}
void refreshMovie().then(() => {
@@ -2443,13 +2515,26 @@ function movieMain(views: HideableView[]): MovieView {
trailer.textContent = "trailer";
actionsEl.append(trailer);
}
actionsEl.append(externalLink("tmdb", tmdbMovieLink(detail.tmdb_id)));
const linkTitle = current?.title ?? "this movie";
actionsEl.append(
externalLink(
providerMark("tmdb"),
`open ${linkTitle} on TMDB`,
tmdbMovieLink(detail.tmdb_id),
),
);
if (detail.imdb_id !== null) {
actionsEl.append(externalLink("imdb", imdbLink(detail.imdb_id)));
actionsEl.append(
externalLink(providerMark("imdb"), `open ${linkTitle} on IMDb`, imdbLink(detail.imdb_id)),
);
}
if (current !== null) {
actionsEl.append(
externalLink("rotten tomatoes", rottenTomatoesSearch(current.title, current.year)),
externalLink(
providerMark("rt"),
`search Rotten Tomatoes for ${current.title}`,
rottenTomatoesSearch(current.title, current.year),
),
);
}
}
@@ -2777,7 +2862,8 @@ function movieMain(views: HideableView[]): MovieView {
filesSection.hidden = true;
diskRows.replaceChildren();
sweep.disabled = false;
back.focus();
titleEl.setAttribute("tabindex", "-1");
titleEl.focus();
await load();
}
@@ -2800,8 +2886,6 @@ function movieMain(views: HideableView[]): MovieView {
target?.focus();
}
back.addEventListener("click", close);
// capture + stopImmediatePropagation: one Escape steps back one layer —
// the library and search decks also listen for Escape on this window
window.addEventListener(
@@ -2962,7 +3046,8 @@ function removePanel(subject: RemoveSubject, actions: RemoveActions): HTMLElemen
interface ReleaseActions {
reload: () => Promise<void>;
notify: (text: string, tone?: "fault") => void;
/** One click on grab — a plain grab, or waive-then-grab on a waived row. */
/** One click on grab — a plain grab, or waive-then-grab where the rule
* that failed has an override (§9.3). */
grab: (
release: MovieRelease,
bucket: "eligible" | "waived" | "rejected",
@@ -3026,10 +3111,23 @@ function paintBuckets(dom: BucketsDom, releases: MovieRelease[], actions: Releas
dom.eligible.rows.append(releaseRow(release, "eligible", scoreStop, actions));
}
} else {
// §9.3: over-strict filters must be visible, not silently absent
// §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.
//
// 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 = "none — every candidate was waived or rejected by policy";
none.textContent =
forceable > 0
? `none — every candidate is below policy or rejected; ${forceable} can be force grabbed`
: "none — every candidate is below policy or rejected";
dom.eligible.rows.append(none);
}
for (const name of ["waived", "rejected"] as const) {
@@ -3116,7 +3214,7 @@ function buildBucketDom(root: HTMLElement): BucketsDom {
const dom: BucketsDom = {
eligible: { section: eligibleSection, count: eligibleCount, rows: eligibleRows },
waived: collapsed("waived"),
waived: collapsed("below policy"),
rejected: collapsed("rejected"),
};
root.append(dom.eligible.section, dom.waived.section, dom.rejected.section);
@@ -3165,10 +3263,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") {
// §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"
? 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(`${bucket} · ${ruleLabel(release.rejected_rule)}`, (span) => {
chip(verdict, (span) => {
span.dataset.verdict = bucket;
if (blacklisted) {
span.dataset.blacklist = blacklisted;
}
}),
);
}
@@ -3177,6 +3301,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");
@@ -3184,12 +3315,22 @@ function releaseRow(
note.setAttribute("role", "status");
note.hidden = true;
if (bucket !== "rejected") {
// A rejected row is normally inert, but §9.3's one click is exactly for
// the rule the operator disagrees with: where an override exists, the row
// offers it and the grab that follows stays a waiver.
const writesOverride = bucket !== "eligible" && overridable(release);
if (bucket !== "rejected" || writesOverride) {
const grab = document.createElement("button");
grab.type = "button";
grab.className = "control rel-grab";
const writesOverride = bucket === "waived" && waiverOverride(release.rejected_rule) !== null;
grab.textContent = writesOverride ? "waive + grab" : "grab";
// "force grab" says what the click does; the accessible name says what
// it forces past, since the rule is the reason the row is not eligible.
grab.textContent = writesOverride ? "force grab" : "grab";
if (writesOverride) {
const forced = `force grab — relaxes ${ruleLabel(release.rejected_rule)} for this title and grabs anyway`;
grab.setAttribute("aria-label", forced);
grab.title = forced;
}
grab.addEventListener("click", () => {
grab.disabled = true;
note.hidden = false;
@@ -3462,7 +3603,7 @@ function seriesRow(
);
}
if (series.blocked) {
chips.append(chip("blocked"));
chips.append(chip(NO_TARGETED_SEARCH));
}
chips.append(
chip(series.status, (span) => {
@@ -3574,7 +3715,7 @@ function seriesCard(
);
}
if (series.blocked) {
chips.push(chip("blocked"));
chips.push(chip(NO_TARGETED_SEARCH));
}
chips.push(
chip(series.status, (span) => {
@@ -3611,7 +3752,7 @@ function movieCard(
chips.push(chip("not wanted"));
}
if (movie.blocked) {
chips.push(chip("blocked"));
chips.push(chip(NO_TARGETED_SEARCH));
}
const rating = ratingChip(movie.vote_average);
if (rating !== null) {
@@ -3650,6 +3791,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;
@@ -3692,15 +3837,49 @@ 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),
grab: (release, bucket) => {
const current = request;
if (!current) {
return Promise.resolve({ kind: "error", detail: "deck closed", overrideWritten: false });
return Promise.resolve({
kind: "error",
detail: "releases closed",
overrideWritten: false,
});
}
if (bucket === "waived") {
if (bucket !== "eligible") {
return waiveAndGrabTv(current.seriesId, current.target, release.id, release.rejected_rule);
}
return current.target
@@ -3731,9 +3910,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);
@@ -3747,8 +3934,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;
}
@@ -3840,6 +4032,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);
@@ -3912,6 +4107,7 @@ function tvReleasesMain(): TvReleasesView {
next.returnTo.hidden = true;
view.hidden = false;
clearBuckets(dom);
paintFailure(null);
sweep.disabled = false;
back.focus();
void load(true);
@@ -3980,7 +4176,6 @@ const SERIES_REFRESH_WAIT_MS = 30_000;
function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const view = must<HTMLElement>("#series");
const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#series-back");
const hero = must<HTMLElement>("#series-hero");
const poster = must<HTMLImageElement>("#series-poster");
const titleEl = must<HTMLElement>("#series-title");
@@ -3993,6 +4188,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const actionsEl = must<HTMLElement>("#series-actions");
const statusEl = must<HTMLElement>("#series-status");
const seasonsList = must<HTMLUListElement>("#rows-seasons");
const rootSelect = must<HTMLSelectElement>("#series-root");
const remove = must<HTMLButtonElement>("#series-remove");
const removeWrap = must<HTMLElement>("#series-remove-panel");
@@ -4087,6 +4283,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
}
titleEl.textContent = current.title;
yearEl.textContent = current.year === null ? "" : String(current.year);
remove.setAttribute("aria-label", `remove ${current.title} from the library`);
remove.title = `remove ${current.title} from the library`;
chipsEl.replaceChildren();
const root = roots.find((candidate) => candidate.id === current.root_id);
chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`));
@@ -4104,10 +4302,53 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
}),
);
if (current.blocked) {
chipsEl.append(chip("blocked"));
chipsEl.append(chip(NO_TARGETED_SEARCH));
}
}
function paintRootControl() {
const current = series;
if (!current) {
return;
}
rootSelect.replaceChildren();
for (const root of roots.filter((candidate) => candidate.kind === "tv")) {
const option = document.createElement("option");
option.value = String(root.id);
option.textContent = `${root.audience} · ${root.policy_name}`;
rootSelect.append(option);
}
if (rootSelect.value !== String(current.root_id)) {
const option = document.createElement("option");
option.value = String(current.root_id);
option.textContent = `root ${current.root_id}`;
rootSelect.append(option);
}
rootSelect.value = String(current.root_id);
}
rootSelect.addEventListener("change", () => {
const current = series;
if (!current) {
return;
}
const next = Number(rootSelect.value);
if (!Number.isInteger(next) || next === current.root_id) {
return;
}
rootSelect.disabled = true;
void updateSeries(current.id, { root_id: next }).then((outcome) => {
rootSelect.disabled = false;
if (outcome.kind === "error") {
rootSelect.value = String(current.root_id);
setStatus(`root change failed — ${outcome.detail}`, "fault");
return;
}
focusKey = "series-root";
void load();
});
});
/* ---- §9.6 rich detail: one request, images hotlinked ---- */
function clearRichDetail() {
@@ -4189,26 +4430,41 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
trailer.textContent = "trailer";
actionsEl.append(trailer);
}
actionsEl.append(externalLink("tmdb", tmdbSeriesLink(detail.tmdb_id)));
const linkTitle = series?.title ?? "this series";
actionsEl.append(
externalLink(
providerMark("tmdb"),
`open ${linkTitle} on TMDB`,
tmdbSeriesLink(detail.tmdb_id),
),
);
// a series has no imdb_id in this app; TVDB is its second id (§9.6)
if (detail.tvdb_id !== null) {
actionsEl.append(externalLink("tvdb", tvdbLink(detail.tvdb_id)));
actionsEl.append(
externalLink(providerMark("tvdb"), `open ${linkTitle} on TVDB`, tvdbLink(detail.tvdb_id)),
);
}
if (series !== null) {
actionsEl.append(
externalLink("rotten tomatoes", rottenTomatoesSearch(series.title, series.year)),
externalLink(
providerMark("rt"),
`search Rotten Tomatoes for ${series.title}`,
rottenTomatoesSearch(series.title, series.year),
),
);
}
}
function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
const counts = seasonCounts(season);
return countsChip(
`${counts.available}/${counts.wanted} on disk`,
const span = countsChip(
`${counts.available}/${counts.wanted}`,
counts.available,
counts.wanted,
`${counts.available} of ${counts.wanted} wanted episodes on disk`,
);
span.prepend(icon("disk"));
return span;
}
function seasonRow(season: ApiSeason): HTMLLIElement {
@@ -4275,8 +4531,14 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const deckBtn = document.createElement("button");
deckBtn.type = "button";
deckBtn.className = "control";
deckBtn.textContent = "deck";
deckBtn.className = "control control-icon";
const deckLabel =
season.number === 0
? "search releases for specials"
: `search releases for season ${PAD_TWO(season.number)}`;
deckBtn.setAttribute("aria-label", deckLabel);
deckBtn.title = deckLabel;
deckBtn.append(icon("search"));
deckBtn.addEventListener("click", () => {
const currentId = seriesId;
const currentTitle = series?.title ?? "";
@@ -4296,6 +4558,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) {
@@ -4309,26 +4589,25 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
// #174: files go, episodes stop being wanted, the season stays listed.
// Only offered with files on disk — intent alone is the tracked toggle.
if (season.episodes.some((episode) => filesByEpisode.has(episode.id))) {
const clear = armedDelete("remove files", () => {
const currentId = seriesId;
if (currentId === null) {
return;
}
void removeSeasonFiles(currentId, season.number).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
const clear = armedDeleteIcon(
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
() => {
const currentId = seriesId;
if (currentId === null) {
return;
}
// the control itself disappears with the files; the tracked
// toggle is the season's control that survives the repaint
focusKey = `track-${season.number}`;
void load();
});
});
clear.setAttribute(
"aria-label",
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
void removeSeasonFiles(currentId, season.number).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
return;
}
// the control itself disappears with the files; the tracked
// toggle is the season's control that survives the repaint
focusKey = `track-${season.number}`;
void load();
});
},
);
line.append(clear);
}
@@ -4396,21 +4675,20 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
if (aired && onDisk) {
// #174: the file goes and the episode stops being wanted; the row
// stays listed. Same arm-then-confirm as a settings row.
const clear = armedDelete("remove file", () => {
void removeEpisodeFiles(episode.id).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
return;
}
// once missing, the row's want control is what remains to focus
focusKey = `want-${episode.id}`;
void load();
});
});
clear.setAttribute(
"aria-label",
const clear = armedDeleteIcon(
`remove the ${episodeTag(seasonNumber, episode.number)} file from disk and stop wanting the episode — it stays listed`,
() => {
void removeEpisodeFiles(episode.id).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
return;
}
// once missing, the row's want control is what remains to focus
focusKey = `want-${episode.id}`;
void load();
});
},
);
actions.append(clear);
} else if (!aired) {
@@ -4441,8 +4719,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const deckBtn = document.createElement("button");
deckBtn.type = "button";
deckBtn.className = "control";
deckBtn.textContent = "deck";
deckBtn.className = "control control-icon";
const deckLabel = `search releases for ${episodeTag(seasonNumber, episode.number)}`;
deckBtn.setAttribute("aria-label", deckLabel);
deckBtn.title = deckLabel;
deckBtn.append(icon("search"));
deckBtn.addEventListener("click", () => {
const currentId = seriesId;
const currentTitle = series?.title ?? "";
@@ -4657,6 +4938,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
: [],
);
paintHeader();
paintRootControl();
clearRichDetail();
renderSeasons();
syncRefreshWatch(ticket);
@@ -4686,7 +4968,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
view.hidden = false;
clearRemove();
clearRichDetail();
back.focus();
titleEl.setAttribute("tabindex", "-1");
titleEl.focus();
await load();
}
@@ -4707,8 +4990,6 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
origin?.focus();
}
back.addEventListener("click", close);
// capture, like every other layer: the tv deck's listener is registered
// first, so one Esc steps back one layer
window.addEventListener(
+77
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 =
@@ -257,6 +280,11 @@ export async function grabRelease(movieId: number, releaseId: number): Promise<A
* The per-title override one click on a waived row writes (§5.2, §9.3).
* Mapping a rule name to its override is bookkeeping, not policy — the
* verdict itself always comes from the API.
*
* `size` relaxes §5.5's floor for this title only, and only into a waiver:
* the release stays out of automatic grabbing and imports on the record as
* a §5.7 waiver. No band is right for every title, which is why the
* override exists at all.
*/
export function waiverOverride(rule: string | null): Record<string, unknown> | null {
switch (rule) {
@@ -264,11 +292,18 @@ export function waiverOverride(rule: string | null): Record<string, unknown> | n
return { allow_english_audio: true };
case "resolution":
return { only_4k: false };
case "size":
return { allow_below_floor: true };
default:
return null;
}
}
/** Whether one click on this row has an override to write (§9.3). */
export function overridable(release: { rejected_rule: string | null }): boolean {
return waiverOverride(release.rejected_rule) !== null;
}
export type WaiveOutcome =
| { kind: "done"; overrideWritten: boolean }
| { kind: "error"; detail: string; overrideWritten: boolean };
@@ -455,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 };
+27
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[];
}
@@ -65,6 +72,26 @@ export async function fetchSeries(seriesId: number): Promise<ApiSeries | null> {
}
}
/** Move a series to another TV root through its partial update endpoint. */
export async function updateSeries(
seriesId: number,
patch: { root_id: number },
): Promise<ActionOutcome> {
try {
const response = await fetch(`/api/series/${seriesId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(patch),
});
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/** One episode by row id — the `/episodes/{id}/releases` deep link's lookup. */
export async function fetchEpisode(episodeId: number): Promise<ApiEpisode | null> {
try {
+21 -55
View File
@@ -2,6 +2,7 @@
// plus the settings view over them — same reasoning as search.ts: the
// generated client (src/api/) is uncommitted, so CI's tsc cannot see it.
import { armedDeleteIcon, icon } from "./icons";
import { navigate } from "./router";
import type { Root } from "./search";
@@ -222,40 +223,14 @@ function listValue(input: HTMLInputElement): string[] {
.filter((part) => part !== "");
}
/**
* First click arms the destructive action, second confirms. The armed state
* clears on blur or after a few seconds, so an accidental double click
* never deletes. Shared with the series detail rows — one confirmation
* idiom for row-level destruction, not one per page.
*/
export function armedDelete(label: string, execute: () => void): HTMLButtonElement {
const button = el("button", "control control-quiet readout", label);
/** Icon-only edit toggle for one row; the label names the row it opens. */
function editIcon(label: string): HTMLButtonElement {
const button = el("button", "control control-quiet control-icon");
button.type = "button";
let armed = false;
let resetTimer: number | undefined;
const disarm = () => {
armed = false;
window.clearTimeout(resetTimer);
delete button.dataset.armed;
button.textContent = label;
};
button.addEventListener("click", () => {
if (armed) {
disarm();
button.disabled = true;
execute();
return;
}
armed = true;
button.dataset.armed = "true";
button.textContent = `confirm ${label}`;
resetTimer = window.setTimeout(disarm, 4000);
});
button.addEventListener("blur", () => {
if (armed) {
disarm();
}
});
button.setAttribute("aria-label", label);
button.title = label;
button.setAttribute("aria-expanded", "false");
button.append(icon("pencil"));
return button;
}
@@ -350,31 +325,29 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
/* -- roots -------------------------------------------------------------- */
/** A delete/edit action pair sharing the row's feedback line. */
function rowActions(
/** The row's line: identity, chips right, the icon action pair, feedback. */
function rowLine(
item: HTMLLIElement,
title: string,
chips: HTMLSpanElement,
del: HTMLButtonElement,
edit: HTMLButtonElement,
): HTMLParagraphElement {
const actions = el("div", "settings-row-actions");
actions.append(edit, del);
item.append(actions);
const note = el("p", "add-note readout");
note.setAttribute("role", "status");
note.hidden = true;
item.append(note);
item.append(rowTitle(title), chips, actions, note);
return note;
}
function rootRow(root: Root): HTMLLIElement {
const item = el("li", "queue-item");
const row = el("div", "row");
const item = el("li", "queue-item settings-row");
const chips = el("span", "row-chips");
chips.append(chip(root.kind), chip(root.audience), chip(root.policy_name));
row.append(rowTitle(root.path), chips);
item.append(row);
const del = armedDelete("delete", () => {
const del = armedDeleteIcon(`delete root ${root.path}`, () => {
void deleteRow("roots", root.id).then((detail) => {
if (detail !== null) {
note.hidden = false;
@@ -386,9 +359,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
});
});
const edit = el("button", "control control-quiet readout", "edit");
edit.type = "button";
edit.setAttribute("aria-expanded", "false");
const edit = editIcon(`edit root ${root.path}`);
edit.addEventListener("click", () => {
const existing = item.querySelector(".edit-panel");
if (existing) {
@@ -400,7 +371,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
edit.setAttribute("aria-expanded", "true");
});
const note = rowActions(item, del, edit);
const note = rowLine(item, root.path, chips, del, edit);
return item;
}
@@ -482,17 +453,14 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
}
function policyRow(policy: PolicyDoc): HTMLLIElement {
const item = el("li", "queue-item");
const row = el("div", "row");
const item = el("li", "queue-item settings-row");
const chips = el("span", "row-chips");
chips.append(chip(policy.resolution_pref.join(" ") || "no resolutions"));
chips.append(chip(requiredAudioLabel(policy)));
const bandCount = Object.keys(policy.size_bands).length;
chips.append(chip(`${bandCount} band${bandCount === 1 ? "" : "s"}`));
row.append(rowTitle(policy.name), chips);
item.append(row);
const del = armedDelete("delete", () => {
const del = armedDeleteIcon(`delete policy ${policy.name}`, () => {
void deleteRow("policies", policy.id).then((detail) => {
if (detail !== null) {
note.hidden = false;
@@ -504,9 +472,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
});
});
const edit = el("button", "control control-quiet readout", "edit");
edit.type = "button";
edit.setAttribute("aria-expanded", "false");
const edit = editIcon(`edit policy ${policy.name}`);
edit.addEventListener("click", () => {
const existing = item.querySelector(".edit-panel");
if (existing) {
@@ -518,7 +484,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
edit.setAttribute("aria-expanded", "true");
});
const note = rowActions(item, del, edit);
const note = rowLine(item, policy.name, chips, del, edit);
return item;
}
+200 -2
View File
@@ -481,6 +481,62 @@ body {
outline-offset: 2px;
}
/* ---- icon-only controls (#230) ---------------------------------------- */
/* One block, shared: title pages, decks and readouts here, the settings
rows via #231. The glyph carries the action, the aria-label carries the
meaning, and the geometry stays on the --control-h rail with the chips. */
.icon {
display: block;
flex: none;
height: 0.875rem;
width: auto;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.control-icon {
min-width: var(--control-h);
padding: 0 var(--space-2);
}
/* provider wordmark badges (§9.6): the mark is drawn and shipped inline,
monochrome on currentColor — never a remote image */
.icon-mark {
height: 1.125rem;
}
.icon-mark rect {
stroke-width: 1.6;
}
/* the mark's letters live in viewBox units, so the size rides the SVG
scale as an attribute; only voice and weight come from the tokens */
.icon-mark text {
fill: currentColor;
stroke: none;
font-family: var(--font-readout);
font-weight: 700;
letter-spacing: 0.06em;
}
/* a readout chip's glyph sits beside its figure, sized to the readout */
.chip .icon {
height: 0.75rem;
stroke-width: 1.7;
}
/* rows widen every .control's padding; an icon control stays near-square */
.season-line .control-icon,
.ep-actions .control-icon,
.movie-controls .control-icon {
padding: 0 var(--space-2);
}
/* ---- unified search (§9.2) ------------------------------------------- */
.rail-search {
@@ -539,6 +595,12 @@ body {
flex: 1;
}
/* movie and series: the hero banner meets the rail — no top padding (#229) */
.deck#movie,
.deck#series {
padding-top: 0;
}
.deck-status {
margin: 0 0 var(--space-4);
color: var(--ink-muted);
@@ -566,6 +628,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);
}
@@ -593,6 +697,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;
@@ -668,6 +784,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. */
@@ -1148,6 +1293,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);
@@ -1468,6 +1627,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);
@@ -1866,6 +2032,17 @@ body {
padding: 0 var(--space-3);
}
/* The one control whose effect is narrower than its name suggests (§6.3)
says so under the row while it is on: three words on the button, the
half they cannot hold on the line below. Aligned with the row's own
padding so it reads as a note on the controls, not a new section. */
.control-note {
margin: 0;
padding: 0 var(--space-1) var(--space-2);
color: var(--ink-muted);
font-size: var(--text-xs);
}
/* wanted and blocked read as rules, pressed = on — the tracked-toggle idiom */
.movie-controls .control[aria-pressed="true"] {
color: var(--accent-bright);
@@ -2097,11 +2274,32 @@ body {
/* ---- settings (issue #116) -------------------------------------------- */
/* rows follow the queues pattern: line, then an action pair */
/* one machined line per row (#231): identity, chips at the right edge,
then the icon action pair — the episode-row idiom, not a two-line group */
.settings-row-actions {
display: flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-1) 0 var(--space-2);
margin-left: var(--space-3);
}
.settings-row .row-title {
overflow-wrap: anywhere;
}
/* feedback and the edit panel drop below the line, full width */
.settings-row .add-note,
.settings-row .edit-panel {
flex-basis: 100%;
}
/* narrow, the row wraps like an episode row: identity first, chips
full-width (the shared .row-chips rule), the icon pair keeps the right
edge. Lives here, after the base rule, so the margin actually flips. */
@media (max-width: 46rem) {
.settings-row-actions {
margin-left: auto;
}
}
.form-grid {