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
@@ -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;
}