Files
jellytau/docs/specs/series-current-episode-navigation.md
dtourolle 58f2506966
🏗️ 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
feat(series): land on the current episode, not season 1 (UR-062, UR-063, UR-064)
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
2026-08-03 20:37:43 +02:00

13 KiB

Spec: series navigation lands on the current episode

Status: Accepted Requirements: UR-062 → DR-101, DR-102, DR-103, DR-104, DR-107; UR-063 → DR-105; UR-064 → DR-106 UX spec: ux-flows.md §5B.1, §5B.2, §5B.4, §5B.5

Summary

Opening a TV series lands you where you actually are in it: the seasons render as collapsible sections with only the current season expanded, the current episode highlighted and scrolled into view, and the hero button opens that episode's focus view (labelled Resume S2E4 / Play S1E1) instead of the first season. A season stops being a destination of its own — every route that used to land on /library/<seasonId> now lands on the series with that season in view, so the full cross-season episode list is always reachable in one place. Watch history can be erased per series and per season. Separately, each video library collapses from three routes (landing, all-titles, genres) to one route with in-page tabs.

Motivation

Two problems, reported together.

1. Series navigation dead-ends at season 1. The series detail page's Play button resolved its target as $libraryItems[0] — the first season child, ordered by SortName — and navigated to /player/<seasonId>. The player route classifies season as a container kind and bounces it back to /library/<seasonId>. So Play on a series played nothing; it navigated you to the season-1 page. Opening a series without pressing Play rendered every season stacked but scrolled to the top, so a viewer 4 seasons deep had to scroll past everything they had already watched.

The backend has been able to answer "where is this viewer in this show" the whole time: repository_get_next_up_episodes(handle, series_id, limit) is wired end-to-end to /Shows/NextUp?SeriesId=. Both frontend call sites pass undefined for series_id — the per-series capability existed and was never used.

2. Seasons are an accidental page. There is no season route. /library/ <seasonId> falls through the detail page's kind chain into the generic "Contents" poster grid, which contradicts ux-flows §5A.2 (episodes in a season must render as a row list). Worse, clicking an episode from that grid opens a bare Episode page, which §5B.1 explicitly forbids. Four call sites fed it: the episode breadcrumb, handleItemClick case "season", the TV landing page, and the broken Play button above.

3. Too many video library routes. Seven routes serve two media types, and the naming does not even agree with itself: /library/tv + /library/tv/shows + /library/shows/genres versus /library/movies + /library/movies/all + /library/movies/genres. The genre routes do not share a prefix, which searchScope.ts:45 carries an apologetic comment about. The two "all" pages are 27-line config wrappers over the same GenericMediaListPage.

Layer assignment

Logic / responsibility Layer Why it belongs there
Which episode is "current" for a series (resume → next-up → first unwatched → first) Rust Domain policy over Jellyfin user-data semantics. It changes if Jellyfin changes what UserData.is_played means, if Next Up's rules change, or if we decide a 98%-watched episode counts as finished. It does not change if the UI is redesigned.
Gathering a series' episodes across all seasons in broadcast order Rust Jellyfin's shape (episodes hang off season folders, except when a series is flat and they hang off the series) is provider vocabulary. The frontend already reimplemented this fan-out and its flat-series fallback; that is domain knowledge that leaked.
Ordering rule for "series order" (season index, then episode index, specials last) Rust Season 0 = specials is a Jellyfin convention, not a layout choice.
Scrolling the current episode into view; the highlight ring and Up next badge Frontend Pure presentation. Changes only if the page is redesigned.
Which seasons start expanded Frontend Consumes the backend's answer (currentEpisode) to decide layout. The decision about where the viewer is stays in Rust; only "and therefore this section opens" is here.
What "erase watch history" means (played flag + resume position, recursive over a container) Rust Jellyfin user-data semantics. Changes if the server's mark-unplayed behaviour changes; unaffected by any UI redesign.
Refusing to clear history while offline Rust A data-integrity rule, not a disabled button: history cleared only locally would be undone by the next sync. The UI disabling the button is a courtesy on top.
Play button label (Resume S2E4 vs Play S1E1) Frontend Rendering a decision the backend already made (the returned episode plus its resume position).
Which route Play navigates to Frontend Navigation is presentation.
Redirecting /library/<seasonId> to the series anchor Frontend Route topology.
Episode-strip window size (3 before / 6 after) Frontend A layout constant; §5B.2 owns it.
Library page tabs and the ?view= param Frontend View preference and route topology.

Borderline row — the strip's cross-season ordering is Rust (it is series order, above), but the window taken from that ordered list is frontend. The tie-breaker: the list handed to the frontend is already correct and complete; choosing how much of it fits on screen is layout.

Design

Rust: the current-episode policy

Two new pieces, split so the policy is unit-testable without a repository.

Pure policysrc-tauri/src/repository/series_progress.rs:

/// Series order: season index asc, then episode index asc. Specials (season 0)
/// sort after every numbered season rather than before season 1.
pub fn sort_series_order(episodes: &mut [MediaItem]);

/// The episode a viewer should land on, given everything already fetched.
/// Order: in-progress episode → Next Up → first unwatched → first episode.
pub fn pick_current_episode(
    episodes: &[MediaItem],   // series order
    next_up: &[MediaItem],
    resume: &[MediaItem],
) -> Option<MediaItem>;

