//! 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) -> 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 episode after the furthest-watched one**, falling back to the first /// unwatched episode when nothing has been watched or the series is finished. /// 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. It deliberately does *not* return the first unwatched /// episode outright — an unwatched episode behind the viewer's furthest /// point was skipped on purpose, and sending them back to it is the bug /// DR-101 was reopened for. /// 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 { // 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. The episode after the furthest-watched one. Not simply the first // unwatched: a viewer who skipped the pilot but is deep into season 3 // must not be dragged back to S1E1. An earlier gap is a deliberate skip; // where they stopped is the *last* thing they watched. if let Some(furthest) = episodes.iter().rposition(is_played) { if let Some(found) = episodes.get(furthest + 1) { return Some(found.clone()); } } // Nothing watched yet (or the furthest-watched episode is the finale): // the first unwatched episode 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, RepoError> { let children = repo.get_items(series_id, list_options()).await?; let mut episodes: Vec = 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, 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 { 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 { (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"); } /// A viewer deep in season 3 who never watched the pilot must not be sent /// back to it: the gap was a skip, not the place they stopped. #[test] fn resumes_after_the_furthest_watched_episode_not_the_first_gap() { let mut eps = [season(1, 4), season(2, 4), season(3, 4)].concat(); for ep in eps.iter_mut() { // Everything through S3E3 watched, except the never-watched pilot. let watched_through = ep.parent_index_number < Some(3) || ep.index_number <= Some(3); if watched_through && ep.id != "s1e1" { *ep = watched(ep.clone()); } } let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap(); assert_eq!(current.id, "s3e4"); } /// The furthest-watched episode being a finale must still roll into the /// next season rather than stopping the series. #[test] fn resumes_into_the_next_season_after_a_skipped_earlier_episode() { let mut eps = [season(1, 3), season(2, 3)].concat(); for ep in eps.iter_mut() { if ep.parent_index_number == Some(1) && ep.id != "s1e1" { *ep = watched(ep.clone()); } } let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap(); assert_eq!(current.id, "s2e1"); } /// Specials sort last, so watching one must not mark the series finished /// while numbered episodes remain. #[test] fn a_watched_special_does_not_end_the_series() { let mut eps = [season(1, 3), vec![episode("s0e1", 0, 1)]].concat(); sort_series_order(&mut eps); for ep in eps.iter_mut() { if ep.id == "s1e1" || ep.id == "s0e1" { *ep = watched(ep.clone()); } } let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap(); assert_eq!(current.id, "s1e2"); } #[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 = 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"); } }