Skip to content

Fix watch progress: retain episode progress and make exit completion runtime-aware - #3349

Open
ieno wants to merge 3 commits into
NuvioMedia:devfrom
ieno:fix/watch-completion-thresholds
Open

ieno wants to merge 3 commits into
NuvioMedia:devfrom
ieno:fix/watch-completion-thresholds

Conversation

@ieno

@ieno ieno commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Fix the provider/local progress mismatch, and align local exit completion with the provider's 80% behavior when the remaining runtime allows it.

  • Retain local in-progress episode positions when the active provider has no entry.
  • Decide exit completion on remaining time and credits segments instead of a flat percentage.
  • Keep same-item teardowns and the external-player handoff out of the new completion rule.
  • Fix a race where switching episodes could suppress the next episode's completion.

Tracker behavior is unchanged. Local completion behavior changes.

Exit completion rule:

complete = progress >= 80%
           AND ( progress >= 90%
                 OR remaining <= 4 min
                 OR ( a credits segment has started
                      AND it ends within the final 4 min
                      AND it runs no longer than 4 min ) )

The 80% floor applies before any of the qualifications. A credits segment means outro, ed or mixed-ed from the skip-intro providers.

Maintainer decision requested. This changes local completion policy, not tracker behavior. The rule runs in the player, so it applies to every exit including movies and users without Trakt or Simkl. Please explicitly approve that scope.

This is not a fixed "less than 20% remaining" threshold. A percentage ignores runtime: 85% of a 22 minute episode leaves three minutes, 85% of a 60 minute episode leaves nine. Four minutes remaining means the same thing on both, which is what a flat threshold cannot express.

exit before after
22 min episode at 85% in progress completed
60 min episode at 85% in progress in progress
60 min episode at 90% completed completed
any runtime below 80% in progress in progress
same-item teardown, any progress unchanged unchanged

What changed

  • mergeEpisodeProgressWithRetainedLocal retains in-progress local entries the provider has no key for. Provider entries win; completed local entries are excluded.
  • getAllEpisodeProgress seeds its local source so the DataStore read does not gate the combined projection.
  • PlaybackCompletionRules decides exit completion. isTerminalEndingSegment accepts a credits segment only when it both ends near the file end and is short enough to be credits; open-ended and malformed intervals are rejected.
  • leavesCurrentItem distinguishes a real exit from same-item teardown, source and engine switches, and the external-player handoff.
  • EpisodeCompletionLatch and currentPlaybackWriteSnapshot make the completion claim and the position it is based on resolve before the write coroutine can suspend.

PR type

  • Translation/localization only
  • Critical bug fix

Why

Exiting an episode between 80% and 90% left it neither in progress nor watched until history synced.

Tracker completion starts at 80%, while local completion used WatchProgress.COMPLETED_THRESHOLD at 90%. flushPlaybackSnapshotForSwitchOrExit used the tracker threshold for the stop scrobble and the local 90% threshold for local completion, so an exit between the two marked watched remotely while keeping the item in progress locally.

The episode card had a second cause. getAllEpisodeProgress reads only provider progress when one is connected, so the locally saved position was not read back once the provider removed its resume point. Continue Watching already retained local progress; the episode path did not.

Issue or approval

Fixes #3348

b580d7e7a changed WatchProgress.COMPLETED_THRESHOLD from 85% to 90%. This PR leaves it at 90% and adds an earlier completion path above an 80% floor for short remaining runtimes.

Reproduction steps

Requires Trakt or Simkl connected and selected as the watch progress source.

  1. Play an episode to roughly 85%, before the next-episode button appears.
  2. Exit the player.
  3. Check the season overview and Continue Watching.

Before this change the episode has no badge and no progress bar, and Continue Watching still offers it until history syncs. After it, the episode either shows a watched badge or a progress bar at the position reached, depending on how much runtime was left.

