feat(trackers): sync anime watch state by episode

This commit is contained in:
edde746
2026-05-16 20:54:39 +02:00
parent 310c4280fc
commit 3e74937386
28 changed files with 2203 additions and 78 deletions
@@ -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);
});
});
}