// Pure navigation/grouping logic for the series detail page. // // Extracted from `/library/[id]/+page.svelte` so it can be unit-tested: the // series Play button used to resolve `$libraryItems[0]` — the first *season* by // SortName — and navigate to `/player/`, which the player route // bounced back to `/library/`. Play on a series therefore played // nothing and landed on the season-1 page. // // Note what is NOT here: *which* episode is current. That is domain policy and // lives in Rust (`repository_get_series_current_episode`); this module only // renders and routes around the answer. // // TRACES: UR-062 | DR-102, DR-103, DR-142 import type { MediaItem } from "$lib/api/types"; export interface SeasonData { season: MediaItem; episodes: MediaItem[]; } /** Jellyfin files specials under season 0. */ const SPECIALS_SEASON = 0; /** Sort key for a season number: specials come after every numbered season. */ function seasonRank(seasonNumber: number | null | undefined): number { if (seasonNumber == null) return Number.MAX_SAFE_INTEGER - 1; return seasonNumber === SPECIALS_SEASON ? Number.MAX_SAFE_INTEGER : seasonNumber; } /** * The in-page anchor for a season, so a season link scrolls the series' single * continuous episode list instead of opening a page of its own. */ export function seasonAnchorId(seasonNumber: number | null | undefined): string { return `season-${seasonNumber ?? 0}`; } /** * Where a link naming a season should actually go: the series, anchored at that * season. Returns `null` when the season carries no `seriesId` (a deep link into * a stale cache), in which case the caller must keep rendering something rather * than strand the user. */ export function seasonRedirectTarget(season: MediaItem): string | null { if (!season.seriesId) return null; const seasonNumber = season.indexNumber ?? season.parentIndexNumber; return `/library/${season.seriesId}#${seasonAnchorId(seasonNumber)}`; } /** * Where an episode link should go: the episode in the context of its series * (ux-flows §5B.1 — an episode is never browsed as a bare Episode page). * Falls back to the bare item page only when the series is unknown. */ export function episodeFocusHref(episode: MediaItem): string { if (!episode.seriesId) return `/library/${episode.id}`; return `/library/${episode.seriesId}?episode=${episode.id}`; } /** * Where a bare `/library/` should actually land — the same rule * seasons follow (DR-103). An episode is never a page of its own, so a deep * link, a stale bookmark, or any caller that missed `episodeFocusHref` is * redirected into the series' Episode Focus View. * * Returns `null` for an episode with no `seriesId` (a deep link into a stale * cache): there is nothing to redirect *to*, so the caller renders the Focus * View series-less rather than stranding the user (ux-flows §5B.1). */ export function episodeRedirectTarget(episode: MediaItem): string | null { if (!episode.seriesId) return null; return episodeFocusHref(episode); } /** * Where the series hero button goes. * * The Episode Focus View, not the player: ux-flows §5B.5 makes Play on a * *container* navigation and Play on a *leaf* the commitment. Returns `null` * when there is no current episode (an empty series), so the caller can hide * the button rather than link nowhere. */ export function seriesPlayHref(seriesId: string, current: MediaItem | null): string | null { if (!current) return null; return `/library/${seriesId}?episode=${current.id}`; } /** Fraction of an episode already watched, 0 when unknown. */ function progressFraction(episode: MediaItem): number { const position = episode.userData?.playbackPositionMs ?? 0; if (!episode.durationMs || position <= 0) return 0; return position / episode.durationMs; } /** * Label for the series hero button — it names the episode it will open, so the * viewer knows where the button leads before pressing it. */ export function seriesPlayLabel(current: MediaItem | null): string { if (!current) return "Play"; const fraction = progressFraction(current); const verb = fraction > 0.01 && fraction < 0.95 ? "Resume" : "Play"; if (current.parentIndexNumber == null || current.indexNumber == null) return verb; return `${verb} S${current.parentIndexNumber}E${current.indexNumber}`; } /** * Group a series' episodes under its season headers. * * The episodes arrive from Rust already in series order; this only decides which * header each one renders beneath, and synthesizes a header for any season the * server did not return one for (a flat series, or a season fetch that failed). * Seasons with no episodes are dropped — an empty accordion row is noise. */ export function groupEpisodesBySeason( seasons: MediaItem[], episodes: MediaItem[] ): SeasonData[] { const headerFor = new Map(); for (const season of seasons) { const number = season.indexNumber ?? season.parentIndexNumber; if (number != null && !headerFor.has(number)) headerFor.set(number, season); } const grouped = new Map(); for (const episode of episodes) { const number = episode.parentIndexNumber ?? 1; const bucket = grouped.get(number); if (bucket) bucket.push(episode); else grouped.set(number, [episode]); } return [...grouped.entries()] .sort(([a], [b]) => seasonRank(a) - seasonRank(b)) .map(([number, seasonEpisodes]) => ({ season: headerFor.get(number) ?? ({ ...seasonEpisodes[0], id: `synthetic-season-${number}`, kind: "season", indexNumber: number, name: number === SPECIALS_SEASON ? "Specials" : `Season ${number}`, overview: null, } as MediaItem), episodes: seasonEpisodes, })); } /** * Which seasons start expanded. * * Only the one the viewer is in. A ten-season show otherwise renders every * episode of every season at once, burying the one episode they came for. A * `?episode=` deep link expands that episode's season as well, and a show with * no resolved current episode falls back to its first season so the page is * never entirely collapsed. * * Returns season ids (not numbers) so the caller can key state per section, * including the synthesized headers. */ export function initialExpandedSeasons( seasons: SeasonData[], currentEpisodeId: string | null | undefined, focusedEpisodeId?: string | null ): Set { if (seasons.length === 0) return new Set(); const expanded = new Set(); for (const id of [currentEpisodeId, focusedEpisodeId]) { if (!id) continue; const owner = seasons.find((s) => s.episodes.some((e) => e.id === id)); if (owner) expanded.add(owner.season.id); } // Nothing matched — open the first season rather than nothing at all. if (expanded.size === 0) expanded.add(seasons[0].season.id); return expanded; }