diff --git a/lib/models/trackers/anime_lists_mapping.dart b/lib/models/trackers/anime_lists_mapping.dart new file mode 100644 index 00000000..80ba98dc --- /dev/null +++ b/lib/models/trackers/anime_lists_mapping.dart @@ -0,0 +1,186 @@ +enum AnimeListProvider { tvdb, tmdb } + +enum AnimeListMatchKind { explicit, range, defaultMapping } + +class AnimeListSeasonRef { + final int? number; + final bool isAbsolute; + + const AnimeListSeasonRef.number(this.number) : isAbsolute = false; + const AnimeListSeasonRef.absolute() : number = null, isAbsolute = true; +} + +class AnimeListEntry { + final int anidbId; + final String? name; + final String? rawTvdbId; + final int? tvdbId; + final AnimeListSeasonRef? defaultTvdbSeason; + final int episodeOffset; + final int? tmdbTvId; + final AnimeListSeasonRef? tmdbSeason; + final int tmdbOffset; + final List tmdbMovieIds; + final List imdbIds; + final List mappings; + + const AnimeListEntry({ + required this.anidbId, + this.name, + this.rawTvdbId, + this.tvdbId, + this.defaultTvdbSeason, + this.episodeOffset = 0, + this.tmdbTvId, + this.tmdbSeason, + this.tmdbOffset = 0, + this.tmdbMovieIds = const [], + this.imdbIds = const [], + this.mappings = const [], + }); + + List resolveEpisode({ + required AnimeListProvider provider, + required int externalSeason, + required int externalEpisode, + }) { + final explicit = []; + for (final mapping in mappings) { + if (!mapping.matchesProviderSeason(provider, externalSeason)) continue; + for (final item in mapping.explicit) { + if (item.externalEpisodes.contains(externalEpisode)) { + explicit.add( + AnimeEpisodeMatch( + anidbId: anidbId, + anidbSeason: mapping.anidbSeason, + anidbEpisode: item.anidbEpisode, + provider: provider, + externalSeason: externalSeason, + externalEpisode: externalEpisode, + kind: AnimeListMatchKind.explicit, + ), + ); + } + } + } + if (explicit.isNotEmpty) return explicit; + + final ranges = []; + for (final mapping in mappings) { + if (!mapping.matchesProviderSeason(provider, externalSeason)) continue; + final start = mapping.start; + if (start == null) continue; + final anidbEpisode = externalEpisode - mapping.offset; + if (anidbEpisode < start) continue; + final end = mapping.end; + if (end != null && anidbEpisode > end) continue; + if (anidbEpisode <= 0) continue; + ranges.add( + AnimeEpisodeMatch( + anidbId: anidbId, + anidbSeason: mapping.anidbSeason, + anidbEpisode: anidbEpisode, + provider: provider, + externalSeason: externalSeason, + externalEpisode: externalEpisode, + kind: AnimeListMatchKind.range, + rangeStart: start, + rangeEnd: end, + ), + ); + } + if (ranges.isNotEmpty) return ranges; + + final defaultSeason = switch (provider) { + AnimeListProvider.tvdb => defaultTvdbSeason, + AnimeListProvider.tmdb => tmdbSeason, + }; + final defaultOffset = switch (provider) { + AnimeListProvider.tvdb => episodeOffset, + AnimeListProvider.tmdb => tmdbOffset, + }; + if (defaultSeason == null || defaultSeason.isAbsolute || defaultSeason.number != externalSeason) return const []; + + final anidbEpisode = externalEpisode - defaultOffset; + if (anidbEpisode <= 0) return const []; + return [ + AnimeEpisodeMatch( + anidbId: anidbId, + anidbSeason: 1, + anidbEpisode: anidbEpisode, + provider: provider, + externalSeason: externalSeason, + externalEpisode: externalEpisode, + kind: AnimeListMatchKind.defaultMapping, + ), + ]; + } + + bool mapsSeason({required AnimeListProvider provider, required int externalSeason}) { + final defaultSeason = switch (provider) { + AnimeListProvider.tvdb => defaultTvdbSeason, + AnimeListProvider.tmdb => tmdbSeason, + }; + if (defaultSeason != null && !defaultSeason.isAbsolute && defaultSeason.number == externalSeason) return true; + return mappings.any((mapping) => mapping.matchesProviderSeason(provider, externalSeason)); + } +} + +class AnimeListEpisodeMapping { + final int anidbSeason; + final AnimeListProvider provider; + final int externalSeason; + final int? start; + final int? end; + final int offset; + final List explicit; + + const AnimeListEpisodeMapping({ + required this.anidbSeason, + required this.provider, + required this.externalSeason, + this.start, + this.end, + this.offset = 0, + this.explicit = const [], + }); + + bool matchesProviderSeason(AnimeListProvider provider, int season) => + this.provider == provider && externalSeason == season; +} + +class AnimeListExplicitEpisodeMapping { + final int anidbEpisode; + final List externalEpisodes; + + const AnimeListExplicitEpisodeMapping({required this.anidbEpisode, required this.externalEpisodes}); +} + +class AnimeEpisodeMatch { + final int anidbId; + final int anidbSeason; + final int anidbEpisode; + final AnimeListProvider provider; + final int externalSeason; + final int externalEpisode; + final AnimeListMatchKind kind; + final int? rangeStart; + final int? rangeEnd; + + const AnimeEpisodeMatch({ + required this.anidbId, + required this.anidbSeason, + required this.anidbEpisode, + required this.provider, + required this.externalSeason, + required this.externalEpisode, + required this.kind, + this.rangeStart, + this.rangeEnd, + }); + + bool sameAnimeEntry(AnimeEpisodeMatch other) => anidbId == other.anidbId && anidbSeason == other.anidbSeason; + + bool sameEpisode(AnimeEpisodeMatch other) => + sameAnimeEntry(other) && anidbEpisode == other.anidbEpisode && kind == other.kind; +} diff --git a/lib/models/trackers/fribb_mapping_row.dart b/lib/models/trackers/fribb_mapping_row.dart index 9a2a0d5b..00c9362a 100644 --- a/lib/models/trackers/fribb_mapping_row.dart +++ b/lib/models/trackers/fribb_mapping_row.dart @@ -18,6 +18,8 @@ Object? _readTmdbSeason(Map json, String key) { /// One row from `anime-list-mini.json` (Fribb/anime-lists). @JsonSerializable(createToJson: false) class FribbMappingRow { + @JsonKey(name: 'anidb_id', fromJson: flexibleInt) + final int? anidbId; @JsonKey(name: 'anilist_id', fromJson: flexibleInt) final int? anilistId; @JsonKey(name: 'imdb_id') @@ -43,6 +45,7 @@ class FribbMappingRow { final String? type; const FribbMappingRow({ + this.anidbId, this.anilistId, this.imdbId, this.malId, diff --git a/lib/models/trackers/fribb_mapping_row.g.dart b/lib/models/trackers/fribb_mapping_row.g.dart index 832505e5..89f22fa4 100644 --- a/lib/models/trackers/fribb_mapping_row.g.dart +++ b/lib/models/trackers/fribb_mapping_row.g.dart @@ -6,15 +6,15 @@ part of 'fribb_mapping_row.dart'; // JsonSerializableGenerator // ************************************************************************** -FribbMappingRow _$FribbMappingRowFromJson(Map json) => - FribbMappingRow( - anilistId: flexibleInt(json['anilist_id']), - imdbId: json['imdb_id'] as String?, - malId: flexibleInt(json['mal_id']), - simklId: flexibleInt(json['simkl_id']), - tmdbId: flexibleInt(json['themoviedb_id']), - tvdbId: flexibleInt(json['tvdb_id']), - tvdbSeason: flexibleInt(_readTvdbSeason(json, 'tvdbSeason')), - tmdbSeason: flexibleInt(_readTmdbSeason(json, 'tmdbSeason')), - type: json['type'] as String?, - ); +FribbMappingRow _$FribbMappingRowFromJson(Map json) => FribbMappingRow( + anidbId: flexibleInt(json['anidb_id']), + anilistId: flexibleInt(json['anilist_id']), + imdbId: json['imdb_id'] as String?, + malId: flexibleInt(json['mal_id']), + simklId: flexibleInt(json['simkl_id']), + tmdbId: flexibleInt(json['themoviedb_id']), + tvdbId: flexibleInt(json['tvdb_id']), + tvdbSeason: flexibleInt(_readTvdbSeason(json, 'tvdbSeason')), + tmdbSeason: flexibleInt(_readTmdbSeason(json, 'tmdbSeason')), + type: json['type'] as String?, +); diff --git a/lib/screens/media_detail/action_buttons.dart b/lib/screens/media_detail/action_buttons.dart index 4e9661bb..13ecfea9 100644 --- a/lib/screens/media_detail/action_buttons.dart +++ b/lib/screens/media_detail/action_buttons.dart @@ -202,8 +202,10 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { if (isWatched) { await client.markUnwatched(metadata); + unawaited(TrackerCoordinator.instance.markUnwatched(metadata, client)); } else { await client.markWatched(metadata); + unawaited(TrackerCoordinator.instance.markWatched(metadata, client)); } if (mounted) { _watchStateChanged = true; diff --git a/lib/services/offline_watch_sync_service.dart b/lib/services/offline_watch_sync_service.dart index 9d939633..47e9e0bc 100644 --- a/lib/services/offline_watch_sync_service.dart +++ b/lib/services/offline_watch_sync_service.dart @@ -16,6 +16,7 @@ import '../utils/watch_state_notifier.dart'; import 'multi_server_manager.dart'; import 'plex_client.dart'; import 'settings_service.dart'; +import 'trackers/tracker_coordinator.dart'; /// Service for managing offline watch progress and syncing it back to the /// owning server. Backend-neutral over [MediaServerClient] — Plex actions @@ -570,10 +571,12 @@ class OfflineWatchSyncService extends ChangeNotifier { switch (action.actionType) { case 'watched': await client.markWatched(item); + await TrackerCoordinator.instance.markWatched(item, client); break; case 'unwatched': await client.markUnwatched(item); + await TrackerCoordinator.instance.markUnwatched(item, client); break; case 'progress': @@ -598,6 +601,7 @@ class OfflineWatchSyncService extends ChangeNotifier { // If progress exceeded threshold, also mark as watched. if (action.shouldMarkWatched) { await client.markWatched(item); + await TrackerCoordinator.instance.markWatched(item, client); } break; } diff --git a/lib/services/trackers/anilist/anilist_client.dart b/lib/services/trackers/anilist/anilist_client.dart index 3c241815..e4c5a9c1 100644 --- a/lib/services/trackers/anilist/anilist_client.dart +++ b/lib/services/trackers/anilist/anilist_client.dart @@ -50,6 +50,34 @@ class AnilistClient { await query(mutation, variables: {'mediaId': mediaId, 'progress': progress, 'status': status}); } + Future deleteMediaListEntry(int mediaId) async { + const idQuery = ''' + query(\$mediaId: Int) { + Media(id: \$mediaId, type: ANIME) { + mediaListEntry { + id + } + } + } + '''; + final data = await query(idQuery, variables: {'mediaId': mediaId}); + final media = data['Media']; + if (media is! Map) return; + final entry = media['mediaListEntry']; + if (entry is! Map) return; + final entryId = flexibleInt(entry['id']); + if (entryId == null) return; + + const mutation = ''' + mutation(\$id: Int) { + DeleteMediaListEntry(id: \$id) { + deleted + } + } + '''; + await query(mutation, variables: {'id': entryId}); + } + Future setMediaListScore({required int mediaId, required int score}) async { const mutation = ''' mutation(\$mediaId: Int, \$scoreRaw: Int) { diff --git a/lib/services/trackers/anilist/anilist_tracker.dart b/lib/services/trackers/anilist/anilist_tracker.dart index a101c674..5377cee5 100644 --- a/lib/services/trackers/anilist/anilist_tracker.dart +++ b/lib/services/trackers/anilist/anilist_tracker.dart @@ -64,6 +64,21 @@ class AnilistTracker extends TrackerBase { appLogger.d('AniList: saved entry (anilist=$anilistId, progress=$watched, status=$status)'); } + @override + Future markUnwatched(TrackerContext ctx) async { + if (ctx.isMovie) { + await removeFromList(ctx); + } + } + + Future removeFromList(TrackerContext ctx) async { + final client = _client; + final anilistId = ctx.anime?.anilist; + if (client == null || anilistId == null) return; + await client.deleteMediaListEntry(anilistId); + appLogger.d('AniList: deleted entry (anilist=$anilistId)'); + } + Future rate(TrackerRatingContext ctx, int score) async { final client = _client; final anilistId = ctx.ids.anime?.anilist; diff --git a/lib/services/trackers/anime_episode_progress_resolver.dart b/lib/services/trackers/anime_episode_progress_resolver.dart index 02f08c87..1ae6fca8 100644 --- a/lib/services/trackers/anime_episode_progress_resolver.dart +++ b/lib/services/trackers/anime_episode_progress_resolver.dart @@ -1,9 +1,10 @@ import '../../media/media_item.dart'; import '../../media/media_kind.dart'; import '../../media/media_server_client.dart'; +import '../../models/trackers/anime_lists_mapping.dart'; import '../../utils/app_logger.dart'; -enum AnimeProgressScope { show, season } +enum AnimeProgressScope { show, season, mapped } class ResolvedAnimeProgress { final int progress; @@ -16,7 +17,14 @@ class ResolvedAnimeProgress { /// 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}); + Future resolve( + MediaItem episode, { + required AnimeProgressScope scope, + AnimeEpisodeMatch? animeMatch, + Future Function(MediaItem episode)? episodeMatcher, + bool includeCurrentEpisode = true, + }); + void clearCache(); } @@ -27,22 +35,68 @@ class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup { AnimeEpisodeProgressResolver(this._client); @override - Future resolve(MediaItem episode, {required AnimeProgressScope scope}) async { + Future resolve( + MediaItem episode, { + required AnimeProgressScope scope, + AnimeEpisodeMatch? animeMatch, + Future Function(MediaItem episode)? episodeMatcher, + bool includeCurrentEpisode = true, + }) async { final showId = episode.grandparentId; final season = episode.parentIndex; if (showId == null || showId.isEmpty) return null; if (season == null || season <= 0) return null; + if (scope == AnimeProgressScope.mapped && animeMatch != null) { + final mapped = episodeMatcher == null + ? null + : await _mappedProgress( + showId, + episode, + animeMatch, + episodeMatcher, + includeCurrentEpisode: includeCurrentEpisode, + ); + if (mapped != null) return mapped; + return includeCurrentEpisode ? ResolvedAnimeProgress(progress: animeMatch.anidbEpisode) : null; + } + final progressBySeason = await _seasonProgressFor(showId); if (progressBySeason == null) return null; - final currentAlreadyWatched = (episode.viewCount ?? 0) > 0; + final currentAlreadyWatched = (episode.viewCount ?? 0) > 0 || !includeCurrentEpisode; return switch (scope) { AnimeProgressScope.show => _showProgress(progressBySeason, currentAlreadyWatched), AnimeProgressScope.season => _seasonProgress(progressBySeason[season], currentAlreadyWatched), + AnimeProgressScope.mapped => null, }; } + Future _mappedProgress( + String showId, + MediaItem current, + AnimeEpisodeMatch target, + Future Function(MediaItem episode) episodeMatcher, { + required bool includeCurrentEpisode, + }) async { + try { + final episodes = await _client.fetchPlayableDescendants(showId); + var progress = includeCurrentEpisode ? target.anidbEpisode : 0; + for (final episode in episodes) { + if (episode.kind != MediaKind.episode) continue; + final isCurrent = episode.id == current.id; + if ((episode.viewCount ?? 0) <= 0 && !(includeCurrentEpisode && isCurrent)) continue; + final match = await episodeMatcher(episode); + if (match == null || !match.sameAnimeEntry(target)) continue; + if (match.anidbEpisode > progress) progress = match.anidbEpisode; + } + return progress > 0 ? ResolvedAnimeProgress(progress: progress) : null; + } catch (e) { + appLogger.d('Anime progress: failed to load mapped episode watched state for $showId', error: e); + return null; + } + } + Future?> _seasonProgressFor(String showId) async { final existing = _seasonProgressLoads[showId]; if (existing != null) return existing; diff --git a/lib/services/trackers/anime_lists_mapping_store.dart b/lib/services/trackers/anime_lists_mapping_store.dart new file mode 100644 index 00000000..d4f4d8a2 --- /dev/null +++ b/lib/services/trackers/anime_lists_mapping_store.dart @@ -0,0 +1,409 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:xml/xml.dart'; + +import '../../models/trackers/anime_lists_mapping.dart'; +import '../../utils/abortable_http_request.dart'; +import '../../utils/app_logger.dart'; +import '../../utils/json_utils.dart'; +import '../../utils/platform_http_client_stub.dart' + if (dart.library.io) '../../utils/platform_http_client_io.dart' + as platform; +import '../base_shared_preferences_service.dart'; + +class AnimeListsIndex { + final Map> byTvdb; + final Map> byTmdbTv; + + const AnimeListsIndex({required this.byTvdb, required this.byTmdbTv}); + + bool get isEmpty => byTvdb.isEmpty && byTmdbTv.isEmpty; +} + +abstract interface class AnimeListsMappingLookup { + Future lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber}); + + Future> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season}); + + Future> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}); +} + +class AnimeListsMappingStore implements AnimeListsMappingLookup { + static const String _diskFileName = 'anime-list.xml'; + static const String _prefsEtagKey = 'anime_lists_etag'; + static const String _prefsLastCheckKey = 'anime_lists_last_check'; + static const String _sourceUrl = 'https://cdn.jsdelivr.net/gh/Anime-Lists/anime-lists@master/anime-list.xml'; + + static const Duration _refreshInterval = Duration(days: 7); + static const Duration _requestTimeout = Duration(seconds: 60); + + AnimeListsMappingStore._(); + static final AnimeListsMappingStore instance = AnimeListsMappingStore._(); + + AnimeListsIndex? _index; + Future? _loading; + bool _refreshRunning = false; + + Future _ensureLoaded() async { + final existing = _index; + if (existing != null) return existing; + final loading = _loading; + if (loading != null) return loading; + + final fresh = _loadOrFetch(); + _loading = fresh; + try { + final idx = await fresh; + if (!idx.isEmpty) { + _index = idx; + unawaited(maybeRefresh()); + } + return idx; + } finally { + _loading = null; + } + } + + Future _loadOrFetch() async { + final path = await _diskPath(); + try { + return await compute(_readAndParseAnimeLists, path); + } on FileSystemException { + appLogger.d('Anime-Lists: no disk cache, downloading from jsDelivr'); + final raw = await _download(); + if (raw == null) return const AnimeListsIndex(byTvdb: {}, byTmdbTv: {}); + return await compute(parseAnimeListsIndex, raw); + } catch (e) { + appLogger.w('Anime-Lists: parse failed - deleting disk copy so next lookup re-downloads', error: e); + await _deleteDiskCopy(); + return const AnimeListsIndex(byTvdb: {}, byTmdbTv: {}); + } + } + + Future _download() async { + final client = platform.createPlatformClient(); + try { + final res = await sendAbortableHttpRequest( + client, + 'GET', + Uri.parse(_sourceUrl), + headers: const {'Accept': 'application/xml,text/xml'}, + timeout: _requestTimeout, + operation: 'Anime-Lists mapping download', + ); + if (res.statusCode != 200) { + appLogger.d('Anime-Lists: download returned HTTP ${res.statusCode}'); + return null; + } + await _writeDiskCopy(res.body, etag: res.headers['etag']); + final prefs = await BaseSharedPreferencesService.sharedCache(); + await prefs.setInt(_prefsLastCheckKey, DateTime.now().millisecondsSinceEpoch); + return res.body; + } catch (e) { + appLogger.w('Anime-Lists: download failed', error: e); + return null; + } finally { + client.close(); + } + } + + @override + Future lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber}) async { + final idx = await _ensureLoaded(); + return lookupAnimeListEpisodeInIndex( + idx, + tvdbId: tvdbId, + tmdbId: tmdbId, + season: season, + episodeNumber: episodeNumber, + ); + } + + @override + Future> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season}) async { + final idx = await _ensureLoaded(); + if (tvdbId != null) { + final ids = _seasonAnimeIds(idx.byTvdb[tvdbId], AnimeListProvider.tvdb, season); + if (ids.isNotEmpty) return ids; + } + if (tmdbId != null) { + return _seasonAnimeIds(idx.byTmdbTv[tmdbId], AnimeListProvider.tmdb, season); + } + return const {}; + } + + @override + Future> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async { + final idx = await _ensureLoaded(); + if (tvdbId != null) { + final entries = idx.byTvdb[tvdbId]; + if (entries != null && entries.isNotEmpty) return {for (final entry in entries) entry.anidbId}; + } + if (tmdbId != null) { + final entries = idx.byTmdbTv[tmdbId]; + if (entries != null && entries.isNotEmpty) return {for (final entry in entries) entry.anidbId}; + } + return const {}; + } + + Future maybeRefresh() async { + if (_refreshRunning) return; + if (_index == null) return; + _refreshRunning = true; + try { + final prefs = await BaseSharedPreferencesService.sharedCache(); + final lastCheck = prefs.getInt(_prefsLastCheckKey) ?? 0; + final now = DateTime.now().millisecondsSinceEpoch; + if (now - lastCheck < _refreshInterval.inMilliseconds) return; + + final etag = prefs.getString(_prefsEtagKey); + final client = platform.createPlatformClient(); + try { + final res = await sendAbortableHttpRequest( + client, + 'GET', + Uri.parse(_sourceUrl), + headers: {'If-None-Match': ?etag, 'Accept': 'application/xml,text/xml'}, + timeout: _requestTimeout, + operation: 'Anime-Lists mapping refresh', + ); + await prefs.setInt(_prefsLastCheckKey, now); + + if (res.statusCode == 304) { + appLogger.d('Anime-Lists: mapping unchanged (304)'); + return; + } + if (res.statusCode != 200) { + appLogger.d('Anime-Lists: refresh returned HTTP ${res.statusCode}'); + return; + } + + await _writeDiskCopy(res.body, etag: res.headers['etag']); + final fresh = await compute(parseAnimeListsIndex, res.body); + _index = fresh; + appLogger.d('Anime-Lists: mapping refreshed (${fresh.byTvdb.length} tvdb entries)'); + } finally { + client.close(); + } + } catch (e) { + appLogger.d('Anime-Lists: refresh failed (non-fatal)', error: e); + } finally { + _refreshRunning = false; + } + } + + Future _writeDiskCopy(String body, {String? etag}) async { + await File(await _diskPath()).writeAsString(body, flush: true); + if (etag != null) { + final prefs = await BaseSharedPreferencesService.sharedCache(); + await prefs.setString(_prefsEtagKey, etag); + } + } + + Future _deleteDiskCopy() async { + try { + await File(await _diskPath()).delete(); + } on FileSystemException { + // Already gone. + } + } + + Future _diskPath() async { + final dir = await getApplicationSupportDirectory(); + return p.join(dir.path, _diskFileName); + } + + @visibleForTesting + void resetForTesting() { + _index = null; + _loading = null; + } +} + +@visibleForTesting +AnimeEpisodeMatch? lookupAnimeListEpisodeInIndex( + AnimeListsIndex idx, { + int? tvdbId, + int? tmdbId, + int? season, + int? episodeNumber, +}) { + if (season == null || episodeNumber == null || episodeNumber <= 0) return null; + if (tvdbId != null) { + final selected = _selectMatch(_matches(idx.byTvdb[tvdbId], AnimeListProvider.tvdb, season, episodeNumber)); + if (selected != null) return selected; + } + if (tmdbId != null) { + return _selectMatch(_matches(idx.byTmdbTv[tmdbId], AnimeListProvider.tmdb, season, episodeNumber)); + } + return null; +} + +List _matches( + List? entries, + AnimeListProvider provider, + int season, + int episodeNumber, +) { + if (entries == null || entries.isEmpty) return const []; + return [ + for (final entry in entries) + ...entry.resolveEpisode(provider: provider, externalSeason: season, externalEpisode: episodeNumber), + ]; +} + +AnimeEpisodeMatch? _selectMatch(List matches) { + if (matches.isEmpty) return null; + final bestPriority = matches.map(_matchPriority).reduce((a, b) => a < b ? a : b); + final best = matches.where((match) => _matchPriority(match) == bestPriority).toList(growable: false); + final first = best.first; + if (best.every(first.sameEpisode)) return first; + return null; +} + +int _matchPriority(AnimeEpisodeMatch match) => switch (match.kind) { + AnimeListMatchKind.explicit => 0, + AnimeListMatchKind.range => 1, + AnimeListMatchKind.defaultMapping => 2, +}; + +Set _seasonAnimeIds(List? entries, AnimeListProvider provider, int season) { + if (entries == null || entries.isEmpty) return const {}; + return { + for (final entry in entries) + if (entry.mapsSeason(provider: provider, externalSeason: season)) entry.anidbId, + }; +} + +AnimeListsIndex _readAndParseAnimeLists(String path) { + final raw = File(path).readAsStringSync(); + return parseAnimeListsIndex(raw); +} + +@visibleForTesting +AnimeListsIndex parseAnimeListsIndex(String raw) { + final document = XmlDocument.parse(raw); + final byTvdb = >{}; + final byTmdbTv = >{}; + + for (final anime in document.findAllElements('anime')) { + final anidbId = flexibleInt(anime.getAttribute('anidbid')); + if (anidbId == null) continue; + final entry = AnimeListEntry( + anidbId: anidbId, + name: anime.getElement('name')?.innerText.trim(), + rawTvdbId: anime.getAttribute('tvdbid'), + tvdbId: flexibleInt(anime.getAttribute('tvdbid')), + defaultTvdbSeason: _seasonRef(anime.getAttribute('defaulttvdbseason')), + episodeOffset: flexibleInt(anime.getAttribute('episodeoffset')) ?? 0, + tmdbTvId: flexibleInt(anime.getAttribute('tmdbtv')), + tmdbSeason: _seasonRef(anime.getAttribute('tmdbseason')), + tmdbOffset: flexibleInt(anime.getAttribute('tmdboffset')) ?? 0, + tmdbMovieIds: _intList(anime.getAttribute('tmdbid')), + imdbIds: _stringList(anime.getAttribute('imdbid')), + mappings: _parseMappings(anime), + ); + + final tvdb = entry.tvdbId; + if (tvdb != null) (byTvdb[tvdb] ??= []).add(entry); + final tmdbTv = entry.tmdbTvId; + if (tmdbTv != null) (byTmdbTv[tmdbTv] ??= []).add(entry); + } + + return AnimeListsIndex(byTvdb: byTvdb, byTmdbTv: byTmdbTv); +} + +AnimeListSeasonRef? _seasonRef(String? value) { + if (value == null || value.isEmpty) return null; + if (value == 'a') return const AnimeListSeasonRef.absolute(); + final number = flexibleInt(value); + return number == null ? null : AnimeListSeasonRef.number(number); +} + +List _intList(String? value) { + if (value == null || value.isEmpty) return const []; + return [ + for (final part in value.split(',')) + if (flexibleInt(part.trim()) case final parsed?) parsed, + ]; +} + +List _stringList(String? value) { + if (value == null || value.isEmpty) return const []; + return [ + for (final part in value.split(',')) + if (part.trim().isNotEmpty) part.trim(), + ]; +} + +List _parseMappings(XmlElement anime) { + final list = anime.getElement('mapping-list'); + if (list == null) return const []; + final mappings = []; + for (final mapping in list.findElements('mapping')) { + final anidbSeason = flexibleInt(mapping.getAttribute('anidbseason')); + if (anidbSeason == null) continue; + final start = flexibleInt(mapping.getAttribute('start')); + final end = flexibleInt(mapping.getAttribute('end')); + final offset = flexibleInt(mapping.getAttribute('offset')) ?? 0; + final explicit = _parseExplicitMappings(mapping.innerText); + + final tvdbSeason = flexibleInt(mapping.getAttribute('tvdbseason')); + if (tvdbSeason != null) { + mappings.add( + AnimeListEpisodeMapping( + anidbSeason: anidbSeason, + provider: AnimeListProvider.tvdb, + externalSeason: tvdbSeason, + start: start, + end: end, + offset: offset, + explicit: explicit, + ), + ); + } + + final tmdbSeason = flexibleInt(mapping.getAttribute('tmdbseason')); + if (tmdbSeason != null) { + mappings.add( + AnimeListEpisodeMapping( + anidbSeason: anidbSeason, + provider: AnimeListProvider.tmdb, + externalSeason: tmdbSeason, + start: start, + end: end, + offset: offset, + explicit: explicit, + ), + ); + } + } + return mappings; +} + +List _parseExplicitMappings(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) return const []; + final mappings = []; + for (final segment in trimmed.split(';')) { + final item = segment.trim(); + if (item.isEmpty) continue; + final separator = item.indexOf('-'); + if (separator <= 0 || separator == item.length - 1) continue; + final anidbEpisode = flexibleInt(item.substring(0, separator)); + if (anidbEpisode == null) continue; + final externalEpisodes = []; + for (final target in item.substring(separator + 1).split('+')) { + final externalEpisode = flexibleInt(target.trim()); + if (externalEpisode == null || externalEpisode == 0) continue; + externalEpisodes.add(externalEpisode); + } + if (externalEpisodes.isEmpty) continue; + mappings.add(AnimeListExplicitEpisodeMapping(anidbEpisode: anidbEpisode, externalEpisodes: externalEpisodes)); + } + return mappings; +} diff --git a/lib/services/trackers/mal/mal_client.dart b/lib/services/trackers/mal/mal_client.dart index 0d8dcd06..ce062075 100644 --- a/lib/services/trackers/mal/mal_client.dart +++ b/lib/services/trackers/mal/mal_client.dart @@ -60,6 +60,10 @@ class MalClient { await _request('PUT', '/anime/$animeId/my_list_status', formBody: fields); } + Future deleteMyListStatus(int animeId) async { + await _request('DELETE', '/anime/$animeId/my_list_status'); + } + Future getMyListScore(int animeId) async { try { final res = await _request('GET', '/anime/$animeId?fields=my_list_status'); diff --git a/lib/services/trackers/mal/mal_tracker.dart b/lib/services/trackers/mal/mal_tracker.dart index 6c99bef9..597ca6a3 100644 --- a/lib/services/trackers/mal/mal_tracker.dart +++ b/lib/services/trackers/mal/mal_tracker.dart @@ -81,6 +81,30 @@ class MalTracker extends TrackerBase { appLogger.d('MAL: updated list status (mal=$malId, fields=$fields)'); } + @override + Future markUnwatched(TrackerContext ctx) async { + if (ctx.isMovie) { + await removeFromList(ctx); + } + } + + Future removeFromList(TrackerContext ctx) async { + final client = _client; + final malId = ctx.anime?.mal; + if (client == null || malId == null) return; + await _deleteMyListStatus(client, malId); + } + + Future _deleteMyListStatus(MalClient client, int malId) async { + try { + await client.deleteMyListStatus(malId); + appLogger.d('MAL: deleted list status (mal=$malId)'); + } on MalApiException catch (e) { + if (e.statusCode == 404) return; + rethrow; + } + } + Future rate(TrackerRatingContext ctx, int score) async { final client = _client; final malId = ctx.ids.anime?.mal; diff --git a/lib/services/trackers/simkl/simkl_client.dart b/lib/services/trackers/simkl/simkl_client.dart index db25fa33..703a8b57 100644 --- a/lib/services/trackers/simkl/simkl_client.dart +++ b/lib/services/trackers/simkl/simkl_client.dart @@ -39,6 +39,8 @@ class SimklClient { /// ``` Future addToHistory(Map body) => _request('POST', '/sync/history', body: body); + Future removeFromHistory(Map body) => _request('POST', '/sync/history/remove', body: body); + Future addRatings(Map body) => _request('POST', '/sync/ratings', body: body); Future removeRatings(Map body) => _request('POST', '/sync/ratings/remove', body: body); diff --git a/lib/services/trackers/simkl/simkl_tracker.dart b/lib/services/trackers/simkl/simkl_tracker.dart index 8fe021ea..68b6e69b 100644 --- a/lib/services/trackers/simkl/simkl_tracker.dart +++ b/lib/services/trackers/simkl/simkl_tracker.dart @@ -56,7 +56,26 @@ class SimklTracker extends TrackerBase { final ids = _buildIds(external: ctx.external, anime: ctx.anime); if (ids.isEmpty) return; - final body = ctx.isMovie + final body = _historyBody(ctx, ids); + + await client.addToHistory(body); + appLogger.d('Simkl: marked watched (ids=$ids, isMovie=${ctx.isMovie})'); + } + + @override + Future markUnwatched(TrackerContext ctx) async { + final client = _client; + if (client == null) return; + + final ids = _buildIds(external: ctx.external, anime: ctx.anime); + if (ids.isEmpty) return; + + await client.removeFromHistory(_historyBody(ctx, ids)); + appLogger.d('Simkl: marked unwatched (ids=$ids, isMovie=${ctx.isMovie})'); + } + + Map _historyBody(TrackerContext ctx, Map ids) { + return ctx.isMovie ? { 'movies': [ {'ids': ids}, @@ -77,9 +96,6 @@ class SimklTracker extends TrackerBase { }, ], }; - - await client.addToHistory(body); - appLogger.d('Simkl: marked watched (ids=$ids, isMovie=${ctx.isMovie})'); } Future getRating(TrackerRatingContext ctx) async { diff --git a/lib/services/trackers/tracker.dart b/lib/services/trackers/tracker.dart index 73e1c9ff..cdba3ccc 100644 --- a/lib/services/trackers/tracker.dart +++ b/lib/services/trackers/tracker.dart @@ -30,6 +30,7 @@ abstract class Tracker { bool shouldScrobbleForLibrary(String? libraryGlobalKey); Future markWatched(TrackerContext ctx); + Future markUnwatched(TrackerContext ctx); } class TrackerRatingUnavailableException implements Exception { diff --git a/lib/services/trackers/tracker_coordinator.dart b/lib/services/trackers/tracker_coordinator.dart index fb7a78fc..0777279f 100644 --- a/lib/services/trackers/tracker_coordinator.dart +++ b/lib/services/trackers/tracker_coordinator.dart @@ -5,7 +5,11 @@ import '../../media/media_kind.dart'; import '../../media/media_server_client.dart'; import '../../models/trackers/tracker_context.dart'; import '../../utils/app_logger.dart'; +import '../../utils/episode_collection.dart'; +import 'anime_episode_progress_resolver.dart'; +import 'anime_lists_mapping_store.dart'; import 'anilist/anilist_tracker.dart'; +import 'fribb_mapping_store.dart'; import 'mal/mal_tracker.dart'; import 'simkl/simkl_tracker.dart'; import 'tracker.dart'; @@ -27,7 +31,11 @@ class TrackerCoordinator { /// Resolver persists across episode swaps so back-to-back episodes of the /// same show reuse the cached IDs. Cleared only on profile switch. TrackerIdResolver? _resolver; + String? _resolverClientKey; String? _activeLibraryGlobalKey; + FribbMappingLookup? _debugFribbStore; + AnimeListsMappingLookup? _debugAnimeListsStore; + AnimeEpisodeProgressLookup? _debugAnimeProgress; TrackerContext? _ctx; Duration _duration = Duration.zero; @@ -43,14 +51,19 @@ class TrackerCoordinator { final mediaType = metadata.kind; if (mediaType != MediaKind.movie && mediaType != MediaKind.episode) return; final libraryGlobalKey = metadata.libraryGlobalKey; - if (!_trackers.any((t) => t.canScrobble && t.shouldScrobbleForLibrary(libraryGlobalKey))) { + if (!_hasActiveTrackerForLibrary(libraryGlobalKey)) { _reset(); return; } _activeLibraryGlobalKey = libraryGlobalKey; - _resolver ??= TrackerIdResolver(client, needsFribb: _anyTrackerNeedsFribb); - final ctx = await _buildContext(metadata); + final clientKey = client.cacheServerId; + if (_resolver == null || _resolverClientKey != clientKey) { + _resolver?.clearCache(); + _resolver = _newResolver(client, needsFribb: _anyTrackerNeedsFribb); + _resolverClientKey = clientKey; + } + final ctx = await _buildContext(metadata, _resolver!); if (ctx == null) { appLogger.d('Trackers: no external IDs for ${metadata.id}'); _reset(); @@ -60,8 +73,217 @@ class TrackerCoordinator { _ctx = ctx; } - bool _anyTrackerNeedsFribb() => - _trackers.any((t) => t.canScrobble && t.needsFribb && t.shouldScrobbleForLibrary(_activeLibraryGlobalKey)); + bool _anyTrackerNeedsFribb() => _anyTrackerNeedsFribbForLibrary(_activeLibraryGlobalKey); + + bool _hasActiveTrackerForLibrary(String? libraryGlobalKey) => + _trackers.any((t) => t.canScrobble && t.shouldScrobbleForLibrary(libraryGlobalKey)); + + bool _anyTrackerNeedsFribbForLibrary(String? libraryGlobalKey) => + _trackers.any((t) => t.canScrobble && t.needsFribb && t.shouldScrobbleForLibrary(libraryGlobalKey)); + + void debugUseResolverDependencies({ + FribbMappingLookup? store, + AnimeListsMappingLookup? animeLists, + AnimeEpisodeProgressLookup? animeProgress, + }) { + _debugFribbStore = store; + _debugAnimeListsStore = animeLists; + _debugAnimeProgress = animeProgress; + invalidateResolverCache(); + } + + TrackerIdResolver _newResolver(MediaServerClient client, {required bool Function() needsFribb}) => TrackerIdResolver( + client, + needsFribb: needsFribb, + store: _debugFribbStore, + animeLists: _debugAnimeListsStore, + animeProgress: _debugAnimeProgress, + ); + + Future markWatched(MediaItem item, MediaServerClient client) async { + try { + await _markWatched(item, client); + } catch (e) { + appLogger.d('Trackers: manual markWatched failed for ${item.id}', error: e); + } + } + + Future markUnwatched(MediaItem item, MediaServerClient client) async { + try { + await _markUnwatched(item, client); + } catch (e) { + appLogger.d('Trackers: manual markUnwatched failed for ${item.id}', error: e); + } + } + + Future _markWatched(MediaItem item, MediaServerClient client) async { + final kind = item.kind; + if (kind != MediaKind.movie && kind != MediaKind.episode && kind != MediaKind.season && kind != MediaKind.show) { + return; + } + + final libraryGlobalKey = item.libraryGlobalKey; + if (!_hasActiveTrackerForLibrary(libraryGlobalKey)) return; + + final resolver = _newResolver(client, needsFribb: () => _anyTrackerNeedsFribbForLibrary(libraryGlobalKey)); + + if (kind == MediaKind.movie || kind == MediaKind.episode) { + await _markSingleWatched(item, resolver); + return; + } + + final episodes = []; + if (kind == MediaKind.show) { + await collectEpisodesForShow(client, item.id, unwatchedOnly: false, out: episodes, fallback: item); + } else { + await collectEpisodesForSeason(client, item.id, unwatchedOnly: false, out: episodes, fallback: item); + } + appLogger.d('Trackers: manual ${kind.name} ${item.id} expanded to ${episodes.length} episodes'); + + await _markContainerEpisodesWatched(episodes, resolver); + } + + Future _markUnwatched(MediaItem item, MediaServerClient client) async { + final kind = item.kind; + if (kind != MediaKind.movie && kind != MediaKind.episode && kind != MediaKind.season && kind != MediaKind.show) { + return; + } + + final libraryGlobalKey = item.libraryGlobalKey; + if (!_hasActiveTrackerForLibrary(libraryGlobalKey)) return; + + final resolver = _newResolver(client, needsFribb: () => _anyTrackerNeedsFribbForLibrary(libraryGlobalKey)); + + if (kind == MediaKind.movie || kind == MediaKind.episode) { + await _markSingleUnwatched(item, resolver); + return; + } + + final episodes = []; + if (kind == MediaKind.show) { + await collectEpisodesForShow(client, item.id, unwatchedOnly: false, out: episodes, fallback: item); + } else { + await collectEpisodesForSeason(client, item.id, unwatchedOnly: false, out: episodes, fallback: item); + } + appLogger.d('Trackers: manual ${kind.name} ${item.id} unwatched expanded to ${episodes.length} episodes'); + + await _markContainerEpisodesUnwatched(episodes, resolver); + } + + Future _markContainerEpisodesWatched(List episodes, TrackerIdResolver resolver) async { + final animeGroups = {}; + var resolved = 0; + + for (final episode in episodes) { + final ctx = await _buildContext(episode, resolver, includeAnimeProgress: false); + if (ctx == null) continue; + resolved++; + + await _dispatchToTrackers([SimklTracker.instance], ctx); + + final key = _animeGroupKey(ctx); + if (key == null) continue; + (animeGroups[key] ??= _ManualAnimeProgress(ctx, fallbackToCount: true)).add(ctx); + } + + appLogger.d('Trackers: manual container resolved $resolved/${episodes.length} episodes'); + + for (final group in animeGroups.values) { + final ctx = group.context; + if (ctx != null) await _dispatchToTrackers([MalTracker.instance, AnilistTracker.instance], ctx); + } + appLogger.d('Trackers: manual container resolved ${animeGroups.length} anime entries'); + } + + Future _markContainerEpisodesUnwatched(List episodes, TrackerIdResolver resolver) async { + final malEntries = {}; + final anilistEntries = {}; + var resolved = 0; + + for (final episode in episodes) { + final ctx = await _buildContext( + episode, + resolver, + includeAnimeProgress: false, + fallbackToAnimeEpisodeNumber: false, + ); + if (ctx == null) continue; + resolved++; + + await _dispatchUnwatchedToTrackers([SimklTracker.instance], ctx); + + final anime = ctx.anime; + if (anime == null) continue; + final malId = anime.mal; + if (malId != null && _isActive(MalTracker.instance, ctx.libraryGlobalKey)) { + malEntries[malId] = ctx; + } + final anilistId = anime.anilist; + if (anilistId != null && _isActive(AnilistTracker.instance, ctx.libraryGlobalKey)) { + anilistEntries[anilistId] = ctx; + } + } + + appLogger.d('Trackers: manual container unwatched resolved $resolved/${episodes.length} episodes'); + + await _removeAnimeEntriesFromLists(malEntries.values, anilistEntries.values); + appLogger.d( + 'Trackers: manual container unwatched resolved ${malEntries.length} MAL and ${anilistEntries.length} AniList entries', + ); + } + + Future _removeAnimeEntriesFromLists( + Iterable malEntries, + Iterable anilistEntries, + ) async { + await Future.wait([ + ...malEntries.map((ctx) async { + try { + await MalTracker.instance.removeFromList(ctx); + } catch (e) { + appLogger.d('mal: removeFromList failed', error: e); + } + }), + ...anilistEntries.map((ctx) async { + try { + await AnilistTracker.instance.removeFromList(ctx); + } catch (e) { + appLogger.d('anilist: removeFromList failed', error: e); + } + }), + ]); + } + + String? _animeGroupKey(TrackerContext ctx) { + final anime = ctx.anime; + if (anime == null) return null; + final hasMal = anime.mal != null && _isActive(MalTracker.instance, ctx.libraryGlobalKey); + final hasAnilist = anime.anilist != null && _isActive(AnilistTracker.instance, ctx.libraryGlobalKey); + if (!hasMal && !hasAnilist) return null; + return '${hasMal ? anime.mal : ''}:${hasAnilist ? anime.anilist : ''}'; + } + + Future _markSingleWatched(MediaItem item, TrackerIdResolver resolver) async { + final ctx = await _buildContext(item, resolver); + if (ctx == null) { + appLogger.d('Trackers: no external IDs for manually watched ${item.id}'); + return; + } + await _dispatchMarkWatched(ctx); + } + + Future _markSingleUnwatched(MediaItem item, TrackerIdResolver resolver) async { + final ctx = await _buildContext(item, resolver, includeAnimeProgress: false, fallbackToAnimeEpisodeNumber: false); + if (ctx == null) { + appLogger.d('Trackers: no external IDs for manually unwatched ${item.id}'); + return; + } + if (ctx.isMovie) { + await _dispatchMarkUnwatched(ctx); + } else { + await _dispatchUnwatchedToTrackers([SimklTracker.instance], ctx); + } + } Future stopPlayback() async { final ctx = _ctx; @@ -96,6 +318,7 @@ class TrackerCoordinator { _reset(); _resolver?.clearCache(); _resolver = null; + _resolverClientKey = null; } /// Drop the resolver's ID cache without touching in-flight playback state. @@ -119,6 +342,19 @@ class TrackerCoordinator { Future _dispatchMarkWatched(TrackerContext ctx) async { final active = _trackers.where((t) => t.canScrobble && t.shouldScrobbleForLibrary(ctx.libraryGlobalKey)); + await _dispatchToTrackers(active, ctx); + } + + Future _dispatchMarkUnwatched(TrackerContext ctx) async { + final active = _trackers.where((t) => t.canScrobble && t.shouldScrobbleForLibrary(ctx.libraryGlobalKey)); + await _dispatchUnwatchedToTrackers(active, ctx); + } + + bool _isActive(Tracker tracker, String? libraryGlobalKey) => + tracker.canScrobble && tracker.shouldScrobbleForLibrary(libraryGlobalKey); + + Future _dispatchToTrackers(Iterable trackers, TrackerContext ctx) async { + final active = trackers.where((t) => _isActive(t, ctx.libraryGlobalKey)); await Future.wait( active.map((t) async { try { @@ -130,10 +366,26 @@ class TrackerCoordinator { ); } - Future _buildContext(MediaItem metadata) async { - final resolver = _resolver; - if (resolver == null) return null; + Future _dispatchUnwatchedToTrackers(Iterable trackers, TrackerContext ctx) async { + final active = trackers.where((t) => _isActive(t, ctx.libraryGlobalKey)); + await Future.wait( + active.map((t) async { + try { + await t.markUnwatched(ctx); + } catch (e) { + appLogger.d('${t.name}: markUnwatched failed', error: e); + } + }), + ); + } + Future _buildContext( + MediaItem metadata, + TrackerIdResolver resolver, { + bool includeAnimeProgress = true, + bool includeCurrentEpisode = true, + bool fallbackToAnimeEpisodeNumber = true, + }) async { final libraryKey = metadata.libraryGlobalKey; if (metadata.kind == MediaKind.movie) { @@ -151,8 +403,17 @@ class TrackerCoordinator { final number = metadata.index; if (season == null || number == null) return null; - final ids = await resolver.resolveShowForEpisode(metadata); + final ids = await resolver.resolveShowForEpisode( + metadata, + includeAnimeProgress: includeAnimeProgress, + includeCurrentEpisode: includeCurrentEpisode, + ); if (ids == null) return null; + final animeProgress = includeAnimeProgress + ? ids.animeProgress ?? (fallbackToAnimeEpisodeNumber ? ids.animeEpisodeNumber : null) + : fallbackToAnimeEpisodeNumber + ? ids.animeEpisodeNumber + : null; return TrackerContext.episode( external: ids.external, anime: ids.anime, @@ -160,7 +421,40 @@ class TrackerCoordinator { libraryGlobalKey: libraryKey, season: season, episodeNumber: number, - animeProgress: ids.animeProgress, + animeProgress: animeProgress, + ); + } +} + +class _ManualAnimeProgress { + final TrackerContext _base; + final bool _fallbackToCount; + int _count = 0; + int? _maxMappedProgress; + + _ManualAnimeProgress(this._base, {required bool fallbackToCount}) : _fallbackToCount = fallbackToCount; + + void add(TrackerContext ctx) { + _count++; + final mapped = ctx.animeProgress; + if (mapped != null && (_maxMappedProgress == null || mapped > _maxMappedProgress!)) { + _maxMappedProgress = mapped; + } + } + + int? get progress => _maxMappedProgress ?? (_fallbackToCount ? _count : null); + + TrackerContext? get context { + final progress = this.progress; + if (progress == null) return null; + return TrackerContext.episode( + external: _base.external, + anime: _base.anime, + ratingKey: _base.ratingKey, + libraryGlobalKey: _base.libraryGlobalKey, + season: _base.season!, + episodeNumber: progress, + animeProgress: progress, ); } } diff --git a/lib/services/trackers/tracker_id_resolver.dart b/lib/services/trackers/tracker_id_resolver.dart index b5b34c89..c77d0158 100644 --- a/lib/services/trackers/tracker_id_resolver.dart +++ b/lib/services/trackers/tracker_id_resolver.dart @@ -2,9 +2,11 @@ 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'; /// Paired ID output: always-present Plex external IDs (tvdb/imdb/tmdb) plus @@ -15,8 +17,17 @@ class TrackerIds { 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}); + const TrackerIds({ + required this.external, + required this.anime, + this.animeProgressScope, + this.animeProgress, + this.animeEpisodeMatch, + this.animeEpisodeNumber, + }); TrackerIds withAnimeProgress(ResolvedAnimeProgress? animeProgress) { return TrackerIds( @@ -24,6 +35,8 @@ class TrackerIds { anime: anime, animeProgressScope: animeProgressScope, animeProgress: animeProgress?.progress, + animeEpisodeMatch: animeEpisodeMatch, + animeEpisodeNumber: animeEpisodeNumber, ); } } @@ -48,8 +61,8 @@ class TrackerRatingContext { /// 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. +/// show-level external IDs. Anime-Lists XML is used when available to +/// disambiguate same-season split-cour episode ranges by AniDB id. /// /// 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, @@ -57,21 +70,25 @@ class TrackerRatingContext { 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 _cache = {}; + final Map> _externalIdLoads = {}; 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; @@ -80,41 +97,67 @@ class TrackerIdResolver { /// [MediaServerClient.fetchExternalIds] surface — Plex hits /// `/library/metadata/{id}?includeGuids=1`, Jellyfin reads the inline /// `ProviderIds` map. - Future _fetchExternalIds(String itemId) => _client.fetchExternalIds(itemId); + Future _fetchExternalIds(String itemId) { + final existing = _externalIdLoads[itemId]; + if (existing != null) return existing; + late final Future loading; + loading = _client.fetchExternalIds(itemId).catchError((Object e) { + if (identical(_externalIdLoads[itemId], loading)) { + final _ = _externalIdLoads.remove(itemId); + } + throw e; + }); + _externalIdLoads[itemId] = loading; + return loading; + } /// Resolve IDs for a movie. Future 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); + 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 - /// the episode's season number. - Future resolveShowForEpisode(MediaItem episode) async { + /// Anime-Lists episode mappings first and season mappings as fallback. + Future 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; - // 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; + 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, isMovie: false); + ids = await _build(external, isEpisodeSeason: season, episodeNumber: number, 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); + 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 @@ -125,19 +168,19 @@ class TrackerIdResolver { final ids = await resolveForMovie(item.id); return ids == null ? null : TrackerRatingContext(ids: ids, kind: MediaKind.movie); case MediaKind.show: - final ids = await _resolveShow(item.id); + 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 _resolveShow(showId, season: season); + 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); + final ids = await resolveShowForEpisode(item, includeAnimeProgress: false); return ids == null ? null : TrackerRatingContext(ids: ids, kind: MediaKind.episode, season: season, episodeNumber: number); @@ -146,35 +189,110 @@ class TrackerIdResolver { } } - Future _resolveShow(String showId, {int? season}) async { + Future _resolveShowForRating(String showId, {int? season}) async { if (showId.isEmpty) return null; - final cacheKey = season != null ? '$showId#s$season' : showId; + 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 _build(external, isEpisodeSeason: season, isMovie: false); + final ids = await _buildShowRating(external, season: season); _cache[cacheKey] = ids; return ids; } void clearCache() { _cache.clear(); + _externalIdLoads.clear(); _animeProgress.clearCache(); } - Future _build(ExternalIds external, {int? isEpisodeSeason, required bool isMovie}) async { + Future _build( + ExternalIds external, { + int? isEpisodeSeason, + int? episodeNumber, + 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 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), + animeProgressScope: _animeProgressScope( + selected: row, + rows: rows, + season: isEpisodeSeason, + isMovie: isMovie, + animeMatch: animeMatch, + ), + animeEpisodeMatch: animeMatch, + animeEpisodeNumber: animeMatch?.anidbEpisode, ); } + Future _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); + 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 _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 _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> _lookupAnimeIdsForSeason(ExternalIds external, int season) async { + try { + return await _animeLists.lookupAnimeIdsForSeason(tvdbId: external.tvdb, tmdbId: external.tmdb, season: season); + } catch (_) { + return const {}; + } + } + + Future> _lookupAnimeIdsForShow(ExternalIds external) async { + try { + return await _animeLists.lookupAnimeIdsForShow(tvdbId: external.tvdb, tmdbId: external.tmdb); + } catch (_) { + return const {}; + } + } + /// Pick the best row for a movie lookup — prefer rows marked `type: MOVIE`. FribbMappingRow? _pickMovieRow(List rows) { if (rows.isEmpty) return null; @@ -189,9 +307,12 @@ class TrackerIdResolver { /// 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 rows, {int? season}) { + FribbMappingRow? _pickShowRow(List 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; @@ -210,15 +331,24 @@ class TrackerIdResolver { return rows.first; } + FribbMappingRow? _rowForAnidb(List rows, int anidbId) { + for (final row in rows) { + if (row.anidbId == anidbId) return row; + } + return null; + } + AnimeProgressScope? _animeProgressScope({ required FribbMappingRow? selected, required List 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; diff --git a/lib/services/trakt/trakt_scrobble_service.dart b/lib/services/trakt/trakt_scrobble_service.dart index 1336e43e..6b1bbe9c 100644 --- a/lib/services/trakt/trakt_scrobble_service.dart +++ b/lib/services/trakt/trakt_scrobble_service.dart @@ -302,7 +302,7 @@ class TraktScrobbleService { final number = metadata.index; if (season == null || number == null) return null; - final showIds = await resolver.resolveShowForEpisode(metadata); + final showIds = await resolver.resolveShowForEpisode(metadata, includeAnimeProgress: false); if (showIds == null) return null; return TraktScrobbleRequest.episode( diff --git a/lib/services/trakt/trakt_sync_service.dart b/lib/services/trakt/trakt_sync_service.dart index c49e4027..0d5c9e15 100644 --- a/lib/services/trakt/trakt_sync_service.dart +++ b/lib/services/trakt/trakt_sync_service.dart @@ -1,9 +1,13 @@ import 'dart:async'; import 'dart:collection'; +import '../../media/media_item.dart'; +import '../../media/media_kind.dart'; +import '../../media/media_server_client.dart'; import '../../models/trakt/trakt_ids.dart'; import '../../models/trakt/trakt_scrobble_request.dart'; import '../../utils/app_logger.dart'; +import '../../utils/episode_collection.dart'; import '../../utils/watch_state_notifier.dart'; import '../multi_server_manager.dart'; import '../settings_service.dart'; @@ -17,8 +21,9 @@ import 'trakt_sync_queue.dart'; /// One-way push of watched/unwatched events from Plezy to Trakt. /// /// Subscribes to `WatchStateNotifier` and filters to `{watched, unwatched}` -/// events on movies/episodes. Failures are queued via `TraktSyncQueue` and -/// drained on app foreground, network restore, and at startup. +/// events on movies/episodes, expanding show/season events to their episodes. +/// Failures are queued via `TraktSyncQueue` and drained on app foreground, +/// network restore, and at startup. class TraktSyncService { /// Inter-request delay during queue drain to stay under Trakt's /// 1000 req / 5 min rate limit. @@ -99,7 +104,7 @@ class TraktSyncService { // Backend-neutral: TrackerIdResolver pulls external IDs through // MediaServerClient.fetchExternalIds — Plex hits `?includeGuids=1`, // Jellyfin reads the inline `ProviderIds` map. - final mediaClient = _serverManager?.getClient(serverId); + final mediaClient = _clientFor(serverId); if (mediaClient == null) return null; final resolver = TrackerIdResolver(mediaClient, needsFribb: () => false); @@ -107,27 +112,89 @@ class TraktSyncService { return resolver; } + MediaServerClient? _clientFor(String serverId) => _serverManager?.getClient(serverId); + Future _onWatchStateEvent(WatchStateEvent event) async { if (!_canPush) return; if (event.changeType != WatchStateChangeType.watched && event.changeType != WatchStateChangeType.unwatched) return; - final kind = TraktMediaKind.tryFromMediaKindId(event.mediaType); - if (kind == null) return; - if (!_isLibraryAllowed(event.librarySectionGlobalKey)) { appLogger.d('Trakt sync: library filtered out for ${event.itemId}'); return; } final op = event.changeType == WatchStateChangeType.watched ? TraktSyncOp.add : TraktSyncOp.remove; - await _push( - op: op, - ratingKey: event.itemId, + final watchedAtIso = DateTime.now().toUtc().toIso8601String(); + + switch (event.mediaType) { + case 'movie': + await _push( + op: op, + ratingKey: event.itemId, + serverId: event.serverId, + libraryGlobalKey: event.librarySectionGlobalKey, + kind: TraktMediaKind.movie, + watchedAtIso: watchedAtIso, + ); + case 'episode': + await _push( + op: op, + ratingKey: event.itemId, + serverId: event.serverId, + libraryGlobalKey: event.librarySectionGlobalKey, + kind: TraktMediaKind.episode, + watchedAtIso: watchedAtIso, + ); + case 'show' || 'season': + await _pushPlayableDescendants(op: op, event: event, watchedAtIso: watchedAtIso); + } + } + + Future _pushPlayableDescendants({ + required TraktSyncOp op, + required WatchStateEvent event, + required String watchedAtIso, + }) async { + final mediaClient = _clientFor(event.serverId); + if (mediaClient == null) { + appLogger.d('Trakt sync: no client registered for server ${event.serverId}, skipping ${event.mediaType}'); + return; + } + + final fallback = MediaItem( + id: event.itemId, + backend: mediaClient.backend, + kind: MediaKind.fromString(event.mediaType), serverId: event.serverId, - libraryGlobalKey: event.librarySectionGlobalKey, - kind: kind, - watchedAtIso: DateTime.now().toUtc().toIso8601String(), + serverName: mediaClient.serverName, + libraryId: event.librarySectionID, + parentId: event.mediaType == 'season' && event.parentChain.isNotEmpty ? event.parentChain.first : null, ); + final episodes = []; + if (fallback.kind == MediaKind.show) { + await collectEpisodesForShow(mediaClient, event.itemId, unwatchedOnly: false, out: episodes, fallback: fallback); + } else { + await collectEpisodesForSeason( + mediaClient, + event.itemId, + unwatchedOnly: false, + out: episodes, + fallback: fallback, + ); + } + + for (final episode in episodes) { + if (episode.kind != MediaKind.episode) continue; + await _push( + op: op, + ratingKey: episode.id, + serverId: event.serverId, + libraryGlobalKey: episode.libraryGlobalKey ?? event.librarySectionGlobalKey, + kind: TraktMediaKind.episode, + watchedAtIso: watchedAtIso, + episodeMeta: episode, + ); + } } Future _push({ @@ -137,6 +204,7 @@ class TraktSyncService { required String? libraryGlobalKey, required TraktMediaKind kind, required String watchedAtIso, + MediaItem? episodeMeta, }) async { final resolver = _resolverFor(serverId); if (resolver == null) { @@ -155,14 +223,14 @@ class TraktSyncService { // doesn't carry the index, so fetch episode metadata via the neutral // MediaServerClient surface (Plex `/library/metadata`, Jellyfin // `/Users/{id}/Items/{id}`). - final mediaClient = _serverManager?.getClient(serverId); + final mediaClient = _clientFor(serverId); if (mediaClient == null) return; - final episodeMeta = await mediaClient.fetchItem(ratingKey); - if (episodeMeta == null) return; - season = episodeMeta.parentIndex; - number = episodeMeta.index; + final metadata = episodeMeta ?? await mediaClient.fetchItem(ratingKey); + if (metadata == null) return; + season = metadata.parentIndex; + number = metadata.index; if (season == null || number == null) return; - resolved = await resolver.resolveShowForEpisode(episodeMeta); + resolved = await resolver.resolveShowForEpisode(metadata, includeAnimeProgress: false); } if (resolved == null) { diff --git a/lib/utils/episode_collection.dart b/lib/utils/episode_collection.dart index 4d440be8..fb0774ed 100644 --- a/lib/utils/episode_collection.dart +++ b/lib/utils/episode_collection.dart @@ -53,10 +53,26 @@ Future _collectPlayable( MediaItem _withFallbackLibrary(MediaItem item, MediaItem? fallback) { if (fallback == null) return item; + final fallbackIsSeason = fallback.kind == MediaKind.season; + final fallbackIsShow = fallback.kind == MediaKind.show; return item.copyWith( serverId: item.serverId ?? fallback.serverId, serverName: item.serverName ?? fallback.serverName, libraryId: item.libraryId ?? fallback.libraryId, libraryTitle: item.libraryTitle ?? fallback.libraryTitle, + parentId: item.parentId ?? (fallbackIsSeason ? fallback.id : null), + parentTitle: item.parentTitle ?? (fallbackIsSeason ? fallback.title : null), + grandparentId: item.grandparentId ?? _fallbackGrandparentId(fallback, isShow: fallbackIsShow), + grandparentTitle: item.grandparentTitle ?? _fallbackGrandparentTitle(fallback, isShow: fallbackIsShow), ); } + +String? _fallbackGrandparentId(MediaItem fallback, {required bool isShow}) { + if (isShow) return fallback.id; + return fallback.grandparentId ?? fallback.parentId; +} + +String? _fallbackGrandparentTitle(MediaItem fallback, {required bool isShow}) { + if (isShow) return fallback.title; + return fallback.grandparentTitle ?? fallback.parentTitle; +} diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 57bf38fa..73778864 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -15,6 +15,7 @@ import '../mixins/controller_disposer_mixin.dart'; import '../services/plex_client.dart'; import '../services/media_list_playback_launcher.dart'; import '../services/playlist_items_loader.dart'; +import '../services/trackers/tracker_coordinator.dart'; import '../models/transcode_quality_preset.dart'; import '../utils/download_version_utils.dart'; import '../utils/download_utils.dart'; @@ -549,8 +550,12 @@ class MediaContextMenuState extends State { // hits /UserPlayedItems. WatchStateNotifier event is fired in both // paths so cross-screen UI updates regardless of backend. await _executeAction(context, () async { + final item = mediaItem; final client = context.tryGetMediaClientForServer(_itemServerId!); - if (client != null) await client.markWatched(mediaItem!); + if (client != null && item != null) { + await client.markWatched(item); + unawaited(TrackerCoordinator.instance.markWatched(item, client)); + } }, t.messages.markedAsWatched); } break; @@ -567,8 +572,12 @@ class MediaContextMenuState extends State { } } else { await _executeAction(context, () async { + final item = mediaItem; final client = context.tryGetMediaClientForServer(_itemServerId!); - if (client != null) await client.markUnwatched(mediaItem!); + if (client != null && item != null) { + await client.markUnwatched(item); + unawaited(TrackerCoordinator.instance.markUnwatched(item, client)); + } }, t.messages.markedAsUnwatched); } break; diff --git a/pubspec.lock b/pubspec.lock index 2af65071..5e102c41 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1485,7 +1485,7 @@ packages: source: hosted version: "1.1.0" xml: - dependency: transitive + dependency: "direct main" description: name: xml sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" diff --git a/pubspec.yaml b/pubspec.yaml index 25f09a34..345f81c3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,6 +71,7 @@ dependencies: win_http: ^0.2.0 collection: ^1.18.0 freezed_annotation: ^3.1.0 + xml: ^6.6.1 dev_dependencies: flutter_test: diff --git a/test/services/trackers/anilist_tracker_test.dart b/test/services/trackers/anilist_tracker_test.dart index 29f929ec..bb2f4271 100644 --- a/test/services/trackers/anilist_tracker_test.dart +++ b/test/services/trackers/anilist_tracker_test.dart @@ -128,5 +128,58 @@ void main() { expect(saved.single, {'mediaId': 21, 'progress': 12, 'status': 'CURRENT'}); }); + + test('episode unwatch is a no-op', () async { + final requests = []; + final client = MockClient((request) async { + requests.add(request); + fail('Unexpected ${request.method} ${request.url}'); + }); + tracker.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: client); + + await tracker.markUnwatched(_episode(animeProgress: 1)); + + expect(requests, isEmpty); + }); + + test('removeFromList removes anime entry', () async { + final variables = >[]; + final client = MockClient((request) async { + final body = json.decode(request.body) as Map; + final query = body['query'] as String; + variables.add((body['variables'] as Map).cast()); + if (query.contains('mediaListEntry')) { + return http.Response( + json.encode({ + 'data': { + 'Media': { + 'mediaListEntry': {'id': 99}, + }, + }, + }), + 200, + ); + } + if (query.contains('DeleteMediaListEntry')) { + return http.Response( + json.encode({ + 'data': { + 'DeleteMediaListEntry': {'deleted': true}, + }, + }), + 200, + ); + } + fail('Unexpected AniList query: $query'); + }); + tracker.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: client); + + await tracker.removeFromList(_episode()); + + expect(variables, [ + {'mediaId': 21}, + {'id': 99}, + ]); + }); }); } diff --git a/test/services/trackers/anime_episode_progress_resolver_test.dart b/test/services/trackers/anime_episode_progress_resolver_test.dart index e8b48cf4..7e1b5ac9 100644 --- a/test/services/trackers/anime_episode_progress_resolver_test.dart +++ b/test/services/trackers/anime_episode_progress_resolver_test.dart @@ -3,14 +3,17 @@ 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/anime_lists_mapping.dart'; import 'package:plezy/services/trackers/anime_episode_progress_resolver.dart'; class _FakeMediaServerClient implements MediaServerClient { final Map> childrenByParent; + final Map> playableByParent; Object? throwOnFetchChildren; int fetchChildrenCalls = 0; + int fetchPlayableDescendantsCalls = 0; - _FakeMediaServerClient(this.childrenByParent); + _FakeMediaServerClient(this.childrenByParent, {this.playableByParent = const {}}); @override Future> fetchChildren(String parentId) async { @@ -20,6 +23,12 @@ class _FakeMediaServerClient implements MediaServerClient { return childrenByParent[parentId] ?? const []; } + @override + Future> fetchPlayableDescendants(String parentId) async { + fetchPlayableDescendantsCalls++; + return playableByParent[parentId] ?? const []; + } + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } @@ -45,6 +54,17 @@ MediaItem _episode({int season = 2, int number = 6, String showId = 'show-1', in viewCount: viewCount, ); +AnimeEpisodeMatch _match({required int anidbId, required int serverEpisode, required int animeEpisode}) => + AnimeEpisodeMatch( + anidbId: anidbId, + anidbSeason: 1, + anidbEpisode: animeEpisode, + provider: AnimeListProvider.tvdb, + externalSeason: 1, + externalEpisode: serverEpisode, + kind: AnimeListMatchKind.range, + ); + void main() { group('AnimeEpisodeProgressResolver', () { test('show scope sums watched counts across regular seasons', () async { @@ -178,5 +198,58 @@ void main() { expect((await resolver.resolve(_episode(season: 1, number: 7), scope: AnimeProgressScope.season))?.progress, 7); expect(client.fetchChildrenCalls, 2); }); + + test('mapped scope counts only watched episodes in the selected anime entry', () async { + final client = _FakeMediaServerClient( + const {}, + playableByParent: { + 'show-1': [ + _episode(season: 1, number: 12, viewCount: 1), + _episode(season: 1, number: 13, viewCount: 1), + _episode(season: 1, number: 14), + ], + }, + ); + final resolver = AnimeEpisodeProgressResolver(client); + + final result = await resolver.resolve( + _episode(season: 1, number: 14), + scope: AnimeProgressScope.mapped, + animeMatch: _match(anidbId: 2, serverEpisode: 14, animeEpisode: 2), + episodeMatcher: (episode) async => switch (episode.index) { + 12 => _match(anidbId: 1, serverEpisode: 12, animeEpisode: 12), + 13 => _match(anidbId: 2, serverEpisode: 13, animeEpisode: 1), + 14 => _match(anidbId: 2, serverEpisode: 14, animeEpisode: 2), + _ => null, + }, + ); + + expect(result?.progress, 2); + expect(client.fetchPlayableDescendantsCalls, 1); + }); + + test('mapped scope can exclude the current episode for unwatch progress', () async { + final client = _FakeMediaServerClient( + const {}, + playableByParent: { + 'show-1': [_episode(season: 1, number: 13, viewCount: 1), _episode(season: 1, number: 14)], + }, + ); + final resolver = AnimeEpisodeProgressResolver(client); + + final result = await resolver.resolve( + _episode(season: 1, number: 14), + scope: AnimeProgressScope.mapped, + animeMatch: _match(anidbId: 2, serverEpisode: 14, animeEpisode: 2), + includeCurrentEpisode: false, + episodeMatcher: (episode) async => switch (episode.index) { + 13 => _match(anidbId: 2, serverEpisode: 13, animeEpisode: 1), + 14 => _match(anidbId: 2, serverEpisode: 14, animeEpisode: 2), + _ => null, + }, + ); + + expect(result?.progress, 1); + }); }); } diff --git a/test/services/trackers/anime_lists_mapping_store_test.dart b/test/services/trackers/anime_lists_mapping_store_test.dart new file mode 100644 index 00000000..39096723 --- /dev/null +++ b/test/services/trackers/anime_lists_mapping_store_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/trackers/anime_lists_mapping.dart'; +import 'package:plezy/services/trackers/anime_lists_mapping_store.dart'; + +void main() { + group('AnimeListsMappingStore parser', () { + test('parses defaults, offsets, ranges, explicit mappings, and absolute seasons', () { + final index = parseAnimeListsIndex(''' + + + First + + + ;1-3;2-0;3-4+5; + + + +'''); + + final entry = index.byTvdb[123]!.single; + + expect(entry.anidbId, 1); + expect(entry.name, 'First'); + expect(entry.defaultTvdbSeason?.number, 1); + expect(entry.episodeOffset, 2); + expect(entry.tmdbSeason?.isAbsolute, isTrue); + expect(entry.tmdbMovieIds, [10, 11]); + expect(entry.imdbIds, ['tt1', 'tt2']); + + final range = entry + .resolveEpisode(provider: AnimeListProvider.tvdb, externalSeason: 1, externalEpisode: 14) + .single; + expect(range.anidbEpisode, 2); + expect(range.kind, AnimeListMatchKind.range); + + final explicit = entry + .resolveEpisode(provider: AnimeListProvider.tvdb, externalSeason: 0, externalEpisode: 5) + .single; + expect(explicit.anidbSeason, 0); + expect(explicit.anidbEpisode, 3); + expect(explicit.kind, AnimeListMatchKind.explicit); + }); + + test('default offset maps external episodes back to AniDB local episodes', () { + final index = parseAnimeListsIndex(''' + + + Second Cour + + +'''); + + final match = lookupAnimeListEpisodeInIndex(index, tvdbId: 123, season: 1, episodeNumber: 14); + + expect(match?.anidbId, 1); + expect(match?.anidbEpisode, 2); + expect(match?.kind, AnimeListMatchKind.defaultMapping); + }); + + test('same TVDB season split across two AniDB entries resolves by range', () { + final index = parseAnimeListsIndex(''' + + + Cour 1 + + + + + + Cour 2 + + + + + +'''); + + final first = lookupAnimeListEpisodeInIndex(index, tvdbId: 123, season: 1, episodeNumber: 12); + final second = lookupAnimeListEpisodeInIndex(index, tvdbId: 123, season: 1, episodeNumber: 14); + + expect(first?.anidbId, 1); + expect(first?.anidbEpisode, 12); + expect(second?.anidbId, 2); + expect(second?.anidbEpisode, 2); + }); + + test('ambiguous same-priority matches do not guess', () { + final index = parseAnimeListsIndex(''' + + A + B + +'''); + + final match = lookupAnimeListEpisodeInIndex(index, tvdbId: 123, season: 1, episodeNumber: 1); + + expect(match, isNull); + }); + }); +} diff --git a/test/services/trackers/mal_tracker_test.dart b/test/services/trackers/mal_tracker_test.dart index 8bb9a1e6..457e53cc 100644 --- a/test/services/trackers/mal_tracker_test.dart +++ b/test/services/trackers/mal_tracker_test.dart @@ -84,5 +84,34 @@ void main() { final put = requests.singleWhere((request) => request.method == 'PUT'); expect(Uri.splitQueryString(put.body), {'status': 'watching', 'num_watched_episodes': '12'}); }); + + test('episode unwatch is a no-op', () async { + final requests = []; + final client = MockClient((request) async { + requests.add(request); + fail('Unexpected ${request.method} ${request.url}'); + }); + tracker.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: client); + + await tracker.markUnwatched(_episode(animeProgress: 1)); + + expect(requests, isEmpty); + }); + + test('removeFromList removes anime entry', () async { + final requests = []; + final client = MockClient((request) async { + requests.add(request); + if (request.method == 'DELETE') return http.Response('{}', 200); + fail('Unexpected ${request.method} ${request.url}'); + }); + tracker.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: client); + + await tracker.removeFromList(_episode()); + + final delete = requests.single; + expect(delete.method, 'DELETE'); + expect(delete.url.path, '/v2/anime/21/my_list_status'); + }); }); } diff --git a/test/services/trackers/tracker_coordinator_manual_test.dart b/test/services/trackers/tracker_coordinator_manual_test.dart new file mode 100644 index 00000000..c4432376 --- /dev/null +++ b/test/services/trackers/tracker_coordinator_manual_test.dart @@ -0,0 +1,499 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.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/anime_lists_mapping.dart'; +import 'package:plezy/models/trackers/fribb_mapping_row.dart'; +import 'package:plezy/services/trackers/anime_lists_mapping_store.dart'; +import 'package:plezy/services/trackers/anilist/anilist_session.dart'; +import 'package:plezy/services/trackers/anilist/anilist_tracker.dart'; +import 'package:plezy/services/trackers/fribb_mapping_store.dart'; +import 'package:plezy/services/trackers/mal/mal_session.dart'; +import 'package:plezy/services/trackers/mal/mal_tracker.dart'; +import 'package:plezy/services/trackers/simkl/simkl_session.dart'; +import 'package:plezy/services/trackers/simkl/simkl_tracker.dart'; +import 'package:plezy/services/trackers/tracker_coordinator.dart'; +import 'package:plezy/utils/external_ids.dart'; + +class _FakeMediaServerClient implements MediaServerClient { + @override + final String serverId; + @override + String? get serverName => null; + + final Map externalIdsByItem; + final Map> descendantsByParent; + final List externalIdCalls = []; + final List descendantCalls = []; + + _FakeMediaServerClient({ + this.serverId = 'server-1', + required this.externalIdsByItem, + required this.descendantsByParent, + }); + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + Future fetchExternalIds(String itemId) async { + externalIdCalls.add(itemId); + return externalIdsByItem[itemId] ?? const ExternalIds(); + } + + @override + Future> fetchPlayableDescendants(String parentId) async { + descendantCalls.add(parentId); + return descendantsByParent[parentId] ?? const []; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeFribbLookup implements FribbMappingLookup { + final List rows; + + const _FakeFribbLookup(this.rows); + + @override + Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async => rows; +} + +class _FakeAnimeListsLookup implements AnimeListsMappingLookup { + final Map matches; + + const _FakeAnimeListsLookup({this.matches = const {}}); + + @override + Future lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber}) async { + return matches['$season-$episodeNumber']; + } + + @override + Future> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season}) async => const {}; + + @override + Future> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async => const {}; +} + +MediaItem _season() => MediaItem( + id: 'season-1', + backend: MediaBackend.plex, + kind: MediaKind.season, + title: 'Season 1', + serverId: 'server-1', + libraryId: 'lib-1', + index: 1, + parentId: 'show-1', +); + +MediaItem _episode(int number, {int season = 1}) => MediaItem( + id: 'episode-$season-$number', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode $number', + serverId: 'server-1', + libraryId: 'lib-1', + parentIndex: season, + index: number, +); + +MediaItem _show() => MediaItem( + id: 'show-1', + backend: MediaBackend.plex, + kind: MediaKind.show, + title: 'Show 1', + serverId: 'server-1', + libraryId: 'lib-1', +); + +AnimeEpisodeMatch _match({required int anidbId, required int serverEpisode, required int animeEpisode}) => + AnimeEpisodeMatch( + anidbId: anidbId, + anidbSeason: 1, + anidbEpisode: animeEpisode, + provider: AnimeListProvider.tvdb, + externalSeason: 1, + externalEpisode: serverEpisode, + kind: AnimeListMatchKind.range, + ); + +SimklSession _simklSession() { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return SimklSession(accessToken: 'token', createdAt: now); +} + +MalSession _malSession() { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return MalSession(accessToken: 'token', refreshToken: 'refresh', expiresAt: now + 86400, createdAt: now); +} + +AnilistSession _anilistSession() { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return AnilistSession(accessToken: 'token', expiresAt: now + 86400, createdAt: now); +} + +void main() { + group('TrackerCoordinator manual watched sync', () { + final coordinator = TrackerCoordinator.instance; + final simkl = SimklTracker.instance; + final mal = MalTracker.instance; + final anilist = AnilistTracker.instance; + + setUp(() async { + await mal.setEnabled(false); + await anilist.setEnabled(false); + await simkl.setEnabled(true); + }); + + tearDown(() async { + coordinator.cancelInFlight(); + coordinator.debugUseResolverDependencies(); + mal.rebindSession(null, onSessionInvalidated: () {}); + anilist.rebindSession(null, onSessionInvalidated: () {}); + simkl.rebindSession(null, onSessionInvalidated: () {}); + await mal.setEnabled(false); + await anilist.setEnabled(false); + await simkl.setEnabled(false); + }); + + test('expands a manually watched season and fills missing episode show context', () async { + final bodies = >[]; + final httpClient = MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/sync/history'); + bodies.add((json.decode(request.body) as Map).cast()); + return http.Response('{}', 200); + }); + simkl.rebindSession(_simklSession(), onSessionInvalidated: () {}, httpClient: httpClient); + + final client = _FakeMediaServerClient( + externalIdsByItem: {'show-1': const ExternalIds(tvdb: 12345)}, + descendantsByParent: { + 'season-1': [_episode(1), _episode(2)], + }, + ); + + await coordinator.markWatched(_season(), client); + + expect(client.descendantCalls, ['season-1']); + expect(client.externalIdCalls, ['show-1']); + expect(bodies, hasLength(2)); + expect(bodies[0]['shows'], [ + { + 'ids': {'tvdb': 12345}, + 'seasons': [ + { + 'number': 1, + 'episodes': [ + {'number': 1}, + ], + }, + ], + }, + ]); + expect(bodies[1]['shows'], [ + { + 'ids': {'tvdb': 12345}, + 'seasons': [ + { + 'number': 1, + 'episodes': [ + {'number': 2}, + ], + }, + ], + }, + ]); + }); + + test('groups manually watched split seasons into separate anime entries', () async { + await simkl.setEnabled(false); + await mal.setEnabled(true); + await anilist.setEnabled(true); + coordinator.debugUseResolverDependencies( + store: const _FakeFribbLookup([ + FribbMappingRow(tvdbId: 12345, malId: 101, anilistId: 201, tvdbSeason: 1, type: 'TV'), + FribbMappingRow(tvdbId: 12345, malId: 102, anilistId: 202, tvdbSeason: 2, type: 'TV'), + ]), + animeLists: const _FakeAnimeListsLookup(), + ); + + final malUpdates = >{}; + final malHttp = MockClient((request) async { + final malId = int.parse(request.url.pathSegments[2]); + if (request.method == 'GET') { + return http.Response(json.encode({'num_episodes': 2}), 200); + } + expect(request.method, 'PUT'); + malUpdates[malId] = Uri.splitQueryString(request.body); + return http.Response('{}', 200); + }); + mal.rebindSession(_malSession(), onSessionInvalidated: () {}, httpClient: malHttp); + + final anilistSaves = >[]; + final anilistHttp = MockClient((request) async { + final body = json.decode(request.body) as Map; + final query = body['query'] as String; + if (query.contains('Media(id:')) { + return http.Response( + json.encode({ + 'data': { + 'Media': {'episodes': 2}, + }, + }), + 200, + ); + } + if (query.contains('SaveMediaListEntry')) { + anilistSaves.add((body['variables'] as Map).cast()); + 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(tvdb: 12345)}, + descendantsByParent: { + 'show-1': [_episode(1, season: 1), _episode(2, season: 1), _episode(1, season: 2), _episode(2, season: 2)], + }, + ); + + await coordinator.markWatched(_show(), client); + + expect(malUpdates, { + 101: {'status': 'completed', 'num_watched_episodes': '2'}, + 102: {'status': 'completed', 'num_watched_episodes': '2'}, + }); + expect(anilistSaves, contains(equals({'mediaId': 201, 'progress': 2, 'status': 'COMPLETED'}))); + expect(anilistSaves, contains(equals({'mediaId': 202, 'progress': 2, 'status': 'COMPLETED'}))); + }); + + test('groups manually watched same-season split cours by Anime-Lists ranges', () async { + await simkl.setEnabled(false); + await mal.setEnabled(true); + await anilist.setEnabled(true); + coordinator.debugUseResolverDependencies( + store: const _FakeFribbLookup([ + FribbMappingRow(anidbId: 111, tvdbId: 12345, malId: 101, anilistId: 201, tvdbSeason: 1, type: 'TV'), + FribbMappingRow(anidbId: 222, tvdbId: 12345, malId: 102, anilistId: 202, tvdbSeason: 1, type: 'TV'), + ]), + animeLists: _FakeAnimeListsLookup( + matches: { + '1-1': _match(anidbId: 111, serverEpisode: 1, animeEpisode: 1), + '1-2': _match(anidbId: 111, serverEpisode: 2, animeEpisode: 2), + '1-13': _match(anidbId: 222, serverEpisode: 13, animeEpisode: 1), + '1-14': _match(anidbId: 222, serverEpisode: 14, animeEpisode: 2), + }, + ), + ); + + final malUpdates = >{}; + final malHttp = MockClient((request) async { + final malId = int.parse(request.url.pathSegments[2]); + if (request.method == 'GET') { + return http.Response(json.encode({'num_episodes': 2}), 200); + } + expect(request.method, 'PUT'); + malUpdates[malId] = Uri.splitQueryString(request.body); + return http.Response('{}', 200); + }); + mal.rebindSession(_malSession(), onSessionInvalidated: () {}, httpClient: malHttp); + + final anilistSaves = >[]; + final anilistHttp = MockClient((request) async { + final body = json.decode(request.body) as Map; + final query = body['query'] as String; + if (query.contains('Media(id:')) { + return http.Response( + json.encode({ + 'data': { + 'Media': {'episodes': 2}, + }, + }), + 200, + ); + } + if (query.contains('SaveMediaListEntry')) { + anilistSaves.add((body['variables'] as Map).cast()); + 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(tvdb: 12345)}, + descendantsByParent: { + 'show-1': [_episode(1), _episode(2), _episode(13), _episode(14)], + }, + ); + + await coordinator.markWatched(_show(), client); + + expect(malUpdates, { + 101: {'status': 'completed', 'num_watched_episodes': '2'}, + 102: {'status': 'completed', 'num_watched_episodes': '2'}, + }); + expect(anilistSaves, contains(equals({'mediaId': 201, 'progress': 2, 'status': 'COMPLETED'}))); + expect(anilistSaves, contains(equals({'mediaId': 202, 'progress': 2, 'status': 'COMPLETED'}))); + }); + + test('removes manually unwatched season episodes from Simkl history', () async { + final bodies = >[]; + final httpClient = MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/sync/history/remove'); + bodies.add((json.decode(request.body) as Map).cast()); + return http.Response('{}', 200); + }); + simkl.rebindSession(_simklSession(), onSessionInvalidated: () {}, httpClient: httpClient); + + final client = _FakeMediaServerClient( + externalIdsByItem: {'show-1': const ExternalIds(tvdb: 12345)}, + descendantsByParent: { + 'season-1': [_episode(1), _episode(2)], + }, + ); + + await coordinator.markUnwatched(_season(), client); + + expect(client.descendantCalls, ['season-1']); + expect(bodies, hasLength(2)); + expect(bodies.first['shows'], [ + { + 'ids': {'tvdb': 12345}, + 'seasons': [ + { + 'number': 1, + 'episodes': [ + {'number': 1}, + ], + }, + ], + }, + ]); + }); + + test('removes manually unwatched split seasons from MAL and AniList lists', () async { + await simkl.setEnabled(false); + await mal.setEnabled(true); + await anilist.setEnabled(true); + coordinator.debugUseResolverDependencies( + store: const _FakeFribbLookup([ + FribbMappingRow(anidbId: 111, tvdbId: 12345, malId: 101, anilistId: 201, tvdbSeason: 1, type: 'TV'), + FribbMappingRow(anidbId: 222, tvdbId: 12345, malId: 102, anilistId: 202, tvdbSeason: 1, type: 'TV'), + ]), + animeLists: _FakeAnimeListsLookup( + matches: { + '1-1': _match(anidbId: 111, serverEpisode: 1, animeEpisode: 1), + '1-2': _match(anidbId: 111, serverEpisode: 2, animeEpisode: 2), + '1-13': _match(anidbId: 222, serverEpisode: 13, animeEpisode: 1), + '1-14': _match(anidbId: 222, serverEpisode: 14, animeEpisode: 2), + }, + ), + ); + + final malDeletes = []; + final malHttp = MockClient((request) async { + expect(request.method, 'DELETE'); + malDeletes.add(int.parse(request.url.pathSegments[2])); + return http.Response('{}', 200); + }); + mal.rebindSession(_malSession(), onSessionInvalidated: () {}, httpClient: malHttp); + + final anilistDeletes = []; + final anilistHttp = MockClient((request) async { + final body = json.decode(request.body) as Map; + final query = body['query'] as String; + final variables = (body['variables'] as Map).cast(); + if (query.contains('mediaListEntry')) { + final mediaId = variables['mediaId'] as int; + return http.Response( + json.encode({ + 'data': { + 'Media': { + 'mediaListEntry': {'id': mediaId + 100}, + }, + }, + }), + 200, + ); + } + if (query.contains('DeleteMediaListEntry')) { + anilistDeletes.add(variables['id'] as int); + return http.Response( + json.encode({ + 'data': { + 'DeleteMediaListEntry': {'deleted': true}, + }, + }), + 200, + ); + } + fail('Unexpected AniList query: $query'); + }); + anilist.rebindSession(_anilistSession(), onSessionInvalidated: () {}, httpClient: anilistHttp); + + final client = _FakeMediaServerClient( + externalIdsByItem: {'show-1': const ExternalIds(tvdb: 12345)}, + descendantsByParent: { + 'show-1': [_episode(1), _episode(2), _episode(13), _episode(14)], + }, + ); + + await coordinator.markUnwatched(_show(), client); + + expect(malDeletes, unorderedEquals([101, 102])); + expect(anilistDeletes, unorderedEquals([301, 302])); + }); + + test('playback resolver is recreated when the server client changes', () async { + simkl.rebindSession( + _simklSession(), + onSessionInvalidated: () {}, + httpClient: MockClient((_) async => http.Response('{}', 200)), + ); + + final firstClient = _FakeMediaServerClient( + serverId: 'server-a', + externalIdsByItem: {'show-a': const ExternalIds(tvdb: 111)}, + descendantsByParent: const {}, + ); + final secondClient = _FakeMediaServerClient( + serverId: 'server-b', + externalIdsByItem: {'show-b': const ExternalIds(tvdb: 222)}, + descendantsByParent: const {}, + ); + final firstEpisode = _episode(1).copyWith(id: 'episode-a', serverId: 'server-a', grandparentId: 'show-a'); + final secondEpisode = _episode(1).copyWith(id: 'episode-b', serverId: 'server-b', grandparentId: 'show-b'); + + await coordinator.startPlayback(firstEpisode, firstClient); + await coordinator.startPlayback(secondEpisode, secondClient); + + expect(firstClient.externalIdCalls, ['show-a']); + expect(secondClient.externalIdCalls, ['show-b']); + }); + }); +} diff --git a/test/services/trackers/tracker_id_resolver_test.dart b/test/services/trackers/tracker_id_resolver_test.dart index ea980ad3..c946e244 100644 --- a/test/services/trackers/tracker_id_resolver_test.dart +++ b/test/services/trackers/tracker_id_resolver_test.dart @@ -3,8 +3,10 @@ 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/anime_lists_mapping.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/anime_lists_mapping_store.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'; @@ -44,15 +46,25 @@ class _FakeAnimeProgressLookup implements AnimeEpisodeProgressLookup { int clearCalls = 0; MediaItem? lastEpisode; AnimeProgressScope? lastScope; + AnimeEpisodeMatch? lastMatch; + bool? lastIncludeCurrentEpisode; _FakeAnimeProgressLookup(int? progress) : result = progress == null ? null : ResolvedAnimeProgress(progress: progress); @override - Future resolve(MediaItem episode, {required AnimeProgressScope scope}) async { + Future resolve( + MediaItem episode, { + required AnimeProgressScope scope, + AnimeEpisodeMatch? animeMatch, + Future Function(MediaItem episode)? episodeMatcher, + bool includeCurrentEpisode = true, + }) async { resolveCalls++; lastEpisode = episode; lastScope = scope; + lastMatch = animeMatch; + lastIncludeCurrentEpisode = includeCurrentEpisode; return result; } @@ -62,6 +74,23 @@ class _FakeAnimeProgressLookup implements AnimeEpisodeProgressLookup { } } +class _FakeAnimeListsLookup implements AnimeListsMappingLookup { + final Map matches; + + const _FakeAnimeListsLookup({this.matches = const {}}); + + @override + Future lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber}) async { + return matches['$season-$episodeNumber']; + } + + @override + Future> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season}) async => const {}; + + @override + Future> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async => const {}; +} + MediaItem _episode({int season = 23, int number = 6}) => MediaItem( id: 'episode-$season-$number', backend: MediaBackend.plex, @@ -76,14 +105,27 @@ TrackerIdResolver _resolver({ required List rows, required _FakeAnimeProgressLookup animeProgress, _FakeFribbLookup? lookup, + AnimeListsMappingLookup animeLists = const _FakeAnimeListsLookup(), }) { return TrackerIdResolver( _FakeMediaServerClient({'show-1': const ExternalIds(tvdb: 81797, tmdb: 37854, imdb: 'tt0388629')}), store: lookup ?? _FakeFribbLookup(rows), + animeLists: animeLists, animeProgress: animeProgress, ); } +AnimeEpisodeMatch _match({required int anidbId, required int serverEpisode, required int animeEpisode}) => + AnimeEpisodeMatch( + anidbId: anidbId, + anidbSeason: 1, + anidbEpisode: animeEpisode, + provider: AnimeListProvider.tvdb, + externalSeason: 1, + externalEpisode: serverEpisode, + kind: AnimeListMatchKind.range, + ); + void main() { group('TrackerIdResolver anime progress', () { test('one unseasoned regular TV row uses show-scope progress', () async { @@ -176,5 +218,68 @@ void main() { expect(animeProgress.clearCalls, 1); expect(animeProgress.resolveCalls, 2); }); + + test('same server season can select different anime entries by episode range', () async { + final animeProgress = _FakeAnimeProgressLookup(2); + final resolver = _resolver( + animeProgress: animeProgress, + animeLists: _FakeAnimeListsLookup(matches: {'1-14': _match(anidbId: 222, serverEpisode: 14, animeEpisode: 2)}), + rows: const [ + FribbMappingRow(anidbId: 111, tvdbId: 81797, malId: 101, tvdbSeason: 1, type: 'TV'), + FribbMappingRow(anidbId: 222, tvdbId: 81797, malId: 102, tvdbSeason: 1, type: 'TV'), + ], + ); + + final ids = await resolver.resolveShowForEpisode(_episode(season: 1, number: 14)); + + expect(ids?.anime?.mal, 102); + expect(ids?.animeProgressScope, AnimeProgressScope.mapped); + expect(ids?.animeEpisodeNumber, 2); + expect(ids?.animeProgress, 2); + expect(animeProgress.lastScope, AnimeProgressScope.mapped); + expect(animeProgress.lastMatch?.anidbId, 222); + }); + + test('passes includeCurrentEpisode through for unwatch progress', () async { + final animeProgress = _FakeAnimeProgressLookup(1); + final resolver = _resolver( + animeProgress: animeProgress, + animeLists: _FakeAnimeListsLookup(matches: {'1-14': _match(anidbId: 222, serverEpisode: 14, animeEpisode: 2)}), + rows: const [FribbMappingRow(anidbId: 222, tvdbId: 81797, malId: 102, tvdbSeason: 1, type: 'TV')], + ); + + final ids = await resolver.resolveShowForEpisode(_episode(season: 1, number: 14), includeCurrentEpisode: false); + + expect(ids?.animeProgress, 1); + expect(animeProgress.lastIncludeCurrentEpisode, isFalse); + }); + + test('episode-aware cache does not reuse a same-season split-cour row', () async { + final animeProgress = _FakeAnimeProgressLookup(null); + final lookup = _FakeFribbLookup(const [ + FribbMappingRow(anidbId: 111, tvdbId: 81797, malId: 101, tvdbSeason: 1, type: 'TV'), + FribbMappingRow(anidbId: 222, tvdbId: 81797, malId: 102, tvdbSeason: 1, type: 'TV'), + ]); + final client = _FakeMediaServerClient({'show-1': const ExternalIds(tvdb: 81797)}); + final resolver = TrackerIdResolver( + client, + store: lookup, + animeLists: _FakeAnimeListsLookup( + matches: { + '1-12': _match(anidbId: 111, serverEpisode: 12, animeEpisode: 12), + '1-13': _match(anidbId: 222, serverEpisode: 13, animeEpisode: 1), + }, + ), + animeProgress: animeProgress, + ); + + final first = await resolver.resolveShowForEpisode(_episode(season: 1, number: 12), includeAnimeProgress: false); + final second = await resolver.resolveShowForEpisode(_episode(season: 1, number: 13), includeAnimeProgress: false); + + expect(first?.anime?.mal, 101); + expect(second?.anime?.mal, 102); + expect(client.externalIdCalls, ['show-1']); + expect(lookup.lookups, 2); + }); }); }