perf(jellyfin): fetch a detail item once when several callers want it at once

Opening a detail screen issued two identical full-detail GETs for the same
id, concurrently: `_loadFullMetadata` calls `fetchItemWithOnDeck`, and
`_initWatchlistState` calls `fetchExternalIds`, which fetches the same item
purely to read `ProviderIds`. Playback start adds three more for its own id.

Each of those makes the server rebuild the entire dto — `People`, `Chapters`
and `MediaSources` cost a database query apiece and `Trickplay` costs several
plus a filesystem stat — so the duplicate is expensive on both ends.

`fetchItem` now shares an in-flight request per item id. Single-flight only:
once a request settles the next caller re-fetches, so nothing can serve a
stale item.

Measured on a remote Jellyfin server, 12 interleaved show-detail opens per
version: requests 4 -> 3, payload 28.4 KB -> 18.6 KB. Median wall time is
unchanged (1394ms -> 1386ms) because the duplicate ran alongside the first
rather than behind it; this removes duplicated work, not latency.

Two things were tried and rejected because measurement did not support them:
starting `/Shows/NextUp` in parallel with the detail fetch (the requests
contend rather than overlap — NextUp went from 380ms alone to 1395ms beside
it — and it costs a wasted request per movie), and dropping `Trickplay` /
`Chapters` from the detail field set (no measurable effect; both are real
data the playback path reads).

Refs #1784
This commit is contained in:
edde746
2026-08-04 06:39:23 +02:00
parent 74d3af3ae1
commit a759e8b3c6
2 changed files with 62 additions and 1 deletions
@@ -134,6 +134,36 @@ void main() {
await scoped.fetchLibraries();
expect(views, 2);
});
test('concurrent fetchItem calls for one id share a single request', () async {
// Opening a detail screen fires `_loadFullMetadata` and, via
// `_initWatchlistState`, `fetchExternalIds` — both a full-detail GET for
// the same id at the same time. Each makes the server rebuild the whole
// dto (People, Chapters and MediaSources are a DB query apiece).
var detailFetches = 0;
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
if (req.url.path == '/Users/user-1/Items/item-1') detailFetches++;
return http.Response(
jsonEncode({'Id': 'item-1', 'Name': 'Item', 'Type': 'Movie'}),
200,
headers: {'content-type': 'application/json'},
);
}),
);
addTearDown(scoped.close);
final results = await Future.wait([scoped.fetchItem('item-1'), scoped.fetchItem('item-1')]);
expect(detailFetches, 1);
expect(results.map((item) => item?.id), ['item-1', 'item-1']);
// Different ids never share, and a later pass re-fetches — single-flight,
// not a cache, so nothing here can serve a stale item.
await scoped.fetchItem('item-2');
await scoped.fetchItem('item-1');
expect(detailFetches, 2);
});
test('buildDirectStreamUrl includes static flag, api_key, and device id', () {
final url = client.buildDirectStreamUrl('item-99');