diff --git a/lib/connection/connection.dart b/lib/connection/connection.dart index 5bb4bc76..9f02de79 100644 --- a/lib/connection/connection.dart +++ b/lib/connection/connection.dart @@ -1,6 +1,7 @@ import '../media/media_backend.dart'; import '../models/plex/plex_home_user.dart'; import '../services/plex_auth_service.dart'; +import '../utils/json_utils.dart'; import '../utils/url_utils.dart'; /// Identifier of a backend kind a [Connection] points at. Lighter-weight than @@ -232,6 +233,13 @@ class JellyfinConnection extends Connection { /// match/unmatch, edit metadata) without an extra round-trip. final bool isAdministrator; + /// The authenticated user's `PrimaryImageTag`, or `null` when they have no + /// profile picture. Jellyfin omits the key entirely in that case, and the + /// tag is `MD5(imagePath + lastModified)` so it changes on every upload — + /// which makes the derived avatar URL self-invalidating. Captured at auth + /// time and refreshed by [JellyfinClient.checkHealth]. + final String? primaryImageTag; + JellyfinConnection({ required this.id, required String baseUrl, @@ -243,6 +251,7 @@ class JellyfinConnection extends Connection { required this.accessToken, required this.deviceId, this.isAdministrator = false, + this.primaryImageTag, this.status = ConnectionStatus.unknown, required this.createdAt, this.lastAuthenticatedAt, @@ -298,6 +307,12 @@ class JellyfinConnection extends Connection { String? accessToken, String? deviceId, bool? isAdministrator, + String? primaryImageTag, + + /// Deleting a Jellyfin profile picture drops `PrimaryImageTag` from the + /// user DTO, so a refresh must be able to null the cached value — a bare + /// `primaryImageTag: null` is indistinguishable from "unchanged". + bool clearPrimaryImageTag = false, ConnectionStatus? status, DateTime? createdAt, DateTime? lastAuthenticatedAt, @@ -314,6 +329,7 @@ class JellyfinConnection extends Connection { accessToken: accessToken ?? this.accessToken, deviceId: deviceId ?? this.deviceId, isAdministrator: isAdministrator ?? this.isAdministrator, + primaryImageTag: clearPrimaryImageTag ? null : (primaryImageTag ?? this.primaryImageTag), status: status ?? this.status, createdAt: createdAt ?? this.createdAt, lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt, @@ -332,6 +348,7 @@ class JellyfinConnection extends Connection { 'accessToken': accessToken, 'deviceId': deviceId, 'isAdministrator': isAdministrator, + 'primaryImageTag': primaryImageTag, }; } @@ -359,9 +376,23 @@ class JellyfinConnection extends Connection { accessToken: json['accessToken'] as String? ?? '', deviceId: json['deviceId'] as String? ?? '', isAdministrator: json['isAdministrator'] as bool? ?? false, + primaryImageTag: normalizePrimaryImageTag(json['primaryImageTag']), status: status, createdAt: createdAt, lastAuthenticatedAt: lastAuthenticatedAt, ); } + + /// Tolerant read of a Jellyfin user DTO's `PrimaryImageTag`. + /// + /// The tag is decorative — a fork or a drifted scalar type must never brick + /// sign-in — so this coerces through [readStringField] rather than casting, + /// and collapses absent/blank to `null` ("no picture"). + static String? readPrimaryImageTag(Map userDto) => + normalizePrimaryImageTag(readStringField(userDto, 'PrimaryImageTag')); + + static String? normalizePrimaryImageTag(Object? raw) { + final tag = raw?.toString().trim(); + return tag == null || tag.isEmpty ? null : tag; + } } diff --git a/lib/main.dart b/lib/main.dart index 2ba36745..a71e6c7b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1472,6 +1472,7 @@ class _MainAppState extends State with WidgetsBindingObserver { registry: context.read(), plexHome: context.read(), connections: context.read(), + profileConnections: context.read(), storage: context.read(), ); unawaited(provider.initialize()); diff --git a/lib/profiles/active_profile_provider.dart b/lib/profiles/active_profile_provider.dart index ba16fd5e..ee0e3dea 100644 --- a/lib/profiles/active_profile_provider.dart +++ b/lib/profiles/active_profile_provider.dart @@ -11,6 +11,9 @@ import '../services/storage_service.dart'; import '../utils/app_logger.dart'; import 'plex_home_service.dart'; import 'profile.dart'; +import 'profile_avatar_source.dart'; +import 'profile_connection.dart'; +import 'profile_connection_registry.dart'; import 'profile_merge.dart'; import 'profile_registry.dart'; @@ -27,6 +30,7 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier required this._registry, required this._plexHome, required this._connections, + required this._profileConnections, this._storage, this._activeProfileIdWriter, }); @@ -34,6 +38,7 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier final ProfileRegistry _registry; final PlexHomeService _plexHome; final ConnectionRegistry _connections; + final ProfileConnectionRegistry _profileConnections; StorageService? _storage; final Future Function(String profileId)? _activeProfileIdWriter; @@ -42,9 +47,12 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier List _localProfiles = const []; Map> _plexHomeUsers = const {}; Map _connectionsById = const {}; + Map> _connectionsByProfile = const {}; + Map _avatarUrls = const {}; StreamSubscription>? _localSub; StreamSubscription>? _connSub; + StreamSubscription>? _pcSub; StreamSubscription>>? _plexHomeSub; Future? _initializeFuture; bool _initialized = false; @@ -61,6 +69,10 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier Profile? get active => _active; String? get activeId => _active?.id; List get profiles => _profiles; + + /// Derived picture URL for [profileId], or null when initials should render. + String? avatarUrlFor(String profileId) => _avatarUrls[profileId]; + bool get hasMultipleProfiles => _profiles.length > 1; bool get isInitialized => _initialized; @@ -170,6 +182,14 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier _resolveActive(); safeNotifyListeners(); }); + _pcSub = _profileConnections.watchAll().listen((list) { + final byProfile = groupConnectionsByProfile(list); + if (_sameProfileConnections(byProfile, _connectionsByProfile)) return; + _connectionsByProfile = byProfile; + _recomputeProfiles(); + _resolveActive(); + safeNotifyListeners(); + }); _plexHomeSub = _plexHome.stream.listen((cache) { if (_samePlexHomeUsers(cache, _plexHomeUsers)) return; _plexHomeUsers = cache; @@ -191,6 +211,7 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier _localProfiles = await _registry.list(); final initialConns = await _connections.list(); _connectionsById = {for (final c in initialConns) c.id: c}; + _connectionsByProfile = groupConnectionsByProfile(await _profileConnections.listAll()); _plexHomeUsers = _plexHome.current; _recomputeProfiles(); _resolveActive(); @@ -209,6 +230,29 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier return true; } + static bool _sameProfileConnections(Map> a, Map> b) { + if (a.length != b.length) return false; + for (final entry in a.entries) { + final other = b[entry.key]; + if (other == null || entry.value.length != other.length) return false; + for (final row in entry.value) { + var hasSameFingerprint = false; + for (final candidate in other) { + if (row.profileId == candidate.profileId && + row.connectionId == candidate.connectionId && + row.userIdentifier == candidate.userIdentifier) { + hasSameFingerprint = true; + break; + } + } + // Only these identity fields affect avatar selection. Deliberately + // ignore tokens, default status, and binder-maintained timestamps. + if (!hasSameFingerprint) return false; + } + } + return true; + } + static bool _samePlexHomeUsers(Map> a, Map> b) { if (a.length != b.length) return false; for (final entry in a.entries) { @@ -225,6 +269,12 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier connectionsById: _connectionsById, storage: _storage, ); + _avatarUrls = resolveProfileAvatarUrls( + profiles: _profiles, + connectionsByProfile: _connectionsByProfile, + connectionsById: _connectionsById, + plexHomeByConnectionId: _plexHomeUsers, + ); } void _resolveActive() { @@ -487,13 +537,17 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier await _localSub?.cancel(); await _connSub?.cancel(); await _plexHomeSub?.cancel(); + await _pcSub?.cancel(); _localSub = null; _connSub = null; _plexHomeSub = null; + _pcSub = null; _profiles = const []; _localProfiles = const []; _plexHomeUsers = const {}; _connectionsById = const {}; + _connectionsByProfile = const {}; + _avatarUrls = const {}; _active = null; _initializeFuture = null; _initialized = false; @@ -525,6 +579,7 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier _localSub?.cancel(); _connSub?.cancel(); _plexHomeSub?.cancel(); + _pcSub?.cancel(); super.dispose(); } } diff --git a/lib/profiles/profile_avatar.dart b/lib/profiles/profile_avatar.dart index 3185e578..54474564 100644 --- a/lib/profiles/profile_avatar.dart +++ b/lib/profiles/profile_avatar.dart @@ -12,13 +12,19 @@ class ProfileAvatar extends StatelessWidget { final double size; final bool showLockBadge; - const ProfileAvatar({super.key, required this.profile, this.size = 40, this.showLockBadge = true}); + /// The derived picture for this profile (see [resolveProfileAvatarUrls]). + /// + /// Falls back to [Profile.avatarThumbUrl] when null. + final String? avatarUrl; + + const ProfileAvatar({super.key, required this.profile, this.size = 40, this.showLockBadge = true, this.avatarUrl}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final p = profile; final lockBadgeSize = size * 0.34; + final memCacheSize = (size * MediaQuery.devicePixelRatioOf(context)).round(); return SizedBox( width: size, @@ -27,7 +33,7 @@ class ProfileAvatar extends StatelessWidget { clipBehavior: Clip.none, children: [ ClipOval( - child: SizedBox(width: size, height: size, child: _buildContent(theme, p)), + child: SizedBox(width: size, height: size, child: _buildContent(theme, p, memCacheSize)), ), if (showLockBadge && p != null && p.isPinProtected) Positioned( @@ -55,16 +61,21 @@ class ProfileAvatar extends StatelessWidget { ); } - Widget _buildContent(ThemeData theme, Profile? p) { + Widget _buildContent(ThemeData theme, Profile? p, int memCacheSize) { if (p == null) { return Container(color: theme.colorScheme.surfaceContainerHighest); } - final thumb = p.avatarThumbUrl; + // An empty override means "nothing derived", not "suppress the profile's + // own picture" — same absent-or-blank rule the thumb itself uses below. + final override = avatarUrl; + final thumb = override != null && override.isNotEmpty ? override : p.avatarThumbUrl; if (thumb != null && thumb.isNotEmpty) { return CachedNetworkImage( imageUrl: thumb, cacheManager: PlexImageCacheManager.instance, fit: BoxFit.cover, + memCacheWidth: memCacheSize, + memCacheHeight: memCacheSize, placeholder: (_, _) => _initialFallback(theme, p), errorBuilder: (_, _, _) => _initialFallback(theme, p), ); diff --git a/lib/profiles/profile_avatar_source.dart b/lib/profiles/profile_avatar_source.dart new file mode 100644 index 00000000..d420e7e4 --- /dev/null +++ b/lib/profiles/profile_avatar_source.dart @@ -0,0 +1,105 @@ +import '../connection/connection.dart'; +import '../models/plex/plex_home_user.dart'; +import '../services/jellyfin_mappers.dart'; +import 'profile.dart'; +import 'profile_connection.dart'; + +/// Picture URL per profile id, `null` meaning "render initials". +/// +/// A [PlexHomeProfile] owns its picture: Plex serves a per-home-user avatar +/// and [Profile.virtualPlexHome] already carries it, so those pass through +/// untouched. +/// +/// A [LocalProfile] has no picture of its own. It shows the user picture of +/// the connection it was linked to **first** — the oldest by +/// [Connection.createdAt], ties broken by connection id so the choice is +/// stable. If that connection has no picture the profile falls back to +/// initials; we deliberately do *not* skip ahead to a later connection, so the +/// avatar stays a property of "the first connection" rather than of whichever +/// backend happens to have an image today. +/// +/// The join row is not a usable ordering key: `profile_connections` has no +/// creation timestamp, and `tokenAcquiredAt` is rewritten every time the +/// binder re-mints a Plex Home token. +Map resolveProfileAvatarUrls({ + required List profiles, + required Map> connectionsByProfile, + required Map connectionsById, + required Map> plexHomeByConnectionId, +}) { + return { + for (final profile in profiles) + profile.id: _avatarForProfile( + profile: profile, + links: connectionsByProfile[profile.id] ?? const [], + connectionsById: connectionsById, + plexHomeByConnectionId: plexHomeByConnectionId, + ), + }; +} + +String? _avatarForProfile({ + required Profile profile, + required List links, + required Map connectionsById, + required Map> plexHomeByConnectionId, +}) { + final own = profile.avatarThumbUrl; + if (own != null && own.isNotEmpty) return own; + // A Plex Home user with no avatar set keeps its initials. Plex owns that + // profile's identity end to end, so it must never borrow the picture of a + // connection it happens to have been lent. + if (profile.isPlexHome) return null; + + ProfileConnection? firstLink; + Connection? first; + for (final link in links) { + // A link whose connection was removed can't contribute a picture, and it + // must not win the ordering either. + final connection = connectionsById[link.connectionId]; + if (connection == null) continue; + if (first == null || _isEarlier(connection, first)) { + first = connection; + firstLink = link; + } + } + if (first == null || firstLink == null) return null; + + return connectionAvatarUrl(connection: first, link: firstLink, plexHomeByConnectionId: plexHomeByConnectionId); +} + +bool _isEarlier(Connection candidate, Connection incumbent) { + final byCreatedAt = candidate.createdAt.compareTo(incumbent.createdAt); + if (byCreatedAt != 0) return byCreatedAt < 0; + return candidate.id.compareTo(incumbent.id) < 0; +} + +/// Picture a single connection contributes to [link]'s profile, or `null`. +/// +/// Plex resolves through the join row rather than the account owner: a profile +/// borrows a *specific* Home user (`userIdentifier`), and that user's live +/// [PlexHomeUser.thumb] is the picture Plex shows for it. Reading the live +/// cache also means the avatar tracks Plex's hourly refresh for free. +String? connectionAvatarUrl({ + required Connection connection, + required ProfileConnection link, + required Map> plexHomeByConnectionId, +}) { + return switch (connection) { + JellyfinConnection(:final baseUrl, :final userId, :final primaryImageTag) => jellyfinUserImageUrl( + baseUrl: baseUrl, + userId: userId, + tag: primaryImageTag, + ), + PlexAccountConnection() => _plexHomeUserThumb(plexHomeByConnectionId[connection.id], link.userIdentifier), + }; +} + +String? _plexHomeUserThumb(List? homeUsers, String homeUserUuid) { + if (homeUsers == null || homeUserUuid.isEmpty) return null; + for (final user in homeUsers) { + if (user.uuid != homeUserUuid) continue; + return user.thumb.isEmpty ? null : user.thumb; + } + return null; +} diff --git a/lib/profiles/profile_merge.dart b/lib/profiles/profile_merge.dart index fe88a83d..71520ce3 100644 --- a/lib/profiles/profile_merge.dart +++ b/lib/profiles/profile_merge.dart @@ -2,6 +2,16 @@ import '../connection/connection.dart'; import '../models/plex/plex_home_user.dart'; import '../services/storage_service.dart'; import 'profile.dart'; +import 'profile_connection.dart'; + +/// Groups profile-connection rows by profile id, preserving input order. +Map> groupConnectionsByProfile(List pcs) { + final out = >{}; + for (final pc in pcs) { + out.putIfAbsent(pc.profileId, () => []).add(pc); + } + return out; +} /// Merge local profiles with virtual Plex Home profiles. Each Plex Home /// user becomes a virtual profile attached to its `connectionId`. Home diff --git a/lib/profiles/profiles_view.dart b/lib/profiles/profiles_view.dart index 5aa017c7..40bb7ff6 100644 --- a/lib/profiles/profiles_view.dart +++ b/lib/profiles/profiles_view.dart @@ -6,6 +6,7 @@ import '../models/plex/plex_home_user.dart'; import '../services/storage_service.dart'; import 'plex_home_service.dart'; import 'profile.dart'; +import 'profile_avatar_source.dart'; import 'profile_connection.dart'; import 'profile_connection_registry.dart'; import 'profile_merge.dart'; @@ -25,9 +26,22 @@ class ProfilesView { final Map connectionsById; - const ProfilesView({required this.profiles, required this.connectionsByProfile, required this.connectionsById}); + /// Picture URL per profile id; null means render initials. See [resolveProfileAvatarUrls]. + final Map avatarUrlByProfile; - static const empty = ProfilesView(profiles: [], connectionsByProfile: {}, connectionsById: {}); + const ProfilesView({ + required this.profiles, + required this.connectionsByProfile, + required this.connectionsById, + required this.avatarUrlByProfile, + }); + + static const empty = ProfilesView( + profiles: [], + connectionsByProfile: {}, + connectionsById: {}, + avatarUrlByProfile: {}, + ); } /// Join-table rows that should be shown as explicit, user-manageable @@ -77,21 +91,24 @@ ProfilesView _build({ required StorageService? storage, }) { final connectionsById = {for (final c in conns) c.id: c}; + final connectionsByProfile = groupConnectionsByProfile(pcs); final all = mergeLocalWithPlexHome( locals: locals, plexHomeByConnectionId: homes, connectionsById: connectionsById, storage: storage, ); - return ProfilesView(profiles: all, connectionsByProfile: _groupByProfile(pcs), connectionsById: connectionsById); -} - -Map> _groupByProfile(List pcs) { - final out = >{}; - for (final pc in pcs) { - out.putIfAbsent(pc.profileId, () => []).add(pc); - } - return out; + return ProfilesView( + profiles: all, + connectionsByProfile: connectionsByProfile, + connectionsById: connectionsById, + avatarUrlByProfile: resolveProfileAvatarUrls( + profiles: all, + connectionsByProfile: connectionsByProfile, + connectionsById: connectionsById, + plexHomeByConnectionId: homes, + ), + ); } /// Lightweight `combineLatest4` — emits the combined value once each input diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 9bf75a3c..fb02bb7b 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -652,12 +652,13 @@ class _DiscoverScreenState extends State key: _userMenuKey, enabled: !_switchingProfile, icon: active != null - ? ProfileAvatar(profile: active, size: 32) + ? ProfileAvatar(profile: active, size: 32, avatarUrl: activeProvider.avatarUrlFor(active.id)) : const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32, color: Colors.white), tooltip: t.profiles.sectionTitle, anchorAlignment: AppMenuAnchorAlignment.end, onSelected: (value) => unawaited(_handleUserMenuAction(context, value)), - entriesBuilder: (context) => _userMenuItems(context, activeProfile: active, profiles: profiles), + entriesBuilder: (context) => + _userMenuItems(context, activeProfile: active, profiles: profiles, activeProvider: activeProvider), ), ); } @@ -666,6 +667,7 @@ class _DiscoverScreenState extends State BuildContext context, { required Profile? activeProfile, required List profiles, + required ActiveProfileProvider activeProvider, }) { final theme = Theme.of(context); final switchable = profiles.where((p) => p.id != activeProfile?.id).toList(); @@ -674,7 +676,7 @@ class _DiscoverScreenState extends State for (final p in switchable) AppMenuItem( value: 'profile:${p.id}', - leading: ProfileAvatar(profile: p, size: 24), + leading: ProfileAvatar(profile: p, size: 24, avatarUrl: activeProvider.avatarUrlFor(p.id)), label: p.displayName, trailing: p.isPinProtected ? AppIcon(Symbols.lock_rounded, fill: 1, size: 14, color: theme.colorScheme.onSurfaceVariant) diff --git a/lib/screens/profile/profile_detail_screen.dart b/lib/screens/profile/profile_detail_screen.dart index bfae71ee..d8b73932 100644 --- a/lib/screens/profile/profile_detail_screen.dart +++ b/lib/screens/profile/profile_detail_screen.dart @@ -10,6 +10,7 @@ import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; import '../../models/plex/plex_home_user.dart'; import '../../profiles/active_profile_binder.dart'; +import '../../profiles/active_profile_provider.dart'; import '../../profiles/plex_home_service.dart'; import '../../profiles/profile.dart'; import '../../profiles/profile_avatar.dart'; @@ -269,6 +270,7 @@ class _ProfileDetailScreenState extends State with Controll Widget build(BuildContext context) { final theme = Theme.of(context); final isLocal = _profile.isLocal; + final avatarUrl = context.watch().avatarUrlFor(_profile.id); return FocusedScrollScaffold( title: Text(_profile.displayName), @@ -277,7 +279,9 @@ class _ProfileDetailScreenState extends State with Controll padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), sliver: SliverList( delegate: SliverChildListDelegate([ - Center(child: ProfileAvatar(profile: _profile, size: 96)), + Center( + child: ProfileAvatar(profile: _profile, size: 96, avatarUrl: avatarUrl), + ), const SizedBox(height: 24), Text(t.profiles.profileNameLabel, style: theme.textTheme.labelLarge), const SizedBox(height: 8), diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index 56424695..74f4ce11 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -266,6 +266,7 @@ class _ProfileSwitchScreenState extends State with MountedS child: _ProfileTile( borderRadius: tileRadii, profile: profile, + avatarUrl: view.avatarUrlByProfile[profile.id], isActive: isActive && !widget.requireSelection, chips: _chipsFor(profile, view), onTap: () => _switchTo(profile), @@ -363,6 +364,7 @@ class _ProfileSwitchScreenState extends State with MountedS class _ProfileTile extends StatelessWidget { final Profile profile; + final String? avatarUrl; final bool isActive; final BorderRadius borderRadius; final List<_ChipData> chips; @@ -377,6 +379,7 @@ class _ProfileTile extends StatelessWidget { const _ProfileTile({ required this.profile, + required this.avatarUrl, required this.isActive, required this.borderRadius, required this.chips, @@ -403,7 +406,7 @@ class _ProfileTile extends StatelessWidget { padding: const EdgeInsets.all(12), child: Row( children: [ - ProfileAvatar(profile: profile, size: 44), + ProfileAvatar(profile: profile, size: 44, avatarUrl: avatarUrl), const SizedBox(width: 14), Expanded( child: Column( diff --git a/lib/services/jellyfin_auth_service.dart b/lib/services/jellyfin_auth_service.dart index 2237e3f3..42fe3966 100644 --- a/lib/services/jellyfin_auth_service.dart +++ b/lib/services/jellyfin_auth_service.dart @@ -28,12 +28,14 @@ class _JellyfinAuthenticationResponse { final String userId; final String userName; final bool isAdministrator; + final String? primaryImageTag; const _JellyfinAuthenticationResponse({ required this.accessToken, required this.userId, required this.userName, required this.isAdministrator, + this.primaryImageTag, }); } @@ -149,6 +151,7 @@ class JellyfinConnectionAuthService { accessToken: auth.accessToken, deviceId: validDeviceId, isAdministrator: auth.isAdministrator, + primaryImageTag: auth.primaryImageTag, ); } finally { client.close(); @@ -316,6 +319,7 @@ class JellyfinConnectionAuthService { accessToken: auth.accessToken, deviceId: validDeviceId, isAdministrator: auth.isAdministrator, + primaryImageTag: auth.primaryImageTag, ); } finally { exchangeClient.close(); @@ -419,6 +423,7 @@ class JellyfinConnectionAuthService { userId: userId, userName: userName, isAdministrator: policy?['IsAdministrator'] as bool? ?? false, + primaryImageTag: JellyfinConnection.readPrimaryImageTag(user), ); } on TimeoutException { // MediaServerHttpClient normally wraps timeouts, but keep raw client @@ -445,6 +450,7 @@ class JellyfinConnectionAuthService { required String accessToken, required String deviceId, required bool isAdministrator, + required String? primaryImageTag, }) { final now = DateTime.now(); return JellyfinConnection( @@ -458,6 +464,7 @@ class JellyfinConnectionAuthService { accessToken: accessToken, deviceId: deviceId, isAdministrator: isAdministrator, + primaryImageTag: primaryImageTag, status: ConnectionStatus.online, createdAt: now, lastAuthenticatedAt: now, diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index a3bff02e..76e94740 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -317,9 +317,10 @@ class JellyfinClient /// real call to 401. /// /// Side-effect: when the response body carries a fresh - /// `Policy.IsAdministrator` that differs from the cached one, refresh the - /// connection so admin-gated UI catches the server-side change without - /// requiring re-auth (see [onConnectionUpdated]). + /// `Policy.IsAdministrator` or primary profile-picture tag that differs + /// from the cached value, refresh the connection so admin-gated UI and + /// profile avatars catch server-side changes without requiring re-auth + /// (see [onConnectionUpdated]). /// /// 401/403 surfaces as [HealthStatus.authError] so the manager can /// distinguish a revoked token from a generic transport failure. @@ -332,17 +333,24 @@ class JellyfinClient final data = response.data; if (data is Map) { final policy = data['Policy']; - if (policy is Map) { - final fresh = policy['IsAdministrator'] as bool?; - if (fresh != null && fresh != _connection.isAdministrator) { - _connection = _connection.copyWith(isAdministrator: fresh); - final listener = onConnectionUpdated; - if (listener != null) { - try { - await Future.sync(() => listener(_connection)); - } catch (e, st) { - appLogger.w('Failed to handle Jellyfin connection update', error: e, stackTrace: st); - } + final freshIsAdministrator = policy is Map ? policy['IsAdministrator'] as bool? : null; + final freshPrimaryImageTag = JellyfinConnection.readPrimaryImageTag(data); + final isAdministratorChanged = + freshIsAdministrator != null && freshIsAdministrator != _connection.isAdministrator; + final primaryImageTagChanged = freshPrimaryImageTag != _connection.primaryImageTag; + + if (isAdministratorChanged || primaryImageTagChanged) { + _connection = _connection.copyWith( + isAdministrator: freshIsAdministrator, + primaryImageTag: freshPrimaryImageTag, + clearPrimaryImageTag: primaryImageTagChanged && freshPrimaryImageTag == null, + ); + final listener = onConnectionUpdated; + if (listener != null) { + try { + await Future.sync(() => listener(_connection)); + } catch (e, st) { + appLogger.w('Failed to handle Jellyfin connection update', error: e, stackTrace: st); } } } diff --git a/lib/services/jellyfin_mappers.dart b/lib/services/jellyfin_mappers.dart index 25d6cad7..71c3af18 100644 --- a/lib/services/jellyfin_mappers.dart +++ b/lib/services/jellyfin_mappers.dart @@ -128,6 +128,40 @@ class JellyfinImageAbsolutizer { } } +/// Absolute URL of a Jellyfin user's own profile picture, or `null` when the +/// user has none (absent [tag]) — returning null keeps us from firing a +/// request that can only 404. +/// +/// Unlike item artwork this endpoint carries **no `api_key`**: the user-image +/// GET has never been authenticated (no `[Authorize]`, and Jellyfin sets no +/// ASP.NET `FallbackPolicy`) on any release from 10.6 through 12.0-dev. +/// Leaving the token out keeps it off the image cache key and out of anything +/// that logs or persists the URL. +/// +/// The legacy `/Users/{id}/Images/Primary` route is used rather than 10.9's +/// `/UserImage` because Plezy declares no minimum server version; upstream +/// still routes the legacy shape and annotates it "Kept for backwards +/// compatibility". The `{imageType}` segment is bound but ignored server-side +/// — it always serves the profile image. +/// +/// [tag] is the server's `PrimaryImageTag`, `MD5(imagePath + lastModified)`, +/// so the URL changes exactly when the picture does and is a safe immutable +/// cache key. [maxSize] is honoured up to 10.10 and silently ignored from +/// 10.11 on, so callers must still bound the decode themselves. +String? jellyfinUserImageUrl({ + required String baseUrl, + required String userId, + required String? tag, + int maxSize = 240, +}) { + if (baseUrl.isEmpty || userId.isEmpty || tag == null || tag.isEmpty) return null; + final uri = JellyfinImageAbsolutizer.joinUri( + baseUrl: baseUrl, + urlOrPath: '/Users/${Uri.encodeComponent(userId)}/Images/Primary', + ); + return uri.replace(queryParameters: {'tag': tag, 'maxWidth': '$maxSize', 'maxHeight': '$maxSize'}).toString(); +} + /// Pure mapping functions from Jellyfin's `BaseItemDto` JSON shape into the /// neutral [MediaItem] / [MediaLibrary] domain types. /// diff --git a/test/connection/connection_models_test.dart b/test/connection/connection_models_test.dart index 3bd5c3ae..a23ae2e6 100644 --- a/test/connection/connection_models_test.dart +++ b/test/connection/connection_models_test.dart @@ -62,6 +62,58 @@ void main() { expect(restored.lastAuthenticatedAt, base.lastAuthenticatedAt); }); + test('primary image tag round-trips through config JSON', () { + final tagged = base.copyWith(primaryImageTag: 'avatar-tag'); + final restored = JellyfinConnection.fromConfigJson( + id: tagged.id, + json: tagged.toConfigJson(), + status: tagged.status, + createdAt: tagged.createdAt, + lastAuthenticatedAt: tagged.lastAuthenticatedAt, + ); + + expect(restored.primaryImageTag, 'avatar-tag'); + }); + + test('config saved before image tags decodes with no primary image tag', () { + final legacyJson = { + 'baseUrl': base.baseUrl, + 'baseUrls': base.baseUrls, + 'serverName': base.serverName, + 'serverMachineId': base.serverMachineId, + 'userId': base.userId, + 'userName': base.userName, + 'accessToken': base.accessToken, + 'deviceId': base.deviceId, + 'isAdministrator': base.isAdministrator, + }; + expect(legacyJson.containsKey('primaryImageTag'), isFalse); + + final restored = JellyfinConnection.fromConfigJson( + id: base.id, + json: legacyJson, + status: base.status, + createdAt: base.createdAt, + lastAuthenticatedAt: base.lastAuthenticatedAt, + ); + + expect(restored.primaryImageTag, isNull); + }); + + test('blank persisted primary image tags decode as null', () { + for (final tag in ['', ' \t\n ']) { + final restored = JellyfinConnection.fromConfigJson( + id: base.id, + json: {...base.toConfigJson(), 'primaryImageTag': tag}, + status: base.status, + createdAt: base.createdAt, + lastAuthenticatedAt: base.lastAuthenticatedAt, + ); + + expect(restored.primaryImageTag, isNull, reason: 'tag: "$tag"'); + } + }); + test('fromConfigJson with empty payload uses safe defaults (no NPE)', () { final restored = JellyfinConnection.fromConfigJson( id: 'orphan', @@ -99,6 +151,32 @@ void main() { expect(updated.baseUrls, ['https://jellyfin.lan:8096', 'https://jellyfin.example.com']); }); + test('copyWith preserves an existing primary image tag by default', () { + final tagged = base.copyWith(primaryImageTag: 'old'); + + expect(tagged.copyWith().primaryImageTag, 'old'); + }); + + test('copyWith replaces an existing primary image tag', () { + final tagged = base.copyWith(primaryImageTag: 'old'); + + expect(tagged.copyWith(primaryImageTag: 'new').primaryImageTag, 'new'); + }); + + test('copyWith only clears a primary image tag through the clear sentinel', () { + final tagged = base.copyWith(primaryImageTag: 'old'); + + expect(tagged.copyWith(primaryImageTag: null).primaryImageTag, 'old'); + expect(tagged.copyWith(clearPrimaryImageTag: true).primaryImageTag, isNull); + }); + + test('reads primary image tags defensively from Jellyfin user DTOs', () { + expect(JellyfinConnection.readPrimaryImageTag(const {'PrimaryImageTag': 'avatar-tag'}), 'avatar-tag'); + expect(JellyfinConnection.readPrimaryImageTag(const {}), isNull); + expect(JellyfinConnection.readPrimaryImageTag(const {'PrimaryImageTag': ' \t\n '}), isNull); + expect(JellyfinConnection.readPrimaryImageTag(const {'PrimaryImageTag': 42}), '42'); + }); + test('kind and backend match Jellyfin', () { expect(base.kind, ConnectionKind.jellyfin); expect(base.backend, MediaBackend.jellyfin); diff --git a/test/navigation/profile_session_screen_test.dart b/test/navigation/profile_session_screen_test.dart index 64de0fd1..bb1e5ec3 100644 --- a/test/navigation/profile_session_screen_test.dart +++ b/test/navigation/profile_session_screen_test.dart @@ -49,6 +49,7 @@ void main() { registry: profileRegistry, plexHome: plexHome, connections: connectionRegistry, + profileConnections: profileConnectionRegistry, storage: storage, ); final serverManager = MultiServerManager(); diff --git a/test/profiles/active_profile_binder_test.dart b/test/profiles/active_profile_binder_test.dart index aec5ebc6..b37044a9 100644 --- a/test/profiles/active_profile_binder_test.dart +++ b/test/profiles/active_profile_binder_test.dart @@ -71,6 +71,7 @@ void main() { registry: profiles, plexHome: plexHome, connections: connections, + profileConnections: profileConnections, storage: storage, ); manager = MultiServerManager(); diff --git a/test/profiles/active_profile_provider_test.dart b/test/profiles/active_profile_provider_test.dart index e02c1fe2..9fdb7cd0 100644 --- a/test/profiles/active_profile_provider_test.dart +++ b/test/profiles/active_profile_provider_test.dart @@ -9,6 +9,7 @@ import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/profiles/active_profile_provider.dart'; import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_connection.dart'; import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/services/storage_service.dart'; @@ -78,12 +79,12 @@ final class _RecordingPreferencesPlatform extends SharedPreferencesAsyncPlatform delegate.getKeys(parameters, options); } -PlexHomeUser _homeUser(String uuid, {String name = 'Home User'}) { +PlexHomeUser _homeUser(String uuid, {int id = 1, String name = 'Home User', String thumb = ''}) { return PlexHomeUser( - id: 1, + id: id, uuid: uuid, title: name, - thumb: '', + thumb: thumb, hasPassword: false, restricted: false, updatedAt: null, @@ -103,10 +104,26 @@ PlexAccountConnection _account(String id) { ); } +JellyfinConnection _jellyfin(String id, {required DateTime createdAt, required String primaryImageTag}) { + return JellyfinConnection( + id: id, + baseUrl: 'https://jellyfin.example', + serverName: 'Jellyfin', + serverMachineId: 'machine-$id', + userId: 'user-$id', + userName: 'User $id', + accessToken: 'token-$id', + deviceId: 'device-$id', + primaryImageTag: primaryImageTag, + createdAt: createdAt, + ); +} + void main() { late AppDatabase db; late ProfileRegistry registry; late ConnectionRegistry connections; + late ProfileConnectionRegistry profileConnections; late PlexHomeService plexHome; late ActiveProfileProvider provider; late StorageService storage; @@ -122,9 +139,10 @@ void main() { connections = ConnectionRegistry(db); storage = await StorageService.getInstance(); fetchedHomeUsers = const []; + profileConnections = ProfileConnectionRegistry(db); plexHome = PlexHomeService( connections: connections, - profileConnections: ProfileConnectionRegistry(db), + profileConnections: profileConnections, storage: storage, plexHomeUserFetcher: (_) async => fetchedHomeUsers, ); @@ -132,6 +150,7 @@ void main() { registry: registry, plexHome: plexHome, connections: connections, + profileConnections: profileConnections, storage: storage, ); }); @@ -219,6 +238,144 @@ void main() { expect(notifications, 0); }); + group('avatarUrlFor', () { + test('returns the linked Jellyfin user picture after initialize', () async { + final profile = Profile.local(id: 'p1', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); + final connection = _jellyfin('first', createdAt: DateTime(2025, 1, 1), primaryImageTag: 'avatar-tag'); + await registry.upsert(profile); + await connections.upsert(connection); + await profileConnections.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'first', userIdentifier: 'user-first'), + ); + + await provider.initialize(); + + expect( + provider.avatarUrlFor(profile.id), + 'https://jellyfin.example/Users/user-first/Images/Primary' + '?tag=avatar-tag&maxWidth=240&maxHeight=240', + ); + }); + + test('returns null for profiles without links and unknown profile ids', () async { + await registry.upsert(Profile.local(id: 'unlinked', displayName: 'Owner', createdAt: DateTime(2026, 1, 1))); + + await provider.initialize(); + + expect(provider.avatarUrlFor('unlinked'), isNull); + expect(provider.avatarUrlFor('unknown'), isNull); + }); + + test('linking an older connection updates the selected picture', () async { + final profile = Profile.local(id: 'p1', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); + final newer = _jellyfin('newer', createdAt: DateTime(2026, 1, 2), primaryImageTag: 'newer-tag'); + final older = _jellyfin('older', createdAt: DateTime(2025, 12, 31), primaryImageTag: 'older-tag'); + await registry.upsert(profile); + await connections.upsert(newer); + await connections.upsert(older); + await profileConnections.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'newer', userIdentifier: 'user-newer'), + ); + await provider.initialize(); + expect( + provider.avatarUrlFor(profile.id), + 'https://jellyfin.example/Users/user-newer/Images/Primary' + '?tag=newer-tag&maxWidth=240&maxHeight=240', + ); + + final changed = Completer(); + void listener() { + if (provider.avatarUrlFor(profile.id)?.contains('older-tag') ?? false) { + if (!changed.isCompleted) changed.complete(); + } + } + + provider.addListener(listener); + addTearDown(() => provider.removeListener(listener)); + + await profileConnections.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'older', userIdentifier: 'user-older'), + ); + await changed.future.timeout(const Duration(seconds: 2)); + + expect( + provider.avatarUrlFor(profile.id), + 'https://jellyfin.example/Users/user-older/Images/Primary' + '?tag=older-tag&maxWidth=240&maxHeight=240', + ); + }); + + test('token and timestamp churn on a link does not notify listeners', () async { + await registry.upsert(Profile.local(id: 'p1', displayName: 'Owner', createdAt: DateTime(2026, 1, 1))); + await connections.upsert(_jellyfin('jellyfin', createdAt: DateTime(2025, 1, 1), primaryImageTag: 'avatar-tag')); + await profileConnections.upsert( + const ProfileConnection( + profileId: 'p1', + connectionId: 'jellyfin', + userToken: 'initial-token', + userIdentifier: 'user-jellyfin', + ), + ); + await provider.initialize(); + + var notifications = 0; + void listener() => notifications++; + provider.addListener(listener); + addTearDown(() => provider.removeListener(listener)); + + final churnObserved = Completer(); + final rowSubscription = profileConnections.watchAll().listen((rows) { + final row = rows.single; + if (row.userToken == 'refreshed-token' && row.tokenAcquiredAt != null && !churnObserved.isCompleted) { + churnObserved.complete(); + } + }); + addTearDown(rowSubscription.cancel); + + await profileConnections.recordToken('p1', 'jellyfin', 'refreshed-token'); + await churnObserved.future.timeout(const Duration(seconds: 2)); + await Future(() {}); + + expect(notifications, 0); + }); + + test('changing a Plex link user changes the picture and notifies listeners', () async { + final profile = Profile.local(id: 'p1', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); + final account = _account('plex.acct'); + final firstUser = _homeUser('home-1', thumb: 'https://images.example/first.jpg'); + final secondUser = _homeUser('home-2', id: 2, thumb: 'https://images.example/second.jpg'); + fetchedHomeUsers = [firstUser, secondUser]; + await registry.upsert(profile); + await connections.upsert(account); + await profileConnections.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'plex.acct', userIdentifier: 'home-1'), + ); + expect(await plexHome.refresh(account), isTrue); + await provider.initialize(); + expect(provider.avatarUrlFor(profile.id), firstUser.thumb); + + var notifications = 0; + final changed = Completer(); + void listener() { + notifications++; + if (provider.avatarUrlFor(profile.id) == secondUser.thumb && !changed.isCompleted) { + changed.complete(); + } + } + + provider.addListener(listener); + addTearDown(() => provider.removeListener(listener)); + + await profileConnections.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'plex.acct', userIdentifier: 'home-2'), + ); + await changed.future.timeout(const Duration(seconds: 2)); + + expect(provider.avatarUrlFor(profile.id), secondUser.thumb); + expect(notifications, greaterThan(0)); + }); + }); + test('initialize resolves the stored active profile id', () async { await registry.upsert(Profile.local(id: 'p1', displayName: 'Owner', createdAt: DateTime(2026, 1, 1))); await registry.upsert(Profile.local(id: 'p2', displayName: 'Kids', createdAt: DateTime(2026, 1, 2))); diff --git a/test/profiles/profile_activation_test.dart b/test/profiles/profile_activation_test.dart index eb25ae6b..73656cd7 100644 --- a/test/profiles/profile_activation_test.dart +++ b/test/profiles/profile_activation_test.dart @@ -118,6 +118,7 @@ class _ThrowingActiveProfileProvider extends ActiveProfileProvider { required super.registry, required super.plexHome, required super.connections, + required super.profileConnections, required super.storage, super.activeProfileIdWriter, }); @@ -515,6 +516,7 @@ void main() { registry: profiles, plexHome: plexHome, connections: connections, + profileConnections: profileConnections, storage: storage, activeProfileIdWriter: writer, ); @@ -623,6 +625,7 @@ Future<_Harness> _pumpHarness( registry: profiles, plexHome: plexHome, connections: connections, + profileConnections: profileConnections, storage: storage, activeProfileIdWriter: gateOwnerRestoreWrite || failNewerIdentityWrite ? activeProfileIdWriter : null, ); diff --git a/test/profiles/profile_avatar_source_test.dart b/test/profiles/profile_avatar_source_test.dart new file mode 100644 index 00000000..04220833 --- /dev/null +++ b/test/profiles/profile_avatar_source_test.dart @@ -0,0 +1,266 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/models/plex/plex_home_user.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_avatar_source.dart'; +import 'package:plezy/profiles/profile_connection.dart'; + +JellyfinConnection _jellyfin( + String id, { + required DateTime createdAt, + String? primaryImageTag, + String userId = 'jf-user', + String baseUrl = 'https://jelly.example', +}) { + return JellyfinConnection( + id: id, + baseUrl: baseUrl, + serverName: 'Jelly', + serverMachineId: 'machine-$id', + userId: userId, + userName: 'Agent', + accessToken: 'secret-token', + deviceId: 'device-1', + primaryImageTag: primaryImageTag, + createdAt: createdAt, + ); +} + +PlexAccountConnection _plex(String id, {required DateTime createdAt}) { + return PlexAccountConnection( + id: id, + accountToken: 'token-$id', + clientIdentifier: 'client-$id', + accountLabel: 'Plex', + createdAt: createdAt, + ); +} + +PlexHomeUser _homeUser(String uuid, {String thumb = ''}) { + return PlexHomeUser( + id: 1, + uuid: uuid, + title: 'Home $uuid', + thumb: thumb, + hasPassword: false, + restricted: false, + updatedAt: null, + admin: false, + guest: false, + protected: false, + ); +} + +ProfileConnection _link(String connectionId, {String profileId = 'local-1', String userIdentifier = 'jf-user'}) { + return ProfileConnection(profileId: profileId, connectionId: connectionId, userIdentifier: userIdentifier); +} + +Profile _local([String id = 'local-1']) => Profile.local(id: id, displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); + +String? _resolve({ + required List profiles, + required Map> links, + required Map connections, + Map> plexHome = const {}, + String profileId = 'local-1', +}) { + return resolveProfileAvatarUrls( + profiles: profiles, + connectionsByProfile: links, + connectionsById: connections, + plexHomeByConnectionId: plexHome, + )[profileId]; +} + +void main() { + group('resolveProfileAvatarUrls', () { + test('uses the picture of the oldest linked connection', () { + final older = _jellyfin('jf-older', createdAt: DateTime(2026, 1, 1), primaryImageTag: 'older-tag'); + final newer = _jellyfin('jf-newer', createdAt: DateTime(2026, 6, 1), primaryImageTag: 'newer-tag'); + + final url = _resolve( + profiles: [_local()], + // Deliberately newest-first: the registry stream has no ordering + // guarantee, so resolution must sort rather than take the head. + links: { + 'local-1': [_link('jf-newer'), _link('jf-older')], + }, + connections: {'jf-older': older, 'jf-newer': newer}, + ); + + expect(url, contains('tag=older-tag')); + }); + + test('falls back to initials when the first connection has no picture', () { + final first = _jellyfin('jf-first', createdAt: DateTime(2026, 1, 1)); + final second = _jellyfin('jf-second', createdAt: DateTime(2026, 6, 1), primaryImageTag: 'second-tag'); + + final url = _resolve( + profiles: [_local()], + links: { + 'local-1': [_link('jf-first'), _link('jf-second')], + }, + connections: {'jf-first': first, 'jf-second': second}, + ); + + // "First wins" is literal: we do not skip ahead to a later connection + // that happens to have an image. + expect(url, isNull); + }); + + test('breaks a createdAt tie on connection id so the choice is stable', () { + final sameInstant = DateTime(2026, 1, 1); + final b = _jellyfin('jf-b', createdAt: sameInstant, primaryImageTag: 'b-tag'); + final a = _jellyfin('jf-a', createdAt: sameInstant, primaryImageTag: 'a-tag'); + + final url = _resolve( + profiles: [_local()], + links: { + 'local-1': [_link('jf-b'), _link('jf-a')], + }, + connections: {'jf-b': b, 'jf-a': a}, + ); + + expect(url, contains('tag=a-tag')); + }); + + test('ignores links whose connection is gone, including for ordering', () { + final survivor = _jellyfin('jf-survivor', createdAt: DateTime(2026, 6, 1), primaryImageTag: 'survivor-tag'); + + final url = _resolve( + profiles: [_local()], + links: { + 'local-1': [_link('jf-removed'), _link('jf-survivor')], + }, + connections: {'jf-survivor': survivor}, + ); + + expect(url, contains('tag=survivor-tag')); + }); + + test('resolves a Plex link through the home user the link points at', () { + final plex = _plex('plex-1', createdAt: DateTime(2026, 1, 1)); + + final url = _resolve( + profiles: [_local()], + links: { + 'local-1': [_link('plex-1', userIdentifier: 'home-b')], + }, + connections: {'plex-1': plex}, + plexHome: { + 'plex-1': [ + _homeUser('home-a', thumb: 'https://plex.tv/users/home-a/avatar'), + _homeUser('home-b', thumb: 'https://plex.tv/users/home-b/avatar'), + ], + }, + ); + + expect(url, 'https://plex.tv/users/home-b/avatar'); + }); + + test('gives no picture for a Plex link with no home user selected', () { + final plex = _plex('plex-1', createdAt: DateTime(2026, 1, 1)); + + final url = _resolve( + profiles: [_local()], + links: { + 'local-1': [_link('plex-1', userIdentifier: '')], + }, + connections: {'plex-1': plex}, + plexHome: { + 'plex-1': [_homeUser('home-a', thumb: 'https://plex.tv/users/home-a/avatar')], + }, + ); + + expect(url, isNull); + }); + + test('gives no picture when the selected home user has a blank thumb', () { + final plex = _plex('plex-1', createdAt: DateTime(2026, 1, 1)); + + final url = _resolve( + profiles: [_local()], + links: { + 'local-1': [_link('plex-1', userIdentifier: 'home-a')], + }, + connections: {'plex-1': plex}, + plexHome: { + 'plex-1': [_homeUser('home-a')], + }, + ); + + expect(url, isNull); + }); + + test('mixed Jellyfin and Plex links still resolve by creation order', () { + final plexFirst = _plex('plex-1', createdAt: DateTime(2026, 1, 1)); + final jellyfinSecond = _jellyfin('jf-1', createdAt: DateTime(2026, 6, 1), primaryImageTag: 'jf-tag'); + + final url = _resolve( + profiles: [_local()], + links: { + 'local-1': [_link('jf-1'), _link('plex-1', userIdentifier: 'home-a')], + }, + connections: {'plex-1': plexFirst, 'jf-1': jellyfinSecond}, + plexHome: { + 'plex-1': [_homeUser('home-a', thumb: 'https://plex.tv/users/home-a/avatar')], + }, + ); + + expect(url, 'https://plex.tv/users/home-a/avatar'); + }); + + test('leaves a Plex Home profile on its own thumb', () { + final plexHomeProfile = Profile.virtualPlexHome( + connectionId: 'plex-1', + homeUser: _homeUser('home-a', thumb: 'https://plex.tv/users/home-a/avatar'), + ); + final borrowed = _jellyfin('jf-1', createdAt: DateTime(2026, 1, 1), primaryImageTag: 'jf-tag'); + + final url = _resolve( + profiles: [plexHomeProfile], + links: { + plexHomeProfile.id: [_link('jf-1', profileId: plexHomeProfile.id)], + }, + connections: {'jf-1': borrowed}, + profileId: plexHomeProfile.id, + ); + + expect(url, 'https://plex.tv/users/home-a/avatar'); + }); + + test('a Plex Home profile with no avatar keeps its initials rather than borrowing one', () { + final plexHomeProfile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser('home-a')); + final borrowed = _jellyfin('jf-1', createdAt: DateTime(2026, 1, 1), primaryImageTag: 'jf-tag'); + + final url = _resolve( + profiles: [plexHomeProfile], + links: { + plexHomeProfile.id: [_link('jf-1', profileId: plexHomeProfile.id)], + }, + connections: {'jf-1': borrowed}, + profileId: plexHomeProfile.id, + ); + + // Plex owns this identity end to end; "nothing changes" for Plex Home + // profiles means a blank thumb stays blank, not that it picks up the + // picture of a lent connection. + expect(url, isNull); + }); + + test('gives no picture to a profile with no links', () { + expect(_resolve(profiles: [_local()], links: const {}, connections: const {}), isNull); + }); + + test('covers every profile so callers can look up by id', () { + final map = resolveProfileAvatarUrls( + profiles: [_local('local-1'), _local('local-2')], + connectionsByProfile: const {}, + connectionsById: const {}, + plexHomeByConnectionId: const {}, + ); + + expect(map.keys, containsAll(['local-1', 'local-2'])); + }); + }); +} diff --git a/test/profiles/profile_avatar_test.dart b/test/profiles/profile_avatar_test.dart new file mode 100644 index 00000000..27e9fffd --- /dev/null +++ b/test/profiles/profile_avatar_test.dart @@ -0,0 +1,132 @@ +import 'package:cached_network_image_ce/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_avatar.dart'; +import 'package:plezy/utils/initials_palette.dart'; + +void main() { + Future pumpAvatar( + WidgetTester tester, { + required Profile profile, + String? avatarUrl, + double size = 40, + double devicePixelRatio = 1, + }) { + return tester.pumpWidget( + MaterialApp( + theme: ThemeData(), + home: MediaQuery( + data: MediaQueryData(devicePixelRatio: devicePixelRatio), + child: Center( + child: ProfileAvatar(profile: profile, avatarUrl: avatarUrl, size: size), + ), + ), + ), + ); + } + + Profile localProfile({String? avatarThumbUrl, String? pinHash}) { + return Profile.local( + id: 'local-owner', + displayName: 'Owner', + avatarThumbUrl: avatarThumbUrl, + pinHash: pinHash, + createdAt: DateTime(2026, 1, 1), + ); + } + + testWidgets('avatarUrl renders the derived network image', (tester) async { + const avatarUrl = 'https://jellyfin.example/Users/user-1/Images/Primary?tag=derived'; + + await pumpAvatar(tester, profile: localProfile(), avatarUrl: avatarUrl); + + final image = tester.widget(find.byType(CachedNetworkImage)); + expect(image.imageUrl, avatarUrl); + }); + + testWidgets('avatarUrl takes precedence over the profile thumbnail', (tester) async { + const derivedUrl = 'https://jellyfin.example/Users/user-1/Images/Primary?tag=derived'; + const profileThumbUrl = 'https://plex.example/profile-thumb.jpg'; + + await pumpAvatar( + tester, + profile: localProfile(avatarThumbUrl: profileThumbUrl), + avatarUrl: derivedUrl, + ); + + final image = tester.widget(find.byType(CachedNetworkImage)); + expect(image.imageUrl, derivedUrl); + }); + + testWidgets('a null avatarUrl preserves the profile thumbnail fallback', (tester) async { + const profileThumbUrl = 'https://plex.example/profile-thumb.jpg'; + + await pumpAvatar(tester, profile: localProfile(avatarThumbUrl: profileThumbUrl)); + + final image = tester.widget(find.byType(CachedNetworkImage)); + expect(image.imageUrl, profileThumbUrl); + }); + + testWidgets('a profile without a picture renders its display-name initial', (tester) async { + final profile = localProfile(); + + await pumpAvatar(tester, profile: profile); + + expect(find.byType(CachedNetworkImage), findsNothing); + expect(find.text(initialOf(profile.displayName)), findsOneWidget); + }); + + testWidgets('an empty avatarUrl falls through to the profile picture rather than suppressing it', (tester) async { + const plexThumb = 'https://plex.tv/users/abc/avatar'; + + // An empty override means "nothing derived". Treating it as a value would + // blank out a Plex Home profile that owns a perfectly good thumb. + await pumpAvatar( + tester, + profile: localProfile(avatarThumbUrl: plexThumb), + avatarUrl: '', + ); + + expect(tester.widget(find.byType(CachedNetworkImage)).imageUrl, plexThumb); + }); + + testWidgets('an empty avatarUrl renders initials instead of requesting an empty URL', (tester) async { + final profile = localProfile(); + + await pumpAvatar(tester, profile: profile, avatarUrl: ''); + + expect(find.byType(CachedNetworkImage), findsNothing); + expect(find.text(initialOf(profile.displayName)), findsOneWidget); + }); + + testWidgets('network image decoding is bounded by the physical avatar size', (tester) async { + const size = 44.0; + const devicePixelRatio = 2.5; + + await pumpAvatar( + tester, + profile: localProfile(), + avatarUrl: 'https://jellyfin.example/Users/user-1/Images/Primary?tag=large-original', + size: size, + devicePixelRatio: devicePixelRatio, + ); + + final image = tester.widget(find.byType(CachedNetworkImage)); + final expectedDecodeSize = (size * devicePixelRatio).round(); + expect(image.memCacheWidth, expectedDecodeSize); + expect(image.memCacheHeight, expectedDecodeSize); + }); + + testWidgets('a derived avatar keeps the PIN lock badge visible', (tester) async { + await pumpAvatar( + tester, + profile: localProfile(pinHash: 'stored-pin-hash'), + avatarUrl: 'https://jellyfin.example/Users/user-1/Images/Primary?tag=derived', + ); + + expect(find.byType(CachedNetworkImage), findsOneWidget); + expect(find.byIcon(Symbols.lock_rounded), findsOneWidget); + }); +} diff --git a/test/profiles/profiles_view_test.dart b/test/profiles/profiles_view_test.dart index ad32e463..6a87c682 100644 --- a/test/profiles/profiles_view_test.dart +++ b/test/profiles/profiles_view_test.dart @@ -1,8 +1,13 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/profile_connection.dart'; import 'package:plezy/profiles/profiles_view.dart'; +import '../test_helpers/prefs.dart'; +import '../test_helpers/profile_stack.dart'; + void main() { group('visibleProfileConnections', () { test('keeps all local profile connection rows', () { @@ -33,4 +38,79 @@ void main() { expect(visible.single.connectionId, 'jellyfin-1'); }); }); + + group('avatarUrlByProfile', () { + late ProfileStack stack; + + setUp(() async { + resetSharedPreferencesForTest(); + stack = await ProfileStack.create( + homeUsers: [ + PlexHomeUser( + id: 1, + uuid: 'home-user', + title: 'Home User', + thumb: 'https://images.example/home.jpg', + hasPassword: false, + restricted: false, + updatedAt: null, + admin: true, + guest: false, + protected: false, + ), + ], + ); + }); + + tearDown(() => stack.dispose()); + + test('maps linked pictures, unlinked initials, and Plex Home pictures', () async { + final linked = Profile.local(id: 'linked', displayName: 'Linked', createdAt: DateTime(2026, 1, 1)); + final unlinked = Profile.local(id: 'unlinked', displayName: 'Unlinked', createdAt: DateTime(2026, 1, 2)); + final jellyfin = JellyfinConnection( + id: 'jellyfin', + baseUrl: 'https://jellyfin.example', + serverName: 'Jellyfin', + serverMachineId: 'machine-id', + userId: 'jellyfin-user', + userName: 'Jellyfin User', + accessToken: 'token', + deviceId: 'device-id', + primaryImageTag: 'image-tag', + createdAt: DateTime(2025, 1, 1), + ); + final plex = PlexAccountConnection( + id: 'plex', + accountToken: 'account-token', + clientIdentifier: 'client-id', + accountLabel: 'Plex', + createdAt: DateTime(2025, 1, 2), + ); + await stack.profiles.upsert(linked); + await stack.profiles.upsert(unlinked); + await stack.connections.upsert(jellyfin); + await stack.connections.upsert(plex); + await stack.profileConnections.upsert( + const ProfileConnection(profileId: 'linked', connectionId: 'jellyfin', userIdentifier: 'jellyfin-user'), + ); + expect(await stack.plexHome.refresh(plex), isTrue); + + final view = await watchProfilesView( + profiles: stack.profiles, + profileConnections: stack.profileConnections, + connections: stack.connections, + plexHome: stack.plexHome, + storage: stack.storage, + ).first.timeout(const Duration(seconds: 2)); + final plexHomeId = plexHomeProfileId(accountConnectionId: plex.id, homeUserUuid: 'home-user'); + + expect( + view.avatarUrlByProfile[linked.id], + 'https://jellyfin.example/Users/jellyfin-user/Images/Primary' + '?tag=image-tag&maxWidth=240&maxHeight=240', + ); + expect(view.avatarUrlByProfile[unlinked.id], isNull); + expect(view.avatarUrlByProfile[plexHomeId], 'https://images.example/home.jpg'); + }); + }); } diff --git a/test/screens/discover_screen_test.dart b/test/screens/discover_screen_test.dart index 7562e9c4..510a05df 100644 --- a/test/screens/discover_screen_test.dart +++ b/test/screens/discover_screen_test.dart @@ -104,6 +104,7 @@ void main() { registry: profileRegistry, plexHome: plexHome, connections: connectionRegistry, + profileConnections: profileConnectionRegistry, storage: storage, ); final discoverProvider = DiscoverProvider( @@ -295,6 +296,7 @@ void main() { registry: profileRegistry, plexHome: plexHome, connections: connectionRegistry, + profileConnections: profileConnectionRegistry, storage: storage, ); final discoverProvider = DiscoverProvider( @@ -406,6 +408,7 @@ void main() { registry: profileRegistry, plexHome: plexHome, connections: connectionRegistry, + profileConnections: profileConnectionRegistry, storage: storage, ); final discoverProvider = DiscoverProvider( diff --git a/test/screens/profile/profile_detail_screen_test.dart b/test/screens/profile/profile_detail_screen_test.dart index d1137cb4..291327af 100644 --- a/test/screens/profile/profile_detail_screen_test.dart +++ b/test/screens/profile/profile_detail_screen_test.dart @@ -7,6 +7,7 @@ import 'package:plezy/connection/connection_registry.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/profiles/active_profile_provider.dart'; import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/profile_connection.dart'; @@ -49,7 +50,19 @@ void main() { storage: storage, plexHomeUserFetcher: (_) async => const [], ); + // The screen reads its avatar through this provider, exactly as it does + // under the app-lifetime scope in main.dart. Left uninitialized: this test + // is about TV back handling, and an idle provider yields no avatar without + // opening stream subscriptions the test would have to settle. + final activeProfile = ActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + profileConnections: profileConnections, + storage: storage, + ); addTearDown(() async { + activeProfile.dispose(); await plexHome.dispose(); await db.close(); }); @@ -62,6 +75,7 @@ void main() { Provider.value(value: profileConnections), Provider.value(value: connections), Provider.value(value: plexHome), + ChangeNotifierProvider.value(value: activeProfile), ], child: InputModeTracker( child: MaterialApp( diff --git a/test/screens/profile/profile_switch_screen_test.dart b/test/screens/profile/profile_switch_screen_test.dart index fced9f21..07819f7e 100644 --- a/test/screens/profile/profile_switch_screen_test.dart +++ b/test/screens/profile/profile_switch_screen_test.dart @@ -9,6 +9,7 @@ import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/profiles/active_profile_provider.dart'; import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_avatar.dart'; import 'package:plezy/profiles/profile_connection.dart'; import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/profiles/profile_registry.dart'; @@ -44,6 +45,7 @@ void main() { registry: profiles, plexHome: plexHome, connections: connections, + profileConnections: profileConnections, storage: storage, ); addTearDown(() async { @@ -106,6 +108,7 @@ void main() { registry: profiles, plexHome: plexHome, connections: connections, + profileConnections: profileConnections, storage: storage, ); addTearDown(() async { @@ -132,6 +135,87 @@ void main() { expect(tester.getTopLeft(find.text('Kids')).dy, lessThan(tester.getTopLeft(find.text('Owner')).dy)); }); + + testWidgets('passes derived Jellyfin avatar URLs only to linked profile tiles', (tester) async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final linkedProfile = Profile.local(id: 'local-linked', displayName: 'Linked', createdAt: DateTime(2026, 1, 1)); + final unlinkedProfile = Profile.local( + id: 'local-unlinked', + displayName: 'Unlinked', + createdAt: DateTime(2026, 1, 2), + ); + final jellyfin = JellyfinConnection( + id: 'jf-machine/jf-user', + baseUrl: 'https://jellyfin.example', + serverName: 'Jellyfin', + serverMachineId: 'jf-machine', + userId: 'jf-user', + userName: 'Linked', + accessToken: 'secret-token', + deviceId: 'device-1', + primaryImageTag: 'primary-tag', + createdAt: DateTime(2025, 12, 1), + ); + final link = ProfileConnection( + profileId: linkedProfile.id, + connectionId: jellyfin.id, + userIdentifier: jellyfin.userId, + ); + final profiles = _FakeProfileRegistry(db, [linkedProfile, unlinkedProfile]); + final connections = _FakeConnectionRegistry(db, [jellyfin]); + final profileConnections = _FakeProfileConnectionRegistry(db, [link]); + final storage = await StorageService.getInstance(); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => const [], + ); + final activeProfile = ActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + profileConnections: profileConnections, + storage: storage, + ); + addTearDown(() async { + activeProfile.dispose(); + await plexHome.dispose(); + await db.close(); + }); + + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + Provider.value(value: profiles), + Provider.value(value: profileConnections), + Provider.value(value: connections), + Provider.value(value: plexHome), + ChangeNotifierProvider.value(value: activeProfile), + ], + child: MaterialApp(theme: monoTheme(dark: true), home: const ProfileSwitchScreen()), + ), + ), + ); + await tester.pumpAndSettle(); + + final linkedAvatar = tester.widget( + find.byWidgetPredicate((widget) => widget is ProfileAvatar && widget.profile?.id == linkedProfile.id), + ); + final unlinkedAvatar = tester.widget( + find.byWidgetPredicate((widget) => widget is ProfileAvatar && widget.profile?.id == unlinkedProfile.id), + ); + expect(linkedAvatar.avatarUrl, isNotNull); + expect( + linkedAvatar.avatarUrl, + 'https://jellyfin.example/Users/jf-user/Images/Primary?tag=primary-tag&maxWidth=240&maxHeight=240', + ); + expect(unlinkedAvatar.avatarUrl, isNull); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); } class _FakeProfileRegistry extends ProfileRegistry { @@ -147,18 +231,22 @@ class _FakeProfileRegistry extends ProfileRegistry { } class _FakeConnectionRegistry extends ConnectionRegistry { - _FakeConnectionRegistry(super.db); + final List _connections; + + _FakeConnectionRegistry(super.db, [this._connections = const []]); @override - Stream> watchConnections() => Stream.value(const []); + Stream> watchConnections() => Stream.value(_connections); @override - Future> list() async => const []; + Future> list() async => _connections; } class _FakeProfileConnectionRegistry extends ProfileConnectionRegistry { - _FakeProfileConnectionRegistry(super.db); + final List _profileConnections; + + _FakeProfileConnectionRegistry(super.db, [this._profileConnections = const []]); @override - Stream> watchAll() => Stream.value(const []); + Stream> watchAll() => Stream.value(_profileConnections); } diff --git a/test/screens/profile/profile_teardown_test.dart b/test/screens/profile/profile_teardown_test.dart index 8895bb1a..c916491c 100644 --- a/test/screens/profile/profile_teardown_test.dart +++ b/test/screens/profile/profile_teardown_test.dart @@ -268,6 +268,7 @@ Future<_Harness> _pumpHarness( registry: profileRegistry, plexHome: plexHome, connections: connections, + profileConnections: profileConnections, storage: storage, ); final profile = Profile.local(id: 'active', displayName: 'Active', createdAt: DateTime(2026, 1, 1)); diff --git a/test/screens/settings/add_jellyfin_screen_test.dart b/test/screens/settings/add_jellyfin_screen_test.dart index 17e6f883..7dfc70dc 100644 --- a/test/screens/settings/add_jellyfin_screen_test.dart +++ b/test/screens/settings/add_jellyfin_screen_test.dart @@ -168,6 +168,7 @@ class _NoWatchActiveProfileProvider extends ActiveProfileProvider { required super.registry, required super.plexHome, required super.connections, + required super.profileConnections, required super.storage, }); @@ -242,6 +243,7 @@ class _RouteHarness { registry: profiles, plexHome: plexHome, connections: connections, + profileConnections: profileConnections, storage: storage, ); final manager = _CountingJellyfinManager(); diff --git a/test/screens/settings/connection_persistence_test.dart b/test/screens/settings/connection_persistence_test.dart index ee6eb681..d34d9c34 100644 --- a/test/screens/settings/connection_persistence_test.dart +++ b/test/screens/settings/connection_persistence_test.dart @@ -79,6 +79,7 @@ class _RejectingActiveProfileProvider extends ActiveProfileProvider { required super.registry, required super.plexHome, required super.connections, + required super.profileConnections, required super.storage, }); @@ -91,6 +92,7 @@ class _ThrowingActiveProfileProvider extends ActiveProfileProvider { required super.registry, required super.plexHome, required super.connections, + required super.profileConnections, required super.storage, }); @@ -184,7 +186,13 @@ void main() { ); activeProfiles = activeFactory?.call(profiles, plexHome, connections, storage) ?? - ActiveProfileProvider(registry: profiles, plexHome: plexHome, connections: connections, storage: storage); + ActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + profileConnections: profileConnections, + storage: storage, + ); if (initializeActive) await tester.runAsync(activeProfiles.initialize); await tester.pumpWidget( MultiProvider( @@ -347,6 +355,7 @@ void main() { registry: profiles, plexHome: plexHome, connections: connections, + profileConnections: profileConnections, storage: storage, ), ); @@ -388,6 +397,7 @@ void main() { registry: profiles, plexHome: plexHome, connections: connections, + profileConnections: profileConnections, storage: storage, ), ); diff --git a/test/screens/settings/settings_screen_test.dart b/test/screens/settings/settings_screen_test.dart index 34c7d384..0e28e559 100644 --- a/test/screens/settings/settings_screen_test.dart +++ b/test/screens/settings/settings_screen_test.dart @@ -642,7 +642,12 @@ Future<_SettingsHarness> _pumpSettingsScreen( profileConnections: profileConnections, plexHomeUserFetcher: (_) async => const [], ); - final activeProfile = ActiveProfileProvider(registry: profiles, plexHome: plexHome, connections: connections); + final activeProfile = ActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + profileConnections: profileConnections, + ); final libraries = LibrariesProvider(); final hiddenLibraries = HiddenLibrariesProvider(storageService: _FakeHiddenLibrariesStorage()); await hiddenLibraries.ensureInitialized(); diff --git a/test/services/jellyfin_auth_service_test.dart b/test/services/jellyfin_auth_service_test.dart index 58bbcf78..e8b866a0 100644 --- a/test/services/jellyfin_auth_service_test.dart +++ b/test/services/jellyfin_auth_service_test.dart @@ -38,6 +38,27 @@ JellyfinConnectionAuthService _service({required _Handler handler}) { ); } +Future _authenticateByNameWithUser(Map user) { + final svc = _service( + handler: (req) { + if (req.url.path == '/System/Info/Public') { + return _ok({'Id': 'srv-1', 'ServerName': 'Home'}); + } + if (req.url.path == '/Users/AuthenticateByName') { + return _ok({'AccessToken': 'tok-new', 'User': user}); + } + return _status(404); + }, + ); + + return svc.authenticateByName( + baseUrl: 'https://jf.example.com', + username: 'edde', + password: 'pw', + deviceId: 'dev-xyz', + ); +} + Future _captureError(Future future) async { try { await future; @@ -157,6 +178,47 @@ void main() { expect(conn.id, 'srv-1/user-7'); }); + test('captures the user primary image tag', () async { + final conn = await _authenticateByNameWithUser({'Id': 'user-7', 'Name': 'edde', 'PrimaryImageTag': 'avatar-tag'}); + + expect(conn.primaryImageTag, 'avatar-tag'); + }); + + test('uses no primary image tag when Jellyfin omits the key', () async { + final conn = await _authenticateByNameWithUser({'Id': 'user-7', 'Name': 'edde'}); + + expect(conn.primaryImageTag, isNull); + }); + + test('uses no primary image tag when Jellyfin returns null', () async { + final conn = await _authenticateByNameWithUser({'Id': 'user-7', 'Name': 'edde', 'PrimaryImageTag': null}); + + expect(conn.primaryImageTag, isNull); + }); + + test('ignores empty and whitespace-only primary image tags', () async { + for (final tag in ['', ' \t ']) { + final conn = await _authenticateByNameWithUser({'Id': 'user-7', 'Name': 'edde', 'PrimaryImageTag': tag}); + + expect(conn.primaryImageTag, isNull, reason: 'tag: "$tag"'); + } + }); + + test('a malformed primary image tag does not fail sign-in', () async { + final cases = <(Object, String)>[ + (12345, '12345'), + (['unexpected'], '[unexpected]'), + ({'unexpected': 'tag'}, '{unexpected: tag}'), + ]; + + for (final (tag, expected) in cases) { + final conn = await _authenticateByNameWithUser({'Id': 'user-7', 'Name': 'edde', 'PrimaryImageTag': tag}); + + expect(conn, isA()); + expect(conn.primaryImageTag, expected, reason: 'tag: $tag'); + } + }); + test('throws MediaServerAuthException on 401', () async { final svc = _service( handler: (req) { @@ -343,6 +405,36 @@ void main() { expect(conn.userId, 'user-9'); }); + test('captures the user primary image tag', () async { + final svc = _service( + handler: (req) { + if (req.url.path == '/System/Info/Public') { + return _ok({'Id': 'srv-1', 'ServerName': 'Home'}); + } + if (req.url.path == '/QuickConnect/Connect') { + return _ok({'Authenticated': true}); + } + if (req.url.path == '/Users/AuthenticateWithQuickConnect') { + return _ok({ + 'AccessToken': 'tok-qc', + 'User': {'Id': 'user-9', 'Name': 'edde', 'PrimaryImageTag': 'quick-connect-avatar'}, + }); + } + return _status(404); + }, + ); + + final conn = await svc.authenticateByQuickConnect( + baseUrl: 'https://jf.example.com', + secret: 'sec', + deviceId: 'dev-xyz', + timeout: const Duration(seconds: 30), + ); + + expect(conn, isNotNull); + expect(conn!.primaryImageTag, 'quick-connect-avatar'); + }); + test('returns null when secret expires server-side (404 mid-poll)', () async { final svc = _service( handler: (req) { diff --git a/test/services/jellyfin_mappers_test.dart b/test/services/jellyfin_mappers_test.dart index de85ca27..744c3ea4 100644 --- a/test/services/jellyfin_mappers_test.dart +++ b/test/services/jellyfin_mappers_test.dart @@ -697,4 +697,55 @@ void main() { ); }); }); + + group('jellyfinUserImageUrl', () { + test('builds an absolute, tag-keyed user image URL', () { + final url = jellyfinUserImageUrl(baseUrl: 'https://jelly.example', userId: 'user-1', tag: 'abc123'); + + final uri = Uri.parse(url!); + expect(uri.origin, 'https://jelly.example'); + expect(uri.path, '/Users/user-1/Images/Primary'); + expect(uri.queryParameters['tag'], 'abc123'); + }); + + test('carries no api_key — the user image endpoint is anonymous', () { + final url = jellyfinUserImageUrl(baseUrl: 'https://jelly.example', userId: 'user-1', tag: 'abc123')!; + + // Item artwork self-authenticates via api_key; baking the access token + // into an avatar URL would put it in the image cache key for no reason. + expect(url, isNot(contains('api_key'))); + expect(url, isNot(contains('secret'))); + }); + + test('returns null when the user has no picture', () { + expect(jellyfinUserImageUrl(baseUrl: 'https://jelly.example', userId: 'user-1', tag: null), isNull); + expect(jellyfinUserImageUrl(baseUrl: 'https://jelly.example', userId: 'user-1', tag: ''), isNull); + }); + + test('returns null when the connection is missing a base URL or user id', () { + expect(jellyfinUserImageUrl(baseUrl: '', userId: 'user-1', tag: 'abc123'), isNull); + expect(jellyfinUserImageUrl(baseUrl: 'https://jelly.example', userId: '', tag: 'abc123'), isNull); + }); + + test('joins a base URL that carries a subpath and a trailing slash', () { + final url = jellyfinUserImageUrl(baseUrl: 'https://host.example/jellyfin/', userId: 'user-1', tag: 'abc123'); + + expect(Uri.parse(url!).path, '/jellyfin/Users/user-1/Images/Primary'); + }); + + test('escapes a user id that would otherwise break the path', () { + final url = jellyfinUserImageUrl(baseUrl: 'https://jelly.example', userId: 'a/b', tag: 'abc123'); + + expect(Uri.parse(url!).pathSegments, ['Users', 'a/b', 'Images', 'Primary']); + }); + + test('requests a bounded size for servers that still honour it', () { + final uri = Uri.parse( + jellyfinUserImageUrl(baseUrl: 'https://jelly.example', userId: 'user-1', tag: 'abc123', maxSize: 96)!, + ); + + expect(uri.queryParameters['maxWidth'], '96'); + expect(uri.queryParameters['maxHeight'], '96'); + }); + }); } diff --git a/test/services/multi_server_manager_test.dart b/test/services/multi_server_manager_test.dart index e85b14ef..3de98e31 100644 --- a/test/services/multi_server_manager_test.dart +++ b/test/services/multi_server_manager_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_server_client.dart'; import 'package:drift/native.dart'; import 'package:fake_async/fake_async.dart'; @@ -1016,6 +1017,150 @@ void main() { expect(persisted.single.isAdministrator, isTrue); }); + test('persists a changed profile picture tag discovered during health checks', () async { + final persisted = []; + var requestCount = 0; + final client = JellyfinClient.forTesting( + connection: _jellyfinConnection('user-a').copyWith(primaryImageTag: 'cached-tag'), + httpClient: MockClient((request) async { + requestCount++; + expect(request.url.path, '/Users/Me'); + return http.Response( + '{"Policy":{"IsAdministrator":false},"PrimaryImageTag":"fresh-tag"}', + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + addTearDown(client.close); + final m = MultiServerManager()..onJellyfinConnectionUpdated = persisted.add; + addTearDown(m.dispose); + m.debugRegisterJellyfinClientForTesting(client); + + final status = await client.checkHealth(); + + expect(status, HealthStatus.online); + expect(requestCount, 1); + expect(persisted, hasLength(1)); + expect(persisted.single.primaryImageTag, 'fresh-tag'); + }); + + test('clears the cached profile picture tag when the user deletes their avatar', () async { + final persisted = []; + var requestCount = 0; + final client = JellyfinClient.forTesting( + connection: _jellyfinConnection('user-a').copyWith(primaryImageTag: 'cached-tag'), + httpClient: MockClient((request) async { + requestCount++; + expect(request.url.path, '/Users/Me'); + return http.Response( + '{"Policy":{"IsAdministrator":false}}', + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + addTearDown(client.close); + final m = MultiServerManager()..onJellyfinConnectionUpdated = persisted.add; + addTearDown(m.dispose); + m.debugRegisterJellyfinClientForTesting(client); + + final status = await client.checkHealth(); + + expect(status, HealthStatus.online); + expect(requestCount, 1); + expect(persisted, hasLength(1)); + expect(persisted.single.primaryImageTag, isNull); + }); + + test('does not persist when the admin flag and profile picture tag are unchanged', () async { + final persisted = []; + var requestCount = 0; + final client = JellyfinClient.forTesting( + connection: _jellyfinConnection('user-a').copyWith(primaryImageTag: 'same-tag'), + httpClient: MockClient((request) async { + requestCount++; + expect(request.url.path, '/Users/Me'); + return http.Response( + '{"Policy":{"IsAdministrator":false},"PrimaryImageTag":"same-tag"}', + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + addTearDown(client.close); + final m = MultiServerManager()..onJellyfinConnectionUpdated = persisted.add; + addTearDown(m.dispose); + m.debugRegisterJellyfinClientForTesting(client); + + final status = await client.checkHealth(); + + expect(status, HealthStatus.online); + expect(requestCount, 1); + expect(persisted, isEmpty); + }); + + test('persists one connection update when the admin flag and profile picture tag both change', () async { + final persisted = []; + var requestCount = 0; + final client = JellyfinClient.forTesting( + connection: _jellyfinConnection('user-a').copyWith(primaryImageTag: 'cached-tag'), + httpClient: MockClient((request) async { + requestCount++; + expect(request.url.path, '/Users/Me'); + return http.Response( + '{"Policy":{"IsAdministrator":true},"PrimaryImageTag":"fresh-tag"}', + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + addTearDown(client.close); + final m = MultiServerManager()..onJellyfinConnectionUpdated = persisted.add; + addTearDown(m.dispose); + m.debugRegisterJellyfinClientForTesting(client); + + final status = await client.checkHealth(); + + expect(status, HealthStatus.online); + expect(requestCount, 1); + expect(persisted, hasLength(1)); + expect(persisted.single.isAdministrator, isTrue); + expect(persisted.single.primaryImageTag, 'fresh-tag'); + }); + + test('refreshes the profile picture tag when Policy is missing or malformed', () async { + final responses = >[ + {'PrimaryImageTag': 'fresh-tag'}, + {'Policy': 'not-a-map', 'PrimaryImageTag': 'fresh-tag'}, + ]; + + for (final responseBody in responses) { + final persisted = []; + var requestCount = 0; + final client = JellyfinClient.forTesting( + connection: _jellyfinConnection('user-a').copyWith(primaryImageTag: 'cached-tag'), + httpClient: MockClient((request) async { + requestCount++; + expect(request.url.path, '/Users/Me'); + return http.Response(jsonEncode(responseBody), 200, headers: {'content-type': 'application/json'}); + }), + ); + addTearDown(client.close); + final m = MultiServerManager()..onJellyfinConnectionUpdated = persisted.add; + addTearDown(m.dispose); + m.debugRegisterJellyfinClientForTesting(client); + + final status = await client.checkHealth(); + + expect(status, HealthStatus.online, reason: 'response: $responseBody'); + expect(requestCount, 1, reason: 'response: $responseBody'); + expect(persisted, hasLength(1), reason: 'response: $responseBody'); + expect(persisted.single.primaryImageTag, 'fresh-tag', reason: 'response: $responseBody'); + expect(persisted.single.isAdministrator, isFalse, reason: 'response: $responseBody'); + } + }); + test('health remains online when persisting refreshed admin status fails', () async { final client = JellyfinClient.forTesting( connection: _jellyfinConnection('user-a'), diff --git a/test/test_helpers/profile_stack.dart b/test/test_helpers/profile_stack.dart index b7d7ecca..bafb99cf 100644 --- a/test/test_helpers/profile_stack.dart +++ b/test/test_helpers/profile_stack.dart @@ -59,7 +59,13 @@ class ProfileStack { profileConnections: profileConnections, profiles: profiles, plexHome: plexHome, - active: ActiveProfileProvider(registry: profiles, plexHome: plexHome, connections: connections, storage: storage), + active: ActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + profileConnections: profileConnections, + storage: storage, + ), storage: storage, ownsDatabase: db == null, );