Files
plezy/test/services/plex_search_test.dart
T
edde746 1d9ffb7427 fix(search): exclude hidden libraries from global search results
searchAcrossServers was the only aggregation entry point without a
hiddenLibraryKeys parameter, so libraries hidden from home hubs, Continue
Watching and the library rail still surfaced their contents in the Search
tab. Thread the profile's hidden keys from SearchScreen through to the
aggregation, and drop matching items between the fan-out and the ranking
pass so hidden hits cannot spend the result limit and shrink what is
shown. Items the backend cannot attribute to a library, such as Plex
shared and external media, are kept.

The screen re-runs the visible query when a library is hidden or unhidden
while results are on screen. Its listener is attached only after the
provider has hydrated, so the initial load notification cannot race the
first query into running twice.

Plex search rows now go through the library-aware tagger, so a response
that names its section only via librarySectionKey or
targetLibrarySectionID is still attributable, and therefore filterable.

Jellyfin search results carry no library id at all: the mapper's
ParentLibraryId is not a Jellyfin field, and ParentId resolves to a
season or physical folder rather than a CollectionFolder. Filtering there
needs server-side ParentId scoping and is left for a follow-up.

close #1770
2026-08-02 09:29:59 +02:00

187 lines
6.3 KiB
Dart

import 'dart:convert';
import 'package:plezy/media/ids.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:plezy/database/app_database.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_client.dart';
import '../test_helpers/backend_client_fixtures.dart';
http.Response _json(Object body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'});
void main() {
late AppDatabase db;
setUp(() {
db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
});
tearDown(() async {
await db.close();
});
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) =>
testPlexClient(serverId: ServerId('plex-1'), serverName: 'Plex', handler: handler);
test('search defaults to 100 movie, TV, and music candidates', () async {
final captured = <Uri>[];
final client = makeClient((request) async {
captured.add(request.url);
if (request.url.path == '/library/search') {
return _json({
'MediaContainer': {
'SearchResult': [
{
'score': 90,
'Metadata': {'ratingKey': 'movie-1', 'type': 'movie', 'title': 'The Movie'},
},
],
},
});
}
return http.Response('unexpected request', 500);
});
addTearDown(client.close);
final results = await client.searchItems('the');
expect(results.map((item) => item.id), ['movie-1']);
expect(captured, hasLength(1));
expect(captured.single.path, '/library/search');
expect(captured.single.queryParameters['limit'], '100');
expect(captured.single.queryParameters['X-Plex-Container-Size'], '100');
expect(captured.single.queryParameters['searchTypes'], 'movies,tv,music');
});
test('search rows carry their library so hidden libraries can be filtered', () async {
final client = makeClient((request) async {
if (request.url.path != '/library/search') return http.Response('unexpected request', 500);
return _json({
'MediaContainer': {
'SearchResult': [
{
'score': 90,
'Metadata': {
'ratingKey': 'movie-1',
'type': 'movie',
'title': 'The Movie',
'librarySectionID': 2,
'librarySectionTitle': 'Movies',
},
},
// Older/edge responses name the section only by key.
{
'score': 80,
'Metadata': {
'ratingKey': 'show-1',
'type': 'show',
'title': 'The Show',
'librarySectionKey': '/library/sections/7',
},
},
// Shared/external media has no local section at all.
{
'score': 70,
'Metadata': {'ratingKey': 'shared-1', 'type': 'movie', 'title': 'The Shared Movie'},
},
],
},
});
});
addTearDown(client.close);
final results = await client.searchItems('the');
expect(results.map((item) => item.id), ['movie-1', 'show-1', 'shared-1']);
expect(results.map((item) => item.libraryId), ['2', '7', null]);
expect(results.map((item) => item.libraryGlobalKey), ['plex-1:2', 'plex-1:7', null]);
});
test('saturated mixed search supplements omitted media categories and deduplicates results', () async {
final captured = <Uri>[];
final primaryResults = <Map<String, Object>>[
for (var index = 0; index < 99; index++)
{
'score': 90,
'Metadata': {'ratingKey': 'movie-$index', 'type': 'movie', 'title': 'Target Movie $index'},
},
{
'score': 10,
'Metadata': {'ratingKey': 'collection-1', 'type': 'collection', 'title': 'Target Collection'},
},
];
final client = makeClient((request) async {
captured.add(request.url);
final searchTypes = request.url.queryParameters['searchTypes'];
final searchResults = switch (searchTypes) {
'movies,tv,music' => primaryResults,
'tv' => [
{
'score': 100,
'Metadata': {'ratingKey': 'show-1', 'type': 'show', 'title': 'Target'},
},
{
'score': 90,
'Metadata': {'ratingKey': 'movie-0', 'type': 'movie', 'title': 'Target Movie 0'},
},
],
'music' => [
{
'score': 100,
'Metadata': {'ratingKey': 'artist-1', 'type': 'artist', 'title': 'Target'},
},
],
_ => <Map<String, Object>>[],
};
return _json({
'MediaContainer': {'SearchResult': searchResults},
});
});
addTearDown(client.close);
final results = await client.searchItems('Target');
final ids = results.map((item) => item.id).toList();
expect(captured.map((uri) => uri.queryParameters['searchTypes']).toSet(), {'movies,tv,music', 'tv', 'music'});
expect(ids.where((id) => id == 'movie-0'), hasLength(1));
expect(ids, containsAll(['show-1', 'artist-1']));
expect(ids, hasLength(101));
});
test('supplemental category failure keeps saturated primary results', () async {
final capturedSearchTypes = <String?>[];
final primaryResults = <Map<String, Object>>[
for (var index = 0; index < 99; index++)
{
'score': 90,
'Metadata': {'ratingKey': 'movie-$index', 'type': 'movie', 'title': 'Target Movie $index'},
},
{
'score': 80,
'Metadata': {'ratingKey': 'artist-1', 'type': 'artist', 'title': 'Target Artist'},
},
];
final client = makeClient((request) async {
final searchTypes = request.url.queryParameters['searchTypes'];
capturedSearchTypes.add(searchTypes);
if (searchTypes == 'movies,tv,music') {
return _json({
'MediaContainer': {'SearchResult': primaryResults},
});
}
return http.Response('temporary failure', 500);
});
addTearDown(client.close);
final results = await client.searchItems('Target');
expect(capturedSearchTypes, ['movies,tv,music', 'tv']);
expect(results, hasLength(100));
expect(results.map((item) => item.id), containsAll(['movie-0', 'artist-1']));
});
}