diff --git a/AGENTS.md b/AGENTS.md
index 1ce5337..e843f2f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -300,6 +300,7 @@ Tracer Bullet: prove the whole pipe works with one thin real thread, then thicke
- `user_show_state` is `user_id`, `show_id`, `tracked_at` and the timestamps, nothing more: the spec 0020 contract migration (`20261010120000_show_tracking_contract.sql`) dropped the legacy status columns, the `tv_status`, `status_source` and `show_hold` enums, the old views and every status and hold function. `user_tracked_shows` is the one view. A show page control is a Plan to watch / Tracking toggle.
- The navbar's SHOWS | MOVIES tabs filter every page (scope feature 22, [prompts/media-tabs-everywhere.md](prompts/media-tabs-everywhere.md)). `/watchlist`, `/upcoming`, `/watched` and `/search` take their media type from `?type=tv|movie`. A bare URL means `tv`, and an invalid value shows a "That page doesn't exist" panel, never a redirect. Each tab has its own reads, count and pages. The parsing and URL building live only in `lib/catalog/media-type.ts`, so use `typedHref` for any link to these pages and always write `type`. The client shell reads `useSearchParams` only on those four paths, inside its own Suspense boundary; reading it anywhere else takes `/shows`, `/movies` and the title pages out of their prerendered shells.
- TMDB images render through `next/image`; `image.tmdb.org` is the one allowed remote pattern.
+- The visitor's country (Where to watch, the age rating chip) comes only from `getVisitorRegion()` in `lib/region.ts`, which reads Vercel's `x-vercel-ip-country` header with a `US` fallback (spec 0022). Call it only inside a streamed Suspense boundary, never in a page shell, a cached scope or an SEO surface, and never store, log or send it to TMDB.
- The navbar is transparent (no background, border or blur) and not sticky, so it scrolls away with the page; every control in it is 40px tall at every width, with the `hit-area` utilities keeping a 44px tap target. The footer is borderless. See [components/AGENTS.md](components/AGENTS.md).
- Legal facts (operator handle and GitHub URL, contact email, processors, `LEGAL_LAST_UPDATED`) live only in `lib/legal/operator.ts`, and TMDB's notice only in `TMDB_ATTRIBUTION`; `lib/legal/boundary.test.ts` fails if either is pasted elsewhere. `/privacy` and `/terms` describe only what the code does, so a change that adds a processor, email provider or analytics updates `PROCESSORS`, the policy text and `LEGAL_LAST_UPDATED` in the same PR (spec 0017). `public/tmdb-logo.svg` is TMDB's official file, hash checked; never edit it.
- Production runs from [docs/deploy.md](docs/deploy.md), the runbook of spec 0018: push a migration with `supabase db push` before merging the PR that adds it, and auth settings reach the cloud only through `supabase config push` (never the dashboard). Run both pushes yourself; from an agent shell the CLI applies them without stopping at the prompt. `vercel.json` pins functions to `fra1`, next to the Supabase region `eu-central-1`. The `/api/search` rate limit is a Vercel Firewall rule, recorded only in `docs/deploy.md`.
diff --git a/app/movies/[id]/page.tsx b/app/movies/[id]/page.tsx
index 9978a63..72619c8 100644
--- a/app/movies/[id]/page.tsx
+++ b/app/movies/[id]/page.tsx
@@ -3,7 +3,15 @@ import { notFound } from "next/navigation";
import { Suspense } from "react";
import { JsonLd } from "@/components/catalog/json-ld";
-import { CastRow } from "@/components/movie/cast-row";
+import { MovieCrew, MovieFacts } from "@/components/catalog/title-details";
+import {
+ AgeRatingSlot,
+ MoreLikeThisSection,
+ TrailerSlot,
+ WhereToWatchSection,
+} from "@/components/catalog/title-extras";
+import { SECTION_HEADING_CLASS } from "@/components/catalog/title-section";
+import { CastRow, CastSection } from "@/components/movie/cast-row";
import { MovieDetailSkeleton } from "@/components/movie/movie-detail-skeleton";
import { MovieHero } from "@/components/movie/movie-hero";
import { MovieOverview } from "@/components/movie/movie-overview";
@@ -71,6 +79,12 @@ export async function generateMetadata({
* scoped piece is the tracking slot from spec 0007, which lives in
* `components/tracking/` and streams inside its own Suspense boundary, so the
* catalog content never waits on the session.
+ *
+ * Spec 0022 adds the Trailer button and age chip to the hero, the crew under
+ * the overview, then Where to watch, Cast, More like this and Facts, in that
+ * order (AC-17). The trailer, chip, Where to watch and More like this stream
+ * from the extras read in their own boundaries; the chip and Where to watch
+ * read the visitor's region there, never in this shell (AC-10).
*/
export default function MoviePage({ params }: PageProps<"/movies/[id]">) {
return (
@@ -107,6 +121,7 @@ async function MovieDetail({
}
const { movie } = result;
+ const title = { mediaType: "movie", id } as const;
return (
@@ -130,33 +145,33 @@ async function MovieDetail({
/>
}
+ actions={}
+ ageRating={}
/>
-
+
Overview
+
-
-
- Cast
-
+
+
+
-
+
+
+
+
+
);
}
diff --git a/app/privacy/page.test.tsx b/app/privacy/page.test.tsx
index f72d705..dd8716d 100644
--- a/app/privacy/page.test.tsx
+++ b/app/privacy/page.test.tsx
@@ -86,6 +86,17 @@ describe("/privacy", () => {
expectOperatorLinks(container);
});
+ it("names YouTube and says the region country is not stored (spec 0022, AC-19)", () => {
+ const { container } = render();
+ const text = container.textContent ?? "";
+
+ expect(text).toContain("YouTube");
+ expect(text).toContain("only after you press Trailer");
+ expect(text).toMatch(
+ /approximate country Vercel derives from your\s+connection picks the region for where to watch and the age rating\. It\s+is not stored\./,
+ );
+ });
+
it("opens external links in a new tab, safely (AC-7)", () => {
const { container } = render();
diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx
index 092e720..445c21a 100644
--- a/app/privacy/page.tsx
+++ b/app/privacy/page.tsx
@@ -76,6 +76,11 @@ export default function PrivacyPage() {
Nothing else. We collect no payment data, no contacts, and no location
beyond what an IP address implies.
+
+ On a title page, the approximate country Vercel derives from your
+ connection picks the region for where to watch and the age rating. It
+ is not stored.
+
@@ -110,6 +115,11 @@ export default function PrivacyPage() {
so there is no consent banner. Fonts are served from BeStats itself,
not loaded from another site.
+
+ A trailer plays in YouTube’s privacy enhanced player, which
+ loads only after you press Trailer. From then on YouTube’s own
+ privacy policy applies to that player.
+
diff --git a/app/showcase/page.tsx b/app/showcase/page.tsx
index e1f964d..e68ba8b 100644
--- a/app/showcase/page.tsx
+++ b/app/showcase/page.tsx
@@ -388,6 +388,22 @@ export default function ShowcasePage() {
+
+ }
+ />
+
+
+ }
+ />
+
diff --git a/app/shows/[id]/page.tsx b/app/shows/[id]/page.tsx
index ae1f24a..e39e226 100644
--- a/app/shows/[id]/page.tsx
+++ b/app/shows/[id]/page.tsx
@@ -4,7 +4,15 @@ import { Suspense } from "react";
import { DetailHero } from "@/components/catalog/detail-hero";
import { CastRowSkeleton } from "@/components/catalog/detail-skeletons";
import { JsonLd } from "@/components/catalog/json-ld";
-import { CastRow } from "@/components/movie/cast-row";
+import { ShowCrew, ShowFacts } from "@/components/catalog/title-details";
+import {
+ AgeRatingSlot,
+ MoreLikeThisSection,
+ TrailerSlot,
+ WhereToWatchSection,
+} from "@/components/catalog/title-extras";
+import { SECTION_HEADING_CLASS } from "@/components/catalog/title-section";
+import { CastRow, CastSection } from "@/components/movie/cast-row";
import { MovieOverview } from "@/components/movie/movie-overview";
import { RetryLink } from "@/components/retry-link";
import { SeasonGrid } from "@/components/show/season-grid";
@@ -77,6 +85,11 @@ export async function generateMetadata({
* user's calculated ratings (spec 0012) stream from `components/tracking/`
* beside the Seasons heading and on each season card, and the hero's tracking
* pill (spec 0020) and progress line (spec 0013) stream into its tracking place.
+ *
+ * Spec 0022 adds the Trailer button and age chip to the hero, the creators
+ * under the overview, then Where to watch, Cast, More like this and Facts
+ * after the seasons (AC-17), each extras section in its own boundary, as on
+ * the movie page.
*/
export default function ShowPage({ params }: PageProps<"/shows/[id]">) {
return (
@@ -112,6 +125,7 @@ async function ShowDetail({
}
const { show } = result;
+ const title = { mediaType: "tv", id } as const;
return (
@@ -126,6 +140,7 @@ async function ShowDetail({
airSpan={formatAirSpan(show)}
status={show.status}
genres={show.genres}
+ ageRating={}
/>
}
tmdbRating={show.tmdbRating}
@@ -143,19 +158,21 @@ async function ShowDetail({
>
}
+ actions={}
/>
-
+
Overview
+
-
+
Seasons
@@ -178,21 +195,21 @@ async function ShowDetail({
/>
-
-
- Cast
-
+
+
+ }>
-
+
+
+
+
+
);
}
-const SECTION_HEADING =
- "text-xl leading-tight font-bold text-foreground md:text-2xl";
-
const NO_CAST_MESSAGE = "TMDB lists no cast for this show.";
/** Failures a retry can fix; the rest are cached as settled answers. */
diff --git a/app/title-pages.test.ts b/app/title-pages.test.ts
new file mode 100644
index 0000000..c315b19
--- /dev/null
+++ b/app/title-pages.test.ts
@@ -0,0 +1,107 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+/**
+ * covers: spec 0022, AC-10, AC-17
+ *
+ * Two properties of the title page modules that rendering cannot pin cheaply:
+ * the order the sections are written in, and that the metadata path never
+ * reaches the region or the extras. A grep, in the shape of
+ * `app/layout-purity.test.ts`, because both are about what a module names.
+ * The rendered order and the prerendered shells are checked live by
+ * `/check verify` and the `pnpm build` route table.
+ */
+const MOVIE_PAGE = readFileSync("app/movies/[id]/page.tsx", "utf8");
+const SHOW_PAGE = readFileSync("app/shows/[id]/page.tsx", "utf8");
+
+/** Each marker's offset in the source, failing loudly on a missing one. */
+function offsets(source: string, markers: string[]): number[] {
+ return markers.map((marker) => {
+ const at = source.indexOf(marker);
+ expect(at, `${marker} is missing`).toBeGreaterThanOrEqual(0);
+ return at;
+ });
+}
+
+/** True when the numbers only ever go up. */
+function ascending(values: number[]): boolean {
+ return values.every(
+ (value, index) => index === 0 || value > values[index - 1],
+ );
+}
+
+/** The body of `generateMetadata`, up to the first top level closing brace. */
+function metadataBody(source: string): string {
+ const start = source.indexOf("export async function generateMetadata");
+ expect(start).toBeGreaterThanOrEqual(0);
+ const end = source.indexOf("\n}\n", start);
+ return source.slice(start, end);
+}
+
+describe("title page section order (AC-17)", () => {
+ it("reads overview, crew, where to watch, cast, more like this, facts on a movie", () => {
+ const order = offsets(MOVIE_PAGE, [
+ " {
+ const hero = MOVIE_PAGE.slice(
+ MOVIE_PAGE.indexOf(" {
+ const order = offsets(SHOW_PAGE, [
+ " {
+ const hero = SHOW_PAGE.slice(
+ SHOW_PAGE.indexOf(" {
+ it.each([
+ ["the movie page", MOVIE_PAGE],
+ ["the show page", SHOW_PAGE],
+ ])(
+ "generateMetadata on %s names no region, rating or extras",
+ (_, source) => {
+ expect(metadataBody(source)).not.toMatch(
+ /getVisitorRegion|Extras|ageRating|AgeRating|watchProviders|headers\(/,
+ );
+ },
+ );
+
+ it("keeps the region out of the shell: only the extras slots read it", () => {
+ for (const source of [MOVIE_PAGE, SHOW_PAGE]) {
+ expect(source).not.toMatch(/getVisitorRegion|from "@\/lib\/region"/);
+ }
+ });
+});
diff --git a/components/AGENTS.md b/components/AGENTS.md
index 84e4c8b..9455b8a 100644
--- a/components/AGENTS.md
+++ b/components/AGENTS.md
@@ -34,7 +34,7 @@ All values live in `app/globals.css`, split across four blocks: `@theme inline`
- **The page background** (`layout/page-background.tsx`, rendered first in `` by the root layout) is fixed at `-z-10`, `aria-hidden` and `pointer-events-none`; its teal gradient (`page-gradient`) and vignette (`page-vignette`) are in the prerendered HTML and are the first paint. `PageBackgroundCanvas` imports `lib/film-scene/start.ts` when the browser is idle, which draws the film strips and bokeh with hand written WebGL2 (no Three.js) in a worker through `OffscreenCanvas`, or on the main thread where a worker cannot draw. Every colour it uses is read from `globals.css` tokens at mount. It stops in a hidden tab, draws one still frame under reduced motion, and falls back to the gradient and vignette alone without WebGL2 or after a lost context. Every fragment is capped so `text-muted-foreground` keeps 4.5:1 over its brightest pixel, read from that token at mount. Nothing in the shell may paint an opaque full page background over it.
- **A dead end is a bug.** `StatePanel`'s `error` and `signed-out` variants cannot render without an action. The discriminated union makes it a type error and the component throws if the type is bypassed.
- **Touch targets** are 44px on mobile and 36px on desktop, the `touch` and `sm` sizes in `buttonVariants`. A control drawn smaller than 44px on mobile carries `hit-area` instead (root AGENTS.md section 3). One accepted exception: the footer's `kris1027` credit link has a 36px tap area, because 44px would mean taking taps from the legal links above it or widening the gap and moving the line.
-- Posters render through `next/image` with `fill` inside `aspect-[2/3]`, using the `POSTER_SIZES` string exported by `poster-card.tsx` so it stays matched to the grid's column counts (2 below `md`, 4 from it, in a grid capped at 1200px and centred, so a 20 title page is 5 full rows; prompts/grid-tabs-footer-legend.md).
+- Posters render through `next/image` with `fill` inside `aspect-[2/3]`, using the `sizes` of the `PosterGrid` preset it sits in. `catalog` (every title grid) steps 2, 3, 4, 5 and 6 columns so a card stays near the home row card's 180px; `wide` (the season grid only) keeps 2 and 4 (spec 0022). Each preset's columns, `POSTER_GRID_SIZES` and `POSTER_GRID_EAGER` in `poster-grid.tsx` change together, and `poster-grid.test.tsx` recomputes the sizes. `POSTER_SIZES` in `poster-card.tsx` is the `catalog` value. The grid is capped at 1200px and centred.
- A missing poster renders the fallback tile at an identical footprint, so the grid never shifts. Never invent a placeholder image or placeholder metadata.
## Layout
@@ -43,6 +43,7 @@ All values live in `app/globals.css`, split across four blocks: `@theme inline`
- `components/layout/` holds the app shell pieces. The navbar ships only its signed out form; the signed in variant arrives with feature 6 and must not be built by reading a client supplied identity.
- Everything else at the top level is a content piece: `glass-pill`, `rating-badges`, `poster-card`, `poster-grid`, `state-panel`, `skeleton`, `pagination-links`, `retry-link`.
- `components/movie/` holds the movie page pieces (`MovieHero`, `MovieOverview`, `CastRow`, `MovieDetailSkeleton`), all Server Components. Feature 10 should reuse their structure for TV rather than design a second page (spec 0006).
+- `components/catalog/` holds the title page sections both `/movies/{id}` and `/shows/{id}` share (spec 0022): `title-details.tsx` (crew and Facts, from the detail read), and `title-extras.tsx`, `where-to-watch.tsx` and `trailer-button.tsx`, fed by the separate extras read. The extras sections stream in their own Suspense boundary and read the visitor's region there through `getVisitorRegion()` (`lib/region.ts`), never in the shell. The trailer iframe (`youtube-nocookie`) mounts only while its dialog is open.
- `components/library/` holds the three private library pages, `/watchlist`, `/upcoming` and `/watched` (spec 0020). `LibrarySection` reads one tab per request; show cards are Server Components (`watchlist-show-card.tsx` streams its season read per card, `show-cards.tsx` holds the Upcoming and Watched cards), and the client pieces, `library-grid.tsx` (movies), `mark-next-watched-button.tsx` and `missing-show-card.tsx`, sit outside the shell, so the shell's client boundary list above is unchanged. `ids.ts` is the one home of the focus ids the server cards and those client controls share. Show tracking writes from any surface go through `components/tracking/use-show-tracking.ts`, which owns the Stop tracking Undo.
- `components/layout/site-footer.tsx` renders under every route from the root layout and must read no request state (`app/layout-purity.test.ts`). It is flat (no glass, no border) and carries no email address. Its credit line links `OPERATOR_HANDLE` to `OPERATOR_GITHUB_URL`, both from `lib/legal/operator.ts`. `components/legal/` holds the reading column and helpers `/privacy` and `/terms` share; use `LEGAL_LINK_CLASS` for any link in legal text (spec 0017).
- The card marks are explained once, by `layout/icon-key.tsx`, the footer's first row on every route; there is no per page legend. A new mark adds its entry there, and a new badge must reuse `GlassPill` rather than re-derive it.
diff --git a/components/catalog/catalog-list-page.test.tsx b/components/catalog/catalog-list-page.test.tsx
index 07c62b2..58a2bed 100644
--- a/components/catalog/catalog-list-page.test.tsx
+++ b/components/catalog/catalog-list-page.test.tsx
@@ -200,13 +200,13 @@ describe("the list page grid (AC-4, AC-9)", () => {
);
});
- it("loads the first 4 posters eagerly and the rest lazily (AC-7)", async () => {
+ it("loads the first 6 posters eagerly and the rest lazily (AC-7; spec 0022, AC-5)", async () => {
read.mockResolvedValue(tvPage(8, 30));
await renderGrid();
- expect(screen.getByText("Show 4")).toHaveAttribute("data-priority", "true");
- expect(screen.getByText("Show 5")).toHaveAttribute(
+ expect(screen.getByText("Show 6")).toHaveAttribute("data-priority", "true");
+ expect(screen.getByText("Show 7")).toHaveAttribute(
"data-priority",
"false",
);
diff --git a/components/catalog/catalog-list-page.tsx b/components/catalog/catalog-list-page.tsx
index 8c862f1..e15992e 100644
--- a/components/catalog/catalog-list-page.tsx
+++ b/components/catalog/catalog-list-page.tsx
@@ -4,7 +4,7 @@ import { Suspense } from "react";
import { CatalogListCards } from "@/components/catalog/catalog-list-cards";
import { PaginationLinks } from "@/components/pagination-links";
-import { PosterGrid } from "@/components/poster-grid";
+import { POSTER_GRID_EAGER, PosterGrid } from "@/components/poster-grid";
import { RetryLink } from "@/components/retry-link";
import { PaginationSkeleton, PosterCardSkeleton } from "@/components/skeleton";
import { StatePanel } from "@/components/state-panel";
@@ -21,13 +21,6 @@ import { TmdbError } from "@/lib/tmdb";
/** TMDB's list page size, which the skeleton mirrors. */
const PAGE_SIZE = 20;
-/**
- * Cards whose posters load eagerly: one full row at the widest grid (4
- * columns from `md`), so the first row is never lazy at any width (spec 0021,
- * AC-7, keeping spec 0006, AC-1).
- */
-const EAGER_POSTERS = 4;
-
const LANDING_LABELS = { tv: "Shows", movie: "Movies" } as const;
type SearchParams = Promise>;
@@ -124,7 +117,7 @@ async function ListGrid({
diff --git a/components/catalog/detail-hero.tsx b/components/catalog/detail-hero.tsx
index 1d47305..4074ebe 100644
--- a/components/catalog/detail-hero.tsx
+++ b/components/catalog/detail-hero.tsx
@@ -28,6 +28,12 @@ type DetailHeroProps = {
* This component reads no session itself, so it stays a plain catalog piece.
*/
tracking?: React.ReactNode;
+ /**
+ * Public actions beside the tracking row: the Trailer button (spec 0022,
+ * AC-11), streamed in its own boundary with no fallback, so it reserves no
+ * height and appears only when the title has a trailer.
+ */
+ actions?: React.ReactNode;
};
/**
@@ -54,6 +60,7 @@ function DetailHero({
tmdbRating,
tmdbVoteCount,
tracking,
+ actions,
}: DetailHeroProps) {
return (
@@ -128,6 +135,7 @@ function DetailHero({
{meta}
{tracking}
+ {actions}
diff --git a/components/catalog/detail-skeletons.tsx b/components/catalog/detail-skeletons.tsx
index 9336e93..b934a3f 100644
--- a/components/catalog/detail-skeletons.tsx
+++ b/components/catalog/detail-skeletons.tsx
@@ -1,6 +1,7 @@
import { cn } from "cn";
-import { Skeleton } from "@/components/skeleton";
+import { ROW_ITEM_CLASS } from "@/components/catalog/carousel-row-states";
+import { PosterCardSkeleton, Skeleton } from "@/components/skeleton";
/**
* The `DetailHero` while TMDB answers, at its footprint (spec 0006, AC-11;
@@ -103,36 +104,36 @@ const OVERVIEW_LINES = [
];
/**
- * One row of cast cards at `CastRow`'s card widths, without a heading. The
- * caption reserves two lines each for the name and the character, the most
- * `CastRow` wraps them to, and `pb-3` matches the row's own padding.
+ * One row of cast cards at `CastRow`'s row card width, without a heading:
+ * the 2:3 frame, the name line and the character line, as on a media card.
+ * Inside the cast carousel it is the cards' fallback; on a page skeleton,
+ * `CastRowSkeletonFrame` stands in for the carousel's scroll region.
*/
function CastRowSkeleton() {
return (
-
- {Array.from({ length: 8 }, (_, index) => (
-
-
-
-
-
-
-
-
-
-
-
+
+ {Array.from({ length: 12 }, (_, index) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: static placeholders with no identity.
+
+
+
))}
+
+ );
+}
+
+/** The cast row skeleton clipped and padded like the carousel's region. */
+function CastRowSkeletonFrame() {
+ return (
+
+
);
}
export {
CastRowSkeleton,
+ CastRowSkeletonFrame,
DetailHeroSkeleton,
OverviewSkeleton,
SectionHeadingSkeleton,
diff --git a/components/catalog/title-details.test.tsx b/components/catalog/title-details.test.tsx
new file mode 100644
index 0000000..51157e4
--- /dev/null
+++ b/components/catalog/title-details.test.tsx
@@ -0,0 +1,166 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import type { Movie, TvShow } from "@/lib/tmdb";
+
+import {
+ AgeRatingChip,
+ MovieCrew,
+ MovieFacts,
+ ShowCrew,
+ ShowFacts,
+} from "./title-details";
+
+/**
+ * covers: spec 0022, AC-13, AC-15, AC-16
+ *
+ * The crew lines, the age chip and the Facts panel: each value only when
+ * TMDB has one, never Unknown or 0, and the panel gone when nothing is left.
+ */
+
+const MOVIE = {
+ title: "Fight Club",
+ originalTitle: "Fight Club",
+ originalLanguage: "en",
+ status: "Released",
+ budget: 63_000_000,
+ revenue: 100_853_753,
+ productionCompanies: [
+ { id: 1, name: "Fox 2000 Pictures", logoUrl: null },
+ { id: 2, name: "Regency Enterprises", logoUrl: null },
+ ],
+ directors: ["David Fincher"],
+ writers: ["Jim Uhls"],
+} as Movie;
+
+const SHOW = {
+ status: "Ended",
+ type: "Scripted",
+ originalLanguage: "en",
+ networks: [
+ { id: 174, name: "AMC", logoUrl: "https://image.tmdb.org/t/p/w154/a.png" },
+ { id: 2, name: "No Logo TV", logoUrl: null },
+ ],
+ productionCompanies: [],
+ firstAirDate: "2008-01-20",
+ lastAirDate: "2013-09-29",
+ createdBy: ["Vince Gilligan"],
+} as unknown as TvShow;
+
+/** The panel's facts as `label: value` pairs, in order. */
+function facts(): string[] {
+ return Array.from(document.querySelectorAll("dl > div")).map(
+ (row) =>
+ `${row.querySelector("dt")?.textContent}: ${row.querySelector("dd")?.textContent}`,
+ );
+}
+
+describe("crew lines (AC-15)", () => {
+ it("shows Directed by and Written by for a movie", () => {
+ render();
+
+ expect(screen.getByText("Directed by")).toBeVisible();
+ expect(screen.getByText("A, B")).toBeVisible();
+ expect(screen.getByText("Written by")).toBeVisible();
+ });
+
+ it("leaves out a line with no names, and renders nothing with none", () => {
+ const { container, rerender } = render(
+ ,
+ );
+ expect(screen.queryByText("Written by")).toBeNull();
+
+ rerender();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("shows Created by for a show", () => {
+ render();
+
+ expect(screen.getByText("Created by")).toBeVisible();
+ expect(screen.getByText("Vince Gilligan")).toBeVisible();
+ });
+});
+
+describe("AgeRatingChip (AC-9, AC-13)", () => {
+ it("shows the rating and names the country", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("16")).toBeVisible();
+ expect(screen.getByText(/Age rating in Germany/)).toBeInTheDocument();
+ });
+});
+
+describe("MovieFacts (AC-16)", () => {
+ it("lists each fact TMDB has, money in compact US dollars", () => {
+ render();
+
+ expect(screen.getByRole("heading", { name: "Facts" })).toBeVisible();
+ expect(facts()).toEqual([
+ "Status: Released",
+ "Original language: English",
+ "Budget: $63M",
+ "Revenue: $100.9M",
+ "Production companies: Fox 2000 Pictures, Regency Enterprises",
+ ]);
+ });
+
+ it("shows the original title only when it differs", () => {
+ render();
+
+ expect(facts()).toContain("Original title: Le Fight Club");
+ });
+
+ it("never shows Unknown or 0 for a missing value", () => {
+ render(
+ ,
+ );
+
+ expect(facts()).toEqual(["Original language: English"]);
+ expect(document.body.textContent).not.toMatch(/Unknown|\$0/);
+ });
+});
+
+describe("ShowFacts (AC-16)", () => {
+ it("lists status, type, language, networks and both air dates", () => {
+ render();
+
+ expect(facts()).toEqual([
+ "Status: Ended",
+ "Type: Scripted",
+ "Original language: English",
+ "Networks: AMCNo Logo TV",
+ "First aired: Jan 20, 2008",
+ "Last aired: Sep 29, 2013",
+ ]);
+ });
+
+ it("leaves out an empty status and missing air dates", () => {
+ render(
+ ,
+ );
+
+ expect(facts()).toEqual(["Original language: English"]);
+ });
+});
diff --git a/components/catalog/title-details.tsx b/components/catalog/title-details.tsx
new file mode 100644
index 0000000..9cf9006
--- /dev/null
+++ b/components/catalog/title-details.tsx
@@ -0,0 +1,185 @@
+import Image from "next/image";
+import type * as React from "react";
+
+import { GlassPill } from "@/components/glass-pill";
+import type { Region } from "@/lib/catalog/region";
+import { formatAirDate, formatUsdCompact, languageName } from "@/lib/format";
+import type { Company, Movie, TvShow } from "@/lib/tmdb";
+
+import { SECTION_HEADING_CLASS } from "./title-section";
+
+/**
+ * The key crew under the overview (spec 0022, AC-15): "Directed by" and
+ * "Written by" for a movie, "Created by" for a show. Each line only when it
+ * has a name, and nothing at all when none does.
+ */
+function CrewLines({
+ lines,
+}: {
+ lines: ReadonlyArray<{ label: string; names: string[] }>;
+}) {
+ const shown = lines.filter((line) => line.names.length > 0);
+ if (shown.length === 0) return null;
+
+ return (
+
+ {shown.map((line) => (
+
+
{line.label}
+
+ {line.names.join(", ")}
+
+
+ ))}
+
+ );
+}
+
+/** A movie's crew lines, from the crew `getMovie` already read. */
+function MovieCrew({ movie }: { movie: Pick }) {
+ return (
+
+ );
+}
+
+/** A show's crew line, from `created_by` on `getTvShow`. */
+function ShowCrew({ show }: { show: Pick }) {
+ return ;
+}
+
+/**
+ * The hero's age rating chip for the visitor's country (spec 0022, AC-13).
+ * The country is named for assistive technology and on hover, since a bare
+ * "16" means little without it (AC-9). Rendered only when the country has a
+ * rating; another country's is never borrowed.
+ */
+function AgeRatingChip({ rating, region }: { rating: string; region: Region }) {
+ return (
+
+ Age rating in {region.name}:
+ {rating}
+
+ );
+}
+
+type Fact = { label: string; value: React.ReactNode };
+
+/**
+ * The Facts panel at the end of a title page (spec 0022, AC-16): a list of
+ * labelled values, each only when TMDB has one. Nothing is shown as Unknown
+ * or 0; with no facts at all the panel is left out.
+ */
+function FactsPanel({ facts }: { facts: Array }) {
+ const shown = facts.filter((fact): fact is Fact => fact !== null);
+ if (shown.length === 0) return null;
+
+ return (
+
+
+ Facts
+
+
+ {shown.map((fact) => (
+
+
{fact.label}
+
+ {fact.value}
+
+
+ ))}
+
+
+ );
+}
+
+/** A fact with a text value, or null when there is no value. */
+function textFact(label: string, value: string | null): Fact | null {
+ return value === null || value === "" ? null : { label, value };
+}
+
+/** Company names joined, or null when TMDB lists none. */
+function companiesFact(label: string, companies: Company[]): Fact | null {
+ return companies.length === 0
+ ? null
+ : { label, value: companies.map((company) => company.name).join(", ") };
+}
+
+/** A movie's facts, all from `getMovie` (AC-16). */
+function MovieFacts({ movie }: { movie: Movie }) {
+ return (
+
+ );
+}
+
+/** A show's facts, all from `getTvShow` (AC-16). */
+function ShowFacts({ show }: { show: TvShow }) {
+ return (
+ ,
+ },
+ companiesFact("Production companies", show.productionCompanies),
+ textFact("First aired", formatAirDate(show.firstAirDate)),
+ textFact("Last aired", formatAirDate(show.lastAirDate)),
+ ]}
+ />
+ );
+}
+
+/**
+ * A show's networks: each `w154` logo on a mid grey tile, followed by the
+ * name. TMDB's network logos come both white and dark, so no single tile
+ * keeps every one readable, and the name is what always reads; the logo is
+ * decoration beside it (`alt=""`). With no logo, the name alone.
+ */
+function NetworkList({ networks }: { networks: Company[] }) {
+ return (
+
+ );
+}
+
+export { AgeRatingChip, MovieCrew, MovieFacts, ShowCrew, ShowFacts };
diff --git a/components/catalog/title-extras.test.tsx b/components/catalog/title-extras.test.tsx
new file mode 100644
index 0000000..1002621
--- /dev/null
+++ b/components/catalog/title-extras.test.tsx
@@ -0,0 +1,286 @@
+import { render, screen, within } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * covers: spec 0022, AC-9, AC-11, AC-12, AC-13, AC-14, AC-18
+ *
+ * The extras slots on their own: the region picks the entry after the read,
+ * an empty or missing value leaves its piece out, and a `TmdbError` (a 404
+ * included) stays inside the slot that asked, as a one line error with a
+ * Retry to the title page. The reads, the region and the cards are replaced;
+ * `TmdbError` is the real one.
+ */
+const readMovie = vi.fn();
+const readTv = vi.fn();
+const region = vi.fn();
+
+vi.mock("@/lib/tmdb", async () => ({
+ TmdbError: (await import("@/lib/tmdb/errors")).TmdbError,
+ getMovieExtras: (...args: unknown[]) => readMovie(...args),
+ getTvExtras: (...args: unknown[]) => readTv(...args),
+}));
+vi.mock("@/lib/region", () => ({ getVisitorRegion: () => region() }));
+vi.mock("@/components/catalog/list-cards", () => ({
+ MovieListCard: ({
+ movie,
+ returnPath,
+ }: {
+ movie: { title: string };
+ returnPath: string;
+ }) => {movie.title},
+ ShowListCard: ({ show }: { show: { name: string } }) => (
+ {show.name}
+ ),
+}));
+
+const { TmdbError } = await import("@/lib/tmdb/errors");
+const { JUSTWATCH_ATTRIBUTION } = await import("@/lib/legal/operator");
+const {
+ AgeRatingSlotContent,
+ MoreLikeThisContent,
+ TrailerSlotContent,
+ WhereToWatchContent,
+} = await import("./title-extras");
+
+const POLAND = { code: "PL", name: "Poland" };
+const MOVIE = { mediaType: "movie", id: 550 } as const;
+
+function provider(id: number, name: string) {
+ return { id, name, logoUrl: `https://image.tmdb.org/t/p/w92/${id}.png` };
+}
+
+function extras(overrides: Record = {}) {
+ return {
+ trailer: { key: "dfeUzm6KF4g", name: "Official Trailer" },
+ recommendations: {
+ mediaType: "movie",
+ paged: {
+ page: 1,
+ totalPages: 2,
+ totalResults: 40,
+ results: Array.from({ length: 20 }, (_, index) => ({
+ id: index + 1,
+ title: `Movie ${index + 1}`,
+ })),
+ },
+ },
+ watchProviders: {
+ PL: {
+ link: "https://www.themoviedb.org/movie/550/watch?locale=PL",
+ stream: [provider(8, "Netflix")],
+ free: [],
+ rentOrBuy: [
+ provider(10, "Amazon Video"),
+ { ...provider(11, "No Logo"), logoUrl: null },
+ ],
+ },
+ US: {
+ link: "https://www.themoviedb.org/movie/550/watch?locale=US",
+ stream: [provider(9, "Hulu")],
+ free: [],
+ rentOrBuy: [],
+ },
+ },
+ ageRatings: { PL: "18", US: "R" },
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ // The row's scroller measures itself; jsdom has no ResizeObserver.
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ disconnect() {}
+ },
+ );
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ readMovie.mockReset();
+ readTv.mockReset();
+ region.mockReset();
+});
+
+describe("TrailerSlotContent (AC-11)", () => {
+ it("renders the Trailer button for a title with a trailer", async () => {
+ readMovie.mockResolvedValue(extras());
+
+ render(await TrailerSlotContent({ ...MOVIE, title: "Fight Club" }));
+
+ expect(readMovie).toHaveBeenCalledWith(550);
+ expect(screen.getByRole("button", { name: "Trailer" })).toBeVisible();
+ // The player does not exist until the dialog opens.
+ expect(document.querySelector("iframe")).toBeNull();
+ });
+
+ it("renders nothing with no trailer, or when the read failed (AC-18)", async () => {
+ readMovie.mockResolvedValue(extras({ trailer: null }));
+ expect(await TrailerSlotContent({ ...MOVIE, title: "X" })).toBeNull();
+
+ readMovie.mockRejectedValue(new TmdbError("timeout", "/movie/550", "x"));
+ expect(await TrailerSlotContent({ ...MOVIE, title: "X" })).toBeNull();
+ });
+
+ it("reads a show's extras for a show", async () => {
+ readTv.mockResolvedValue(extras({ trailer: null }));
+
+ await TrailerSlotContent({ mediaType: "tv", id: 1396, title: "X" });
+
+ expect(readTv).toHaveBeenCalledWith(1396);
+ expect(readMovie).not.toHaveBeenCalled();
+ });
+
+ it("rethrows anything that is not a TMDB failure", async () => {
+ readMovie.mockRejectedValue(new Error("bug"));
+
+ await expect(TrailerSlotContent({ ...MOVIE, title: "X" })).rejects.toThrow(
+ "bug",
+ );
+ });
+});
+
+describe("AgeRatingSlotContent (AC-9, AC-13)", () => {
+ it("shows the visitor's country rating, naming the country", async () => {
+ readMovie.mockResolvedValue(extras());
+ region.mockResolvedValue(POLAND);
+
+ render(<>{await AgeRatingSlotContent(MOVIE)}>);
+
+ expect(screen.getByText("18")).toBeVisible();
+ expect(screen.getByText(/Age rating in Poland/)).toBeInTheDocument();
+ });
+
+ it("never borrows another country's rating", async () => {
+ readMovie.mockResolvedValue(extras({ ageRatings: { US: "R" } }));
+ region.mockResolvedValue(POLAND);
+
+ expect(await AgeRatingSlotContent(MOVIE)).toBeNull();
+ });
+
+ it("renders nothing when the read failed (AC-18)", async () => {
+ readMovie.mockRejectedValue(new TmdbError("not_found", "/movie/550", "x"));
+ region.mockResolvedValue(POLAND);
+
+ expect(await AgeRatingSlotContent(MOVIE)).toBeNull();
+ });
+});
+
+describe("WhereToWatchContent (AC-9, AC-12, AC-18)", () => {
+ it("names the country and lists only its non empty groups, in order", async () => {
+ readMovie.mockResolvedValue(extras());
+ region.mockResolvedValue(POLAND);
+
+ render(await WhereToWatchContent(MOVIE));
+
+ expect(
+ screen.getByRole("heading", { name: "Where to watch in Poland" }),
+ ).toBeVisible();
+ const groups = screen
+ .getAllByRole("list")
+ .map((list) => list.getAttribute("aria-label"));
+ expect(groups).toEqual(["Stream", "Rent or buy"]);
+ expect(
+ within(screen.getByRole("list", { name: "Stream" })).getByRole("img", {
+ name: "Netflix",
+ }),
+ ).toBeVisible();
+ expect(screen.queryByRole("img", { name: "Hulu" })).toBeNull();
+ // A provider with no logo shows its name.
+ expect(screen.getByText("No Logo")).toBeVisible();
+ });
+
+ it("links once to TMDB's page for the country, with the JustWatch credit", async () => {
+ readMovie.mockResolvedValue(extras());
+ region.mockResolvedValue(POLAND);
+
+ render(await WhereToWatchContent(MOVIE));
+
+ const link = screen.getByRole("link", { name: /All options on TMDB/ });
+ expect(link).toHaveAttribute(
+ "href",
+ "https://www.themoviedb.org/movie/550/watch?locale=PL",
+ );
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
+ expect(screen.getByText(JUSTWATCH_ATTRIBUTION)).toBeVisible();
+ });
+
+ it("says plainly when the country has no providers", async () => {
+ readMovie.mockResolvedValue(extras({ watchProviders: {} }));
+ region.mockResolvedValue(POLAND);
+
+ render(await WhereToWatchContent(MOVIE));
+
+ expect(
+ screen.getByText("Not streaming in Poland right now."),
+ ).toBeVisible();
+ expect(screen.getByText(JUSTWATCH_ATTRIBUTION)).toBeVisible();
+ expect(screen.queryByRole("link")).toBeNull();
+ });
+
+ it("keeps a failure to one line with a Retry to the page (AC-18)", async () => {
+ readMovie.mockRejectedValue(new TmdbError("not_found", "/movie/550", "x"));
+ region.mockResolvedValue(POLAND);
+
+ render(await WhereToWatchContent(MOVIE));
+
+ expect(
+ screen.getByText("Couldn't load where to watch from TMDB."),
+ ).toBeVisible();
+ expect(screen.getByRole("link", { name: "Try again" })).toHaveAttribute(
+ "href",
+ "/movies/550",
+ );
+ });
+});
+
+describe("MoreLikeThisContent (AC-14, AC-18)", () => {
+ it("shows up to 12 recommendations, returning to the title page", async () => {
+ readMovie.mockResolvedValue(extras());
+
+ render(<>{await MoreLikeThisContent(MOVIE)}>);
+
+ expect(
+ screen.getByRole("heading", { name: "More like this" }),
+ ).toBeVisible();
+ expect(screen.getByText("Movie 12")).toHaveAttribute(
+ "data-return",
+ "/movies/550",
+ );
+ expect(screen.queryByText("Movie 13")).toBeNull();
+ expect(screen.queryByRole("link", { name: /See all/ })).toBeNull();
+ });
+
+ it("leaves the whole section out with no recommendations", async () => {
+ readMovie.mockResolvedValue(
+ extras({
+ recommendations: {
+ mediaType: "movie",
+ paged: { page: 1, results: [], totalPages: 0, totalResults: 0 },
+ },
+ }),
+ );
+
+ expect(await MoreLikeThisContent(MOVIE)).toBeNull();
+ });
+
+ it("keeps its heading with a one line error and Retry on failure", async () => {
+ readTv.mockRejectedValue(new TmdbError("upstream", "/tv/1396", "x"));
+
+ render(<>{await MoreLikeThisContent({ mediaType: "tv", id: 1396 })}>);
+
+ expect(
+ screen.getByRole("heading", { name: "More like this" }),
+ ).toBeVisible();
+ expect(
+ screen.getByText("Couldn't load recommendations from TMDB."),
+ ).toBeVisible();
+ expect(screen.getByRole("link", { name: "Try again" })).toHaveAttribute(
+ "href",
+ "/shows/1396",
+ );
+ });
+});
diff --git a/components/catalog/title-extras.tsx b/components/catalog/title-extras.tsx
new file mode 100644
index 0000000..8d20450
--- /dev/null
+++ b/components/catalog/title-extras.tsx
@@ -0,0 +1,248 @@
+import type * as React from "react";
+import { cache, Suspense } from "react";
+
+import {
+ CarouselRowSkeleton,
+ ROW_ITEM_CLASS,
+ ROW_POSTER_SIZES,
+ ROW_SIZE,
+} from "@/components/catalog/carousel-row-states";
+import { CarouselScroller } from "@/components/catalog/carousel-scroller";
+import { CatalogListCards } from "@/components/catalog/catalog-list-cards";
+import { type MediaType, titleHref } from "@/lib/catalog/media-type";
+import { getVisitorRegion } from "@/lib/region";
+import {
+ getMovieExtras,
+ getTvExtras,
+ type MovieExtras,
+ TmdbError,
+ type TvExtras,
+} from "@/lib/tmdb";
+
+import { AgeRatingChip } from "./title-details";
+import { SECTION_HEADING_CLASS, SectionErrorLine } from "./title-section";
+import { TrailerButton } from "./trailer-button";
+import {
+ WhereToWatch,
+ WhereToWatchError,
+ WhereToWatchSkeleton,
+} from "./where-to-watch";
+
+/**
+ * The title page sections fed by the extras read (spec 0022, AC-11 to
+ * AC-14, AC-18), shared by the movie and show pages.
+ *
+ * Each slot is its own async Server Component inside its own Suspense
+ * boundary and calls `readExtras` itself. The calls share one read: the
+ * cached `getMovieExtras` or `getTvExtras` underneath, and React `cache()`
+ * on top, so one render asks once. The visitor's region is read only in the
+ * slots that need it, outside the cache and inside their boundaries, so the
+ * title pages keep their prerendered shells (AC-9, AC-10).
+ */
+
+/** Which extras read a slot makes, and the page a Retry reloads. */
+type TitleRef = { mediaType: MediaType; id: number };
+
+/** A `TitleRef` with the title's name, which the trailer dialog is labelled by. */
+type NamedTitleRef = TitleRef & { title: string };
+
+/**
+ * The title's extras, or null when TMDB failed, a 404 included (AC-18). A
+ * `TmdbError` stays inside the slot that asked; anything else is a bug and
+ * is rethrown.
+ */
+const readExtras = cache(
+ async (
+ mediaType: TitleRef["mediaType"],
+ id: number,
+ ): Promise => {
+ try {
+ return mediaType === "movie"
+ ? await getMovieExtras(id)
+ : await getTvExtras(id);
+ } catch (error) {
+ if (!(error instanceof TmdbError)) throw error;
+ return null;
+ }
+ },
+);
+
+/**
+ * The hero's Trailer button (AC-11), or nothing: while the extras load,
+ * when they failed, and when the title has no usable trailer.
+ */
+function TrailerSlot(props: NamedTitleRef) {
+ return (
+
+
+
+ );
+}
+
+/**
+ * The trailer slot's body. Exported apart from its Suspense boundary so the
+ * tests can await it as the async Server Component it is; pages render
+ * `TrailerSlot`.
+ */
+async function TrailerSlotContent({ mediaType, id, title }: NamedTitleRef) {
+ const trailer = (await readExtras(mediaType, id))?.trailer ?? null;
+ if (trailer === null) return null;
+ return (
+
+ );
+}
+
+/**
+ * The hero's age rating chip for the visitor's country (AC-13), or nothing:
+ * while loading, on failure, and when that country has no rating.
+ */
+function AgeRatingSlot(props: TitleRef) {
+ return (
+
+
+
+ );
+}
+
+/**
+ * The age rating slot's body. Exported apart from its Suspense boundary for
+ * the tests, like `TrailerSlotContent`; pages render `AgeRatingSlot`. It
+ * reads the region here, never above the boundary (AC-9).
+ */
+async function AgeRatingSlotContent({ mediaType, id }: TitleRef) {
+ const [extras, region] = await Promise.all([
+ readExtras(mediaType, id),
+ getVisitorRegion(),
+ ]);
+ const rating = extras?.ageRatings[region.code];
+ if (rating === undefined) return null;
+ return ;
+}
+
+/** Where to watch in the visitor's country, with its own states (AC-12, AC-18). */
+function WhereToWatchSection(props: TitleRef) {
+ return (
+ }>
+
+
+ );
+}
+
+/**
+ * Where to watch's body. Exported apart from its Suspense boundary for the
+ * tests, like `TrailerSlotContent`; pages render `WhereToWatchSection`. It
+ * reads the region here, never above the boundary (AC-9).
+ */
+async function WhereToWatchContent(props: TitleRef) {
+ const [extras, region] = await Promise.all([
+ readExtras(props.mediaType, props.id),
+ getVisitorRegion(),
+ ]);
+ if (extras === null) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+/**
+ * More like this (AC-14): up to 12 of TMDB's recommendations in the home
+ * row card, through the same `CatalogListCards` as a landing row, so each
+ * card carries the visitor's tracked, planned or watched status. No See all,
+ * since TMDB's recommendations have no list page here. With none, the whole
+ * section is left out; on failure it keeps its heading with a one line
+ * error and Retry.
+ */
+function MoreLikeThisSection(props: TitleRef) {
+ return (
+
+
+
+ }
+ >
+
+
+ );
+}
+
+/**
+ * More like this's body. Exported apart from its Suspense boundary for the
+ * tests, like `TrailerSlotContent`; pages render `MoreLikeThisSection`.
+ */
+async function MoreLikeThisContent(props: TitleRef) {
+ const extras = await readExtras(props.mediaType, props.id);
+ if (extras === null) {
+ return (
+
+
+
+ );
+ }
+ if (extras.recommendations.paged.results.length === 0) return null;
+
+ return (
+
+
+
+
+
+ );
+}
+
+/** The section, its heading and the row's arrow scroller, in every state. */
+function MoreLikeThisFrame({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ More like this
+
+ }
+ seeAll={null}
+ >
+ {children}
+
+
+ );
+}
+
+export {
+ AgeRatingSlot,
+ AgeRatingSlotContent,
+ MoreLikeThisContent,
+ MoreLikeThisSection,
+ TrailerSlot,
+ TrailerSlotContent,
+ WhereToWatchContent,
+ WhereToWatchSection,
+};
diff --git a/components/catalog/title-section.tsx b/components/catalog/title-section.tsx
new file mode 100644
index 0000000..3fba6ec
--- /dev/null
+++ b/components/catalog/title-section.tsx
@@ -0,0 +1,35 @@
+import { RetryLink } from "@/components/retry-link";
+
+/**
+ * The h2 look every title page section shares: Overview, Seasons, Where to
+ * watch, Cast, More like this and Facts (spec 0006, spec 0009, spec 0022).
+ * One constant, so a new section cannot drift from the old ones.
+ */
+export const SECTION_HEADING_CLASS =
+ "text-xl leading-tight font-bold text-foreground md:text-2xl";
+
+/**
+ * The one line a title page section shows when the extras read failed, with
+ * a full page Retry (spec 0022, AC-18). It names no cause: a 404, a timeout
+ * and a bad response all land here, and "didn't respond" would be untrue
+ * for the first and the last.
+ *
+ * @param what What failed to load, as it reads after "Couldn't load".
+ * @param href The title page, which the Retry reloads in full.
+ */
+export function SectionErrorLine({
+ what,
+ href,
+}: {
+ what: string;
+ href: string;
+}) {
+ return (
+
+
+ Couldn't load {what} from TMDB.
+
+
+
+ );
+}
diff --git a/components/catalog/trailer-button.test.tsx b/components/catalog/trailer-button.test.tsx
new file mode 100644
index 0000000..3e3d37a
--- /dev/null
+++ b/components/catalog/trailer-button.test.tsx
@@ -0,0 +1,84 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+
+import { TrailerButton } from "./trailer-button";
+
+/**
+ * covers: spec 0022, AC-11, AC-20
+ *
+ * Against the real Base UI dialog: no YouTube iframe exists until the
+ * visitor presses Trailer, the embed is the privacy enhanced one with the
+ * attributes the spec fixes, focus opens on Close (so Escape still reaches
+ * the dialog rather than the cross origin player) and returns to the button,
+ * and closing removes the player.
+ */
+function renderButton() {
+ render(
+ ,
+ );
+ return screen.getByRole("button", { name: "Trailer" });
+}
+
+describe("TrailerButton", () => {
+ it("loads nothing from YouTube until it is pressed", () => {
+ renderButton();
+
+ expect(document.querySelector("iframe")).toBeNull();
+ });
+
+ it("opens the privacy enhanced player from the keyboard, focus on Close", async () => {
+ const user = userEvent.setup();
+ const trigger = renderButton();
+
+ trigger.focus();
+ await user.keyboard("{Enter}");
+
+ const dialog = await screen.findByRole("dialog", {
+ name: "Fight Club trailer",
+ });
+ const iframe = dialog.querySelector("iframe");
+ expect(iframe).toHaveAttribute(
+ "src",
+ "https://www.youtube-nocookie.com/embed/dfeUzm6KF4g?autoplay=1&rel=0&playsinline=1",
+ );
+ expect(iframe).toHaveAttribute(
+ "title",
+ "Fight Club: Official Trailer (YouTube video player)",
+ );
+ expect(iframe).toHaveAttribute(
+ "allow",
+ "autoplay; encrypted-media; picture-in-picture; fullscreen",
+ );
+ expect(iframe).toHaveAttribute(
+ "referrerpolicy",
+ "strict-origin-when-cross-origin",
+ );
+ await waitFor(() => {
+ expect(document.activeElement).toBe(
+ screen.getByRole("button", { name: "Close" }),
+ );
+ });
+ });
+
+ it("closes on Escape, removes the player and returns focus", async () => {
+ const user = userEvent.setup();
+ const trigger = renderButton();
+
+ await user.click(trigger);
+ await screen.findByRole("dialog");
+ await user.keyboard("{Escape}");
+
+ await waitFor(() => {
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+ expect(document.querySelector("iframe")).toBeNull();
+ await waitFor(() => {
+ expect(document.activeElement).toBe(trigger);
+ });
+ });
+});
diff --git a/components/catalog/trailer-button.tsx b/components/catalog/trailer-button.tsx
new file mode 100644
index 0000000..28382f4
--- /dev/null
+++ b/components/catalog/trailer-button.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import { PlayIcon, XIcon } from "lucide-react";
+import { useRef } from "react";
+
+import { Button, buttonVariants } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+/**
+ * The hero's Trailer button and its player dialog (spec 0022, AC-11).
+ *
+ * The trigger is base-ui's `Dialog.Trigger`, so focus returns to it when the
+ * dialog closes. The YouTube iframe exists only while the dialog is open: the
+ * popup unmounts when closed, so no request reaches YouTube until the visitor
+ * presses Trailer, and closing it stops playback. The privacy enhanced
+ * `youtube-nocookie.com` host is the one YouTube joined `PROCESSORS` with
+ * (AC-19). Full screen below `md`, a 16:9 panel from it.
+ *
+ * Focus opens on the Close button rather than the player: once focus is
+ * inside the cross origin iframe, Escape goes to YouTube and never reaches
+ * the dialog, so a keyboard user could not close it.
+ *
+ * @param videoKey Already checked against `[A-Za-z0-9_-]{6,20}` by the
+ * normalizer, so it is safe in the embed path.
+ * @param videoName TMDB's name for the video, used in the iframe's title.
+ * @param title The movie or show title, so the dialog names what it plays.
+ */
+function TrailerButton({
+ videoKey,
+ videoName,
+ title,
+}: {
+ videoKey: string;
+ videoName: string;
+ title: string;
+}) {
+ const closeRef = useRef(null);
+
+ return (
+
+ );
+}
+
+export { TrailerButton };
diff --git a/components/catalog/where-to-watch.test.tsx b/components/catalog/where-to-watch.test.tsx
new file mode 100644
index 0000000..586a23e
--- /dev/null
+++ b/components/catalog/where-to-watch.test.tsx
@@ -0,0 +1,86 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import {
+ WhereToWatch,
+ WhereToWatchError,
+ WhereToWatchSkeleton,
+} from "./where-to-watch";
+
+/**
+ * covers: spec 0022, AC-9, AC-12, AC-18
+ *
+ * The section's own states, below the slot that reads the extras
+ * (`title-extras.test.tsx` covers the read): the heading names the country
+ * whenever one is known, the TMDB link appears only beside real providers,
+ * and loading keeps the heading without guessing a country.
+ */
+const POLAND = { code: "PL", name: "Poland" };
+const LINK = "https://www.themoviedb.org/movie/550/watch?locale=PL";
+
+describe("WhereToWatch", () => {
+ it("leaves the TMDB link out when the country's groups are all empty", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByText("Not streaming in Poland right now."),
+ ).toBeVisible();
+ expect(screen.queryByRole("link")).toBeNull();
+ });
+
+ it("tells assistive technology the TMDB link opens a new tab", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByRole("link", {
+ name: /^All options on TMDB\s*\(opens in a new tab\)$/,
+ }),
+ ).toHaveAttribute("href", LINK);
+ expect(screen.getByRole("list", { name: "Free" })).toHaveTextContent(
+ "Pluto TV",
+ );
+ });
+});
+
+describe("WhereToWatchSkeleton (AC-18)", () => {
+ it("keeps the heading without a country and announces the load", () => {
+ render();
+
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Where to watch" }),
+ ).toBeVisible();
+ expect(screen.getByRole("status")).toHaveTextContent(
+ "Loading where to watch",
+ );
+ expect(screen.queryByRole("list")).toBeNull();
+ });
+});
+
+describe("WhereToWatchError (AC-18)", () => {
+ it("still names the country above the one line error", () => {
+ render();
+
+ expect(
+ screen.getByRole("heading", { name: "Where to watch in Poland" }),
+ ).toBeVisible();
+ expect(screen.getByRole("link", { name: "Try again" })).toHaveAttribute(
+ "href",
+ "/shows/1396",
+ );
+ });
+});
diff --git a/components/catalog/where-to-watch.tsx b/components/catalog/where-to-watch.tsx
new file mode 100644
index 0000000..ae41145
--- /dev/null
+++ b/components/catalog/where-to-watch.tsx
@@ -0,0 +1,189 @@
+import { ExternalLinkIcon } from "lucide-react";
+import Image from "next/image";
+import type * as React from "react";
+
+import { Skeleton } from "@/components/skeleton";
+import type { Region } from "@/lib/catalog/region";
+import { JUSTWATCH_ATTRIBUTION } from "@/lib/legal/operator";
+import type { Provider, RegionProviders } from "@/lib/tmdb";
+
+import { SECTION_HEADING_CLASS, SectionErrorLine } from "./title-section";
+
+/** The three groups in their AC-12 order, each with its label. */
+const GROUPS = [
+ { key: "stream", label: "Stream" },
+ { key: "free", label: "Free" },
+ { key: "rentOrBuy", label: "Rent or buy" },
+] as const satisfies ReadonlyArray<{
+ key: keyof Omit;
+ label: string;
+}>;
+
+/**
+ * Where to watch a title in the visitor's country (spec 0022, AC-12): Stream,
+ * Free and Rent or buy, each only when it has a provider, one link to TMDB's
+ * watch page for that country, and the JustWatch credit TMDB's data asks
+ * for. The heading names the country, because the region is a guess from
+ * the connection (AC-9). With no providers there, it says so plainly and
+ * never shows another country's.
+ *
+ * @param providers The region's entry from the extras, or undefined when
+ * TMDB lists nothing for that country.
+ */
+function WhereToWatch({
+ region,
+ providers,
+}: {
+ region: Region;
+ providers: RegionProviders | undefined;
+}) {
+ const groups = GROUPS.map((group) => ({
+ ...group,
+ providers: providers?.[group.key] ?? [],
+ })).filter((group) => group.providers.length > 0);
+
+ return (
+
+ {groups.length === 0 ? (
+
+
+ );
+}
+
+/** One group: its label, then the providers' logos in a wrapping row. */
+function ProviderGroup({
+ label,
+ providers,
+}: {
+ label: string;
+ providers: Provider[];
+}) {
+ return (
+
+
{label}
+
+ {providers.map((provider) => (
+
+
+
+ ))}
+
+
+ );
+}
+
+/**
+ * A provider's square logo, named for assistive technology and on hover, or
+ * its name in a pill of the same height when TMDB has no logo.
+ */
+function ProviderLogo({ provider }: { provider: Provider }) {
+ if (provider.logoUrl === null) {
+ return (
+
+ {provider.name}
+
+ );
+ }
+ return (
+
+ );
+}
+
+/**
+ * The section and its heading, shared by every state, so loading, failure
+ * and the loaded list sit under the same "Where to watch in {country}".
+ */
+function WhereToWatchFrame({
+ region,
+ children,
+}: {
+ region: Region | null;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {region === null
+ ? "Where to watch"
+ : `Where to watch in ${region.name}`}
+
+ {children}
+
+ );
+}
+
+/**
+ * The section while the extras load (AC-18): one group's label and a row of
+ * logo tiles at their size, so the usual height is held.
+ */
+function WhereToWatchSkeleton() {
+ return (
+
+
+
+ );
+}
+
+/** The extras read failed (AC-18): one line and a full page Retry. */
+function WhereToWatchError({ region, href }: { region: Region; href: string }) {
+ return (
+
+
+
+ );
+}
+
+export { WhereToWatch, WhereToWatchError, WhereToWatchSkeleton };
diff --git a/components/library/library-grid.tsx b/components/library/library-grid.tsx
index c0aad35..fad14a9 100644
--- a/components/library/library-grid.tsx
+++ b/components/library/library-grid.tsx
@@ -10,7 +10,7 @@ import {
setMovieWatched,
setMovieWatchlist,
} from "@/app/movies/actions";
-import { PosterGrid } from "@/components/poster-grid";
+import { POSTER_GRID_EAGER, PosterGrid } from "@/components/poster-grid";
import {
runUndoInToast,
settleResultCall,
@@ -38,12 +38,6 @@ import {
libraryItemKey,
} from "./types";
-/**
- * Cards whose posters load eagerly: one full row at the widest grid, as on
- * `/movies`.
- */
-const EAGER_POSTERS = 4;
-
/**
* The movie grid on `/watchlist`, `/upcoming` and `/watched`, and everything
* a removal does (spec 0008, AC-5 to AC-7, AC-16, AC-18; spec 0020, AC-13).
@@ -221,7 +215,7 @@ function LibraryGrid({
list={list}
item={item}
onRemove={() => remove(item)}
- priority={index < EAGER_POSTERS}
+ priority={index < POSTER_GRID_EAGER.catalog}
/>
);
diff --git a/components/library/library-panels.tsx b/components/library/library-panels.tsx
index 8888aa6..7477378 100644
--- a/components/library/library-panels.tsx
+++ b/components/library/library-panels.tsx
@@ -232,8 +232,8 @@ function LoadFailed({
}
/**
- * The static shell's stand in for the list: one row of cards, two below `md`
- * and four from it. The real count is unknown until the read, and a full page
+ * The static shell's stand in for the list: one row of cards at the current
+ * column count, 2 up to 6 (spec 0022, AC-6). The real count is unknown until the read, and a full page
* of placeholders made a short library collapse by several rows when it
* arrived (prompts/grid-tabs-footer-legend.md); one row is the smallest
* honest guess and never wraps.
@@ -251,5 +251,12 @@ export function LibrarySkeleton() {
);
}
-/** Each card's visibility in that row: the last two show only from `md`. */
-const ROW_CARD_VISIBILITY = ["", "", "hidden md:block", "hidden md:block"];
+/** Each card's visibility in that row: one more card at each column step. */
+const ROW_CARD_VISIBILITY = [
+ "",
+ "",
+ "hidden sm:block",
+ "hidden md:block",
+ "hidden lg:block",
+ "hidden xl:block",
+];
diff --git a/components/library/show-library-tab.tsx b/components/library/show-library-tab.tsx
index 831a891..1c73dba 100644
--- a/components/library/show-library-tab.tsx
+++ b/components/library/show-library-tab.tsx
@@ -1,5 +1,5 @@
import { PaginationLinks } from "@/components/pagination-links";
-import { PosterGrid } from "@/components/poster-grid";
+import { POSTER_GRID_EAGER, PosterGrid } from "@/components/poster-grid";
import {
getShowLibraryTab,
type LibraryTabQuery,
@@ -21,9 +21,6 @@ import { UpcomingShowCard, WatchedShowCard } from "./show-cards";
import type { LibraryList } from "./types";
import { WatchlistShowCard } from "./watchlist-show-card";
-/** Posters that load eagerly: one full row at the widest grid. */
-const EAGER_POSTERS = 4;
-
/**
* A show tab (AC-9, AC-11, AC-12, AC-15 to AC-18): Watchlist with Mark
* watched cards; Upcoming with dated and Date TBA cards; Watched with the
@@ -73,7 +70,7 @@ export async function showLibraryTab(query: LibraryTabQuery) {
itemId={itemId}
today={today}
rating={ratings.get(card.showId) ?? null}
- priority={index < EAGER_POSTERS}
+ priority={index < POSTER_GRID_EAGER.catalog}
/>
);
diff --git a/components/movie/cast-row.test.tsx b/components/movie/cast-row.test.tsx
index 2a30ce1..2910bd0 100644
--- a/components/movie/cast-row.test.tsx
+++ b/components/movie/cast-row.test.tsx
@@ -1,10 +1,10 @@
import { render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
-import { CastRow } from "@/components/movie/cast-row";
+import { CastRow, CastSection } from "@/components/movie/cast-row";
import type { CastMember } from "@/lib/tmdb";
-/** covers: spec 0006, AC-4, AC-6 */
+/** covers: spec 0006, AC-4, AC-6 (the row drawn as a carousel since spec 0022) */
function member(index: number, overrides: Partial = {}) {
return {
personId: index,
@@ -17,6 +17,17 @@ function member(index: number, overrides: Partial = {}) {
};
}
+beforeEach(() => {
+ // The carousel's scroller measures itself; jsdom has no ResizeObserver.
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ disconnect() {}
+ },
+ );
+});
+
describe("CastRow", () => {
it("shows at most the first 12, in billing order", () => {
render( member(i))} />);
@@ -27,11 +38,35 @@ describe("CastRow", () => {
expect(items[11]).toHaveTextContent("Actor 11");
});
- it("is a labelled region the keyboard can reach", () => {
+ it("is a carousel: a Cast heading and a labelled region the keyboard can reach", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole("heading", { name: "Cast" })).toBeVisible();
+ // The section is named by its heading; the scroll region inside it is
+ // the focusable one, as on every carousel row.
+ const region = screen
+ .getAllByRole("region", { name: "Cast" })
+ .find((element) => element.getAttribute("tabindex") === "0");
+ expect(region).toBeDefined();
+ expect(region).toContainElement(screen.getByRole("listitem"));
+ });
+
+ it("draws each person like a row media card", () => {
render();
- const region = screen.getByRole("region", { name: "Cast" });
- expect(region).toHaveAttribute("tabindex", "0");
+ const item = screen.getByRole("listitem");
+ expect(item).toHaveClass("w-[140px]", "md:w-[180px]", "snap-start");
+ expect(
+ screen.getByRole("heading", { level: 3, name: "Actor 1" }),
+ ).toBeVisible();
+ expect(item.querySelector("img")).toHaveAttribute(
+ "sizes",
+ "(min-width: 768px) 180px, 140px",
+ );
});
it("has no links, because there is no person page", () => {
@@ -49,7 +84,8 @@ describe("CastRow", () => {
container.querySelector('[data-slot="profile-fallback"]'),
).toBeInTheDocument();
expect(container.querySelectorAll("img")).toHaveLength(0);
- expect(screen.getByRole("listitem").querySelectorAll("p")).toHaveLength(1);
+ // The name is the caption; with no character there is no second line.
+ expect(screen.getByRole("listitem").querySelectorAll("p")).toHaveLength(0);
});
it("says so when TMDB lists no cast", () => {
diff --git a/components/movie/cast-row.tsx b/components/movie/cast-row.tsx
index a9f4727..e49593b 100644
--- a/components/movie/cast-row.tsx
+++ b/components/movie/cast-row.tsx
@@ -1,6 +1,13 @@
import { UserRoundIcon } from "lucide-react";
import Image from "next/image";
+import type * as React from "react";
+import {
+ ROW_ITEM_CLASS,
+ ROW_POSTER_SIZES,
+} from "@/components/catalog/carousel-row-states";
+import { CarouselScroller } from "@/components/catalog/carousel-scroller";
+import { SECTION_HEADING_CLASS } from "@/components/catalog/title-section";
import type { CastMember } from "@/lib/tmdb";
/**
@@ -11,11 +18,36 @@ import type { CastMember } from "@/lib/tmdb";
const CAST_LIMIT = 12;
/**
- * The top billed cast as one horizontal row (spec 0006, AC-6).
+ * The Cast section as a carousel row, the same frame as a landing row and
+ * More like this: the heading with the arrow buttons from `md`, then a
+ * focusable, labelled scroll region that touch, trackpad and the arrow keys
+ * scroll natively. It never widens the page (spec 0006, AC-14).
*
- * The scroll container is a focusable, labelled region, so a keyboard user can
- * tab to it and scroll it with the arrow keys; touch and trackpad scroll it
- * natively. The row scrolls inside itself and never widens the page (AC-14).
+ * The cards stream in as `children`, so the show page can keep its cast in
+ * its own Suspense boundary under a prerendered heading.
+ */
+function CastSection({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ Cast
+
+ }
+ seeAll={null}
+ >
+ {children}
+
+
+ );
+}
+
+/**
+ * The top billed cast as row cards (spec 0006, AC-6), drawn like the media
+ * cards beside them: the 2:3 frame and rim at a row card's width, the name as
+ * the caption and the character on the line under it.
*
* Cards are not links: there is no person page to go to, and a link that
* leads nowhere useful is worse than none. The photo is decorative (`alt=""`)
@@ -37,58 +69,52 @@ function CastRow({
}
return (
-
-
);
diff --git a/components/movie/movie-hero.test.tsx b/components/movie/movie-hero.test.tsx
index b563370..fdf22c3 100644
--- a/components/movie/movie-hero.test.tsx
+++ b/components/movie/movie-hero.test.tsx
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
import { MovieHero } from "@/components/movie/movie-hero";
/**
- * covers: spec 0006, AC-3, AC-4, AC-7
+ * covers: spec 0006, AC-3, AC-4, AC-7; spec 0022, AC-11, AC-13
*
* The hero is where a missing value is most tempting to paper over, so every
* fallback is asserted: absent means absent, never a zero or a stand in.
@@ -100,4 +100,19 @@ describe("MovieHero", () => {
expect(screen.queryByText(/watchlist|watched|your score/i)).toBeNull();
});
+
+ it("renders the age rating slot in the meta row and the actions slot under the rating (spec 0022)", () => {
+ render(
+ R}
+ actions={}
+ />,
+ );
+
+ expect(screen.getByText("R")).toBeVisible();
+ expect(screen.getByRole("button", { name: "Trailer" })).toBeVisible();
+ // With no year, runtime or genres, the chip still has its row.
+ expect(screen.getByText("R").parentElement).toHaveClass("flex-wrap");
+ });
});
diff --git a/components/movie/movie-hero.tsx b/components/movie/movie-hero.tsx
index dd62eeb..0a104e8 100644
--- a/components/movie/movie-hero.tsx
+++ b/components/movie/movie-hero.tsx
@@ -12,6 +12,13 @@ type MovieHeroProps = {
* session itself, so it stays a plain catalog piece.
*/
tracking?: React.ReactNode;
+ /** The Trailer slot, passed through to `DetailHero` (spec 0022, AC-11). */
+ actions?: React.ReactNode;
+ /**
+ * The age rating chip's slot, shown in the meta row after the runtime
+ * (spec 0022, AC-13). It renders nothing when there is no rating.
+ */
+ ageRating?: React.ReactNode;
} & Pick<
Movie,
| "title"
@@ -45,6 +52,8 @@ function MovieHero({
tmdbRating,
tmdbVoteCount,
tracking,
+ actions,
+ ageRating,
}: MovieHeroProps) {
const meta = [
releaseYear === null ? null : String(releaseYear),
@@ -57,30 +66,34 @@ function MovieHero({
tagline={tagline}
posterUrl={posterUrl}
backdropUrl={backdropUrl}
- meta={}
+ meta={}
tmdbRating={tmdbRating}
tmdbVoteCount={tmdbVoteCount}
tracking={tracking}
+ actions={actions}
/>
);
}
/**
- * The meta line and the genre chips in one wrapping row, or nothing when the
- * movie has neither.
+ * The meta line, the age rating slot and the genre chips in one wrapping
+ * row, or nothing when the movie has none of them.
*/
-function MovieFacts({
+function MovieMetaRow({
meta,
genres,
+ ageRating,
}: {
meta: string[];
genres: Movie["genres"];
+ ageRating?: React.ReactNode;
}) {
- if (meta.length === 0 && genres.length === 0) return null;
+ if (meta.length === 0 && genres.length === 0 && !ageRating) return null;
return (
);
diff --git a/components/poster-card.tsx b/components/poster-card.tsx
index a568d84..5cc9f1d 100644
--- a/components/poster-card.tsx
+++ b/components/poster-card.tsx
@@ -4,16 +4,17 @@ import Image from "next/image";
import Link from "next/link";
import type * as React from "react";
+import { POSTER_GRID_SIZES } from "@/components/poster-grid";
+
/**
- * The `sizes` string that matches the grid in `PosterGrid`.
+ * The `sizes` string that matches the default (`catalog`) `PosterGrid`.
*
- * Kept next to the card rather than in the grid because `next/image` needs it
- * on the image, and kept as one constant because a grid change that misses it
- * silently ships the wrong image width to every screen (spec 0004, AC-8).
- * Mirrors the column counts: 2 at base, 4 from md, and a fixed width once the
- * grid reaches its 1200px cap.
+ * Kept as the card's default because nearly every card sits in that grid, and
+ * owned by the grid's preset map because a column change that misses it
+ * silently ships the wrong image width to every screen (spec 0004, AC-8;
+ * spec 0022, AC-4). A card in another preset passes that preset's sizes.
*/
-const POSTER_SIZES = "(min-width: 1232px) 288px, (min-width: 768px) 25vw, 50vw";
+const POSTER_SIZES = POSTER_GRID_SIZES.catalog;
/**
* The single most repeated element in the UI: a 2:3 poster inside a rounded
diff --git a/components/poster-grid.test.tsx b/components/poster-grid.test.tsx
index 4267c4a..5da42a5 100644
--- a/components/poster-grid.test.tsx
+++ b/components/poster-grid.test.tsx
@@ -2,31 +2,52 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { POSTER_SIZES } from "@/components/poster-card";
-import { PosterGrid } from "@/components/poster-grid";
+import {
+ POSTER_GRID_EAGER,
+ POSTER_GRID_SIZES,
+ PosterGrid,
+ type PosterGridColumns,
+} from "@/components/poster-grid";
/**
- * covers: AC-9, AC-8
+ * covers: spec 0004 AC-9, AC-8; spec 0022 AC-1, AC-3, AC-4, AC-5
*
* The grid's own comment names the failure this file is here to prevent: the
- * column counts live in `poster-grid.tsx` and are mirrored by `POSTER_SIZES`
- * in `poster-card.tsx`, and changing one without the other silently ships the
+ * column counts and their image `sizes` live side by side in
+ * `poster-grid.tsx`, and changing one without the other silently ships the
* wrong image width to every screen. Nothing breaks visibly, nothing fails to
* compile, the app just gets slower and blurrier.
*
* So the interesting test here is not "does it render a grid" but the
- * cross file invariant: at every breakpoint, the `sizes` viewport width has to
- * be roughly one column's worth of the viewport.
+ * invariant, checked for every preset: at every breakpoint, the `sizes` entry
+ * has to be one column's worth of the viewport, and past the cap one column's
+ * fixed width.
*/
-/** Tailwind's default breakpoints; `globals.css` does not override them. */
-const COLUMNS_AT: ReadonlyArray<{ minWidth: number | null; columns: number }> =
- [
+/**
+ * Each preset's column steps, widest first. Tailwind's default breakpoints;
+ * `globals.css` does not override them. A `Record` so a new preset with no
+ * entry here fails the type check.
+ */
+const COLUMNS_AT: Record<
+ PosterGridColumns,
+ ReadonlyArray<{ minWidth: number | null; columns: number }>
+> = {
+ catalog: [
+ { minWidth: 1280, columns: 6 },
+ { minWidth: 1024, columns: 5 },
{ minWidth: 768, columns: 4 },
+ { minWidth: 640, columns: 3 },
{ minWidth: null, columns: 2 },
- ];
+ ],
+ wide: [
+ { minWidth: 768, columns: 4 },
+ { minWidth: null, columns: 2 },
+ ],
+};
/** The grid's `max-w-[1200px]` cap, reached once the viewport passes it plus the 16px gutters. */
-const GRID_CAP = { width: 1200, viewport: 1232, columns: 4, gap: 16 };
+const GRID_CAP = { width: 1200, viewport: 1232, gap: 16 };
type SizesEntry = {
minWidth: number | null;
@@ -51,6 +72,41 @@ function parseSizes(sizes: string): SizesEntry[] {
});
}
+/**
+ * The `sizes` entries the AC-4 rule gives a preset: a fixed width for every
+ * column count shown at or past the capped viewport, then one floored `vw`
+ * entry per fluid step below it.
+ */
+function expectedSizes(steps: (typeof COLUMNS_AT)[PosterGridColumns]) {
+ const fixed: SizesEntry[] = [];
+ const fluid: SizesEntry[] = [];
+ for (const [index, step] of steps.entries()) {
+ const wider = steps[index - 1];
+ // Capped while any part of this step's range is at or past 1232px.
+ const capped =
+ wider?.minWidth == null || wider.minWidth > GRID_CAP.viewport;
+ if (capped) {
+ fixed.push({
+ minWidth: Math.max(step.minWidth ?? 0, GRID_CAP.viewport),
+ vw: null,
+ px: Math.ceil(
+ (GRID_CAP.width - GRID_CAP.gap * (step.columns - 1)) / step.columns,
+ ),
+ });
+ }
+ if (step.minWidth === null || step.minWidth < GRID_CAP.viewport) {
+ fluid.push({
+ minWidth: step.minWidth,
+ vw: Math.floor(100 / step.columns),
+ px: null,
+ });
+ }
+ }
+ return [...fixed, ...fluid];
+}
+
+const PRESETS = Object.keys(COLUMNS_AT) as PosterGridColumns[];
+
describe("PosterGrid", () => {
it("is a list, so a screen reader can count the results", () => {
render(
@@ -76,17 +132,33 @@ describe("PosterGrid", () => {
expect(items).toEqual(["First", "Second"]);
});
- it("steps from 2 to 4 columns at md and never goes wider", () => {
+ it("steps the catalog grid from 2 to 6 columns by default", () => {
render();
const grid = screen.getByRole("list");
expect(grid).toHaveClass(
"grid-cols-2",
+ "sm:grid-cols-3",
"md:grid-cols-4",
+ "lg:grid-cols-5",
+ "xl:grid-cols-6",
"max-w-[1200px]",
"mx-auto",
+ "gap-x-4",
+ "gap-y-6",
);
+ expect(grid.className).not.toMatch(/2xl:grid-cols-/);
+ });
+
+ it("keeps the wide preset at today's 2 and 4 columns", () => {
+ render();
+
+ const grid = screen.getByRole("list");
+ expect(grid).toHaveClass("grid-cols-2", "md:grid-cols-4", "max-w-[1200px]");
expect(grid.className).not.toMatch(/(?:sm|lg|xl|2xl):grid-cols-/);
+ expect(POSTER_GRID_SIZES.wide).toBe(
+ "(min-width: 1232px) 288px, (min-width: 768px) 25vw, 50vw",
+ );
});
it("keeps a caller class without dropping the column steps", () => {
@@ -95,26 +167,34 @@ describe("PosterGrid", () => {
expect(screen.getByRole("list")).toHaveClass("mt-8", "grid-cols-2");
});
- it("matches POSTER_SIZES breakpoint for breakpoint to the column counts", () => {
- const [capped, ...fluid] = parseSizes(POSTER_SIZES);
-
- // Past the cap a column has a fixed width: the grid's share less the gaps,
- // rounded up so the browser never picks an image narrower than the slot.
- expect(capped.minWidth).toBe(GRID_CAP.viewport);
- expect(capped.px).toBe(
- Math.ceil(
- (GRID_CAP.width - GRID_CAP.gap * (GRID_CAP.columns - 1)) /
- GRID_CAP.columns,
- ),
- );
+ it("defaults a card's sizes to the catalog preset", () => {
+ expect(POSTER_SIZES).toBe(POSTER_GRID_SIZES.catalog);
+ });
- expect(fluid.map((entry) => entry.minWidth)).toEqual(
- COLUMNS_AT.map((entry) => entry.minWidth),
- );
- for (const [index, { columns }] of COLUMNS_AT.entries()) {
- // One column is 100/columns vw, floored: the gaps make a column slightly
- // narrower than its share, so rounding down is the right direction.
- expect(fluid[index].vw).toBe(Math.floor(100 / columns));
+ it.each(PRESETS)(
+ "matches the %s sizes breakpoint for breakpoint to its columns",
+ (preset) => {
+ render();
+ const className = screen.getByRole("list").className;
+
+ // The columns in the table are the ones the grid really renders.
+ for (const { minWidth, columns } of COLUMNS_AT[preset]) {
+ const prefix =
+ minWidth === null
+ ? ""
+ : `${{ 640: "sm", 768: "md", 1024: "lg", 1280: "xl" }[minWidth]}:`;
+ expect(className.split(" ")).toContain(`${prefix}grid-cols-${columns}`);
+ }
+
+ expect(parseSizes(POSTER_GRID_SIZES[preset])).toEqual(
+ expectedSizes(COLUMNS_AT[preset]),
+ );
+ },
+ );
+
+ it("loads one full row eagerly at each preset's widest count", () => {
+ for (const preset of PRESETS) {
+ expect(POSTER_GRID_EAGER[preset]).toBe(COLUMNS_AT[preset][0].columns);
}
});
});
diff --git a/components/poster-grid.tsx b/components/poster-grid.tsx
index aff3e88..b939d7e 100644
--- a/components/poster-grid.tsx
+++ b/components/poster-grid.tsx
@@ -2,32 +2,72 @@ import { cn } from "cn";
import type * as React from "react";
/**
- * The poster grid, mobile first (spec 0004, AC-9).
+ * The grid's column presets (spec 0022, AC-1 and AC-3). `catalog` is every
+ * title grid; `wide` is the show page's season grid, which keeps the larger
+ * cards it has always had.
+ */
+type PosterGridColumns = "catalog" | "wide";
+
+/**
+ * Full class literals, never built from parts, so Tailwind finds and emits
+ * every one of them.
+ */
+const COLUMN_CLASSES: Record = {
+ catalog:
+ "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6",
+ wide: "grid-cols-2 md:grid-cols-4",
+};
+
+/**
+ * Each preset's image `sizes`, matched breakpoint for breakpoint to its
+ * columns (spec 0022, AC-4): one `floor(100 / columns)vw` per fluid step, and
+ * one fixed width per column count the grid shows once it reaches its 1200px
+ * cap at a 1232px viewport. `poster-grid.test.tsx` recomputes every entry, so
+ * a column change that misses its sizes fails the suite.
+ */
+const POSTER_GRID_SIZES: Record = {
+ catalog:
+ "(min-width: 1280px) 187px, (min-width: 1232px) 228px, (min-width: 1024px) 20vw, (min-width: 768px) 25vw, (min-width: 640px) 33vw, 50vw",
+ wide: "(min-width: 1232px) 288px, (min-width: 768px) 25vw, 50vw",
+};
+
+/**
+ * Posters that load eagerly: one full row at the preset's widest count, so the
+ * first row is never lazy at any width (spec 0022, AC-5).
+ */
+const POSTER_GRID_EAGER: Record = {
+ catalog: 6,
+ wide: 4,
+};
+
+/**
+ * The poster grid, mobile first (spec 0004, AC-9, amended by spec 0022).
*
- * Column counts are fixed here and mirrored by `POSTER_SIZES` in
- * `poster-card.tsx`; changing one without the other ships wrong sized images.
- * Two columns below `md`, four from it: a page holds 20 titles, which fills
- * every row at both counts (prompts/grid-tabs-footer-legend.md), where three
- * or five columns would leave a ragged last row. Two columns at 390px keeps a
- * poster wide enough to read its caption with no horizontal page scroll.
+ * `catalog` steps 2, 3 (`sm`), 4 (`md`), 5 (`lg`) and 6 (`xl`) columns, so a
+ * grid card stays close to the home row card's 180px; a 20 title page leaves a
+ * short last row at 3, 5 and 6 columns, accepted in spec 0022. `wide` keeps 2
+ * and 4 for the season grid. The columns, `POSTER_GRID_SIZES` and
+ * `POSTER_GRID_EAGER` change together; a card in the grid passes the preset's
+ * sizes.
*
- * The 1200px cap keeps a four column poster near 285px wide, so a full page
- * stays a sensible length on a wide screen; past the cap the grid is centred
- * on every page, under the centred pagination.
+ * The 1200px cap keeps a full page a sensible length on a wide screen; past
+ * the cap the grid is centred on every page, under the centred pagination.
*
* A `
` rather than a bare div: a collection of titles is a list, and that
* is what tells a screen reader how many results there are.
*/
function PosterGrid({
+ columns = "catalog",
className,
children,
...props
-}: React.ComponentProps<"ul">) {
+}: React.ComponentProps<"ul"> & { columns?: PosterGridColumns }) {
return (
;
/** TMDB's page size, which the skeleton mirrors (AC-21). */
const PAGE_SIZE = 20;
-/** One full row at the widest grid loads its posters eagerly. */
-const EAGER_POSTERS = 4;
-
/**
* The results half of `/search`, for one URL (spec 0010, AC-11 to AC-19).
*
@@ -225,7 +225,7 @@ function ResultGrid({
result={result}
gridIds={gridIds}
returnPath={returnPath}
- priority={index < EAGER_POSTERS}
+ priority={index < POSTER_GRID_EAGER.catalog}
/>
))}
diff --git a/components/show/episode-list.tsx b/components/show/episode-list.tsx
index acde84e..83f9136 100644
--- a/components/show/episode-list.tsx
+++ b/components/show/episode-list.tsx
@@ -7,7 +7,7 @@ import type { Episode } from "@/lib/tmdb";
/**
* Stills that load eagerly: the first two rows, which is what fits above the
* fold on a desktop season page. The counterpart of the landing's
- * `EAGER_POSTERS` (spec 0009, AC-10); every later still loads lazily, so a
+ * `POSTER_GRID_EAGER` (spec 0009, AC-10); every later still loads lazily, so a
* season of hundreds of episodes stays usable.
*/
const EAGER_STILLS = 2;
diff --git a/components/show/season-grid.tsx b/components/show/season-grid.tsx
index d62e489..4246665 100644
--- a/components/show/season-grid.tsx
+++ b/components/show/season-grid.tsx
@@ -1,7 +1,7 @@
import { Suspense } from "react";
import { PosterCard } from "@/components/poster-card";
-import { PosterGrid } from "@/components/poster-grid";
+import { POSTER_GRID_SIZES, PosterGrid } from "@/components/poster-grid";
import { SeasonRatingBadgeSlot } from "@/components/tracking/season-rating-badge-slot";
import { seasonMetaParts } from "@/lib/format";
import type { SeasonSummary } from "@/lib/tmdb";
@@ -18,6 +18,9 @@ import type { SeasonSummary } from "@/lib/tmdb";
* Each card's badge is the signed in user's calculated season rating
* (spec 0012, AC-8), streamed in its own boundary with no fallback so the
* grid itself stays part of the public page.
+ *
+ * The grid keeps the `wide` preset, 2 and 4 columns (spec 0022, AC-3): a show
+ * has few seasons, and their cards stay as large as they have always been.
*/
function SeasonGrid({
showId,
@@ -39,13 +42,14 @@ function SeasonGrid({
}
return (
-
+
{seasons.map((season) => (
);
}
diff --git a/components/show/show-meta.test.tsx b/components/show/show-meta.test.tsx
new file mode 100644
index 0000000..ba3b0d0
--- /dev/null
+++ b/components/show/show-meta.test.tsx
@@ -0,0 +1,46 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { ShowMeta } from "./show-meta";
+
+/**
+ * covers: spec 0022, AC-13 (with spec 0009, AC-5)
+ *
+ * The age rating chip joins the facts row after the status pill. A show with
+ * only a rating still gets the row, and one with nothing at all still
+ * renders nothing.
+ */
+const CHIP = TV-MA;
+
+describe("ShowMeta", () => {
+ it("puts the age rating after the status pill", () => {
+ render(
+ ,
+ );
+
+ const status = screen.getByText("Ended");
+ const chip = screen.getByTestId("age-chip");
+ expect(
+ status.compareDocumentPosition(chip) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
+ it("shows the facts row for a rating alone", () => {
+ render();
+
+ expect(screen.getByTestId("age-chip")).toBeVisible();
+ });
+
+ it("renders nothing with no facts, genres or rating", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/components/show/show-meta.tsx b/components/show/show-meta.tsx
index 3d48fa6..97d2826 100644
--- a/components/show/show-meta.tsx
+++ b/components/show/show-meta.tsx
@@ -1,3 +1,5 @@
+import type * as React from "react";
+
import { GlassPill } from "@/components/glass-pill";
import { GenreChips } from "@/components/movie/movie-hero";
import type { Genre } from "@/lib/tmdb";
@@ -15,14 +17,20 @@ function ShowMeta({
airSpan,
status,
genres,
+ ageRating,
}: {
/** From `formatAirSpan`, or null when TMDB has no first air year. */
airSpan: string | null;
/** TMDB's status word; empty when TMDB gives none. */
status: string;
genres: Genre[];
+ /**
+ * The age rating chip's slot, after the status pill (spec 0022, AC-13). It
+ * renders nothing when there is no rating.
+ */
+ ageRating?: React.ReactNode;
}) {
- const hasFacts = airSpan !== null || status !== "";
+ const hasFacts = airSpan !== null || status !== "" || Boolean(ageRating);
if (!hasFacts && genres.length === 0) return null;
return (
@@ -37,6 +45,7 @@ function ShowMeta({
{status !== "" ? (
{status}
) : null}
+ {ageRating}
) : null}
{genres.length > 0 ? : null}
diff --git a/docs/scope/scope.md b/docs/scope/scope.md
index 39e16f4..ca21422 100644
--- a/docs/scope/scope.md
+++ b/docs/scope/scope.md
@@ -39,6 +39,7 @@ _2026-10-06: the `design/` artboards were removed. The UI built from them carrie
| 22 | Media tabs on every page | Slice 9 | done |
| 23 | Progress based library pages | Slice 10 | done |
| 24 | Home carousels | Slice 11 | done |
+| 25 | Consistent card size and richer title pages | Slice 12 | done |
## Foundations
@@ -414,10 +415,26 @@ spec [0020](../specs/0020-progress-based-library-pages/index.md) · code in `lib
- [x] Test it: `/test home carousels`
spec [0021](../specs/0021-home-carousels/index.md) · code in `lib/catalog/lists.ts`, `lib/catalog-list-read.ts`, `lib/tmdb/lists.ts`, `components/catalog/`, `app/shows/{slug}/`, `app/movies/{slug}/`, `proxy.ts`, `next.config.ts`
+## Slice 12: Title pages
+
+### 25. Consistent card size and richer title pages · done
+Two strands, one feature (merged on 2026-10-12). **Cards:** grid cards match the home row card (about 180px) everywhere: `PosterGrid` steps 2 / 3 (`sm`) / 4 (`md`) / 5 (`lg`) / 6 (`xl`) columns and its `sizes` follows, on the list pages, `/search` and the three library pages; the season grid keeps its 2 and 4 columns. A 20 title page leaves a short last row, accepted. **Title pages:** movie and show pages gain a Trailer (hero button, modal with a click to load `youtube-nocookie` embed; YouTube joins `PROCESSORS` and the privacy text), Where to watch (TMDB watch providers grouped Stream, Free, Rent, Buy, region from `x-vercel-ip-country` with a `US` fallback, named in the heading, JustWatch attribution, an honest "Not streaming in {country}" line), More like this (TMDB recommendations in the home row card, hidden when empty), key crew (Director and Writers, or Created by), an age rating chip for the same region, and a Facts panel (status, original title and language, budget and revenue when known, companies, networks, type, air dates). Order: hero, overview, crew, the show's rating and seasons, where to watch, cast, more like this, facts. Settled in a grilling session on 2026-10-12.
+**Done when:** every changed grid renders cards close to the home row width with matching image `sizes` and the season grid unchanged; both title pages show each new section from cached TMDB data with missing values omitted, never invented; YouTube loads only after a click; the region is named wherever it applies; the shells stay prerendered; and `/privacy` lists YouTube with `LEGAL_LAST_UPDATED` bumped.
+- [x] Design it (spec): `/architect consistent card size and richer title pages`
+- [x] Build it: `/develop consistent card size and richer title pages`
+ - [x] Card strand: the `columns` preset with `catalog` and `wide`, the sizes and eager maps, the season grid on `wide`, the library skeleton and `/showcase`, checked at five widths — AC-1 to AC-7
+ - [x] Tracer bullet: the movie extras read, `lib/region.ts` and Where to watch on the movie page end to end against live TMDB — AC-8 to AC-10, AC-12, AC-18
+ - [x] Movie page thickened: Trailer dialog, age chip, the legal change, More like this, crew and facts in order — AC-11, AC-13 to AC-17, AC-19
+ - [x] Show page parity and proof: the TV extras read, the new show fields, every section, tests, 375px and keyboard passes, checks and spec amendments — AC-7, AC-8, AC-10 to AC-17, AC-20 (built and checked; the spec 0004, 0006, 0009 and grid prompt amendment notes are spec content, handed to `/sync`)
+- [x] Verify it: `/check verify consistent card size and richer title pages`
+- [x] Test it: `/test consistent card size and richer title pages`
+spec [0022](../specs/0022-card-size-title-pages/index.md) · code in `components/poster-grid.tsx`, `lib/tmdb/extras.ts`, `lib/tmdb/normalize-extras.ts`, `lib/region.ts`, `lib/catalog/region.ts`, `components/catalog/title-extras.tsx`, `components/catalog/title-details.tsx`, `components/catalog/where-to-watch.tsx`, `components/catalog/trailer-button.tsx`, `app/movies/[id]/`, `app/shows/[id]/`
+
## Deferred
Out of scope for the current build pass, kept so the plan stays honest.
- **Public profiles and social features**: ruled out of the MVP by `AGENTS.md`
- **Per visitor region and timezone on the home rows** (from spec 0021): Now playing, Upcoming, Airing today and On the air use the fixed `PL` region and `Europe/Warsaw`. Following the visitor needs its own decision (a request header or an account setting).
+- **More title page sections** (from the 2026-10-12 grilling): external links (IMDb, homepage, socials) and the movie collection row are a later batch; keywords, image gallery, TMDB reviews and the aggregate show cast were skipped
- **Error monitoring and product analytics**: not selected for this pass
- **Self serve account deletion and data export** (from specs 0005 and 0017): GDPR requests are handled by email for now. Undesigned; a `/account` flow needs the Supabase admin API or a security definer function, an elevated call nothing else in the app uses, so it deserves its own decision, including what happens to the cascading tracking data
- **Rate limit on sign in and sign up** (from spec 0018): Supabase counts Vercel's addresses, so its auth limit is shared by every visitor ([docs/deploy.md](../deploy.md) section 6). If it bites, add a second Vercel Firewall rule on sign in and sign up posts per IP. (The `/api/search` limit from spec 0010 shipped as a firewall rule in feature 20.)
diff --git a/docs/specs/0022-card-size-title-pages/index.md b/docs/specs/0022-card-size-title-pages/index.md
new file mode 100644
index 0000000..7efd455
--- /dev/null
+++ b/docs/specs/0022-card-size-title-pages/index.md
@@ -0,0 +1,154 @@
+# 0022. Consistent card size and richer title pages
+
+**Date**: 2026-10-12
+**Status**: Accepted
+**Amended**: 2026-10-12. After the branch review, the spec records what shipped: Cast is a carousel row of home row cards (AC-17), a network shows its name with its logo beside it (AC-16), the error line names no cause (AC-18), `/privacy` also explains the trailer player (AC-19), and the extras reads throw like every other read (interface table). The owner kept each as built; see [rationale.md](rationale.md#amendment-2026-10-12-recording-what-shipped).
+
+## Summary
+
+This spec covers two related changes. First, poster cards on every grid page (See all lists, `/search`, Watchlist, Upcoming and Watched) shrink to about the home row card's 180px by stepping the grid from 2 up to 6 columns; the season grid keeps its 2 and 4 columns. Second, movie and show pages gain a trailer, where to watch, more like this, key crew, an age rating and a facts panel. The new title data comes from one extra TMDB request per title, cached for an hour and shared by every visitor, so the library pages, which read up to 500 shows, stay as cheap as today. YouTube is added to the privacy policy, and the visitor's country (from Vercel's `x-vercel-ip-country` request header, never stored) picks the streaming and age rating region.
+
+## Requirements
+
+**User stories**:
+- As a visitor, I want a poster to be the same size in a home row and on its See all page, so the catalog feels like one product.
+- As a signed in user, I want my library cards at that same size with their controls still easy to use.
+- As a visitor on a title page, I want to watch the trailer, see where I can stream, rent or buy it in my country, find similar titles, and read who made it and its basic facts, without leaving BeStats to look them up.
+
+**Acceptance criteria**:
+
+*Card size strand*
+
+- **AC-1**: The default (`catalog`) `PosterGrid` has 2 columns below `sm` (640px), 3 from `sm`, 4 from `md` (768px), 5 from `lg` (1024px) and 6 from `xl` (1280px). The 1200px cap, centring and gaps (`gap-x-4 gap-y-6`) are unchanged.
+- **AC-2**: The `catalog` grid applies to the See all list pages (`/shows/{slug}`, `/movies/{slug}`), `/search`, Watchlist, Upcoming and Watched (both types), every one of their skeletons, and `/showcase`. At 1280px and wider a card is between 180px and 190px wide. From 1024px to 1279px cards are deliberately larger (about 186px to 228px).
+- **AC-3**: The season grid on `/shows/{id}` and its skeleton use the `wide` preset, which is exactly today's layout: 2 columns below `md`, 4 from it, with today's `sizes`.
+- **AC-4**: Each preset's `sizes` string has one `floor(100 / columns)vw` entry per fluid column step, and one fixed px entry, `ceil((1200 - (columns - 1) × 16) / columns)`, for every column count the grid shows while capped at 1200px. For `catalog` that is `(min-width: 1280px) 187px, (min-width: 1232px) 228px, (min-width: 1024px) 20vw, (min-width: 768px) 25vw, (min-width: 640px) 33vw, 50vw`. For `wide` it is today's `(min-width: 1232px) 288px, (min-width: 768px) 25vw, 50vw`. `poster-grid.test.tsx` checks every key of `POSTER_GRID_SIZES` with this rule, so a preset with no sizes fails the type check, and a column change that misses its sizes fails the suite.
+- **AC-5**: Eager posters are one full row at the preset's widest count: 6 for `catalog` and 4 for `wide`, read from one exported `POSTER_GRID_EAGER` map. The four local `EAGER_POSTERS` copies are removed.
+- **AC-6**: `LibrarySkeleton` shows one row at the current column count at every width (6 cards, visibility `["", "", "hidden sm:block", "hidden md:block", "hidden lg:block", "hidden xl:block"]`), so a one row library does not shift when it arrives. The list page and search skeletons keep their 20 cards.
+- **AC-7**: At 375px, 640px, 768px, 1024px and 1280px no grid page scrolls sideways, the missing poster tile keeps a real poster's footprint, and every card control keeps its 44px tap target. `/showcase` shows 6 grid cards.
+
+*Title page strand: data*
+
+- **AC-8**: Each title page makes at most one new TMDB request per title per cache lifetime: `/movie/{id}` or `/tv/{id}` with `append_to_response=videos,recommendations,watch/providers,release_dates` (movie) or `…,content_ratings` (show) and `include_video_language=en,null`, cached with `cacheLife("hours")`. `getTvShow`, `getMovie` and every library read make no extra request and keep their lifetimes.
+- **AC-9**: The region is the `x-vercel-ip-country` request header when it is two capital letters that `Intl.DisplayNames` can name and is not `XX` or `T1`, otherwise `US`. It is read outside every cached scope, inside the Suspense boundary of the section that needs it, once per request (`getVisitorRegion` is wrapped in React `cache()`), and is never stored, logged or sent to TMDB. The extras read returns every country; the page picks the region's entry after the cached read returns. Every region dependent section names the country in English (`Intl.DisplayNames`), for example "Where to watch in Poland".
+- **AC-10**: Every route keeps its prerendered shell (`pnpm build` route table unchanged for `/movies/[id]` and `/shows/[id]`), and nothing new reads cookies or headers outside a Suspense boundary. No SEO surface (metadata, JSON-LD, share image) receives the region, the age rating or any extras data, and `proxy.ts`, the sitemap and `generateMetadata` are unchanged.
+
+*Title page strand: sections*
+
+- **AC-11** (Trailer): When the extras include a YouTube video of type `Trailer` (else `Teaser`), preferring `official` and then the newest `published_at` (compared with `Date.parse`), the hero's new `actions` slot shows a Trailer button. It is the base-ui `Dialog.Trigger`, so focus returns to it on close. The dialog (full screen below `md`) renders, only while open, an iframe from `https://www.youtube-nocookie.com/embed/{key}?autoplay=1&rel=0&playsinline=1` with a descriptive `title`, `allow="autoplay; encrypted-media; picture-in-picture; fullscreen"` and `referrerPolicy="strict-origin-when-cross-origin"`. Closing the dialog unmounts the iframe. A key that is not 6 to 20 characters of `[A-Za-z0-9_-]` is treated as no trailer. No trailer means no button.
+- **AC-12** (Where to watch): A section titled "Where to watch in {country}" lists providers from the region's entry in three groups, in this order and each only when non empty: Stream (`flatrate`), Free (`free` then `ads`, merged by `provider_id`), and Rent or buy (`rent` then `buy`, merged by `provider_id`). A provider may appear in more than one group. Each group is ordered by `display_priority`, then by name, and each provider shows its `w92` logo (name text when the logo is missing). The section links once to the `link` TMDB returns for the region and carries the JustWatch credit line, `JUSTWATCH_ATTRIBUTION` ("Availability data from JustWatch"), a new constant in `lib/legal/operator.ts` beside `TMDB_ATTRIBUTION` that the legal boundary test keeps in one place. With no entry or no providers for the region it shows "Not streaming in {country} right now." and the JustWatch line.
+- **AC-13** (Age rating): The hero meta shows a chip with the region's rating: for a movie, the non empty certification of release type 3 (Theatrical) for the region, else the first non empty certification for the region in TMDB's array order (trimmed, an empty string counts as empty); for a show, the region's `content_ratings` rating. No rating for the region means no chip, never another country's.
+- **AC-14** (More like this): A row titled "More like this" shows up to 12 of page 1 of TMDB recommendations in the home row card (same width, badges and your tracked, planned or watched status, through `CatalogListCards`), with the arrow scroller and no See all link. Recommendations are validated with the existing list item schemas that carry `adult` (`movieListItemSchema`, `tvListItemSchema`) and adult titles are dropped, as on the spec 0021 lists. No recommendations means no row.
+- **AC-15** (Key crew): Under the overview, a movie shows "Directed by" (crew job `Director`) and "Written by" (jobs `Screenplay` and `Writer`, not `Story`; names deduplicated in credit order, then capped at 3), and a show shows "Created by" (`created_by`, at most 3). Each line appears only when it has a name.
+- **AC-16** (Facts): A Facts panel at the end of the page shows, each only when TMDB has a value: for a movie, status, original title (only when it differs from the title), original language, budget and revenue (only when above 0, `formatUsdCompact`, US dollars in compact form with at most one decimal, such as "$165M" or "$1.5B"), and production companies; for a show, status, type, original language, networks (the name, with the `w154` logo beside it on a neutral tile when TMDB has one, since network logos come in both white and dark), production companies, and the first and last air dates (`formatAirDate`, the full date `formatReleaseDate` shows). Status and type show TMDB's English strings as is ("Released", "Returning Series", "Miniseries"), languages go through `languageName`. Nothing is shown as "Unknown" or 0. Facts and the hero read only `getMovie` and `getTvShow`, never the copy of the details inside the extras response.
+- **AC-17** (Page order): The movie page reads hero (with Trailer and age chip), overview, crew, where to watch, cast, more like this, facts. The show page reads hero, overview, crew, your rating and seasons, where to watch, cast, more like this, facts. Cast is drawn like More like this: a row of home row cards (`ROW_ITEM_CLASS`, `ROW_POSTER_SIZES`) in the arrow scroller with no See all link, the name as an `h3` and the character on one line, and a 12 card skeleton.
+- **AC-18** (States): While the extras load, Where to watch and More like this show skeletons at their usual height, and the Trailer button and age chip render nothing (the hero grows slightly when they arrive, and an empty section collapses on arrival; both accepted, as with the tracking slot). If the extras read fails, including a 404 for a title the detail read found, the Trailer button and age chip are absent, and Where to watch and More like this each show a one line error, "Couldn't load {what} from TMDB.", with a Retry link to the page (`RetryLink`). The line names no cause, because a 404 and a bad response land there too. The rest of the page, including tracking, is unaffected. Crew and Facts come from the existing detail reads and share their states.
+- **AC-19** (Legal): `PROCESSORS` gains YouTube (trailer playback, only after you press Trailer), `/privacy` adds one sentence saying the approximate country Vercel derives from your connection picks the streaming and age rating region and is not stored, a short paragraph under Cookies saying the trailer player loads only after you press Trailer and that YouTube's own policy then applies to it, and `LEGAL_LAST_UPDATED` is bumped to the ship date.
+- **AC-20** (Mobile and keyboard): At 375px every new section fits without sideways page scroll (the recommendations row scrolls inside itself), the trailer dialog fills the screen, every new control is reachable by keyboard with visible focus, and tap targets stay 44px.
+
+## Decision
+
+**Chosen options**: a `columns` preset on `PosterGrid` (`catalog` default, `wide` for seasons) that owns its classes, `sizes` and eager count; and one separate cached "extras" read per title for the new TMDB data, with the region picked per request outside the cache.
+
+**Implementation skills**: `next-dev-loop` (`vercel/next.js`, `.agents/skills/next-dev-loop/`) for checking each section in the running app. `next-cache-components-optimizer` (`vercel/next.js`, `.agents/skills/next-cache-components-optimizer/`) if a title page's shell regresses.
+
+## Rationale
+
+Reasoning and options: see [rationale.md](rationale.md).
+
+## Feature design
+
+**Data model sketch**: no database change. New TMDB types in `lib/tmdb/types.ts`:
+
+- `MovieExtras` and `TvExtras`, two types with the same fields: `{ trailer: Trailer | null; recommendations: TaggedCatalogPage; watchProviders: Record; ageRatings: Record }`, where `recommendations` is already the `{ mediaType, paged }` shape `CatalogListCards` takes (`mediaType` fixed per type, `paged` built from page 1). Region maps are keyed by ISO 3166 1 code; `ageRatings` already holds the picked certification per country (the AC-13 rule runs in the normalizer for every country), and the page reads `[region]` after the cache returns.
+- Appended TMDB shapes the extras schema reads (a separate non strict schema that picks only these keys; every list is `.nullish()` and defaults to empty): `videos.results[]` `{ site, type, key, name, official?, published_at? }`; `recommendations` page 1, results through the list item schemas; `watch/providers.results` `{ [CC]: { link?, flatrate?, free?, ads?, rent?, buy? } }`, each provider `{ provider_id, provider_name, logo_path?, display_priority? }`; `release_dates.results[]` `{ iso_3166_1, release_dates: [{ certification, type }] }` (type 3 is Theatrical); `content_ratings.results[]` `{ iso_3166_1, rating }`. The request keeps `buildUrl`'s `language=en-US`; `include_video_language=en,null` is what stops that language filter from dropping videos with no language.
+- `Trailer` = `{ key: string; name: string }`.
+- `RegionProviders` = `{ link: string | null; stream: Provider[]; free: Provider[]; rentOrBuy: Provider[] }`, already grouped and deduplicated by the normalizer.
+- `Provider` = `{ id: number; name: string; logoUrl: string | null }`.
+- `Movie` adds `status: string | null`, `budget: number | null`, `revenue: number | null`, `productionCompanies: Company[]`, `directors: string[]`, `writers: string[]` (parsed from the existing `credits.crew`, no new request).
+- `TvShow` adds `type: string | null`, `createdBy: string[]`, `networks: Company[]`, `productionCompanies: Company[]` (from the existing details response, no new request).
+- `Company` = `{ id: number; name: string; logoUrl: string | null }`.
+
+**Interface surface** (no HTTP endpoints, no Server Actions):
+
+| Unit | Shape | Notes |
+|---|---|---|
+| `fetchMovieExtras(id)`, `fetchTvExtras(id)` | uncached fetch + Zod + normalize | `lib/tmdb/extras.ts`, the inner half per spec 0002 AC-23; `fetchMovie` extends its schema to keep `credits.crew` |
+| `getMovieExtras(id)`, `getTvExtras(id)` | thin wrappers that throw `TmdbError`, over private `use cache` twins with `cacheLife("hours")` that return a `TmdbResult` | in `reads.ts`, exported through the barrel, the pair pattern of every read |
+| `getVisitorRegion()` | `Promise<{ code: string; name: string }>` | new `lib/region.ts` (outside `lib/tmdb`, which may not read request state), `server-only`, React `cache()`d, reads `headers()`; the pure `parseRegion(header)` lives in `lib/catalog/region.ts` and is tested alone |
+| `DetailHero` | new `actions?: ReactNode` slot, rendered beside `tracking` | holds the Trailer slot; the age chip goes inside each page's `meta` |
+| Each extras slot | an async Server Component that calls `getMovieExtras(id)` / `getTvExtras(id)` itself | repeated calls in one render share one cached read; the trailer slot passes `{ key, name }` as props to the client `TrailerButton` |
+| `PosterGrid` | `columns?: "catalog" \| "wide"` | plus exported `POSTER_GRID_SIZES` and `POSTER_GRID_EAGER`, both `Record`; class strings are full literals so Tailwind emits them |
+| New components | `TrailerButton` (client, uses `ui/dialog`), `WhereToWatch`, `AgeRatingChip`, `MoreLikeThis`, `CrewLines`, `FactsPanel` | in `components/catalog/` (shared by both pages); each async slot sits in its own Suspense boundary |
+
+**Value sourcing**:
+
+| Action | Value | Source |
+|---|---|---|
+| Grid | columns, `sizes`, eager count | the preset in `poster-grid.tsx` (AC-1 to AC-5) |
+| Trailer | video key and name | extras `videos.results`, filtered by AC-11 |
+| Where to watch | providers, groups, link | extras `watch/providers.results[region]` |
+| Where to watch, age chip | region code and country name | `x-vercel-ip-country` → `parseRegion` → `Intl.DisplayNames(["en"], { type: "region" })`, fallback `US` (AC-9) |
+| Age chip | certification | movie: extras `release_dates.results[region]`; show: extras `content_ratings.results[region]` (AC-13) |
+| More like this | titles | extras `recommendations.results` (page 1), normalized with the existing summary normalizers |
+| More like this | your status per card | the existing grid state reads behind `CatalogListCards` (`getMovieGridStates`, `lib/tracking/show-grid-status.ts`) |
+| Crew | names | movie: existing `credits.crew`; show: existing `created_by` |
+| Facts | every field | existing detail responses (`getMovie`, `getTvShow`), newly parsed |
+| Facts | money format | new `formatUsdCompact` in `lib/format.ts` (`Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact" })`) |
+| Facts | language name | existing language name helper in `lib/format.ts` |
+| Provider and company logos | image URL | `imageUrl` in `lib/tmdb/images.ts` from `logo_path` (host `image.tmdb.org`, already allowed) |
+
+**Key invariants**:
+- Nothing user or request specific enters a cached scope (lib/tmdb rule): the extras read takes only the id, and the region is applied after it returns.
+- The library and catalog grid reads never trigger the extras read.
+- Every preset's classes, `sizes` and eager count change together, and the test covers every preset.
+- Missing stays missing: no section, line or chip is shown with placeholder text, a zero, or another region's data.
+- A failure crosses the cache boundary as a `TmdbResult`, never a thrown `TmdbError`; the exported wrapper rebuilds and throws it outside the scope.
+
+**Security model**: everything new is public catalog data. The region header is read per request and is not stored or logged. The YouTube iframe does not exist in the page until the visitor presses Trailer. No new environment variable, secret or database access. The TMDB token stays in `lib/tmdb/env.ts`.
+
+**Configuration required**: none.
+
+**Critical test scenarios**:
+- `poster-grid.test.tsx` loops over `POSTER_GRID_SIZES` and checks each against its columns with the AC-4 rule, and checks that `wide` keeps today's classes, verifies **AC-1**, **AC-3**, **AC-4**.
+- Fixture tests for the extras normalizer: trailer choice (official trailer over teaser, newest wins, bad key dropped), provider grouping and deduplication, certification choice (type 3 first, empty skipped), adult recommendations dropped, missing logos, verifies **AC-11** to **AC-14**.
+- `parseRegion`: `PL` → Poland, missing, lowercase, three letters, `XX`, `T1` or an unnameable code → `US`, verifies **AC-9**. Another real country is checked on a preview with `curl -H "x-vercel-ip-country: PL"`, since local runs have no header and always show `US`.
+- Crew and facts normalizers: writer deduplication and cap, zero budget becomes null, verifies **AC-15**, **AC-16**.
+- A request scope test: rendering a library tab or `getTvShow` makes no extras call, and the extras read takes no request state, verifies **AC-8**, **AC-9**.
+- In the running app: a movie and a show with every section, a title with none (no trailer, no providers, no recommendations), a forced extras failure, the trailer dialog by keyboard, and every grid page at the five widths, verifies **AC-2**, **AC-7**, **AC-11** to **AC-18**, **AC-20**.
+- `pnpm build` route table and the prerendered shells, verifies **AC-10**.
+- `/privacy` shows YouTube and the country sentence; the legal boundary test passes with `JUSTWATCH_ATTRIBUTION`, verifies **AC-12**, **AC-19**.
+- Existing tests to update: `movie-hero.test.tsx` and the show hero tests (new slot), `lib/legal/boundary.test.ts`, the `/privacy` page test and any `PROCESSORS` assertion, `app/showcase/page.test.tsx`, `skeleton.test.tsx` and the library skeleton tests. `json-ld.test.tsx` and `security-boundary.test.ts` should need no change; if they do, something leaked.
+
+## Build plan
+
+1. Card preset: add `columns` with `catalog` and `wide`, `POSTER_GRID_SIZES` and `POSTER_GRID_EAGER`; point `POSTER_SIZES` at the catalog value; switch `SeasonGrid` and `ShowDetailSkeleton` to `wide` with its sizes; rewrite `poster-grid.test.tsx` to loop over presets and update its `covers` header. Satisfies **AC-1**, **AC-3**, **AC-4**.
+2. Card rollout: replace the four `EAGER_POSTERS` with `POSTER_GRID_EAGER.catalog`, give `LibrarySkeleton` the 6 card visibility list, show 6 cards in `/showcase`, then check every grid page at 375, 640, 768, 1024 and 1280px. Satisfies **AC-2**, **AC-5**, **AC-6**, **AC-7**.
+3. Tracer bullet for title pages: the extras schemas, normalizer and cached reads for movies, `lib/region.ts`, and the Where to watch section on the movie page with its skeleton, empty and error states, verified against live TMDB in the running app and in the build. Satisfies **AC-8**, **AC-9**, **AC-10**, **AC-12**, **AC-18**.
+4. Trailer and age rating on the movie page: `TrailerButton` with the dialog and the click to load iframe, `AgeRatingChip` in the hero meta, and the legal change (`PROCESSORS`, the country sentence, `LEGAL_LAST_UPDATED`). Satisfies **AC-11**, **AC-13**, **AC-19**.
+5. More like this, crew and facts on the movie page: the recommendations row through `CatalogListCards`, the new `Movie` fields and `formatUsdCompact`, `CrewLines` and `FactsPanel`, in the AC-17 order. Satisfies **AC-14**, **AC-15**, **AC-16**, **AC-17**.
+6. Show page parity: the TV extras read (with `content_ratings`), the new `TvShow` fields, and every section on `/shows/{id}` in its order. Satisfies **AC-8**, **AC-11** to **AC-17**.
+7. Proof: the fixture, region and request scope tests, 375px and keyboard passes, `pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm build`; amendment notes in spec 0004 (AC-9), spec 0006 and spec 0009 (new sections), and `prompts/grid-tabs-footer-legend.md`. Satisfies **AC-7**, **AC-10**, **AC-20**.
+
+## Consequences
+
+**Positive**:
+- One card size everywhere, and 30 titles per desktop screen instead of 20.
+- Title pages answer the questions visitors leave for: what it looks like, where to watch it, what else is like it, who made it.
+- The library pages pay nothing for the new data.
+
+**Negative / tradeoffs**:
+- A 20 title page ends with a short last row at 3, 5 and 6 columns, the problem the 2 and 4 column grid was built to remove.
+- Smaller desktop posters make captions and controls tighter, and six eager posters means a phone preloads three rows instead of two.
+- Each title page view can cost one more TMDB request per hour per title, and a cold page waits on it for two of its sections.
+- Embedding YouTube adds a processor and a legal update. Region from an IP header is approximate (VPNs and travel show another country), and local development always shows the `US` fallback.
+- The title pages get longer, and more sections means more states to keep honest.
+
+**Neutral**:
+- Spec 0004 AC-9 returns to its original column counts. The 2 and 4 column decision in `prompts/grid-tabs-footer-legend.md` now applies only to the season grid.
+- The deferred "per visitor region on the home rows" item now has a precedent (`lib/region.ts`) it can reuse.
+
+## Follow-up
+
+- [ ] `components/AGENTS.md` still describes the grid as 2 and 4 columns with 5 full rows, and `lib/tmdb/AGENTS.md` should list the extras read and its `hours` lifetime; `/sync` updates both after the build.
+- [ ] Later batch (from the grilling, kept on the scope's Deferred list): external links and the movie collection row.
diff --git a/docs/specs/0022-card-size-title-pages/rationale.md b/docs/specs/0022-card-size-title-pages/rationale.md
new file mode 100644
index 0000000..4381bea
--- /dev/null
+++ b/docs/specs/0022-card-size-title-pages/rationale.md
@@ -0,0 +1,71 @@
+# 0022. Consistent card size and richer title pages: rationale
+
+## Context
+
+> ⚠️ Premise note: the card strand partly reverses [prompts/grid-tabs-footer-legend.md](../../../prompts/grid-tabs-footer-legend.md), which moved every grid from 2/3/4/5/6 columns to 2/4 so a 20 title page fills five complete rows. Six columns bring the ragged last row back, and TMDB pages are fixed at 20, so a page size change cannot fix it on the catalog pages. The owner accepted the ragged row in the 2026-10-12 grilling session in exchange for one card size everywhere. Spec 0004 AC-9 originally asked for exactly these column counts.
+
+**Card size.** Spec 0021 gave the home rows fixed width cards, 140px below `md` and 180px from it (`ROW_ITEM_CLASS`). Every other grid goes through `PosterGrid`, which is fluid at 2 and 4 columns and capped at 1200px, so at 1280px a grid card is about 288px, 1.6 times the row card. The column counts live in `PosterGrid` and the image `sizes` in `POSTER_SIZES`; if one changes without the other, every screen silently downloads the wrong image width, which is why `poster-grid.test.tsx` exists. Eager poster counts (`EAGER_POSTERS = 4`, copied in four files) and `LibrarySkeleton`'s one row are also tied to the column count. The season grid uses `PosterGrid` too, but holds a few seasons in a narrower detail layout.
+
+**Title pages.** The movie page shows a hero, tracking, overview and cast; the show page adds meta, progress, your rating and seasons. Movies request `credits,translations` and shows `translations`. TMDB also offers videos, watch providers (supplied by JustWatch, whose attribution TMDB requires), recommendations, release dates with certifications, content ratings, and more detail fields the app currently drops. The constraint that shapes everything: `getTvShow` is the read the library pages run for every tracked show, up to 500 per tab, so anything appended to it is paid hundreds of times per library view. Watch providers and certifications are per country, the app stores no locale, and `lib/tmdb` forbids request state inside a cached scope. Embedding a third party player is a new processor under spec 0017, which requires `PROCESSORS`, the policy text and `LEGAL_LAST_UPDATED` to change in the same PR.
+
+## Options considered
+
+### Grid: a `columns` preset on `PosterGrid` (chosen)
+
+`PosterGrid` takes `columns: "catalog" | "wide"`; each preset owns its class string, `sizes` and eager count in exported maps.
+
+**Pros**: the column, sizes and eager invariant lives in one file and one test that loops over every preset; the season grid opts out by name.
+**Cons**: callers of `wide` must pass its sizes to their cards; a small new API.
+
+### Grid: a class override from the season grid
+
+Change the default and let `SeasonGrid` pass overriding classes, merged by `cn`.
+
+**Pros**: smallest change.
+**Cons**: the invariant splits across files and the merge must undo three breakpoints, which the test cannot see.
+
+### Grid: the season grid owns its own list
+
+**Pros**: plain to read.
+**Cons**: duplicates the cap, gap and list semantics, and the season grid escapes the sizes test.
+
+### Data: one separate extras read per title (chosen)
+
+A new cached read per title appends videos, recommendations, watch providers and release dates or content ratings in one TMDB request, `hours` lifetime. Crew and facts come from parsing more of the existing detail responses.
+
+**Pros**: one request per title per hour shared by every visitor; the library reads are untouched; the shorter lifetime fits provider availability without shortening the movie read's `days`.
+**Cons**: the details payload comes back a second time inside the extras response; two sections share one failure.
+
+### Data: append to the existing detail reads
+
+**Pros**: nothing new to wire.
+**Cons**: the 500 show library read gets much heavier and the movie read inherits a shorter lifetime.
+
+### Data: one read per section
+
+**Pros**: each section fails alone.
+**Cons**: four TMDB requests per cold title instead of one, against a rate limited API.
+
+### Region: request header (chosen), fixed region, or account setting
+
+The header follows the visitor with no storage, and the country is named on screen so a wrong guess is visible. A fixed region is wrong for most visitors; an account setting needs a migration and still needs a default for visitors who are signed out.
+
+### Trailer: dialog with click to load embed (chosen) or a link out
+
+A link out avoids a processor, but the owner asked for trailers on the page. Loading `youtube-nocookie.com` only on click keeps YouTube off every page view that does not ask for it.
+
+## Rationale
+
+The grid failure mode is silent (wrong image widths, nothing visibly broken), so the design keeps every number that depends on the column count in one exported map per preset, and the test iterates the map. The cross check showed the conditional `library` preset could not fail at a width that matters (library cards are 172px at 768px today and fit, and 6 columns give 186px), so it was dropped for one grid everywhere. The fixed 228px entry for the capped five column range removes an avoidable overshoot.
+
+For the data, the deciding force is the library read: appending to `getTvShow` would multiply the cost of the new data by every tracked show, while a separate read costs one request per title per hour shared across visitors. Watch providers and certifications arrive for every country in one response, so the cache stays free of request state and the region is a cheap pick afterward, which honours the `lib/tmdb` rule. Hiding a missing age rating rather than falling back to another country, and naming the country on Where to watch, follow the project rule never to present data that may be false. Failure states follow spec 0021's row pattern (one line with Retry), so a slow or failing extras read never takes the page or its tracking down.
+
+## Amendment 2026-10-12: recording what shipped
+
+A review of the branch against this spec found five places where the build went past or beside the text. The owner kept each as built and asked for the spec to say so:
+
+- **Cast as a carousel row.** Once More like this used the home row card, the old fixed width cast cards were the one row on the page drawn differently. Cast now uses the same card, scroller and skeleton, so the page has one row style.
+- **Network name beside its logo.** TMDB's network logos come in both white and dark, so a logo alone vanishes on one of them. The name always shows, with the logo beside it on a neutral tile.
+- **An error line with no cause.** "TMDB didn't respond" was untrue for a 404 or a bad response, which the extras read also turns into the error line, so the line now says only that the section couldn't load from TMDB.
+- **The trailer paragraph in `/privacy`.** Saying when the YouTube player loads and whose policy then applies belongs beside the cookie text, in addition to the one country sentence AC-19 asked for.
+- **The read signatures.** The extras reads follow the module's pair pattern: the cached twin returns a `TmdbResult` and the exported wrapper throws. The interface table described only the inner half.
diff --git a/docs/specs/0022-card-size-title-pages/verify.md b/docs/specs/0022-card-size-title-pages/verify.md
new file mode 100644
index 0000000..bb4bfd8
--- /dev/null
+++ b/docs/specs/0022-card-size-title-pages/verify.md
@@ -0,0 +1,58 @@
+# Verify: Consistent card size and richer title pages · spec 0022 · updated 2026-10-12
+_Steps derived from spec 0022 acceptance criteria. `/check verify` runs these; `/test` locks the durable ones._
+
+## UI / manual
+
+### Card strand
+- [x] Open `/movies/popular` at 1280px → 6 columns, a card 180 to 190px wide, image `sizes` starts `(min-width: 1280px) 187px`, the first 6 posters eager → AC-1, AC-2, AC-4, AC-5
+- [x] Same page at 375, 640, 768 and 1024px → 2, 3, 4 and 5 columns, the grid never wider than the page → AC-1, AC-7
+- [x] Repeat at 1280px on `/shows/top-rated`, `/search?type=movie&q=star`, `/watchlist`, `/upcoming` and `/watched` (both `?type=`) → 6 columns everywhere, card controls keep their tap targets → AC-2, AC-7
+ - Partly passed: Watchlist (both types) and Watched movies show 6 columns and 186.7px cards. Upcoming (both types) and Watched shows are empty for user-a, so had no grid; they render through the same `PosterGrid` preset.
+- [x] Slow the network and load `/watchlist?type=tv` → the skeleton is one row (2, 3, 4, 5 or 6 cards for the width) and a one row library does not shift on arrival → AC-6
+- [x] Open `/shows/1396` → the season grid is still 2 columns below 768px and 4 from it, cards 288px wide at 1280px → AC-3
+- [x] Open `/showcase` → the poster grid section shows 6 cards, including the missing poster tile at a real poster's footprint → AC-7
+- [ ] Known, not caused by this feature: signed in at 768px and 1024px the navbar is wider than the page (scrollWidth 1173 and 1239). Confirm on `main` and track it separately.
+
+### Title pages
+- [x] Open `/movies/550` → hero shows an `R` chip and a Trailer button; sections read Overview (with "Directed by", "Written by"), Where to watch in United States, Cast, More like this, Facts → AC-13, AC-15, AC-17
+- [x] Open `/shows/1396` → hero shows `TV-MA` and Trailer; sections read Overview (with "Created by"), Seasons, Where to watch, Cast, More like this, Facts → AC-15, AC-17
+- [x] On `/movies/550` press Tab to Trailer and Enter → dialog opens with focus on Close, iframe from `youtube-nocookie.com/embed/...?autoplay=1&rel=0&playsinline=1`; Escape closes it, the iframe is gone and focus is back on Trailer → AC-11, AC-20
+- [x] Before pressing Trailer, check the network panel → no request to any YouTube host → AC-11, AC-19
+- [x] Where to watch on `/movies/550` → groups in the order Stream, Free, Rent or buy, each only when it has providers; one "All options on TMDB" link (new tab) and "Availability data from JustWatch" → AC-12
+- [x] Open `/movies/1000010` → no Trailer, no chip, "Not streaming in United States right now." with the JustWatch line → AC-12, AC-13, AC-16
+- [x] More like this on `/movies/550` → up to 12 cards at the home row width with badges and your status, arrow scroller, no See all → AC-14
+- [x] Facts on `/movies/550` → Status, Original language, Budget `$63M`, Revenue `$100.9M`, Production companies; no Unknown or $0 anywhere → AC-16
+- [x] Facts on `/shows/1396` → Status, Type, Original language, Networks (AMC), Production companies, First aired, Last aired → AC-16
+- [x] Throttle the network on a title page → Where to watch and More like this show skeletons; the hero has no placeholder for Trailer or the chip → AC-18
+- [x] Force the extras read to fail (block `api.themoviedb.org` for the extras request, or a title whose extras 404) → no Trailer or chip, Where to watch and More like this each show one line with Try again to the page; tracking and the rest still work → AC-18
+ - Neither route works live: the extras share `/movie/{id}` and `/tv/{id}` with the detail read, and a browser block never reaches a server fetch. Verified 2026-10-12 by a temporary `throw new TmdbError("not_found", ...)` in `fetchMovieExtras` and `fetchTvExtras` (reverted): `/movies/550` and `/shows/1396` showed no Trailer or chip, both error lines with Try again to the page, Cast, Facts and Seasons intact, and Plan saved, survived a reload and was undone.
+- [x] At 375px on both title pages → no sideways page scroll, More like this scrolls inside itself, the trailer dialog fills the screen with square corners, Close is 44px → AC-20
+- [x] Open `/privacy` → YouTube under processors, the country sentence under What we collect, the trailer sentence under Cookies, Last updated 2026-10-12 → AC-19
+
+## Value sourcing (one step per row)
+- [x] Grid columns, sizes and eager count come from the preset: change nothing, read `POSTER_GRID_SIZES` in the page HTML at 1280px and 1024px → `187px` and `20vw` entries → AC-4
+- [x] Trailer key: on `/movies/550` the trailer is `dfeUzm6KF4g` (official, newer than the other official trailer), not the unofficial `6JnN1DmbqoU` → AC-11
+- [x] Region from the header: `curl -s -H "x-vercel-ip-country: PL" /movies/550` → "Where to watch in Poland" with Netflix and HBO Max, chip "Age rating in Poland" (18) → AC-9
+- [x] Region fallback: repeat with `XX`, `T1`, `pl` and no header → "United States" every time → AC-9
+- [x] Age chip certification: with `PL` the movie shows `18` (Polish theatrical), with `US` it shows `R` (theatrical, not the festival release) → AC-13
+- [x] Recommendations: More like this titles match TMDB's `/movie/550/recommendations` page 1 with adult titles removed → AC-14
+- [x] Your status per card: plan one recommended movie, reload → its More like this card shows Planned, as on the catalog grids → AC-14
+- [x] Crew names: "Directed by David Fincher", "Written by Jim Uhls" come from `getMovie`'s credits; no extra TMDB request is made for them → AC-15
+- [x] Facts: every value matches `getMovie` / `getTvShow` (budget and revenue in compact US dollars, languages via `languageName`) → AC-16
+- [x] Provider and network logos load from `image.tmdb.org` through `next/image` → AC-12, AC-16
+
+## Commands
+- [x] `pnpm typecheck` → no errors → all
+- [x] `pnpm lint` → no errors → all
+- [x] `pnpm test` → all pass, including `poster-grid.test.tsx`, `lib/tmdb/extras.test.ts`, `lib/catalog/region.test.ts`, `components/catalog/title-extras.test.tsx`, `trailer-button.test.tsx`, `title-details.test.tsx`, `lib/legal/boundary.test.ts` → AC-1 to AC-19
+- [x] `pnpm build` → `/movies/[id]` and `/shows/[id]` still listed as ◐ (Partial Prerender) → AC-10
+- [x] `grep -rn "getVisitorRegion\|extras" lib/seo app/**/opengraph-image* proxy.ts app/sitemap.ts` → nothing; `generateMetadata` unchanged → AC-10
+
+## Acceptance-criteria coverage
+- AC-1, AC-2, AC-3, AC-4, AC-5, AC-6, AC-7: card strand steps and the value sourcing grid step
+- AC-8: value sourcing crew step, `extras.test.ts`, the request scope test in `region.test.ts`
+- AC-9: both region value sourcing steps, `region.test.ts`
+- AC-10: build and grep commands
+- AC-11: trailer steps; AC-12: Where to watch steps; AC-13: chip steps; AC-14: More like this steps
+- AC-15, AC-16: crew and Facts steps; AC-17: page order steps; AC-18: loading and failure steps
+- AC-19: privacy step and the no YouTube request step; AC-20: 375px and keyboard steps
diff --git a/lib/catalog/media-type.test.ts b/lib/catalog/media-type.test.ts
index ddb3f42..323fc14 100644
--- a/lib/catalog/media-type.test.ts
+++ b/lib/catalog/media-type.test.ts
@@ -4,6 +4,7 @@ import {
isTypedPath,
mediaTypeForLocation,
parseMediaTypeParam,
+ titleHref,
typedHref,
} from "./media-type";
@@ -84,3 +85,10 @@ describe("typedHref", () => {
expect(isTypedPath(null)).toBe(false);
});
});
+
+describe("titleHref", () => {
+ it("opens a movie on its movie page and a show on its show page", () => {
+ expect(titleHref("movie", 438631)).toBe("/movies/438631");
+ expect(titleHref("tv", 1396)).toBe("/shows/1396");
+ });
+});
diff --git a/lib/catalog/media-type.ts b/lib/catalog/media-type.ts
index 940fdd8..fb36dc3 100644
--- a/lib/catalog/media-type.ts
+++ b/lib/catalog/media-type.ts
@@ -63,6 +63,15 @@ export function parseMediaTypeParam(
return parsed.success ? parsed.data : null;
}
+/**
+ * A title's own page: `/shows/{id}` or `/movies/{id}`, under its catalog's
+ * browse path (feature 22). The one builder of a title page URL, shared by
+ * search results and the title pages' Retry and return links.
+ */
+export function titleHref(type: MediaType, id: number): string {
+ return `${CATALOG_PATHS[type]}/${id}`;
+}
+
/**
* The media type of the page being viewed, which the tabs light and the
* Library links and navbar search carry on (feature 22). The catalog and
diff --git a/lib/catalog/region.test.ts b/lib/catalog/region.test.ts
new file mode 100644
index 0000000..8ac3822
--- /dev/null
+++ b/lib/catalog/region.test.ts
@@ -0,0 +1,86 @@
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { extname, join } from "node:path";
+import { describe, expect, it } from "vitest";
+
+import { DEFAULT_REGION, parseRegion } from "./region";
+
+/**
+ * covers: spec 0022, AC-8, AC-9
+ *
+ * The region rule, and the request scope boundary around it: the region is
+ * read only in the title page slots, never inside the TMDB module, and no
+ * library or grid read ever reaches the extras read. Local runs have no
+ * `x-vercel-ip-country` header, so another real country is checked on a
+ * preview with `curl -H "x-vercel-ip-country: PL"`.
+ */
+
+describe("parseRegion", () => {
+ it("names a real two letter country in English", () => {
+ expect(parseRegion("PL")).toEqual({ code: "PL", name: "Poland" });
+ expect(parseRegion("DE")).toEqual({ code: "DE", name: "Germany" });
+ });
+
+ it.each<[string | null | undefined, string]>([
+ [null, "missing"],
+ [undefined, "missing"],
+ ["", "empty"],
+ ["pl", "lower case"],
+ ["POL", "three letters"],
+ ["P1", "a digit"],
+ ["XX", "Vercel's unknown country"],
+ ["T1", "Tor"],
+ ["ZZ", "Unicode's unknown region"],
+ ["QQ", "a code nobody can name"],
+ ])("falls back to US for %j (%s)", (header) => {
+ expect(parseRegion(header)).toEqual(DEFAULT_REGION);
+ });
+
+ it("falls back to the United States, named", () => {
+ expect(DEFAULT_REGION).toEqual({ code: "US", name: "United States" });
+ });
+});
+
+/** Application source under a directory, tests left out. */
+function sourcesUnder(dir: string): string[] {
+ const found: string[] = [];
+ for (const entry of readdirSync(dir)) {
+ const path = join(dir, entry);
+ if (statSync(path).isDirectory()) found.push(...sourcesUnder(path));
+ else if (
+ [".ts", ".tsx"].includes(extname(path)) &&
+ !/\.test\.tsx?$/.test(path)
+ ) {
+ found.push(path);
+ }
+ }
+ return found;
+}
+
+describe("the request scope boundary (AC-8, AC-9)", () => {
+ it("keeps request state out of the TMDB module, extras included", () => {
+ for (const path of sourcesUnder("lib/tmdb")) {
+ const source = readFileSync(path, "utf8");
+ expect(source, path).not.toContain("next/headers");
+ expect(source, path).not.toContain('"x-vercel-ip-country"');
+ }
+ });
+
+ it("reads the country header only in lib/region.ts", () => {
+ const readers = ["app", "components", "lib"]
+ .flatMap(sourcesUnder)
+ .filter((path) =>
+ readFileSync(path, "utf8").includes('"x-vercel-ip-country"'),
+ );
+ expect(readers).toEqual(["lib/region.ts"]);
+ });
+
+ it("never reaches the extras read from a library or grid read", () => {
+ const callers = ["app", "components", "lib"]
+ .flatMap(sourcesUnder)
+ .filter((path) =>
+ /\b(getMovieExtras|getTvExtras)\b/.test(readFileSync(path, "utf8")),
+ )
+ .filter((path) => !path.startsWith("lib/tmdb/"));
+ expect(callers).toEqual(["components/catalog/title-extras.tsx"]);
+ });
+});
diff --git a/lib/catalog/region.ts b/lib/catalog/region.ts
new file mode 100644
index 0000000..011469b
--- /dev/null
+++ b/lib/catalog/region.ts
@@ -0,0 +1,46 @@
+/** A country the title page names and picks its region data for (spec 0022, AC-9). */
+export type Region = {
+ /** ISO 3166-1 alpha-2, upper case. */
+ code: string;
+ /** The English country name, from `Intl.DisplayNames`. */
+ name: string;
+};
+
+/**
+ * The fallback when the request names no usable country: local development,
+ * a preview with no header, or a code nobody can name.
+ */
+export const DEFAULT_REGION: Region = { code: "US", name: "United States" };
+
+/**
+ * Vercel's codes for an unknown country and for Tor traffic, plus Unicode's
+ * unknown region, which `Intl.DisplayNames` names "Unknown Region".
+ */
+const UNKNOWN_CODES: ReadonlySet = new Set(["XX", "T1", "ZZ"]);
+
+const regionNames = new Intl.DisplayNames(["en"], { type: "region" });
+
+/**
+ * The visitor's region from Vercel's `x-vercel-ip-country` header value
+ * (spec 0022, AC-9): two capital letters that `Intl.DisplayNames` can name,
+ * and not `XX` or `T1`, otherwise `US`.
+ *
+ * Pure and free of `server-only`, like the rest of `lib/catalog/`, so it is
+ * tested alone. The header read itself lives in `lib/region.ts`.
+ *
+ * @param header The raw header value, or null when there is none.
+ */
+export function parseRegion(header: string | null | undefined): Region {
+ if (!header || !/^[A-Z]{2}$/.test(header) || UNKNOWN_CODES.has(header)) {
+ return DEFAULT_REGION;
+ }
+ let name: string | undefined;
+ try {
+ name = regionNames.of(header);
+ } catch {
+ return DEFAULT_REGION;
+ }
+ // An unnameable code comes back unchanged rather than as a name.
+ if (!name || name === header) return DEFAULT_REGION;
+ return { code: header, name };
+}
diff --git a/lib/format.test.ts b/lib/format.test.ts
index f4a1cbb..a9995dc 100644
--- a/lib/format.test.ts
+++ b/lib/format.test.ts
@@ -8,6 +8,7 @@ import {
formatReleaseDate,
formatRuntime,
formatShortDate,
+ formatUsdCompact,
formatVoteCount,
languageName,
parseTmdbDate,
@@ -280,3 +281,16 @@ describe("parseTmdbDate · covers spec 0011 AC-3", () => {
expect(parseTmdbDate("0099-01-01")?.getUTCFullYear()).toBe(99);
});
});
+
+/** covers: spec 0022, AC-16 */
+describe("formatUsdCompact", () => {
+ it.each([
+ [165_000_000, "$165M"],
+ [1_500_000_000, "$1.5B"],
+ [100_853_753, "$100.9M"],
+ [63_000_000, "$63M"],
+ [950, "$950"],
+ ])("formats %d as %s", (amount, expected) => {
+ expect(formatUsdCompact(amount)).toBe(expected);
+ });
+});
diff --git a/lib/format.ts b/lib/format.ts
index face8c2..efb4ba9 100644
--- a/lib/format.ts
+++ b/lib/format.ts
@@ -49,6 +49,25 @@ export function languageName(code: string): string {
}
}
+const USD_COMPACT_FORMAT = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ notation: "compact",
+ // Explicit, because Node 22's ICU floors a currency at one decimal under
+ // a maximum of 1 and prints `$165.0M`; Node 24 already drops it.
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 1,
+});
+
+/**
+ * A budget or revenue in US dollars, compact with at most one decimal, for
+ * example `$165M` or `$1.5B` (spec 0022, AC-16). TMDB reports both in US
+ * dollars; a 0 never reaches here, because the normalizer turns it to null.
+ */
+export function formatUsdCompact(amount: number): string {
+ return USD_COMPACT_FORMAT.format(amount);
+}
+
/** Whitespace and punctuation left dangling where a sentence was cut. */
const TRAILING_CUT = /[\s.,;:!?\-–—]+$/;
diff --git a/lib/legal/boundary.test.ts b/lib/legal/boundary.test.ts
index 6be1641..ad370f5 100644
--- a/lib/legal/boundary.test.ts
+++ b/lib/legal/boundary.test.ts
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
import {
CONTACT_EMAIL,
+ JUSTWATCH_ATTRIBUTION,
OPERATOR_COUNTRY,
OPERATOR_GITHUB_URL,
OPERATOR_HANDLE,
@@ -54,6 +55,12 @@ describe("one home for each legal fact", () => {
]);
});
+ it("writes the JustWatch credit only in lib/legal/operator.ts (spec 0022, AC-12)", () => {
+ expect(filesContaining(JUSTWATCH_ATTRIBUTION)).toEqual([
+ "lib/legal/operator.ts",
+ ]);
+ });
+
it("writes the contact email only in lib/legal/operator.ts (AC-10)", () => {
expect(filesContaining(CONTACT_EMAIL)).toEqual(["lib/legal/operator.ts"]);
});
diff --git a/lib/legal/operator.ts b/lib/legal/operator.ts
index 66de7d7..42a45a2 100644
--- a/lib/legal/operator.ts
+++ b/lib/legal/operator.ts
@@ -42,7 +42,7 @@ export const MINIMUM_AGE = 16;
* whenever the text changes materially. Pages format it in UTC and never read
* the clock (spec 0017, AC-7).
*/
-export const LEGAL_LAST_UPDATED = "2026-10-11";
+export const LEGAL_LAST_UPDATED = "2026-10-12";
/**
* Where the production Supabase project stores your data, read from
@@ -56,6 +56,13 @@ export const SUPABASE_REGION = {
name: "Frankfurt, Germany",
} as const;
+/**
+ * The credit TMDB's watch provider data asks for, shown under every Where to
+ * watch section (spec 0022, AC-12). One home, like `TMDB_ATTRIBUTION`, so the
+ * wording cannot drift between the movie and show pages.
+ */
+export const JUSTWATCH_ATTRIBUTION = "Availability data from JustWatch";
+
/** Days within which an account deletion request is completed. */
export const DELETION_DAYS = 30;
@@ -72,6 +79,8 @@ export type Processor = { name: string; purpose: string; url: string };
* Only the processors live today. Production sends no email (spec 0018), so
* neither Google nor an email provider is listed; both join with the deferred
* email delivery and Google sign in work, in the PR that turns them on.
+ * YouTube joined with the title page trailers (spec 0022, AC-19): its player
+ * loads only after you press Trailer.
*/
export const PROCESSORS: readonly Processor[] = [
{
@@ -85,4 +94,10 @@ export const PROCESSORS: readonly Processor[] = [
"Hosts the website, delivers catalog images, and keeps request logs.",
url: "https://vercel.com",
},
+ {
+ name: "YouTube",
+ purpose:
+ "Plays a title's trailer, only after you press Trailer on its page.",
+ url: "https://www.youtube.com",
+ },
];
diff --git a/lib/region.test.ts b/lib/region.test.ts
new file mode 100644
index 0000000..47c7e8e
--- /dev/null
+++ b/lib/region.test.ts
@@ -0,0 +1,44 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * covers: spec 0022, AC-9
+ *
+ * The one place the country header is read: it goes through `parseRegion`,
+ * so a real country is named and anything else falls back to the United
+ * States. `next/headers` is the boundary and is replaced; the parsing is real.
+ */
+const header = vi.fn<(name: string) => string | null>();
+
+vi.mock("next/headers", () => ({
+ headers: async () => ({ get: (name: string) => header(name) }),
+}));
+
+const { getVisitorRegion } = await import("./region");
+
+afterEach(() => {
+ header.mockReset();
+});
+
+describe("getVisitorRegion", () => {
+ it("reads Vercel's country header and names the country", async () => {
+ header.mockReturnValue("PL");
+
+ const region = await getVisitorRegion();
+
+ expect(header).toHaveBeenCalledWith("x-vercel-ip-country");
+ expect(region).toEqual({ code: "PL", name: "Poland" });
+ });
+
+ it.each<[string | null, string]>([
+ [null, "no header, as on a local run"],
+ ["XX", "Vercel's unknown country"],
+ ["pl", "a lower case code"],
+ ])("falls back to the United States for %j (%s)", async (value) => {
+ header.mockReturnValue(value);
+
+ await expect(getVisitorRegion()).resolves.toEqual({
+ code: "US",
+ name: "United States",
+ });
+ });
+});
diff --git a/lib/region.ts b/lib/region.ts
new file mode 100644
index 0000000..18caebe
--- /dev/null
+++ b/lib/region.ts
@@ -0,0 +1,21 @@
+import "server-only";
+import { headers } from "next/headers";
+import { cache } from "react";
+
+import { parseRegion, type Region } from "@/lib/catalog/region";
+
+/**
+ * The visitor's country for Where to watch and the age rating (spec 0022,
+ * AC-9), from the approximate country Vercel derives from the connection.
+ *
+ * Read once per request (React `cache()`), always outside a `use cache`
+ * scope and inside the Suspense boundary of the section that needs it, so
+ * the title pages keep their prerendered shells (AC-10). It lives outside
+ * `lib/tmdb`, which may not read request state, and the value is never
+ * stored, logged or sent to TMDB: the cached extras hold every country and
+ * the page picks this one after they return.
+ */
+export const getVisitorRegion = cache(async (): Promise => {
+ const requestHeaders = await headers();
+ return parseRegion(requestHeaders.get("x-vercel-ip-country"));
+});
diff --git a/lib/search/quick.test.ts b/lib/search/quick.test.ts
index 7dfb92c..9f00790 100644
--- a/lib/search/quick.test.ts
+++ b/lib/search/quick.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { quickSearchRequestSchema, seeAllHref, titleHref } from "./quick";
+import { quickSearchRequestSchema, seeAllHref } from "./quick";
/** covers: spec 0010, AC-2, AC-4, AC-20 */
@@ -42,13 +42,6 @@ describe("quickSearchRequestSchema", () => {
});
});
-describe("titleHref", () => {
- it("opens a movie on its movie page and a show on its show page", () => {
- expect(titleHref("movie", 438631)).toBe("/movies/438631");
- expect(titleHref("tv", 1396)).toBe("/shows/1396");
- });
-});
-
describe("seeAllHref", () => {
it("names the type and the trimmed query, in the canonical order", () => {
expect(seeAllHref("tv", " dune ")).toBe("/search?type=tv&q=dune");
diff --git a/lib/search/quick.ts b/lib/search/quick.ts
index e676280..9c23b37 100644
--- a/lib/search/quick.ts
+++ b/lib/search/quick.ts
@@ -38,11 +38,6 @@ export type QuickSearchError =
| { error: "invalid" }
| { error: "upstream"; kind: string };
-/** Where a result opens: `/shows/{id}` or `/movies/{id}`. */
-export function titleHref(type: SearchType, id: number): string {
- return type === "movie" ? `/movies/${id}` : `/shows/${id}`;
-}
-
/** The `See all` target, the results page for the same query (AC-4). */
export function seeAllHref(type: SearchType, query: string): string {
const params = new URLSearchParams({ type, q: query.trim() });
diff --git a/lib/search/read.ts b/lib/search/read.ts
index 7665a99..19338ca 100644
--- a/lib/search/read.ts
+++ b/lib/search/read.ts
@@ -1,5 +1,6 @@
import "server-only";
+import { titleHref } from "@/lib/catalog/media-type";
import {
discoverMovies,
discoverTvShows,
@@ -10,10 +11,9 @@ import {
searchTvShows,
type TvShowSummary,
} from "@/lib/tmdb";
-
import { MIN_VOTE_COUNT, QUICK_LIMIT } from "./constants";
import type { SearchParams, SearchType } from "./params";
-import { type QuickSearchResponse, titleHref } from "./quick";
+import type { QuickSearchResponse } from "./quick";
/**
* A search result from either catalog, reduced to what search needs to show
diff --git a/lib/tmdb/AGENTS.md b/lib/tmdb/AGENTS.md
index 2ac9e3c..fefe133 100644
--- a/lib/tmdb/AGENTS.md
+++ b/lib/tmdb/AGENTS.md
@@ -19,7 +19,9 @@ feature 4. Runtime steps live in that spec's `verify.md`.
app's own types), `validation.ts` (input guards), `errors.ts`, `result.ts`,
`images.ts`, `concurrency.ts`, then the reads themselves in `movies.ts`,
`tv.ts`, `search.ts`, `genres.ts`, `batch.ts`, `show-episodes.ts` and
- `lists.ts` (TMDB's trending, dated and top rated lists, spec 0021).
+ `lists.ts` (TMDB's trending, dated and top rated lists, spec 0021), and
+ `extras.ts` with `normalize-extras.ts` (a title's videos, recommendations,
+ watch providers and age ratings, spec 0022).
## Conventions this module holds to
@@ -39,6 +41,11 @@ feature 4. Runtime steps live in that spec's `verify.md`.
rest.
- **Nothing user specific enters a cached scope.** No `cookies()`, no
`headers()`, no search params in this folder.
+- **Title extras are their own read, never appended to the detail read.**
+ `getMovieExtras` and `getTvExtras` (`hours`) carry every country's watch
+ providers and age ratings, so the cache holds no request state and the page
+ picks its region afterwards. Appending them to `getTvShow` would multiply
+ their cost by every tracked show the library pages read (spec 0022).
- **No Zod schema is `.strict()`.** TMDB adds keys freely and an unknown key
must never fail a read.
- **Missing stays missing.** A null path yields a null URL, an empty string
diff --git a/lib/tmdb/__fixtures__/movie-550-extras.json b/lib/tmdb/__fixtures__/movie-550-extras.json
new file mode 100644
index 0000000..8871f9e
--- /dev/null
+++ b/lib/tmdb/__fixtures__/movie-550-extras.json
@@ -0,0 +1,528 @@
+{
+ "id": 550,
+ "videos": {
+ "results": [
+ {
+ "iso_639_1": "en",
+ "iso_3166_1": "US",
+ "name": "Yeah... no wonder this movie never won an Oscar",
+ "key": "V0Fqdb-smqo",
+ "site": "YouTube",
+ "size": 2160,
+ "type": "Featurette",
+ "official": false,
+ "id": "67c90f7a3dd7da394f24957b",
+ "published_at": "2025-02-14T14:25:16.000Z"
+ },
+ {
+ "iso_639_1": "en",
+ "iso_3166_1": "US",
+ "name": "20th Anniversary Trailer",
+ "key": "dfeUzm6KF4g",
+ "site": "YouTube",
+ "size": 1080,
+ "type": "Trailer",
+ "official": true,
+ "id": "64fb16fbdb4ed610343d72c3",
+ "published_at": "2019-10-15T18:59:47.000Z"
+ },
+ {
+ "iso_639_1": "en",
+ "iso_3166_1": "US",
+ "name": "Mark Kermode introduces Fight Club | Film4 Interview",
+ "key": "tiWCNtGDGEY",
+ "site": "YouTube",
+ "size": 480,
+ "type": "Featurette",
+ "official": true,
+ "id": "65d00d0c57176f017b8fc73d",
+ "published_at": "2015-05-08T15:59:05.000Z"
+ },
+ {
+ "iso_639_1": "en",
+ "iso_3166_1": "US",
+ "name": "Theatrical Trailer (HD Fan Remaster)",
+ "key": "6JnN1DmbqoU",
+ "site": "YouTube",
+ "size": 1080,
+ "type": "Trailer",
+ "official": false,
+ "id": "653b36ba5907de00c4953699",
+ "published_at": "2015-02-26T03:19:25.000Z"
+ },
+ {
+ "iso_639_1": "en",
+ "iso_3166_1": "US",
+ "name": "#TBT Trailer",
+ "key": "BdJKm16Co6M",
+ "site": "YouTube",
+ "size": 1080,
+ "type": "Trailer",
+ "official": true,
+ "id": "5c9294240e0a267cd516835f",
+ "published_at": "2014-10-02T19:20:22.000Z"
+ }
+ ]
+ },
+ "recommendations": {
+ "page": 1,
+ "results": [
+ {
+ "adult": false,
+ "backdrop_path": "/fAGYRJCujRBMw7TCuTp893eSG8e.jpg",
+ "id": 641,
+ "title": "Requiem for a Dream",
+ "original_title": "Requiem for a Dream",
+ "overview": "The drug-induced utopias of four Coney Island residents are shattered when their addictions run deep.",
+ "poster_path": "/9BTwsLaMVHOGFlmsSlx5QYCaXb.jpg",
+ "media_type": "movie",
+ "original_language": "en",
+ "genre_ids": [80, 18],
+ "popularity": 10.0549,
+ "release_date": "2000-10-06",
+ "softcore": false,
+ "video": false,
+ "vote_average": 8.0,
+ "vote_count": 11374
+ },
+ {
+ "adult": false,
+ "backdrop_path": "/t9kQnqiaxQ8TLB25rTzDKLd0fdM.jpg",
+ "id": 187,
+ "title": "Sin City",
+ "original_title": "Sin City",
+ "overview": "Welcome to Sin City. This town beckons to the tough, the corrupt, the brokenhearted. Some call it dark\u2026 Hard-boiled. Then there are those who call it home \u2014 Crooked cops, sexy dames, desperate vigilantes. Some are seeking revenge, others lust after redemption, and then there are those hoping for a little of both. A universe of unlikely and reluctant heroes still trying to do the right thing in a city that refuses to care.",
+ "poster_path": "/i66G50wATMmPrvpP95f0XP6ZdVS.jpg",
+ "media_type": "movie",
+ "original_language": "en",
+ "genre_ids": [80, 53],
+ "popularity": 13.222,
+ "release_date": "2005-04-01",
+ "softcore": false,
+ "video": false,
+ "vote_average": 7.463,
+ "vote_count": 9135
+ },
+ {
+ "adult": false,
+ "backdrop_path": "/oaDTXNmyqeNEEBat3h4ijiu27jv.jpg",
+ "id": 426249,
+ "title": "Lords of Chaos",
+ "original_title": "Lords of Chaos",
+ "overview": "A teenager's quest to launch Norwegian Black Metal in Oslo in the 1990s results in a very violent outcome.",
+ "poster_path": "/5jDShbwEsILXa2AAmAbOLxyNwyq.jpg",
+ "media_type": "movie",
+ "original_language": "en",
+ "genre_ids": [18, 53, 10402],
+ "popularity": 3.8842,
+ "release_date": "2018-09-20",
+ "softcore": false,
+ "video": false,
+ "vote_average": 6.786,
+ "vote_count": 585
+ }
+ ],
+ "total_pages": 17,
+ "total_results": 338
+ },
+ "watch/providers": {
+ "results": {
+ "DE": {
+ "link": "https://www.themoviedb.org/movie/550-fight-club/watch?locale=DE",
+ "buy": [
+ {
+ "logo_path": "/qdEGArH3lKfFnAtYXMkSYk5wxuG.png",
+ "provider_id": 2,
+ "provider_name": "Apple TV Store",
+ "display_priority": 5
+ },
+ {
+ "logo_path": "/jn6TLbtaTZntTRX9UYucHJvpQx1.png",
+ "provider_id": 10,
+ "provider_name": "Amazon Video",
+ "display_priority": 7
+ },
+ {
+ "logo_path": "/aZRENwYILujqs0RVOZutTh0BVGV.png",
+ "provider_id": 3,
+ "provider_name": "Google Play Movies",
+ "display_priority": 9
+ },
+ {
+ "logo_path": "/5Maob4o5w8oZnNeYpCDyVFD3M7X.png",
+ "provider_id": 192,
+ "provider_name": "YouTube",
+ "display_priority": 10
+ },
+ {
+ "logo_path": "/mjB4eyswPoC0XeJPUd1W1oitOlh.png",
+ "provider_id": 20,
+ "provider_name": "maxdome Store",
+ "display_priority": 18
+ },
+ {
+ "logo_path": "/nNkZ1JKe6vNhzti14KiKEUWHE6H.png",
+ "provider_id": 130,
+ "provider_name": "Sky Store",
+ "display_priority": 20
+ },
+ {
+ "logo_path": "/872dfVu1biZISJ8rO143CZZutPR.png",
+ "provider_id": 35,
+ "provider_name": "Rakuten TV",
+ "display_priority": 22
+ }
+ ],
+ "flatrate": [
+ {
+ "logo_path": "/rK1KljqmbvO9HQa1PBFLILWah72.png",
+ "provider_id": 8,
+ "provider_name": "Netflix",
+ "display_priority": 0
+ },
+ {
+ "logo_path": "/5eZ872CghnHFLB1j8grszbrx0dx.png",
+ "provider_id": 337,
+ "provider_name": "Disney Plus",
+ "display_priority": 3
+ },
+ {
+ "logo_path": "/eGUwOOIxcazwEBxqJpSN5sWEu6V.png",
+ "provider_id": 1796,
+ "provider_name": "Netflix Standard with Ads",
+ "display_priority": 83
+ },
+ {
+ "logo_path": "/2HcyGXRwSTh3DkgDwaF59AzACHu.png",
+ "provider_id": 2412,
+ "provider_name": "Magenta TV+",
+ "display_priority": 190
+ }
+ ],
+ "rent": [
+ {
+ "logo_path": "/qdEGArH3lKfFnAtYXMkSYk5wxuG.png",
+ "provider_id": 2,
+ "provider_name": "Apple TV Store",
+ "display_priority": 5
+ },
+ {
+ "logo_path": "/jn6TLbtaTZntTRX9UYucHJvpQx1.png",
+ "provider_id": 10,
+ "provider_name": "Amazon Video",
+ "display_priority": 7
+ },
+ {
+ "logo_path": "/aZRENwYILujqs0RVOZutTh0BVGV.png",
+ "provider_id": 3,
+ "provider_name": "Google Play Movies",
+ "display_priority": 9
+ },
+ {
+ "logo_path": "/5Maob4o5w8oZnNeYpCDyVFD3M7X.png",
+ "provider_id": 192,
+ "provider_name": "YouTube",
+ "display_priority": 10
+ },
+ {
+ "logo_path": "/mjB4eyswPoC0XeJPUd1W1oitOlh.png",
+ "provider_id": 20,
+ "provider_name": "maxdome Store",
+ "display_priority": 18
+ },
+ {
+ "logo_path": "/nNkZ1JKe6vNhzti14KiKEUWHE6H.png",
+ "provider_id": 130,
+ "provider_name": "Sky Store",
+ "display_priority": 20
+ },
+ {
+ "logo_path": "/872dfVu1biZISJ8rO143CZZutPR.png",
+ "provider_id": 35,
+ "provider_name": "Rakuten TV",
+ "display_priority": 22
+ },
+ {
+ "logo_path": "/iUGee0WhY5ZSBZZuCunNt0K7au2.png",
+ "provider_id": 2209,
+ "provider_name": "Freenet meinVOD",
+ "display_priority": 126
+ }
+ ]
+ },
+ "PL": {
+ "link": "https://www.themoviedb.org/movie/550-fight-club/watch?locale=PL",
+ "flatrate": [
+ {
+ "logo_path": "/rK1KljqmbvO9HQa1PBFLILWah72.png",
+ "provider_id": 8,
+ "provider_name": "Netflix",
+ "display_priority": 0
+ },
+ {
+ "logo_path": "/skypuy7SXuugIQeYg0IglmzoKaS.png",
+ "provider_id": 1899,
+ "provider_name": "HBO Max",
+ "display_priority": 38
+ },
+ {
+ "logo_path": "/19i0ihHvFzvK0GF5qQ6Wc4UnkOf.png",
+ "provider_id": 2214,
+ "provider_name": "CDA Premium",
+ "display_priority": 42
+ },
+ {
+ "logo_path": "/en9BC8BR1Sm0lNdKhHkw5hWqvSH.png",
+ "provider_id": 2363,
+ "provider_name": "Polsat Box Go",
+ "display_priority": 46
+ }
+ ],
+ "buy": [
+ {
+ "logo_path": "/qdEGArH3lKfFnAtYXMkSYk5wxuG.png",
+ "provider_id": 2,
+ "provider_name": "Apple TV Store",
+ "display_priority": 5
+ },
+ {
+ "logo_path": "/aZRENwYILujqs0RVOZutTh0BVGV.png",
+ "provider_id": 3,
+ "provider_name": "Google Play Movies",
+ "display_priority": 6
+ },
+ {
+ "logo_path": "/5Maob4o5w8oZnNeYpCDyVFD3M7X.png",
+ "provider_id": 192,
+ "provider_name": "YouTube",
+ "display_priority": 8
+ },
+ {
+ "logo_path": "/jn6TLbtaTZntTRX9UYucHJvpQx1.png",
+ "provider_id": 10,
+ "provider_name": "Amazon Video",
+ "display_priority": 27
+ },
+ {
+ "logo_path": "/872dfVu1biZISJ8rO143CZZutPR.png",
+ "provider_id": 35,
+ "provider_name": "Rakuten TV",
+ "display_priority": 35
+ },
+ {
+ "logo_path": "/1VxmGpd4KF1m74zYo4CXQEqk3Q2.png",
+ "provider_id": 2669,
+ "provider_name": "Pilot WP",
+ "display_priority": 63
+ }
+ ],
+ "rent": [
+ {
+ "logo_path": "/qdEGArH3lKfFnAtYXMkSYk5wxuG.png",
+ "provider_id": 2,
+ "provider_name": "Apple TV Store",
+ "display_priority": 5
+ },
+ {
+ "logo_path": "/8mtdz2Y3NW5NfiSZiBFhngFSy3S.png",
+ "provider_id": 505,
+ "provider_name": "Player",
+ "display_priority": 11
+ },
+ {
+ "logo_path": "/jn6TLbtaTZntTRX9UYucHJvpQx1.png",
+ "provider_id": 10,
+ "provider_name": "Amazon Video",
+ "display_priority": 27
+ },
+ {
+ "logo_path": "/872dfVu1biZISJ8rO143CZZutPR.png",
+ "provider_id": 35,
+ "provider_name": "Rakuten TV",
+ "display_priority": 35
+ },
+ {
+ "logo_path": "/1VxmGpd4KF1m74zYo4CXQEqk3Q2.png",
+ "provider_id": 2669,
+ "provider_name": "Pilot WP",
+ "display_priority": 63
+ }
+ ]
+ },
+ "US": {
+ "link": "https://www.themoviedb.org/movie/550-fight-club/watch?locale=US",
+ "rent": [
+ {
+ "logo_path": "/jn6TLbtaTZntTRX9UYucHJvpQx1.png",
+ "provider_id": 10,
+ "provider_name": "Amazon Video",
+ "display_priority": 8
+ },
+ {
+ "logo_path": "/qdEGArH3lKfFnAtYXMkSYk5wxuG.png",
+ "provider_id": 2,
+ "provider_name": "Apple TV Store",
+ "display_priority": 9
+ },
+ {
+ "logo_path": "/aZRENwYILujqs0RVOZutTh0BVGV.png",
+ "provider_id": 3,
+ "provider_name": "Google Play Movies",
+ "display_priority": 20
+ },
+ {
+ "logo_path": "/5Maob4o5w8oZnNeYpCDyVFD3M7X.png",
+ "provider_id": 192,
+ "provider_name": "YouTube",
+ "display_priority": 21
+ },
+ {
+ "logo_path": "/wpOt6x0dRxTrIzuBosto4LGd7E9.png",
+ "provider_id": 7,
+ "provider_name": "Fandango At Home",
+ "display_priority": 37
+ }
+ ],
+ "buy": [
+ {
+ "logo_path": "/jn6TLbtaTZntTRX9UYucHJvpQx1.png",
+ "provider_id": 10,
+ "provider_name": "Amazon Video",
+ "display_priority": 8
+ },
+ {
+ "logo_path": "/qdEGArH3lKfFnAtYXMkSYk5wxuG.png",
+ "provider_id": 2,
+ "provider_name": "Apple TV Store",
+ "display_priority": 9
+ },
+ {
+ "logo_path": "/aZRENwYILujqs0RVOZutTh0BVGV.png",
+ "provider_id": 3,
+ "provider_name": "Google Play Movies",
+ "display_priority": 20
+ },
+ {
+ "logo_path": "/5Maob4o5w8oZnNeYpCDyVFD3M7X.png",
+ "provider_id": 192,
+ "provider_name": "YouTube",
+ "display_priority": 21
+ },
+ {
+ "logo_path": "/wpOt6x0dRxTrIzuBosto4LGd7E9.png",
+ "provider_id": 7,
+ "provider_name": "Fandango At Home",
+ "display_priority": 37
+ }
+ ]
+ }
+ }
+ },
+ "release_dates": {
+ "results": [
+ {
+ "iso_3166_1": "DE",
+ "release_dates": [
+ {
+ "certification": "18",
+ "descriptors": [],
+ "iso_639_1": "",
+ "note": "",
+ "release_date": "1999-11-11T00:00:00.000Z",
+ "type": 3
+ },
+ {
+ "certification": "18",
+ "descriptors": [],
+ "iso_639_1": "",
+ "note": "4K Ultra HD Steelbook",
+ "release_date": "2026-05-29T00:00:00.000Z",
+ "type": 5
+ }
+ ]
+ },
+ {
+ "iso_3166_1": "PL",
+ "release_dates": [
+ {
+ "certification": "18",
+ "descriptors": [],
+ "iso_639_1": "",
+ "note": "",
+ "release_date": "2000-02-11T00:00:00.000Z",
+ "type": 3
+ }
+ ]
+ },
+ {
+ "iso_3166_1": "US",
+ "release_dates": [
+ {
+ "certification": "",
+ "descriptors": [],
+ "iso_639_1": "",
+ "note": "CMJ Film Festival",
+ "release_date": "1999-09-21T00:00:00.000Z",
+ "type": 1
+ },
+ {
+ "certification": "",
+ "descriptors": [],
+ "iso_639_1": "",
+ "note": "Westwood, California",
+ "release_date": "1999-10-06T00:00:00.000Z",
+ "type": 1
+ },
+ {
+ "certification": "R",
+ "descriptors": [],
+ "iso_639_1": "",
+ "note": "4K Remaster",
+ "release_date": "2026-04-22T00:00:00.000Z",
+ "type": 2
+ },
+ {
+ "certification": "R",
+ "descriptors": [],
+ "iso_639_1": "",
+ "note": "",
+ "release_date": "1999-10-15T00:00:00.000Z",
+ "type": 3
+ },
+ {
+ "certification": "R",
+ "descriptors": [],
+ "iso_639_1": "",
+ "note": "4K Digital",
+ "release_date": "2026-05-12T00:00:00.000Z",
+ "type": 4
+ },
+ {
+ "certification": "R",
+ "descriptors": [],
+ "iso_639_1": "",
+ "note": "VHS",
+ "release_date": "2000-04-25T00:00:00.000Z",
+ "type": 5
+ },
+ {
+ "certification": "R",
+ "descriptors": [],
+ "iso_639_1": "",
+ "note": "4K Ultra HD SteelBook",
+ "release_date": "2026-05-12T00:00:00.000Z",
+ "type": 5
+ },
+ {
+ "certification": "",
+ "descriptors": [],
+ "iso_639_1": "",
+ "note": "",
+ "release_date": "2026-09-08T00:00:00.000Z",
+ "type": 5
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/lib/tmdb/__fixtures__/tv-1396-extras.json b/lib/tmdb/__fixtures__/tv-1396-extras.json
new file mode 100644
index 0000000..12165d1
--- /dev/null
+++ b/lib/tmdb/__fixtures__/tv-1396-extras.json
@@ -0,0 +1,214 @@
+{
+ "id": 1396,
+ "videos": {
+ "results": [
+ {
+ "iso_639_1": "en",
+ "iso_3166_1": "US",
+ "name": "Breaking Bad - Full Series Trailer",
+ "key": "VFkjBy2b50Q",
+ "site": "YouTube",
+ "size": 1080,
+ "type": "Trailer",
+ "official": false,
+ "id": "6abec1d63b14b1600ae7400f",
+ "published_at": "2024-09-17T16:44:25.000Z"
+ }
+ ]
+ },
+ "recommendations": {
+ "page": 1,
+ "results": [
+ {
+ "adult": false,
+ "backdrop_path": "/rfxryDIv8huejujg4JueDJx8zCz.jpg",
+ "id": 60059,
+ "name": "Better Call Saul",
+ "original_name": "Better Call Saul",
+ "overview": "Six years before Saul Goodman meets Walter White. We meet him when the man who will become Saul Goodman is known as Jimmy McGill, a small-time lawyer searching for his destiny, and, more immediately, hustling to make ends meet. Working alongside, and, often, against Jimmy, is \u201cfixer\u201d Mike Ehrmantraut. The series tracks Jimmy\u2019s transformation into Saul Goodman, the man who puts \u201ccriminal\u201d in \u201ccriminal lawyer\".",
+ "poster_path": "/fC2HDm5t0kHl7mTm7jxMR31b7by.jpg",
+ "media_type": "tv",
+ "original_language": "en",
+ "genre_ids": [80, 18],
+ "popularity": 108.5734,
+ "first_air_date": "2015-02-08",
+ "softcore": false,
+ "vote_average": 8.7,
+ "vote_count": 6967,
+ "origin_country": ["US"]
+ },
+ {
+ "adult": false,
+ "backdrop_path": "/64jEzC7YeKEpnn1YwjCGRo9ZvOX.jpg",
+ "id": 80968,
+ "name": "Narcos: Mexico",
+ "original_name": "Narcos: Mexico",
+ "overview": "See the rise of the Guadalajara Cartel as an American DEA agent learns the danger of targeting narcos in 1980s Mexico.",
+ "poster_path": "/aXwMx8OvRFMfI1RmkbJm6Bq1jVg.jpg",
+ "media_type": "tv",
+ "original_language": "en",
+ "genre_ids": [18, 80],
+ "popularity": 28.9348,
+ "first_air_date": "2018-11-16",
+ "softcore": false,
+ "vote_average": 7.9,
+ "vote_count": 1549,
+ "origin_country": ["US"]
+ },
+ {
+ "adult": false,
+ "backdrop_path": "/eEXwPg7nIERmRHC1yJoBf3Q5C7k.jpg",
+ "id": 71694,
+ "name": "Snowfall",
+ "original_name": "Snowfall",
+ "overview": "Los Angeles. 1983. A storm is coming and its name is crack. Set against the infancy of the crack cocaine epidemic and its ultimate radical impact on the culture as we know it, the story follows numerous characters on a violent collision course.",
+ "poster_path": "/y7ntzl7msiaS6o7dvDbNG7XFYcR.jpg",
+ "media_type": "tv",
+ "original_language": "en",
+ "genre_ids": [18, 80],
+ "popularity": 56.4355,
+ "first_air_date": "2017-07-05",
+ "softcore": false,
+ "vote_average": 8.4,
+ "vote_count": 818,
+ "origin_country": ["US"]
+ }
+ ],
+ "total_pages": 42,
+ "total_results": 821
+ },
+ "watch/providers": {
+ "results": {
+ "DE": {
+ "link": "https://www.themoviedb.org/tv/1396-breaking-bad/watch?locale=DE",
+ "buy": [
+ {
+ "logo_path": "/qdEGArH3lKfFnAtYXMkSYk5wxuG.png",
+ "provider_id": 2,
+ "provider_name": "Apple TV Store",
+ "display_priority": 5
+ },
+ {
+ "logo_path": "/jn6TLbtaTZntTRX9UYucHJvpQx1.png",
+ "provider_id": 10,
+ "provider_name": "Amazon Video",
+ "display_priority": 7
+ },
+ {
+ "logo_path": "/f1uWfExJA6DSAvuTL0YJ9Kyftfg.png",
+ "provider_id": 178,
+ "provider_name": "MagentaTV",
+ "display_priority": 15
+ },
+ {
+ "logo_path": "/mjB4eyswPoC0XeJPUd1W1oitOlh.png",
+ "provider_id": 20,
+ "provider_name": "maxdome Store",
+ "display_priority": 18
+ },
+ {
+ "logo_path": "/kWgg0b4wHDr4pwSyOXVK8nAsNKB.png",
+ "provider_id": 1993,
+ "provider_name": "Videoload",
+ "display_priority": 103
+ }
+ ],
+ "flatrate": [
+ {
+ "logo_path": "/rK1KljqmbvO9HQa1PBFLILWah72.png",
+ "provider_id": 8,
+ "provider_name": "Netflix",
+ "display_priority": 0
+ },
+ {
+ "logo_path": "/eGUwOOIxcazwEBxqJpSN5sWEu6V.png",
+ "provider_id": 1796,
+ "provider_name": "Netflix Standard with Ads",
+ "display_priority": 83
+ }
+ ]
+ },
+ "PL": {
+ "link": "https://www.themoviedb.org/tv/1396-breaking-bad/watch?locale=PL",
+ "buy": [
+ {
+ "logo_path": "/jn6TLbtaTZntTRX9UYucHJvpQx1.png",
+ "provider_id": 10,
+ "provider_name": "Amazon Video",
+ "display_priority": 27
+ }
+ ],
+ "flatrate": [
+ {
+ "logo_path": "/rK1KljqmbvO9HQa1PBFLILWah72.png",
+ "provider_id": 8,
+ "provider_name": "Netflix",
+ "display_priority": 0
+ }
+ ]
+ },
+ "US": {
+ "link": "https://www.themoviedb.org/tv/1396-breaking-bad/watch?locale=US",
+ "buy": [
+ {
+ "logo_path": "/jn6TLbtaTZntTRX9UYucHJvpQx1.png",
+ "provider_id": 10,
+ "provider_name": "Amazon Video",
+ "display_priority": 8
+ },
+ {
+ "logo_path": "/qdEGArH3lKfFnAtYXMkSYk5wxuG.png",
+ "provider_id": 2,
+ "provider_name": "Apple TV Store",
+ "display_priority": 9
+ },
+ {
+ "logo_path": "/aZRENwYILujqs0RVOZutTh0BVGV.png",
+ "provider_id": 3,
+ "provider_name": "Google Play Movies",
+ "display_priority": 20
+ },
+ {
+ "logo_path": "/wpOt6x0dRxTrIzuBosto4LGd7E9.png",
+ "provider_id": 7,
+ "provider_name": "Fandango At Home",
+ "display_priority": 37
+ }
+ ],
+ "flatrate": [
+ {
+ "logo_path": "/rK1KljqmbvO9HQa1PBFLILWah72.png",
+ "provider_id": 8,
+ "provider_name": "Netflix",
+ "display_priority": 2
+ },
+ {
+ "logo_path": "/eGUwOOIxcazwEBxqJpSN5sWEu6V.png",
+ "provider_id": 1796,
+ "provider_name": "Netflix Standard with Ads",
+ "display_priority": 150
+ }
+ ]
+ }
+ }
+ },
+ "content_ratings": {
+ "results": [
+ {
+ "descriptors": [],
+ "iso_3166_1": "DE",
+ "rating": "16"
+ },
+ {
+ "descriptors": [],
+ "iso_3166_1": "GB",
+ "rating": "18"
+ },
+ {
+ "descriptors": [],
+ "iso_3166_1": "US",
+ "rating": "TV-MA"
+ }
+ ]
+ }
+}
diff --git a/lib/tmdb/batch.test.ts b/lib/tmdb/batch.test.ts
index e1f93e0..a7c1af4 100644
--- a/lib/tmdb/batch.test.ts
+++ b/lib/tmdb/batch.test.ts
@@ -35,6 +35,12 @@ function movie(id: number): Movie {
genres: [],
genreIds: [],
cast: [],
+ status: null,
+ budget: null,
+ revenue: null,
+ productionCompanies: [],
+ directors: [],
+ writers: [],
};
}
@@ -71,6 +77,10 @@ function show(id: number, seasonNumbers: number[]): TvShow {
})),
lastEpisodeToAir: null,
nextEpisodeToAir: null,
+ type: null,
+ createdBy: [],
+ networks: [],
+ productionCompanies: [],
};
}
diff --git a/lib/tmdb/extras.test.ts b/lib/tmdb/extras.test.ts
new file mode 100644
index 0000000..7a987e1
--- /dev/null
+++ b/lib/tmdb/extras.test.ts
@@ -0,0 +1,351 @@
+import { afterEach, describe, expect, it } from "vitest";
+
+import { installFetchMock, restoreFetchMock } from "./__fixtures__/helpers";
+import movieExtrasFixture from "./__fixtures__/movie-550-extras.json";
+import tvExtrasFixture from "./__fixtures__/tv-1396-extras.json";
+import { fetchMovieExtras, fetchTvExtras } from "./extras";
+import { PROVIDER_LOGO_SIZE } from "./images";
+import {
+ movieAgeRatings,
+ normalizeWatchProviders,
+ pickTrailer,
+ showAgeRatings,
+} from "./normalize-extras";
+
+/**
+ * covers: spec 0022, AC-8, AC-11, AC-12, AC-13, AC-14
+ *
+ * The extras read and the rules that pick one value out of many. The live
+ * fixtures (captured 2026-10-12, trimmed to three countries and three
+ * recommendations) prove the request and the shapes; the unit cases pin the
+ * edges of each choice, where a wrong branch would show another country's
+ * rating, a teaser over a trailer, or an unsafe embed key.
+ */
+
+afterEach(restoreFetchMock);
+
+function video(overrides: Record = {}) {
+ return {
+ site: "YouTube",
+ type: "Trailer",
+ key: "abcdefghijk",
+ name: "Official Trailer",
+ official: true,
+ published_at: "2020-01-01T00:00:00.000Z",
+ ...overrides,
+ };
+}
+
+function provider(id: number, name: string, priority?: number) {
+ return {
+ provider_id: id,
+ provider_name: name,
+ logo_path: `/${id}.png`,
+ display_priority: priority,
+ };
+}
+
+describe("fetchMovieExtras", () => {
+ it("asks for every extra in one request, keeping videos with no language (AC-8)", async () => {
+ const fetchMock = installFetchMock([{ body: movieExtrasFixture }]);
+
+ await fetchMovieExtras(550);
+
+ expect(fetchMock.attempts).toBe(1);
+ expect(fetchMock.url().pathname).toBe("/3/movie/550");
+ expect(fetchMock.url().searchParams.get("append_to_response")).toBe(
+ "videos,recommendations,watch/providers,release_dates",
+ );
+ expect(fetchMock.url().searchParams.get("include_video_language")).toBe(
+ "en,null",
+ );
+ // No region is sent: every country comes back (AC-9).
+ expect(fetchMock.url().searchParams.has("watch_region")).toBe(false);
+ expect(fetchMock.url().searchParams.has("region")).toBe(false);
+ });
+
+ it("normalizes the live payload for every country at once", async () => {
+ installFetchMock([{ body: movieExtrasFixture }]);
+
+ const extras = await fetchMovieExtras(550);
+
+ // The official trailer beats the newer unofficial one and the featurettes.
+ expect(extras.trailer).toEqual({
+ key: "dfeUzm6KF4g",
+ name: expect.any(String),
+ });
+ expect(Object.keys(extras.watchProviders).sort()).toEqual([
+ "DE",
+ "PL",
+ "US",
+ ]);
+ expect(extras.watchProviders.PL?.stream.map((p) => p.name)).toContain(
+ "Netflix",
+ );
+ expect(extras.watchProviders.PL?.link).toContain("locale=PL");
+ expect(extras.ageRatings).toMatchObject({ US: "R", PL: "18" });
+ expect(extras.recommendations.mediaType).toBe("movie");
+ expect(extras.recommendations.paged.results).toHaveLength(3);
+ expect(extras.recommendations.paged.results[0]).toHaveProperty("title");
+ });
+
+ it("reads a missing key as empty, never as a failure", async () => {
+ installFetchMock([{ body: { id: 1 } }]);
+
+ expect(await fetchMovieExtras(1)).toEqual({
+ trailer: null,
+ recommendations: {
+ mediaType: "movie",
+ paged: { page: 1, results: [], totalPages: 0, totalResults: 0 },
+ },
+ watchProviders: {},
+ ageRatings: {},
+ });
+ });
+
+ it("drops an adult or malformed recommendation (AC-14)", async () => {
+ const base = movieExtrasFixture.recommendations.results[0];
+ installFetchMock([
+ {
+ body: {
+ recommendations: {
+ page: 1,
+ total_pages: 1,
+ total_results: 3,
+ results: [
+ { ...base, id: 1, adult: true },
+ { id: 2 },
+ { ...base, id: 3, adult: false },
+ ],
+ },
+ },
+ },
+ ]);
+
+ const extras = await fetchMovieExtras(550);
+
+ expect(extras.recommendations.paged.results.map((m) => m.id)).toEqual([3]);
+ });
+
+ it("surfaces a 404 as not_found, for the page to show its error line", async () => {
+ installFetchMock([{ status: 404, body: {} }]);
+
+ await expect(fetchMovieExtras(550)).rejects.toMatchObject({
+ kind: "not_found",
+ });
+ });
+});
+
+describe("fetchTvExtras", () => {
+ it("appends content ratings instead of release dates (AC-8, AC-13)", async () => {
+ const fetchMock = installFetchMock([{ body: tvExtrasFixture }]);
+
+ const extras = await fetchTvExtras(1396);
+
+ expect(fetchMock.url().pathname).toBe("/3/tv/1396");
+ expect(fetchMock.url().searchParams.get("append_to_response")).toBe(
+ "videos,recommendations,watch/providers,content_ratings",
+ );
+ expect(extras.ageRatings).toMatchObject({ US: "TV-MA", DE: "16" });
+ expect(extras.recommendations.mediaType).toBe("tv");
+ expect(extras.recommendations.paged.results[0]).toHaveProperty("name");
+ expect(extras.trailer?.key).toBe("VFkjBy2b50Q");
+ });
+});
+
+describe("pickTrailer (AC-11)", () => {
+ it("prefers a trailer over a teaser, even an official teaser", () => {
+ expect(
+ pickTrailer([
+ video({ type: "Teaser", key: "teaserkey01" }),
+ video({ official: false, key: "trailerkey1" }),
+ ])?.key,
+ ).toBe("trailerkey1");
+ });
+
+ it("falls back to a teaser when there is no trailer", () => {
+ expect(
+ pickTrailer([
+ video({ type: "Featurette", key: "featurette1" }),
+ video({ type: "Teaser", key: "teaserkey01" }),
+ ])?.key,
+ ).toBe("teaserkey01");
+ });
+
+ it("prefers an official trailer, then the newest", () => {
+ expect(
+ pickTrailer([
+ video({
+ official: false,
+ key: "unofficial1",
+ published_at: "2024-01-01",
+ }),
+ video({ key: "officialold", published_at: "2010-01-01" }),
+ video({ key: "officialnew", published_at: "2015-01-01" }),
+ ])?.key,
+ ).toBe("officialnew");
+ });
+
+ it("sorts an unreadable date as the oldest", () => {
+ expect(
+ pickTrailer([
+ video({ key: "nodatekey01", published_at: "not a date" }),
+ video({ key: "datedkey001", published_at: "2001-01-01" }),
+ ])?.key,
+ ).toBe("datedkey001");
+ });
+
+ it("drops a video with an unsafe key, without hiding a good one", () => {
+ expect(
+ pickTrailer([
+ video({ key: "bad/key?x=1", published_at: "2030-01-01" }),
+ video({ key: "short" }),
+ video({ key: "goodkey0001", published_at: "2000-01-01" }),
+ ])?.key,
+ ).toBe("goodkey0001");
+ expect(pickTrailer([video({ key: "a".repeat(21) })])).toBeNull();
+ });
+
+ it("ignores videos hosted anywhere but YouTube", () => {
+ expect(pickTrailer([video({ site: "Vimeo" })])).toBeNull();
+ });
+
+ it("names an unnamed video after its type", () => {
+ expect(pickTrailer([video({ name: " " })])?.name).toBe("Trailer");
+ });
+
+ it("returns null with no videos at all", () => {
+ expect(pickTrailer(undefined)).toBeNull();
+ expect(pickTrailer([{ junk: true }])).toBeNull();
+ });
+});
+
+describe("normalizeWatchProviders (AC-12)", () => {
+ it("groups Stream, Free (free then ads) and Rent or buy (rent then buy), merged by id", () => {
+ const regions = normalizeWatchProviders({
+ PL: {
+ link: "https://www.themoviedb.org/movie/1/watch?locale=PL",
+ flatrate: [provider(8, "Netflix", 0)],
+ free: [provider(1, "Free One", 5)],
+ ads: [provider(1, "Free One", 5), provider(2, "Ads Two", 1)],
+ rent: [provider(10, "Amazon Video", 3)],
+ buy: [provider(10, "Amazon Video", 3), provider(11, "Apple TV", 2)],
+ },
+ });
+
+ expect(regions.PL?.stream.map((p) => p.name)).toEqual(["Netflix"]);
+ expect(regions.PL?.free.map((p) => p.id)).toEqual([2, 1]);
+ expect(regions.PL?.rentOrBuy.map((p) => p.id)).toEqual([11, 10]);
+ expect(regions.PL?.link).toContain("locale=PL");
+ });
+
+ it("orders by priority, then name, with no priority last", () => {
+ const regions = normalizeWatchProviders({
+ US: {
+ flatrate: [
+ provider(3, "Zeta"),
+ provider(2, "Beta", 1),
+ provider(1, "Alpha", 1),
+ ],
+ },
+ });
+
+ expect(regions.US?.stream.map((p) => p.name)).toEqual([
+ "Alpha",
+ "Beta",
+ "Zeta",
+ ]);
+ });
+
+ it("lets one provider sit in more than one group", () => {
+ const regions = normalizeWatchProviders({
+ US: {
+ flatrate: [provider(9, "Both", 1)],
+ rent: [provider(9, "Both", 1)],
+ },
+ });
+
+ expect(regions.US?.stream).toHaveLength(1);
+ expect(regions.US?.rentOrBuy).toHaveLength(1);
+ });
+
+ it("keeps a missing logo as null and drops a malformed provider", () => {
+ const regions = normalizeWatchProviders({
+ US: {
+ flatrate: [
+ { provider_id: 5, provider_name: "No Logo", logo_path: null },
+ { provider_name: "No id" },
+ provider(6, "With Logo", 1),
+ ],
+ },
+ });
+
+ expect(regions.US?.stream).toEqual([
+ {
+ id: 6,
+ name: "With Logo",
+ logoUrl: expect.stringContaining(`/${PROVIDER_LOGO_SIZE}/6.png`),
+ },
+ { id: 5, name: "No Logo", logoUrl: null },
+ ]);
+ });
+
+ it("keeps a country with no providers, with empty groups and no link", () => {
+ expect(normalizeWatchProviders({ FR: {} })).toEqual({
+ FR: { link: null, stream: [], free: [], rentOrBuy: [] },
+ });
+ });
+});
+
+describe("movieAgeRatings (AC-13)", () => {
+ it("takes the theatrical certification first", () => {
+ expect(
+ movieAgeRatings([
+ {
+ iso_3166_1: "US",
+ release_dates: [
+ { certification: "PG", type: 1 },
+ { certification: "R", type: 3 },
+ ],
+ },
+ ]),
+ ).toEqual({ US: "R" });
+ });
+
+ it("falls back to the first non empty certification in TMDB's order", () => {
+ expect(
+ movieAgeRatings([
+ {
+ iso_3166_1: "DE",
+ release_dates: [
+ { certification: "", type: 3 },
+ { certification: " ", type: 1 },
+ { certification: " 16 ", type: 4 },
+ { certification: "12", type: 5 },
+ ],
+ },
+ ]),
+ ).toEqual({ DE: "16" });
+ });
+
+ it("leaves out a country with no certification at all", () => {
+ expect(
+ movieAgeRatings([
+ { iso_3166_1: "FR", release_dates: [{ certification: "", type: 3 }] },
+ { junk: true },
+ ]),
+ ).toEqual({});
+ });
+});
+
+describe("showAgeRatings (AC-13)", () => {
+ it("keeps each country's rating, trimmed, and leaves out an empty one", () => {
+ expect(
+ showAgeRatings([
+ { iso_3166_1: "US", rating: " TV-MA " },
+ { iso_3166_1: "PL", rating: "" },
+ { iso_3166_1: "GB" },
+ ]),
+ ).toEqual({ US: "TV-MA" });
+ });
+});
diff --git a/lib/tmdb/extras.ts b/lib/tmdb/extras.ts
new file mode 100644
index 0000000..b2b1edf
--- /dev/null
+++ b/lib/tmdb/extras.ts
@@ -0,0 +1,65 @@
+import "server-only";
+import type { z } from "zod";
+import { tmdbRequest } from "./client";
+import { normalizeMovieExtras, normalizeTvExtras } from "./normalize-extras";
+import { movieExtrasSchema, tvExtrasSchema } from "./schemas";
+import type { MovieExtras, TvExtras } from "./types";
+import { assertId } from "./validation";
+
+/**
+ * The title page extras: trailer, recommendations, watch providers and age
+ * ratings, in one request per title (spec 0022, AC-8). The one request both
+ * `fetchMovieExtras` and `fetchTvExtras` make; they differ only in the path
+ * and in where TMDB keeps the age ratings.
+ *
+ * A separate read from `fetchMovie` and `fetchTvShow` on purpose: the
+ * library pages read those for up to 500 titles, and none of them needs any
+ * of this, so they keep their size and their lifetime. The details TMDB sends
+ * back alongside the appended keys are ignored; the page reads them from the
+ * detail reads (AC-16).
+ *
+ * `include_video_language=en,null` keeps videos with no language, which
+ * `buildUrl`'s `language=en-US` would otherwise filter out. No region is
+ * sent: every country comes back, and the page picks its visitor's after the
+ * cache returns (AC-9).
+ */
+async function requestExtras(
+ endpoint: string,
+ ageRatingsKey: "release_dates" | "content_ratings",
+ schema: z.ZodType,
+): Promise {
+ return tmdbRequest(
+ endpoint,
+ {
+ append_to_response: `videos,recommendations,watch/providers,${ageRatingsKey}`,
+ include_video_language: "en,null",
+ },
+ schema,
+ );
+}
+
+/**
+ * Reads a movie's extras, uncached. The inner half of `getMovieExtras`.
+ *
+ * @param id TMDB movie id.
+ * @throws {TmdbError} `not_found` when TMDB has no such movie.
+ */
+export async function fetchMovieExtras(id: number): Promise {
+ const endpoint = `/movie/${id}`;
+ assertId(id, endpoint);
+ const raw = await requestExtras(endpoint, "release_dates", movieExtrasSchema);
+ return normalizeMovieExtras(raw);
+}
+
+/**
+ * Reads a show's extras, uncached. The inner half of `getTvExtras`.
+ *
+ * @param id TMDB show id.
+ * @throws {TmdbError} `not_found` when TMDB has no such show.
+ */
+export async function fetchTvExtras(id: number): Promise {
+ const endpoint = `/tv/${id}`;
+ assertId(id, endpoint);
+ const raw = await requestExtras(endpoint, "content_ratings", tvExtrasSchema);
+ return normalizeTvExtras(raw);
+}
diff --git a/lib/tmdb/images.ts b/lib/tmdb/images.ts
index 01edfee..b77e945 100644
--- a/lib/tmdb/images.ts
+++ b/lib/tmdb/images.ts
@@ -29,6 +29,10 @@ export const POSTER_SIZE: TmdbImageSize = "w500";
export const BACKDROP_SIZE: TmdbImageSize = "w1280";
export const PROFILE_SIZE: TmdbImageSize = "w185";
export const STILL_SIZE: TmdbImageSize = "w300";
+/** A company or network logo in the Facts panel (spec 0022, AC-16). */
+export const COMPANY_LOGO_SIZE: TmdbImageSize = "w154";
+/** A watch provider logo (spec 0022, AC-12). */
+export const PROVIDER_LOGO_SIZE: TmdbImageSize = "w92";
/**
* Builds an absolute TMDB image URL, or null when there is no image.
diff --git a/lib/tmdb/index.ts b/lib/tmdb/index.ts
index e6b279d..9cd0b7b 100644
--- a/lib/tmdb/index.ts
+++ b/lib/tmdb/index.ts
@@ -23,6 +23,7 @@ export {
discoverMovies,
discoverTvShows,
getMovie,
+ getMovieExtras,
getMovieGenres,
getMovieList,
getMovieSummaries,
@@ -31,6 +32,7 @@ export {
getSeason,
getShowCast,
getShowEpisodes,
+ getTvExtras,
getTvGenres,
getTvList,
getTvShow,
@@ -42,21 +44,27 @@ export {
export type {
BatchResult,
CastMember,
+ Company,
DiscoverOptions,
Episode,
EpisodeToAir,
Genre,
ListOptions,
Movie,
+ MovieExtras,
MovieListKind,
MovieSummary,
Paged,
+ Provider,
+ RegionProviders,
SearchOptions,
SeasonDetail,
SeasonSummary,
ShowCastMember,
ShowEpisodes,
TmdbShowStatus,
+ Trailer,
+ TvExtras,
TvListKind,
TvShow,
TvShowSummary,
diff --git a/lib/tmdb/normalize-extras.ts b/lib/tmdb/normalize-extras.ts
new file mode 100644
index 0000000..c7b9a7b
--- /dev/null
+++ b/lib/tmdb/normalize-extras.ts
@@ -0,0 +1,234 @@
+import { imageUrl, PROVIDER_LOGO_SIZE } from "./images";
+import { normalizeMovieSummary, normalizeTvSummary } from "./normalize";
+import {
+ contentRatingSchema,
+ movieListItemSchema,
+ type RawMovieExtras,
+ type RawTvExtras,
+ regionProvidersSchema,
+ releaseDatesCountrySchema,
+ tvListItemSchema,
+ videoSchema,
+ watchProviderSchema,
+} from "./schemas";
+import type {
+ MovieExtras,
+ MovieSummary,
+ Paged,
+ Provider,
+ RegionProviders,
+ Trailer,
+ TvExtras,
+ TvShowSummary,
+} from "./types";
+
+/**
+ * The appended extras of a title page turned into the app's shapes
+ * (spec 0022, AC-11 to AC-14).
+ *
+ * Every rule that picks one value out of many (the trailer, a provider's
+ * group, a country's certification) runs here, once per cached read, and for
+ * every country at once. The page only reads its visitor's entry after the
+ * cache returns, so nothing request specific enters the cached value
+ * (AC-9). Missing stays missing: an empty group, an empty certification or an
+ * unusable video is left out, never filled.
+ */
+
+/** A YouTube key the embed URL can carry safely (AC-11). */
+const YOUTUBE_KEY = /^[A-Za-z0-9_-]{6,20}$/;
+
+/**
+ * The one trailer a title page offers (AC-11): a YouTube `Trailer`, else a
+ * YouTube `Teaser`; within the type, an official video first, then the newest
+ * `published_at`. A video with an unusable key is dropped before choosing, so
+ * a bad key never hides a good one; with none left there is no trailer.
+ */
+export function pickTrailer(
+ items: unknown[] | null | undefined,
+): Trailer | null {
+ const videos = [];
+ for (const item of items ?? []) {
+ const parsed = videoSchema.safeParse(item);
+ if (!parsed.success) continue;
+ const video = parsed.data;
+ if (video.site !== "YouTube" || !YOUTUBE_KEY.test(video.key)) continue;
+ videos.push(video);
+ }
+
+ for (const type of ["Trailer", "Teaser"]) {
+ const candidates = videos
+ .filter((video) => video.type === type)
+ .sort(
+ (a, b) =>
+ Number(b.official === true) - Number(a.official === true) ||
+ publishedAt(b.published_at) - publishedAt(a.published_at),
+ );
+ const chosen = candidates[0];
+ if (chosen !== undefined) {
+ return { key: chosen.key, name: chosen.name?.trim() || type };
+ }
+ }
+ return null;
+}
+
+/** `Date.parse` of `published_at`; a missing or unreadable date sorts oldest. */
+function publishedAt(value: string | null | undefined): number {
+ const time = value ? Date.parse(value) : Number.NaN;
+ return Number.isNaN(time) ? Number.NEGATIVE_INFINITY : time;
+}
+
+/**
+ * One group's providers, merged in the order given by provider id (the first
+ * entry wins), then ordered by `display_priority` and name (AC-12). A
+ * provider with no priority sorts after those with one.
+ */
+function providerGroup(...lists: (unknown[] | null | undefined)[]): Provider[] {
+ const byId = new Map();
+ for (const list of lists) {
+ for (const item of list ?? []) {
+ const parsed = watchProviderSchema.safeParse(item);
+ if (!parsed.success) continue;
+ const provider = parsed.data;
+ const name = provider.provider_name.trim();
+ if (name === "" || byId.has(provider.provider_id)) continue;
+ byId.set(provider.provider_id, {
+ id: provider.provider_id,
+ name,
+ logoUrl: imageUrl(provider.logo_path, PROVIDER_LOGO_SIZE),
+ priority: provider.display_priority ?? Number.POSITIVE_INFINITY,
+ });
+ }
+ }
+ return [...byId.values()]
+ .sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
+ .map(({ priority: _, ...provider }) => provider);
+}
+
+/**
+ * Every country's providers, grouped into Stream, Free and Rent or buy
+ * (AC-12). A country whose entry does not parse is dropped; a country with
+ * no provider in any group is kept with empty groups, so the page can still
+ * link to TMDB's watch page there.
+ */
+export function normalizeWatchProviders(
+ results: Record | null | undefined,
+): Record {
+ const regions: Record = {};
+ for (const [code, entry] of Object.entries(results ?? {})) {
+ const parsed = regionProvidersSchema.safeParse(entry);
+ if (!parsed.success) continue;
+ const region = parsed.data;
+ regions[code] = {
+ link: region.link?.trim() || null,
+ stream: providerGroup(region.flatrate),
+ free: providerGroup(region.free, region.ads),
+ rentOrBuy: providerGroup(region.rent, region.buy),
+ };
+ }
+ return regions;
+}
+
+/** TMDB's release type for a theatrical release (AC-13). */
+const THEATRICAL = 3;
+
+/**
+ * A movie's certification per country (AC-13): the non empty certification
+ * of the theatrical release, else the first non empty one in TMDB's array
+ * order. A country with none is left out, so the page never borrows another
+ * country's rating.
+ */
+export function movieAgeRatings(
+ items: unknown[] | null | undefined,
+): Record {
+ const ratings: Record = {};
+ for (const item of items ?? []) {
+ const parsed = releaseDatesCountrySchema.safeParse(item);
+ if (!parsed.success) continue;
+ const dates = parsed.data.release_dates.map((date) => ({
+ type: date.type,
+ certification: date.certification?.trim() ?? "",
+ }));
+ const chosen =
+ dates.find((date) => date.type === THEATRICAL && date.certification) ??
+ dates.find((date) => date.certification);
+ if (chosen) ratings[parsed.data.iso_3166_1] = chosen.certification;
+ }
+ return ratings;
+}
+
+/** A show's `content_ratings` per country, empty ratings left out (AC-13). */
+export function showAgeRatings(
+ items: unknown[] | null | undefined,
+): Record {
+ const ratings: Record = {};
+ for (const item of items ?? []) {
+ const parsed = contentRatingSchema.safeParse(item);
+ if (!parsed.success) continue;
+ const rating = parsed.data.rating?.trim();
+ if (rating) ratings[parsed.data.iso_3166_1] = rating;
+ }
+ return ratings;
+}
+
+type RawRecommendations = RawMovieExtras["recommendations"];
+
+/**
+ * Page 1 of recommendations through the list item schemas, so an adult
+ * title is dropped exactly as on the spec 0021 lists (AC-14), along with any
+ * malformed result. The totals pass through; a missing envelope is an empty
+ * page.
+ */
+function recommendationsPage(
+ raw: RawRecommendations,
+ parse: (item: unknown) => Summary | null,
+): Paged {
+ const results: Summary[] = [];
+ for (const item of raw?.results ?? []) {
+ const summary = parse(item);
+ if (summary !== null) results.push(summary);
+ }
+ return {
+ page: raw?.page ?? 1,
+ results,
+ totalPages: raw?.total_pages ?? 0,
+ totalResults: raw?.total_results ?? 0,
+ };
+}
+
+function movieRecommendation(item: unknown): MovieSummary | null {
+ const parsed = movieListItemSchema.safeParse(item);
+ if (!parsed.success || parsed.data.adult === true) return null;
+ return normalizeMovieSummary(parsed.data);
+}
+
+function tvRecommendation(item: unknown): TvShowSummary | null {
+ const parsed = tvListItemSchema.safeParse(item);
+ if (!parsed.success || parsed.data.adult === true) return null;
+ return normalizeTvSummary(parsed.data);
+}
+
+/** A movie's extras, every country at once (AC-8). */
+export function normalizeMovieExtras(raw: RawMovieExtras): MovieExtras {
+ return {
+ trailer: pickTrailer(raw.videos?.results),
+ recommendations: {
+ mediaType: "movie",
+ paged: recommendationsPage(raw.recommendations, movieRecommendation),
+ },
+ watchProviders: normalizeWatchProviders(raw["watch/providers"]?.results),
+ ageRatings: movieAgeRatings(raw.release_dates?.results),
+ };
+}
+
+/** A show's extras, every country at once (AC-8). */
+export function normalizeTvExtras(raw: RawTvExtras): TvExtras {
+ return {
+ trailer: pickTrailer(raw.videos?.results),
+ recommendations: {
+ mediaType: "tv",
+ paged: recommendationsPage(raw.recommendations, tvRecommendation),
+ },
+ watchProviders: normalizeWatchProviders(raw["watch/providers"]?.results),
+ ageRatings: showAgeRatings(raw.content_ratings?.results),
+ };
+}
diff --git a/lib/tmdb/normalize.test.ts b/lib/tmdb/normalize.test.ts
index 39269ac..7d21f6b 100644
--- a/lib/tmdb/normalize.test.ts
+++ b/lib/tmdb/normalize.test.ts
@@ -3,6 +3,9 @@ import { describe, expect, it } from "vitest";
import { SHOW_CAST_LIMIT } from "./constants";
import {
normalizeCast,
+ normalizeCompanies,
+ normalizeCreators,
+ normalizeCrew,
normalizeEpisodeToAir,
normalizeSeasonDetail,
normalizeSeasonSummary,
@@ -292,3 +295,71 @@ describe("normalizeEpisodeToAir", () => {
expect(normalizeEpisodeToAir(raw)).toBeNull();
});
});
+
+/** covers: spec 0022, AC-15 */
+describe("normalizeCrew", () => {
+ it("reads directors and writers, Screenplay and Writer but not Story", () => {
+ expect(
+ normalizeCrew([
+ { name: "David Fincher", job: "Director" },
+ { name: "Jim Uhls", job: "Screenplay" },
+ { name: "Chuck Palahniuk", job: "Novel" },
+ { name: "Someone", job: "Story" },
+ ]),
+ ).toEqual({ directors: ["David Fincher"], writers: ["Jim Uhls"] });
+ });
+
+ it("deduplicates writers in credit order, then caps them at 3", () => {
+ expect(
+ normalizeCrew([
+ { name: "A", job: "Writer" },
+ { name: "A", job: "Screenplay" },
+ { name: "B", job: "Screenplay" },
+ { name: "C", job: "Writer" },
+ { name: "D", job: "Writer" },
+ ]).writers,
+ ).toEqual(["A", "B", "C"]);
+ });
+
+ it("drops a malformed credit and has no names with no crew", () => {
+ expect(normalizeCrew([{ job: "Director" }, null])).toEqual({
+ directors: [],
+ writers: [],
+ });
+ expect(normalizeCrew(undefined)).toEqual({ directors: [], writers: [] });
+ });
+});
+
+/** covers: spec 0022, AC-15 */
+describe("normalizeCreators", () => {
+ it("keeps up to 3 names, deduplicated, dropping a malformed one", () => {
+ expect(
+ normalizeCreators([
+ { name: "A" },
+ { name: "A" },
+ {},
+ { name: "B" },
+ { name: "C" },
+ { name: "D" },
+ ]),
+ ).toEqual(["A", "B", "C"]);
+ });
+});
+
+/** covers: spec 0022, AC-16 */
+describe("normalizeCompanies", () => {
+ it("keeps a missing logo null and drops a malformed or unnamed entry", () => {
+ const companies = normalizeCompanies([
+ { id: 1, name: "AMC", logo_path: "/amc.png" },
+ { id: 2, name: "No Logo", logo_path: null },
+ { id: 3, name: " " },
+ { name: "No id" },
+ ]);
+ expect(companies.map((company) => company.name)).toEqual([
+ "AMC",
+ "No Logo",
+ ]);
+ expect(companies[0]?.logoUrl).toContain("/w154/amc.png");
+ expect(companies[1]?.logoUrl).toBeNull();
+ });
+});
diff --git a/lib/tmdb/normalize.ts b/lib/tmdb/normalize.ts
index 7b6aa13..878d4a2 100644
--- a/lib/tmdb/normalize.ts
+++ b/lib/tmdb/normalize.ts
@@ -1,8 +1,11 @@
+import type { z } from "zod";
+
import { parseTmdbDate } from "@/lib/format";
import { SHOW_CAST_LIMIT } from "./constants";
import {
BACKDROP_SIZE,
+ COMPANY_LOGO_SIZE,
imageUrl,
POSTER_SIZE,
PROFILE_SIZE,
@@ -11,6 +14,9 @@ import {
import {
aggregateCastMemberSchema,
castMemberSchema,
+ companySchema,
+ creatorSchema,
+ crewMemberSchema,
episodeToAirSchema,
type RawEpisode,
type RawMovie,
@@ -23,6 +29,7 @@ import {
} from "./schemas";
import type {
CastMember,
+ Company,
Episode,
EpisodeToAir,
Genre,
@@ -111,6 +118,87 @@ export function normalizeCast(
return cast.sort((a, b) => a.order - b.order);
}
+/** A money amount TMDB reports as 0 when it does not know it, so 0 is null. */
+function moneyOrNull(value: number | null | undefined): number | null {
+ return typeof value === "number" && value > 0 ? value : null;
+}
+
+/**
+ * The items of a TMDB list that parse and carry a name, each with that name
+ * trimmed, in TMDB's order. Companies, crew and creators all drop a bad entry
+ * this way rather than failing the read (spec 0022, AC-15, AC-16), as a cast
+ * credit is dropped.
+ */
+function namedItems(
+ items: unknown[] | null | undefined,
+ schema: z.ZodType,
+): Array<{ data: T; name: string }> {
+ const named: Array<{ data: T; name: string }> = [];
+ for (const item of items ?? []) {
+ const parsed = schema.safeParse(item);
+ if (!parsed.success) continue;
+ const name = textOrNull(parsed.data.name);
+ if (name !== null) named.push({ data: parsed.data, name });
+ }
+ return named;
+}
+
+/**
+ * Production companies or networks, dropping any entry that does not parse
+ * (spec 0022, AC-16), in TMDB's order.
+ */
+export function normalizeCompanies(
+ items: unknown[] | null | undefined,
+): Company[] {
+ return namedItems(items, companySchema).map(({ data, name }) => ({
+ id: data.id,
+ name,
+ logoUrl: imageUrl(data.logo_path, COMPANY_LOGO_SIZE),
+ }));
+}
+
+/** Most writers a movie page names (spec 0022, AC-15). */
+const WRITER_LIMIT = 3;
+
+/** Most show creators a show page names (spec 0022, AC-15). */
+const CREATOR_LIMIT = 3;
+
+/** The crew jobs that count as writing. `Story` is left out on purpose. */
+const WRITER_JOBS: ReadonlySet = new Set(["Screenplay", "Writer"]);
+
+/**
+ * A movie's directors and writers from the crew `credits` already carries,
+ * so the crew lines cost no request (spec 0022, AC-15). Names are
+ * deduplicated in credit order, because one person often holds both
+ * `Screenplay` and `Writer`; writers are cut to 3 after that. A malformed
+ * credit is dropped, like a cast credit.
+ */
+export function normalizeCrew(items: unknown[] | null | undefined): {
+ directors: string[];
+ writers: string[];
+} {
+ const directors = new Set();
+ const writers = new Set();
+ for (const { data, name } of namedItems(items, crewMemberSchema)) {
+ if (data.job === "Director") directors.add(name);
+ if (data.job != null && WRITER_JOBS.has(data.job)) writers.add(name);
+ }
+ return {
+ directors: [...directors],
+ writers: [...writers].slice(0, WRITER_LIMIT),
+ };
+}
+
+/** A show's `created_by` names, deduplicated and capped at 3 (AC-15). */
+export function normalizeCreators(
+ items: unknown[] | null | undefined,
+): string[] {
+ const names = new Set(
+ namedItems(items, creatorSchema).map(({ name }) => name),
+ );
+ return [...names].slice(0, CREATOR_LIMIT);
+}
+
export function normalizeMovieSummary(raw: RawMovieSummary): MovieSummary {
const releaseDate = textOrNull(raw.release_date);
return {
@@ -174,6 +262,11 @@ export function normalizeMovie(raw: RawMovie): Movie {
// the list the page shows rather than staying empty.
genreIds: normalizeGenres(raw.genres).map((genre) => genre.id),
cast: normalizeCast(raw.credits?.cast),
+ ...normalizeCrew(raw.credits?.crew),
+ status: textOrNull(raw.status),
+ budget: moneyOrNull(raw.budget),
+ revenue: moneyOrNull(raw.revenue),
+ productionCompanies: normalizeCompanies(raw.production_companies),
};
}
@@ -254,6 +347,10 @@ export function normalizeTvShow(raw: RawTvShow): TvShow {
seasons: (raw.seasons ?? []).map(normalizeSeasonSummary),
lastEpisodeToAir: normalizeEpisodeToAir(raw.last_episode_to_air),
nextEpisodeToAir: normalizeEpisodeToAir(raw.next_episode_to_air),
+ type: textOrNull(raw.type),
+ createdBy: normalizeCreators(raw.created_by),
+ networks: normalizeCompanies(raw.networks),
+ productionCompanies: normalizeCompanies(raw.production_companies),
};
}
diff --git a/lib/tmdb/reads.test.ts b/lib/tmdb/reads.test.ts
index ec0c090..9faf183 100644
--- a/lib/tmdb/reads.test.ts
+++ b/lib/tmdb/reads.test.ts
@@ -52,6 +52,28 @@ describe("fetchMovie", () => {
expect(movie.cast[0]?.order).toBe(0);
});
+ it("parses the facts and crew from the same response · covers spec 0022 AC-15, AC-16", async () => {
+ installFetchMock([{ body: movieFixture }]);
+
+ const movie = await fetchMovie(550);
+
+ expect(movie.status).toBe("Released");
+ expect(movie.budget).toBe(63000000);
+ expect(movie.productionCompanies.length).toBeGreaterThan(0);
+ // The fixture's trimmed crew holds only the novel's author.
+ expect(movie.directors).toEqual([]);
+ expect(movie.writers).toEqual([]);
+ });
+
+ it("reports a zero budget and revenue as missing · covers spec 0022 AC-16", async () => {
+ installFetchMock([{ body: { ...movieFixture, budget: 0, revenue: 0 } }]);
+
+ const movie = await fetchMovie(550);
+
+ expect(movie.budget).toBeNull();
+ expect(movie.revenue).toBeNull();
+ });
+
it("builds every image URL with its pinned default size", async () => {
installFetchMock([{ body: movieFixture }]);
@@ -288,6 +310,19 @@ describe("fetchTvShow", () => {
});
/** covers: spec 0009, AC-8 */
+describe("fetchTvShow facts · covers spec 0022 AC-15, AC-16", () => {
+ it("reads the type, creators, networks and companies with no extra request", async () => {
+ const fetchMock = installFetchMock([{ body: tvFixture }]);
+
+ const show = await fetchTvShow(1396);
+
+ expect(fetchMock.attempts).toBe(1);
+ expect(show.type).toBe("Scripted");
+ expect(show.createdBy).toEqual(["Vince Gilligan"]);
+ expect(show.networks.map((network) => network.name)).toEqual(["AMC"]);
+ });
+});
+
describe("fetchShowCast", () => {
it("reads the aggregate credits and keeps the most episodes first, capped at 12", async () => {
const fetchMock = installFetchMock([{ body: aggregateFixture }]);
diff --git a/lib/tmdb/reads.ts b/lib/tmdb/reads.ts
index 211df2a..103d875 100644
--- a/lib/tmdb/reads.ts
+++ b/lib/tmdb/reads.ts
@@ -6,6 +6,7 @@ import {
readEachSettled,
type SettledBatch,
} from "./batch";
+import { fetchMovieExtras, fetchTvExtras } from "./extras";
import { fetchMovieGenres, fetchTvGenres } from "./genres";
import { fetchMovieList, fetchTvList } from "./lists";
import { fetchMovie } from "./movies";
@@ -30,6 +31,7 @@ import type {
Genre,
ListOptions,
Movie,
+ MovieExtras,
MovieListKind,
MovieSummary,
Paged,
@@ -37,6 +39,7 @@ import type {
SeasonDetail,
ShowCastMember,
ShowEpisodes,
+ TvExtras,
TvListKind,
TvShow,
TvShowSummary,
@@ -127,6 +130,59 @@ export async function getTvShow(id: number): Promise {
return unwrap(await getTvShowCached(id));
}
+/**
+ * A title page's extras (spec 0022, AC-8): `hours`, because watch providers
+ * and recommendations move within a day, and one entry per title shared by
+ * every visitor, since it holds every country and no request state. Only the
+ * title pages read it; the library and catalog grid reads never do.
+ */
+async function getMovieExtrasCached(
+ id: number,
+): Promise> {
+ "use cache";
+ try {
+ const value = await fetchMovieExtras(id);
+ cacheLife("hours");
+ return { ok: true, value };
+ } catch (error) {
+ const failure = toFailure(error);
+ cacheFailure(failure);
+ return { ok: false, failure };
+ }
+}
+
+/**
+ * A movie's trailer, recommendations, watch providers and age ratings.
+ *
+ * @throws {TmdbError} Rebuilt outside the cached scope from the stored failure.
+ */
+export async function getMovieExtras(id: number): Promise {
+ return unwrap(await getMovieExtrasCached(id));
+}
+
+/** The show twin of `getMovieExtrasCached`, with the same lifetime. */
+async function getTvExtrasCached(id: number): Promise> {
+ "use cache";
+ try {
+ const value = await fetchTvExtras(id);
+ cacheLife("hours");
+ return { ok: true, value };
+ } catch (error) {
+ const failure = toFailure(error);
+ cacheFailure(failure);
+ return { ok: false, failure };
+ }
+}
+
+/**
+ * A show's trailer, recommendations, watch providers and age ratings.
+ *
+ * @throws {TmdbError} Rebuilt outside the cached scope from the stored failure.
+ */
+export async function getTvExtras(id: number): Promise {
+ return unwrap(await getTvExtrasCached(id));
+}
+
/** A series cast changes rarely, so it keeps the detail profile (AC-20). */
async function getShowCastCached(
id: number,
diff --git a/lib/tmdb/schemas.ts b/lib/tmdb/schemas.ts
index 783538c..85cbab2 100644
--- a/lib/tmdb/schemas.ts
+++ b/lib/tmdb/schemas.ts
@@ -39,10 +39,37 @@ export const castMemberSchema = z.object({
order: z.number().nullish(),
});
+/**
+ * One crew credit, read for a movie's directors and writers (spec 0022,
+ * AC-15). Parsed per item like a cast credit.
+ */
+export const crewMemberSchema = z.object({
+ name: z.string(),
+ job: z.string().nullish(),
+});
+
+/**
+ * A production company or a network (spec 0022, AC-16). Parsed per item, so
+ * one malformed entry drops that entry rather than the title.
+ */
+export const companySchema = z.object({
+ id: z.number(),
+ name: z.string(),
+ logo_path: nullableString,
+});
+
+/**
+ * A show creator from `created_by` (spec 0022, AC-15). Parsed per item.
+ */
+export const creatorSchema = z.object({
+ name: z.string(),
+});
+
/** Credits arrive as unknown items so one bad credit can be dropped by hand. */
const creditsSchema = z
.object({
cast: z.array(z.unknown()).nullish(),
+ crew: z.array(z.unknown()).nullish(),
})
.nullish();
@@ -87,6 +114,10 @@ export const movieSchema = movieSummarySchema.extend({
genres: z.array(genreSchema).nullish(),
credits: creditsSchema,
translations: translationsSchema,
+ status: nullableString,
+ budget: nullableNumber,
+ revenue: nullableNumber,
+ production_companies: z.array(z.unknown()).nullish(),
});
export const tvSummarySchema = z.object({
@@ -136,6 +167,10 @@ export const tvShowSchema = tvSummarySchema.extend({
genres: z.array(genreSchema).nullish(),
translations: translationsSchema,
seasons: z.array(seasonSummarySchema).nullish(),
+ type: nullableString,
+ created_by: z.array(z.unknown()).nullish(),
+ networks: z.array(z.unknown()).nullish(),
+ production_companies: z.array(z.unknown()).nullish(),
/**
* Left unparsed here and checked one by one in the normalizer, so a
* malformed value is read as null rather than failing the whole show
@@ -207,6 +242,96 @@ export const seasonDetailSchema = z.object({
episodes: z.array(episodeSchema),
});
+/**
+ * One video from the appended `videos` (spec 0022, AC-11). Parsed per item,
+ * so one malformed video is dropped rather than the extras.
+ */
+export const videoSchema = z.object({
+ site: z.string(),
+ type: z.string(),
+ key: z.string(),
+ name: nullableString,
+ official: z.boolean().nullish(),
+ published_at: nullableString,
+});
+
+/** One watch provider in a region's group (spec 0022, AC-12). */
+export const watchProviderSchema = z.object({
+ provider_id: z.number(),
+ provider_name: z.string(),
+ logo_path: nullableString,
+ display_priority: nullableNumber,
+});
+
+/**
+ * One region's entry in `watch/providers.results`. The groups stay unknown
+ * items so one malformed provider is dropped by hand.
+ */
+export const regionProvidersSchema = z.object({
+ link: nullableString,
+ flatrate: z.array(z.unknown()).nullish(),
+ free: z.array(z.unknown()).nullish(),
+ ads: z.array(z.unknown()).nullish(),
+ rent: z.array(z.unknown()).nullish(),
+ buy: z.array(z.unknown()).nullish(),
+});
+
+/** One country of a movie's `release_dates` (spec 0022, AC-13). */
+export const releaseDatesCountrySchema = z.object({
+ iso_3166_1: z.string(),
+ release_dates: z.array(
+ z.object({
+ certification: nullableString,
+ type: nullableNumber,
+ }),
+ ),
+});
+
+/** One country of a show's `content_ratings` (spec 0022, AC-13). */
+export const contentRatingSchema = z.object({
+ iso_3166_1: z.string(),
+ rating: nullableString,
+});
+
+/**
+ * The appended keys both extras reads share. Every list is unknown items,
+ * checked one by one in the normalizer, so a malformed video, provider,
+ * country or recommendation drops that entry and never the whole read; a
+ * missing key reads as empty.
+ */
+const extrasSchema = z.object({
+ videos: z.object({ results: z.array(z.unknown()).nullish() }).nullish(),
+ recommendations: z
+ .object({
+ page: nullableNumber,
+ results: z.array(z.unknown()).nullish(),
+ total_pages: nullableNumber,
+ total_results: nullableNumber,
+ })
+ .nullish(),
+ "watch/providers": z
+ .object({ results: z.record(z.string(), z.unknown()).nullish() })
+ .nullish(),
+});
+
+/**
+ * `/movie/{id}` with the extras appended (spec 0022, AC-8). Only the
+ * appended keys are read: the details in the same response are ignored, and
+ * the page takes them from `getMovie` (AC-16).
+ */
+export const movieExtrasSchema = extrasSchema.extend({
+ release_dates: z
+ .object({ results: z.array(z.unknown()).nullish() })
+ .nullish(),
+});
+
+/** The show twin of `movieExtrasSchema`, with `content_ratings`. */
+export const tvExtrasSchema = extrasSchema.extend({
+ content_ratings: z
+ .object({ results: z.array(z.unknown()).nullish() })
+ .nullish(),
+});
+
/**
* TMDB's pagination envelope. `page`, `total_pages` and `total_results` are
* returned to callers unchanged and never recomputed (spec 0002, AC-18).
@@ -228,3 +353,5 @@ export type RawTvSummary = z.infer;
export type RawSeasonSummary = z.infer;
export type RawSeasonDetail = z.infer;
export type RawEpisode = z.infer;
+export type RawMovieExtras = z.infer;
+export type RawTvExtras = z.infer;
diff --git a/lib/tmdb/types.ts b/lib/tmdb/types.ts
index f9fee79..ad642ab 100644
--- a/lib/tmdb/types.ts
+++ b/lib/tmdb/types.ts
@@ -89,6 +89,20 @@ export type Movie = Omit & {
runtimeMinutes: number | null;
genres: Genre[];
cast: CastMember[];
+ /** TMDB's English status word as is, for example `Released` (spec 0022, AC-16). */
+ status: string | null;
+ /** US dollars; null when TMDB reports 0 or nothing. */
+ budget: number | null;
+ /** US dollars; null when TMDB reports 0 or nothing. */
+ revenue: number | null;
+ productionCompanies: Company[];
+ /** Crew job `Director`, in credit order, deduplicated (spec 0022, AC-15). */
+ directors: string[];
+ /**
+ * Crew jobs `Screenplay` and `Writer` (not `Story`), deduplicated in credit
+ * order, then capped at 3 (spec 0022, AC-15).
+ */
+ writers: string[];
};
export type TvShowSummary = {
@@ -164,8 +178,80 @@ export type TvShow = Omit & {
lastEpisodeToAir: EpisodeToAir | null;
/** The next episode TMDB has announced, dated or not. */
nextEpisodeToAir: EpisodeToAir | null;
+ /** TMDB's English type word as is, for example `Miniseries` (spec 0022, AC-16). */
+ type: string | null;
+ /** `created_by` names, at most 3 (spec 0022, AC-15). */
+ createdBy: string[];
+ networks: Company[];
+ productionCompanies: Company[];
+};
+
+/** A production company or a network (spec 0022, AC-16). */
+export type Company = {
+ id: number;
+ name: string;
+ /** `w154`, or null when TMDB has no logo, so the name shows instead. */
+ logoUrl: string | null;
+};
+
+/** The one trailer a title page offers (spec 0022, AC-11). */
+export type Trailer = {
+ /** A YouTube video key, already checked against `[A-Za-z0-9_-]{6,20}`. */
+ key: string;
+ name: string;
+};
+
+/** One watch provider (spec 0022, AC-12). */
+export type Provider = {
+ id: number;
+ name: string;
+ /** `w92`, or null when TMDB has no logo, so the name shows instead. */
+ logoUrl: string | null;
};
+/**
+ * One country's watch providers, already grouped, deduplicated and ordered
+ * (spec 0022, AC-12). A provider may sit in more than one group.
+ */
+export type RegionProviders = {
+ /** TMDB's watch page for the title in this country. */
+ link: string | null;
+ /** `flatrate`. */
+ stream: Provider[];
+ /** `free` then `ads`, merged by provider id. */
+ free: Provider[];
+ /** `rent` then `buy`, merged by provider id. */
+ rentOrBuy: Provider[];
+};
+
+/**
+ * The extras of one title, every country at once (spec 0022, AC-8, AC-9).
+ * Region maps are keyed by ISO 3166-1 code; the page reads its visitor's
+ * entry after the cached read returns, so no request state enters the cache.
+ */
+type TitleExtras = {
+ trailer: Trailer | null;
+ recommendations: Recommendations;
+ watchProviders: Record;
+ /** The certification already picked per country by the AC-13 rule. */
+ ageRatings: Record;
+};
+
+/**
+ * Page 1 of a movie's recommendations, in the tagged shape `CatalogListCards`
+ * renders (spec 0022, AC-14), adult titles already dropped.
+ */
+export type MovieExtras = TitleExtras<{
+ mediaType: "movie";
+ paged: Paged;
+}>;
+
+/** The show twin of `MovieExtras`. */
+export type TvExtras = TitleExtras<{
+ mediaType: "tv";
+ paged: Paged;
+}>;
+
export type Episode = {
id: number;
/** Injected from the request argument; TMDB's season payload omits it. */