Attention queues: no-PT-source and needs-decision (#89)
This commit was merged in pull request #89.
This commit is contained in:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user