From 82100ef7aba0f7677040b9cc8d199e750360464a Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:12:06 +0100 Subject: [PATCH] feat(web): series detail view with season and episode decks --- web/index.html | 38 ++ web/src/main.ts | 1096 ++++++++++++++++++++++++++++++++++++++++----- web/src/style.css | 143 ++++++ 3 files changed, 1161 insertions(+), 116 deletions(-) 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%;