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
+67 -1
View File
@@ -15,7 +15,8 @@ use uuid::Uuid;
use crate::domain::rank_search_results;
use crate::jellyfin::HttpClient;
use crate::repository::{
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
OnlineRepository,
};
/// Repository handle manager
@@ -320,6 +321,71 @@ pub async fn repository_get_next_up_episodes(
.map_err(|e| format!("{:?}", e))
}
/// Every episode of a series, across all seasons, in series order.
///
/// Jellyfin hangs episodes off season folders — except for "flat" series whose
/// children are episodes directly. Both shapes are provider vocabulary, so the
/// fan-out and its fallback live in Rust rather than being reimplemented in the
/// frontend (which is what it used to do).
///
/// TRACES: UR-062 | DR-101
#[tauri::command]
#[specta::specta]
pub async fn repository_get_series_episodes(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
series_id: String,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
series_progress::fetch_series_episodes(repo.as_ref(), &series_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// The episode a viewer should land on when they open a series.
///
/// "Current" is domain policy, not layout: an episode in progress, else the
/// server's Next Up for the series, else the first unwatched episode, else the
/// first. The third rung is what makes this work offline, where Next Up is
/// always empty. Returns `None` only when the series has no episodes at all.
///
/// TRACES: UR-062 | DR-101
#[tauri::command]
#[specta::specta]
pub async fn repository_get_series_current_episode(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
series_id: String,
) -> Result<Option<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
series_progress::resolve_current_episode(repo.as_ref(), &series_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Erase the viewer's watch history for an item.
///
/// Clears the played flag and the resume position; on a series or season the
/// server applies it to everything inside. A series cleared this way is "never
/// watched" again, so `repository_get_series_current_episode` returns its
/// premiere. Requires the server — offline this fails rather than diverging
/// local state the next sync would overwrite.
///
/// TRACES: UR-064 | DR-106
#[tauri::command]
#[specta::specta]
pub async fn repository_clear_watch_history(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.clear_watch_history(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get recently played audio
#[tauri::command]
#[specta::specta]