fix(trackers): use catalog totals for anime completion

close #1025
This commit is contained in:
edde746
2026-05-13 15:07:19 +02:00
parent 126e6b9349
commit 8f9947a874
12 changed files with 348 additions and 58 deletions
-4
View File
@@ -16,7 +16,6 @@ class TrackerContext {
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.
@@ -35,7 +34,6 @@ class TrackerContext {
this.season,
this.episodeNumber,
this.animeProgress,
this.animeProgressComplete = false,
});
factory TrackerContext.movie({
@@ -61,7 +59,6 @@ class TrackerContext {
required int season,
required int episodeNumber,
int? animeProgress,
bool animeProgressComplete = false,
}) {
return TrackerContext._(
external: external,
@@ -72,7 +69,6 @@ class TrackerContext {
season: season,
episodeNumber: episodeNumber,
animeProgress: animeProgress,
animeProgressComplete: animeProgressComplete,
);
}
}
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
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;
@@ -49,6 +50,21 @@ class AnilistClient {
await query(mutation, variables: {'mediaId': mediaId, 'progress': progress, 'status': status});
}
Future<int?> getAnimeEpisodeCount(int mediaId) async {
const mediaQuery = '''
query(\$mediaId: Int) {
Media(id: \$mediaId, type: ANIME) {
episodes
}
}
''';
final data = await query(mediaQuery, variables: {'mediaId': mediaId});
final media = data['Media'];
if (media is! Map) return null;
final count = flexibleInt(media['episodes']);
return count != null && count > 0 ? count : null;
}
Future<Map<String, dynamic>> query(String query, {Map<String, dynamic>? variables}) async {
final uri = Uri.parse(AnilistConstants.apiBase);
final headers = AnilistConstants.headers(accessToken: _session.accessToken);
@@ -1,3 +1,5 @@
import 'package:http/http.dart' as http;
import '../../../models/trackers/tracker_context.dart';
import '../../../utils/app_logger.dart';
import '../../settings_service.dart';
@@ -25,6 +27,7 @@ class AnilistTracker extends TrackerBase {
bool get needsFribb => true;
AnilistClient? _client;
final Map<int, Future<int?>> _episodeCountLoads = {};
@override
bool get hasActiveClient => _client != null;
@@ -32,9 +35,16 @@ class AnilistTracker extends TrackerBase {
@override
bool readEnabledSetting(SettingsService settings) => settings.read(SettingsService.enableAnilistScrobble);
void rebindSession(AnilistSession? session, {required void Function() onSessionInvalidated}) {
void rebindSession(
AnilistSession? session, {
required void Function() onSessionInvalidated,
http.Client? httpClient,
}) {
_client?.dispose();
_client = session == null ? null : AnilistClient(session, onSessionInvalidated: onSessionInvalidated);
_episodeCountLoads.clear();
_client = session == null
? null
: AnilistClient(session, onSessionInvalidated: onSessionInvalidated, httpClient: httpClient);
}
@override
@@ -45,9 +55,27 @@ class AnilistTracker extends TrackerBase {
final progress = ctx.isMovie ? 1 : (ctx.animeProgress ?? ctx.episodeNumber);
if (progress == null || progress <= 0) return;
final status = ctx.isMovie || ctx.animeProgressComplete ? 'COMPLETED' : 'CURRENT';
final total = ctx.isMovie || ctx.animeProgress == null ? null : await _episodeCount(client, anilistId);
final watched = total != null && progress > total ? total : progress;
final status = ctx.isMovie || (total != null && progress >= total) ? 'COMPLETED' : 'CURRENT';
await client.saveMediaListEntry(mediaId: anilistId, progress: progress, status: status);
appLogger.d('AniList: saved entry (anilist=$anilistId, progress=$progress, status=$status)');
await client.saveMediaListEntry(mediaId: anilistId, progress: watched, status: status);
appLogger.d('AniList: saved entry (anilist=$anilistId, progress=$watched, status=$status)');
}
Future<int?> _episodeCount(AnilistClient client, int anilistId) {
final existing = _episodeCountLoads[anilistId];
if (existing != null) return existing;
late final Future<int?> loading;
loading = client.getAnimeEpisodeCount(anilistId).catchError((Object e) {
if (identical(_episodeCountLoads[anilistId], loading)) {
final _ = _episodeCountLoads.remove(anilistId);
}
appLogger.d('AniList: failed to fetch anime episode count (anilist=$anilistId)', error: e);
return null;
});
_episodeCountLoads[anilistId] = loading;
return loading;
}
}
@@ -7,9 +7,8 @@ enum AnimeProgressScope { show, season }
class ResolvedAnimeProgress {
final int progress;
final bool isComplete;
const ResolvedAnimeProgress({required this.progress, required this.isComplete});
const ResolvedAnimeProgress({required this.progress});
}
/// Resolves watched progress in the MAL/AniList anime entry selected by Fribb.
@@ -48,21 +47,20 @@ class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup {
final existing = _seasonProgressLoads[showId];
if (existing != null) return existing;
final loading = _loadSeasonProgress(showId);
late final Future<Map<int, _SeasonProgress>?> loading;
loading = _loadSeasonProgress(showId).whenComplete(() {
if (identical(_seasonProgressLoads[showId], loading)) {
final _ = _seasonProgressLoads.remove(showId);
}
});
_seasonProgressLoads[showId] = loading;
final progress = await loading;
if (progress == null) {
final _ = _seasonProgressLoads.remove(showId);
}
return progress;
return loading;
}
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;
@@ -70,14 +68,11 @@ class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup {
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);
return ResolvedAnimeProgress(progress: total > 0 && progress > total ? total : progress);
}
ResolvedAnimeProgress? _seasonProgress(_SeasonProgress? season, bool currentAlreadyWatched) {
@@ -85,8 +80,7 @@ class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup {
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);
return ResolvedAnimeProgress(progress: total != null && total > 0 && progress > total ? total : progress);
}
Future<Map<int, _SeasonProgress>?> _loadSeasonProgress(String showId) async {
+9 -1
View File
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
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;
@@ -56,7 +57,14 @@ class MalClient {
/// ```
Future<void> updateMyListStatus(int animeId, Map<String, String> fields) async {
// MAL's list-status endpoint is form-encoded (not JSON).
await _request('PATCH', '/anime/$animeId/my_list_status', formBody: fields);
await _request('PUT', '/anime/$animeId/my_list_status', formBody: fields);
}
Future<int?> getAnimeEpisodeCount(int animeId) async {
final res = await _request('GET', '/anime/$animeId?fields=num_episodes');
if (res is! Map) return null;
final count = flexibleInt(res['num_episodes']);
return count != null && count > 0 ? count : null;
}
Future<MalSession> _refresh() => _refreshCoalescer.run(_doRefresh);
+33 -2
View File
@@ -1,3 +1,5 @@
import 'package:http/http.dart' as http;
import '../../../models/trackers/tracker_context.dart';
import '../../../utils/app_logger.dart';
import '../../settings_service.dart';
@@ -28,6 +30,7 @@ class MalTracker extends TrackerBase {
bool get needsFribb => true;
MalClient? _client;
final Map<int, Future<int?>> _episodeCountLoads = {};
@override
bool get hasActiveClient => _client != null;
@@ -39,11 +42,18 @@ class MalTracker extends TrackerBase {
MalSession? session, {
required void Function() onSessionInvalidated,
void Function(MalSession)? onSessionUpdated,
http.Client? httpClient,
}) {
_client?.dispose();
_episodeCountLoads.clear();
_client = session == null
? null
: MalClient(session, onSessionInvalidated: onSessionInvalidated, onSessionUpdated: onSessionUpdated);
: MalClient(
session,
onSessionInvalidated: onSessionInvalidated,
onSessionUpdated: onSessionUpdated,
httpClient: httpClient,
);
}
@override
@@ -58,10 +68,31 @@ class MalTracker extends TrackerBase {
} else {
final progress = ctx.animeProgress ?? ctx.episodeNumber;
if (progress == null || progress <= 0) return;
fields = {'status': ctx.animeProgressComplete ? 'completed' : 'watching', 'num_watched_episodes': '$progress'};
final total = ctx.animeProgress == null ? null : await _episodeCount(client, malId);
final watched = total != null && progress > total ? total : progress;
fields = {
'status': total != null && progress >= total ? 'completed' : 'watching',
'num_watched_episodes': '$watched',
};
}
await client.updateMyListStatus(malId, fields);
appLogger.d('MAL: updated list status (mal=$malId, fields=$fields)');
}
Future<int?> _episodeCount(MalClient client, int malId) {
final existing = _episodeCountLoads[malId];
if (existing != null) return existing;
late final Future<int?> loading;
loading = client.getAnimeEpisodeCount(malId).catchError((Object e) {
if (identical(_episodeCountLoads[malId], loading)) {
final _ = _episodeCountLoads.remove(malId);
}
appLogger.d('MAL: failed to fetch anime episode count (mal=$malId)', error: e);
return null;
});
_episodeCountLoads[malId] = loading;
return loading;
}
}
@@ -161,7 +161,6 @@ class TrackerCoordinator {
season: season,
episodeNumber: number,
animeProgress: ids.animeProgress,
animeProgressComplete: ids.animeProgressComplete,
);
}
}
@@ -14,15 +14,8 @@ class TrackerIds {
final AnimeIds? anime;
final AnimeProgressScope? animeProgressScope;
final int? animeProgress;
final bool animeProgressComplete;
const TrackerIds({
required this.external,
required this.anime,
this.animeProgressScope,
this.animeProgress,
this.animeProgressComplete = false,
});
const TrackerIds({required this.external, required this.anime, this.animeProgressScope, this.animeProgress});
TrackerIds withAnimeProgress(ResolvedAnimeProgress? animeProgress) {
return TrackerIds(
@@ -30,7 +23,6 @@ class TrackerIds {
anime: anime,
animeProgressScope: animeProgressScope,
animeProgress: animeProgress?.progress,
animeProgressComplete: animeProgress?.isComplete ?? false,
);
}
}