UI / behavior impact

  • No UI change
  • No behavior change
  • UI changed only to fix a documented glitch/bug
  • Behavior changed only to fix a documented bug/regression
  • UI change has explicit maintainer approval
  • Behavior change has explicit maintainer approval

No layout or design change. The existing watched/progress UI now reflects the corrected state.

A watched episode no longer renders a progress bar alongside the badge. Resume still uses the retained position, so a watched episode can offer Resume at 85% where it previously offered Play.

Policy check

  • I have read and understood CONTRIBUTING.md.
  • This PR fits the current PR policy: localization/translation only or a critical bug fix.
  • This PR does not add features, UI changes, refactors, or other non-critical changes.
  • This PR is small, focused, and limited to one issue.
  • This PR does not bundle unrelated refactors, cleanups, formatting, or drive-by changes.
  • This PR includes a linked issue, reproduction steps, and testing notes if it is a critical bug fix.
  • I listed the testing performed below.

Scope boundaries

Progress source Tracker connected Completion rule Retained-local merge
Trakt or Simkl yes applies runs
Nuvio Sync yes applies not reached
Nuvio Sync no applies not reached
None no applies not reached

The completion rule applies to every configuration; retained-local progress applies only to the first.

Intentionally not changed

  • What the tracker records. A stop scrobble at 85% still marks the episode watched server-side.
  • Stop scrobbles on source and engine switches.
  • Cloud library playback, which still completes only on a natural end.
  • The next-episode card threshold.
  • WatchProgress.COMPLETED_THRESHOLD, which stays at 90%.
  • Retained-local projection for movies. A movie at 85% still shows no retained progress until history syncs.

No extra polish. No component, layout or design changes, and no unrelated cleanups.

Known limitations

  • A retained local position stays visible until the local entry is cleared or the episode completes, so a position removed on another device persists locally.
  • SearchViewModel, HomeViewModelPresentationPipeline and LibraryViewModel call .first() on the seeded projection and can therefore miss retained entries. Those callers received provider-only data before this PR and receive provider-only data now.
  • The credits-length bound is a heuristic. Genuinely longer endings fall back to the 90% threshold.

Testing

Unit tests: 55 new, in PlaybackCompletionRulesTest (40), TrackingProgressProjectionTest (7), EpisodeSwitchTargetTest (4) and EpisodeCompletionLatchTest (4), covering the decision table edges, projection merge semantics, episode-switch classification and the completion latch invariant.

Full suite: 1225 tests on this branch against 1170 on dev at cf83131c7, 15 failures on each with identical names. The 55-test difference is exactly the new tests.

Device: NVIDIA Shield TV Pro (2019), Android 11, Simkl connected and selected as progress source, both engines exercised. Percentages are providedProgress from the scrobble diagnostics. flag is leavesCurrentItem.

Scenario Progress Flag Result
Episodes panel, different episode (short) 85.07% true episode left completed
Episodes panel, different episode, playing across the switch 84.91% true episode left completed; next episode completed at its natural end
Episodes panel, different episode (long) 84.93% true episode left keeps progress bar
Source switch, same episode 86.27% false not completed
Engine switch, same episode 84.66% false not completed, bar stable across navigation
Source switch, same episode 95.32% false completed, via the 90% rule
Long episode exit 85.08% true progress bar
Short episode exit 84.12% true completed
Short episode exit 85.98% true completed
Exit below the floor 75.19% true progress bar
Exit below the floor 73.56% true progress bar
Movie exit (26:49 runtime) 84.12% true progress bar, below the band
Movie exit (26:49 runtime) 87.85% true completed
Feature-length movie exit 89.72% true progress bar
Feature-length movie exit 91.69% true completed
External-player handoff 87.19% false not completed locally
External-player handoff 93.90% false completed, via the 90% rule
Profile isolation, two profiles 84.53% / 75.01% true second profile shows neither entry
Natural end 99.5% true completed, next-episode flow unchanged

