The Discover tab fanned out its whole request set twice on every cold start and replayed slow rows on a shrinking timeout ladder, so a healthy remote server produced anywhere from 4s to 15s of loading. Measured against a remote Jellyfin server with four libraries, 24 interleaved cold-start samples per side: requests 19 -> 9 payload 219 KB -> 94 KB settled 5231ms -> 2502ms median, 13222ms -> 5927ms p95 Four independent causes: - Retry policy. `Client.send` resolves on response headers, so the connect budget covers the server's think time and a slow-but-alive query raises `connectionTimeout`. Replaying it made the server re-run the query with a shorter budget than the one it just missed; the `[10s, 8s, 5s]` ladder turned an 11s answer into an empty row after 23s. Hub surfaces now get one whole-request deadline, retry only immediate connection errors, and the deadline bounds the whole call including the request still in flight. - Request shape. `/Items/Latest` groups a TV library by series, so its rows are Series folder dtos and `RecursiveItemCount`/`ChildCount` cost a DB count each, per row. Hub rows now ask for `Overview` only; watch state survives because Jellyfin derives `UserData.Played` from `UnplayedItemCount` when the count fields are absent. `/Shows/NextUp` sends `NextUpDateCutoff` to bound the server's series-key scan, and `Thumb` leaves `EnableImageTypes` since nothing reads it. `UserData` and `PremiereDate` leave the browse set: neither is an `ItemFields` member, so the server dropped them anyway. - Fan-out. Per-library hubs ran in batches of three separated by a barrier, so one slow library stalled every library behind it. A sliding window keeps the same peak concurrency without head-of-line blocking. Concurrent `fetchLibraries` calls now share one `/Views` instead of racing two identical round trips, Plex's global and music hub legs start together, and Jellyfin gets Plex's pool tuning. - Duplicate pass. `DiscoverScreen.initState` starts a load and the online-entry hook asked for a full refresh on top of it, which `CoalescedLoadCoordinator` correctly queued as a trailing pass. The hook now calls `primeRefresh`, which rides along with a load already in flight; profile switches still go through `fullRefresh`. Refs #1784
472 lines
17 KiB
Dart
472 lines
17 KiB
Dart
import 'dart:async';
|
|
import 'package:plezy/media/ids.dart';
|
|
import 'dart:convert';
|
|
|
|
import 'package:drift/native.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:plezy/database/app_database.dart';
|
|
import 'package:plezy/models/plex/plex_config.dart';
|
|
import 'package:plezy/services/plex_api_cache.dart';
|
|
import 'package:plezy/services/plex_client.dart';
|
|
import 'package:plezy/utils/active_client_scope.dart';
|
|
|
|
typedef _RequestHandler = Future<http.StreamedResponse> Function(http.BaseRequest request);
|
|
|
|
class _SequenceClient extends http.BaseClient {
|
|
_SequenceClient(this._handlers);
|
|
|
|
final List<_RequestHandler> _handlers;
|
|
final requests = <http.BaseRequest>[];
|
|
|
|
@override
|
|
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
|
requests.add(request);
|
|
if (_handlers.isEmpty) {
|
|
throw StateError('Unexpected request: ${request.url}');
|
|
}
|
|
return _handlers.removeAt(0)(request);
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
group('PlexConfig language headers', () {
|
|
test('includes Plex language headers when configured', () {
|
|
final config = PlexConfig(
|
|
baseUrl: 'http://server:32400',
|
|
token: 'token',
|
|
clientIdentifier: 'client-id',
|
|
product: 'Plezy',
|
|
version: 'test',
|
|
languageCode: 'fr',
|
|
);
|
|
|
|
expect(config.headers['Accept-Language'], 'fr');
|
|
expect(config.headers['X-Plex-Language'], 'fr');
|
|
});
|
|
|
|
test('copyWith preserves language headers when refreshing the token', () {
|
|
final config = PlexConfig(
|
|
baseUrl: 'http://server:32400',
|
|
token: 'old-token',
|
|
clientIdentifier: 'client-id',
|
|
product: 'Plezy',
|
|
version: 'test',
|
|
languageCode: 'es',
|
|
).copyWith(token: 'new-token');
|
|
|
|
expect(config.headers['X-Plex-Token'], 'new-token');
|
|
expect(config.headers['Accept-Language'], 'es');
|
|
expect(config.headers['X-Plex-Language'], 'es');
|
|
});
|
|
});
|
|
|
|
group('PlexClient home hub retries', () {
|
|
test('fetchGlobalHubs retries a first-attempt connection error', () async {
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
PlexApiCache.initialize(db);
|
|
addTearDown(db.close);
|
|
|
|
final httpClient = _SequenceClient([
|
|
(_) async => throw http.ClientException('connection reset on cold Plex start'),
|
|
(_) async => _jsonResponse(_globalHubsPayload()),
|
|
]);
|
|
final client = PlexClient.forTesting(
|
|
config: PlexConfig(
|
|
baseUrl: 'http://server:32400',
|
|
token: 'token',
|
|
clientIdentifier: 'client-id',
|
|
product: 'Plezy',
|
|
version: 'test',
|
|
),
|
|
serverId: ServerId('server-id'),
|
|
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
|
|
serverName: 'Server',
|
|
httpClient: httpClient,
|
|
);
|
|
addTearDown(client.close);
|
|
|
|
final hubs = await client.fetchGlobalHubs(limit: 12);
|
|
|
|
expect(hubs, hasLength(1));
|
|
expect(hubs.single.title, 'Recently Added Movies');
|
|
expect(hubs.single.items.single.title, 'Movie A');
|
|
expect(httpClient.requests, hasLength(2));
|
|
expect(httpClient.requests.map((r) => r.url.path), everyElement('/hubs'));
|
|
expect(httpClient.requests.map((r) => r.url.queryParameters['count']), everyElement('12'));
|
|
});
|
|
|
|
test('fetchGlobalHubs does not replay a hub row that timed out', () async {
|
|
// `Client.send` resolves on response headers, so a hub timeout usually
|
|
// means the server is still working on the query. Replaying it made the
|
|
// server start over on a shorter budget — the #1784 cold-start stall.
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
PlexApiCache.initialize(db);
|
|
addTearDown(db.close);
|
|
|
|
final httpClient = _SequenceClient([
|
|
(_) async => throw TimeoutException('server still building the hub'),
|
|
(_) async => _jsonResponse(_globalHubsPayload()),
|
|
]);
|
|
final client = PlexClient.forTesting(
|
|
config: PlexConfig(
|
|
baseUrl: 'http://server:32400',
|
|
token: 'token',
|
|
clientIdentifier: 'client-id',
|
|
product: 'Plezy',
|
|
version: 'test',
|
|
),
|
|
serverId: ServerId('server-id'),
|
|
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
|
|
serverName: 'Server',
|
|
httpClient: httpClient,
|
|
);
|
|
addTearDown(client.close);
|
|
|
|
// `_fetchHubs` degrades a failed row to empty rather than sinking home.
|
|
expect(await client.fetchGlobalHubs(limit: 12), isEmpty);
|
|
expect(httpClient.requests, hasLength(1));
|
|
});
|
|
|
|
test('fetchGlobalHubs sends configured Plex language headers', () async {
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
PlexApiCache.initialize(db);
|
|
addTearDown(db.close);
|
|
|
|
final httpClient = _SequenceClient([(_) async => _jsonResponse(_globalHubsPayload())]);
|
|
final client = PlexClient.forTesting(
|
|
config: PlexConfig(
|
|
baseUrl: 'http://server:32400',
|
|
token: 'token',
|
|
clientIdentifier: 'client-id',
|
|
product: 'Plezy',
|
|
version: 'test',
|
|
languageCode: 'fr',
|
|
),
|
|
serverId: ServerId('server-id'),
|
|
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
|
|
serverName: 'Server',
|
|
httpClient: httpClient,
|
|
);
|
|
addTearDown(client.close);
|
|
|
|
await client.fetchGlobalHubs(limit: 12);
|
|
|
|
expect(httpClient.requests.single.headers['Accept-Language'], 'fr');
|
|
expect(httpClient.requests.single.headers['X-Plex-Language'], 'fr');
|
|
});
|
|
|
|
test('applyLanguageUpdate refreshes headers on the live HTTP client', () async {
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
PlexApiCache.initialize(db);
|
|
addTearDown(db.close);
|
|
|
|
final httpClient = _SequenceClient([
|
|
(_) async => _jsonResponse(_globalHubsPayload()),
|
|
(_) async => _jsonResponse(_globalHubsPayload()),
|
|
]);
|
|
final client = PlexClient.forTesting(
|
|
config: PlexConfig(
|
|
baseUrl: 'http://server:32400',
|
|
token: 'token',
|
|
clientIdentifier: 'client-id',
|
|
product: 'Plezy',
|
|
version: 'test',
|
|
languageCode: 'en',
|
|
),
|
|
serverId: ServerId('server-id'),
|
|
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
|
|
serverName: 'Server',
|
|
httpClient: httpClient,
|
|
);
|
|
addTearDown(client.close);
|
|
|
|
await client.fetchGlobalHubs(limit: 12);
|
|
client.applyLanguageUpdate('fr');
|
|
await client.fetchGlobalHubs(limit: 12);
|
|
|
|
expect(httpClient.requests[0].headers['Accept-Language'], 'en');
|
|
expect(httpClient.requests[0].headers['X-Plex-Language'], 'en');
|
|
expect(httpClient.requests[1].headers['Accept-Language'], 'fr');
|
|
expect(httpClient.requests[1].headers['X-Plex-Language'], 'fr');
|
|
});
|
|
|
|
test('fetchGlobalHubs retries a connection error without switching Plex endpoints', () async {
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
PlexApiCache.initialize(db);
|
|
addTearDown(db.close);
|
|
|
|
const primary = 'http://primary:32400';
|
|
const fallback = 'http://fallback:32400';
|
|
final httpClient = _SequenceClient([
|
|
(_) async => throw http.ClientException('connection reset'),
|
|
(_) async => _jsonResponse(_globalHubsPayload()),
|
|
]);
|
|
final client = PlexClient.forTesting(
|
|
config: PlexConfig(
|
|
baseUrl: primary,
|
|
token: 'token',
|
|
clientIdentifier: 'client-id',
|
|
product: 'Plezy',
|
|
version: 'test',
|
|
),
|
|
serverId: ServerId('server-id'),
|
|
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
|
|
serverName: 'Server',
|
|
httpClient: httpClient,
|
|
prioritizedEndpoints: const [primary, fallback],
|
|
);
|
|
addTearDown(client.close);
|
|
|
|
final hubs = await client.fetchGlobalHubs(limit: 12);
|
|
|
|
expect(hubs, hasLength(1));
|
|
expect(client.config.baseUrl, primary);
|
|
expect(httpClient.requests.map((r) => r.url.origin), everyElement(primary));
|
|
});
|
|
|
|
test('resets live base URL after fallback endpoint is exhausted', () async {
|
|
const primary = 'http://primary:32400';
|
|
const fallback = 'http://fallback:32400';
|
|
final httpClient = _SequenceClient([
|
|
(_) async => throw TimeoutException('primary down'),
|
|
(_) async => throw TimeoutException('fallback down'),
|
|
(_) async => _jsonResponse({
|
|
'MediaContainer': {'machineIdentifier': 'server-id'},
|
|
}),
|
|
]);
|
|
final client = PlexClient.forTesting(
|
|
config: PlexConfig(
|
|
baseUrl: primary,
|
|
token: 'token',
|
|
clientIdentifier: 'client-id',
|
|
product: 'Plezy',
|
|
version: 'test',
|
|
),
|
|
serverId: ServerId('server-id'),
|
|
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
|
|
serverName: 'Server',
|
|
httpClient: httpClient,
|
|
prioritizedEndpoints: const [primary, fallback],
|
|
);
|
|
addTearDown(client.close);
|
|
|
|
await expectLater(client.getServerIdentity(), throwsA(isA<Object>()));
|
|
|
|
expect(client.config.baseUrl, primary);
|
|
expect(httpClient.requests.map((r) => r.url.origin), [primary, fallback]);
|
|
|
|
await client.getServerIdentity();
|
|
expect(httpClient.requests.map((r) => r.url.origin), [primary, fallback, primary]);
|
|
});
|
|
|
|
test('fetchGlobalHubs uses promoted hub endpoint advertised by media providers', () async {
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
PlexApiCache.initialize(db);
|
|
addTearDown(db.close);
|
|
|
|
final httpClient = _SequenceClient([
|
|
(_) async => _jsonResponse(_mediaProvidersPayload()),
|
|
(_) async => _jsonResponse(_globalHubsPayload()),
|
|
]);
|
|
final client = await PlexClient.create(
|
|
PlexConfig(
|
|
baseUrl: 'http://server:32400',
|
|
token: 'token',
|
|
clientIdentifier: 'client-id',
|
|
product: 'Plezy',
|
|
version: 'test',
|
|
),
|
|
serverId: ServerId('server-id'),
|
|
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
|
|
serverName: 'Server',
|
|
httpClient: httpClient,
|
|
seedTranscoderVideoSupport: true,
|
|
);
|
|
addTearDown(client.close);
|
|
|
|
final hubs = await client.fetchGlobalHubs(limit: 12);
|
|
|
|
expect(hubs, hasLength(1));
|
|
expect(hubs.single.title, 'Recently Added Movies');
|
|
expect(httpClient.requests.map((r) => r.url.path), ['/media/providers', '/hubs/promoted']);
|
|
expect(httpClient.requests.last.url.queryParameters['count'], '12');
|
|
});
|
|
|
|
test('fetchContinueWatching uses advertised provider feature endpoint', () async {
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
PlexApiCache.initialize(db);
|
|
addTearDown(db.close);
|
|
|
|
final httpClient = _SequenceClient([
|
|
(_) async => _jsonResponse(_mediaProvidersPayload()),
|
|
(_) async => _jsonResponse(_continueWatchingPayload()),
|
|
]);
|
|
final client = await PlexClient.create(
|
|
PlexConfig(
|
|
baseUrl: 'http://server:32400',
|
|
token: 'token',
|
|
clientIdentifier: 'client-id',
|
|
product: 'Plezy',
|
|
version: 'test',
|
|
),
|
|
serverId: ServerId('server-id'),
|
|
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
|
|
serverName: 'Server',
|
|
httpClient: httpClient,
|
|
seedTranscoderVideoSupport: true,
|
|
);
|
|
addTearDown(client.close);
|
|
|
|
final items = await client.fetchContinueWatching(count: 21);
|
|
|
|
expect(items, hasLength(1));
|
|
expect(items.single.title, 'Movie A');
|
|
expect(httpClient.requests.map((r) => r.url.path), ['/media/providers', '/hubs/continueWatching']);
|
|
expect(httpClient.requests.last.url.queryParameters['count'], '21');
|
|
expect(httpClient.requests.last.url.queryParameters['includeGuids'], '1');
|
|
expect(httpClient.requests.last.url.queryParameters.containsKey('identifier'), isFalse);
|
|
});
|
|
|
|
test('fetchContinueWatching omits count when uncapped', () async {
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
PlexApiCache.initialize(db);
|
|
addTearDown(db.close);
|
|
|
|
final httpClient = _SequenceClient([(_) async => _jsonResponse(_continueWatchingPayload())]);
|
|
final client = PlexClient.forTesting(
|
|
config: PlexConfig(
|
|
baseUrl: 'http://server:32400',
|
|
token: 'token',
|
|
clientIdentifier: 'client-id',
|
|
product: 'Plezy',
|
|
version: 'test',
|
|
),
|
|
serverId: ServerId('server-id'),
|
|
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
|
|
serverName: 'Server',
|
|
httpClient: httpClient,
|
|
);
|
|
addTearDown(client.close);
|
|
|
|
final items = await client.fetchContinueWatching(count: null);
|
|
|
|
expect(items, hasLength(1));
|
|
expect(items.single.title, 'Movie A');
|
|
expect(httpClient.requests.single.url.path, '/hubs');
|
|
expect(httpClient.requests.single.url.queryParameters['identifier'], 'home.continue,home.ondeck');
|
|
expect(httpClient.requests.single.url.queryParameters.containsKey('count'), isFalse);
|
|
expect(httpClient.requests.single.url.queryParameters['includeGuids'], '1');
|
|
});
|
|
|
|
test('fetchLibraryHubs retries a connection error without switching Plex endpoints', () async {
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
PlexApiCache.initialize(db);
|
|
addTearDown(db.close);
|
|
|
|
const primary = 'http://primary:32400';
|
|
const fallback = 'http://fallback:32400';
|
|
final httpClient = _SequenceClient([
|
|
(_) async => throw http.ClientException('connection reset'),
|
|
(_) async => _jsonResponse(_globalHubsPayload()),
|
|
]);
|
|
final client = PlexClient.forTesting(
|
|
config: PlexConfig(
|
|
baseUrl: primary,
|
|
token: 'token',
|
|
clientIdentifier: 'client-id',
|
|
product: 'Plezy',
|
|
version: 'test',
|
|
),
|
|
serverId: ServerId('server-id'),
|
|
profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'),
|
|
serverName: 'Server',
|
|
httpClient: httpClient,
|
|
prioritizedEndpoints: const [primary, fallback],
|
|
);
|
|
addTearDown(client.close);
|
|
|
|
final hubs = await client.fetchLibraryHubs('4', libraryName: 'Movies', limit: 12);
|
|
|
|
expect(hubs, hasLength(1));
|
|
expect(hubs.single.items.single.libraryId, '4');
|
|
expect(hubs.single.items.single.libraryTitle, 'Movies');
|
|
expect(client.config.baseUrl, primary);
|
|
expect(httpClient.requests, hasLength(2));
|
|
expect(httpClient.requests.map((r) => r.url.origin), everyElement(primary));
|
|
expect(httpClient.requests.map((r) => r.url.path), everyElement('/hubs/sections/4'));
|
|
expect(httpClient.requests.map((r) => r.url.queryParameters['count']), everyElement('12'));
|
|
});
|
|
});
|
|
}
|
|
|
|
Future<http.StreamedResponse> _jsonResponse(Map<String, dynamic> body) async {
|
|
return http.StreamedResponse(
|
|
Stream.value(utf8.encode(jsonEncode(body))),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> _globalHubsPayload() => {
|
|
'MediaContainer': {
|
|
'Hub': [
|
|
{
|
|
'key': '/hubs/movie.recentlyAdded',
|
|
'title': 'Recently Added Movies',
|
|
'type': 'movie',
|
|
'hubIdentifier': 'movie.recentlyAdded.1',
|
|
'size': 1,
|
|
'Metadata': [
|
|
{'ratingKey': '1', 'type': 'movie', 'title': 'Movie A'},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
Map<String, dynamic> _continueWatchingPayload() => {
|
|
'MediaContainer': {
|
|
'Hub': [
|
|
{
|
|
'key': '/hubs/home/continueWatching',
|
|
'title': 'Continue Watching',
|
|
'type': 'mixed',
|
|
'hubIdentifier': 'home.continue',
|
|
'size': 1,
|
|
'more': false,
|
|
'Metadata': [
|
|
{'ratingKey': '1', 'type': 'movie', 'title': 'Movie A'},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
Map<String, dynamic> _mediaProvidersPayload() => {
|
|
'MediaContainer': {
|
|
'MediaProvider': [
|
|
{
|
|
'identifier': 'com.plexapp.plugins.library',
|
|
'Feature': [
|
|
{
|
|
'type': 'content',
|
|
'Directory': [
|
|
{'title': 'Home', 'hubKey': '/hubs'},
|
|
{
|
|
'id': '1',
|
|
'key': '/library/sections/1',
|
|
'hubKey': '/hubs/sections/1',
|
|
'type': 'movie',
|
|
'title': 'Movies',
|
|
},
|
|
],
|
|
},
|
|
{'type': 'promoted', 'key': '/hubs/promoted'},
|
|
{'type': 'continuewatching', 'key': '/hubs/continueWatching'},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|