Files
plezy/test/utils/title_match_candidates_test.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

110 lines
4.7 KiB
Dart

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']);
});
});
}