fix(continue-watching): clear the resume position when an item is marked watched

Marking a movie or episode watched left it sitting in Continue Watching with a
checkmark, and the only way to shift it was to play it and skip to the end.

Continue Watching membership on a MediaBrowser server is derived from
UserData.PlaybackPositionTicks alone; Played is never consulted. Marking played
normally zeroes that position as a side effect, so the row usually disappears
and nothing ever checked that it had. When something writes a position back
afterwards the item is left played *and* resumable, which the resume route
happily keeps returning forever. markWatched now reads the UserItemDataDto the
mark already returns and clears the bookmark itself when the server left one
behind, so the postcondition holds however the item got into that state. The
follow-up write costs a request only when the invariant is actually broken.

The writer putting items there is our own offline queue. insertWatchAction
already drops queued progress for an item when the mark is itself queued, but
the online mark writes straight to the server and queues nothing, so a progress
row recorded earlier survived and replayed afterwards — pending actions go out
oldest first — restoring the very position the mark had cleared. The sync
service now listens for watch-state events and discards queued progress for
that item as the mark lands. Progress recorded after a mark is a rewatch and is
queued later, so it is untouched. Plex never showed this because it forwards the
recorded-at timestamp and lets the server discard a stale replay; the
MediaBrowser stop report has nowhere to put one.

Continue Watching also drops the row locally now instead of waiting a round trip
for the refetch to confirm it, matching what removal events already did, and
marking a season or show takes its on-deck episode with it.

Watched items are deliberately still not filtered out of the shelf: Jellyfin
keeps Played set when new progress arrives, so a rewatch in progress is
indistinguishable from a stuck row, and filtering would hide it.

close #1812
This commit is contained in:
edde746
2026-08-06 04:21:24 +02:00
parent 309a107912
commit 094be1fa3e
9 changed files with 386 additions and 6 deletions
+31
View File
@@ -984,6 +984,37 @@ class AppDatabase extends _$AppDatabase {
});
}
/// Drop queued `progress` rows for one item, leaving `watched`/`unwatched`
/// rows alone. Returns how many were removed.
///
/// The mirror of the purge [insertWatchAction] performs: a terminal watch
/// state written straight to the server (the online path, which queues
/// nothing) also supersedes any progress still waiting to replay. Without
/// it, [getPendingWatchActions] hands back the older progress row — it
/// orders by `createdAt` — and replaying it rewrites the resume position the
/// mark just cleared, pinning the item to Continue Watching (#1812).
///
/// Progress queued *after* a mark is a genuine rewatch and is not affected:
/// this only runs at the moment the mark lands.
Future<int> deleteQueuedProgressForItem({
String? profileId,
required ServerId serverId,
String? clientScopeId,
required String ratingKey,
}) {
return _runPendingMutation(() async {
final globalKey = buildGlobalKey(ServerId(serverId), ratingKey);
return (delete(offlineWatchProgress)..where(
(t) =>
t.globalKey.equals(globalKey) &
_nullableTextPredicate(t.profileId, profileId) &
_nullableTextPredicate(t.clientScopeId, clientScopeId) &
t.actionType.equals(OfflineActionType.progress.id),
))
.go();
});
}
/// Delete a specific watch action after successful sync
Future<void> deleteWatchAction(int id) {
return _runPendingMutation(() async {