Files
jellytau/src/routes/library/[id]/+page.svelte
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

670 lines
26 KiB
Svelte

<!-- TRACES: UR-035, UR-038, UR-048 | DR-043, DR-062 -->
<script lang="ts">
import { onMount, untrack } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { kindLabel } from "$lib/utils/mediaKind";
import { commands } from "$lib/api/bindings";
import type { MediaItem, Library } from "$lib/api/types";
import { library, libraryItems, isLibraryLoading, currentLibrary, libraries } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import { isServerReachable } from "$lib/stores/connectivity";
import { downloads } from "$lib/stores/downloads";
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
import TrackList from "$lib/components/library/TrackList.svelte";
import AlbumDownloadButton from "$lib/components/library/AlbumDownloadButton.svelte";
import SeasonSection from "$lib/components/library/SeasonSection.svelte";
import EpisodeFocusView from "$lib/components/library/EpisodeFocusView.svelte";
import SeriesDownloadButton from "$lib/components/library/SeriesDownloadButton.svelte";
import VideoDownloadButton from "$lib/components/library/VideoDownloadButton.svelte";
import CastSection from "$lib/components/library/CastSection.svelte";
import PersonDetailView from "$lib/components/library/PersonDetailView.svelte";
import RelatedItemsSection from "$lib/components/library/RelatedItemsSection.svelte";
import ArtistDetailView from "$lib/components/library/ArtistDetailView.svelte";
import PlaylistDetailView from "$lib/components/library/PlaylistDetailView.svelte";
import CrewLinks from "$lib/components/library/CrewLinks.svelte";
import GenreTags from "$lib/components/library/GenreTags.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import BackButton from "$lib/components/common/BackButton.svelte";
import ArtistLinks from "$lib/components/library/ArtistLinks.svelte";
interface SeasonData {
season: MediaItem;
episodes: MediaItem[];
}
let item = $state<MediaItem | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let seasonData = $state<SeasonData[]>([]);
let directFetchedEpisode = $state<MediaItem | null>(null);
// Track if we've done an initial load and previous server state
let hasLoadedOnce = false;
let previousServerReachable = false;
const itemId = $derived($page.params.id);
const focusedEpisodeId = $derived($page.url.searchParams.get("episode"));
onMount(async () => {
await loadItem();
hasLoadedOnce = true;
});
$effect(() => {
if (itemId) {
loadItem();
hasLoadedOnce = true;
}
});
// Reload when server becomes reachable (handles cache-first timing issue)
$effect(() => {
const serverReachable = $isServerReachable;
// If server just became reachable and we've already loaded, reload to get fresh data
if (serverReachable && !previousServerReachable && hasLoadedOnce && itemId) {
loadItem();
}
previousServerReachable = serverReachable;
});
async function loadItem() {
if (!itemId) return;
// Only show spinner when navigating to a different item
// untrack prevents $effect from tracking `item` as a dependency (avoids infinite loop)
const isNewItem = untrack(() => !item || item.id !== itemId);
if (isNewItem) {
loading = true;
error = null;
seasonData = [];
directFetchedEpisode = null;
}
try {
item = await library.loadItem(itemId);
console.log(`[LibraryDetail] ✓ Loaded item: ${item?.name} (${item?.kind})`);
console.log(`[LibraryDetail] - Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`);
if (item?.people) {
item.people.forEach((p, i) => {
console.log(`[LibraryDetail] [${i}] ${p.name} (${p.type})`);
});
}
// Set currentLibrary for music items if not already set
// This ensures navigation to music library pages works correctly
if ((item?.kind === "album" || item?.kind === "artist" || item?.kind === "track") && !$currentLibrary) {
// Find the music library
if ($libraries.length === 0) {
await library.loadLibraries();
}
const musicLibrary = $libraries.find(lib => lib.collectionType === "music");
if (musicLibrary) {
library.setCurrentLibrary(musicLibrary);
console.log("[LibraryDetail] Set current library to music library for music item");
}
}
await library.loadItems(itemId, { limit: 100 });
// Ensure cast/crew data is loaded for Movies, Series, and Episodes
// Some APIs/caches may not include people data on first load
if ((item?.kind === "movie" || item?.kind === "series" || item?.kind === "episode") && (!item.people || item.people.length === 0)) {
console.log(`[LibraryDetail] ⚠ People data missing, reloading ${item?.kind}...`);
try {
const repo = auth.getRepository();
const fullItem = await repo.getItem(itemId);
console.log(`[LibraryDetail] - Reloaded has people? ${fullItem.people ? `YES (${fullItem.people.length})` : 'NO'}`);
if (fullItem.people && fullItem.people.length > 0) {
item = fullItem;
console.log(`[LibraryDetail] ✓ Updated item with ${fullItem.people.length} people`);
fullItem.people.forEach((p, i) => {
console.log(`[LibraryDetail] [${i}] ${p.name} (${p.type})`);
});
}
} catch (e) {
console.warn(`Could not reload ${item?.kind} with full cast data:`, e);
}
}
// For Series, load seasons and their episodes
if (item?.kind === "series") {
const seasons = $libraryItems.filter((i) => i.kind === "season");
const repo = auth.getRepository();
// Load episodes for each season in parallel
const seasonDataPromises = seasons.map(async (season) => {
const result = await repo.getItems(season.id, { limit: 100 });
const episodes = result.items
.filter((i) => i.kind === "episode")
.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0));
return { season, episodes };
});
seasonData = await Promise.all(seasonDataPromises);
// 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<number, MediaItem[]>();
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");
if (episodeIdParam) {
const allEps = seasonData.flatMap((s) => s.episodes);
const foundInSeasons = allEps.some((e) => e.id === episodeIdParam);
if (!foundInSeasons) {
try {
directFetchedEpisode = await repo.getItem(episodeIdParam);
} catch {
console.warn("Could not fetch focused episode directly:", episodeIdParam);
}
}
}
}
} catch (e) {
error = e instanceof Error ? e.message : "Failed to load item";
} finally {
loading = false;
}
}
// Images now handled by CachedImage component
function formatDuration(ms?: number | null): string {
if (!ms) return "";
const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
function handleItemClick(clickedItem: MediaItem | Library) {
if (!("kind" in clickedItem)) {
// Library item - navigate to library
goto(`/library/${clickedItem.id}`);
return;
}
// A ChannelFolderItem can be either a folder (drill in) or a playable leaf.
// Route non-folder channel items straight to the player.
if (clickedItem.kind === "channelItem" || clickedItem.kind === "liveChannel") {
goto(`/player/${clickedItem.id}`);
return;
}
switch (clickedItem.kind) {
case "series":
case "season":
case "album":
case "artist":
case "folder":
case "playlist":
case "channel":
case "episode":
case "movie":
goto(`/library/${clickedItem.id}`);
break;
default:
goto(`/player/${clickedItem.id}`);
break;
}
}
// Removed custom handleTrackClick - let TrackList use its built-in playback logic
// This fixes Android playback issues where navigation-based approach was hanging
function handleEpisodeClick(episode: MediaItem) {
// Play the episode with the series queued for next episode
goto(`/player/${episode.id}`);
}
async function handlePlayAll() {
// For single items (Episode, Movie), play the item directly
if (item?.kind === "episode" || item?.kind === "movie") {
goto(`/player/${itemId}`);
} else if (item?.kind === "album" && $libraryItems.length > 0) {
// For albums, use the backend command (backend fetches and queues all tracks)
try {
const repo = auth.getRepository();
const repositoryHandle = repo.getHandle();
const firstTrack = $libraryItems[0];
await commands.playerPlayAlbumTrack(repositoryHandle, {
albumId: item.id,
albumName: item.name,
trackId: firstTrack.id,
shuffle: false,
});
} catch (e) {
console.error("Failed to play album:", e);
alert(`Failed to play album: ${e instanceof Error ? e.message : 'Unknown error'}`);
}
} else if ($libraryItems.length > 0) {
// For other collections, start playing first item
goto(`/player/${$libraryItems[0].id}?queue=parent:${itemId}`);
}
}
async function handleShufflePlay() {
if (item?.kind === "album" && $libraryItems.length > 0) {
// For albums, use the backend command with shuffle
try {
const repo = auth.getRepository();
const repositoryHandle = repo.getHandle();
// Pick a random track to start with
const randomTrack = $libraryItems[Math.floor(Math.random() * $libraryItems.length)];
await commands.playerPlayAlbumTrack(repositoryHandle, {
albumId: item.id,
albumName: item.name,
trackId: randomTrack.id,
shuffle: true,
});
} catch (e) {
console.error("Failed to shuffle play album:", e);
alert(`Failed to shuffle play: ${e instanceof Error ? e.message : 'Unknown error'}`);
}
} else if ($libraryItems.length > 0) {
const randomIndex = Math.floor(Math.random() * $libraryItems.length);
goto(`/player/${$libraryItems[randomIndex].id}?queue=parent:${itemId}&shuffle=true`);
}
}
// For episode focus view: get all episodes across all seasons
const allEpisodes = $derived(
seasonData.flatMap((s) => s.episodes)
);
// Find the focused episode (check allEpisodes first, then fall back to directly fetched)
const focusedEpisode = $derived(
focusedEpisodeId
? allEpisodes.find((e) => e.id === focusedEpisodeId) ?? directFetchedEpisode
: null
);
const isMusicItem = $derived(
item?.kind === "track" || item?.kind === "album" || item?.kind === "artist" || item?.kind === "playlist"
);
function handleBackToSeries() {
// Navigate to series page without the episode param
goto(`/library/${itemId}`);
}
function goBack() {
// Prefer real history so the user returns to wherever they came from
// (libraries grid, a list page, a parent album/artist, search, etc.),
// falling back to the libraries overview on a fresh deep-link.
navigateBack("/library");
}
</script>
<div class="relative">
<!-- Backdrop -->
{#if item?.backdropImageTags?.[0]}
<div class="absolute inset-0 -z-10 h-96 overflow-hidden">
<CachedImage
itemId={item.id}
imageType="Backdrop"
tag={item.backdropImageTags[0]}
maxWidth={1920}
class="w-full h-full object-cover opacity-30"
/>
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-[var(--color-background)]"></div>
</div>
{/if}
{#if loading}
<div class="flex justify-center py-12">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else if error}
<div class="text-center py-12">
<p class="text-red-400">{error}</p>
<button
onclick={() => goto("/library")}
class="mt-4 text-[var(--color-jellyfin)] hover:underline"
>
Back to library
</button>
</div>
{:else if item}
<!-- Person Detail View - shown for Person items -->
{#if item.kind === "person"}
<div class="pt-4">
<BackButton onClick={goBack} label="Back" />
</div>
<PersonDetailView person={item} />
<!-- Episode Focus View - shown when navigating with ?episode param -->
{:else if item.kind === "series" && focusedEpisode}
<EpisodeFocusView
episode={focusedEpisode}
series={item}
{allEpisodes}
onBack={handleBackToSeries}
/>
{:else}
<div class="space-y-8">
<!-- Back navigation -->
<div class="pt-4">
<BackButton onClick={goBack} label="Back" />
</div>
<!-- Header with item info -->
<div class="flex gap-6">
<!-- Poster -->
<div class="flex-shrink-0 w-48">
{#if item.imageId}
<CachedImage
itemId={item.id}
imageType="Primary"
tag={item.imageId}
maxWidth={400}
alt={item.name}
class="w-full {isMusicItem ? 'aspect-square' : ''} rounded-lg shadow-lg"
/>
{:else}
<div class="w-full {isMusicItem ? 'aspect-square' : 'aspect-[2/3]'} bg-[var(--color-surface)] rounded-lg flex items-center justify-center">
<svg class="w-16 h-16 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
{#if isMusicItem}
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
{:else}
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
{/if}
</svg>
</div>
{/if}
</div>
<!-- Info -->
<div class="flex-1 space-y-4">
<div>
<h1 class="text-3xl font-bold text-white">{item.name}</h1>
{#if item.kind === "episode"}
<!-- Links back to the parent series/season so the episode detail
page is a navigable hub, not a dead end. TRACES: UR-058 | DR-087 -->
{#if item.seriesId && item.seriesName}
<p class="text-lg mt-1">
<a
href={`/library/${item.seriesId}`}
class="text-[var(--color-jellyfin)] hover:underline"
>{item.seriesName}</a>
</p>
{/if}
{#if item.parentIndexNumber || item.indexNumber}
<p class="text-lg text-gray-400 mt-1">
{#if item.seasonId && item.parentIndexNumber}
<a
href={`/library/${item.seasonId}`}
class="hover:underline hover:text-[var(--color-jellyfin)] transition-colors"
>Season {item.parentIndexNumber}</a>
{:else if item.parentIndexNumber}Season {item.parentIndexNumber}{/if}
{#if item.parentIndexNumber && item.indexNumber}, {/if}
{#if item.indexNumber}Episode {item.indexNumber}{/if}
</p>
{/if}
{:else if item.artistItems?.length || item.artists?.length}
<p class="text-lg text-gray-400 mt-1">
<ArtistLinks
artistItems={item.artistItems}
artists={item.artists}
linkClass="text-lg text-[var(--color-jellyfin)] hover:underline"
textClass="text-gray-400"
/>
</p>
{:else if item.productionYear}
<p class="text-lg text-gray-400 mt-1">{item.productionYear}</p>
{/if}
</div>
<!-- Metadata -->
<div class="flex items-center gap-4 text-sm text-gray-400">
{#if kindLabel(item.kind)}
<span class="px-2 py-1 bg-[var(--color-surface)] rounded">{kindLabel(item.kind)}</span>
{/if}
{#if item.durationMs}
<span>{formatDuration(item.durationMs)}</span>
{/if}
{#if item.communityRating}
<span class="flex items-center gap-1">
<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
{item.communityRating.toFixed(1)}
</span>
{/if}
</div>
<!-- Actions -->
<div class="flex gap-3 flex-wrap">
<button
onclick={handlePlayAll}
class="px-6 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Play
</button>
{#if item.kind !== "episode" && item.kind !== "movie"}
<button
onclick={handleShufflePlay}
class="px-6 py-2 bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)] rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/>
</svg>
Shuffle
</button>
{/if}
{#if item.kind === "album"}
<AlbumDownloadButton
albumId={item.id}
albumName={item.name}
tracks={$libraryItems}
/>
{:else if item.kind === "series"}
<SeriesDownloadButton
seriesId={item.id}
seriesName={item.name}
episodeCount={allEpisodes.length || undefined}
/>
{:else if item.kind === "movie"}
<VideoDownloadButton
itemId={item.id}
itemName={item.name}
isMovie={true}
size="lg"
/>
{:else if item.kind === "episode"}
<VideoDownloadButton
itemId={item.id}
itemName={item.name}
isMovie={false}
size="lg"
/>
{/if}
</div>
<!-- Overview -->
{#if item.overview}
<p class="text-gray-300 leading-relaxed max-w-2xl">{item.overview}</p>
{/if}
</div>
</div>
<!-- Crew Links - for Movies and Series -->
{#if item.people && (item.kind === "movie" || item.kind === "series")}
<div class="space-y-2">
{#if item.people.some(p => p.type === "Director")}
<CrewLinks
people={item.people ?? undefined}
roleFilter={["Director"]}
label="Directed by"
maxShow={3}
/>
{/if}
{#if item.people.some(p => p.type === "Writer")}
<CrewLinks
people={item.people ?? undefined}
roleFilter={["Writer"]}
label="Written by"
maxShow={3}
/>
{/if}
{#if item.people.some(p => p.type === "Composer")}
<CrewLinks
people={item.people ?? undefined}
roleFilter={["Composer"]}
label="Music by"
maxShow={2}
/>
{/if}
</div>
{/if}
<!-- Genre Tags -->
{#if item.genres?.length}
<div>
<GenreTags genres={item.genres ?? undefined} maxShow={6} itemKind={item.kind} />
</div>
{/if}
<!-- Cast / Related — for Movies and Episodes these sit above the content
block; for Series they render *below* the seasons instead, so
continuation content precedes discovery content (UX §5B.4). -->
{#if item.kind !== "series"}
<!-- Cast Section - for Movies and Episodes -->
{#if (item.kind === "movie" || item.kind === "episode") && item.people?.length}
<CastSection people={item.people ?? undefined} />
{/if}
<!-- Related Items Section - for Movies -->
{#if item.kind === "movie" && (item.genres?.length || item.people?.length)}
<RelatedItemsSection
currentItemId={item.id}
itemKind={item.kind}
genres={item.genres ?? undefined}
people={item.people ?? undefined}
limit={12}
/>
{/if}
{/if}
<!-- Content items -->
<div>
{#if item.kind === "album"}
<!-- Tracks in list view -->
<div class="space-y-8">
<div class="space-y-4">
<h2 class="text-xl font-semibold text-white">Tracks</h2>
<TrackList
tracks={$libraryItems}
loading={$isLibraryLoading}
showArtist={false}
showAlbum={false}
showDownload={true}
context={{ type: "album", albumId: item.id, albumName: item.name }}
/>
</div>
<!-- Related Albums -->
{#if item.genres?.length || item.artistItems?.length}
<RelatedItemsSection
currentItemId={item.id}
itemKind="album"
genres={item.genres ?? undefined}
artistIds={item.artistItems?.map(a => a.id)}
limit={12}
/>
{/if}
</div>
{:else if item.kind === "series"}
<!-- Series: Seasons with episodes -->
<div class="space-y-8">
{#if $isLibraryLoading}
<div class="flex justify-center py-8">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else if seasonData.length === 0}
<div class="text-center py-12 text-gray-400">
<p>No seasons found</p>
</div>
{:else}
{#each seasonData as { season, episodes } (season.id)}
<SeasonSection
{season}
{episodes}
focusedEpisodeId={focusedEpisodeId ?? undefined}
onEpisodeClick={handleEpisodeClick}
/>
{/each}
{/if}
<!-- Discovery content sits below the episodes (UX §5B.4) -->
{#if item.people?.length}
<CastSection people={item.people ?? undefined} />
{/if}
{#if item.genres?.length || item.people?.length}
<RelatedItemsSection
currentItemId={item.id}
itemKind={item.kind}
genres={item.genres ?? undefined}
people={item.people ?? undefined}
limit={12}
/>
{/if}
</div>
{:else if item.kind === "artist"}
<!-- Enhanced artist detail view with discography -->
<ArtistDetailView artist={item} />
{:else if item.kind === "playlist"}
<!-- Playlist detail view with track management -->
<PlaylistDetailView playlist={item} />
{:else}
<!-- Other content in grid view -->
<LibraryGrid
title="Contents"
items={$libraryItems}
loading={$isLibraryLoading}
onItemClick={handleItemClick}
/>
{/if}
</div>
</div>
{/if}
{/if}
</div>