fix(plex): read external ids from legacy agent and HAMA AniDB guids
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, so `fetchExternalIds` returned nothing for it and every consumer went quiet: trackers logged "no external IDs" and skipped the write, manual ratings showed "Not available", the detail screen dropped its watchlist button, and Continue Watching stopped collapsing duplicate copies. The reverse lookup already read that scalar; only the forward path ignored it. Read both shapes from the one request the method already makes, with the array winning per field and the scalar filling the rest. HAMA identifies anime by AniDB id and nothing else, which no id set could carry. AniDB is the Fribb mapping's own primary key, so it now travels on `ExternalIds` and indexes those rows directly — 7177 of them expose no tvdb/tmdb/imdb at all and were unreachable by any other path. Only plain `anidb-` maps: `anidb2`..`anidb9` group several AniDB entries under one TVDB-numbered show, so the guid names the root entry only. Two guards keep the new id where it means something. It is trusted for season 1, because that mode puts the anime there and its specials in season 0, while a higher season means the library is numbered by TVDB instead. And it resolves nothing for Trakt and Simkl, which never map anime and cannot address an AniDB id, so they keep reporting no ids rather than failing silently further down. `hasCatalogIds` marks the callers that can only speak IMDb/TMDB/TVDB. close #1788
This commit is contained in:
@@ -364,7 +364,12 @@ class AnilistCatalogSource with CatalogWatchlistMachinery implements CatalogSour
|
||||
@override
|
||||
Future<CatalogItemIds?> 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(
|
||||
|
||||
@@ -368,7 +368,12 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
|
||||
@override
|
||||
Future<CatalogItemIds?> 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);
|
||||
|
||||
@@ -103,7 +103,9 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
|
||||
|
||||
@override
|
||||
Future<CatalogItemIds?> 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;
|
||||
|
||||
@@ -565,7 +565,7 @@ class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource
|
||||
|
||||
@override
|
||||
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async =>
|
||||
external.hasAny ? CatalogItemIds.fromExternal(external) : null;
|
||||
external.hasCatalogIds ? CatalogItemIds.fromExternal(external) : null;
|
||||
|
||||
@override
|
||||
Future<WatchlistKeyPage> fetchWatchlistKeyPage(int page, int limit) async {
|
||||
|
||||
@@ -117,7 +117,7 @@ class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource
|
||||
|
||||
@override
|
||||
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async =>
|
||||
external.hasAny ? CatalogItemIds.fromExternal(external) : null;
|
||||
external.hasCatalogIds ? CatalogItemIds.fromExternal(external) : null;
|
||||
|
||||
@override
|
||||
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<List<dynamic>> 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<void> markAsWatched(String ratingKey) async {
|
||||
await _getWithFailover(
|
||||
@@ -3871,10 +3847,30 @@ class PlexClient
|
||||
@override
|
||||
Map<String, String> 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<ExternalIds> 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
|
||||
|
||||
@@ -21,17 +21,29 @@ class FribbIndex implements RemoteIndex {
|
||||
/// MAL entry can be matched back to library external ids.
|
||||
final Map<int, FribbMappingRow> 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<int, FribbMappingRow> 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<List<FribbMappingRow>> lookup({int? tvdbId, int? tmdbId, String? imdbId});
|
||||
Future<List<FribbMappingRow>> lookup({int? anidbId, int? tvdbId, int? tmdbId, String? imdbId});
|
||||
|
||||
Future<FribbMappingRow?> lookupByMal(int malId);
|
||||
}
|
||||
@@ -56,11 +68,19 @@ class FribbMappingStore extends EtagCachedRemoteStore<FribbIndex> 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<List<FribbMappingRow>> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async {
|
||||
Future<List<FribbMappingRow>> 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 = <int, List<FribbMappingRow>>{};
|
||||
final byImdb = <String, List<FribbMappingRow>>{};
|
||||
final byMal = <int, FribbMappingRow>{};
|
||||
final byAnidb = <int, FribbMappingRow>{};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<TrackerIds?> _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
|
||||
|
||||
@@ -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<String, Object?> 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<dynamic> 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 `<source>-<id>` 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));
|
||||
}
|
||||
|
||||
@@ -37,9 +37,10 @@ class _FakeFribb implements FribbMappingLookup {
|
||||
_FakeFribb(this.rows);
|
||||
|
||||
@override
|
||||
Future<List<FribbMappingRow>> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async => [
|
||||
Future<List<FribbMappingRow>> 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,
|
||||
|
||||
@@ -32,9 +32,10 @@ class _FakeFribb implements FribbMappingLookup {
|
||||
_FakeFribb(this.rows);
|
||||
|
||||
@override
|
||||
Future<List<FribbMappingRow>> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async => [
|
||||
Future<List<FribbMappingRow>> 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,
|
||||
|
||||
@@ -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<Object>? 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);
|
||||
});
|
||||
}
|
||||
@@ -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', () {
|
||||
|
||||
@@ -66,7 +66,7 @@ class _FakeFribbLookup implements FribbMappingLookup {
|
||||
const _FakeFribbLookup(this.rows);
|
||||
|
||||
@override
|
||||
Future<List<FribbMappingRow>> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async => rows;
|
||||
Future<List<FribbMappingRow>> lookup({int? anidbId, int? tvdbId, int? tmdbId, String? imdbId}) async => rows;
|
||||
|
||||
@override
|
||||
Future<FribbMappingRow?> 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 = <int, Map<String, String>>{};
|
||||
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 = <Map<String, dynamic>>[];
|
||||
final anilistHttp = MockClient((request) async {
|
||||
final body = json.decode(request.body) as Map<String, dynamic>;
|
||||
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<String, dynamic>());
|
||||
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);
|
||||
|
||||
@@ -31,12 +31,21 @@ class _FakeMediaServerClient implements MediaServerClient {
|
||||
class _FakeFribbLookup implements FribbMappingLookup {
|
||||
final List<FribbMappingRow> 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<List<FribbMappingRow>> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async {
|
||||
Future<List<FribbMappingRow>> 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<List<FribbMappingRow>> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async =>
|
||||
Future<List<FribbMappingRow>> lookup({int? anidbId, int? tvdbId, int? tmdbId, String? imdbId}) async =>
|
||||
rows.where((row) => tvdbId == null || row.tvdbId == tvdbId).toList();
|
||||
|
||||
@override
|
||||
|
||||
@@ -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 = <Object?>[
|
||||
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'});
|
||||
|
||||
Reference in New Issue
Block a user