Files
plezy/lib/services/trackers/tracker_id_resolver.dart
T

189 lines
7.2 KiB
Dart

import '../../media/media_item.dart';
import '../../media/media_server_client.dart';
import '../../models/trackers/anime_ids.dart';
import '../../models/trackers/fribb_mapping_row.dart';
import '../../utils/external_ids.dart';
import 'anime_episode_progress_resolver.dart';
import 'fribb_mapping_store.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;
const TrackerIds({required this.external, required this.anime, this.animeProgressScope, this.animeProgress});
TrackerIds withAnimeProgress(ResolvedAnimeProgress? animeProgress) {
return TrackerIds(
external: external,
anime: anime,
animeProgressScope: animeProgressScope,
animeProgress: animeProgress?.progress,
);
}
}
/// 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; split-cour disambiguation uses the season
/// number.
///
/// 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 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 = {};
TrackerIdResolver(
MediaServerClient client, {
bool Function()? needsFribb,
FribbMappingLookup? store,
AnimeEpisodeProgressLookup? animeProgress,
}) : _client = client,
_needsFribb = needsFribb ?? _returnTrue,
_store = store ?? FribbMappingStore.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) => _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, 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
/// the episode's season number.
Future<TrackerIds?> resolveShowForEpisode(MediaItem episode) async {
final showId = episode.grandparentId;
if (showId == null || showId.isEmpty) return null;
final season = episode.parentIndex;
// Cache under the (showId, season) pair so a show with multiple Fribb
// rows caches each season separately during a marathon.
final cacheKey = season != null ? '$showId#s$season' : showId;
TrackerIds? ids;
if (_cache.containsKey(cacheKey)) {
ids = _cache[cacheKey];
} else {
final external = await _fetchExternalIds(showId);
ids = await _build(external, isEpisodeSeason: season, isMovie: false);
_cache[cacheKey] = ids;
}
if (ids == null || ids.animeProgressScope == null) return ids;
final progress = await _animeProgress.resolve(episode, scope: ids.animeProgressScope!);
return ids.withAnimeProgress(progress);
}
void clearCache() {
_cache.clear();
_animeProgress.clearCache();
}
Future<TrackerIds?> _build(ExternalIds external, {int? isEpisodeSeason, 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);
final row = isMovie ? _pickMovieRow(rows) : _pickShowRow(rows, season: isEpisodeSeason);
final anime = row == null ? null : AnimeIds.fromFribb(row);
return TrackerIds(
external: external,
anime: anime,
animeProgressScope: _animeProgressScope(selected: row, rows: rows, season: isEpisodeSeason, isMovie: isMovie),
);
}
/// 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}) {
if (rows.isEmpty) return null;
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;
}
AnimeProgressScope? _animeProgressScope({
required FribbMappingRow? selected,
required List<FribbMappingRow> rows,
required int? season,
required bool isMovie,
}) {
if (isMovie) return null;
if (season == null || season <= 0) return null;
if (selected == null) return null;
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,
};
}
}