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
@@ -38,6 +38,27 @@ JellyfinConnectionAuthService _service({required _Handler handler}) {
);
}
Future<JellyfinConnection> _authenticateByNameWithUser(Map<String, Object?> 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<Object> _captureError(Future<dynamic> 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<JellyfinConnection>());
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) {
+51
View File
@@ -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');
});
});
}
@@ -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'),