From bc13ff007a75bcefcedaf472f2b2ddef6f864f92 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:46:37 +0200 Subject: [PATCH] perf(explore): resolve library copies with one guid filter and a native-title search per server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Explore library lookup spent up to eight search-index queries per server per tap, plus one children fetch per candidate for sequels, and the Trakt path added two alias/translation requests per detail open to reach romaji and localized library titles. Both backends index originalTitle on every copy of a foreign title whatever language it is filed under, so the lookup now leads with the native title and drops alternate titles as candidates: one query reaches the English, romaji, localized and native copies alike. Plex's /library/all?guid= filter is a literal prefix match with comma as OR, so every legacy-agent guid form the external ids imply rides one indexed request alongside the Discover guid, and the sequel gate asks for the season of every candidate in one /library/all?type=3&show.id=…&season.index=N request. Worst case is five concurrent requests per server, typically two or three, with no cloud call. The Trakt alias/translation fetch is removed: the copies it reached are the ones the native title reaches, and Trakt already sends original_title. close #2098 --- lib/media/media_server_client.dart | 31 +- lib/models/trakt/trakt_title_variant.dart | 18 - lib/models/trakt/trakt_title_variant.g.dart | 14 - .../catalog/catalog_library_matcher.dart | 34 +- .../catalog/trakt_catalog_source.dart | 73 +--- lib/services/plex_client.dart | 109 ++--- lib/services/trackers/trakt/trakt_client.dart | 24 -- lib/utils/external_ids.dart | 22 + lib/utils/title_match_candidates.dart | 23 +- .../catalog_item_detail_screen_test.dart | 10 +- .../catalog/catalog_library_matcher_test.dart | 44 +- .../catalog/trakt_catalog_source_test.dart | 94 +--- .../plex_external_id_lookup_test.dart | 400 ++++++++++-------- test/utils/external_ids_test.dart | 20 + test/utils/title_match_candidates_test.dart | 15 +- 15 files changed, 404 insertions(+), 527 deletions(-) delete mode 100644 lib/models/trakt/trakt_title_variant.dart delete mode 100644 lib/models/trakt/trakt_title_variant.g.dart diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 5c107daa1..d2dad8846 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -603,11 +603,13 @@ abstract class MediaServerClient { /// Reverse lookup: find every library movie/show matching any of [ids]. /// - /// Neither backend can filter by external id — Plex's `guid=` matches only - /// the primary `plex://` guid (verified on PMS 1.43) and Jellyfin dropped - /// `anyProviderIdEquals` (silently ignored on 10.11.10) — so both search by - /// title and verify candidates against their exact external ids. Title - /// alone never produces a match. + /// Neither backend can filter by the external ids a modern item carries — + /// Plex's `guid=` sees only the primary guid (verified on PMS 1.43) and + /// Jellyfin dropped `anyProviderIdEquals` (silently ignored on 10.11.10) — + /// so both search by title and verify candidates against their exact + /// external ids. Title alone never produces a match. Plex does reach a + /// legacy-agent item by id, because that item's primary guid *is* the + /// external id ([ExternalIds.legacyPlexGuidPrefixes]). /// /// One title can own several library items: a server with a 4K section and /// an HD section holds two rating keys for the same movie, and a library @@ -619,15 +621,16 @@ abstract class MediaServerClient { /// them. Ordering is the implementation's, and callers re-sort. /// /// 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. + /// candidates is returned. The caller (`CatalogLibraryMatcher.lookupTitles`) + /// leads with the native title — both search indexes cover `originalTitle`, + /// so it reaches a copy filed under any display language — and bounds the + /// list; that cap is the request budget. A title that hit MUST NOT stop the + /// others: 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 diff --git a/lib/models/trakt/trakt_title_variant.dart b/lib/models/trakt/trakt_title_variant.dart deleted file mode 100644 index a8aafb2e1..000000000 --- a/lib/models/trakt/trakt_title_variant.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'trakt_title_variant.g.dart'; - -/// One row of `GET /{movies|shows}/{id}/aliases` or -/// `GET /{movies|shows}/{id}/translations/{language}`: a title as some -/// region or language knows it. [country] is Trakt's lowercase ISO 3166-1 -/// alpha-2 code; translations also carry [language]. -@JsonSerializable(createToJson: false) -class TraktTitleVariant { - final String? title; - final String? country; - final String? language; - - const TraktTitleVariant({this.title, this.country, this.language}); - - factory TraktTitleVariant.fromJson(Map json) => _$TraktTitleVariantFromJson(json); -} diff --git a/lib/models/trakt/trakt_title_variant.g.dart b/lib/models/trakt/trakt_title_variant.g.dart deleted file mode 100644 index 1d547b6ab..000000000 --- a/lib/models/trakt/trakt_title_variant.g.dart +++ /dev/null @@ -1,14 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'trakt_title_variant.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -TraktTitleVariant _$TraktTitleVariantFromJson(Map json) => - TraktTitleVariant( - title: json['title'] as String?, - country: json['country'] as String?, - language: json['language'] as String?, - ); diff --git a/lib/services/catalog/catalog_library_matcher.dart b/lib/services/catalog/catalog_library_matcher.dart index 6f0bca757..da770aff8 100644 --- a/lib/services/catalog/catalog_library_matcher.dart +++ b/lib/services/catalog/catalog_library_matcher.dart @@ -37,12 +37,11 @@ class CatalogLibraryMatcher { // `mal45576 s1`, `mal51179 s2`, `mal55888 s2`, `mal59193 s3`) collapse to // `imdb:tt13293588`, so the first season-gated result would poison the rest. // Namespace by source too: MAL and AniList can share a MAL id while - // contributing different localized title candidates. The id forms and - // the title candidates join the key because a detail load can enrich an - // item with external ids its row form lacked (#1715: Plex rows carry only - // a rating key) or with alternate titles (Trakt aliases, #2098); the - // richer lookup must not be short-circuited by the poorer form's cached - // negative. + // carrying different titles. The id forms and the title candidates join + // the key because a detail load can enrich an item with external ids its + // row form lacked (#1715: Plex rows carry only a rating key) or with the + // native title; the richer lookup must not be short-circuited by the + // poorer form's cached negative. final key = '${item.source.name}/${item.entryIdentityKey}/${item.ids.allKeys.join(',')}/${titles.join('\u0000')}'; final cached = _cache[key]; if (cached != null && (_isAuthoritativeHit(cached.result) || _now().difference(cached.at) < negativeTtl)) { @@ -66,14 +65,21 @@ class CatalogLibraryMatcher { return result; } - /// The title candidates a lookup for [item] spends its request budget on: - /// every title the source knows, own title first, native title last (it is - /// the one a Plex library reaches through `originalTitle` even when filed - /// under another display title, so it earns the slot least). A detail load - /// that adds titles changes this list, which is what tells the detail - /// screen to ask again. - static List lookupTitles(CatalogItem item) => - titleMatchCandidates([item.title, ...item.altTitles, item.originalTitle]); + /// The title candidates a lookup for [item] spends its request budget on. + /// + /// Native title first: both backends index `originalTitle` alongside the + /// display title (Plex `/hubs/search`, Jellyfin `SearchTerm`; verified on + /// PMS 1.43 and Jellyfin 10.11), and every copy of a foreign title carries + /// the same `originalTitle` whatever language it is filed under — English, + /// romaji, localized or native. One query with it reaches all of them + /// (#2098). The item's own title follows for the catalog/agent spelling + /// drift the native form can suffer, and each brings its season-stripped + /// form for sequel entries. Alternate titles are not candidates: the + /// copies they used to reach are exactly the ones the native title reaches. + /// + /// A detail load that changes this list is what tells the detail screen + /// to ask again. + static List lookupTitles(CatalogItem item) => titleMatchCandidates([item.originalTitle, item.title]); static bool _isAuthoritativeHit(LibraryLookupResult result) => result.items.isNotEmpty && result.failedServerIds.isEmpty && result.cancelledServerIds.isEmpty; diff --git a/lib/services/catalog/trakt_catalog_source.dart b/lib/services/catalog/trakt_catalog_source.dart index f7598b5fd..8fc93afc3 100644 --- a/lib/services/catalog/trakt_catalog_source.dart +++ b/lib/services/catalog/trakt_catalog_source.dart @@ -1,6 +1,3 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; - import '../../i18n/strings.g.dart'; import '../../media/media_kind.dart'; import '../../models/catalog/catalog_cast_member.dart'; @@ -10,7 +7,6 @@ import '../../models/trakt/trakt_cast_entry.dart'; import '../../models/trakt/trakt_catalog_entry.dart'; import '../../models/trakt/trakt_catalog_media.dart'; import '../../models/trakt/trakt_ids.dart'; -import '../../models/trakt/trakt_title_variant.dart'; import '../../utils/app_logger.dart'; import '../../utils/country_codes.dart'; import '../../utils/external_ids.dart'; @@ -129,24 +125,14 @@ class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource if (id == null) return CatalogDetail(item: item); final type = item.kind == MediaKind.movie ? TraktCatalogType.movies : TraktCatalogType.shows; - // Start every request before awaiting any. Trakt keeps people, related - // titles, aliases and translations on separate endpoints, so four calls - // are the minimum; the last two exist for the library lookup (#2098). + // Start both requests before awaiting either. Trakt keeps people and + // related titles on separate endpoints, so two calls are the minimum. final peopleFuture = _withoutFailure(_client.getPeople(type, id), 'people', item); final relatedFuture = _withoutFailure(_client.getRelated(type, id, limit: relatedLimit), 'related', item); - final aliasesFuture = _withoutFailure(_client.getAliases(type, id), 'aliases', item); - final translationsFuture = _withoutFailure( - _client.getTranslations(type, id, LocaleSettings.currentLocale.languageCode), - 'translations', - item, - ); final people = await peopleFuture; final related = await relatedFuture; - final aliases = await aliasesFuture; - final translations = await translationsFuture; final credits = _creditsFrom(people?.crew); - final altTitles = lookupTitles(item, aliases: aliases ?? const [], translations: translations ?? const []); - final enrichedItem = credits.isEmpty && altTitles.isEmpty + final enrichedItem = credits.isEmpty ? item : item.enrichedWith( CatalogItem( @@ -155,7 +141,6 @@ class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource title: '', ids: const CatalogItemIds(), credits: credits, - altTitles: altTitles, ), ); @@ -166,58 +151,6 @@ class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource ); } - /// The alternate titles worth one library-lookup request each, in the - /// order the candidate budget should spend them (see - /// `titleMatchCandidates`; Trakt rows carry none of their own): - /// - /// 1. the romaji/transliterated title — the first alias from the item's - /// own country written in plain ASCII, which is how a Latin-script - /// library files a Japanese title (`Sousou no Frieren`); - /// 2. the app-locale translation, for a library titled in the user's - /// language (`Frieren: Tras finalizar el viaje`). - /// - /// The native title is not repeated here: Trakt already sends it as - /// `original_title`, and the matcher reads [CatalogItem.originalTitle] - /// for every source. - /// - /// Only one alias is taken, and only an ASCII one: Trakt lists every - /// spelling (`Sōsō no Frieren`, `Sōsō no Furīren`) and every region's - /// marketing name, hundreds of rows for a long-running show, and each - /// would cost a request of its own. A variant that is the item's own - /// title in other punctuation (`"Oshi no Ko"`, `【OSHI NO KO】`) is skipped: - /// the search index tokenizes on punctuation, so it would only repeat the - /// own title's request. - @visibleForTesting - static List lookupTitles( - CatalogItem item, { - required List aliases, - required List translations, - }) { - final own = _letters(item.title); - final country = item.countries?.firstOrNull; - String? romaji; - if (country != null) { - for (final alias in aliases) { - final title = alias.title?.trim(); - if (title == null || title.isEmpty || alias.country == null) continue; - if (CountryCodes.normalizeCode(alias.country!) != country) continue; - if (!_isAscii(title) || _letters(title) == own) continue; - romaji = title; - break; - } - } - final localized = translations.map((variant) => variant.title?.trim()).firstWhereOrNull((title) { - return title != null && title.isNotEmpty && _letters(title) != own; - }); - return {?romaji, ?localized}.toList(growable: false); - } - - static final RegExp _notLetterOrDigit = RegExp(r'[^\p{L}\p{N}]+', unicode: true); - - static String _letters(String title) => title.toLowerCase().replaceAll(_notLetterOrDigit, ''); - - static bool _isAscii(String value) => value.codeUnits.every((unit) => unit < 0x80); - Future _withoutFailure(Future request, String requestName, CatalogItem item) async { try { return await request; diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 5be543d4f..6452b9706 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -4419,10 +4419,12 @@ class PlexClient /// supply, and reading it costs two extra requests per lookup, so a /// disagreeing ref is left ungated rather than gated on a guess. /// - /// Gating costs one `fetchChildren` per show candidate. The candidate list - /// is deliberately never truncated (see - /// [MediaServerClient.findByExternalIds]), so the gates run concurrently - /// rather than letting latency grow with the number of library copies. + /// Gating costs one request for every candidate together: + /// `/library/all?type=3&show.id=&season.index=N` answers with the + /// matching season of each show that has it (the media-query `,` is OR; + /// verified on PMS 1.43), and a candidate is kept when its rating key is + /// among the parents. A candidate without a rating key cannot be asked + /// about and is kept. Future> _gateExternalIdMatches( Iterable> candidates, { required MediaKind kind, @@ -4430,52 +4432,64 @@ class PlexClient }) async { final entries = [ for (final metadata in candidates) - (metadata: metadata, item: PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(metadata))), + ( + ratingKey: metadata['ratingKey']?.toString(), + item: PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(metadata)), + ), ]; final seasonIndex = season?.agreedSeason; if (kind != MediaKind.show || seasonIndex == null || seasonIndex <= 1) { return [for (final entry in entries) entry.item]; } - final kept = await Future.wait([for (final entry in entries) _hasSeason(entry.metadata, seasonIndex)]); + final ratingKeys = [ + for (final entry in entries) + if (entry.ratingKey case final key? when key.isNotEmpty) key, + ]; + if (ratingKeys.isEmpty) return [for (final entry in entries) entry.item]; + final response = await _lookupRequest('/library/all', { + 'type': 3, + 'show.id': ratingKeys.join(','), + 'season.index': seasonIndex, + }, operation: 'Plex season lookup'); + final withSeason = { + for (final row in _getMetadataJsonList(response)) + if (row['parentRatingKey']?.toString() case final parent? when parent.isNotEmpty) parent, + }; return [ - for (var index = 0; index < entries.length; index++) - if (kept[index]) entries[index].item, + for (final entry in entries) + if (entry.ratingKey == null || entry.ratingKey!.isEmpty || withSeason.contains(entry.ratingKey)) entry.item, ]; } - Future _hasSeason(Map metadata, int seasonIndex) async { - final ratingKey = metadata['ratingKey']?.toString(); - // Cannot ask the question => do not gate. - if (ratingKey == null || ratingKey.isEmpty) return true; - final children = await fetchChildren(ratingKey); - return children.any((child) => child.kind == MediaKind.season && child.index == seasonIndex); - } - - /// 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 on - /// `/library/all`, while ids in `Guid` arrays are verified client-side - /// against the rows a title search returns. + /// Server-wide external-id reverse lookup, at most one exact request plus + /// one search per title. + /// + /// The `guid=` filter on `/library/all` is a literal prefix match on the + /// item's primary guid with `,` as OR (verified on PMS 1.43; Plex Web's own + /// "available on your servers" is this exact request). One call therefore + /// covers the resolved [plexGuid] and every legacy-agent form the external + /// ids imply ([ExternalIds.legacyPlexGuidPrefixes]) — a library still on + /// `com.plexapp.agents.thetvdb` or HAMA is reached by id, not by title. + /// Only the modern `Guid` array is unfilterable, which is what the title + /// searches are for. /// /// 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). + /// `originalTitle`, so the native title the caller leads with reaches a + /// copy filed under any display language in one query. 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). /// - /// 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. + /// Every request runs concurrently, and id verification is the only gate + /// on every row, guid page included — a prefix filter can over-match, and a + /// title that hit does not make its sibling redundant. The exact guid is + /// itself an id: a row whose primary guid is [plexGuid] verifies without + /// external ids, which is how a bare Plex Discover row resolves. /// /// Requests run under [MediaServerTimeouts.libraryLookup] with endpoint /// failover off: a large library answering slowly is not a dead endpoint, @@ -4496,14 +4510,15 @@ class PlexClient _ => null, }; if (plexType == null) return const []; + final guids = [?plexGuid, ...ids.legacyPlexGuidPrefixes]; // Title searches confirm candidates by external-id intersection, so - // without external ids they cannot match anything — only the exact guid - // lookup can. + // without external ids they cannot match anything — only the guid filter + // can. final searchTitles = ids.hasAny ? titles : const []; - if (searchTitles.isEmpty && plexGuid == null) return const []; + if (guids.isEmpty && searchTitles.isEmpty) return const []; final pages = await Future.wait([ - if (plexGuid != null) _lookupByPlexGuid(plexGuid, plexType: plexType), + if (guids.isNotEmpty) _lookupByGuids(guids, plexType: plexType), for (final title in searchTitles) _searchTitleForLookup(title, plexType: plexType), ]); @@ -4520,16 +4535,12 @@ class PlexClient into.putIfAbsent(ratingKey, () => item); } - var page = 0; - if (plexGuid != null) { - for (final item in pages[page++]) { - collect(exact, item); - } - } - for (; page < pages.length; page++) { - for (final item in pages[page]) { - final guids = item['Guid']; - if (guids is List && ids.intersects(ExternalIds.fromGuids(guids))) { + for (final page in pages) { + for (final item in page) { + final guid = item['Guid']; + if (plexGuid != null && item['guid'] == plexGuid) { + collect(exact, item); + } else if (guid is List && ids.intersects(ExternalIds.fromGuids(guid))) { collect(modern, item); } else if (ids.intersects(ExternalIds.fromLegacyPlexGuid(item['guid']))) { collect(legacy, item); @@ -4565,9 +4576,9 @@ class PlexClient ), ); - Future>> _lookupByPlexGuid(String plexGuid, {required int plexType}) async { + Future>> _lookupByGuids(List guids, {required int plexType}) async { final response = await _lookupRequest('/library/all', { - 'guid': plexGuid, + 'guid': guids.join(','), 'type': plexType, 'includeGuids': 1, }, operation: 'Plex guid lookup'); diff --git a/lib/services/trackers/trakt/trakt_client.dart b/lib/services/trackers/trakt/trakt_client.dart index fae13de00..57058eace 100644 --- a/lib/services/trackers/trakt/trakt_client.dart +++ b/lib/services/trackers/trakt/trakt_client.dart @@ -7,7 +7,6 @@ import '../../../models/trakt/trakt_cast_entry.dart'; import '../../../models/trakt/trakt_catalog_entry.dart'; import '../../../models/trakt/trakt_catalog_media.dart'; import '../../../models/trakt/trakt_scrobble_request.dart'; -import '../../../models/trakt/trakt_title_variant.dart'; import '../../../models/trakt/trakt_user.dart'; import '../../../utils/app_logger.dart'; import '../future_coalescer.dart'; @@ -162,29 +161,6 @@ class TraktClient implements DisposableTrackerClient { ); } - /// Alternate titles (`GET /{movies|shows}/{id}/aliases`): every title a - /// region knows the item by, tagged with that region. Unauthenticated, - /// unpaged, and for a long-running show it can run to hundreds of rows with - /// repeats, so callers select and dedupe. - Future> getAliases(TraktCatalogType type, String id) async { - final res = await _requestResponse('GET', '/${type.name}/$id/aliases'); - return _decodeTitleVariants(res.body); - } - - /// Localized titles for one [language] - /// (`GET /{movies|shows}/{id}/translations/{language}`), one row per - /// country variant. Empty when Trakt has no translation in that language. - Future> getTranslations(TraktCatalogType type, String id, String language) async { - final res = await _requestResponse('GET', '/${type.name}/$id/translations/${Uri.encodeComponent(language)}'); - return _decodeTitleVariants(res.body); - } - - static List _decodeTitleVariants(String body) { - final decoded = TrackerHttpClient.decodeJson(body); - if (decoded is! List) return const []; - return [for (final e in decoded.whereType>()) TraktTitleVariant.fromJson(e)]; - } - /// Body shape: `{"movies":[{"ids":{...}}],"shows":[{"ids":{...}}]}`. Future addToWatchlist(Map body) => _request('POST', '/sync/watchlist', body: body, allowStatuses: const {200, 201}); diff --git a/lib/utils/external_ids.dart b/lib/utils/external_ids.dart index 25264a0b5..8f24d471d 100644 --- a/lib/utils/external_ids.dart +++ b/lib/utils/external_ids.dart @@ -129,6 +129,28 @@ class ExternalIds { return ExternalIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb); } + /// The scalar `guid` a legacy-agent Plex item carrying these ids would + /// have, as `/library/all?guid=` filter values — the inverse of + /// [fromLegacyPlexGuid]. That filter is a literal prefix match (verified on + /// PMS 1.43), and every legacy guid ends in `?lang=xx`, so each value ends + /// in the `?` and cannot run into a longer id (`themoviedb://1` would + /// otherwise match every id starting with 1). HAMA's `anidb2`..`anidb9` + /// grouping modes are left out for the reason [fromLegacyPlexGuid] gives. + List get legacyPlexGuidPrefixes => [ + if (imdb case final imdb?) ...['com.plexapp.agents.imdb://$imdb?', 'com.plexapp.agents.hama://imdb-$imdb?'], + if (tmdb case final tmdb?) ...[ + 'com.plexapp.agents.themoviedb://$tmdb?', + 'com.plexapp.agents.hama://tmdb-$tmdb?', + 'com.plexapp.agents.hama://tsdb-$tmdb?', + ], + if (tvdb case final tvdb?) ...[ + 'com.plexapp.agents.thetvdb://$tvdb?', + 'com.plexapp.agents.hama://tvdb-$tvdb?', + for (var mode = 2; mode <= 9; mode++) 'com.plexapp.agents.hama://tvdb$mode-$tvdb?', + ], + if (anidb case final anidb?) 'com.plexapp.agents.hama://anidb-$anidb?', + ]; + /// Build from a legacy Plex item's scalar `guid`. /// /// HAMA composes its guid as `-` over diff --git a/lib/utils/title_match_candidates.dart b/lib/utils/title_match_candidates.dart index 7841827e0..e80c8ba10 100644 --- a/lib/utils/title_match_candidates.dart +++ b/lib/utils/title_match_candidates.dart @@ -63,23 +63,20 @@ String _normalize(String title) { /// backend searches every candidate concurrently and verifies external ids, /// so a candidate can only ever add genuine copies. /// -/// [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*, each a title plus its stripped form. 8 is four families: the -/// entry's own title, a transliterated one (Trakt's original-country alias, -/// AniList/MAL romaji), the app-locale translation, and the native title. -/// Against a 267-show Plex library, the own family alone matched 77 of 113 -/// real sequel entries and six candidates reached 81; the extra families are -/// not for hit rate but for the copies the first family cannot name. The -/// requests run concurrently against an indexed search and are memoized per -/// session, so the cost is bounded even on the miss path that dominates a -/// discovery tab. +/// [limit] is the request budget per server per lookup. 4 is two title +/// families, each a title plus its stripped form: the native title, which +/// both backends index as `originalTitle` on every copy of a foreign title +/// whatever language it is filed under, and the item's own title for the +/// spelling drift the native form can suffer between catalog and agent +/// (`CatalogLibraryMatcher.lookupTitles`). Against a 267-show Plex library, +/// the own family alone matched 77 of 113 real sequel entries and six +/// candidates reached 81; the native family is not for hit rate but for the +/// copies the own title cannot name (#2098). /// /// Each title is emitted immediately followed by its stripped form rather than /// in two passes, so the cap can never spend every slot on unstripped titles /// and never try the one candidate that actually reaches the parent show. -List titleMatchCandidates(Iterable titles, {int limit = 8}) { +List titleMatchCandidates(Iterable titles, {int limit = 4}) { final out = []; final seen = {}; diff --git a/test/screens/catalog_item_detail_screen_test.dart b/test/screens/catalog_item_detail_screen_test.dart index 8b034a9f6..8da561db3 100644 --- a/test/screens/catalog_item_detail_screen_test.dart +++ b/test/screens/catalog_item_detail_screen_test.dart @@ -379,10 +379,10 @@ void main() { expect(find.text('Movies'), findsOneWidget); }); - testWidgets('detail enrichment that adds alternate titles re-resolves library matches', (tester) async { - // #2098: a Trakt row carries no alternate titles; the detail load brings - // the romaji alias, and a copy filed under it is reachable only through - // that title. Same ids, so the id-based trigger alone would not re-ask. + testWidgets('detail enrichment that adds the native title re-resolves library matches', (tester) async { + // #2098: a row item without originalTitle gains it from the detail load, + // and a romaji-filed copy is reachable only through it. Same ids, so the + // id-based trigger alone would not re-ask. const bare = CatalogItem( source: CatalogSourceId.trakt, kind: MediaKind.show, @@ -393,7 +393,7 @@ void main() { source: CatalogSourceId.trakt, kind: MediaKind.show, title: "Frieren: Beyond Journey's End", - altTitles: ['Sousou no Frieren'], + originalTitle: '葬送のフリーレン', ids: CatalogItemIds(trakt: 198225, tvdb: 424536), ); late _ScriptedMatcher matcher; diff --git a/test/services/catalog/catalog_library_matcher_test.dart b/test/services/catalog/catalog_library_matcher_test.dart index d9a045276..0b6d1d5f6 100644 --- a/test/services/catalog/catalog_library_matcher_test.dart +++ b/test/services/catalog/catalog_library_matcher_test.dart @@ -112,14 +112,13 @@ void main() { source: CatalogSourceId.mal, kind: MediaKind.show, title: 'English Title', - altTitles: ['MAL Synonym'], ids: CatalogItemIds(mal: 100, tmdb: 200), ); const anilistItem = CatalogItem( source: CatalogSourceId.anilist, kind: MediaKind.show, title: 'English Title', - altTitles: ['日本語タイトル'], + originalTitle: '日本語タイトル', ids: CatalogItemIds(mal: 100, anilist: 300, tmdb: 200), ); @@ -218,14 +217,9 @@ 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)); - // 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', - ]); + // The own family only: alternates are not candidates, and the native + // title is absent here. + expect(call.titles, ['You and I Are Polar Opposites Season 2', 'You and I Are Polar Opposites']); }); test('keeps the year for an entry that is not a sequel', () async { @@ -247,14 +241,17 @@ void main() { expect(call.titles, ['Severance'], reason: 'nothing to strip, so one candidate and one request'); }); - test('the native title is a candidate for every source, after the alternates', () async { + test('the native title leads and alternate titles are not candidates', () async { + // #2098: both backends index `originalTitle`, so the native title alone + // reaches a copy filed under English, romaji or a localized title. The + // alternates only ever repeated that work. final harness = _Harness(); addTearDown(harness.dispose); harness.aggregation.responses.add(libraryLookupResult(const [])); const item = CatalogItem( source: CatalogSourceId.trakt, kind: MediaKind.show, - title: "Frieren: Beyond Journey's End", + title: "Frieren: Beyond Journey's End Season 2", originalTitle: '葬送のフリーレン', altTitles: ['Sousou no Frieren', 'Frieren: Tras finalizar el viaje'], ids: CatalogItemIds(trakt: 198225, tvdb: 424536), @@ -263,16 +260,15 @@ void main() { await harness.matcher.match(item); expect(harness.aggregation.calls.single.titles, [ - "Frieren: Beyond Journey's End", - 'Sousou no Frieren', - 'Frieren: Tras finalizar el viaje', '葬送のフリーレン', + "Frieren: Beyond Journey's End Season 2", + "Frieren: Beyond Journey's End", ]); }); - test('a detail load that adds titles is not served the bare form\'s cached negative', () async { - // #2098: Trakt rows carry no alternate titles; the detail load brings - // the romaji alias, and only that title reaches a romaji-filed library. + test('a detail load that adds the native title is not served the bare form\'s cached negative', () async { + // A Plex Discover row carries no originalTitle; the detail body does, + // and only that title reaches a romaji-filed library. final harness = _Harness(); addTearDown(harness.dispose); final romajiCopy = testMediaItem(id: 'romaji-copy', kind: MediaKind.show); @@ -281,23 +277,23 @@ void main() { libraryLookupResult([romajiCopy]), ]); const bare = CatalogItem( - source: CatalogSourceId.trakt, + source: CatalogSourceId.plex, kind: MediaKind.show, title: "Frieren: Beyond Journey's End", - ids: CatalogItemIds(trakt: 198225, tvdb: 424536), + ids: CatalogItemIds(plex: '631cc', tvdb: 424536), ); const enriched = CatalogItem( - source: CatalogSourceId.trakt, + source: CatalogSourceId.plex, kind: MediaKind.show, title: "Frieren: Beyond Journey's End", - altTitles: ['Sousou no Frieren'], - ids: CatalogItemIds(trakt: 198225, tvdb: 424536), + originalTitle: '葬送のフリーレン', + ids: CatalogItemIds(plex: '631cc', tvdb: 424536), ); expect((await harness.matcher.match(bare)).items, isEmpty); expect((await harness.matcher.match(enriched)).items.single, same(romajiCopy)); expect(harness.aggregation.calls, hasLength(2)); - expect(harness.aggregation.calls.last.titles, contains('Sousou no Frieren')); + expect(harness.aggregation.calls.last.titles.first, '葬送のフリーレン'); }); test('drops the year from a sequel title even when Fribb mapped no season', () async { diff --git a/test/services/catalog/trakt_catalog_source_test.dart b/test/services/catalog/trakt_catalog_source_test.dart index f8943b919..6b5268529 100644 --- a/test/services/catalog/trakt_catalog_source_test.dart +++ b/test/services/catalog/trakt_catalog_source_test.dart @@ -8,7 +8,6 @@ import 'package:plezy/media/catalog_item_ref.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/models/catalog/catalog_item.dart'; import 'package:plezy/models/catalog/catalog_metadata.dart'; -import 'package:plezy/models/trakt/trakt_title_variant.dart'; import 'package:plezy/services/catalog/catalog_source.dart'; import 'package:plezy/services/catalog/trakt_catalog_source.dart'; import 'package:plezy/services/trackers/tracker_session.dart'; @@ -266,12 +265,7 @@ void main() { ); final detail = await source.fetchDetail(item, castLimit: 2, relatedLimit: 7); - expect(requests.map((request) => request.url.path).toSet(), { - '/shows/1388/people', - '/shows/1388/related', - '/shows/1388/aliases', - '/shows/1388/translations/en', - }); + expect(requests.map((request) => request.url.path).toSet(), {'/shows/1388/people', '/shows/1388/related'}); final peopleRequest = requests.singleWhere((request) => request.url.path.endsWith('/people')); expect(peopleRequest.url.queryParameters['extended'], 'full,images,guest_stars'); final relatedRequest = requests.singleWhere((request) => request.url.path.endsWith('/related')); @@ -298,92 +292,6 @@ void main() { expect(detail.related.single.kind, MediaKind.show); }); - test('fetchDetail carries the romaji alias and the locale translation as lookup titles', () async { - // #2098: a Plex library filed under `Sousou no Frieren` is reachable - // only through that title, and Trakt rows carry no alternate titles. - http.Response detailResponse(http.Request request) { - return switch (request.url.path) { - '/shows/198225/aliases' => http.Response.bytes( - utf8.encode( - json.encode([ - {'title': 'Sōsō no Frieren', 'country': 'jp'}, - {'title': 'Frieren of the Funeral', 'country': 'us'}, - {'title': 'Sousou no Frieren', 'country': 'jp'}, - {'title': '葬送のフリーレン', 'country': 'jp'}, - ]), - ), - 200, - headers: const {'content-type': 'application/json; charset=utf-8'}, - ), - '/shows/198225/translations/en' => http.Response( - json.encode([ - {'title': "Frieren: Beyond Journey's End", 'language': 'en', 'country': 'us'}, - {'title': 'Frieren the Slayer', 'language': 'en', 'country': 'gb'}, - ]), - 200, - ), - _ => http.Response('[]', 200), - }; - } - - handlers.addAll([detailResponse, detailResponse, detailResponse, detailResponse]); - final item = CatalogItem( - source: CatalogSourceId.trakt, - kind: MediaKind.show, - title: "Frieren: Beyond Journey's End", - originalTitle: '葬送のフリーレン', - countries: const ['JP'], - ids: const CatalogItemIds(trakt: 198225), - ); - - final detail = await source.fetchDetail(item); - - expect(detail.item.altTitles, ['Sousou no Frieren', 'Frieren the Slayer']); - expect(detail.item.originalTitle, '葬送のフリーレン', reason: 'the native title rides original_title, not aliases'); - }); - - test('lookupTitles takes one ASCII alias from the item\'s own country and skips its own title', () { - const item = CatalogItem( - source: CatalogSourceId.trakt, - kind: MediaKind.show, - title: 'Oshi no Ko', - countries: ['JP'], - ids: CatalogItemIds(trakt: 1), - ); - final titles = TraktCatalogSource.lookupTitles( - item, - aliases: const [ - TraktTitleVariant(title: '【OSHI NO KO】', country: 'jp'), - TraktTitleVariant(title: 'oshi no ko', country: 'jp'), - TraktTitleVariant(title: 'My Star', country: 'us'), - TraktTitleVariant(title: 'Oshi no Ko: My Star', country: 'jp'), - TraktTitleVariant(title: 'Oshi no Ko 2nd Season', country: 'jp'), - ], - translations: const [TraktTitleVariant(title: '【OSHI NO KO】', language: 'en', country: 'us')], - ); - - expect(titles, [ - 'Oshi no Ko: My Star', - ], reason: 'the bracketed translation is the own title in other punctuation'); - }); - - test('lookupTitles yields nothing without a country or when every variant repeats the title', () { - const noCountry = CatalogItem( - source: CatalogSourceId.trakt, - kind: MediaKind.movie, - title: 'Severance', - ids: CatalogItemIds(trakt: 2), - ); - expect( - TraktCatalogSource.lookupTitles( - noCountry, - aliases: const [TraktTitleVariant(title: 'Severance Alias', country: 'us')], - translations: const [TraktTitleVariant(title: ' severance ', language: 'en')], - ), - isEmpty, - ); - }); - test('trending watchers reach audience and pagination count reaches totalResults', () async { handlers.add( (request) => http.Response( diff --git a/test/services/plex_external_id_lookup_test.dart b/test/services/plex_external_id_lookup_test.dart index 71d40d185..f4901b92f 100644 --- a/test/services/plex_external_id_lookup_test.dart +++ b/test/services/plex_external_id_lookup_test.dart @@ -46,6 +46,27 @@ http.Response _hubs(List rows, {String type = 'movie'}) => _json({ }); bool _isTitleSearch(http.Request request) => request.url.path == '/hubs/search'; +bool _isGuidLookup(http.Request request) => + request.url.path == '/library/all' && request.url.queryParameters.containsKey('guid'); +bool _isSeasonLookup(http.Request request) => + request.url.path == '/library/all' && request.url.queryParameters['type'] == '3'; + +/// Rows of `/library/all?type=3&show.id=…&season.index=N` for the shows in +/// [showsWithSeason]. +http.Response _seasons(http.Request request, Set showsWithSeason) { + final asked = request.url.queryParameters['show.id']!.split(','); + return _metadata([ + for (final show in asked) + if (showsWithSeason.contains(show)) + { + 'ratingKey': '$show-season', + 'parentRatingKey': show, + 'type': 'season', + 'title': 'Season', + 'index': int.parse(request.url.queryParameters['season.index']!), + }, + ]); +} void main() { late AppDatabase db; @@ -59,11 +80,12 @@ void main() { await db.close(); }); - test('searches the title through the hub search index and reads the legacy scalar guid', () async { - late Uri requestUri; + test('searches the title through the hub search index and filters legacy guids by id in one request', () async { + final requests = []; final client = testPlexClient( handler: (request) async { - requestUri = request.url; + requests.add(request); + if (_isGuidLookup(request)) return _metadata(const []); return _hubs([ { 'ratingKey': 'legacy-movie', @@ -92,22 +114,32 @@ void main() { expect(matches.single.libraryTitle, 'Legacy Movies'); expect(matches.single.serverId, 'server-1'); expect(matches.single.serverName, 'Server'); + expect(requests, hasLength(2)); + // A legacy-agent item's primary guid is the external id, and the filter + // is a prefix match, so every legacy form rides one indexed request. + final guidLookup = requests.singleWhere(_isGuidLookup).url.queryParameters; + expect(guidLookup['type'], '1'); + expect(guidLookup['includeGuids'], '1'); + expect(guidLookup['guid']!.split(','), [ + 'com.plexapp.agents.imdb://tt29768334?', + 'com.plexapp.agents.hama://imdb-tt29768334?', + ]); // #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['limit'], '50'); - expect(requestUri.queryParameters.containsKey('guid'), isFalse); - expect(requestUri.queryParameters.containsKey('year'), isFalse, reason: 'the index ranks; no year window'); + final search = requests.singleWhere(_isTitleSearch).url.queryParameters; + expect(search['query'], 'Legacy Movie'); + expect(search['searchTypes'], 'movies'); + expect(search['includeGuids'], '1'); + expect(search['limit'], '50'); + expect(search.containsKey('year'), isFalse, reason: 'the index ranks; no year window'); }); test('a show search reads the show hub only, never the episode hub', () async { - late Uri requestUri; + late Uri searchUri; final client = testPlexClient( handler: (request) async { - requestUri = request.url; + if (_isGuidLookup(request)) return _metadata(const []); + searchUri = request.url; return _hubs(type: 'show', [ { 'ratingKey': 'legacy-show', @@ -127,28 +159,88 @@ void main() { ); expect(matches.map((match) => match.id), ['legacy-show']); - expect(requestUri.queryParameters['searchTypes'], 'tv'); + expect(searchUri.queryParameters['searchTypes'], 'tv'); + }); + + test('a legacy-agent copy is reached by id even when no title search finds it', () async { + // The title ladder is for modern-agent libraries, whose `Guid` array is + // unfilterable. A legacy library's primary guid carries the id itself, + // so a copy filed under a title no candidate names still resolves. + final client = testPlexClient( + handler: (request) async { + if (!_isGuidLookup(request)) return _hubs(type: 'show', const []); + expect(request.url.queryParameters['guid']!.split(','), contains('com.plexapp.agents.thetvdb://315500?')); + return _metadata([ + { + 'ratingKey': 'legacy-only', + 'type': 'show', + 'title': 'Shirayuki-hime', + 'librarySectionTitle': 'Anime (romaji)', + 'guid': 'com.plexapp.agents.thetvdb://315500?lang=en', + }, + ]); + }, + ); + addTearDown(client.close); + + final matches = await client.findByExternalIds( + const ExternalIds(tvdb: 315500), + kind: MediaKind.show, + titles: const ['Snow White with the Red Hair'], + ); + + expect(matches.map((match) => match.id), ['legacy-only']); + }); + + test('a guid-filter row that does not verify by id is dropped', () async { + // The filter is a prefix match; ids are the only gate, so an over-match + // never becomes a copy. + final client = testPlexClient( + handler: (request) async { + if (!_isGuidLookup(request)) return _hubs(type: 'show', const []); + return _metadata([ + { + 'ratingKey': 'other-show', + 'type': 'show', + 'title': 'Other Show', + 'guid': 'com.plexapp.agents.thetvdb://3155001?lang=en', + }, + ]); + }, + ); + addTearDown(client.close); + + final matches = await client.findByExternalIds( + const ExternalIds(tvdb: 315500), + kind: MediaKind.show, + titles: const ['Snow White with the Red Hair'], + ); + + expect(matches, isEmpty); }); test('returns both agent variants of one title, modern Guid match first', () async { final client = testPlexClient( - 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'}, - ], - }, - ]), + handler: (request) async { + if (_isGuidLookup(request)) return _metadata(const []); + return _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); @@ -164,10 +256,18 @@ void main() { test('does not match unsupported or malformed scalar GUIDs', () async { final client = testPlexClient( - 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}, - ]), + handler: (request) async { + if (_isGuidLookup(request)) return _metadata(const []); + return _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); @@ -184,35 +284,18 @@ void main() { // #2098: a copy filed under another language's title is reachable only // through that title, so a title that hit must not stop its siblings — // and id verification keeps the wrong show out of every page. - final requests = []; + final searches = []; final client = testPlexClient( handler: (request) async { - requests.add(request.url); - 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', [ + if (_isGuidLookup(request)) return _metadata(const []); + final query = request.url.queryParameters['query']!; + searches.add(query); + return switch (query) { + '葬送のフリーレン' => _hubs(type: 'show', [ { 'ratingKey': 'romaji-parent', 'type': 'show', - 'title': 'Oya Bangumi', + 'title': 'Sousou no Frieren', 'librarySectionTitle': 'Anime (romaji)', 'Guid': [ {'id': 'tvdb://123'}, @@ -221,12 +304,31 @@ void main() { { 'ratingKey': 'verified-parent', 'type': 'show', - 'title': 'Parent Show', + 'title': 'Frieren', 'Guid': [ {'id': 'tvdb://123'}, ], }, ]), + 'Frieren' => _hubs(type: 'show', [ + { + 'ratingKey': 'wrong-parent', + 'type': 'show', + 'title': 'Frieren', + 'Guid': [ + {'id': 'tvdb://999'}, + ], + }, + { + 'ratingKey': 'verified-parent', + 'type': 'show', + 'title': 'Frieren', + 'Guid': [ + {'id': 'tvdb://123'}, + ], + }, + ]), + _ => _hubs(type: 'show', const []), }; }, ); @@ -235,11 +337,11 @@ void main() { final matches = await client.findByExternalIds( const ExternalIds(tvdb: 123), kind: MediaKind.show, - titles: const ['Parent Show Season 2', 'Parent Show', 'Oya Bangumi'], + titles: const ['葬送のフリーレン', 'Frieren Season 2', 'Frieren'], ); - 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']); + expect(matches.map((match) => match.id), ['romaji-parent', 'verified-parent']); + expect(searches, ['葬送のフリーレン', 'Frieren Season 2', 'Frieren']); }); test('uses an exact Plex guid without external ids or a title query', () async { @@ -300,43 +402,38 @@ void main() { expect(requests.single.queryParameters['guid'], 'plex://movie/5d776b59ad5437001f79c6f8'); }); - test('an agreed season ref gates on the season hierarchy and nothing else', () async { - final childRequests = []; + test('an agreed season ref gates every candidate with one season request and nothing else', () async { + final seasonRequests = []; final extraRequests = []; final client = testPlexClient( handler: (request) async { - if (request.url.queryParameters.containsKey('includePreferences') || request.url.path.endsWith('/prefs')) { + if (request.url.queryParameters.containsKey('includePreferences') || + request.url.path.endsWith('/prefs') || + request.url.path.endsWith('/children')) { extraRequests.add(request.url.path); } - if (request.url.path.endsWith('/children')) { - childRequests.add(request.url.path); - final parentId = request.url.pathSegments[2]; - return _json({ - 'MediaContainer': { - 'totalSize': 1, - 'Metadata': [ - { - 'ratingKey': '$parentId-season', - 'type': 'season', - 'title': 'Season', - 'index': parentId == 'complete-show' ? 2 : 1, - }, - ], - }, - }); + if (_isSeasonLookup(request)) { + seasonRequests.add(request.url); + return _seasons(request, {'complete-show'}); } - if (request.url.path.startsWith('/library/metadata/')) { - return _json({'MediaContainer': {}}); - } - final complete = request.url.queryParameters['query'] == 'Complete Show'; + if (_isGuidLookup(request)) return _metadata(const []); return _hubs(type: 'show', [ { - 'ratingKey': complete ? 'complete-show' : 'incomplete-show', + 'ratingKey': 'incomplete-show', 'type': 'show', - 'title': complete ? 'Complete Show' : 'Incomplete Show', - 'librarySectionID': 4, + 'title': 'Split Show', + 'librarySectionTitle': 'Shows', 'Guid': [ - {'id': 'tvdb://${complete ? 101 : 100}'}, + {'id': 'tvdb://555'}, + ], + }, + { + 'ratingKey': 'complete-show', + 'type': 'show', + 'title': 'Split Show', + 'librarySectionTitle': '4K Shows', + 'Guid': [ + {'id': 'tvdb://555'}, ], }, ]); @@ -344,33 +441,29 @@ void main() { ); addTearDown(client.close); - final missing = await client.findByExternalIds( - const ExternalIds(tvdb: 100), + final matches = await client.findByExternalIds( + const ExternalIds(tvdb: 555), kind: MediaKind.show, - titles: const ['Incomplete Show'], - season: const ExternalSeasonRef(tvdb: 2, tmdb: 2), - ); - final present = await client.findByExternalIds( - const ExternalIds(tvdb: 101), - kind: MediaKind.show, - titles: const ['Complete Show'], + titles: const ['Split Show'], season: const ExternalSeasonRef(tvdb: 2, tmdb: 2), ); - expect(missing, isEmpty); - expect(present.map((match) => match.id), ['complete-show']); - expect(childRequests, ['/library/metadata/incomplete-show/children', '/library/metadata/complete-show/children']); - expect(extraRequests, isEmpty, reason: 'season ordering is a server setting the gate deliberately never reads'); + expect(matches.map((match) => match.id), ['complete-show']); + expect(seasonRequests, hasLength(1), reason: 'every candidate is gated in one request'); + expect(seasonRequests.single.queryParameters['show.id'], 'incomplete-show,complete-show'); + expect(seasonRequests.single.queryParameters['season.index'], '2'); + expect(extraRequests, isEmpty, reason: 'no per-candidate children; season ordering is never read'); }); test('a disagreeing season ref is left ungated rather than gated on a guess', () async { // TVDB says season 2, TMDB folds it into season 1. Which one this library // follows is a server setting no dataset supplies, and reading it costs // requests, so the match stands ungated. - final requests = []; + final requests = []; final client = testPlexClient( handler: (request) async { - requests.add(request.url); + requests.add(request); + if (_isGuidLookup(request)) return _metadata(const []); return _hubs(type: 'show', [ { 'ratingKey': 'ungated-show', @@ -394,7 +487,8 @@ void main() { ); expect(matches.map((match) => match.id), ['ungated-show']); - expect(requests.map((uri) => uri.path), ['/hubs/search'], reason: 'no children, no preferences'); + expect(requests.where(_isSeasonLookup), isEmpty, reason: 'no season request, no preferences'); + expect(requests.map((request) => request.url.path), ['/library/all', '/hubs/search']); }); test('returns every library copy sharing one exact Plex guid', () async { @@ -442,13 +536,13 @@ void main() { expect(matches.map((match) => match.mediaVersions?.single.videoResolution), ['1080', '4k']); }); - test('keeps searching by title after an exact guid hit so legacy-agent copies surface', () async { - // A library still on a legacy agent carries `com.plexapp.agents.*` as its - // primary guid, so the `guid=` filter cannot see it at all (#1754). The - // copy the two passes agree on must still appear only once. - final requests = []; - final modernCopy = { - 'ratingKey': 'modern-copy', + test('keeps searching by title after an exact guid hit so a modern-agent duplicate surfaces', () async { + // Discover has duplicate entries for one show; the library may have + // matched the other one, whose primary guid the filter cannot name, so + // the title search still runs. The copy both agree on appears once. + final requests = []; + final exactCopy = { + 'ratingKey': 'exact-copy', 'type': 'movie', 'title': 'Mixed Agents', 'guid': 'plex://movie/mixed', @@ -459,16 +553,22 @@ void main() { }; final client = testPlexClient( handler: (request) async { - requests.add(request.url); - if (!_isTitleSearch(request)) return _metadata([modernCopy]); + requests.add(request); + if (_isGuidLookup(request)) { + expect(request.url.queryParameters['guid']!.split(',').first, 'plex://movie/mixed'); + return _metadata([exactCopy]); + } return _hubs([ - modernCopy, + exactCopy, { - 'ratingKey': 'legacy-copy', + 'ratingKey': 'dupe-copy', 'type': 'movie', 'title': 'Mixed Agents', - 'guid': 'com.plexapp.agents.imdb://tt777?lang=en', + 'guid': 'plex://movie/mixed-duplicate', 'librarySectionTitle': 'Movies', + 'Guid': [ + {'id': 'imdb://tt777'}, + ], }, ]); }, @@ -482,64 +582,8 @@ void main() { plexGuid: 'plex://movie/mixed', ); - expect(matches.map((match) => match.id), ['modern-copy', 'legacy-copy']); - expect(requests.map((uri) => uri.path), ['/library/all', '/hubs/search']); - }); - - test('season-gates every candidate, not just the first', () async { - final client = testPlexClient( - handler: (request) async { - if (request.url.path.endsWith('/children')) { - final parentId = request.url.pathSegments[2]; - return _json({ - 'MediaContainer': { - 'totalSize': 1, - 'Metadata': [ - { - 'ratingKey': '$parentId-season', - 'type': 'season', - 'title': 'Season', - 'index': parentId == 'full-copy' ? 2 : 1, - }, - ], - }, - }); - } - if (request.url.path.startsWith('/library/metadata/')) { - return _json({'MediaContainer': {}}); - } - 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); - - final matches = await client.findByExternalIds( - const ExternalIds(tvdb: 555), - kind: MediaKind.show, - titles: const ['Split Show'], - season: const ExternalSeasonRef(tvdb: 2, tmdb: 2), - ); - - expect(matches.map((match) => match.id), ['full-copy']); + expect(matches.map((match) => match.id), ['exact-copy', 'dupe-copy']); + expect(requests.map((request) => request.url.path), ['/library/all', '/hubs/search']); }); test('a server that cannot answer fails the lookup without cascading through its endpoints', () async { @@ -547,14 +591,14 @@ void main() { // 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 = {}; + final hosts = {}; 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); + hosts.add(request.url.host); return http.Response('busy', 503); }, ); @@ -564,7 +608,7 @@ void main() { client.findByExternalIds(const ExternalIds(tmdb: 42), kind: MediaKind.movie, titles: const ['Busy Movie']), throwsA(isA()), ); - expect(requestsByHost, {'primary.example.com': 1}); + expect(hosts, {'primary.example.com'}); expect(exhausted, 0); }); } diff --git a/test/utils/external_ids_test.dart b/test/utils/external_ids_test.dart index ec5f6daf9..7c53a14b2 100644 --- a/test/utils/external_ids_test.dart +++ b/test/utils/external_ids_test.dart @@ -100,6 +100,26 @@ void main() { }); }); + group('ExternalIds.legacyPlexGuidPrefixes', () { + test('every prefix names a guid fromLegacyPlexGuid reads back to the same id', () { + const ids = ExternalIds(imdb: 'tt0245429', tmdb: 129, tvdb: 276, anidb: 112); + final prefixes = ids.legacyPlexGuidPrefixes; + expect(prefixes, isNotEmpty); + for (final prefix in prefixes) { + expect(prefix, endsWith('?'), reason: 'a literal prefix filter must stop at the lang argument'); + final parsed = ExternalIds.fromLegacyPlexGuid('${prefix}lang=en'); + expect(parsed.intersects(ids), isTrue, reason: prefix); + } + expect(prefixes, contains('com.plexapp.agents.thetvdb://276?')); + expect(prefixes, contains('com.plexapp.agents.hama://tvdb4-276?')); + expect(prefixes, contains('com.plexapp.agents.hama://anidb-112?')); + }); + + test('is empty without ids', () { + expect(const ExternalIds().legacyPlexGuidPrefixes, isEmpty); + }); + }); + group('ExternalIds.fillFrom', () { test('keeps its own ids and only fills the ones it is missing', () { const modern = ExternalIds(tvdb: 315500); diff --git a/test/utils/title_match_candidates_test.dart b/test/utils/title_match_candidates_test.dart index 6deb9f669..d72a0c87e 100644 --- a/test/utils/title_match_candidates_test.dart +++ b/test/utils/title_match_candidates_test.dart @@ -55,8 +55,7 @@ void main() { 'You and I Are Polar Opposites Season 2', 'Seihantai na Kimi to Boku 2nd Season', ]); - // 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. + // Two families, each carrying its own parent-show form. expect(candidates, [ 'You and I Are Polar Opposites Season 2', 'You and I Are Polar Opposites', @@ -95,27 +94,21 @@ void main() { }); test('the preferred title\'s stripped form survives a long alias list', () { - // MAL synonyms routinely exceed the cap. Emitting every raw alias first - // would spend every slot without ever reaching the parent show. + // Emitting every raw title first would spend every slot without ever + // reaching the parent show. final candidates = titleMatchCandidates([ 'Mushoku Tensei: Jobless Reincarnation Season 2', 'Mushoku Tensei II: Isekai Ittara Honki Dasu', 'Mushoku Tensei 2', 'MT2', - 'Mushoku Tensei: Isekai Ittara Honki Dasu Part 2', - 'Jobless Reincarnation II', ]); // `II:` mid-title is not a sequel suffix, so the second family has no - // stripped form; the cap lands before the sixth alias. + // stripped form and the third title 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', - 'Mushoku Tensei', - 'MT2', - 'Mushoku Tensei: Isekai Ittara Honki Dasu Part 2', - 'Mushoku Tensei: Isekai Ittara Honki Dasu', ]); }); });