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

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:
2026-08-26 18:56:16 +02:00
parent 1ba836928f
commit 079153d9d5
4 changed files with 7958 additions and 6928 deletions
+106 -4
View File
@@ -684,26 +684,33 @@ impl OfflineRepository {
/// The position half is what makes cross-device resume work: the resume
/// check reads this table alone, so before it was mirrored an item watched
/// elsewhere resumed from whatever *this* device last saw, or not at all.
/// The played flag rides along for the same reason: nothing else writes it
/// but an explicit local toggle, so a cached episode list read every
/// episode back as unwatched — the list the season view ticks and the one
/// `pick_current_episode` reads to decide what is up next (DR-264).
///
/// TRACES: UR-025, UR-069 | DR-114, DR-155 | UT-102, UT-152
/// TRACES: UR-025, UR-062, UR-069 | DR-114, DR-155, DR-264 | UT-102, UT-152, UT-240
async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> {
let user_data = item.user_data.as_ref();
let is_favorite = user_data.and_then(|ud| ud.is_favorite);
let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks);
let is_played = user_data.and_then(|ud| ud.is_played);
// Nothing the server actually told us about — do not invent a row.
if is_favorite.is_none() && position_ticks.is_none() {
if is_favorite.is_none() && position_ticks.is_none() && is_played.is_none() {
return Ok(());
}
let query = Query::with_params(
"INSERT INTO user_data
(user_id, item_id, is_favorite, playback_position_ticks, synced_at, pending_sync)
VALUES (?1, ?2, ?3, ?4, ?5, 0)
(user_id, item_id, is_favorite, playback_position_ticks, is_played,
synced_at, pending_sync)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)
ON CONFLICT(user_id, item_id) DO UPDATE SET
is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite),
playback_position_ticks = COALESCE(
excluded.playback_position_ticks, user_data.playback_position_ticks),
is_played = COALESCE(excluded.is_played, user_data.is_played),
synced_at = excluded.synced_at
WHERE user_data.pending_sync = 0",
vec![
@@ -715,6 +722,9 @@ impl OfflineRepository {
position_ticks
.map(QueryParam::Int64)
.unwrap_or(QueryParam::Null),
is_played
.map(|p| QueryParam::Int(if p { 1 } else { 0 }))
.unwrap_or(QueryParam::Null),
QueryParam::String(now.to_string()),
],
);
@@ -4786,6 +4796,98 @@ mod tests {
);
}
/// UT-240 — the server's *played* flag is mirrored locally, so an episode
/// watched anywhere is watched here.
///
/// The mirror carried only the favourite flag and the position, so
/// `user_data.is_played` was written by nothing but an explicit local
/// toggle: a cached episode list reported every episode as unwatched, which
/// is the list `pick_current_episode` reads to decide what is up next
/// (DR-264), and the list the season view ticks.
///
/// TRACES: UR-025, UR-062 | DR-264 | UT-240
#[tokio::test]
async fn test_save_to_cache_mirrors_played_flag_without_clobbering_pending() {
use crate::storage::db_service::DatabaseService;
let db_service = create_test_db();
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
let played_flag = |id: &'static str| {
let db = db_service.clone();
async move {
db.query_optional(
Query::with_params(
"SELECT is_played, pending_sync FROM user_data \
WHERE user_id = ? AND item_id = ?",
vec![
QueryParam::String("test-user".to_string()),
QueryParam::String(id.to_string()),
],
),
|row| Ok((row.get::<_, Option<i32>>(0)?, row.get::<_, Option<i32>>(1)?)),
)
.await
.unwrap()
}
};
// Watched to the end on another client.
let mut watched = create_test_item("ep-4", "Watched Elsewhere", None);
watched.user_data = Some(UserData {
is_played: Some(true),
..Default::default()
});
// No user data at all — must not fabricate an "unwatched" record.
let untouched = create_test_item("ep-5", "No User Data", None);
repo.save_to_cache("parent-1", &[watched, untouched])
.await
.unwrap();
assert_eq!(
played_flag("ep-4").await,
Some((Some(1), Some(0))),
"the server's played flag should be mirrored as synced"
);
assert_eq!(
played_flag("ep-5").await,
None,
"an item without UserData should not get an invented played flag"
);
// Marked unwatched here while the server was unreachable.
db_service
.execute(Query::with_params(
"UPDATE user_data SET is_played = 0, pending_sync = 1 \
WHERE user_id = ? AND item_id = ?",
vec![
QueryParam::String("test-user".to_string()),
QueryParam::String("ep-4".to_string()),
],
))
.await
.unwrap();
let mut still_played = create_test_item("ep-4", "Watched Elsewhere", None);
still_played.user_data = Some(UserData {
is_played: Some(true),
..Default::default()
});
repo.save_to_cache("parent-1", &[still_played])
.await
.unwrap();
assert_eq!(
played_flag("ep-4").await,
Some((Some(0), Some(1))),
"an unsynced local toggle must survive a cache write"
);
}
/// UT-152 — a server item carrying *only* a position (no favourite flag)
/// still gets mirrored.
///
+80 -8
View File
@@ -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();