feat(profiles): show the first linked connection's user picture

A local profile had no picture of its own and always fell back to
initials. It now borrows the user picture of the connection it was
linked to first — oldest Connection.createdAt, ties broken by
connection id, since the join table carries no creation time.

Jellyfin links resolve to /Users/{id}/Images/Primary, keyed by the
PrimaryImageTag now captured at authentication and refreshed from the
/Users/Me body checkHealth already fetches. That endpoint is anonymous
on every Jellyfin release, so the URL carries no api_key and the access
token stays out of the image cache key. Plex links resolve the Home
user the link points at against PlexHomeService's live cache, so no
account-level lookup is needed and the picture tracks Plex's own
refresh.

The picture is derived per snapshot and never written back onto a
Profile: ProfileDetailScreen upserts the model it holds, so a
persisted URL would go stale and outlive the connection it came from.
Plex Home profiles are untouched, including one whose Plex avatar is
unset — it keeps its initials rather than borrowing a lent connection's
picture.

close #1667
This commit is contained in:
edde746
2026-08-04 02:22:44 +02:00
parent 2b4875d389
commit 860ce1e11a
32 changed files with 1469 additions and 46 deletions
+7
View File
@@ -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,
+22 -14
View File
@@ -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<String, dynamic>) {
final policy = data['Policy'];
if (policy is Map<String, dynamic>) {
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<String, dynamic> ? 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);
}
}
}
+34
View File
@@ -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.
///