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:
+48
-18
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user