The job "which Plex token represents the active profile right now" was implemented three times against the same registries — the Discover session supplier, the Seerr token supplier, and UserProfileProvider's settings refresh — and the copies had already diverged on whether a Plex Home profile may fall back to the account token. resolveActivePlexToken in lib/profiles/active_plex_token.dart now owns the policy: the per-user ProfileConnection token wins when present, else the account-owner token, with an explicit allowAccountTokenForHomeUser flag (true for Discover/Seerr, false for the settings refresh, which must not impersonate the owner). The three call sites keep only their genuine differences.
333 lines
13 KiB
Dart
333 lines
13 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/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 '../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;
|
|
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,
|
|
}) {
|
|
_connectionRegistry = connections;
|
|
final profileConnectionsChanged = !identical(_profileConnectionRegistry, profileConnections);
|
|
_profileConnectionRegistry = profileConnections;
|
|
_serverManager = serverManager;
|
|
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;
|
|
safeNotifyListeners();
|
|
_watchActiveProfileConnections(ap.active);
|
|
if (_isInitialized) unawaited(refreshProfileSettings());
|
|
}
|
|
|
|
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;
|
|
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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
_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();
|
|
super.dispose();
|
|
}
|
|
}
|