diff --git a/web/index.html b/web/index.html
index 1046c98..2624b4a 100644
--- a/web/index.html
+++ b/web/index.html
@@ -401,13 +401,36 @@
-
+
+
+
+
![]()
+
+
+
—
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/main.ts b/web/src/main.ts
index 45c9100..a5bdc5f 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -18,6 +18,8 @@ import {
tmdbImage,
tmdbMovieLink,
tmdbPersonLink,
+ tmdbSeriesLink,
+ tvdbLink,
updateMovie,
youtubeLink,
} from "./movie";
@@ -87,8 +89,10 @@ import {
fileAttributeTags,
formatAirDate,
isUnaired,
+ type SeriesMetadata,
seasonCounts,
seasonTarget,
+ seriesMetadata,
setEpisodeWanted,
setSeasonTracked,
type TvTarget,
@@ -1119,6 +1123,68 @@ function wireCollapsedToggle(bucket: CollapsedBucketDom) {
});
}
+/* ---- §9.6 painters shared by both detail pages ------------------------- */
+
+/** The drawn star before a TMDB rating — no glyph standing in for an icon. */
+function starIcon(): SVGSVGElement {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("viewBox", "0 0 12 12");
+ svg.setAttribute("class", "star");
+ svg.setAttribute("aria-hidden", "true");
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute(
+ "d",
+ "M6 0.8 L7.5 4.2 L11.2 4.6 L8.4 7.1 L9.2 10.8 L6 8.9 L2.8 10.8 L3.6 7.1 L0.8 4.6 L4.5 4.2 Z",
+ );
+ svg.append(path);
+ return svg;
+}
+
+/** External links read as quiet controls; they leave the app entirely. */
+function externalLink(label: string, href: string): HTMLAnchorElement {
+ const link = document.createElement("a");
+ link.className = "control control-quiet";
+ link.href = href;
+ link.target = "_blank";
+ link.rel = "noreferrer";
+ link.textContent = label;
+ return link;
+}
+
+/** One top-billed cast member, linking out to their TMDB page (§9.6). */
+function castItem(member: MetadataCastMember): HTMLLIElement {
+ const item = document.createElement("li");
+ item.className = "cast-item";
+ const link = document.createElement("a");
+ link.className = "cast-link";
+ link.href = tmdbPersonLink(member.tmdb_id);
+ link.target = "_blank";
+ link.rel = "noreferrer";
+ const photo = document.createElement("span");
+ photo.className = "cast-photo";
+ const image = tmdbImage(member.profile_path, "w185");
+ if (image !== null) {
+ const img = document.createElement("img");
+ img.src = image;
+ img.alt = "";
+ img.loading = "lazy";
+ photo.append(img);
+ } else {
+ photo.classList.add("cast-photo-none");
+ }
+ const name = document.createElement("span");
+ name.className = "cast-name";
+ name.textContent = member.name;
+ const character = document.createElement("span");
+ character.className = "cast-character readout dim";
+ character.textContent = member.character;
+ link.setAttribute("aria-label", `${member.name} as ${member.character} — on TMDB`);
+ link.append(photo, name, character);
+ link.title = `${member.name} — ${member.character}`;
+ item.append(link);
+ return item;
+}
+
function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
const view = must("#movie");
const deckEl = must("#deck");
@@ -1384,65 +1450,6 @@ function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
castRows.replaceChildren();
}
- /** The drawn star before a TMDB rating — no glyph standing in for an icon. */
- function starIcon(): SVGSVGElement {
- const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
- svg.setAttribute("viewBox", "0 0 12 12");
- svg.setAttribute("class", "star");
- svg.setAttribute("aria-hidden", "true");
- const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
- path.setAttribute(
- "d",
- "M6 0.8 L7.5 4.2 L11.2 4.6 L8.4 7.1 L9.2 10.8 L6 8.9 L2.8 10.8 L3.6 7.1 L0.8 4.6 L4.5 4.2 Z",
- );
- svg.append(path);
- return svg;
- }
-
- /** External links read as quiet controls; they leave the app entirely. */
- function externalLink(label: string, href: string): HTMLAnchorElement {
- const link = document.createElement("a");
- link.className = "control control-quiet";
- link.href = href;
- link.target = "_blank";
- link.rel = "noreferrer";
- link.textContent = label;
- return link;
- }
-
- function castItem(member: MetadataCastMember): HTMLLIElement {
- const item = document.createElement("li");
- item.className = "cast-item";
- const link = document.createElement("a");
- link.className = "cast-link";
- link.href = tmdbPersonLink(member.tmdb_id);
- link.target = "_blank";
- link.rel = "noreferrer";
- const photo = document.createElement("span");
- photo.className = "cast-photo";
- const image = tmdbImage(member.profile_path, "w185");
- if (image !== null) {
- const img = document.createElement("img");
- img.src = image;
- img.alt = "";
- img.loading = "lazy";
- photo.append(img);
- } else {
- photo.classList.add("cast-photo-none");
- }
- const name = document.createElement("span");
- name.className = "cast-name";
- name.textContent = member.name;
- const character = document.createElement("span");
- character.className = "cast-character readout dim";
- character.textContent = member.character;
- link.setAttribute("aria-label", `${member.name} as ${member.character} — on TMDB`);
- link.append(photo, name, character);
- link.title = `${member.name} — ${member.character}`;
- item.append(link);
- return item;
- }
-
async function loadMetadata(id: number, ticket: number) {
const outcome = await movieMetadata(id);
if (ticket !== sequence || movieId !== id) {
@@ -2682,9 +2689,18 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
const view = must("#series");
const deckEl = must("#deck");
const back = must("#series-back");
+ const hero = must("#series-hero");
+ const poster = must("#series-poster");
const titleEl = must("#series-title");
const yearEl = must("#series-year");
const chipsEl = must("#series-chips");
+ const ratingEl = must("#series-rating");
+ const metaEl = must("#series-meta");
+ const taglineEl = must("#series-tagline");
+ const overviewEl = must("#series-overview");
+ const actionsEl = must("#series-actions");
+ const castSection = must("#series-cast");
+ const castRows = must("#rows-series-cast");
const statusEl = must("#series-status");
const seasonsList = must("#rows-seasons");
@@ -2741,6 +2757,107 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
}
}
+ /* ---- §9.6 rich detail: one request, images hotlinked ---- */
+
+ function clearRichDetail() {
+ hero.classList.remove("has-backdrop");
+ hero.style.removeProperty("--backdrop");
+ poster.hidden = true;
+ poster.removeAttribute("src");
+ ratingEl.hidden = true;
+ ratingEl.replaceChildren();
+ metaEl.hidden = true;
+ taglineEl.hidden = true;
+ overviewEl.hidden = true;
+ actionsEl.replaceChildren();
+ castSection.hidden = true;
+ castRows.replaceChildren();
+ }
+
+ async function loadMetadata(id: number, ticket: number) {
+ const outcome = await seriesMetadata(id);
+ if (ticket !== sequence || seriesId !== id) {
+ return;
+ }
+ if (outcome.kind === "error") {
+ // the operational header stands; the actions row says why the rest is absent
+ const note = document.createElement("p");
+ note.className = "add-note readout";
+ note.dataset.tone = "fault";
+ note.textContent = `metadata unavailable — ${outcome.detail}`;
+ actionsEl.append(note);
+ return;
+ }
+ paintMetadata(outcome.metadata);
+ }
+
+ /**
+ * What the show IS (#150): art, rating, overview above the season tree.
+ * The operational chips — audience, counts, derived status — stay from
+ * [`paintHeader`] and outrank all of it.
+ */
+ function paintMetadata(detail: SeriesMetadata) {
+ const posterUrl = tmdbImage(detail.poster_path, "w342");
+ if (posterUrl !== null && series !== null) {
+ poster.src = posterUrl;
+ poster.alt = `${series.title} poster`;
+ poster.hidden = false;
+ }
+ const backdropUrl = tmdbImage(detail.backdrop_path, "w1280");
+ if (backdropUrl !== null) {
+ hero.classList.add("has-backdrop");
+ hero.style.setProperty("--backdrop", `url("${backdropUrl}")`);
+ }
+ if (detail.vote_average !== null) {
+ ratingEl.hidden = false;
+ ratingEl.append(starIcon(), document.createTextNode(`${formatRating(detail.vote_average)} `));
+ const votes = document.createElement("span");
+ votes.className = "dim";
+ votes.textContent = `(${formatVoteCount(detail.vote_count)})`;
+ ratingEl.append(votes);
+ }
+ const metaLine = formatMetaLine(detail.runtime, detail.genres);
+ if (metaLine !== "") {
+ metaEl.hidden = false;
+ metaEl.textContent = metaLine;
+ }
+ if (detail.tagline !== null && detail.tagline !== "") {
+ taglineEl.hidden = false;
+ taglineEl.textContent = `“${detail.tagline}”`;
+ }
+ if (detail.overview !== null && detail.overview !== "") {
+ overviewEl.hidden = false;
+ overviewEl.textContent = detail.overview;
+ }
+ // the trailer resolves from this response's own key — no second call.
+ // Hidden when absent rather than shown dead (§9.6).
+ if (detail.trailer !== null) {
+ const trailer = document.createElement("a");
+ trailer.className = "control";
+ trailer.href = youtubeLink(detail.trailer.youtube_key);
+ trailer.target = "_blank";
+ trailer.rel = "noreferrer";
+ trailer.textContent = "trailer";
+ actionsEl.append(trailer);
+ }
+ actionsEl.append(externalLink("tmdb", tmdbSeriesLink(detail.tmdb_id)));
+ // a series has no imdb_id in this app; TVDB is its second id (§9.6)
+ if (detail.tvdb_id !== null) {
+ actionsEl.append(externalLink("tvdb", tvdbLink(detail.tvdb_id)));
+ }
+ if (series !== null) {
+ actionsEl.append(
+ externalLink("rotten tomatoes", rottenTomatoesSearch(series.title, series.year)),
+ );
+ }
+ if (detail.cast.length > 0) {
+ castSection.hidden = false;
+ for (const member of detail.cast) {
+ castRows.append(castItem(member));
+ }
+ }
+ }
+
function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
const counts = seasonCounts(season);
return chip(`${counts.available}/${counts.wanted} on disk`, (span) => {
@@ -3056,8 +3173,10 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
: [],
);
paintHeader();
+ clearRichDetail();
renderSeasons();
setStatus(null);
+ void loadMetadata(id, ticket);
if (focusKey !== null) {
view.querySelector(`[data-focus-id="${focusKey}"]`)?.focus();
focusKey = null;
@@ -3078,6 +3197,7 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
board.hidden = true;
deckEl.hidden = true;
view.hidden = false;
+ clearRichDetail();
back.focus();
await load();
}
@@ -3087,6 +3207,7 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
seriesId = null;
seasons = null;
sequence += 1;
+ clearRichDetail();
}
function close() {
diff --git a/web/src/movie.ts b/web/src/movie.ts
index 50f40f3..acdf984 100644
--- a/web/src/movie.ts
+++ b/web/src/movie.ts
@@ -90,6 +90,15 @@ export function tmdbMovieLink(tmdbId: number): string {
return `https://www.themoviedb.org/movie/${tmdbId}`;
}
+export function tmdbSeriesLink(tmdbId: number): string {
+ return `https://www.themoviedb.org/tv/${tmdbId}`;
+}
+
+/** A series has no imdb_id in this app; TVDB is its second id (§9.6). */
+export function tvdbLink(tvdbId: number): string {
+ return `https://www.thetvdb.com/dereferrer/series/${tvdbId}`;
+}
+
export function tmdbPersonLink(tmdbId: number): string {
return `https://www.themoviedb.org/person/${tmdbId}`;
}
diff --git a/web/src/series.ts b/web/src/series.ts
index a0230aa..eb48246 100644
--- a/web/src/series.ts
+++ b/web/src/series.ts
@@ -2,6 +2,7 @@
// release schemas — same reasoning as search.ts: the generated client
// (src/api/) is uncommitted, so CI's tsc cannot see it.
+import type { MetadataCastMember, MetadataTrailer } from "./movie";
import type { ActionOutcome, MovieRelease, ReleasesOutcome, WaiveOutcome } from "./releases";
import { errorDetail, probedAttributeTags, waiverOverride } from "./releases";
@@ -239,6 +240,46 @@ export async function waiveAndGrabTv(
return { kind: "done", overrideWritten };
}
+/* ---- §9.6 rich detail --------------------------------------------------- */
+
+/** Rich detail for one library series' §9.6 page (#146). */
+export interface SeriesMetadata {
+ tmdb_id: number;
+ overview: string | null;
+ tagline: string | null;
+ genres: string[];
+ /** Minutes per episode. */
+ runtime: number | null;
+ status: string;
+ poster_path: string | null;
+ backdrop_path: string | null;
+ /** `null` when TMDB has no votes for the title (#156). */
+ vote_average: number | null;
+ vote_count: number;
+ homepage: string | null;
+ /** §9.6 links out to TVDB for series; absent when the id is unknown. */
+ tvdb_id: number | null;
+ cast: MetadataCastMember[];
+ trailer: MetadataTrailer | null;
+}
+
+export type SeriesMetadataOutcome =
+ | { kind: "metadata"; metadata: SeriesMetadata }
+ | { kind: "error"; detail: string };
+
+/** One request for everything above the season tree (§9.6). */
+export async function seriesMetadata(seriesId: number): Promise {
+ try {
+ const response = await fetch(`/api/series/${seriesId}/metadata`);
+ if (!response.ok) {
+ return { kind: "error", detail: await errorDetail(response) };
+ }
+ return { kind: "metadata", metadata: (await response.json()) as SeriesMetadata };
+ } catch {
+ return { kind: "error", detail: "daemon unreachable" };
+ }
+}
+
/* ---- display helpers --------------------------------------------------- */
/** Wanted / on-disk counts for one season, whatever its number. */
diff --git a/web/src/style.css b/web/src/style.css
index 179b692..4d49d81 100644
--- a/web/src/style.css
+++ b/web/src/style.css
@@ -1103,11 +1103,6 @@ body {
/* ---- 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%);
@@ -1284,12 +1279,13 @@ body {
overflow-wrap: anywhere;
}
-/* ---- movie page (§9.6, issue #149) ------------------------------------- */
+/* ---- title detail pages (§9.6, issues #149 and #150) ------------------- */
-/* The hero is one machined module carrying what the film IS. The backdrop
- hotlinks from image.tmdb.org (§9.6) and sinks into the console charcoal
- under a scrim, so the readouts keep their contrast — a photo never sits
- behind text bare. */
+/* The hero is one machined module carrying what the title IS — shared by
+ the movie and series pages, which is why its classes read `movie-*`.
+ The backdrop hotlinks from image.tmdb.org (§9.6) and sinks into the
+ console charcoal under a scrim, so the readouts keep their contrast — a
+ photo never sits behind text bare. */
.movie-hero {
position: relative;