fix(trackers): sync scoped anime progress

This commit is contained in:
edde746
2026-05-13 14:10:01 +02:00
parent fcbe459177
commit 126e6b9349
9 changed files with 600 additions and 28 deletions
+8
View File
@@ -15,6 +15,8 @@ class TrackerContext {
final bool isMovie;
final int? season;
final int? episodeNumber;
final int? animeProgress;
final bool animeProgressComplete;
/// Plex ratingKey of the item being played. Used only for logging — not
/// sent to any tracker.
@@ -32,6 +34,8 @@ class TrackerContext {
required this.libraryGlobalKey,
this.season,
this.episodeNumber,
this.animeProgress,
this.animeProgressComplete = false,
});
factory TrackerContext.movie({
@@ -56,6 +60,8 @@ class TrackerContext {
required String? libraryGlobalKey,
required int season,
required int episodeNumber,
int? animeProgress,
bool animeProgressComplete = false,
}) {
return TrackerContext._(
external: external,
@@ -65,6 +71,8 @@ class TrackerContext {
libraryGlobalKey: libraryGlobalKey,
season: season,
episodeNumber: episodeNumber,
animeProgress: animeProgress,
animeProgressComplete: animeProgressComplete,
);
}
}
@@ -43,11 +43,9 @@ class AnilistTracker extends TrackerBase {
final anilistId = ctx.anime?.anilist;
if (client == null || anilistId == null) return;
// AniList auto-promotes CURRENT → COMPLETED when progress == total, so we
// only need to send CURRENT for episodes. Movies are always a single-unit
// completion.
final progress = ctx.isMovie ? 1 : (ctx.episodeNumber ?? 0);
final status = ctx.isMovie ? 'COMPLETED' : 'CURRENT';
final progress = ctx.isMovie ? 1 : (ctx.animeProgress ?? ctx.episodeNumber);
if (progress == null || progress <= 0) return;
final status = ctx.isMovie || ctx.animeProgressComplete ? 'COMPLETED' : 'CURRENT';
await client.saveMediaListEntry(mediaId: anilistId, progress: progress, status: status);
appLogger.d('AniList: saved entry (anilist=$anilistId, progress=$progress, status=$status)');
@@ -0,0 +1,122 @@
import '../../media/media_item.dart';
import '../../media/media_kind.dart';
import '../../media/media_server_client.dart';
import '../../utils/app_logger.dart';
enum AnimeProgressScope { show, season }
class ResolvedAnimeProgress {
final int progress;
final bool isComplete;
const ResolvedAnimeProgress({required this.progress, required this.isComplete});
}
/// Resolves watched progress in the MAL/AniList anime entry selected by Fribb.
///
/// The coordinator builds tracker context before the current playback is marked
/// watched, so unwatched current episodes are added to the watched rollup.
abstract interface class AnimeEpisodeProgressLookup {
Future<ResolvedAnimeProgress?> resolve(MediaItem episode, {required AnimeProgressScope scope});
void clearCache();
}
class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup {
final MediaServerClient _client;
final Map<String, Future<Map<int, _SeasonProgress>?>> _seasonProgressLoads = {};
AnimeEpisodeProgressResolver(this._client);
@override
Future<ResolvedAnimeProgress?> resolve(MediaItem episode, {required AnimeProgressScope scope}) async {
final showId = episode.grandparentId;
final season = episode.parentIndex;
if (showId == null || showId.isEmpty) return null;
if (season == null || season <= 0) return null;
final progressBySeason = await _seasonProgressFor(showId);
if (progressBySeason == null) return null;
final currentAlreadyWatched = (episode.viewCount ?? 0) > 0;
return switch (scope) {
AnimeProgressScope.show => _showProgress(progressBySeason, currentAlreadyWatched),
AnimeProgressScope.season => _seasonProgress(progressBySeason[season], currentAlreadyWatched),
};
}
Future<Map<int, _SeasonProgress>?> _seasonProgressFor(String showId) async {
final existing = _seasonProgressLoads[showId];
if (existing != null) return existing;
final loading = _loadSeasonProgress(showId);
_seasonProgressLoads[showId] = loading;
final progress = await loading;
if (progress == null) {
final _ = _seasonProgressLoads.remove(showId);
}
return progress;
}
ResolvedAnimeProgress? _showProgress(Map<int, _SeasonProgress> seasons, bool currentAlreadyWatched) {
if (seasons.isEmpty) return null;
var watched = 0;
var total = 0;
var totalKnown = true;
for (final entry in seasons.entries) {
final season = entry.key;
if (season <= 0) continue;
watched += entry.value.watched;
final count = entry.value.total;
if (count != null && count > 0) {
total += count;
} else {
totalKnown = false;
}
}
final progress = watched + (currentAlreadyWatched ? 0 : 1);
if (progress <= 0) return null;
final isComplete = totalKnown && total > 0 && progress >= total;
return ResolvedAnimeProgress(progress: isComplete && progress > total ? total : progress, isComplete: isComplete);
}
ResolvedAnimeProgress? _seasonProgress(_SeasonProgress? season, bool currentAlreadyWatched) {
if (season == null) return null;
final progress = season.watched + (currentAlreadyWatched ? 0 : 1);
if (progress <= 0) return null;
final total = season.total;
final isComplete = total != null && total > 0 && progress >= total;
return ResolvedAnimeProgress(progress: isComplete && progress > total ? total : progress, isComplete: isComplete);
}
Future<Map<int, _SeasonProgress>?> _loadSeasonProgress(String showId) async {
try {
final children = await _client.fetchChildren(showId);
final progress = <int, _SeasonProgress>{};
for (final item in children) {
if (item.kind != MediaKind.season) continue;
final season = item.index;
if (season == null || season < 0) continue;
final watched = item.viewedLeafCount;
if (watched == null || watched < 0) continue;
final total = item.leafCount ?? item.childCount;
if (progress.containsKey(season)) return null;
progress[season] = _SeasonProgress(total: total, watched: watched);
}
return progress.isEmpty ? null : progress;
} catch (e) {
appLogger.d('Anime progress: failed to load season watched counts for $showId', error: e);
return null;
}
}
@override
void clearCache() => _seasonProgressLoads.clear();
}
class _SeasonProgress {
final int? total;
final int watched;
const _SeasonProgress({required this.total, required this.watched});
}
@@ -29,13 +29,17 @@ class FribbIndex {
bool get isEmpty => byTvdb.isEmpty && byTmdb.isEmpty && byImdb.isEmpty;
}
abstract interface class FribbMappingLookup {
Future<List<FribbMappingRow>> lookup({int? tvdbId, int? tmdbId, String? imdbId});
}
/// Loads and refreshes the Fribb anime-lists mapping on demand.
///
/// On first lookup the ~5 MB JSON is downloaded from jsDelivr and cached to
/// the app-support directory. Subsequent lookups read from the cache. Parsing
/// runs in a background isolate. [maybeRefresh] does a weekly conditional-GET
/// (If-None-Match) to pick up upstream changes.
class FribbMappingStore {
class FribbMappingStore implements FribbMappingLookup {
static const String _diskFileName = 'anime-list-mini.json';
static const String _prefsEtagKey = 'fribb_anime_list_etag';
static const String _prefsLastCheckKey = 'fribb_anime_list_last_check';
@@ -127,6 +131,7 @@ class FribbMappingStore {
/// Look up rows by Plex external IDs. Returns the first non-empty candidate
/// list in preference order: tvdb → tmdb → imdb.
@override
Future<List<FribbMappingRow>> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async {
final idx = await _ensureLoaded();
if (tvdbId != null) {
+10 -7
View File
@@ -11,10 +11,8 @@ import 'mal_session.dart';
///
/// MAL is anime-only: no-op when [TrackerContext.anime] is null.
///
/// For split-cour shows Fribb maps each cour to a distinct MAL ID; the
/// episode number sent here is the Plex episode index within the current
/// season, which is usually what MAL expects for the mapped entry. Episode
/// offsets for irregular cuts aren't in the mini mapping — known v1 gap.
/// For anime episodes, MAL receives watched progress in the mapped anime entry
/// when Fribb can define that scope, otherwise local episode progress.
class MalTracker extends TrackerBase {
static MalTracker? _instance;
static MalTracker get instance => _instance ??= MalTracker._();
@@ -54,9 +52,14 @@ class MalTracker extends TrackerBase {
final malId = ctx.anime?.mal;
if (client == null || malId == null) return;
final fields = ctx.isMovie
? {'status': 'completed', 'num_watched_episodes': '1'}
: {'status': 'watching', 'num_watched_episodes': '${ctx.episodeNumber}'};
final Map<String, String> fields;
if (ctx.isMovie) {
fields = {'status': 'completed', 'num_watched_episodes': '1'};
} else {
final progress = ctx.animeProgress ?? ctx.episodeNumber;
if (progress == null || progress <= 0) return;
fields = {'status': ctx.animeProgressComplete ? 'completed' : 'watching', 'num_watched_episodes': '$progress'};
}
await client.updateMyListStatus(malId, fields);
appLogger.d('MAL: updated list status (mal=$malId, fields=$fields)');
@@ -160,6 +160,8 @@ class TrackerCoordinator {
libraryGlobalKey: libraryKey,
season: season,
episodeNumber: number,
animeProgress: ids.animeProgress,
animeProgressComplete: ids.animeProgressComplete,
);
}
}
+93 -15
View File
@@ -3,6 +3,7 @@ import '../../media/media_server_client.dart';
import '../../models/trackers/anime_ids.dart';
import '../../models/trackers/fribb_mapping_row.dart';
import '../../utils/external_ids.dart';
import 'anime_episode_progress_resolver.dart';
import 'fribb_mapping_store.dart';
/// Paired ID output: always-present Plex external IDs (tvdb/imdb/tmdb) plus
@@ -11,8 +12,27 @@ import 'fribb_mapping_store.dart';
class TrackerIds {
final ExternalIds external;
final AnimeIds? anime;
final AnimeProgressScope? animeProgressScope;
final int? animeProgress;
final bool animeProgressComplete;
const TrackerIds({required this.external, required this.anime});
const TrackerIds({
required this.external,
required this.anime,
this.animeProgressScope,
this.animeProgress,
this.animeProgressComplete = false,
});
TrackerIds withAnimeProgress(ResolvedAnimeProgress? animeProgress) {
return TrackerIds(
external: external,
anime: anime,
animeProgressScope: animeProgressScope,
animeProgress: animeProgress?.progress,
animeProgressComplete: animeProgress?.isComplete ?? false,
);
}
}
/// Resolves item ids → tracker external IDs. Returns both backend-native
@@ -27,16 +47,23 @@ class TrackerIds {
/// so those users don't pay the 5.6 MB mapping download they'll never need.
class TrackerIdResolver {
final MediaServerClient _client;
final FribbMappingStore _store;
final FribbMappingLookup _store;
final AnimeEpisodeProgressLookup _animeProgress;
final bool Function() _needsFribb;
/// Null entries mean "the server had no IDs" — cached so scrubbing on an
/// un-matched item doesn't re-hit the server every position update.
final Map<String, TrackerIds?> _cache = {};
TrackerIdResolver(this._client, {bool Function()? needsFribb, FribbMappingStore? store})
: _needsFribb = needsFribb ?? _returnTrue,
_store = store ?? FribbMappingStore.instance;
TrackerIdResolver(
MediaServerClient client, {
bool Function()? needsFribb,
FribbMappingLookup? store,
AnimeEpisodeProgressLookup? animeProgress,
}) : _client = client,
_needsFribb = needsFribb ?? _returnTrue,
_store = store ?? FribbMappingStore.instance,
_animeProgress = animeProgress ?? AnimeEpisodeProgressResolver(client);
static bool _returnTrue() => true;
@@ -67,15 +94,24 @@ class TrackerIdResolver {
// Cache under the (showId, season) pair so a show with multiple Fribb
// rows caches each season separately during a marathon.
final cacheKey = season != null ? '$showId#s$season' : showId;
if (_cache.containsKey(cacheKey)) return _cache[cacheKey];
TrackerIds? ids;
if (_cache.containsKey(cacheKey)) {
ids = _cache[cacheKey];
} else {
final external = await _fetchExternalIds(showId);
ids = await _build(external, isEpisodeSeason: season, isMovie: false);
_cache[cacheKey] = ids;
}
final external = await _fetchExternalIds(showId);
final ids = await _build(external, isEpisodeSeason: season, isMovie: false);
_cache[cacheKey] = ids;
return ids;
if (ids == null || ids.animeProgressScope == null) return ids;
final progress = await _animeProgress.resolve(episode, scope: ids.animeProgressScope!);
return ids.withAnimeProgress(progress);
}
void clearCache() => _cache.clear();
void clearCache() {
_cache.clear();
_animeProgress.clearCache();
}
Future<TrackerIds?> _build(ExternalIds external, {int? isEpisodeSeason, required bool isMovie}) async {
if (!external.hasAny) return null;
@@ -83,7 +119,11 @@ class TrackerIdResolver {
final rows = await _store.lookup(tvdbId: external.tvdb, tmdbId: external.tmdb, imdbId: external.imdb);
final row = isMovie ? _pickMovieRow(rows) : _pickShowRow(rows, season: isEpisodeSeason);
final anime = row == null ? null : AnimeIds.fromFribb(row);
return TrackerIds(external: external, anime: anime);
return TrackerIds(
external: external,
anime: anime,
animeProgressScope: _animeProgressScope(selected: row, rows: rows, season: isEpisodeSeason, isMovie: isMovie),
);
}
/// Pick the best row for a movie lookup — prefer rows marked `type: MOVIE`.
@@ -98,8 +138,8 @@ class TrackerIdResolver {
/// Pick the best row for a show lookup. When Fribb has multiple rows
/// sharing the same show-level external ID (split-cour anime), prefer the
/// one whose `season.tvdb` matches the Plex episode's season; otherwise
/// the first non-MOVIE row.
/// one whose `season.tvdb` or `season.tmdb` matches the Plex episode's
/// season; otherwise prefer regular TV/ONA rows.
FribbMappingRow? _pickShowRow(List<FribbMappingRow> rows, {int? season}) {
if (rows.isEmpty) return null;
@@ -109,10 +149,48 @@ class TrackerIdResolver {
}
}
// No season match — fall back to the first non-MOVIE row (prefer series).
// No season match — prefer regular TV/ONA rows over movies/OVAs/specials.
for (final row in rows) {
if (_isRegularSeriesRow(row)) return row;
}
// Fall back to the first non-MOVIE row (prefer series-like entries).
for (final row in rows) {
if (!row.isMovie) return row;
}
return rows.first;
}
AnimeProgressScope? _animeProgressScope({
required FribbMappingRow? selected,
required List<FribbMappingRow> rows,
required int? season,
required bool isMovie,
}) {
if (isMovie) return null;
if (season == null || season <= 0) return null;
if (selected == null) return null;
if (_hasSeasonMapping(selected)) {
final exactSeason = selected.tvdbSeason == season || selected.tmdbSeason == season;
return exactSeason && _isRegularSeriesRow(selected) ? AnimeProgressScope.season : null;
}
final regularRows = rows.where(_isRegularSeriesRow).toList(growable: false);
if (regularRows.length == 1 && identical(regularRows.single, selected)) {
return AnimeProgressScope.show;
}
return null;
}
bool _hasSeasonMapping(FribbMappingRow row) => row.tvdbSeason != null || row.tmdbSeason != null;
bool _isRegularSeriesRow(FribbMappingRow row) {
if (row.isMovie) return false;
if (row.tvdbSeason == 0 || row.tmdbSeason == 0) return false;
return switch (row.type?.toUpperCase()) {
null || 'TV' || 'ONA' || 'UNKNOWN' => true,
_ => false,
};
}
}