In these tests, a stop scrobble at or above 80% moved the episode into Simkl's history, so a watched badge in the table is often provider-sourced rather than local.

Not covered: cloud library playback, for lack of a cloud library on the test device. The credits-segment clause is only the deciding rule when credits start with more than four minutes remaining. Across 95 episodes from 16 shows the median outro is 66 s; on four episodes checked against real runtimes, credits began at 95-97% of runtime.

Supporting evidence, from an earlier run before the latch and credits-length changes. The 85.01% row is the reported bug; that card was blank before this change.

Scenario Progress Result
Long episode exit (57:58) 85.01% progress bar retained, CW agrees at 8m left
Short episode exit, no tracker connected 86.34% completed
Resume from the episode card after an 85% exit 85.33% resumes at the retained position

Screenshots / Video

Not a UI change.

Breaking changes

None.

Linked issues

Fixes #3348

@ram130

ram130 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

A lot of paragraphs, long explanations in both the pr and code. Almost 50% of the lines added is explanations. Must be gemini.

@ieno

ieno commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Opus and GPT actually, you should've seen the initial draft ;)

Honestly, I find they're great for making unit tests, which do sometimes end up being a bit too substantive. They're also pretty good at summarizing what I'm trying to get at, although most LLMs do tend to be on the lengthy side, as you've noted.

Anyway, did you get a chance to look at the changes themselves? If so, any feedback on that part?

@ram130

ram130 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Opus and GPT actually, you should've seen the initial draft ;)

Honestly, I find they're great for making unit tests, which do sometimes end up being a bit too substantive. They're also pretty good at summarizing what I'm trying to get at, although most LLMs do tend to be on the lengthy side, as you've noted.

Anyway, did you get a chance to look at the changes themselves? If so, any feedback on that part?

Seems good so far. What was the short-term goal of this? To basically stop marking watched on Trakt when the user pauses with less than 20% remaining?

@halibiram Thoughts?

@ieno

ieno commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Opus and GPT actually, you should've seen the initial draft ;)
Honestly, I find they're great for making unit tests, which do sometimes end up being a bit too substantive. They're also pretty good at summarizing what I'm trying to get at, although most LLMs do tend to be on the lengthy side, as you've noted.
Anyway, did you get a chance to look at the changes themselves? If so, any feedback on that part?

Seems good so far. What was the short-term goal of this? To basically stop marking watched on Trakt when the user pauses with less than 20% remaining?

@halibiram Thoughts?

TL;DR: what you said, except the other way round :)

The PR doesn't change what Trakt/Simkl record; they still mark something watched at 80% server-side. The issue was that locally it uses 90%, so if you exit around 85%, the provider has already dropped its progress while locally it's still considered in progress. That could leave the season overview showing neither progress nor a watched badge until the tracker history synced back up.

This change fixes that mismatch by retaining the local position when the provider no longer has one, and making local completion runtime-aware. So a 22-minute episode at 85% can be considered complete, while a 60-minute one at 85% isn't.

@ieno
ieno force-pushed the fix/watch-completion-thresholds branch from 33fafdf to f096d76 Compare September 3, 2026 11:44
@ieno

ieno commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Folded in an additional fix for the external-player handoff that surfaced during recent on device testing: stopAndRelease was treating it as leaving the item, so handing a nearly-finished episode to VLC marked it watched locally.

Also tightened the terminal-ending check against an open-ended sentinel in the animeskip data, and addressed the lengthyness a bit :)

@ieno ieno changed the title Fix watch progress: retain episode progress when the provider drops resume state, and make exit completion runtime-aware Fix watch progress: retain episode progress and make exit completion runtime-aware Sep 3, 2026
ieno added 3 commits September 4, 2026 14:05
Trakt and Simkl delete the resume point once a stop scrobble reports 80% or more. getAllEpisodeProgress reads only provider progress while a provider is connected, so the position saved on exit was never read back and the episode card showed nothing until history synced.

