Manual search buckets and attribute chips (#79)
ci / rust (push) Successful in 1m54s
ci / web (push) Successful in 27s
e2e / e2e (push) Successful in 1m21s

This commit was merged in pull request #79.
This commit is contained in:
2026-08-22 22:42:50 +01:00
parent f26f278a75
commit e650a762f9
5 changed files with 991 additions and 14 deletions
+400 -6
View File
@@ -1,4 +1,22 @@
import { type CheckStatus, type Probe, probeHealth } from "./health";
import {
bucketOf,
formatAudio,
formatHdr,
formatPublishDate,
formatResolution,
formatScore,
formatSeeders,
formatSize,
formatSource,
grabRelease,
type MovieRelease,
movieReleases,
queueSearch,
ruleLabel,
waiveAndGrab,
waiverOverride,
} from "./releases";
import {
addMovie,
type LibraryMovie,
@@ -165,7 +183,7 @@ function main() {
}, POLL_MS);
void probe();
searchMain(board);
searchMain(board, releasesMain());
}
const DEBOUNCE_MS = 250;
@@ -181,7 +199,7 @@ interface DeckRefs {
manualIntake: HTMLElement;
}
function searchMain(board: HTMLElement) {
function searchMain(board: HTMLElement, releases: ReleasesView) {
const input = must<HTMLInputElement>("#search");
const hint = must<HTMLElement>("#search-hint");
const refs: DeckRefs = {
@@ -222,11 +240,13 @@ function searchMain(board: HTMLElement) {
function showBoard() {
controller?.abort();
controller = null;
releases.hide();
refs.deck.hidden = true;
board.hidden = false;
}
function showDeck() {
releases.hide();
board.hidden = true;
refs.deck.hidden = false;
}
@@ -290,7 +310,7 @@ function searchMain(board: HTMLElement) {
refs.groups.library.section.hidden = false;
refs.groups.library.count.textContent = String(response.library.length);
for (const movie of response.library) {
refs.groups.library.rows.append(libraryRow(movie, roots));
refs.groups.library.rows.append(libraryRow(movie, roots, releases.open));
}
}
if (response.tmdb.length > 0) {
@@ -403,9 +423,15 @@ function rowTitle(title: string, year: number | null): HTMLElement {
return wrap;
}
function libraryRow(movie: LibraryMovie, roots: Root[]): HTMLLIElement {
function libraryRow(
movie: LibraryMovie,
roots: Root[],
open: (movie: LibraryMovie, origin: HTMLElement) => void,
): HTMLLIElement {
const item = document.createElement("li");
item.className = "row";
const row = document.createElement("button");
row.type = "button";
row.className = "row row-tmdb";
const chips = document.createElement("span");
chips.className = "row-chips";
const root = roots.find((candidate) => candidate.id === movie.root_id);
@@ -421,7 +447,15 @@ function libraryRow(movie: LibraryMovie, roots: Root[]): HTMLLIElement {
if (movie.blocked) {
chips.append(chip("blocked"));
}
item.append(rowTitle(movie.title, movie.year), chips);
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "releases";
chips.append(affordance);
row.append(rowTitle(movie.title, movie.year), chips);
row.addEventListener("click", () => {
open(movie, row);
});
item.append(row);
return item;
}
@@ -586,6 +620,366 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML
return panel;
}
/* ---- release deck (§9.3, issue #31) ---------------------------------- */
interface ReleasesView {
open: (movie: LibraryMovie, origin: HTMLElement) => void;
hide: () => void;
}
interface BucketRefs {
section: HTMLElement;
rows: HTMLUListElement;
}
function releasesMain(): ReleasesView {
const view = must<HTMLElement>("#releases");
const deck = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#releases-back");
const title = must<HTMLElement>("#releases-title");
const year = must<HTMLElement>("#releases-year");
const sweep = must<HTMLButtonElement>("#releases-sweep");
const status = must<HTMLElement>("#releases-status");
const eligible: BucketRefs & { count: HTMLElement } = {
section: must<HTMLElement>("#bucket-eligible"),
count: must<HTMLElement>("#count-eligible"),
rows: must<HTMLUListElement>("#rows-eligible"),
};
const collapsed = {
waived: {
section: must<HTMLElement>("#bucket-waived"),
toggle: must<HTMLButtonElement>("#toggle-waived"),
wrap: must<HTMLElement>("#wrap-waived"),
rows: must<HTMLUListElement>("#rows-waived"),
},
rejected: {
section: must<HTMLElement>("#bucket-rejected"),
toggle: must<HTMLButtonElement>("#toggle-rejected"),
wrap: must<HTMLElement>("#wrap-rejected"),
rows: must<HTMLUListElement>("#rows-rejected"),
},
} as const;
let current: LibraryMovie | null = null;
let origin: HTMLElement | null = null;
const actions: ReleaseActions = {
reload: () => load(),
notify: (text, tone) => setStatus(text, tone),
};
// guards a stale fetch from painting over a newer view
let sequence = 0;
let refetchTimer: number | undefined;
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 clearBuckets() {
eligible.section.hidden = true;
eligible.rows.replaceChildren();
for (const bucket of [collapsed.waived, collapsed.rejected]) {
bucket.section.hidden = true;
bucket.wrap.hidden = true;
bucket.toggle.setAttribute("aria-expanded", "false");
bucket.rows.replaceChildren();
}
}
function setToggle(bucket: (typeof collapsed)["waived"], count: number) {
const open = !bucket.wrap.hidden;
bucket.toggle.textContent = open ? `hide ${count}` : `show ${count}`;
bucket.toggle.setAttribute("aria-expanded", String(open));
}
for (const bucket of [collapsed.waived, collapsed.rejected]) {
bucket.toggle.addEventListener("click", () => {
bucket.wrap.hidden = !bucket.wrap.hidden;
setToggle(bucket, bucket.rows.childElementCount);
});
}
function render(releases: MovieRelease[]) {
const movie = current;
if (!movie) {
return;
}
clearBuckets();
if (releases.length === 0) {
setStatus("no releases indexed for this title — search indexers runs a sweep now");
return;
}
setStatus(null);
// the API pre-sorts by bucket then score; splitting preserves order
const buckets = {
eligible: releases.filter((release) => bucketOf(release) === "eligible"),
waived: releases.filter((release) => bucketOf(release) === "waived"),
rejected: releases.filter((release) => bucketOf(release) === "rejected"),
};
eligible.section.hidden = false;
eligible.count.textContent = String(buckets.eligible.length);
if (buckets.eligible.length > 0) {
for (const release of buckets.eligible) {
eligible.rows.append(releaseRow(release, movie, "eligible", actions));
}
} else {
// §9.3: over-strict filters must be visible, not silently absent
const none = document.createElement("li");
none.className = "rel rel-none readout dim";
none.textContent = "none — every candidate was waived or rejected by policy";
eligible.rows.append(none);
}
for (const name of ["waived", "rejected"] as const) {
const rows = buckets[name];
if (rows.length === 0) {
continue;
}
const bucket = collapsed[name];
bucket.section.hidden = false;
for (const release of rows) {
bucket.rows.append(releaseRow(release, movie, name, actions));
}
setToggle(bucket, rows.length);
}
}
async function load() {
const movie = current;
if (!movie) {
return;
}
sequence += 1;
const ticket = sequence;
setStatus("reading releases…");
const outcome = await movieReleases(movie.id);
if (ticket !== sequence || current !== movie) {
return;
}
if (outcome.kind === "error") {
clearBuckets();
setStatus(`releases unavailable — ${outcome.detail}`, "fault");
return;
}
render(outcome.releases);
}
function open(movie: LibraryMovie, from: HTMLElement) {
current = movie;
origin = from;
title.textContent = movie.title;
year.textContent = movie.year === null ? "—" : String(movie.year);
deck.hidden = true;
view.hidden = false;
clearBuckets();
sweep.disabled = false;
back.focus();
void load();
}
function hide() {
view.hidden = true;
current = null;
sequence += 1;
window.clearTimeout(refetchTimer);
}
function close() {
const target = origin;
hide();
deck.hidden = false;
target?.focus();
}
back.addEventListener("click", close);
// capture + stopPropagation: one Escape steps back one layer, instead
// of falling through to the search deck's own Escape handler
window.addEventListener(
"keydown",
(event) => {
if (event.key === "Escape" && !view.hidden) {
event.stopPropagation();
close();
}
},
true,
);
sweep.addEventListener("click", () => {
const movie = current;
if (!movie) {
return;
}
sweep.disabled = true;
void queueSearch(movie.id).then((outcome) => {
if (current !== movie) {
return;
}
sweep.disabled = false;
if (outcome.kind === "error") {
setStatus(`search failed — ${outcome.detail}`, "fault");
return;
}
setStatus("search queued — indexers answer asynchronously, rereading shortly");
window.clearTimeout(refetchTimer);
refetchTimer = window.setTimeout(() => {
void load();
}, 3000);
});
});
return { open, hide };
}
interface ReleaseActions {
reload: () => Promise<void>;
notify: (text: string, tone?: "fault") => void;
}
/** One classified release: chips lead, the name is secondary (§9.3). */
function releaseRow(
release: MovieRelease,
movie: LibraryMovie,
bucket: "eligible" | "waived" | "rejected",
actions: ReleaseActions,
): HTMLLIElement {
const item = document.createElement("li");
item.className = "rel";
const line = document.createElement("button");
line.type = "button";
line.className = "rel-line";
line.setAttribute("aria-expanded", "false");
const columns: [string, string, string][] = [
["cw-score", "score", formatScore(release.score)],
["cw-res", "resolution", formatResolution(release.parsed)],
["cw-src", "source", formatSource(release.parsed)],
["cw-hdr", "hdr", formatHdr(release.parsed)],
["cw-aud", "audio", formatAudio(release.parsed)],
["cw-size", "size", formatSize(release.size)],
["cw-seed", "seeders", formatSeeders(release.seeders)],
];
for (const [width, label, value] of columns) {
line.append(
chip(value, (span) => {
span.classList.add("cw", width);
span.setAttribute("aria-label", value === "—" ? `${label} unclaimed` : `${label} ${value}`);
if (value === "—") {
span.classList.add("dim");
}
// the world's non-colour eligible mark rides the leading chip
if (bucket === "eligible" && width === "cw-score") {
span.dataset.verdict = "eligible";
}
}),
);
}
if (bucket !== "eligible") {
line.append(
chip(`${bucket} · ${ruleLabel(release.rejected_rule)}`, (span) => {
span.dataset.verdict = bucket;
}),
);
}
const name = document.createElement("span");
name.className = "rel-name readout";
name.textContent = release.name;
line.append(name);
item.append(line);
const note = document.createElement("span");
note.className = "rel-note readout";
note.setAttribute("role", "status");
note.hidden = true;
if (bucket !== "rejected") {
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";
grab.addEventListener("click", () => {
grab.disabled = true;
note.hidden = false;
delete note.dataset.tone;
note.textContent = "grabbing…";
const action =
bucket === "waived"
? waiveAndGrab(movie.id, release.id, release.rejected_rule)
: grabRelease(movie.id, release.id).then((outcome) =>
outcome.kind === "done"
? ({ kind: "done", overrideWritten: false } as const)
: ({ kind: "error", detail: outcome.detail, overrideWritten: false } as const),
);
void action.then((outcome) => {
const text =
outcome.kind === "error"
? `${outcome.overrideWritten ? "override written · " : ""}grab failed — ${outcome.detail}`
: `${outcome.overrideWritten ? "override written · " : ""}grab sent`;
if (outcome.kind === "error") {
grab.disabled = false;
note.textContent = text;
note.dataset.tone = "fault";
} else {
note.textContent = text;
}
if (outcome.overrideWritten) {
// the override changed classification; reread the verdicts and
// keep the outcome visible past the re-render
void actions.reload().then(() => {
actions.notify(text, outcome.kind === "error" ? "fault" : undefined);
});
}
});
});
item.append(grab);
}
item.append(note);
const full = document.createElement("div");
full.className = "rel-full";
full.hidden = true;
line.addEventListener("click", () => {
if (full.childElementCount === 0) {
const raw = document.createElement("p");
raw.className = "rel-raw readout";
raw.textContent = release.name;
full.append(raw);
const meta = document.createElement("p");
meta.className = "rel-meta readout dim";
const published = formatPublishDate(release.publish_date);
meta.textContent = [
`indexer ${release.indexer_id}`,
published === null ? null : `published ${published}`,
`${release.size} bytes`,
]
.filter((part) => part !== null)
.join(" · ");
full.append(meta);
if (bucket === "waived" && waiverOverride(release.rejected_rule) !== null) {
const waiver = document.createElement("p");
waiver.className = "rel-meta readout dim";
waiver.textContent = "waive + grab writes this title's override, then grabs";
full.append(waiver);
}
}
full.hidden = !full.hidden;
line.setAttribute("aria-expanded", String(!full.hidden));
});
item.append(full);
return item;
}
function renderManual(intake: HTMLElement, kind: "magnet" | "torrent_url", raw: string) {
const parsed = parseManualInput(kind, raw);
intake.replaceChildren();