perf(explore): resolve library copies with one guid filter and a native-title search per server

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
This commit is contained in:
edde746
2026-09-05 14:44:02 +02:00
parent 29a6286fe6
commit bc13ff007a
15 changed files with 404 additions and 527 deletions
+17 -14
View File
@@ -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
-18
View File
@@ -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<String, dynamic> json) => _$TraktTitleVariantFromJson(json);
}
@@ -1,14 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'trakt_title_variant.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TraktTitleVariant _$TraktTitleVariantFromJson(Map<String, dynamic> json) =>
TraktTitleVariant(
title: json['title'] as String?,
country: json['country'] as String?,
language: json['language'] as String?,
);
@@ -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<String> 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<String> lookupTitles(CatalogItem item) => titleMatchCandidates([item.originalTitle, item.title]);
static bool _isAuthoritativeHit(LibraryLookupResult result) =>
result.items.isNotEmpty && result.failedServerIds.isEmpty && result.cancelledServerIds.isEmpty;
+3 -70
View File
@@ -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<String> lookupTitles(
CatalogItem item, {
required List<TraktTitleVariant> aliases,
required List<TraktTitleVariant> 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 <String>{?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<T?> _withoutFailure<T>(Future<T> request, String requestName, CatalogItem item) async {
try {
return await request;
+60 -49
View File
@@ -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=<keys>&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<List<MediaItem>> _gateExternalIdMatches(
Iterable<Map<String, dynamic>> 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<bool> _hasSeason(Map<String, dynamic> 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 <String>[];
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<List<Map<String, dynamic>>> _lookupByPlexGuid(String plexGuid, {required int plexType}) async {
Future<List<Map<String, dynamic>>> _lookupByGuids(List<String> guids, {required int plexType}) async {
final response = await _lookupRequest('/library/all', {
'guid': plexGuid,
'guid': guids.join(','),
'type': plexType,
'includeGuids': 1,
}, operation: 'Plex guid lookup');
@@ -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<List<TraktTitleVariant>> 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<List<TraktTitleVariant>> 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<TraktTitleVariant> _decodeTitleVariants(String body) {
final decoded = TrackerHttpClient.decodeJson(body);
if (decoded is! List) return const [];
return [for (final e in decoded.whereType<Map<String, dynamic>>()) TraktTitleVariant.fromJson(e)];
}
/// Body shape: `{"movies":[{"ids":{...}}],"shows":[{"ids":{...}}]}`.
Future<void> addToWatchlist(Map<String, dynamic> body) =>
_request('POST', '/sync/watchlist', body: body, allowStatuses: const {200, 201});
+22
View File
@@ -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<String> 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 `<source>-<id>` over
+10 -13
View File
@@ -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<String> titleMatchCandidates(Iterable<String?> titles, {int limit = 8}) {
List<String> titleMatchCandidates(Iterable<String?> titles, {int limit = 4}) {
final out = <String>[];
final seen = <String>{};
@@ -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;
@@ -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 {
@@ -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(
+222 -178
View File
@@ -46,6 +46,27 @@ http.Response _hubs(List<Object> 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<String> 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 = <http.Request>[];
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 = <Uri>[];
final searches = <String>[];
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 = <String>[];
test('an agreed season ref gates every candidate with one season request and nothing else', () async {
final seasonRequests = <Uri>[];
final extraRequests = <String>[];
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': <String, Object?>{}});
}
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 = <Uri>[];
final requests = <http.Request>[];
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 = <Uri>[];
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 = <http.Request>[];
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': <String, Object?>{}});
}
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 = <String, int>{};
final hosts = <String>{};
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<MediaServerHttpException>()),
);
expect(requestsByHost, {'primary.example.com': 1});
expect(hosts, {'primary.example.com'});
expect(exhausted, 0);
});
}
+20
View File
@@ -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);
+4 -11
View File
@@ -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',
]);
});
});