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:
edde746
2026-08-04 16:56:54 +02:00
parent 4872adcde3
commit 58d5d3c4ef
18 changed files with 519 additions and 71 deletions
@@ -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(
+6 -1
View File
@@ -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) {
+22 -26
View File
@@ -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
+30 -7
View File
@@ -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);
}
+43 -4
View File
@@ -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