Files
plezy/lib/providers/user_profile_provider.dart
edde746andGitHub ae555ed5d7 feat(settings): account preferences, stored on the server instead of the device (#2112)
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
2026-08-25 08:42:48 +02:00

394 lines
16 KiB
Dart

import 'dart:async';
import '../media/ids.dart';
import 'package:flutter/foundation.dart';
import '../connection/connection.dart';
import '../connection/connection_registry.dart';
import '../media/account_ref.dart';
import '../media/media_server_user_profile.dart';
import '../mixins/disposable_change_notifier_mixin.dart';
import '../profiles/active_plex_token.dart';
import '../profiles/active_profile_provider.dart';
import '../profiles/profile.dart';
import '../profiles/profile_connection.dart';
import '../profiles/profile_connection_registry.dart';
import 'account_preferences_controller.dart';
import '../services/jellyfin_client.dart';
import '../services/multi_server_manager.dart';
import '../services/plex_auth_service.dart';
import '../services/storage_service.dart';
import '../utils/app_logger.dart';
/// Holds the *current user's playback preferences* (audio/subtitle language
/// defaults) for the active profile. Plex profiles fetch from
/// `https://clients.plex.tv/api/v2/user`; MediaBrowser profiles use their
/// dialect's current-user route on the bound server.
///
/// Profile *identity* and *switching* are owned by [ActiveProfileProvider]
/// and [ActiveProfileBinder]. This provider is just the settings cache so
/// the video player can apply the active user's defaults.
///
/// Plex settings are fetched with the *active Home user's token* (minted via
/// `/home/users/{uuid}/switch` and cached in
/// the parent [ProfileConnection.userToken], or stored on the
/// [ProfileConnection] row for local profiles). Falling back to the
/// account-owner's token would silently return the *owner's* settings —
/// wrong defaults for kid profiles, parental restrictions, etc.
class UserProfileProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
UserProfileProvider({this._storageService, this._authService});
MediaServerUserProfile? _profileSettings;
bool _isInitialized = false;
MediaServerUserProfile? get profileSettings => _profileSettings;
PlexAuthService? _authService;
StorageService? _storageService;
ConnectionRegistry? _connectionRegistry;
ProfileConnectionRegistry? _profileConnectionRegistry;
ActiveProfileProvider? _activeProfile;
MultiServerManager? _serverManager;
AccountPreferencesController? _accountPreferences;
StreamSubscription<AccountRef>? _accountPreferencesSubscription;
AccountRef? _servedAccount;
String? _lastSeenActiveId;
StreamSubscription<List<ProfileConnection>>? _profileConnectionSubscription;
String? _watchedProfileConnectionProfileId;
ProfileConnectionRegistry? _watchedProfileConnectionRegistry;
String? _watchedProfileConnectionFingerprint;
/// Wire the dependencies needed to resolve the active user's token / client.
/// May be called multiple times (proxy provider re-builds) — only the
/// most recent values are kept; we re-attach the listener on the new
/// [activeProfile] each time so settings refresh whenever the active
/// profile changes (or the binder finishes wiring up its token).
void attach({
required ConnectionRegistry connections,
required ActiveProfileProvider activeProfile,
required ProfileConnectionRegistry profileConnections,
MultiServerManager? serverManager,
AccountPreferencesController? accountPreferences,
}) {
_connectionRegistry = connections;
final profileConnectionsChanged = !identical(_profileConnectionRegistry, profileConnections);
_profileConnectionRegistry = profileConnections;
_serverManager = serverManager;
if (accountPreferences != null && !identical(_accountPreferences, accountPreferences)) {
_accountPreferences = accountPreferences;
_accountPreferencesSubscription?.cancel();
// A write in the Account preferences screen must reach playback without
// an app restart: the repository is the one cache, so mirror its
// changes for the account this provider is currently serving.
_accountPreferencesSubscription = accountPreferences.repository.changes.listen(_onAccountPreferencesChanged);
}
if (!identical(_activeProfile, activeProfile)) {
_activeProfile?.removeListener(_onActiveProfileChanged);
_activeProfile = activeProfile;
_lastSeenActiveId = activeProfile.activeId;
activeProfile.addListener(_onActiveProfileChanged);
}
if (profileConnectionsChanged) {
_profileConnectionSubscription?.cancel();
_profileConnectionSubscription = null;
_watchedProfileConnectionProfileId = null;
_watchedProfileConnectionRegistry = null;
}
_watchActiveProfileConnections(activeProfile.active);
}
void _onActiveProfileChanged() {
final ap = _activeProfile;
if (ap == null) return;
// Only refresh on actual profile change, not on every binding-state
// tick — refreshProfileSettings awaits awaitBindingSettle internally
// so it'll always read the fresh post-bind token.
final id = ap.activeId;
if (id == _lastSeenActiveId) return;
_lastSeenActiveId = id;
// The previous profile's settings must not bleed into the new profile
// (playback defaults, parental restrictions) while the fetch runs — or
// permanently, when the fetch fails/is unavailable.
_profileSettings = null;
_servedAccount = null;
safeNotifyListeners();
_watchActiveProfileConnections(ap.active);
if (_isInitialized) unawaited(refreshProfileSettings());
}
/// Adopt a value the Account preferences screen just wrote (or refreshed)
/// for the account this provider serves. Other accounts are ignored — their
/// preferences do not govern the active profile's playback.
void _onAccountPreferencesChanged(AccountRef ref) {
if (ref != _servedAccount) return;
final prefs = _accountPreferences?.repository.cached(ref);
if (prefs == null) return;
_profileSettings = prefs;
safeNotifyListeners();
}
void _watchActiveProfileConnections(Profile? profile) {
final registry = _profileConnectionRegistry;
final profileId = profile?.id;
if (identical(_watchedProfileConnectionRegistry, registry) && _watchedProfileConnectionProfileId == profileId) {
return;
}
_profileConnectionSubscription?.cancel();
_profileConnectionSubscription = null;
_watchedProfileConnectionRegistry = registry;
_watchedProfileConnectionProfileId = profileId;
_watchedProfileConnectionFingerprint = null;
if (registry == null || profileId == null) return;
_profileConnectionSubscription = registry.watchForProfile(profileId).listen((rows) {
// Refresh only when something settings-relevant changed. The binder
// bumps lastUsedAt on every bind (markUsed), and drift re-emits on
// each of those writes — refetching plex.tv settings for them is
// wasted round-trips that also wake every awaitBindingSettle path.
final fingerprint = [
for (final row in rows) '${row.connectionId}|${row.userToken ?? ''}|${row.isDefault}',
].join(';');
if (fingerprint == _watchedProfileConnectionFingerprint) return;
final first = _watchedProfileConnectionFingerprint == null;
_watchedProfileConnectionFingerprint = fingerprint;
// The initial emission mirrors the subscribe-time state; the profile
// change that created this subscription already refreshes.
if (first) return;
if (_isInitialized) unawaited(refreshProfileSettings());
});
}
Future<void> initialize() async {
if (_isInitialized && _profileSettings != null) {
return;
}
appLogger.d('UserProfileProvider: initializing');
try {
_storageService = await StorageService.getInstance();
try {
await refreshProfileSettings();
} catch (e) {
appLogger.w('UserProfileProvider: failed to fetch profile settings during initialization', error: e);
}
_isInitialized = true;
} catch (e) {
appLogger.e('UserProfileProvider: critical initialization failure', error: e);
_authService = null;
_storageService = null;
_isInitialized = false;
}
}
/// Fetch the user's profile settings from the API. Best-effort: failures
/// leave [profileSettings] unchanged (cached or null).
Future<void> refreshProfileSettings() async {
_storageService ??= await StorageService.getInstance();
// Wait for the binder to finish wiring up the active profile so we
// read the freshly-minted user-token rather than racing the cache.
await _activeProfile?.awaitBindingSettle();
// A late-landing fetch must not clobber another profile's settings —
// discard the result when the active profile changed mid-flight.
final requestedId = _activeProfile?.activeId;
bool stale() => _activeProfile?.activeId != requestedId;
final settingsConnection = await _resolveActiveSettingsConnection();
final connection = settingsConnection?.connection;
// Preferred path: the shared account-preferences cache, so the Account
// preferences screen and playback read one value. Falls through to the
// direct fetches below only when no account resolves (no controller
// attached, unreachable client, or an unminted Plex Home token).
if (connection != null && await _refreshFromAccountCache(connection, stale)) return;
if (connection is JellyfinConnection) {
final mediaBrowserClient = _resolveMediaBrowserClient(connection);
if (mediaBrowserClient == null) {
appLogger.d('UserProfileProvider: default MediaBrowser client unavailable, skipping settings refresh');
return;
}
final profile = await mediaBrowserClient.fetchUserProfile();
if (profile != null && !stale()) {
_profileSettings = profile;
safeNotifyListeners();
}
return;
}
final userToken = await _resolveActivePlexUserToken(preferred: settingsConnection);
if (userToken == null || userToken.isEmpty) {
appLogger.d('UserProfileProvider: no token for active profile, skipping settings refresh');
return;
}
try {
_authService ??= await PlexAuthService.create();
final profile = await _authService!.getUserProfile(userToken);
if (stale()) return;
_profileSettings = profile;
safeNotifyListeners();
} catch (e) {
appLogger.w('UserProfileProvider: failed to fetch user profile settings', error: e);
}
}
/// Load this connection's account preferences through the shared repository.
///
/// Returns false when no account resolves for [connection], so the caller can
/// use its direct fetch. A resolved-but-failed load returns true: the failure
/// is already best-effort here, and retrying the same request through a
/// second code path would only double the round-trips.
Future<bool> _refreshFromAccountCache(Connection connection, bool Function() stale) async {
final controller = _accountPreferences;
if (controller == null) return false;
final account = await controller.accountForConnectionId(connection.id);
if (account == null) return false;
final ref = account.ref;
_servedAccount = ref;
try {
final prefs = await controller.repository.load(ref, forceRefresh: true);
if (stale()) return true;
_profileSettings = prefs;
safeNotifyListeners();
} catch (e) {
appLogger.w('UserProfileProvider: failed to load account preferences', error: e);
}
return true;
}
JellyfinClient? _resolveMediaBrowserClient(JellyfinConnection conn) {
final manager = _serverManager;
if (manager == null) return null;
final client = manager.getClient(ServerId(conn.serverMachineId));
return client is JellyfinClient ? client : null;
}
/// Resolve the Plex credential for the active profile without crossing
/// identity boundaries.
///
/// A Plex Home profile may use only the switched token stored on its exact
/// parent [ProfileConnection] — [resolveActivePlexToken] with the
/// account-token fallback disabled. A missing or empty switched token
/// returns `null`; the parent account token represents a different user.
///
/// Local Plezy profiles keep their explicitly selected Plex account fallback
/// because that account is the identity selected by the local profile.
Future<String?> _resolveActivePlexUserToken({
({ProfileConnection profileConnection, Connection connection})? preferred,
}) async {
final connections = _connectionRegistry;
final activeProfile = _activeProfile;
if (connections == null || activeProfile == null) return null;
final profile = activeProfile.active;
if (profile == null) return null;
final connectionList = await connections.list();
final pcRegistry = _profileConnectionRegistry;
if (profile.kind == ProfileKind.plexHome) {
// Divergent preconditions layered over the shared resolver: require the
// switched-user uuid and the exact parent account — never resolve a
// Home profile through some other bound account.
final parentId = profile.parentConnectionId;
final uuid = profile.plexHomeUserUuid;
if (parentId == null || uuid == null) return null;
if (!connectionList.whereType<PlexAccountConnection>().any((account) => account.id == parentId)) {
return null;
}
if (pcRegistry == null) return null;
final resolved = await resolveActivePlexToken(
activeProfile: activeProfile,
connections: connections,
profileConnections: pcRegistry,
allowAccountTokenForHomeUser: false,
);
return resolved?.token;
}
final plexAccounts = connectionList.whereType<PlexAccountConnection>().toList();
if (plexAccounts.isEmpty) return null;
// Local profile — read the user-token off the default ProfileConnection
// (listForProfile orders default first). Each connection persists its
// own minted token, so this is already user-scoped.
final resolved = preferred ?? await _resolveActiveSettingsConnection();
if (resolved?.connection is PlexAccountConnection && resolved!.profileConnection.hasToken) {
return resolved.profileConnection.userToken;
}
final resolvedConnection = resolved?.connection;
if (resolvedConnection is PlexAccountConnection) {
return resolvedConnection.accountToken;
}
return plexAccounts.first.accountToken;
}
Future<({ProfileConnection profileConnection, Connection connection})?> _resolveActiveSettingsConnection() async {
final pcRegistry = _profileConnectionRegistry;
final activeProfile = _activeProfile;
final connections = _connectionRegistry;
if (pcRegistry == null || activeProfile == null || connections == null) return null;
final profile = activeProfile.active;
if (profile == null || profile.kind == ProfileKind.plexHome) return null;
final pcs = await pcRegistry.listForProfile(profile.id);
if (pcs.isEmpty) return null;
final connectionsList = await connections.list();
final byId = {for (final c in connectionsList) c.id: c};
for (final pc in pcs) {
final conn = byId[pc.connectionId];
if (conn != null) return (profileConnection: pc, connection: conn);
}
return null;
}
@visibleForTesting
Future<Connection?> debugResolveActiveSettingsConnectionForTesting() async {
return (await _resolveActiveSettingsConnection())?.connection;
}
@visibleForTesting
Future<String?> debugResolveActivePlexUserTokenForTesting() {
return _resolveActivePlexUserToken();
}
@visibleForTesting
String? get debugWatchedProfileConnectionProfileId => _watchedProfileConnectionProfileId;
/// Logout — clear settings and credentials. Called from the discover
/// screen "sign out" action; the rest of the teardown (clearing
/// connections, profiles, etc.) happens in the screen's logout flow.
Future<void> logout() async {
try {
_storageService ??= await StorageService.getInstance();
await _storageService!.clearUserData();
_profileSettings = null;
_servedAccount = null;
_authService = null;
_storageService = null;
_isInitialized = false;
appLogger.i('UserProfileProvider: logged out');
} catch (e) {
appLogger.e('UserProfileProvider: logout error', error: e);
} finally {
safeNotifyListeners();
}
}
@override
void dispose() {
_activeProfile?.removeListener(_onActiveProfileChanged);
_profileConnectionSubscription?.cancel();
_accountPreferencesSubscription?.cancel();
super.dispose();
}
}