diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index bfdeb5a9..6167e9fd 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -272,7 +272,18 @@ abstract class MediaServerClient { /// candidate budget; a backend may supplement omitted media categories and /// return more candidates for cross-server ranking. [abort] cancels every /// backend request owned by this search pass. - Future> searchItems(String query, {int limit = 100, AbortController? abort}); + /// + /// [excludedLibraryIds] names server-local libraries the user has hidden. A + /// backend whose search rows carry a library id may ignore it, because the + /// caller drops those rows by id. A backend whose rows cannot be attributed + /// to a library MUST scope the request server-side instead — the caller has + /// nothing to filter on. + Future> searchItems( + String query, { + int limit = 100, + AbortController? abort, + Set excludedLibraryIds = const {}, + }); /// Items the user has started but not finished. Plex calls this "On Deck" /// internally; the neutral name matches the Continue Watching UI surface. diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 8f418939..5352f3f0 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -68,6 +68,19 @@ List _withoutHiddenLibraries(List items, Set? hidd }).toList(); } +/// The server-local library ids [serverId] owns within [hiddenLibraryKeys], +/// which hold cross-server `serverId:libraryId` global keys. Backends that +/// cannot attribute a search hit to a library need these to scope the request. +Set _hiddenLibraryIdsOn(String serverId, Set? hiddenLibraryKeys) { + if (hiddenLibraryKeys == null || hiddenLibraryKeys.isEmpty) return const {}; + final ids = {}; + for (final key in hiddenLibraryKeys) { + final parsed = parseGlobalKey(key); + if (parsed != null && parsed.serverId == serverId) ids.add(parsed.ratingKey); + } + return ids; +} + /// Cross-server aggregation: fans calls out to every online client and /// merges the results. Single-server operations now go through the /// [MediaServerClient] interface directly (resolved via @@ -581,7 +594,12 @@ class DataAggregationService { failureMessage: (serverId) => 'Search failed on $serverId', fetch: (serverId, client) async { final stopwatch = Stopwatch()..start(); - final items = await client.searchItems(query, limit: fetchLimit, abort: abort); + final items = await client.searchItems( + query, + limit: fetchLimit, + abort: abort, + excludedLibraryIds: _hiddenLibraryIdsOn(serverId, hiddenLibraryKeys), + ); appLogger.i( 'Search completed on $serverId in ${stopwatch.elapsedMilliseconds}ms: ' '${items.length} results ${_searchKindCounts(items)}', diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 2fe24d65..ebd11055 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -73,6 +73,10 @@ const _browseFields = 'RecursiveItemCount,ChildCount,UserData,PremiereDate,Origi /// queries because it is the heaviest item field Jellyfin returns. const _episodeRowFields = '$_browseFields,MediaSources'; +/// Media types global search surfaces. Episodes are included so a user can +/// find a single episode by name. +const _searchItemTypes = 'Movie,Series,Episode,MusicAlbum,Audio'; + /// Folder-tree field set for MEDIA children. The tree renders /// title/thumb/watch state plus default dto fields (year, runtime, ratings); /// it deliberately skips `RecursiveItemCount`/`ChildCount` — per-item COUNT @@ -197,9 +201,30 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { // endpoints. We mirror that exactly so requests hash the same way against // proxy rules and rate limiters as a stock Jellyfin app. + /// Views as of the last load, reused by scoped search. + /// + /// Scoped search runs on every debounced keystroke and `/Views` sits + /// serially in front of its per-library legs, so re-fetching it each pass is + /// pure added latency. `LibrariesProvider` loads libraries before the content + /// tabs refresh (main_screen `_primeOnlineServices` awaits `loadLibraries()`), + /// so this is already warm by the time the user can type, and every later + /// load replaces it — search can never be working from a staler library list + /// than the one on screen. Dies with the client, like Plex's + /// `_providerLibraries`. + List? _loadedLibraryViews; + @override Future> fetchLibraries() async { - final response = await _http.get('/Users/${_segment(connection.userId)}/Views'); + final libraries = await _fetchLibraries(); + _loadedLibraryViews = libraries; + return libraries; + } + + /// [abort] tears the view fetch down with the pass that owns it — a + /// superseded search must not leave `/Views` running. + Future> _fetchLibraries({AbortController? abort}) async { + final response = await _http.get('/Users/${_segment(connection.userId)}/Views', abort: abort); + abort?.throwIfAborted(); throwIfHttpError(response); final items = _itemsArray(response.data); // Jellyfin surfaces the user's collection (BoxSet) and playlist roots as @@ -1084,25 +1109,85 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { } @override - Future> searchItems(String query, {int limit = 100, AbortController? abort}) async { - // Artists come from the dedicated /Artists endpoint: `/Items?SearchTerm=` - // only matches folder-derived MusicArtist rows (under folder names), so - // tag-only artists would never appear in search. The artists leg is - // best-effort — a music-endpoint hiccup shouldn't sink video search. + Future> searchItems( + String query, { + int limit = 100, + AbortController? abort, + Set excludedLibraryIds = const {}, + }) async { + if (excludedLibraryIds.isEmpty) return _searchEverywhere(query, limit: limit, abort: abort); + + // Jellyfin search rows cannot be attributed to a library after the fact: + // there is no library field, and `ParentId` (which this request does not + // even ask for) resolves to a season or physical folder, never the owning + // CollectionFolder. A hidden library can therefore only be excluded by + // scoping the request, one per visible library. + var libraries = _loadedLibraryViews; + if (libraries == null) { + libraries = await _fetchLibraries(abort: abort); + // `??=`, not `=`: an explicit library load that started later can finish + // first, and this older response must not clobber its newer views. + _loadedLibraryViews ??= libraries; + } + abort?.throwIfAborted(); + final visible = [ + for (final library in libraries) + if (!excludedLibraryIds.contains(library.id)) library, + ]; + // Nothing hidden actually belongs to this server — keep the single query. + if (visible.length == libraries.length) return _searchEverywhere(query, limit: limit, abort: abort); + if (visible.isEmpty) return const []; + + // Each leg keeps the full candidate budget. Splitting it would cap a + // library that holds every match (two visible libraries, 100 matches in + // one, would return 50), and the caller's pre-ranking budget guarantee is + // worth more than the payload saved. + const concurrency = 3; + // Legs can overlap: `/Artists` resolves parentId to an *ancestor* filter, + // so an artist with tracks in two visible music libraries comes back from + // both. Ranking does not deduplicate, so the first hit wins here — the + // same merge the Plex search does across its supplemental legs. + final deduplicated = {}; + for (var start = 0; start < visible.length; start += concurrency) { + abort?.throwIfAborted(); + final batch = visible.skip(start).take(concurrency); + final results = await Future.wait([ + for (final library in batch) _searchLibrary(library, query, limit: limit, abort: abort), + ]); + for (final items in results) { + for (final item in items) { + deduplicated.putIfAbsent(item.id, () => item); + } + } + } + return deduplicated.values.toList(); + } + + /// Unscoped search: one request across every library the user can see. + /// + /// Artists come from the dedicated /Artists endpoint: `/Items?SearchTerm=` + /// only matches folder-derived MusicArtist rows (under folder names), so + /// tag-only artists would never appear in search. The artists leg is + /// best-effort — a music-endpoint hiccup shouldn't sink video search. + Future> _searchEverywhere(String query, {required int limit, AbortController? abort}) async { final results = await Future.wait([ _fetchItemsArray('/Items', { 'userId': connection.userId, 'SearchTerm': query, 'Recursive': 'true', 'Limit': limit.toString(), - 'IncludeItemTypes': 'Movie,Series,Episode,MusicAlbum,Audio', + 'IncludeItemTypes': _searchItemTypes, 'Fields': _browseFields, + // Search ranks and trims client-side and never reads the total, which + // the server pays for separately on a broad term. + 'EnableTotalRecordCount': 'false', ...jellyfinImageQueryParameters, }, abort: abort), _safeFetchItemsArray('/Artists', { 'userId': connection.userId, 'searchTerm': query, 'Limit': limit.toString(), + 'EnableTotalRecordCount': 'false', ...jellyfinImageQueryParameters, }, abort: abort), ]); @@ -1110,6 +1195,79 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { return _mapItems([...results.first, ...results[1]]); } + /// Search a single library. Results are stamped with it, which is the only + /// way a Jellyfin search hit ever learns which library it came from. + Future> _searchLibrary( + MediaLibrary library, + String query, { + required int limit, + AbortController? abort, + }) async { + // MusicAlbum is a folder DTO: requesting UserData or count fields makes + // small servers compute recursive unplayed/count data per album (#1552). + // Keep albums and Audio leaves on separate requests so albums can disable + // UserData while tracks retain their cheap direct play-state lookup. + final itemFutures = library.kind == MediaKind.artist + ? >>>[ + _fetchItemsArray('/Items', { + 'userId': connection.userId, + 'SearchTerm': query, + 'Recursive': 'true', + 'Limit': limit.toString(), + 'IncludeItemTypes': 'MusicAlbum', + 'ParentId': library.id, + 'Fields': _musicAlbumRowFields, + 'EnableUserData': 'false', + 'EnableTotalRecordCount': 'false', + ...jellyfinImageQueryParameters, + }, abort: abort), + _fetchItemsArray('/Items', { + 'userId': connection.userId, + 'SearchTerm': query, + 'Recursive': 'true', + 'Limit': limit.toString(), + 'IncludeItemTypes': 'Audio', + 'ParentId': library.id, + 'Fields': _musicTrackRowFields, + 'EnableTotalRecordCount': 'false', + ...jellyfinImageQueryParameters, + }, abort: abort), + ] + : >>>[ + _fetchItemsArray('/Items', { + 'userId': connection.userId, + 'SearchTerm': query, + 'Recursive': 'true', + 'Limit': limit.toString(), + 'IncludeItemTypes': _searchItemTypes, + 'ParentId': library.id, + 'Fields': _browseFields, + 'EnableTotalRecordCount': 'false', + ...jellyfinImageQueryParameters, + }, abort: abort), + ]; + // `/Artists` takes parentId and resolves it to an ancestor filter, but + // only a music library can contain any — asking elsewhere just buys an + // empty response. + final artistsFuture = library.kind == MediaKind.artist + ? _safeFetchItemsArray('/Artists', { + 'userId': connection.userId, + 'searchTerm': query, + 'Limit': limit.toString(), + 'parentId': library.id, + 'EnableTotalRecordCount': 'false', + ...jellyfinImageQueryParameters, + }, abort: abort) + : Future>>.value(const []); + + final results = await Future.wait([...itemFutures, artistsFuture]); + abort?.throwIfAborted(); + return [ + for (final item in _mapItems([for (final result in results) ...result])) + item.copyWith(libraryId: library.id, libraryTitle: library.title), + ]; + } + /// Jellyfin removed `anyProviderIdEquals` (silently ignored on 10.11.10, so /// it returns the unfiltered page), leaving a title search verified against /// each candidate's inline `ProviderIds`. [plexGuid] is a Plex-only hint and diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 3cf3bd3c..11bde7e4 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -3607,8 +3607,16 @@ class PlexClient return getFirstCharacters(libraryId, filters: filters); } + /// [excludedLibraryIds] is unused: `/library/search` has no section-scoping + /// parameter, and every row carries its `librarySectionID`, so the caller + /// filters hidden libraries out of the mapped results. @override - Future> searchItems(String query, {int limit = 100, AbortController? abort}) async { + Future> searchItems( + String query, { + int limit = 100, + AbortController? abort, + Set excludedLibraryIds = const {}, + }) async { final results = await _search(query, limit: limit, abort: abort); return results.map((m) => PlexMappers.mediaItem(m)).toList(); } diff --git a/test/screens/search_screen_test.dart b/test/screens/search_screen_test.dart index e12ec949..7358d3ef 100644 --- a/test/screens/search_screen_test.dart +++ b/test/screens/search_screen_test.dart @@ -561,7 +561,12 @@ class _FakeMediaServerClient implements MediaServerClient { ServerCapabilities get capabilities => ServerCapabilities.plex; @override - Future> searchItems(String query, {int limit = 100, AbortController? abort}) async { + Future> searchItems( + String query, { + int limit = 100, + AbortController? abort, + Set excludedLibraryIds = const {}, + }) async { queries.add(query); lastSearchAbort = abort; abort?.throwIfAborted(); diff --git a/test/services/data_aggregation_bridge_test.dart b/test/services/data_aggregation_bridge_test.dart index 988c5ee3..7340eff1 100644 --- a/test/services/data_aggregation_bridge_test.dart +++ b/test/services/data_aggregation_bridge_test.dart @@ -54,6 +54,7 @@ class _LibrariesClient implements MediaServerClient { final List libraries; final Object? searchError; final List searchResults; + Set? lastExcludedLibraryIds; @override Future> fetchLibraries() async { @@ -62,7 +63,13 @@ class _LibrariesClient implements MediaServerClient { } @override - Future> searchItems(String query, {int limit = 100, AbortController? abort}) async { + Future> searchItems( + String query, { + int limit = 100, + AbortController? abort, + Set excludedLibraryIds = const {}, + }) async { + lastExcludedLibraryIds = excludedLibraryIds; abort?.throwIfAborted(); if (searchError != null) throw searchError!; return searchResults; @@ -242,6 +249,29 @@ void main() { expect(result.items.map((item) => item.id), ['visible-1']); }); + test('each server is told only about its own hidden libraries', () async { + // Global keys are cross-server; a backend that scopes its search needs + // the bare library ids it actually owns, and none of its neighbour's. + final alpha = _LibrariesClient(ServerId('alpha')); + final beta = _LibrariesClient(ServerId('beta')); + manager.debugRegisterClientForTesting(alpha); + manager.debugRegisterClientForTesting(beta); + + await service.searchAcrossServers('Target', hiddenLibraryKeys: {'alpha:1', 'alpha:9', 'beta:1'}); + + expect(alpha.lastExcludedLibraryIds, {'1', '9'}); + expect(beta.lastExcludedLibraryIds, {'1'}); + }); + + test('no hidden libraries passes an empty exclusion set', () async { + final client = _LibrariesClient(ServerId('alpha')); + manager.debugRegisterClientForTesting(client); + + await service.searchAcrossServers('Target'); + + expect(client.lastExcludedLibraryIds, isEmpty); + }); + test('searchAcrossServers overfetches and ranks before trimming across backends', () async { final plexRequests = []; final jellyfinRequests = []; diff --git a/test/services/jellyfin_search_test.dart b/test/services/jellyfin_search_test.dart new file mode 100644 index 00000000..1f225744 --- /dev/null +++ b/test/services/jellyfin_search_test.dart @@ -0,0 +1,447 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/exceptions/media_server_exceptions.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; +import 'package:plezy/services/jellyfin_client.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; + +import '../test_helpers/backend_client_fixtures.dart'; + +http.Response _json(Object body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}); + +/// `/Users/{id}/Views` shape: the id is the CollectionFolder id that +/// `ParentId=` accepts and that hidden-library keys are built from. +Map _view(String id, String name, String collectionType) => { + 'Id': id, + 'Name': name, + 'CollectionType': collectionType, + 'Type': 'CollectionFolder', +}; + +const _views = [ + {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies', 'Type': 'CollectionFolder'}, + {'Id': 'lib-shows', 'Name': 'Shows', 'CollectionType': 'tvshows', 'Type': 'CollectionFolder'}, + {'Id': 'lib-music', 'Name': 'Music', 'CollectionType': 'music', 'Type': 'CollectionFolder'}, +]; + +/// A search hit as Jellyfin actually returns it: no library field of any kind. +Map _hit(String id, String type, String name) => {'Id': id, 'Type': type, 'Name': name}; + +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + JellyfinApiCache.initialize(db); + }); + + tearDown(() async { + await db.close(); + }); + + JellyfinClient makeClient( + List captured, { + Map>> itemsByParent = const {}, + List> views = _views, + }) { + return testJellyfinClient( + httpClient: MockClient((request) async { + captured.add(request.url); + final path = request.url.path; + if (path.endsWith('/Views')) return _json({'Items': views}); + if (path == '/Items') { + final parent = request.url.queryParameters['ParentId'] ?? '*'; + final includedTypes = request.url.queryParameters['IncludeItemTypes']?.split(',').toSet(); + final items = itemsByParent[parent] ?? const >[]; + return _json({ + 'Items': [ + for (final item in items) + if (includedTypes == null || includedTypes.contains(item['Type'])) item, + ], + }); + } + if (path == '/Artists') { + final parent = request.url.queryParameters['parentId'] ?? '*'; + return _json({'Items': itemsByParent['artists:$parent'] ?? const >[]}); + } + fail('Unexpected request: ${request.url}'); + }), + ); + } + + test('with nothing hidden, search stays a single unscoped query', () async { + final captured = []; + final client = makeClient( + captured, + itemsByParent: { + '*': [_hit('movie-1', 'Movie', 'The Movie')], + }, + ); + addTearDown(client.close); + + final results = await client.searchItems('the'); + + expect(results.map((item) => item.id), ['movie-1']); + expect(captured.map((uri) => uri.path), ['/Items', '/Artists']); + expect(captured.first.queryParameters.containsKey('ParentId'), isFalse); + // No library listing is fetched when there is nothing to exclude. + expect(captured.where((uri) => uri.path.endsWith('/Views')), isEmpty); + }); + + test('a hidden library is excluded by scoping one request per visible library', () async { + final captured = []; + final client = makeClient( + captured, + itemsByParent: { + 'lib-movies': [_hit('movie-1', 'Movie', 'The Movie')], + 'lib-shows': [_hit('show-1', 'Series', 'The Show')], + 'lib-music': [_hit('album-1', 'MusicAlbum', 'The Album')], + }, + ); + addTearDown(client.close); + + final results = await client.searchItems('the', excludedLibraryIds: {'lib-shows'}); + + expect(results.map((item) => item.id), ['movie-1', 'album-1']); + // The unscoped query must not run: it would reintroduce the hidden library. + expect(captured.where((uri) => uri.path == '/Items' && !uri.queryParameters.containsKey('ParentId')), isEmpty); + expect(captured.where((uri) => uri.path == '/Items').map((uri) => uri.queryParameters['ParentId']), [ + 'lib-movies', + 'lib-music', + 'lib-music', + ]); + }); + + test('scoped results carry the library they came from', () async { + final captured = []; + final client = makeClient( + captured, + itemsByParent: { + 'lib-movies': [_hit('movie-1', 'Movie', 'The Movie')], + 'lib-music': [_hit('album-1', 'MusicAlbum', 'The Album')], + }, + ); + addTearDown(client.close); + + final results = await client.searchItems('the', excludedLibraryIds: {'lib-shows'}); + + // Jellyfin sends no library field, so this stamp is the only attribution + // these items will ever have — and what the caller filters on. + expect(results.map((item) => item.libraryId), ['lib-movies', 'lib-music']); + expect(results.map((item) => item.libraryTitle), ['Movies', 'Music']); + expect(results.map((item) => item.libraryGlobalKey), ['srv-1:lib-movies', 'srv-1:lib-music']); + }); + + test('only a music library scopes the artists leg', () async { + final captured = []; + final client = makeClient( + captured, + itemsByParent: { + 'artists:lib-music': [_hit('artist-1', 'MusicArtist', 'The Artist')], + }, + ); + addTearDown(client.close); + + final results = await client.searchItems('the', excludedLibraryIds: {'lib-shows'}); + + expect(results.map((item) => item.id), ['artist-1']); + expect(captured.where((uri) => uri.path == '/Artists').map((uri) => uri.queryParameters['parentId']), [ + 'lib-music', + ]); + }); + + test('an artist spanning two music libraries is returned once', () async { + final captured = []; + // /Artists resolves parentId to an ancestor filter, so an artist with + // tracks in both libraries is a genuine hit for both legs. Ranking does + // not deduplicate, so an undeduped merge would render the card twice. + final client = makeClient( + captured, + views: const [ + {'Id': 'lib-shows', 'Name': 'Shows', 'CollectionType': 'tvshows', 'Type': 'CollectionFolder'}, + {'Id': 'lib-music', 'Name': 'Music', 'CollectionType': 'music', 'Type': 'CollectionFolder'}, + {'Id': 'lib-scores', 'Name': 'Soundtracks', 'CollectionType': 'music', 'Type': 'CollectionFolder'}, + ], + itemsByParent: { + 'artists:lib-music': [_hit('artist-1', 'MusicArtist', 'The Artist')], + 'artists:lib-scores': [_hit('artist-1', 'MusicArtist', 'The Artist')], + 'lib-scores': [_hit('album-1', 'MusicAlbum', 'The Album')], + }, + ); + addTearDown(client.close); + + final results = await client.searchItems('the', excludedLibraryIds: {'lib-shows'}); + + expect(results.map((item) => item.id), ['artist-1', 'album-1']); + // First leg wins, so the stamp stays deterministic. + expect(results.first.libraryId, 'lib-music'); + expect(captured.where((uri) => uri.path == '/Artists').map((uri) => uri.queryParameters['parentId']), [ + 'lib-music', + 'lib-scores', + ]); + }); + + test('scoped search reuses the views the library load already fetched', () async { + final captured = []; + final client = makeClient(captured); + addTearDown(client.close); + + // What LibrariesProvider does before the content tabs refresh. + await client.fetchLibraries(); + final afterLoad = captured.length; + + await client.searchItems('the', excludedLibraryIds: {'lib-shows'}); + await client.searchItems('the movie', excludedLibraryIds: {'lib-shows'}); + + // /Views sits serially in front of every leg, so re-fetching it per + // keystroke is pure latency. + expect(captured.sublist(afterLoad).where((uri) => uri.path.endsWith('/Views')), isEmpty); + }); + + test('a later library load is what the next scoped search sees', () async { + final captured = []; + var views = const [ + {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies', 'Type': 'CollectionFolder'}, + {'Id': 'lib-shows', 'Name': 'Shows', 'CollectionType': 'tvshows', 'Type': 'CollectionFolder'}, + ]; + final client = testJellyfinClient( + httpClient: MockClient((request) async { + captured.add(request.url); + if (request.url.path.endsWith('/Views')) return _json({'Items': views}); + return _json({'Items': >[]}); + }), + ); + addTearDown(client.close); + + await client.fetchLibraries(); + views = const [ + {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies', 'Type': 'CollectionFolder'}, + {'Id': 'lib-shows', 'Name': 'Shows', 'CollectionType': 'tvshows', 'Type': 'CollectionFolder'}, + {'Id': 'lib-new', 'Name': 'Documentaries', 'CollectionType': 'movies', 'Type': 'CollectionFolder'}, + ]; + await client.fetchLibraries(); + + captured.clear(); + await client.searchItems('the', excludedLibraryIds: {'lib-shows'}); + + // A library added on the server reaches search as soon as anything + // reloads the list, so search is never staler than the sidebar. + expect(captured.where((uri) => uri.path == '/Items').map((uri) => uri.queryParameters['ParentId']), [ + 'lib-movies', + 'lib-new', + ]); + }); + + test('a slow cold search does not clobber views from a newer library load', () async { + final viewsGate = Completer(); + var viewsServed = 0; + var views = const [ + {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies', 'Type': 'CollectionFolder'}, + {'Id': 'lib-shows', 'Name': 'Shows', 'CollectionType': 'tvshows', 'Type': 'CollectionFolder'}, + ]; + final captured = []; + final client = testJellyfinClient( + httpClient: MockClient((request) async { + captured.add(request.url); + if (request.url.path.endsWith('/Views')) { + viewsServed++; + // Snapshot BEFORE gating: the delayed response must carry the views + // as they were when it was issued, or it cannot be the stale one. + final responseViews = views; + // Hold the search's own (first) view fetch in flight. + if (viewsServed == 1) await viewsGate.future; + return _json({'Items': responseViews}); + } + return _json({'Items': >[]}); + }), + ); + addTearDown(client.close); + + // A cold search starts fetching views... + final search = client.searchItems('the', excludedLibraryIds: {'lib-shows'}); + await pumpEventQueue(); + // ...then a load that started later finishes first, with newer views. + views = const [ + {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies', 'Type': 'CollectionFolder'}, + {'Id': 'lib-shows', 'Name': 'Shows', 'CollectionType': 'tvshows', 'Type': 'CollectionFolder'}, + {'Id': 'lib-new', 'Name': 'Documentaries', 'CollectionType': 'movies', 'Type': 'CollectionFolder'}, + ]; + await client.fetchLibraries(); + viewsGate.complete(); + await search; + + captured.clear(); + await client.searchItems('the', excludedLibraryIds: {'lib-shows'}); + + // The authoritative load must win: had the older in-flight response + // overwritten it, the new library would stay invisible to search. + expect(captured.where((uri) => uri.path == '/Items').map((uri) => uri.queryParameters['ParentId']), [ + 'lib-movies', + 'lib-new', + ]); + }); + + test('one library holding every match still fills the whole budget', () async { + final captured = []; + // All 100 matches live in Movies; Music has none. Splitting the budget + // across legs would hand back 50 and silently halve the result set. + final client = makeClient( + captured, + itemsByParent: { + 'lib-movies': [for (var i = 0; i < 100; i++) _hit('movie-$i', 'Movie', 'The Movie $i')], + }, + ); + addTearDown(client.close); + + final results = await client.searchItems('the', limit: 100, excludedLibraryIds: {'lib-shows'}); + + expect(results, hasLength(100)); + expect(captured.where((uri) => uri.path == '/Items').map((uri) => uri.queryParameters['Limit']), [ + '100', + '100', + '100', + ]); + }); + + test('search never pays for a total it does not read', () async { + final captured = []; + final client = makeClient(captured); + addTearDown(client.close); + + await client.searchItems('the'); + await client.searchItems('the', excludedLibraryIds: {'lib-shows'}); + + final counted = captured + .where((uri) => uri.path == '/Items' || uri.path == '/Artists') + .where((uri) => uri.queryParameters['EnableTotalRecordCount'] != 'false'); + expect(counted, isEmpty); + }); + + test('music album and audio legs use safe field sets without losing either kind', () async { + final captured = []; + final client = makeClient( + captured, + itemsByParent: { + 'lib-music': [_hit('album-1', 'MusicAlbum', 'The Album'), _hit('track-1', 'Audio', 'The Track')], + }, + ); + addTearDown(client.close); + + final results = await client.searchItems('the', excludedLibraryIds: {'lib-shows'}); + + expect(results.map((item) => item.id), ['album-1', 'track-1']); + final musicRequests = captured + .where((uri) => uri.path == '/Items' && uri.queryParameters['ParentId'] == 'lib-music') + .toList(); + final album = musicRequests.singleWhere((uri) => uri.queryParameters['IncludeItemTypes'] == 'MusicAlbum'); + final audio = musicRequests.singleWhere((uri) => uri.queryParameters['IncludeItemTypes'] == 'Audio'); + // Album UserData and count fields trigger recursive per-album work. + expect(album.queryParameters['EnableUserData'], 'false'); + expect(album.queryParameters['Fields'], isNot(contains('UserData'))); + expect(album.queryParameters['Fields'], isNot(contains('RecursiveItemCount'))); + expect(album.queryParameters['Fields'], isNot(contains('ChildCount'))); + // Audio is a leaf, so retaining its direct play-state lookup is cheap. + expect(audio.queryParameters['Fields'], contains('UserData')); + final movies = captured.singleWhere( + (uri) => uri.path == '/Items' && uri.queryParameters['ParentId'] == 'lib-movies', + ); + expect(movies.queryParameters['Fields'], contains('ChildCount')); + }); + + test('excluded ids owned by another server leave the single query in place', () async { + final captured = []; + final client = makeClient( + captured, + itemsByParent: { + '*': [_hit('movie-1', 'Movie', 'The Movie')], + }, + ); + addTearDown(client.close); + + final results = await client.searchItems('the', excludedLibraryIds: {'some-other-servers-library'}); + + expect(results.map((item) => item.id), ['movie-1']); + expect(captured.where((uri) => uri.path == '/Items').single.queryParameters.containsKey('ParentId'), isFalse); + }); + + test('hiding every library returns nothing instead of everything', () async { + final captured = []; + final client = makeClient(captured); + addTearDown(client.close); + + final results = await client.searchItems('the', excludedLibraryIds: {'lib-movies', 'lib-shows', 'lib-music'}); + + expect(results, isEmpty); + expect(captured.where((uri) => uri.path == '/Items'), isEmpty); + expect(captured.where((uri) => uri.path == '/Artists'), isEmpty); + }); + + test('view ids map onto the library ids hidden keys are built from', () async { + final captured = []; + final client = makeClient(captured); + addTearDown(client.close); + + final libraries = await client.fetchLibraries(); + + expect(libraries.map((library) => library.id), ['lib-movies', 'lib-shows', 'lib-music']); + expect(libraries.map((library) => library.globalKey), ['srv-1:lib-movies', 'srv-1:lib-shows', 'srv-1:lib-music']); + expect(_view('lib-movies', 'Movies', 'movies')['Id'], libraries.first.id); + }); + + // MockClient cannot abort a request itself — "it is the handler's + // responsibility to throw RequestAbortedException". MockClient.streaming + // hands over the real AbortableRequest, so honouring its trigger here is + // both the documented contract and proof that the search pass wired its + // controller into /Views: without that, the trigger only fires on client + // teardown and this test would hang instead of completing. + test('aborting mid-flight tears down the view fetch and launches no library legs', () async { + final paths = []; + final viewsEntered = Completer(); + final client = testJellyfinClient( + httpClient: MockClient.streaming((request, _) async { + paths.add(request.url.path); + if (request.url.path.endsWith('/Views')) { + viewsEntered.complete(); + await (request as http.AbortableRequest).abortTrigger!; + throw http.RequestAbortedException(request.url); + } + fail('No library search may start once the pass is cancelled: ${request.url}'); + }), + ); + addTearDown(client.close); + + final abort = AbortController(); + final search = client.searchItems('the', excludedLibraryIds: {'lib-shows'}, abort: abort); + await viewsEntered.future; + abort.abort(); + + await expectLater( + search, + throwsA(isA().having((e) => e.isCancellation, 'isCancellation', isTrue)), + ); + expect(paths.where((path) => path == '/Items'), isEmpty); + }); + + test('a failed view fetch fails the search instead of falling back to an unscoped one', () async { + final paths = []; + final client = testJellyfinClient( + httpClient: MockClient((request) async { + paths.add(request.url.path); + if (request.url.path.endsWith('/Views')) return http.Response('nope', 500); + return _json({'Items': >[]}); + }), + ); + addTearDown(client.close); + + // Falling back to the unscoped query would quietly reintroduce every + // hidden library; the server must be reported as failed instead. + await expectLater(client.searchItems('the', excludedLibraryIds: {'lib-shows'}), throwsA(isA())); + expect(paths.where((path) => path == '/Items'), isEmpty); + }); +}