diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 1cacbdc4..940dd4d2 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -505,16 +505,44 @@ abstract class MediaServerClient { Future fetchExternalIds(String itemId); /// Reverse lookup: find a library movie/show matching any of [ids]. - /// Both backends search by [title] (narrowed by a ±1 [year] window when - /// known, with an unfiltered fallback) and verify candidates against - /// their exact external ids. Plex checks its modern `Guid` array first, - /// then recognized legacy scalar `guid` formats; its `guid=` field filter - /// only matches the primary `plex://` guid. Jellyfin checks the inline - /// `ProviderIds`. False negatives remain possible on differing titles, - /// but title alone never produces a match. Returns null when this server - /// has no match or [kind] is not movie/show. Used to match external catalog - /// items (Explore tab) back to the user's libraries. - Future findByExternalIds(ExternalIds ids, {required MediaKind kind, String? title, int? year}); + /// + /// 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. + /// + /// [titles] are tried in order until one yields an id-verified candidate; + /// pass the entry's own title first and broader forms after (see + /// `titleMatchCandidates`). A sequel entry's own title never matches its + /// parent show, which is why more than one is needed. [year] applies a ±1 + /// window to the first attempt only — for a sequel the catalog year is the + /// season's, not the show's. + /// + /// [plexGuid] is a Plex-only escape hatch: a `plex://show/…` guid the caller + /// already holds, which the local server *can* filter on exactly, skipping + /// the title search. It is never resolved over the network — only Plex + /// Discover catalog items carry one, in their own rating key. Other backends + /// ignore it. + /// + /// [season] gates the result: when the entry maps to season 2+ of a longer + /// series, a match is only returned if the server actually has that season. + /// Implementations MUST gate only on [ExternalSeasonRef.agreedSeason] — the + /// provider a library numbers its seasons by is a server-side setting no + /// dataset supplies, so a disagreeing ref is left ungated rather than gated + /// on a guess. + /// + /// Returns null when this server has no match or [kind] is not movie/show. + /// Used to match external catalog items (Explore tab) back to the user's + /// libraries. + Future findByExternalIds( + ExternalIds ids, { + required MediaKind kind, + List titles = const [], + int? year, + String? plexGuid, + ExternalSeasonRef? season, + }); /// Chapters and intro/credits markers for [itemId]. Plex returns both in one /// round trip; Jellyfin combines item-level chapters with best-effort native diff --git a/lib/models/anilist/anilist_media.dart b/lib/models/anilist/anilist_media.dart index 66d7e3ef..ee681305 100644 --- a/lib/models/anilist/anilist_media.dart +++ b/lib/models/anilist/anilist_media.dart @@ -11,6 +11,7 @@ class AnilistMedia { final String? titleEnglish; final String? titleRomaji; final String? titleUserPreferred; + final String? titleNative; final String? format; final String? status; final int? episodes; @@ -21,6 +22,7 @@ class AnilistMedia { final int? seasonYear; final int? startYear; final List? genres; + final List? synonyms; final bool isAdult; final String? coverImageExtraLarge; final String? coverImageLarge; @@ -35,6 +37,7 @@ class AnilistMedia { this.titleEnglish, this.titleRomaji, this.titleUserPreferred, + this.titleNative, this.format, this.status, this.episodes, @@ -45,6 +48,7 @@ class AnilistMedia { this.seasonYear, this.startYear, this.genres, + this.synonyms, this.isAdult = false, this.coverImageExtraLarge, this.coverImageLarge, @@ -67,6 +71,7 @@ class AnilistMedia { titleEnglish: title is Map ? title['english'] as String? : null, titleRomaji: title is Map ? title['romaji'] as String? : null, titleUserPreferred: title is Map ? title['userPreferred'] as String? : null, + titleNative: title is Map ? title['native'] as String? : null, format: json['format'] as String?, status: json['status'] as String?, episodes: flexibleInt(json['episodes']), @@ -77,6 +82,7 @@ class AnilistMedia { seasonYear: flexibleInt(json['seasonYear']), startYear: startDate is Map ? flexibleInt(startDate['year']) : null, genres: _stringList(json['genres']), + synonyms: _stringList(json['synonyms']), isAdult: json['isAdult'] == true, coverImageExtraLarge: coverImage is Map ? coverImage['extraLarge'] as String? : null, coverImageLarge: coverImage is Map ? coverImage['large'] as String? : null, diff --git a/lib/models/catalog/catalog_item.dart b/lib/models/catalog/catalog_item.dart index 1d9ef47c..57fc897c 100644 --- a/lib/models/catalog/catalog_item.dart +++ b/lib/models/catalog/catalog_item.dart @@ -65,6 +65,24 @@ class CatalogItemIds { return null; } + /// Identity of *this* entry, preferring provider-native entry ids over the + /// series ids it shares with its own other seasons. + /// + /// [canonicalKey] deliberately prefers imdb/tmdb/tvdb so two sources + /// describing the same title agree; that is exactly wrong for anything + /// season-specific, because every MAL/AniList season of one series carries + /// the same series ids. All five Mushoku Tensei entries collapse to + /// `imdb:tt13293588` under [canonicalKey]. + String? get entryKey { + if (mal != null) return 'mal:$mal'; + if (anilist != null) return 'anilist:$anilist'; + if (simkl != null) return 'simkl:$simkl'; + if (trakt != null) return 'trakt:$trakt'; + if (plex != null) return 'plex:$plex'; + if (slug != null) return 'slug:$slug'; + return canonicalKey; + } + /// Every id-form key. Membership checks match on any of these so that two /// sides carrying different id subsets (e.g. Jellyfin tmdb-only vs a Trakt /// entry keyed by imdb) still intersect. @@ -120,6 +138,17 @@ class CatalogItem { /// [MediaKind.movie] or [MediaKind.show]. final MediaKind kind; final String title; + + /// Other titles the same entry is known by (MAL `alternative_titles`, + /// AniList `romaji`/`native`/`synonyms`). Media servers index one localized + /// title each, so these widen the reverse lookup without weakening it — + /// candidates are still verified by exact external id. + final List altTitles; + + /// Season of the parent series this entry maps to, when the provider covers + /// one season of a longer show (Fribb `season`). Null for whole-series + /// entries and every non-anime source. + final ExternalSeasonRef? season; final int? year; final String? overview; final int? runtimeMinutes; @@ -149,6 +178,8 @@ class CatalogItem { required this.source, required this.kind, required this.title, + this.altTitles = const [], + this.season, this.year, this.overview, this.runtimeMinutes, @@ -168,6 +199,10 @@ class CatalogItem { /// Kind-namespaced identity key for caches and dedupe. String get identityKey => '${kind.id}/${ids.canonicalKey}'; + /// Cache key for anything whose answer is season-specific — see + /// [CatalogItemIds.entryKey], which [identityKey] deliberately does not use. + String get entryIdentityKey => '${kind.id}/${ids.entryKey}'; + /// Synthesize a [MediaItem] so catalog items flow through the existing /// shelf/grid/card stack ([MediaHub.items] is `List`). /// @@ -196,6 +231,8 @@ class CatalogItem { 'source': source.name, 'kind': kind.id, 'title': title, + if (altTitles.isNotEmpty) 'altTitles': altTitles, + if (season != null) 'season': season!.toJson(), if (year != null) 'year': year, if (overview != null) 'overview': overview, if (runtimeMinutes != null) 'runtimeMinutes': runtimeMinutes, @@ -221,6 +258,12 @@ class CatalogItem { (throw ArgumentError('Unknown catalog source: ${json['source']}')), kind: MediaKind.fromString(json['kind'] as String?), title: json['title'] as String? ?? '', + altTitles: (json['altTitles'] as List?)?.cast() ?? const [], + season: switch (json['season']) { + final Map s => ExternalSeasonRef.fromJson(s), + final Map s => ExternalSeasonRef.fromJson(s.cast()), + _ => null, + }, year: json['year'] as int?, overview: json['overview'] as String?, runtimeMinutes: json['runtimeMinutes'] as int?, diff --git a/lib/services/catalog/anilist_catalog_source.dart b/lib/services/catalog/anilist_catalog_source.dart index b481bcf4..48734ae9 100644 --- a/lib/services/catalog/anilist_catalog_source.dart +++ b/lib/services/catalog/anilist_catalog_source.dart @@ -118,6 +118,16 @@ class AnilistCatalogSource with CatalogWatchlistMachinery implements CatalogSour source: CatalogSourceId.anilist, kind: anime.isMovie ? MediaKind.movie : MediaKind.show, title: anime.displayTitle, + altTitles: [ + for (final title in [ + anime.titleEnglish, + anime.titleUserPreferred, + anime.titleRomaji, + anime.titleNative, + ...?anime.synonyms, + ]) + if (title != null && title.isNotEmpty && title != anime.displayTitle) title, + ], year: anime.year, overview: anime.description, runtimeMinutes: anime.runtimeMinutes, @@ -135,6 +145,9 @@ class AnilistCatalogSource with CatalogWatchlistMachinery implements CatalogSour tmdb: row?.tmdbIds?.firstOrNull, tvdb: row?.tvdbId, ), + season: row == null || (row.tvdbSeason == null && row.tmdbSeason == null) + ? null + : ExternalSeasonRef(tvdb: row.tvdbSeason, tmdb: row.tmdbSeason), posterUrl: anime.posterUrl, backdropUrl: anime.backdropUrl, ); diff --git a/lib/services/catalog/catalog_library_matcher.dart b/lib/services/catalog/catalog_library_matcher.dart index be8e9538..87402a04 100644 --- a/lib/services/catalog/catalog_library_matcher.dart +++ b/lib/services/catalog/catalog_library_matcher.dart @@ -1,6 +1,10 @@ +import 'package:flutter/foundation.dart'; + import '../../media/media_item.dart'; +import '../../media/media_kind.dart'; import '../../models/catalog/catalog_item.dart'; import '../../providers/multi_server_provider.dart'; +import '../../utils/title_match_candidates.dart'; /// Matches external catalog items back to the user's libraries. /// @@ -14,24 +18,53 @@ class CatalogLibraryMatcher { static const Duration negativeTtl = Duration(minutes: 10); final MultiServerProvider _multiServer; + final DateTime Function() _now; final Map items})> _cache = {}; - CatalogLibraryMatcher(this._multiServer); + CatalogLibraryMatcher(this._multiServer) : _now = DateTime.now; + + @visibleForTesting + CatalogLibraryMatcher.withClock(this._multiServer, this._now); Future> match(CatalogItem item) async { if (!item.ids.hasAny) return const []; - final key = item.identityKey; + // Do not use `identityKey`: its canonical series ids make every MAL/AniList + // season collide. All five Mushoku Tensei entries (`mal39535 s1`, + // `mal45576 s1`, `mal51179 s2`, `mal55888 s2`, `mal59193 s3`) collapse to + // `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. + final key = '${item.source.name}/${item.entryIdentityKey}'; final cached = _cache[key]; - if (cached != null && (cached.items.isNotEmpty || DateTime.now().difference(cached.at) < negativeTtl)) { + if (cached != null && (cached.items.isNotEmpty || _now().difference(cached.at) < negativeTtl)) { return cached.items; } + + // A sequel entry's year is its own season's, not the parent show's, so a + // ±1 window around it excludes the very show we are looking for. Fribb + // does not map a season for every entry, so fall back to the title: a + // strippable season suffix says "sequel" just as reliably. Dropping the + // year here also keeps the lookup at two requests, because the client no + // longer spends one on a year-filtered attempt that cannot match. + final isSequel = (item.season?.isSequel ?? false) || stripSeasonSuffix(item.title) != null; final matches = await _multiServer.aggregationService.findByExternalIdsAcrossServers( item.ids.toExternalIds(), kind: item.kind, - title: item.title, - year: item.year, + titles: titleMatchCandidates([item.title, ...item.altTitles]), + year: isSequel ? null : item.year, + plexGuid: _plexGuidFor(item), + season: item.season, ); - _cache[key] = (at: DateTime.now(), items: matches); + _cache[key] = (at: _now(), items: matches); return matches; } + + /// The exact `plex://` guid for a Plex Discover item, which its own rating + /// key already is. Free — no request, no cloud lookup; other sources get + /// null and fall back to the title candidates. + String? _plexGuidFor(CatalogItem item) { + final plexId = item.ids.plex; + if (item.source != CatalogSourceId.plex || plexId == null || plexId.isEmpty) return null; + return 'plex://${item.kind == MediaKind.movie ? 'movie' : 'show'}/$plexId'; + } } diff --git a/lib/services/catalog/mal_catalog_source.dart b/lib/services/catalog/mal_catalog_source.dart index 8390ff1d..7e6c45ac 100644 --- a/lib/services/catalog/mal_catalog_source.dart +++ b/lib/services/catalog/mal_catalog_source.dart @@ -118,6 +118,15 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource { source: CatalogSourceId.mal, kind: anime.isMovie ? MediaKind.movie : MediaKind.show, title: anime.displayTitle, + altTitles: [ + for (final title in [ + anime.alternativeTitles?.en, + anime.title, + anime.alternativeTitles?.ja, + ...?anime.alternativeTitles?.synonyms, + ]) + if (title != null && title.isNotEmpty && title != anime.displayTitle) title, + ], year: anime.year, overview: anime.synopsis, runtimeMinutes: anime.runtimeMinutes, @@ -134,6 +143,9 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource { tmdb: row?.tmdbIds?.firstOrNull, tvdb: row?.tvdbId, ), + season: row == null || (row.tvdbSeason == null && row.tmdbSeason == null) + ? null + : ExternalSeasonRef(tvdb: row.tvdbSeason, tmdb: row.tmdbSeason), posterUrl: anime.mainPicture?.primary, ); diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 060e9a80..16f6aba1 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -597,16 +597,25 @@ class DataAggregationService { Future> findByExternalIdsAcrossServers( ExternalIds ids, { required MediaKind kind, - String? title, + List titles = const [], int? year, + String? plexGuid, + ExternalSeasonRef? season, }) async { - if (!ids.hasAny) return []; + if (!ids.hasAny && plexGuid == null) return []; final clients = _serverManager.onlineClients; if (clients.isEmpty) return []; final futures = clients.entries.map((entry) async { try { - return await entry.value.findByExternalIds(ids, kind: kind, title: title, year: year); + return await entry.value.findByExternalIds( + ids, + kind: kind, + titles: titles, + year: year, + plexGuid: plexGuid, + season: season, + ); } catch (e, st) { appLogger.w('External-id lookup failed on ${entry.key}', error: e, stackTrace: st); return null; diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 79f80714..fd81318d 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -1110,47 +1110,65 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { return _mapItems([...results.first, ...results[1]]); } - /// Jellyfin removed `anyProviderIdEquals`, so the reverse lookup is a - /// title search verified against each candidate's inline `ProviderIds` — - /// exact-id verification, so localized-title misses are possible but a - /// wrong item can never match. + /// Jellyfin removed `anyProviderIdEquals` (silently ignored on 10.11.10, so + /// it returns the unfiltered page), leaving a title search verified against + /// each candidate's inline `ProviderIds`. [plexGuid] is a Plex-only hint and + /// has no meaning in Jellyfin's provider-id model, so it is ignored. /// - /// When [year] is known, a ±1 `years=` window (comma-OR, verified on JF - /// 10.11) is applied first so short/common titles keep the true match - /// inside the 20-item response; a second unfiltered attempt covers items - /// with missing or off-window year metadata. + /// Jellyfin cannot report season ordering to a non-admin: on 10.11.10, + /// `/Library/VirtualFolders` returns 403 and Series items omit + /// `DisplayOrder`. Resolve against an unknown provider rather than guessing + /// from `ProviderIds`, so only seasons on which TVDB and TMDB agree are gated. @override - Future findByExternalIds(ExternalIds ids, {required MediaKind kind, String? title, int? year}) async { + Future findByExternalIds( + ExternalIds ids, { + required MediaKind kind, + List titles = const [], + int? year, + String? plexGuid, + ExternalSeasonRef? season, + }) async { final itemType = switch (kind) { MediaKind.movie => 'Movie', MediaKind.show => 'Series', _ => null, }; - if (itemType == null || !ids.hasAny || title == null || title.isEmpty) return null; + if (itemType == null || !ids.hasAny || titles.isEmpty) return null; - Future attempt(String? years) async { + final seasonIndex = season?.agreedSeason; + final shouldGateSeason = kind == MediaKind.show && seasonIndex != null && seasonIndex > 1; + // Not `seasonIndex`: when the two providers disagree the season number is + // unusable but the entry is still a sequel, and the ±1 window around a + // sequel's own year excludes the parent show (its year is season one's). + final skipYearWindow = season?.isSequel ?? false; + + for (var index = 0; index < titles.length; index++) { + final isFirstCandidate = index == 0; + final years = isFirstCandidate && year != null && !skipYearWindow ? '${year - 1},$year,${year + 1}' : null; final candidates = await _fetchItemsArray('/Items', { 'userId': connection.userId, - 'SearchTerm': title, + 'SearchTerm': titles[index], 'Recursive': 'true', - 'Limit': '20', + 'Limit': isFirstCandidate ? '20' : '50', 'IncludeItemTypes': itemType, 'Fields': 'ProviderIds,$_browseFields', 'years': ?years, ...jellyfinImageQueryParameters, }); final match = ExternalIds.jellyfinCandidateMatching(candidates, ids); - if (match == null) return null; + if (match == null) continue; final item = _mapItems([match]).firstOrNull; - if (item == null) return null; + if (item == null) continue; + + if (shouldGateSeason) { + final children = await fetchChildren(item.id); + if (!children.any((child) => child.kind == MediaKind.season && child.index == seasonIndex)) { + return null; + } + } return _withLibraryFromAncestors(item); } - - if (year != null) { - final match = await attempt('${year - 1},$year,${year + 1}'); - if (match != null) return match; - } - return attempt(null); + return null; } /// Best-effort library stamp for items found outside a library context diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 27e48046..d5dc8fd4 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -3761,37 +3761,77 @@ class PlexClient return ExternalIds.fromGuids(guids); } + /// Drop a sequel match when the server does not actually have that season. + /// + /// Only an [ExternalSeasonRef.agreedSeason] is gated on. Which provider a + /// library numbers its seasons by is a server-side setting no dataset can + /// supply, and reading it costs two extra requests per lookup, so a + /// disagreeing ref is left ungated rather than gated on a guess. + Future _applyExternalIdSeasonGate( + Map metadata, { + required MediaKind kind, + required ExternalSeasonRef? season, + }) async { + final item = PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(metadata)); + if (kind != MediaKind.show) return item; + + final seasonIndex = season?.agreedSeason; + if (seasonIndex == null || seasonIndex <= 1) return item; + + final ratingKey = metadata['ratingKey']?.toString(); + // Cannot ask the question => do not gate. + if (ratingKey == null || ratingKey.isEmpty) return item; + + final children = await fetchChildren(ratingKey); + return children.any((child) => child.kind == MediaKind.season && child.index == seasonIndex) ? item : null; + } + /// Server-wide title filter + client-side external-ID verification. Plex's /// `guid=` field filter matches only the item's primary `plex://` guid - /// (verified against PMS 1.43) — never the external `imdb://`/`tmdb://` - /// ids in the modern `Guid` array — so this mirrors the Jellyfin - /// title-search-and-verify approach. Recognized legacy scalar `guid` values - /// are checked only after modern verification fails. - /// - /// When [year] is known, a ±1 window is applied server-side first - /// (`year=` takes comma-separated values as OR, verified on PMS 1.43) so - /// short/common titles keep the true match inside the 20-item response; - /// the year filter drops items with no year metadata, hence the - /// unfiltered second attempt. + /// (verified against PMS 1.43), so external ids in modern `Guid` arrays are + /// verified after title search. A resolved primary [plexGuid] can use the + /// exact server-side filter directly. @override - Future findByExternalIds(ExternalIds ids, {required MediaKind kind, String? title, int? year}) async { + Future findByExternalIds( + ExternalIds ids, { + required MediaKind kind, + List titles = const [], + int? year, + String? plexGuid, + ExternalSeasonRef? season, + }) async { final plexType = switch (kind) { MediaKind.movie => 1, MediaKind.show => 2, _ => null, }; - if (plexType == null || !ids.hasAny || title == null || title.isEmpty) { - return null; + if (plexType == null) return null; + if (!ids.hasAny && plexGuid == null) return null; + if (titles.isEmpty && plexGuid == null) return null; + + if (plexGuid != null) { + final response = await _getWithFailover( + '/library/all', + queryParameters: {'guid': plexGuid, 'type': plexType, 'includeGuids': 1}, + ); + final metadata = _getFirstMetadataJson(response); + if (metadata != null) { + return _applyExternalIdSeasonGate(metadata, kind: kind, season: season); + } } - Future<({Map? modern, Map? legacy})> attempt(String? years) async { + Future<({Map? modern, Map? legacy})> attempt( + String title, { + required int size, + String? years, + }) async { final response = await _getWithFailover( '/library/all', queryParameters: { 'title': title, 'type': plexType, 'includeGuids': 1, - 'X-Plex-Container-Size': 20, + 'X-Plex-Container-Size': size, 'year': ?years, }, ); @@ -3813,18 +3853,29 @@ class PlexClient return (modern: null, legacy: legacy); } - ({Map? modern, Map? legacy})? filtered; - if (year != null) { - filtered = await attempt('${year - 1},$year,${year + 1}'); - final modern = filtered.modern; - if (modern != null) { - return PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(modern)); + // Not `resolve(null)`: when the two providers disagree the season number is + // unresolvable but the entry is still a sequel, and the ±1 window around a + // sequel's own year excludes the parent show (its year is season one's). + final skipYearWindow = season?.isSequel ?? false; + for (var index = 0; index < titles.length; index++) { + final title = titles[index]; + final size = index == 0 ? 20 : 50; + ({Map? modern, Map? legacy})? filtered; + if (index == 0 && year != null && !skipYearWindow) { + filtered = await attempt(title, size: size, years: '${year - 1},$year,${year + 1}'); + final modern = filtered.modern; + if (modern != null) { + return _applyExternalIdSeasonGate(modern, kind: kind, season: season); + } + } + + final unfiltered = await attempt(title, size: size); + final match = unfiltered.modern ?? filtered?.legacy ?? unfiltered.legacy; + if (match != null) { + return _applyExternalIdSeasonGate(match, kind: kind, season: season); } } - - final unfiltered = await attempt(null); - final match = unfiltered.modern ?? filtered?.legacy ?? unfiltered.legacy; - return match == null ? null : PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(match)); + return null; } @override diff --git a/lib/services/trackers/anilist/anilist_client.dart b/lib/services/trackers/anilist/anilist_client.dart index 214dcccb..f928a941 100644 --- a/lib/services/trackers/anilist/anilist_client.dart +++ b/lib/services/trackers/anilist/anilist_client.dart @@ -27,6 +27,7 @@ class AnilistClient implements DisposableTrackerClient { english romaji userPreferred + native } format status @@ -40,6 +41,7 @@ class AnilistClient implements DisposableTrackerClient { year } genres + synonyms isAdult coverImage { extraLarge diff --git a/lib/utils/external_ids.dart b/lib/utils/external_ids.dart index 319c1d40..6a25731c 100644 --- a/lib/utils/external_ids.dart +++ b/lib/utils/external_ids.dart @@ -1,3 +1,49 @@ +/// Season of a parent series as numbered by each external provider. +/// +/// Populated from the Fribb mapping's `season: {tvdb: N, tmdb: M}`. +class ExternalSeasonRef { + final int? tvdb; + final int? tmdb; + + const ExternalSeasonRef({this.tvdb, this.tmdb}); + + bool get hasAny => tvdb != null || tmdb != null; + + /// True when this entry covers a later season under either provider. + /// + /// Independent of [agreedSeason]: it answers "is this a sequel at all", + /// which stays knowable when the two providers disagree. Callers use it to + /// drop the ±1 year window, because a sequel's catalog year is its own + /// season's, not the parent show's. + bool get isSequel => (tvdb ?? 0) > 1 || (tmdb ?? 0) > 1; + + /// The season number both providers mapped and agree on, else null. + /// + /// TVDB and TMDB disagree on split-cour / continuation seasons — a season + /// the former numbers `2` the latter often folds into `1` at an episode + /// offset. Which one a library follows is a server-side setting, not + /// anything a dataset can tell us, and it is NOT inferable from which ids an + /// item exposes (a Plex show carries all three regardless). So when the two + /// disagree the honest answer is "cannot tell" and the caller must not gate. + /// + /// A missing number is not agreement either: `tvdb: 2, tmdb: null` means + /// Fribb has no TMDB season mapping, and a TMDB-ordered server would number + /// that season by the mapping we do not have. Holds for 1133 of the 1185 + /// gate-eligible Fribb rows; the other 52 simply go ungated. + int? get agreedSeason => tvdb != null && tvdb == tmdb ? tvdb : null; + + Map toJson() => {if (tvdb != null) 'tvdb': tvdb, if (tmdb != null) 'tmdb': tmdb}; + + factory ExternalSeasonRef.fromJson(Map json) => + ExternalSeasonRef(tvdb: json['tvdb'] as int?, tmdb: json['tmdb'] as int?); + + @override + bool operator ==(Object other) => other is ExternalSeasonRef && other.tvdb == tvdb && other.tmdb == tmdb; + + @override + int get hashCode => Object.hash(tvdb, tmdb); +} + /// External IDs (IMDb / TMDB / TVDB) extracted from a media server's /// metadata. Shared by the Trakt and tracker resolvers. /// diff --git a/lib/utils/title_match_candidates.dart b/lib/utils/title_match_candidates.dart new file mode 100644 index 00000000..911e139f --- /dev/null +++ b/lib/utils/title_match_candidates.dart @@ -0,0 +1,96 @@ +/// Season markers that begin a sequel suffix. Everything from the first match +/// to the end of the string is dropped, which collapses stacked suffixes +/// (`… Season 2 Part 2`, `… Season 3: The Culling Game Part 1`, +/// `… Season 2 -Arise from the Shadow-`) in one pass. +final List _seasonSuffixes = [ + RegExp(r'\s+season\s+\d+\b.*$', caseSensitive: false), + RegExp(r'\s+\d+(?:st|nd|rd|th)\s+season\b.*$', caseSensitive: false), + RegExp(r'\s+final\s+season\b.*$', caseSensitive: false), + RegExp(r'\s+(?:part|cour|act)\.?\s*(?:\d+|i{1,3}v?|vi{0,3}|ix|x)\b.*$', caseSensitive: false), + RegExp(r'\s+(?:ii|iii|iv|v|vi|vii|viii|ix|x)$', caseSensitive: false), +]; + +/// A bare trailing number usually marks a sequel (`Isekai Quartet 3`), so it is +/// stripped last — but only when the token before it does not expect a number. +/// `Kaiju No. 8` and `Vol. 3` are titles, not season two of anything. +final RegExp _bareTrailingNumber = RegExp(r'\s+\d+$'); +final RegExp _numberedNoun = RegExp(r'\b(?:no|vol|pt|ep|episode|chapter)\.?\s+\d+$', caseSensitive: false); + +/// Typographic variants media servers index differently from what catalog +/// providers emit. Plex tokenizes on these, Jellyfin substring-matches them, +/// and both miss `Journey’s` against a stored `Journey's`. +const Map _punctuation = { + '\u2019': "'", // right single quote + '\u2018': "'", + '\u201C': '"', + '\u201D': '"', + '\u2013': '-', // en dash + '\u2014': '-', // em dash + '\u30FB': ' ', // katakana middle dot + '\uFF1A': ':', + '\uFF01': '!', + '\uFF1F': '?', +}; + +/// Drop the sequel suffix from [title], or return null when it has none. +String? stripSeasonSuffix(String title) { + var out = title; + for (final marker in _seasonSuffixes) { + out = out.replaceFirst(marker, ''); + } + if (!_numberedNoun.hasMatch(out)) { + out = out.replaceFirst(_bareTrailingNumber, ''); + } + out = out.trim(); + return out.isEmpty || out == title.trim() ? null : out; +} + +String _normalize(String title) { + var out = title; + for (final entry in _punctuation.entries) { + out = out.replaceAll(entry.key, entry.value); + } + return out.trim(); +} + +/// Ordered, deduplicated title candidates for a media-server reverse lookup. +/// +/// Neither backend can filter by external id (Plex's `guid=` matches only the +/// primary `plex://` guid; Jellyfin dropped `anyProviderIdEquals`), so the +/// title is the only candidate filter available and a sequel entry's own +/// title — `You and I Are Polar Opposites Season 2` — never matches the parent +/// show. Each input contributes itself plus its season-stripped form; the +/// caller tries them in order and stops at the first candidate whose external +/// ids verify, so a broader title can never widen what actually matches. +/// +/// [limit] bounds the request fan-out, and 2 is deliberate: the entry's own +/// title plus its season-stripped form matched 77 of 113 real sequel entries +/// against a 267-show Plex library, where the unexpanded title alone matched +/// 3. Raising it to 6 (adding romaji/native/synonym variants) reached only 81 +/// — four more entries for up to five more requests per lookup that finds +/// nothing, which is the common case on a discovery tab. Two candidates cost +/// the same two requests the single-title lookup already spent. +/// +/// Each title is emitted immediately followed by its stripped form rather than +/// in two passes, so the cap can never spend every slot on unstripped titles +/// and never try the one candidate that actually reaches the parent show. +List titleMatchCandidates(Iterable titles, {int limit = 2}) { + final out = []; + final seen = {}; + + bool add(String? raw) { + if (raw == null || out.length >= limit) return false; + final title = _normalize(raw); + if (title.isEmpty || !seen.add(title.toLowerCase())) return false; + out.add(title); + return true; + } + + for (final title in titles) { + if (out.length >= limit) break; + final normalized = title == null ? null : _normalize(title); + add(normalized); + if (normalized != null) add(stripSeasonSuffix(normalized)); + } + return out; +} diff --git a/test/models/catalog_item_test.dart b/test/models/catalog_item_test.dart index 76b82570..bd6a8938 100644 --- a/test/models/catalog_item_test.dart +++ b/test/models/catalog_item_test.dart @@ -1,5 +1,9 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_kind.dart'; import 'package:plezy/models/catalog/catalog_item.dart'; +import 'package:plezy/utils/external_ids.dart'; void main() { group('CatalogItemIds', () { @@ -66,5 +70,72 @@ void main() { expect(const CatalogItemIds(simkl: 7, trakt: 8).canonicalKey, 'simkl:7'); expect(const CatalogItemIds(plex: 'plex-4', trakt: 8).canonicalKey, 'plex:plex-4'); }); + test('entryKey identifies the entry, not the series it shares with its seasons', () { + // Every MAL/AniList season of one show carries the same series ids, so + // canonicalKey collides across seasons and cannot key a season-gated + // result. All five Mushoku Tensei entries collapse to imdb:tt13293588. + const s1 = CatalogItemIds(mal: 39535, imdb: 'tt13293588', tmdb: 94664, tvdb: 371310); + const s2 = CatalogItemIds(mal: 51179, imdb: 'tt13293588', tmdb: 94664, tvdb: 371310); + + expect(s1.canonicalKey, s2.canonicalKey); + expect(s1.entryKey, 'mal:39535'); + expect(s2.entryKey, 'mal:51179'); + expect(const CatalogItemIds(anilist: 6, imdb: 'tt1').entryKey, 'anilist:6'); + // Falls back to the series id when the entry has no provider-native id. + expect(const CatalogItemIds(imdb: 'tt1').entryKey, 'imdb:tt1'); + }); + }); + + group('CatalogItem', () { + const item = CatalogItem( + source: CatalogSourceId.anilist, + kind: MediaKind.show, + title: 'You and I Are Polar Opposites Season 2', + altTitles: ['Seihantai na Kimi to Boku 2nd Season', '\u6b63\u53cd\u5bfe\u306a\u541b\u3068\u50d5 \u7b2c2\u671f'], + season: ExternalSeasonRef(tvdb: 2, tmdb: 1), + year: 2026, + ids: CatalogItemIds(anilist: 210031, mal: 63832, tvdb: 457078), + ); + + test('survives the MediaItem.raw round trip the detail screen relies on', () { + // The Explore detail screen rebuilds the item out of MediaItem.raw, so a + // field that does not survive this seam silently disables the match fix + // in production while every source-level test still passes. + final raw = item.toMediaItem().raw?[CatalogItem.rawKey] as Map?; + final decoded = CatalogItem.fromJson(raw!); + + expect(decoded.altTitles, item.altTitles); + expect(decoded.season, const ExternalSeasonRef(tvdb: 2, tmdb: 1)); + expect(decoded.title, item.title); + expect(decoded.ids.entryKey, 'mal:63832'); + }); + + test('survives an encode/decode cycle that erases the static map types', () { + // Persisted/transport JSON comes back as Map and + // List; the nested season object must not depend on its + // compile-time type to be read back. + final decoded = CatalogItem.fromJson(jsonDecode(jsonEncode(item.toJson())) as Map); + + expect(decoded.altTitles, item.altTitles); + expect(decoded.season?.tvdb, 2); + expect(decoded.season?.tmdb, 1); + expect(decoded.season?.isSequel, isTrue); + expect(decoded.season?.agreedSeason, isNull); + }); + + test('omits both new fields when absent rather than emitting empties', () { + const bare = CatalogItem( + source: CatalogSourceId.trakt, + kind: MediaKind.movie, + title: 'Solo Movie', + ids: CatalogItemIds(imdb: 'tt1'), + ); + + expect(bare.toJson().containsKey('altTitles'), isFalse); + expect(bare.toJson().containsKey('season'), isFalse); + final decoded = CatalogItem.fromJson(bare.toJson()); + expect(decoded.altTitles, isEmpty); + expect(decoded.season, isNull); + }); }); } diff --git a/test/services/catalog/anilist_catalog_source_test.dart b/test/services/catalog/anilist_catalog_source_test.dart index d363c96f..44fda094 100644 --- a/test/services/catalog/anilist_catalog_source_test.dart +++ b/test/services/catalog/anilist_catalog_source_test.dart @@ -97,6 +97,7 @@ void main() { malId: 35760, tvdbId: 267440, tvdbSeason: 3, + tmdbSeason: 2, imdbIds: ['tt2560140'], ); const movie = FribbMappingRow( @@ -217,6 +218,34 @@ void main() { expect(item.episodeCount, 25); }); + test('sequel entries preserve alternate-title order and both Fribb season numbers', () async { + responder = (request) { + final query = _requestBody(request)['query'] as String; + expect(query, contains('native')); + expect(query, contains('synonyms')); + final sequel = _media(id: 35760, idMal: 35760, title: 'Attack on Titan Season 3'); + sequel['title'] = { + 'english': 'Attack on Titan Season 3', + 'userPreferred': 'Preferred Season 3', + 'romaji': 'Shingeki no Kyojin Season 3', + 'native': '進撃の巨人 Season 3', + }; + sequel['synonyms'] = ['', 'Attack on Titan Season 3', 'AoT 3']; + return _data({ + 'Page': { + 'pageInfo': {'hasNextPage': false}, + 'media': [sequel], + }, + }); + }; + + final item = (await source.fetchRow(CatalogRowId.trendingAnime)).items.single; + + expect(item.title, 'Attack on Titan Season 3'); + expect(item.altTitles, ['Preferred Season 3', 'Shingeki no Kyojin Season 3', '進撃の巨人 Season 3', 'AoT 3']); + expect(item.season, const ExternalSeasonRef(tvdb: 3, tmdb: 2)); + }); + test('seasonal client sends season and year variables', () async { responder = (request) { final variables = _requestBody(request)['variables'] as Map; diff --git a/test/services/catalog/catalog_library_matcher_test.dart b/test/services/catalog/catalog_library_matcher_test.dart new file mode 100644 index 00000000..658bcf5f --- /dev/null +++ b/test/services/catalog/catalog_library_matcher_test.dart @@ -0,0 +1,271 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/models/catalog/catalog_item.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/services/catalog/catalog_library_matcher.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/utils/external_ids.dart'; + +import '../../test_helpers/media_items.dart'; + +class _LookupCall { + final ExternalIds ids; + final MediaKind kind; + final List titles; + final int? year; + final String? plexGuid; + final ExternalSeasonRef? season; + + const _LookupCall({ + required this.ids, + required this.kind, + required this.titles, + required this.year, + required this.plexGuid, + required this.season, + }); +} + +class _FakeDataAggregationService extends DataAggregationService { + _FakeDataAggregationService(super.serverManager); + + final List<_LookupCall> calls = []; + final List> responses = []; + + @override + Future> findByExternalIdsAcrossServers( + ExternalIds ids, { + required MediaKind kind, + List titles = const [], + int? year, + String? plexGuid, + ExternalSeasonRef? season, + }) async { + calls.add( + _LookupCall(ids: ids, kind: kind, titles: List.of(titles), year: year, plexGuid: plexGuid, season: season), + ); + return responses.removeAt(0); + } +} + +class _Harness { + late final MultiServerManager manager; + late final _FakeDataAggregationService aggregation; + late final MultiServerProvider multiServer; + late final CatalogLibraryMatcher matcher; + + _Harness({DateTime Function()? now}) { + manager = MultiServerManager(); + aggregation = _FakeDataAggregationService(manager); + multiServer = MultiServerProvider(manager, aggregation); + matcher = now == null ? CatalogLibraryMatcher(multiServer) : CatalogLibraryMatcher.withClock(multiServer, now); + } + + void dispose() { + multiServer.dispose(); + manager.dispose(); + } +} + +void main() { + test('season entries sharing canonical ids keep independent cached matches', () async { + final harness = _Harness(); + addTearDown(harness.dispose); + final firstHit = testMediaItem(id: 'server-season-1', kind: MediaKind.show); + final secondHit = testMediaItem(id: 'server-season-2', kind: MediaKind.show); + harness.aggregation.responses.addAll([ + [firstHit], + [secondHit], + ]); + const first = CatalogItem( + source: CatalogSourceId.mal, + kind: MediaKind.show, + title: 'Mushoku Tensei', + ids: CatalogItemIds(mal: 39535, imdb: 'tt13293588'), + ); + const second = CatalogItem( + source: CatalogSourceId.mal, + kind: MediaKind.show, + title: 'Mushoku Tensei II', + ids: CatalogItemIds(mal: 51179, imdb: 'tt13293588'), + ); + + expect(first.identityKey, second.identityKey); + expect(first.entryIdentityKey, isNot(second.entryIdentityKey)); + expect((await harness.matcher.match(first)).single, same(firstHit)); + expect((await harness.matcher.match(second)).single, same(secondHit)); + expect((await harness.matcher.match(first)).single, same(firstHit)); + expect(harness.aggregation.calls, hasLength(2)); + }); + + test('negative cache entries are isolated by catalog source', () async { + final harness = _Harness(); + addTearDown(harness.dispose); + final anilistHit = testMediaItem(id: 'japanese-title-match', kind: MediaKind.show); + harness.aggregation.responses.addAll([ + const [], + [anilistHit], + ]); + const malItem = CatalogItem( + 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: ['日本語タイトル'], + ids: CatalogItemIds(mal: 100, anilist: 300, tmdb: 200), + ); + + expect(malItem.entryIdentityKey, anilistItem.entryIdentityKey); + expect(await harness.matcher.match(malItem), isEmpty); + expect((await harness.matcher.match(anilistItem)).single, same(anilistHit)); + expect(harness.aggregation.calls, hasLength(2)); + expect(harness.aggregation.calls.last.titles, contains('日本語タイトル')); + }); + + test('negative matches expire after negativeTtl while positive matches persist', () async { + var now = DateTime.utc(2026, 7, 28, 12); + final harness = _Harness(now: () => now); + addTearDown(harness.dispose); + final hit = testMediaItem(id: 'new-library-item', kind: MediaKind.show); + harness.aggregation.responses.addAll([ + const [], + [hit], + ]); + const item = CatalogItem( + source: CatalogSourceId.anilist, + kind: MediaKind.show, + title: 'New Show', + ids: CatalogItemIds(anilist: 1, tmdb: 42), + ); + + expect(await harness.matcher.match(item), isEmpty); + now = now.add(CatalogLibraryMatcher.negativeTtl - const Duration(seconds: 1)); + expect(await harness.matcher.match(item), isEmpty); + expect(harness.aggregation.calls, hasLength(1)); + + now = now.add(const Duration(seconds: 1)); + expect((await harness.matcher.match(item)).single, same(hit)); + expect(harness.aggregation.calls, hasLength(2)); + + now = now.add(const Duration(days: 30)); + expect((await harness.matcher.match(item)).single, same(hit)); + expect(harness.aggregation.calls, hasLength(2)); + }); + + test('forwards season-stripped title candidates and season reference', () async { + final harness = _Harness(); + addTearDown(harness.dispose); + harness.aggregation.responses.add(const []); + const season = ExternalSeasonRef(tvdb: 2, tmdb: 1); + const item = CatalogItem( + source: CatalogSourceId.mal, + kind: MediaKind.show, + title: 'You and I Are Polar Opposites Season 2', + altTitles: ['Seihantai na Kimi to Boku 2nd Season'], + season: season, + year: 2027, + ids: CatalogItemIds(mal: 59193, tvdb: 457078), + ); + + await harness.matcher.match(item); + + final call = harness.aggregation.calls.single; + expect(call.kind, MediaKind.show); + expect(call.ids.tvdb, 457078); + expect(call.year, isNull, reason: '2027 is season two\'s year, not the parent show\'s'); + expect(call.plexGuid, isNull); + expect(call.season, same(season)); + // Capped at two: the entry's own title and its season-stripped form. + expect(call.titles, ['You and I Are Polar Opposites Season 2', 'You and I Are Polar Opposites']); + }); + + test('keeps the year for an entry that is not a sequel', () async { + final harness = _Harness(); + addTearDown(harness.dispose); + harness.aggregation.responses.add(const []); + const item = CatalogItem( + source: CatalogSourceId.trakt, + kind: MediaKind.show, + title: 'Severance', + year: 2022, + ids: CatalogItemIds(trakt: 1, tvdb: 371980), + ); + + await harness.matcher.match(item); + + final call = harness.aggregation.calls.single; + expect(call.year, 2022); + expect(call.titles, ['Severance'], reason: 'nothing to strip, so one candidate and one request'); + }); + + test('drops the year from a sequel title even when Fribb mapped no season', () async { + // RC3 entries carry no season, but a strippable suffix says sequel just as + // reliably, and the year window around it would exclude the parent show. + final harness = _Harness(); + addTearDown(harness.dispose); + harness.aggregation.responses.add(const []); + const item = CatalogItem( + source: CatalogSourceId.anilist, + kind: MediaKind.show, + title: 'Some Show 2nd Season', + year: 2026, + ids: CatalogItemIds(anilist: 5, tvdb: 1), + ); + + await harness.matcher.match(item); + + expect(harness.aggregation.calls.single.year, isNull); + }); + + test('constructs a Plex guid for Discover items without external ids', () async { + final harness = _Harness(); + addTearDown(harness.dispose); + harness.aggregation.responses.add(const []); + const item = CatalogItem( + source: CatalogSourceId.plex, + kind: MediaKind.movie, + title: 'Plex-only Movie', + ids: CatalogItemIds(plex: '5d776828880197001ec90e13'), + ); + + await harness.matcher.match(item); + + final call = harness.aggregation.calls.single; + expect(call.ids.hasAny, isFalse); + expect(call.plexGuid, 'plex://movie/5d776828880197001ec90e13'); + }); + + test('only a Plex Discover item contributes a guid, and it costs no request', () async { + final harness = _Harness(); + addTearDown(harness.dispose); + harness.aggregation.responses.addAll([const [], const []]); + // A MAL entry can carry a Plex rating key through cross-source membership, + // but that key is not a Discover guid and must never be synthesised into + // one — the fast path is only sound for items that came from Discover. + const foreign = CatalogItem( + source: CatalogSourceId.mal, + kind: MediaKind.show, + title: 'Not A Discover Item', + ids: CatalogItemIds(mal: 7, plex: '1234'), + ); + const discover = CatalogItem( + source: CatalogSourceId.plex, + kind: MediaKind.show, + title: 'Discover Show', + ids: CatalogItemIds(plex: 'abc123'), + ); + + await harness.matcher.match(foreign); + await harness.matcher.match(discover); + + expect(harness.aggregation.calls.map((call) => call.plexGuid), [null, 'plex://show/abc123']); + }); +} diff --git a/test/services/catalog/mal_catalog_source_test.dart b/test/services/catalog/mal_catalog_source_test.dart index 1d0f898c..fdfb3f13 100644 --- a/test/services/catalog/mal_catalog_source_test.dart +++ b/test/services/catalog/mal_catalog_source_test.dart @@ -47,12 +47,14 @@ Map _node({ required int id, required String title, String? en, + String? ja, + List? synonyms, String mediaType = 'tv', String status = 'finished_airing', }) => { 'id': id, 'title': title, - if (en != null) 'alternative_titles': {'en': en}, + if (en != null || ja != null || synonyms != null) 'alternative_titles': {'en': ?en, 'ja': ?ja, 'synonyms': ?synonyms}, 'media_type': mediaType, 'main_picture': {'large': 'https://cdn.myanimelist.net/images/anime/$id.jpg'}, 'status': status, @@ -73,7 +75,13 @@ Map _pageBody(List> nodes, {bool hasMore = void main() { // Attack on Titan: split-cour show — one Fribb row per season, same tvdb id. const aotSeason1 = FribbMappingRow(malId: 16498, tvdbId: 267440, tvdbSeason: 1, imdbIds: ['tt2560140']); - const aotSeason3 = FribbMappingRow(malId: 35760, tvdbId: 267440, tvdbSeason: 3, imdbIds: ['tt2560140']); + const aotSeason3 = FribbMappingRow( + malId: 35760, + tvdbId: 267440, + tvdbSeason: 3, + tmdbSeason: 2, + imdbIds: ['tt2560140'], + ); // An anime movie. const yourName = FribbMappingRow(malId: 32281, tmdbIds: [372058], imdbIds: ['tt5311514'], type: 'MOVIE'); @@ -145,6 +153,32 @@ void main() { expect(movie.episodeCount, isNull); }); + test('sequel entries preserve alternate-title order and both Fribb season numbers', () async { + handlers.add( + (request) => http.Response( + json.encode( + _pageBody([ + _node( + id: 35760, + title: 'Shingeki no Kyojin Season 3', + en: 'Attack on Titan Season 3', + ja: '進撃の巨人 Season 3', + synonyms: ['', 'Attack on Titan Season 3', 'AoT 3'], + ), + ]), + ), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ), + ); + + final item = (await source.fetchRow(CatalogRowId.watchlist)).items.single; + + expect(item.title, 'Attack on Titan Season 3'); + expect(item.altTitles, ['Shingeki no Kyojin Season 3', '進撃の巨人 Season 3', 'AoT 3']); + expect(item.season, const ExternalSeasonRef(tvdb: 3, tmdb: 2)); + }); + test('fetchCast maps MAL characters with joined names and roles', () async { handlers.add( (request) => http.Response( diff --git a/test/services/jellyfin_external_id_lookup_test.dart b/test/services/jellyfin_external_id_lookup_test.dart new file mode 100644 index 00000000..b3f3ee1d --- /dev/null +++ b/test/services/jellyfin_external_id_lookup_test.dart @@ -0,0 +1,180 @@ +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; +import 'package:plezy/utils/external_ids.dart'; + +import '../test_helpers/backend_client_fixtures.dart'; + +http.Response _json(Object body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}); + +Map _series({String id = 'series-1', int tmdb = 42}) => { + 'Id': id, + 'Type': 'Series', + 'Name': 'Parent Series', + 'ProviderIds': {'Tmdb': '$tmdb'}, +}; + +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + JellyfinApiCache.initialize(db); + }); + + tearDown(() async { + await db.close(); + }); + + test('tries later titles and verifies the matching provider id', () async { + final searchTerms = []; + final client = testJellyfinClient( + httpClient: MockClient((request) async { + if (request.url.path == '/Items') { + final searchTerm = request.url.queryParameters['SearchTerm']!; + searchTerms.add(searchTerm); + return _json({ + 'Items': searchTerm == 'Parent Series' ? [_series()] : [], + }); + } + if (request.url.path == '/Items/series-1/Ancestors') { + return _json([ + {'Id': 'library-1', 'Name': 'Shows', 'Type': 'CollectionFolder'}, + ]); + } + fail('Unexpected request: ${request.url}'); + }), + ); + addTearDown(client.close); + + final match = await client.findByExternalIds( + const ExternalIds(tmdb: 42), + kind: MediaKind.show, + titles: const ['Parent Series Season 2', 'Parent Series'], + ); + + expect(searchTerms, ['Parent Series Season 2', 'Parent Series']); + expect(match?.id, 'series-1'); + expect(match?.libraryId, 'library-1'); + expect(match?.libraryTitle, 'Shows'); + }); + + test('rejects a title hit whose provider ids do not intersect', () async { + final client = testJellyfinClient( + httpClient: MockClient((request) async { + expect(request.url.path, '/Items'); + return _json({ + 'Items': [_series(tmdb: 99)], + }); + }), + ); + addTearDown(client.close); + + final match = await client.findByExternalIds( + const ExternalIds(tmdb: 42), + kind: MediaKind.show, + titles: const ['Parent Series'], + ); + + expect(match, isNull); + }); + + test('uses the year window only for the first title and broadens the later limit', () async { + final searches = []; + final client = testJellyfinClient( + httpClient: MockClient((request) async { + if (request.url.path == '/Items') { + searches.add(request.url); + final searchTerm = request.url.queryParameters['SearchTerm']; + return _json({ + 'Items': searchTerm == 'Parent Series' ? [_series()] : [], + }); + } + if (request.url.path == '/Items/series-1/Ancestors') return _json([]); + fail('Unexpected request: ${request.url}'); + }), + ); + addTearDown(client.close); + + final match = await client.findByExternalIds( + const ExternalIds(tmdb: 42), + kind: MediaKind.show, + titles: const ['Parent Series Season 2', 'Parent Series'], + year: 2024, + ); + + expect(match?.id, 'series-1'); + expect(searches, hasLength(2)); + expect(searches.first.queryParameters['years'], '2023,2024,2025'); + expect(searches.first.queryParameters['Limit'], '20'); + expect(searches.last.queryParameters.containsKey('years'), isFalse); + expect(searches.last.queryParameters['Limit'], '50'); + }); + + test('requires an agreed sequel season to exist in the matched series', () async { + Future lookupWithSeasons(List seasonNumbers) async { + final client = testJellyfinClient( + httpClient: MockClient((request) async { + if (request.url.path == '/Items') { + return _json({ + 'Items': [_series()], + }); + } + if (request.url.path == '/Shows/series-1/Seasons') { + return _json({ + 'Items': [ + for (final number in seasonNumbers) + {'Id': 'season-$number', 'Type': 'Season', 'Name': 'Season $number', 'IndexNumber': number}, + ], + }); + } + if (request.url.path == '/Items/series-1/Ancestors') return _json([]); + fail('Unexpected request: ${request.url}'); + }), + ); + addTearDown(client.close); + + final match = await client.findByExternalIds( + const ExternalIds(tmdb: 42), + kind: MediaKind.show, + titles: const ['Parent Series'], + year: 2024, + season: const ExternalSeasonRef(tvdb: 2, tmdb: 2), + ); + return match?.id; + } + + expect(await lookupWithSeasons([1]), isNull); + expect(await lookupWithSeasons([1, 2]), 'series-1'); + }); + + test('does not gate when TVDB and TMDB seasons disagree and Jellyfin order is unknown', () async { + final client = testJellyfinClient( + httpClient: MockClient((request) async { + if (request.url.path == '/Items') { + return _json({ + 'Items': [_series()], + }); + } + if (request.url.path == '/Items/series-1/Ancestors') return _json([]); + fail('Season hierarchy must not be requested: ${request.url}'); + }), + ); + addTearDown(client.close); + + final match = await client.findByExternalIds( + const ExternalIds(tmdb: 42), + kind: MediaKind.show, + titles: const ['Parent Series'], + season: const ExternalSeasonRef(tvdb: 2, tmdb: 1), + ); + + expect(match?.id, 'series-1'); + }); +} diff --git a/test/services/plex_external_id_lookup_test.dart b/test/services/plex_external_id_lookup_test.dart index b6dff21c..d6c410da 100644 --- a/test/services/plex_external_id_lookup_test.dart +++ b/test/services/plex_external_id_lookup_test.dart @@ -53,7 +53,7 @@ void main() { final match = await client.findByExternalIds( const ExternalIds(imdb: 'tt29768334'), kind: MediaKind.movie, - title: 'Legacy Movie', + titles: const ['Legacy Movie'], ); expect(match?.id, 'legacy-movie'); @@ -89,7 +89,7 @@ void main() { final match = await client.findByExternalIds( const ExternalIds(tvdb: 315500), kind: MediaKind.show, - title: 'Legacy Show', + titles: const ['Legacy Show'], ); expect(match?.id, 'legacy-show'); @@ -124,7 +124,7 @@ void main() { final match = await client.findByExternalIds( const ExternalIds(imdb: 'tt12345'), kind: MediaKind.movie, - title: 'Duplicate', + titles: const ['Duplicate'], ); expect(match?.id, 'modern-match'); @@ -166,7 +166,7 @@ void main() { final match = await client.findByExternalIds( const ExternalIds(tmdb: 42), kind: MediaKind.movie, - title: 'Missing Year', + titles: const ['Missing Year'], year: 2024, ); @@ -197,9 +197,199 @@ void main() { final match = await client.findByExternalIds( const ExternalIds(tvdb: 315500), kind: MediaKind.show, - title: 'Unsupported', + titles: const ['Unsupported'], ); expect(match, isNull); }); + + test('tries broader title candidates in order and still verifies external ids', () async { + final requests = []; + final client = testPlexClient( + handler: (request) async { + requests.add(request.url); + if (request.url.queryParameters['title'] == 'Parent Show Season 2') { + return _json({ + 'MediaContainer': {'Metadata': []}, + }); + } + return _json({ + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': 'wrong-parent', + 'type': 'show', + 'title': 'Parent Show', + 'Guid': [ + {'id': 'tvdb://999'}, + ], + }, + { + 'ratingKey': 'verified-parent', + 'type': 'show', + 'title': 'Parent Show', + 'Guid': [ + {'id': 'tvdb://123'}, + ], + }, + ], + }, + }); + }, + ); + addTearDown(client.close); + + final match = await client.findByExternalIds( + const ExternalIds(tvdb: 123), + kind: MediaKind.show, + titles: const ['Parent Show Season 2', 'Parent Show'], + ); + + expect(match?.id, 'verified-parent'); + expect(requests.map((uri) => uri.queryParameters['title']), ['Parent Show Season 2', 'Parent Show']); + expect(requests.first.queryParameters['X-Plex-Container-Size'], '20'); + expect(requests.last.queryParameters['X-Plex-Container-Size'], '50'); + }); + + test('uses an exact Plex guid without external ids or a title query', () async { + final requests = []; + final client = testPlexClient( + handler: (request) async { + requests.add(request.url); + return _json({ + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': 'exact-show', + 'type': 'show', + 'title': 'Exact Show', + 'guid': 'plex://show/5e01fc33932ff9001db3b242', + }, + ], + }, + }); + }, + ); + addTearDown(client.close); + + final match = await client.findByExternalIds( + const ExternalIds(), + kind: MediaKind.show, + titles: const ['Never Searched'], + plexGuid: 'plex://show/5e01fc33932ff9001db3b242', + ); + + expect(match?.id, 'exact-show'); + expect(requests, hasLength(1)); + expect(requests.single.path, '/library/all'); + expect(requests.single.queryParameters['guid'], 'plex://show/5e01fc33932ff9001db3b242'); + expect(requests.single.queryParameters['type'], '2'); + expect(requests.single.queryParameters['includeGuids'], '1'); + expect(requests.single.queryParameters.containsKey('title'), isFalse); + }); + + test('an agreed season ref gates on the season hierarchy and nothing else', () async { + final childRequests = []; + final extraRequests = []; + final client = testPlexClient( + handler: (request) async { + if (request.url.queryParameters.containsKey('includePreferences') || request.url.path.endsWith('/prefs')) { + 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 (request.url.path.startsWith('/library/metadata/')) { + return _json({'MediaContainer': {}}); + } + final complete = request.url.queryParameters['title'] == 'Complete Show'; + return _json({ + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': complete ? 'complete-show' : 'incomplete-show', + 'type': 'show', + 'title': complete ? 'Complete Show' : 'Incomplete Show', + 'librarySectionID': 4, + 'Guid': [ + {'id': 'tvdb://${complete ? 101 : 100}'}, + ], + }, + ], + }, + }); + }, + ); + addTearDown(client.close); + + final missing = await client.findByExternalIds( + const ExternalIds(tvdb: 100), + 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'], + season: const ExternalSeasonRef(tvdb: 2, tmdb: 2), + ); + + expect(missing, isNull); + expect(present?.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'); + }); + + 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 client = testPlexClient( + handler: (request) async { + requests.add(request.url); + return _json({ + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': 'ungated-show', + 'type': 'show', + 'title': 'Ungated Show', + 'librarySectionID': 9, + 'Guid': [ + {'id': 'tvdb://300'}, + ], + }, + ], + }, + }); + }, + ); + addTearDown(client.close); + + final match = await client.findByExternalIds( + const ExternalIds(tvdb: 300), + kind: MediaKind.show, + titles: const ['Ungated Show'], + season: const ExternalSeasonRef(tvdb: 2, tmdb: 1), + ); + + expect(match?.id, 'ungated-show'); + expect(requests.map((uri) => uri.path), ['/library/all'], reason: 'no children, no preferences'); + }); } diff --git a/test/utils/external_season_ref_test.dart b/test/utils/external_season_ref_test.dart new file mode 100644 index 00000000..8d7025f7 --- /dev/null +++ b/test/utils/external_season_ref_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/utils/external_ids.dart'; + +void main() { + group('ExternalSeasonRef.agreedSeason', () { + test('resolves only when both providers mapped the season and agree', () { + expect(const ExternalSeasonRef(tvdb: 3, tmdb: 3).agreedSeason, 3); + // Fribb maps "You and I Are Polar Opposites Season 2" to TVDB season 2 + // but TMDB season 1 (a continuation at an episode offset). Which one a + // library follows is a server setting, so there is no honest answer. + expect(const ExternalSeasonRef(tvdb: 2, tmdb: 1).agreedSeason, isNull); + }); + + test('a missing number is not agreement', () { + // Absence means Fribb has no mapping for that provider, so a server + // ordered by it would number the season by data we do not have. + expect(const ExternalSeasonRef(tvdb: 2).agreedSeason, isNull); + expect(const ExternalSeasonRef(tmdb: 2).agreedSeason, isNull); + expect(const ExternalSeasonRef().agreedSeason, isNull); + }); + + test('isSequel stays knowable even when the numbers disagree', () { + // The gate cannot resolve this ref, but the year window still must be + // dropped: a sequel's catalog year is its own, not the parent show's. + expect(const ExternalSeasonRef(tvdb: 2, tmdb: 1).isSequel, isTrue); + expect(const ExternalSeasonRef(tvdb: 1, tmdb: 1).isSequel, isFalse); + expect(const ExternalSeasonRef(tmdb: 4).isSequel, isTrue); + expect(const ExternalSeasonRef().isSequel, isFalse); + }); + + test('round-trips through JSON', () { + const ref = ExternalSeasonRef(tvdb: 2, tmdb: 1); + expect(ExternalSeasonRef.fromJson(ref.toJson()), ref); + expect(ExternalSeasonRef.fromJson(const ExternalSeasonRef().toJson()).hasAny, isFalse); + }); + }); +} diff --git a/test/utils/title_match_candidates_test.dart b/test/utils/title_match_candidates_test.dart new file mode 100644 index 00000000..78b3f99c --- /dev/null +++ b/test/utils/title_match_candidates_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/utils/title_match_candidates.dart'; + +void main() { + group('stripSeasonSuffix', () { + test('drops every sequel suffix shape seen on MAL/AniList', () { + final cases = { + 'You and I Are Polar Opposites Season 2': 'You and I Are Polar Opposites', + 'Mushoku Tensei: Jobless Reincarnation Season 2': 'Mushoku Tensei: Jobless Reincarnation', + '[Oshi no Ko] 2nd Season': '[Oshi no Ko]', + 'The Duke of Death and His Maid 3rd Season': 'The Duke of Death and His Maid', + 'My Hero Academia FINAL SEASON': 'My Hero Academia', + 'Dr. STONE: SCIENCE FUTURE Part 2': 'Dr. STONE: SCIENCE FUTURE', + 'Dr. STONE SCIENCE FUTURE Cour 2': 'Dr. STONE SCIENCE FUTURE', + 'Unnamed Memory Act.2': 'Unnamed Memory', + 'Kekkon Yubiwa Monogatari II': 'Kekkon Yubiwa Monogatari', + 'Isekai Quartet 3': 'Isekai Quartet', + }; + for (final entry in cases.entries) { + expect(stripSeasonSuffix(entry.key), entry.value, reason: entry.key); + } + }); + + test('collapses stacked suffixes in one pass', () { + // Truncating from the FIRST marker is what makes these single-shot. + expect( + stripSeasonSuffix('Mushoku Tensei: Jobless Reincarnation Season 2 Part 2'), + 'Mushoku Tensei: Jobless Reincarnation', + ); + expect(stripSeasonSuffix('JUJUTSU KAISEN Season 3: The Culling Game Part 1'), 'JUJUTSU KAISEN'); + expect(stripSeasonSuffix('Solo Leveling Season 2 -Arise from the Shadow-'), 'Solo Leveling'); + expect( + stripSeasonSuffix('Classroom of the Elite 4th Season: Second Year, First Semester'), + 'Classroom of the Elite', + ); + }); + + test('returns null when the title carries no sequel suffix', () { + expect(stripSeasonSuffix('Frieren: Beyond Journey\'s End'), isNull); + expect(stripSeasonSuffix('Cowboy Bebop'), isNull); + // Stripping must not consume the whole title. + expect(stripSeasonSuffix('86'), isNull); + }); + + test('does not truncate a title whose trailing number is part of its name', () { + expect(stripSeasonSuffix('Kaiju No. 8'), isNull); + expect(stripSeasonSuffix('Mob Psycho 100'), 'Mob Psycho'); + expect(stripSeasonSuffix('Vol. 3'), isNull); + }); + }); + + group('titleMatchCandidates', () { + test('emits a title immediately followed by its stripped form', () { + final candidates = titleMatchCandidates([ + 'You and I Are Polar Opposites Season 2', + 'Seihantai na Kimi to Boku 2nd Season', + ]); + // Capped at two, so the second provider title never gets a slot — the + // stripped form of the first is worth far more than an alias. + expect(candidates, ['You and I Are Polar Opposites Season 2', 'You and I Are Polar Opposites']); + }); + + test('honours a wider cap by interleaving, never by listing raw titles first', () { + final candidates = titleMatchCandidates([ + 'You and I Are Polar Opposites Season 2', + 'Seihantai na Kimi to Boku 2nd Season', + ], limit: 4); + expect(candidates, [ + 'You and I Are Polar Opposites Season 2', + 'You and I Are Polar Opposites', + 'Seihantai na Kimi to Boku 2nd Season', + 'Seihantai na Kimi to Boku', + ]); + }); + test('normalizes typographic punctuation both backends miss on', () { + // Verified live: Plex and Jellyfin both return 0 for the curly form. + expect(titleMatchCandidates(['Frieren: Beyond Journey\u2019s End']), ["Frieren: Beyond Journey's End"]); + expect(titleMatchCandidates(['Kaguya-sama \u2013 Love is War']), ['Kaguya-sama - Love is War']); + }); + + test('drops nulls, blanks and case-insensitive duplicates', () { + final candidates = titleMatchCandidates([ + 'Bocchi the Rock!', + null, + ' ', + 'BOCCHI THE ROCK!', + 'Bocchi the Rock!', + ]); + expect(candidates, ['Bocchi the Rock!']); + }); + + test('does not emit a stripped form that duplicates a provider title', () { + final candidates = titleMatchCandidates(['Kaiju No. 8 Season 2', 'Kaiju No. 8']); + expect(candidates, ['Kaiju No. 8 Season 2', 'Kaiju No. 8']); + }); + + 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. + final candidates = titleMatchCandidates([ + 'Mushoku Tensei: Jobless Reincarnation Season 2', + 'Mushoku Tensei II: Isekai Ittara Honki Dasu', + 'Mushoku Tensei 2', + 'MT2', + ]); + expect(candidates, ['Mushoku Tensei: Jobless Reincarnation Season 2', 'Mushoku Tensei: Jobless Reincarnation']); + }); + }); +}