fix(trackers): sync scoped anime progress
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user