From 1fea9ef6e3c2c300356a84a32b227aee24dd3808 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:16:45 +0200 Subject: [PATCH] fix(search): recover omitted Plex media categories close #1598 --- lib/i18n/bg.i18n.json | 2 + lib/i18n/da.i18n.json | 2 + lib/i18n/de.i18n.json | 2 + lib/i18n/en.i18n.json | 2 + lib/i18n/es.i18n.json | 2 + lib/i18n/fr.i18n.json | 2 + lib/i18n/hu.i18n.json | 2 + lib/i18n/it.i18n.json | 2 + lib/i18n/ja.i18n.json | 2 + lib/i18n/ko.i18n.json | 2 + lib/i18n/nb.i18n.json | 2 + lib/i18n/nl.i18n.json | 2 + lib/i18n/pl.i18n.json | 2 + lib/i18n/pt.i18n.json | 2 + lib/i18n/ru.i18n.json | 2 + lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_en.g.dart | 12 +- lib/i18n/sv.i18n.json | 2 + lib/i18n/zh-Hant.i18n.json | 2 + lib/i18n/zh.i18n.json | 2 + lib/media/media_server_client.dart | 7 +- lib/mixins/debounced_media_search.dart | 19 +++- lib/screens/search_screen.dart | 52 ++++++++- lib/services/data_aggregation_service.dart | 77 +++++++++++-- .../jellyfin_client/parts/browse.dart | 29 +++-- .../jellyfin_client/parts/live_tv.dart | 2 + lib/services/plex_client.dart | 76 +++++++++++-- test/screens/search_screen_test.dart | 105 +++++++++++++++++- .../data_aggregation_bridge_test.dart | 62 ++++++++++- test/services/plex_search_test.dart | 83 ++++++++++++++ 30 files changed, 512 insertions(+), 50 deletions(-) diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 707628da..d677123d 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -558,6 +558,7 @@ }, "removedFromContinueWatching": "Премахнато от продължаване на гледането", "errorLoading": "Грешка: ${error}", + "searchPartialResults": "", "streamInterrupted": "Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.", "liveStreamInterrupted": "Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.", "fileInfoNotAvailable": "Информацията за файла не е налична", @@ -743,6 +744,7 @@ }, "errors": { "searchFailed": "Търсенето е неуспешно: ${error}", + "searchUnavailable": "", "connectionTimeout": "Изтече времето за връзка при зареждане на ${context}", "connectionFailed": "Не може да се осъществи връзка с медиен сървър", "unableToLoad": "Не може да се зареди ${context}. Опитайте отново.", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 657a0541..e5fbca34 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -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.", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 0f7e8591..4c75a897 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -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.", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 3e5822b2..3c52d1a5 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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.", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index be1b6299..416c4dae 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -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.", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 8096ef78..04303fc2 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -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.", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index d7d182c6..cc01fc44 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -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.", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index f4430f09..98a08ac9 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -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.", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 011922e8..1cbe1bc2 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -557,6 +557,7 @@ }, "removedFromContinueWatching": "視聴中から削除しました", "errorLoading": "エラー: ${error}", + "searchPartialResults": "", "streamInterrupted": "ストリームが中断されました。再生を押すかシークして再試行してください。", "liveStreamInterrupted": "ライブストリームが中断されました。再生を押して再試行してください。", "fileInfoNotAvailable": "ファイル情報が利用できません", @@ -742,6 +743,7 @@ }, "errors": { "searchFailed": "検索に失敗しました: ${error}", + "searchUnavailable": "", "connectionTimeout": "${context}の読み込み中に接続がタイムアウトしました", "connectionFailed": "メディアサーバーに接続できません", "unableToLoad": "${context}を読み込めませんでした。もう一度お試しください。", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 41e99f49..bdf4cf59 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -557,6 +557,7 @@ }, "removedFromContinueWatching": "계속 시청 목록에서 제거됨", "errorLoading": "오류: ${error}", + "searchPartialResults": "", "streamInterrupted": "스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.", "liveStreamInterrupted": "라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.", "fileInfoNotAvailable": "파일 정보가 없습니다", @@ -742,6 +743,7 @@ }, "errors": { "searchFailed": "검색 실패: ${error}", + "searchUnavailable": "", "connectionTimeout": "${context} 로드 중 연결 시간 초과", "connectionFailed": "미디어 서버에 연결할 수 없습니다", "unableToLoad": "${context}을(를) 불러올 수 없습니다. 다시 시도하세요.", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 22110408..27454aff 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -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.", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 674fa91e..e019a451 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -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.", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index ba1ac2f3..31fdd24e 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -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.", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 3d2c2ccd..05833897 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -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.", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index fbefd1e9..c051362c 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -560,6 +560,7 @@ }, "removedFromContinueWatching": "Удалено из «Продолжить просмотр»", "errorLoading": "Ошибка: ${error}", + "searchPartialResults": "", "streamInterrupted": "Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.", "liveStreamInterrupted": "Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.", "fileInfoNotAvailable": "Информация о файле недоступна", @@ -745,6 +746,7 @@ }, "errors": { "searchFailed": "Ошибка поиска: ${error}", + "searchUnavailable": "", "connectionTimeout": "Таймаут подключения при загрузке ${context}", "connectionFailed": "Не удалось подключиться к медиасерверу", "unableToLoad": "Не удалось загрузить ${context}. Повторите попытку.", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 89de0e64..ce68aa71 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -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 diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 3157693a..4a0b1273 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index ef269a85..f4a0ffb4 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -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.", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index cc866811..1aa81d62 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -557,6 +557,7 @@ }, "removedFromContinueWatching": "已從「繼續觀看」中移除", "errorLoading": "錯誤:${error}", + "searchPartialResults": "", "streamInterrupted": "影片串流中斷。請按播放鍵或拖動進度條重試。", "liveStreamInterrupted": "直播串流中斷。請按播放鍵重試。", "fileInfoNotAvailable": "無法取得檔案資訊", @@ -742,6 +743,7 @@ }, "errors": { "searchFailed": "搜尋失敗:${error}", + "searchUnavailable": "", "connectionTimeout": "載入 ${context} 時連線逾時", "connectionFailed": "無法連線至媒體伺服器", "unableToLoad": "無法載入 ${context}。請重試。", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 91e4e29a..309eae76 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -557,6 +557,7 @@ }, "removedFromContinueWatching": "已从继续观看中移除", "errorLoading": "出错:${error}", + "searchPartialResults": "", "streamInterrupted": "视频流已中断。按播放键或拖动进度条重试。", "liveStreamInterrupted": "直播流已中断。按播放键重试。", "fileInfoNotAvailable": "文件信息不可用", @@ -742,6 +743,7 @@ }, "errors": { "searchFailed": "搜索失败:${error}", + "searchUnavailable": "", "connectionTimeout": "加载 ${context} 时连接超时", "connectionFailed": "无法连接到媒体服务器", "unableToLoad": "无法加载${context}。请重试。", diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 8ac9894c..edf6bb5f 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -268,8 +268,11 @@ abstract class MediaServerClient { /// [Lyrics.synced]; gated by [ServerCapabilities.lyrics]. Future fetchLyrics(MediaItem track); - /// Free-text search across the user's libraries. - Future> 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> searchItems(String query, {int limit = 100, AbortController? abort}); /// Recently-added items across all libraries. Future> fetchRecentlyAdded({int limit = 50}); diff --git a/lib/mixins/debounced_media_search.dart b/lib/mixins/debounced_media_search.dart index 1fd7ce19..f09ed606 100644 --- a/lib/mixins/debounced_media_search.dart +++ b/lib/mixins/debounced_media_search.dart @@ -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 on State { /// 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> performSearchQuery(String query); /// A failed search was applied to the state (e.g. show a snackbar). @@ -51,6 +52,11 @@ mixin DebouncedMediaSearch on State { /// 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 on State { @override void dispose() { _debounceTimer?.cancel(); + onSearchInvalidated(); searchController.removeListener(_onSearchTextChanged); searchController.dispose(); searchFocusNode.dispose(); @@ -88,6 +95,7 @@ mixin DebouncedMediaSearch on State { if (query.isEmpty) { _debounceTimer?.cancel(); _searchGeneration++; + if (_inFlightQuery != null) onSearchInvalidated(); _inFlightQuery = null; setState(() { searchResults = []; @@ -118,6 +126,7 @@ mixin DebouncedMediaSearch on State { bool _invalidateStaleInFlight(String current) { if (_inFlightQuery == null || _inFlightQuery == current) return false; _searchGeneration++; + onSearchInvalidated(); _inFlightQuery = null; return true; } @@ -125,6 +134,7 @@ mixin DebouncedMediaSearch on State { /// Run [query] now, bypassing the debounce (submit, external refresh). Future 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 on State { }); 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 = []; diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index e91f9b3e..676446dd 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -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 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 Future> performSearchQuery(String query) async { final multiServerProvider = Provider.of(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 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 ); } } + +final class _SearchUnavailableException implements Exception { + const _SearchUnavailableException(); +} diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 99456a41..6f1910d7 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -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 succeededServerIds, Set cancelledServerIds, }); +typedef SearchAggregationResult = ({ + List items, + Set succeededServerIds, + Set cancelledServerIds, + Set 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 _searchKindCounts(Iterable items) { + final counts = {}; + 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> 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 searchAcrossServers(String query, {int? limit, AbortController? abort}) async { if (query.trim().isEmpty) { - return []; + return ( + items: const [], + succeededServerIds: const {}, + cancelledServerIds: const {}, + failedServerIds: const {}, + ); } + abort?.throwIfAborted(); final clients = _serverManager.onlineClients; - if (clients.isEmpty) return []; + if (clients.isEmpty) { + return ( + items: const [], + succeededServerIds: const {}, + cancelledServerIds: const {}, + failedServerIds: const {}, + ); + } final resultLimit = limit ?? defaultMediaSearchLimit; final fetchLimit = resultLimit < defaultMediaSearchLimit ? defaultMediaSearchLimit : resultLimit; + final succeededServerIds = {}; + final cancelledServerIds = {}; + final failedServerIds = {}; 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 []; } }); - 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 diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 3c9084a9..5a9ac2a2 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -1053,7 +1053,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { } @override - Future> searchItems(String query, {int limit = 100}) async { + Future> 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 _getItemsResponse( String path, Map 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 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 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) { diff --git a/lib/services/jellyfin_client/parts/live_tv.dart b/lib/services/jellyfin_client/parts/live_tv.dart index 471f9353..2c132aaf 100644 --- a/lib/services/jellyfin_client/parts/live_tv.dart +++ b/lib/services/jellyfin_client/parts/live_tv.dart @@ -9,6 +9,8 @@ mixin _JellyfinLiveTvMethods on MediaServerCacheMixin { Map queryParameters, { // ignore: unused_element_parameter _HubRetryPolicy? retry, + // ignore: unused_element_parameter + AbortController? abort, }); /// Returns `true` when this server has Live TV configured (channels diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 79541d74..9290d139 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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> _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> _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 = [ + 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 = {}; + 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> _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 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 = []; - 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> searchItems(String query, {int limit = 100}) async { - final results = await _search(query, limit: limit); + Future> 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(); } diff --git a/test/screens/search_screen_test.dart b/test/screens/search_screen_test.dart index 1e2f499e..5063c6ca 100644 --- a/test/screens/search_screen_test.dart +++ b/test/screens/search_screen_test.dart @@ -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(); + 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>)> _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>)> _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 items; + Object? searchError; final List queries = []; final List fetchedItemIds = []; MediaItem? itemResult; Completer? fetchGate; + Completer? 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> searchItems(String query, {int limit = 100}) async { + Future> 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; } diff --git a/test/services/data_aggregation_bridge_test.dart b/test/services/data_aggregation_bridge_test.dart index 9e4a4567..acdd814b 100644 --- a/test/services/data_aggregation_bridge_test.dart +++ b/test/services/data_aggregation_bridge_test.dart @@ -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 libraries; + final Object? searchError; + final List searchResults; @override Future> fetchLibraries() async { @@ -52,6 +61,13 @@ class _LibrariesClient implements MediaServerClient { return libraries; } + @override + Future> 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 = []; final jellyfinRequests = []; @@ -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'); diff --git a/test/services/plex_search_test.dart b/test/services/plex_search_test.dart index c9e9d3c7..9ae77cb1 100644 --- a/test/services/plex_search_test.dart +++ b/test/services/plex_search_test.dart @@ -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 = []; + final primaryResults = >[ + 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'}, + }, + ], + _ => >[], + }; + 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 = []; + final primaryResults = >[ + 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'])); + }); }