Manual search buckets and attribute chips (#79)
This commit was merged in pull request #79.
This commit is contained in:
@@ -35,6 +35,14 @@
|
||||
a pasted magnet or .torrent routes to a manual-intake panel. There is
|
||||
no separate add page, ever. Esc returns to the board; "/" focuses the
|
||||
box from anywhere.
|
||||
BUCKETS (§9.3, issue #31): a library row opens the release deck — the
|
||||
policy engine's verdicts, inverted from Radarr: ELIGIBLE rows lead with
|
||||
fixed-width attribute chips (score res source hdr audio size seed) in
|
||||
aligned mono columns; WAIVED and REJECTED collapse to counts. The
|
||||
release name is secondary, truncated, full on expand. Every rejected
|
||||
row names its killing rule; one click on a waived row writes the
|
||||
override and grabs. No horizontal scroll at any viewport. Esc returns
|
||||
to the search deck.
|
||||
-->
|
||||
<header class="rail">
|
||||
<div class="rail-id">
|
||||
@@ -87,6 +95,85 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main class="deck releases" id="releases" hidden>
|
||||
<header class="releases-head">
|
||||
<button type="button" class="control" id="releases-back">back</button>
|
||||
<div class="releases-id">
|
||||
<h2 class="releases-title" id="releases-title">—</h2>
|
||||
<span class="releases-year readout dim" id="releases-year">—</span>
|
||||
</div>
|
||||
<button type="button" class="control" id="releases-sweep">search indexers</button>
|
||||
</header>
|
||||
<p class="deck-status readout" id="releases-status" role="status" hidden></p>
|
||||
|
||||
<section class="deck-group" id="bucket-eligible" hidden aria-labelledby="label-eligible">
|
||||
<header class="deck-head">
|
||||
<h3 class="deck-label" id="label-eligible">eligible</h3>
|
||||
<span class="deck-count readout" id="count-eligible"></span>
|
||||
</header>
|
||||
<div class="colhead" aria-hidden="true">
|
||||
<span class="cw cw-score">score</span>
|
||||
<span class="cw cw-res">res</span>
|
||||
<span class="cw cw-src">source</span>
|
||||
<span class="cw cw-hdr">hdr</span>
|
||||
<span class="cw cw-aud">audio</span>
|
||||
<span class="cw cw-size">size</span>
|
||||
<span class="cw cw-seed">seed</span>
|
||||
</div>
|
||||
<ul class="deck-rows" id="rows-eligible"></ul>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="bucket-waived" hidden aria-labelledby="label-waived">
|
||||
<header class="deck-head bucket-head">
|
||||
<h3 class="deck-label" id="label-waived">waived</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="bucket-toggle readout"
|
||||
id="toggle-waived"
|
||||
aria-expanded="false"
|
||||
aria-controls="wrap-waived"
|
||||
></button>
|
||||
</header>
|
||||
<div id="wrap-waived" hidden>
|
||||
<div class="colhead" aria-hidden="true">
|
||||
<span class="cw cw-score">score</span>
|
||||
<span class="cw cw-res">res</span>
|
||||
<span class="cw cw-src">source</span>
|
||||
<span class="cw cw-hdr">hdr</span>
|
||||
<span class="cw cw-aud">audio</span>
|
||||
<span class="cw cw-size">size</span>
|
||||
<span class="cw cw-seed">seed</span>
|
||||
</div>
|
||||
<ul class="deck-rows" id="rows-waived"></ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="bucket-rejected" hidden aria-labelledby="label-rejected">
|
||||
<header class="deck-head bucket-head">
|
||||
<h3 class="deck-label" id="label-rejected">rejected</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="bucket-toggle readout"
|
||||
id="toggle-rejected"
|
||||
aria-expanded="false"
|
||||
aria-controls="wrap-rejected"
|
||||
></button>
|
||||
</header>
|
||||
<div id="wrap-rejected" hidden>
|
||||
<div class="colhead" aria-hidden="true">
|
||||
<span class="cw cw-score">score</span>
|
||||
<span class="cw cw-res">res</span>
|
||||
<span class="cw cw-src">source</span>
|
||||
<span class="cw cw-hdr">hdr</span>
|
||||
<span class="cw cw-aud">audio</span>
|
||||
<span class="cw cw-size">size</span>
|
||||
<span class="cw cw-seed">seed</span>
|
||||
</div>
|
||||
<ul class="deck-rows" id="rows-rejected"></ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main class="board">
|
||||
<section class="chain" aria-label="signal chain">
|
||||
<article class="module area-tmdb" data-check="tmdb">
|
||||
|
||||
+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();
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
// Hand-written mirror of arr-api's /api/movies/{id}/releases, grab and
|
||||
// override schemas — same reasoning as search.ts: the generated client
|
||||
// (src/api/) is uncommitted, so CI's tsc cannot see it.
|
||||
|
||||
export type Bucket = "eligible" | "waived" | "rejected";
|
||||
|
||||
/** What the release name claims (arr-parse `NameClaims`, §5.6). */
|
||||
export interface ParsedClaims {
|
||||
resolution?: string | null;
|
||||
source?: string | null;
|
||||
codec?: string | null;
|
||||
hdr?: string[];
|
||||
languages?: string[];
|
||||
}
|
||||
|
||||
export interface MovieRelease {
|
||||
id: number;
|
||||
indexer_id: number;
|
||||
guid: string;
|
||||
name: string;
|
||||
size: number;
|
||||
seeders: number | null;
|
||||
publish_date: string | null;
|
||||
download_url: string;
|
||||
parsed: ParsedClaims;
|
||||
score: number | null;
|
||||
verdict: string | null;
|
||||
rejected_rule: string | null;
|
||||
}
|
||||
|
||||
export type ReleasesOutcome =
|
||||
| { kind: "results"; releases: MovieRelease[] }
|
||||
| { kind: "error"; detail: string };
|
||||
|
||||
export async function movieReleases(movieId: number): Promise<ReleasesOutcome> {
|
||||
try {
|
||||
const response = await fetch(`/api/movies/${movieId}/releases`);
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "results", releases: (await response.json()) as MovieRelease[] };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
export type ActionOutcome = { kind: "done" } | { kind: "error"; detail: string };
|
||||
|
||||
/** §6.2 manual trigger: one targeted sweep, user-initiated. */
|
||||
export async function queueSearch(movieId: number): Promise<ActionOutcome> {
|
||||
try {
|
||||
const response = await fetch(`/api/movies/${movieId}/search`, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "done" };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function grabRelease(movieId: number, releaseId: number): Promise<ActionOutcome> {
|
||||
try {
|
||||
const response = await fetch(`/api/movies/${movieId}/releases/${releaseId}/grab`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "done" };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-title override one click on a waived row writes (§5.2, §9.3).
|
||||
* Mapping a rule name to its override is bookkeeping, not policy — the
|
||||
* verdict itself always comes from the API.
|
||||
*/
|
||||
export function waiverOverride(rule: string | null): Record<string, unknown> | null {
|
||||
switch (rule) {
|
||||
case "required_audio":
|
||||
return { allow_english_audio: true };
|
||||
case "resolution":
|
||||
return { only_4k: false };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type WaiveOutcome =
|
||||
| { kind: "done"; overrideWritten: boolean }
|
||||
| { kind: "error"; detail: string; overrideWritten: boolean };
|
||||
|
||||
/** One click on a waived row: write the override, then grab (§9.3). */
|
||||
export async function waiveAndGrab(
|
||||
movieId: number,
|
||||
releaseId: number,
|
||||
rule: string | null,
|
||||
): Promise<WaiveOutcome> {
|
||||
const override = waiverOverride(rule);
|
||||
let overrideWritten = false;
|
||||
if (override) {
|
||||
try {
|
||||
const current = await fetch(`/api/movies/${movieId}`);
|
||||
if (!current.ok) {
|
||||
return { kind: "error", detail: await errorDetail(current), overrideWritten };
|
||||
}
|
||||
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, ...override } }),
|
||||
});
|
||||
if (!patch.ok) {
|
||||
return { kind: "error", detail: await errorDetail(patch), overrideWritten };
|
||||
}
|
||||
overrideWritten = true;
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable", overrideWritten };
|
||||
}
|
||||
}
|
||||
const grabbed = await grabRelease(movieId, releaseId);
|
||||
if (grabbed.kind === "error") {
|
||||
return { kind: "error", detail: grabbed.detail, overrideWritten };
|
||||
}
|
||||
return { kind: "done", overrideWritten };
|
||||
}
|
||||
|
||||
export function bucketOf(release: MovieRelease): Bucket {
|
||||
if (release.verdict === "eligible") {
|
||||
return "eligible";
|
||||
}
|
||||
if (release.verdict === "waived") {
|
||||
return "waived";
|
||||
}
|
||||
return "rejected";
|
||||
}
|
||||
|
||||
/* ---- chip formatting: parsed attributes to fixed-width mono values ---- */
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
Cam: "cam",
|
||||
Telesync: "ts",
|
||||
Telecine: "tc",
|
||||
Screener: "scr",
|
||||
Dvd: "dvd",
|
||||
Hdtv: "hdtv",
|
||||
WebRip: "webrip",
|
||||
WebDl: "web-dl",
|
||||
BluRay: "bluray",
|
||||
Remux: "remux",
|
||||
};
|
||||
|
||||
const HDR_LABEL: Record<string, string> = {
|
||||
DolbyVision: "DV",
|
||||
Hdr10Plus: "HDR10+",
|
||||
Hdr10: "HDR10",
|
||||
Hdr: "HDR",
|
||||
Hlg: "HLG",
|
||||
Sdr: "SDR",
|
||||
};
|
||||
|
||||
const LANGUAGE_LABEL: Record<string, string> = {
|
||||
PtBr: "pt-BR",
|
||||
PtPt: "pt-PT",
|
||||
Portuguese: "pt",
|
||||
English: "en",
|
||||
French: "fr",
|
||||
German: "de",
|
||||
Spanish: "es",
|
||||
Italian: "it",
|
||||
Multi: "multi",
|
||||
Dual: "dual",
|
||||
};
|
||||
|
||||
/** §5.7 rule names as short chip words. */
|
||||
const RULE_LABEL: Record<string, string> = {
|
||||
required_audio: "audio",
|
||||
dub_blacklist: "dub",
|
||||
portuguese_unverified: "pt unverified",
|
||||
dolby_vision_profile: "dv profile",
|
||||
resolution: "resolution",
|
||||
source: "source",
|
||||
size: "size",
|
||||
};
|
||||
|
||||
export function formatScore(score: number | null): string {
|
||||
return score === null ? "—" : String(Math.round(score));
|
||||
}
|
||||
|
||||
export function formatResolution(parsed: ParsedClaims): string {
|
||||
return parsed.resolution ?? "—";
|
||||
}
|
||||
|
||||
export function formatSource(parsed: ParsedClaims): string {
|
||||
if (!parsed.source) {
|
||||
return "—";
|
||||
}
|
||||
return SOURCE_LABEL[parsed.source] ?? parsed.source.toLowerCase();
|
||||
}
|
||||
|
||||
export function formatHdr(parsed: ParsedClaims): string {
|
||||
const markers = parsed.hdr ?? [];
|
||||
if (markers.length === 0) {
|
||||
return "—";
|
||||
}
|
||||
return markers.map((marker) => HDR_LABEL[marker] ?? marker).join(" ");
|
||||
}
|
||||
|
||||
export function formatAudio(parsed: ParsedClaims): string {
|
||||
const markers = parsed.languages ?? [];
|
||||
if (markers.length === 0) {
|
||||
return "—";
|
||||
}
|
||||
return markers.map((marker) => LANGUAGE_LABEL[marker] ?? marker.toLowerCase()).join(" ");
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes <= 0) {
|
||||
return "—";
|
||||
}
|
||||
const gib = bytes / 2 ** 30;
|
||||
if (gib >= 100) {
|
||||
return `${Math.round(gib)}G`;
|
||||
}
|
||||
if (gib >= 1) {
|
||||
return `${gib.toFixed(1)}G`;
|
||||
}
|
||||
return `${Math.round(bytes / 2 ** 20)}M`;
|
||||
}
|
||||
|
||||
export function formatSeeders(seeders: number | null): string {
|
||||
return seeders === null ? "—" : String(seeders);
|
||||
}
|
||||
|
||||
export function ruleLabel(rule: string | null): string {
|
||||
if (rule === null) {
|
||||
return "unclassified";
|
||||
}
|
||||
return RULE_LABEL[rule] ?? rule.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
/** The publish date as a calendar day; the exact instant is noise here. */
|
||||
export function formatPublishDate(date: string | null): string | null {
|
||||
if (date === null) {
|
||||
return null;
|
||||
}
|
||||
const day = date.slice(0, 10);
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : date;
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@@ -629,6 +629,18 @@ body {
|
||||
border-color: oklch(from var(--verdict-eligible) l c h / 45%);
|
||||
}
|
||||
|
||||
/* the dashed border is the visible mark of a relaxed rule */
|
||||
.chip[data-verdict="waived"] {
|
||||
color: var(--verdict-waived);
|
||||
border-style: dashed;
|
||||
border-color: oklch(from var(--verdict-waived) l c h / 55%);
|
||||
}
|
||||
|
||||
/* rejected is the policy working, never fault red */
|
||||
.chip[data-verdict="rejected"] {
|
||||
color: var(--verdict-rejected);
|
||||
}
|
||||
|
||||
.chip[data-movie-state="imported"] {
|
||||
color: var(--ink);
|
||||
}
|
||||
@@ -745,6 +757,201 @@ body {
|
||||
color: var(--verdict-eligible);
|
||||
}
|
||||
|
||||
/* ---- release deck (§9.3) ---------------------------------------------- */
|
||||
|
||||
.releases-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
margin: 0 0 var(--space-6);
|
||||
}
|
||||
|
||||
.releases-id {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.releases-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.releases-year {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.bucket-head {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bucket-toggle {
|
||||
margin-left: auto;
|
||||
min-height: 2.75rem;
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent);
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
transition: color 150ms var(--ease-out);
|
||||
}
|
||||
|
||||
.bucket-toggle:hover {
|
||||
color: var(--accent-bright);
|
||||
}
|
||||
|
||||
/* fixed-width mono columns: chips and header labels share rem widths, so
|
||||
the pseudo-table survives font-size differences and flex wrapping alike */
|
||||
.cw {
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.cw-score {
|
||||
width: 4.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.cw-res {
|
||||
width: 4rem;
|
||||
}
|
||||
|
||||
.cw-src {
|
||||
width: 4.5rem;
|
||||
}
|
||||
|
||||
.cw-hdr {
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.cw-aud {
|
||||
width: 7.5rem;
|
||||
}
|
||||
|
||||
.cw-size {
|
||||
width: 4.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.cw-seed {
|
||||
width: 3.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.colhead {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-1) 0;
|
||||
}
|
||||
|
||||
.colhead .cw {
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
font-family: var(--font-readout);
|
||||
font-size: var(--text-2xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.rel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-2);
|
||||
padding: var(--space-2) var(--space-1);
|
||||
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
|
||||
}
|
||||
|
||||
/* the whole line is the expand affordance; grab is its own control */
|
||||
.rel-line {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: background 150ms var(--ease-out);
|
||||
}
|
||||
|
||||
.rel-line:hover,
|
||||
.rel-line:focus-visible {
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
/* §9.3: the release name is secondary — evidence, not the primary key */
|
||||
.rel-name {
|
||||
flex-basis: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.rel-none {
|
||||
padding: var(--space-2) var(--space-1);
|
||||
font-size: var(--text-xs);
|
||||
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
|
||||
}
|
||||
|
||||
.rel-note {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.rel-note[data-tone="fault"] {
|
||||
color: var(--signal-fault);
|
||||
}
|
||||
|
||||
.rel-full {
|
||||
flex-basis: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: inset 0 1px var(--highlight);
|
||||
}
|
||||
|
||||
.rel-raw {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.rel-meta {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-size: var(--text-2xs);
|
||||
}
|
||||
|
||||
/* ---- manual intake ---------------------------------------------------- */
|
||||
|
||||
.intake .module-detail {
|
||||
@@ -779,6 +986,21 @@ body {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.releases-id {
|
||||
order: -1;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
/* the row and the column header must wrap at the same width, so the
|
||||
grab control drops below the line instead of narrowing it */
|
||||
.rel-line {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.rel-grab {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.board {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user