Files
jellytau/src-tauri/src/repository/series_progress.rs
T
dtourolle 079153d9d5
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m0s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 35s
Traceability Validation / Check Requirement Traces (push) Successful in 26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m27s
fix(library): stop offering the episode you just finished as "up next"
Finish an episode, leave the player with Back, and the season view still put
the yellow ring and the "Up next" badge on the episode that had just ended --
and scrolled to it.

Nothing records completion locally. storage_update_playback_progress writes a
position and never touches is_played, and mirror_user_data carried the
server's favourite flag and position but not its played flag, so that column
was written by nothing except an explicit local toggle. By the second visit to
a series page get_items is a cache hit, so every episode reads back unwatched;
meanwhile Jellyfin's Next Up is still one stop-report behind and names the
episode that just ended. pick_current_episode had no reason to disagree with
either of them.

is_finished -- the played flag, or a position at or past MAX_PROGRESS_FRACTION
of the runtime, the same 95% threshold that already disqualifies an episode
from counting as in-progress -- replaces the bare is_played in the
furthest-watched scan and the first-unwatched fallback, and screens the Next Up
candidate before it is accepted: the server is briefly behind, the local
position is not. The current episode becomes the next one, and the highlight,
the badge, the auto-scroll and which season starts expanded all follow it.

mirror_user_data now carries is_played alongside the rest, under the same
pending_sync = 0 conflict rule, so watched state survives a cache write instead
of being dropped -- which is also what puts the checkmarks back in the season
list.

TRACES: UR-025, UR-062 | DR-264 | UT-239, UT-240
2026-08-26 18:56:16 +02:00

570 lines
20 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, DR-264 | UT-239
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)
}
/// Has the viewer reached the end of this episode?
///
/// The played *flag* is not enough. Nothing records completion locally — the
/// stop report writes a position, and the cache mirror carries the server's
/// flag only on the next refresh — so within seconds of an episode ending the
/// only local evidence that it is over is its position, parked at the very end
/// of its runtime. Leaving the player with Back reloads the series page inside
/// that window (DR-264).
fn is_finished(item: &MediaItem) -> bool {
if is_played(item) {
return true;
}
let Some(user_data) = item.user_data.as_ref() else {
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);
// Without a duration a position says nothing about how much is left.
let Some(duration_ms) = item.duration_ms.filter(|d| *d > 0) else {
return false;
};
position_ms as f64 / duration_ms as f64 >= MAX_PROGRESS_FRACTION
}
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 — unless it names an episode we can already
// see is over. Next Up is the server's answer, and the server is one
// stop-report behind for a moment after an episode ends; the local
// position is not, so a finished candidate is dropped rather than handed
// back as "up next" (DR-264).
if let Some(found) = next_up.iter().find(|e| {
if !(e.series_id.is_none() || belongs_to_series(e, series_id)) {
return false;
}
// Judge it by the copy from `episodes` when there is one: that is the
// copy carrying the local user-data.
let local = episodes.iter().find(|listed| listed.id == e.id);
!is_finished(local.unwrap_or(e))
}) {
// 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_finished) {
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_finished(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");
}
/// The episode the viewer just finished must not still be "up next".
///
/// Leaving the player with Back reloads the series page within a second of
/// the stop report, and Jellyfin's Next Up can still name the episode that
/// just ended. Locally we know better: the position sits at the very end of
/// its runtime.
///
/// TRACES: UR-062 | DR-264 | UT-239
#[test]
fn a_just_finished_episode_is_not_current_even_when_next_up_still_names_it() {
let mut eps = season(1, 5);
eps[0] = watched(eps[0].clone());
// Just finished: the position is at the end, the flag has not landed.
eps[1] = in_progress(eps[1].clone(), 0.99);
// The server has not caught up with the stop report.
let next_up = vec![episode("s1e2", 1, 2)];
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
assert_eq!(current.id, "s1e3");
}
/// The same, offline: with no Next Up to lean on, an episode watched to the
/// end counts as watched when scanning for the furthest-watched one.
///
/// TRACES: UR-062 | DR-264 | UT-239
#[test]
fn an_episode_watched_to_the_end_counts_as_watched_offline() {
let mut eps = season(1, 5);
eps[0] = watched(eps[0].clone());
eps[1] = in_progress(eps[1].clone(), 0.99);
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
assert_eq!(current.id, "s1e3");
}
#[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");
}
}