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
77 lines
3.7 KiB
Dart
77 lines
3.7 KiB
Dart
import '../media/media_browser_dialect.dart';
|
|
|
|
/// Route builders for the endpoints where the Jellyfin and Emby dialects of the
|
|
/// MediaBrowser API diverge.
|
|
///
|
|
/// Jellyfin 10.9 renamed a batch of user-scoped routes to unprefixed forms
|
|
/// (`/Users/{uid}/PlayedItems/{id}` → `/UserPlayedItems/{id}`) and introduced
|
|
/// `/Users/Me`. Emby only ever shipped the user-scoped spellings and fails on
|
|
/// the new ones — `/Users/Me` returns 500 `Unrecognized Guid format` because
|
|
/// `Me` is bound as a user id, and the rest 404. Keeping every divergent route
|
|
/// here lets the client parts stay dialect-agnostic.
|
|
///
|
|
/// Routes that are identical on both dialects (`/Items`, `/Users/{uid}/Views`,
|
|
/// `/Users/{uid}/Items/{id}`, `/Users/{uid}/Items/Latest`, `/Shows/*`,
|
|
/// `/Sessions/Playing*`, `/Items/{id}/PlaybackInfo`, image and stream routes)
|
|
/// deliberately do not appear here.
|
|
class MediaBrowserPaths {
|
|
const MediaBrowserPaths({required this.dialect, required this.userId});
|
|
|
|
final MediaBrowserDialect dialect;
|
|
final String userId;
|
|
|
|
String get _user => '/Users/${Uri.encodeComponent(userId)}';
|
|
|
|
static String _id(String itemId) => Uri.encodeComponent(itemId);
|
|
|
|
/// The authenticated user's own DTO — health probe and user-preference read.
|
|
String get currentUser => dialect.requiresUserScopedItemRoutes ? _user : '/Users/Me';
|
|
|
|
/// Continue Watching / resumable items.
|
|
String get resumeItems => dialect.requiresUserScopedItemRoutes ? '$_user/Items/Resume' : '/UserItems/Resume';
|
|
|
|
/// Played flag write route (`POST` to mark, `DELETE` to unmark).
|
|
String playedItem(String itemId) =>
|
|
dialect.requiresUserScopedItemRoutes ? '$_user/PlayedItems/${_id(itemId)}' : '/UserPlayedItems/${_id(itemId)}';
|
|
|
|
/// Per-user playback-state write. `POST` with `{"PlaybackPositionTicks": 0}`
|
|
/// clears the resume bookmark while leaving `Played` untouched (verified on
|
|
/// Jellyfin 10.11.10 for both spellings).
|
|
///
|
|
/// Continue Watching membership on this API is derived purely from
|
|
/// `UserData.PlaybackPositionTicks > 0` — `Played` is not consulted — so this
|
|
/// is the only route that can guarantee a finished item stops being
|
|
/// resumable. See [MediaServerClient.markWatched].
|
|
String userItemData(String itemId) => dialect.requiresUserScopedItemRoutes
|
|
? '$_user/Items/${_id(itemId)}/UserData'
|
|
: '/UserItems/${_id(itemId)}/UserData';
|
|
|
|
/// Favourite flag write route (`POST` to add, `DELETE` to remove).
|
|
String favoriteItem(String itemId) => dialect.requiresUserScopedItemRoutes
|
|
? '$_user/FavoriteItems/${_id(itemId)}'
|
|
: '/UserFavoriteItems/${_id(itemId)}';
|
|
|
|
/// Thumbs-up/down write route (`POST ?Likes=`, `DELETE` to clear).
|
|
String itemRating(String itemId) =>
|
|
dialect.requiresUserScopedItemRoutes ? '$_user/Items/${_id(itemId)}/Rating' : '/UserItems/${_id(itemId)}/Rating';
|
|
|
|
/// Trailers attached to a movie/series.
|
|
String localTrailers(String itemId) => dialect.requiresUserScopedItemRoutes
|
|
? '$_user/Items/${_id(itemId)}/LocalTrailers'
|
|
: '/Items/${_id(itemId)}/LocalTrailers';
|
|
|
|
/// Extras/behind-the-scenes children.
|
|
String specialFeatures(String itemId) => dialect.requiresUserScopedItemRoutes
|
|
? '$_user/Items/${_id(itemId)}/SpecialFeatures'
|
|
: '/Items/${_id(itemId)}/SpecialFeatures';
|
|
|
|
/// Hide an item from Continue Watching without touching its playback
|
|
/// position (`?Hide=true` to hide, `?Hide=false` to restore).
|
|
///
|
|
/// Emby-only: Jellyfin 10.11 has no equivalent under either spelling
|
|
/// (measured 404 for both `/UserItems/{id}/HideFromResume` and the
|
|
/// user-scoped form), which is why [ServerCapabilities.jellyfin] leaves
|
|
/// `continueWatchingRemoval` false while [ServerCapabilities.emby] sets it.
|
|
String hideFromResume(String itemId) => '$_user/Items/${_id(itemId)}/HideFromResume';
|
|
}
|