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
🏗️ 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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user