From be0e9686b51c418a3d4545efcf6b9dce6b31eb4a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 16 May 2026 21:39:10 +0200 Subject: [PATCH] fix(search): rank media search results close #1056 --- lib/media/media_server_client.dart | 2 +- lib/services/data_aggregation_service.dart | 8 +- .../jellyfin_client/parts/browse.dart | 2 +- lib/services/plex_client.dart | 4 +- lib/utils/search_relevance.dart | 119 ++++++++++++++++++ pubspec.lock | 8 ++ pubspec.yaml | 1 + .../data_aggregation_bridge_test.dart | 59 +++++++++ test/services/plex_search_test.dart | 70 +++++++++++ 9 files changed, 267 insertions(+), 6 deletions(-) create mode 100644 lib/utils/search_relevance.dart create mode 100644 test/services/plex_search_test.dart diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 5c1dc3c6..e04d8d0b 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -195,7 +195,7 @@ abstract class MediaServerClient { Future?> fetchClientSideEpisodeQueue(String seriesId); /// Free-text search across the user's libraries. - Future> searchItems(String query, {int limit = 30}); + Future> searchItems(String query, {int limit = 100}); /// Recently-added items across all libraries. Future> fetchRecentlyAdded({int limit = 50}); diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 76b1b676..3fdb0554 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -7,6 +7,7 @@ import '../media/media_library.dart'; import '../media/media_server_client.dart'; import '../utils/app_logger.dart'; import '../utils/global_key_utils.dart'; +import '../utils/search_relevance.dart'; import 'multi_server_manager.dart'; /// Cross-server aggregation: fans calls out to every online client and @@ -213,10 +214,13 @@ class DataAggregationService { final clients = _serverManager.onlineClients; if (clients.isEmpty) return []; + final resultLimit = limit ?? defaultMediaSearchLimit; + final fetchLimit = resultLimit < defaultMediaSearchLimit ? defaultMediaSearchLimit : resultLimit; + final futures = clients.entries.map((entry) async { final client = entry.value; try { - return await client.searchItems(query, limit: limit ?? 30); + return await client.searchItems(query, limit: fetchLimit); } catch (e, st) { appLogger.e('Search failed on ${entry.key}', error: e, stackTrace: st); return []; @@ -224,7 +228,7 @@ class DataAggregationService { }); final allResults = (await Future.wait(futures)).expand((l) => l).toList(); - final result = limit != null && limit < allResults.length ? allResults.sublist(0, limit) : allResults; + final result = rankMediaSearchResults(allResults, query, limit: resultLimit); appLogger.i('Found ${result.length} search results across all servers'); diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index e2c6118f..090e5609 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -532,7 +532,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { } @override - Future> searchItems(String query, {int limit = 30}) async { + Future> searchItems(String query, {int limit = 100}) async { final response = await _http.get( '/Items', queryParameters: { diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 81bf535c..48b5bb33 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1216,7 +1216,7 @@ class PlexClient /// Search across all libraries including individually shared items. /// Uses /library/search (same endpoint as Plex Web) which finds shared content. /// Only returns movies and shows, filtering out other types. - Future> _search(String query, {int limit = 30}) async { + Future> _search(String query, {int limit = 100}) async { final response = await _getWithFailover( '/library/search', queryParameters: { @@ -3293,7 +3293,7 @@ class PlexClient } @override - Future> searchItems(String query, {int limit = 30}) async { + Future> searchItems(String query, {int limit = 100}) async { final results = await _search(query, limit: limit); return results.map((m) => PlexMappers.mediaItem(m)).toList(); } diff --git a/lib/utils/search_relevance.dart b/lib/utils/search_relevance.dart new file mode 100644 index 00000000..557f446e --- /dev/null +++ b/lib/utils/search_relevance.dart @@ -0,0 +1,119 @@ +import 'dart:math' as math; + +import 'package:string_similarity/string_similarity.dart'; + +import '../media/media_item.dart'; + +const int defaultMediaSearchLimit = 100; + +List rankMediaSearchResults(List items, String query, {int? limit}) { + final normalizedQuery = normalizeSearchText(query); + if (normalizedQuery.isEmpty) { + return limit == null ? List.of(items) : items.take(limit).toList(); + } + + final ranked = <_RankedMediaItem>[ + for (var i = 0; i < items.length; i++) + _RankedMediaItem(item: items[i], score: mediaSearchRelevanceScore(items[i], normalizedQuery), originalIndex: i), + ]; + + ranked.sort((a, b) { + final scoreComparison = b.score.compareTo(a.score); + if (scoreComparison != 0) return scoreComparison; + return a.originalIndex.compareTo(b.originalIndex); + }); + + final result = ranked.map((entry) => entry.item); + return limit == null ? result.toList() : result.take(limit).toList(); +} + +double mediaSearchRelevanceScore(MediaItem item, String query) { + final normalizedQuery = normalizeSearchText(query); + if (normalizedQuery.isEmpty) return 0; + final fields = <({String? value, double weight})>[ + (value: item.title, weight: 1.0), + (value: item.titleSort, weight: 0.98), + (value: item.originalTitle, weight: 0.96), + (value: item.grandparentTitle, weight: 0.90), + (value: item.parentTitle, weight: 0.80), + ]; + + var best = 0.0; + for (final field in fields) { + final candidate = normalizeSearchText(field.value); + if (candidate.isEmpty) continue; + best = math.max(best, _scoreNormalizedField(normalizedQuery, candidate) * field.weight); + } + return best; +} + +String normalizeSearchText(String? value) { + if (value == null) return ''; + return value + .toLowerCase() + .replaceAll(RegExp(r'[\u0000-\u002f\u003a-\u0040\u005b-\u0060\u007b-\u007f]+'), ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); +} + +double _scoreNormalizedField(String query, String candidate) { + if (candidate == query) return 1000; + + final queryWithoutArticle = _withoutLeadingArticle(query); + final candidateWithoutArticle = _withoutLeadingArticle(candidate); + if (queryWithoutArticle.isNotEmpty && candidateWithoutArticle == queryWithoutArticle) return 980; + + if (candidate.startsWith(query)) return 900 + _lengthCloseness(query, candidate, 50); + if (queryWithoutArticle.isNotEmpty && candidateWithoutArticle.startsWith(queryWithoutArticle)) { + return 880 + _lengthCloseness(queryWithoutArticle, candidateWithoutArticle, 50); + } + + if (candidate.contains(query)) return 800 + _lengthCloseness(query, candidate, 50); + + final queryTokens = _tokens(query); + final candidateTokens = _tokens(candidate); + if (queryTokens.isEmpty || candidateTokens.isEmpty) return 0; + + final candidateTokenSet = candidateTokens.toSet(); + final matchingTokens = queryTokens.where(candidateTokenSet.contains).length; + final sortedQuery = _sortedTokens(queryTokens); + final sortedCandidate = _sortedTokens(candidateTokens); + final tokenSimilarity = StringSimilarity.compareTwoStrings(sortedQuery, sortedCandidate); + final rawSimilarity = StringSimilarity.compareTwoStrings(query, candidate); + final fuzzyScore = math.max(rawSimilarity, tokenSimilarity) * 650; + + if (matchingTokens == queryTokens.length) return math.max(700 + tokenSimilarity * 100, fuzzyScore); + if (matchingTokens > 0) return math.max(400 + (matchingTokens / queryTokens.length) * 100, fuzzyScore); + + return fuzzyScore; +} + +List _tokens(String value) => value.split(' ').where((token) => token.isNotEmpty).toList(); + +String _sortedTokens(List tokens) { + final sorted = List.of(tokens)..sort(); + return sorted.join(' '); +} + +String _withoutLeadingArticle(String value) { + for (final article in const ['the ', 'a ', 'an ']) { + if (value.startsWith(article)) return value.substring(article.length); + } + return value; +} + +double _lengthCloseness(String query, String candidate, double maxBonus) { + final longest = math.max(query.length, candidate.length); + if (longest == 0) return 0; + final distance = (candidate.length - query.length).abs(); + final closeness = math.max(0.0, math.min(1.0, 1 - distance / longest)); + return maxBonus * closeness; +} + +class _RankedMediaItem { + const _RankedMediaItem({required this.item, required this.score, required this.originalIndex}); + + final MediaItem item; + final double score; + final int originalIndex; +} diff --git a/pubspec.lock b/pubspec.lock index 5e102c41..03161a44 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1243,6 +1243,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + string_similarity: + dependency: "direct main" + description: + name: string_similarity + sha256: "3ee1fc76e0c800aeb3dce197e28ed8541b622224d09b10c34a022803066a7c50" + url: "https://pub.dev" + source: hosted + version: "2.2.0" system_info2: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 345f81c3..e9994952 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,6 +72,7 @@ dependencies: collection: ^1.18.0 freezed_annotation: ^3.1.0 xml: ^6.6.1 + string_similarity: ^2.2.0 dev_dependencies: flutter_test: diff --git a/test/services/data_aggregation_bridge_test.dart b/test/services/data_aggregation_bridge_test.dart index 379cf6c0..dcee9c8d 100644 --- a/test/services/data_aggregation_bridge_test.dart +++ b/test/services/data_aggregation_bridge_test.dart @@ -59,6 +59,65 @@ void main() { expect(await service.getOnDeckFromAllServers(), isEmpty); }); + test('searchAcrossServers overfetches and ranks before trimming across backends', () async { + final plexRequests = []; + final jellyfinRequests = []; + + final plexClient = PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example.com', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: 'test', + ), + serverId: 'plex-1', + serverName: 'Plex', + httpClient: MockClient((req) async { + plexRequests.add(req.url); + if (req.url.path == '/library/search') { + return _json({ + 'MediaContainer': { + 'SearchResult': [ + { + 'score': 100, + 'Metadata': {'ratingKey': 'plex-movie', 'type': 'movie', 'title': 'The Boys in the Boat'}, + }, + ], + }, + }); + } + return http.Response('unexpected request', 500); + }), + ); + addTearDown(plexClient.close); + manager.debugRegisterClientForTesting(plexClient); + + final jellyfinClient = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((req) async { + jellyfinRequests.add(req.url); + if (req.url.path == '/Items') { + return _json({ + 'Items': [ + {'Id': 'jf-show', 'Type': 'Series', 'Name': 'The Boys'}, + ], + }); + } + return http.Response('unexpected request', 500); + }), + ); + addTearDown(jellyfinClient.close); + manager.debugRegisterJellyfinClientForTesting(jellyfinClient); + + final results = await service.searchAcrossServers('The Boys', limit: 1); + + expect(results.map((item) => item.id), ['jf-show']); + expect(plexRequests.single.queryParameters['limit'], '100'); + expect(plexRequests.single.queryParameters['searchTypes'], 'movies,tv'); + expect(jellyfinRequests.single.queryParameters['Limit'], '100'); + }); + test('getOnDeckFromAllServers forwards preview limit to clients', () async { final captured = []; diff --git a/test/services/plex_search_test.dart b/test/services/plex_search_test.dart new file mode 100644 index 00000000..72b79e37 --- /dev/null +++ b/test/services/plex_search_test.dart @@ -0,0 +1,70 @@ +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/models/plex/plex_config.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/plex_client.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 Function(http.Request request) handler) { + return PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example.com', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: 'test', + ), + serverId: 'plex-1', + serverName: 'Plex', + httpClient: MockClient(handler), + ); + } + + test('search defaults to 100 movie and TV candidates', () async { + final captured = []; + 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'); + }); +}