Why that order:

  • In-progress wins because a partially-watched episode is literally where the viewer stopped; Next Up would skip past it. Ties break toward the earliest in series order, so a viewer who dipped into a later episode still resumes the one they are actually working through.
  • Next Up second because it is the server's own answer, and it accounts for history we do not cache.
  • First unwatched third — the offline repository returns an empty vec for Next Up (offline.rs:1247), so without this fallback the whole feature would be online-only. This is the offline path, not dead code.
  • First episode last so a never-watched series lands on S1E1 rather than nothing.

A resume/next_up entry that is not among episodes is still honoured — it comes from the same server and may carry an id the season fan-out missed — but it must belong to this series.

Fetch + commandsrc-tauri/src/commands/repository.rs:

#[tauri::command]
#[specta::specta]
pub async fn repository_get_series_episodes(
    manager: State<'_, RepositoryManagerWrapper>,
    handle: String,
    series_id: String,
) -> Result<Vec<MediaItem>, String>

#[tauri::command]
#[specta::specta]
pub async fn repository_get_series_current_episode(
    manager: State<'_, RepositoryManagerWrapper>,
    handle: String,
    series_id: String,
) -> Result<Option<MediaItem>, String>

Frontend params are camelCase ({ handle, seriesId }) per the Tauri v2 rule.

repository_get_series_episodes performs the fan-out the frontend used to do: get_items(series_id) → seasons → get_items(season_id) per season, plus the flat-series fallback (a series whose children are episodes, not seasons), then sort_series_order. repository_get_series_current_episode calls it, adds get_next_up_episodes(Some(series_id), Some(1)) and get_resume_items(Some(series_id), Some(10)), and applies pick_current_episode. Both tolerate a failing Next Up (offline) by treating it as empty rather than failing the whole call.

Frontend: series page

  • loadItem() calls repositoryGetSeriesEpisodes once instead of fanning out over seasons itself, and repositoryGetSeriesCurrentEpisode for the anchor. Season headers still come from get_items(seriesId); the page groups the returned episodes under them by parentIndexNumber.
  • No ?episode= param → series view, SeasonSection receives currentEpisodeId, EpisodeRow renders the highlight and scrolls itself into view (scrollIntoView({ block: "center" }), the existing focused mechanism, now distinguishing focused from current).
  • Seasons are collapsible and only the current season is expanded (initialExpandedSeasons). Without this a ten-season show renders every episode of every season at once and buries the one the viewer came for. A collapsed season still shows its episode count and watched count, so progress is legible without expanding. Toggle state is local and not persisted — it is a reading position, not a preference.
  • Hero Play → goto(/library/<seriesId>?episode=<currentId>), i.e. the Episode Focus View, where an explicit Play/Resume starts playback. This follows ux-flows §5B.5's "tap opens, never commits" rule: Play on a container is navigation; Play on a leaf (the focus view, a movie) commits.
  • Clicking an episode in a season section → ?episode= swap, not /player/<id>. §5B.1.

Frontend: seasons are not a destination

/library/<seasonId> resolves the season's seriesId and redirects to /library/<seriesId>#season-<indexNumber>; SeasonSection renders that anchor id. A season with no seriesId (deep link into a stale cache) keeps the old generic rendering as a fallback so the user is never stranded. Inbound links updated: episode breadcrumb, handleItemClick case "season", the TV landing page's case "Season", and DownloadedBrowse.

Erasing watch history

#[tauri::command]
#[specta::specta]
pub async fn repository_clear_watch_history(
    manager: State<'_, RepositoryManagerWrapper>,
    handle: String,
    item_id: String,
) -> Result<(), String>

OnlineRepository maps it to DELETE /Users/{userId}/PlayedItems/{itemId} — Jellyfin's mark-unplayed, which clears the played flag and zeroes the resume position, and which the server applies recursively to a folder. One call therefore handles a whole series or a single season; no per-episode fan-out. OfflineRepository returns RepoError::Offline rather than clearing locally, because divergent local history is undone by the next sync.

ClearHistoryButton is shared by the series hero (scope="series") and each SeasonSection header (scope="season"). It confirms first — there is no undo — disables itself while the server is unreachable, and reloads the page on success so the recomputed current episode is what the viewer sees. Clearing a whole series therefore returns it to S1E1, which is the same path a never-watched series takes through pick_current_episode.

Frontend: one route per video library

/library/tv and /library/movies each gain ?view=browse|all|genres tabs, rendering the existing GenericMediaListPage / GenericGenreBrowser components inline. ?view= is omitted for browse (the default) to keep URLs clean — the same convention searchRouteUrl uses for the all scope.

The four legacy routes become redirect-only +page.ts loads:

Legacy Redirects to
/library/tv/shows /library/tv?view=all
/library/shows/genres /library/tv?view=genres
/library/movies/all /library/movies?view=all
/library/movies/genres /library/movies?view=genres

They are kept (rather than deleted) because GenreTags builds links to them and users may have them in history. resolveSearchScope keeps its /library/shows branch for the same reason.

The "Browse" tile grid at the bottom of both landing pages is removed — the tabs replace it, and the tiles were a second navigation affordance to the same two destinations the carousels' "Show all" links already reach.

Out of scope

  • Cross-season autoplay. player/mod.rs:fetch_next_episode_for_item is still season-bounded, so autoplay stops at a season boundary. Fixing it should reuse repository_get_series_episodes, but it touches the playback state machine and the Android JNI advance path (see the AutoplayDecision deadlock note in CLAUDE.md) and belongs in its own change.
  • Music library routes. /library/music/* has five sub-routes with the same shape; the same consolidation applies but is not done here.
  • Marking a series' progress (mark-watched / mark-unwatched from the series page).