// Pure logic for the "More Episodes" strip in EpisodeFocusView. // // Extracted from the component so it can be unit-tested: the strip must never // collapse to just the current episode while real siblings exist, must not // mistake number-less episodes for the current one, and must run past a season // boundary rather than dead-ending at the end of a season (ux-flows §5B.2). // // TRACES: UR-048, UR-062 | DR-062, DR-104 import type { MediaItem } from "$lib/api/types"; /** Episodes shown before / after the current one in the strip window. */ const BEFORE = 3; const AFTER = 6; /** Jellyfin puts specials in season 0; they air outside the numbered run. */ const SPECIALS_SEASON = 0; /** * Does `ep` refer to the same episode as `current`? * * Matches by id first. Falls back to season+episode number, but ONLY when both * numbers are known on both sides — otherwise `undefined === undefined` would * mark every number-less episode as the current one (the bug that made the * whole strip look like the current episode). */ export function isCurrentEpisode(ep: MediaItem, current: MediaItem): boolean { if (ep.id === current.id) return true; if ( ep.indexNumber == null || current.indexNumber == null || ep.parentIndexNumber == null || current.parentIndexNumber == null ) { return false; } return ( ep.parentIndexNumber === current.parentIndexNumber && ep.indexNumber === current.indexNumber ); } /** * Sort key for a season: specials (season 0) come *after* every numbered * season, matching how a viewer works through a show — S1, S2, …, then the * extras — rather than opening on a special because 0 < 1. */ function seasonRank(seasonNumber: number | null | undefined): number { return seasonNumber === SPECIALS_SEASON ? Number.MAX_SAFE_INTEGER : seasonNumber!; } /** * Broadcast order across a whole series: season ascending, then episode. * * When *either* side's season is unknown there is no season axis to compare on, * so it falls through to episode number. That makes the comparator technically * non-transitive across such a mix, which is safe here because only the * directly-fetched `current` episode can lack a season and it is never part of * the array being sorted — it is only positioned against it (see * `adjacentEpisodes`). */ export function compareSeriesOrder(a: MediaItem, b: MediaItem): number { if (a.parentIndexNumber != null && b.parentIndexNumber != null) { const bySeason = seasonRank(a.parentIndexNumber) - seasonRank(b.parentIndexNumber); if (bySeason !== 0) return bySeason; } return (a.indexNumber ?? 0) - (b.indexNumber ?? 0); } /** * The window of episodes shown under the hero: up to 3 before and 6 after the * current episode, in series order across *all* seasons. * * Crossing a season boundary is the point (ux-flows §5B.2): finishing a season * finale should offer the next season's premiere, not an empty strip. Degrades * gracefully: * - splices the current episode into the pool at its ordered position when it * isn't present (an API id mismatch on a directly-fetched episode), so it * still anchors the window; * - returns just `[current]` only when there genuinely are no other episodes. */ export function adjacentEpisodes(current: MediaItem, allEpisodes: MediaItem[]): MediaItem[] { const pool = allEpisodes.slice().sort(compareSeriesOrder); let idx = pool.findIndex((e) => isCurrentEpisode(e, current)); if (idx === -1) { const insertAt = pool.findIndex((e) => compareSeriesOrder(e, current) > 0); idx = insertAt === -1 ? pool.length : insertAt; pool.splice(idx, 0, current); } const start = Math.max(0, idx - BEFORE); const end = Math.min(pool.length, idx + AFTER + 1); return pool.slice(start, end); } /** * Label for a strip card, relative to the episode in focus. * * Within the current season a bare number reads cleanly ("6."). Once the window * crosses into another season that number is ambiguous, so the card names the * season too ("S3E1") — otherwise the premiere after a finale just reads "1." */ export function stripCardLabel(ep: MediaItem, current: MediaItem): string { const crossesSeason = ep.parentIndexNumber != null && current.parentIndexNumber != null && ep.parentIndexNumber !== current.parentIndexNumber; if (crossesSeason) return `S${ep.parentIndexNumber}E${ep.indexNumber ?? 0}`; return `${ep.indexNumber ?? 0}.`; }