# 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/` 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/`. The player route classifies `season` as a container kind and bounces it back to `/library/`. 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/ ` 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/` 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; ``` 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, String> #[tauri::command] #[specta::specta] pub async fn repository_get_series_current_episode( manager: State<'_, RepositoryManagerWrapper>, handle: String, series_id: String, ) -> Result, 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/?episode=)`, 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/`. §5B.1. ### Frontend: seasons are not a destination `/library/` resolves the season's `seriesId` and redirects to `/library/#season-`; `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).