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

240 lines
13 KiB
Markdown

# 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](../ux-flows.md), [§5B.2](../ux-flows.md), [§5B.4](../ux-flows.md), [§5B.5](../ux-flows.md)
## 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 policy**`src-tauri/src/repository/series_progress.rs`:
```rust
/// 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 + command**`src-tauri/src/commands/repository.rs`:
```rust
#[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
```rust
#[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).