From 126e6b9349707253b254bd76b2bc58e92a040ef8 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 13 May 2026 14:10:01 +0200 Subject: [PATCH] fix(trackers): sync scoped anime progress --- lib/models/trackers/tracker_context.dart | 8 + .../trackers/anilist/anilist_tracker.dart | 8 +- .../anime_episode_progress_resolver.dart | 122 ++++++++++++ .../trackers/fribb_mapping_store.dart | 7 +- lib/services/trackers/mal/mal_tracker.dart | 17 +- .../trackers/tracker_coordinator.dart | 2 + .../trackers/tracker_id_resolver.dart | 108 +++++++++-- .../anime_episode_progress_resolver_test.dart | 174 +++++++++++++++++ .../trackers/tracker_id_resolver_test.dart | 182 ++++++++++++++++++ 9 files changed, 600 insertions(+), 28 deletions(-) create mode 100644 lib/services/trackers/anime_episode_progress_resolver.dart create mode 100644 test/services/trackers/anime_episode_progress_resolver_test.dart create mode 100644 test/services/trackers/tracker_id_resolver_test.dart diff --git a/lib/models/trackers/tracker_context.dart b/lib/models/trackers/tracker_context.dart index 8efd0825..3f0081a6 100644 --- a/lib/models/trackers/tracker_context.dart +++ b/lib/models/trackers/tracker_context.dart @@ -15,6 +15,8 @@ class TrackerContext { final bool isMovie; final int? season; final int? episodeNumber; + final int? animeProgress; + final bool animeProgressComplete; /// Plex ratingKey of the item being played. Used only for logging — not /// sent to any tracker. @@ -32,6 +34,8 @@ class TrackerContext { required this.libraryGlobalKey, this.season, this.episodeNumber, + this.animeProgress, + this.animeProgressComplete = false, }); factory TrackerContext.movie({ @@ -56,6 +60,8 @@ class TrackerContext { required String? libraryGlobalKey, required int season, required int episodeNumber, + int? animeProgress, + bool animeProgressComplete = false, }) { return TrackerContext._( external: external, @@ -65,6 +71,8 @@ class TrackerContext { libraryGlobalKey: libraryGlobalKey, season: season, episodeNumber: episodeNumber, + animeProgress: animeProgress, + animeProgressComplete: animeProgressComplete, ); } } diff --git a/lib/services/trackers/anilist/anilist_tracker.dart b/lib/services/trackers/anilist/anilist_tracker.dart index 2f06c3da..e0873f0c 100644 --- a/lib/services/trackers/anilist/anilist_tracker.dart +++ b/lib/services/trackers/anilist/anilist_tracker.dart @@ -43,11 +43,9 @@ class AnilistTracker extends TrackerBase { final anilistId = ctx.anime?.anilist; if (client == null || anilistId == null) return; - // AniList auto-promotes CURRENT → COMPLETED when progress == total, so we - // only need to send CURRENT for episodes. Movies are always a single-unit - // completion. - final progress = ctx.isMovie ? 1 : (ctx.episodeNumber ?? 0); - final status = ctx.isMovie ? 'COMPLETED' : 'CURRENT'; + final progress = ctx.isMovie ? 1 : (ctx.animeProgress ?? ctx.episodeNumber); + if (progress == null || progress <= 0) return; + final status = ctx.isMovie || ctx.animeProgressComplete ? 'COMPLETED' : 'CURRENT'; await client.saveMediaListEntry(mediaId: anilistId, progress: progress, status: status); appLogger.d('AniList: saved entry (anilist=$anilistId, progress=$progress, status=$status)'); diff --git a/lib/services/trackers/anime_episode_progress_resolver.dart b/lib/services/trackers/anime_episode_progress_resolver.dart new file mode 100644 index 00000000..3ac2aefd --- /dev/null +++ b/lib/services/trackers/anime_episode_progress_resolver.dart @@ -0,0 +1,122 @@ +import '../../media/media_item.dart'; +import '../../media/media_kind.dart'; +import '../../media/media_server_client.dart'; +import '../../utils/app_logger.dart'; + +enum AnimeProgressScope { show, season } + +class ResolvedAnimeProgress { + final int progress; + final bool isComplete; + + const ResolvedAnimeProgress({required this.progress, required this.isComplete}); +} + +/// Resolves watched progress in the MAL/AniList anime entry selected by Fribb. +/// +/// The coordinator builds tracker context before the current playback is marked +/// watched, so unwatched current episodes are added to the watched rollup. +abstract interface class AnimeEpisodeProgressLookup { + Future resolve(MediaItem episode, {required AnimeProgressScope scope}); + void clearCache(); +} + +class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup { + final MediaServerClient _client; + final Map?>> _seasonProgressLoads = {}; + + AnimeEpisodeProgressResolver(this._client); + + @override + Future resolve(MediaItem episode, {required AnimeProgressScope scope}) async { + final showId = episode.grandparentId; + final season = episode.parentIndex; + if (showId == null || showId.isEmpty) return null; + if (season == null || season <= 0) return null; + + final progressBySeason = await _seasonProgressFor(showId); + if (progressBySeason == null) return null; + + final currentAlreadyWatched = (episode.viewCount ?? 0) > 0; + return switch (scope) { + AnimeProgressScope.show => _showProgress(progressBySeason, currentAlreadyWatched), + AnimeProgressScope.season => _seasonProgress(progressBySeason[season], currentAlreadyWatched), + }; + } + + Future?> _seasonProgressFor(String showId) async { + final existing = _seasonProgressLoads[showId]; + if (existing != null) return existing; + + final loading = _loadSeasonProgress(showId); + _seasonProgressLoads[showId] = loading; + + final progress = await loading; + if (progress == null) { + final _ = _seasonProgressLoads.remove(showId); + } + return progress; + } + + ResolvedAnimeProgress? _showProgress(Map seasons, bool currentAlreadyWatched) { + if (seasons.isEmpty) return null; + var watched = 0; + var total = 0; + var totalKnown = true; + for (final entry in seasons.entries) { + final season = entry.key; + if (season <= 0) continue; + watched += entry.value.watched; + final count = entry.value.total; + if (count != null && count > 0) { + total += count; + } else { + totalKnown = false; + } + } + final progress = watched + (currentAlreadyWatched ? 0 : 1); + if (progress <= 0) return null; + final isComplete = totalKnown && total > 0 && progress >= total; + return ResolvedAnimeProgress(progress: isComplete && progress > total ? total : progress, isComplete: isComplete); + } + + ResolvedAnimeProgress? _seasonProgress(_SeasonProgress? season, bool currentAlreadyWatched) { + if (season == null) return null; + final progress = season.watched + (currentAlreadyWatched ? 0 : 1); + if (progress <= 0) return null; + final total = season.total; + final isComplete = total != null && total > 0 && progress >= total; + return ResolvedAnimeProgress(progress: isComplete && progress > total ? total : progress, isComplete: isComplete); + } + + Future?> _loadSeasonProgress(String showId) async { + try { + final children = await _client.fetchChildren(showId); + final progress = {}; + for (final item in children) { + if (item.kind != MediaKind.season) continue; + final season = item.index; + if (season == null || season < 0) continue; + final watched = item.viewedLeafCount; + if (watched == null || watched < 0) continue; + final total = item.leafCount ?? item.childCount; + if (progress.containsKey(season)) return null; + progress[season] = _SeasonProgress(total: total, watched: watched); + } + return progress.isEmpty ? null : progress; + } catch (e) { + appLogger.d('Anime progress: failed to load season watched counts for $showId', error: e); + return null; + } + } + + @override + void clearCache() => _seasonProgressLoads.clear(); +} + +class _SeasonProgress { + final int? total; + final int watched; + + const _SeasonProgress({required this.total, required this.watched}); +} diff --git a/lib/services/trackers/fribb_mapping_store.dart b/lib/services/trackers/fribb_mapping_store.dart index 846de1ee..fafc0991 100644 --- a/lib/services/trackers/fribb_mapping_store.dart +++ b/lib/services/trackers/fribb_mapping_store.dart @@ -29,13 +29,17 @@ class FribbIndex { bool get isEmpty => byTvdb.isEmpty && byTmdb.isEmpty && byImdb.isEmpty; } +abstract interface class FribbMappingLookup { + Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}); +} + /// Loads and refreshes the Fribb anime-lists mapping on demand. /// /// On first lookup the ~5 MB JSON is downloaded from jsDelivr and cached to /// the app-support directory. Subsequent lookups read from the cache. Parsing /// runs in a background isolate. [maybeRefresh] does a weekly conditional-GET /// (If-None-Match) to pick up upstream changes. -class FribbMappingStore { +class FribbMappingStore implements FribbMappingLookup { static const String _diskFileName = 'anime-list-mini.json'; static const String _prefsEtagKey = 'fribb_anime_list_etag'; static const String _prefsLastCheckKey = 'fribb_anime_list_last_check'; @@ -127,6 +131,7 @@ class FribbMappingStore { /// Look up rows by Plex external IDs. Returns the first non-empty candidate /// list in preference order: tvdb → tmdb → imdb. + @override Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async { final idx = await _ensureLoaded(); if (tvdbId != null) { diff --git a/lib/services/trackers/mal/mal_tracker.dart b/lib/services/trackers/mal/mal_tracker.dart index de8391fc..a388a0ee 100644 --- a/lib/services/trackers/mal/mal_tracker.dart +++ b/lib/services/trackers/mal/mal_tracker.dart @@ -11,10 +11,8 @@ import 'mal_session.dart'; /// /// MAL is anime-only: no-op when [TrackerContext.anime] is null. /// -/// For split-cour shows Fribb maps each cour to a distinct MAL ID; the -/// episode number sent here is the Plex episode index within the current -/// season, which is usually what MAL expects for the mapped entry. Episode -/// offsets for irregular cuts aren't in the mini mapping — known v1 gap. +/// For anime episodes, MAL receives watched progress in the mapped anime entry +/// when Fribb can define that scope, otherwise local episode progress. class MalTracker extends TrackerBase { static MalTracker? _instance; static MalTracker get instance => _instance ??= MalTracker._(); @@ -54,9 +52,14 @@ class MalTracker extends TrackerBase { final malId = ctx.anime?.mal; if (client == null || malId == null) return; - final fields = ctx.isMovie - ? {'status': 'completed', 'num_watched_episodes': '1'} - : {'status': 'watching', 'num_watched_episodes': '${ctx.episodeNumber}'}; + final Map fields; + if (ctx.isMovie) { + fields = {'status': 'completed', 'num_watched_episodes': '1'}; + } else { + final progress = ctx.animeProgress ?? ctx.episodeNumber; + if (progress == null || progress <= 0) return; + fields = {'status': ctx.animeProgressComplete ? 'completed' : 'watching', 'num_watched_episodes': '$progress'}; + } await client.updateMyListStatus(malId, fields); appLogger.d('MAL: updated list status (mal=$malId, fields=$fields)'); diff --git a/lib/services/trackers/tracker_coordinator.dart b/lib/services/trackers/tracker_coordinator.dart index 079e8522..b8b2a29c 100644 --- a/lib/services/trackers/tracker_coordinator.dart +++ b/lib/services/trackers/tracker_coordinator.dart @@ -160,6 +160,8 @@ class TrackerCoordinator { libraryGlobalKey: libraryKey, season: season, episodeNumber: number, + animeProgress: ids.animeProgress, + animeProgressComplete: ids.animeProgressComplete, ); } } diff --git a/lib/services/trackers/tracker_id_resolver.dart b/lib/services/trackers/tracker_id_resolver.dart index 80725773..64989ac6 100644 --- a/lib/services/trackers/tracker_id_resolver.dart +++ b/lib/services/trackers/tracker_id_resolver.dart @@ -3,6 +3,7 @@ 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 @@ -11,8 +12,27 @@ import 'fribb_mapping_store.dart'; class TrackerIds { final ExternalIds external; final AnimeIds? anime; + final AnimeProgressScope? animeProgressScope; + final int? animeProgress; + final bool animeProgressComplete; - const TrackerIds({required this.external, required this.anime}); + const TrackerIds({ + required this.external, + required this.anime, + this.animeProgressScope, + this.animeProgress, + this.animeProgressComplete = false, + }); + + TrackerIds withAnimeProgress(ResolvedAnimeProgress? animeProgress) { + return TrackerIds( + external: external, + anime: anime, + animeProgressScope: animeProgressScope, + animeProgress: animeProgress?.progress, + animeProgressComplete: animeProgress?.isComplete ?? false, + ); + } } /// Resolves item ids → tracker external IDs. Returns both backend-native @@ -27,16 +47,23 @@ class TrackerIds { /// so those users don't pay the 5.6 MB mapping download they'll never need. class TrackerIdResolver { final MediaServerClient _client; - final FribbMappingStore _store; + 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 _cache = {}; - TrackerIdResolver(this._client, {bool Function()? needsFribb, FribbMappingStore? store}) - : _needsFribb = needsFribb ?? _returnTrue, - _store = store ?? FribbMappingStore.instance; + 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; @@ -67,15 +94,24 @@ class TrackerIdResolver { // 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; - if (_cache.containsKey(cacheKey)) return _cache[cacheKey]; + 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; + } - final external = await _fetchExternalIds(showId); - final ids = await _build(external, isEpisodeSeason: season, isMovie: false); - _cache[cacheKey] = ids; - return 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(); + void clearCache() { + _cache.clear(); + _animeProgress.clearCache(); + } Future _build(ExternalIds external, {int? isEpisodeSeason, required bool isMovie}) async { if (!external.hasAny) return null; @@ -83,7 +119,11 @@ class TrackerIdResolver { 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); + 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`. @@ -98,8 +138,8 @@ class TrackerIdResolver { /// 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` matches the Plex episode's season; otherwise - /// the first non-MOVIE row. + /// one whose `season.tvdb` or `season.tmdb` matches the Plex episode's + /// season; otherwise prefer regular TV/ONA rows. FribbMappingRow? _pickShowRow(List rows, {int? season}) { if (rows.isEmpty) return null; @@ -109,10 +149,48 @@ class TrackerIdResolver { } } - // No season match — fall back to the first non-MOVIE row (prefer series). + // 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 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, + }; + } } diff --git a/test/services/trackers/anime_episode_progress_resolver_test.dart b/test/services/trackers/anime_episode_progress_resolver_test.dart new file mode 100644 index 00000000..ad29ad80 --- /dev/null +++ b/test/services/trackers/anime_episode_progress_resolver_test.dart @@ -0,0 +1,174 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/services/trackers/anime_episode_progress_resolver.dart'; + +class _FakeMediaServerClient implements MediaServerClient { + final Map> childrenByParent; + Object? throwOnFetchChildren; + int fetchChildrenCalls = 0; + + _FakeMediaServerClient(this.childrenByParent); + + @override + Future> fetchChildren(String parentId) async { + fetchChildrenCalls++; + final error = throwOnFetchChildren; + if (error != null) throw error; + return childrenByParent[parentId] ?? const []; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +MediaItem _season(int number, {int? watched, int? total}) => MediaItem( + id: 'season-$number', + backend: MediaBackend.plex, + kind: MediaKind.season, + title: 'Season $number', + index: number, + leafCount: total, + viewedLeafCount: watched, +); + +MediaItem _episode({int season = 2, int number = 6, String showId = 'show-1', int? viewCount}) => MediaItem( + id: 'episode-$season-$number', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode $number', + grandparentId: showId, + parentIndex: season, + index: number, + viewCount: viewCount, +); + +void main() { + group('AnimeEpisodeProgressResolver', () { + test('show scope sums watched counts across regular seasons', () async { + final seasons = [_season(1, watched: 45), _season(2, watched: 10)]; + final resolver = AnimeEpisodeProgressResolver(_FakeMediaServerClient({'show-1': seasons})); + + final result = await resolver.resolve(_episode(), scope: AnimeProgressScope.show); + + expect(result?.progress, 56); + expect(result?.isComplete, isFalse); + }); + + test('show scope ignores specials season', () async { + final resolver = AnimeEpisodeProgressResolver( + _FakeMediaServerClient({ + 'show-1': [_season(0, watched: 999), _season(1, watched: 5)], + }), + ); + + final result = await resolver.resolve(_episode(season: 1, number: 6), scope: AnimeProgressScope.show); + + expect(result?.progress, 6); + expect(result?.isComplete, isFalse); + }); + + test('season scope uses only current season watched count', () async { + final resolver = AnimeEpisodeProgressResolver( + _FakeMediaServerClient({ + 'show-1': [_season(1, watched: 100), _season(2, watched: 5)], + }), + ); + + final result = await resolver.resolve(_episode(season: 2, number: 6), scope: AnimeProgressScope.season); + + expect(result?.progress, 6); + expect(result?.isComplete, isFalse); + }); + + test('season scope marks complete when progress reaches known season total', () async { + final resolver = AnimeEpisodeProgressResolver( + _FakeMediaServerClient({ + 'show-1': [_season(2, watched: 11, total: 12)], + }), + ); + + final result = await resolver.resolve(_episode(season: 2, number: 12), scope: AnimeProgressScope.season); + + expect(result?.progress, 12); + expect(result?.isComplete, isTrue); + }); + + test('show scope marks complete when progress reaches known show total', () async { + final resolver = AnimeEpisodeProgressResolver( + _FakeMediaServerClient({ + 'show-1': [_season(1, watched: 12, total: 12), _season(2, watched: 11, total: 12)], + }), + ); + + final result = await resolver.resolve(_episode(season: 2, number: 12), scope: AnimeProgressScope.show); + + expect(result?.progress, 24); + expect(result?.isComplete, isTrue); + }); + + test('unknown total does not mark complete', () async { + final resolver = AnimeEpisodeProgressResolver( + _FakeMediaServerClient({ + 'show-1': [_season(1, watched: 11)], + }), + ); + + final result = await resolver.resolve(_episode(season: 1, number: 12), scope: AnimeProgressScope.season); + + expect(result?.progress, 12); + expect(result?.isComplete, isFalse); + }); + + test('already watched current episode does not add one', () async { + final resolver = AnimeEpisodeProgressResolver( + _FakeMediaServerClient({ + 'show-1': [_season(1, watched: 5)], + }), + ); + + final result = await resolver.resolve( + _episode(season: 1, number: 5, viewCount: 1), + scope: AnimeProgressScope.season, + ); + + expect(result?.progress, 5); + expect(result?.isComplete, isFalse); + }); + + test('missing viewedLeafCount returns null', () async { + final resolver = AnimeEpisodeProgressResolver( + _FakeMediaServerClient({ + 'show-1': [_season(1, total: 12)], + }), + ); + + final result = await resolver.resolve(_episode(season: 1, number: 1), scope: AnimeProgressScope.season); + + expect(result, isNull); + }); + + test('returns null instead of throwing when season fetch fails', () async { + final client = _FakeMediaServerClient(const {}); + client.throwOnFetchChildren = StateError('offline'); + final resolver = AnimeEpisodeProgressResolver(client); + + final result = await resolver.resolve(_episode(season: 2, number: 1), scope: AnimeProgressScope.show); + + expect(result, isNull); + }); + + test('cache is reused for multiple episodes in the same show', () async { + final client = _FakeMediaServerClient({ + 'show-1': [_season(1, watched: 10), _season(2, watched: 5)], + }); + final resolver = AnimeEpisodeProgressResolver(client); + + expect((await resolver.resolve(_episode(season: 2, number: 6), scope: AnimeProgressScope.show))?.progress, 16); + expect((await resolver.resolve(_episode(season: 2, number: 7), scope: AnimeProgressScope.show))?.progress, 16); + expect(client.fetchChildrenCalls, 1); + }); + }); +} diff --git a/test/services/trackers/tracker_id_resolver_test.dart b/test/services/trackers/tracker_id_resolver_test.dart new file mode 100644 index 00000000..5095da98 --- /dev/null +++ b/test/services/trackers/tracker_id_resolver_test.dart @@ -0,0 +1,182 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/models/trackers/fribb_mapping_row.dart'; +import 'package:plezy/services/trackers/anime_episode_progress_resolver.dart'; +import 'package:plezy/services/trackers/fribb_mapping_store.dart'; +import 'package:plezy/services/trackers/tracker_id_resolver.dart'; +import 'package:plezy/utils/external_ids.dart'; + +class _FakeMediaServerClient implements MediaServerClient { + final Map externalIdsByItem; + final List externalIdCalls = []; + + _FakeMediaServerClient(this.externalIdsByItem); + + @override + Future fetchExternalIds(String itemId) async { + externalIdCalls.add(itemId); + return externalIdsByItem[itemId] ?? const ExternalIds(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeFribbLookup implements FribbMappingLookup { + final List rows; + int lookups = 0; + + _FakeFribbLookup(this.rows); + + @override + Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async { + lookups++; + return rows; + } +} + +class _FakeAnimeProgressLookup implements AnimeEpisodeProgressLookup { + ResolvedAnimeProgress? result; + int resolveCalls = 0; + int clearCalls = 0; + MediaItem? lastEpisode; + AnimeProgressScope? lastScope; + + _FakeAnimeProgressLookup(int? progress, {bool isComplete = false}) + : result = progress == null ? null : ResolvedAnimeProgress(progress: progress, isComplete: isComplete); + + @override + Future resolve(MediaItem episode, {required AnimeProgressScope scope}) async { + resolveCalls++; + lastEpisode = episode; + lastScope = scope; + return result; + } + + @override + void clearCache() { + clearCalls++; + } +} + +MediaItem _episode({int season = 23, int number = 6}) => MediaItem( + id: 'episode-$season-$number', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode $number', + grandparentId: 'show-1', + parentIndex: season, + index: number, +); + +TrackerIdResolver _resolver({ + required List rows, + required _FakeAnimeProgressLookup animeProgress, + _FakeFribbLookup? lookup, +}) { + return TrackerIdResolver( + _FakeMediaServerClient({'show-1': const ExternalIds(tvdb: 81797, tmdb: 37854, imdb: 'tt0388629')}), + store: lookup ?? _FakeFribbLookup(rows), + animeProgress: animeProgress, + ); +} + +void main() { + group('TrackerIdResolver anime progress', () { + test('one unseasoned regular TV row uses show-scope progress', () async { + final animeProgress = _FakeAnimeProgressLookup(6); + final resolver = _resolver( + animeProgress: animeProgress, + rows: const [ + FribbMappingRow(tvdbId: 81797, tmdbId: 37854, imdbId: 'tt0388629', malId: 21, anilistId: 21, type: 'TV'), + ], + ); + + final ids = await resolver.resolveShowForEpisode(_episode()); + + expect(ids?.anime?.mal, 21); + expect(ids?.animeProgressScope, AnimeProgressScope.show); + expect(ids?.animeProgress, 6); + expect(ids?.animeProgressComplete, isFalse); + expect(animeProgress.resolveCalls, 1); + expect(animeProgress.lastEpisode?.id, 'episode-23-6'); + expect(animeProgress.lastScope, AnimeProgressScope.show); + }); + + test('exact season-scoped row uses season-scope progress', () async { + final animeProgress = _FakeAnimeProgressLookup(18, isComplete: true); + final resolver = _resolver( + animeProgress: animeProgress, + rows: const [ + FribbMappingRow(tvdbId: 81797, malId: 100, tvdbSeason: 1, type: 'TV'), + FribbMappingRow(tvdbId: 81797, malId: 200, tvdbSeason: 2, type: 'TV'), + ], + ); + + final ids = await resolver.resolveShowForEpisode(_episode(season: 2)); + + expect(ids?.anime?.mal, 200); + expect(ids?.animeProgressScope, AnimeProgressScope.season); + expect(ids?.animeProgress, 18); + expect(ids?.animeProgressComplete, isTrue); + expect(animeProgress.resolveCalls, 1); + expect(animeProgress.lastScope, AnimeProgressScope.season); + }); + + test('does not guess when multiple regular rows are unseasoned', () async { + final animeProgress = _FakeAnimeProgressLookup(1061); + final resolver = _resolver( + animeProgress: animeProgress, + rows: const [ + FribbMappingRow(tvdbId: 81797, malId: 1, type: 'TV'), + FribbMappingRow(tvdbId: 81797, malId: 2, type: 'ONA'), + ], + ); + + final ids = await resolver.resolveShowForEpisode(_episode()); + + expect(ids?.anime?.mal, 1); + expect(ids?.animeProgressScope, isNull); + expect(ids?.animeProgress, isNull); + expect(animeProgress.resolveCalls, 0); + }); + + test('movie and special rows do not make a regular TV row ambiguous', () async { + final animeProgress = _FakeAnimeProgressLookup(1061); + final resolver = _resolver( + animeProgress: animeProgress, + rows: const [ + FribbMappingRow(tvdbId: 81797, malId: 21, type: 'TV'), + FribbMappingRow(tvdbId: 81797, malId: 459, tvdbSeason: 0, type: 'MOVIE'), + FribbMappingRow(tvdbId: 81797, malId: 466, tvdbSeason: 0, type: 'OVA'), + FribbMappingRow(tvdbId: 81797, malId: 492, tvdbSeason: 0, type: 'SPECIAL'), + ], + ); + + final ids = await resolver.resolveShowForEpisode(_episode()); + + expect(ids?.anime?.mal, 21); + expect(ids?.animeProgressScope, AnimeProgressScope.show); + expect(ids?.animeProgress, 1061); + expect(animeProgress.resolveCalls, 1); + expect(animeProgress.lastScope, AnimeProgressScope.show); + }); + + test('clearCache clears ID and anime progress caches', () async { + final animeProgress = _FakeAnimeProgressLookup(1061); + final lookup = _FakeFribbLookup(const [FribbMappingRow(tvdbId: 81797, malId: 21, type: 'TV')]); + final resolver = _resolver(rows: lookup.rows, lookup: lookup, animeProgress: animeProgress); + + await resolver.resolveShowForEpisode(_episode()); + resolver.clearCache(); + await resolver.resolveShowForEpisode(_episode(number: 7)); + + expect(lookup.lookups, 2); + expect(animeProgress.clearCalls, 1); + expect(animeProgress.resolveCalls, 2); + }); + }); +}