fix(explore): find library copies through the Plex search index and report servers that could not answer
Explore showed "Not in your library" for titles the user owns. Two causes: Plex's /library/all?title= filter is an ordered word-prefix substring match, so `Oshi no Ko` never found a library titled `[Oshi no Ko]` and a romaji or localized title was unreachable once the first candidate hit; and a server that answered slower than the 10s header budget was treated as a dead endpoint, dropped from the wave, cached as a negative for ten minutes and cascaded through its stale LAN candidates. Plex title candidates now go through /hubs/search with includeGuids, the full-text index behind Plex's own search, which also covers originalTitle. Both backends search every candidate concurrently and union the id-verified copies instead of stopping at the first title that hit; the candidate cap becomes two title families so a sequel can reach its alternate title. Lookup requests run under a dedicated deadline with endpoint failover off. The aggregation layer reports per-server failures, the matcher only memoizes complete positive waves, and the detail screen shows "Couldn't check N servers" instead of claiming absence. close #2098
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -1263,6 +1263,10 @@
|
||||
"notInLibrary": "Не е в твоята библиотека",
|
||||
"inTheseLibraries": "В тези библиотеки",
|
||||
"checkingLibrary": "Проверка на твоята библиотека...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Тук все още няма нищо",
|
||||
"emptyMessage": "Редовете от ${source} ще се появят тук, когато има съдържание.",
|
||||
"searchHint": "Търсене в ${source}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -1257,6 +1257,9 @@
|
||||
"notInLibrary": "ライブラリにありません",
|
||||
"inTheseLibraries": "これらのライブラリにあります",
|
||||
"checkingLibrary": "ライブラリを確認中…",
|
||||
"libraryCheckFailed": {
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "まだ何もありません",
|
||||
"emptyMessage": "${source}にコンテンツが追加されると、ここに表示されます。",
|
||||
"searchHint": "${source}を検索",
|
||||
|
||||
@@ -1263,6 +1263,10 @@
|
||||
"notInLibrary": "Кітапханаңызда жоқ",
|
||||
"inTheseLibraries": "Осы кітапханаларда бар",
|
||||
"checkingLibrary": "Кітапхана тексерілуде...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Әлі де мұнда ештеңе жоқ",
|
||||
"emptyMessage": "${source} дереккөзінен алынған қатарлар мұнда көрінеді.",
|
||||
"searchHint": "${source} ішінен іздеу",
|
||||
|
||||
@@ -1257,6 +1257,9 @@
|
||||
"notInLibrary": "라이브러리에 없음",
|
||||
"inTheseLibraries": "이 라이브러리에 있음",
|
||||
"checkingLibrary": "라이브러리 확인 중...",
|
||||
"libraryCheckFailed": {
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "아직 아무것도 없습니다",
|
||||
"emptyMessage": "${source}에 콘텐츠가 추가되면 여기에 표시됩니다.",
|
||||
"searchHint": "${source}에서 검색",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -1275,6 +1275,12 @@
|
||||
"notInLibrary": "Нет в вашей библиотеке",
|
||||
"inTheseLibraries": "В этих библиотеках",
|
||||
"checkingLibrary": "Проверка вашей библиотеки...",
|
||||
"libraryCheckFailed": {
|
||||
"one": "",
|
||||
"few": "",
|
||||
"many": "",
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "Здесь пока ничего нет",
|
||||
"emptyMessage": "Разделы из ${source} появятся здесь, когда в них появится контент.",
|
||||
"searchHint": "Поиск в ${source}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1257,6 +1257,9 @@
|
||||
"notInLibrary": "不在您的媒體庫中",
|
||||
"inTheseLibraries": "在這些媒體庫中",
|
||||
"checkingLibrary": "正在檢查您的媒體庫…",
|
||||
"libraryCheckFailed": {
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "這裡還沒有任何內容",
|
||||
"emptyMessage": "當 ${source} 有內容時,相關資訊將顯示在此處。",
|
||||
"searchHint": "搜尋 ${source}",
|
||||
|
||||
@@ -1257,6 +1257,9 @@
|
||||
"notInLibrary": "不在你的媒体库中",
|
||||
"inTheseLibraries": "在这些媒体库中",
|
||||
"checkingLibrary": "正在检查你的媒体库…",
|
||||
"libraryCheckFailed": {
|
||||
"other": ""
|
||||
},
|
||||
"emptyTitle": "这里还什么都没有",
|
||||
"emptyMessage": "当 ${source} 有内容时,相关内容将显示在这里。",
|
||||
"searchHint": "搜索 ${source}",
|
||||
|
||||
@@ -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<List<MediaItem>> findByExternalIds(
|
||||
ExternalIds ids, {
|
||||
required MediaKind kind,
|
||||
|
||||
@@ -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<CatalogItemDetailScreen> {
|
||||
/// Library items matching this catalog item; null while resolving.
|
||||
List<MediaItem>? _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<String> _checkedServerIds = {};
|
||||
final Set<String> _uncheckedServerIds = {};
|
||||
|
||||
/// Cast/characters from the item's own source; null while loading (the
|
||||
/// section only renders once loaded non-empty).
|
||||
List<CatalogCastMember>? _cast;
|
||||
@@ -169,9 +177,9 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
||||
}
|
||||
|
||||
Future<void> _resolveMatches(CatalogItem item) async {
|
||||
List<MediaItem> matches;
|
||||
LibraryLookupResult result;
|
||||
try {
|
||||
matches = await context.read<CatalogLibraryMatcher>().match(item);
|
||||
result = await context.read<CatalogLibraryMatcher>().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<CatalogItemDetailScreen> {
|
||||
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<CatalogItemDetailScreen> {
|
||||
}
|
||||
|
||||
/// 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<CatalogItemDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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<CatalogItemDetailScreen> {
|
||||
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),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<String, ({DateTime at, List<MediaItem> items})> _cache = {};
|
||||
final Map<String, ({DateTime at, LibraryLookupResult result})> _cache = {};
|
||||
|
||||
CatalogLibraryMatcher(this._multiServer) : _now = DateTime.now;
|
||||
|
||||
@visibleForTesting
|
||||
CatalogLibraryMatcher.withClock(this._multiServer, this._now);
|
||||
|
||||
Future<List<MediaItem>> match(CatalogItem item) async {
|
||||
if (!item.ids.hasAny) return const [];
|
||||
Future<LibraryLookupResult> 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: <MediaItem>[],
|
||||
succeededServerIds: <String>{},
|
||||
cancelledServerIds: <String>{},
|
||||
failedServerIds: <String>{},
|
||||
);
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -61,6 +61,17 @@ typedef SearchAggregationResult = ({
|
||||
Set<String> cancelledServerIds,
|
||||
Set<String> 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<MediaItem> items,
|
||||
Set<String> succeededServerIds,
|
||||
Set<String> cancelledServerIds,
|
||||
Set<String> failedServerIds,
|
||||
});
|
||||
typedef _FanOutResult<T> = ({
|
||||
List<T> items,
|
||||
Set<String> 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<List<MediaItem>> 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<LibraryLookupResult> findByExternalIdsAcrossServers(
|
||||
ExternalIds ids, {
|
||||
required MediaKind kind,
|
||||
List<String> 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 <MediaItem>[],
|
||||
succeededServerIds: const <String>{},
|
||||
cancelledServerIds: const <String>{},
|
||||
failedServerIds: const <String>{},
|
||||
);
|
||||
}
|
||||
|
||||
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 <MediaItem>[];
|
||||
}
|
||||
});
|
||||
|
||||
return mergeLibraryCopies(const [], (await Future.wait(futures)).expand((items) => items));
|
||||
final fetched = await _fanOut<MediaItem>(
|
||||
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).
|
||||
|
||||
@@ -19,6 +19,11 @@ const _HubRetryPolicy _continueWatchingRetry = (
|
||||
deadline: MediaServerTimeouts.homeHubDeadline,
|
||||
);
|
||||
|
||||
const _HubRetryPolicy _libraryLookupRetry = (
|
||||
operation: 'Jellyfin library lookup',
|
||||
deadline: MediaServerTimeouts.libraryLookup,
|
||||
);
|
||||
|
||||
List<Map<String, dynamic>> _itemsArray(Object? data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
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 = <String>{};
|
||||
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
|
||||
|
||||
@@ -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<List<MediaItem>> 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 <String>[];
|
||||
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 = <String, Map<String, dynamic>>{};
|
||||
final modern = <String, Map<String, dynamic>>{};
|
||||
final legacy = <String, Map<String, dynamic>>{};
|
||||
@@ -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<bool> 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<MediaServerResponse> _lookupRequest(
|
||||
String path,
|
||||
Map<String, dynamic> queryParameters, {
|
||||
required String operation,
|
||||
}) => retryTransientMediaServerCall(
|
||||
operation: operation,
|
||||
deadline: MediaServerTimeouts.libraryLookup,
|
||||
call: (timeout, abort) => _getWithFailover(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
timeout: timeout,
|
||||
abort: abort,
|
||||
allowEndpointFailover: false,
|
||||
),
|
||||
);
|
||||
|
||||
Future<List<Map<String, dynamic>>> _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<List<Map<String, dynamic>>> _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<String, dynamic>) item,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reportPlaybackStarted({
|
||||
required String itemId,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String> titleMatchCandidates(Iterable<String?> titles, {int limit = 2}) {
|
||||
List<String> titleMatchCandidates(Iterable<String?> titles, {int limit = 4}) {
|
||||
final out = <String>[];
|
||||
final seen = <String>{};
|
||||
|
||||
|
||||
@@ -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<MediaItem> matches;
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> match(CatalogItem item) async => matches;
|
||||
Future<LibraryLookupResult> 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<CatalogItem> calls = [];
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> match(CatalogItem item) async {
|
||||
Future<LibraryLookupResult> 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<List<MediaItem> Function()> passes;
|
||||
final List<LibraryLookupResult Function()> passes;
|
||||
int calls = 0;
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> match(CatalogItem item) async {
|
||||
Future<LibraryLookupResult> 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'),
|
||||
],
|
||||
]),
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -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<List<MediaItem>> match(CatalogItem item) async => const [];
|
||||
Future<LibraryLookupResult> match(CatalogItem item) async => libraryLookupResult(const []);
|
||||
}
|
||||
|
||||
Future<void> _pumpMenuSearch(WidgetTester tester, _FakeSearchSource source, {required TargetPlatform platform}) async {
|
||||
|
||||
@@ -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<List<MediaItem>> responses = [];
|
||||
final List<LibraryLookupResult> responses = [];
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> findByExternalIdsAcrossServers(
|
||||
Future<LibraryLookupResult> findByExternalIdsAcrossServers(
|
||||
ExternalIds ids, {
|
||||
required MediaKind kind,
|
||||
List<String> 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.
|
||||
|
||||
@@ -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 = <String>[];
|
||||
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()] : <Object>[],
|
||||
'Items': switch (searchTerm) {
|
||||
'Parent Series' => [_series()],
|
||||
'Oya Series' => [_series(), _series(id: 'series-romaji')],
|
||||
_ => <Object>[],
|
||||
},
|
||||
});
|
||||
}
|
||||
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(<Object>[]);
|
||||
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 = <Uri>[];
|
||||
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 {
|
||||
|
||||
@@ -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<Object> 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<Object> 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 = <Uri>[];
|
||||
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 = <Uri>[];
|
||||
final client = testPlexClient(
|
||||
handler: (request) async {
|
||||
requests.add(request.url);
|
||||
if (request.url.queryParameters['title'] == 'Parent Show Season 2') {
|
||||
return _json({
|
||||
'MediaContainer': {'Metadata': <Object>[]},
|
||||
});
|
||||
}
|
||||
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 = <Uri>[];
|
||||
final client = testPlexClient(
|
||||
handler: (request) async {
|
||||
requests.add(request.url);
|
||||
return _json({
|
||||
'MediaContainer': {'Metadata': <Object>[]},
|
||||
});
|
||||
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': <String, Object?>{}});
|
||||
}
|
||||
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': <String, Object?>{}});
|
||||
}
|
||||
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 = <String, int>{};
|
||||
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<MediaServerHttpException>()),
|
||||
);
|
||||
expect(requestsByHost, {'primary.example.com': 1});
|
||||
expect(exhausted, 0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<MediaItem> items, {
|
||||
Set<String> succeeded = const {},
|
||||
Set<String> failed = const {},
|
||||
Set<String> cancelled = const {},
|
||||
}) => (items: items, succeededServerIds: succeeded, cancelledServerIds: cancelled, failedServerIds: failed);
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user