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:
edde746
2026-08-04 04:35:06 +02:00
parent 8624c37041
commit 74d3af3ae1
19 changed files with 831 additions and 181 deletions
+59 -45
View File
@@ -453,29 +453,35 @@ class DataAggregationService {
final serverLibraries = libraries?[serverId];
final shouldUseGlobalHubs = useGlobalHubs && client.capabilities.richHubs;
final hubItemLimit = limit ?? defaultHubPreviewLimit;
final hubs = shouldUseGlobalHubs
? [
...await client.fetchGlobalHubs(limit: hubItemLimit, includePlaybackHubs: includePlaybackHubs),
// Plex's promoted/global hub endpoint never includes music
// libraries — append their per-library hubs so music rows
// reach home. No-op (zero extra calls) without a visible
// music library.
...await _fetchLibraryHubsForClient(
client,
limit: hubItemLimit,
hiddenLibraryKeys: hiddenLibraryKeys,
includePlaybackHubs: includePlaybackHubs,
libraries: serverLibraries ?? const [],
kinds: const {MediaKind.artist},
),
]
: await _fetchLibraryHubsForClient(
client,
limit: hubItemLimit,
hiddenLibraryKeys: hiddenLibraryKeys,
includePlaybackHubs: includePlaybackHubs,
libraries: useGlobalHubs ? serverLibraries : null,
);
List<MediaHub> hubs;
if (shouldUseGlobalHubs) {
// Both legs are independent, so start them before awaiting either.
// Spreading `...await a, ...await b` into one list literal evaluates
// them in order, which serialised the music rows behind the global
// hub round trip.
final globalFuture = client.fetchGlobalHubs(limit: hubItemLimit, includePlaybackHubs: includePlaybackHubs);
// Plex's promoted/global hub endpoint never includes music
// libraries — append their per-library hubs so music rows
// reach home. No-op (zero extra calls) without a visible
// music library.
final musicFuture = _fetchLibraryHubsForClient(
client,
limit: hubItemLimit,
hiddenLibraryKeys: hiddenLibraryKeys,
includePlaybackHubs: includePlaybackHubs,
libraries: serverLibraries ?? const [],
kinds: const {MediaKind.artist},
);
hubs = [...await globalFuture, ...await musicFuture];
} else {
hubs = await _fetchLibraryHubsForClient(
client,
limit: hubItemLimit,
hiddenLibraryKeys: hiddenLibraryKeys,
includePlaybackHubs: includePlaybackHubs,
libraries: useGlobalHubs ? serverLibraries : null,
);
}
return _postProcessHubs(hubs, serverId: ServerId(serverId), hiddenLibraryKeys: hiddenLibraryKeys);
},
);
@@ -506,31 +512,39 @@ class DataAggregationService {
return true;
}).toList();
// Sliding window rather than batches of three separated by a barrier: a
// batch waits for its slowest member before the next one starts, so wall
// time was the sum of per-batch maxima and one slow library stalled every
// library queued behind it (#1784). Starting the next request the moment
// any slot frees keeps the same peak concurrency with no head-of-line
// blocking. Results are written back by index so hub order stays the
// library order regardless of completion order.
const concurrency = 3;
final all = <MediaHub>[];
for (var start = 0; start < visible.length; start += concurrency) {
final batch = visible.skip(start).take(concurrency);
final results = await Future.wait(
batch.map((l) async {
try {
return await client.fetchLibraryHubs(
l.id,
libraryName: l.title,
limit: limit,
includePlaybackHubs: includePlaybackHubs,
libraryKind: l.kind,
);
} catch (e, st) {
appLogger.e('Failed to fetch library hubs for ${l.globalKey}', error: e, stackTrace: st);
return <MediaHub>[];
}
}),
);
for (final list in results) {
all.addAll(list);
final results = List<List<MediaHub>>.filled(visible.length, const []);
var next = 0;
Future<void> worker() async {
while (true) {
final index = next++;
if (index >= visible.length) return;
final library = visible[index];
try {
results[index] = await client.fetchLibraryHubs(
library.id,
libraryName: library.title,
limit: limit,
includePlaybackHubs: includePlaybackHubs,
libraryKind: library.kind,
);
} catch (e, st) {
appLogger.e('Failed to fetch library hubs for ${library.globalKey}', error: e, stackTrace: st);
}
}
}
return all;
await Future.wait([for (var i = 0; i < concurrency && i < visible.length; i++) worker()]);
return [for (final list in results) ...list];
}
/// Filter hidden-library items and drop empty hubs.
+5
View File
@@ -177,6 +177,11 @@ class JellyfinClient
baseUrl: connection.baseUrl,
defaultHeaders: headers,
logLabel: 'Jellyfin',
// Same pool tuning Plex uses: the home fan-out issues several concurrent
// requests per pass, and the untuned dart:io default drops idle
// connections after 15s — a fresh TLS handshake per request on a
// high-RTT/CDN link.
usePlexApiClient: true,
prioritizedEndpoints: connection.baseUrls,
onEndpointSwitch: (newBaseUrl, {required persist}) => client._handleEndpointSwitch(newBaseUrl, persist: persist),
onAllEndpointsExhausted: onAllEndpointsExhausted,
+84 -31
View File
@@ -2,23 +2,21 @@ part of '../../jellyfin_client.dart';
String _segment(String value) => Uri.encodeComponent(value);
/// Transport policy for a hub surface: bounded transient retries, no
/// endpoint failover. See `_getItemsResponse`.
typedef _HubRetryPolicy = ({String operation, List<Duration> attemptTimeouts});
/// Transport policy for a hub surface: one whole-request deadline, retries
/// only on immediate connection errors, no endpoint failover. See
/// `_getItemsResponse` and [retryTransientMediaServerCall].
typedef _HubRetryPolicy = ({String operation, Duration deadline});
const _HubRetryPolicy _homeHubRetry = (
operation: 'Jellyfin home hubs',
attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts,
);
const _HubRetryPolicy _homeHubRetry = (operation: 'Jellyfin home hubs', deadline: MediaServerTimeouts.homeHubDeadline);
const _HubRetryPolicy _libraryHubRetry = (
operation: 'Jellyfin library hubs',
attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts,
deadline: MediaServerTimeouts.libraryHubDeadline,
);
const _HubRetryPolicy _continueWatchingRetry = (
operation: 'Jellyfin continue watching',
attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts,
deadline: MediaServerTimeouts.homeHubDeadline,
);
List<Map<String, dynamic>> _itemsArray(Object? data) {
@@ -58,15 +56,47 @@ LibraryPage<T> _pagedItems<T>(
/// list calls; we ask for the minimum extras needed to drive the
/// MediaItem mapper:
/// - `RecursiveItemCount`/`ChildCount` for series leaf count
/// - `UserData` is included in defaults but pinned for safety
/// - `PremiereDate` for sort-by-release-date and episode metadata
/// - `OriginalTitle`/`SortName` for sort + alphabetised display
/// - `Overview` so list rows can show their description
///
/// Heavier fields (`MediaSources`, `People`, `Genres`, `Tags`, `Studios`,
/// `Taglines`, `ProviderIds`, `Chapters`) stay in [_detailFields] — together
/// they added seconds to large-library pages on small home servers.
const _browseFields = 'RecursiveItemCount,ChildCount,UserData,PremiereDate,OriginalTitle,SortName,Overview';
///
/// `UserData` and `PremiereDate` are deliberately absent: neither is a member
/// of Jellyfin's `ItemFields` enum, so the server's
/// `CommaDelimitedCollectionModelBinder` drops them element-by-element and
/// they never did anything. `UserData` is governed by `EnableUserData`
/// (default true) and `dto.PremiereDate` is set unconditionally.
const _browseFields = 'RecursiveItemCount,ChildCount,OriginalTitle,SortName,Overview';
/// Field set for the home / per-library hub rows (Recently Added, Continue
/// Watching, Next Up). Poster cards render artwork, title, year and the
/// watch badge; only `Overview` needs asking for, to feed the mobile hero
/// (`discover_screen`) and the TV spotlight blurb.
///
/// Crucially this drops `RecursiveItemCount`/`ChildCount`. `/Items/Latest`
/// groups a TV library by series, so those rows are Series FOLDER dtos and
/// each count field costs a per-row DB query server-side
/// (`Folder.GetRecursiveChildCount` / `Folder.GetChildCount`) — the same cost
/// already documented on [_folderBrowseFields] and [_musicAlbumRowFields],
/// which the home rows never got (#1784).
///
/// Watch state survives intact: Jellyfin computes `UserData.Played` from
/// `UnplayedItemCount` when `RecursiveItemCount` is absent
/// (`Folder.FillUserDataDtoValues`), and [MediaItem.unwatchedCount] falls back
/// to `UserData.UnplayedItemCount`. Only the season progress bar needs real
/// leaf totals, and seasons never appear on a hub row.
const _hubRowFields = 'Overview';
/// How far back `/Shows/NextUp` looks for a series to resume, mirroring
/// Jellyfin web's `maxDaysForNextUp` default. Without it the server's
/// `GetNextUpSeriesKeys` scan is unbounded over every series the user has ever
/// played an episode of.
const _nextUpDateCutoffDays = 365;
String _nextUpDateCutoff() =>
DateTime.now().toUtc().subtract(const Duration(days: _nextUpDateCutoffDays)).toIso8601String();
/// Existing episode-row requests can show Plex-style quality labels when the
/// response includes `MediaSources`. Keep this off broad library/search/latest
@@ -213,11 +243,32 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
/// `_providerLibraries`.
List<MediaLibrary>? _loadedLibraryViews;
/// In-flight `/Views` request, shared by concurrent callers.
///
/// At cold start `LibrariesProvider.loadLibraries()` and
/// `DataAggregationService.getHubsFromAllServers` both ask for libraries at
/// the same time, and the hub fan-out sits *serially* behind its copy. Two
/// identical uncached round trips is a full RTT of pure cold-start latency on
/// a remote server (#1784). Plex has never paid it — its library list comes
/// from the `/media/providers` response cached at client creation.
///
/// Single-flight only: once the request settles the next caller re-fetches,
/// so a library added server-side still shows up on the next refresh.
Future<List<MediaLibrary>>? _inFlightLibraries;
@override
Future<List<MediaLibrary>> fetchLibraries() async {
final libraries = await _fetchLibraries();
_loadedLibraryViews = libraries;
return libraries;
Future<List<MediaLibrary>> fetchLibraries() {
final inFlight = _inFlightLibraries;
if (inFlight != null) return inFlight;
final request = _fetchLibraries().then((libraries) {
_loadedLibraryViews = libraries;
return libraries;
});
_inFlightLibraries = request;
return request.whenComplete(() {
if (identical(_inFlightLibraries, request)) _inFlightLibraries = null;
});
}
/// [abort] tears the view fetch down with the pass that owns it — a
@@ -1414,7 +1465,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
_fetchItemsArray('/UserItems/Resume', {
'userId': connection.userId,
'Limit': ?count?.toString(),
'Fields': _browseFields,
'Fields': _hubRowFields,
'MediaTypes': 'Video',
'Recursive': 'true',
'EnableTotalRecordCount': 'false',
@@ -1423,8 +1474,9 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
_safeFetchItemsArray('/Shows/NextUp', {
'userId': connection.userId,
'Limit': ?count?.toString(),
'Fields': _browseFields,
'Fields': _hubRowFields,
'EnableResumable': 'false',
'NextUpDateCutoff': _nextUpDateCutoff(),
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
}, retry: _continueWatchingRetry),
@@ -1519,7 +1571,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', {
'Limit': limit.toString(),
'ParentId': ?parentId,
'Fields': _browseFields,
'Fields': _hubRowFields,
'IncludeItemTypes': ?latestItemTypes,
...jellyfinImageQueryParameters,
}, retry: retry);
@@ -1547,7 +1599,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
'userId': connection.userId,
'ParentId': ?parentId,
'Limit': limit.toString(),
'Fields': _browseFields,
'Fields': _hubRowFields,
'MediaTypes': 'Video',
'Recursive': 'true',
'EnableTotalRecordCount': 'false',
@@ -1558,8 +1610,9 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
'userId': connection.userId,
'ParentId': ?parentId,
'Limit': limit.toString(),
'Fields': _browseFields,
'Fields': _hubRowFields,
'EnableResumable': 'false',
'NextUpDateCutoff': _nextUpDateCutoff(),
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
}, retry: retry)
@@ -1701,7 +1754,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
'IncludeItemTypes': 'Movie,Series,Episode,Video,MusicVideo,Photo',
'SortBy': 'DateCreated,SortName,ProductionYear',
'SortOrder': 'Descending,Descending,Descending',
'Fields': _browseFields,
'Fields': _hubRowFields,
...jellyfinImageQueryParameters,
},
offset: offset,
@@ -1732,7 +1785,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
'userId': connection.userId,
'StartIndex': offset.toString(),
'Limit': effectiveLimit,
'Fields': _browseFields,
'Fields': _hubRowFields,
'Recursive': 'true',
'EnableTotalRecordCount': 'true',
if (parentId != null) 'ParentId': parentId else 'MediaTypes': 'Video',
@@ -1749,9 +1802,10 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
'userId': connection.userId,
'StartIndex': offset.toString(),
'Limit': effectiveLimit,
'Fields': _browseFields,
'Fields': _hubRowFields,
'ParentId': ?parentId,
'EnableResumable': 'false',
'NextUpDateCutoff': _nextUpDateCutoff(),
'EnableTotalRecordCount': 'true',
...jellyfinImageQueryParameters,
},
@@ -2036,14 +2090,13 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
}
/// GET [path], optionally under a hub-surface transport policy ([retry]):
/// bounded transient retries with per-attempt timeouts and **no endpoint
/// failover** — a slow hub row must not move the whole client off an
/// otherwise working endpoint (same policy as Plex's three hub fetches;
/// see [retryTransientMediaServerCall] / [FailoverHttpClient]).
/// one whole-request deadline, retries only on immediate connection errors,
/// and **no endpoint failover** — a slow hub row must not move the whole
/// client off an otherwise working endpoint (same policy as Plex's three hub
/// fetches; see [retryTransientMediaServerCall] / [FailoverHttpClient]).
///
/// [timeout] and [allowEndpointFailover] configure the un-retried path only; a
/// [retry] policy carries its own per-attempt timeouts and always disables
/// failover.
/// [retry] policy carries its own deadline and always disables failover.
Future<MediaServerResponse> _getItemsResponse(
String path,
Map<String, dynamic> queryParameters,
@@ -2064,7 +2117,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
abort?.throwIfAborted();
return retryTransientMediaServerCall(
operation: retry.operation,
attemptTimeouts: retry.attemptTimeouts,
deadline: retry.deadline,
call: (timeout, attemptAbort) => _http.get(
path,
queryParameters: queryParameters,
+6 -1
View File
@@ -5,8 +5,13 @@ import 'plex_constants.dart';
/// Browse responses retain up to three backdrops so hero surfaces can rotate
/// artwork without allowing image-tag payloads to grow without bound.
const jellyfinBackdropImageLimit = 3;
/// `Thumb` is deliberately absent: `JellyfinMappers` never reads
/// `ImageTags['Thumb']`, and `parentThumbPath`/`grandparentThumbPath` are built
/// from the season/series *Primary* tags. Asking for it added a dead image type
/// to ~40 requests and widened the server's inherited-image parent walk.
const jellyfinImageQueryParameters = <String, String>{
'EnableImageTypes': 'Primary,Backdrop,Thumb,Logo',
'EnableImageTypes': 'Primary,Backdrop,Logo',
'ImageTypeLimit': '$jellyfinBackdropImageLimit',
};
+6 -6
View File
@@ -1564,7 +1564,7 @@ class PlexClient
final response = await retryTransientMediaServerCall(
operation: 'Plex continue watching hubs',
attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts,
deadline: MediaServerTimeouts.homeHubDeadline,
call: (timeout, abort) => _getWithFailover(
continueWatchingHubKey ?? '/hubs',
queryParameters: queryParameters,
@@ -2244,7 +2244,7 @@ class PlexClient
required String path,
required Map<String, dynamic> queryParameters,
required String operation,
required List<Duration> attemptTimeouts,
required Duration deadline,
required String failureLabel,
int? librarySectionID,
String? librarySectionTitle,
@@ -2253,7 +2253,7 @@ class PlexClient
try {
final response = await retryTransientMediaServerCall(
operation: operation,
attemptTimeouts: attemptTimeouts,
deadline: deadline,
call: (timeout, abort) => _getWithFailover(
path,
queryParameters: queryParameters,
@@ -2291,7 +2291,7 @@ class PlexClient
path: '/hubs/sections/$sectionId',
queryParameters: {'count': limit, 'includeGuids': 1},
operation: 'Plex library hubs',
attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts,
deadline: MediaServerTimeouts.libraryHubDeadline,
failureLabel: 'library hubs',
librarySectionID: _librarySectionIdFromString(sectionId),
librarySectionTitle: libraryName,
@@ -2305,7 +2305,7 @@ class PlexClient
path: _providerPromotedHubKey ?? _providerHomeHubKey ?? '/hubs',
queryParameters: {'count': limit, 'includeGuids': 1},
operation: 'Plex global hubs',
attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts,
deadline: MediaServerTimeouts.homeHubDeadline,
failureLabel: 'global hubs',
);
@@ -2314,7 +2314,7 @@ class PlexClient
path: '/hubs/metadata/$ratingKey/related',
queryParameters: {'count': count},
operation: 'Plex related hubs',
attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts,
deadline: MediaServerTimeouts.libraryHubDeadline,
failureLabel: 'related hubs',
filter: _videoOrCollectionHubItem,
);