feat(series): land on the current episode, not season 1 (UR-062, UR-063, UR-064)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 5m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 18m49s
Build & Release / Build Windows (push) Successful in 14m4s
Build & Release / Build Android (push) Successful in 30m17s
Build & Release / Create Release (push) Successful in 18s

Opening a series dumped the viewer at the top of season 1, and its Play
button played nothing at all: it resolved `$libraryItems[0]` — the first
*season* by SortName — and navigated to `/player/<seasonId>`, which the
player route bounced straight back to `/library/<seasonId>`.

The backend could already answer "where is this viewer in this show":
`repository_get_next_up_episodes` has accepted a `series_id` since it was
written and no caller had ever passed one.

Backend (DR-101, DR-106)
- `repository/series_progress.rs`: `pick_current_episode` — in progress,
  else Next Up, else first unwatched, else the premiere. The third rung is
  the offline path, where Next Up is always empty. `sort_series_order` puts
  specials (season 0) after the numbered seasons.
- `repository_get_series_episodes` takes over the season fan-out and the
  flat-series fallback, which were domain knowledge living in the frontend.
- `clear_watch_history` maps to Jellyfin's mark-unplayed (recursive over a
  container, also zeroes resume). Offline it refuses rather than diverging
  state the next sync would undo.

Frontend (DR-102, DR-103, DR-104, DR-107)
- Seasons collapse; only the current one is expanded, and the current
  episode is badged and scrolled into view.
- Hero button reads `Resume S2E4` / `Play S1E1` and opens that episode's
  focus view, where Play commits (ux-flows §5B.5).
- Seasons are no longer a destination: `/library/<seasonId>` redirects to
  `/library/<seriesId>#season-N`, and every inbound link follows.
- The "More Episodes" strip spans the whole series, so a season finale
  offers the next premiere instead of dead-ending (§5B.2).
- Clear-history buttons on the series hero and each season header.

Routes (DR-105)
- `/library/tv` and `/library/movies` absorb their all-titles and genres
  pages as `?view=` tabs; the four legacy routes redirect. 6 video routes
  become 2, and `/library/shows/genres` stops being the odd one out.

