Files
jellytau/src/lib/components/library/episodeStrip.ts
T
dtourolle fb967433f0 fix(library): populate "More Episodes" for series without season folders
The Episode Focus View's episode strip collapsed to just the current
episode on some series. Two causes:

- Series that expose episodes directly as children rather than under
  season folders yielded an empty season fetch, leaving allEpisodes
  empty. The library page now groups those flat episode children by
  their season number and synthesizes minimal season headers.
- isCurrentEpisode over-matched: episodes with no season/episode number
  compared equal (undefined === undefined) and every one of them looked
  like the focused episode.

Extracts the strip's pure logic into episodeStrip.ts so both behaviours
are unit-tested, per the failing-test-first rule.

TRACES: UR-058 | DR-087
2026-07-25 09:21:15 +02:00

62 lines
2.4 KiB
TypeScript

// 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, and it must
// not mistake number-less episodes for the current one.
//
// TRACES: UR-048 | DR-062
import type { MediaItem } from "$lib/api/types";
/**
* 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
);
}
/**
* The window of episodes shown under the hero: up to 3 before and 6 after the
* current episode. Degrades gracefully:
* - prefers the current season, falling back to the full list when the season
* is unknown (e.g. the episode was fetched directly on an API-ID mismatch);
* - splices the current episode into the pool at its numeric position when it
* isn't present, 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 seasonMatches = allEpisodes.filter(
(e) => current.parentIndexNumber != null && e.parentIndexNumber === current.parentIndexNumber
);
const pool = (seasonMatches.length > 0 ? seasonMatches : allEpisodes)
.slice()
.sort((a, b) => (a.indexNumber ?? 0) - (b.indexNumber ?? 0));
let idx = pool.findIndex((e) => isCurrentEpisode(e, current));
if (idx === -1) {
const epNum = current.indexNumber ?? 0;
const insertAt = pool.findIndex((e) => (e.indexNumber ?? 0) > epNum);
idx = insertAt === -1 ? pool.length : insertAt;
pool.splice(idx, 0, current);
}
const start = Math.max(0, idx - 3);
const end = Math.min(pool.length, idx + 7);
return pool.slice(start, end);
}