Plezy carried three native HTTP clients on the assumption that they beat Dart's own. Benchmarked against real Plex and Jellyfin servers on macOS, Windows, Linux and three Android devices, two of them do not. Cronet loses on every request shape the app issues: 8.1 vs 83.8 MiB/s on a LAN body read, 84 vs 150 req/s on an artwork fan-out, 14.3 vs 10.7 ms on sequential API calls. It also fails a 60-way fan-out outright with net::ERR_CACHE_WRITE_FAILURE under the 2 MiB memory cache we configured, and cost 70-705 ms of CronetEngine.build on first use. Paying that build off the critical path is the only reason AndroidPlatformHttpClient, warmUpPlatformHttpClient and the per-request delegate swap existed; all three go away with it. CupertinoClient had no measured advantage either, losing the TLS fan-out 44 vs 60 req/s, and no reported issue ever justified it. tvOS already shipped the dart:io client, so Apple platforms now agree with it. WinHttpClient stays. WINHTTP_OPTION_IPV6_FAST_FALLBACK (#1128) has no dart:io equivalent, and it brings the system proxy and the Schannel trust store. The pool tuning becomes unconditional. It was opt-in behind usePlexApiClient so generic tracker and auth clients stayed disposable, but every dart:io client has carried connectionTimeout and forceCloseOnDrainTimeout since #1972, so tuned and untuned already share shutdown semantics and the flag only cost throughput: 12 connections per host with a 90s idle measured ~4x the dart:io default on a 60-way fan-out on Linux and ~2x on Android. media3-datasource-cronet and cronet-embedded stay. They back ExoPlayer's CronetDataSource independently of package:cronet_http. Refs #2140.
384 lines
15 KiB
Dart
384 lines
15 KiB
Dart
import 'dart:convert';
|
|
import 'package:plezy/media/ids.dart';
|
|
|
|
import 'package:drift/drift.dart' show Value;
|
|
import 'package:drift/native.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/database/app_database.dart';
|
|
import 'package:plezy/services/credential_vault.dart';
|
|
import 'package:plezy/services/jellyfin_api_cache.dart';
|
|
|
|
import '../test_helpers/prefs.dart';
|
|
|
|
void main() {
|
|
late AppDatabase db;
|
|
late JellyfinApiCache cache;
|
|
|
|
setUp(() {
|
|
resetSharedPreferencesForTest();
|
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
JellyfinApiCache.initialize(db);
|
|
cache = JellyfinApiCache.instance;
|
|
});
|
|
|
|
tearDown(() async {
|
|
await db.close();
|
|
});
|
|
|
|
// Minimal Jellyfin BaseItemDto-shaped payload. The mapper only needs Id +
|
|
// Type + Name to produce a valid MediaItem; the rest is pass-through.
|
|
Map<String, dynamic> jellyfinItem({String id = 'item-1', String name = 'Hello', String type = 'Movie'}) {
|
|
return {'Id': id, 'Type': type, 'Name': name};
|
|
}
|
|
|
|
// Insert a Jellyfin connection row with the production-shape id
|
|
// (`${machineId}/$userId`) and configJson containing the bare serverName.
|
|
Future<void> insertJellyfinConnection({
|
|
required String machineId,
|
|
required String userId,
|
|
required String serverName,
|
|
String accessToken = 'token',
|
|
}) async {
|
|
await db
|
|
.into(db.connections)
|
|
.insert(
|
|
ConnectionsCompanion.insert(
|
|
id: '$machineId/$userId',
|
|
kind: 'jellyfin',
|
|
displayName: 'someone · $serverName',
|
|
configJson: jsonEncode({
|
|
'baseUrl': 'http://example.lan',
|
|
'serverName': serverName,
|
|
'serverMachineId': machineId,
|
|
'userId': userId,
|
|
'userName': 'someone',
|
|
'accessToken': accessToken,
|
|
'deviceId': 'device',
|
|
}),
|
|
createdAt: DateTime.now().millisecondsSinceEpoch,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Cache rows are written by [JellyfinClient] under
|
|
// `serverId:/Users/{userId}/Items/{itemId}` — mirror the shape exactly so
|
|
// we exercise the same lookup pattern.
|
|
Future<void> putItemRow({
|
|
required ServerId serverId,
|
|
required String userId,
|
|
required String itemId,
|
|
Map<String, dynamic>? data,
|
|
bool pinned = false,
|
|
}) async {
|
|
final payload = data ?? jellyfinItem(id: itemId);
|
|
await db
|
|
.into(db.apiCache)
|
|
.insert(
|
|
ApiCacheCompanion.insert(
|
|
cacheKey: '$serverId:/Users/$userId/Items/$itemId',
|
|
data: jsonEncode(payload),
|
|
pinned: Value(pinned),
|
|
),
|
|
);
|
|
}
|
|
|
|
group('getMetadata', () {
|
|
test('resolves serverName via the Jellyfin compound connection id (machineId/userId)', () async {
|
|
// Production stores connection rows under `${machineId}/$userId` while
|
|
// [JellyfinClient.serverId] returns the bare machineId. The cache
|
|
// lookup must reconcile the mismatch — otherwise downloaded Jellyfin
|
|
// items lose their metadata after an app reload.
|
|
const machineId = 'jf-machine';
|
|
const userId = 'jf-user';
|
|
await insertJellyfinConnection(machineId: machineId, userId: userId, serverName: 'My Jellyfin');
|
|
await putItemRow(
|
|
serverId: ServerId(machineId),
|
|
userId: userId,
|
|
itemId: 'item-1',
|
|
data: jellyfinItem(id: 'item-1', name: 'A Movie'),
|
|
);
|
|
|
|
final meta = await cache.getMetadata(ServerId(machineId), 'item-1');
|
|
expect(meta, isNotNull, reason: 'cache lookup must succeed despite id-format mismatch');
|
|
expect(meta!.title, 'A Movie');
|
|
expect(meta.serverId, machineId);
|
|
expect(meta.serverName, 'My Jellyfin', reason: 'serverName is the bare value, not the compound displayName');
|
|
});
|
|
|
|
test('returns null when the connection row is missing', () async {
|
|
// Cache row exists but no Connections row → lookup can't resolve serverName.
|
|
await putItemRow(serverId: ServerId('orphan'), userId: 'u', itemId: 'item-1');
|
|
expect(await cache.getMetadata(ServerId('orphan'), 'item-1'), isNull);
|
|
});
|
|
|
|
test('absolutizes image paths against the connection baseUrl + accessToken', () async {
|
|
// Regression: cached items used to skip absolutization, leaking raw
|
|
// `/Items/.../Images/Primary?tag=...` paths into the download manager
|
|
// → the download rejected them as invalid URLs.
|
|
const machineId = 'jf-machine';
|
|
const userId = 'jf-user';
|
|
await insertJellyfinConnection(machineId: machineId, userId: userId, serverName: 'My Jellyfin');
|
|
await putItemRow(
|
|
serverId: ServerId(machineId),
|
|
userId: userId,
|
|
itemId: 'item-1',
|
|
data: {
|
|
'Id': 'item-1',
|
|
'Type': 'Movie',
|
|
'Name': 'A Movie',
|
|
'ImageTags': {'Primary': 'tag-abc', 'Logo': 'tag-logo'},
|
|
},
|
|
);
|
|
|
|
final meta = await cache.getMetadata(ServerId(machineId), 'item-1');
|
|
expect(meta, isNotNull);
|
|
expect(meta!.thumbPath, 'http://example.lan/Items/item-1/Images/Primary?tag=tag-abc&api_key=token');
|
|
expect(meta.clearLogoPath, 'http://example.lan/Items/item-1/Images/Logo?tag=tag-logo&api_key=token');
|
|
});
|
|
|
|
test('absolutizes image paths with decrypted accessToken', () async {
|
|
const machineId = 'jf-machine';
|
|
const userId = 'jf-user';
|
|
await insertJellyfinConnection(
|
|
machineId: machineId,
|
|
userId: userId,
|
|
serverName: 'My Jellyfin',
|
|
accessToken: await CredentialVault.protect('secret-token'),
|
|
);
|
|
await putItemRow(
|
|
serverId: ServerId(machineId),
|
|
userId: userId,
|
|
itemId: 'item-1',
|
|
data: {
|
|
'Id': 'item-1',
|
|
'Type': 'Movie',
|
|
'Name': 'A Movie',
|
|
'ImageTags': {'Primary': 'tag-abc'},
|
|
},
|
|
);
|
|
|
|
final meta = await cache.getMetadata(ServerId(machineId), 'item-1');
|
|
expect(meta, isNotNull);
|
|
expect(meta!.thumbPath, contains('api_key=secret-token'));
|
|
expect(meta.thumbPath, isNot(contains('enc:v1:')));
|
|
});
|
|
|
|
test('scopes UserData by Jellyfin compound connection id', () async {
|
|
const machineId = 'jf-machine';
|
|
await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF');
|
|
await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF');
|
|
await putItemRow(
|
|
serverId: ServerId('$machineId/user-a'),
|
|
userId: 'user-a',
|
|
itemId: 'item-1',
|
|
data: {
|
|
...jellyfinItem(id: 'item-1', name: 'For A'),
|
|
'UserData': {'Played': false, 'PlayCount': 0},
|
|
},
|
|
);
|
|
await putItemRow(
|
|
serverId: ServerId('$machineId/user-b'),
|
|
userId: 'user-b',
|
|
itemId: 'item-1',
|
|
data: {
|
|
...jellyfinItem(id: 'item-1', name: 'For B'),
|
|
'UserData': {'Played': true, 'PlayCount': 1},
|
|
},
|
|
);
|
|
|
|
final a = await cache.getMetadata(ServerId('$machineId/user-a'), 'item-1');
|
|
final b = await cache.getMetadata(ServerId('$machineId/user-b'), 'item-1');
|
|
|
|
expect(a, isNotNull);
|
|
expect(b, isNotNull);
|
|
expect(a!.serverId, machineId);
|
|
expect(a.isWatched, isFalse);
|
|
expect(a.title, 'For A');
|
|
expect(b!.serverId, machineId);
|
|
expect(b.isWatched, isTrue);
|
|
expect(b.title, 'For B');
|
|
});
|
|
});
|
|
|
|
group('getAllPinnedMetadata', () {
|
|
test('aggregates pinned items keyed by globalKey across multiple users on the same server', () async {
|
|
// Same machineId, two users → two connection rows. Both pin items.
|
|
// Both should resolve via the prefix match.
|
|
const machineId = 'jf-machine';
|
|
await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF');
|
|
await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF');
|
|
|
|
await putItemRow(serverId: ServerId(machineId), userId: 'user-a', itemId: 'item-1', pinned: true);
|
|
await putItemRow(serverId: ServerId(machineId), userId: 'user-b', itemId: 'item-2', pinned: true);
|
|
// Unpinned row is filtered out.
|
|
await putItemRow(serverId: ServerId(machineId), userId: 'user-a', itemId: 'item-3');
|
|
|
|
final pinned = await cache.getAllPinnedMetadata();
|
|
expect(pinned.keys.toSet(), {'$machineId:item-1', '$machineId:item-2'});
|
|
expect(pinned['$machineId:item-1']!.serverName, 'Shared JF');
|
|
});
|
|
|
|
test('compound scope filtering selects only that user from legacy bare-scope rows', () async {
|
|
const machineId = 'jf-machine';
|
|
await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF');
|
|
await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF');
|
|
await putItemRow(
|
|
serverId: ServerId(machineId),
|
|
userId: 'user-a',
|
|
itemId: 'item-a',
|
|
data: jellyfinItem(id: 'item-a', name: 'For A'),
|
|
pinned: true,
|
|
);
|
|
await putItemRow(
|
|
serverId: ServerId(machineId),
|
|
userId: 'user-b',
|
|
itemId: 'item-b',
|
|
data: jellyfinItem(id: 'item-b', name: 'For B'),
|
|
pinned: true,
|
|
);
|
|
|
|
final pinned = await cache.getAllPinnedMetadata(cacheServerIds: {ServerId('$machineId/user-b')});
|
|
|
|
expect(pinned.keys, ['$machineId:item-b']);
|
|
expect(pinned.values.single.title, 'For B');
|
|
});
|
|
|
|
test('skips pinned rows whose serverId has no matching connection', () async {
|
|
await putItemRow(serverId: ServerId('orphan-machine'), userId: 'u', itemId: 'lost', pinned: true);
|
|
expect(await cache.getAllPinnedMetadata(), isEmpty);
|
|
});
|
|
|
|
test('keeps same-server Jellyfin users addressable by compound pinned keys', () async {
|
|
const machineId = 'jf-machine';
|
|
await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF');
|
|
await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF');
|
|
await putItemRow(
|
|
serverId: ServerId('$machineId/user-a'),
|
|
userId: 'user-a',
|
|
itemId: 'item-1',
|
|
data: {
|
|
...jellyfinItem(id: 'item-1', name: 'For A'),
|
|
'UserData': {'Played': false, 'PlayCount': 0},
|
|
},
|
|
pinned: true,
|
|
);
|
|
await putItemRow(
|
|
serverId: ServerId('$machineId/user-b'),
|
|
userId: 'user-b',
|
|
itemId: 'item-1',
|
|
data: {
|
|
...jellyfinItem(id: 'item-1', name: 'For B'),
|
|
'UserData': {'Played': true, 'PlayCount': 1},
|
|
},
|
|
pinned: true,
|
|
);
|
|
|
|
final pinned = await cache.getAllPinnedMetadata();
|
|
expect(pinned['$machineId:item-1'], isNull);
|
|
expect(pinned['$machineId/user-a:item-1']!.title, 'For A');
|
|
expect(pinned['$machineId/user-b:item-1']!.title, 'For B');
|
|
expect(pinned['$machineId/user-a:item-1']!.serverId, machineId);
|
|
expect(pinned['$machineId/user-b:item-1']!.serverId, machineId);
|
|
});
|
|
});
|
|
|
|
group('pinForOffline', () {
|
|
test('pins by user-segment wildcard so a single call covers any user', () async {
|
|
const machineId = 'jf-machine';
|
|
await putItemRow(serverId: ServerId(machineId), userId: 'user-a', itemId: 'item-1');
|
|
await putItemRow(serverId: ServerId(machineId), userId: 'user-b', itemId: 'item-1');
|
|
await putItemRow(serverId: ServerId(machineId), userId: 'user-a', itemId: 'item-2');
|
|
|
|
await cache.pinForOffline(ServerId(machineId), 'item-1');
|
|
|
|
// Both per-user rows for item-1 get pinned, item-2 stays unpinned.
|
|
final rows = await db.select(db.apiCache).get();
|
|
final pinnedKeys = rows.where((r) => r.pinned).map((r) => r.cacheKey).toSet();
|
|
expect(pinnedKeys, {'$machineId:/Users/user-a/Items/item-1', '$machineId:/Users/user-b/Items/item-1'});
|
|
});
|
|
|
|
test('pins only the requested compound Jellyfin user scope', () async {
|
|
const machineId = 'jf-machine';
|
|
await putItemRow(serverId: ServerId('$machineId/user-a'), userId: 'user-a', itemId: 'item-1');
|
|
await putItemRow(serverId: ServerId('$machineId/user-b'), userId: 'user-b', itemId: 'item-1');
|
|
|
|
await cache.pinForOffline(ServerId('$machineId/user-a'), 'item-1');
|
|
|
|
final rows = await db.select(db.apiCache).get();
|
|
final pinnedKeys = rows.where((r) => r.pinned).map((r) => r.cacheKey).toSet();
|
|
expect(pinnedKeys, {'$machineId/user-a:/Users/user-a/Items/item-1'});
|
|
});
|
|
});
|
|
|
|
group('applyWatchState', () {
|
|
test('mutates only the requested compound Jellyfin user scope', () async {
|
|
const machineId = 'jf-machine';
|
|
await putItemRow(
|
|
serverId: ServerId('$machineId/user-a'),
|
|
userId: 'user-a',
|
|
itemId: 'item-1',
|
|
data: {
|
|
...jellyfinItem(id: 'item-1'),
|
|
'UserData': {'Played': false, 'PlayCount': 0},
|
|
},
|
|
);
|
|
await putItemRow(
|
|
serverId: ServerId('$machineId/user-b'),
|
|
userId: 'user-b',
|
|
itemId: 'item-1',
|
|
data: {
|
|
...jellyfinItem(id: 'item-1'),
|
|
'UserData': {'Played': false, 'PlayCount': 0},
|
|
},
|
|
);
|
|
|
|
await cache.applyWatchState(serverId: ServerId('$machineId/user-a'), itemId: 'item-1', isWatched: true);
|
|
|
|
final rows = await db.select(db.apiCache).get();
|
|
final byKey = {for (final row in rows) row.cacheKey: jsonDecode(row.data) as Map<String, dynamic>};
|
|
expect((byKey['$machineId/user-a:/Users/user-a/Items/item-1']!['UserData'] as Map)['Played'], isTrue);
|
|
expect((byKey['$machineId/user-b:/Users/user-b/Items/item-1']!['UserData'] as Map)['Played'], isFalse);
|
|
});
|
|
|
|
test('bare Jellyfin server id updates the only cached user scope', () async {
|
|
const machineId = 'jf-machine';
|
|
await putItemRow(
|
|
serverId: ServerId(machineId),
|
|
userId: 'user-a',
|
|
itemId: 'item-1',
|
|
data: {
|
|
...jellyfinItem(id: 'item-1'),
|
|
'UserData': {'Played': false, 'PlayCount': 0},
|
|
},
|
|
);
|
|
|
|
await cache.applyWatchState(serverId: ServerId(machineId), itemId: 'item-1', isWatched: true);
|
|
|
|
final row = await db.select(db.apiCache).getSingle();
|
|
expect((jsonDecode(row.data)['UserData'] as Map)['Played'], isTrue);
|
|
});
|
|
|
|
test('bare Jellyfin server id skips ambiguous multi-user cache updates', () async {
|
|
const machineId = 'jf-machine';
|
|
for (final userId in ['user-a', 'user-b']) {
|
|
await putItemRow(
|
|
serverId: ServerId(machineId),
|
|
userId: userId,
|
|
itemId: 'item-1',
|
|
data: {
|
|
...jellyfinItem(id: 'item-1'),
|
|
'UserData': {'Played': false, 'PlayCount': 0},
|
|
},
|
|
);
|
|
}
|
|
|
|
await cache.applyWatchState(serverId: ServerId(machineId), itemId: 'item-1', isWatched: true);
|
|
|
|
final rows = await db.select(db.apiCache).get();
|
|
for (final row in rows) {
|
|
expect((jsonDecode(row.data)['UserData'] as Map)['Played'], isFalse);
|
|
}
|
|
});
|
|
});
|
|
}
|