fix(search): recover omitted Plex media categories

close #1598
This commit is contained in:
edde746
2026-07-26 14:58:38 +02:00
parent 8ef977d890
commit 1fea9ef6e3
30 changed files with 512 additions and 50 deletions
+2
View File
@@ -558,6 +558,7 @@
},
"removedFromContinueWatching": "Премахнато от продължаване на гледането",
"errorLoading": "Грешка: ${error}",
"searchPartialResults": "",
"streamInterrupted": "Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.",
"liveStreamInterrupted": "Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.",
"fileInfoNotAvailable": "Информацията за файла не е налична",
@@ -743,6 +744,7 @@
},
"errors": {
"searchFailed": "Търсенето е неуспешно: ${error}",
"searchUnavailable": "",
"connectionTimeout": "Изтече времето за връзка при зареждане на ${context}",
"connectionFailed": "Не може да се осъществи връзка с медиен сървър",
"unableToLoad": "Не може да се зареди ${context}. Опитайте отново.",
+2
View File
@@ -558,6 +558,7 @@
},
"removedFromContinueWatching": "Fjernet fra Fortsæt med at se",
"errorLoading": "Fejl: ${error}",
"searchPartialResults": "",
"streamInterrupted": "Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.",
"liveStreamInterrupted": "Livestreamen blev afbrudt. Tryk på afspil for at prøve igen.",
"fileInfoNotAvailable": "Filinfo ikke tilgængelig",
@@ -743,6 +744,7 @@
},
"errors": {
"searchFailed": "Søgning mislykkedes: ${error}",
"searchUnavailable": "",
"connectionTimeout": "Forbindelsen fik timeout under indlæsning af ${context}",
"connectionFailed": "Kan ikke oprette forbindelse til medieserver",
"unableToLoad": "Kunne ikke indlæse ${context}. Prøv igen.",
+2
View File
@@ -558,6 +558,7 @@
},
"removedFromContinueWatching": "Aus „Weiterschauen“ entfernt",
"errorLoading": "Fehler: ${error}",
"searchPartialResults": "",
"streamInterrupted": "Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.",
"liveStreamInterrupted": "Der Livestream wurde unterbrochen. Drücke auf Wiedergabe, um es erneut zu versuchen.",
"fileInfoNotAvailable": "Dateiinfo nicht verfügbar",
@@ -743,6 +744,7 @@
},
"errors": {
"searchFailed": "Suche fehlgeschlagen: ${error}",
"searchUnavailable": "",
"connectionTimeout": "Zeitüberschreitung beim Laden von ${context}",
"connectionFailed": "Keine Verbindung zum Medienserver möglich",
"unableToLoad": "${context} konnte nicht geladen werden. Bitte erneut versuchen.",
+2
View File
@@ -558,6 +558,7 @@
},
"removedFromContinueWatching": "Removed from Continue Watching",
"errorLoading": "Error: ${error}",
"searchPartialResults": "Some media servers could not be searched. Showing available results.",
"streamInterrupted": "The stream was interrupted. Press play or seek to retry.",
"liveStreamInterrupted": "The live stream was interrupted. Press play to retry.",
"fileInfoNotAvailable": "File information not available",
@@ -743,6 +744,7 @@
},
"errors": {
"searchFailed": "Search failed: ${error}",
"searchUnavailable": "Search could not reach any media server.",
"connectionTimeout": "Connection timeout while loading ${context}",
"connectionFailed": "Unable to connect to media server",
"unableToLoad": "Unable to load ${context}. Please try again.",
+2
View File
@@ -558,6 +558,7 @@
},
"removedFromContinueWatching": "Eliminado de Seguir Viendo",
"errorLoading": "Error: ${error}",
"searchPartialResults": "",
"streamInterrupted": "La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.",
"liveStreamInterrupted": "La transmisión en vivo se interrumpió. Pulsa reproducir para volver a intentarlo.",
"fileInfoNotAvailable": "Información de archivo no disponible",
@@ -743,6 +744,7 @@
},
"errors": {
"searchFailed": "Error en la búsqueda: ${error}",
"searchUnavailable": "",
"connectionTimeout": "Tiempo de conexión agotado al cargar ${context}",
"connectionFailed": "No se puede conectar al servidor multimedia",
"unableToLoad": "No se pudo cargar ${context}. Inténtalo de nuevo.",
+2
View File
@@ -558,6 +558,7 @@
},
"removedFromContinueWatching": "Supprimé de « Continuer à regarder »",
"errorLoading": "Erreur : ${error}",
"searchPartialResults": "",
"streamInterrupted": "La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.",
"liveStreamInterrupted": "Le direct a été interrompu. Appuyez sur Lecture pour réessayer.",
"fileInfoNotAvailable": "Informations sur le fichier non disponibles",
@@ -743,6 +744,7 @@
},
"errors": {
"searchFailed": "Échec de la recherche : ${error}",
"searchUnavailable": "",
"connectionTimeout": "Délai d'attente de connexion dépassé pendant le chargement ${context}",
"connectionFailed": "Impossible de se connecter au serveur multimédia",
"unableToLoad": "Impossible de charger ${context}. Réessayez.",
+2
View File
@@ -558,6 +558,7 @@
},
"removedFromContinueWatching": "Eltávolítva a folytatásból",
"errorLoading": "Hiba: ${error}",
"searchPartialResults": "",
"streamInterrupted": "Az adatfolyam megszakadt. Az újrapróbálkozáshoz indítsd el a lejátszást, vagy tekerj másik pozícióra.",
"liveStreamInterrupted": "Az élő adás megszakadt. Nyomd meg a lejátszást az újrapróbálkozáshoz.",
"fileInfoNotAvailable": "A fájlinformáció nem érhető el",
@@ -743,6 +744,7 @@
},
"errors": {
"searchFailed": "Keresés sikertelen: ${error}",
"searchUnavailable": "",
"connectionTimeout": "Hálózati időtúllépés a következő betöltésekor: ${context}",
"connectionFailed": "Nem sikerült csatlakozni a médiaszerverhez",
"unableToLoad": "Nem sikerült betölteni a következőt: ${context}. Próbáld újra.",
+2
View File
@@ -558,6 +558,7 @@
},
"removedFromContinueWatching": "Rimosso da Continua a guardare",
"errorLoading": "Errore: ${error}",
"searchPartialResults": "",
"streamInterrupted": "La riproduzione si è interrotta. Premi Riproduci o vai a un altro punto per riprovare.",
"liveStreamInterrupted": "La diretta si è interrotta. Premi Riproduci per riprovare.",
"fileInfoNotAvailable": "Informazioni sul file non disponibili",
@@ -743,6 +744,7 @@
},
"errors": {
"searchFailed": "Ricerca non riuscita: ${error}",
"searchUnavailable": "",
"connectionTimeout": "Tempo scaduto per la connessione durante il caricamento di ${context}",
"connectionFailed": "Impossibile connettersi al server multimediale",
"unableToLoad": "Impossibile caricare ${context}. Riprova.",
+2
View File
@@ -557,6 +557,7 @@
},
"removedFromContinueWatching": "視聴中から削除しました",
"errorLoading": "エラー: ${error}",
"searchPartialResults": "",
"streamInterrupted": "ストリームが中断されました。再生を押すかシークして再試行してください。",
"liveStreamInterrupted": "ライブストリームが中断されました。再生を押して再試行してください。",
"fileInfoNotAvailable": "ファイル情報が利用できません",
@@ -742,6 +743,7 @@
},
"errors": {
"searchFailed": "検索に失敗しました: ${error}",
"searchUnavailable": "",
"connectionTimeout": "${context}の読み込み中に接続がタイムアウトしました",
"connectionFailed": "メディアサーバーに接続できません",
"unableToLoad": "${context}を読み込めませんでした。もう一度お試しください。",
+2
View File
@@ -557,6 +557,7 @@
},
"removedFromContinueWatching": "계속 시청 목록에서 제거됨",
"errorLoading": "오류: ${error}",
"searchPartialResults": "",
"streamInterrupted": "스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.",
"liveStreamInterrupted": "라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.",
"fileInfoNotAvailable": "파일 정보가 없습니다",
@@ -742,6 +743,7 @@
},
"errors": {
"searchFailed": "검색 실패: ${error}",
"searchUnavailable": "",
"connectionTimeout": "${context} 로드 중 연결 시간 초과",
"connectionFailed": "미디어 서버에 연결할 수 없습니다",
"unableToLoad": "${context}을(를) 불러올 수 없습니다. 다시 시도하세요.",
+2
View File
@@ -558,6 +558,7 @@
},
"removedFromContinueWatching": "Fjernet fra Fortsett å se",
"errorLoading": "Feil: ${error}",
"searchPartialResults": "",
"streamInterrupted": "Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.",
"liveStreamInterrupted": "Direktesendingen ble avbrutt. Trykk på Spill av for å prøve på nytt.",
"fileInfoNotAvailable": "Filinformasjon ikke tilgjengelig",
@@ -743,6 +744,7 @@
},
"errors": {
"searchFailed": "Søk mislyktes: ${error}",
"searchUnavailable": "",
"connectionTimeout": "Tidsavbrudd ved lasting av ${context}",
"connectionFailed": "Kan ikke koble til medieserver",
"unableToLoad": "Kunne ikke laste ${context}. Prøv igjen.",
+2
View File
@@ -558,6 +558,7 @@
},
"removedFromContinueWatching": "Verwijderd uit 'Doorgaan met kijken'",
"errorLoading": "Fout: ${error}",
"searchPartialResults": "",
"streamInterrupted": "De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.",
"liveStreamInterrupted": "De livestream is onderbroken. Druk op afspelen om het opnieuw te proberen.",
"fileInfoNotAvailable": "Bestandsinformatie niet beschikbaar",
@@ -743,6 +744,7 @@
},
"errors": {
"searchFailed": "Zoeken mislukt: ${error}",
"searchUnavailable": "",
"connectionTimeout": "Time-out van verbinding tijdens het laden van ${context}",
"connectionFailed": "Kan geen verbinding maken met mediaserver",
"unableToLoad": "Kan ${context} niet laden. Probeer het opnieuw.",
+2
View File
@@ -560,6 +560,7 @@
},
"removedFromContinueWatching": "Usunięto z kontynuowania oglądania",
"errorLoading": "Błąd: ${error}",
"searchPartialResults": "",
"streamInterrupted": "Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.",
"liveStreamInterrupted": "Transmisja na żywo została przerwana. Naciśnij odtwarzanie, aby spróbować ponownie.",
"fileInfoNotAvailable": "Informacje o pliku niedostępne",
@@ -745,6 +746,7 @@
},
"errors": {
"searchFailed": "Wyszukiwanie nie powiodło się: ${error}",
"searchUnavailable": "",
"connectionTimeout": "Limit czasu połączenia przy ładowaniu ${context}",
"connectionFailed": "Nie można połączyć się z serwerem multimediów",
"unableToLoad": "Nie udało się załadować ${context}. Spróbuj ponownie.",
+2
View File
@@ -558,6 +558,7 @@
},
"removedFromContinueWatching": "Removido de Continuar assistindo",
"errorLoading": "Erro: ${error}",
"searchPartialResults": "",
"streamInterrupted": "A transmissão foi interrompida. Pressione reproduzir ou avance para tentar novamente.",
"liveStreamInterrupted": "A transmissão ao vivo foi interrompida. Pressione reproduzir para tentar novamente.",
"fileInfoNotAvailable": "Informações do arquivo não disponíveis",
@@ -743,6 +744,7 @@
},
"errors": {
"searchFailed": "Falha na busca: ${error}",
"searchUnavailable": "",
"connectionTimeout": "Tempo de conexão esgotado ao carregar ${context}",
"connectionFailed": "Não foi possível conectar ao servidor de mídia",
"unableToLoad": "Não foi possível carregar ${context}. Tente novamente.",
+2
View File
@@ -560,6 +560,7 @@
},
"removedFromContinueWatching": "Удалено из «Продолжить просмотр»",
"errorLoading": "Ошибка: ${error}",
"searchPartialResults": "",
"streamInterrupted": "Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.",
"liveStreamInterrupted": "Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.",
"fileInfoNotAvailable": "Информация о файле недоступна",
@@ -745,6 +746,7 @@
},
"errors": {
"searchFailed": "Ошибка поиска: ${error}",
"searchUnavailable": "",
"connectionTimeout": "Таймаут подключения при загрузке ${context}",
"connectionFailed": "Не удалось подключиться к медиасерверу",
"unableToLoad": "Не удалось загрузить ${context}. Повторите попытку.",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 18
/// Strings: 26499 (1472 per locale)
/// Strings: 26501 (1472 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+10 -2
View File
@@ -1705,6 +1705,9 @@ class Translations$messages$en {
/// en: 'Error: ${error}'
String errorLoading({required Object error}) => 'Error: ${error}';
/// en: 'Some media servers could not be searched. Showing available results.'
String get searchPartialResults => 'Some media servers could not be searched. Showing available results.';
/// en: 'The stream was interrupted. Press play or seek to retry.'
String get streamInterrupted => 'The stream was interrupted. Press play or seek to retry.';
@@ -2281,6 +2284,9 @@ class Translations$errors$en {
/// en: 'Search failed: ${error}'
String searchFailed({required Object error}) => 'Search failed: ${error}';
/// en: 'Search could not reach any media server.'
String get searchUnavailable => 'Search could not reach any media server.';
/// en: 'Connection timeout while loading ${context}'
String connectionTimeout({required Object context}) => 'Connection timeout while loading ${context}';
@@ -5664,6 +5670,7 @@ extension on Translations {
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'Auto-removed ${n} watched download', other: 'Auto-removed ${n} watched downloads', ),
'messages.removedFromContinueWatching' => 'Removed from Continue Watching',
'messages.errorLoading' => ({required Object error}) => 'Error: ${error}',
'messages.searchPartialResults' => 'Some media servers could not be searched. Showing available results.',
'messages.streamInterrupted' => 'The stream was interrupted. Press play or seek to retry.',
'messages.liveStreamInterrupted' => 'The live stream was interrupted. Press play to retry.',
'messages.fileInfoNotAvailable' => 'File information not available',
@@ -5835,6 +5842,7 @@ extension on Translations {
'discover.minutesLeft' => ({required Object minutes}) => '${minutes} min left',
'discover.moreLikeThis' => 'More Like This',
'errors.searchFailed' => ({required Object error}) => 'Search failed: ${error}',
'errors.searchUnavailable' => 'Search could not reach any media server.',
'errors.connectionTimeout' => ({required Object context}) => 'Connection timeout while loading ${context}',
'errors.connectionFailed' => 'Unable to connect to media server',
'errors.unableToLoad' => ({required Object context}) => 'Unable to load ${context}. Please try again.',
@@ -6166,10 +6174,10 @@ extension on Translations {
'watchTogether.sessionCodeCopied' => 'Session code copied to clipboard',
'watchTogether.relayUnreachable' => 'Relay server unreachable. ISP blocking may prevent Watch Together.',
'watchTogether.reconnectingToHost' => 'Reconnecting to host...',
'watchTogether.currentPlayback' => 'Current Playback',
'watchTogether.joinCurrentPlayback' => 'Join Current Playback',
_ => null,
} ?? switch (path) {
'watchTogether.currentPlayback' => 'Current Playback',
'watchTogether.joinCurrentPlayback' => 'Join Current Playback',
'watchTogether.joinCurrentPlaybackDescription' => 'Jump back into what the host is currently watching',
'watchTogether.failedToOpenCurrentPlayback' => 'Failed to open current playback',
'watchTogether.participantJoined' => ({required Object name}) => '${name} joined',
+2
View File
@@ -558,6 +558,7 @@
},
"removedFromContinueWatching": "Borttagen från Fortsätt titta",
"errorLoading": "Fel: ${error}",
"searchPartialResults": "",
"streamInterrupted": "Strömmen avbröts. Tryck på uppspelning eller spola för att försöka igen.",
"liveStreamInterrupted": "Liveströmmen avbröts. Tryck på uppspelning för att försöka igen.",
"fileInfoNotAvailable": "Filinformation är inte tillgänglig",
@@ -743,6 +744,7 @@
},
"errors": {
"searchFailed": "Sökningen misslyckades: ${error}",
"searchUnavailable": "",
"connectionTimeout": "Anslutningen tog för lång tid när ${context} lästes in",
"connectionFailed": "Det gick inte att ansluta till medieservern",
"unableToLoad": "Det gick inte att läsa in ${context}. Försök igen.",
+2
View File
@@ -557,6 +557,7 @@
},
"removedFromContinueWatching": "已從「繼續觀看」中移除",
"errorLoading": "錯誤:${error}",
"searchPartialResults": "",
"streamInterrupted": "影片串流中斷。請按播放鍵或拖動進度條重試。",
"liveStreamInterrupted": "直播串流中斷。請按播放鍵重試。",
"fileInfoNotAvailable": "無法取得檔案資訊",
@@ -742,6 +743,7 @@
},
"errors": {
"searchFailed": "搜尋失敗:${error}",
"searchUnavailable": "",
"connectionTimeout": "載入 ${context} 時連線逾時",
"connectionFailed": "無法連線至媒體伺服器",
"unableToLoad": "無法載入 ${context}。請重試。",
+2
View File
@@ -557,6 +557,7 @@
},
"removedFromContinueWatching": "已从继续观看中移除",
"errorLoading": "出错:${error}",
"searchPartialResults": "",
"streamInterrupted": "视频流已中断。按播放键或拖动进度条重试。",
"liveStreamInterrupted": "直播流已中断。按播放键重试。",
"fileInfoNotAvailable": "文件信息不可用",
@@ -742,6 +743,7 @@
},
"errors": {
"searchFailed": "搜索失败:${error}",
"searchUnavailable": "",
"connectionTimeout": "加载 ${context} 时连接超时",
"connectionFailed": "无法连接到媒体服务器",
"unableToLoad": "无法加载${context}。请重试。",
+5 -2
View File
@@ -268,8 +268,11 @@ abstract class MediaServerClient {
/// [Lyrics.synced]; gated by [ServerCapabilities.lyrics].
Future<Lyrics?> fetchLyrics(MediaItem track);
/// Free-text search across the user's libraries.
Future<List<MediaItem>> searchItems(String query, {int limit = 100});
/// Free-text search across the user's libraries. [limit] is a per-request
/// 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<List<MediaItem>> searchItems(String query, {int limit = 100, AbortController? abort});
/// Recently-added items across all libraries.
Future<List<MediaItem>> fetchRecentlyAdded({int limit = 50});
+17 -2
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import '../exceptions/media_server_exceptions.dart';
import '../media/media_item.dart';
import '../utils/app_logger.dart';
@@ -39,7 +40,7 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
/// Names the focus nodes and log lines.
String get searchDebugLabel => widget.runtimeType.toString();
/// Run the actual search. Thrown errors flip [lastSearchFailed].
/// Run the actual search. Non-cancellation errors flip [lastSearchFailed].
Future<List<MediaItem>> performSearchQuery(String query);
/// A failed search was applied to the state (e.g. show a snackbar).
@@ -51,6 +52,11 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
/// The field was cleared and the state reset.
void onSearchCleared() {}
/// The active query was superseded or the search scope is being disposed.
/// Implementations may cancel transport work here; the generation guard
/// remains authoritative for preventing stale UI commits.
void onSearchInvalidated() {}
@override
void initState() {
super.initState();
@@ -60,6 +66,7 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
@override
void dispose() {
_debounceTimer?.cancel();
onSearchInvalidated();
searchController.removeListener(_onSearchTextChanged);
searchController.dispose();
searchFocusNode.dispose();
@@ -88,6 +95,7 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
if (query.isEmpty) {
_debounceTimer?.cancel();
_searchGeneration++;
if (_inFlightQuery != null) onSearchInvalidated();
_inFlightQuery = null;
setState(() {
searchResults = [];
@@ -118,6 +126,7 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
bool _invalidateStaleInFlight(String current) {
if (_inFlightQuery == null || _inFlightQuery == current) return false;
_searchGeneration++;
onSearchInvalidated();
_inFlightQuery = null;
return true;
}
@@ -125,6 +134,7 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
/// Run [query] now, bypassing the debounce (submit, external refresh).
Future<void> runSearch(String query) async {
if (!mounted || query.isEmpty) return;
if (_inFlightQuery != null) onSearchInvalidated();
final generation = ++_searchGeneration;
_inFlightQuery = query;
setState(() {
@@ -143,8 +153,13 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
});
onSearchCompleted(query, results);
} catch (e) {
appLogger.w('$searchDebugLabel: search failed', error: e);
if (!mounted || generation != _searchGeneration) return;
if (e is MediaServerHttpException && e.isCancellation) {
_inFlightQuery = null;
setState(() => isSearching = false);
return;
}
appLogger.w('$searchDebugLabel: search failed', error: e);
_inFlightQuery = null;
setState(() {
searchResults = [];
+49 -3
View File
@@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../exceptions/media_server_exceptions.dart';
import '../focus/focusable_text_field.dart';
import '../focus/focusable_button.dart';
import '../i18n/strings.g.dart';
@@ -12,9 +13,11 @@ import '../mixins/debounced_media_search.dart';
import '../mixins/mounted_set_state_mixin.dart';
import '../mixins/refreshable.dart';
import '../providers/multi_server_provider.dart';
import '../services/data_aggregation_service.dart';
import '../utils/app_logger.dart';
import '../utils/platform_detector.dart';
import '../utils/snackbar_helper.dart';
import '../utils/media_server_http_client.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/loading_indicator_box.dart';
import '../widgets/pill_input_decoration.dart';
@@ -35,6 +38,8 @@ class _SearchScreenState extends State<SearchScreen>
String? _focusResultsForQuery;
final _tvTextInputController = TvTextInputController();
final _clearFocusNode = FocusNode(debugLabel: 'Search.clear');
AbortController? _activeSearchAbort;
({String query, SearchAggregationResult result})? _pendingSearchOutcome;
@override
void initState() {
@@ -60,24 +65,61 @@ class _SearchScreenState extends State<SearchScreen>
Future<List<MediaItem>> performSearchQuery(String query) async {
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
if (!multiServerProvider.hasConnectedServers) {
throw Exception('No servers available');
throw const _SearchUnavailableException();
}
return multiServerProvider.aggregationService.searchAcrossServers(query);
final abort = AbortController();
_activeSearchAbort = abort;
try {
final result = await multiServerProvider.aggregationService.searchAcrossServers(query, abort: abort);
abort.throwIfAborted();
if (result.succeededServerIds.isEmpty && result.failedServerIds.isNotEmpty) {
throw const _SearchUnavailableException();
}
if (result.succeededServerIds.isEmpty && result.cancelledServerIds.isNotEmpty) {
throw MediaServerHttpException(
type: MediaServerHttpErrorType.cancelled,
message: 'Search was cancelled before any server completed',
);
}
_pendingSearchOutcome = (query: query, result: result);
return result.items;
} finally {
if (identical(_activeSearchAbort, abort)) _activeSearchAbort = null;
}
}
@override
void onSearchInvalidated() {
_activeSearchAbort?.abort();
_activeSearchAbort = null;
_pendingSearchOutcome = null;
}
@override
void onSearchError(Object error) {
_focusResultsForQuery = null;
showErrorSnackBar(context, t.errors.searchFailed(error: error));
_pendingSearchOutcome = null;
final message = error is _SearchUnavailableException
? t.errors.searchUnavailable
: t.errors.searchFailed(error: error);
showErrorSnackBar(context, message);
}
@override
void onSearchCleared() {
_focusResultsForQuery = null;
_pendingSearchOutcome = null;
}
@override
void onSearchCompleted(String query, List<MediaItem> results) {
final outcome = _pendingSearchOutcome;
_pendingSearchOutcome = null;
if (outcome?.query == query && outcome!.result.failedServerIds.isNotEmpty) {
showAppSnackBar(context, t.messages.searchPartialResults);
}
if (_focusResultsForQuery == null || _focusResultsForQuery != query) return;
_focusResultsForQuery = null;
if (results.isEmpty) return;
@@ -294,3 +336,7 @@ class _SearchScreenState extends State<SearchScreen>
);
}
}
final class _SearchUnavailableException implements Exception {
const _SearchUnavailableException();
}
+65 -12
View File
@@ -11,6 +11,7 @@ import '../utils/app_logger.dart';
import '../utils/external_ids.dart';
import '../utils/global_key_utils.dart';
import '../utils/search_relevance.dart';
import '../utils/media_server_http_client.dart';
import 'local_playback_history.dart';
import 'multi_server_manager.dart';
@@ -25,6 +26,12 @@ typedef LibraryAggregationResult = ({
Set<String> succeededServerIds,
Set<String> cancelledServerIds,
});
typedef SearchAggregationResult = ({
List<MediaItem> items,
Set<String> succeededServerIds,
Set<String> cancelledServerIds,
Set<String> failedServerIds,
});
/// Whether [error] is a client-side abort (client teardown mid-request)
/// rather than a genuine server failure. Aggregation reports these servers
@@ -32,6 +39,14 @@ typedef LibraryAggregationResult = ({
/// results say nothing about actual content — from a settled failure.
bool _isCancellation(Object error) => error is MediaServerHttpException && error.isCancellation;
Map<String, int> _searchKindCounts(Iterable<MediaItem> items) {
final counts = <String, int>{};
for (final item in items) {
counts.update(item.kind.name, (count) => count + 1, ifAbsent: () => 1);
}
return counts;
}
/// 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
@@ -506,35 +521,73 @@ class DataAggregationService {
return filtered;
}
/// Search across all online servers (Plex + Jellyfin). Returns neutral
/// [MediaItem]s.
Future<List<MediaItem>> searchAcrossServers(String query, {int? limit}) async {
/// Search across all online servers (Plex + Jellyfin). Per-server outcomes
/// distinguish authoritative empty results from failed or cancelled legs.
Future<SearchAggregationResult> searchAcrossServers(String query, {int? limit, AbortController? abort}) async {
if (query.trim().isEmpty) {
return [];
return (
items: const <MediaItem>[],
succeededServerIds: const <String>{},
cancelledServerIds: const <String>{},
failedServerIds: const <String>{},
);
}
abort?.throwIfAborted();
final clients = _serverManager.onlineClients;
if (clients.isEmpty) return [];
if (clients.isEmpty) {
return (
items: const <MediaItem>[],
succeededServerIds: const <String>{},
cancelledServerIds: const <String>{},
failedServerIds: const <String>{},
);
}
final resultLimit = limit ?? defaultMediaSearchLimit;
final fetchLimit = resultLimit < defaultMediaSearchLimit ? defaultMediaSearchLimit : resultLimit;
final succeededServerIds = <String>{};
final cancelledServerIds = <String>{};
final failedServerIds = <String>{};
final futures = clients.entries.map((entry) async {
final client = entry.value;
final stopwatch = Stopwatch()..start();
try {
return await client.searchItems(query, limit: fetchLimit);
final items = await entry.value.searchItems(query, limit: fetchLimit, abort: abort);
succeededServerIds.add(entry.key);
appLogger.i(
'Search completed on ${entry.key} in ${stopwatch.elapsedMilliseconds}ms: '
'${items.length} results ${_searchKindCounts(items)}',
);
return items;
} catch (e, st) {
appLogger.e('Search failed on ${entry.key}', error: e, stackTrace: st);
if (_isCancellation(e)) {
cancelledServerIds.add(entry.key);
appLogger.d('Search cancelled on ${entry.key} after ${stopwatch.elapsedMilliseconds}ms');
} else {
failedServerIds.add(entry.key);
appLogger.e('Search failed on ${entry.key}', error: e, stackTrace: st);
}
return <MediaItem>[];
}
});
final allResults = (await Future.wait(futures)).expand((l) => l).toList();
final result = rankMediaSearchResults(allResults, query, limit: resultLimit);
final allResults = (await Future.wait(futures)).expand((items) => items).toList();
abort?.throwIfAborted();
final items = rankMediaSearchResults(allResults, query, limit: resultLimit);
appLogger.i('Found ${result.length} search results across all servers');
appLogger.i(
'Search aggregation completed: ${items.length} results '
'(${succeededServerIds.length} succeeded, ${cancelledServerIds.length} cancelled, '
'${failedServerIds.length} failed) ${_searchKindCounts(items)}',
);
return result;
return (
items: items,
succeededServerIds: succeededServerIds,
cancelledServerIds: cancelledServerIds,
failedServerIds: failedServerIds,
);
}
/// Reverse external-id lookup fanned out to every online server (see
+19 -10
View File
@@ -1053,7 +1053,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
}
@override
Future<List<MediaItem>> searchItems(String query, {int limit = 100}) async {
Future<List<MediaItem>> 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
@@ -1067,14 +1067,15 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
'IncludeItemTypes': 'Movie,Series,Episode,MusicAlbum,Audio',
'Fields': _browseFields,
...jellyfinImageQueryParameters,
}),
}, abort: abort),
_safeFetchItemsArray('/Artists', {
'userId': connection.userId,
'searchTerm': query,
'Limit': limit.toString(),
...jellyfinImageQueryParameters,
}),
}, abort: abort),
]);
abort?.throwIfAborted();
return _mapItems([...results.first, ...results[1]]);
}
@@ -1848,17 +1849,21 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
Future<MediaServerResponse> _getItemsResponse(
String path,
Map<String, dynamic> queryParameters,
_HubRetryPolicy? retry,
) {
if (retry == null) return _http.get(path, queryParameters: queryParameters);
_HubRetryPolicy? retry, {
AbortController? abort,
}) {
if (retry == null) {
return _http.get(path, queryParameters: queryParameters, abort: abort);
}
abort?.throwIfAborted();
return retryTransientMediaServerCall(
operation: retry.operation,
attemptTimeouts: retry.attemptTimeouts,
call: (timeout, abort) => _http.get(
call: (timeout, attemptAbort) => _http.get(
path,
queryParameters: queryParameters,
timeout: timeout,
abort: abort,
abort: attemptAbort,
allowEndpointFailover: false,
),
);
@@ -1868,8 +1873,10 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
String path,
Map<String, dynamic> queryParameters, {
_HubRetryPolicy? retry,
AbortController? abort,
}) async {
final response = await _getItemsResponse(path, queryParameters, retry);
final response = await _getItemsResponse(path, queryParameters, retry, abort: abort);
abort?.throwIfAborted();
throwIfHttpError(response);
return _itemsArray(response.data);
}
@@ -1878,9 +1885,11 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
String path,
Map<String, dynamic> queryParameters, {
_HubRetryPolicy? retry,
AbortController? abort,
}) async {
try {
final response = await _getItemsResponse(path, queryParameters, retry);
final response = await _getItemsResponse(path, queryParameters, retry, abort: abort);
abort?.throwIfAborted();
throwIfHttpError(response);
final data = response.data;
if (data is List) {
@@ -9,6 +9,8 @@ mixin _JellyfinLiveTvMethods on MediaServerCacheMixin {
Map<String, dynamic> queryParameters, {
// ignore: unused_element_parameter
_HubRetryPolicy? retry,
// ignore: unused_element_parameter
AbortController? abort,
});
/// Returns `true` when this server has Live TV configured (channels
+66 -10
View File
@@ -1388,28 +1388,84 @@ 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, shows, and music (artists/albums/tracks), filtering
/// out other types.
Future<List<PlexMetadataDto>> _search(String query, {int limit = 100}) async {
/// A saturated mixed-type response is supplemented with concurrent requests
/// for categories Plex omitted so one large library cannot starve another.
Future<List<PlexMetadataDto>> _search(String query, {int limit = 100, AbortController? abort}) async {
const allSearchTypes = 'movies,tv,music';
final primary = await _searchByTypes(query, searchTypes: allSearchTypes, limit: limit, abort: abort);
final results = primary.items;
if (limit <= 0 || primary.rawCount < limit) return results;
final presentTypes = {for (final item in results) item.type};
final missingSearchTypes = <String>[
if (!presentTypes.contains('movie')) 'movies',
if (!presentTypes.contains('show')) 'tv',
if (!presentTypes.any(const {'artist', 'album', 'track'}.contains)) 'music',
];
if (missingSearchTypes.isEmpty) return results;
appLogger.i(
'Plex search response saturated; fetching omitted media categories '
'(${missingSearchTypes.join(',')}; ${results.length} usable results)',
);
final supplemental = await Future.wait([
for (final searchTypes in missingSearchTypes)
_searchSupplementalByTypes(query, searchTypes: searchTypes, limit: limit, abort: abort),
]);
abort?.throwIfAborted();
final deduplicated = <String, PlexMetadataDto>{};
for (final item in [...results, ...supplemental.expand((items) => items)]) {
final identity = item.ratingKey.isNotEmpty
? item.ratingKey
: '${item.type ?? ''}:${item.guid ?? ''}:${item.title ?? ''}';
deduplicated.putIfAbsent(identity, () => item);
}
return deduplicated.values.toList();
}
Future<List<PlexMetadataDto>> _searchSupplementalByTypes(
String query, {
required String searchTypes,
required int limit,
AbortController? abort,
}) async {
try {
final result = await _searchByTypes(query, searchTypes: searchTypes, limit: limit, abort: abort);
return result.items;
} catch (e, st) {
abort?.throwIfAborted();
if (e is MediaServerHttpException && e.isCancellation) rethrow;
appLogger.w('Plex supplemental $searchTypes search failed; keeping primary results', error: e, stackTrace: st);
return const [];
}
}
Future<({List<PlexMetadataDto> items, int rawCount})> _searchByTypes(
String query, {
required String searchTypes,
required int limit,
AbortController? abort,
}) async {
final response = await _getWithFailover(
'/library/search',
queryParameters: {
'query': query,
'limit': limit,
'searchTypes': 'movies,tv,music',
'searchTypes': searchTypes,
'includeCollections': 1,
'includeExternalMedia': 1,
'X-Plex-Container-Size': limit,
},
abort: abort,
);
final results = <PlexMetadataDto>[];
final container = _getMediaContainer(response);
if (container == null) return results;
if (container == null) return (items: results, rawCount: 0);
final searchResults = container['SearchResult'] as List?;
if (searchResults == null) return results;
if (searchResults == null) return (items: results, rawCount: 0);
for (final result in searchResults) {
try {
@@ -1427,7 +1483,7 @@ class PlexClient
}
}
return results;
return (items: results, rawCount: searchResults.length);
}
/// Get recently added media (filtered to video content only)
@@ -3473,8 +3529,8 @@ class PlexClient
}
@override
Future<List<MediaItem>> searchItems(String query, {int limit = 100}) async {
final results = await _search(query, limit: limit);
Future<List<MediaItem>> searchItems(String query, {int limit = 100, AbortController? abort}) async {
final results = await _search(query, limit: limit, abort: abort);
return results.map((m) => PlexMappers.mediaItem(m)).toList();
}
+103 -2
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/focus/focusable_text_field.dart';
import 'package:plezy/focus/key_event_utils.dart';
@@ -22,6 +23,7 @@ import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/widgets/focusable_media_card.dart';
import 'package:plezy/widgets/loading_indicator_box.dart';
import 'package:provider/provider.dart';
@@ -192,6 +194,89 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('partial server failure shows available results and a warning', (tester) async {
final failedClient = _FakeMediaServerClient(
serverIdValue: 'server_2',
serverNameValue: 'Offline Server',
items: const [],
searchError: MediaServerHttpException(type: MediaServerHttpErrorType.connectionError, message: 'refused'),
);
final (client, key) = await _pumpTvSearchScreen(tester, additionalClients: [failedClient]);
await tester.pumpAndSettle();
(key.currentState! as SearchInputFocusable).submitSearchQuery('movie');
await tester.pumpAndSettle();
expect(client.queries, ['movie']);
expect(failedClient.queries, ['movie']);
expect(find.text('Movie 1'), findsOneWidget);
expect(find.text(t.messages.searchPartialResults), findsOneWidget);
});
testWidgets('all server failures render the failed state instead of empty results', (tester) async {
final (client, key) = await _pumpTvSearchScreen(
tester,
searchError: MediaServerHttpException(type: MediaServerHttpErrorType.connectionError, message: 'refused'),
);
await tester.pumpAndSettle();
(key.currentState! as SearchInputFocusable).submitSearchQuery('movie');
await tester.pumpAndSettle();
expect(client.queries, ['movie']);
expect(find.text(t.explore.searchFailed), findsOneWidget);
expect(find.text(t.errors.searchUnavailable), findsOneWidget);
expect(find.text(t.messages.noResultsFound), findsNothing);
});
testWidgets('editing cancels the stale server request before the next debounce', (tester) async {
final (client, _) = await _pumpTvSearchScreen(tester);
await tester.pumpAndSettle();
final gate = Completer<void>();
client.searchGate = gate;
_searchController(tester).text = 'first';
await tester.pump(const Duration(milliseconds: 500));
await tester.pump();
expect(client.queries, ['first']);
final staleAbort = client.lastSearchAbort;
expect(staleAbort, isNotNull);
expect(staleAbort!.isAborted, isFalse);
_searchController(tester).text = 'second';
await tester.pump();
expect(staleAbort.isAborted, isTrue);
expect(client.queries, ['first']);
gate.complete();
await tester.pump(const Duration(milliseconds: 500));
await tester.pumpAndSettle();
expect(client.queries, ['first', 'second']);
expect(find.text(t.explore.searchFailed), findsNothing);
});
testWidgets('all-server cancellation preserves prior results without an error', (tester) async {
final (client, key) = await _pumpTvSearchScreen(tester);
await tester.pumpAndSettle();
(key.currentState! as SearchInputFocusable).submitSearchQuery('movie');
await tester.pumpAndSettle();
expect(find.text('Movie 1'), findsOneWidget);
client.searchError = MediaServerHttpException(
type: MediaServerHttpErrorType.cancelled,
message: 'connection replaced',
);
(key.currentState! as SearchInputFocusable).submitSearchQuery('second');
await tester.pumpAndSettle();
expect(client.queries, ['movie', 'second']);
expect(find.text('Movie 1'), findsOneWidget);
expect(find.text(t.explore.searchFailed), findsNothing);
expect(find.text(t.errors.searchUnavailable), findsNothing);
});
testWidgets('card refresh stays server-qualified without restarting search', (tester) async {
final serverOneItem = testMediaItem(
id: 'shared-id',
@@ -279,6 +364,7 @@ Future<(_FakeMediaServerClient, GlobalKey<State<SearchScreen>>)> _pumpTvSearchSc
// When false, no server is registered, so performSearchQuery throws — the
// path a companion-remote submit hits when the search fails outright.
bool registerClient = true,
Object? searchError,
List<_FakeMediaServerClient> additionalClients = const [],
}) async {
TvDetectionService.debugSetAppleTVOverride(true);
@@ -302,6 +388,7 @@ Future<(_FakeMediaServerClient, GlobalKey<State<SearchScreen>>)> _pumpTvSearchSc
serverName: 'Server',
),
],
searchError: searchError,
);
final manager = MultiServerManager();
if (registerClient) manager.debugRegisterClientForTesting(client);
@@ -351,12 +438,20 @@ class _FakeMediaServerClient implements MediaServerClient {
final String serverIdValue;
final String serverNameValue;
final List<MediaItem> items;
Object? searchError;
final List<String> queries = [];
final List<String> fetchedItemIds = [];
MediaItem? itemResult;
Completer<void>? fetchGate;
Completer<void>? searchGate;
AbortController? lastSearchAbort;
_FakeMediaServerClient({required this.items, this.serverIdValue = 'server_1', this.serverNameValue = 'Server'});
_FakeMediaServerClient({
required this.items,
this.serverIdValue = 'server_1',
this.serverNameValue = 'Server',
this.searchError,
});
@override
ServerId get serverId => ServerId(serverIdValue);
@@ -371,8 +466,14 @@ class _FakeMediaServerClient implements MediaServerClient {
ServerCapabilities get capabilities => ServerCapabilities.plex;
@override
Future<List<MediaItem>> searchItems(String query, {int limit = 100}) async {
Future<List<MediaItem>> searchItems(String query, {int limit = 100, AbortController? abort}) async {
queries.add(query);
lastSearchAbort = abort;
abort?.throwIfAborted();
if (searchError != null) throw searchError!;
final gate = searchGate;
if (gate != null) await gate.future;
abort?.throwIfAborted();
return items;
}
@@ -12,14 +12,17 @@ import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/models/plex/plex_config.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/prefs.dart';
JellyfinConnection _conn() => testJellyfinConnection(
@@ -31,11 +34,15 @@ JellyfinConnection _conn() => testJellyfinConnection(
http.Response _json(Object body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'});
/// Minimal client whose `fetchLibraries` either returns canned libraries or
/// throws [error] — enough surface to exercise the fan-out's per-server
/// failure classification without a real backend.
/// Minimal client with independently configurable library and search outcomes.
class _LibrariesClient implements MediaServerClient {
_LibrariesClient(this.serverId, {this.error, this.libraries = const []});
_LibrariesClient(
this.serverId, {
this.error,
this.libraries = const [],
this.searchError,
this.searchResults = const [],
});
@override
final ServerId serverId;
@@ -45,6 +52,8 @@ class _LibrariesClient implements MediaServerClient {
final Object? error;
final List<MediaLibrary> libraries;
final Object? searchError;
final List<MediaItem> searchResults;
@override
Future<List<MediaLibrary>> fetchLibraries() async {
@@ -52,6 +61,13 @@ class _LibrariesClient implements MediaServerClient {
return libraries;
}
@override
Future<List<MediaItem>> searchItems(String query, {int limit = 100, AbortController? abort}) async {
abort?.throwIfAborted();
if (searchError != null) throw searchError!;
return searchResults;
}
@override
void close() {}
@@ -91,7 +107,11 @@ void main() {
});
test('searchAcrossServers and getOnDeckFromAllServers return empty when no clients', () async {
expect(await service.searchAcrossServers('hello'), isEmpty);
final search = await service.searchAcrossServers('hello');
expect(search.items, isEmpty);
expect(search.succeededServerIds, isEmpty);
expect(search.cancelledServerIds, isEmpty);
expect(search.failedServerIds, isEmpty);
final onDeck = await service.getOnDeckFromAllServers();
expect(onDeck.items, isEmpty);
expect(onDeck.succeededServerIds, isEmpty);
@@ -128,6 +148,36 @@ void main() {
expect(result.cancelledServerIds, {'torn-down'});
});
test('search classifies successful, cancelled, and failed servers independently', () async {
final item = testMediaItem(
id: 'show-1',
backend: MediaBackend.plex,
kind: MediaKind.show,
title: 'Target',
serverId: 'ok',
);
manager.debugRegisterClientForTesting(_LibrariesClient(ServerId('ok'), searchResults: [item]));
manager.debugRegisterClientForTesting(
_LibrariesClient(
ServerId('cancelled'),
searchError: MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'superseded search'),
),
);
manager.debugRegisterClientForTesting(
_LibrariesClient(
ServerId('failed'),
searchError: MediaServerHttpException(type: MediaServerHttpErrorType.connectionError, message: 'refused'),
),
);
final result = await service.searchAcrossServers('Target');
expect(result.items.map((item) => item.id), ['show-1']);
expect(result.succeededServerIds, {'ok'});
expect(result.cancelledServerIds, {'cancelled'});
expect(result.failedServerIds, {'failed'});
});
test('searchAcrossServers overfetches and ranks before trimming across backends', () async {
final plexRequests = <Uri>[];
final jellyfinRequests = <Uri>[];
@@ -181,7 +231,7 @@ void main() {
final results = await service.searchAcrossServers('Spider Man', limit: 1);
expect(results.map((item) => item.id), ['jf-show']);
expect(results.items.map((item) => item.id), ['jf-show']);
expect(plexRequests.single.queryParameters['query'], 'Spider Man');
expect(plexRequests.single.queryParameters['limit'], '100');
expect(plexRequests.single.queryParameters['searchTypes'], 'movies,tv,music');
+83
View File
@@ -56,4 +56,87 @@ void main() {
expect(captured.single.queryParameters['X-Plex-Container-Size'], '100');
expect(captured.single.queryParameters['searchTypes'], 'movies,tv,music');
});
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']));
});
}