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:
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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 `Journey’s` 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;
|
||||
}
|
||||
Reference in New Issue
Block a user