Files
jellytau/src-tauri/src/repository/series_progress.rs
dtourolleandClaude Opus 5 9d7cb085e9 fix(series): resume after the furthest-watched episode, not the first gap
`pick_current_episode` rung 3 returned the first unwatched episode in
series order. A viewer who skipped the pilot but is three seasons deep
was sent back to S1E1: the gap was a deliberate skip, not the place they
stopped.

This read as flaky rather than consistently wrong because rung 3 only
fires when the server's Next Up (rung 2) yields nothing, and
`resolve_current_episode` swallows that call's errors with
`.unwrap_or_default()`. `HybridRepository::get_next_up_episodes`
delegates unconditionally to the online repo, so any unreachable-server
moment silently degraded to the empty vec — same series, same watch
state, different answer depending on one request's outcome.

Rung 3 now scans the ordered list from the end with `rposition(is_played)`
and returns the episode after the furthest-watched one, falling back to
the previous first-unwatched behaviour when nothing is watched or the
series is finished. Season crossing comes free from the already-flat
series ordering, and `season_rank` keeps specials last so a watched
special cannot mark a show finished.

Tests written first and confirmed failing (S1E1 where S3E4 was
expected), covering the skipped-pilot case, rolling into the next season
past a skipped episode, and the watched-special case. All 17 existing
tests still pass.

Note: cargo test could not run locally (javascriptcoregtk-4.1 /
webkit2gtk-4.1 absent on this host). The pure policy half plus its
verbatim test module were extracted into a standalone crate to get real
red/green; the full crate suite still needs a run on a complete
toolchain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:23:26 +02:00

498 lines
17 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 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<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. 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<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");
}
/// 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<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");
}
}