Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
180 lines
6.8 KiB
TypeScript
180 lines
6.8 KiB
TypeScript
// 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/<seasonId>`, which the player route
|
|
// bounced back to `/library/<seasonId>`. 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/<episodeId>` 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<number, MediaItem>();
|
|
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<number, MediaItem[]>();
|
|
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<string> {
|
|
if (seasons.length === 0) return new Set();
|
|
|
|
const expanded = new Set<string>();
|
|
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;
|
|
}
|