diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index 1bd2e26b7..88f947a6a 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -1263,6 +1263,10 @@ "notInLibrary": "Kitabxananızda yoxdur", "inTheseLibraries": "Bu kitabxanalarda var", "checkingLibrary": "Kitabxananız yoxlanılır...", + "libraryCheckFailed": { + "one": "", + "other": "" + }, "emptyTitle": "Hələlik burada heç nə yoxdur", "emptyMessage": "${source} mənbəsindən olan sətirlər burada görünəcək.", "searchHint": "${source} daxilində axtar", diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 62ddf316a..37971ecdb 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -1263,6 +1263,10 @@ "notInLibrary": "Не е в твоята библиотека", "inTheseLibraries": "В тези библиотеки", "checkingLibrary": "Проверка на твоята библиотека...", + "libraryCheckFailed": { + "one": "", + "other": "" + }, "emptyTitle": "Тук все още няма нищо", "emptyMessage": "Редовете от ${source} ще се появят тук, когато има съдържание.", "searchHint": "Търсене в ${source}", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 611fd8b29..a87e2aa8c 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -1263,6 +1263,10 @@ "notInLibrary": "Ikke i dit bibliotek", "inTheseLibraries": "I disse biblioteker", "checkingLibrary": "Tjekker dit bibliotek...", + "libraryCheckFailed": { + "one": "", + "other": "" + }, "emptyTitle": "Der er ikke noget her endnu", "emptyMessage": "Indholdsrækker fra ${source} vises her, når de har indhold.", "searchHint": "Søg i ${source}", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 37196072b..36910fd2c 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -1263,6 +1263,10 @@ "notInLibrary": "Nicht in deiner Mediathek", "inTheseLibraries": "In diesen Mediatheken", "checkingLibrary": "Deine Mediathek wird überprüft …", + "libraryCheckFailed": { + "one": "", + "other": "" + }, "emptyTitle": "Hier ist noch nichts", "emptyMessage": "Zeilen aus ${source} erscheinen hier, sobald sie Inhalte enthalten.", "searchHint": "${source} durchsuchen", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 60bd9b1b6..c4105cd5c 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -1263,6 +1263,10 @@ "notInLibrary": "Not in your library", "inTheseLibraries": "In these libraries", "checkingLibrary": "Checking your library...", + "libraryCheckFailed": { + "one": "Couldn't check ${n} server", + "other": "Couldn't check ${n} servers" + }, "emptyTitle": "Nothing here yet", "emptyMessage": "Rows from ${source} will appear here once they have content.", "searchHint": "Search ${source}", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index ce27f2541..9da3bba85 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -1263,6 +1263,10 @@ "notInLibrary": "No está en tu biblioteca", "inTheseLibraries": "En estas bibliotecas", "checkingLibrary": "Comprobando tu biblioteca...", + "libraryCheckFailed": { + "one": "", + "other": "" + }, "emptyTitle": "Aquí no hay nada todavía", "emptyMessage": "Las filas de ${source} aparecerán aquí cuando tengan contenido.", "searchHint": "Buscar en ${source}", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 3920e89d5..45825061e 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -1263,6 +1263,10 @@ "notInLibrary": "Absent de votre bibliothèque", "inTheseLibraries": "Dans ces bibliothèques", "checkingLibrary": "Vérification de votre bibliothèque...", + "libraryCheckFailed": { + "one": "", + "other": "" + }, "emptyTitle": "Rien ici pour l'instant", "emptyMessage": "Les lignes de ${source} apparaîtront ici dès qu’elles contiendront des éléments.", "searchHint": "Rechercher dans ${source}", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index d6ec6ef7e..306351511 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -1263,6 +1263,10 @@ "notInLibrary": "Nincs a könyvtáradban", "inTheseLibraries": "Ezekben a könyvtárakban", "checkingLibrary": "Könyvtár ellenőrzése...", + "libraryCheckFailed": { + "one": "", + "other": "" + }, "emptyTitle": "Még nincs itt semmi", "emptyMessage": "A(z) ${source} forrásból származó sorok itt fognak megjelenni, amint van tartalmuk.", "searchHint": "Keresés itt: ${source}", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 7861b449f..04515376f 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -1263,6 +1263,10 @@ "notInLibrary": "Non è nella tua libreria", "inTheseLibraries": "In queste librerie", "checkingLibrary": "Ricerca nella tua libreria...", + "libraryCheckFailed": { + "one": "", + "other": "" + }, "emptyTitle": "Ancora niente qui", "emptyMessage": "Le sezioni di ${source} appariranno qui quando saranno disponibili dei contenuti.", "searchHint": "Cerca su ${source}", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 3a581462c..6ea2e7fbc 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -1257,6 +1257,9 @@ "notInLibrary": "ライブラリにありません", "inTheseLibraries": "これらのライブラリにあります", "checkingLibrary": "ライブラリを確認中…", + "libraryCheckFailed": { + "other": "" + }, "emptyTitle": "まだ何もありません", "emptyMessage": "${source}にコンテンツが追加されると、ここに表示されます。", "searchHint": "${source}を検索", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 2a5a637c9..0d4c1bc1a 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -1263,6 +1263,10 @@ "notInLibrary": "Кітапханаңызда жоқ", "inTheseLibraries": "Осы кітапханаларда бар", "checkingLibrary": "Кітапхана тексерілуде...", + "libraryCheckFailed": { + "one": "", + "other": "" + }, "emptyTitle": "Әлі де мұнда ештеңе жоқ", "emptyMessage": "${source} дереккөзінен алынған қатарлар мұнда көрінеді.", "searchHint": "${source} ішінен іздеу", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 8bcaa7468..50906d1cc 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -1257,6 +1257,9 @@ "notInLibrary": "라이브러리에 없음", "inTheseLibraries": "이 라이브러리에 있음", "checkingLibrary": "라이브러리 확인 중...", + "libraryCheckFailed": { + "other": "" + }, "emptyTitle": "아직 아무것도 없습니다", "emptyMessage": "${source}에 콘텐츠가 추가되면 여기에 표시됩니다.", "searchHint": "${source}에서 검색", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index c40e66a5d..a8367c3f5 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -1263,6 +1263,10 @@ "notInLibrary": "Ikke i biblioteket ditt", "inTheseLibraries": "I disse bibliotekene", "checkingLibrary": "Sjekker biblioteket ditt...", + "libraryCheckFailed": { + "one": "", + "other": "" + }, "emptyTitle": "Ingenting her ennå", "emptyMessage": "Rader fra ${source} vises her når de har innhold.", "searchHint": "Søk i ${source}", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 1f13deb8f..c70e182cc 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -1263,6 +1263,10 @@ "notInLibrary": "Niet in je bibliotheek", "inTheseLibraries": "In deze bibliotheken", "checkingLibrary": "Je bibliotheek controleren...", + "libraryCheckFailed": { + "one": "", + "other": "" + }, "emptyTitle": "Hier is nog niets", "emptyMessage": "Rijen van ${source} verschijnen hier zodra ze inhoud hebben.", "searchHint": "Zoeken in ${source}", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index a4533bc44..e29f15b21 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -1275,6 +1275,12 @@ "notInLibrary": "Nie ma tego w Twojej bibliotece", "inTheseLibraries": "W tych bibliotekach", "checkingLibrary": "Sprawdzanie Twojej biblioteki...", + "libraryCheckFailed": { + "one": "", + "few": "", + "many": "", + "other": "" + }, "emptyTitle": "Jeszcze nic tu nie ma", "emptyMessage": "Wiersze z ${source} pojawią się tutaj, gdy będą zawierać treści.", "searchHint": "Szukaj w ${source}", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index e88910162..bf8abe643 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -1263,6 +1263,10 @@ "notInLibrary": "Não está na sua biblioteca", "inTheseLibraries": "Nestas bibliotecas", "checkingLibrary": "Verificando sua biblioteca...", + "libraryCheckFailed": { + "one": "", + "other": "" + }, "emptyTitle": "Ainda não há nada aqui", "emptyMessage": "As linhas de ${source} aparecerão aqui quando tiverem conteúdo.", "searchHint": "Buscar em ${source}", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 7eeba3b01..3dff07c84 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -1275,6 +1275,12 @@ "notInLibrary": "Нет в вашей библиотеке", "inTheseLibraries": "В этих библиотеках", "checkingLibrary": "Проверка вашей библиотеки...", + "libraryCheckFailed": { + "one": "", + "few": "", + "many": "", + "other": "" + }, "emptyTitle": "Здесь пока ничего нет", "emptyMessage": "Разделы из ${source} появятся здесь, когда в них появится контент.", "searchHint": "Поиск в ${source}", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 2010731f7..3adb4b779 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 22 -/// Strings: 43681 (1985 per locale) +/// Strings: 43683 (1985 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 87fd07c37..c622287ef 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -3447,6 +3447,12 @@ class Translations$explore$en { /// en: 'Checking your library...' String get checkingLibrary => 'Checking your library...'; + /// en: '(one) {Couldn't check ${n} server} (other) {Couldn't check ${n} servers}' + String libraryCheckFailed({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, + one: 'Couldn\'t check ${n} server', + other: 'Couldn\'t check ${n} servers', + ); + /// en: 'Nothing here yet' String get emptyTitle => 'Nothing here yet'; @@ -8153,6 +8159,7 @@ extension on Translations { 'explore.notInLibrary' => 'Not in your library', 'explore.inTheseLibraries' => 'In these libraries', 'explore.checkingLibrary' => 'Checking your library...', + 'explore.libraryCheckFailed' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'Couldn\'t check ${n} server', other: 'Couldn\'t check ${n} servers', ), 'explore.emptyTitle' => 'Nothing here yet', 'explore.emptyMessage' => ({required Object source}) => 'Rows from ${source} will appear here once they have content.', 'explore.searchHint' => ({required Object source}) => 'Search ${source}', @@ -8540,9 +8547,9 @@ extension on Translations { 'downloads.pauseAll' => 'Pause all', 'downloads.resumeAll' => 'Resume all', 'downloads.deleteAll' => 'Delete all', - 'downloads.selectVersion' => 'Select Version', _ => null, } ?? switch (path) { + 'downloads.selectVersion' => 'Select Version', 'downloads.allEpisodes' => 'All episodes', 'downloads.unwatchedOnly' => 'Unwatched only', 'downloads.nextNUnwatched' => ({required Object count}) => 'Next ${count} unwatched', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index d9601c21b..425c8fd86 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -1263,6 +1263,10 @@ "notInLibrary": "Finns inte i ditt bibliotek", "inTheseLibraries": "I dessa bibliotek", "checkingLibrary": "Kontrollerar ditt bibliotek...", + "libraryCheckFailed": { + "one": "", + "other": "" + }, "emptyTitle": "Inget här ännu", "emptyMessage": "Rader från ${source} visas här när de har innehåll.", "searchHint": "Sök i ${source}", diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index bbab5bdad..46ccbae1d 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -1263,6 +1263,10 @@ "notInLibrary": "Kitaplığınızda yok", "inTheseLibraries": "Bu kitaplıklarda var", "checkingLibrary": "Kitaplığınız kontrol ediliyor...", + "libraryCheckFailed": { + "one": "", + "other": "" + }, "emptyTitle": "Henüz burada bir şey yok", "emptyMessage": "${source} kaynağındaki satırlar içerik bulunduğunda burada görünecektir.", "searchHint": "${source} üzerinde ara", diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index 2c08923da..345ad925f 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -1263,6 +1263,10 @@ "notInLibrary": "Kutubxonangizda yoʻq", "inTheseLibraries": "Ushbu kutubxonalarda bor", "checkingLibrary": "Kutubxona tekshirilmoqda...", + "libraryCheckFailed": { + "one": "", + "other": "" + }, "emptyTitle": "Hali bu yerda hech narsa yoʻq", "emptyMessage": "${source} manbasidan olingan qatorlar bu yerda koʻrinadi.", "searchHint": "${source} ichidan qidirish", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index 5aa9532c3..bd2c1b1b1 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -1257,6 +1257,9 @@ "notInLibrary": "不在您的媒體庫中", "inTheseLibraries": "在這些媒體庫中", "checkingLibrary": "正在檢查您的媒體庫…", + "libraryCheckFailed": { + "other": "" + }, "emptyTitle": "這裡還沒有任何內容", "emptyMessage": "當 ${source} 有內容時,相關資訊將顯示在此處。", "searchHint": "搜尋 ${source}", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 264ddd7ec..14a59c0a7 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -1257,6 +1257,9 @@ "notInLibrary": "不在你的媒体库中", "inTheseLibraries": "在这些媒体库中", "checkingLibrary": "正在检查你的媒体库…", + "libraryCheckFailed": { + "other": "" + }, "emptyTitle": "这里还什么都没有", "emptyMessage": "当 ${source} 有内容时,相关内容将显示在这里。", "searchHint": "搜索 ${source}", diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 6a406ac0b..5c107daa1 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -618,12 +618,16 @@ abstract class MediaServerClient { /// the caller (the Explore "In these libraries" chooser) exists to show /// them. Ordering is the implementation's, and callers re-sort. /// - /// [titles] are tried in order until one yields id-verified candidates; - /// pass the entry's own title first and broader forms after (see - /// `titleMatchCandidates`). A sequel entry's own title never matches its - /// parent show, which is why more than one is needed. [year] applies a ±1 - /// window to the first attempt only — for a sequel the catalog year is the - /// season's, not the show's. + /// Every entry of [titles] is searched, and the union of their id-verified + /// candidates is returned. The caller (`titleMatchCandidates`) already + /// bounds the list, and that cap is the request budget; a title that hit + /// MUST NOT stop the others, because the copies it missed are exactly the + /// ones filed under another language's title or a split-season name, and + /// id verification means an extra title can only add genuine copies. A + /// sequel entry's own title never matches its parent show, which is why the + /// list carries season-stripped forms. [year] is a hint for backends whose + /// title search is a substring match: a ±1 window on the first title only, + /// since for a sequel the catalog year is the season's, not the show's. /// /// [plexGuid] is a Plex-only escape hatch: a `plex://show/…` guid the caller /// already holds, which the local server *can* filter on exactly. It is @@ -638,8 +642,10 @@ abstract class MediaServerClient { /// on a guess. /// /// Returns an empty list when this server has no match or [kind] is not - /// movie/show. Used to match external catalog items (Explore tab) back to - /// the user's libraries. + /// movie/show, and throws when the server could not be asked — a slow or + /// unreachable server is not evidence of absence, and the aggregation layer + /// reports it separately. Used to match external catalog items (Explore + /// tab) back to the user's libraries. Future> findByExternalIds( ExternalIds ids, { required MediaKind kind, diff --git a/lib/screens/catalog_item_detail_screen.dart b/lib/screens/catalog_item_detail_screen.dart index 034129517..7d71cb01a 100644 --- a/lib/screens/catalog_item_detail_screen.dart +++ b/lib/screens/catalog_item_detail_screen.dart @@ -30,6 +30,7 @@ import '../providers/catalog_sources_provider.dart'; import '../services/catalog/catalog_library_matcher.dart'; import '../services/catalog/catalog_source.dart'; import '../services/catalog/seerr_catalog_source.dart'; +import '../services/data_aggregation_service.dart'; import '../utils/app_logger.dart'; import '../utils/catalog_navigation_helper.dart'; import '../utils/desktop_window_padding.dart'; @@ -100,6 +101,13 @@ class _CatalogItemDetailScreenState extends State { /// Library items matching this catalog item; null while resolving. List? _matches; + /// Servers that answered at least one resolution pass, and servers that + /// failed one and have never answered. A server in the second set is shown + /// as unchecked rather than counted as "not in your library" — a slow or + /// unreachable server is no evidence of absence (#2098). + final Set _checkedServerIds = {}; + final Set _uncheckedServerIds = {}; + /// Cast/characters from the item's own source; null while loading (the /// section only renders once loaded non-empty). List? _cast; @@ -169,9 +177,9 @@ class _CatalogItemDetailScreenState extends State { } Future _resolveMatches(CatalogItem item) async { - List matches; + LibraryLookupResult result; try { - matches = await context.read().match(item); + result = await context.read().match(item); } catch (e) { appLogger.w('Catalog library match failed for ${item.identityKey}', error: e); // A failed pass is no evidence about copies an earlier pass already @@ -179,7 +187,13 @@ class _CatalogItemDetailScreenState extends State { if (_matches == null) _mergeMatches(const []); return; } - _mergeMatches(matches); + if (!mounted) return; + _checkedServerIds.addAll(result.succeededServerIds); + _uncheckedServerIds + ..addAll(result.failedServerIds) + ..addAll(result.cancelledServerIds) + ..removeAll(_checkedServerIds); + _mergeMatches(result.items); } /// Fold a resolution pass into the visible copies. @@ -629,11 +643,15 @@ class _CatalogItemDetailScreenState extends State { } /// Library availability, resolved in place: a progress row while the - /// matcher runs, "Not in your library" when nothing matched, otherwise an - /// "In these libraries" list whose rows open the normal media detail - /// screen. Rows are focusable tiles (dpad-safe, background focus effect). + /// matcher runs, "Not in your library" when every server answered and none + /// matched, "Couldn't check n servers" when nothing matched but a server + /// never answered, otherwise an "In these libraries" list whose rows open + /// the normal media detail screen — with the unchecked count beneath it + /// when a server sat the lookup out. Rows are focusable tiles (dpad-safe, + /// background focus effect). Widget _buildLibrarySection(ThemeData theme) { - final mutedStyle = theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.5)); + final mutedColor = theme.colorScheme.onSurface.withValues(alpha: 0.5); + final mutedStyle = theme.textTheme.bodyMedium?.copyWith(color: mutedColor); final matches = _matches; if (matches == null) { @@ -646,14 +664,19 @@ class _CatalogItemDetailScreenState extends State { ); } + final unchecked = _uncheckedServerIds.length; + Widget note(String text, {required IconData icon}) => Row( + children: [ + AppIcon(icon, fill: 1, size: 18, color: mutedColor), + const SizedBox(width: 8), + Expanded(child: Text(text, style: mutedStyle)), + ], + ); + if (matches.isEmpty) { - return Row( - children: [ - AppIcon(Symbols.info_rounded, fill: 1, size: 18, color: theme.colorScheme.onSurface.withValues(alpha: 0.5)), - const SizedBox(width: 8), - Text(t.explore.notInLibrary, style: mutedStyle), - ], - ); + return unchecked == 0 + ? note(t.explore.notInLibrary, icon: Symbols.info_rounded) + : note(t.explore.libraryCheckFailed(n: unchecked), icon: Symbols.cloud_off_rounded); } return Column( @@ -669,6 +692,10 @@ class _CatalogItemDetailScreenState extends State { for (var index = 0; index < matches.length; index++) _buildLibraryMatchTile(matches[index], index), ], ), + if (unchecked > 0) ...[ + const SizedBox(height: 8), + note(t.explore.libraryCheckFailed(n: unchecked), icon: Symbols.cloud_off_rounded), + ], ], ); } diff --git a/lib/services/catalog/catalog_library_matcher.dart b/lib/services/catalog/catalog_library_matcher.dart index 3dc420291..915f73d0a 100644 --- a/lib/services/catalog/catalog_library_matcher.dart +++ b/lib/services/catalog/catalog_library_matcher.dart @@ -5,29 +5,32 @@ import '../../media/media_kind.dart'; import '../../models/catalog/catalog_item.dart'; import '../../providers/multi_server_provider.dart'; import '../../utils/title_match_candidates.dart'; +import '../data_aggregation_service.dart'; /// Matches external catalog items back to the user's libraries. /// /// One reverse-lookup fan-out per tap (see /// `DataAggregationService.findByExternalIdsAcrossServers`), memoized for -/// the session: positive hits are kept (library membership rarely shrinks -/// mid-session), negatives expire so newly-added media is picked up. -/// Profile-scoped via the provider subtree, so a profile switch drops the -/// cache by construction. +/// the session: a complete positive wave is kept (library membership rarely +/// shrinks mid-session); negatives expire so newly-added media is picked up, +/// and so does any wave a server sat out — a slow or unreachable server is +/// not evidence of absence, and the next tap after [negativeTtl] asks it +/// again. Profile-scoped via the provider subtree, so a profile switch drops +/// the cache by construction. class CatalogLibraryMatcher { static const Duration negativeTtl = Duration(minutes: 10); final MultiServerProvider _multiServer; final DateTime Function() _now; - final Map items})> _cache = {}; + final Map _cache = {}; CatalogLibraryMatcher(this._multiServer) : _now = DateTime.now; @visibleForTesting CatalogLibraryMatcher.withClock(this._multiServer, this._now); - Future> match(CatalogItem item) async { - if (!item.ids.hasAny) return const []; + Future match(CatalogItem item) async { + if (!item.ids.hasAny) return _nothing; // Do not use `identityKey`: its canonical series ids make every MAL/AniList // season collide. All five Mushoku Tensei entries (`mal39535 s1`, // `mal45576 s1`, `mal51179 s2`, `mal55888 s2`, `mal59193 s3`) collapse to @@ -40,18 +43,16 @@ class CatalogLibraryMatcher { // negative. final key = '${item.source.name}/${item.entryIdentityKey}/${item.ids.allKeys.join(',')}'; final cached = _cache[key]; - if (cached != null && (cached.items.isNotEmpty || _now().difference(cached.at) < negativeTtl)) { - return cached.items; + if (cached != null && (_isAuthoritativeHit(cached.result) || _now().difference(cached.at) < negativeTtl)) { + return cached.result; } // A sequel entry's year is its own season's, not the parent show's, so a // ±1 window around it excludes the very show we are looking for. Fribb // does not map a season for every entry, so fall back to the title: a - // strippable season suffix says "sequel" just as reliably. Dropping the - // year here also keeps the lookup at two requests, because the client no - // longer spends one on a year-filtered attempt that cannot match. + // strippable season suffix says "sequel" just as reliably. final isSequel = (item.season?.isSequel ?? false) || stripSeasonSuffix(item.title) != null; - final matches = await _multiServer.aggregationService.findByExternalIdsAcrossServers( + final result = await _multiServer.aggregationService.findByExternalIdsAcrossServers( item.ids.toExternalIds(), kind: item.kind, titles: titleMatchCandidates([item.title, ...item.altTitles]), @@ -59,10 +60,20 @@ class CatalogLibraryMatcher { plexGuid: _plexGuidFor(item), season: item.season, ); - _cache[key] = (at: _now(), items: matches); - return matches; + _cache[key] = (at: _now(), result: result); + return result; } + static bool _isAuthoritativeHit(LibraryLookupResult result) => + result.items.isNotEmpty && result.failedServerIds.isEmpty && result.cancelledServerIds.isEmpty; + + static const LibraryLookupResult _nothing = ( + items: [], + succeededServerIds: {}, + cancelledServerIds: {}, + failedServerIds: {}, + ); + /// The exact `plex://` guid for a Plex Discover item, which its own rating /// key already is. Free — no request, no cloud lookup; other sources get /// null and fall back to the title candidates. diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 412a579d4..eb74f4868 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -61,6 +61,17 @@ typedef SearchAggregationResult = ({ Set cancelledServerIds, Set failedServerIds, }); + +/// One reverse-lookup wave over every online server. `items` are the +/// id-verified copies, deduped and ordered best-first; a server in +/// `failedServerIds` or `cancelledServerIds` contributed nothing and says +/// nothing about what it holds. +typedef LibraryLookupResult = ({ + List items, + Set succeededServerIds, + Set cancelledServerIds, + Set failedServerIds, +}); typedef _FanOutResult = ({ List items, Set succeededServerIds, @@ -787,17 +798,19 @@ class DataAggregationService { /// Reverse external-id lookup fanned out to every online server (see /// [MediaServerClient.findByExternalIds]). One request wave per tap on an - /// Explore catalog item; per-server failures are logged and skipped. + /// Explore catalog item. /// /// Every server contributes every copy it holds, not one apiece: the same /// movie routinely sits in a 4K library and an HD library on one server /// (#1754). Results are deduped by global key and ordered best-first with /// [compareLibraryCopies] so the chooser is stable across repeated passes. /// - /// Because per-server failures are dropped here, a caller holding earlier - /// results must merge rather than replace (see [mergeLibraryCopies]) — a - /// degraded wave is not evidence that a copy went away. - Future> findByExternalIdsAcrossServers( + /// A server that could not be asked — slow past its lookup deadline, + /// unreachable, or aborted client-side — is reported in the failed or + /// cancelled set rather than folded into an empty answer (#2098): the + /// caller shows it as unchecked, and a caller holding earlier results must + /// merge rather than replace (see [mergeLibraryCopies]). + Future findByExternalIdsAcrossServers( ExternalIds ids, { required MediaKind kind, List titles = const [], @@ -805,27 +818,28 @@ class DataAggregationService { String? plexGuid, ExternalSeasonRef? season, }) async { - if (!ids.hasAny && plexGuid == null) return []; final clients = _serverManager.onlineClients; - if (clients.isEmpty) return []; + if ((!ids.hasAny && plexGuid == null) || clients.isEmpty) { + return ( + items: const [], + succeededServerIds: const {}, + cancelledServerIds: const {}, + failedServerIds: const {}, + ); + } - final futures = clients.entries.map((entry) async { - try { - return await entry.value.findByExternalIds( - ids, - kind: kind, - titles: titles, - year: year, - plexGuid: plexGuid, - season: season, - ); - } catch (e, st) { - appLogger.w('External-id lookup failed on ${entry.key}', error: e, stackTrace: st); - return const []; - } - }); - - return mergeLibraryCopies(const [], (await Future.wait(futures)).expand((items) => items)); + final fetched = await _fanOut( + clients, + failureMessage: (serverId) => 'External-id lookup failed on $serverId', + fetch: (_, client) => + client.findByExternalIds(ids, kind: kind, titles: titles, year: year, plexGuid: plexGuid, season: season), + ); + return ( + items: mergeLibraryCopies(const [], fetched.items), + succeededServerIds: fetched.succeededServerIds, + cancelledServerIds: fetched.cancelledServerIds, + failedServerIds: fetched.failedServerIds, + ); } /// Group libraries by server (internal aggregation helper). diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 2a3d31f53..428a5c778 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -19,6 +19,11 @@ const _HubRetryPolicy _continueWatchingRetry = ( deadline: MediaServerTimeouts.homeHubDeadline, ); +const _HubRetryPolicy _libraryLookupRetry = ( + operation: 'Jellyfin library lookup', + deadline: MediaServerTimeouts.libraryLookup, +); + List> _itemsArray(Object? data) { if (data is Map) { final items = data['Items']; @@ -1445,9 +1450,14 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { /// each candidate's inline `ProviderIds`. [plexGuid] is a Plex-only hint and /// has no meaning in Jellyfin's provider-id model, so it is ignored. /// - /// Every id-verified candidate of the first matching title is returned, not - /// just the first: one movie can sit in both a 4K library and an HD library - /// as two separate items, and the caller shows the user each copy (#1754). + /// Every title runs concurrently and every id-verified candidate of every + /// title is returned: one movie can sit in both a 4K library and an HD + /// library as two separate items (#1754), and a copy filed under another + /// language's title is only reachable through that title (#2098). Id + /// verification is the only correctness gate, so a title that hit cannot + /// make its siblings redundant. [year] narrows the entry's own title to a + /// ±1 window — `SearchTerm` is a substring match and the first page of a + /// short title can fill with other shows. /// /// Jellyfin cannot report season ordering to a non-admin: on 10.11.10, /// `/Library/VirtualFolders` returns 403 and Series items omit @@ -1476,30 +1486,32 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { // sequel's own year excludes the parent show (its year is season one's). final skipYearWindow = season?.isSequel ?? false; - for (var index = 0; index < titles.length; index++) { - final isFirstCandidate = index == 0; - final years = isFirstCandidate && year != null && !skipYearWindow ? '${year - 1},$year,${year + 1}' : null; - final candidates = await _fetchItemsArray('/Items', { - 'userId': connection.userId, - 'SearchTerm': titles[index], - 'Recursive': 'true', - 'Limit': isFirstCandidate ? '20' : '50', - 'IncludeItemTypes': itemType, - 'Fields': 'ProviderIds,$_browseFields', - 'years': ?years, - ...jellyfinImageQueryParameters, - }); - final matches = _mapItems(ExternalIds.jellyfinCandidatesMatching(candidates, ids)); - if (matches.isEmpty) continue; + // The window narrows the entry's own title only; broader forms are meant + // to reach the parent show, whose year is not the entry's. + final years = year != null && !skipYearWindow ? '${year - 1},$year,${year + 1}' : null; + final pages = await Future.wait([ + for (var index = 0; index < titles.length; index++) + _fetchItemsArray('/Items', { + 'userId': connection.userId, + 'SearchTerm': titles[index], + 'Recursive': 'true', + 'Limit': '50', + 'IncludeItemTypes': itemType, + 'Fields': 'ProviderIds,$_browseFields', + 'years': ?(index == 0 ? years : null), + ...jellyfinImageQueryParameters, + }, retry: _libraryLookupRetry), + ]); + final seen = {}; + final matches = [ + for (final page in pages) + for (final item in _mapItems(ExternalIds.jellyfinCandidatesMatching(page, ids))) + if (seen.add(item.id)) item, + ]; + if (matches.isEmpty) return const []; - final kept = shouldGateSeason ? await _keepMatchesWithSeason(matches, seasonIndex) : matches; - // A title that verified but has no season-gated survivor is a definitive - // "this server has the show, just not that season"; broader title forms - // would only reach other shows. - if (kept.isEmpty) return const []; - return Future.wait([for (final item in kept) _withLibraryFromAncestors(item)]); - } - return const []; + final kept = shouldGateSeason ? await _keepMatchesWithSeason(matches, seasonIndex) : matches; + return Future.wait([for (final item in kept) _withLibraryFromAncestors(item)]); } /// Keep only the series that actually have [seasonIndex]. One diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 14194cbbc..5be543d4f 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -4454,22 +4454,33 @@ class PlexClient /// Server-wide external-id reverse lookup. Plex's `guid=` field filter /// matches only the item's primary `plex://` guid (verified against PMS - /// 1.43), so a resolved [plexGuid] uses that exact filter while ids in - /// modern `Guid` arrays are verified client-side after a title search. + /// 1.43), so a resolved [plexGuid] uses that exact filter on + /// `/library/all`, while ids in `Guid` arrays are verified client-side + /// against the rows a title search returns. /// - /// `/library/all` is server-wide — it is not scoped to a section — so a - /// movie held by both a 4K and an HD library answers as two sibling - /// `Metadata` entries, each carrying its own `librarySectionID`. Every - /// id-verified entry is kept (#1754). + /// Titles go through `/hubs/search` — the full-text index behind Plex's own + /// search — not `/library/all?title=`. That filter is an ordered + /// word-prefix substring match with a leading bracket glued to its first + /// word: `Oshi no Ko` never finds a library titled `[Oshi no Ko]`, `Timer` + /// never finds `Part-Timer!` (#2098). The index also covers + /// `originalTitle`, so a native-language title reaches a copy filed under + /// any display title. Both endpoints are server-wide: a movie held by a 4K + /// and an HD library answers as two rows, each with its own + /// `librarySectionID`, and every id-verified row is kept (#1754). /// - /// An exact-guid hit does not short-circuit the title ladder: a library - /// still on a legacy agent carries `com.plexapp.agents.*` as its primary - /// guid, so that copy is invisible to the `guid=` filter and only the - /// id-verified title search finds it. Likewise the year-filtered page can - /// surface a copy the unfiltered page cut off at the container size, so - /// both contribute. The extra requests are spent once per uncached lookup, - /// off the render path and memoized for the session by - /// `CatalogLibraryMatcher`. + /// The guid filter and every title run concurrently. Id verification is the + /// only correctness gate, so no attempt can add a wrong show and none makes + /// another redundant: an exact-guid hit does not skip the titles (a + /// legacy-agent library carries `com.plexapp.agents.*` as its primary guid + /// and is invisible to `guid=`), and a title that hit does not skip its + /// siblings (the copies it missed are exactly the ones filed under another + /// language's title). The candidate cap in `titleMatchCandidates` is the + /// request budget. + /// + /// Requests run under [MediaServerTimeouts.libraryLookup] with endpoint + /// failover off: a large library answering slowly is not a dead endpoint, + /// and the caller reports a server that timed out as unchecked, not as + /// lacking the title. @override Future> findByExternalIds( ExternalIds ids, { @@ -4485,13 +4496,20 @@ class PlexClient _ => null, }; if (plexType == null) return const []; - if (!ids.hasAny && plexGuid == null) return const []; - if (titles.isEmpty && plexGuid == null) return const []; + // Title searches confirm candidates by external-id intersection, so + // without external ids they cannot match anything — only the exact guid + // lookup can. + final searchTitles = ids.hasAny ? titles : const []; + if (searchTitles.isEmpty && plexGuid == null) return const []; - // Rating-key keyed so the guid filter and the title ladder can both - // contribute without doubling a copy they agree on. Kept in three buckets - // because the result order is exact-guid hits, then modern `Guid` - // matches, then legacy-agent ones. + final pages = await Future.wait([ + if (plexGuid != null) _lookupByPlexGuid(plexGuid, plexType: plexType), + for (final title in searchTitles) _searchTitleForLookup(title, plexType: plexType), + ]); + + // Rating-key keyed so the guid filter and the title searches contribute a + // copy they agree on once. Kept in three buckets because the result order + // is exact-guid hits, then modern `Guid` matches, then legacy-agent ones. final exact = >{}; final modern = >{}; final legacy = >{}; @@ -4502,59 +4520,20 @@ class PlexClient into.putIfAbsent(ratingKey, () => item); } + var page = 0; if (plexGuid != null) { - final response = await _getWithFailover( - '/library/all', - queryParameters: {'guid': plexGuid, 'type': plexType, 'includeGuids': 1}, - ); - for (final item in _getMetadataJsonList(response)) { + for (final item in pages[page++]) { collect(exact, item); } } - - // Title attempts confirm candidates by external-id intersection, so - // without external ids they cannot match anything — stop at the exact - // guid lookup instead of burning requests that always come back empty. - if (ids.hasAny) { - Future attempt(String title, {required int size, String? years}) async { - final response = await _getWithFailover( - '/library/all', - queryParameters: { - 'title': title, - 'type': plexType, - 'includeGuids': 1, - 'X-Plex-Container-Size': size, - 'year': ?years, - }, - ); - var matched = false; - for (final item in _getMetadataJsonList(response)) { - final guids = item['Guid']; - if (guids is List && ids.intersects(ExternalIds.fromGuids(guids))) { - collect(modern, item); - matched = true; - } else if (ids.intersects(ExternalIds.fromLegacyPlexGuid(item['guid']))) { - collect(legacy, item); - matched = true; - } + for (; page < pages.length; page++) { + for (final item in pages[page]) { + final guids = item['Guid']; + if (guids is List && ids.intersects(ExternalIds.fromGuids(guids))) { + collect(modern, item); + } else if (ids.intersects(ExternalIds.fromLegacyPlexGuid(item['guid']))) { + collect(legacy, item); } - return matched; - } - - // Not `resolve(null)`: when the two providers disagree the season number is - // unresolvable but the entry is still a sequel, and the ±1 window around a - // sequel's own year excludes the parent show (its year is season one's). - final skipYearWindow = season?.isSequel ?? false; - for (var index = 0; index < titles.length; index++) { - final title = titles[index]; - final size = index == 0 ? 20 : 50; - final filteredMatched = index == 0 && year != null && !skipYearWindow - ? await attempt(title, size: size, years: '${year - 1},$year,${year + 1}') - : false; - final unfilteredMatched = await attempt(title, size: size); - // The ladder exists to widen a title that matched nothing; once a - // title has produced copies, broader forms would only add other shows. - if (filteredMatched || unfilteredMatched) break; } } @@ -4568,6 +4547,55 @@ class PlexClient return _gateExternalIdMatches(ordered.values, kind: kind, season: season); } + /// One reverse-lookup request under the lookup deadline; see + /// [findByExternalIds] for why failover is off. + Future _lookupRequest( + String path, + Map queryParameters, { + required String operation, + }) => retryTransientMediaServerCall( + operation: operation, + deadline: MediaServerTimeouts.libraryLookup, + call: (timeout, abort) => _getWithFailover( + path, + queryParameters: queryParameters, + timeout: timeout, + abort: abort, + allowEndpointFailover: false, + ), + ); + + Future>> _lookupByPlexGuid(String plexGuid, {required int plexType}) async { + final response = await _lookupRequest('/library/all', { + 'guid': plexGuid, + 'type': plexType, + 'includeGuids': 1, + }, operation: 'Plex guid lookup'); + return _getMetadataJsonList(response); + } + + /// Movie or show rows for [title] from the search index. `searchTypes=tv` + /// also answers an episode hub, so rows are taken from the hub of the + /// requested type only. + Future>> _searchTitleForLookup(String title, {required int plexType}) async { + final isMovie = plexType == 1; + final response = await _lookupRequest('/hubs/search', { + 'query': title, + 'searchTypes': isMovie ? 'movies' : 'tv', + 'includeGuids': 1, + 'limit': 50, + }, operation: 'Plex title lookup'); + final hubs = _getMediaContainer(response)?['Hub']; + if (hubs is! List) return const []; + final hubType = isMovie ? 'movie' : 'show'; + return [ + for (final hub in hubs) + if (hub is Map && hub['type'] == hubType) + for (final item in hub['Metadata'] as List? ?? const []) + if (item is Map) item, + ]; + } + @override Future reportPlaybackStarted({ required String itemId, diff --git a/lib/utils/media_server_timeouts.dart b/lib/utils/media_server_timeouts.dart index 271033c50..3e960d007 100644 --- a/lib/utils/media_server_timeouts.dart +++ b/lib/utils/media_server_timeouts.dart @@ -26,6 +26,15 @@ class MediaServerTimeouts { /// worst case was the dominant cold-start stall in #1784. static const libraryHubDeadline = Duration(seconds: 20); + /// Whole-request deadline for the Explore reverse library lookup + /// (`findByExternalIds`: Plex `/hubs/search` and `/library/all?guid=`, + /// Jellyfin `/Items?SearchTerm=`). It runs behind an in-page progress row + /// after a tap, so it can afford the same budget as a library hub row. Used + /// with endpoint failover off: a large library that answers slowly is not a + /// dead endpoint, and treating the timeout as one used to cascade the whole + /// client through its stale LAN candidates (#2098). + static const libraryLookup = Duration(seconds: 20); + /// Timeout for probing a cached/preferred endpoint (used in /// [PlexServer.findBestWorkingConnection]). static const preferredEndpointProbe = Duration(milliseconds: 1500); diff --git a/lib/utils/title_match_candidates.dart b/lib/utils/title_match_candidates.dart index 911e139f5..120c50cac 100644 --- a/lib/utils/title_match_candidates.dart +++ b/lib/utils/title_match_candidates.dart @@ -60,21 +60,23 @@ String _normalize(String title) { /// title is the only candidate filter available and a sequel entry's own /// title — `You and I Are Polar Opposites Season 2` — never matches the parent /// show. Each input contributes itself plus its season-stripped form; the -/// caller tries them in order and stops at the first candidate whose external -/// ids verify, so a broader title can never widen what actually matches. +/// backend searches every candidate concurrently and verifies external ids, +/// so a candidate can only ever add genuine copies. /// -/// [limit] bounds the request fan-out, and 2 is deliberate: the entry's own -/// title plus its season-stripped form matched 77 of 113 real sequel entries -/// against a 267-show Plex library, where the unexpanded title alone matched -/// 3. Raising it to 6 (adding romaji/native/synonym variants) reached only 81 -/// — four more entries for up to five more requests per lookup that finds -/// nothing, which is the common case on a discovery tab. Two candidates cost -/// the same two requests the single-title lookup already spent. +/// [limit] is the request budget per server per lookup. A title is a key into +/// the server's index, and a copy filed under a localized or romaji title is +/// reachable only through that title (#2098), so the budget buys title +/// *families*: 4 is the entry's own title and its first alternate, each with +/// its stripped form. Against a 267-show Plex library, the own family alone +/// matched 77 of 113 real sequel entries and six candidates reached 81; the +/// second family is not for hit rate but for the copies the first family +/// cannot name, and every request beyond it buys near nothing on the miss +/// path that dominates a discovery tab. /// /// Each title is emitted immediately followed by its stripped form rather than /// in two passes, so the cap can never spend every slot on unstripped titles /// and never try the one candidate that actually reaches the parent show. -List titleMatchCandidates(Iterable titles, {int limit = 2}) { +List titleMatchCandidates(Iterable titles, {int limit = 4}) { final out = []; final seen = {}; diff --git a/test/screens/catalog_item_detail_screen_test.dart b/test/screens/catalog_item_detail_screen_test.dart index d5578c41b..464c180fe 100644 --- a/test/screens/catalog_item_detail_screen_test.dart +++ b/test/screens/catalog_item_detail_screen_test.dart @@ -23,6 +23,7 @@ import 'package:plezy/screens/catalog_item_detail_screen.dart'; import 'package:plezy/services/catalog/catalog_source.dart'; import 'package:plezy/services/catalog/catalog_library_matcher.dart'; import 'package:plezy/services/catalog/seerr_catalog_source.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/seerr/seerr_client.dart'; import 'package:plezy/services/seerr/seerr_constants.dart'; @@ -36,6 +37,7 @@ import 'package:plezy/widgets/media_card.dart'; import 'package:plezy/widgets/optimized_media_image.dart'; import 'package:provider/provider.dart'; +import '../test_helpers/library_lookup.dart'; import '../test_helpers/media_items.dart'; import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; @@ -172,7 +174,7 @@ class _FakeCatalogLibraryMatcher extends CatalogLibraryMatcher { final List matches; @override - Future> match(CatalogItem item) async => matches; + Future match(CatalogItem item) async => libraryLookupResult(matches); } /// Matches only items that carry an external id, the way a real lookup for a @@ -185,9 +187,9 @@ class _ExternalIdGatedMatcher extends CatalogLibraryMatcher { final List calls = []; @override - Future> match(CatalogItem item) async { + Future match(CatalogItem item) async { calls.add(item); - return item.ids.toExternalIds().hasAny ? [hit] : const []; + return libraryLookupResult(item.ids.toExternalIds().hasAny ? [hit] : const []); } } @@ -196,11 +198,11 @@ class _ExternalIdGatedMatcher extends CatalogLibraryMatcher { class _ScriptedMatcher extends CatalogLibraryMatcher { _ScriptedMatcher(super.multiServer, this.passes); - final List Function()> passes; + final List passes; int calls = 0; @override - Future> match(CatalogItem item) async { + Future match(CatalogItem item) async { final pass = passes[calls < passes.length ? calls : passes.length - 1]; calls++; return pass(); @@ -449,8 +451,8 @@ void main() { source, item: _bareRow, matcherBuilder: (multiServer) => matcher = _ScriptedMatcher(multiServer, [ - () => [_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies')], - () => const [], + () => libraryLookupResult([_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies')]), + () => libraryLookupResult(const []), ]), ); @@ -469,7 +471,7 @@ void main() { source, item: _bareRow, matcherBuilder: (multiServer) => matcher = _ScriptedMatcher(multiServer, [ - () => [_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies')], + () => libraryLookupResult([_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies')]), () => throw StateError('server unreachable'), ]), ); @@ -479,6 +481,64 @@ void main() { expect(find.text(t.explore.notInLibrary), findsNothing); }); + testWidgets('a server that could not be asked is reported instead of counted as a miss', (tester) async { + // #2098: a slow or unreachable server is no evidence of absence. With no + // copies found elsewhere, "Not in your library" would be a false claim. + final source = _FakeCatalogSource(detail: const CatalogDetail(item: _enrichedRow)); + + await _pumpDetail( + tester, + source, + item: _bareRow, + matcherBuilder: (multiServer) => _ScriptedMatcher(multiServer, [ + () => libraryLookupResult(const [], failed: {'server-1'}), + ]), + ); + + expect(find.text(t.explore.notInLibrary), findsNothing); + expect(find.text(t.explore.libraryCheckFailed(n: 1)), findsOneWidget); + }); + + testWidgets('an unchecked server is noted under the copies other servers found', (tester) async { + final source = _FakeCatalogSource(detail: const CatalogDetail(item: _enrichedRow)); + + await _pumpDetail( + tester, + source, + item: _bareRow, + matcherBuilder: (multiServer) => _ScriptedMatcher(multiServer, [ + () => libraryLookupResult( + [_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies')], + succeeded: {'server-1'}, + failed: {'server-2', 'server-3'}, + ), + ]), + ); + + expect(find.text(t.explore.inTheseLibraries), findsOneWidget); + expect(find.text('Movies'), findsOneWidget); + expect(find.text(t.explore.libraryCheckFailed(n: 2)), findsOneWidget); + }); + + testWidgets('a server that answers a later pass stops being reported as unchecked', (tester) async { + // The bare-row pass and the enriched pass race; a server that timed out + // on one and answered the other has been checked. + final source = _FakeCatalogSource(detail: const CatalogDetail(item: _enrichedRow)); + + await _pumpDetail( + tester, + source, + item: _bareRow, + matcherBuilder: (multiServer) => _ScriptedMatcher(multiServer, [ + () => libraryLookupResult(const [], failed: {'server-1'}), + () => libraryLookupResult(const [], succeeded: {'server-1'}), + ]), + ); + + expect(find.text(t.explore.libraryCheckFailed(n: 1)), findsNothing); + expect(find.text(t.explore.notInLibrary), findsOneWidget); + }); + testWidgets('a re-resolve that lost its library stamp keeps the one already shown', (tester) async { // Jellyfin stamps a copy's library with a best-effort ancestors lookup // that returns the item bare when it fails. A row that fell back to the @@ -492,8 +552,8 @@ void main() { source, item: _bareRow, matcherBuilder: (multiServer) => matcher = _ScriptedMatcher(multiServer, [ - () => [_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies', videoResolution: '1080')], - () => [_libraryCopy(id: 'hd-copy', serverName: null)], + () => libraryLookupResult([_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies', videoResolution: '1080')]), + () => libraryLookupResult([_libraryCopy(id: 'hd-copy', serverName: null)]), ]), ); @@ -515,11 +575,11 @@ void main() { source, item: _bareRow, matcherBuilder: (multiServer) => matcher = _ScriptedMatcher(multiServer, [ - () => [_libraryCopy(id: 'uhd-copy', libraryTitle: '4K Movies', videoResolution: '4k')], - () => [ + () => libraryLookupResult([_libraryCopy(id: 'uhd-copy', libraryTitle: '4K Movies', videoResolution: '4k')]), + () => libraryLookupResult([ _libraryCopy(id: 'uhd-copy', libraryTitle: '4K Movies', videoResolution: '4k'), _libraryCopy(id: 'hd-copy', libraryTitle: 'Movies', videoResolution: '1080'), - ], + ]), ]), ); @@ -539,11 +599,11 @@ void main() { source, item: _bareRow, matcherBuilder: (multiServer) => _ScriptedMatcher(multiServer, [ - () => [_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies', videoResolution: '1080')], - () => [ + () => libraryLookupResult([_libraryCopy(id: 'hd-copy', libraryTitle: 'Movies', videoResolution: '1080')]), + () => libraryLookupResult([ _libraryCopy(id: 'hd-copy', libraryTitle: 'Movies', videoResolution: '1080'), _libraryCopy(id: 'uhd-copy', libraryTitle: '4K Movies', videoResolution: '4k'), - ], + ]), ]), ); diff --git a/test/screens/catalog_search_screen_test.dart b/test/screens/catalog_search_screen_test.dart index 4bd292610..cb037848c 100644 --- a/test/screens/catalog_search_screen_test.dart +++ b/test/screens/catalog_search_screen_test.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/i18n/strings.g.dart'; -import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/models/catalog/catalog_item.dart'; import 'package:plezy/models/catalog/catalog_metadata.dart'; @@ -11,6 +10,7 @@ import 'package:plezy/screens/catalog_item_detail_screen.dart'; import 'package:plezy/screens/catalog_search_screen.dart'; import 'package:plezy/services/catalog/catalog_source.dart'; import 'package:plezy/services/catalog/catalog_library_matcher.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; @@ -18,6 +18,7 @@ import 'package:plezy/widgets/app_menu.dart'; import 'package:plezy/widgets/media_card.dart'; import 'package:provider/provider.dart'; +import '../test_helpers/library_lookup.dart'; import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; @@ -210,7 +211,7 @@ class _FakeCatalogLibraryMatcher extends CatalogLibraryMatcher { _FakeCatalogLibraryMatcher(super.multiServer); @override - Future> match(CatalogItem item) async => const []; + Future match(CatalogItem item) async => libraryLookupResult(const []); } Future _pumpMenuSearch(WidgetTester tester, _FakeSearchSource source, {required TargetPlatform platform}) async { diff --git a/test/services/catalog/catalog_library_matcher_test.dart b/test/services/catalog/catalog_library_matcher_test.dart index 96e9f21a8..3f0903af0 100644 --- a/test/services/catalog/catalog_library_matcher_test.dart +++ b/test/services/catalog/catalog_library_matcher_test.dart @@ -1,5 +1,4 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/models/catalog/catalog_item.dart'; import 'package:plezy/providers/multi_server_provider.dart'; @@ -8,6 +7,7 @@ import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/utils/external_ids.dart'; +import '../../test_helpers/library_lookup.dart'; import '../../test_helpers/media_items.dart'; class _LookupCall { @@ -32,10 +32,10 @@ class _FakeDataAggregationService extends DataAggregationService { _FakeDataAggregationService(super.serverManager); final List<_LookupCall> calls = []; - final List> responses = []; + final List responses = []; @override - Future> findByExternalIdsAcrossServers( + Future findByExternalIdsAcrossServers( ExternalIds ids, { required MediaKind kind, List titles = const [], @@ -76,8 +76,8 @@ void main() { final firstHit = testMediaItem(id: 'server-season-1', kind: MediaKind.show); final secondHit = testMediaItem(id: 'server-season-2', kind: MediaKind.show); harness.aggregation.responses.addAll([ - [firstHit], - [secondHit], + libraryLookupResult([firstHit]), + libraryLookupResult([secondHit]), ]); const first = CatalogItem( source: CatalogSourceId.mal, @@ -94,9 +94,9 @@ void main() { expect(first.identityKey, second.identityKey); expect(first.entryIdentityKey, isNot(second.entryIdentityKey)); - expect((await harness.matcher.match(first)).single, same(firstHit)); - expect((await harness.matcher.match(second)).single, same(secondHit)); - expect((await harness.matcher.match(first)).single, same(firstHit)); + expect((await harness.matcher.match(first)).items.single, same(firstHit)); + expect((await harness.matcher.match(second)).items.single, same(secondHit)); + expect((await harness.matcher.match(first)).items.single, same(firstHit)); expect(harness.aggregation.calls, hasLength(2)); }); @@ -105,8 +105,8 @@ void main() { addTearDown(harness.dispose); final anilistHit = testMediaItem(id: 'japanese-title-match', kind: MediaKind.show); harness.aggregation.responses.addAll([ - const [], - [anilistHit], + libraryLookupResult(const []), + libraryLookupResult([anilistHit]), ]); const malItem = CatalogItem( source: CatalogSourceId.mal, @@ -124,8 +124,8 @@ void main() { ); expect(malItem.entryIdentityKey, anilistItem.entryIdentityKey); - expect(await harness.matcher.match(malItem), isEmpty); - expect((await harness.matcher.match(anilistItem)).single, same(anilistHit)); + expect((await harness.matcher.match(malItem)).items, isEmpty); + expect((await harness.matcher.match(anilistItem)).items.single, same(anilistHit)); expect(harness.aggregation.calls, hasLength(2)); expect(harness.aggregation.calls.last.titles, contains('日本語タイトル')); }); @@ -136,8 +136,8 @@ void main() { addTearDown(harness.dispose); final hit = testMediaItem(id: 'new-library-item', kind: MediaKind.show); harness.aggregation.responses.addAll([ - const [], - [hit], + libraryLookupResult(const []), + libraryLookupResult([hit]), ]); const item = CatalogItem( source: CatalogSourceId.anilist, @@ -146,24 +146,59 @@ void main() { ids: CatalogItemIds(anilist: 1, tmdb: 42), ); - expect(await harness.matcher.match(item), isEmpty); + expect((await harness.matcher.match(item)).items, isEmpty); now = now.add(CatalogLibraryMatcher.negativeTtl - const Duration(seconds: 1)); - expect(await harness.matcher.match(item), isEmpty); + expect((await harness.matcher.match(item)).items, isEmpty); expect(harness.aggregation.calls, hasLength(1)); now = now.add(const Duration(seconds: 1)); - expect((await harness.matcher.match(item)).single, same(hit)); + expect((await harness.matcher.match(item)).items.single, same(hit)); expect(harness.aggregation.calls, hasLength(2)); now = now.add(const Duration(days: 30)); - expect((await harness.matcher.match(item)).single, same(hit)); + expect((await harness.matcher.match(item)).items.single, same(hit)); expect(harness.aggregation.calls, hasLength(2)); }); + test('a wave a server sat out is retried after negativeTtl even when it found copies', () async { + // #2098: a slow server timing out is not evidence the title is absent + // there. The degraded answer is shown, with the failed server named, but + // it must not be memoized for the session like an authoritative hit. + var now = DateTime.utc(2026, 7, 28, 12); + final harness = _Harness(now: () => now); + addTearDown(harness.dispose); + final fastCopy = testMediaItem(id: 'fast-copy', serverId: 'fast'); + final slowCopy = testMediaItem(id: 'slow-copy', serverId: 'slow'); + harness.aggregation.responses.addAll([ + libraryLookupResult([fastCopy], succeeded: {'fast'}, failed: {'slow'}), + libraryLookupResult([fastCopy, slowCopy], succeeded: {'fast', 'slow'}), + ]); + const item = CatalogItem( + source: CatalogSourceId.trakt, + kind: MediaKind.movie, + title: 'Slow Server Movie', + ids: CatalogItemIds(trakt: 9, tmdb: 77), + ); + + final degraded = await harness.matcher.match(item); + expect(degraded.items, [fastCopy]); + expect(degraded.failedServerIds, {'slow'}); + now = now.add(CatalogLibraryMatcher.negativeTtl - const Duration(seconds: 1)); + expect(await harness.matcher.match(item), same(degraded)); + expect(harness.aggregation.calls, hasLength(1)); + + now = now.add(const Duration(seconds: 1)); + expect((await harness.matcher.match(item)).items, [fastCopy, slowCopy]); + expect(harness.aggregation.calls, hasLength(2)); + now = now.add(const Duration(days: 30)); + expect((await harness.matcher.match(item)).items, [fastCopy, slowCopy]); + expect(harness.aggregation.calls, hasLength(2), reason: 'every server answered, so the hit is kept'); + }); + test('forwards season-stripped title candidates and season reference', () async { final harness = _Harness(); addTearDown(harness.dispose); - harness.aggregation.responses.add(const []); + harness.aggregation.responses.add(libraryLookupResult(const [])); const season = ExternalSeasonRef(tvdb: 2, tmdb: 1); const item = CatalogItem( source: CatalogSourceId.mal, @@ -183,14 +218,20 @@ void main() { expect(call.year, isNull, reason: '2027 is season two\'s year, not the parent show\'s'); expect(call.plexGuid, isNull); expect(call.season, same(season)); - // Capped at two: the entry's own title and its season-stripped form. - expect(call.titles, ['You and I Are Polar Opposites Season 2', 'You and I Are Polar Opposites']); + // Two title families, each with its stripped form: the alternate family + // is the only way to a copy filed under the romaji title (#2098). + expect(call.titles, [ + 'You and I Are Polar Opposites Season 2', + 'You and I Are Polar Opposites', + 'Seihantai na Kimi to Boku 2nd Season', + 'Seihantai na Kimi to Boku', + ]); }); test('keeps the year for an entry that is not a sequel', () async { final harness = _Harness(); addTearDown(harness.dispose); - harness.aggregation.responses.add(const []); + harness.aggregation.responses.add(libraryLookupResult(const [])); const item = CatalogItem( source: CatalogSourceId.trakt, kind: MediaKind.show, @@ -211,7 +252,7 @@ void main() { // reliably, and the year window around it would exclude the parent show. final harness = _Harness(); addTearDown(harness.dispose); - harness.aggregation.responses.add(const []); + harness.aggregation.responses.add(libraryLookupResult(const [])); const item = CatalogItem( source: CatalogSourceId.anilist, kind: MediaKind.show, @@ -228,7 +269,7 @@ void main() { test('constructs a Plex guid for Discover items without external ids', () async { final harness = _Harness(); addTearDown(harness.dispose); - harness.aggregation.responses.add(const []); + harness.aggregation.responses.add(libraryLookupResult(const [])); const item = CatalogItem( source: CatalogSourceId.plex, kind: MediaKind.movie, @@ -253,8 +294,8 @@ void main() { addTearDown(harness.dispose); final hit = testMediaItem(id: 'server-movie', kind: MediaKind.movie); harness.aggregation.responses.addAll([ - const [], - [hit], + libraryLookupResult(const []), + libraryLookupResult([hit]), ]); const bare = CatalogItem( source: CatalogSourceId.plex, @@ -270,20 +311,20 @@ void main() { ); expect(bare.entryIdentityKey, enriched.entryIdentityKey); - expect(await harness.matcher.match(bare), isEmpty); - expect((await harness.matcher.match(enriched)).single, same(hit)); + expect((await harness.matcher.match(bare)).items, isEmpty); + expect((await harness.matcher.match(enriched)).items.single, same(hit)); expect(harness.aggregation.calls, hasLength(2)); expect(harness.aggregation.calls.last.ids.imdb, 'tt0089445'); // Both forms stay memoized independently. - expect((await harness.matcher.match(enriched)).single, same(hit)); + expect((await harness.matcher.match(enriched)).items.single, same(hit)); expect(harness.aggregation.calls, hasLength(2)); }); test('only a Plex Discover item contributes a guid, and it costs no request', () async { final harness = _Harness(); addTearDown(harness.dispose); - harness.aggregation.responses.addAll([const [], const []]); + harness.aggregation.responses.addAll([libraryLookupResult(const []), libraryLookupResult(const [])]); // A MAL entry can carry a Plex rating key through cross-source membership, // but that key is not a Discover guid and must never be synthesised into // one — the fast path is only sound for items that came from Discover. diff --git a/test/services/jellyfin_external_id_lookup_test.dart b/test/services/jellyfin_external_id_lookup_test.dart index dc6192346..cd412211b 100644 --- a/test/services/jellyfin_external_id_lookup_test.dart +++ b/test/services/jellyfin_external_id_lookup_test.dart @@ -32,7 +32,9 @@ void main() { await db.close(); }); - test('tries later titles and verifies the matching provider id', () async { + test('searches every title and unions the id-verified copies each one reaches', () async { + // #2098: a copy filed under another language's title is only reachable + // through that title, so a title that hit must not stop its siblings. final searchTerms = []; final client = testJellyfinClient( httpClient: MockClient((request) async { @@ -40,7 +42,11 @@ void main() { final searchTerm = request.url.queryParameters['SearchTerm']!; searchTerms.add(searchTerm); return _json({ - 'Items': searchTerm == 'Parent Series' ? [_series()] : [], + 'Items': switch (searchTerm) { + 'Parent Series' => [_series()], + 'Oya Series' => [_series(), _series(id: 'series-romaji')], + _ => [], + }, }); } if (request.url.path == '/Items/series-1/Ancestors') { @@ -48,6 +54,7 @@ void main() { {'Id': 'library-1', 'Name': 'Shows', 'Type': 'CollectionFolder'}, ]); } + if (request.url.path == '/Items/series-romaji/Ancestors') return _json([]); fail('Unexpected request: ${request.url}'); }), ); @@ -56,14 +63,13 @@ void main() { final matches = await client.findByExternalIds( const ExternalIds(tmdb: 42), kind: MediaKind.show, - titles: const ['Parent Series Season 2', 'Parent Series'], + titles: const ['Parent Series Season 2', 'Parent Series', 'Oya Series'], ); - expect(searchTerms, ['Parent Series Season 2', 'Parent Series']); - expect(matches, hasLength(1)); - expect(matches.single.id, 'series-1'); - expect(matches.single.libraryId, 'library-1'); - expect(matches.single.libraryTitle, 'Shows'); + expect(searchTerms, ['Parent Series Season 2', 'Parent Series', 'Oya Series']); + expect(matches.map((item) => item.id), ['series-1', 'series-romaji'], reason: 'the shared copy appears once'); + expect(matches.first.libraryId, 'library-1'); + expect(matches.first.libraryTitle, 'Shows'); }); test('rejects a title hit whose provider ids do not intersect', () async { @@ -86,7 +92,7 @@ void main() { expect(matches, isEmpty); }); - test('uses the year window only for the first title and broadens the later limit', () async { + test('applies the year window to the first title only', () async { final searches = []; final client = testJellyfinClient( httpClient: MockClient((request) async { @@ -113,9 +119,7 @@ void main() { expect(matches.map((item) => item.id), ['series-1']); expect(searches, hasLength(2)); expect(searches.first.queryParameters['years'], '2023,2024,2025'); - expect(searches.first.queryParameters['Limit'], '20'); expect(searches.last.queryParameters.containsKey('years'), isFalse); - expect(searches.last.queryParameters['Limit'], '50'); }); test('requires an agreed sequel season to exist in the matched series', () async { diff --git a/test/services/plex_external_id_lookup_test.dart b/test/services/plex_external_id_lookup_test.dart index c208e8dbb..71d40d185 100644 --- a/test/services/plex_external_id_lookup_test.dart +++ b/test/services/plex_external_id_lookup_test.dart @@ -4,6 +4,7 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:plezy/database/app_database.dart'; +import 'package:plezy/exceptions/media_server_exceptions.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/utils/external_ids.dart'; @@ -12,6 +13,40 @@ import '../test_helpers/backend_client_fixtures.dart'; http.Response _json(Object body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}); +/// A `/library/all` page. +http.Response _metadata(List rows) => _json({ + 'MediaContainer': {'Metadata': rows}, +}); + +/// A `/hubs/search` answer: the hub of [type] plus the episode hub Plex +/// always adds to a `tv` search, so a lookup that read every hub would leak +/// episodes into the match list. +http.Response _hubs(List rows, {String type = 'movie'}) => _json({ + 'MediaContainer': { + 'Hub': [ + {'type': type, 'hubIdentifier': type, 'Metadata': rows}, + { + 'type': 'episode', + 'hubIdentifier': 'episode', + 'Metadata': [ + { + 'ratingKey': 'stray-episode', + 'type': 'episode', + 'title': 'Pilot', + 'Guid': [ + {'id': 'tvdb://123'}, + {'id': 'tmdb://42'}, + {'id': 'imdb://tt12345'}, + ], + }, + ], + }, + ], + }, +}); + +bool _isTitleSearch(http.Request request) => request.url.path == '/hubs/search'; + void main() { late AppDatabase db; @@ -24,28 +59,24 @@ void main() { await db.close(); }); - test('falls back to an official legacy scalar guid without changing the request contract', () async { + test('searches the title through the hub search index and reads the legacy scalar guid', () async { late Uri requestUri; final client = testPlexClient( handler: (request) async { requestUri = request.url; - return _json({ - 'MediaContainer': { - 'Metadata': [ - { - 'ratingKey': 'legacy-movie', - 'type': 'movie', - 'title': 'Legacy Movie', - 'librarySectionID': 5, - 'librarySectionTitle': 'Legacy Movies', - 'guid': 'com.plexapp.agents.imdb://tt29768334?lang=en', - 'Guid': [ - {'id': 'tmdb://999'}, - ], - }, + return _hubs([ + { + 'ratingKey': 'legacy-movie', + 'type': 'movie', + 'title': 'Legacy Movie', + 'librarySectionID': 5, + 'librarySectionTitle': 'Legacy Movies', + 'guid': 'com.plexapp.agents.imdb://tt29768334?lang=en', + 'Guid': [ + {'id': 'tmdb://999'}, ], }, - }); + ]); }, ); addTearDown(client.close); @@ -61,63 +92,63 @@ void main() { expect(matches.single.libraryTitle, 'Legacy Movies'); expect(matches.single.serverId, 'server-1'); expect(matches.single.serverName, 'Server'); - expect(requestUri.path, '/library/all'); - expect(requestUri.queryParameters['title'], 'Legacy Movie'); - expect(requestUri.queryParameters['type'], '1'); + // #2098: `/library/all?title=` is a word-prefix substring match that + // cannot find `[Oshi no Ko]` from `Oshi no Ko`; the search index can. + expect(requestUri.path, '/hubs/search'); + expect(requestUri.queryParameters['query'], 'Legacy Movie'); + expect(requestUri.queryParameters['searchTypes'], 'movies'); expect(requestUri.queryParameters['includeGuids'], '1'); - expect(requestUri.queryParameters['X-Plex-Container-Size'], '20'); + expect(requestUri.queryParameters['limit'], '50'); expect(requestUri.queryParameters.containsKey('guid'), isFalse); + expect(requestUri.queryParameters.containsKey('year'), isFalse, reason: 'the index ranks; no year window'); }); - test('matches a HAMA show guid', () async { + test('a show search reads the show hub only, never the episode hub', () async { + late Uri requestUri; final client = testPlexClient( - handler: (request) async => _json({ - 'MediaContainer': { - 'Metadata': [ - { - 'ratingKey': 'legacy-show', - 'type': 'show', - 'title': 'Legacy Show', - 'guid': 'com.plexapp.agents.hama://tvdb4-315500?lang=en', - }, - ], - }, - }), + handler: (request) async { + requestUri = request.url; + return _hubs(type: 'show', [ + { + 'ratingKey': 'legacy-show', + 'type': 'show', + 'title': 'Legacy Show', + 'guid': 'com.plexapp.agents.hama://tvdb4-315500?lang=en', + }, + ]); + }, ); addTearDown(client.close); final matches = await client.findByExternalIds( - const ExternalIds(tvdb: 315500), + const ExternalIds(tvdb: 315500, tmdb: 42), kind: MediaKind.show, titles: const ['Legacy Show'], ); expect(matches.map((match) => match.id), ['legacy-show']); + expect(requestUri.queryParameters['searchTypes'], 'tv'); }); test('returns both agent variants of one title, modern Guid match first', () async { final client = testPlexClient( - handler: (request) async => _json({ - 'MediaContainer': { - 'Metadata': [ - { - 'ratingKey': 'legacy-match', - 'type': 'movie', - 'title': 'Duplicate', - 'guid': 'com.plexapp.agents.imdb://tt12345', - }, - { - 'ratingKey': 'modern-match', - 'type': 'movie', - 'title': 'Duplicate', - 'guid': 'plex://movie/modern', - 'Guid': [ - {'id': 'imdb://tt12345'}, - ], - }, + handler: (request) async => _hubs([ + { + 'ratingKey': 'legacy-match', + 'type': 'movie', + 'title': 'Duplicate', + 'guid': 'com.plexapp.agents.imdb://tt12345', + }, + { + 'ratingKey': 'modern-match', + 'type': 'movie', + 'title': 'Duplicate', + 'guid': 'plex://movie/modern', + 'Guid': [ + {'id': 'imdb://tt12345'}, ], }, - }), + ]), ); addTearDown(client.close); @@ -131,67 +162,12 @@ void main() { expect(matches.map((match) => match.id), ['modern-match', 'legacy-match']); }); - test('unions the year-filtered and unfiltered pages, modern match first', () async { - final requests = []; - final client = testPlexClient( - handler: (request) async { - requests.add(request.url); - final isFiltered = request.url.queryParameters.containsKey('year'); - return _json({ - 'MediaContainer': { - 'Metadata': [ - if (isFiltered) - { - 'ratingKey': 'filtered-legacy', - 'type': 'movie', - 'title': 'Missing Year', - 'guid': 'com.plexapp.agents.themoviedb://42', - } - else - { - 'ratingKey': 'unfiltered-modern', - 'type': 'movie', - 'title': 'Missing Year', - 'guid': 'plex://movie/modern', - 'Guid': [ - {'id': 'tmdb://42'}, - ], - }, - ], - }, - }); - }, - ); - addTearDown(client.close); - - final matches = await client.findByExternalIds( - const ExternalIds(tmdb: 42), - kind: MediaKind.movie, - titles: const ['Missing Year'], - year: 2024, - ); - - expect(matches.map((match) => match.id), ['unfiltered-modern', 'filtered-legacy']); - expect(requests, hasLength(2)); - expect(requests.first.queryParameters['year'], '2023,2024,2025'); - expect(requests.last.queryParameters.containsKey('year'), isFalse); - }); - test('does not match unsupported or malformed scalar GUIDs', () async { final client = testPlexClient( - handler: (request) async => _json({ - 'MediaContainer': { - 'Metadata': [ - { - 'ratingKey': 'anidb', - 'type': 'show', - 'title': 'Unsupported', - 'guid': 'com.plexapp.agents.hama://anidb-11905', - }, - {'ratingKey': 'wrong-shape', 'type': 'show', 'title': 'Unsupported', 'guid': 315500}, - ], - }, - }), + handler: (request) async => _hubs(type: 'show', [ + {'ratingKey': 'anidb', 'type': 'show', 'title': 'Unsupported', 'guid': 'com.plexapp.agents.hama://anidb-11905'}, + {'ratingKey': 'wrong-shape', 'type': 'show', 'title': 'Unsupported', 'guid': 315500}, + ]), ); addTearDown(client.close); @@ -204,38 +180,54 @@ void main() { expect(matches, isEmpty); }); - test('tries broader title candidates in order and still verifies external ids', () async { + test('searches every title candidate and unions their id-verified copies', () async { + // #2098: a copy filed under another language's title is reachable only + // through that title, so a title that hit must not stop its siblings — + // and id verification keeps the wrong show out of every page. final requests = []; final client = testPlexClient( handler: (request) async { requests.add(request.url); - if (request.url.queryParameters['title'] == 'Parent Show Season 2') { - return _json({ - 'MediaContainer': {'Metadata': []}, - }); - } - return _json({ - 'MediaContainer': { - 'Metadata': [ - { - 'ratingKey': 'wrong-parent', - 'type': 'show', - 'title': 'Parent Show', - 'Guid': [ - {'id': 'tvdb://999'}, - ], - }, - { - 'ratingKey': 'verified-parent', - 'type': 'show', - 'title': 'Parent Show', - 'Guid': [ - {'id': 'tvdb://123'}, - ], - }, - ], - }, - }); + return switch (request.url.queryParameters['query']) { + 'Parent Show Season 2' => _hubs(type: 'show', const []), + 'Parent Show' => _hubs(type: 'show', [ + { + 'ratingKey': 'wrong-parent', + 'type': 'show', + 'title': 'Parent Show', + 'Guid': [ + {'id': 'tvdb://999'}, + ], + }, + { + 'ratingKey': 'verified-parent', + 'type': 'show', + 'title': 'Parent Show', + 'Guid': [ + {'id': 'tvdb://123'}, + ], + }, + ]), + _ => _hubs(type: 'show', [ + { + 'ratingKey': 'romaji-parent', + 'type': 'show', + 'title': 'Oya Bangumi', + 'librarySectionTitle': 'Anime (romaji)', + 'Guid': [ + {'id': 'tvdb://123'}, + ], + }, + { + 'ratingKey': 'verified-parent', + 'type': 'show', + 'title': 'Parent Show', + 'Guid': [ + {'id': 'tvdb://123'}, + ], + }, + ]), + }; }, ); addTearDown(client.close); @@ -243,13 +235,11 @@ void main() { final matches = await client.findByExternalIds( const ExternalIds(tvdb: 123), kind: MediaKind.show, - titles: const ['Parent Show Season 2', 'Parent Show'], + titles: const ['Parent Show Season 2', 'Parent Show', 'Oya Bangumi'], ); - expect(matches.map((match) => match.id), ['verified-parent']); - expect(requests.map((uri) => uri.queryParameters['title']), ['Parent Show Season 2', 'Parent Show']); - expect(requests.first.queryParameters['X-Plex-Container-Size'], '20'); - expect(requests.last.queryParameters['X-Plex-Container-Size'], '50'); + expect(matches.map((match) => match.id), ['verified-parent', 'romaji-parent']); + expect(requests.map((uri) => uri.queryParameters['query']), ['Parent Show Season 2', 'Parent Show', 'Oya Bangumi']); }); test('uses an exact Plex guid without external ids or a title query', () async { @@ -257,18 +247,14 @@ void main() { final client = testPlexClient( handler: (request) async { requests.add(request.url); - return _json({ - 'MediaContainer': { - 'Metadata': [ - { - 'ratingKey': 'exact-show', - 'type': 'show', - 'title': 'Exact Show', - 'guid': 'plex://show/5e01fc33932ff9001db3b242', - }, - ], + return _metadata([ + { + 'ratingKey': 'exact-show', + 'type': 'show', + 'title': 'Exact Show', + 'guid': 'plex://show/5e01fc33932ff9001db3b242', }, - }); + ]); }, ); addTearDown(client.close); @@ -286,20 +272,18 @@ void main() { expect(requests.single.queryParameters['guid'], 'plex://show/5e01fc33932ff9001db3b242'); expect(requests.single.queryParameters['type'], '2'); expect(requests.single.queryParameters['includeGuids'], '1'); - expect(requests.single.queryParameters.containsKey('title'), isFalse); + expect(requests.single.queryParameters.containsKey('query'), isFalse); }); test('a guid-only lookup stops after the exact guid miss', () async { - // Title attempts verify candidates by external-id intersection, so with + // Title searches verify candidates by external-id intersection, so with // no external ids they can never confirm anything — the guid miss must // be the lookup's only request (#1715). final requests = []; final client = testPlexClient( handler: (request) async { requests.add(request.url); - return _json({ - 'MediaContainer': {'Metadata': []}, - }); + return _metadata(const []); }, ); addTearDown(client.close); @@ -314,7 +298,6 @@ void main() { expect(matches, isEmpty); expect(requests, hasLength(1)); expect(requests.single.queryParameters['guid'], 'plex://movie/5d776b59ad5437001f79c6f8'); - expect(requests.single.queryParameters.containsKey('title'), isFalse); }); test('an agreed season ref gates on the season hierarchy and nothing else', () async { @@ -345,22 +328,18 @@ void main() { if (request.url.path.startsWith('/library/metadata/')) { return _json({'MediaContainer': {}}); } - final complete = request.url.queryParameters['title'] == 'Complete Show'; - return _json({ - 'MediaContainer': { - 'Metadata': [ - { - 'ratingKey': complete ? 'complete-show' : 'incomplete-show', - 'type': 'show', - 'title': complete ? 'Complete Show' : 'Incomplete Show', - 'librarySectionID': 4, - 'Guid': [ - {'id': 'tvdb://${complete ? 101 : 100}'}, - ], - }, + final complete = request.url.queryParameters['query'] == 'Complete Show'; + return _hubs(type: 'show', [ + { + 'ratingKey': complete ? 'complete-show' : 'incomplete-show', + 'type': 'show', + 'title': complete ? 'Complete Show' : 'Incomplete Show', + 'librarySectionID': 4, + 'Guid': [ + {'id': 'tvdb://${complete ? 101 : 100}'}, ], }, - }); + ]); }, ); addTearDown(client.close); @@ -392,21 +371,17 @@ void main() { final client = testPlexClient( handler: (request) async { requests.add(request.url); - return _json({ - 'MediaContainer': { - 'Metadata': [ - { - 'ratingKey': 'ungated-show', - 'type': 'show', - 'title': 'Ungated Show', - 'librarySectionID': 9, - 'Guid': [ - {'id': 'tvdb://300'}, - ], - }, + return _hubs(type: 'show', [ + { + 'ratingKey': 'ungated-show', + 'type': 'show', + 'title': 'Ungated Show', + 'librarySectionID': 9, + 'Guid': [ + {'id': 'tvdb://300'}, ], }, - }); + ]); }, ); addTearDown(client.close); @@ -419,7 +394,7 @@ void main() { ); expect(matches.map((match) => match.id), ['ungated-show']); - expect(requests.map((uri) => uri.path), ['/library/all'], reason: 'no children, no preferences'); + expect(requests.map((uri) => uri.path), ['/hubs/search'], reason: 'no children, no preferences'); }); test('returns every library copy sharing one exact Plex guid', () async { @@ -427,34 +402,30 @@ void main() { // section and an HD section answers with two sibling entries. Taking // Metadata[0] is what hid the second copy from the Explore chooser. final client = testPlexClient( - handler: (request) async => _json({ - 'MediaContainer': { - 'Metadata': [ - { - 'ratingKey': 'hd-copy', - 'type': 'movie', - 'title': 'Dual Library', - 'guid': 'plex://movie/dual', - 'librarySectionID': 1, - 'librarySectionTitle': 'Movies', - 'Media': [ - {'id': 10, 'videoResolution': '1080', 'videoCodec': 'h264', 'container': 'mkv'}, - ], - }, - { - 'ratingKey': 'uhd-copy', - 'type': 'movie', - 'title': 'Dual Library', - 'guid': 'plex://movie/dual', - 'librarySectionID': 2, - 'librarySectionTitle': '4K Movies', - 'Media': [ - {'id': 20, 'videoResolution': '4k', 'videoCodec': 'hevc', 'container': 'mkv'}, - ], - }, + handler: (request) async => _metadata([ + { + 'ratingKey': 'hd-copy', + 'type': 'movie', + 'title': 'Dual Library', + 'guid': 'plex://movie/dual', + 'librarySectionID': 1, + 'librarySectionTitle': 'Movies', + 'Media': [ + {'id': 10, 'videoResolution': '1080', 'videoCodec': 'h264', 'container': 'mkv'}, ], }, - }), + { + 'ratingKey': 'uhd-copy', + 'type': 'movie', + 'title': 'Dual Library', + 'guid': 'plex://movie/dual', + 'librarySectionID': 2, + 'librarySectionTitle': '4K Movies', + 'Media': [ + {'id': 20, 'videoResolution': '4k', 'videoCodec': 'hevc', 'container': 'mkv'}, + ], + }, + ]), ); addTearDown(client.close); @@ -489,22 +460,17 @@ void main() { final client = testPlexClient( handler: (request) async { requests.add(request.url); - final byGuid = request.url.queryParameters.containsKey('guid'); - return _json({ - 'MediaContainer': { - 'Metadata': [ - modernCopy, - if (!byGuid) - { - 'ratingKey': 'legacy-copy', - 'type': 'movie', - 'title': 'Mixed Agents', - 'guid': 'com.plexapp.agents.imdb://tt777?lang=en', - 'librarySectionTitle': 'Movies', - }, - ], + if (!_isTitleSearch(request)) return _metadata([modernCopy]); + return _hubs([ + modernCopy, + { + 'ratingKey': 'legacy-copy', + 'type': 'movie', + 'title': 'Mixed Agents', + 'guid': 'com.plexapp.agents.imdb://tt777?lang=en', + 'librarySectionTitle': 'Movies', }, - }); + ]); }, ); addTearDown(client.close); @@ -517,7 +483,7 @@ void main() { ); expect(matches.map((match) => match.id), ['modern-copy', 'legacy-copy']); - expect(requests.map((uri) => uri.queryParameters.containsKey('guid')), [true, false]); + expect(requests.map((uri) => uri.path), ['/library/all', '/hubs/search']); }); test('season-gates every candidate, not just the first', () async { @@ -542,30 +508,26 @@ void main() { if (request.url.path.startsWith('/library/metadata/')) { return _json({'MediaContainer': {}}); } - return _json({ - 'MediaContainer': { - 'Metadata': [ - { - 'ratingKey': 'partial-copy', - 'type': 'show', - 'title': 'Split Show', - 'librarySectionTitle': 'Shows', - 'Guid': [ - {'id': 'tvdb://555'}, - ], - }, - { - 'ratingKey': 'full-copy', - 'type': 'show', - 'title': 'Split Show', - 'librarySectionTitle': '4K Shows', - 'Guid': [ - {'id': 'tvdb://555'}, - ], - }, + return _hubs(type: 'show', [ + { + 'ratingKey': 'partial-copy', + 'type': 'show', + 'title': 'Split Show', + 'librarySectionTitle': 'Shows', + 'Guid': [ + {'id': 'tvdb://555'}, ], }, - }); + { + 'ratingKey': 'full-copy', + 'type': 'show', + 'title': 'Split Show', + 'librarySectionTitle': '4K Shows', + 'Guid': [ + {'id': 'tvdb://555'}, + ], + }, + ]); }, ); addTearDown(client.close); @@ -579,4 +541,30 @@ void main() { expect(matches.map((match) => match.id), ['full-copy']); }); + + test('a server that cannot answer fails the lookup without cascading through its endpoints', () async { + // #2098: a slow or erroring server is not evidence the title is absent — + // the caller must see a failure, not an empty page — and it is not a + // dead endpoint either, so the lookup must not move the client onto the + // next candidate the way a request left on the default policy would. + final requestsByHost = {}; + var exhausted = 0; + final client = testPlexClient( + baseUrl: 'https://primary.example.com', + prioritizedEndpoints: const ['https://primary.example.com', 'https://secondary.example.com'], + onAllEndpointsExhausted: () => exhausted++, + handler: (request) async { + requestsByHost.update(request.url.host, (count) => count + 1, ifAbsent: () => 1); + return http.Response('busy', 503); + }, + ); + addTearDown(client.close); + + await expectLater( + client.findByExternalIds(const ExternalIds(tmdb: 42), kind: MediaKind.movie, titles: const ['Busy Movie']), + throwsA(isA()), + ); + expect(requestsByHost, {'primary.example.com': 1}); + expect(exhausted, 0); + }); } diff --git a/test/test_helpers/library_lookup.dart b/test/test_helpers/library_lookup.dart new file mode 100644 index 000000000..ed911df50 --- /dev/null +++ b/test/test_helpers/library_lookup.dart @@ -0,0 +1,11 @@ +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; + +/// A reverse-lookup wave fixture. By default every listed server answered; +/// name [failed] / [cancelled] servers to model a wave a server sat out. +LibraryLookupResult libraryLookupResult( + List items, { + Set succeeded = const {}, + Set failed = const {}, + Set cancelled = const {}, +}) => (items: items, succeededServerIds: succeeded, cancelledServerIds: cancelled, failedServerIds: failed); diff --git a/test/utils/title_match_candidates_test.dart b/test/utils/title_match_candidates_test.dart index 78b3f99c4..748ba32ff 100644 --- a/test/utils/title_match_candidates_test.dart +++ b/test/utils/title_match_candidates_test.dart @@ -50,21 +50,13 @@ void main() { }); group('titleMatchCandidates', () { - test('emits a title immediately followed by its stripped form', () { + test('emits each title immediately followed by its stripped form', () { final candidates = titleMatchCandidates([ 'You and I Are Polar Opposites Season 2', 'Seihantai na Kimi to Boku 2nd Season', ]); - // Capped at two, so the second provider title never gets a slot — the - // stripped form of the first is worth far more than an alias. - expect(candidates, ['You and I Are Polar Opposites Season 2', 'You and I Are Polar Opposites']); - }); - - test('honours a wider cap by interleaving, never by listing raw titles first', () { - final candidates = titleMatchCandidates([ - 'You and I Are Polar Opposites Season 2', - 'Seihantai na Kimi to Boku 2nd Season', - ], limit: 4); + // The second family is the only route to a copy filed under the romaji + // title (#2098), and each family carries its own parent-show form. expect(candidates, [ 'You and I Are Polar Opposites Season 2', 'You and I Are Polar Opposites', @@ -72,6 +64,14 @@ void main() { 'Seihantai na Kimi to Boku', ]); }); + + test('honours a narrower cap by interleaving, never by listing raw titles first', () { + final candidates = titleMatchCandidates([ + 'You and I Are Polar Opposites Season 2', + 'Seihantai na Kimi to Boku 2nd Season', + ], limit: 2); + expect(candidates, ['You and I Are Polar Opposites Season 2', 'You and I Are Polar Opposites']); + }); test('normalizes typographic punctuation both backends miss on', () { // Verified live: Plex and Jellyfin both return 0 for the curly form. expect(titleMatchCandidates(['Frieren: Beyond Journey\u2019s End']), ["Frieren: Beyond Journey's End"]); @@ -103,7 +103,14 @@ void main() { 'Mushoku Tensei 2', 'MT2', ]); - expect(candidates, ['Mushoku Tensei: Jobless Reincarnation Season 2', 'Mushoku Tensei: Jobless Reincarnation']); + // `II:` mid-title is not a sequel suffix, so the second family has no + // stripped form and the third alias takes the last slot. + expect(candidates, [ + 'Mushoku Tensei: Jobless Reincarnation Season 2', + 'Mushoku Tensei: Jobless Reincarnation', + 'Mushoku Tensei II: Isekai Ittara Honki Dasu', + 'Mushoku Tensei 2', + ]); }); }); }