diff --git a/web/index.html b/web/index.html
index d090179..a0818f2 100644
--- a/web/index.html
+++ b/web/index.html
@@ -306,6 +306,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
—
+
+
+
+
+
+
+
+
diff --git a/web/src/main.ts b/web/src/main.ts
index 344d843..58f9580 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -36,6 +36,7 @@ import {
ruleLabel,
sweepExpected,
totalSize,
+ type WaiveOutcome,
waiveAndGrab,
waiverOverride,
} from "./releases";
@@ -55,6 +56,25 @@ import {
type TmdbMovie,
type TmdbSeries,
} from "./search";
+import {
+ type ApiEpisode,
+ type ApiSeason,
+ type ApiSeries,
+ type EpisodeFile,
+ episodeTarget,
+ fetchSeasons,
+ fetchSeries,
+ fetchSeriesFiles,
+ fileAttributeTags,
+ formatAirDate,
+ isUnaired,
+ seasonCounts,
+ seasonTarget,
+ setEpisodeWanted,
+ setSeasonTracked,
+ type TvTarget,
+ waiveAndGrabTv,
+} from "./series";
import { settingsMain } from "./settings";
import "./style.css";
@@ -209,11 +229,15 @@ function main() {
// views hide each other on open; the array is shared and filled once
const views: HideableView[] = [];
const releases = releasesMain();
- const library = libraryMain(board, releases, views);
+ // the tv deck registers its Escape handler before the series view does,
+ // so one Esc always steps back from the innermost layer first
+ const tvDeck = tvReleasesMain();
+ const seriesDetail = seriesMain(board, tvDeck, views);
+ const library = libraryMain(board, releases, seriesDetail, views);
const queues = queuesMain(board, releases, views);
const settings = settingsMain(board, views);
- views.push(library, queues, settings);
- const search = searchMain(board, releases, views);
+ views.push(tvDeck, seriesDetail, library, queues, settings);
+ const search = searchMain(board, releases, seriesDetail, views);
// a removed title must not survive on the surface the deck opened over
releases.setRemoved((parent) => {
switch (parent.kind) {
@@ -270,6 +294,26 @@ function main() {
});
break;
}
+ case "series": {
+ const detail = await fetchSeries(route.seriesId);
+ if (!detail) {
+ navigate({ kind: "library" }, { replace: true });
+ library.open();
+ return;
+ }
+ // the surface underneath must be live before the detail view covers
+ // it, so back and Esc land somewhere real (same as a movie deep link)
+ library.open();
+ seriesDetail.open(
+ route.seriesId,
+ must("#nav-library"),
+ must("#library"),
+ {
+ kind: "library",
+ },
+ );
+ break;
+ }
}
}
@@ -303,7 +347,12 @@ interface SearchView {
restore: (query: string) => void;
}
-function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableView[]): SearchView {
+function searchMain(
+ board: HTMLElement,
+ releases: ReleasesView,
+ seriesDetail: SeriesView,
+ views: HideableView[],
+): SearchView {
const input = must("#search");
const hint = must("#search-hint");
const refs: DeckRefs = {
@@ -338,6 +387,15 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV
);
void fetchRoots();
+ /** A series or episode hit opens the series detail view (#129). */
+ const openSeries = (id: number, origin: HTMLElement) => {
+ navigate({ kind: "series", seriesId: id });
+ void seriesDetail.open(id, origin, refs.deck, {
+ kind: "search",
+ query: input.value.trim(),
+ });
+ };
+
let timer: number | undefined;
let controller: AbortController | null = null;
@@ -439,10 +497,10 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV
);
}
for (const series of librarySeries) {
- refs.groups.library.rows.append(librarySeriesRow(series, roots));
+ refs.groups.library.rows.append(librarySeriesRow(series, roots, openSeries));
}
for (const episode of libraryEpisodes) {
- refs.groups.library.rows.append(episodeRow(episode));
+ refs.groups.library.rows.append(episodeRow(episode, openSeries));
}
}
if (response.tmdb.length > 0) {
@@ -620,13 +678,18 @@ function libraryRow(
}
/**
- * An in-library series hit. Display-only, like its library-view sibling —
- * the series detail view it would open into is issue 129.
+ * An in-library series hit: the whole row opens the series detail view,
+ * which is where its seasons, episodes and decks live (#129).
*/
-function librarySeriesRow(series: LibrarySeriesHit, roots: Root[]): HTMLLIElement {
+function librarySeriesRow(
+ series: LibrarySeriesHit,
+ roots: Root[],
+ open: (id: number, origin: HTMLElement) => void,
+): HTMLLIElement {
const item = document.createElement("li");
- const row = document.createElement("div");
- row.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 === series.root_id);
@@ -634,16 +697,27 @@ function librarySeriesRow(series: LibrarySeriesHit, roots: Root[]): HTMLLIElemen
if (series.blocked) {
chips.append(chip("blocked"));
}
+ const affordance = document.createElement("span");
+ affordance.className = "row-add readout";
+ affordance.textContent = "episodes";
+ chips.append(affordance);
row.append(rowTitle(series.title, series.year), chips);
+ row.addEventListener("click", () => {
+ open(series.id, row);
+ });
item.append(row);
return item;
}
/** An in-library episode hit: its series, the `SxxEyy` tag, then the title. */
-function episodeRow(episode: LibraryEpisodeHit): HTMLLIElement {
+function episodeRow(
+ episode: LibraryEpisodeHit,
+ open: (id: number, origin: HTMLElement) => void,
+): HTMLLIElement {
const item = document.createElement("li");
- const row = document.createElement("div");
- row.className = "row";
+ const row = document.createElement("button");
+ row.type = "button";
+ row.className = "row row-tmdb";
const chips = document.createElement("span");
chips.className = "row-chips";
chips.append(
@@ -656,7 +730,14 @@ function episodeRow(episode: LibraryEpisodeHit): HTMLLIElement {
title.className = "row-sub";
title.textContent = episode.title;
chips.append(title);
+ const affordance = document.createElement("span");
+ affordance.className = "row-add readout";
+ affordance.textContent = "episodes";
+ chips.append(affordance);
row.append(rowTitle(episode.series_title, null), chips);
+ row.addEventListener("click", () => {
+ open(episode.series_id, row);
+ });
item.append(row);
return item;
}
@@ -892,11 +973,49 @@ interface ReleasesView {
setRemoved: (handler: (parent: Route) => void) => void;
}
-interface BucketRefs {
+/** The eligible bucket always shows; waived and rejected collapse to counts. */
+interface CollapsedBucketDom {
section: HTMLElement;
+ toggle: HTMLButtonElement;
+ wrap: HTMLElement;
rows: HTMLUListElement;
}
+/**
+ * The §9.3 three-bucket structure. The movie deck builds it from static
+ * markup in index.html; the tv deck builds it with [`buildBucketDom`] —
+ * both paint through [`paintBuckets`].
+ */
+interface BucketsDom {
+ eligible: { section: HTMLElement; count: HTMLElement; rows: HTMLUListElement };
+ waived: CollapsedBucketDom;
+ rejected: CollapsedBucketDom;
+}
+
+function clearBuckets(dom: BucketsDom) {
+ dom.eligible.section.hidden = true;
+ dom.eligible.rows.replaceChildren();
+ for (const bucket of [dom.waived, dom.rejected]) {
+ bucket.section.hidden = true;
+ bucket.wrap.hidden = true;
+ bucket.toggle.setAttribute("aria-expanded", "false");
+ bucket.rows.replaceChildren();
+ }
+}
+
+function setToggle(bucket: CollapsedBucketDom, count: number) {
+ const open = !bucket.wrap.hidden;
+ bucket.toggle.textContent = open ? `hide ${count}` : `show ${count}`;
+ bucket.toggle.setAttribute("aria-expanded", String(open));
+}
+
+function wireCollapsedToggle(bucket: CollapsedBucketDom) {
+ bucket.toggle.addEventListener("click", () => {
+ bucket.wrap.hidden = !bucket.wrap.hidden;
+ setToggle(bucket, bucket.rows.childElementCount);
+ });
+}
+
function releasesMain(): ReleasesView {
const view = must("#releases");
const deck = must("#deck");
@@ -907,7 +1026,7 @@ function releasesMain(): ReleasesView {
const remove = must("#releases-remove");
const removeWrap = must("#remove-panel");
const status = must("#releases-status");
- const eligible: BucketRefs & { count: HTMLElement } = {
+ const eligible: BucketsDom["eligible"] = {
section: must("#bucket-eligible"),
count: must("#count-eligible"),
rows: must("#rows-eligible"),
@@ -932,9 +1051,23 @@ function releasesMain(): ReleasesView {
let origin: HTMLElement | null = null;
let returnTo: HTMLElement = deck;
let parentRoute: Route = { kind: "board" };
+ const dom: BucketsDom = { eligible, waived: collapsed.waived, rejected: collapsed.rejected };
const actions: ReleaseActions = {
reload: () => load(),
notify: (text, tone) => setStatus(text, tone),
+ grab: async (release, bucket) => {
+ const movie = current;
+ if (!movie) {
+ return { kind: "error", detail: "deck closed", overrideWritten: false };
+ }
+ if (bucket === "waived") {
+ return waiveAndGrab(movie.id, release.id, release.rejected_rule);
+ }
+ const outcome = await grabRelease(movie.id, release.id);
+ return outcome.kind === "done"
+ ? { kind: "done", overrideWritten: false }
+ : { kind: "error", detail: outcome.detail, overrideWritten: false };
+ },
};
// guards a stale fetch from painting over a newer view
let sequence = 0;
@@ -962,95 +1095,15 @@ function releasesMain(): ReleasesView {
}
}
- 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) {
+ if (!paintBuckets(dom, releases, actions)) {
return;
}
setStatus(null);
-
- // score colour is relative to this list (issue 111): highest green,
- // lowest red, seven interpolated stops between
- let min = Number.POSITIVE_INFINITY;
- let max = Number.NEGATIVE_INFINITY;
- for (const release of releases) {
- if (release.score !== null) {
- if (release.score < min) {
- min = release.score;
- }
- if (release.score > max) {
- max = release.score;
- }
- }
- }
- const scoreStop = (score: number | null): number | null => {
- if (score === null || min > max) {
- return null;
- }
- // all-equal scores are jointly the best the list offers
- return max === min ? SCORE_STOPS : Math.round(((score - min) / (max - min)) * SCORE_STOPS);
- };
-
- // 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", scoreStop, 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, scoreStop, actions));
- }
- setToggle(bucket, rows.length);
- }
}
async function load() {
@@ -1067,12 +1120,12 @@ function releasesMain(): ReleasesView {
return;
}
if (outcome.kind === "error") {
- clearBuckets();
+ clearBuckets(dom);
setStatus(`releases unavailable — ${outcome.detail}`, "fault");
return;
}
if (outcome.releases.length === 0) {
- clearBuckets();
+ clearBuckets(dom);
await emptyVerdict(movie, ticket);
return;
}
@@ -1187,7 +1240,7 @@ function releasesMain(): ReleasesView {
year.textContent = movie.year === null ? "—" : String(movie.year);
container.hidden = true;
view.hidden = false;
- clearBuckets();
+ clearBuckets(dom);
clearRemove();
sweep.disabled = false;
back.focus();
@@ -1378,6 +1431,11 @@ function removePanel(movie: LibraryMovie, actions: RemoveActions): HTMLElement {
interface ReleaseActions {
reload: () => Promise;
notify: (text: string, tone?: "fault") => void;
+ /** One click on grab — a plain grab, or waive-then-grab on a waived row. */
+ grab: (
+ release: MovieRelease,
+ bucket: "eligible" | "waived" | "rejected",
+ ) => Promise;
}
/** Stops in the score heat scale — keep in step with the --score-* tokens. */
@@ -1385,6 +1443,155 @@ const SCORE_STOPS = 6;
type ScoreStopper = (score: number | null) => number | null;
+/**
+ * Score colour is relative to one list (issue 111): highest green, lowest
+ * red, seven interpolated stops between.
+ */
+function scoreStopper(releases: MovieRelease[]): ScoreStopper {
+ let min = Number.POSITIVE_INFINITY;
+ let max = Number.NEGATIVE_INFINITY;
+ for (const release of releases) {
+ if (release.score !== null) {
+ if (release.score < min) {
+ min = release.score;
+ }
+ if (release.score > max) {
+ max = release.score;
+ }
+ }
+ }
+ return (score: number | null): number | null => {
+ if (score === null || min > max) {
+ return null;
+ }
+ // all-equal scores are jointly the best the list offers
+ return max === min ? SCORE_STOPS : Math.round(((score - min) / (max - min)) * SCORE_STOPS);
+ };
+}
+
+/**
+ * Splits classified releases into the three buckets and paints them. The
+ * API pre-sorts by bucket then score; splitting preserves order. Returns
+ * false when there was nothing to paint — the caller owns the empty state,
+ * which differs per deck.
+ */
+function paintBuckets(dom: BucketsDom, releases: MovieRelease[], actions: ReleaseActions): boolean {
+ clearBuckets(dom);
+ if (releases.length === 0) {
+ return false;
+ }
+
+ const scoreStop = scoreStopper(releases);
+ const buckets = {
+ eligible: releases.filter((release) => bucketOf(release) === "eligible"),
+ waived: releases.filter((release) => bucketOf(release) === "waived"),
+ rejected: releases.filter((release) => bucketOf(release) === "rejected"),
+ };
+
+ dom.eligible.section.hidden = false;
+ dom.eligible.count.textContent = String(buckets.eligible.length);
+ if (buckets.eligible.length > 0) {
+ for (const release of buckets.eligible) {
+ dom.eligible.rows.append(releaseRow(release, "eligible", scoreStop, 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";
+ dom.eligible.rows.append(none);
+ }
+ for (const name of ["waived", "rejected"] as const) {
+ const rows = buckets[name];
+ if (rows.length === 0) {
+ continue;
+ }
+ const bucket = dom[name];
+ bucket.section.hidden = false;
+ for (const release of rows) {
+ bucket.rows.append(releaseRow(release, name, scoreStop, actions));
+ }
+ setToggle(bucket, rows.length);
+ }
+ return true;
+}
+
+/** Builds the §9.3 bucket structure for a deck whose markup is dynamic. */
+function buildBucketDom(root: HTMLElement): BucketsDom {
+ function colhead(): HTMLElement {
+ const head = document.createElement("div");
+ head.className = "colhead";
+ head.setAttribute("aria-hidden", "true");
+ for (const [width, label] of [
+ ["cw-score", "score"],
+ ["cw-res", "res"],
+ ["cw-src", "source"],
+ ["cw-hdr", "hdr"],
+ ["cw-aud", "audio"],
+ ["cw-size", "size"],
+ ["cw-seed", "seed"],
+ ] as const) {
+ const cell = document.createElement("span");
+ cell.className = `cw ${width}`;
+ cell.textContent = label;
+ head.append(cell);
+ }
+ return head;
+ }
+
+ function rows(): HTMLUListElement {
+ const list = document.createElement("ul");
+ list.className = "deck-rows";
+ return list;
+ }
+
+ const eligibleSection = document.createElement("section");
+ eligibleSection.className = "deck-group";
+ eligibleSection.hidden = true;
+ const eligibleHead = document.createElement("header");
+ eligibleHead.className = "deck-head";
+ const eligibleName = document.createElement("h3");
+ eligibleName.className = "deck-label";
+ eligibleName.textContent = "eligible";
+ const eligibleCount = document.createElement("span");
+ eligibleCount.className = "deck-count readout";
+ eligibleHead.append(eligibleName, eligibleCount);
+ const eligibleRows = rows();
+ eligibleSection.append(eligibleHead, colhead(), eligibleRows);
+
+ function collapsed(label: string): CollapsedBucketDom {
+ const toggle = document.createElement("button");
+ toggle.type = "button";
+ toggle.className = "bucket-toggle readout";
+ toggle.setAttribute("aria-expanded", "false");
+ const sectionEl = document.createElement("section");
+ sectionEl.className = "deck-group";
+ sectionEl.hidden = true;
+ const head = document.createElement("header");
+ head.className = "deck-head bucket-head";
+ const name = document.createElement("h3");
+ name.className = "deck-label";
+ name.textContent = label;
+ head.append(name, toggle);
+ const wrap = document.createElement("div");
+ wrap.hidden = true;
+ const list = rows();
+ wrap.append(colhead(), list);
+ sectionEl.append(head, wrap);
+ const bucket: CollapsedBucketDom = { section: sectionEl, toggle, wrap, rows: list };
+ wireCollapsedToggle(bucket);
+ return bucket;
+ }
+
+ const dom: BucketsDom = {
+ eligible: { section: eligibleSection, count: eligibleCount, rows: eligibleRows },
+ waived: collapsed("waived"),
+ rejected: collapsed("rejected"),
+ };
+ root.append(dom.eligible.section, dom.waived.section, dom.rejected.section);
+ return dom;
+}
+
/**
* One classified release: chips lead, the name is secondary (§9.3). The
* score chip is coloured on the list-relative heat scale (issue 111);
@@ -1392,7 +1599,6 @@ type ScoreStopper = (score: number | null) => number | null;
*/
function releaseRow(
release: MovieRelease,
- movie: LibraryMovie,
bucket: "eligible" | "waived" | "rejected",
scoreStop: ScoreStopper,
actions: ReleaseActions,
@@ -1458,15 +1664,7 @@ function releaseRow(
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) => {
+ void actions.grab(release, bucket).then((outcome) => {
const text =
outcome.kind === "error"
? `${outcome.overrideWritten ? "override written · " : ""}grab failed — ${outcome.detail}`
@@ -1510,6 +1708,7 @@ interface LibraryGroup {
function libraryMain(
board: HTMLElement,
releases: ReleasesView,
+ seriesDetail: SeriesView,
views: HideableView[],
): LibraryView {
const view = must("#library");
@@ -1583,7 +1782,12 @@ function libraryMain(
const seriesShown = showAll ? library.series : library.series.filter(seriesNeedsAttention);
const moviesShown = showAll ? library.movies : library.movies.filter(movieNeedsAttention);
- renderGroup(groups.series, seriesShown, (series) => seriesRow(series, roots));
+ renderGroup(groups.series, seriesShown, (series) =>
+ seriesRow(series, roots, (id, origin) => {
+ navigate({ kind: "series", seriesId: id });
+ void seriesDetail.open(id, origin, view, { kind: "library" });
+ }),
+ );
renderGroup(groups.movies, moviesShown, (movie) =>
libraryRow(movie, roots, (target, origin) => {
navigate({ kind: "releases", movieId: target.id });
@@ -1677,12 +1881,18 @@ function libraryMain(
}
/**
- * One series with its §4.2 derived status: displayed, never editable.
- * No release deck yet — per-season and per-episode actions are issue #39.
+ * One series with its §4.2 derived status: displayed, never editable. The
+ * row opens the detail view — seasons, episodes and their decks (#129).
*/
-function seriesRow(series: LibrarySeries, roots: Root[]): HTMLLIElement {
+function seriesRow(
+ series: LibrarySeries,
+ roots: Root[],
+ open: (id: number, 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 === series.root_id);
@@ -1705,10 +1915,664 @@ function seriesRow(series: LibrarySeries, roots: Root[]): HTMLLIElement {
span.dataset.status = series.status;
}),
);
- item.append(rowTitle(series.title, series.year), chips);
+ const affordance = document.createElement("span");
+ affordance.className = "row-add readout";
+ affordance.textContent = "episodes";
+ chips.append(affordance);
+ row.append(rowTitle(series.title, series.year), chips);
+ row.addEventListener("click", () => {
+ open(series.id, row);
+ });
+ item.append(row);
return item;
}
+/* ---- tv release deck (§9.3 for season packs and single episodes) ------- */
+
+interface TvDeckRequest {
+ title: string;
+ sub: string | null;
+ seriesId: number;
+ target: TvTarget;
+ origin: HTMLElement;
+ returnTo: HTMLElement;
+ parentRoute: Route;
+}
+
+interface TvReleasesView {
+ open: (request: TvDeckRequest) => void;
+ hide: () => void;
+}
+
+/** §6.2: the sweep runs on the daemon's cadence. Poll modestly, give up softly. */
+const TV_SWEEP_POLL_MS = 5000;
+const TV_SWEEP_WAIT_MS = 150_000;
+
+function tvReleasesMain(): TvReleasesView {
+ const view = must("#tv-releases");
+ const back = must("#tv-releases-back");
+ const title = must("#tv-releases-title");
+ const sub = must("#tv-releases-sub");
+ const sweep = must("#tv-releases-sweep");
+ const statusEl = must("#tv-releases-status");
+ const dom = buildBucketDom(must("#tv-buckets"));
+
+ let request: TvDeckRequest | null = null;
+ // guards a stale fetch from painting over a newer view
+ let sequence = 0;
+ let pollTimer: number | undefined;
+
+ function setStatus(text: string | null, tone?: "fault", busy = false) {
+ statusEl.hidden = text === null;
+ if (busy && text !== null) {
+ const lamp = document.createElement("span");
+ lamp.className = "lamp";
+ lamp.dataset.state = "probing";
+ statusEl.replaceChildren(lamp, document.createTextNode(text));
+ } else {
+ statusEl.textContent = text ?? "";
+ }
+ if (tone) {
+ statusEl.dataset.tone = tone;
+ } else {
+ delete statusEl.dataset.tone;
+ }
+ }
+
+ const actions: ReleaseActions = {
+ reload: () => load(),
+ notify: (text, tone) => setStatus(text, tone),
+ grab: (release, bucket) => {
+ const current = request;
+ if (!current) {
+ return Promise.resolve({ kind: "error", detail: "deck closed", overrideWritten: false });
+ }
+ if (bucket === "waived") {
+ return waiveAndGrabTv(current.seriesId, current.target, release.id, release.rejected_rule);
+ }
+ return current.target
+ .grab(release.id)
+ .then((outcome) =>
+ outcome.kind === "done"
+ ? { kind: "done" as const, overrideWritten: false }
+ : { kind: "error" as const, detail: outcome.detail, overrideWritten: false },
+ );
+ },
+ };
+
+ async function load() {
+ const current = request;
+ if (!current) {
+ return;
+ }
+ sequence += 1;
+ const ticket = sequence;
+ window.clearTimeout(pollTimer);
+ setStatus("reading releases…");
+ const outcome = await current.target.releases();
+ if (ticket !== sequence || request !== current) {
+ return;
+ }
+ if (outcome.kind === "error") {
+ clearBuckets(dom);
+ setStatus(`releases unavailable — ${outcome.detail}`, "fault");
+ return;
+ }
+ if (!paintBuckets(dom, outcome.releases, actions)) {
+ setStatus("no releases indexed yet — search indexers queues a targeted sweep");
+ return;
+ }
+ setStatus(null);
+ }
+
+ /**
+ * A queued sweep is done when its releases appear in the table. Nothing
+ * found is indistinguishable from still running, so the wait says so
+ * honestly instead of promising either way.
+ */
+ function watchSweep(current: TvDeckRequest, ticket: number) {
+ sweep.disabled = true;
+ setStatus("sweeping indexers…", undefined, true);
+ const deadline = Date.now() + TV_SWEEP_WAIT_MS;
+ const tick = async () => {
+ if (ticket !== sequence || request !== current) {
+ return;
+ }
+ const outcome = await current.target.releases();
+ if (ticket !== sequence || request !== current) {
+ return;
+ }
+ if (outcome.kind === "results" && outcome.releases.length > 0) {
+ sweep.disabled = false;
+ paintBuckets(dom, outcome.releases, actions);
+ setStatus(null);
+ return;
+ }
+ if (Date.now() >= deadline) {
+ sweep.disabled = false;
+ setStatus(
+ "sweep has not landed yet — it may be waiting out its backoff; results appear here once it runs",
+ );
+ return;
+ }
+ pollTimer = window.setTimeout(() => {
+ void tick();
+ }, TV_SWEEP_POLL_MS);
+ };
+ pollTimer = window.setTimeout(() => {
+ void tick();
+ }, TV_SWEEP_POLL_MS);
+ }
+
+ sweep.addEventListener("click", () => {
+ const current = request;
+ if (!current) {
+ return;
+ }
+ sweep.disabled = true;
+ void (async () => {
+ const outcome = await current.target.search();
+ if (request !== current) {
+ return;
+ }
+ if (outcome.kind === "error") {
+ sweep.disabled = false;
+ setStatus(`search failed — ${outcome.detail}`, "fault");
+ return;
+ }
+ sequence += 1;
+ window.clearTimeout(pollTimer);
+ watchSweep(current, sequence);
+ })();
+ });
+
+ function open(next: TvDeckRequest) {
+ request = next;
+ title.textContent = next.title;
+ sub.textContent = next.sub ?? "";
+ next.returnTo.hidden = true;
+ view.hidden = false;
+ clearBuckets(dom);
+ sweep.disabled = false;
+ back.focus();
+ void load();
+ }
+
+ function hide() {
+ view.hidden = true;
+ request = null;
+ sequence += 1;
+ window.clearTimeout(pollTimer);
+ }
+
+ function close() {
+ const current = request;
+ navigate(current?.parentRoute ?? { kind: "board" });
+ hide();
+ if (current) {
+ current.returnTo.hidden = false;
+ current.origin.focus();
+ }
+ }
+
+ back.addEventListener("click", close);
+
+ // capture + stopImmediatePropagation: registered before the series view's
+ // own Escape listener, so one Esc steps back one layer — deck first
+ window.addEventListener(
+ "keydown",
+ (event) => {
+ if (event.key !== "Escape" || view.hidden) {
+ return;
+ }
+ event.stopImmediatePropagation();
+ close();
+ },
+ true,
+ );
+
+ return { open, hide };
+}
+
+/* ---- series detail view (§4.1, §4.2, issue #129) ----------------------- */
+
+interface SeriesView {
+ hide: () => void;
+ open: (
+ seriesId: number,
+ origin: HTMLElement,
+ returnTo: HTMLElement,
+ parentRoute: Route,
+ ) => Promise;
+}
+
+const PAD_TWO = (value: number): string => String(value).padStart(2, "0");
+
+function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
+ const view = must("#series");
+ const deckEl = must("#deck");
+ const back = must("#series-back");
+ const titleEl = must("#series-title");
+ const yearEl = must("#series-year");
+ const chipsEl = must("#series-chips");
+ const statusEl = must("#series-status");
+ const seasonsList = must("#rows-seasons");
+
+ let roots: Root[] = [];
+ let series: ApiSeries | null = null;
+ let seasons: ApiSeason[] | null = null;
+ let filesByEpisode = new Map();
+ // which seasons stand open survives the refetch every action triggers
+ let expanded = new Set();
+ let origin: HTMLElement | null = null;
+ let returnTo: HTMLElement = deckEl;
+ let parentRoute: Route = { kind: "library" };
+ let seriesId: number | null = null;
+ // the control to refocus once the refetch a click triggered repainted
+ let focusKey: string | null = null;
+ // guards a stale fetch from painting over a newer view
+ let sequence = 0;
+
+ function setStatus(text: string | null, tone?: "fault") {
+ statusEl.hidden = text === null;
+ statusEl.textContent = text ?? "";
+ if (tone) {
+ statusEl.dataset.tone = tone;
+ } else {
+ delete statusEl.dataset.tone;
+ }
+ }
+
+ function paintHeader() {
+ const current = series;
+ if (!current) {
+ return;
+ }
+ titleEl.textContent = current.title;
+ yearEl.textContent = current.year === null ? "" : String(current.year);
+ chipsEl.replaceChildren();
+ const root = roots.find((candidate) => candidate.id === current.root_id);
+ chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`));
+ chipsEl.append(
+ chip(`${current.available_episodes}/${current.wanted_episodes} eps`, (span) => {
+ span.setAttribute(
+ "aria-label",
+ `${current.available_episodes} of ${current.wanted_episodes} wanted episodes on disk`,
+ );
+ }),
+ );
+ chipsEl.append(
+ chip(current.status, (span) => {
+ span.dataset.status = current.status;
+ }),
+ );
+ if (current.blocked) {
+ chipsEl.append(chip("blocked"));
+ }
+ }
+
+ function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
+ const counts = seasonCounts(season);
+ return chip(`${counts.available}/${counts.wanted} on disk`, (span) => {
+ span.setAttribute(
+ "aria-label",
+ `${counts.available} of ${counts.wanted} wanted episodes on disk`,
+ );
+ });
+ }
+
+ function seasonRow(season: ApiSeason): HTMLLIElement {
+ const item = document.createElement("li");
+ item.className = "season";
+
+ const line = document.createElement("div");
+ line.className = "season-line";
+
+ const isOpen = expanded.has(season.number);
+ const disclose = document.createElement("button");
+ disclose.type = "button";
+ disclose.className = "season-disclose";
+ disclose.setAttribute("aria-expanded", String(isOpen));
+ disclose.setAttribute("aria-controls", `episodes-${season.number}`);
+ disclose.setAttribute("aria-label", `season ${PAD_TWO(season.number)} episodes`);
+ disclose.addEventListener("click", () => {
+ const nowOpen = !expanded.has(season.number);
+ if (nowOpen) {
+ expanded.add(season.number);
+ } else {
+ expanded.delete(season.number);
+ }
+ disclose.setAttribute("aria-expanded", String(nowOpen));
+ const list = item.querySelector(`#episodes-${season.number}`);
+ if (list) {
+ list.hidden = !nowOpen;
+ }
+ disclose.focus();
+ });
+
+ // season 0 never counts toward tracking or totals (§4.1, §4.2); say so
+ const name = document.createElement("span");
+ name.className = "season-name";
+ name.textContent = season.number === 0 ? "specials · s00" : `season ${PAD_TWO(season.number)}`;
+
+ const track = document.createElement("button");
+ track.type = "button";
+ track.className = "control control-quiet";
+ track.dataset.focusId = `track-${season.number}`;
+ track.setAttribute("aria-pressed", String(season.tracked));
+ track.textContent = "tracked";
+ track.addEventListener("click", () => {
+ if (seriesId === null) {
+ return;
+ }
+ track.disabled = true;
+ const wantedNext = !season.tracked;
+ void setSeasonTracked(seriesId, season.number, wantedNext).then((outcome) => {
+ if (outcome.kind === "error") {
+ track.disabled = false;
+ setStatus(`tracking failed — ${outcome.detail}`, "fault");
+ return;
+ }
+ // §4.1: turning tracking on marks revealed episodes wanted; turning
+ // it off withdraws nothing. The refetch paints both truths.
+ focusKey = `track-${season.number}`;
+ void load();
+ });
+ });
+
+ const space = document.createElement("span");
+ space.className = "season-space";
+
+ const searchBtn = document.createElement("button");
+ searchBtn.type = "button";
+ searchBtn.className = "control";
+ searchBtn.textContent = "search";
+ searchBtn.addEventListener("click", () => {
+ const currentId = seriesId;
+ if (currentId === null) {
+ return;
+ }
+ searchBtn.disabled = true;
+ void (async () => {
+ const outcome = await seasonTarget(currentId, season.number).search();
+ searchBtn.disabled = false;
+ if (outcome.kind === "error") {
+ setStatus(`search failed — ${outcome.detail}`, "fault");
+ return;
+ }
+ setStatus(`season ${PAD_TWO(season.number)} sweep queued — results land in its deck`);
+ })();
+ });
+
+ const deckBtn = document.createElement("button");
+ deckBtn.type = "button";
+ deckBtn.className = "control";
+ deckBtn.textContent = "deck";
+ deckBtn.addEventListener("click", () => {
+ const currentId = seriesId;
+ const currentTitle = series?.title ?? "";
+ if (currentId === null) {
+ return;
+ }
+ tvDeck.open({
+ title: currentTitle,
+ sub: `season ${PAD_TWO(season.number)} · packs`,
+ seriesId: currentId,
+ target: seasonTarget(currentId, season.number),
+ origin: deckBtn,
+ returnTo: view,
+ parentRoute: { kind: "series", seriesId: currentId },
+ });
+ });
+
+ line.append(disclose, name, track, seasonCountsChip(season), space, searchBtn, deckBtn);
+ item.append(line);
+
+ const episodes = document.createElement("ul");
+ episodes.className = "episodes";
+ episodes.id = `episodes-${season.number}`;
+ episodes.hidden = !isOpen;
+ for (const episode of season.episodes) {
+ episodes.append(detailEpisodeRow(season.number, episode));
+ }
+ item.append(episodes);
+ return item;
+ }
+
+ function detailEpisodeRow(seasonNumber: number, episode: ApiEpisode): HTMLLIElement {
+ const item = document.createElement("li");
+ item.className = "episode";
+
+ const tag = document.createElement("span");
+ tag.className = "ep-tag readout dim";
+ tag.textContent = `E${PAD_TWO(episode.number)}`;
+
+ const name = document.createElement("span");
+ name.className = "ep-title";
+ name.textContent = episode.title === "" ? "—" : episode.title;
+
+ const air = document.createElement("span");
+ air.className = "ep-air readout dim";
+ air.textContent = formatAirDate(episode.air_date);
+
+ const chips = document.createElement("span");
+ chips.className = "row-chips ep-chips";
+ chips.append(
+ chip(episode.state, (span) => {
+ span.dataset.movieState = episode.state;
+ }),
+ );
+ // #122: gone upstream while its file remained — a conflict, not a state
+ if (episode.vanished) {
+ chips.append(
+ chip("vanished", (span) => {
+ span.dataset.flag = "vanished";
+ }),
+ );
+ }
+ const file = filesByEpisode.get(episode.id);
+ if (file) {
+ // §7.4: attribute tags come from ffprobe, so they are true
+ for (const attribute of fileAttributeTags(file)) {
+ chips.append(chip(attribute));
+ }
+ const waiver = waiverLabel(file.waiver);
+ if (waiver !== null) {
+ chips.append(
+ chip(waiver, (span) => {
+ span.dataset.verdict = "waived";
+ }),
+ );
+ }
+ }
+
+ const actions = document.createElement("span");
+ actions.className = "ep-actions";
+ const aired = !isUnaired(episode.air_date);
+ const onDisk = episode.state === "available";
+
+ if (aired && onDisk) {
+ // present and correct: nothing to decide here
+ } else if (!aired) {
+ const note = document.createElement("span");
+ note.className = "readout dim ep-unaired";
+ note.textContent = "unaired";
+ actions.append(note);
+ } else {
+ const want = document.createElement("button");
+ want.type = "button";
+ want.className = "control control-quiet";
+ want.dataset.focusId = `want-${episode.id}`;
+ want.setAttribute("aria-pressed", String(episode.wanted));
+ want.textContent = "want";
+ want.addEventListener("click", () => {
+ want.disabled = true;
+ void setEpisodeWanted(episode.id, !episode.wanted).then((outcome) => {
+ if (outcome.kind === "error") {
+ want.disabled = false;
+ setStatus(`want failed — ${outcome.detail}`, "fault");
+ return;
+ }
+ focusKey = `want-${episode.id}`;
+ void load();
+ });
+ });
+ actions.append(want);
+
+ const searchBtn = document.createElement("button");
+ searchBtn.type = "button";
+ searchBtn.className = "control";
+ searchBtn.textContent = "search";
+ searchBtn.addEventListener("click", () => {
+ searchBtn.disabled = true;
+ void (async () => {
+ const outcome = await episodeTarget(episode.id).search();
+ searchBtn.disabled = false;
+ if (outcome.kind === "error") {
+ setStatus(`search failed — ${outcome.detail}`, "fault");
+ return;
+ }
+ setStatus(`S${PAD_TWO(seasonNumber)}E${PAD_TWO(episode.number)} sweep queued`);
+ })();
+ });
+ actions.append(searchBtn);
+
+ const deckBtn = document.createElement("button");
+ deckBtn.type = "button";
+ deckBtn.className = "control";
+ deckBtn.textContent = "deck";
+ deckBtn.addEventListener("click", () => {
+ const currentId = seriesId;
+ const currentTitle = series?.title ?? "";
+ if (currentId === null) {
+ return;
+ }
+ tvDeck.open({
+ title: currentTitle,
+ sub: `S${PAD_TWO(seasonNumber)}E${PAD_TWO(episode.number)} · ${episode.title}`,
+ seriesId: currentId,
+ target: episodeTarget(episode.id),
+ origin: deckBtn,
+ returnTo: view,
+ parentRoute: { kind: "series", seriesId: currentId },
+ });
+ });
+ actions.append(deckBtn);
+ }
+
+ item.append(tag, name, air, chips, actions);
+ return item;
+ }
+
+ function renderSeasons() {
+ seasonsList.replaceChildren();
+ if (!seasons) {
+ return;
+ }
+ for (const season of seasons) {
+ seasonsList.append(seasonRow(season));
+ }
+ if (seasons.length === 0) {
+ const none = document.createElement("li");
+ none.className = "rel rel-none readout dim";
+ none.textContent = "no seasons revealed yet — a metadata refresh fills this in";
+ seasonsList.append(none);
+ }
+ }
+
+ async function load() {
+ const id = seriesId;
+ if (id === null) {
+ return;
+ }
+ sequence += 1;
+ const ticket = sequence;
+ setStatus("reading series…");
+ const [detail, seasonsOutcome, filesOutcome, fetchedRoots] = await Promise.all([
+ fetchSeries(id),
+ fetchSeasons(id),
+ fetchSeriesFiles(id),
+ allRoots().catch(() => roots),
+ ]);
+ if (ticket !== sequence || seriesId !== id) {
+ return;
+ }
+ roots = fetchedRoots;
+ if (!detail) {
+ setStatus("series unavailable", "fault");
+ return;
+ }
+ if (seasonsOutcome.kind === "error") {
+ setStatus(`seasons unavailable — ${seasonsOutcome.detail}`, "fault");
+ return;
+ }
+ series = detail;
+ seasons = seasonsOutcome.seasons;
+ filesByEpisode = new Map(
+ filesOutcome.kind === "files"
+ ? filesOutcome.files.map((file) => [file.episode_id, file])
+ : [],
+ );
+ paintHeader();
+ renderSeasons();
+ setStatus(null);
+ if (focusKey !== null) {
+ view.querySelector(`[data-focus-id="${focusKey}"]`)?.focus();
+ focusKey = null;
+ }
+ }
+
+ async function open(id: number, from: HTMLElement, container: HTMLElement, parent: Route) {
+ seriesId = id;
+ origin = from;
+ returnTo = container;
+ parentRoute = parent;
+ expanded = new Set();
+ focusKey = null;
+ tvDeck.hide();
+ for (const sibling of views) {
+ sibling.hide();
+ }
+ board.hidden = true;
+ deckEl.hidden = true;
+ view.hidden = false;
+ back.focus();
+ await load();
+ }
+
+ function hide() {
+ view.hidden = true;
+ seriesId = null;
+ seasons = null;
+ sequence += 1;
+ }
+
+ function close() {
+ navigate(parentRoute);
+ hide();
+ returnTo.hidden = false;
+ origin?.focus();
+ }
+
+ back.addEventListener("click", close);
+
+ // capture, like every other layer: the tv deck's listener is registered
+ // first, so one Esc steps back one layer
+ window.addEventListener(
+ "keydown",
+ (event) => {
+ if (event.key === "Escape" && !view.hidden) {
+ event.stopImmediatePropagation();
+ navigate(parentRoute);
+ close();
+ }
+ },
+ true,
+ );
+
+ return { hide, open };
+}
+
/* ---- attention queues (§5.2 + §5.7, issue #33) ------------------------ */
interface QueuesView {
diff --git a/web/src/style.css b/web/src/style.css
index dfe4dd6..8a6d078 100644
--- a/web/src/style.css
+++ b/web/src/style.css
@@ -1101,6 +1101,135 @@ body {
padding: 0;
}
+/* ---- series detail (§4.1 + §4.2, issue #129) --------------------------- */
+
+/* the title line carries its readouts beside the name, not pushed right */
+.series-chips {
+ margin-left: var(--space-3);
+}
+
+/* one season: a collapsible group with its rule, counts and actions */
+.season {
+ border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
+}
+
+.season-line {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-2) var(--space-3);
+ padding: var(--space-2) var(--space-1);
+}
+
+/* the disclosure is a drawn chevron — no glyph standing in for an icon */
+.season-disclose {
+ flex: none;
+ width: 1.75rem;
+ height: 1.75rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: none;
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ cursor: pointer;
+ transition: border-color 150ms var(--ease-out);
+}
+
+.season-disclose:hover,
+.season-disclose:focus-visible {
+ border-color: var(--accent);
+}
+
+.season-disclose::before {
+ content: "";
+ width: 0.4rem;
+ height: 0.4rem;
+ border-right: 2px solid var(--ink-muted);
+ border-bottom: 2px solid var(--ink-muted);
+ transform: rotate(-45deg) translate(-5%, -5%);
+ transition: transform 200ms var(--ease-out);
+}
+
+.season-disclose[aria-expanded="true"]::before {
+ transform: rotate(45deg) translate(-5%, -5%);
+}
+
+.season-name {
+ font-family: var(--font-display);
+ font-size: var(--text-base);
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: var(--ink);
+}
+
+/* the tracked toggle reads as the rule it is; pressed = on */
+.season-line .control,
+.ep-actions .control {
+ min-height: 2.25rem;
+ padding: 0 var(--space-3);
+}
+
+.season-space {
+ flex: 1;
+}
+
+.episodes {
+ margin: 0;
+ padding: 0 0 var(--space-2) var(--space-8);
+ list-style: none;
+}
+
+.episode {
+ 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%);
+}
+
+.ep-tag {
+ flex: none;
+ width: 2.75rem;
+ color: var(--ink-faint);
+}
+
+.ep-title {
+ min-width: 0;
+ font-size: var(--text-sm);
+ color: var(--ink);
+}
+
+.ep-air {
+ flex: none;
+ width: 6.5rem;
+ color: var(--ink-faint);
+}
+
+.ep-chips {
+ margin-left: auto;
+}
+
+.ep-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ margin-left: var(--space-3);
+}
+
+.ep-unaired {
+ font-size: var(--text-xs);
+}
+
+/* #122: gone upstream while its file remained — a conflict, amber dashed */
+.chip[data-flag="vanished"] {
+ color: var(--signal-warn);
+ border-style: dashed;
+ border-color: oklch(from var(--signal-warn) l c h / 55%);
+}
+
/* ---- attention queues (§5.2 + §5.7) ------------------------------------ */
/* the badge is a signal, not a control: amber attention on the cyan word */
@@ -1194,6 +1323,20 @@ body {
flex-basis: 100%;
}
+ /* an episode row wraps: identity first, chips full-width, actions right */
+ .ep-chips {
+ margin-left: 0;
+ flex-basis: 100%;
+ }
+
+ .ep-actions {
+ margin-left: auto;
+ }
+
+ .episodes {
+ padding-left: var(--space-4);
+ }
+
.releases-id {
order: -1;
flex-basis: 100%;