diff --git a/src/lib/components/library/EpisodeFocusView.svelte b/src/lib/components/library/EpisodeFocusView.svelte index d34d3790..7dae39d1 100644 --- a/src/lib/components/library/EpisodeFocusView.svelte +++ b/src/lib/components/library/EpisodeFocusView.svelte @@ -4,6 +4,7 @@ import { truncateMiddle } from "$lib/utils/truncateMiddle"; import type { MediaItem } from "$lib/api/types"; import CachedImage from "$lib/components/common/CachedImage.svelte"; + import { isCurrentEpisode as isSameEpisode, adjacentEpisodes as computeAdjacent } from "./episodeStrip"; interface Props { episode: MediaItem; @@ -14,63 +15,12 @@ let { episode, series, allEpisodes, onBack }: Props = $props(); - // Check if an episode matches the focused episode (by ID or season/episode number) + // Pure logic lives in ./episodeStrip.ts (unit-tested). Wrap for local use. function isCurrentEpisode(ep: MediaItem): boolean { - if (ep.id === episode.id) return true; - // Also match by season/episode number in case IDs differ - return ep.parentIndexNumber === episode.parentIndexNumber && - ep.indexNumber === episode.indexNumber; + return isSameEpisode(ep, episode); } - // Find adjacent episodes - use season/episode numbers if ID not found - const adjacentEpisodes = $derived(() => { - // First, try to find the episode by ID - let idx = allEpisodes.findIndex((e) => e.id === episode.id); - - // If not found by ID, try to find by season/episode number - if (idx === -1 && episode.parentIndexNumber !== undefined && episode.indexNumber !== undefined) { - idx = allEpisodes.findIndex( - (e) => e.parentIndexNumber === episode.parentIndexNumber && e.indexNumber === episode.indexNumber - ); - } - - // If still not found, filter to same season and show those centered around the episode number - if (idx === -1) { - const sameSeasonEpisodes = allEpisodes - .filter((e) => e.parentIndexNumber === episode.parentIndexNumber) - .sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0)); - - if (sameSeasonEpisodes.length > 0) { - // Find position based on episode number - const epNum = episode.indexNumber || 1; - const centerIdx = sameSeasonEpisodes.findIndex((e) => (e.indexNumber || 0) >= epNum); - const actualIdx = centerIdx === -1 ? sameSeasonEpisodes.length - 1 : centerIdx; - const start = Math.max(0, actualIdx - 3); - const end = Math.min(sameSeasonEpisodes.length, actualIdx + 7); - const result = sameSeasonEpisodes.slice(start, end); - - // Insert the focused episode if not already present (by season/episode number match) - const hasCurrentEpisode = result.some(isCurrentEpisode); - if (!hasCurrentEpisode) { - // Insert at correct position based on episode number - const insertIdx = result.findIndex((e) => (e.indexNumber || 0) > epNum); - if (insertIdx === -1) { - result.push(episode); - } else { - result.splice(insertIdx, 0, episode); - } - } - return result; - } - // Last resort: return focused episode with first 9 episodes - return [episode, ...allEpisodes.slice(0, 9)]; - } - - // Get 3 before and 6 after (or adjust based on position) - const start = Math.max(0, idx - 3); - const end = Math.min(allEpisodes.length, idx + 7); - return allEpisodes.slice(start, end); - }); + const adjacentEpisodes = $derived(() => computeAdjacent(episode, allEpisodes)); // Compute best backdrop source (no fetch, pure derivation) const backdropSource = $derived.by(() => { diff --git a/src/lib/components/library/episodeStrip.test.ts b/src/lib/components/library/episodeStrip.test.ts new file mode 100644 index 00000000..f1f5c43f --- /dev/null +++ b/src/lib/components/library/episodeStrip.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import type { MediaItem } from "$lib/api/types"; +import { isCurrentEpisode, adjacentEpisodes } from "./episodeStrip"; + +// Minimal episode factory — only the fields the strip logic reads. +function ep( + id: string, + season: number | null, + number: number | null, +): MediaItem { + return { + id, + name: `S${season}E${number}`, + kind: "episode", + parentIndexNumber: season, + indexNumber: number, + } as unknown as MediaItem; +} + +function season(n: number, count: number): MediaItem[] { + return Array.from({ length: count }, (_, i) => ep(`s${n}e${i + 1}`, n, i + 1)); +} + +describe("isCurrentEpisode", () => { + const current = ep("abc", 1, 3); + + it("matches by id", () => { + expect(isCurrentEpisode(ep("abc", 9, 9), current)).toBe(true); + }); + + it("matches by season+episode number when id differs", () => { + expect(isCurrentEpisode(ep("other", 1, 3), current)).toBe(true); + }); + + it("does not match a different episode number", () => { + expect(isCurrentEpisode(ep("other", 1, 4), current)).toBe(false); + }); + + it("does NOT treat two number-less episodes as the same (the reported bug)", () => { + const a = ep("a", null, null); + const b = ep("b", null, null); + expect(isCurrentEpisode(a, b)).toBe(false); + }); + + it("does not match when only one side has numbers", () => { + expect(isCurrentEpisode(ep("a", null, null), current)).toBe(false); + expect(isCurrentEpisode(ep("a", 1, 3), ep("b", null, null))).toBe(false); + }); +}); + +describe("adjacentEpisodes", () => { + it("returns just the current episode when there are no others", () => { + const current = ep("only", 1, 1); + expect(adjacentEpisodes(current, [])).toEqual([current]); + }); + + it("returns siblings, not just the current episode", () => { + const eps = season(1, 8); + const current = eps[2]; // S1E3 + const strip = adjacentEpisodes(current, eps); + expect(strip.length).toBeGreaterThan(1); + expect(strip).toContain(current); + }); + + it("windows to 3 before and 6 after the current episode", () => { + const eps = season(1, 20); + const current = eps[9]; // S1E10, index 9 + const strip = adjacentEpisodes(current, eps); + // start = max(0, 9-3)=6 (E7), end = min(20, 9+7)=16 → E7..E16 (10 items) + expect(strip.map((e) => e.indexNumber)).toEqual([7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); + expect(strip).toContain(current); + }); + + it("restricts to the current season when multiple seasons are present", () => { + const eps = [...season(1, 5), ...season(2, 5)]; + const current = eps[6]; // S2E2 + const strip = adjacentEpisodes(current, eps); + expect(strip.every((e) => e.parentIndexNumber === 2)).toBe(true); + }); + + it("splices in a directly-fetched episode absent from the list (ID mismatch)", () => { + const eps = season(1, 5); + // Focused episode has a different id than any in the list but same numbers. + const current = ep("fetched-directly", 1, 3); + const strip = adjacentEpisodes(current, eps); + // It should appear once, anchored at its numeric position, alongside siblings. + expect(strip.filter((e) => e.indexNumber === 3).length).toBe(1); + expect(strip.length).toBeGreaterThan(1); + }); + + it("falls back to the full list when the current season is unknown", () => { + const eps = season(1, 5); + const current = ep("mystery", null, 3); // no season number + const strip = adjacentEpisodes(current, eps); + expect(strip.length).toBeGreaterThan(1); + }); +}); diff --git a/src/lib/components/library/episodeStrip.ts b/src/lib/components/library/episodeStrip.ts new file mode 100644 index 00000000..6b2604d6 --- /dev/null +++ b/src/lib/components/library/episodeStrip.ts @@ -0,0 +1,61 @@ +// 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); +} diff --git a/src/routes/library/[id]/+page.svelte b/src/routes/library/[id]/+page.svelte index 60abf7be..874ef2ce 100644 --- a/src/routes/library/[id]/+page.svelte +++ b/src/routes/library/[id]/+page.svelte @@ -147,6 +147,34 @@ // Sort seasons by index number seasonData.sort((a, b) => (a.season.indexNumber || 0) - (b.season.indexNumber || 0)); + // Some series expose episodes directly as children rather than under + // season folders. In that case the season fetch above yields nothing — + // group the flat episode children by their season number so the Episode + // Focus View still has a populated `allEpisodes` (otherwise "More + // Episodes" collapses to just the current episode). + if (seasonData.every((s) => s.episodes.length === 0)) { + const flatEpisodes = $libraryItems.filter((i) => i.kind === "episode"); + if (flatEpisodes.length > 0) { + const bySeason = new Map(); + for (const ep of flatEpisodes) { + const key = ep.parentIndexNumber ?? 1; + (bySeason.get(key) ?? bySeason.set(key, []).get(key)!).push(ep); + } + seasonData = [...bySeason.entries()] + .sort(([a], [b]) => a - b) + .map(([seasonNumber, episodes]) => ({ + // Synthesize a minimal season header from the episodes we have. + season: { + ...(seasons.find((s) => s.indexNumber === seasonNumber) ?? episodes[0]), + kind: "season", + indexNumber: seasonNumber, + name: `Season ${seasonNumber}`, + } as MediaItem, + episodes: episodes.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0)), + })); + } + } + // If we have a focused episode ID but couldn't find it in the seasons, // fetch it directly (handles ID mismatch between APIs) const episodeIdParam = $page.url.searchParams.get("episode");