Files
plezy/lib/utils/external_ids.dart
T
edde746 0eee9f688d 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
2026-07-29 01:35:35 +02:00

179 lines
7.1 KiB
Dart

/// 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.
///
/// - **Plex** stores modern IDs in a `Guid` array (`imdb://tt123`,
/// `tmdb://456`, `tvdb://789`) and some legacy agents expose one scalar
/// `guid`. Use [ExternalIds.fromGuids] or [ExternalIds.fromLegacyPlexGuid].
/// - **Jellyfin** stores them inline as a `ProviderIds` map on every
/// `BaseItemDto`. Use [ExternalIds.fromJellyfinProviderIds].
class ExternalIds {
final String? imdb;
final int? tmdb;
final int? tvdb;
const ExternalIds({this.imdb, this.tmdb, this.tvdb});
bool get hasAny => imdb != null || tmdb != null || tvdb != null;
/// True when any id form matches [other]. Used to verify reverse-lookup
/// candidates (never yields false positives; the two sides may carry
/// different id subsets).
bool intersects(ExternalIds other) =>
(imdb != null && imdb == other.imdb) ||
(tmdb != null && tmdb == other.tmdb) ||
(tvdb != null && tvdb == other.tvdb);
factory ExternalIds.fromGuids(List<dynamic> guids) {
String? imdb;
int? tmdb;
int? tvdb;
for (final g in guids) {
if (g is! Map) continue;
final id = g['id'];
if (id is! String) continue;
if (id.startsWith('imdb://')) {
imdb = id.substring(7);
} else if (id.startsWith('tmdb://')) {
tmdb = int.tryParse(id.substring(7));
} else if (id.startsWith('tvdb://')) {
tvdb = int.tryParse(id.substring(7));
}
}
return ExternalIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb);
}
/// Build from a legacy Plex item's scalar `guid`.
///
/// Only agent formats that map directly to IMDb, TMDB, or TVDB are
/// recognized. HAMA AniDB identifiers require an external mapping and are
/// deliberately left unsupported here.
factory ExternalIds.fromLegacyPlexGuid(Object? guid) {
if (guid is! String || guid.isEmpty) return const ExternalIds();
final uri = Uri.tryParse(guid);
if (uri == null || !uri.hasAuthority || uri.path.isNotEmpty) return const ExternalIds();
final value = uri.host;
switch (uri.scheme.toLowerCase()) {
case 'com.plexapp.agents.imdb':
return ExternalIds(imdb: _normalizeImdb(value, allowBareDigits: false));
case 'com.plexapp.agents.themoviedb':
return ExternalIds(tmdb: _parseNumericId(value));
case 'com.plexapp.agents.thetvdb':
return ExternalIds(tvdb: _parseNumericId(value));
case 'com.plexapp.agents.hama':
final separator = value.indexOf('-');
if (separator <= 0 || separator == value.length - 1) return const ExternalIds();
final source = value.substring(0, separator).toLowerCase();
final id = value.substring(separator + 1);
if (source == 'imdb') {
return ExternalIds(imdb: _normalizeImdb(id, allowBareDigits: true));
}
if (source == 'tmdb' || source == 'tsdb') {
return ExternalIds(tmdb: _parseNumericId(id));
}
if (_hamaTvdbSource.hasMatch(source)) {
return ExternalIds(tvdb: _parseNumericId(id));
}
}
return const ExternalIds();
}
static final RegExp _decimalId = RegExp(r'^[0-9]+$');
static final RegExp _hamaTvdbSource = RegExp(r'^tvdb(?:[2-9])?$');
static int? _parseNumericId(String value) => _decimalId.hasMatch(value) ? int.tryParse(value) : null;
static String? _normalizeImdb(String value, {required bool allowBareDigits}) {
final normalized = value.toLowerCase();
if (normalized.startsWith('tt')) {
final digits = normalized.substring(2);
return _decimalId.hasMatch(digits) ? 'tt$digits' : null;
}
return allowBareDigits && _decimalId.hasMatch(normalized) ? 'tt$normalized' : null;
}
/// Pick the first raw Jellyfin item whose inline `ProviderIds` intersect
/// [ids]. Pure helper so the reverse-lookup verification stays
/// unit-testable (its call site lives in a part file).
static Map<String, dynamic>? jellyfinCandidateMatching(List<Map<String, dynamic>> candidates, ExternalIds ids) {
for (final item in candidates) {
final providerIds = item['ProviderIds'];
if (providerIds is! Map) continue;
final candidate = ExternalIds.fromJellyfinProviderIds(providerIds.cast<String, Object?>());
if (ids.intersects(candidate)) return item;
}
return null;
}
/// Build from a Jellyfin `ProviderIds` map. Jellyfin stores external IDs
/// directly on every `BaseItemDto` so no extra fetch is needed.
/// Keys are case-insensitive in practice (`Tmdb`, `Imdb`, `Tvdb`).
factory ExternalIds.fromJellyfinProviderIds(Map<String, Object?> providerIds) {
String? imdb;
int? tmdb;
int? tvdb;
providerIds.forEach((key, value) {
if (value is! String || value.isEmpty) return;
switch (key.toLowerCase()) {
case 'imdb':
imdb = value;
break;
case 'tmdb':
tmdb = int.tryParse(value);
break;
case 'tvdb':
tvdb = int.tryParse(value);
break;
}
});
return ExternalIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb);
}
}