Files
plezy/lib/services/jellyfin_client/parts/watch_state.dart
T
edde746 094be1fa3e 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
2026-08-06 04:21:24 +02:00

92 lines
4.3 KiB
Dart

part of '../../jellyfin_client.dart';
mixin _JellyfinWatchStateMethods on _JellyfinClientInternals {
/// Marking played normally zeroes `UserData.PlaybackPositionTicks` server-side,
/// which is what drops the row from Continue Watching — membership on this API
/// is derived from the position alone, never from `Played` (verified on
/// Jellyfin 10.11.10). Relying on that side effect is not enough: a stale
/// playback report replayed from the offline queue, another client, or a
/// server-side plugin can leave `Played` set *and* a position behind, and the
/// row is then pinned to Continue Watching forever (#1812).
///
/// So assert the postcondition instead of assuming it, using the
/// `UserItemDataDto` the mark already returns. The follow-up write costs a
/// request only when the invariant is actually broken.
///
/// Folders (series/season) report their own position as 0 while the server
/// resets their children recursively, so nothing extra is owed here.
@override
Future<void> markWatched(MediaItem item) async {
final response = await _http.post(paths.playedItem(item.id), queryParameters: {'userId': connection.userId});
throwIfHttpError(response);
final data = response.data;
final positionMs = data is Map<String, dynamic> ? jellyfinTicksToMs(data['PlaybackPositionTicks']) : null;
if (positionMs == null || positionMs <= 0) return;
appLogger.d('JellyfinClient: ${item.id} stayed resumable after mark-played; clearing its resume position');
await _clearResumePosition(item.id);
}
/// Drop [itemId]'s resume bookmark without touching its played flag.
Future<void> _clearResumePosition(String itemId) async {
final response = await _http.post(
paths.userItemData(itemId),
queryParameters: {'userId': connection.userId},
body: {'PlaybackPositionTicks': 0},
);
throwIfHttpError(response);
}
@override
Future<void> markUnwatched(MediaItem item) async {
final response = await _http.delete(paths.playedItem(item.id), queryParameters: {'userId': connection.userId});
throwIfHttpError(response);
}
/// Hide [item] from Continue Watching while keeping its resume position.
///
/// Emby-only. `POST /Users/{uid}/Items/{id}/HideFromResume?Hide=true` drops
/// the row from `/Users/{uid}/Items/Resume` and leaves
/// `UserData.PlaybackPositionTicks` untouched (verified on Emby 4.9.5).
/// Jellyfin 10.11 has no equivalent route, so it keeps throwing and
/// [ServerCapabilities.continueWatchingRemoval] keeps the affordance hidden.
@override
Future<void> removeFromContinueWatching(MediaItem item) async {
if (!dialect.supportsContinueWatchingRemoval) {
throw UnsupportedError('${dialect.productName} does not support removing items from Continue Watching.');
}
final response = await _http.post(paths.hideFromResume(item.id), queryParameters: {'Hide': 'true'});
throwIfHttpError(response);
}
@override
Future<void> rate(MediaItem item, double rating) async {
// Lossy mapping — the MediaBrowser API only stores a binary like/dislike.
// Treat a negative input as "clear the rating" (DELETE), >= 6/10 as a like
// (POST Likes=true), and the rest as a dislike (POST Likes=false).
// No longer reachable from the rate sheet, which uses [setFavorite]
// for MediaBrowser servers instead; kept as transport for the abstract member.
final response = rating < 0
? await _http.delete(paths.itemRating(item.id), queryParameters: {'userId': connection.userId})
: await _http.post(
paths.itemRating(item.id),
queryParameters: {'userId': connection.userId, 'Likes': (rating >= 6.0).toString()},
);
throwIfHttpError(response);
}
@override
Future<void> setFavorite(MediaItem item, bool isFavorite) => _setItemFavorite(item.id, isFavorite);
/// Toggle the per-user `IsFavorite` flag for [itemId]. Backs [setFavorite]
/// and the live-TV favorite-channel adapter; works on either MediaBrowser dialect.
Future<void> _setItemFavorite(String itemId, bool isFavorite) async {
final path = paths.favoriteItem(itemId);
final response = isFavorite
? await _http.post(path, queryParameters: {'userId': connection.userId})
: await _http.delete(path, queryParameters: {'userId': connection.userId});
throwIfHttpError(response);
}
}