🏗️ 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
434 lines
15 KiB
Rust
434 lines
15 KiB
Rust
//! Where a viewer is in a TV series.
|
|
//!
|
|
//! This is domain policy, not presentation: it encodes what Jellyfin's user-data
|
|
//! means ("in progress", "played") and what Jellyfin's season numbering means
|
|
//! (season 0 is specials). The frontend asks for *the* current episode and
|
|
//! renders it; it does not get to decide what "current" means.
|
|
//!
|
|
//! Split into a pure half (`pick_current_episode`, `sort_series_order`) and an
|
|
//! I/O half (`fetch_series_episodes`, `resolve_current_episode`) so the policy
|
|
//! can be unit-tested without standing up a repository.
|
|
//!
|
|
//! TRACES: UR-062 | DR-101
|
|
|
|
use super::{GetItemsOptions, MediaItem, MediaRepository, RepoError};
|
|
|
|
/// Jellyfin files specials under season 0.
|
|
const SPECIALS_SEASON: i32 = 0;
|
|
|
|
/// Below this fraction watched, a position is a false start rather than
|
|
/// progress — the same threshold the resume dialog uses.
|
|
const MIN_PROGRESS_FRACTION: f64 = 0.01;
|
|
|
|
/// Above this fraction watched, an episode is effectively finished; resuming it
|
|
/// would drop the viewer into the closing credits.
|
|
const MAX_PROGRESS_FRACTION: f64 = 0.95;
|
|
|
|
/// Sort key for a season number. Specials sort *after* every numbered season:
|
|
/// a viewer works through S1, S2, … and only then the extras, so season 0 must
|
|
/// not lead just because `0 < 1`.
|
|
fn season_rank(season: Option<i32>) -> i64 {
|
|
match season {
|
|
Some(SPECIALS_SEASON) => i64::MAX,
|
|
Some(n) => n as i64,
|
|
None => i64::MAX - 1,
|
|
}
|
|
}
|
|
|
|
/// Order episodes as the series is watched: season ascending, then episode,
|
|
/// specials last.
|
|
pub fn sort_series_order(episodes: &mut [MediaItem]) {
|
|
episodes.sort_by(|a, b| {
|
|
season_rank(a.parent_index_number)
|
|
.cmp(&season_rank(b.parent_index_number))
|
|
.then(
|
|
a.index_number
|
|
.unwrap_or(0)
|
|
.cmp(&b.index_number.unwrap_or(0)),
|
|
)
|
|
});
|
|
}
|
|
|
|
/// Is this episode genuinely part-watched (not a false start, not finished)?
|
|
fn is_in_progress(item: &MediaItem) -> bool {
|
|
let Some(user_data) = item.user_data.as_ref() else {
|
|
return false;
|
|
};
|
|
if user_data.is_played.unwrap_or(false) {
|
|
return false;
|
|
}
|
|
|
|
let position_ms = user_data
|
|
.playback_position_ms
|
|
.or_else(|| user_data.playback_position_ticks.map(|t| t / 10_000))
|
|
.unwrap_or(0);
|
|
if position_ms <= 0 {
|
|
return false;
|
|
}
|
|
|
|
// Without a duration we cannot tell "2 minutes in" from "2 minutes left",
|
|
// so any recorded position counts as progress.
|
|
let Some(duration_ms) = item.duration_ms.filter(|d| *d > 0) else {
|
|
return true;
|
|
};
|
|
|
|
let fraction = position_ms as f64 / duration_ms as f64;
|
|
(MIN_PROGRESS_FRACTION..MAX_PROGRESS_FRACTION).contains(&fraction)
|
|
}
|
|
|
|
fn is_played(item: &MediaItem) -> bool {
|
|
item.user_data
|
|
.as_ref()
|
|
.and_then(|u| u.is_played)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
|
|
item.series_id.as_deref() == Some(series_id)
|
|
}
|
|
|
|
/// The episode a viewer should land on when they open `series_id`.
|
|
///
|
|
/// Order of preference, and why:
|
|
///
|
|
/// 1. **An episode in progress.** That is literally where playback stopped;
|
|
/// Next Up would skip past it. On a tie the earliest in series order wins, so
|
|
/// a viewer who dipped into a later episode still returns to the one they are
|
|
/// working through.
|
|
/// 2. **The server's Next Up** for this series — it accounts for watch history
|
|
/// we do not cache locally.
|
|
/// 3. **The first unwatched episode** in series order. This is the offline path:
|
|
/// `OfflineRepository::get_next_up_episodes` returns an empty vec, so without
|
|
/// this rung the whole feature would be online-only.
|
|
/// 4. **The first episode**, so a never-watched series opens on its premiere
|
|
/// rather than on nothing.
|
|
///
|
|
/// `next_up` / `resume` entries are honoured even when absent from `episodes`
|
|
/// (the season fan-out can miss an id the server returns), but only when they
|
|
/// belong to this series.
|
|
pub fn pick_current_episode(
|
|
series_id: &str,
|
|
episodes: &[MediaItem],
|
|
next_up: &[MediaItem],
|
|
resume: &[MediaItem],
|
|
) -> Option<MediaItem> {
|
|
// 1. In progress — prefer a match inside the ordered episode list so the
|
|
// "earliest in series order" tie-break is meaningful; fall back to the
|
|
// resume feed for an episode the fan-out missed.
|
|
if let Some(found) = episodes.iter().find(|e| is_in_progress(e)) {
|
|
return Some(found.clone());
|
|
}
|
|
if let Some(found) = resume
|
|
.iter()
|
|
.find(|e| belongs_to_series(e, series_id) && is_in_progress(e))
|
|
{
|
|
return Some(found.clone());
|
|
}
|
|
|
|
// 2. Next Up for this series.
|
|
if let Some(found) = next_up
|
|
.iter()
|
|
.find(|e| e.series_id.is_none() || belongs_to_series(e, series_id))
|
|
{
|
|
// Prefer the copy from `episodes` when we have one: it carries the
|
|
// user-data and images the list already fetched.
|
|
let matched = episodes.iter().find(|e| e.id == found.id);
|
|
return Some(matched.unwrap_or(found).clone());
|
|
}
|
|
|
|
// 3. First unwatched in series order.
|
|
if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
|
|
return Some(found.clone());
|
|
}
|
|
|
|
// 4. First episode — a fully-watched series reopens at the start.
|
|
episodes.first().cloned()
|
|
}
|
|
|
|
/// Every episode of a series, 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 the fallback live here rather than in the frontend.
|
|
pub async fn fetch_series_episodes(
|
|
repo: &dyn MediaRepository,
|
|
series_id: &str,
|
|
) -> Result<Vec<MediaItem>, RepoError> {
|
|
let children = repo.get_items(series_id, list_options()).await?;
|
|
|
|
let mut episodes: Vec<MediaItem> = Vec::new();
|
|
for season in children.items.iter().filter(|i| is_season(i)) {
|
|
// One failing season must not blank the whole show.
|
|
match repo.get_items(&season.id, list_options()).await {
|
|
Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
|
|
Err(e) => {
|
|
log::warn!(
|
|
"[series] season {} of {} failed to load: {:?}",
|
|
season.id,
|
|
series_id,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Flat series: the children *are* the episodes.
|
|
if episodes.is_empty() {
|
|
episodes.extend(children.items.into_iter().filter(is_episode));
|
|
}
|
|
|
|
sort_series_order(&mut episodes);
|
|
Ok(episodes)
|
|
}
|
|
|
|
/// Resolve the current episode, fetching everything the policy needs.
|
|
///
|
|
/// Next Up and resume are best-effort: offline they fail or come back empty, and
|
|
/// `pick_current_episode` has fallbacks for exactly that.
|
|
pub async fn resolve_current_episode(
|
|
repo: &dyn MediaRepository,
|
|
series_id: &str,
|
|
) -> Result<Option<MediaItem>, RepoError> {
|
|
let episodes = fetch_series_episodes(repo, series_id).await?;
|
|
|
|
let next_up = repo
|
|
.get_next_up_episodes(Some(series_id), Some(1))
|
|
.await
|
|
.unwrap_or_default();
|
|
let resume = repo
|
|
.get_resume_items(Some(series_id), Some(10))
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
Ok(pick_current_episode(
|
|
series_id, &episodes, &next_up, &resume,
|
|
))
|
|
}
|
|
|
|
fn list_options() -> Option<GetItemsOptions> {
|
|
Some(GetItemsOptions {
|
|
limit: Some(500),
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
fn is_season(item: &MediaItem) -> bool {
|
|
item.item_type == "Season" || matches!(item.kind, crate::domain::MediaKind::Season)
|
|
}
|
|
|
|
fn is_episode(item: &MediaItem) -> bool {
|
|
item.item_type == "Episode" || matches!(item.kind, crate::domain::MediaKind::Episode)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::repository::UserData;
|
|
|
|
const SERIES: &str = "series-1";
|
|
|
|
fn episode(id: &str, season: i32, number: i32) -> MediaItem {
|
|
MediaItem {
|
|
id: id.to_string(),
|
|
name: format!("S{season}E{number}"),
|
|
item_type: "Episode".to_string(),
|
|
series_id: Some(SERIES.to_string()),
|
|
parent_index_number: Some(season),
|
|
index_number: Some(number),
|
|
duration_ms: Some(1_000_000),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn watched(mut item: MediaItem) -> MediaItem {
|
|
item.user_data = Some(UserData {
|
|
is_played: Some(true),
|
|
..Default::default()
|
|
});
|
|
item
|
|
}
|
|
|
|
fn in_progress(mut item: MediaItem, fraction: f64) -> MediaItem {
|
|
let duration = item.duration_ms.unwrap_or(1_000_000) as f64;
|
|
item.user_data = Some(UserData {
|
|
is_played: Some(false),
|
|
playback_position_ms: Some((duration * fraction) as i64),
|
|
..Default::default()
|
|
});
|
|
item
|
|
}
|
|
|
|
fn season(n: i32, count: i32) -> Vec<MediaItem> {
|
|
(1..=count)
|
|
.map(|i| episode(&format!("s{n}e{i}"), n, i))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn sorts_by_season_then_episode() {
|
|
let mut eps = vec![
|
|
episode("b", 2, 1),
|
|
episode("d", 1, 10),
|
|
episode("a", 1, 2),
|
|
episode("c", 2, 2),
|
|
];
|
|
sort_series_order(&mut eps);
|
|
let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
|
|
assert_eq!(ids, ["a", "d", "b", "c"]);
|
|
}
|
|
|
|
#[test]
|
|
fn sorts_specials_after_numbered_seasons() {
|
|
let mut eps = vec![episode("special", 0, 1), episode("premiere", 1, 1)];
|
|
sort_series_order(&mut eps);
|
|
let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
|
|
assert_eq!(ids, ["premiere", "special"]);
|
|
}
|
|
|
|
#[test]
|
|
fn picks_the_in_progress_episode_over_next_up() {
|
|
let mut eps = season(1, 5);
|
|
eps[0] = watched(eps[0].clone());
|
|
eps[1] = in_progress(eps[1].clone(), 0.4);
|
|
// The server would send us past it; the half-watched episode wins.
|
|
let next_up = vec![episode("s1e3", 1, 3)];
|
|
|
|
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
|
|
assert_eq!(current.id, "s1e2");
|
|
}
|
|
|
|
#[test]
|
|
fn picks_the_earliest_in_progress_episode() {
|
|
let mut eps = season(1, 5);
|
|
eps[1] = in_progress(eps[1].clone(), 0.3);
|
|
eps[3] = in_progress(eps[3].clone(), 0.5);
|
|
|
|
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
|
assert_eq!(current.id, "s1e2");
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_a_false_start_and_a_finished_episode() {
|
|
let mut eps = season(1, 5);
|
|
eps[0] = watched(eps[0].clone());
|
|
eps[1] = in_progress(eps[1].clone(), 0.001); // barely started
|
|
eps[2] = in_progress(eps[2].clone(), 0.99); // effectively over
|
|
|
|
// Neither counts as progress, so Next Up decides.
|
|
let next_up = vec![episode("s1e4", 1, 4)];
|
|
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
|
|
assert_eq!(current.id, "s1e4");
|
|
}
|
|
|
|
#[test]
|
|
fn falls_back_to_next_up_when_nothing_is_in_progress() {
|
|
let eps = season(1, 5);
|
|
let next_up = vec![episode("s1e3", 1, 3)];
|
|
|
|
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
|
|
assert_eq!(current.id, "s1e3");
|
|
}
|
|
|
|
#[test]
|
|
fn next_up_from_another_series_is_ignored() {
|
|
let eps = season(1, 3);
|
|
let mut foreign = episode("other-show-ep", 1, 1);
|
|
foreign.series_id = Some("series-2".to_string());
|
|
|
|
let current = pick_current_episode(SERIES, &eps, &[foreign], &[]).unwrap();
|
|
assert_eq!(current.id, "s1e1");
|
|
}
|
|
|
|
/// The offline path: `OfflineRepository::get_next_up_episodes` returns an
|
|
/// empty vec, so the first unwatched episode has to carry the feature.
|
|
#[test]
|
|
fn falls_back_to_first_unwatched_when_next_up_is_empty() {
|
|
let mut eps = [season(1, 3), season(2, 3)].concat();
|
|
for ep in eps.iter_mut().take(4) {
|
|
*ep = watched(ep.clone());
|
|
}
|
|
|
|
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
|
assert_eq!(current.id, "s2e2");
|
|
}
|
|
|
|
#[test]
|
|
fn crosses_a_season_boundary_when_a_season_is_finished() {
|
|
let mut eps = [season(1, 3), season(2, 3)].concat();
|
|
for ep in eps.iter_mut().take(3) {
|
|
*ep = watched(ep.clone());
|
|
}
|
|
|
|
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
|
assert_eq!(current.id, "s2e1");
|
|
}
|
|
|
|
#[test]
|
|
fn a_never_watched_series_opens_on_its_premiere() {
|
|
let eps = [season(2, 3), season(1, 3)].concat();
|
|
let mut ordered = eps.clone();
|
|
sort_series_order(&mut ordered);
|
|
|
|
let current = pick_current_episode(SERIES, &ordered, &[], &[]).unwrap();
|
|
assert_eq!(current.id, "s1e1");
|
|
}
|
|
|
|
#[test]
|
|
fn a_fully_watched_series_reopens_at_the_start() {
|
|
let eps: Vec<MediaItem> = season(1, 3).into_iter().map(watched).collect();
|
|
|
|
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
|
assert_eq!(current.id, "s1e1");
|
|
}
|
|
|
|
#[test]
|
|
fn honours_a_resume_entry_missing_from_the_episode_list() {
|
|
// Season fan-out returned nothing usable, but the resume feed knows.
|
|
let resume = vec![in_progress(episode("s3e7", 3, 7), 0.5)];
|
|
|
|
let current = pick_current_episode(SERIES, &[], &[], &resume).unwrap();
|
|
assert_eq!(current.id, "s3e7");
|
|
}
|
|
|
|
#[test]
|
|
fn resume_entries_from_other_series_are_ignored() {
|
|
let mut foreign = in_progress(episode("other", 1, 1), 0.5);
|
|
foreign.series_id = Some("series-2".to_string());
|
|
|
|
assert!(pick_current_episode(SERIES, &[], &[], &[foreign]).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn a_series_with_no_episodes_has_no_current_episode() {
|
|
assert!(pick_current_episode(SERIES, &[], &[], &[]).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn an_episode_without_a_duration_still_counts_as_in_progress() {
|
|
let mut ep = episode("s1e2", 1, 2);
|
|
ep.duration_ms = None;
|
|
ep.user_data = Some(UserData {
|
|
is_played: Some(false),
|
|
playback_position_ms: Some(120_000),
|
|
..Default::default()
|
|
});
|
|
|
|
let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
|
|
assert_eq!(current.id, "s1e2");
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_tick_positions_still_register_as_progress() {
|
|
let mut ep = episode("s1e2", 1, 2);
|
|
ep.user_data = Some(UserData {
|
|
is_played: Some(false),
|
|
// 400_000 ms expressed in Jellyfin ticks, no ms field.
|
|
playback_position_ticks: Some(400_000 * 10_000),
|
|
..Default::default()
|
|
});
|
|
|
|
let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
|
|
assert_eq!(current.id, "s1e2");
|
|
}
|
|
}
|