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,
);
}
}
@@ -0,0 +1,132 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/testing.dart';
import 'package:http/http.dart' as http;
import 'package:plezy/models/trackers/anime_ids.dart';
import 'package:plezy/models/trackers/tracker_context.dart';
import 'package:plezy/services/trackers/anilist/anilist_session.dart';
import 'package:plezy/services/trackers/anilist/anilist_tracker.dart';
import 'package:plezy/utils/external_ids.dart';
AnilistSession _session() {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
return AnilistSession(accessToken: 'token', expiresAt: now + 86400, createdAt: now);
}
TrackerContext _episode({int anilistId = 21, int episodeNumber = 12, int? animeProgress = 12}) {
return TrackerContext.episode(
external: const ExternalIds(tvdb: 1),
anime: AnimeIds(anilist: anilistId),
ratingKey: 'episode-1',
libraryGlobalKey: null,
season: 1,
episodeNumber: episodeNumber,
animeProgress: animeProgress,
);
}
void main() {
group('AnilistTracker', () {
final tracker = AnilistTracker.instance;
tearDown(() {
tracker.rebindSession(null, onSessionInvalidated: () {});
});
test('marks completed when scoped progress reaches AniList total', () async {
final saved = <Map<String, dynamic>>[];
final client = 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': 12},
},
}),
200,
);
}
if (query.contains('SaveMediaListEntry')) {
saved.add((body['variables'] as Map).cast<String, dynamic>());
return http.Response(
json.encode({
'data': {
'SaveMediaListEntry': {'id': 1},
},
}),
200,
);
}
fail('Unexpected AniList query: $query');
});
tracker.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: client);
await tracker.markWatched(_episode(animeProgress: 13));
expect(saved.single, {'mediaId': 21, 'progress': 12, 'status': 'COMPLETED'});
});
test('keeps fallback local progress as current without total lookup', () async {
final saved = <Map<String, dynamic>>[];
final client = MockClient((request) async {
final body = json.decode(request.body) as Map<String, dynamic>;
final query = body['query'] as String;
if (query.contains('SaveMediaListEntry')) {
saved.add((body['variables'] as Map).cast<String, dynamic>());
return http.Response(
json.encode({
'data': {
'SaveMediaListEntry': {'id': 1},
},
}),
200,
);
}
fail('Unexpected AniList query: $query');
});
tracker.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: client);
await tracker.markWatched(_episode(animeProgress: null));
expect(saved.single, {'mediaId': 21, 'progress': 12, 'status': 'CURRENT'});
});
test('keeps progress current when AniList total is unknown', () async {
final saved = <Map<String, dynamic>>[];
final client = 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': null},
},
}),
200,
);
}
if (query.contains('SaveMediaListEntry')) {
saved.add((body['variables'] as Map).cast<String, dynamic>());
return http.Response(
json.encode({
'data': {
'SaveMediaListEntry': {'id': 1},
},
}),
200,
);
}
fail('Unexpected AniList query: $query');
});
tracker.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: client);
await tracker.markWatched(_episode());
expect(saved.single, {'mediaId': 21, 'progress': 12, 'status': 'CURRENT'});
});
});
}
@@ -54,7 +54,6 @@ void main() {
final result = await resolver.resolve(_episode(), scope: AnimeProgressScope.show);
expect(result?.progress, 56);
expect(result?.isComplete, isFalse);
});
test('show scope ignores specials season', () async {
@@ -67,7 +66,6 @@ void main() {
final result = await resolver.resolve(_episode(season: 1, number: 6), scope: AnimeProgressScope.show);
expect(result?.progress, 6);
expect(result?.isComplete, isFalse);
});
test('season scope uses only current season watched count', () async {
@@ -80,36 +78,33 @@ void main() {
final result = await resolver.resolve(_episode(season: 2, number: 6), scope: AnimeProgressScope.season);
expect(result?.progress, 6);
expect(result?.isComplete, isFalse);
});
test('season scope marks complete when progress reaches known season total', () async {
test('season scope caps progress at known season total', () async {
final resolver = AnimeEpisodeProgressResolver(
_FakeMediaServerClient({
'show-1': [_season(2, watched: 11, total: 12)],
'show-1': [_season(2, watched: 12, total: 12)],
}),
);
final result = await resolver.resolve(_episode(season: 2, number: 12), scope: AnimeProgressScope.season);
expect(result?.progress, 12);
expect(result?.isComplete, isTrue);
});
test('show scope marks complete when progress reaches known show total', () async {
test('show scope caps progress at known show total', () async {
final resolver = AnimeEpisodeProgressResolver(
_FakeMediaServerClient({
'show-1': [_season(1, watched: 12, total: 12), _season(2, watched: 11, total: 12)],
'show-1': [_season(1, watched: 12, total: 12), _season(2, watched: 12, total: 12)],
}),
);
final result = await resolver.resolve(_episode(season: 2, number: 12), scope: AnimeProgressScope.show);
expect(result?.progress, 24);
expect(result?.isComplete, isTrue);
});
test('unknown total does not mark complete', () async {
test('unknown total still returns progress', () async {
final resolver = AnimeEpisodeProgressResolver(
_FakeMediaServerClient({
'show-1': [_season(1, watched: 11)],
@@ -119,7 +114,6 @@ void main() {
final result = await resolver.resolve(_episode(season: 1, number: 12), scope: AnimeProgressScope.season);
expect(result?.progress, 12);
expect(result?.isComplete, isFalse);
});
test('already watched current episode does not add one', () async {
@@ -135,7 +129,6 @@ void main() {
);
expect(result?.progress, 5);
expect(result?.isComplete, isFalse);
});
test('missing viewedLeafCount returns null', () async {
@@ -160,15 +153,30 @@ void main() {
expect(result, isNull);
});
test('cache is reused for multiple episodes in the same show', () async {
test('in-flight load is reused for concurrent episodes in the same show', () async {
final client = _FakeMediaServerClient({
'show-1': [_season(1, watched: 10), _season(2, watched: 5)],
});
final resolver = AnimeEpisodeProgressResolver(client);
expect((await resolver.resolve(_episode(season: 2, number: 6), scope: AnimeProgressScope.show))?.progress, 16);
expect((await resolver.resolve(_episode(season: 2, number: 7), scope: AnimeProgressScope.show))?.progress, 16);
final first = resolver.resolve(_episode(season: 2, number: 6), scope: AnimeProgressScope.show);
final second = resolver.resolve(_episode(season: 2, number: 7), scope: AnimeProgressScope.show);
expect((await first)?.progress, 16);
expect((await second)?.progress, 16);
expect(client.fetchChildrenCalls, 1);
});
test('sequential loads refetch watched counts', () async {
final client = _FakeMediaServerClient({
'show-1': [_season(1, watched: 5)],
});
final resolver = AnimeEpisodeProgressResolver(client);
expect((await resolver.resolve(_episode(season: 1, number: 6), scope: AnimeProgressScope.season))?.progress, 6);
client.childrenByParent['show-1'] = [_season(1, watched: 6)];
expect((await resolver.resolve(_episode(season: 1, number: 7), scope: AnimeProgressScope.season))?.progress, 7);
expect(client.fetchChildrenCalls, 2);
});
});
}
@@ -0,0 +1,88 @@
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/models/trackers/anime_ids.dart';
import 'package:plezy/models/trackers/tracker_context.dart';
import 'package:plezy/services/trackers/mal/mal_session.dart';
import 'package:plezy/services/trackers/mal/mal_tracker.dart';
import 'package:plezy/utils/external_ids.dart';
MalSession _session() {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
return MalSession(accessToken: 'token', refreshToken: 'refresh', expiresAt: now + 86400, createdAt: now);
}
TrackerContext _episode({int malId = 21, int episodeNumber = 12, int? animeProgress = 12}) {
return TrackerContext.episode(
external: const ExternalIds(tvdb: 1),
anime: AnimeIds(mal: malId),
ratingKey: 'episode-1',
libraryGlobalKey: null,
season: 1,
episodeNumber: episodeNumber,
animeProgress: animeProgress,
);
}
void main() {
group('MalTracker', () {
final tracker = MalTracker.instance;
tearDown(() {
tracker.rebindSession(null, onSessionInvalidated: () {});
});
test('marks completed when scoped progress reaches MAL total', () async {
final requests = <http.Request>[];
final client = MockClient((request) async {
requests.add(request);
if (request.method == 'GET') {
expect(request.url.path, '/v2/anime/21');
expect(request.url.queryParameters['fields'], 'num_episodes');
return http.Response(json.encode({'num_episodes': 12}), 200);
}
if (request.method == 'PUT') return http.Response('{}', 200);
fail('Unexpected ${request.method} ${request.url}');
});
tracker.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: client);
await tracker.markWatched(_episode(animeProgress: 13));
final put = requests.singleWhere((request) => request.method == 'PUT');
expect(Uri.splitQueryString(put.body), {'status': 'completed', 'num_watched_episodes': '12'});
});
test('keeps fallback local progress as watching without total lookup', () async {
final requests = <http.Request>[];
final client = MockClient((request) async {
requests.add(request);
if (request.method == 'PUT') return http.Response('{}', 200);
fail('Unexpected ${request.method} ${request.url}');
});
tracker.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: client);
await tracker.markWatched(_episode(animeProgress: null));
final put = requests.singleWhere((request) => request.method == 'PUT');
expect(Uri.splitQueryString(put.body), {'status': 'watching', 'num_watched_episodes': '12'});
});
test('keeps progress as watching when MAL total is unknown', () async {
final requests = <http.Request>[];
final client = MockClient((request) async {
requests.add(request);
if (request.method == 'GET') return http.Response(json.encode({'num_episodes': 0}), 200);
if (request.method == 'PUT') return http.Response('{}', 200);
fail('Unexpected ${request.method} ${request.url}');
});
tracker.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: client);
await tracker.markWatched(_episode());
final put = requests.singleWhere((request) => request.method == 'PUT');
expect(Uri.splitQueryString(put.body), {'status': 'watching', 'num_watched_episodes': '12'});
});
});
}
@@ -45,8 +45,8 @@ class _FakeAnimeProgressLookup implements AnimeEpisodeProgressLookup {
MediaItem? lastEpisode;
AnimeProgressScope? lastScope;
_FakeAnimeProgressLookup(int? progress, {bool isComplete = false})
: result = progress == null ? null : ResolvedAnimeProgress(progress: progress, isComplete: isComplete);
_FakeAnimeProgressLookup(int? progress)
: result = progress == null ? null : ResolvedAnimeProgress(progress: progress);
@override
Future<ResolvedAnimeProgress?> resolve(MediaItem episode, {required AnimeProgressScope scope}) async {
@@ -100,14 +100,13 @@ void main() {
expect(ids?.anime?.mal, 21);
expect(ids?.animeProgressScope, AnimeProgressScope.show);
expect(ids?.animeProgress, 6);
expect(ids?.animeProgressComplete, isFalse);
expect(animeProgress.resolveCalls, 1);
expect(animeProgress.lastEpisode?.id, 'episode-23-6');
expect(animeProgress.lastScope, AnimeProgressScope.show);
});
test('exact season-scoped row uses season-scope progress', () async {
final animeProgress = _FakeAnimeProgressLookup(18, isComplete: true);
final animeProgress = _FakeAnimeProgressLookup(18);
final resolver = _resolver(
animeProgress: animeProgress,
rows: const [
@@ -121,7 +120,6 @@ void main() {
expect(ids?.anime?.mal, 200);
expect(ids?.animeProgressScope, AnimeProgressScope.season);
expect(ids?.animeProgress, 18);
expect(ids?.animeProgressComplete, isTrue);
expect(animeProgress.resolveCalls, 1);
expect(animeProgress.lastScope, AnimeProgressScope.season);
});