diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 66a52988e..9482a49b0 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -627,9 +627,9 @@ abstract class MediaServerClient { /// /// Every entry of [titles] is searched, and the union of their id-verified /// candidates is returned. The caller (`CatalogLibraryMatcher.lookupTitles`) - /// leads with the native title — both search indexes cover `originalTitle`, - /// so it reaches a copy filed under any display language — and bounds the - /// list; that cap is the request budget. A title that hit MUST NOT stop the + /// prioritizes original/display titles and bounds best-effort alternatives; + /// original titles may be native or romanized depending on the source. + /// The cap is the title-search budget. A title that hit MUST NOT stop the /// others: id verification means an extra title can only add genuine /// copies. A sequel entry's own title never matches its parent show, which /// is why the list carries season-stripped forms. [year] is a hint for diff --git a/lib/models/catalog/catalog_item.dart b/lib/models/catalog/catalog_item.dart index d34dc7afd..f351dd1f4 100644 --- a/lib/models/catalog/catalog_item.dart +++ b/lib/models/catalog/catalog_item.dart @@ -424,7 +424,13 @@ class CatalogItem { physicalReleaseDate: detail.physicalReleaseDate ?? physicalReleaseDate, endDate: detail.endDate ?? endDate, originalTitle: detail.originalTitle ?? originalTitle, - altTitles: altTitles.isNotEmpty ? altTitles : detail.altTitles, + // Detail aliases get first chance at the bounded lookup budget, without + // losing names known only to the row. + altTitles: detail.altTitles.isEmpty + ? altTitles + : altTitles.isEmpty + ? detail.altTitles + : {...detail.altTitles, ...altTitles}.toList(), tagline: detail.tagline ?? tagline, broadcastSeason: detail.broadcastSeason ?? broadcastSeason, format: detail.format ?? format, diff --git a/lib/services/catalog/catalog_library_matcher.dart b/lib/services/catalog/catalog_library_matcher.dart index 0159d809a..f0b3bd297 100644 --- a/lib/services/catalog/catalog_library_matcher.dart +++ b/lib/services/catalog/catalog_library_matcher.dart @@ -67,8 +67,8 @@ class CatalogLibraryMatcher { // Namespace by source too: MAL and AniList can share a MAL id while // carrying different titles. The id forms and the title candidates join // the key because a detail load can enrich an item with external ids its - // row form lacked (#1715: Plex rows carry only a rating key) or with the - // native title; the richer lookup must not be short-circuited by the + // row form lacked (#1715: Plex rows carry only a rating key) or additional + // lookup titles; the richer lookup must not be short-circuited by the // poorer form's cached negative. final key = '${item.source.name}/${item.entryIdentityKey}/${item.ids.allKeys.join(',')}/${titles.join('\u0000')}'; final cached = _cache[key]; @@ -159,19 +159,17 @@ class CatalogLibraryMatcher { /// The title candidates a lookup for [item] spends its request budget on. /// - /// Native title first: both backends index `originalTitle` alongside the - /// display title (Plex `/hubs/search`, Jellyfin `SearchTerm`; verified on - /// PMS 1.43 and Jellyfin 10.11), and every copy of a foreign title carries - /// the same `originalTitle` whatever language it is filed under — English, - /// romaji, localized or native. One query with it reaches all of them - /// (#2098). The item's own title follows for the catalog/agent spelling - /// drift the native form can suffer, and each brings its season-stripped - /// form for sequel entries. Alternate titles are not candidates: the - /// copies they used to reach are exactly the ones the native title reaches. + /// Prefer the source's original title, then its display title and aliases. + /// MAL and AniList supply typed native titles, but other sources may supply + /// production/romaji titles or no original title at all. Server indexes can + /// search original titles when present; they do not make aliases redundant. + /// Each admitted title brings its season-stripped partner within the same + /// four-query budget. Later families are best-effort and may not fit. /// /// A detail load that changes this list is what tells the detail screen /// to ask again. - static List lookupTitles(CatalogItem item) => titleMatchCandidates([item.originalTitle, item.title]); + static List lookupTitles(CatalogItem item) => + titleMatchCandidates([item.originalTitle, item.title, ...item.altTitles]); /// Whether [result] may be memoized for the rest of the session: a hit /// every expected server took part in. One that sat out — failed, diff --git a/lib/services/catalog/mal_catalog_source.dart b/lib/services/catalog/mal_catalog_source.dart index 0c5b1ca7b..d6f6ad157 100644 --- a/lib/services/catalog/mal_catalog_source.dart +++ b/lib/services/catalog/mal_catalog_source.dart @@ -189,14 +189,16 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource { String? background, }) { final title = anime.displayTitle; - final originalTitle = anime.title; + final nativeTitle = anime.alternativeTitles?.ja?.trim(); + final originalTitle = nativeTitle != null && nativeTitle.isNotEmpty ? nativeTitle : anime.title; return CatalogItem( source: CatalogSourceId.mal, kind: anime.isMovie ? MediaKind.movie : MediaKind.show, title: title, - // Set literal: insertion-ordered and deduped. MAL repeats the English - // and romaji titles inside `synonyms`, and a duplicate candidate costs - // the reverse library lookup a wasted request. + // Retain romaji even when it is also the display title. The typed native + // title leads library matching; alternatives remain bounded fallbacks. + // MAL repeats these titles in synonyms, so preserve insertion order and + // deduplicate before exposing them as descriptive aliases. altTitles: { for (final candidate in [ anime.alternativeTitles?.en, @@ -204,7 +206,7 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource { anime.alternativeTitles?.ja, ...?anime.alternativeTitles?.synonyms, ]) - if (candidate != null && candidate.isNotEmpty && candidate != title) candidate, + if (candidate != null && candidate.isNotEmpty && (candidate != title || candidate == anime.title)) candidate, }.toList(), year: anime.year, overview: anime.synopsis, diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 26b171465..4d543cff9 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -4495,8 +4495,8 @@ class PlexClient /// word-prefix substring match with a leading bracket glued to its first /// word: `Oshi no Ko` never finds a library titled `[Oshi no Ko]`, `Timer` /// never finds `Part-Timer!` (#2098). The index also covers - /// `originalTitle`, so the native title the caller leads with reaches a - /// copy filed under any display language in one query. Both endpoints are + /// `originalTitle`, so a known original title can reach copies filed under + /// another display language. Both endpoints are /// server-wide: a movie held by a 4K and an HD library answers as two rows, /// each with its own `librarySectionID`, and every id-verified row is kept /// (#1754). diff --git a/lib/utils/title_match_candidates.dart b/lib/utils/title_match_candidates.dart index e80c8ba10..8c0fed244 100644 --- a/lib/utils/title_match_candidates.dart +++ b/lib/utils/title_match_candidates.dart @@ -63,15 +63,12 @@ String _normalize(String title) { /// backend searches every candidate concurrently and verifies external ids, /// so a candidate can only ever add genuine copies. /// -/// [limit] is the request budget per server per lookup. 4 is two title -/// families, each a title plus its stripped form: the native title, which -/// both backends index as `originalTitle` on every copy of a foreign title -/// whatever language it is filed under, and the item's own title for the -/// spelling drift the native form can suffer between catalog and agent -/// (`CatalogLibraryMatcher.lookupTitles`). Against a 267-show Plex library, -/// the own family alone matched 77 of 113 real sequel entries and six -/// candidates reached 81; the native family is not for hit rate but for the -/// copies the own title cannot name (#2098). +/// [limit] bounds logical title searches per server per lookup. Four admits +/// two families when both have stripped partners; families without a suffix +/// leave room for more aliases. The caller supplies original/display titles +/// first, then best-effort alternatives (`CatalogLibraryMatcher.lookupTitles`). +/// A source's original title is not necessarily native, and neither source nor +/// server metadata guarantees that every alias fits or reaches every copy. /// /// 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 diff --git a/test/services/catalog/catalog_library_matcher_test.dart b/test/services/catalog/catalog_library_matcher_test.dart index 7baf80a83..55535a926 100644 --- a/test/services/catalog/catalog_library_matcher_test.dart +++ b/test/services/catalog/catalog_library_matcher_test.dart @@ -1,6 +1,10 @@ import 'dart:async'; +import 'dart:convert'; +import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:plezy/database/app_database.dart'; import 'package:plezy/exceptions/media_server_exceptions.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_item.dart'; @@ -10,28 +14,21 @@ 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/jellyfin_api_cache.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/utils/external_ids.dart'; +import '../../test_helpers/backend_client_fixtures.dart'; import '../../test_helpers/library_lookup.dart'; import '../../test_helpers/media_items.dart'; class _LookupCall { final ExternalIds ids; - final MediaKind kind; final List 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, - }); + const _LookupCall({required this.ids, required this.titles, required this.year, required this.plexGuid}); } class _FakeDataAggregationService extends DataAggregationService { @@ -50,9 +47,7 @@ class _FakeDataAggregationService extends DataAggregationService { String? plexGuid, ExternalSeasonRef? season, }) async { - calls.add( - _LookupCall(ids: ids, kind: kind, titles: List.of(titles), year: year, plexGuid: plexGuid, season: season), - ); + calls.add(_LookupCall(ids: ids, titles: List.of(titles), year: year, plexGuid: plexGuid)); return responses.removeAt(0); } } @@ -124,7 +119,7 @@ class _LiveHarness { matcher = CatalogLibraryMatcher.withClock(multiServer, now); } - void register(_LookupClient client, {bool online = true}) => + void register(MediaServerClient client, {bool online = true}) => manager.debugRegisterClientForTesting(client, online: online); void dispose() { @@ -622,34 +617,6 @@ void main() { expect((await harness.matcher.match(item)).items, [other]); }); - test('forwards season-stripped title candidates and season reference', () async { - final harness = _Harness(); - addTearDown(harness.dispose); - harness.aggregation.responses.add(libraryLookupResult(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)); - // The own family only: alternates are not candidates, and the native - // title is absent here. - 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); @@ -669,29 +636,153 @@ void main() { expect(call.titles, ['Severance'], reason: 'nothing to strip, so one candidate and one request'); }); - test('the native title leads and alternate titles are not candidates', () async { - // #2098: both backends index `originalTitle`, so the native title alone - // reaches a copy filed under English, romaji or a localized title. The - // alternates only ever repeated that work. - final harness = _Harness(); - addTearDown(harness.dispose); - harness.aggregation.responses.add(libraryLookupResult(const [])); - const item = CatalogItem( - source: CatalogSourceId.trakt, - kind: MediaKind.show, - title: "Frieren: Beyond Journey's End Season 2", - originalTitle: '葬送のフリーレン', - altTitles: ['Sousou no Frieren', 'Frieren: Tras finalizar el viaje'], - ids: CatalogItemIds(trakt: 198225, tvdb: 424536), - ); + group('bounded alias lookup', () { + late AppDatabase db; - await harness.matcher.match(item); + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + JellyfinApiCache.initialize(db); + }); - expect(harness.aggregation.calls.single.titles, [ - '葬送のフリーレン', - "Frieren: Beyond Journey's End Season 2", - "Frieren: Beyond Journey's End", - ]); + tearDown(() async { + await db.close(); + }); + + test('detail aliases gain verified copies without dropping row-only aliases or reusing a weaker cache', () async { + final searches = []; + final harness = _LiveHarness(() => DateTime.utc(2026, 9, 6)); + addTearDown(harness.dispose); + harness.register( + testJellyfinClient( + handler: (request) async { + if (request.url.path.endsWith('/Ancestors')) return http.Response('[]', 200); + expect(request.url.path, '/Items'); + final query = request.url.queryParameters['SearchTerm']!; + searches.add(query); + final id = switch (query) { + 'Row Alias' => 'row-copy', + '君の名は。' => 'native-copy', + _ => null, + }; + return http.Response( + jsonEncode({ + 'Items': [ + if (id != null) + for (final candidateId in [id, 'wrong-id']) + { + 'Id': candidateId, + 'Type': 'Movie', + 'Name': 'Your Name.', + 'ProviderIds': {'Tmdb': candidateId == 'wrong-id' ? '999' : '372058'}, + }, + ], + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }, + ), + ); + const row = CatalogItem( + source: CatalogSourceId.simkl, + kind: MediaKind.movie, + title: 'Your Name.', + originalTitle: 'Kimi no Na wa.', + altTitles: ['Row Alias', 'Other Alias', 'Overflow Alias'], + ids: CatalogItemIds(simkl: 210, tmdb: 372058), + ); + const detail = CatalogItem( + source: CatalogSourceId.simkl, + kind: MediaKind.movie, + title: 'Your Name.', + altTitles: ['君の名は。', '君の名は。'], + ids: CatalogItemIds(simkl: 210, tmdb: 372058), + ); + final enriched = row.enrichedWith(detail); + + expect((await harness.matcher.match(row)).items.map((copy) => copy.id), ['row-copy']); + expect(searches.length, lessThanOrEqualTo(4)); + searches.clear(); + + // A fifth family remains best-effort: it cannot spend more requests or + // invalidate the cache when no admitted query changed. + final outsideBudget = CatalogItem.fromJson({ + ...row.toJson(), + 'altTitles': [...row.altTitles, '君の名は。'], + }); + expect((await harness.matcher.match(outsideBudget)).items.map((copy) => copy.id), ['row-copy']); + expect(searches, isEmpty); + + final richer = await harness.matcher.match(enriched); + expect(richer.items.map((copy) => copy.id), unorderedEquals(['row-copy', 'native-copy'])); + expect(richer.failedServerIds, isEmpty); + expect(searches.length, lessThanOrEqualTo(4)); + searches.clear(); + expect((await harness.matcher.match(row)).items.map((copy) => copy.id), ['row-copy']); + expect( + (await harness.matcher.match(enriched)).items.map((copy) => copy.id), + unorderedEquals(['row-copy', 'native-copy']), + ); + expect(searches, isEmpty, reason: 'the candidate-sensitive answers remain independently memoized'); + }); + + test('a failed alias query retains prior verified copies until the server answers a complete miss', () async { + var now = DateTime.utc(2026, 9, 6); + var failAlias = false; + var hasCopy = true; + final harness = _LiveHarness(() => now); + addTearDown(harness.dispose); + final client = testJellyfinClient( + handler: (request) async { + if (request.url.path.endsWith('/Ancestors')) return http.Response('[]', 200); + expect(request.url.path, '/Items'); + final isNative = request.url.queryParameters['SearchTerm'] == '君の名は。'; + if (isNative && failAlias) return http.Response('Unavailable', 403); + return http.Response( + jsonEncode({ + 'Items': [ + if (isNative && hasCopy) + { + 'Id': 'owned', + 'Type': 'Movie', + 'Name': 'Your Name.', + 'ProviderIds': {'Tmdb': '372058'}, + }, + ], + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }, + ); + final other = _LookupClient('offline', error: StateError('unavailable')); + harness.register(client); + harness.register(other); + const item = CatalogItem( + source: CatalogSourceId.mal, + kind: MediaKind.movie, + title: 'Kimi no Na wa.', + altTitles: ['君の名は。'], + ids: CatalogItemIds(mal: 32281, tmdb: 372058), + ); + + expect((await harness.matcher.match(item)).items.map((copy) => copy.id), ['owned']); + other.error = null; + failAlias = true; + now = now.add(CatalogLibraryMatcher.negativeTtl); + final partial = await harness.matcher.match(item); + expect(partial.items.map((copy) => copy.id), ['owned']); + expect(partial.failedServerIds, {client.serverId.value}); + expect(partial.succeededServerIds, {'offline'}); + + failAlias = false; + hasCopy = false; + now = now.add(CatalogLibraryMatcher.negativeTtl); + final absent = await harness.matcher.match(item); + expect(absent.items, isEmpty); + expect(absent.failedServerIds, isEmpty); + expect(absent.succeededServerIds, {client.serverId.value, 'offline'}); + }); }); test('a detail load that adds the native title is not served the bare form\'s cached negative', () async { diff --git a/test/services/catalog/mal_catalog_source_test.dart b/test/services/catalog/mal_catalog_source_test.dart index 11dd7779b..9bbcad6d5 100644 --- a/test/services/catalog/mal_catalog_source_test.dart +++ b/test/services/catalog/mal_catalog_source_test.dart @@ -1,19 +1,136 @@ 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_backend.dart'; import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/models/catalog/catalog_item.dart'; import 'package:plezy/models/catalog/catalog_metadata.dart'; import 'package:plezy/models/trackers/fribb_mapping_row.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/services/catalog/catalog_library_matcher.dart'; import 'package:plezy/services/catalog/catalog_source.dart'; import 'package:plezy/services/catalog/mal_catalog_source.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/trackers/fribb_mapping_store.dart'; import 'package:plezy/services/trackers/mal/mal_client.dart'; import 'package:plezy/services/trackers/tracker_session.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; charset=utf-8'}); + +/// Exercise real catalog matching and ID verification, replacing only HTTP. +Future _expectNativeLibraryMatch( + MediaBackend backend, + CatalogItem item, { + required String nativeQuery, + required String storedTitle, + required int tmdbId, + int? availableSeason, +}) async { + final searches = []; + Future respond(http.Request request) async { + final uri = request.url; + final isPlex = backend == MediaBackend.plex; + if (uri.path == '/Items' && uri.queryParameters.containsKey('ParentId')) { + return _json({'Items': [], 'TotalRecordCount': 0}); + } + if (uri.path == '/hubs/search' || uri.path == '/Items') { + searches.add(uri); + final query = uri.queryParameters[isPlex ? 'query' : 'SearchTerm']; + final rows = [ + if (query == nativeQuery) + for (final id in ['owned', 'wrong-id', if (availableSeason != null) 'no-sequel']) + if (isPlex) + { + 'ratingKey': id, + 'type': item.kind.id, + 'title': storedTitle, + 'originalTitle': nativeQuery, + 'librarySectionID': 1, + 'librarySectionTitle': 'Anime', + 'Guid': [ + {'id': 'tmdb://${id == 'wrong-id' ? 999 : tmdbId}'}, + ], + } + else + { + 'Id': id, + 'Type': item.kind == MediaKind.movie ? 'Movie' : 'Series', + 'Name': storedTitle, + 'OriginalTitle': nativeQuery, + 'ProviderIds': {'Tmdb': '${id == 'wrong-id' ? 999 : tmdbId}'}, + }, + ]; + return _json( + isPlex + ? { + 'MediaContainer': { + 'Hub': [ + {'type': item.kind.id, 'Metadata': rows}, + ], + }, + } + : {'Items': rows}, + ); + } + if (uri.path == '/library/all') { + return _json({ + 'MediaContainer': { + 'Metadata': [ + if (uri.queryParameters['type'] == '3' && availableSeason != null) + {'ratingKey': 'owned-season', 'parentRatingKey': 'owned', 'type': 'season', 'index': availableSeason}, + ], + }, + }); + } + if (uri.path == '/Shows/owned/Seasons' || uri.path == '/Shows/no-sequel/Seasons') { + return _json({ + 'Items': [ + if (uri.path == '/Shows/owned/Seasons') + {'Id': 'owned-season', 'Type': 'Season', 'IndexNumber': availableSeason}, + ], + }); + } + if (uri.path == '/Items/owned/Ancestors') return _json([]); + fail('Unexpected ${backend.name} request: $uri'); + } + + final MediaServerClient client = switch (backend) { + MediaBackend.plex => testPlexClient(handler: respond), + MediaBackend.jellyfin => testJellyfinClient(handler: respond), + MediaBackend.emby => testEmbyClient(handler: respond), + }; + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final multiServer = MultiServerProvider(manager, DataAggregationService(manager)); + final matcher = CatalogLibraryMatcher(multiServer); + addTearDown(() { + matcher.dispose(); + multiServer.dispose(); + manager.dispose(); + }); + + final result = await matcher.match(item); + expect(result.items.map((copy) => copy.id), ['owned']); + expect(result.items.single.backend, backend); + expect(result.failedServerIds, isEmpty); + expect(result.succeededServerIds, {client.serverId.value}); + expect(searches.length, lessThanOrEqualTo(4), reason: 'logical title searches, excluding GUID/season/ancestor work'); + if (availableSeason != null) { + expect(searches.every((uri) => !uri.queryParameters.containsKey('years')), isTrue); + } +} + TrackerSession _session() { final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; return TrackerSession( @@ -187,8 +304,8 @@ void main() { expect(show.episodeCount, 25); expect(show.votes, 2326268); expect(show.network, 'Wit Studio'); - expect(show.originalTitle, 'Shingeki no Kyojin'); - expect(show.altTitles, ['Shingeki no Kyojin', '進撃の巨人', 'AoT']); + expect(show.originalTitle, '進撃の巨人'); + expect(show.altTitles, contains('Shingeki no Kyojin')); expect(show.broadcastSeason?.name, CatalogSeasonName.spring); expect(show.broadcastSeason?.year, 2013); expect(show.broadcast?.weekday, DateTime.sunday); @@ -208,6 +325,11 @@ void main() { expect(movie.kind, MediaKind.movie); expect(movie.ids.mal, 32281); expect(movie.ids.tmdb, 372058); + expect( + movie.originalTitle, + 'Kimi no Na wa.', + reason: 'the production title remains the fallback without Japanese', + ); expect(movie.posterUrl, 'https://cdn.myanimelist.net/images/anime/32281.jpg'); // finished_airing on a movie is noise, and movies have no episode chip. expect(movie.airStatus, isNull); @@ -221,7 +343,19 @@ void main() { expect(movie.audience, isNull); }); - test('sequel entries preserve alternate-title order and both Fribb season numbers', () async { + test('a blank typed native title falls back to the production title', () async { + handlers.add( + (_) => _json( + _pageBody([_node(id: 32281, title: 'Kimi no Na wa.', en: 'Your Name.', ja: ' ', mediaType: 'movie')]), + ), + ); + + final item = (await source.fetchRow(CatalogRowId.watchlist)).items.single; + expect(item.originalTitle, 'Kimi no Na wa.'); + expect(item.altTitles, contains('Kimi no Na wa.')); + }); + + test('sequel entries retain romaji aliases and both Fribb season numbers', () async { handlers.add( (request) => http.Response( json.encode( @@ -243,10 +377,78 @@ void main() { 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.altTitles, contains('Shingeki no Kyojin Season 3')); expect(item.season, const ExternalSeasonRef(tvdb: 3, tmdb: 2)); }); + group('native library matching', () { + late AppDatabase db; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + }); + + tearDown(() async { + await db.close(); + }); + + for (final backend in MediaBackend.values) { + test('Kimi no Na wa. finds only the ID-verified native-title copy on ${backend.name}', () async { + handlers.add( + (_) => _json(_pageBody([_node(id: 32281, title: 'Kimi no Na wa.', ja: '君の名は。', mediaType: 'movie')])), + ); + final item = (await source.fetchRow(CatalogRowId.watchlist)).items.single; + expect(item.originalTitle, '君の名は。'); + expect(item.altTitles, contains('Kimi no Na wa.')); + + await _expectNativeLibraryMatch( + backend, + item, + nativeQuery: '君の名は。', + storedTitle: 'Your Name.', + tmdbId: 372058, + ); + }); + + test('three sequel title families reach the native parent within budget on ${backend.name}', () async { + source.dispose(); + source = MalCatalogSource( + client, + fribb: _FakeFribb(const [ + FribbMappingRow(malId: 35760, tmdbIds: [1429], tvdbSeason: 3, tmdbSeason: 3), + ]), + ); + handlers.add( + (_) => _json( + _pageBody([ + _node( + id: 35760, + title: 'Shingeki no Kyojin Season 3', + en: 'Attack on Titan Season 3', + ja: '進撃の巨人 Season 3', + synonyms: ['AoT 3', 'Attack on Titan 3rd Season', 'Shingeki no Kyojin 3'], + extra: {'start_date': '2018-07-23'}, + ), + ]), + ), + ); + final item = (await source.fetchRow(CatalogRowId.watchlist)).items.single; + expect(item.altTitles, contains('Shingeki no Kyojin Season 3')); + + await _expectNativeLibraryMatch( + backend, + item, + nativeQuery: '進撃の巨人', + storedTitle: 'Ataque a los titanes', + tmdbId: 1429, + availableSeason: 3, + ); + }); + } + }); + test('ranking sidecars map to the scope implied by each row', () async { handlers.add( (request) => http.Response(