feat(trackers): sync anime watch state by episode

This commit is contained in:
edde746
2026-05-16 20:54:39 +02:00
parent 310c4280fc
commit 3e74937386
28 changed files with 2203 additions and 78 deletions
@@ -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<int> tmdbMovieIds;
final List<String> imdbIds;
final List<AnimeListEpisodeMapping> 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<AnimeEpisodeMatch> resolveEpisode({
required AnimeListProvider provider,
required int externalSeason,
required int externalEpisode,
}) {
final explicit = <AnimeEpisodeMatch>[];
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 = <AnimeEpisodeMatch>[];
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<AnimeListExplicitEpisodeMapping> 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<int> 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;
}
@@ -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,
+12 -12
View File
@@ -6,15 +6,15 @@ part of 'fribb_mapping_row.dart';
// JsonSerializableGenerator
// **************************************************************************
FribbMappingRow _$FribbMappingRowFromJson(Map<String, dynamic> 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<String, dynamic> 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?,
);
@@ -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;
@@ -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;
}
@@ -50,6 +50,34 @@ class AnilistClient {
await query(mutation, variables: {'mediaId': mediaId, 'progress': progress, 'status': status});
}
Future<void> 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<void> setMediaListScore({required int mediaId, required int score}) async {
const mutation = '''
mutation(\$mediaId: Int, \$scoreRaw: Int) {
@@ -64,6 +64,21 @@ class AnilistTracker extends TrackerBase {
appLogger.d('AniList: saved entry (anilist=$anilistId, progress=$watched, status=$status)');
}
@override
Future<void> markUnwatched(TrackerContext ctx) async {
if (ctx.isMovie) {
await removeFromList(ctx);
}
}
Future<void> 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<void> rate(TrackerRatingContext ctx, int score) async {
final client = _client;
final anilistId = ctx.ids.anime?.anilist;
@@ -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<ResolvedAnimeProgress?> resolve(MediaItem episode, {required AnimeProgressScope scope});
Future<ResolvedAnimeProgress?> resolve(
MediaItem episode, {
required AnimeProgressScope scope,
AnimeEpisodeMatch? animeMatch,
Future<AnimeEpisodeMatch?> Function(MediaItem episode)? episodeMatcher,
bool includeCurrentEpisode = true,
});
void clearCache();
}
@@ -27,22 +35,68 @@ class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup {
AnimeEpisodeProgressResolver(this._client);
@override
Future<ResolvedAnimeProgress?> resolve(MediaItem episode, {required AnimeProgressScope scope}) async {
Future<ResolvedAnimeProgress?> resolve(
MediaItem episode, {
required AnimeProgressScope scope,
AnimeEpisodeMatch? animeMatch,
Future<AnimeEpisodeMatch?> 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<ResolvedAnimeProgress?> _mappedProgress(
String showId,
MediaItem current,
AnimeEpisodeMatch target,
Future<AnimeEpisodeMatch?> 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<Map<int, _SeasonProgress>?> _seasonProgressFor(String showId) async {
final existing = _seasonProgressLoads[showId];
if (existing != null) return existing;
@@ -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<int, List<AnimeListEntry>> byTvdb;
final Map<int, List<AnimeListEntry>> byTmdbTv;
const AnimeListsIndex({required this.byTvdb, required this.byTmdbTv});
bool get isEmpty => byTvdb.isEmpty && byTmdbTv.isEmpty;
}
abstract interface class AnimeListsMappingLookup {
Future<AnimeEpisodeMatch?> lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber});
Future<Set<int>> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season});
Future<Set<int>> 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<AnimeListsIndex>? _loading;
bool _refreshRunning = false;
Future<AnimeListsIndex> _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<AnimeListsIndex> _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<String?> _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<AnimeEpisodeMatch?> 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<Set<int>> 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 <int>{};
}
@override
Future<Set<int>> 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 <int>{};
}
Future<void> 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<void> _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<void> _deleteDiskCopy() async {
try {
await File(await _diskPath()).delete();
} on FileSystemException {
// Already gone.
}
}
Future<String> _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<AnimeEpisodeMatch> _matches(
List<AnimeListEntry>? 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<AnimeEpisodeMatch> 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<int> _seasonAnimeIds(List<AnimeListEntry>? entries, AnimeListProvider provider, int season) {
if (entries == null || entries.isEmpty) return const <int>{};
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 = <int, List<AnimeListEntry>>{};
final byTmdbTv = <int, List<AnimeListEntry>>{};
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] ??= <AnimeListEntry>[]).add(entry);
final tmdbTv = entry.tmdbTvId;
if (tmdbTv != null) (byTmdbTv[tmdbTv] ??= <AnimeListEntry>[]).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<int> _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<String> _stringList(String? value) {
if (value == null || value.isEmpty) return const [];
return [
for (final part in value.split(','))
if (part.trim().isNotEmpty) part.trim(),
];
}
List<AnimeListEpisodeMapping> _parseMappings(XmlElement anime) {
final list = anime.getElement('mapping-list');
if (list == null) return const [];
final mappings = <AnimeListEpisodeMapping>[];
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<AnimeListExplicitEpisodeMapping> _parseExplicitMappings(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) return const [];
final mappings = <AnimeListExplicitEpisodeMapping>[];
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 = <int>[];
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;
}
@@ -60,6 +60,10 @@ class MalClient {
await _request('PUT', '/anime/$animeId/my_list_status', formBody: fields);
}
Future<void> deleteMyListStatus(int animeId) async {
await _request('DELETE', '/anime/$animeId/my_list_status');
}
Future<int?> getMyListScore(int animeId) async {
try {
final res = await _request('GET', '/anime/$animeId?fields=my_list_status');
@@ -81,6 +81,30 @@ class MalTracker extends TrackerBase {
appLogger.d('MAL: updated list status (mal=$malId, fields=$fields)');
}
@override
Future<void> markUnwatched(TrackerContext ctx) async {
if (ctx.isMovie) {
await removeFromList(ctx);
}
}
Future<void> removeFromList(TrackerContext ctx) async {
final client = _client;
final malId = ctx.anime?.mal;
if (client == null || malId == null) return;
await _deleteMyListStatus(client, malId);
}
Future<void> _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<void> rate(TrackerRatingContext ctx, int score) async {
final client = _client;
final malId = ctx.ids.anime?.mal;
@@ -39,6 +39,8 @@ class SimklClient {
/// ```
Future<void> addToHistory(Map<String, dynamic> body) => _request('POST', '/sync/history', body: body);
Future<void> removeFromHistory(Map<String, dynamic> body) => _request('POST', '/sync/history/remove', body: body);
Future<void> addRatings(Map<String, dynamic> body) => _request('POST', '/sync/ratings', body: body);
Future<void> removeRatings(Map<String, dynamic> body) => _request('POST', '/sync/ratings/remove', body: body);
+20 -4
View File
@@ -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<void> 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<String, dynamic> _historyBody(TrackerContext ctx, Map<String, Object> 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<int?> getRating(TrackerRatingContext ctx) async {
+1
View File
@@ -30,6 +30,7 @@ abstract class Tracker {
bool shouldScrobbleForLibrary(String? libraryGlobalKey);
Future<void> markWatched(TrackerContext ctx);
Future<void> markUnwatched(TrackerContext ctx);
}
class TrackerRatingUnavailableException implements Exception {
+304 -10
View File
@@ -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<void> 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<void> 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<void> _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 = <MediaItem>[];
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<void> _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 = <MediaItem>[];
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<void> _markContainerEpisodesWatched(List<MediaItem> episodes, TrackerIdResolver resolver) async {
final animeGroups = <String, _ManualAnimeProgress>{};
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<void> _markContainerEpisodesUnwatched(List<MediaItem> episodes, TrackerIdResolver resolver) async {
final malEntries = <int, TrackerContext>{};
final anilistEntries = <int, TrackerContext>{};
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<void> _removeAnimeEntriesFromLists(
Iterable<TrackerContext> malEntries,
Iterable<TrackerContext> 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<void> _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<void> _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<void> 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<void> _dispatchMarkWatched(TrackerContext ctx) async {
final active = _trackers.where((t) => t.canScrobble && t.shouldScrobbleForLibrary(ctx.libraryGlobalKey));
await _dispatchToTrackers(active, ctx);
}
Future<void> _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<void> _dispatchToTrackers(Iterable<Tracker> 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<TrackerContext?> _buildContext(MediaItem metadata) async {
final resolver = _resolver;
if (resolver == null) return null;
Future<void> _dispatchUnwatchedToTrackers(Iterable<Tracker> 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<TrackerContext?> _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,
);
}
}
+154 -24
View File
@@ -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<String, TrackerIds?> _cache = {};
final Map<String, Future<ExternalIds>> _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<ExternalIds> _fetchExternalIds(String itemId) => _client.fetchExternalIds(itemId);
Future<ExternalIds> _fetchExternalIds(String itemId) {
final existing = _externalIdLoads[itemId];
if (existing != null) return existing;
late final Future<ExternalIds> 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<TrackerIds?> resolveForMovie(String itemId) async {
if (_cache.containsKey(itemId)) return _cache[itemId];
final external = await _fetchExternalIds(itemId);
final ids = await _build(external, isEpisodeSeason: null, isMovie: true);
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<TrackerIds?> resolveShowForEpisode(MediaItem episode) async {
/// Anime-Lists episode mappings first and season mappings as fallback.
Future<TrackerIds?> resolveShowForEpisode(
MediaItem episode, {
bool includeAnimeProgress = true,
bool includeCurrentEpisode = true,
}) async {
final showId = episode.grandparentId;
if (showId == null || showId.isEmpty) return null;
final season = episode.parentIndex;
// 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<TrackerIds?> _resolveShow(String showId, {int? season}) async {
Future<TrackerIds?> _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<TrackerIds?> _build(ExternalIds external, {int? isEpisodeSeason, required bool isMovie}) async {
Future<TrackerIds?> _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<TrackerIds?> _buildShowRating(ExternalIds external, {int? season}) async {
if (!external.hasAny) return null;
if (!_needsFribb()) return TrackerIds(external: external, anime: null);
final rows = await _store.lookup(tvdbId: external.tvdb, tmdbId: external.tmdb, imdbId: external.imdb);
FribbMappingRow? row;
final animeIds = season == null
? await _lookupAnimeIdsForShow(external)
: await _lookupAnimeIdsForSeason(external, season);
if (animeIds.length == 1) {
row = _rowForAnidb(rows, animeIds.single);
} else if (animeIds.isEmpty) {
row = _pickShowRow(rows, season: season, animeMatch: null);
}
return TrackerIds(external: external, anime: row == null ? null : AnimeIds.fromFribb(row));
}
Future<AnimeEpisodeMatch?> _lookupAnimeEpisodeMatch(ExternalIds external, MediaItem episode) async {
final season = episode.parentIndex;
final number = episode.index;
if (season == null || number == null) return null;
return _lookupAnimeEpisodeMatchByCoordinate(external, season, number);
}
Future<AnimeEpisodeMatch?> _lookupAnimeEpisodeMatchByCoordinate(
ExternalIds external,
int season,
int episodeNumber,
) async {
try {
return await _animeLists.lookupEpisode(
tvdbId: external.tvdb,
tmdbId: external.tmdb,
season: season,
episodeNumber: episodeNumber,
);
} catch (_) {
return null;
}
}
Future<Set<int>> _lookupAnimeIdsForSeason(ExternalIds external, int season) async {
try {
return await _animeLists.lookupAnimeIdsForSeason(tvdbId: external.tvdb, tmdbId: external.tmdb, season: season);
} catch (_) {
return const <int>{};
}
}
Future<Set<int>> _lookupAnimeIdsForShow(ExternalIds external) async {
try {
return await _animeLists.lookupAnimeIdsForShow(tvdbId: external.tvdb, tmdbId: external.tmdb);
} catch (_) {
return const <int>{};
}
}
/// Pick the best row for a movie lookup — prefer rows marked `type: MOVIE`.
FribbMappingRow? _pickMovieRow(List<FribbMappingRow> rows) {
if (rows.isEmpty) return null;
@@ -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<FribbMappingRow> rows, {int? season}) {
FribbMappingRow? _pickShowRow(List<FribbMappingRow> rows, {int? season, AnimeEpisodeMatch? animeMatch}) {
if (rows.isEmpty) return null;
final match = animeMatch;
if (match != null) return _rowForAnidb(rows, match.anidbId);
if (season != null) {
for (final row in rows) {
if (row.tvdbSeason == season || row.tmdbSeason == season) return row;
@@ -210,15 +331,24 @@ class TrackerIdResolver {
return rows.first;
}
FribbMappingRow? _rowForAnidb(List<FribbMappingRow> rows, int anidbId) {
for (final row in rows) {
if (row.anidbId == anidbId) return row;
}
return null;
}
AnimeProgressScope? _animeProgressScope({
required FribbMappingRow? selected,
required List<FribbMappingRow> rows,
required int? season,
required bool isMovie,
required AnimeEpisodeMatch? animeMatch,
}) {
if (isMovie) return null;
if (season == null || season <= 0) return null;
if (selected == null) return null;
if (animeMatch != null && selected.anidbId == animeMatch.anidbId) return AnimeProgressScope.mapped;
if (_hasSeasonMapping(selected)) {
final exactSeason = selected.tvdbSeason == season || selected.tmdbSeason == season;
return exactSeason && _isRegularSeriesRow(selected) ? AnimeProgressScope.season : null;
@@ -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(
+86 -18
View File
@@ -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<void> _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<void> _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 = <MediaItem>[];
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<void> _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) {
+16
View File
@@ -53,10 +53,26 @@ Future<void> _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;
}
+11 -2
View File
@@ -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<MediaContextMenu> {
// 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<MediaContextMenu> {
}
} 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;
+1 -1
View File
@@ -1485,7 +1485,7 @@ packages:
source: hosted
version: "1.1.0"
xml:
dependency: transitive
dependency: "direct main"
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
+1
View File
@@ -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:
@@ -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 = <http.Request>[];
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 = <Map<String, dynamic>>[];
final client = MockClient((request) async {
final body = json.decode(request.body) as Map<String, dynamic>;
final query = body['query'] as String;
variables.add((body['variables'] as Map).cast<String, dynamic>());
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},
]);
});
});
}
@@ -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<String, List<MediaItem>> childrenByParent;
final Map<String, List<MediaItem>> playableByParent;
Object? throwOnFetchChildren;
int fetchChildrenCalls = 0;
int fetchPlayableDescendantsCalls = 0;
_FakeMediaServerClient(this.childrenByParent);
_FakeMediaServerClient(this.childrenByParent, {this.playableByParent = const {}});
@override
Future<List<MediaItem>> fetchChildren(String parentId) async {
@@ -20,6 +23,12 @@ class _FakeMediaServerClient implements MediaServerClient {
return childrenByParent[parentId] ?? const [];
}
@override
Future<List<MediaItem>> 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);
});
});
}
@@ -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('''
<anime-list>
<anime anidbid="1" tvdbid="123" defaulttvdbseason="1" episodeoffset="2" tmdbtv="456" tmdbseason="a" tmdbid="10,11" imdbid="tt1,tt2">
<name>First</name>
<mapping-list>
<mapping anidbseason="1" tvdbseason="1" start="1" end="12" offset="12" />
<mapping anidbseason="0" tvdbseason="0">;1-3;2-0;3-4+5;</mapping>
</mapping-list>
</anime>
</anime-list>
''');
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('''
<anime-list>
<anime anidbid="1" tvdbid="123" defaulttvdbseason="1" episodeoffset="12">
<name>Second Cour</name>
</anime>
</anime-list>
''');
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('''
<anime-list>
<anime anidbid="1" tvdbid="123" defaulttvdbseason="1">
<name>Cour 1</name>
<mapping-list>
<mapping anidbseason="1" tvdbseason="1" start="1" end="12" />
</mapping-list>
</anime>
<anime anidbid="2" tvdbid="123" defaulttvdbseason="1">
<name>Cour 2</name>
<mapping-list>
<mapping anidbseason="1" tvdbseason="1" start="1" end="12" offset="12" />
</mapping-list>
</anime>
</anime-list>
''');
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('''
<anime-list>
<anime anidbid="1" tvdbid="123" defaulttvdbseason="1"><name>A</name></anime>
<anime anidbid="2" tvdbid="123" defaulttvdbseason="1"><name>B</name></anime>
</anime-list>
''');
final match = lookupAnimeListEpisodeInIndex(index, tvdbId: 123, season: 1, episodeNumber: 1);
expect(match, isNull);
});
});
}
@@ -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 = <http.Request>[];
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 = <http.Request>[];
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');
});
});
}
@@ -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<String, ExternalIds> externalIdsByItem;
final Map<String, List<MediaItem>> descendantsByParent;
final List<String> externalIdCalls = [];
final List<String> descendantCalls = [];
_FakeMediaServerClient({
this.serverId = 'server-1',
required this.externalIdsByItem,
required this.descendantsByParent,
});
@override
MediaBackend get backend => MediaBackend.plex;
@override
Future<ExternalIds> fetchExternalIds(String itemId) async {
externalIdCalls.add(itemId);
return externalIdsByItem[itemId] ?? const ExternalIds();
}
@override
Future<List<MediaItem>> 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<FribbMappingRow> rows;
const _FakeFribbLookup(this.rows);
@override
Future<List<FribbMappingRow>> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async => rows;
}
class _FakeAnimeListsLookup implements AnimeListsMappingLookup {
final Map<String, AnimeEpisodeMatch> matches;
const _FakeAnimeListsLookup({this.matches = const {}});
@override
Future<AnimeEpisodeMatch?> lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber}) async {
return matches['$season-$episodeNumber'];
}
@override
Future<Set<int>> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season}) async => const <int>{};
@override
Future<Set<int>> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async => const <int>{};
}
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 = <Map<String, dynamic>>[];
final httpClient = MockClient((request) async {
expect(request.method, 'POST');
expect(request.url.path, '/sync/history');
bodies.add((json.decode(request.body) as Map).cast<String, dynamic>());
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 = <int, Map<String, String>>{};
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 = <Map<String, dynamic>>[];
final anilistHttp = MockClient((request) async {
final body = json.decode(request.body) as Map<String, dynamic>;
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<String, dynamic>());
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 = <int, Map<String, String>>{};
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 = <Map<String, dynamic>>[];
final anilistHttp = MockClient((request) async {
final body = json.decode(request.body) as Map<String, dynamic>;
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<String, dynamic>());
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 = <Map<String, dynamic>>[];
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<String, dynamic>());
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 = <int>[];
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 = <int>[];
final anilistHttp = MockClient((request) async {
final body = json.decode(request.body) as Map<String, dynamic>;
final query = body['query'] as String;
final variables = (body['variables'] as Map).cast<String, dynamic>();
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']);
});
});
}
@@ -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<ResolvedAnimeProgress?> resolve(MediaItem episode, {required AnimeProgressScope scope}) async {
Future<ResolvedAnimeProgress?> resolve(
MediaItem episode, {
required AnimeProgressScope scope,
AnimeEpisodeMatch? animeMatch,
Future<AnimeEpisodeMatch?> 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<String, AnimeEpisodeMatch> matches;
const _FakeAnimeListsLookup({this.matches = const {}});
@override
Future<AnimeEpisodeMatch?> lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber}) async {
return matches['$season-$episodeNumber'];
}
@override
Future<Set<int>> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season}) async => const <int>{};
@override
Future<Set<int>> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async => const <int>{};
}
MediaItem _episode({int season = 23, int number = 6}) => MediaItem(
id: 'episode-$season-$number',
backend: MediaBackend.plex,
@@ -76,14 +105,27 @@ TrackerIdResolver _resolver({
required List<FribbMappingRow> 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);
});
});
}