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
+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();