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:
edde746
2026-09-05 14:44:01 +02:00
parent a629a3407a
commit a2c9bea041
39 changed files with 808 additions and 492 deletions
+4
View File
@@ -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",
+4
View File
@@ -1263,6 +1263,10 @@
"notInLibrary": "Не е в твоята библиотека",
"inTheseLibraries": "В тези библиотеки",
"checkingLibrary": "Проверка на твоята библиотека...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Тук все още няма нищо",
"emptyMessage": "Редовете от ${source} ще се появят тук, когато има съдържание.",
"searchHint": "Търсене в ${source}",
+4
View File
@@ -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}",
+4
View File
@@ -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",
+4
View File
@@ -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}",
+4
View File
@@ -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}",
+4
View File
@@ -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}",
+4
View File
@@ -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}",
+4
View File
@@ -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}",
+3
View File
@@ -1257,6 +1257,9 @@
"notInLibrary": "ライブラリにありません",
"inTheseLibraries": "これらのライブラリにあります",
"checkingLibrary": "ライブラリを確認中…",
"libraryCheckFailed": {
"other": ""
},
"emptyTitle": "まだ何もありません",
"emptyMessage": "${source}にコンテンツが追加されると、ここに表示されます。",
"searchHint": "${source}を検索",
+4
View File
@@ -1263,6 +1263,10 @@
"notInLibrary": "Кітапханаңызда жоқ",
"inTheseLibraries": "Осы кітапханаларда бар",
"checkingLibrary": "Кітапхана тексерілуде...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Әлі де мұнда ештеңе жоқ",
"emptyMessage": "${source} дереккөзінен алынған қатарлар мұнда көрінеді.",
"searchHint": "${source} ішінен іздеу",
+3
View File
@@ -1257,6 +1257,9 @@
"notInLibrary": "라이브러리에 없음",
"inTheseLibraries": "이 라이브러리에 있음",
"checkingLibrary": "라이브러리 확인 중...",
"libraryCheckFailed": {
"other": ""
},
"emptyTitle": "아직 아무것도 없습니다",
"emptyMessage": "${source}에 콘텐츠가 추가되면 여기에 표시됩니다.",
"searchHint": "${source}에서 검색",
+4
View File
@@ -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}",
+4
View File
@@ -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}",
+6
View File
@@ -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}",
+4
View File
@@ -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}",
+6
View File
@@ -1275,6 +1275,12 @@
"notInLibrary": "Нет в вашей библиотеке",
"inTheseLibraries": "В этих библиотеках",
"checkingLibrary": "Проверка вашей библиотеки...",
"libraryCheckFailed": {
"one": "",
"few": "",
"many": "",
"other": ""
},
"emptyTitle": "Здесь пока ничего нет",
"emptyMessage": "Разделы из ${source} появятся здесь, когда в них появится контент.",
"searchHint": "Поиск в ${source}",
+1 -1
View File
@@ -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
+8 -1
View File
@@ -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',
+4
View File
@@ -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}",
+4
View File
@@ -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",
+4
View File
@@ -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",
+3
View File
@@ -1257,6 +1257,9 @@
"notInLibrary": "不在您的媒體庫中",
"inTheseLibraries": "在這些媒體庫中",
"checkingLibrary": "正在檢查您的媒體庫…",
"libraryCheckFailed": {
"other": ""
},
"emptyTitle": "這裡還沒有任何內容",
"emptyMessage": "當 ${source} 有內容時,相關資訊將顯示在此處。",
"searchHint": "搜尋 ${source}",
+3
View File
@@ -1257,6 +1257,9 @@
"notInLibrary": "不在你的媒体库中",
"inTheseLibraries": "在这些媒体库中",
"checkingLibrary": "正在检查你的媒体库…",
"libraryCheckFailed": {
"other": ""
},
"emptyTitle": "这里还什么都没有",
"emptyMessage": "当 ${source} 有内容时,相关内容将显示在这里。",
"searchHint": "搜索 ${source}",
+14 -8
View File
@@ -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,
+41 -14
View File
@@ -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.
+38 -24
View File
@@ -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).
+38 -26
View File
@@ -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
+96 -68
View File
@@ -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,
+9
View File
@@ -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);
+12 -10
View File
@@ -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'),
],
]),
]),
);
+3 -2
View File
@@ -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 {
+242 -254
View File
@@ -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);
});
}
+11
View File
@@ -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);
+19 -12
View File
@@ -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',
]);
});
});
}