Files
plezy/lib/services/trackers/tracker_id_resolver.dart
T
edde746 58d5d3c4ef 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
2026-08-04 16:56:54 +02:00

405 lines
15 KiB
Dart

import '../../media/media_item.dart';
import '../../media/media_kind.dart';
import '../../media/media_server_client.dart';
import '../../models/trackers/anime_ids.dart';
import '../../models/trackers/anime_lists_mapping.dart';
import '../../models/trackers/fribb_mapping_row.dart';
import '../../utils/external_ids.dart';
import 'anime_episode_progress_resolver.dart';
import 'anime_lists_mapping_store.dart';
import 'fribb_mapping_store.dart';
import 'future_coalescer.dart';
/// Paired ID output: always-present Plex external IDs (tvdb/imdb/tmdb) plus
/// optional Fribb-sourced anime IDs (mal/anilist/simkl). Simkl uses [external]
/// directly for non-anime titles; MAL/AniList no-op when [anime] is null.
class TrackerIds {
final ExternalIds external;
final AnimeIds? anime;
final AnimeProgressScope? animeProgressScope;
final int? animeProgress;
final AnimeEpisodeMatch? animeEpisodeMatch;
final int? animeEpisodeNumber;
const TrackerIds({
required this.external,
required this.anime,
this.animeProgressScope,
this.animeProgress,
this.animeEpisodeMatch,
this.animeEpisodeNumber,
});
TrackerIds withAnimeProgress(ResolvedAnimeProgress? animeProgress) {
return TrackerIds(
external: external,
anime: anime,
animeProgressScope: animeProgressScope,
animeProgress: animeProgress?.progress,
animeEpisodeMatch: animeEpisodeMatch,
animeEpisodeNumber: animeEpisodeNumber,
);
}
}
/// Resolved IDs for manually rating a media item on external trackers.
///
/// For TV items, [ids.external] is the show-level ID set. Trakt can then rate
/// the show/season/episode through nested season/episode numbers, while MAL,
/// AniList, and Simkl rate the mapped anime/show entry itself.
class TrackerRatingContext {
final TrackerIds ids;
final MediaKind kind;
final int? season;
final int? episodeNumber;
const TrackerRatingContext({required this.ids, required this.kind, this.season, this.episodeNumber});
bool get isMovie => kind == MediaKind.movie;
}
/// Resolves item ids → tracker external IDs. Returns both backend-native
/// external IDs (used by Trakt and by Simkl for non-anime matches) and Fribb
/// anime IDs (used by MAL/AniList, and by Simkl for anime precision).
/// Episodes resolve against the show's GUIDs because Fribb only maps
/// 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.
class TrackerIdResolver {
final MediaServerClient _client;
final FribbMappingLookup _store;
final AnimeListsMappingLookup _animeLists;
final AnimeEpisodeProgressLookup _animeProgress;
final bool Function() _needsFribb;
/// Null entries mean "the server had no IDs" — cached so scrubbing on an
/// un-matched item doesn't re-hit the server every position update.
final Map<String, TrackerIds?> _cache = {};
final KeyedFutureCache<String, ExternalIds> _externalIdLoads = KeyedFutureCache();
TrackerIdResolver(
MediaServerClient client, {
bool Function()? needsFribb,
FribbMappingLookup? store,
AnimeListsMappingLookup? animeLists,
AnimeEpisodeProgressLookup? animeProgress,
}) : _client = client,
_needsFribb = needsFribb ?? _returnTrue,
_store = store ?? FribbMappingStore.instance,
_animeLists = animeLists ?? AnimeListsMappingStore.instance,
_animeProgress = animeProgress ?? AnimeEpisodeProgressResolver(client);
static bool _returnTrue() => true;
/// Fetch external IDs for an item via the neutral
/// [MediaServerClient.fetchExternalIds] surface — Plex hits
/// `/library/metadata/{id}?includeGuids=1`, Jellyfin reads the inline
/// `ProviderIds` map.
Future<ExternalIds> _fetchExternalIds(String itemId) =>
_externalIdLoads.run(itemId, () => _client.fetchExternalIds(itemId));
/// Resolve IDs for a movie.
Future<TrackerIds?> resolveForMovie(String itemId) async {
if (_cache.containsKey(itemId)) return _cache[itemId];
final external = await _fetchExternalIds(itemId);
final ids = await _build(external, isEpisodeSeason: null, episodeNumber: null, isMovie: true);
_cache[itemId] = ids;
return ids;
}
/// Resolve IDs for an episode. Looks up the *show's* external IDs (via
/// `grandparentId`), then disambiguates among candidate Fribb rows using
/// Anime-Lists episode mappings first and season mappings as fallback.
Future<TrackerIds?> resolveShowForEpisode(
MediaItem episode, {
bool includeAnimeProgress = true,
bool includeCurrentEpisode = true,
}) async {
final showId = episode.grandparentId;
if (showId == null || showId.isEmpty) return null;
final season = episode.parentIndex;
final number = episode.index;
// Cache under the full aired episode coordinate. Same-season split-cour
// mappings can point different episode ranges at different anime entries.
final cacheKey = season != null && number != null ? '$showId#s$season#e$number' : showId;
TrackerIds? ids;
if (_cache.containsKey(cacheKey)) {
ids = _cache[cacheKey];
} else {
final external = await _fetchExternalIds(showId);
ids = await _build(external, isEpisodeSeason: season, episodeNumber: number, isMovie: false);
_cache[cacheKey] = ids;
}
final resolvedIds = ids;
if (!includeAnimeProgress || resolvedIds == null || resolvedIds.animeProgressScope == null) return resolvedIds;
final progress = await _animeProgress.resolve(
episode,
scope: resolvedIds.animeProgressScope!,
animeMatch: resolvedIds.animeEpisodeMatch,
episodeMatcher: resolvedIds.animeEpisodeMatch == null
? null
: (item) => _lookupAnimeEpisodeMatch(resolvedIds.external, item),
includeCurrentEpisode: includeCurrentEpisode,
);
return resolvedIds.withAnimeProgress(progress);
}
/// Resolve IDs for manual tracker ratings. Ratings can be attached to a
/// movie, show, season, or episode from the detail screen/context menu.
Future<TrackerRatingContext?> resolveForRating(MediaItem item) async {
switch (item.kind) {
case MediaKind.movie:
final ids = await resolveForMovie(item.id);
return ids == null ? null : TrackerRatingContext(ids: ids, kind: MediaKind.movie);
case MediaKind.show:
final ids = await _resolveShowForRating(item.id);
return ids == null ? null : TrackerRatingContext(ids: ids, kind: MediaKind.show);
case MediaKind.season:
final showId = item.parentId;
final season = item.index ?? item.parentIndex;
if (showId == null || showId.isEmpty || season == null) return null;
final ids = await _resolveShowForRating(showId, season: season);
return ids == null ? null : TrackerRatingContext(ids: ids, kind: MediaKind.season, season: season);
case MediaKind.episode:
final season = item.parentIndex;
final number = item.index;
if (season == null || number == null) return null;
final ids = await resolveShowForEpisode(item, includeAnimeProgress: false);
return ids == null
? null
: TrackerRatingContext(ids: ids, kind: MediaKind.episode, season: season, episodeNumber: number);
default:
return null;
}
}
Future<TrackerIds?> _resolveShowForRating(String showId, {int? season}) async {
if (showId.isEmpty) return null;
final cacheKey = season != null ? '$showId#rating-s$season' : '$showId#rating';
if (_cache.containsKey(cacheKey)) return _cache[cacheKey];
final external = await _fetchExternalIds(showId);
final ids = await _buildShowRating(external, season: season);
_cache[cacheKey] = ids;
return ids;
}
void clearCache() {
_cache.clear();
_externalIdLoads.clear();
_animeProgress.clearCache();
}
Future<TrackerIds?> _build(
ExternalIds external, {
int? isEpisodeSeason,
int? episodeNumber,
required bool isMovie,
}) async {
if (!external.hasAny) return null;
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);
final row = isMovie ? _pickMovieRow(rows) : _pickShowRow(rows, season: isEpisodeSeason, animeMatch: animeMatch);
final anime = row == null ? null : AnimeIds.fromFribb(row);
return TrackerIds(
external: external,
anime: anime,
animeProgressScope: _animeProgressScope(
selected: row,
rows: rows,
season: isEpisodeSeason,
isMovie: isMovie,
animeMatch: animeMatch,
),
animeEpisodeMatch: animeMatch,
animeEpisodeNumber: animeMatch?.anidbEpisode,
);
}
/// 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 _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
? await _lookupAnimeIdsForShow(external)
: await _lookupAnimeIdsForSeason(external, season);
if (animeIds.length == 1) {
row = _rowForAnidb(rows, animeIds.single);
} else if (animeIds.isEmpty) {
row = _pickShowRow(rows, season: season, animeMatch: null);
}
return TrackerIds(external: external, anime: row == null ? null : AnimeIds.fromFribb(row));
}
Future<AnimeEpisodeMatch?> _lookupAnimeEpisodeMatch(ExternalIds external, MediaItem episode) async {
final season = episode.parentIndex;
final number = episode.index;
if (season == null || number == null) return null;
return _lookupAnimeEpisodeMatchByCoordinate(external, season, number);
}
Future<AnimeEpisodeMatch?> _lookupAnimeEpisodeMatchByCoordinate(
ExternalIds external,
int season,
int episodeNumber,
) async {
try {
return await _animeLists.lookupEpisode(
tvdbId: external.tvdb,
tmdbId: external.tmdb,
season: season,
episodeNumber: episodeNumber,
);
} catch (_) {
return null;
}
}
Future<Set<int>> _lookupAnimeIdsForSeason(ExternalIds external, int season) async {
try {
return await _animeLists.lookupAnimeIdsForSeason(tvdbId: external.tvdb, tmdbId: external.tmdb, season: season);
} catch (_) {
return const <int>{};
}
}
Future<Set<int>> _lookupAnimeIdsForShow(ExternalIds external) async {
try {
return await _animeLists.lookupAnimeIdsForShow(tvdbId: external.tvdb, tmdbId: external.tmdb);
} catch (_) {
return const <int>{};
}
}
/// Pick the best row for a movie lookup — prefer rows marked `type: MOVIE`.
FribbMappingRow? _pickMovieRow(List<FribbMappingRow> rows) {
if (rows.isEmpty) return null;
final movies = rows.where((r) => r.isMovie);
if (movies.isNotEmpty) return movies.first;
// Fall back to any row if no explicit MOVIE row matches — some rows have
// no type field.
return rows.first;
}
/// Pick the best row for a show lookup. When Fribb has multiple rows
/// sharing the same show-level external ID (split-cour anime), prefer the
/// one whose `season.tvdb` or `season.tmdb` matches the Plex episode's
/// season; otherwise prefer regular TV/ONA rows.
FribbMappingRow? _pickShowRow(List<FribbMappingRow> rows, {int? season, AnimeEpisodeMatch? animeMatch}) {
if (rows.isEmpty) return null;
final match = animeMatch;
if (match != null) return _rowForAnidb(rows, match.anidbId);
if (season != null) {
for (final row in rows) {
if (row.tvdbSeason == season || row.tmdbSeason == season) return row;
}
}
// No season match — prefer regular TV/ONA rows over movies/OVAs/specials.
for (final row in rows) {
if (_isRegularSeriesRow(row)) return row;
}
// Fall back to the first non-MOVIE row (prefer series-like entries).
for (final row in rows) {
if (!row.isMovie) return row;
}
return rows.first;
}
FribbMappingRow? _rowForAnidb(List<FribbMappingRow> rows, int anidbId) {
for (final row in rows) {
if (row.anidbId == anidbId) return row;
}
return null;
}
AnimeProgressScope? _animeProgressScope({
required FribbMappingRow? selected,
required List<FribbMappingRow> rows,
required int? season,
required bool isMovie,
required AnimeEpisodeMatch? animeMatch,
}) {
if (isMovie) return null;
if (season == null || season <= 0) return null;
if (selected == null) return null;
if (animeMatch != null && selected.anidbId == animeMatch.anidbId) return AnimeProgressScope.mapped;
if (_hasSeasonMapping(selected)) {
final exactSeason = selected.tvdbSeason == season || selected.tmdbSeason == season;
return exactSeason && _isRegularSeriesRow(selected) ? AnimeProgressScope.season : null;
}
final regularRows = rows.where(_isRegularSeriesRow).toList(growable: false);
if (regularRows.length == 1 && identical(regularRows.single, selected)) {
return AnimeProgressScope.show;
}
return null;
}
bool _hasSeasonMapping(FribbMappingRow row) => row.tvdbSeason != null || row.tmdbSeason != null;
bool _isRegularSeriesRow(FribbMappingRow row) {
if (row.isMovie) return false;
if (row.tvdbSeason == 0 || row.tmdbSeason == 0) return false;
return switch (row.type?.toUpperCase()) {
null || 'TV' || 'ONA' || 'UNKNOWN' => true,
_ => false,
};
}
}