fix(search): rank media search results

close #1056
This commit is contained in:
edde746
2026-05-16 21:40:22 +02:00
parent 7ec45274bd
commit be0e9686b5
9 changed files with 267 additions and 6 deletions
+1 -1
View File
@@ -195,7 +195,7 @@ abstract class MediaServerClient {
Future<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId);
/// Free-text search across the user's libraries.
Future<List<MediaItem>> searchItems(String query, {int limit = 30});
Future<List<MediaItem>> searchItems(String query, {int limit = 100});
/// Recently-added items across all libraries.
Future<List<MediaItem>> fetchRecentlyAdded({int limit = 50});
+6 -2
View File
@@ -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 <MediaItem>[];
@@ -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');
@@ -532,7 +532,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
}
@override
Future<List<MediaItem>> searchItems(String query, {int limit = 30}) async {
Future<List<MediaItem>> searchItems(String query, {int limit = 100}) async {
final response = await _http.get(
'/Items',
queryParameters: {
+2 -2
View File
@@ -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<List<PlexMetadataDto>> _search(String query, {int limit = 30}) async {
Future<List<PlexMetadataDto>> _search(String query, {int limit = 100}) async {
final response = await _getWithFailover(
'/library/search',
queryParameters: {
@@ -3293,7 +3293,7 @@ class PlexClient
}
@override
Future<List<MediaItem>> searchItems(String query, {int limit = 30}) async {
Future<List<MediaItem>> searchItems(String query, {int limit = 100}) async {
final results = await _search(query, limit: limit);
return results.map((m) => PlexMappers.mediaItem(m)).toList();
}
+119
View File
@@ -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<MediaItem> rankMediaSearchResults(List<MediaItem> items, String query, {int? limit}) {
final normalizedQuery = normalizeSearchText(query);
if (normalizedQuery.isEmpty) {
return limit == null ? List<MediaItem>.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<String> _tokens(String value) => value.split(' ').where((token) => token.isNotEmpty).toList();
String _sortedTokens(List<String> tokens) {
final sorted = List<String>.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;
}