fix(explore): match sequel catalog entries to their parent library show

MAL/AniList season entries never matched the library show they belong to.
Both backends use the catalog title as a server-side filter before verifying
external ids, and a title like "Mushoku Tensei: Jobless Reincarnation Season 2"
cannot reach a show stored as "Mushoku Tensei: Jobless Reincarnation". Measured
against a 267-show Plex library, 3 of 113 mapped sequel entries matched.

The reverse lookup now takes two ordered title candidates instead of one: the
entry's own title and its season-stripped form, with typographic punctuation
normalised because both servers miss on a curly apostrophe. That matches 77 of
113. Widening it further to romaji/native/synonym variants reached only 81, so
the cap stays at two rather than spending up to five more requests per lookup
that finds nothing.

A sequel's year is its own season's, not the parent show's, so the +/-1 year
window is dropped for one - it would exclude the very show being looked for.
That also keeps a miss at the two requests the single-title lookup already
spent. A Plex Discover item additionally skips the title search entirely by
filtering on the plex:// guid its own rating key already is, which costs no
extra request and needs no cloud lookup.

A season 2+ entry only matches when the server really has that season, which
costs one children fetch on a match. Only a season both TVDB and TMDB agree on
is gated: which provider a library numbers its seasons by is a server setting
no dataset supplies and none of it is inferable from the ids an item exposes,
so a disagreeing reference is left ungated rather than gated on a guess.

The match cache is keyed per source and per entry rather than by canonical id,
which every season of a series shares: all five Mushoku Tensei entries collapse
to imdb:tt13293588, so one season-gated result would have poisoned the rest.

Entries whose Fribb row carries no provider id at all remain unmatched. That is
an upstream mapping gap, not something to guess around with extra lookups.

close #1704
This commit is contained in:
edde746
2026-07-29 01:35:35 +02:00
parent 1165998dae
commit 0eee9f688d
20 changed files with 1350 additions and 72 deletions
+38 -10
View File
@@ -505,16 +505,44 @@ abstract class MediaServerClient {
Future<ExternalIds> 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<MediaItem?> 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<MediaItem?> findByExternalIds(
ExternalIds ids, {
required MediaKind kind,
List<String> 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
+6
View File
@@ -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<String>? genres;
final List<String>? 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,
+43
View File
@@ -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<String> 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<MediaItem>`).
///
@@ -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<String>() ?? const [],
season: switch (json['season']) {
final Map<String, Object?> s => ExternalSeasonRef.fromJson(s),
final Map s => ExternalSeasonRef.fromJson(s.cast<String, Object?>()),
_ => null,
},
year: json['year'] as int?,
overview: json['overview'] as String?,
runtimeMinutes: json['runtimeMinutes'] as int?,
@@ -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 <String?>[
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,
);
@@ -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<String, ({DateTime at, List<MediaItem> items})> _cache = {};
CatalogLibraryMatcher(this._multiServer);
CatalogLibraryMatcher(this._multiServer) : _now = DateTime.now;
@visibleForTesting
CatalogLibraryMatcher.withClock(this._multiServer, this._now);
Future<List<MediaItem>> match(CatalogItem item) async {
if (!item.ids.hasAny) return const [];
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';
}
}
@@ -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 <String?>[
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,
);
+12 -3
View File
@@ -597,16 +597,25 @@ class DataAggregationService {
Future<List<MediaItem>> findByExternalIdsAcrossServers(
ExternalIds ids, {
required MediaKind kind,
String? title,
List<String> 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;
+39 -21
View File
@@ -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<MediaItem?> findByExternalIds(ExternalIds ids, {required MediaKind kind, String? title, int? year}) async {
Future<MediaItem?> findByExternalIds(
ExternalIds ids, {
required MediaKind kind,
List<String> 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<MediaItem?> 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
+76 -25
View File
@@ -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<MediaItem?> _applyExternalIdSeasonGate(
Map<String, dynamic> 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<MediaItem?> findByExternalIds(ExternalIds ids, {required MediaKind kind, String? title, int? year}) async {
Future<MediaItem?> findByExternalIds(
ExternalIds ids, {
required MediaKind kind,
List<String> 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<String, dynamic>? modern, Map<String, dynamic>? legacy})> attempt(String? years) async {
Future<({Map<String, dynamic>? modern, Map<String, dynamic>? 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<String, dynamic>? modern, Map<String, dynamic>? 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<String, dynamic>? modern, Map<String, dynamic>? 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
@@ -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
+46
View File
@@ -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<String, Object?> toJson() => {if (tvdb != null) 'tvdb': tvdb, if (tmdb != null) 'tmdb': tmdb};
factory ExternalSeasonRef.fromJson(Map<String, Object?> 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.
///
+96
View File
@@ -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<RegExp> _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 `Journeys` against a stored `Journey's`.
const Map<String, String> _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<String> titleMatchCandidates(Iterable<String?> titles, {int limit = 2}) {
final out = <String>[];
final seen = <String>{};
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;
}
+71
View File
@@ -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<String, Object?>?;
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<String, dynamic> and
// List<dynamic>; 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<String, dynamic>);
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);
});
});
}
@@ -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<String, dynamic>;
@@ -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<String> 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<List<MediaItem>> responses = [];
@override
Future<List<MediaItem>> findByExternalIdsAcrossServers(
ExternalIds ids, {
required MediaKind kind,
List<String> 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']);
});
}
@@ -47,12 +47,14 @@ Map<String, dynamic> _node({
required int id,
required String title,
String? en,
String? ja,
List<String>? 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<String, dynamic> _pageBody(List<Map<String, dynamic>> 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(
@@ -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<String, dynamic> _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 = <String>[];
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()] : <Object>[],
});
}
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 = <Uri>[];
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()] : <Object>[],
});
}
if (request.url.path == '/Items/series-1/Ancestors') return _json(<Object>[]);
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<String?> lookupWithSeasons(List<int> 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(<Object>[]);
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(<Object>[]);
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');
});
}
+195 -5
View File
@@ -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 = <Uri>[];
final client = testPlexClient(
handler: (request) async {
requests.add(request.url);
if (request.url.queryParameters['title'] == 'Parent Show Season 2') {
return _json({
'MediaContainer': {'Metadata': <Object>[]},
});
}
return _json({
'MediaContainer': {
'Metadata': [
{
'ratingKey': 'wrong-parent',
'type': 'show',
'title': 'Parent Show',
'Guid': [
{'id': 'tvdb://999'},
],
},
{
'ratingKey': 'verified-parent',
'type': 'show',
'title': 'Parent Show',
'Guid': [
{'id': 'tvdb://123'},
],
},
],
},
});
},
);
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 = <Uri>[];
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 = <String>[];
final extraRequests = <String>[];
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': <String, Object?>{}});
}
final complete = request.url.queryParameters['title'] == 'Complete Show';
return _json({
'MediaContainer': {
'Metadata': [
{
'ratingKey': complete ? 'complete-show' : 'incomplete-show',
'type': 'show',
'title': complete ? 'Complete Show' : 'Incomplete Show',
'librarySectionID': 4,
'Guid': [
{'id': 'tvdb://${complete ? 101 : 100}'},
],
},
],
},
});
},
);
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 = <Uri>[];
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');
});
}
+37
View File
@@ -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);
});
});
}
+109
View File
@@ -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 = <String, String>{
'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']);
});
});
}