fix(catalog): retain native titles within the bounded matching budget
Native-title library copies could be missed when only display and romaji titles reached the bounded search budget. Prefer typed Japanese titles, preserve row and detail aliases, and keep matching within the existing four-query budget and external-ID verification rules.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String> lookupTitles(CatalogItem item) => titleMatchCandidates([item.originalTitle, item.title]);
|
||||
static List<String> 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,
|
||||
|
||||
@@ -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: <String>{
|
||||
for (final candidate in <String?>[
|
||||
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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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,
|
||||
});
|
||||
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 = <String>[];
|
||||
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 {
|
||||
|
||||
@@ -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<void> _expectNativeLibraryMatch(
|
||||
MediaBackend backend,
|
||||
CatalogItem item, {
|
||||
required String nativeQuery,
|
||||
required String storedTitle,
|
||||
required int tmdbId,
|
||||
int? availableSeason,
|
||||
}) async {
|
||||
final searches = <Uri>[];
|
||||
Future<http.Response> 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': <Object>[], '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(<Object>[]);
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user