fix(library): stop offering the episode you just finished as "up next"
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
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
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
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
//! 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
|
||||
//! TRACES: UR-062 | DR-101, DR-264 | UT-239
|
||||
|
||||
use super::{GetItemsOptions, MediaItem, MediaRepository, RepoError};
|
||||
|
||||
@@ -83,6 +83,34 @@ fn is_played(item: &MediaItem) -> bool {
|
||||
.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)
|
||||
}
|
||||
@@ -130,11 +158,20 @@ pub fn pick_current_episode(
|
||||
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))
|
||||
{
|
||||
// 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);
|
||||
@@ -145,7 +182,7 @@ pub fn pick_current_episode(
|
||||
// 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(furthest) = episodes.iter().rposition(is_finished) {
|
||||
if let Some(found) = episodes.get(furthest + 1) {
|
||||
return Some(found.clone());
|
||||
}
|
||||
@@ -153,7 +190,7 @@ pub fn pick_current_episode(
|
||||
|
||||
// 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)) {
|
||||
if let Some(found) = episodes.iter().find(|e| !is_finished(e)) {
|
||||
return Some(found.clone());
|
||||
}
|
||||
|
||||
@@ -427,6 +464,41 @@ mod tests {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user