Retain local in-progress entries the provider has no entry for. Provider entries still win. Completed local entries are not retained, so they cannot resurrect watched state removed elsewhere.

Seed the local flow with onStart so the DataStore read does not gate the combine's first emission.

The completed badge now takes precedence over the progress bar on the episode card.
Trakt and Simkl treat a stop scrobble at 80% or more as completion. Local completion used WatchProgress.COMPLETED_THRESHOLD at 90%. Exiting between the two told the tracker the item was finished while recording it as in progress locally.

A fixed percentage ignores runtime: 85% of a 22 minute episode leaves three minutes, 85% of a 60 minute episode leaves nine. PlaybackCompletionRules qualifies an exit on remaining time or on credits having started, above the 80% floor the provider has already acted on. The 90% threshold stays as the fallback.

The four minute window only opens a band under the fallback between 20 and 40 minutes of runtime. Outside that the floor or the fallback is reached first. Tests pin both crossovers from either side.

A segment counts as credits when it ends within four minutes of the file end and runs no longer than that. The length bound is a deliberate cutoff, not something the metadata asserts: without it an ending annotated from 42:00 to the end of a 60 minute file completes an exit at 48:00, with twelve minutes left. Bounding the start at the 80% floor instead was rejected, because it leaves the clause doing almost nothing on the runtimes where skip metadata exists. Open-ended animeskip intervals and non-finite times fail the bounds.

leavesCurrentItem separates an item exit from a teardown that keeps the same item playing, such as an engine switch or codec recovery. It defaults to false. Episode switches resolve it from the target video id, since a stream selection can target the episode already playing. The external-player handoff passes false: stopAndRelease ran just before the intent, so handing off a short episode at 85% completed it locally for playback the user was continuing elsewhere. This covers local state only; the flush still emits a stop scrobble, so the provider drops its resume point either way.
…ends

saveWatchProgressInternal read hasMarkedCurrentEpisodeCompleted, then set it inside its write coroutine, after awaiting normalizeParentContentId.

That put a suspend call between the read and the write. An episode switch continues on the calling thread meanwhile, reaches resetLoadingOverlayForNewStream and clears the latch, and the write then lands against the incoming episode. Its own completion is skipped, leaving it at its last periodic save. Whether the call actually suspends on a given run does not matter; the code has to be correct either way, and it was not.

Replace the boolean with EpisodeCompletionLatch, whose claim() is a single read-modify-write. The read cannot be separated from the write, so the shape that caused this cannot be spelled. The decision is resolved before the launch: only contentId is normalized in the coroutine, and isCompleted() does not read it.

A claim always reaches its write. normalizeParentContentId resolves through a StateFlow and a string parse, with no throw surface, so there is no path that claims and then skips markAsCompleted.

The latch is confined to the player's main-thread callbacks and its viewModelScope jobs, so a plain field is correct. An atomic would imply cross-thread use that does not exist, and would not address the ordering, which is the actual property.

flushPlaybackSnapshotForSwitchOrExit had a second version of the same problem. It read the position to decide completion, then saveWatchProgress read it again to persist, with a stop scrobble dispatched in between and the player not yet stopped. currentPlaybackWriteSnapshot resolves position and duration once, carrying the guards saveWatchProgress already applied.

EpisodeCompletionLatchTest pins the invariant the switch broke: a claim, a reset, then a claim that still succeeds. It covers the latch, not the call sites, so a device run is still needed to confirm the controller claims and resets in the right places. Switch episodes during the outro, let the next episode play to its end, confirm it is marked watched.
@ieno
ieno force-pushed the fix/watch-completion-thresholds branch from f096d76 to 074dc90 Compare September 4, 2026 16:53
@ieno

ieno commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current dev for the new profileId parameter in getAllEpisodeProgress.

Also folded the handoff fix into commit 2, so we're back to 3 commits. The completion latch and ending-segment bound are correctness fixes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Episode near the end shows neither progress nor watched state until tracker sync

2 participants