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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user