Manual search buckets and attribute chips (#79)
This commit was merged in pull request #79.
This commit is contained in:
+400
-6
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user