feat(trackers): sync anime watch state by episode
This commit is contained in:
@@ -128,5 +128,58 @@ void main() {
|
||||
|
||||
expect(saved.single, {'mediaId': 21, 'progress': 12, 'status': 'CURRENT'});
|
||||
});
|
||||
|
||||
test('episode unwatch is a no-op', () async {
|
||||
final requests = <http.Request>[];
|
||||
final client = MockClient((request) async {
|
||||
requests.add(request);
|
||||
fail('Unexpected ${request.method} ${request.url}');
|
||||
});
|
||||
tracker.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: client);
|
||||
|
||||
await tracker.markUnwatched(_episode(animeProgress: 1));
|
||||
|
||||
expect(requests, isEmpty);
|
||||
});
|
||||
|
||||
test('removeFromList removes anime entry', () async {
|
||||
final variables = <Map<String, dynamic>>[];
|
||||
final client = MockClient((request) async {
|
||||
final body = json.decode(request.body) as Map<String, dynamic>;
|
||||
final query = body['query'] as String;
|
||||
variables.add((body['variables'] as Map).cast<String, dynamic>());
|
||||
if (query.contains('mediaListEntry')) {
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'data': {
|
||||
'Media': {
|
||||
'mediaListEntry': {'id': 99},
|
||||
},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (query.contains('DeleteMediaListEntry')) {
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'data': {
|
||||
'DeleteMediaListEntry': {'deleted': true},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
fail('Unexpected AniList query: $query');
|
||||
});
|
||||
tracker.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: client);
|
||||
|
||||
await tracker.removeFromList(_episode());
|
||||
|
||||
expect(variables, [
|
||||
{'mediaId': 21},
|
||||
{'id': 99},
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@ import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/models/trackers/anime_lists_mapping.dart';
|
||||
import 'package:plezy/services/trackers/anime_episode_progress_resolver.dart';
|
||||
|
||||
class _FakeMediaServerClient implements MediaServerClient {
|
||||
final Map<String, List<MediaItem>> childrenByParent;
|
||||
final Map<String, List<MediaItem>> playableByParent;
|
||||
Object? throwOnFetchChildren;
|
||||
int fetchChildrenCalls = 0;
|
||||
int fetchPlayableDescendantsCalls = 0;
|
||||
|
||||
_FakeMediaServerClient(this.childrenByParent);
|
||||
_FakeMediaServerClient(this.childrenByParent, {this.playableByParent = const {}});
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchChildren(String parentId) async {
|
||||
@@ -20,6 +23,12 @@ class _FakeMediaServerClient implements MediaServerClient {
|
||||
return childrenByParent[parentId] ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
|
||||
fetchPlayableDescendantsCalls++;
|
||||
return playableByParent[parentId] ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
@@ -45,6 +54,17 @@ MediaItem _episode({int season = 2, int number = 6, String showId = 'show-1', in
|
||||
viewCount: viewCount,
|
||||
);
|
||||
|
||||
AnimeEpisodeMatch _match({required int anidbId, required int serverEpisode, required int animeEpisode}) =>
|
||||
AnimeEpisodeMatch(
|
||||
anidbId: anidbId,
|
||||
anidbSeason: 1,
|
||||
anidbEpisode: animeEpisode,
|
||||
provider: AnimeListProvider.tvdb,
|
||||
externalSeason: 1,
|
||||
externalEpisode: serverEpisode,
|
||||
kind: AnimeListMatchKind.range,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('AnimeEpisodeProgressResolver', () {
|
||||
test('show scope sums watched counts across regular seasons', () async {
|
||||
@@ -178,5 +198,58 @@ void main() {
|
||||
expect((await resolver.resolve(_episode(season: 1, number: 7), scope: AnimeProgressScope.season))?.progress, 7);
|
||||
expect(client.fetchChildrenCalls, 2);
|
||||
});
|
||||
|
||||
test('mapped scope counts only watched episodes in the selected anime entry', () async {
|
||||
final client = _FakeMediaServerClient(
|
||||
const {},
|
||||
playableByParent: {
|
||||
'show-1': [
|
||||
_episode(season: 1, number: 12, viewCount: 1),
|
||||
_episode(season: 1, number: 13, viewCount: 1),
|
||||
_episode(season: 1, number: 14),
|
||||
],
|
||||
},
|
||||
);
|
||||
final resolver = AnimeEpisodeProgressResolver(client);
|
||||
|
||||
final result = await resolver.resolve(
|
||||
_episode(season: 1, number: 14),
|
||||
scope: AnimeProgressScope.mapped,
|
||||
animeMatch: _match(anidbId: 2, serverEpisode: 14, animeEpisode: 2),
|
||||
episodeMatcher: (episode) async => switch (episode.index) {
|
||||
12 => _match(anidbId: 1, serverEpisode: 12, animeEpisode: 12),
|
||||
13 => _match(anidbId: 2, serverEpisode: 13, animeEpisode: 1),
|
||||
14 => _match(anidbId: 2, serverEpisode: 14, animeEpisode: 2),
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.progress, 2);
|
||||
expect(client.fetchPlayableDescendantsCalls, 1);
|
||||
});
|
||||
|
||||
test('mapped scope can exclude the current episode for unwatch progress', () async {
|
||||
final client = _FakeMediaServerClient(
|
||||
const {},
|
||||
playableByParent: {
|
||||
'show-1': [_episode(season: 1, number: 13, viewCount: 1), _episode(season: 1, number: 14)],
|
||||
},
|
||||
);
|
||||
final resolver = AnimeEpisodeProgressResolver(client);
|
||||
|
||||
final result = await resolver.resolve(
|
||||
_episode(season: 1, number: 14),
|
||||
scope: AnimeProgressScope.mapped,
|
||||
animeMatch: _match(anidbId: 2, serverEpisode: 14, animeEpisode: 2),
|
||||
includeCurrentEpisode: false,
|
||||
episodeMatcher: (episode) async => switch (episode.index) {
|
||||
13 => _match(anidbId: 2, serverEpisode: 13, animeEpisode: 1),
|
||||
14 => _match(anidbId: 2, serverEpisode: 14, animeEpisode: 2),
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.progress, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/models/trackers/anime_lists_mapping.dart';
|
||||
import 'package:plezy/services/trackers/anime_lists_mapping_store.dart';
|
||||
|
||||
void main() {
|
||||
group('AnimeListsMappingStore parser', () {
|
||||
test('parses defaults, offsets, ranges, explicit mappings, and absolute seasons', () {
|
||||
final index = parseAnimeListsIndex('''
|
||||
<anime-list>
|
||||
<anime anidbid="1" tvdbid="123" defaulttvdbseason="1" episodeoffset="2" tmdbtv="456" tmdbseason="a" tmdbid="10,11" imdbid="tt1,tt2">
|
||||
<name>First</name>
|
||||
<mapping-list>
|
||||
<mapping anidbseason="1" tvdbseason="1" start="1" end="12" offset="12" />
|
||||
<mapping anidbseason="0" tvdbseason="0">;1-3;2-0;3-4+5;</mapping>
|
||||
</mapping-list>
|
||||
</anime>
|
||||
</anime-list>
|
||||
''');
|
||||
|
||||
final entry = index.byTvdb[123]!.single;
|
||||
|
||||
expect(entry.anidbId, 1);
|
||||
expect(entry.name, 'First');
|
||||
expect(entry.defaultTvdbSeason?.number, 1);
|
||||
expect(entry.episodeOffset, 2);
|
||||
expect(entry.tmdbSeason?.isAbsolute, isTrue);
|
||||
expect(entry.tmdbMovieIds, [10, 11]);
|
||||
expect(entry.imdbIds, ['tt1', 'tt2']);
|
||||
|
||||
final range = entry
|
||||
.resolveEpisode(provider: AnimeListProvider.tvdb, externalSeason: 1, externalEpisode: 14)
|
||||
.single;
|
||||
expect(range.anidbEpisode, 2);
|
||||
expect(range.kind, AnimeListMatchKind.range);
|
||||
|
||||
final explicit = entry
|
||||
.resolveEpisode(provider: AnimeListProvider.tvdb, externalSeason: 0, externalEpisode: 5)
|
||||
.single;
|
||||
expect(explicit.anidbSeason, 0);
|
||||
expect(explicit.anidbEpisode, 3);
|
||||
expect(explicit.kind, AnimeListMatchKind.explicit);
|
||||
});
|
||||
|
||||
test('default offset maps external episodes back to AniDB local episodes', () {
|
||||
final index = parseAnimeListsIndex('''
|
||||
<anime-list>
|
||||
<anime anidbid="1" tvdbid="123" defaulttvdbseason="1" episodeoffset="12">
|
||||
<name>Second Cour</name>
|
||||
</anime>
|
||||
</anime-list>
|
||||
''');
|
||||
|
||||
final match = lookupAnimeListEpisodeInIndex(index, tvdbId: 123, season: 1, episodeNumber: 14);
|
||||
|
||||
expect(match?.anidbId, 1);
|
||||
expect(match?.anidbEpisode, 2);
|
||||
expect(match?.kind, AnimeListMatchKind.defaultMapping);
|
||||
});
|
||||
|
||||
test('same TVDB season split across two AniDB entries resolves by range', () {
|
||||
final index = parseAnimeListsIndex('''
|
||||
<anime-list>
|
||||
<anime anidbid="1" tvdbid="123" defaulttvdbseason="1">
|
||||
<name>Cour 1</name>
|
||||
<mapping-list>
|
||||
<mapping anidbseason="1" tvdbseason="1" start="1" end="12" />
|
||||
</mapping-list>
|
||||
</anime>
|
||||
<anime anidbid="2" tvdbid="123" defaulttvdbseason="1">
|
||||
<name>Cour 2</name>
|
||||
<mapping-list>
|
||||
<mapping anidbseason="1" tvdbseason="1" start="1" end="12" offset="12" />
|
||||
</mapping-list>
|
||||
</anime>
|
||||
</anime-list>
|
||||
''');
|
||||
|
||||
final first = lookupAnimeListEpisodeInIndex(index, tvdbId: 123, season: 1, episodeNumber: 12);
|
||||
final second = lookupAnimeListEpisodeInIndex(index, tvdbId: 123, season: 1, episodeNumber: 14);
|
||||
|
||||
expect(first?.anidbId, 1);
|
||||
expect(first?.anidbEpisode, 12);
|
||||
expect(second?.anidbId, 2);
|
||||
expect(second?.anidbEpisode, 2);
|
||||
});
|
||||
|
||||
test('ambiguous same-priority matches do not guess', () {
|
||||
final index = parseAnimeListsIndex('''
|
||||
<anime-list>
|
||||
<anime anidbid="1" tvdbid="123" defaulttvdbseason="1"><name>A</name></anime>
|
||||
<anime anidbid="2" tvdbid="123" defaulttvdbseason="1"><name>B</name></anime>
|
||||
</anime-list>
|
||||
''');
|
||||
|
||||
final match = lookupAnimeListEpisodeInIndex(index, tvdbId: 123, season: 1, episodeNumber: 1);
|
||||
|
||||
expect(match, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -84,5 +84,34 @@ void main() {
|
||||
final put = requests.singleWhere((request) => request.method == 'PUT');
|
||||
expect(Uri.splitQueryString(put.body), {'status': 'watching', 'num_watched_episodes': '12'});
|
||||
});
|
||||
|
||||
test('episode unwatch is a no-op', () async {
|
||||
final requests = <http.Request>[];
|
||||
final client = MockClient((request) async {
|
||||
requests.add(request);
|
||||
fail('Unexpected ${request.method} ${request.url}');
|
||||
});
|
||||
tracker.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: client);
|
||||
|
||||
await tracker.markUnwatched(_episode(animeProgress: 1));
|
||||
|
||||
expect(requests, isEmpty);
|
||||
});
|
||||
|
||||
test('removeFromList removes anime entry', () async {
|
||||
final requests = <http.Request>[];
|
||||
final client = MockClient((request) async {
|
||||
requests.add(request);
|
||||
if (request.method == 'DELETE') return http.Response('{}', 200);
|
||||
fail('Unexpected ${request.method} ${request.url}');
|
||||
});
|
||||
tracker.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: client);
|
||||
|
||||
await tracker.removeFromList(_episode());
|
||||
|
||||
final delete = requests.single;
|
||||
expect(delete.method, 'DELETE');
|
||||
expect(delete.url.path, '/v2/anime/21/my_list_status');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
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/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/models/trackers/anime_lists_mapping.dart';
|
||||
import 'package:plezy/models/trackers/fribb_mapping_row.dart';
|
||||
import 'package:plezy/services/trackers/anime_lists_mapping_store.dart';
|
||||
import 'package:plezy/services/trackers/anilist/anilist_session.dart';
|
||||
import 'package:plezy/services/trackers/anilist/anilist_tracker.dart';
|
||||
import 'package:plezy/services/trackers/fribb_mapping_store.dart';
|
||||
import 'package:plezy/services/trackers/mal/mal_session.dart';
|
||||
import 'package:plezy/services/trackers/mal/mal_tracker.dart';
|
||||
import 'package:plezy/services/trackers/simkl/simkl_session.dart';
|
||||
import 'package:plezy/services/trackers/simkl/simkl_tracker.dart';
|
||||
import 'package:plezy/services/trackers/tracker_coordinator.dart';
|
||||
import 'package:plezy/utils/external_ids.dart';
|
||||
|
||||
class _FakeMediaServerClient implements MediaServerClient {
|
||||
@override
|
||||
final String serverId;
|
||||
@override
|
||||
String? get serverName => null;
|
||||
|
||||
final Map<String, ExternalIds> externalIdsByItem;
|
||||
final Map<String, List<MediaItem>> descendantsByParent;
|
||||
final List<String> externalIdCalls = [];
|
||||
final List<String> descendantCalls = [];
|
||||
|
||||
_FakeMediaServerClient({
|
||||
this.serverId = 'server-1',
|
||||
required this.externalIdsByItem,
|
||||
required this.descendantsByParent,
|
||||
});
|
||||
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.plex;
|
||||
|
||||
@override
|
||||
Future<ExternalIds> fetchExternalIds(String itemId) async {
|
||||
externalIdCalls.add(itemId);
|
||||
return externalIdsByItem[itemId] ?? const ExternalIds();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
|
||||
descendantCalls.add(parentId);
|
||||
return descendantsByParent[parentId] ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeFribbLookup implements FribbMappingLookup {
|
||||
final List<FribbMappingRow> rows;
|
||||
|
||||
const _FakeFribbLookup(this.rows);
|
||||
|
||||
@override
|
||||
Future<List<FribbMappingRow>> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async => rows;
|
||||
}
|
||||
|
||||
class _FakeAnimeListsLookup implements AnimeListsMappingLookup {
|
||||
final Map<String, AnimeEpisodeMatch> matches;
|
||||
|
||||
const _FakeAnimeListsLookup({this.matches = const {}});
|
||||
|
||||
@override
|
||||
Future<AnimeEpisodeMatch?> lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber}) async {
|
||||
return matches['$season-$episodeNumber'];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<int>> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season}) async => const <int>{};
|
||||
|
||||
@override
|
||||
Future<Set<int>> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async => const <int>{};
|
||||
}
|
||||
|
||||
MediaItem _season() => MediaItem(
|
||||
id: 'season-1',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.season,
|
||||
title: 'Season 1',
|
||||
serverId: 'server-1',
|
||||
libraryId: 'lib-1',
|
||||
index: 1,
|
||||
parentId: 'show-1',
|
||||
);
|
||||
|
||||
MediaItem _episode(int number, {int season = 1}) => MediaItem(
|
||||
id: 'episode-$season-$number',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.episode,
|
||||
title: 'Episode $number',
|
||||
serverId: 'server-1',
|
||||
libraryId: 'lib-1',
|
||||
parentIndex: season,
|
||||
index: number,
|
||||
);
|
||||
|
||||
MediaItem _show() => MediaItem(
|
||||
id: 'show-1',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.show,
|
||||
title: 'Show 1',
|
||||
serverId: 'server-1',
|
||||
libraryId: 'lib-1',
|
||||
);
|
||||
|
||||
AnimeEpisodeMatch _match({required int anidbId, required int serverEpisode, required int animeEpisode}) =>
|
||||
AnimeEpisodeMatch(
|
||||
anidbId: anidbId,
|
||||
anidbSeason: 1,
|
||||
anidbEpisode: animeEpisode,
|
||||
provider: AnimeListProvider.tvdb,
|
||||
externalSeason: 1,
|
||||
externalEpisode: serverEpisode,
|
||||
kind: AnimeListMatchKind.range,
|
||||
);
|
||||
|
||||
SimklSession _simklSession() {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return SimklSession(accessToken: 'token', createdAt: now);
|
||||
}
|
||||
|
||||
MalSession _malSession() {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return MalSession(accessToken: 'token', refreshToken: 'refresh', expiresAt: now + 86400, createdAt: now);
|
||||
}
|
||||
|
||||
AnilistSession _anilistSession() {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return AnilistSession(accessToken: 'token', expiresAt: now + 86400, createdAt: now);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('TrackerCoordinator manual watched sync', () {
|
||||
final coordinator = TrackerCoordinator.instance;
|
||||
final simkl = SimklTracker.instance;
|
||||
final mal = MalTracker.instance;
|
||||
final anilist = AnilistTracker.instance;
|
||||
|
||||
setUp(() async {
|
||||
await mal.setEnabled(false);
|
||||
await anilist.setEnabled(false);
|
||||
await simkl.setEnabled(true);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
coordinator.cancelInFlight();
|
||||
coordinator.debugUseResolverDependencies();
|
||||
mal.rebindSession(null, onSessionInvalidated: () {});
|
||||
anilist.rebindSession(null, onSessionInvalidated: () {});
|
||||
simkl.rebindSession(null, onSessionInvalidated: () {});
|
||||
await mal.setEnabled(false);
|
||||
await anilist.setEnabled(false);
|
||||
await simkl.setEnabled(false);
|
||||
});
|
||||
|
||||
test('expands a manually watched season and fills missing episode show context', () async {
|
||||
final bodies = <Map<String, dynamic>>[];
|
||||
final httpClient = MockClient((request) async {
|
||||
expect(request.method, 'POST');
|
||||
expect(request.url.path, '/sync/history');
|
||||
bodies.add((json.decode(request.body) as Map).cast<String, dynamic>());
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
simkl.rebindSession(_simklSession(), onSessionInvalidated: () {}, httpClient: httpClient);
|
||||
|
||||
final client = _FakeMediaServerClient(
|
||||
externalIdsByItem: {'show-1': const ExternalIds(tvdb: 12345)},
|
||||
descendantsByParent: {
|
||||
'season-1': [_episode(1), _episode(2)],
|
||||
},
|
||||
);
|
||||
|
||||
await coordinator.markWatched(_season(), client);
|
||||
|
||||
expect(client.descendantCalls, ['season-1']);
|
||||
expect(client.externalIdCalls, ['show-1']);
|
||||
expect(bodies, hasLength(2));
|
||||
expect(bodies[0]['shows'], [
|
||||
{
|
||||
'ids': {'tvdb': 12345},
|
||||
'seasons': [
|
||||
{
|
||||
'number': 1,
|
||||
'episodes': [
|
||||
{'number': 1},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(bodies[1]['shows'], [
|
||||
{
|
||||
'ids': {'tvdb': 12345},
|
||||
'seasons': [
|
||||
{
|
||||
'number': 1,
|
||||
'episodes': [
|
||||
{'number': 2},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('groups manually watched split seasons into separate anime entries', () async {
|
||||
await simkl.setEnabled(false);
|
||||
await mal.setEnabled(true);
|
||||
await anilist.setEnabled(true);
|
||||
coordinator.debugUseResolverDependencies(
|
||||
store: const _FakeFribbLookup([
|
||||
FribbMappingRow(tvdbId: 12345, malId: 101, anilistId: 201, tvdbSeason: 1, type: 'TV'),
|
||||
FribbMappingRow(tvdbId: 12345, malId: 102, anilistId: 202, tvdbSeason: 2, type: 'TV'),
|
||||
]),
|
||||
animeLists: const _FakeAnimeListsLookup(),
|
||||
);
|
||||
|
||||
final malUpdates = <int, Map<String, String>>{};
|
||||
final malHttp = MockClient((request) async {
|
||||
final malId = int.parse(request.url.pathSegments[2]);
|
||||
if (request.method == 'GET') {
|
||||
return http.Response(json.encode({'num_episodes': 2}), 200);
|
||||
}
|
||||
expect(request.method, 'PUT');
|
||||
malUpdates[malId] = Uri.splitQueryString(request.body);
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
mal.rebindSession(_malSession(), onSessionInvalidated: () {}, httpClient: malHttp);
|
||||
|
||||
final anilistSaves = <Map<String, dynamic>>[];
|
||||
final anilistHttp = 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': 2},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (query.contains('SaveMediaListEntry')) {
|
||||
anilistSaves.add((body['variables'] as Map).cast<String, dynamic>());
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'data': {
|
||||
'SaveMediaListEntry': {'id': 1},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
fail('Unexpected AniList query: $query');
|
||||
});
|
||||
anilist.rebindSession(_anilistSession(), onSessionInvalidated: () {}, httpClient: anilistHttp);
|
||||
|
||||
final client = _FakeMediaServerClient(
|
||||
externalIdsByItem: {'show-1': const ExternalIds(tvdb: 12345)},
|
||||
descendantsByParent: {
|
||||
'show-1': [_episode(1, season: 1), _episode(2, season: 1), _episode(1, season: 2), _episode(2, season: 2)],
|
||||
},
|
||||
);
|
||||
|
||||
await coordinator.markWatched(_show(), client);
|
||||
|
||||
expect(malUpdates, {
|
||||
101: {'status': 'completed', 'num_watched_episodes': '2'},
|
||||
102: {'status': 'completed', 'num_watched_episodes': '2'},
|
||||
});
|
||||
expect(anilistSaves, contains(equals({'mediaId': 201, 'progress': 2, 'status': 'COMPLETED'})));
|
||||
expect(anilistSaves, contains(equals({'mediaId': 202, 'progress': 2, 'status': 'COMPLETED'})));
|
||||
});
|
||||
|
||||
test('groups manually watched same-season split cours by Anime-Lists ranges', () async {
|
||||
await simkl.setEnabled(false);
|
||||
await mal.setEnabled(true);
|
||||
await anilist.setEnabled(true);
|
||||
coordinator.debugUseResolverDependencies(
|
||||
store: const _FakeFribbLookup([
|
||||
FribbMappingRow(anidbId: 111, tvdbId: 12345, malId: 101, anilistId: 201, tvdbSeason: 1, type: 'TV'),
|
||||
FribbMappingRow(anidbId: 222, tvdbId: 12345, malId: 102, anilistId: 202, tvdbSeason: 1, type: 'TV'),
|
||||
]),
|
||||
animeLists: _FakeAnimeListsLookup(
|
||||
matches: {
|
||||
'1-1': _match(anidbId: 111, serverEpisode: 1, animeEpisode: 1),
|
||||
'1-2': _match(anidbId: 111, serverEpisode: 2, animeEpisode: 2),
|
||||
'1-13': _match(anidbId: 222, serverEpisode: 13, animeEpisode: 1),
|
||||
'1-14': _match(anidbId: 222, serverEpisode: 14, animeEpisode: 2),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final malUpdates = <int, Map<String, String>>{};
|
||||
final malHttp = MockClient((request) async {
|
||||
final malId = int.parse(request.url.pathSegments[2]);
|
||||
if (request.method == 'GET') {
|
||||
return http.Response(json.encode({'num_episodes': 2}), 200);
|
||||
}
|
||||
expect(request.method, 'PUT');
|
||||
malUpdates[malId] = Uri.splitQueryString(request.body);
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
mal.rebindSession(_malSession(), onSessionInvalidated: () {}, httpClient: malHttp);
|
||||
|
||||
final anilistSaves = <Map<String, dynamic>>[];
|
||||
final anilistHttp = 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': 2},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (query.contains('SaveMediaListEntry')) {
|
||||
anilistSaves.add((body['variables'] as Map).cast<String, dynamic>());
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'data': {
|
||||
'SaveMediaListEntry': {'id': 1},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
fail('Unexpected AniList query: $query');
|
||||
});
|
||||
anilist.rebindSession(_anilistSession(), onSessionInvalidated: () {}, httpClient: anilistHttp);
|
||||
|
||||
final client = _FakeMediaServerClient(
|
||||
externalIdsByItem: {'show-1': const ExternalIds(tvdb: 12345)},
|
||||
descendantsByParent: {
|
||||
'show-1': [_episode(1), _episode(2), _episode(13), _episode(14)],
|
||||
},
|
||||
);
|
||||
|
||||
await coordinator.markWatched(_show(), client);
|
||||
|
||||
expect(malUpdates, {
|
||||
101: {'status': 'completed', 'num_watched_episodes': '2'},
|
||||
102: {'status': 'completed', 'num_watched_episodes': '2'},
|
||||
});
|
||||
expect(anilistSaves, contains(equals({'mediaId': 201, 'progress': 2, 'status': 'COMPLETED'})));
|
||||
expect(anilistSaves, contains(equals({'mediaId': 202, 'progress': 2, 'status': 'COMPLETED'})));
|
||||
});
|
||||
|
||||
test('removes manually unwatched season episodes from Simkl history', () async {
|
||||
final bodies = <Map<String, dynamic>>[];
|
||||
final httpClient = MockClient((request) async {
|
||||
expect(request.method, 'POST');
|
||||
expect(request.url.path, '/sync/history/remove');
|
||||
bodies.add((json.decode(request.body) as Map).cast<String, dynamic>());
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
simkl.rebindSession(_simklSession(), onSessionInvalidated: () {}, httpClient: httpClient);
|
||||
|
||||
final client = _FakeMediaServerClient(
|
||||
externalIdsByItem: {'show-1': const ExternalIds(tvdb: 12345)},
|
||||
descendantsByParent: {
|
||||
'season-1': [_episode(1), _episode(2)],
|
||||
},
|
||||
);
|
||||
|
||||
await coordinator.markUnwatched(_season(), client);
|
||||
|
||||
expect(client.descendantCalls, ['season-1']);
|
||||
expect(bodies, hasLength(2));
|
||||
expect(bodies.first['shows'], [
|
||||
{
|
||||
'ids': {'tvdb': 12345},
|
||||
'seasons': [
|
||||
{
|
||||
'number': 1,
|
||||
'episodes': [
|
||||
{'number': 1},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('removes manually unwatched split seasons from MAL and AniList lists', () async {
|
||||
await simkl.setEnabled(false);
|
||||
await mal.setEnabled(true);
|
||||
await anilist.setEnabled(true);
|
||||
coordinator.debugUseResolverDependencies(
|
||||
store: const _FakeFribbLookup([
|
||||
FribbMappingRow(anidbId: 111, tvdbId: 12345, malId: 101, anilistId: 201, tvdbSeason: 1, type: 'TV'),
|
||||
FribbMappingRow(anidbId: 222, tvdbId: 12345, malId: 102, anilistId: 202, tvdbSeason: 1, type: 'TV'),
|
||||
]),
|
||||
animeLists: _FakeAnimeListsLookup(
|
||||
matches: {
|
||||
'1-1': _match(anidbId: 111, serverEpisode: 1, animeEpisode: 1),
|
||||
'1-2': _match(anidbId: 111, serverEpisode: 2, animeEpisode: 2),
|
||||
'1-13': _match(anidbId: 222, serverEpisode: 13, animeEpisode: 1),
|
||||
'1-14': _match(anidbId: 222, serverEpisode: 14, animeEpisode: 2),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final malDeletes = <int>[];
|
||||
final malHttp = MockClient((request) async {
|
||||
expect(request.method, 'DELETE');
|
||||
malDeletes.add(int.parse(request.url.pathSegments[2]));
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
mal.rebindSession(_malSession(), onSessionInvalidated: () {}, httpClient: malHttp);
|
||||
|
||||
final anilistDeletes = <int>[];
|
||||
final anilistHttp = MockClient((request) async {
|
||||
final body = json.decode(request.body) as Map<String, dynamic>;
|
||||
final query = body['query'] as String;
|
||||
final variables = (body['variables'] as Map).cast<String, dynamic>();
|
||||
if (query.contains('mediaListEntry')) {
|
||||
final mediaId = variables['mediaId'] as int;
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'data': {
|
||||
'Media': {
|
||||
'mediaListEntry': {'id': mediaId + 100},
|
||||
},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (query.contains('DeleteMediaListEntry')) {
|
||||
anilistDeletes.add(variables['id'] as int);
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'data': {
|
||||
'DeleteMediaListEntry': {'deleted': true},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
fail('Unexpected AniList query: $query');
|
||||
});
|
||||
anilist.rebindSession(_anilistSession(), onSessionInvalidated: () {}, httpClient: anilistHttp);
|
||||
|
||||
final client = _FakeMediaServerClient(
|
||||
externalIdsByItem: {'show-1': const ExternalIds(tvdb: 12345)},
|
||||
descendantsByParent: {
|
||||
'show-1': [_episode(1), _episode(2), _episode(13), _episode(14)],
|
||||
},
|
||||
);
|
||||
|
||||
await coordinator.markUnwatched(_show(), client);
|
||||
|
||||
expect(malDeletes, unorderedEquals([101, 102]));
|
||||
expect(anilistDeletes, unorderedEquals([301, 302]));
|
||||
});
|
||||
|
||||
test('playback resolver is recreated when the server client changes', () async {
|
||||
simkl.rebindSession(
|
||||
_simklSession(),
|
||||
onSessionInvalidated: () {},
|
||||
httpClient: MockClient((_) async => http.Response('{}', 200)),
|
||||
);
|
||||
|
||||
final firstClient = _FakeMediaServerClient(
|
||||
serverId: 'server-a',
|
||||
externalIdsByItem: {'show-a': const ExternalIds(tvdb: 111)},
|
||||
descendantsByParent: const {},
|
||||
);
|
||||
final secondClient = _FakeMediaServerClient(
|
||||
serverId: 'server-b',
|
||||
externalIdsByItem: {'show-b': const ExternalIds(tvdb: 222)},
|
||||
descendantsByParent: const {},
|
||||
);
|
||||
final firstEpisode = _episode(1).copyWith(id: 'episode-a', serverId: 'server-a', grandparentId: 'show-a');
|
||||
final secondEpisode = _episode(1).copyWith(id: 'episode-b', serverId: 'server-b', grandparentId: 'show-b');
|
||||
|
||||
await coordinator.startPlayback(firstEpisode, firstClient);
|
||||
await coordinator.startPlayback(secondEpisode, secondClient);
|
||||
|
||||
expect(firstClient.externalIdCalls, ['show-a']);
|
||||
expect(secondClient.externalIdCalls, ['show-b']);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/models/trackers/anime_lists_mapping.dart';
|
||||
import 'package:plezy/models/trackers/fribb_mapping_row.dart';
|
||||
import 'package:plezy/services/trackers/anime_episode_progress_resolver.dart';
|
||||
import 'package:plezy/services/trackers/anime_lists_mapping_store.dart';
|
||||
import 'package:plezy/services/trackers/fribb_mapping_store.dart';
|
||||
import 'package:plezy/services/trackers/tracker_id_resolver.dart';
|
||||
import 'package:plezy/utils/external_ids.dart';
|
||||
@@ -44,15 +46,25 @@ class _FakeAnimeProgressLookup implements AnimeEpisodeProgressLookup {
|
||||
int clearCalls = 0;
|
||||
MediaItem? lastEpisode;
|
||||
AnimeProgressScope? lastScope;
|
||||
AnimeEpisodeMatch? lastMatch;
|
||||
bool? lastIncludeCurrentEpisode;
|
||||
|
||||
_FakeAnimeProgressLookup(int? progress)
|
||||
: result = progress == null ? null : ResolvedAnimeProgress(progress: progress);
|
||||
|
||||
@override
|
||||
Future<ResolvedAnimeProgress?> resolve(MediaItem episode, {required AnimeProgressScope scope}) async {
|
||||
Future<ResolvedAnimeProgress?> resolve(
|
||||
MediaItem episode, {
|
||||
required AnimeProgressScope scope,
|
||||
AnimeEpisodeMatch? animeMatch,
|
||||
Future<AnimeEpisodeMatch?> Function(MediaItem episode)? episodeMatcher,
|
||||
bool includeCurrentEpisode = true,
|
||||
}) async {
|
||||
resolveCalls++;
|
||||
lastEpisode = episode;
|
||||
lastScope = scope;
|
||||
lastMatch = animeMatch;
|
||||
lastIncludeCurrentEpisode = includeCurrentEpisode;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -62,6 +74,23 @@ class _FakeAnimeProgressLookup implements AnimeEpisodeProgressLookup {
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeAnimeListsLookup implements AnimeListsMappingLookup {
|
||||
final Map<String, AnimeEpisodeMatch> matches;
|
||||
|
||||
const _FakeAnimeListsLookup({this.matches = const {}});
|
||||
|
||||
@override
|
||||
Future<AnimeEpisodeMatch?> lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber}) async {
|
||||
return matches['$season-$episodeNumber'];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<int>> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season}) async => const <int>{};
|
||||
|
||||
@override
|
||||
Future<Set<int>> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async => const <int>{};
|
||||
}
|
||||
|
||||
MediaItem _episode({int season = 23, int number = 6}) => MediaItem(
|
||||
id: 'episode-$season-$number',
|
||||
backend: MediaBackend.plex,
|
||||
@@ -76,14 +105,27 @@ TrackerIdResolver _resolver({
|
||||
required List<FribbMappingRow> rows,
|
||||
required _FakeAnimeProgressLookup animeProgress,
|
||||
_FakeFribbLookup? lookup,
|
||||
AnimeListsMappingLookup animeLists = const _FakeAnimeListsLookup(),
|
||||
}) {
|
||||
return TrackerIdResolver(
|
||||
_FakeMediaServerClient({'show-1': const ExternalIds(tvdb: 81797, tmdb: 37854, imdb: 'tt0388629')}),
|
||||
store: lookup ?? _FakeFribbLookup(rows),
|
||||
animeLists: animeLists,
|
||||
animeProgress: animeProgress,
|
||||
);
|
||||
}
|
||||
|
||||
AnimeEpisodeMatch _match({required int anidbId, required int serverEpisode, required int animeEpisode}) =>
|
||||
AnimeEpisodeMatch(
|
||||
anidbId: anidbId,
|
||||
anidbSeason: 1,
|
||||
anidbEpisode: animeEpisode,
|
||||
provider: AnimeListProvider.tvdb,
|
||||
externalSeason: 1,
|
||||
externalEpisode: serverEpisode,
|
||||
kind: AnimeListMatchKind.range,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('TrackerIdResolver anime progress', () {
|
||||
test('one unseasoned regular TV row uses show-scope progress', () async {
|
||||
@@ -176,5 +218,68 @@ void main() {
|
||||
expect(animeProgress.clearCalls, 1);
|
||||
expect(animeProgress.resolveCalls, 2);
|
||||
});
|
||||
|
||||
test('same server season can select different anime entries by episode range', () async {
|
||||
final animeProgress = _FakeAnimeProgressLookup(2);
|
||||
final resolver = _resolver(
|
||||
animeProgress: animeProgress,
|
||||
animeLists: _FakeAnimeListsLookup(matches: {'1-14': _match(anidbId: 222, serverEpisode: 14, animeEpisode: 2)}),
|
||||
rows: const [
|
||||
FribbMappingRow(anidbId: 111, tvdbId: 81797, malId: 101, tvdbSeason: 1, type: 'TV'),
|
||||
FribbMappingRow(anidbId: 222, tvdbId: 81797, malId: 102, tvdbSeason: 1, type: 'TV'),
|
||||
],
|
||||
);
|
||||
|
||||
final ids = await resolver.resolveShowForEpisode(_episode(season: 1, number: 14));
|
||||
|
||||
expect(ids?.anime?.mal, 102);
|
||||
expect(ids?.animeProgressScope, AnimeProgressScope.mapped);
|
||||
expect(ids?.animeEpisodeNumber, 2);
|
||||
expect(ids?.animeProgress, 2);
|
||||
expect(animeProgress.lastScope, AnimeProgressScope.mapped);
|
||||
expect(animeProgress.lastMatch?.anidbId, 222);
|
||||
});
|
||||
|
||||
test('passes includeCurrentEpisode through for unwatch progress', () async {
|
||||
final animeProgress = _FakeAnimeProgressLookup(1);
|
||||
final resolver = _resolver(
|
||||
animeProgress: animeProgress,
|
||||
animeLists: _FakeAnimeListsLookup(matches: {'1-14': _match(anidbId: 222, serverEpisode: 14, animeEpisode: 2)}),
|
||||
rows: const [FribbMappingRow(anidbId: 222, tvdbId: 81797, malId: 102, tvdbSeason: 1, type: 'TV')],
|
||||
);
|
||||
|
||||
final ids = await resolver.resolveShowForEpisode(_episode(season: 1, number: 14), includeCurrentEpisode: false);
|
||||
|
||||
expect(ids?.animeProgress, 1);
|
||||
expect(animeProgress.lastIncludeCurrentEpisode, isFalse);
|
||||
});
|
||||
|
||||
test('episode-aware cache does not reuse a same-season split-cour row', () async {
|
||||
final animeProgress = _FakeAnimeProgressLookup(null);
|
||||
final lookup = _FakeFribbLookup(const [
|
||||
FribbMappingRow(anidbId: 111, tvdbId: 81797, malId: 101, tvdbSeason: 1, type: 'TV'),
|
||||
FribbMappingRow(anidbId: 222, tvdbId: 81797, malId: 102, tvdbSeason: 1, type: 'TV'),
|
||||
]);
|
||||
final client = _FakeMediaServerClient({'show-1': const ExternalIds(tvdb: 81797)});
|
||||
final resolver = TrackerIdResolver(
|
||||
client,
|
||||
store: lookup,
|
||||
animeLists: _FakeAnimeListsLookup(
|
||||
matches: {
|
||||
'1-12': _match(anidbId: 111, serverEpisode: 12, animeEpisode: 12),
|
||||
'1-13': _match(anidbId: 222, serverEpisode: 13, animeEpisode: 1),
|
||||
},
|
||||
),
|
||||
animeProgress: animeProgress,
|
||||
);
|
||||
|
||||
final first = await resolver.resolveShowForEpisode(_episode(season: 1, number: 12), includeAnimeProgress: false);
|
||||
final second = await resolver.resolveShowForEpisode(_episode(season: 1, number: 13), includeAnimeProgress: false);
|
||||
|
||||
expect(first?.anime?.mal, 101);
|
||||
expect(second?.anime?.mal, 102);
|
||||
expect(client.externalIdCalls, ['show-1']);
|
||||
expect(lookup.lookups, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user