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
@@ -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 = <JellyfinConnection>[];
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 = <JellyfinConnection>[];
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 = <JellyfinConnection>[];
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 = <JellyfinConnection>[];
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 = <Map<String, Object?>>[
{'PrimaryImageTag': 'fresh-tag'},
{'Policy': 'not-a-map', 'PrimaryImageTag': 'fresh-tag'},
];
for (final responseBody in responses) {
final persisted = <JellyfinConnection>[];
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'),