From b097954aa2f979690f0b541a1dafda5d52339b6a Mon Sep 17 00:00:00 2001 From: Ismael Leon Date: Thu, 10 Sep 2026 21:50:35 -0600 Subject: [PATCH 1/2] refactor: close the structural findings from the architecture review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four of the five that were left open in #81. The fifth is comment density, which is a house-style question and does not belong in the same commit as a set of mechanical moves. The e2e suite could run against somebody else's application. It listened on 5173 — the port every Vite project takes by default — and `reuseExistingServer` adopted whatever was already there without checking whose it was. That is how seventeen tests once failed against a stranger's HTML, pointing at robots.txt and security headers rather than at anything real; the dangerous direction is the other one, where a reused server running stale code reports green. It now has a port nothing else defaults to, and `--strictPort` so Vite cannot answer a busy port by quietly moving to another one. Six exports were only ever used inside their own module and are now private. Each was checked against its spec first: several siblings are public precisely so they can be unit-tested, and those stay. Two components were typed against the database schema. The import was type-only, so nothing ever shipped to the browser, but it pointed presentation at the persistence shape — adding a column changed the type a card saw. They now take `SavedTitle`, which the domain owns: what the rules need, plus the three things only a rendering cares about. The database row satisfies it structurally, so nothing converts anything, and the card's own test fixture lost `userId`, `overview` and `addedAt` — three fields it never used and should never have been asked for. `watchlistActions` was one 485-line object with four reasons to change. Split into the titles, the links that publish them, and the account's own settings, spread back into one action map so the route contract is untouched: every one of the 55 action tests passes unedited. `tmdb.ts` was 612 lines and six endpoint families whose only genuinely shared parts are the fetch and two raw response shapes. Those are now `client`, and search, seasons, details, recommendations, trending and people each have their own file behind an index that re-exports the same public surface. No import anywhere else changed. --- playwright.config.ts | 27 +- .../components/media/ContinueWatching.svelte | 8 +- src/lib/components/media/WatchlistCard.svelte | 4 +- .../media/WatchlistCard.svelte.spec.ts | 9 +- src/lib/domain/deletion.ts | 2 +- src/lib/domain/progress.ts | 2 +- src/lib/domain/recommendations.ts | 6 +- src/lib/domain/share.ts | 2 +- src/lib/domain/watchlist.ts | 23 + src/lib/server/oauth.ts | 2 +- src/lib/server/tmdb.ts | 612 ------------------ src/lib/server/tmdb/client.ts | 84 +++ src/lib/server/tmdb/details.ts | 175 +++++ src/lib/server/tmdb/index.ts | 15 + src/lib/server/tmdb/person.ts | 89 +++ src/lib/server/tmdb/recommendations.ts | 31 + src/lib/server/tmdb/search.ts | 86 +++ src/lib/server/tmdb/seasons.ts | 136 ++++ src/lib/server/tmdb/trending.ts | 45 ++ src/lib/server/watchlist/actions/index.ts | 22 + .../watchlist/{actions.ts => actions/list.ts} | 181 +----- src/lib/server/watchlist/actions/settings.ts | 75 +++ src/lib/server/watchlist/actions/shared.ts | 23 + src/lib/server/watchlist/actions/sharing.ts | 90 +++ 24 files changed, 945 insertions(+), 804 deletions(-) delete mode 100644 src/lib/server/tmdb.ts create mode 100644 src/lib/server/tmdb/client.ts create mode 100644 src/lib/server/tmdb/details.ts create mode 100644 src/lib/server/tmdb/index.ts create mode 100644 src/lib/server/tmdb/person.ts create mode 100644 src/lib/server/tmdb/recommendations.ts create mode 100644 src/lib/server/tmdb/search.ts create mode 100644 src/lib/server/tmdb/seasons.ts create mode 100644 src/lib/server/tmdb/trending.ts create mode 100644 src/lib/server/watchlist/actions/index.ts rename src/lib/server/watchlist/{actions.ts => actions/list.ts} (62%) create mode 100644 src/lib/server/watchlist/actions/settings.ts create mode 100644 src/lib/server/watchlist/actions/shared.ts create mode 100644 src/lib/server/watchlist/actions/sharing.ts diff --git a/playwright.config.ts b/playwright.config.ts index ee01dfd..1745d0a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,12 +5,33 @@ import { defineConfig } from '@playwright/test'; * database). Because it needs those secrets, e2e is a local/manual step and is * intentionally not part of the CI workflow. */ + +/** + * A port of this suite's own, deliberately not Vite's default. + * + * On 5173 the suite once ran end to end against somebody else's application: + * another Vite project had taken the default port first, `reuseExistingServer` + * adopted it without checking whose it was, and all seventeen tests failed + * against a stranger's HTML — including the ones about `robots.txt` and + * security headers, which pointed nowhere near the truth. The dangerous + * direction is the other one: a reused server running stale code can report + * green for a failure that is really there. + * + * A port nothing else defaults to is what removes that collision. `--strictPort` + * closes the second half of it — without it Vite answers a busy port by quietly + * moving to the next one, and the suite would then be pointed at nothing. + */ +const PORT = 5273; + export default defineConfig({ testMatch: '**/*.e2e.{ts,js}', - use: { baseURL: 'http://localhost:5173' }, + use: { baseURL: `http://localhost:${PORT}` }, webServer: { - command: 'npm run dev', - port: 5173, + command: `npm run dev -- --port ${PORT} --strictPort`, + port: PORT, + // Kept for local re-runs, which are otherwise a fresh server every time. + // It is safe here in a way it was not on 5173: nothing but this suite ever + // listens on this port, so the server being reused is always our own. reuseExistingServer: !process.env.CI } }); diff --git a/src/lib/components/media/ContinueWatching.svelte b/src/lib/components/media/ContinueWatching.svelte index 49c6353..ba6e9a2 100644 --- a/src/lib/components/media/ContinueWatching.svelte +++ b/src/lib/components/media/ContinueWatching.svelte @@ -6,7 +6,7 @@ import { getSeasonProgress } from '$lib/domain/progress'; import { progressNote } from '$lib/domain/episodes'; import { posterUrl } from '$lib/format/tmdb-image'; - import type { WatchlistItem } from '$lib/server/db/schema'; + import type { SavedTitle } from '$lib/domain/watchlist'; /** * "What was I in the middle of?" — the first question anyone opens a watchlist @@ -19,9 +19,9 @@ * row of vertical space. */ interface Props { - items: WatchlistItem[]; - onSelect: (item: WatchlistItem) => void; - onSetSeasons: (item: WatchlistItem) => SubmitFunction; + items: SavedTitle[]; + onSelect: (item: SavedTitle) => void; + onSetSeasons: (item: SavedTitle) => SubmitFunction; } let { items, onSelect, onSetSeasons }: Props = $props(); diff --git a/src/lib/components/media/WatchlistCard.svelte b/src/lib/components/media/WatchlistCard.svelte index 48773dc..2f18231 100644 --- a/src/lib/components/media/WatchlistCard.svelte +++ b/src/lib/components/media/WatchlistCard.svelte @@ -12,7 +12,7 @@ shouldWarnAboutDeletion, type DeletionWindow } from '$lib/domain/deletion'; - import type { WatchlistItem } from '$lib/server/db/schema'; + import type { SavedTitle } from '$lib/domain/watchlist'; /** * A saved title with its controls. @@ -21,7 +21,7 @@ * completing the tracker is what marks them as watched. */ interface Props { - item: WatchlistItem; + item: SavedTitle; /** Forwarded to the poster; set for the tiles above the fold. */ priority?: boolean; /** The account's auto-delete window, or null when the feature is off. */ diff --git a/src/lib/components/media/WatchlistCard.svelte.spec.ts b/src/lib/components/media/WatchlistCard.svelte.spec.ts index 8d7bf8d..386eb82 100644 --- a/src/lib/components/media/WatchlistCard.svelte.spec.ts +++ b/src/lib/components/media/WatchlistCard.svelte.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; import WatchlistCard from './WatchlistCard.svelte'; -import type { WatchlistItem } from '$lib/server/db/schema'; +import type { SavedTitle } from '$lib/domain/watchlist'; /** * Which control a saved title gets, and whether what replaces it fits. @@ -23,16 +23,14 @@ const TILE_WIDTH = 155; */ const LONGEST = { mediaType: 'tv' as const, releaseDate: '2099-09-30' }; -function item(over: Partial = {}): WatchlistItem { +function item(over: Partial = {}): SavedTitle { return { id: 'item-1', - userId: 'user-1', tmdbId: 1, mediaType: 'movie', title: 'Interstellar', posterPath: null, releaseDate: '2014-11-05', - overview: null, voteAverage: 8.5, watched: false, seasonsSeen: 0, @@ -42,12 +40,11 @@ function item(over: Partial = {}): WatchlistItem { nextSeasonNumber: null, nextSeasonAirDate: null, watchedAt: null, - addedAt: new Date(), ...over }; } -function card(over: Partial = {}) { +function card(over: Partial = {}) { const container = document.createElement('div'); container.style.width = `${TILE_WIDTH}px`; document.body.appendChild(container); diff --git a/src/lib/domain/deletion.ts b/src/lib/domain/deletion.ts index b21a50e..e3115d4 100644 --- a/src/lib/domain/deletion.ts +++ b/src/lib/domain/deletion.ts @@ -92,7 +92,7 @@ export function isDueForDeletion( * read. A week is long enough to notice and act, and acting is one tap: the * warning carries the button that resets the clock. */ -export const DELETION_WARNING_DAYS = 7; +const DELETION_WARNING_DAYS = 7; export function shouldWarnAboutDeletion( entry: DeletableEntry, diff --git a/src/lib/domain/progress.ts b/src/lib/domain/progress.ts index 9336454..4a04203 100644 --- a/src/lib/domain/progress.ts +++ b/src/lib/domain/progress.ts @@ -65,7 +65,7 @@ export interface SeasonProgress { * (an entry saved before air dates were tracked). That is the pre-existing * behaviour, and the read-path backfill replaces it on the next visit. */ -export function watchableSeasons(entry: TrackableEntry): number | null { +function watchableSeasons(entry: TrackableEntry): number | null { return entry.airedSeasons ?? entry.totalSeasons; } diff --git a/src/lib/domain/recommendations.ts b/src/lib/domain/recommendations.ts index 5c6e495..50062d2 100644 --- a/src/lib/domain/recommendations.ts +++ b/src/lib/domain/recommendations.ts @@ -41,10 +41,10 @@ export interface RecommendationRail { } /** Requested in parallel, so the third costs no wall-clock time — it is a spare. */ -export const MAX_SEEDS = 3; +const MAX_SEEDS = 3; /** How many rows to render. Two is enough to be useful without burying trending. */ -export const MAX_RAILS = 2; +const MAX_RAILS = 2; /** Below this a row looks like a mistake rather than a suggestion. */ export const MIN_RAIL_ITEMS = 4; @@ -56,7 +56,7 @@ export const MIN_RAIL_ITEMS = 4; * its suggestions — so the tail was the weakest half of an already secondary * section. */ -export const MAX_RAIL_ITEMS = 8; +const MAX_RAIL_ITEMS = 8; /** * Whether a title says anything about taste. Saving is a guess; watching is a diff --git a/src/lib/domain/share.ts b/src/lib/domain/share.ts index 552bad7..49a4fc0 100644 --- a/src/lib/domain/share.ts +++ b/src/lib/domain/share.ts @@ -9,7 +9,7 @@ */ /** The parts of a list somebody might be willing to publish. */ -export const SHARE_SCOPES = ['toWatch', 'watched', 'both'] as const; +const SHARE_SCOPES = ['toWatch', 'watched', 'both'] as const; export type ShareScope = (typeof SHARE_SCOPES)[number]; diff --git a/src/lib/domain/watchlist.ts b/src/lib/domain/watchlist.ts index e52e0a9..2645053 100644 --- a/src/lib/domain/watchlist.ts +++ b/src/lib/domain/watchlist.ts @@ -25,6 +25,29 @@ export interface WatchlistEntry { nextSeasonAirDate: string | null; } +/** + * A saved title as the interface renders one. + * + * `WatchlistEntry` is what the *rules* need — enough to filter, sort and reason + * about a title. A card needs that plus the three things only a rendering cares + * about: which row it is, what to draw, and when it was finished so the + * deletion countdown can be worked out. + * + * It exists so components can stop importing `WatchlistItem` from the database + * schema. That import was type-only, so nothing ever shipped to the browser, + * but it pointed presentation at the persistence shape: adding a column changed + * the type a card saw, and a card has no business knowing a column exists. The + * database row satisfies this structurally, so nothing has to convert anything. + */ +export interface SavedTitle extends WatchlistEntry { + id: string; + /** TMDB's identity, which is what opening or linking to the title needs. */ + tmdbId: number; + posterPath: string | null; + /** When it became watched; the clock the deletion warning counts down. */ + watchedAt: Date | null; +} + /** Current view options coming from the UI controls. */ export interface WatchlistView { /** 'all' | 'toWatch' | 'inProgress' | 'upcoming' | 'watched' */ diff --git a/src/lib/server/oauth.ts b/src/lib/server/oauth.ts index 1bee75c..90687f4 100644 --- a/src/lib/server/oauth.ts +++ b/src/lib/server/oauth.ts @@ -13,7 +13,7 @@ import { env } from '$env/dynamic/private'; export const GOOGLE_SCOPES = ['openid', 'profile', 'email']; /** Path Google redirects back to. Must be registered in the Google Console. */ -export const CALLBACK_PATH = '/auth/google/callback'; +const CALLBACK_PATH = '/auth/google/callback'; /** * Short-lived cookies that carry the handshake across the redirect to Google: diff --git a/src/lib/server/tmdb.ts b/src/lib/server/tmdb.ts deleted file mode 100644 index 224a9f7..0000000 --- a/src/lib/server/tmdb.ts +++ /dev/null @@ -1,612 +0,0 @@ -import { env } from '$env/dynamic/private'; -import { - PEOPLE_RESULTS_SIZE, - PERSON_CREDITS_SIZE, - rankCredits, - rankPeople, - type CreditCandidate, - type PersonCandidate -} from '$lib/domain/filmography'; -import type { - Episode, - MediaDetails, - MediaResult, - MediaType, - PersonCredit, - PersonFilmography, - PersonResult, - SeasonEpisodes, - UpcomingSeason, - WatchOptions, - WatchProvider -} from '$lib/types'; - -/** - * Server-only TMDB client. This module lives under `$lib/server`, so SvelteKit - * guarantees it can never be bundled into client code — the access token stays - * on the server at all times. - */ - -const TMDB_BASE_URL = 'https://api.themoviedb.org/3'; - -/** Raw TMDB item. Movies expose `title`/`release_date`; TV uses `name`/`first_air_date`. */ -interface TmdbRawResult { - id: number; - media_type?: string; - title?: string; - name?: string; - poster_path?: string | null; - release_date?: string; - first_air_date?: string; - overview?: string; - vote_average?: number; -} - -interface TmdbPaginatedResponse { - results: TmdbRawResult[]; - page?: number; - total_pages?: number; -} - -/** - * Perform an authenticated GET against the TMDB API and parse the JSON body. - * - * `cacheSeconds` hands the response to Cloudflare's edge cache. It is keyed by - * URL and holds nothing user-specific — every visitor asking about the same - * title gets the same answer — so the saving is shared rather than per-session. - * `cacheEverything` is required because the request carries an `Authorization` - * header, which the edge otherwise treats as a reason never to cache; the header - * is our own server token, not the visitor's, so it identifies nobody. - * - * The option is Cloudflare-only and simply ignored elsewhere, which is what - * keeps local development and the tests on live data. - */ -async function tmdbFetch( - path: string, - params: Record = {}, - cacheSeconds = 0 -): Promise { - const token = env.TMDB_ACCESS_TOKEN; - if (!token) throw new Error('TMDB_ACCESS_TOKEN is not set'); - - const url = new URL(`${TMDB_BASE_URL}${path}`); - for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value); - - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - accept: 'application/json' - }, - ...(cacheSeconds > 0 && { cf: { cacheTtl: cacheSeconds, cacheEverything: true } }) - }); - - if (!response.ok) { - throw new Error(`TMDB request failed with status ${response.status}`); - } - - return response.json() as Promise; -} - -/** Convert a raw TMDB item into our normalized, media-type-agnostic shape. */ -function normalize(raw: TmdbRawResult, mediaType: MediaType): MediaResult { - return { - tmdbId: raw.id, - mediaType, - title: raw.title ?? raw.name ?? 'Untitled', - posterPath: raw.poster_path ?? null, - releaseDate: raw.release_date ?? raw.first_air_date ?? null, - overview: raw.overview ?? null, - voteAverage: raw.vote_average ?? null - }; -} - -/** Person entries in a multi-search, which carry their best-known titles inline. */ -interface TmdbPersonSearchRaw { - id: number; - media_type?: string; - name?: string; - profile_path?: string | null; - known_for_department?: string; - popularity?: number; - known_for?: TmdbRawResult[]; -} - -/** How many titles a person's search entry names to tell them apart by. */ -const KNOWN_FOR_TITLES = 2; - -/** Both halves of what a search can match. */ -export interface SearchResults { - titles: MediaResult[]; - people: PersonResult[]; -} - -/** - * Search movies, TV shows and people in a single request via TMDB multi-search. - * - * People used to be discarded here, which made the app searchable only by title - * — the one thing you cannot do is look up the actor whose name you remember - * when the film's has gone. They come back from the same response as the titles, - * so answering both halves of the question costs no extra request. - * - * `known_for` rides along on each person entry, which is what lets the strip say - * *which* Chris Evans this is without a second lookup per face. - */ -export async function searchMulti(query: string): Promise { - const data = await tmdbFetch( - '/search/multi', - { - query, - include_adult: 'false', - language: 'en-US', - page: '1' - } - ); - - const raw = data.results as (TmdbRawResult & TmdbPersonSearchRaw)[]; - - const titles = raw - .filter( - (item): item is TmdbRawResult & { media_type: MediaType } => - item.media_type === 'movie' || item.media_type === 'tv' - ) - .map((item) => normalize(item, item.media_type)); - - const people = raw - .filter((item) => item.media_type === 'person') - .map((item): PersonResult & PersonCandidate => ({ - id: item.id, - name: item.name?.trim() || 'Unknown', - profilePath: item.profile_path ?? null, - knownFor: item.known_for_department?.trim() || null, - knownForTitles: (item.known_for ?? []) - .map((credit) => credit.title ?? credit.name ?? '') - .filter(Boolean) - .slice(0, KNOWN_FOR_TITLES), - popularity: item.popularity ?? 0 - })); - - return { - titles, - // The ranking score is dropped rather than sent on: the order is the - // answer, and the number behind it is not the browser's business. - people: rankPeople(people, PEOPLE_RESULTS_SIZE).map( - ({ id, name, profilePath, knownFor, knownForTitles }) => ({ - id, - name, - profilePath, - knownFor, - knownForTitles - }) - ) - }; -} - -/** Extra fields returned by the single-title details endpoint. */ -interface TmdbGenre { - name: string; -} -interface TmdbCastRaw { - id: number; - name: string; - character?: string; - profile_path?: string | null; -} -interface TmdbVideoRaw { - site: string; - type: string; - key: string; -} -interface TmdbSeasonRaw { - season_number: number; - air_date?: string | null; - episode_count?: number; -} -interface TmdbProviderRaw { - provider_id: number; - provider_name: string; - logo_path?: string | null; - display_priority?: number; -} -interface TmdbProviderCountryRaw { - link?: string; - flatrate?: TmdbProviderRaw[]; - free?: TmdbProviderRaw[]; - ads?: TmdbProviderRaw[]; - rent?: TmdbProviderRaw[]; - buy?: TmdbProviderRaw[]; -} -interface TmdbEpisodeRaw { - episode_number: number; - name?: string; - air_date?: string | null; - runtime?: number | null; -} -interface TmdbSeasonDetailRaw { - season_number?: number; - episodes?: TmdbEpisodeRaw[]; -} -interface TmdbDetailsRaw extends TmdbRawResult { - vote_count?: number; - genres?: TmdbGenre[]; - runtime?: number; - episode_run_time?: number[]; - tagline?: string; - number_of_seasons?: number; - seasons?: TmdbSeasonRaw[]; - status?: string; - backdrop_path?: string | null; - credits?: { cast?: TmdbCastRaw[] }; - videos?: { results?: TmdbVideoRaw[] }; - 'watch/providers'?: { results?: Record }; - // Populated by `append_to_response=season/N`, keyed by that same string. - [appendedSeason: `season/${number}`]: TmdbSeasonDetailRaw | undefined; -} - -/** - * Whether a `YYYY-MM-DD` date has arrived, compared as calendar days in UTC. - * - * Shared by seasons and episodes so both answer "is this out yet?" the same way - * — a premiere on the 12th is out on the 12th regardless of the viewer's zone. - */ -function hasAired(date: string | null | undefined, now: Date): boolean { - if (!date) return false; - const parsed = Date.parse(`${date}T00:00:00Z`); - const todayUtc = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); - return Number.isFinite(parsed) && parsed <= todayUtc; -} - -/** - * Normalize one season's episode list. - * - * The overview and still image of every episode are dropped: they multiply the - * payload several times over for something no view renders, and this response is - * cached at the edge for every visitor. - */ -export function normalizeSeasonEpisodes( - raw: TmdbSeasonDetailRaw | undefined, - seasonNumber: number, - now: Date = new Date() -): SeasonEpisodes | null { - const list = raw?.episodes; - if (!list || list.length === 0) return null; - - const episodes: Episode[] = list - .filter((episode) => Number.isInteger(episode.episode_number) && episode.episode_number >= 1) - .sort((a, b) => a.episode_number - b.episode_number) - .map((episode) => ({ - number: episode.episode_number, - name: episode.name?.trim() || `Episode ${episode.episode_number}`, - airDate: episode.air_date ?? null, - runtimeMinutes: episode.runtime ?? null, - aired: hasAired(episode.air_date, now) - })); - - if (episodes.length === 0) return null; - - return { - seasonNumber, - episodes, - airedCount: episodes.filter((episode) => episode.aired).length - }; -} - -/** What the season list tells us once unaired seasons are separated out. */ -export interface SeasonBreakdown { - /** Every season TMDB lists, aired or not. */ - totalSeasons: number | null; - /** Seasons that have actually premiered — the ceiling for progress. */ - airedSeasons: number | null; - upcomingSeason: UpcomingSeason | null; - /** Episodes per numbered season, so rollover needs no extra request. */ - episodeCounts: Record; -} - -/** - * Split a show's seasons into "already premiered" and "still to come". - * - * TMDB's `number_of_seasons` counts announced seasons, which is what makes a - * show with three aired seasons and a fourth dated for next year look fully - * watchable today. Season 0 ("Specials") is excluded throughout: it is not part - * of the numbered run and counting it would shift every season by one. - * - * A season with no `air_date` is treated as *not* aired. TMDB leaves the date - * empty for seasons that are announced but unscheduled, and guessing "aired" - * there would recreate the exact bug this exists to prevent. - */ -export function splitSeasons( - seasons: TmdbSeasonRaw[] | undefined, - now: Date = new Date() -): SeasonBreakdown { - const numbered = (seasons ?? []) - .filter((season) => Number.isInteger(season.season_number) && season.season_number >= 1) - .sort((a, b) => a.season_number - b.season_number); - - if (numbered.length === 0) - return { totalSeasons: null, airedSeasons: null, upcomingSeason: null, episodeCounts: {} }; - - const premiered = (season: TmdbSeasonRaw) => hasAired(season.air_date, now); - const aired = numbered.filter(premiered); - const next = numbered.find((season) => !premiered(season)); - - const episodeCounts: Record = {}; - for (const season of numbered) { - if (typeof season.episode_count === 'number' && season.episode_count > 0) { - episodeCounts[season.season_number] = season.episode_count; - } - } - - return { - totalSeasons: numbered.length, - airedSeasons: aired.length, - upcomingSeason: next ? { number: next.season_number, airDate: next.air_date ?? null } : null, - episodeCounts - }; -} - -/** - * Normalize TMDB's per-country watch providers. - * - * "Free with ads" is folded into `free` because the distinction between TMDB's - * `free` and `ads` buckets is not one anybody is making when they ask where to - * watch something. Providers are ordered by TMDB's `display_priority`, which - * reflects how prominent the service is in that country. - */ -function normalizeWatchOptions( - raw: Record | undefined, - country: string -): WatchOptions | null { - const entry = raw?.[country]; - if (!entry) return null; - - const map = (providers: TmdbProviderRaw[] | undefined): WatchProvider[] => - [...(providers ?? [])] - .sort((a, b) => (a.display_priority ?? 99) - (b.display_priority ?? 99)) - .map((provider) => ({ - id: provider.provider_id, - name: provider.provider_name, - logoPath: provider.logo_path ?? null - })); - - const options: WatchOptions = { - country, - stream: map(entry.flatrate), - free: [...map(entry.free), ...map(entry.ads)], - rent: map(entry.rent), - buy: map(entry.buy), - link: entry.link ?? null - }; - - // A country can be listed with a link but no actual offers; that is not an - // answer worth rendering a section for. - const hasAny = - options.stream.length + options.free.length + options.rent.length + options.buy.length > 0; - return hasAny ? options : null; -} - -/** - * Fetch rich details for a single movie/TV title, including credits (cast) and - * videos (trailer) in one request via `append_to_response`. - */ -export async function getDetails( - mediaType: MediaType, - id: number, - country = 'US', - season: number | null = null -): Promise { - /** - * The requested season's episodes ride along on the same request via - * `append_to_response`, so showing where you are in a series costs nothing - * beyond the details call the sheet already makes. - */ - const wantsSeason = mediaType === 'tv' && Number.isInteger(season) && (season as number) >= 1; - const appended = ['credits', 'videos', 'watch/providers']; - if (wantsSeason) appended.push(`season/${season}`); - - const raw = await tmdbFetch(`/${mediaType}/${id}`, { - language: 'en-US', - append_to_response: appended.join(',') - }); - - const seasons = mediaType === 'tv' ? splitSeasons(raw.seasons) : null; - const videos = raw.videos?.results ?? []; - const trailer = - videos.find((v) => v.site === 'YouTube' && v.type === 'Trailer') ?? - videos.find((v) => v.site === 'YouTube'); - - return { - tmdbId: raw.id, - mediaType, - title: raw.title ?? raw.name ?? 'Untitled', - overview: raw.overview ?? null, - tagline: raw.tagline?.trim() || null, - genres: (raw.genres ?? []).map((genre) => genre.name), - releaseDate: raw.release_date ?? raw.first_air_date ?? null, - runtimeMinutes: raw.runtime ?? raw.episode_run_time?.[0] ?? null, - // `number_of_seasons` is the fallback only when the season list is missing; - // the list is the authority because it is the one that carries air dates. - seasons: seasons?.totalSeasons ?? raw.number_of_seasons ?? null, - airedSeasons: seasons?.airedSeasons ?? null, - upcomingSeason: seasons?.upcomingSeason ?? null, - episodeCounts: seasons?.episodeCounts ?? {}, - season: wantsSeason - ? normalizeSeasonEpisodes(raw[`season/${season as number}`], season as number) - : null, - productionStatus: raw.status?.trim() || null, - voteAverage: raw.vote_average ?? null, - voteCount: raw.vote_count ?? 0, - backdropPath: raw.backdrop_path ?? null, - posterPath: raw.poster_path ?? null, - cast: (raw.credits?.cast ?? []).slice(0, 12).map((member) => ({ - id: member.id, - name: member.name, - character: member.character ?? '', - profilePath: member.profile_path ?? null - })), - trailerKey: trailer?.key ?? null, - watch: normalizeWatchOptions(raw['watch/providers']?.results, country) - }; -} - -/** - * Titles TMDB considers close to one already on the list. - * - * This is the same "what should I watch" question trending answers, asked of a - * far better source: the list itself. Trending is identical for every visitor, - * so it is the one part of Discover that can never get more relevant the longer - * somebody uses the app. - * - * The endpoint is per-title and one page deep — twenty suggestions is already - * more than a rail shows, and which titles are worth asking about is a decision - * that belongs to `domain/recommendations`, not here. - */ -/** - * A day at the edge. - * - * What a title is close to does not move hour to hour, and the rows are rebuilt - * on every visit to Discover — so without this, opening the page twice costs two - * identical sets of lookups. The rows still turn over daily; that comes from - * which titles get asked about, not from asking the same one again. - */ -const RECOMMENDATION_CACHE_SECONDS = 86_400; - -export async function getRecommendations(mediaType: MediaType, id: number): Promise { - const data = await tmdbFetch( - `/${mediaType}/${id}/recommendations`, - { language: 'en-US', page: '1' }, - RECOMMENDATION_CACHE_SECONDS - ); - - // Recommendations for a film are overwhelmingly films, but TMDB does mix in - // the odd show, so the per-result type wins and the seed's is only a fallback. - return data.results.map((raw) => - normalize( - raw, - raw.media_type === 'movie' || raw.media_type === 'tv' ? raw.media_type : mediaType - ) - ); -} - -/** One page of trending titles, plus whether another page exists. */ -export interface TrendingPage { - results: MediaResult[]; - page: number; - hasMore: boolean; -} - -/** - * TMDB caps trending at 500 pages, and we stop well short of it: nobody browses - * two thousand titles, and the tail of the trending list is noise. This is a - * guard against an unbounded `?page=` in the URL, not a UX target. - */ -const MAX_TRENDING_PAGES = 25; - -/** - * Fetch a page of this week's trending movies and TV shows. Used to populate the - * home screen with content before the user has searched for anything. - * - * Pages hold 20 titles and do not overlap, so appending them is safe. `person` - * entries are filtered out, which is why a page can return fewer than 20. - */ -export async function getTrending(page = 1): Promise { - const safePage = Math.min(Math.max(Math.trunc(page) || 1, 1), MAX_TRENDING_PAGES); - - const data = await tmdbFetch('/trending/all/week', { - language: 'en-US', - page: String(safePage) - }); - - const results = data.results - .filter( - (raw): raw is TmdbRawResult & { media_type: MediaType } => - raw.media_type === 'movie' || raw.media_type === 'tv' - ) - .map((raw) => normalize(raw, raw.media_type)); - - const lastPage = Math.min(data.total_pages ?? safePage, MAX_TRENDING_PAGES); - - return { results, page: safePage, hasMore: safePage < lastPage }; -} - -/** A person's own record, and their whole career in one response. */ -interface TmdbPersonCreditRaw extends TmdbRawResult { - character?: string; - popularity?: number; - vote_count?: number; - episode_count?: number; -} - -interface TmdbPersonRaw { - id: number; - name?: string; - profile_path?: string | null; - known_for_department?: string; - combined_credits?: { cast?: TmdbPersonCreditRaw[] }; -} - -/** - * A career changes about as often as a birthday, so this caches for a day at the - * edge like recommendations do. Keyed by person id and identical for everyone. - */ -const PERSON_CACHE_SECONDS = 86_400; - -/** - * Who somebody is, and the few titles worth recognising them from. - * - * `combined_credits` is appended rather than fetched separately: the panel needs - * both halves before it can render anything, and two round trips from the worker - * to TMDB would be paid on every face a viewer taps. - * - * The ranking is deliberately not done here — see `domain/filmography` for what - * "worth recognising" means and why it is not simply the most recent five. - */ -export async function getPersonFilmography(personId: number): Promise { - const raw = await tmdbFetch( - `/person/${personId}`, - { language: 'en-US', append_to_response: 'combined_credits' }, - PERSON_CACHE_SECONDS - ); - - const candidates = (raw.combined_credits?.cast ?? []) - .filter( - (credit): credit is TmdbPersonCreditRaw & { media_type: MediaType } => - credit.media_type === 'movie' || credit.media_type === 'tv' - ) - .map((credit): PersonCredit & CreditCandidate => ({ - ...normalize(credit, credit.media_type), - character: credit.character?.trim() || null, - voteCount: credit.vote_count ?? 0, - popularity: credit.popularity ?? 0, - episodeCount: credit.media_type === 'tv' ? (credit.episode_count ?? null) : null - })); - - return { - id: raw.id, - name: raw.name?.trim() || 'Unknown', - profilePath: raw.profile_path ?? null, - knownFor: raw.known_for_department?.trim() || null, - // Ranking fields are dropped here rather than carried to the client: the - // order is the answer, and the numbers behind it are not the browser's - // business. - credits: rankCredits(candidates, PERSON_CREDITS_SIZE).map( - ({ - tmdbId, - mediaType, - title, - posterPath, - releaseDate, - overview, - voteAverage, - character - }) => ({ - tmdbId, - mediaType, - title, - posterPath, - releaseDate, - overview, - voteAverage, - character - }) - ) - }; -} diff --git a/src/lib/server/tmdb/client.ts b/src/lib/server/tmdb/client.ts new file mode 100644 index 0000000..7e9b110 --- /dev/null +++ b/src/lib/server/tmdb/client.ts @@ -0,0 +1,84 @@ +import { env } from '$env/dynamic/private'; +import type { MediaResult, MediaType } from '$lib/types'; + +/** + * The one way this codebase talks to TMDB, and the two shapes every endpoint + * answers in. + * + * Server-only: this module lives under `$lib/server`, so SvelteKit guarantees + * it can never be bundled into client code — the access token stays on the + * server at all times. + */ + +const TMDB_BASE_URL = 'https://api.themoviedb.org/3'; + +/** Raw TMDB item. Movies expose `title`/`release_date`; TV uses `name`/`first_air_date`. */ +export interface TmdbRawResult { + id: number; + media_type?: string; + title?: string; + name?: string; + poster_path?: string | null; + release_date?: string; + first_air_date?: string; + overview?: string; + vote_average?: number; +} + +export interface TmdbPaginatedResponse { + results: TmdbRawResult[]; + page?: number; + total_pages?: number; +} + +/** + * Perform an authenticated GET against the TMDB API and parse the JSON body. + * + * `cacheSeconds` hands the response to Cloudflare's edge cache. It is keyed by + * URL and holds nothing user-specific — every visitor asking about the same + * title gets the same answer — so the saving is shared rather than per-session. + * `cacheEverything` is required because the request carries an `Authorization` + * header, which the edge otherwise treats as a reason never to cache; the header + * is our own server token, not the visitor's, so it identifies nobody. + * + * The option is Cloudflare-only and simply ignored elsewhere, which is what + * keeps local development and the tests on live data. + */ +export async function tmdbFetch( + path: string, + params: Record = {}, + cacheSeconds = 0 +): Promise { + const token = env.TMDB_ACCESS_TOKEN; + if (!token) throw new Error('TMDB_ACCESS_TOKEN is not set'); + + const url = new URL(`${TMDB_BASE_URL}${path}`); + for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value); + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + accept: 'application/json' + }, + ...(cacheSeconds > 0 && { cf: { cacheTtl: cacheSeconds, cacheEverything: true } }) + }); + + if (!response.ok) { + throw new Error(`TMDB request failed with status ${response.status}`); + } + + return response.json() as Promise; +} + +/** Convert a raw TMDB item into our normalized, media-type-agnostic shape. */ +export function normalize(raw: TmdbRawResult, mediaType: MediaType): MediaResult { + return { + tmdbId: raw.id, + mediaType, + title: raw.title ?? raw.name ?? 'Untitled', + posterPath: raw.poster_path ?? null, + releaseDate: raw.release_date ?? raw.first_air_date ?? null, + overview: raw.overview ?? null, + voteAverage: raw.vote_average ?? null + }; +} diff --git a/src/lib/server/tmdb/details.ts b/src/lib/server/tmdb/details.ts new file mode 100644 index 0000000..950d0c3 --- /dev/null +++ b/src/lib/server/tmdb/details.ts @@ -0,0 +1,175 @@ +import { tmdbFetch, type TmdbRawResult } from './client'; +import { + normalizeSeasonEpisodes, + splitSeasons, + type TmdbSeasonDetailRaw, + type TmdbSeasonRaw +} from './seasons'; +import type { MediaDetails, MediaType, WatchOptions, WatchProvider } from '$lib/types'; + +/** Everything known about one title: credits, videos and where to watch it. */ + +/** Extra fields returned by the single-title details endpoint. */ +interface TmdbGenre { + name: string; +} +interface TmdbCastRaw { + id: number; + name: string; + character?: string; + profile_path?: string | null; +} +interface TmdbVideoRaw { + site: string; + type: string; + key: string; +} + +interface TmdbProviderRaw { + provider_id: number; + provider_name: string; + logo_path?: string | null; + display_priority?: number; +} +interface TmdbProviderCountryRaw { + link?: string; + flatrate?: TmdbProviderRaw[]; + free?: TmdbProviderRaw[]; + ads?: TmdbProviderRaw[]; + rent?: TmdbProviderRaw[]; + buy?: TmdbProviderRaw[]; +} + +interface TmdbDetailsRaw extends TmdbRawResult { + vote_count?: number; + genres?: TmdbGenre[]; + runtime?: number; + episode_run_time?: number[]; + tagline?: string; + number_of_seasons?: number; + seasons?: TmdbSeasonRaw[]; + status?: string; + backdrop_path?: string | null; + credits?: { cast?: TmdbCastRaw[] }; + videos?: { results?: TmdbVideoRaw[] }; + 'watch/providers'?: { results?: Record }; + // Populated by `append_to_response=season/N`, keyed by that same string. + [appendedSeason: `season/${number}`]: TmdbSeasonDetailRaw | undefined; +} + +/** + * Normalize TMDB's per-country watch providers. + * + * "Free with ads" is folded into `free` because the distinction between TMDB's + * `free` and `ads` buckets is not one anybody is making when they ask where to + * watch something. Providers are ordered by TMDB's `display_priority`, which + * reflects how prominent the service is in that country. + */ +function normalizeWatchOptions( + raw: Record | undefined, + country: string +): WatchOptions | null { + const entry = raw?.[country]; + if (!entry) return null; + + const map = (providers: TmdbProviderRaw[] | undefined): WatchProvider[] => + [...(providers ?? [])] + .sort((a, b) => (a.display_priority ?? 99) - (b.display_priority ?? 99)) + .map((provider) => ({ + id: provider.provider_id, + name: provider.provider_name, + logoPath: provider.logo_path ?? null + })); + + const options: WatchOptions = { + country, + stream: map(entry.flatrate), + free: [...map(entry.free), ...map(entry.ads)], + rent: map(entry.rent), + buy: map(entry.buy), + link: entry.link ?? null + }; + + // A country can be listed with a link but no actual offers; that is not an + // answer worth rendering a section for. + const hasAny = + options.stream.length + options.free.length + options.rent.length + options.buy.length > 0; + return hasAny ? options : null; +} + +/** + * Fetch rich details for a single movie/TV title, including credits (cast) and + * videos (trailer) in one request via `append_to_response`. + */ +export async function getDetails( + mediaType: MediaType, + id: number, + country = 'US', + season: number | null = null +): Promise { + /** + * The requested season's episodes ride along on the same request via + * `append_to_response`, so showing where you are in a series costs nothing + * beyond the details call the sheet already makes. + */ + const wantsSeason = mediaType === 'tv' && Number.isInteger(season) && (season as number) >= 1; + const appended = ['credits', 'videos', 'watch/providers']; + if (wantsSeason) appended.push(`season/${season}`); + + const raw = await tmdbFetch(`/${mediaType}/${id}`, { + language: 'en-US', + append_to_response: appended.join(',') + }); + + const seasons = mediaType === 'tv' ? splitSeasons(raw.seasons) : null; + const videos = raw.videos?.results ?? []; + const trailer = + videos.find((v) => v.site === 'YouTube' && v.type === 'Trailer') ?? + videos.find((v) => v.site === 'YouTube'); + + return { + tmdbId: raw.id, + mediaType, + title: raw.title ?? raw.name ?? 'Untitled', + overview: raw.overview ?? null, + tagline: raw.tagline?.trim() || null, + genres: (raw.genres ?? []).map((genre) => genre.name), + releaseDate: raw.release_date ?? raw.first_air_date ?? null, + runtimeMinutes: raw.runtime ?? raw.episode_run_time?.[0] ?? null, + // `number_of_seasons` is the fallback only when the season list is missing; + // the list is the authority because it is the one that carries air dates. + seasons: seasons?.totalSeasons ?? raw.number_of_seasons ?? null, + airedSeasons: seasons?.airedSeasons ?? null, + upcomingSeason: seasons?.upcomingSeason ?? null, + episodeCounts: seasons?.episodeCounts ?? {}, + season: wantsSeason + ? normalizeSeasonEpisodes(raw[`season/${season as number}`], season as number) + : null, + productionStatus: raw.status?.trim() || null, + voteAverage: raw.vote_average ?? null, + voteCount: raw.vote_count ?? 0, + backdropPath: raw.backdrop_path ?? null, + posterPath: raw.poster_path ?? null, + cast: (raw.credits?.cast ?? []).slice(0, 12).map((member) => ({ + id: member.id, + name: member.name, + character: member.character ?? '', + profilePath: member.profile_path ?? null + })), + trailerKey: trailer?.key ?? null, + watch: normalizeWatchOptions(raw['watch/providers']?.results, country) + }; +} + +/** + * Titles TMDB considers close to one already on the list. + * + * This is the same "what should I watch" question trending answers, asked of a + * far better source: the list itself. Trending is identical for every visitor, + * so it is the one part of Discover that can never get more relevant the longer + * somebody uses the app. + * + * The endpoint is per-title and one page deep — twenty suggestions is already + * more than a rail shows, and which titles are worth asking about is a decision + * that belongs to `domain/recommendations`, not here. + */ diff --git a/src/lib/server/tmdb/index.ts b/src/lib/server/tmdb/index.ts new file mode 100644 index 0000000..1a7e8c2 --- /dev/null +++ b/src/lib/server/tmdb/index.ts @@ -0,0 +1,15 @@ +/** + * The TMDB client's public surface. + * + * It was one 612-line module covering search, details, seasons, trending, + * recommendations and people — six endpoint families whose only genuinely + * shared parts are the fetch and two raw response shapes, now in `client`. + * Re-exported from here so every existing `$lib/server/tmdb` import is + * untouched by the split. + */ +export { searchMulti, type SearchResults } from './search'; +export { normalizeSeasonEpisodes, splitSeasons, type SeasonBreakdown } from './seasons'; +export { getDetails } from './details'; +export { getRecommendations } from './recommendations'; +export { getTrending, type TrendingPage } from './trending'; +export { getPersonFilmography } from './person'; diff --git a/src/lib/server/tmdb/person.ts b/src/lib/server/tmdb/person.ts new file mode 100644 index 0000000..5410334 --- /dev/null +++ b/src/lib/server/tmdb/person.ts @@ -0,0 +1,89 @@ +import { normalize, tmdbFetch, type TmdbRawResult } from './client'; +import { PERSON_CREDITS_SIZE, rankCredits, type CreditCandidate } from '$lib/domain/filmography'; +import type { MediaType, PersonCredit, PersonFilmography } from '$lib/types'; + +/** One person's record, and their whole career in a single response. */ + +/** A person's own record, and their whole career in one response. */ +interface TmdbPersonCreditRaw extends TmdbRawResult { + character?: string; + popularity?: number; + vote_count?: number; + episode_count?: number; +} + +interface TmdbPersonRaw { + id: number; + name?: string; + profile_path?: string | null; + known_for_department?: string; + combined_credits?: { cast?: TmdbPersonCreditRaw[] }; +} + +/** + * A career changes about as often as a birthday, so this caches for a day at the + * edge like recommendations do. Keyed by person id and identical for everyone. + */ +const PERSON_CACHE_SECONDS = 86_400; + +/** + * Who somebody is, and the few titles worth recognising them from. + * + * `combined_credits` is appended rather than fetched separately: the panel needs + * both halves before it can render anything, and two round trips from the worker + * to TMDB would be paid on every face a viewer taps. + * + * The ranking is deliberately not done here — see `domain/filmography` for what + * "worth recognising" means and why it is not simply the most recent five. + */ +export async function getPersonFilmography(personId: number): Promise { + const raw = await tmdbFetch( + `/person/${personId}`, + { language: 'en-US', append_to_response: 'combined_credits' }, + PERSON_CACHE_SECONDS + ); + + const candidates = (raw.combined_credits?.cast ?? []) + .filter( + (credit): credit is TmdbPersonCreditRaw & { media_type: MediaType } => + credit.media_type === 'movie' || credit.media_type === 'tv' + ) + .map((credit): PersonCredit & CreditCandidate => ({ + ...normalize(credit, credit.media_type), + character: credit.character?.trim() || null, + voteCount: credit.vote_count ?? 0, + popularity: credit.popularity ?? 0, + episodeCount: credit.media_type === 'tv' ? (credit.episode_count ?? null) : null + })); + + return { + id: raw.id, + name: raw.name?.trim() || 'Unknown', + profilePath: raw.profile_path ?? null, + knownFor: raw.known_for_department?.trim() || null, + // Ranking fields are dropped here rather than carried to the client: the + // order is the answer, and the numbers behind it are not the browser's + // business. + credits: rankCredits(candidates, PERSON_CREDITS_SIZE).map( + ({ + tmdbId, + mediaType, + title, + posterPath, + releaseDate, + overview, + voteAverage, + character + }) => ({ + tmdbId, + mediaType, + title, + posterPath, + releaseDate, + overview, + voteAverage, + character + }) + ) + }; +} diff --git a/src/lib/server/tmdb/recommendations.ts b/src/lib/server/tmdb/recommendations.ts new file mode 100644 index 0000000..ac87167 --- /dev/null +++ b/src/lib/server/tmdb/recommendations.ts @@ -0,0 +1,31 @@ +import { normalize, tmdbFetch, type TmdbPaginatedResponse } from './client'; +import type { MediaResult, MediaType } from '$lib/types'; + +/** "More like this", for one title. */ + +/** + * A day at the edge. + * + * What a title is close to does not move hour to hour, and the rows are rebuilt + * on every visit to Discover — so without this, opening the page twice costs two + * identical sets of lookups. The rows still turn over daily; that comes from + * which titles get asked about, not from asking the same one again. + */ +const RECOMMENDATION_CACHE_SECONDS = 86_400; + +export async function getRecommendations(mediaType: MediaType, id: number): Promise { + const data = await tmdbFetch( + `/${mediaType}/${id}/recommendations`, + { language: 'en-US', page: '1' }, + RECOMMENDATION_CACHE_SECONDS + ); + + // Recommendations for a film are overwhelmingly films, but TMDB does mix in + // the odd show, so the per-result type wins and the seed's is only a fallback. + return data.results.map((raw) => + normalize( + raw, + raw.media_type === 'movie' || raw.media_type === 'tv' ? raw.media_type : mediaType + ) + ); +} diff --git a/src/lib/server/tmdb/search.ts b/src/lib/server/tmdb/search.ts new file mode 100644 index 0000000..18fc43d --- /dev/null +++ b/src/lib/server/tmdb/search.ts @@ -0,0 +1,86 @@ +import { normalize, tmdbFetch, type TmdbPaginatedResponse, type TmdbRawResult } from './client'; +import { PEOPLE_RESULTS_SIZE, rankPeople, type PersonCandidate } from '$lib/domain/filmography'; +import type { MediaResult, MediaType, PersonResult } from '$lib/types'; + +/** Searching titles and people in one request, and ranking what comes back. */ + +/** Person entries in a multi-search, which carry their best-known titles inline. */ +interface TmdbPersonSearchRaw { + id: number; + media_type?: string; + name?: string; + profile_path?: string | null; + known_for_department?: string; + popularity?: number; + known_for?: TmdbRawResult[]; +} + +/** How many titles a person's search entry names to tell them apart by. */ +const KNOWN_FOR_TITLES = 2; + +/** Both halves of what a search can match. */ +export interface SearchResults { + titles: MediaResult[]; + people: PersonResult[]; +} + +/** + * Search movies, TV shows and people in a single request via TMDB multi-search. + * + * People used to be discarded here, which made the app searchable only by title + * — the one thing you cannot do is look up the actor whose name you remember + * when the film's has gone. They come back from the same response as the titles, + * so answering both halves of the question costs no extra request. + * + * `known_for` rides along on each person entry, which is what lets the strip say + * *which* Chris Evans this is without a second lookup per face. + */ +export async function searchMulti(query: string): Promise { + const data = await tmdbFetch( + '/search/multi', + { + query, + include_adult: 'false', + language: 'en-US', + page: '1' + } + ); + + const raw = data.results as (TmdbRawResult & TmdbPersonSearchRaw)[]; + + const titles = raw + .filter( + (item): item is TmdbRawResult & { media_type: MediaType } => + item.media_type === 'movie' || item.media_type === 'tv' + ) + .map((item) => normalize(item, item.media_type)); + + const people = raw + .filter((item) => item.media_type === 'person') + .map((item): PersonResult & PersonCandidate => ({ + id: item.id, + name: item.name?.trim() || 'Unknown', + profilePath: item.profile_path ?? null, + knownFor: item.known_for_department?.trim() || null, + knownForTitles: (item.known_for ?? []) + .map((credit) => credit.title ?? credit.name ?? '') + .filter(Boolean) + .slice(0, KNOWN_FOR_TITLES), + popularity: item.popularity ?? 0 + })); + + return { + titles, + // The ranking score is dropped rather than sent on: the order is the + // answer, and the number behind it is not the browser's business. + people: rankPeople(people, PEOPLE_RESULTS_SIZE).map( + ({ id, name, profilePath, knownFor, knownForTitles }) => ({ + id, + name, + profilePath, + knownFor, + knownForTitles + }) + ) + }; +} diff --git a/src/lib/server/tmdb/seasons.ts b/src/lib/server/tmdb/seasons.ts new file mode 100644 index 0000000..34dbc38 --- /dev/null +++ b/src/lib/server/tmdb/seasons.ts @@ -0,0 +1,136 @@ +import type { Episode, SeasonEpisodes, UpcomingSeason } from '$lib/types'; + +/** + * Reading TMDB's season and episode data, and deciding what has actually aired. + * + * The distinction this module holds is the one the rest of the app depends on: + * TMDB counts announced seasons alongside broadcast ones, and progress can only + * ever be measured against the seasons that exist. + */ + +export interface TmdbSeasonRaw { + season_number: number; + air_date?: string | null; + episode_count?: number; +} + +interface TmdbEpisodeRaw { + episode_number: number; + name?: string; + air_date?: string | null; + runtime?: number | null; +} +export interface TmdbSeasonDetailRaw { + season_number?: number; + episodes?: TmdbEpisodeRaw[]; +} + +/** + * Whether a `YYYY-MM-DD` date has arrived, compared as calendar days in UTC. + * + * Shared by seasons and episodes so both answer "is this out yet?" the same way + * — a premiere on the 12th is out on the 12th regardless of the viewer's zone. + */ +function hasAired(date: string | null | undefined, now: Date): boolean { + if (!date) return false; + const parsed = Date.parse(`${date}T00:00:00Z`); + const todayUtc = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); + return Number.isFinite(parsed) && parsed <= todayUtc; +} + +/** + * Normalize one season's episode list. + * + * The overview and still image of every episode are dropped: they multiply the + * payload several times over for something no view renders, and this response is + * cached at the edge for every visitor. + */ +export function normalizeSeasonEpisodes( + raw: TmdbSeasonDetailRaw | undefined, + seasonNumber: number, + now: Date = new Date() +): SeasonEpisodes | null { + const list = raw?.episodes; + if (!list || list.length === 0) return null; + + const episodes: Episode[] = list + .filter((episode) => Number.isInteger(episode.episode_number) && episode.episode_number >= 1) + .sort((a, b) => a.episode_number - b.episode_number) + .map((episode) => ({ + number: episode.episode_number, + name: episode.name?.trim() || `Episode ${episode.episode_number}`, + airDate: episode.air_date ?? null, + runtimeMinutes: episode.runtime ?? null, + aired: hasAired(episode.air_date, now) + })); + + if (episodes.length === 0) return null; + + return { + seasonNumber, + episodes, + airedCount: episodes.filter((episode) => episode.aired).length + }; +} + +/** What the season list tells us once unaired seasons are separated out. */ +export interface SeasonBreakdown { + /** Every season TMDB lists, aired or not. */ + totalSeasons: number | null; + /** Seasons that have actually premiered — the ceiling for progress. */ + airedSeasons: number | null; + upcomingSeason: UpcomingSeason | null; + /** Episodes per numbered season, so rollover needs no extra request. */ + episodeCounts: Record; +} + +/** + * Split a show's seasons into "already premiered" and "still to come". + * + * TMDB's `number_of_seasons` counts announced seasons, which is what makes a + * show with three aired seasons and a fourth dated for next year look fully + * watchable today. Season 0 ("Specials") is excluded throughout: it is not part + * of the numbered run and counting it would shift every season by one. + * + * A season with no `air_date` is treated as *not* aired. TMDB leaves the date + * empty for seasons that are announced but unscheduled, and guessing "aired" + * there would recreate the exact bug this exists to prevent. + */ +export function splitSeasons( + seasons: TmdbSeasonRaw[] | undefined, + now: Date = new Date() +): SeasonBreakdown { + const numbered = (seasons ?? []) + .filter((season) => Number.isInteger(season.season_number) && season.season_number >= 1) + .sort((a, b) => a.season_number - b.season_number); + + if (numbered.length === 0) + return { totalSeasons: null, airedSeasons: null, upcomingSeason: null, episodeCounts: {} }; + + const premiered = (season: TmdbSeasonRaw) => hasAired(season.air_date, now); + const aired = numbered.filter(premiered); + const next = numbered.find((season) => !premiered(season)); + + const episodeCounts: Record = {}; + for (const season of numbered) { + if (typeof season.episode_count === 'number' && season.episode_count > 0) { + episodeCounts[season.season_number] = season.episode_count; + } + } + + return { + totalSeasons: numbered.length, + airedSeasons: aired.length, + upcomingSeason: next ? { number: next.season_number, airDate: next.air_date ?? null } : null, + episodeCounts + }; +} + +/** + * Normalize TMDB's per-country watch providers. + * + * "Free with ads" is folded into `free` because the distinction between TMDB's + * `free` and `ads` buckets is not one anybody is making when they ask where to + * watch something. Providers are ordered by TMDB's `display_priority`, which + * reflects how prominent the service is in that country. + */ diff --git a/src/lib/server/tmdb/trending.ts b/src/lib/server/tmdb/trending.ts new file mode 100644 index 0000000..0e3d1c8 --- /dev/null +++ b/src/lib/server/tmdb/trending.ts @@ -0,0 +1,45 @@ +import { normalize, tmdbFetch, type TmdbPaginatedResponse, type TmdbRawResult } from './client'; +import type { MediaResult, MediaType } from '$lib/types'; + +/** This week's trending titles, paged. */ + +/** One page of trending titles, plus whether another page exists. */ +export interface TrendingPage { + results: MediaResult[]; + page: number; + hasMore: boolean; +} + +/** + * TMDB caps trending at 500 pages, and we stop well short of it: nobody browses + * two thousand titles, and the tail of the trending list is noise. This is a + * guard against an unbounded `?page=` in the URL, not a UX target. + */ +const MAX_TRENDING_PAGES = 25; + +/** + * Fetch a page of this week's trending movies and TV shows. Used to populate the + * home screen with content before the user has searched for anything. + * + * Pages hold 20 titles and do not overlap, so appending them is safe. `person` + * entries are filtered out, which is why a page can return fewer than 20. + */ +export async function getTrending(page = 1): Promise { + const safePage = Math.min(Math.max(Math.trunc(page) || 1, 1), MAX_TRENDING_PAGES); + + const data = await tmdbFetch('/trending/all/week', { + language: 'en-US', + page: String(safePage) + }); + + const results = data.results + .filter( + (raw): raw is TmdbRawResult & { media_type: MediaType } => + raw.media_type === 'movie' || raw.media_type === 'tv' + ) + .map((raw) => normalize(raw, raw.media_type)); + + const lastPage = Math.min(data.total_pages ?? safePage, MAX_TRENDING_PAGES); + + return { results, page: safePage, hasMore: safePage < lastPage }; +} diff --git a/src/lib/server/watchlist/actions/index.ts b/src/lib/server/watchlist/actions/index.ts new file mode 100644 index 0000000..ee54924 --- /dev/null +++ b/src/lib/server/watchlist/actions/index.ts @@ -0,0 +1,22 @@ +import type { Actions } from '@sveltejs/kit'; +import { listActions } from './list'; +import { sharingActions } from './sharing'; +import { settingsActions } from './settings'; + +/** + * Every write a visitor can make to their own list, as one action map. + * + * Both routes need these: Discover saves titles and My List edits them, and a + * SvelteKit form action only exists on the route it is declared in. Rather than + * two drifting copies, each `+page.server.ts` re-exports this one set. + * + * The three groups behind it have genuinely separate reasons to change — the + * titles, the links that publish them, and the account's own settings — and + * they were one 485-line object until they did, repeatedly, in the same file. + * Spreading them here keeps the route contract exactly as it was. + */ +export const watchlistActions = { + ...listActions, + ...sharingActions, + ...settingsActions +} satisfies Actions; diff --git a/src/lib/server/watchlist/actions.ts b/src/lib/server/watchlist/actions/list.ts similarity index 62% rename from src/lib/server/watchlist/actions.ts rename to src/lib/server/watchlist/actions/list.ts index 1d174b7..8001a61 100644 --- a/src/lib/server/watchlist/actions.ts +++ b/src/lib/server/watchlist/actions/list.ts @@ -1,8 +1,8 @@ import { fail, type Actions } from '@sveltejs/kit'; -import { and, eq, isNotNull, lt, sql } from 'drizzle-orm'; -import { getDb } from '../db'; -import { user, watchlistItem } from '../db/schema'; -import { clip, toPositiveInt, toRating } from '../form'; +import { eq, sql } from 'drizzle-orm'; +import { getDb } from '../../db'; +import { watchlistItem } from '../../db/schema'; +import { clip, toPositiveInt, toRating } from '../../form'; import { clampSeasons, deriveWatched, @@ -10,31 +10,19 @@ import { normalizeTotalSeasons } from '$lib/domain/progress'; import { resolveEpisodeTarget, seasonBoundary } from '$lib/domain/episodes'; -import { normalizeDeletionWindow } from '$lib/domain/deletion'; import { canMarkWatched } from '$lib/domain/release'; -import { resolveSeasonInfo, safeDetails, seasonInfoForSave } from './seasons'; -import { watchedStamp } from './stamp'; -import { issueCalendarToken, revokeCalendarToken } from '../calendar'; -import { issueShareToken, revokeShareToken, setShareScope } from '../share'; -import { scopeFromChoices } from '$lib/domain/share'; +import { resolveSeasonInfo, safeDetails, seasonInfoForSave } from '../seasons'; +import { watchedStamp } from '../stamp'; +import { UNAUTHENTICATED, ownedRow } from './shared'; import type { MediaType } from '$lib/types'; /** - * Every write a visitor can make to their own list. + * The titles themselves: saving one, dropping one, and moving through it. * - * Both routes need these: Discover saves titles and My List edits them, and a - * SvelteKit form action only exists on the route it is declared in. Rather than - * two drifting copies, each `+page.server.ts` re-exports this one set. - * - * Two rules hold across all of them. Nothing is trusted from the browser except - * intent — every bound is resolved here — and every statement is scoped by the - * session's user id, because item ids travel through the browser as form fields - * and knowing one must not be enough to use it. + * Nothing is trusted from the browser except intent — every bound is resolved + * here — and every statement is scoped by the session's user id. */ -/** Returned by every action when the caller has no session. */ -const UNAUTHENTICATED = { message: 'Please sign in first.' }; - /** * Ceiling on how many titles one account may store. * @@ -44,28 +32,7 @@ const UNAUTHENTICATED = { message: 'Please sign in first.' }; */ const MAX_ITEMS_PER_USER = 5000; -/** - * Match a row by id *and* owner. - * - * The id alone would be enough to find the row, which is exactly the problem. - * Someone else's id simply matches nothing. - */ -function ownedRow(id: string, userId: string) { - return and(eq(watchlistItem.id, id), eq(watchlistItem.userId, userId)); -} - -/** - * The scope the share form is asking for, or null when it names nothing. - * - * An unchecked box is absent from a form body rather than false, so presence is - * the whole test. Both actions that write a scope read it through here, which is - * what keeps "neither box ticked" one rule rather than two. - */ -function scopeFromShareForm(form: FormData) { - return scopeFromChoices(form.has('toWatch'), form.has('watched')); -} - -export const watchlistActions = { +export const listActions = { /** Save a movie/TV show. Duplicates are silently ignored via the unique index. */ add: async ({ request, locals }) => { if (!locals.user) return fail(401, UNAUTHENTICATED); @@ -119,71 +86,6 @@ export const watchlistActions = { return { added: true }; }, - /** - * Turn the calendar feed on, or roll it over. - * - * One action for both, because they are the same operation: a new token - * replaces whatever was there. Rolling over is how somebody takes back a URL - * that ended up somewhere it should not have, and it necessarily breaks every - * subscription made with the old one — which is the point, and which the UI - * says out loud before doing it. - */ - issueCalendarFeed: async ({ locals }) => { - if (!locals.user) return fail(401, UNAUTHENTICATED); - await issueCalendarToken(locals.user.id); - return { calendar: 'issued' as const }; - }, - - /** Turn the calendar feed off, invalidating every subscription to it. */ - revokeCalendarFeed: async ({ locals }) => { - if (!locals.user) return fail(401, UNAUTHENTICATED); - await revokeCalendarToken(locals.user.id); - return { calendar: 'revoked' as const }; - }, - - /** - * Turn the share link on, or roll it over. - * - * The scope arrives with the request rather than being assumed, because the - * two checkboxes and the button are one decision: nobody creates a link and - * then wonders what is on it. Neither box ticked is refused outright — there - * is no way to spell "share my list, showing nothing", and quietly picking a - * default here would publish something the owner did not tick. - */ - issueShareLink: async ({ request, locals }) => { - if (!locals.user) return fail(401, UNAUTHENTICATED); - - const scope = scopeFromShareForm(await request.formData()); - if (!scope) return fail(400, { message: 'Choose what to share first.' }); - - await issueShareToken(locals.user.id, scope); - return { share: 'issued' as const }; - }, - - /** - * Change what the existing link shows, keeping the link itself. - * - * Separate from issuing on purpose. Widening a scope is not a request to - * break the URL already sent, and narrowing one only achieves anything if it - * is the same URL that starts showing less. - */ - updateShareScope: async ({ request, locals }) => { - if (!locals.user) return fail(401, UNAUTHENTICATED); - - const scope = scopeFromShareForm(await request.formData()); - if (!scope) return fail(400, { message: 'Choose what to share first.' }); - - await setShareScope(locals.user.id, scope); - return { share: 'updated' as const }; - }, - - /** Turn the share link off, so every copy of the URL stops resolving. */ - revokeShareLink: async ({ locals }) => { - if (!locals.user) return fail(401, UNAUTHENTICATED); - await revokeShareToken(locals.user.id); - return { share: 'revoked' as const }; - }, - /** Remove an item from the list. */ remove: async ({ request, locals }) => { if (!locals.user) return fail(401, UNAUTHENTICATED); @@ -267,67 +169,6 @@ export const watchlistActions = { return { toggled: true, watched }; }, - /** - * Choose how long a watched title stays before it is deleted, or turn the - * whole thing off. - * - * Anything that is not one of the offered windows is stored as null — "off" — - * rather than rejected, because the failure mode of a bad value here is - * someone's list emptying itself on a schedule they never picked. - * - * Picking a window also restarts the clock on anything already past it. The - * countdown is only half the feature; the other half is the week of warning - * the card shows before the end, and a title watched two months before you - * turned this on would run out the moment you did — deleted having never once - * said it was going to be. Under archiving that was survivable, because the - * title was still there to restore. It is not survivable now. - * - * So the window means "from here", and everything gets its full run. Turning - * the feature off touches nothing: there is no countdown to restart. - */ - setAutoDelete: async ({ request, locals }) => { - if (!locals.user) return fail(401, UNAUTHENTICATED); - - const form = await request.formData(); - const days = normalizeDeletionWindow(form.get('days')); - - const db = getDb(); - await db.update(user).set({ autoDeleteDays: days }).where(eq(user.id, locals.user.id)); - - if (days !== null) { - const now = new Date(); - await db - .update(watchlistItem) - .set({ watchedAt: now }) - .where( - and( - eq(watchlistItem.userId, locals.user.id), - eq(watchlistItem.watched, true), - isNotNull(watchlistItem.watchedAt), - lt(watchlistItem.watchedAt, new Date(now.getTime() - days * 86_400_000)) - ) - ); - } - - return { autoDeleteDays: days }; - }, - - /** Reset the deletion countdown for a title without changing anything else. */ - keepLonger: async ({ request, locals }) => { - if (!locals.user) return fail(401, UNAUTHENTICATED); - - const form = await request.formData(); - const id = clip(form.get('id'), 64); - if (!id) return fail(400, { message: 'Missing id.' }); - - await getDb() - .update(watchlistItem) - .set({ watchedAt: new Date() }) - .where(ownedRow(id, locals.user.id)); - - return { kept: true }; - }, - /** * Move the bookmark to "watched through season S, episode E". * diff --git a/src/lib/server/watchlist/actions/settings.ts b/src/lib/server/watchlist/actions/settings.ts new file mode 100644 index 0000000..0b5c915 --- /dev/null +++ b/src/lib/server/watchlist/actions/settings.ts @@ -0,0 +1,75 @@ +import { fail, type Actions } from '@sveltejs/kit'; +import { and, eq, isNotNull, lt } from 'drizzle-orm'; +import { getDb } from '../../db'; +import { user, watchlistItem } from '../../db/schema'; +import { clip } from '../../form'; +import { normalizeDeletionWindow } from '$lib/domain/deletion'; +import { UNAUTHENTICATED, ownedRow } from './shared'; + +/** + * Account-level choices about the list rather than edits to it: how long a + * watched title survives, and the one-tap way to answer that countdown. + */ + +export const settingsActions = { + /** + * Choose how long a watched title stays before it is deleted, or turn the + * whole thing off. + * + * Anything that is not one of the offered windows is stored as null — "off" — + * rather than rejected, because the failure mode of a bad value here is + * someone's list emptying itself on a schedule they never picked. + * + * Picking a window also restarts the clock on anything already past it. The + * countdown is only half the feature; the other half is the week of warning + * the card shows before the end, and a title watched two months before you + * turned this on would run out the moment you did — deleted having never once + * said it was going to be. Under archiving that was survivable, because the + * title was still there to restore. It is not survivable now. + * + * So the window means "from here", and everything gets its full run. Turning + * the feature off touches nothing: there is no countdown to restart. + */ + setAutoDelete: async ({ request, locals }) => { + if (!locals.user) return fail(401, UNAUTHENTICATED); + + const form = await request.formData(); + const days = normalizeDeletionWindow(form.get('days')); + + const db = getDb(); + await db.update(user).set({ autoDeleteDays: days }).where(eq(user.id, locals.user.id)); + + if (days !== null) { + const now = new Date(); + await db + .update(watchlistItem) + .set({ watchedAt: now }) + .where( + and( + eq(watchlistItem.userId, locals.user.id), + eq(watchlistItem.watched, true), + isNotNull(watchlistItem.watchedAt), + lt(watchlistItem.watchedAt, new Date(now.getTime() - days * 86_400_000)) + ) + ); + } + + return { autoDeleteDays: days }; + }, + + /** Reset the deletion countdown for a title without changing anything else. */ + keepLonger: async ({ request, locals }) => { + if (!locals.user) return fail(401, UNAUTHENTICATED); + + const form = await request.formData(); + const id = clip(form.get('id'), 64); + if (!id) return fail(400, { message: 'Missing id.' }); + + await getDb() + .update(watchlistItem) + .set({ watchedAt: new Date() }) + .where(ownedRow(id, locals.user.id)); + + return { kept: true }; + } +} satisfies Actions; diff --git a/src/lib/server/watchlist/actions/shared.ts b/src/lib/server/watchlist/actions/shared.ts new file mode 100644 index 0000000..59e3547 --- /dev/null +++ b/src/lib/server/watchlist/actions/shared.ts @@ -0,0 +1,23 @@ +import { and, eq } from 'drizzle-orm'; +import { watchlistItem } from '../../db/schema'; + +/** + * The two things every group of actions needs, and nothing else. + * + * Kept apart from any one of them so that adding a third group does not mean + * picking which existing file to import from. + */ + +/** Returned by every action when the caller has no session. */ +export const UNAUTHENTICATED = { message: 'Please sign in first.' }; + +/** + * Match a row by id *and* owner. + * + * The id alone would be enough to find the row, which is exactly the problem. + * Item ids travel through the browser as form fields, so knowing one must not + * be enough to use it: someone else's id simply matches nothing. + */ +export function ownedRow(id: string, userId: string) { + return and(eq(watchlistItem.id, id), eq(watchlistItem.userId, userId)); +} diff --git a/src/lib/server/watchlist/actions/sharing.ts b/src/lib/server/watchlist/actions/sharing.ts new file mode 100644 index 0000000..007b598 --- /dev/null +++ b/src/lib/server/watchlist/actions/sharing.ts @@ -0,0 +1,90 @@ +import { fail, type Actions } from '@sveltejs/kit'; +import { issueCalendarToken, revokeCalendarToken } from '../../calendar'; +import { issueShareToken, revokeShareToken, setShareScope } from '../../share'; +import { scopeFromChoices } from '$lib/domain/share'; +import { UNAUTHENTICATED } from './shared'; + +/** + * Handing the list to somebody else: the calendar feed, and the public page. + * + * Two links rather than one, and deliberately separate tokens — turning off a + * page sent to a friend must not unsubscribe a calendar. + */ + +/** + * The scope the share form is asking for, or null when it names nothing. + * + * An unchecked box is absent from a form body rather than false, so presence is + * the whole test. Both actions that write a scope read it through here, which is + * what keeps "neither box ticked" one rule rather than two. + */ +function scopeFromShareForm(form: FormData) { + return scopeFromChoices(form.has('toWatch'), form.has('watched')); +} + +export const sharingActions = { + /** + * Turn the calendar feed on, or roll it over. + * + * One action for both, because they are the same operation: a new token + * replaces whatever was there. Rolling over is how somebody takes back a URL + * that ended up somewhere it should not have, and it necessarily breaks every + * subscription made with the old one — which is the point, and which the UI + * says out loud before doing it. + */ + issueCalendarFeed: async ({ locals }) => { + if (!locals.user) return fail(401, UNAUTHENTICATED); + await issueCalendarToken(locals.user.id); + return { calendar: 'issued' as const }; + }, + + /** Turn the calendar feed off, invalidating every subscription to it. */ + revokeCalendarFeed: async ({ locals }) => { + if (!locals.user) return fail(401, UNAUTHENTICATED); + await revokeCalendarToken(locals.user.id); + return { calendar: 'revoked' as const }; + }, + + /** + * Turn the share link on, or roll it over. + * + * The scope arrives with the request rather than being assumed, because the + * two checkboxes and the button are one decision: nobody creates a link and + * then wonders what is on it. Neither box ticked is refused outright — there + * is no way to spell "share my list, showing nothing", and quietly picking a + * default here would publish something the owner did not tick. + */ + issueShareLink: async ({ request, locals }) => { + if (!locals.user) return fail(401, UNAUTHENTICATED); + + const scope = scopeFromShareForm(await request.formData()); + if (!scope) return fail(400, { message: 'Choose what to share first.' }); + + await issueShareToken(locals.user.id, scope); + return { share: 'issued' as const }; + }, + + /** + * Change what the existing link shows, keeping the link itself. + * + * Separate from issuing on purpose. Widening a scope is not a request to + * break the URL already sent, and narrowing one only achieves anything if it + * is the same URL that starts showing less. + */ + updateShareScope: async ({ request, locals }) => { + if (!locals.user) return fail(401, UNAUTHENTICATED); + + const scope = scopeFromShareForm(await request.formData()); + if (!scope) return fail(400, { message: 'Choose what to share first.' }); + + await setShareScope(locals.user.id, scope); + return { share: 'updated' as const }; + }, + + /** Turn the share link off, so every copy of the URL stops resolving. */ + revokeShareLink: async ({ locals }) => { + if (!locals.user) return fail(401, UNAUTHENTICATED); + await revokeShareToken(locals.user.id); + return { share: 'revoked' as const }; + } +} satisfies Actions; From 9aeff566ed638cef743889b5e2fcca28ea9b7259 Mon Sep 17 00:00:00 2001 From: Ismael Leon Date: Thu, 10 Sep 2026 21:53:57 -0600 Subject: [PATCH 2/2] docs: condense MediaCard's longest comment blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file carried two thirteen-line essays in its markup. Both record something worth keeping — a measurement, and a pair of CSS mechanics that are not guessable — and both said it at four times the necessary length. They are now five and six lines. Nothing factual is lost: the blur block still carries the numbers that justify the ban (794ms -> 392ms over 300 frames, 154 compositing layers on a grid of 77) and the clamp block still names both reasons the clamp cannot move to the button. Four prop docs and two inline notes got the same treatment. The LCP figures stay; the retelling around them does not. --- src/lib/components/media/MediaCard.svelte | 72 ++++++----------------- 1 file changed, 19 insertions(+), 53 deletions(-) diff --git a/src/lib/components/media/MediaCard.svelte b/src/lib/components/media/MediaCard.svelte index 3a83d25..455c639 100644 --- a/src/lib/components/media/MediaCard.svelte +++ b/src/lib/components/media/MediaCard.svelte @@ -22,35 +22,21 @@ watched?: boolean; /** Extra line under the title, e.g. season progress. */ note?: string; - /** - * A TV season that has not aired, badged on the poster the same way an - * unreleased film is — the visual language for "not watchable yet" should - * not depend on whether the title is a film or a show. - */ + /** An unaired season, badged like an unreleased film: same language for both. */ upcomingSeason?: { number: number; label: string } | null; /** - * Set on the tiles that land above the fold. - * - * `loading="lazy"` is right for a long grid but wrong for its first row: - * lazy images are invisible to the preload scanner and fetched at low - * priority, so applying it to every poster pushes the one that *is* the - * LCP element to the back of the queue. - * - * Measured against the production build on Discover: LCP 972ms -> ~885ms - * and FCP 640ms -> ~570ms. Local numbers, so the real-network gap is - * likely wider — priority hints matter most under bandwidth contention. + * Set on the tiles above the fold. Lazy images are invisible to the preload + * scanner, so lazy-loading the first row delays the very poster that is the + * LCP element. Measured on Discover: LCP 972ms -> 885ms, FCP 640ms -> 570ms. */ priority?: boolean; /** When provided, the poster and title become clickable to open details. */ onSelect?: () => void; /** - * Where the poster and title lead, when following the tile means going - * somewhere rather than opening a sheet over what is already here. - * - * A real anchor rather than a button that navigates, because a grid of - * links is a grid people open in background tabs — which is precisely what - * somebody does with a list they have been sent. Ignored when `onSelect` is - * given; a tile has one behaviour. + * Where the tile leads, when following it means navigating rather than + * opening a sheet. A real anchor, so a shared list can be opened into + * background tabs. Ignored when `onSelect` is given; a tile has one + * behaviour. */ href?: string; /** Action controls rendered in the card footer (buttons, forms, badges). */ @@ -75,12 +61,7 @@ /** Which of the two follow behaviours this tile has, if either. */ const interactive = $derived(Boolean(onSelect || href)); - /** - * The title's own styling, shared by the button and the anchor forms of it. - * - * Named rather than repeated: the two differ only in which element they are, - * and a class list copied into both is one that will be edited in one. - */ + /** Shared by the button and anchor forms of the title, so neither drifts. */ const TITLE_LINK = '-my-3 block cursor-pointer py-3 text-left text-sm leading-snug font-semibold text-ink transition-colors duration-200 hover:text-brand-hi'; @@ -88,8 +69,7 @@ const year = $derived(releaseYear(releaseDate)); const rating = $derived(voteAverage ? voteAverage.toFixed(1) : null); - // TMDB returns titles that are still in production alongside released ones, - // so the card has to be able to say "not out yet — here's when". + // TMDB lists in-production titles alongside released ones. const release = $derived(getReleaseInfo(releaseDate)); const unreleased = $derived(release.state !== 'released'); @@ -133,18 +113,10 @@ > {#if interactive} {#if onSelect}