@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user