From ae7f4a78717263941fd082258830ca46f69f7eaf Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 12 May 2026 05:14:15 +0200 Subject: [PATCH] fix(jellyfin): unblock large library loading close #1018 --- lib/media/media_server_client.dart | 4 +- .../libraries/tabs/library_browse_tab.dart | 67 +++++++++++++--- .../tabs/library_recommended_tab.dart | 9 ++- lib/services/data_aggregation_service.dart | 1 + .../jellyfin_client/parts/browse.dart | 76 ++++++++++++------- .../jellyfin_client/parts/file_info.dart | 4 +- lib/services/plex_client.dart | 1 + test/services/jellyfin_client_urls_test.dart | 62 ++++++++++++++- 8 files changed, 177 insertions(+), 47 deletions(-) diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index b9e15e90..3de75153 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -208,12 +208,14 @@ abstract class MediaServerClient { /// the title of synthetic hubs (Jellyfin) so per-library "Recently Added" /// / "Next Up" hubs aren't all identically named on the home screen. /// [includePlaybackHubs] lets surfaces that already render Continue - /// Watching skip duplicate playback rows. + /// Watching skip duplicate playback rows. [libraryKind] lets backends avoid + /// irrelevant expensive probes, e.g. Jellyfin `NextUp` for movie libraries. Future> fetchLibraryHubs( String libraryId, { required String libraryName, int limit = 10, bool includePlaybackHubs = true, + MediaKind? libraryKind, }); /// "More like this" recommendations for [id]. diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 08956a16..bee2e6b0 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -6,6 +6,7 @@ import 'package:provider/provider.dart'; import 'package:cached_network_image_ce/cached_network_image.dart'; import '../../../media/library_first_character.dart'; import '../../../media/library_query.dart'; +import '../../../media/media_backend.dart'; import '../../../media/media_item.dart'; import '../../../providers/multi_server_provider.dart'; import '../../../utils/media_server_http_client.dart'; @@ -19,6 +20,7 @@ import '../../../services/image_cache_service.dart'; import '../../../services/library_query_translator.dart'; import '../../../services/plex_constants.dart'; import '../../../utils/error_message_utils.dart'; +import '../../../utils/app_logger.dart'; import '../../../utils/grid_size_calculator.dart'; import '../../../utils/layout_constants.dart'; import '../../../utils/media_image_helper.dart'; @@ -267,10 +269,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState widget.library.backend == MediaBackend.jellyfin; + int get _activeFetchSize => _isJellyfinLibrary ? _jellyfinFetchSize : _fetchSize; + // Focus nodes for filter chips final FocusNode _groupingChipFocusNode = FocusNode(debugLabel: 'grouping_chip'); final FocusNode _filtersChipFocusNode = FocusNode(debugLabel: 'filters_chip'); @@ -445,18 +451,29 @@ class _LibraryBrowseTabState extends BaseLibraryTabState client); try { - // Filters+sorts must resolve before items so the saved-sort restoration - // can match a saved key against the just-loaded sort list, and so the - // first item fetch already includes the restored sort param. - final loaded = await loader.load(widget.library); final storage = await StorageService.getInstance(); final savedFilters = storage.getLibraryFilters(sectionId: widget.library.globalKey); final savedSort = storage.getLibrarySort(widget.library.globalKey); final savedGrouping = storage.getLibraryGrouping(widget.library.globalKey); + final LoadedFiltersAndSorts loaded; + if (_isJellyfinLibrary) { + // `/Items/Filters` can be much slower than the paged `/Items` browse + // request on large Jellyfin libraries. Load only the local sort list + // before page 1, then fill filter values in the background. + final sorts = await client.fetchSortOptions(widget.library.id, libraryType: widget.library.kind.id); + loaded = LoadedFiltersAndSorts(filters: const [], sorts: sorts); + } else { + // Plex filters+sorts must resolve before items so saved-sort restoration + // can match a saved key against the just-loaded sort list, and so the + // first item fetch already includes the restored sort param. + loaded = await loader.load(widget.library); + } + if (generation != _contentRequestId || !mounted) return; setState(() { _filters = loaded.filters; @@ -480,6 +497,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _filters.isNotEmpty && _selectedGrouping != 'folders'; + bool get _isFiltersChipVisible => + (_filters.isNotEmpty || _selectedFilters.isNotEmpty) && _selectedGrouping != 'folders'; /// Whether the sort chip is visible bool get _isSortChipVisible => _sortOptions.isNotEmpty && _selectedGrouping != 'folders'; diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 3b15b79a..0ffce69f 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -148,7 +148,14 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState[] - : List.of(await client.fetchLibraryHubs(widget.library.id, libraryName: widget.library.title, limit: 12)); + : List.of( + await client.fetchLibraryHubs( + widget.library.id, + libraryName: widget.library.title, + limit: 12, + libraryKind: widget.library.kind, + ), + ); // Move Continue Watching hub to the front if present final cwIndex = hubs.indexWhere(_isContinueWatchingHub); diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 85c0f5a4..753a5007 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -167,6 +167,7 @@ class DataAggregationService { 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); diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 15dcabc2..d17c8a7c 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -12,9 +12,7 @@ List> _itemsArray(Object? data) { } /// Slim field set for grid/list browsing — what the card UI actually -/// renders (title, year, watched badge, episode count for series), -/// plus `MediaSources` so the long-press "Play Version" gate matches -/// Plex's flow (Plex always inlines `Media[]`). +/// renders (title, year, watched badge, episode count for series). /// /// The real Jellyfin web client + Findroid skip explicit `Fields` for /// list calls; we ask for the minimum extras needed to drive the @@ -23,15 +21,12 @@ List> _itemsArray(Object? data) { /// - `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 episode-list rows show their description -/// - `MediaSources` so the context menu can hide `Play Version` when -/// there's nothing to pick (cost: ~40ms per 50-item page) +/// - `Overview` so list rows can show their description /// -/// Heavier fields (`People`, `Genres`, `Tags`, `Studios`, `Taglines`, -/// `ProviderIds`, `Chapters`) stay in [_detailFields] — together they -/// added ~6s to a 100-item Series page on a small home server. -const _browseFields = - 'RecursiveItemCount,ChildCount,UserData,PremiereDate,OriginalTitle,SortName,Overview,MediaSources'; +/// 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'; /// Even slimmer set used by [fetchClientSideEpisodeQueue]. Queue rows /// only need title, thumbnail (`ImageTags['Primary']`), season/episode @@ -45,6 +40,10 @@ const _queueFields = 'UserData'; /// bounded while still returning the full series queue. const _episodeQueuePageSize = 200; +/// `/Items/Filters` is a legacy unpaged endpoint; keep failures isolated from +/// the paged Browse tab so very large libraries can still open. +const _filtersTimeout = Duration(seconds: 8); + /// Full field set for the detail screen and the resume / next-up /// pre-fetch paths. Mirrors what the Jellyfin web detail view requests. const _detailFields = @@ -122,13 +121,8 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { /// recognise it as cached and skip the per-category value fetch. @override Future fetchLibraryFiltersWithValues(String libraryId) async { - final response = await _http.get( - '/Items/Filters', - queryParameters: {'userId': connection.userId, 'ParentId': libraryId}, - ); - throwIfHttpError(response); - final data = response.data; - if (data is! Map) return LibraryFilterResult.empty; + final data = await _safeFetchFilterPayload(libraryId); + if (data == null) return LibraryFilterResult.empty; List stringList(Object? raw) { if (raw is! List) return const []; return raw.whereType().where((s) => s.isNotEmpty).toList(); @@ -169,6 +163,23 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { return LibraryFilterResult(filters: filters, cachedValues: values); } + Future?> _safeFetchFilterPayload(String libraryId) async { + try { + final response = await _http.get( + '/Items/Filters', + queryParameters: {'userId': connection.userId, 'ParentId': libraryId}, + timeout: _filtersTimeout, + ); + throwIfHttpError(response); + final data = response.data; + return data is Map ? data : null; + } on MediaServerHttpException catch (e, st) { + if (!e.isTransient) rethrow; + appLogger.w('JellyfinClient: /Items/Filters timed out (filters disabled)', error: e, stackTrace: st); + return null; + } + } + /// Jellyfin has no `/sorts` listing endpoint, so this returns a hardcoded /// list mirroring the Plex fallback set. Keys are the backend-neutral names /// understood by [JellyfinLibraryQueryTranslator] (`title`, `addedAt`, …); @@ -549,6 +560,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { 'Fields': _browseFields, 'MediaTypes': 'Video', 'Recursive': 'true', + 'EnableTotalRecordCount': 'false', ...jellyfinImageQueryParameters, }), _safeFetchItemsArray('/Shows/NextUp', { @@ -599,6 +611,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { 'Fields': _browseFields, 'MediaTypes': 'Video', 'Recursive': 'true', + 'EnableTotalRecordCount': 'false', ...jellyfinImageQueryParameters, }), _safeFetchItemsArray('/Shows/NextUp', { @@ -648,14 +661,14 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { required String libraryName, int limit = 10, bool includePlaybackHubs = true, + MediaKind? libraryKind, }) async { // Mirror the Jellyfin web client's per-library "Suggestions" tab: // Continue Watching + Next Up (TV libraries) + Recently Added. // // Issued in parallel so the recommended tab loads in one round-trip. - // We probe the library kind first to decide whether to ask for NextUp - // — querying it for a movie library is harmless (returns []), but - // skipping the request keeps the wire chatter tighter. + // When the caller knows the library kind, skip NextUp for movie libraries; + // Jellyfin can otherwise spend time scanning TV state only to return []. final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { 'Limit': limit.toString(), 'ParentId': libraryId, @@ -678,6 +691,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { ].where((h) => h.items.isNotEmpty).toList(); } + final includeNextUp = libraryKind == null || libraryKind == MediaKind.show; final results = await Future.wait([ latestFuture, _safeFetchItemsArray('/UserItems/Resume', { @@ -687,17 +701,20 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { 'Fields': _browseFields, 'MediaTypes': 'Video', 'Recursive': 'true', - ...jellyfinImageQueryParameters, - }), - _safeFetchItemsArray('/Shows/NextUp', { - 'userId': connection.userId, - 'ParentId': libraryId, - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'EnableResumable': 'false', 'EnableTotalRecordCount': 'false', ...jellyfinImageQueryParameters, }), + includeNextUp + ? _safeFetchItemsArray('/Shows/NextUp', { + 'userId': connection.userId, + 'ParentId': libraryId, + 'Limit': limit.toString(), + 'Fields': _browseFields, + 'EnableResumable': 'false', + 'EnableTotalRecordCount': 'false', + ...jellyfinImageQueryParameters, + }) + : Future.value(const >[]), ]); return [ @@ -762,6 +779,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { 'Limit': effectiveLimit, 'Fields': _browseFields, 'Recursive': 'true', + 'EnableTotalRecordCount': 'false', if (parentId != null) 'ParentId': parentId else 'MediaTypes': 'Video', ...jellyfinImageQueryParameters, }); diff --git a/lib/services/jellyfin_client/parts/file_info.dart b/lib/services/jellyfin_client/parts/file_info.dart index 46bd6940..ea6c30e6 100644 --- a/lib/services/jellyfin_client/parts/file_info.dart +++ b/lib/services/jellyfin_client/parts/file_info.dart @@ -3,8 +3,8 @@ part of '../../jellyfin_client.dart'; mixin _JellyfinFileInfoMethods on MediaServerCacheMixin { @override Future getFileInfo(MediaItem item) async { - // Browse responses already include `MediaSources` (see [_browseFields]). - // Re-fetch via [fetchItem] only if the inline data isn't available. + // Lightweight browse responses omit `MediaSources`; detail and some cached + // entries still include them, otherwise fetch the full item on demand. final raw = item.raw is Map ? item.raw as Map : null; Map? itemJson = raw; if (itemJson == null || itemJson['MediaSources'] is! List) { diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 8b657262..a74eaf84 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -3128,6 +3128,7 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements required String libraryName, int limit = 10, bool includePlaybackHubs = true, + MediaKind? libraryKind, }) async { // libraryName is unused: Plex's /hubs/sections/{id} returns hubs already // titled per-library (e.g. "Recently Added in Movies"). diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 6460f330..b33efe0a 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; +import 'package:plezy/media/library_query.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; @@ -736,6 +737,44 @@ void main() { expect(headers['Accept'], 'application/json'); }); + test('fetchLibraryContent sends a bounded paged Items request', () async { + Uri? captured; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((req) async { + captured = req.url; + return http.Response( + jsonEncode({ + 'Items': [ + {'Id': 'movie-1', 'Type': 'Movie', 'Name': 'Movie'}, + ], + 'TotalRecordCount': 123, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + addTearDown(scoped.close); + + final page = await scoped.fetchLibraryContent( + 'lib-1', + const LibraryQuery(kind: MediaKind.movie, offset: 50, limit: 25), + ); + + expect(page.items.single.id, 'movie-1'); + expect(page.totalCount, 123); + expect(captured, isNotNull); + expect(captured!.path, '/Items'); + expect(captured!.queryParameters['ParentId'], 'lib-1'); + expect(captured!.queryParameters['StartIndex'], '50'); + expect(captured!.queryParameters['Limit'], '25'); + expect(captured!.queryParameters['IncludeItemTypes'], 'Movie'); + expect(captured!.queryParameters['Fields'], isNot(contains('MediaSources'))); + expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); + expect(captured!.queryParameters['ImageTypeLimit'], '1'); + }); + test('fetchClientSideEpisodeQueue pages past the first 200 episodes', () async { final starts = []; final pagedClient = JellyfinClient.forTesting( @@ -955,6 +994,7 @@ void main() { expect(resume.queryParameters['Limit'], '3'); 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['ImageTypeLimit'], '1'); final nextUp = requests.singleWhere((uri) => uri.path == '/Shows/NextUp'); @@ -1016,6 +1056,9 @@ void main() { await client.fetchGlobalHubs(limit: 12); + final resume = captured.singleWhere((uri) => uri.path == '/UserItems/Resume'); + expect(resume.queryParameters['EnableTotalRecordCount'], 'false'); + final nextUp = capturedNextUpRequest(); expect(nextUp.queryParameters['userId'], 'user-1'); expect(nextUp.queryParameters['Limit'], '12'); @@ -1050,11 +1093,11 @@ void main() { return JellyfinClient.forTesting(connection: _conn(), httpClient: mock); } - test('library Next Up excludes resumable episodes without date cutoff', () async { + test('show library Next Up excludes resumable episodes without date cutoff', () async { final client = buildClient(); addTearDown(client.close); - await client.fetchLibraryHubs('lib-99', libraryName: 'Movies', limit: 12); + await client.fetchLibraryHubs('lib-99', libraryName: 'Shows', limit: 12, libraryKind: MediaKind.show); final nextUp = captured.singleWhere((uri) => uri.path == '/Shows/NextUp'); expect(nextUp.queryParameters['ParentId'], 'lib-99'); @@ -1067,6 +1110,19 @@ void main() { expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse); }); + test('movie library skips Next Up and disables resume total count', () async { + final client = buildClient(); + addTearDown(client.close); + + await client.fetchLibraryHubs('lib-99', libraryName: 'Movies', limit: 12, libraryKind: MediaKind.movie); + + expect(captured.where((uri) => uri.path == '/Shows/NextUp'), isEmpty); + final resume = captured.singleWhere((uri) => uri.path == '/UserItems/Resume'); + expect(resume.queryParameters['ParentId'], 'lib-99'); + expect(resume.queryParameters['Limit'], '12'); + expect(resume.queryParameters['EnableTotalRecordCount'], 'false'); + }); + test('can skip library playback hubs', () async { final client = buildClient(); addTearDown(client.close); @@ -1115,6 +1171,7 @@ void main() { expect(captured!.queryParameters['Limit'], '50'); expect(captured!.queryParameters['MediaTypes'], 'Video'); expect(captured!.queryParameters['Recursive'], 'true'); + expect(captured!.queryParameters['EnableTotalRecordCount'], 'false'); expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); expect(captured!.queryParameters['ImageTypeLimit'], '1'); expect(captured!.queryParameters.containsKey('ParentId'), isFalse); @@ -1163,6 +1220,7 @@ void main() { expect(captured!.queryParameters['ParentId'], 'lib-99'); expect(captured!.queryParameters['userId'], 'user-1'); expect(captured!.queryParameters['Recursive'], 'true'); + expect(captured!.queryParameters['EnableTotalRecordCount'], 'false'); expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); expect(captured!.queryParameters['ImageTypeLimit'], '1'); client.close();