perf(home): load the home screen once instead of twice per cold start
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
This commit is contained in:
@@ -102,6 +102,38 @@ void main() {
|
||||
tearDown(() {
|
||||
client.close();
|
||||
});
|
||||
test('concurrent fetchLibraries calls share one /Views request', () async {
|
||||
// At cold start LibrariesProvider.loadLibraries() and
|
||||
// DataAggregationService.getHubsFromAllServers ask for libraries at the
|
||||
// same time, and the hub fan-out waits serially behind its copy. Two
|
||||
// identical round trips was a full RTT of dead cold-start latency (#1784).
|
||||
var views = 0;
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((req) async {
|
||||
if (req.url.path == '/Users/user-1/Views') views++;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Items': [
|
||||
{'Id': 'lib-1', 'Name': 'Movies', 'CollectionType': 'movies'},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
addTearDown(scoped.close);
|
||||
|
||||
final results = await Future.wait([scoped.fetchLibraries(), scoped.fetchLibraries()]);
|
||||
|
||||
expect(views, 1);
|
||||
expect(results.map((libs) => libs.single.id), ['lib-1', 'lib-1']);
|
||||
|
||||
// Single-flight, not a cache: a later pass still sees server-side changes.
|
||||
await scoped.fetchLibraries();
|
||||
expect(views, 2);
|
||||
});
|
||||
|
||||
test('buildDirectStreamUrl includes static flag, api_key, and device id', () {
|
||||
final url = client.buildDirectStreamUrl('item-99');
|
||||
@@ -264,7 +296,7 @@ void main() {
|
||||
'/Items/$encodedItemId/SpecialFeatures',
|
||||
});
|
||||
expect(requests.every((uri) => uri.queryParameters['userId'] == 'user-1'), isTrue);
|
||||
expect(requests.every((uri) => uri.queryParameters['EnableImageTypes'] == 'Primary,Backdrop,Thumb,Logo'), isTrue);
|
||||
expect(requests.every((uri) => uri.queryParameters['EnableImageTypes'] == 'Primary,Backdrop,Logo'), isTrue);
|
||||
expect(requests.every((uri) => uri.queryParameters['ImageTypeLimit'] == '3'), isTrue);
|
||||
expect(extras.map((item) => item.id).toList(), ['trailer-1', 'featurette-1']);
|
||||
expect(extras.every((item) => item.kind.isVideo), isTrue);
|
||||
@@ -2291,7 +2323,7 @@ void main() {
|
||||
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
|
||||
expect(captured!.queryParameters['IncludeItemTypes'], 'Movie');
|
||||
expect(captured!.queryParameters['Fields'], isNot(contains('MediaSources')));
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
|
||||
expect(captured!.queryParameters['ImageTypeLimit'], '3');
|
||||
});
|
||||
|
||||
@@ -2617,7 +2649,7 @@ void main() {
|
||||
expect(captured!.queryParameters['SortBy'], 'PremiereDate,ProductionYear,SortName');
|
||||
expect(captured!.queryParameters['SortOrder'], 'Descending,Descending,Ascending');
|
||||
expect(captured!.queryParameters['CollapseBoxSetItems'], 'false');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
|
||||
expect(captured!.queryParameters['ImageTypeLimit'], '3');
|
||||
});
|
||||
|
||||
@@ -2643,7 +2675,7 @@ void main() {
|
||||
expect(capturedNextUp, isNotNull);
|
||||
expect(capturedNextUp!.queryParameters['seriesId'], 'show-1');
|
||||
expect(capturedNextUp!.queryParameters['Limit'], '1');
|
||||
expect(capturedNextUp!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(capturedNextUp!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
|
||||
expect(capturedNextUp!.queryParameters['ImageTypeLimit'], '3');
|
||||
expect(capturedNextUp!.queryParameters.containsKey('EnableResumable'), isFalse);
|
||||
expect(capturedNextUp!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
|
||||
@@ -2747,16 +2779,21 @@ void main() {
|
||||
expect(resume.queryParameters['MediaTypes'], 'Video');
|
||||
expect(resume.queryParameters['Recursive'], 'true');
|
||||
expect(resume.queryParameters['EnableTotalRecordCount'], 'false');
|
||||
expect(resume.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(resume.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
|
||||
expect(resume.queryParameters['ImageTypeLimit'], '3');
|
||||
final nextUp = requests.singleWhere((uri) => uri.path == '/Shows/NextUp');
|
||||
expect(nextUp.queryParameters['userId'], 'user-1');
|
||||
expect(nextUp.queryParameters['Limit'], '3');
|
||||
expect(nextUp.queryParameters['EnableResumable'], 'false');
|
||||
expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false');
|
||||
expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
|
||||
expect(nextUp.queryParameters['ImageTypeLimit'], '3');
|
||||
expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
|
||||
// Bounds the server's unbounded GetNextUpSeriesKeys scan (#1784).
|
||||
expect(
|
||||
DateTime.parse(nextUp.queryParameters['NextUpDateCutoff']!),
|
||||
isNot(null),
|
||||
reason: 'must be a parseable ISO-8601 instant',
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchContinueWatching orders a recently watched series Next Up above an older resume item', () async {
|
||||
@@ -3226,7 +3263,7 @@ void main() {
|
||||
expect(hubs.single.more, isTrue);
|
||||
});
|
||||
|
||||
test('global Next Up excludes resumable episodes without date cutoff', () async {
|
||||
test('global Next Up excludes resumable episodes and bounds the server scan with a date cutoff', () async {
|
||||
final client = buildClient();
|
||||
addTearDown(client.close);
|
||||
|
||||
@@ -3240,9 +3277,9 @@ void main() {
|
||||
expect(nextUp.queryParameters['Limit'], '12');
|
||||
expect(nextUp.queryParameters['EnableResumable'], 'false');
|
||||
expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false');
|
||||
expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
|
||||
expect(nextUp.queryParameters['ImageTypeLimit'], '3');
|
||||
expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
|
||||
expect(nextUp.queryParameters['NextUpDateCutoff'], isNotNull, reason: 'bounds the server-side scan (#1784)');
|
||||
});
|
||||
|
||||
test('can skip global playback hubs', () async {
|
||||
@@ -3269,7 +3306,7 @@ void main() {
|
||||
return JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
|
||||
}
|
||||
|
||||
test('show library Next Up excludes resumable episodes without date cutoff', () async {
|
||||
test('show library Next Up excludes resumable episodes and bounds the server scan with a date cutoff', () async {
|
||||
final client = buildClient();
|
||||
addTearDown(client.close);
|
||||
|
||||
@@ -3281,9 +3318,9 @@ void main() {
|
||||
expect(nextUp.queryParameters['Limit'], '12');
|
||||
expect(nextUp.queryParameters['EnableResumable'], 'false');
|
||||
expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false');
|
||||
expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
|
||||
expect(nextUp.queryParameters['ImageTypeLimit'], '3');
|
||||
expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
|
||||
expect(nextUp.queryParameters['NextUpDateCutoff'], isNotNull, reason: 'bounds the server-side scan (#1784)');
|
||||
});
|
||||
|
||||
test('movie library skips Next Up and disables resume total count', () async {
|
||||
@@ -3337,7 +3374,7 @@ void main() {
|
||||
expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode,Video,MusicVideo,Photo');
|
||||
expect(captured!.queryParameters['SortBy'], 'DateCreated,SortName,ProductionYear');
|
||||
expect(captured!.queryParameters['SortOrder'], 'Descending,Descending,Descending');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
|
||||
expect(captured!.queryParameters['ImageTypeLimit'], '3');
|
||||
expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
|
||||
client.close();
|
||||
@@ -3355,7 +3392,7 @@ void main() {
|
||||
expect(captured!.queryParameters['MediaTypes'], 'Video');
|
||||
expect(captured!.queryParameters['Recursive'], 'true');
|
||||
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
|
||||
expect(captured!.queryParameters['ImageTypeLimit'], '3');
|
||||
expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
|
||||
client.close();
|
||||
@@ -3373,9 +3410,9 @@ void main() {
|
||||
expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
|
||||
expect(captured!.queryParameters['EnableResumable'], 'false');
|
||||
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
|
||||
expect(captured!.queryParameters['ImageTypeLimit'], '3');
|
||||
expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
|
||||
expect(captured!.queryParameters['NextUpDateCutoff'], isNotNull, reason: 'bounds the server-side scan (#1784)');
|
||||
client.close();
|
||||
});
|
||||
|
||||
@@ -3392,7 +3429,7 @@ void main() {
|
||||
expect(captured!.queryParameters['Recursive'], 'true');
|
||||
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
|
||||
expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode,Video,MusicVideo,Photo');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
|
||||
expect(captured!.queryParameters['ImageTypeLimit'], '3');
|
||||
client.close();
|
||||
});
|
||||
@@ -3408,7 +3445,7 @@ void main() {
|
||||
expect(captured!.queryParameters['StartIndex'], '0');
|
||||
expect(captured!.queryParameters['Recursive'], 'true');
|
||||
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
|
||||
expect(captured!.queryParameters['ImageTypeLimit'], '3');
|
||||
client.close();
|
||||
});
|
||||
@@ -3424,9 +3461,9 @@ void main() {
|
||||
expect(captured!.queryParameters['StartIndex'], '0');
|
||||
expect(captured!.queryParameters['EnableResumable'], 'false');
|
||||
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
|
||||
expect(captured!.queryParameters['ImageTypeLimit'], '3');
|
||||
expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
|
||||
expect(captured!.queryParameters['NextUpDateCutoff'], isNotNull, reason: 'bounds the server-side scan (#1784)');
|
||||
client.close();
|
||||
});
|
||||
|
||||
@@ -3602,12 +3639,9 @@ void main() {
|
||||
expect(itemsRequest.queryParameters['Limit'], '36');
|
||||
expect(itemsRequest.queryParameters['SortBy'], 'SortName');
|
||||
expect(itemsRequest.queryParameters['SortOrder'], 'Ascending');
|
||||
expect(
|
||||
itemsRequest.queryParameters['Fields'],
|
||||
'RecursiveItemCount,ChildCount,UserData,PremiereDate,OriginalTitle,SortName,Overview',
|
||||
);
|
||||
expect(itemsRequest.queryParameters['Fields'], 'RecursiveItemCount,ChildCount,OriginalTitle,SortName,Overview');
|
||||
expect(itemsRequest.queryParameters.containsKey('EnableTotalRecordCount'), isFalse);
|
||||
expect(itemsRequest.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(itemsRequest.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
|
||||
expect(itemsRequest.queryParameters['ImageTypeLimit'], '3');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user