Attention queues: no-PT-source and needs-decision (#89)
ci / web (push) Successful in 24s
ci / rust (push) Successful in 1m3s
e2e / e2e (push) Successful in 53s

This commit was merged in pull request #89.
This commit is contained in:
2026-08-22 23:57:42 +01:00
parent 01af397a40
commit 22024d4be2
4 changed files with 491 additions and 6 deletions
+44
View File
@@ -54,6 +54,16 @@
ONE toggle with its count. A file imported under a waiver reads
honestly ("english, no dub") as a dashed amber chip, never as a clean
match. A movie row opens the release deck and returns here on Esc.
QUEUES (§5.2 + §5.7, issue #33): the QUEUES control opens the attention
deck — the only two places the app asks for a human. NO PT SOURCE holds
kids titles with no qualifying pt release; parked for months by design;
one click writes allow_english_audio and the normal search proceeds.
NEEDS DECISION holds titles that hard-failed twice on different
releases; the row opens the release deck to decide by hand. Both
sections always render, even empty — the operator visits to confirm
nothing needs them. The rail control carries an amber entry count,
refreshed with the health poll; amber is attention, cyan stays the
interaction hue.
-->
<header class="rail">
<div class="rail-id">
@@ -64,6 +74,9 @@
<button type="button" class="control" id="nav-library" aria-pressed="false">
library
</button>
<button type="button" class="control" id="nav-queues" aria-pressed="false">
queues<span class="nav-count readout" id="queues-count" hidden></span>
</button>
</nav>
<div class="rail-search">
<input
@@ -143,6 +156,37 @@
</section>
</main>
<main class="deck" id="queues" hidden aria-label="attention queues">
<header class="deck-head library-bar">
<h2 class="deck-label">attention</h2>
<span class="deck-count readout" id="queues-summary"></span>
</header>
<p class="deck-status readout" id="queues-status" role="status" hidden></p>
<section class="deck-group" id="queue-no-pt" hidden aria-labelledby="label-no-pt">
<header class="deck-head">
<h3 class="deck-label" id="label-no-pt">no pt source</h3>
<span class="deck-count readout" id="count-no-pt"></span>
</header>
<p class="queue-note readout dim">
kids titles with no qualifying pt release — parked here, sometimes for months, by
design. allow english writes this title's override and the normal search proceeds.
</p>
<ul class="deck-rows" id="rows-no-pt"></ul>
</section>
<section class="deck-group" id="queue-decision" hidden aria-labelledby="label-decision">
<header class="deck-head">
<h3 class="deck-label" id="label-decision">needs decision</h3>
<span class="deck-count readout" id="count-decision"></span>
</header>
<p class="queue-note readout dim">
hard-failed twice on different releases — open the releases and decide by hand.
</p>
<ul class="deck-rows" id="rows-decision"></ul>
</section>
</main>
<main class="deck releases" id="releases" hidden>
<header class="releases-head">
<button type="button" class="control" id="releases-back">back</button>
+304 -6
View File
@@ -6,6 +6,14 @@ import {
seriesNeedsAttention,
waiverLabel,
} from "./library";
import {
type AttentionMovie,
type AttentionQueues,
allowEnglishAudio,
attemptsLabel,
attentionTotal,
fetchAttention,
} from "./queues";
import {
bucketOf,
formatAudio,
@@ -171,6 +179,8 @@ function main() {
}
let inFlight = false;
// assigned once the queues view exists; the poll keeps the badge honest
let refreshQueuesBadge: (() => void) | null = null;
async function probe() {
if (inFlight) {
@@ -181,6 +191,7 @@ function main() {
render(await probeHealth());
probeButton.disabled = false;
inFlight = false;
refreshQueuesBadge?.();
}
probeButton.addEventListener("click", () => {
@@ -191,9 +202,21 @@ function main() {
}, POLL_MS);
void probe();
// views hide each other on open; the array is shared and filled once
const views: HideableView[] = [];
const releases = releasesMain();
const library = libraryMain(board, releases);
searchMain(board, releases, library);
const library = libraryMain(board, releases, views);
const queues = queuesMain(board, releases, views);
views.push(library, queues);
searchMain(board, releases, views);
refreshQueuesBadge = () => {
void queues.refreshBadge();
};
void queues.refreshBadge();
}
interface HideableView {
hide: () => void;
}
const DEBOUNCE_MS = 250;
@@ -209,7 +232,7 @@ interface DeckRefs {
manualIntake: HTMLElement;
}
function searchMain(board: HTMLElement, releases: ReleasesView, library: LibraryView) {
function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableView[]) {
const input = must<HTMLInputElement>("#search");
const hint = must<HTMLElement>("#search-hint");
const refs: DeckRefs = {
@@ -251,14 +274,18 @@ function searchMain(board: HTMLElement, releases: ReleasesView, library: Library
controller?.abort();
controller = null;
releases.hide();
library.hide();
for (const view of views) {
view.hide();
}
refs.deck.hidden = true;
board.hidden = false;
}
function showDeck() {
releases.hide();
library.hide();
for (const view of views) {
view.hide();
}
board.hidden = true;
refs.deck.hidden = false;
}
@@ -1020,7 +1047,11 @@ interface LibraryGroup {
rows: HTMLUListElement;
}
function libraryMain(board: HTMLElement, releases: ReleasesView): LibraryView {
function libraryMain(
board: HTMLElement,
releases: ReleasesView,
views: HideableView[],
): LibraryView {
const view = must<HTMLElement>("#library");
const deck = must<HTMLElement>("#deck");
const nav = must<HTMLButtonElement>("#nav-library");
@@ -1131,6 +1162,9 @@ function libraryMain(board: HTMLElement, releases: ReleasesView): LibraryView {
function open() {
releases.hide();
for (const sibling of views) {
sibling.hide();
}
board.hidden = true;
deck.hidden = true;
view.hidden = false;
@@ -1211,6 +1245,270 @@ function seriesRow(series: LibrarySeries, roots: Root[]): HTMLLIElement {
return item;
}
/* ---- attention queues (§5.2 + §5.7, issue #33) ------------------------ */
interface QueuesView {
hide: () => void;
/** Refetches both queues and repaints the rail badge. */
refreshBadge: () => Promise<void>;
}
interface QueueGroup {
section: HTMLElement;
count: HTMLElement;
rows: HTMLUListElement;
}
function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableView[]): QueuesView {
const view = must<HTMLElement>("#queues");
const deck = must<HTMLElement>("#deck");
const nav = must<HTMLButtonElement>("#nav-queues");
const badge = must<HTMLElement>("#queues-count");
const summary = must<HTMLElement>("#queues-summary");
const status = must<HTMLElement>("#queues-status");
const groups: { noPt: QueueGroup; decision: QueueGroup } = {
noPt: {
section: must<HTMLElement>("#queue-no-pt"),
count: must<HTMLElement>("#count-no-pt"),
rows: must<HTMLUListElement>("#rows-no-pt"),
},
decision: {
section: must<HTMLElement>("#queue-decision"),
count: must<HTMLElement>("#count-decision"),
rows: must<HTMLUListElement>("#rows-decision"),
},
};
let roots: Root[] = [];
// guards a stale fetch from painting over a newer view
let sequence = 0;
function setStatus(text: string | null, tone?: "fault") {
status.hidden = text === null;
status.textContent = text ?? "";
if (tone) {
status.dataset.tone = tone;
} else {
delete status.dataset.tone;
}
}
function clearGroups() {
for (const group of [groups.noPt, groups.decision]) {
group.section.hidden = true;
group.rows.replaceChildren();
}
summary.textContent = "";
}
function paintBadge(total: number) {
badge.hidden = total === 0;
badge.textContent = total === 0 ? "" : String(total);
}
function emptyLine(group: QueueGroup) {
if (group.rows.childElementCount === 0) {
const none = document.createElement("li");
none.className = "queue-empty readout dim";
none.textContent = "queue empty";
group.rows.append(none);
}
}
function entryCount(group: QueueGroup): number {
return [...group.rows.children].filter((child) => !child.classList.contains("queue-empty"))
.length;
}
function paintCounts() {
let total = 0;
for (const group of [groups.noPt, groups.decision]) {
const entries = entryCount(group);
group.count.textContent = String(entries);
total += entries;
}
paintBadge(total);
summary.textContent = total === 0 ? "" : `${total} waiting`;
}
const openReleases = (movie: LibraryMovie, origin: HTMLElement) => {
releases.open(movie, origin, view);
};
/** Drops an emptied item without a refetch: the override IS written. */
function settle(item: HTMLLIElement, group: QueueGroup, text: string) {
item.remove();
emptyLine(group);
paintCounts();
setStatus(text);
}
function render(queues: AttentionQueues) {
clearGroups();
// both sections always render, even empty — this is the one surface
// whose good news is an absence, and the operator visits to see it
for (const group of [groups.noPt, groups.decision]) {
group.section.hidden = false;
}
for (const movie of queues.no_pt_source) {
groups.noPt.rows.append(
noPtRow(movie, roots, openReleases, (item, text) => {
settle(item, groups.noPt, text);
}),
);
}
for (const movie of queues.needs_decision) {
groups.decision.rows.append(libraryRow(movie, roots, openReleases));
}
for (const group of [groups.noPt, groups.decision]) {
emptyLine(group);
}
paintCounts();
setStatus(attentionTotal(queues) === 0 ? "nothing needs attention" : null);
}
async function load() {
sequence += 1;
const ticket = sequence;
setStatus("reading queues…");
const [outcome, fetchedRoots] = await Promise.all([
fetchAttention(),
allRoots().catch(() => roots),
]);
if (ticket !== sequence) {
return;
}
roots = fetchedRoots;
if (outcome.kind === "error") {
clearGroups();
setStatus(`queues unavailable — ${outcome.detail}`, "fault");
return;
}
render(outcome.queues);
}
function open() {
releases.hide();
for (const sibling of views) {
sibling.hide();
}
board.hidden = true;
deck.hidden = true;
view.hidden = false;
nav.setAttribute("aria-pressed", "true");
void load();
}
function hide() {
view.hidden = true;
nav.setAttribute("aria-pressed", "false");
sequence += 1;
}
function close() {
hide();
board.hidden = false;
nav.focus();
}
nav.addEventListener("click", () => {
if (view.hidden) {
open();
} else {
close();
}
});
// capture, like the release deck: one Esc steps back one layer
window.addEventListener(
"keydown",
(event) => {
if (event.key === "Escape" && !view.hidden) {
event.stopImmediatePropagation();
close();
}
},
true,
);
async function refreshBadge() {
const outcome = await fetchAttention();
if (outcome.kind === "error") {
return;
}
paintBadge(attentionTotal(outcome.queues));
}
return { hide, refreshBadge };
}
/**
* One no-PT-source entry: the line opens the release deck as evidence; the
* §5.2 one click sits beside it and empties the item.
*/
function noPtRow(
movie: AttentionMovie,
roots: Root[],
open: (movie: LibraryMovie, origin: HTMLElement) => void,
settled: (item: HTMLLIElement, text: string) => void,
): HTMLLIElement {
const item = document.createElement("li");
item.className = "queue-item";
const line = document.createElement("button");
line.type = "button";
line.className = "row row-tmdb queue-line";
const chips = document.createElement("span");
chips.className = "row-chips";
const root = roots.find((candidate) => candidate.id === movie.root_id);
chips.append(chip(root ? root.audience : `root ${movie.root_id}`));
const attempts = attemptsLabel(movie.search_attempts);
if (attempts !== null) {
chips.append(
chip(attempts, (span) => {
span.setAttribute("aria-label", `${movie.search_attempts} searches found no pt source`);
}),
);
}
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "releases";
chips.append(affordance);
line.append(rowTitle(movie.title, movie.year), chips);
line.addEventListener("click", () => {
open(movie, line);
});
const allow = document.createElement("button");
allow.type = "button";
allow.className = "control queue-allow";
allow.textContent = "allow english";
const note = document.createElement("span");
note.className = "queue-feedback readout";
note.setAttribute("role", "status");
note.hidden = true;
allow.addEventListener("click", () => {
allow.disabled = true;
note.hidden = false;
delete note.dataset.tone;
note.textContent = "writing override…";
void allowEnglishAudio(movie.id).then((outcome) => {
if (outcome.kind === "error") {
allow.disabled = false;
note.textContent = `${outcome.overrideWritten ? "override written · " : ""}${outcome.detail}`;
note.dataset.tone = "fault";
return;
}
settled(item, `${movie.title} — english allowed, search queued`);
});
});
item.append(line, allow, note);
return item;
}
function renderManual(intake: HTMLElement, kind: "magnet" | "torrent_url", raw: string) {
const parsed = parseManualInput(kind, raw);
intake.replaceChildren();
+86
View File
@@ -0,0 +1,86 @@
// Hand-written mirror of arr-api's /api/queues/attention schema — same
// reasoning as search.ts: the generated client (src/api/) is uncommitted,
// so CI's tsc cannot see it.
import { type ActionOutcome, queueSearch } from "./releases";
import type { LibraryMovie } from "./search";
/** A queue entry: the movie plus its §6.2 search history. */
export interface AttentionMovie extends LibraryMovie {
search_attempts: number;
last_searched_at: string | null;
}
export interface AttentionQueues {
no_pt_source: AttentionMovie[];
needs_decision: AttentionMovie[];
}
export type AttentionOutcome =
| { kind: "results"; queues: AttentionQueues }
| { kind: "error"; detail: string };
export async function fetchAttention(): Promise<AttentionOutcome> {
try {
const response = await fetch("/api/queues/attention");
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "results", queues: (await response.json()) as AttentionQueues };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
export type AllowEnglishOutcome =
| { kind: "done"; searchQueued: boolean }
| { kind: "error"; detail: string; overrideWritten: boolean };
/**
* The §5.2 one click: write `allow_english_audio` on the title, then queue
* the normal search. Stored verdicts are stale until the daemon
* reclassifies, so emptying the queue is the search's job, not a re-read's.
*/
export async function allowEnglishAudio(movieId: number): Promise<AllowEnglishOutcome> {
try {
const current = await fetch(`/api/movies/${movieId}`);
if (!current.ok) {
return { kind: "error", detail: await errorDetail(current), overrideWritten: false };
}
const movie = (await current.json()) as { overrides: Record<string, unknown> };
const patch = await fetch(`/api/movies/${movieId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ overrides: { ...movie.overrides, allow_english_audio: true } }),
});
if (!patch.ok) {
return { kind: "error", detail: await errorDetail(patch), overrideWritten: false };
}
} catch {
return { kind: "error", detail: "daemon unreachable", overrideWritten: false };
}
const search: ActionOutcome = await queueSearch(movieId);
if (search.kind === "error") {
return { kind: "error", detail: `search not queued — ${search.detail}`, overrideWritten: true };
}
return { kind: "done", searchQueued: true };
}
/** Entries across both queues — the rail badge's number. */
export function attentionTotal(queues: AttentionQueues): number {
return queues.no_pt_source.length + queues.needs_decision.length;
}
/** `searched 3×`, or null before the first attempt ever lands here. */
export function attemptsLabel(attempts: number): string | null {
return attempts > 0 ? `searched ${attempts}×` : null;
}
async function errorDetail(response: Response): Promise<string> {
try {
const body = (await response.json()) as { error?: string };
return body.error ?? `http ${response.status}`;
} catch {
return `http ${response.status}`;
}
}
+57
View File
@@ -1004,6 +1004,54 @@ body {
padding: 0;
}
/* ---- attention queues (§5.2 + §5.7) ------------------------------------ */
/* the badge is a signal, not a control: amber attention on the cyan word */
.nav-count {
margin-left: var(--space-2);
font-size: var(--text-xs);
color: var(--signal-warn);
}
.queue-note {
margin: var(--space-2) var(--space-1) 0;
font-size: var(--text-xs);
color: var(--ink-faint);
}
/* like .rel: the line expands to fill, the action is its own control */
.queue-item {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-1) var(--space-3);
padding: var(--space-2) var(--space-1);
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
}
.queue-item .queue-line {
flex: 1;
min-width: 0;
width: auto;
padding: 0;
border-bottom: 0;
}
.queue-empty {
padding: var(--space-2) var(--space-1);
font-size: var(--text-xs);
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
}
.queue-feedback {
font-size: var(--text-xs);
color: var(--ink-muted);
}
.queue-feedback[data-tone="fault"] {
color: var(--signal-fault);
}
/* ---- manual intake ---------------------------------------------------- */
.intake .module-detail {
@@ -1064,6 +1112,15 @@ body {
margin-left: auto;
}
/* same wrap on a queue entry: the one click drops below its line */
.queue-item .queue-line {
flex-basis: 100%;
}
.queue-allow {
margin-left: auto;
}
.board {
padding: var(--space-6) var(--space-4);
}