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