diff --git a/lib/services/catalog/anilist_catalog_source.dart b/lib/services/catalog/anilist_catalog_source.dart index 7ebe5638..872a886f 100644 --- a/lib/services/catalog/anilist_catalog_source.dart +++ b/lib/services/catalog/anilist_catalog_source.dart @@ -364,7 +364,12 @@ class AnilistCatalogSource with CatalogWatchlistMachinery implements CatalogSour @override Future resolveItemIds(MediaKind kind, ExternalIds external) async { if (!external.hasAny) return null; - final rows = await _fribb.lookup(tvdbId: external.tvdb, tmdbId: external.tmdb, imdbId: external.imdb); + final rows = await _fribb.lookup( + anidbId: external.anidb, + tvdbId: external.tvdb, + tmdbId: external.tmdb, + imdbId: external.imdb, + ); final row = _pickRow(kind, rows); if (row?.anilistId == null) return null; return CatalogItemIds( diff --git a/lib/services/catalog/mal_catalog_source.dart b/lib/services/catalog/mal_catalog_source.dart index 3e47eb00..0c5b1ca7 100644 --- a/lib/services/catalog/mal_catalog_source.dart +++ b/lib/services/catalog/mal_catalog_source.dart @@ -368,7 +368,12 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource { @override Future resolveItemIds(MediaKind kind, ExternalIds external) async { if (!external.hasAny) return null; - final rows = await _fribb.lookup(tvdbId: external.tvdb, tmdbId: external.tmdb, imdbId: external.imdb); + final rows = await _fribb.lookup( + anidbId: external.anidb, + tvdbId: external.tvdb, + tmdbId: external.tmdb, + imdbId: external.imdb, + ); final malId = _pickRow(kind, rows)?.malId; if (malId == null) return null; return CatalogItemIds(mal: malId, imdb: external.imdb, tmdb: external.tmdb, tvdb: external.tvdb); diff --git a/lib/services/catalog/plex_catalog_source.dart b/lib/services/catalog/plex_catalog_source.dart index 3c0b5d2e..3952516d 100644 --- a/lib/services/catalog/plex_catalog_source.dart +++ b/lib/services/catalog/plex_catalog_source.dart @@ -103,7 +103,9 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource, @override Future resolveItemIds(MediaKind kind, ExternalIds external) async { - if (!external.hasAny) return null; + // Plex Discover matches on imdb/tmdb/tvdb only; an AniDB-only item has + // nothing to send it. + if (!external.hasCatalogIds) return null; final metadata = await _client.match(external); final matchedKind = metadata == null ? null : _kindFor(metadata['type']); if (metadata == null || matchedKind != kind) return null; diff --git a/lib/services/catalog/simkl_catalog_source.dart b/lib/services/catalog/simkl_catalog_source.dart index 04404c51..905f77d9 100644 --- a/lib/services/catalog/simkl_catalog_source.dart +++ b/lib/services/catalog/simkl_catalog_source.dart @@ -565,7 +565,7 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource @override Future resolveItemIds(MediaKind kind, ExternalIds external) async => - external.hasAny ? CatalogItemIds.fromExternal(external) : null; + external.hasCatalogIds ? CatalogItemIds.fromExternal(external) : null; @override Future fetchWatchlistKeyPage(int page, int limit) async { diff --git a/lib/services/catalog/trakt_catalog_source.dart b/lib/services/catalog/trakt_catalog_source.dart index e76e6572..765bd22f 100644 --- a/lib/services/catalog/trakt_catalog_source.dart +++ b/lib/services/catalog/trakt_catalog_source.dart @@ -117,7 +117,7 @@ class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource @override Future resolveItemIds(MediaKind kind, ExternalIds external) async => - external.hasAny ? CatalogItemIds.fromExternal(external) : null; + external.hasCatalogIds ? CatalogItemIds.fromExternal(external) : null; @override Future fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async { diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 8f6847c3..7988a84d 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -406,6 +406,8 @@ class DataAggregationService { if (tmdb != null) keys.add('$scope:tmdb:$tmdb'); final tvdb = externalIds.tvdb; if (tvdb != null) keys.add('$scope:tvdb:$tvdb'); + final anidb = externalIds.anidb; + if (anidb != null) keys.add('$scope:anidb:$anidb'); } String? _stableMediaGuid(String? guid) { diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 9491e252..ef60a7f4 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1930,30 +1930,6 @@ class PlexClient return _getFirstMetadataJsonFromData(data); } - /// Fetch the raw `Guid` array for a metadata item (`includeGuids=1`). - /// - /// Returns the list of `{id: 'imdb://tt...'}` maps as Plex returns them, or - /// an empty list if the item has no external IDs / can't be fetched. - /// Used by the Trakt integration to match Plex items against Trakt's catalog. - Future> fetchExternalGuids(String ratingKey) async { - try { - final response = await _getWithFailover('/library/metadata/$ratingKey', queryParameters: {'includeGuids': 1}); - final data = response.data; - if (data is! Map) return const []; - final container = data['MediaContainer'] as Map?; - final metadata = container?['Metadata']; - if (metadata is! List || metadata.isEmpty) return const []; - final first = metadata.first; - if (first is! Map) return const []; - final guids = first['Guid']; - if (guids is List) return guids; - return const []; - } catch (e) { - appLogger.d('fetchExternalGuids failed for $ratingKey', error: e); - return const []; - } - } - /// Mark media as watched (transport only — see [MediaServerClient.markWatched]). Future markAsWatched(String ratingKey) async { await _getWithFailover( @@ -3871,10 +3847,30 @@ class PlexClient @override Map get streamHeaders => Map.unmodifiable(config.headers); + /// Reads both guid shapes Plex can answer with. The `Guid` array only exists + /// for items matched by the Plex Movie / Plex TV Series agents; a library + /// still on a legacy agent (HAMA, `com.plexapp.agents.thetvdb`, ...) carries + /// its ids in the scalar `guid` instead, so reading only the array left every + /// tracker, watchlist and dedupe path blind to those libraries (#1788). + /// + /// The array wins per field; the scalar only fills what it left null. @override Future fetchExternalIds(String itemId) async { - final guids = await fetchExternalGuids(itemId); - return ExternalIds.fromGuids(guids); + try { + final response = await _getWithFailover('/library/metadata/$itemId', queryParameters: {'includeGuids': 1}); + final data = response.data; + if (data is! Map) return const ExternalIds(); + final metadata = (data['MediaContainer'] as Map?)?['Metadata']; + if (metadata is! List || metadata.isEmpty) return const ExternalIds(); + final first = metadata.first; + if (first is! Map) return const ExternalIds(); + final guids = first['Guid']; + final modern = guids is List ? ExternalIds.fromGuids(guids) : const ExternalIds(); + return modern.fillFrom(ExternalIds.fromLegacyPlexGuid(first['guid'])); + } catch (e) { + appLogger.d('fetchExternalIds failed for $itemId', error: e); + return const ExternalIds(); + } } /// Map id-verified candidates to items, dropping any sequel the server does diff --git a/lib/services/trackers/fribb_mapping_store.dart b/lib/services/trackers/fribb_mapping_store.dart index 5653e088..2a7ad889 100644 --- a/lib/services/trackers/fribb_mapping_store.dart +++ b/lib/services/trackers/fribb_mapping_store.dart @@ -21,17 +21,29 @@ class FribbIndex implements RemoteIndex { /// MAL entry can be matched back to library external ids. final Map byMal; - const FribbIndex({required this.byTvdb, required this.byTmdb, required this.byImdb, this.byMal = const {}}); + /// AniDB id → its (single) row. AniDB is the dataset's own primary key, so + /// unlike the three catalog indexes this one never resolves to a list. + /// Plex's HAMA agent identifies anime by AniDB id and nothing else, which is + /// the only way a library item reaches the mapping through it (#1788). + final Map byAnidb; + + const FribbIndex({ + required this.byTvdb, + required this.byTmdb, + required this.byImdb, + this.byMal = const {}, + this.byAnidb = const {}, + }); @override - bool get isEmpty => byTvdb.isEmpty && byTmdb.isEmpty && byImdb.isEmpty && byMal.isEmpty; + bool get isEmpty => byTvdb.isEmpty && byTmdb.isEmpty && byImdb.isEmpty && byMal.isEmpty && byAnidb.isEmpty; @override String get logSummary => '${byTvdb.length} tvdb entries'; } abstract interface class FribbMappingLookup { - Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}); + Future> lookup({int? anidbId, int? tvdbId, int? tmdbId, String? imdbId}); Future lookupByMal(int malId); } @@ -56,11 +68,19 @@ class FribbMappingStore extends EtagCachedRemoteStore implements Fri static final FribbMappingStore instance = FribbMappingStore._(); - /// Look up rows by Plex external IDs. Returns the first non-empty candidate - /// list in preference order: tvdb → tmdb → imdb. + /// Look up rows by a library item's external IDs. Returns the first non-empty + /// candidate list in preference order: anidb → tvdb → tmdb → imdb. + /// + /// AniDB leads because it is the dataset's primary key: it names exactly one + /// entry, where a tvdb/tmdb/imdb hit can be a whole split-cour show the + /// caller still has to disambiguate. @override - Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async { + Future> lookup({int? anidbId, int? tvdbId, int? tmdbId, String? imdbId}) async { final idx = await ensureLoaded(); + if (anidbId != null) { + final hit = idx.byAnidb[anidbId]; + if (hit != null) return [hit]; + } if (tvdbId != null) { final hits = idx.byTvdb[tvdbId]; if (hits != null && hits.isNotEmpty) return hits; @@ -102,6 +122,7 @@ FribbIndex parseFribbIndex(String raw) { final byTmdb = >{}; final byImdb = >{}; final byMal = {}; + final byAnidb = {}; var skipped = 0; for (final raw in decoded) { @@ -126,8 +147,10 @@ FribbIndex parseFribbIndex(String raw) { } final mal = row.malId; if (mal != null) byMal.putIfAbsent(mal, () => row); + final anidb = row.anidbId; + if (anidb != null) byAnidb.putIfAbsent(anidb, () => row); } if (skipped > 0) appLogger.w('Fribb: skipped $skipped malformed row(s)'); - return FribbIndex(byTvdb: byTvdb, byTmdb: byTmdb, byImdb: byImdb, byMal: byMal); + return FribbIndex(byTvdb: byTvdb, byTmdb: byTmdb, byImdb: byImdb, byMal: byMal, byAnidb: byAnidb); } diff --git a/lib/services/trackers/tracker_id_resolver.dart b/lib/services/trackers/tracker_id_resolver.dart index 4a271939..d080a5a9 100644 --- a/lib/services/trackers/tracker_id_resolver.dart +++ b/lib/services/trackers/tracker_id_resolver.dart @@ -65,6 +65,12 @@ class TrackerRatingContext { /// show-level external IDs. Anime-Lists XML is used when available to /// disambiguate same-season split-cour episode ranges by AniDB id. /// +/// A HAMA-matched Plex library supplies an AniDB id and nothing else. That is +/// Fribb's own primary key, so it resolves an anime directly — but Anime-Lists +/// maps tvdb/tmdb episode coordinates onto AniDB rather than the reverse, so +/// those items get no episode mapping and fall back to the show's own +/// numbering, which is what HAMA's plain `anidb` mode already uses. +/// /// The Fribb lookup is skipped when [needsFribb] returns false — set this way /// for Trakt (which never uses anime IDs) and for a Simkl-only configuration, /// so those users don't pay the 5.6 MB mapping download they'll never need. @@ -203,8 +209,14 @@ class TrackerIdResolver { required bool isMovie, }) async { if (!external.hasAny) return null; - if (!_needsFribb()) return TrackerIds(external: external, anime: null); - final rows = await _store.lookup(tvdbId: external.tvdb, tmdbId: external.tmdb, imdbId: external.imdb); + if (!_needsFribb()) return _withoutAnimeMapping(external); + final anidbId = _usableAnidbId(external, season: isMovie ? null : isEpisodeSeason); + final rows = await _store.lookup( + anidbId: anidbId, + tvdbId: external.tvdb, + tmdbId: external.tmdb, + imdbId: external.imdb, + ); final animeMatch = isMovie || isEpisodeSeason == null || episodeNumber == null ? null : await _lookupAnimeEpisodeMatchByCoordinate(external, isEpisodeSeason, episodeNumber); @@ -225,10 +237,37 @@ class TrackerIdResolver { ); } + /// The id set with no Fribb mapping attached, for trackers that never use one. + /// + /// Null for an AniDB-only item: Trakt and Simkl are the only trackers that + /// report `needsFribb == false`, and neither speaks AniDB, so handing them a + /// context they cannot address would trade the "no external IDs" log for a + /// silent no-op further down. + TrackerIds? _withoutAnimeMapping(ExternalIds external) => + external.hasCatalogIds ? TrackerIds(external: external, anime: null) : null; + + /// The AniDB id to look Fribb up by, or null when it cannot be trusted. + /// + /// Only Plex's HAMA agent supplies one, and only in its plain `anidb` mode: + /// one AniDB entry per Plex show, its episodes in season 1 and its specials + /// in season 0. A higher season means the library is numbered by TVDB + /// instead — HAMA warns about that itself — so the guid's entry does not + /// describe what is playing and the tvdb/tmdb/imdb ladder must handle it. + int? _usableAnidbId(ExternalIds external, {required int? season}) { + final anidb = external.anidb; + if (anidb == null) return null; + return season == null || season == 1 ? anidb : null; + } + Future _buildShowRating(ExternalIds external, {int? season}) async { if (!external.hasAny) return null; - if (!_needsFribb()) return TrackerIds(external: external, anime: null); - final rows = await _store.lookup(tvdbId: external.tvdb, tmdbId: external.tmdb, imdbId: external.imdb); + if (!_needsFribb()) return _withoutAnimeMapping(external); + final rows = await _store.lookup( + anidbId: _usableAnidbId(external, season: season), + tvdbId: external.tvdb, + tmdbId: external.tmdb, + imdbId: external.imdb, + ); FribbMappingRow? row; final animeIds = season == null diff --git a/lib/utils/external_ids.dart b/lib/utils/external_ids.dart index 003f4b8f..25264a0b 100644 --- a/lib/utils/external_ids.dart +++ b/lib/utils/external_ids.dart @@ -44,12 +44,14 @@ class ExternalSeasonRef { int get hashCode => Object.hash(tvdb, tmdb); } -/// External IDs (IMDb / TMDB / TVDB) extracted from a media server's +/// External IDs (IMDb / TMDB / TVDB / AniDB) extracted from a media server's /// metadata. Shared by the Trakt and tracker resolvers. /// /// - **Plex** stores modern IDs in a `Guid` array (`imdb://tt123`, -/// `tmdb://456`, `tvdb://789`) and some legacy agents expose one scalar -/// `guid`. Use [ExternalIds.fromGuids] or [ExternalIds.fromLegacyPlexGuid]. +/// `tmdb://456`, `tvdb://789`) and legacy agents expose one scalar `guid` +/// instead — the `Guid` array only exists for the Plex Movie / Plex TV +/// Series agents. Use [ExternalIds.fromGuids] and [fillFrom] with +/// [ExternalIds.fromLegacyPlexGuid] so both shapes are read. /// - **Jellyfin** stores them inline as a `ProviderIds` map on every /// `BaseItemDto`. Use [ExternalIds.fromJellyfinProviderIds]. class ExternalIds { @@ -57,9 +59,20 @@ class ExternalIds { final int? tmdb; final int? tvdb; - const ExternalIds({this.imdb, this.tmdb, this.tvdb}); + /// AniDB series id, only ever produced by Plex's HAMA agent. Separate from + /// the three catalog ids because almost nothing accepts it: it names a Fribb + /// row (and through it MAL/AniList/Simkl) but Trakt, Plex Discover, Seerr and + /// the Anime-Lists episode mappings are all keyed the other way. + final int? anidb; - bool get hasAny => imdb != null || tmdb != null || tvdb != null; + const ExternalIds({this.imdb, this.tmdb, this.tvdb, this.anidb}); + + bool get hasAny => hasCatalogIds || anidb != null; + + /// The ids a title database can be queried with. Callers that can only search + /// IMDb/TMDB/TVDB gate on this rather than [hasAny], so an AniDB-only item + /// does not send them looking for something they cannot express. + bool get hasCatalogIds => imdb != null || tmdb != null || tvdb != null; /// True when any id form matches [other]. Used to verify reverse-lookup /// candidates (never yields false positives; the two sides may carry @@ -67,7 +80,19 @@ class ExternalIds { bool intersects(ExternalIds other) => (imdb != null && imdb == other.imdb) || (tmdb != null && tmdb == other.tmdb) || - (tvdb != null && tvdb == other.tvdb); + (tvdb != null && tvdb == other.tvdb) || + (anidb != null && anidb == other.anidb); + + /// This set with every absent id taken from [other]. + /// + /// Used to read a Plex item that carries both shapes: the modern `Guid` array + /// wins per field and the legacy scalar `guid` only fills what it left null. + ExternalIds fillFrom(ExternalIds other) => ExternalIds( + imdb: imdb ?? other.imdb, + tmdb: tmdb ?? other.tmdb, + tvdb: tvdb ?? other.tvdb, + anidb: anidb ?? other.anidb, + ); /// Round-trips through the persisted tracker write queue. Absent ids stay /// absent so a re-read yields the same [hasAny]/[intersects] answers. @@ -75,12 +100,14 @@ class ExternalIds { if (imdb != null) 'imdb': imdb, if (tmdb != null) 'tmdb': tmdb, if (tvdb != null) 'tvdb': tvdb, + if (anidb != null) 'anidb': anidb, }; factory ExternalIds.fromJson(Map json) => ExternalIds( imdb: json['imdb'] as String?, tmdb: (json['tmdb'] as num?)?.toInt(), tvdb: (json['tvdb'] as num?)?.toInt(), + anidb: (json['anidb'] as num?)?.toInt(), ); factory ExternalIds.fromGuids(List guids) { @@ -104,9 +131,11 @@ class ExternalIds { /// Build from a legacy Plex item's scalar `guid`. /// - /// Only agent formats that map directly to IMDb, TMDB, or TVDB are - /// recognized. HAMA AniDB identifiers require an external mapping and are - /// deliberately left unsupported here. + /// HAMA composes its guid as `-` over + /// `anidb|anidb2..9|tvdb|tvdb2..9|tmdb|tsdb|imdb`. Only plain `anidb-` is + /// mapped: `anidb2`..`anidb9` are HAMA's grouping modes, where several AniDB + /// entries share one Plex show under TVDB-shaped seasons, so the guid names + /// the root entry only and would mislabel every later season. factory ExternalIds.fromLegacyPlexGuid(Object? guid) { if (guid is! String || guid.isEmpty) return const ExternalIds(); @@ -129,6 +158,9 @@ class ExternalIds { if (source == 'imdb') { return ExternalIds(imdb: _normalizeImdb(id, allowBareDigits: true)); } + if (source == 'anidb') { + return ExternalIds(anidb: _parseNumericId(id)); + } if (source == 'tmdb' || source == 'tsdb') { return ExternalIds(tmdb: _parseNumericId(id)); } diff --git a/test/services/catalog/anilist_catalog_source_test.dart b/test/services/catalog/anilist_catalog_source_test.dart index 9931ea1d..65988d56 100644 --- a/test/services/catalog/anilist_catalog_source_test.dart +++ b/test/services/catalog/anilist_catalog_source_test.dart @@ -37,9 +37,10 @@ class _FakeFribb implements FribbMappingLookup { _FakeFribb(this.rows); @override - Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async => [ + Future> lookup({int? anidbId, int? tvdbId, int? tmdbId, String? imdbId}) async => [ for (final row in rows) - if ((tvdbId != null && row.tvdbId == tvdbId) || + if ((anidbId != null && row.anidbId == anidbId) || + (tvdbId != null && row.tvdbId == tvdbId) || (tmdbId != null && (row.tmdbIds?.contains(tmdbId) ?? false)) || (imdbId != null && (row.imdbIds?.contains(imdbId) ?? false))) row, diff --git a/test/services/catalog/mal_catalog_source_test.dart b/test/services/catalog/mal_catalog_source_test.dart index 64b34846..bd5186e3 100644 --- a/test/services/catalog/mal_catalog_source_test.dart +++ b/test/services/catalog/mal_catalog_source_test.dart @@ -32,9 +32,10 @@ class _FakeFribb implements FribbMappingLookup { _FakeFribb(this.rows); @override - Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async => [ + Future> lookup({int? anidbId, int? tvdbId, int? tmdbId, String? imdbId}) async => [ for (final row in rows) - if ((tvdbId != null && row.tvdbId == tvdbId) || + if ((anidbId != null && row.anidbId == anidbId) || + (tvdbId != null && row.tvdbId == tvdbId) || (tmdbId != null && (row.tmdbIds?.contains(tmdbId) ?? false)) || (imdbId != null && (row.imdbIds?.contains(imdbId) ?? false))) row, diff --git a/test/services/plex_external_ids_fetch_test.dart b/test/services/plex_external_ids_fetch_test.dart new file mode 100644 index 00000000..3b84fce5 --- /dev/null +++ b/test/services/plex_external_ids_fetch_test.dart @@ -0,0 +1,113 @@ +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/services/plex_api_cache.dart'; + +import '../test_helpers/backend_client_fixtures.dart'; + +http.Response _json(Object body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}); + +/// A `/library/metadata/{id}` response carrying whichever guid shapes the +/// server's agent produces. +http.Response _metadata({List? guidArray, Object? scalarGuid}) => _json({ + 'MediaContainer': { + 'Metadata': [ + {'ratingKey': 'show-1', 'type': 'show', 'title': 'Show', 'guid': ?scalarGuid, 'Guid': ?guidArray}, + ], + }, +}); + +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + }); + + tearDown(() async { + await db.close(); + }); + + test('reads the modern Guid array and asks for it', () async { + late Uri requestUri; + final client = testPlexClient( + handler: (request) async { + requestUri = request.url; + return _metadata( + guidArray: [ + {'id': 'imdb://tt12345'}, + {'id': 'tmdb://456'}, + {'id': 'tvdb://789'}, + ], + scalarGuid: 'plex://show/abc', + ); + }, + ); + addTearDown(client.close); + + final ids = await client.fetchExternalIds('show-1'); + + expect(requestUri.path, '/library/metadata/show-1'); + expect(requestUri.queryParameters['includeGuids'], '1'); + expect((ids.imdb, ids.tmdb, ids.tvdb), ('tt12345', 456, 789)); + }); + + // Plex only builds the `Guid` array for the Plex Movie / Plex TV Series + // agents. A library still on a legacy agent answers with the scalar `guid` + // alone, and reading only the array left every tracker blind to it (#1788). + test('falls back to a legacy agent scalar guid', () async { + final client = testPlexClient( + handler: (request) async => _metadata(scalarGuid: 'com.plexapp.agents.thetvdb://315500?lang=en'), + ); + addTearDown(client.close); + + final ids = await client.fetchExternalIds('show-1'); + + expect(ids.tvdb, 315500); + }); + + test('maps a HAMA AniDB guid, which carries no catalog id at all', () async { + final client = testPlexClient( + handler: (request) async => _metadata(scalarGuid: 'com.plexapp.agents.hama://anidb-11905?lang=en'), + ); + addTearDown(client.close); + + final ids = await client.fetchExternalIds('show-1'); + + expect(ids.anidb, 11905); + expect(ids.hasAny, isTrue); + expect(ids.hasCatalogIds, isFalse); + }); + + test('the Guid array wins per field and the scalar fills the rest', () async { + final client = testPlexClient( + handler: (request) async => _metadata( + guidArray: [ + {'id': 'tvdb://789'}, + ], + scalarGuid: 'com.plexapp.agents.hama://tvdb-315500', + ), + ); + addTearDown(client.close); + + final ids = await client.fetchExternalIds('show-1'); + + expect(ids.tvdb, 789); + }); + + test('an unmatched item and a failed request both resolve to no ids', () async { + final unmatched = testPlexClient( + handler: (request) async => _metadata(scalarGuid: 'com.plexapp.agents.none://315500'), + ); + addTearDown(unmatched.close); + expect((await unmatched.fetchExternalIds('show-1')).hasAny, isFalse); + + final failing = testPlexClient(handler: (request) async => http.Response('nope', 500)); + addTearDown(failing.close); + expect((await failing.fetchExternalIds('show-1')).hasAny, isFalse); + }); +} diff --git a/test/services/trackers/fribb_mapping_store_test.dart b/test/services/trackers/fribb_mapping_store_test.dart index 834f7d6c..1b983bd7 100644 --- a/test/services/trackers/fribb_mapping_store_test.dart +++ b/test/services/trackers/fribb_mapping_store_test.dart @@ -53,6 +53,11 @@ void main() { expect(tv.imdbIds, ['tt2']); expect(index.byTmdb[456]!.single, same(tv)); expect(index.byImdb['tt2']!.single, same(tv)); + + // AniDB is the dataset's primary key, so it indexes to a single row — + // the only handle a HAMA-matched Plex library can offer (#1788). + expect(index.byAnidb[7], same(movie)); + expect(index.byAnidb[8], same(tv)); }); test('an unexpected field shape yields null fields, not a whole-parse crash', () { diff --git a/test/services/trackers/tracker_coordinator_manual_test.dart b/test/services/trackers/tracker_coordinator_manual_test.dart index f03c7645..ca027f0e 100644 --- a/test/services/trackers/tracker_coordinator_manual_test.dart +++ b/test/services/trackers/tracker_coordinator_manual_test.dart @@ -66,7 +66,7 @@ class _FakeFribbLookup implements FribbMappingLookup { const _FakeFribbLookup(this.rows); @override - Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async => rows; + Future> lookup({int? anidbId, int? tvdbId, int? tmdbId, String? imdbId}) async => rows; @override Future lookupByMal(int malId) async => rows.where((row) => row.malId == malId).firstOrNull; @@ -111,6 +111,20 @@ MediaItem _episode(int number, {int season = 1}) => testMediaItem( index: number, ); +/// An episode that already knows its show, as playback metadata does — the +/// resolver reads the show's guids through `grandparentId`. +MediaItem _episodeOfShow(int number, {int season = 1}) => testMediaItem( + id: 'episode-$season-$number', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode $number', + serverId: ServerId('server-1'), + libraryId: 'lib-1', + grandparentId: 'show-1', + parentIndex: season, + index: number, +); + MediaItem _show() => testMediaItem( id: 'show-1', backend: MediaBackend.plex, @@ -299,6 +313,72 @@ void main() { expect(anilistSaves, contains(equals({'mediaId': 202, 'progress': 2, 'status': 'COMPLETED'}))); }); + // A HAMA-matched library identifies anime by AniDB id and nothing else, so + // every tracker used to be skipped with "no external IDs" (#1788). + test('a HAMA show identified only by AniDB still reaches MAL and AniList', () async { + await simkl.setEnabled(false); + await mal.setEnabled(true); + await anilist.setEnabled(true); + coordinator.debugUseResolverDependencies( + store: const _FakeFribbLookup([FribbMappingRow(anidbId: 11905, malId: 21, anilistId: 30, type: 'TV')]), + animeLists: const _FakeAnimeListsLookup(), + ); + + final malUpdates = >{}; + final malHttp = MockClient((request) async { + final malId = int.parse(request.url.pathSegments[2]); + if (request.method == 'GET') return http.Response(json.encode({'num_episodes': 12}), 200); + expect(request.method, 'PUT'); + malUpdates[malId] = Uri.splitQueryString(request.body); + return http.Response('{}', 200); + }); + mal.rebindSession(_malSession(), onSessionInvalidated: () {}, httpClient: malHttp); + + final anilistSaves = >[]; + final anilistHttp = MockClient((request) async { + final body = json.decode(request.body) as Map; + final query = body['query'] as String; + if (query.contains('Media(id:')) { + return http.Response( + json.encode({ + 'data': { + 'Media': {'episodes': 12}, + }, + }), + 200, + ); + } + if (query.contains('SaveMediaListEntry')) { + anilistSaves.add((body['variables'] as Map).cast()); + return http.Response( + json.encode({ + 'data': { + 'SaveMediaListEntry': {'id': 1}, + }, + }), + 200, + ); + } + fail('Unexpected AniList query: $query'); + }); + anilist.rebindSession(_anilistSession(), onSessionInvalidated: () {}, httpClient: anilistHttp); + + final client = _FakeMediaServerClient( + externalIdsByItem: {'show-1': const ExternalIds(anidb: 11905)}, + descendantsByParent: const {}, + ); + + await coordinator.markWatched(_episodeOfShow(4), client); + + expect(client.externalIdCalls, ['show-1']); + expect(malUpdates, { + 21: {'status': 'watching', 'num_watched_episodes': '4'}, + }); + expect(anilistSaves, [ + {'mediaId': 30, 'progress': 4, 'status': 'CURRENT'}, + ]); + }); + test('groups manually watched same-season split cours by Anime-Lists ranges', () async { await simkl.setEnabled(false); await mal.setEnabled(true); diff --git a/test/services/trackers/tracker_id_resolver_test.dart b/test/services/trackers/tracker_id_resolver_test.dart index 8caf9ecf..d37f3ecd 100644 --- a/test/services/trackers/tracker_id_resolver_test.dart +++ b/test/services/trackers/tracker_id_resolver_test.dart @@ -31,12 +31,21 @@ class _FakeMediaServerClient implements MediaServerClient { class _FakeFribbLookup implements FribbMappingLookup { final List rows; int lookups = 0; + int? lastAnidbId; _FakeFribbLookup(this.rows); + /// Mirrors the real store: an AniDB id is the dataset's primary key and + /// resolves at most one row, so it short-circuits the tvdb/tmdb/imdb ladder. @override - Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async { + Future> lookup({int? anidbId, int? tvdbId, int? tmdbId, String? imdbId}) async { lookups++; + lastAnidbId = anidbId; + if (anidbId != null) { + final hit = rows.where((row) => row.anidbId == anidbId).firstOrNull; + if (hit != null) return [hit]; + } + if (tvdbId == null && tmdbId == null && imdbId == null) return const []; return rows; } @@ -110,9 +119,12 @@ TrackerIdResolver _resolver({ required _FakeAnimeProgressLookup animeProgress, _FakeFribbLookup? lookup, AnimeListsMappingLookup animeLists = const _FakeAnimeListsLookup(), + ExternalIds showIds = const ExternalIds(tvdb: 81797, tmdb: 37854, imdb: 'tt0388629'), + bool Function()? needsFribb, }) { return TrackerIdResolver( - _FakeMediaServerClient({'show-1': const ExternalIds(tvdb: 81797, tmdb: 37854, imdb: 'tt0388629')}), + _FakeMediaServerClient({'show-1': showIds}), + needsFribb: needsFribb, store: lookup ?? _FakeFribbLookup(rows), animeLists: animeLists, animeProgress: animeProgress, @@ -293,4 +305,97 @@ void main() { expect(lookup.lookups, 2); }); }); + + group('TrackerIdResolver AniDB-only items', () { + const hamaRow = FribbMappingRow(anidbId: 11905, malId: 21, anilistId: 30, simklId: 40, type: 'TV'); + + test('a HAMA show resolves its anime through the AniDB id alone', () async { + final lookup = _FakeFribbLookup(const [hamaRow]); + final resolver = _resolver( + rows: const [hamaRow], + lookup: lookup, + animeProgress: _FakeAnimeProgressLookup(4), + showIds: const ExternalIds(anidb: 11905), + ); + + final ids = await resolver.resolveShowForEpisode(_episode(season: 1, number: 4)); + + expect(lookup.lastAnidbId, 11905); + expect(ids?.anime?.mal, 21); + expect(ids?.anime?.anilist, 30); + expect(ids?.animeProgressScope, AnimeProgressScope.show); + expect(ids?.animeProgress, 4); + }); + + test('an AniDB id does not describe a season beside season 1', () async { + final lookup = _FakeFribbLookup(const [hamaRow]); + final resolver = _resolver( + rows: const [hamaRow], + lookup: lookup, + animeProgress: _FakeAnimeProgressLookup(null), + showIds: const ExternalIds(anidb: 11905), + ); + + final ids = await resolver.resolveShowForEpisode(_episode(season: 2, number: 4)); + + expect(lookup.lastAnidbId, isNull, reason: 'season 2 means the library is TVDB-numbered'); + expect(ids?.anime?.mal, isNull); + }); + + test('a catalog id still wins the ladder when both are present', () async { + final lookup = _FakeFribbLookup(const [ + hamaRow, + FribbMappingRow(anidbId: 222, tvdbId: 81797, malId: 999, type: 'TV'), + ]); + final resolver = _resolver( + rows: const [], + lookup: lookup, + animeProgress: _FakeAnimeProgressLookup(null), + showIds: const ExternalIds(anidb: 11905, tvdb: 81797), + ); + + final ids = await resolver.resolveShowForEpisode(_episode(season: 1, number: 4)); + + expect(ids?.anime?.mal, 21, reason: 'AniDB names exactly one row, so it leads the ladder'); + }); + + test('an item with no ids at all resolves to nothing', () async { + final resolver = _resolver( + rows: const [hamaRow], + animeProgress: _FakeAnimeProgressLookup(null), + showIds: const ExternalIds(), + ); + + expect(await resolver.resolveShowForEpisode(_episode(season: 1, number: 4)), isNull); + }); + + test('trackers that never map anime get nothing from an AniDB-only item', () async { + final resolver = _resolver( + rows: const [hamaRow], + animeProgress: _FakeAnimeProgressLookup(null), + showIds: const ExternalIds(anidb: 11905), + needsFribb: () => false, + ); + + expect( + await resolver.resolveShowForEpisode(_episode(season: 1, number: 4)), + isNull, + reason: 'Trakt and Simkl cannot address an AniDB id', + ); + }); + + test('trackers that never map anime still get a catalog-id context', () async { + final resolver = _resolver( + rows: const [], + animeProgress: _FakeAnimeProgressLookup(null), + showIds: const ExternalIds(tvdb: 81797), + needsFribb: () => false, + ); + + final ids = await resolver.resolveShowForEpisode(_episode(season: 1, number: 4)); + + expect(ids?.external.tvdb, 81797); + expect(ids?.anime, isNull); + }); + }); } diff --git a/test/services/trackers/tracker_write_retry_test.dart b/test/services/trackers/tracker_write_retry_test.dart index 7b72463b..a734c510 100644 --- a/test/services/trackers/tracker_write_retry_test.dart +++ b/test/services/trackers/tracker_write_retry_test.dart @@ -69,7 +69,7 @@ class _FakeFribbLookup implements FribbMappingLookup { /// Filters by tvdb id so distinct shows map to distinct anime entries, which is /// what makes their queued rows distinct. @override - Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async => + Future> lookup({int? anidbId, int? tvdbId, int? tmdbId, String? imdbId}) async => rows.where((row) => tvdbId == null || row.tvdbId == tvdbId).toList(); @override diff --git a/test/utils/external_ids_test.dart b/test/utils/external_ids_test.dart index 6b18c44a..ec5f6daf 100644 --- a/test/utils/external_ids_test.dart +++ b/test/utils/external_ids_test.dart @@ -45,27 +45,34 @@ void main() { }); test('normalizes HAMA GUID modes with direct external IDs', () { - final cases = <({String guid, String? imdb, int? tmdb, int? tvdb})>[ - (guid: 'com.plexapp.agents.hama://tvdb-315500', imdb: null, tmdb: null, tvdb: 315500), - (guid: 'com.plexapp.agents.hama://tvdb2-315500', imdb: null, tmdb: null, tvdb: 315500), - (guid: 'com.plexapp.agents.hama://tvdb9-315500', imdb: null, tmdb: null, tvdb: 315500), - (guid: 'com.plexapp.agents.hama://tmdb-69346', imdb: null, tmdb: 69346, tvdb: null), - (guid: 'com.plexapp.agents.hama://tsdb-69346?lang=en', imdb: null, tmdb: 69346, tvdb: null), - (guid: 'com.plexapp.agents.hama://imdb-6455986', imdb: 'tt6455986', tmdb: null, tvdb: null), - (guid: 'com.plexapp.agents.hama://imdb-tt6455986', imdb: 'tt6455986', tmdb: null, tvdb: null), + final cases = <({String guid, String? imdb, int? tmdb, int? tvdb, int? anidb})>[ + (guid: 'com.plexapp.agents.hama://tvdb-315500', imdb: null, tmdb: null, tvdb: 315500, anidb: null), + (guid: 'com.plexapp.agents.hama://tvdb2-315500', imdb: null, tmdb: null, tvdb: 315500, anidb: null), + (guid: 'com.plexapp.agents.hama://tvdb9-315500', imdb: null, tmdb: null, tvdb: 315500, anidb: null), + (guid: 'com.plexapp.agents.hama://tmdb-69346', imdb: null, tmdb: 69346, tvdb: null, anidb: null), + (guid: 'com.plexapp.agents.hama://tsdb-69346?lang=en', imdb: null, tmdb: 69346, tvdb: null, anidb: null), + (guid: 'com.plexapp.agents.hama://imdb-6455986', imdb: 'tt6455986', tmdb: null, tvdb: null, anidb: null), + (guid: 'com.plexapp.agents.hama://imdb-tt6455986', imdb: 'tt6455986', tmdb: null, tvdb: null, anidb: null), + (guid: 'com.plexapp.agents.hama://anidb-11905?lang=en', imdb: null, tmdb: null, tvdb: null, anidb: 11905), ]; for (final testCase in cases) { final ids = ExternalIds.fromLegacyPlexGuid(testCase.guid); expect( - (imdb: ids.imdb, tmdb: ids.tmdb, tvdb: ids.tvdb), - (imdb: testCase.imdb, tmdb: testCase.tmdb, tvdb: testCase.tvdb), + (imdb: ids.imdb, tmdb: ids.tmdb, tvdb: ids.tvdb, anidb: ids.anidb), + (imdb: testCase.imdb, tmdb: testCase.tmdb, tvdb: testCase.tvdb, anidb: testCase.anidb), reason: testCase.guid, ); } }); - test('rejects unsupported agents, AniDB modes, and malformed IDs', () { + test('an AniDB id is not a catalog id', () { + final ids = ExternalIds.fromLegacyPlexGuid('com.plexapp.agents.hama://anidb-11905'); + expect(ids.hasAny, isTrue); + expect(ids.hasCatalogIds, isFalse, reason: 'IMDb/TMDB/TVDB consumers must not act on an AniDB id'); + }); + + test('rejects unsupported agents, AniDB grouping modes, and malformed IDs', () { final invalid = [ null, 315500, @@ -74,7 +81,12 @@ void main() { 'plex://movie/abc', 'local://315500', 'com.plexapp.agents.none://315500', - 'com.plexapp.agents.hama://anidb-11905', + // anidb2..9 group several AniDB entries under one TVDB-numbered Plex + // show, so the guid's id does not describe the seasons beside it. + 'com.plexapp.agents.hama://anidb2-11905', + 'com.plexapp.agents.hama://anidb9-11905', + 'com.plexapp.agents.hama://anidb-not-a-number', + 'com.plexapp.agents.hama://anidb-', 'com.plexapp.agents.hama://tvdb10-315500', 'com.plexapp.agents.hama://tvdb-not-a-number', 'com.plexapp.agents.hama://tmdb-', @@ -88,6 +100,33 @@ void main() { }); }); + group('ExternalIds.fillFrom', () { + test('keeps its own ids and only fills the ones it is missing', () { + const modern = ExternalIds(tvdb: 315500); + const legacy = ExternalIds(imdb: 'tt6455986', tvdb: 999, anidb: 11905); + + final merged = modern.fillFrom(legacy); + + expect(merged.tvdb, 315500, reason: 'the modern Guid array wins per field'); + expect(merged.imdb, 'tt6455986'); + expect(merged.anidb, 11905); + }); + + test('round-trips every id through JSON', () { + const ids = ExternalIds(imdb: 'tt1', tmdb: 2, tvdb: 3, anidb: 4); + final restored = ExternalIds.fromJson(ids.toJson()); + + expect((restored.imdb, restored.tmdb, restored.tvdb, restored.anidb), ('tt1', 2, 3, 4)); + expect(ExternalIds.fromJson(const ExternalIds().toJson()).hasAny, isFalse); + }); + + test('intersects matches on an AniDB id alone', () { + const hama = ExternalIds(anidb: 11905); + expect(hama.intersects(const ExternalIds(anidb: 11905)), isTrue); + expect(hama.intersects(const ExternalIds(tvdb: 315500)), isFalse); + }); + }); + group('ExternalIds.fromJellyfinProviderIds', () { test('extracts Tmdb/Imdb/Tvdb (case-insensitive)', () { final ids = ExternalIds.fromJellyfinProviderIds({'Tmdb': '12345', 'Imdb': 'tt99999', 'Tvdb': '777'});