Logic extracted to `seriesNavigation.ts`, `episodeStrip.ts` and
`libraryView.ts` so it is unit-tested rather than buried in components.
Spec: docs/specs/series-current-episode-navigation.md
This commit is contained in:
2026-08-03 20:37:43 +02:00
parent a818fee297
commit 58f2506966
47 changed files with 3178 additions and 1130 deletions
+119 -71
View File
@@ -1,4 +1,4 @@
<!-- TRACES: UR-035, UR-038, UR-048 | DR-043, DR-062 -->
<!-- TRACES: UR-035, UR-038, UR-048, UR-062 | DR-043, DR-062, DR-102, DR-103 -->
<script lang="ts">
import { onMount, untrack } from "svelte";
import { page } from "$app/stores";
@@ -17,6 +17,7 @@
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 ClearHistoryButton from "$lib/components/library/ClearHistoryButton.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";
@@ -28,17 +29,27 @@
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[];
}
import {
groupEpisodesBySeason,
seasonAnchorId,
seasonRedirectTarget,
episodeFocusHref,
seriesPlayHref,
seriesPlayLabel,
initialExpandedSeasons,
type SeasonData,
} from "$lib/components/library/seriesNavigation";
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);
// The episode the viewer is up to. Resolved by Rust (DR-101), not here.
let currentEpisode = $state<MediaItem | null>(null);
// Season ids whose episode list is open. A reading position, not a saved
// preference, so it resets with each load (DR-107).
let expandedSeasons = $state<Set<string>>(new Set());
// Track if we've done an initial load and previous server state
let hasLoadedOnce = false;
@@ -81,10 +92,25 @@
error = null;
seasonData = [];
directFetchedEpisode = null;
currentEpisode = null;
expandedSeasons = new Set();
}
try {
item = await library.loadItem(itemId);
// A season is not a destination — send it to its series, anchored at that
// season, so the episodes of every season stay one continuous list.
// TRACES: UR-062 | DR-103
if (item?.kind === "season") {
const target = seasonRedirectTarget(item);
if (target) {
await goto(target, { replaceState: true });
return;
}
// No seriesId (stale cache / deep link) — fall through to the generic
// rendering below rather than stranding the user.
}
console.log(`[LibraryDetail] ✓ Loaded item: ${item?.name} (${item?.kind})`);
console.log(`[LibraryDetail] - Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`);
if (item?.people) {
@@ -129,64 +155,42 @@
}
}
// For Series, load seasons and their episodes
// For Series, load every episode across all seasons plus the episode the
// viewer is up to. Both come from Rust: the season fan-out (and the
// flat-series fallback for shows whose children are episodes rather than
// season folders) is Jellyfin's shape, and "which episode is current" is
// domain policy — neither belongs in the presentation layer.
// TRACES: UR-062 | DR-101, DR-102
if (item?.kind === "series") {
const seasons = $libraryItems.filter((i) => i.kind === "season");
const repo = auth.getRepository();
const seasons = $libraryItems.filter((i) => i.kind === "season");
// 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 };
});
const [episodes, current] = await Promise.all([
repo.getSeriesEpisodes(itemId),
// Best-effort: a series still renders if the anchor cannot be resolved.
repo.getSeriesCurrentEpisode(itemId).catch((e) => {
console.warn("Could not resolve the current episode:", e);
return null;
}),
]);
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)),
}));
}
}
seasonData = groupEpisodesBySeason(seasons, episodes);
currentEpisode = current;
// Open only the season the viewer is in (DR-107).
expandedSeasons = initialExpandedSeasons(
seasonData,
current?.id,
$page.url.searchParams.get("episode")
);
// 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);
}
if (episodeIdParam && !episodes.some((e) => e.id === episodeIdParam)) {
try {
directFetchedEpisode = await repo.getItem(episodeIdParam);
} catch {
console.warn("Could not fetch focused episode directly:", episodeIdParam);
}
}
}
@@ -224,14 +228,21 @@
return;
}
switch (clickedItem.kind) {
case "series":
// A season link lands on its series, anchored at that season — seasons
// have no page of their own (DR-103).
case "season":
goto(seasonRedirectTarget(clickedItem) ?? `/library/${clickedItem.id}`);
break;
// An episode always opens in the context of its series (ux-flows §5B.1).
case "episode":
goto(episodeFocusHref(clickedItem));
break;
case "series":
case "album":
case "artist":
case "folder":
case "playlist":
case "channel":
case "episode":
case "movie":
goto(`/library/${clickedItem.id}`);
break;
@@ -244,15 +255,29 @@
// Removed custom handleTrackClick - let TrackList use its built-in playback logic
// This fixes Android playback issues where navigation-based approach was hanging
function toggleSeason(seasonId: string) {
// Reassign rather than mutate — a Set mutation is invisible to $state.
const next = new Set(expandedSeasons);
if (!next.delete(seasonId)) next.add(seasonId);
expandedSeasons = next;
}
function handleEpisodeClick(episode: MediaItem) {
// Play the episode with the series queued for next episode
goto(`/player/${episode.id}`);
// Swap focus to the episode in place; playback starts from the focus view's
// own Play button, never from a list tap (ux-flows §5B.1, §5B.5).
goto(episodeFocusHref(episode));
}
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 === "series" && itemId) {
// Open the episode the viewer is up to, where an explicit Play/Resume
// commits. Play on a container navigates; Play on a leaf plays.
// TRACES: UR-062 | DR-102
const target = seriesPlayHref(itemId, currentEpisode);
if (target) goto(target);
} else if (item?.kind === "album" && $libraryItems.length > 0) {
// For albums, use the backend command (backend fetches and queues all tracks)
try {
@@ -293,6 +318,11 @@
console.error("Failed to shuffle play album:", e);
alert(`Failed to shuffle play: ${e instanceof Error ? e.message : 'Unknown error'}`);
}
} else if (item?.kind === "series" && allEpisodes.length > 0) {
// Shuffle a *series* means a random episode, not a random season — the
// player has nothing to do with a season id.
const random = allEpisodes[Math.floor(Math.random() * allEpisodes.length)];
goto(`/player/${random.id}?restart=true`);
} else if ($libraryItems.length > 0) {
const randomIndex = Math.floor(Math.random() * $libraryItems.length);
goto(`/player/${$libraryItems[randomIndex].id}?queue=parent:${itemId}&shuffle=true`);
@@ -304,6 +334,10 @@
seasonData.flatMap((s) => s.episodes)
);
const playLabel = $derived(item?.kind === "series" ? seriesPlayLabel(currentEpisode) : "Play");
// An empty series has nowhere for the hero button to lead.
const canPlay = $derived(item?.kind !== "series" || currentEpisode !== null);
// Find the focused episode (check allEpisodes first, then fall back to directly fetched)
const focusedEpisode = $derived(
focusedEpisodeId
@@ -422,9 +456,11 @@
{/if}
{#if item.parentIndexNumber || item.indexNumber}
<p class="text-lg text-gray-400 mt-1">
{#if item.seasonId && item.parentIndexNumber}
<!-- Links to the season's place in the series list, not to a
season page — seasons have none (DR-103). -->
{#if item.seriesId && item.parentIndexNumber}
<a
href={`/library/${item.seasonId}`}
href={`/library/${item.seriesId}#${seasonAnchorId(item.parentIndexNumber)}`}
class="hover:underline hover:text-[var(--color-jellyfin)] transition-colors"
>Season {item.parentIndexNumber}</a>
{:else if item.parentIndexNumber}Season {item.parentIndexNumber}{/if}
@@ -466,15 +502,17 @@
<!-- 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 canPlay}
<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>
{playLabel}
</button>
{/if}
{#if item.kind !== "episode" && item.kind !== "movie"}
<button
onclick={handleShufflePlay}
@@ -498,6 +536,12 @@
seriesName={item.name}
episodeCount={allEpisodes.length || undefined}
/>
<ClearHistoryButton
itemId={item.id}
itemName={item.name}
scope="series"
onCleared={loadItem}
/>
{:else if item.kind === "movie"}
<VideoDownloadButton
itemId={item.id}
@@ -627,7 +671,11 @@
{season}
{episodes}
focusedEpisodeId={focusedEpisodeId ?? undefined}
currentEpisodeId={currentEpisode?.id}
expanded={expandedSeasons.has(season.id)}
onToggle={() => toggleSeason(season.id)}
onEpisodeClick={handleEpisodeClick}
onHistoryCleared={loadItem}
/>
{/each}
{/if}