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
+40
View File
@@ -1267,6 +1267,46 @@ async repositoryGetResumeItems(handle: string, parentId: string | null, limit: n
async repositoryGetNextUpEpisodes(handle: string, seriesId: string | null, limit: number | null) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_next_up_episodes", { handle, seriesId, limit });
},
/**
* Every episode of a series, across all seasons, in series order.
*
* Jellyfin hangs episodes off season folders — except for "flat" series whose
* children are episodes directly. Both shapes are provider vocabulary, so the
* fan-out and its fallback live in Rust rather than being reimplemented in the
* frontend (which is what it used to do).
*
* TRACES: UR-062 | DR-101
*/
async repositoryGetSeriesEpisodes(handle: string, seriesId: string) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_series_episodes", { handle, seriesId });
},
/**
* The episode a viewer should land on when they open a series.
*
* "Current" is domain policy, not layout: an episode in progress, else the
* server's Next Up for the series, else the first unwatched episode, else the
* first. The third rung is what makes this work offline, where Next Up is
* always empty. Returns `None` only when the series has no episodes at all.
*
* TRACES: UR-062 | DR-101
*/
async repositoryGetSeriesCurrentEpisode(handle: string, seriesId: string) : Promise<MediaItem | null> {
return await TAURI_INVOKE("repository_get_series_current_episode", { handle, seriesId });
},
/**
* Erase the viewer's watch history for an item.
*
* Clears the played flag and the resume position; on a series or season the
* server applies it to everything inside. A series cleared this way is "never
* watched" again, so `repository_get_series_current_episode` returns its
* premiere. Requires the server — offline this fails rather than diverging
* local state the next sync would overwrite.
*
* TRACES: UR-064 | DR-106
*/
async repositoryClearWatchHistory(handle: string, itemId: string) : Promise<null> {
return await TAURI_INVOKE("repository_clear_watch_history", { handle, itemId });
},
/**
* Get recently played audio
*/
+31
View File
@@ -137,6 +137,37 @@ export class RepositoryClient {
return commands.repositoryGetNextUpEpisodes(this.ensureHandle(), seriesId ?? null, limit ?? null);
}
/**
* Every episode of a series, across all seasons, already in series order.
* The backend owns the season fan-out and the flat-series fallback.
*
* TRACES: UR-062 | DR-101
*/
async getSeriesEpisodes(seriesId: string): Promise<MediaItem[]> {
return commands.repositoryGetSeriesEpisodes(this.ensureHandle(), seriesId);
}
/**
* The episode the viewer should land on when opening this series. `null` only
* when the series has no episodes.
*
* TRACES: UR-062 | DR-101
*/
async getSeriesCurrentEpisode(seriesId: string): Promise<MediaItem | null> {
return commands.repositoryGetSeriesCurrentEpisode(this.ensureHandle(), seriesId);
}
/**
* Erase watch history for an item. On a series or season the server applies
* it to everything inside, so the container returns to "never watched".
* Requires the server — this fails offline rather than diverging local state.
*
* TRACES: UR-064 | DR-106
*/
async clearWatchHistory(itemId: string): Promise<void> {
await commands.repositoryClearWatchHistory(this.ensureHandle(), itemId);
}
async getRecentlyPlayedAudio(limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetRecentlyPlayedAudio(this.ensureHandle(), limit ?? null);
}
@@ -14,6 +14,7 @@
import { goto } from "$app/navigation";
import type { Library, MediaItem } from "$lib/api/types";
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
import { seasonRedirectTarget, episodeFocusHref } from "$lib/components/library/seriesNavigation";
import { formatBytes } from "$lib/utils/formatBytes";
import {
downloadedCatalog,
@@ -62,6 +63,16 @@
void openLibrary(item as Library);
return;
}
// Seasons and episodes resolve inside their series (DR-103): a season has
// no page of its own and an episode is never browsed bare.
if (item.kind === "season") {
goto(seasonRedirectTarget(item) ?? `/library/${item.id}`);
return;
}
if (item.kind === "episode") {
goto(episodeFocusHref(item));
return;
}
goto(`/library/${item.id}`);
}
@@ -0,0 +1,94 @@
<!--
Erase watch history for a series or a season.
The backend does the work (`repository_clear_watch_history` → Jellyfin's
mark-unplayed, which is recursive over a container and also zeroes resume
positions); this only confirms the intent and reports the outcome. Clearing a
series returns it to "never watched", so it reopens on S1E1.
TRACES: UR-064 | DR-106
-->
<script lang="ts">
import { auth } from "$lib/stores/auth";
import { isServerReachable } from "$lib/stores/connectivity";
interface Props {
/** Series or season id to clear. */
itemId: string;
/** Name shown in the confirm prompt. */
itemName: string;
/** What is being cleared, for the prompt wording. */
scope: "series" | "season";
size?: "sm" | "lg";
/** Called after a successful clear so the caller can reload. */
onCleared?: () => void;
}
let { itemId, itemName, scope, size = "lg", onCleared }: Props = $props();
let busy = $state(false);
const label = $derived(scope === "series" ? "Clear history" : "Clear season history");
async function handleClick() {
if (busy) return;
const subject = scope === "series" ? `all of “${itemName}”` : `“${itemName}”`;
// Destructive and not undoable — always ask, even though the server keeps
// no undo of its own.
if (
!confirm(
`Erase watch history for ${subject}?\n\n` +
"Every episode is marked unwatched and resume positions are cleared. " +
"This cannot be undone."
)
) {
return;
}
busy = true;
try {
await auth.getRepository().clearWatchHistory(itemId);
onCleared?.();
} catch (e) {
console.error("Failed to clear watch history:", e);
alert(
`Could not clear watch history: ${e instanceof Error ? e.message : String(e)}`
);
} finally {
busy = false;
}
}
</script>
<button
onclick={handleClick}
disabled={busy || !$isServerReachable}
title={$isServerReachable
? "Mark everything unwatched and clear resume positions"
: "Needs a connection to the server"}
class="rounded-lg font-medium flex items-center gap-2 transition-colors
bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)]
disabled:opacity-40 disabled:cursor-not-allowed
{size === 'lg' ? 'px-6 py-2' : 'px-3 py-1.5 text-sm'}"
>
{#if busy}
<div
class="border-2 border-current border-t-transparent rounded-full animate-spin
{size === 'lg' ? 'w-5 h-5' : 'w-4 h-4'}"
></div>
{:else}
<svg
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6a7 7 0 1 1 7 7c-1.93
0-3.68-.79-4.94-2.06l-1.42 1.42A8.95 8.95 0 0 0 13 21a9 9 0 0 0
0-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"
/>
</svg>
{/if}
{busy ? "Clearing…" : label}
</button>
@@ -4,7 +4,11 @@
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";
import {
isCurrentEpisode as isSameEpisode,
adjacentEpisodes as computeAdjacent,
stripCardLabel,
} from "./episodeStrip";
interface Props {
episode: MediaItem;
@@ -245,8 +249,8 @@
<!-- Episode info -->
<div class="mt-2 space-y-1">
<div class="flex items-center gap-2">
<span class="text-[var(--color-jellyfin)] text-sm font-semibold">
{ep.indexNumber || 0}.
<span class="text-[var(--color-jellyfin)] text-sm font-semibold whitespace-nowrap">
{stripCardLabel(ep, episode)}
</span>
<p class="text-white font-medium truncate {isCurrent ? 'text-yellow-400' : 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors">
{ep.name}
+20 -3
View File
@@ -10,15 +10,21 @@
interface Props {
episode: MediaItem;
focused?: boolean;
/**
* This is the episode the viewer is up to. Marked and scrolled to when the
* series page opens, so a viewer four seasons deep lands on their place
* instead of the top of season 1. TRACES: UR-062 | DR-102
*/
current?: boolean;
onclick?: () => void;
}
let { episode, focused = false, onclick }: Props = $props();
let { episode, focused = false, current = false, onclick }: Props = $props();
let buttonRef: HTMLButtonElement | null = null;
onMount(() => {
if (focused && buttonRef) {
if ((focused || current) && buttonRef) {
// Scroll into view with some offset from top
setTimeout(() => {
buttonRef?.scrollIntoView({ behavior: "smooth", block: "center" });
@@ -51,7 +57,11 @@
<button
bind:this={buttonRef}
type="button"
class="group/row flex gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused ? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]' : ''}"
class="group/row flex gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused
? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]'
: current
? 'ring-2 ring-yellow-400 bg-[var(--color-surface)]'
: ''}"
{onclick}
>
<!-- Thumbnail -->
@@ -137,6 +147,13 @@
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
{truncateMiddle(episode.name, 56)}
</h3>
{#if current}
<span
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
>
Up next
</span>
{/if}
<!-- Played indicator -->
{#if episode.userData?.isPlayed}
<svg class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
@@ -34,9 +34,16 @@
interface Props {
config: GenreConfig;
/**
* Suppress the back button + title when this renders as a *tab* of a
* library page that already has a header. Drilling into a single genre
* still shows the header — there the back button is the way out.
* TRACES: UR-063 | DR-105
*/
showHeader?: boolean;
}
let { config }: Props = $props();
let { config, showHeader = true }: Props = $props();
let genres = $state<Genre[]>([]);
let filteredGenres = $state<Genre[]>([]);
@@ -153,17 +160,20 @@
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center gap-4">
<BackButton onClick={goBack} label="Back" />
<h1 class="text-3xl font-bold text-white">
{#if selectedGenre}
{selectedGenre.name}
{:else}
{config.title}
{/if}
</h1>
</div>
<!-- Header. Inside a genre the back button is the only way out, so it shows
even when the host page suppresses the top-level header. -->
{#if showHeader || selectedGenre}
<div class="flex items-center gap-4">
<BackButton onClick={goBack} label="Back" />
<h1 class="text-3xl font-bold text-white">
{#if selectedGenre}
{selectedGenre.name}
{:else}
{config.title}
{/if}
</h1>
</div>
{/if}
{#if !selectedGenre}
<!-- Genre Browser -->
@@ -41,9 +41,15 @@
interface Props {
config: MediaListConfig;
/**
* Suppress the back button + title. Set when this renders as a *tab* of a
* library page, which already has its own header — two stacked headers and
* two back buttons read as two pages. TRACES: UR-063 | DR-105
*/
showHeader?: boolean;
}
let { config }: Props = $props();
let { config, showHeader = true }: Props = $props();
let items = $state<MediaItem[]>([]);
let loading = $state(true);
@@ -246,10 +252,12 @@
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center gap-4">
<BackButton onClick={goBack} label="Back" />
<h1 class="text-3xl font-bold text-white">{config.title}</h1>
</div>
{#if showHeader}
<div class="flex items-center gap-4">
<BackButton onClick={goBack} label="Back" />
<h1 class="text-3xl font-bold text-white">{config.title}</h1>
</div>
{/if}
<!-- Search and Sort Bar -->
<div class="flex flex-col sm:flex-row gap-4">
+7 -4
View File
@@ -2,6 +2,7 @@
import { goto } from "$app/navigation";
import type { MediaKind } from "$lib/api/types";
import { libraryViewUrl } from "$lib/utils/libraryView";
interface Props {
genres: string[];
@@ -17,7 +18,9 @@
itemKind
}: Props = $props();
// Map the item kind to its genre-browse route
// Map the item kind to its genre-browse surface. Video genres are a tab of
// the library page now, not a route of their own (DR-105); linking straight
// to the tab avoids a redirect hop through the legacy paths.
function genreBasePath(kind: MediaKind | undefined): string {
switch (kind) {
case "album":
@@ -28,11 +31,11 @@
case "series":
case "season":
case "episode":
return "/library/shows/genres";
return libraryViewUrl("/library/tv", "genres");
case "movie":
return "/library/movies/genres";
return libraryViewUrl("/library/movies", "genres");
default:
return "/library/movies/genres";
return libraryViewUrl("/library/movies", "genres");
}
}
@@ -0,0 +1,44 @@
<!--
Browse / All / Genres for a video library.
These were three routes per library with names that did not agree across the
two libraries; they are now tabs on one route, driven by `?view=` so a tab is
linkable and survives a back navigation.
TRACES: UR-063 | DR-105
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { LIBRARY_VIEWS, libraryViewUrl, type LibraryView } from "$lib/utils/libraryView";
interface Props {
/** Route the tabs live on, e.g. `/library/tv`. */
basePath: string;
active: LibraryView;
/** Per-view labels — "All Shows" vs "All Movies". */
labels: Record<LibraryView, string>;
}
let { basePath, active, labels }: Props = $props();
function select(view: LibraryView) {
if (view === active) return;
// replaceState: switching tabs is not a navigation step worth a back press.
goto(libraryViewUrl(basePath, view), { replaceState: true, noScroll: true });
}
</script>
<nav class="flex items-center gap-1 px-4" aria-label="Library sections">
{#each LIBRARY_VIEWS as view (view)}
<button
onclick={() => select(view)}
aria-current={view === active ? "page" : undefined}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
{view === active
? 'bg-[var(--color-jellyfin)] text-white'
: 'text-gray-400 hover:text-white hover:bg-white/10'}"
>
{labels[view]}
</button>
{/each}
</nav>
+94 -22
View File
@@ -1,26 +1,56 @@
<!-- TRACES: UR-062, UR-064 | DR-102, DR-103, DR-106, DR-107 -->
<script lang="ts">
import type { MediaItem } from "$lib/api/types";
import EpisodeRow from "./EpisodeRow.svelte";
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
import ClearHistoryButton from "./ClearHistoryButton.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import { seasonAnchorId } from "./seriesNavigation";
interface Props {
season: MediaItem;
episodes: MediaItem[];
focusedEpisodeId?: string;
/** The episode the viewer is up to — highlighted and scrolled into view. */
currentEpisodeId?: string;
/**
* Whether this season's episode list is open. Only the current season
* starts expanded, so a ten-season show does not render every episode at
* once. TRACES: UR-062 | DR-107
*/
expanded?: boolean;
onToggle?: () => void;
onEpisodeClick?: (episode: MediaItem) => void;
onHistoryCleared?: () => void;
}
let { season, episodes, focusedEpisodeId, onEpisodeClick }: Props = $props();
let {
season,
episodes,
focusedEpisodeId,
currentEpisodeId,
expanded = false,
onToggle,
onEpisodeClick,
onHistoryCleared,
}: Props = $props();
const holdsCurrentEpisode = $derived(
currentEpisodeId != null && episodes.some((e) => e.id === currentEpisodeId)
);
const watchedCount = $derived(episodes.filter((e) => e.userData?.isPlayed).length);
const episodeCount = $derived(episodes.length);
const seasonNumber = $derived(season.indexNumber || season.parentIndexNumber);
const seasonNumber = $derived(season.indexNumber ?? season.parentIndexNumber);
const seasonName = $derived(
season.name || (seasonNumber ? `Season ${seasonNumber}` : "Unknown Season")
season.name || (seasonNumber != null ? `Season ${seasonNumber}` : "Unknown Season")
);
// Seasons have no page of their own; a season link scrolls to this anchor
// inside the series' single continuous episode list.
const anchor = $derived(seasonAnchorId(seasonNumber));
</script>
<section class="space-y-4">
<section class="space-y-4 scroll-mt-4" id={anchor}>
<!-- Season header -->
<div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl">
<!-- Season poster -->
@@ -38,28 +68,60 @@
<!-- Season info -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<h2 class="text-xl font-bold text-white">
{seasonName}
<!-- The whole title block toggles the season open/closed. -->
<button
type="button"
onclick={onToggle}
aria-expanded={expanded}
aria-controls="{anchor}-episodes"
class="flex-1 min-w-0 text-left group/season"
>
<h2 class="text-xl font-bold text-white flex items-center gap-2">
<svg
class="w-5 h-5 flex-shrink-0 text-gray-400 transition-transform duration-200
group-hover/season:text-white {expanded ? 'rotate-90' : ''}"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span class="truncate">{seasonName}</span>
{#if holdsCurrentEpisode}
<span
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
>
Up next
</span>
{/if}
</h2>
<div class="flex items-center gap-3 mt-1 text-sm text-gray-400">
<div class="flex items-center gap-3 mt-1 text-sm text-gray-400 pl-7">
<span>{episodeCount} {episodeCount === 1 ? "Episode" : "Episodes"}</span>
<!-- Collapsed, this is the only progress signal the season shows. -->
{#if watchedCount > 0}
<span></span>
<span>
{watchedCount === episodeCount ? "Watched" : `${watchedCount} watched`}
</span>
{/if}
{#if season.productionYear}
<span></span>
<span>{season.productionYear}</span>
{/if}
</div>
{#if season.overview}
<p class="text-gray-400 text-sm mt-3 line-clamp-3">
{#if season.overview && expanded}
<p class="text-gray-400 text-sm mt-3 line-clamp-3 pl-7">
{season.overview}
</p>
{/if}
</div>
</button>
<!-- Download Season Button -->
<div class="flex-shrink-0">
<!-- Per-season actions -->
<div class="flex-shrink-0 flex items-center gap-2">
<SeasonDownloadButton
seasonId={season.id}
seriesName={season.seriesName || ""}
@@ -68,19 +130,29 @@
{episodeCount}
size="sm"
/>
<ClearHistoryButton
itemId={season.id}
itemName={seasonName}
scope="season"
size="sm"
onCleared={onHistoryCleared}
/>
</div>
</div>
</div>
</div>
<!-- Episode list -->
<div class="space-y-1 pl-2">
{#each episodes as episode (episode.id)}
<EpisodeRow
{episode}
focused={episode.id === focusedEpisodeId}
onclick={() => onEpisodeClick?.(episode)}
/>
{/each}
</div>
{#if expanded}
<div class="space-y-1 pl-2" id="{anchor}-episodes">
{#each episodes as episode (episode.id)}
<EpisodeRow
{episode}
focused={episode.id === focusedEpisodeId}
current={episode.id === currentEpisodeId}
onclick={() => onEpisodeClick?.(episode)}
/>
{/each}
</div>
{/if}
</section>
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import { isCurrentEpisode, adjacentEpisodes } from "./episodeStrip";
import { isCurrentEpisode, adjacentEpisodes, compareSeriesOrder, stripCardLabel } from "./episodeStrip";
// Minimal episode factory — only the fields the strip logic reads.
function ep(
@@ -71,11 +71,41 @@ describe("adjacentEpisodes", () => {
expect(strip).toContain(current);
});
it("restricts to the current season when multiple seasons are present", () => {
// ux-flows §5B.2, "Cross-season continuity": the window spans the whole
// series in episode order, so it runs past a season boundary rather than
// dead-ending at the end of a season.
it("runs past the end of a season into the next one", () => {
const eps = [...season(1, 5), ...season(2, 5)];
const current = eps[6]; // S2E2
const current = eps[4]; // S1E5 — the season finale
const strip = adjacentEpisodes(current, eps);
expect(strip.every((e) => e.parentIndexNumber === 2)).toBe(true);
expect(strip.map((e) => e.id)).toEqual([
"s1e2", "s1e3", "s1e4", "s1e5",
"s2e1", "s2e2", "s2e3", "s2e4", "s2e5",
]);
});
it("reaches back into the previous season from a season opener", () => {
const eps = [...season(1, 5), ...season(2, 5)];
const current = eps[5]; // S2E1
const strip = adjacentEpisodes(current, eps);
expect(strip.slice(0, 3).map((e) => e.id)).toEqual(["s1e3", "s1e4", "s1e5"]);
expect(strip[3].id).toBe("s2e1");
});
it("orders by season then episode, never interleaving seasons", () => {
const eps = [...season(2, 3), ...season(1, 3)]; // deliberately out of order
const current = eps[3]; // S1E1
const strip = adjacentEpisodes(current, eps);
expect(strip.map((e) => e.id)).toEqual([
"s1e1", "s1e2", "s1e3", "s2e1", "s2e2", "s2e3",
]);
});
it("sorts specials (season 0) after the numbered seasons", () => {
const eps = [...season(0, 2), ...season(1, 2)];
const current = eps[2]; // S1E1
const strip = adjacentEpisodes(current, eps);
expect(strip.map((e) => e.id)).toEqual(["s1e1", "s1e2", "s0e1", "s0e2"]);
});
it("splices in a directly-fetched episode absent from the list (ID mismatch)", () => {
@@ -93,5 +123,42 @@ describe("adjacentEpisodes", () => {
const current = ep("mystery", null, 3); // no season number
const strip = adjacentEpisodes(current, eps);
expect(strip.length).toBeGreaterThan(1);
// Anchored at its episode number, not dumped at one end of the list.
expect(strip.indexOf(current)).toBeGreaterThan(0);
expect(strip.indexOf(current)).toBeLessThan(strip.length - 1);
});
});
describe("compareSeriesOrder", () => {
it("orders by season, then episode", () => {
expect(compareSeriesOrder(ep("a", 1, 9), ep("b", 2, 1))).toBeLessThan(0);
expect(compareSeriesOrder(ep("a", 2, 1), ep("b", 2, 2))).toBeLessThan(0);
expect(compareSeriesOrder(ep("a", 2, 2), ep("b", 2, 2))).toBe(0);
});
it("puts specials last", () => {
expect(compareSeriesOrder(ep("a", 0, 1), ep("b", 1, 1))).toBeGreaterThan(0);
expect(compareSeriesOrder(ep("a", 0, 1), ep("b", 9, 1))).toBeGreaterThan(0);
});
it("falls back to episode number when a season is unknown", () => {
expect(compareSeriesOrder(ep("a", null, 2), ep("b", 1, 5))).toBeLessThan(0);
});
});
describe("stripCardLabel", () => {
const current = ep("cur", 2, 4);
it("shows a bare episode number within the current season", () => {
expect(stripCardLabel(ep("a", 2, 6), current)).toBe("6.");
});
it("shows SxEy once the card crosses a season boundary", () => {
expect(stripCardLabel(ep("a", 3, 1), current)).toBe("S3E1");
expect(stripCardLabel(ep("a", 1, 8), current)).toBe("S1E8");
});
it("degrades to the episode number when the season is unknown", () => {
expect(stripCardLabel(ep("a", null, 7), current)).toBe("7.");
});
});
+67 -18
View File
@@ -1,12 +1,20 @@
// 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.
// 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 | DR-062
// 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`?
*
@@ -29,33 +37,74 @@ export function isCurrentEpisode(ep: MediaItem, current: MediaItem): boolean {
);
}
/**
* 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. 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;
* 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 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));
const pool = allEpisodes.slice().sort(compareSeriesOrder);
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);
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 - 3);
const end = Math.min(pool.length, idx + 7);
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}.`;
}
@@ -0,0 +1,197 @@
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import {
seasonAnchorId,
seasonRedirectTarget,
episodeFocusHref,
seriesPlayHref,
seriesPlayLabel,
groupEpisodesBySeason,
initialExpandedSeasons,
} from "./seriesNavigation";
const SERIES = "series-1";
function ep(id: string, season: number | null, number: number | null): MediaItem {
return {
id,
name: `S${season}E${number}`,
kind: "episode",
seriesId: SERIES,
parentIndexNumber: season,
indexNumber: number,
durationMs: 1_000_000,
} as unknown as MediaItem;
}
function seasonHeader(number: number, id = `season-${number}`): MediaItem {
return {
id,
name: `Season ${number}`,
kind: "season",
seriesId: SERIES,
indexNumber: number,
} as unknown as MediaItem;
}
function withProgress(episode: MediaItem, fraction: number): MediaItem {
return {
...episode,
userData: { playbackPositionMs: (episode.durationMs ?? 0) * fraction },
} as MediaItem;
}
describe("seriesPlayHref", () => {
// The reported bug: Play resolved the first *season* child and navigated to
// /player/<seasonId>, which bounced back to the season-1 page.
it("opens the current episode's focus view, never a season or the player", () => {
const href = seriesPlayHref(SERIES, ep("s2e4", 2, 4));
expect(href).toBe("/library/series-1?episode=s2e4");
expect(href).not.toContain("/player/");
});
it("returns null for a series with no episodes so the button can hide", () => {
expect(seriesPlayHref(SERIES, null)).toBeNull();
});
});
describe("seriesPlayLabel", () => {
it("names the episode it will open", () => {
expect(seriesPlayLabel(ep("s2e4", 2, 4))).toBe("Play S2E4");
});
it("says Resume for a part-watched episode", () => {
expect(seriesPlayLabel(withProgress(ep("s2e4", 2, 4), 0.4))).toBe("Resume S2E4");
});
it("says Play for a barely-started or nearly-finished episode", () => {
expect(seriesPlayLabel(withProgress(ep("s1e1", 1, 1), 0.001))).toBe("Play S1E1");
expect(seriesPlayLabel(withProgress(ep("s1e1", 1, 1), 0.99))).toBe("Play S1E1");
});
it("degrades to a bare verb when the numbering is unknown", () => {
expect(seriesPlayLabel(ep("x", null, null))).toBe("Play");
expect(seriesPlayLabel(null)).toBe("Play");
});
});
describe("seasonRedirectTarget", () => {
it("sends a season to its series, anchored at that season", () => {
expect(seasonRedirectTarget(seasonHeader(3))).toBe("/library/series-1#season-3");
});
it("returns null when the series is unknown, so the caller can fall back", () => {
const orphan = { ...seasonHeader(3), seriesId: undefined } as MediaItem;
expect(seasonRedirectTarget(orphan)).toBeNull();
});
it("matches the anchor the season section renders", () => {
expect(seasonRedirectTarget(seasonHeader(2))).toBe(
`/library/${SERIES}#${seasonAnchorId(2)}`
);
});
});
describe("episodeFocusHref", () => {
it("opens an episode inside its series (never a bare episode page)", () => {
expect(episodeFocusHref(ep("s1e2", 1, 2))).toBe("/library/series-1?episode=s1e2");
});
it("falls back to the bare item page when the series is unknown", () => {
const orphan = { ...ep("lone", 1, 2), seriesId: undefined } as MediaItem;
expect(episodeFocusHref(orphan)).toBe("/library/lone");
});
});
describe("groupEpisodesBySeason", () => {
it("groups episodes under their season headers, in season order", () => {
const seasons = [seasonHeader(2), seasonHeader(1)];
const episodes = [ep("s1e1", 1, 1), ep("s1e2", 1, 2), ep("s2e1", 2, 1)];
const grouped = groupEpisodesBySeason(seasons, episodes);
expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 2]);
expect(grouped[0].episodes.map((e) => e.id)).toEqual(["s1e1", "s1e2"]);
expect(grouped[1].episodes.map((e) => e.id)).toEqual(["s2e1"]);
});
it("puts specials after the numbered seasons", () => {
const grouped = groupEpisodesBySeason(
[seasonHeader(0), seasonHeader(1)],
[ep("s0e1", 0, 1), ep("s1e1", 1, 1)]
);
expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 0]);
});
// A flat series: episodes hang off the series, no season folders exist.
it("synthesizes headers when the server returned no seasons", () => {
const grouped = groupEpisodesBySeason([], [ep("s1e1", 1, 1), ep("s2e1", 2, 1)]);
expect(grouped.map((g) => g.season.name)).toEqual(["Season 1", "Season 2"]);
expect(grouped.every((g) => g.season.kind === "season")).toBe(true);
});
it("names a synthesized season 0 'Specials'", () => {
const grouped = groupEpisodesBySeason([], [ep("s0e1", 0, 1)]);
expect(grouped[0].season.name).toBe("Specials");
});
it("gives synthesized headers distinct ids so keyed #each blocks are stable", () => {
const grouped = groupEpisodesBySeason([], [ep("s1e1", 1, 1), ep("s2e1", 2, 1)]);
const ids = grouped.map((g) => g.season.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("drops seasons that have no episodes", () => {
const grouped = groupEpisodesBySeason(
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
[ep("s2e1", 2, 1)]
);
expect(grouped.map((g) => g.season.indexNumber)).toEqual([2]);
});
it("buckets season-less episodes into season 1 rather than losing them", () => {
const grouped = groupEpisodesBySeason([], [ep("lone", null, 1)]);
expect(grouped).toHaveLength(1);
expect(grouped[0].episodes.map((e) => e.id)).toEqual(["lone"]);
});
});
describe("initialExpandedSeasons", () => {
const seasons = groupEpisodesBySeason(
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
[
ep("s1e1", 1, 1),
ep("s2e1", 2, 1),
ep("s2e2", 2, 2),
ep("s3e1", 3, 1),
]
);
it("expands only the season holding the current episode", () => {
const expanded = initialExpandedSeasons(seasons, "s2e2");
expect([...expanded]).toEqual(["season-2"]);
});
it("also expands the season of a ?episode= deep link", () => {
const expanded = initialExpandedSeasons(seasons, "s1e1", "s3e1");
expect(expanded.has("season-1")).toBe(true);
expect(expanded.has("season-3")).toBe(true);
expect(expanded.has("season-2")).toBe(false);
});
it("collapses nothing extra when current and focused share a season", () => {
const expanded = initialExpandedSeasons(seasons, "s2e1", "s2e2");
expect([...expanded]).toEqual(["season-2"]);
});
it("falls back to the first season when there is no current episode", () => {
expect([...initialExpandedSeasons(seasons, null)]).toEqual(["season-1"]);
});
it("falls back to the first season when the current episode is unknown here", () => {
expect([...initialExpandedSeasons(seasons, "not-in-this-show")]).toEqual(["season-1"]);
});
it("returns nothing for a series with no seasons", () => {
expect(initialExpandedSeasons([], "s1e1").size).toBe(0);
});
});
@@ -0,0 +1,167 @@
// Pure navigation/grouping logic for the series detail page.
//
// Extracted from `/library/[id]/+page.svelte` so it can be unit-tested: the
// series Play button used to resolve `$libraryItems[0]` — the first *season* by
// SortName — and navigate to `/player/<seasonId>`, which the player route
// bounced back to `/library/<seasonId>`. Play on a series therefore played
// nothing and landed on the season-1 page.
//
// Note what is NOT here: *which* episode is current. That is domain policy and
// lives in Rust (`repository_get_series_current_episode`); this module only
// renders and routes around the answer.
//
// TRACES: UR-062 | DR-102, DR-103
import type { MediaItem } from "$lib/api/types";
export interface SeasonData {
season: MediaItem;
episodes: MediaItem[];
}
/** Jellyfin files specials under season 0. */
const SPECIALS_SEASON = 0;
/** Sort key for a season number: specials come after every numbered season. */
function seasonRank(seasonNumber: number | null | undefined): number {
if (seasonNumber == null) return Number.MAX_SAFE_INTEGER - 1;
return seasonNumber === SPECIALS_SEASON ? Number.MAX_SAFE_INTEGER : seasonNumber;
}
/**
* The in-page anchor for a season, so a season link scrolls the series' single
* continuous episode list instead of opening a page of its own.
*/
export function seasonAnchorId(seasonNumber: number | null | undefined): string {
return `season-${seasonNumber ?? 0}`;
}
/**
* Where a link naming a season should actually go: the series, anchored at that
* season. Returns `null` when the season carries no `seriesId` (a deep link into
* a stale cache), in which case the caller must keep rendering something rather
* than strand the user.
*/
export function seasonRedirectTarget(season: MediaItem): string | null {
if (!season.seriesId) return null;
const seasonNumber = season.indexNumber ?? season.parentIndexNumber;
return `/library/${season.seriesId}#${seasonAnchorId(seasonNumber)}`;
}
/**
* Where an episode link should go: the episode in the context of its series
* (ux-flows §5B.1 — an episode is never browsed as a bare Episode page).
* Falls back to the bare item page only when the series is unknown.
*/
export function episodeFocusHref(episode: MediaItem): string {
if (!episode.seriesId) return `/library/${episode.id}`;
return `/library/${episode.seriesId}?episode=${episode.id}`;
}
/**
* Where the series hero button goes.
*
* The Episode Focus View, not the player: ux-flows §5B.5 makes Play on a
* *container* navigation and Play on a *leaf* the commitment. Returns `null`
* when there is no current episode (an empty series), so the caller can hide
* the button rather than link nowhere.
*/
export function seriesPlayHref(seriesId: string, current: MediaItem | null): string | null {
if (!current) return null;
return `/library/${seriesId}?episode=${current.id}`;
}
/** Fraction of an episode already watched, 0 when unknown. */
function progressFraction(episode: MediaItem): number {
const position = episode.userData?.playbackPositionMs ?? 0;
if (!episode.durationMs || position <= 0) return 0;
return position / episode.durationMs;
}
/**
* Label for the series hero button — it names the episode it will open, so the
* viewer knows where the button leads before pressing it.
*/
export function seriesPlayLabel(current: MediaItem | null): string {
if (!current) return "Play";
const fraction = progressFraction(current);
const verb = fraction > 0.01 && fraction < 0.95 ? "Resume" : "Play";
if (current.parentIndexNumber == null || current.indexNumber == null) return verb;
return `${verb} S${current.parentIndexNumber}E${current.indexNumber}`;
}
/**
* Group a series' episodes under its season headers.
*
* The episodes arrive from Rust already in series order; this only decides which
* header each one renders beneath, and synthesizes a header for any season the
* server did not return one for (a flat series, or a season fetch that failed).
* Seasons with no episodes are dropped — an empty accordion row is noise.
*/
export function groupEpisodesBySeason(
seasons: MediaItem[],
episodes: MediaItem[]
): SeasonData[] {
const headerFor = new Map<number, MediaItem>();
for (const season of seasons) {
const number = season.indexNumber ?? season.parentIndexNumber;
if (number != null && !headerFor.has(number)) headerFor.set(number, season);
}
const grouped = new Map<number, MediaItem[]>();
for (const episode of episodes) {
const number = episode.parentIndexNumber ?? 1;
const bucket = grouped.get(number);
if (bucket) bucket.push(episode);
else grouped.set(number, [episode]);
}
return [...grouped.entries()]
.sort(([a], [b]) => seasonRank(a) - seasonRank(b))
.map(([number, seasonEpisodes]) => ({
season:
headerFor.get(number) ??
({
...seasonEpisodes[0],
id: `synthetic-season-${number}`,
kind: "season",
indexNumber: number,
name: number === SPECIALS_SEASON ? "Specials" : `Season ${number}`,
overview: null,
} as MediaItem),
episodes: seasonEpisodes,
}));
}
/**
* Which seasons start expanded.
*
* Only the one the viewer is in. A ten-season show otherwise renders every
* episode of every season at once, burying the one episode they came for. A
* `?episode=` deep link expands that episode's season as well, and a show with
* no resolved current episode falls back to its first season so the page is
* never entirely collapsed.
*
* Returns season ids (not numbers) so the caller can key state per section,
* including the synthesized headers.
*/
export function initialExpandedSeasons(
seasons: SeasonData[],
currentEpisodeId: string | null | undefined,
focusedEpisodeId?: string | null
): Set<string> {
if (seasons.length === 0) return new Set();
const expanded = new Set<string>();
for (const id of [currentEpisodeId, focusedEpisodeId]) {
if (!id) continue;
const owner = seasons.find((s) => s.episodes.some((e) => e.id === id));
if (owner) expanded.add(owner.season.id);
}
// Nothing matched — open the first season rather than nothing at all.
if (expanded.size === 0) expanded.add(seasons[0].season.id);
return expanded;
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import {
resolveLibraryView,
libraryViewUrl,
LIBRARY_VIEWS,
DEFAULT_LIBRARY_VIEW,
} from "./libraryView";
describe("resolveLibraryView", () => {
it("resolves each known view", () => {
for (const view of LIBRARY_VIEWS) {
expect(resolveLibraryView(view)).toBe(view);
}
});
it("defaults to browse when the param is absent", () => {
expect(resolveLibraryView(null)).toBe("browse");
expect(resolveLibraryView(undefined)).toBe("browse");
});
it("falls back to the default rather than rendering nothing for junk", () => {
expect(resolveLibraryView("shows")).toBe(DEFAULT_LIBRARY_VIEW);
expect(resolveLibraryView("")).toBe(DEFAULT_LIBRARY_VIEW);
});
it("tolerates case and surrounding whitespace", () => {
expect(resolveLibraryView("Genres")).toBe("genres");
expect(resolveLibraryView(" all ")).toBe("all");
});
});
describe("libraryViewUrl", () => {
it("omits the param for the default view so the landing URL stays clean", () => {
expect(libraryViewUrl("/library/tv", "browse")).toBe("/library/tv");
});
it("names the non-default views", () => {
expect(libraryViewUrl("/library/tv", "all")).toBe("/library/tv?view=all");
expect(libraryViewUrl("/library/movies", "genres")).toBe("/library/movies?view=genres");
});
it("round-trips through resolveLibraryView", () => {
for (const view of LIBRARY_VIEWS) {
const url = libraryViewUrl("/library/tv", view);
const param = new URL(url, "http://x").searchParams.get("view");
expect(resolveLibraryView(param)).toBe(view);
}
});
});
+36
View File
@@ -0,0 +1,36 @@
// Which section of a video library page is showing.
//
// Browse / All / Genres used to be three routes per library, named
// inconsistently across the two libraries (`/library/tv/shows` vs
// `/library/movies/all`; `/library/shows/genres` vs `/library/movies/genres`).
// They are now one route with tabs, and this is the pure `?view=` ↔ tab
// mapping.
//
// TRACES: UR-063 | DR-105
export type LibraryView = "browse" | "all" | "genres";
/** Tab order, left to right. `browse` leads because it is the landing view. */
export const LIBRARY_VIEWS: readonly LibraryView[] = ["browse", "all", "genres"];
/** The view a page shows when `?view=` is absent or unrecognised. */
export const DEFAULT_LIBRARY_VIEW: LibraryView = "browse";
/**
* Read a `?view=` value. Anything unknown — a typo, a stale bookmark, a
* removed tab — lands on the default rather than rendering nothing.
*/
export function resolveLibraryView(value: string | null | undefined): LibraryView {
if (value == null) return DEFAULT_LIBRARY_VIEW;
const normalized = value.trim().toLowerCase();
return (LIBRARY_VIEWS as readonly string[]).includes(normalized)
? (normalized as LibraryView)
: DEFAULT_LIBRARY_VIEW;
}
/**
* URL for a tab. The default view omits the param, so the landing URL stays
* `/library/tv` — the same convention `searchRouteUrl` uses for the `all` scope.
*/
export function libraryViewUrl(basePath: string, view: LibraryView): string {
return view === DEFAULT_LIBRARY_VIEW ? basePath : `${basePath}?view=${view}`;
}
+3 -1
View File
@@ -42,7 +42,9 @@ export function resolveSearchScope(pathname: string): SearchScope {
if (path === "/library/music" || path.startsWith("/library/music/")) return "music";
if (path === "/library/movies" || path.startsWith("/library/movies/")) return "movies";
if (path === "/library/tv" || path.startsWith("/library/tv/")) return "tv";
// `/library/shows/genres` is the TV genre route despite the differing segment.
// `/library/shows/*` is a legacy TV route that now redirects into
// `/library/tv?view=genres` (DR-105). Kept so a search typed on the URL
// before the redirect lands still scopes to TV.
if (path === "/library/shows" || path.startsWith("/library/shows/")) return "tv";
return "all";