@@ -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});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -59,6 +59,65 @@ void main() {
|
||||
expect(await service.getOnDeckFromAllServers(), isEmpty);
|
||||
});
|
||||
|
||||
test('searchAcrossServers overfetches and ranks before trimming across backends', () async {
|
||||
final plexRequests = <Uri>[];
|
||||
final jellyfinRequests = <Uri>[];
|
||||
|
||||
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 = <Uri>[];
|
||||
|
||||
|
||||
@@ -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<http.Response> 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 = <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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user