Files
plezy/lib/services/library_query_translator.dart
edde746 74d3af3ae1 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
2026-08-04 04:35:06 +02:00

326 lines
12 KiB
Dart

import '../media/library_query.dart';
import '../media/media_kind.dart';
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,Logo',
'ImageTypeLimit': '$jellyfinBackdropImageLimit',
};
/// Translates a backend-neutral [LibraryQuery] into the per-backend
/// query-parameter map that the corresponding `/library/sections/{id}/all`
/// (Plex) or `/Items` (Jellyfin) endpoint expects.
///
/// Pulled out of the clients so the translation can be unit-tested without
/// spinning up an HTTP layer, and so the per-backend filter/sort name
/// mappings live in one place.
abstract class LibraryQueryTranslator {
Map<String, dynamic> toQueryParameters(LibraryQuery query);
/// Parse a Plex-style sort string (`field` or `field:desc`) into the
/// backend-neutral [LibrarySort] consumed by the translators. Returns
/// `null` when the input is empty or the field portion is missing.
static LibrarySort? parseSortParam(String? raw) {
if (raw == null || raw.isEmpty) return null;
const descSuffix = ':desc';
const ascSuffix = ':asc';
final descending = raw.endsWith(descSuffix);
final ascending = raw.endsWith(ascSuffix);
final field = descending
? raw.substring(0, raw.length - descSuffix.length)
: ascending
? raw.substring(0, raw.length - ascSuffix.length)
: raw;
if (field.isEmpty) return null;
return LibrarySort(
field: field,
direction: descending ? LibrarySortDirection.descending : LibrarySortDirection.ascending,
);
}
}
/// Plex's `/library/sections/{id}/all` accepts a flat `key=value` map.
/// Numeric `type=` selects the result class (1=movie, 2=show, …);
/// `sort=titleSort:asc` chains field+direction; filters are passed
/// verbatim under their original Plex names.
class PlexLibraryQueryTranslator implements LibraryQueryTranslator {
const PlexLibraryQueryTranslator();
@override
Map<String, String> toQueryParameters(LibraryQuery query) {
final filters = <String, String>{};
if (query.includeKinds.isNotEmpty) {
final kindNumbers = query.includeKinds.map(_plexTypeNumberFor).whereType<int>().join(',');
if (kindNumbers.isNotEmpty) {
filters['type'] = kindNumbers;
}
} else {
final kindNumber = _plexTypeNumberFor(query.kind);
if (kindNumber != null) {
filters['type'] = kindNumber.toString();
}
}
final sort = query.sort;
if (sort != null) {
final dir = sort.direction == LibrarySortDirection.descending ? ':desc' : ':asc';
filters['sort'] = '${sort.field}$dir';
}
if (query.search != null && query.search!.isNotEmpty) {
filters['title'] = query.search!;
}
if (!query.includeWatched) {
filters['unwatched'] = '1';
}
// Typed slots: emit under the Plex API names so a `LibraryQuery` built
// from the FiltersBottomSheet (which still hands the browse tab a
// `Map<String,String>`) round-trips back to the same wire query that the
// legacy `plexStyleFilters` parameter used to carry.
if (query.genres != null && query.genres!.isNotEmpty) {
filters['genre'] = query.genres!.join(',');
}
if (query.officialRatings != null && query.officialRatings!.isNotEmpty) {
filters['contentRating'] = query.officialRatings!.join(',');
}
if (query.years != null && query.years!.isNotEmpty) {
filters['year'] = query.years!.join(',');
}
if (query.tags != null && query.tags!.isNotEmpty) {
filters['tag'] = query.tags!.join(',');
}
if (query.nameStartsWith != null && query.nameStartsWith!.isNotEmpty) {
filters['alphaPrefix'] = query.nameStartsWith!;
}
for (final f in query.filters) {
filters[f.field] = f.values.join(',');
}
return filters;
}
static int? _plexTypeNumberFor(MediaKind? kind) {
if (kind == null) return null;
return switch (kind) {
MediaKind.movie => PlexMetadataType.movie,
MediaKind.show => PlexMetadataType.show,
MediaKind.season => PlexMetadataType.season,
MediaKind.episode => PlexMetadataType.episode,
MediaKind.artist => PlexMetadataType.artist,
MediaKind.album => PlexMetadataType.album,
MediaKind.track => PlexMetadataType.track,
_ => null,
};
}
}
/// Inverse of [PlexLibraryQueryTranslator.toQueryParameters]: build a neutral
/// [LibraryQuery] from the legacy Plex-style `Map<String,String>` filter map.
///
/// Lives here so the round-trip stays in one file and the test that pins
/// equivalence (`map → LibraryQuery → Plex map` byte-for-byte) can import a
/// single symbol.
///
/// Recognised keys map to their typed [LibraryQuery] slots (genre/year/
/// contentRating/tag/unwatched/sort/type/alphaPrefix). Anything else carries
/// over as a generic [LibraryFilter] entry so Plex's verbatim-pass-through
/// behaviour for ad-hoc keys (director, writer, label, …) is preserved.
///
/// `libraryKind` overrides any `type=` entry — both can be sources of truth
/// in the existing browse tab and the explicit argument wins.
LibraryQuery libraryQueryFromPlexMap({
required Map<String, String> map,
MediaKind? libraryKind,
int offset = 0,
int limit = 50,
}) {
const knownKeys = {
'genre',
'year',
'contentRating',
'tag',
'unwatched',
'favorite',
'sort',
'type',
'alphaPrefix',
'includeCollections',
'title',
};
String? nonEmpty(String? raw) => (raw == null || raw.isEmpty) ? null : raw;
// libraryKind has priority; otherwise derive from `type` (single numeric
// value only — multi-value `type` like "1,4" stays in the generic filter
// bucket so Plex still receives it verbatim).
final typeRaw = nonEmpty(map['type']);
final kindFromMap = (typeRaw != null && !typeRaw.contains(',')) ? _plexTypeMediaKind(typeRaw) : null;
final kind = libraryKind ?? kindFromMap;
final unknownFilters = <LibraryFilter>[];
for (final entry in map.entries) {
if (knownKeys.contains(entry.key) || entry.value.isEmpty) continue;
unknownFilters.add(LibraryFilter(field: entry.key, values: entry.value.split(',')));
}
// Multi-value `type` couldn't fold into `kind`; preserve it as a generic
// filter entry so Plex still gets it on the wire.
if (typeRaw != null && typeRaw.contains(',')) {
unknownFilters.add(LibraryFilter(field: 'type', values: typeRaw.split(',')));
}
List<String>? singleton(String? raw) => raw == null ? null : [raw];
final yearRaw = nonEmpty(map['year']);
final years = yearRaw?.split(',').map(int.tryParse).whereType<int>().toList();
return LibraryQuery(
kind: (kind == null || kind == MediaKind.unknown) ? null : kind,
offset: offset,
limit: limit,
includeWatched: nonEmpty(map['unwatched']) != '1',
favoritesOnly: nonEmpty(map['favorite']) == '1',
nameStartsWith: nonEmpty(map['alphaPrefix']),
search: nonEmpty(map['title']),
genres: singleton(nonEmpty(map['genre'])),
officialRatings: singleton(nonEmpty(map['contentRating'])),
tags: singleton(nonEmpty(map['tag'])),
years: (years == null || years.isEmpty) ? null : years,
sort: LibraryQueryTranslator.parseSortParam(nonEmpty(map['sort'])),
filters: unknownFilters,
);
}
MediaKind? _plexTypeMediaKind(String typeNumber) {
return switch (typeNumber) {
'1' => MediaKind.movie,
'2' => MediaKind.show,
'3' => MediaKind.season,
'4' => MediaKind.episode,
'8' => MediaKind.artist,
'9' => MediaKind.album,
'10' => MediaKind.track,
_ => null,
};
}
/// Jellyfin's `/Items` accepts a richer parameter set with separate keys
/// for filters (`Genres`, `OfficialRatings`, `Tags`, `Years`), sort
/// (`SortBy`/`SortOrder`), pagination (`StartIndex`/`Limit`), and
/// item-type narrowing (`IncludeItemTypes`).
///
/// The translator needs the calling user's id (every Jellyfin browse
/// query is user-scoped) and the parent library id; both are passed in
/// at construction time so the resulting map round-trips through
/// `_http.get('/Items', queryParameters: ...)` without further mutation.
class JellyfinLibraryQueryTranslator implements LibraryQueryTranslator {
final String userId;
final String parentId;
final String fields;
const JellyfinLibraryQueryTranslator({required this.userId, required this.parentId, required this.fields});
@override
Map<String, dynamic> toQueryParameters(LibraryQuery query) {
final params = <String, dynamic>{
'userId': userId,
'ParentId': parentId,
'Recursive': 'true',
'StartIndex': query.offset.toString(),
'Limit': query.limit.toString(),
'EnableTotalRecordCount': 'true',
'IncludeItemTypes': _includeTypesFor(query),
'Fields': fields,
...jellyfinImageQueryParameters,
};
final wireFilters = <String>[if (!query.includeWatched) 'IsUnplayed', if (query.favoritesOnly) 'IsFavorite'];
if (wireFilters.isNotEmpty) {
params['Filters'] = wireFilters.join(',');
}
if (query.genres != null && query.genres!.isNotEmpty) {
// Jellyfin uses `|` as the multi-value separator for Genres.
params['Genres'] = query.genres!.join('|');
}
if (query.officialRatings != null && query.officialRatings!.isNotEmpty) {
params['OfficialRatings'] = query.officialRatings!.join('|');
}
if (query.years != null && query.years!.isNotEmpty) {
params['Years'] = query.years!.join(',');
}
if (query.tags != null && query.tags!.isNotEmpty) {
params['Tags'] = query.tags!.join('|');
}
final sort = query.sort;
if (sort != null) {
params['SortBy'] = _sortFieldFor(sort.field, query.kind);
params['SortOrder'] = sort.direction == LibrarySortDirection.descending ? 'Descending' : 'Ascending';
}
if (query.search != null && query.search!.isNotEmpty) {
params['SearchTerm'] = query.search;
}
final prefix = query.nameStartsWith;
if (prefix != null && prefix.isNotEmpty) {
// `#` is the alpha-bar sentinel for "non-alphabetic" — match the JF
// web client by asking for everything sorted before "A".
if (prefix == '#') {
params['NameLessThan'] = 'A';
} else {
params['NameStartsWith'] = prefix;
}
}
return params;
}
static String _includeTypesFor(LibraryQuery query) {
if (query.includeKinds.isNotEmpty) {
return query.includeKinds.map(_includeTypesForKind).join(',');
}
return _includeTypesForKind(query.kind);
}
static String _includeTypesForKind(MediaKind? kind) {
return switch (kind) {
MediaKind.movie => 'Movie',
MediaKind.show => 'Series',
MediaKind.season => 'Season',
MediaKind.episode => 'Episode',
MediaKind.artist => 'MusicArtist',
MediaKind.album => 'MusicAlbum',
MediaKind.track => 'Audio',
MediaKind.collection => 'BoxSet',
MediaKind.playlist => 'Playlist',
MediaKind.clip => 'Video,MusicVideo',
MediaKind.photo => 'Photo',
_ => 'Movie,Series,Episode,Audio',
};
}
static String _sortFieldFor(String neutral, MediaKind? kind) {
return switch (neutral) {
'addedAt' => 'DateCreated',
'episode.addedAt' => 'DateLastContentAdded,SortName',
'dateCreated' => 'DateCreated',
'originallyAvailableAt' => 'PremiereDate',
'premiereDate' => 'PremiereDate',
'lastViewedAt' || 'datePlayed' => kind == MediaKind.show ? 'SeriesDatePlayed' : 'DatePlayed',
'title' => 'SortName',
'name' => 'SortName',
'rating' || 'communityRating' => 'CommunityRating',
'viewCount' || 'playCount' => 'PlayCount',
'productionYear' => 'ProductionYear',
'runtime' => 'Runtime',
'officialRating' => 'OfficialRating',
'criticRating' => 'CriticRating',
'startDate' => 'StartDate',
'airTime' => 'AirTime',
'studio' => 'Studio',
'random' => 'Random',
_ => neutral,
};
}
}