Preferences that belong to the media-server account had nowhere to live in Plezy. Jellyfin keeps a user's audio/subtitle language, subtitle mode and several library options in `UserConfiguration`; plex.tv keeps the same language choices plus watched indicators and review visibility on the account. Plezy read four of those fields for auto-track selection and could never show or change any of them.
Adds an Account preferences section under Connections. One account edits in place; several show a picker first, scoped to the active profile's own connections so a managed Plex Home user never edits the owner's record. Every row is gated on what the backend can actually store.
It also moves "rewatching in Next Up" onto the account. There is no `UserConfiguration` field for it, but the per-user `DisplayPreferences` store is keyed `(userId, displayPreferencesId, client)` with no device component, so the switch goes there and follows the account.
`AccountRef` keys by account, not `clientScopeId`: MediaBrowser is `{machineId}/{userId}`, Plex is (account, Home user). Writes are patch-shaped because both backends replace whole objects — Jellyfin's `POST /Users/{id}/Configuration` and its `DisplayPreferences` row both reset omitted fields, so each write re-reads, merges only the patched keys, and posts back. Plex takes its changes as query parameters with an empty body, and its `experience` blob and the PMS `/accounts/1` mirror are deliberately untouched.
`AccountPreferencesController` owns a single repository above the profile session, so changing a language in settings reaches the next playback without a restart. Emby is gated out of rewatching through `MediaBrowserDialect.supportsNextUpRewatching`.
close #1910
98 lines
5.0 KiB
Dart
98 lines
5.0 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';
|
|
|
|
/// Whole-object user-configuration write. Jellyfin's modern spelling is
|
|
/// `POST /Users/Configuration?userId=`, but this legacy user-scoped route is
|
|
/// preferred because Emby only shipped it and Jellyfin still routes it
|
|
/// through `UserController.UpdateUserConfigurationLegacy`.
|
|
String get userConfiguration => '$_user/Configuration';
|
|
|
|
/// Per-user, per-client key/value store, identical on both dialects (Plezy
|
|
/// inherits it from Emby's original API). `GET` returns the row, `POST`
|
|
/// replaces it; both need `userId` and `client` query parameters.
|
|
///
|
|
/// The row is keyed `(userId, displayPreferencesId, client)`, so a value
|
|
/// written under Plezy's own client name follows the user across devices and
|
|
/// installs without touching the `emby` row that jellyfin-web and
|
|
/// jellyfin-androidtv share.
|
|
static String displayPreferences(String displayPreferencesId) => '/DisplayPreferences/${_id(displayPreferencesId)}';
|
|
|
|
/// Continue Watching / resumable items. On Emby the response also carries
|
|
/// one zero-position next episode per started series, and it is the only
|
|
/// listing that honours `HideFromResume` — see
|
|
/// [MediaBrowserDialect.resumeReturnsOnlyStartedItems].
|
|
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). Honoured only
|
|
/// by the dedicated [resumeItems] route, which is why every Emby playback
|
|
/// shelf reads it.
|
|
///
|
|
/// 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';
|
|
}
|