feat(trackers): sync anime watch state by episode
This commit is contained in:
@@ -0,0 +1,186 @@
|
|||||||
|
enum AnimeListProvider { tvdb, tmdb }
|
||||||
|
|
||||||
|
enum AnimeListMatchKind { explicit, range, defaultMapping }
|
||||||
|
|
||||||
|
class AnimeListSeasonRef {
|
||||||
|
final int? number;
|
||||||
|
final bool isAbsolute;
|
||||||
|
|
||||||
|
const AnimeListSeasonRef.number(this.number) : isAbsolute = false;
|
||||||
|
const AnimeListSeasonRef.absolute() : number = null, isAbsolute = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AnimeListEntry {
|
||||||
|
final int anidbId;
|
||||||
|
final String? name;
|
||||||
|
final String? rawTvdbId;
|
||||||
|
final int? tvdbId;
|
||||||
|
final AnimeListSeasonRef? defaultTvdbSeason;
|
||||||
|
final int episodeOffset;
|
||||||
|
final int? tmdbTvId;
|
||||||
|
final AnimeListSeasonRef? tmdbSeason;
|
||||||
|
final int tmdbOffset;
|
||||||
|
final List<int> tmdbMovieIds;
|
||||||
|
final List<String> imdbIds;
|
||||||
|
final List<AnimeListEpisodeMapping> mappings;
|
||||||
|
|
||||||
|
const AnimeListEntry({
|
||||||
|
required this.anidbId,
|
||||||
|
this.name,
|
||||||
|
this.rawTvdbId,
|
||||||
|
this.tvdbId,
|
||||||
|
this.defaultTvdbSeason,
|
||||||
|
this.episodeOffset = 0,
|
||||||
|
this.tmdbTvId,
|
||||||
|
this.tmdbSeason,
|
||||||
|
this.tmdbOffset = 0,
|
||||||
|
this.tmdbMovieIds = const [],
|
||||||
|
this.imdbIds = const [],
|
||||||
|
this.mappings = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
List<AnimeEpisodeMatch> resolveEpisode({
|
||||||
|
required AnimeListProvider provider,
|
||||||
|
required int externalSeason,
|
||||||
|
required int externalEpisode,
|
||||||
|
}) {
|
||||||
|
final explicit = <AnimeEpisodeMatch>[];
|
||||||
|
for (final mapping in mappings) {
|
||||||
|
if (!mapping.matchesProviderSeason(provider, externalSeason)) continue;
|
||||||
|
for (final item in mapping.explicit) {
|
||||||
|
if (item.externalEpisodes.contains(externalEpisode)) {
|
||||||
|
explicit.add(
|
||||||
|
AnimeEpisodeMatch(
|
||||||
|
anidbId: anidbId,
|
||||||
|
anidbSeason: mapping.anidbSeason,
|
||||||
|
anidbEpisode: item.anidbEpisode,
|
||||||
|
provider: provider,
|
||||||
|
externalSeason: externalSeason,
|
||||||
|
externalEpisode: externalEpisode,
|
||||||
|
kind: AnimeListMatchKind.explicit,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (explicit.isNotEmpty) return explicit;
|
||||||
|
|
||||||
|
final ranges = <AnimeEpisodeMatch>[];
|
||||||
|
for (final mapping in mappings) {
|
||||||
|
if (!mapping.matchesProviderSeason(provider, externalSeason)) continue;
|
||||||
|
final start = mapping.start;
|
||||||
|
if (start == null) continue;
|
||||||
|
final anidbEpisode = externalEpisode - mapping.offset;
|
||||||
|
if (anidbEpisode < start) continue;
|
||||||
|
final end = mapping.end;
|
||||||
|
if (end != null && anidbEpisode > end) continue;
|
||||||
|
if (anidbEpisode <= 0) continue;
|
||||||
|
ranges.add(
|
||||||
|
AnimeEpisodeMatch(
|
||||||
|
anidbId: anidbId,
|
||||||
|
anidbSeason: mapping.anidbSeason,
|
||||||
|
anidbEpisode: anidbEpisode,
|
||||||
|
provider: provider,
|
||||||
|
externalSeason: externalSeason,
|
||||||
|
externalEpisode: externalEpisode,
|
||||||
|
kind: AnimeListMatchKind.range,
|
||||||
|
rangeStart: start,
|
||||||
|
rangeEnd: end,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ranges.isNotEmpty) return ranges;
|
||||||
|
|
||||||
|
final defaultSeason = switch (provider) {
|
||||||
|
AnimeListProvider.tvdb => defaultTvdbSeason,
|
||||||
|
AnimeListProvider.tmdb => tmdbSeason,
|
||||||
|
};
|
||||||
|
final defaultOffset = switch (provider) {
|
||||||
|
AnimeListProvider.tvdb => episodeOffset,
|
||||||
|
AnimeListProvider.tmdb => tmdbOffset,
|
||||||
|
};
|
||||||
|
if (defaultSeason == null || defaultSeason.isAbsolute || defaultSeason.number != externalSeason) return const [];
|
||||||
|
|
||||||
|
final anidbEpisode = externalEpisode - defaultOffset;
|
||||||
|
if (anidbEpisode <= 0) return const [];
|
||||||
|
return [
|
||||||
|
AnimeEpisodeMatch(
|
||||||
|
anidbId: anidbId,
|
||||||
|
anidbSeason: 1,
|
||||||
|
anidbEpisode: anidbEpisode,
|
||||||
|
provider: provider,
|
||||||
|
externalSeason: externalSeason,
|
||||||
|
externalEpisode: externalEpisode,
|
||||||
|
kind: AnimeListMatchKind.defaultMapping,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool mapsSeason({required AnimeListProvider provider, required int externalSeason}) {
|
||||||
|
final defaultSeason = switch (provider) {
|
||||||
|
AnimeListProvider.tvdb => defaultTvdbSeason,
|
||||||
|
AnimeListProvider.tmdb => tmdbSeason,
|
||||||
|
};
|
||||||
|
if (defaultSeason != null && !defaultSeason.isAbsolute && defaultSeason.number == externalSeason) return true;
|
||||||
|
return mappings.any((mapping) => mapping.matchesProviderSeason(provider, externalSeason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AnimeListEpisodeMapping {
|
||||||
|
final int anidbSeason;
|
||||||
|
final AnimeListProvider provider;
|
||||||
|
final int externalSeason;
|
||||||
|
final int? start;
|
||||||
|
final int? end;
|
||||||
|
final int offset;
|
||||||
|
final List<AnimeListExplicitEpisodeMapping> explicit;
|
||||||
|
|
||||||
|
const AnimeListEpisodeMapping({
|
||||||
|
required this.anidbSeason,
|
||||||
|
required this.provider,
|
||||||
|
required this.externalSeason,
|
||||||
|
this.start,
|
||||||
|
this.end,
|
||||||
|
this.offset = 0,
|
||||||
|
this.explicit = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
bool matchesProviderSeason(AnimeListProvider provider, int season) =>
|
||||||
|
this.provider == provider && externalSeason == season;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AnimeListExplicitEpisodeMapping {
|
||||||
|
final int anidbEpisode;
|
||||||
|
final List<int> externalEpisodes;
|
||||||
|
|
||||||
|
const AnimeListExplicitEpisodeMapping({required this.anidbEpisode, required this.externalEpisodes});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AnimeEpisodeMatch {
|
||||||
|
final int anidbId;
|
||||||
|
final int anidbSeason;
|
||||||
|
final int anidbEpisode;
|
||||||
|
final AnimeListProvider provider;
|
||||||
|
final int externalSeason;
|
||||||
|
final int externalEpisode;
|
||||||
|
final AnimeListMatchKind kind;
|
||||||
|
final int? rangeStart;
|
||||||
|
final int? rangeEnd;
|
||||||
|
|
||||||
|
const AnimeEpisodeMatch({
|
||||||
|
required this.anidbId,
|
||||||
|
required this.anidbSeason,
|
||||||
|
required this.anidbEpisode,
|
||||||
|
required this.provider,
|
||||||
|
required this.externalSeason,
|
||||||
|
required this.externalEpisode,
|
||||||
|
required this.kind,
|
||||||
|
this.rangeStart,
|
||||||
|
this.rangeEnd,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool sameAnimeEntry(AnimeEpisodeMatch other) => anidbId == other.anidbId && anidbSeason == other.anidbSeason;
|
||||||
|
|
||||||
|
bool sameEpisode(AnimeEpisodeMatch other) =>
|
||||||
|
sameAnimeEntry(other) && anidbEpisode == other.anidbEpisode && kind == other.kind;
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ Object? _readTmdbSeason(Map json, String key) {
|
|||||||
/// One row from `anime-list-mini.json` (Fribb/anime-lists).
|
/// One row from `anime-list-mini.json` (Fribb/anime-lists).
|
||||||
@JsonSerializable(createToJson: false)
|
@JsonSerializable(createToJson: false)
|
||||||
class FribbMappingRow {
|
class FribbMappingRow {
|
||||||
|
@JsonKey(name: 'anidb_id', fromJson: flexibleInt)
|
||||||
|
final int? anidbId;
|
||||||
@JsonKey(name: 'anilist_id', fromJson: flexibleInt)
|
@JsonKey(name: 'anilist_id', fromJson: flexibleInt)
|
||||||
final int? anilistId;
|
final int? anilistId;
|
||||||
@JsonKey(name: 'imdb_id')
|
@JsonKey(name: 'imdb_id')
|
||||||
@@ -43,6 +45,7 @@ class FribbMappingRow {
|
|||||||
final String? type;
|
final String? type;
|
||||||
|
|
||||||
const FribbMappingRow({
|
const FribbMappingRow({
|
||||||
|
this.anidbId,
|
||||||
this.anilistId,
|
this.anilistId,
|
||||||
this.imdbId,
|
this.imdbId,
|
||||||
this.malId,
|
this.malId,
|
||||||
|
|||||||
@@ -6,15 +6,15 @@ part of 'fribb_mapping_row.dart';
|
|||||||
// JsonSerializableGenerator
|
// JsonSerializableGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
FribbMappingRow _$FribbMappingRowFromJson(Map<String, dynamic> json) =>
|
FribbMappingRow _$FribbMappingRowFromJson(Map<String, dynamic> json) => FribbMappingRow(
|
||||||
FribbMappingRow(
|
anidbId: flexibleInt(json['anidb_id']),
|
||||||
anilistId: flexibleInt(json['anilist_id']),
|
anilistId: flexibleInt(json['anilist_id']),
|
||||||
imdbId: json['imdb_id'] as String?,
|
imdbId: json['imdb_id'] as String?,
|
||||||
malId: flexibleInt(json['mal_id']),
|
malId: flexibleInt(json['mal_id']),
|
||||||
simklId: flexibleInt(json['simkl_id']),
|
simklId: flexibleInt(json['simkl_id']),
|
||||||
tmdbId: flexibleInt(json['themoviedb_id']),
|
tmdbId: flexibleInt(json['themoviedb_id']),
|
||||||
tvdbId: flexibleInt(json['tvdb_id']),
|
tvdbId: flexibleInt(json['tvdb_id']),
|
||||||
tvdbSeason: flexibleInt(_readTvdbSeason(json, 'tvdbSeason')),
|
tvdbSeason: flexibleInt(_readTvdbSeason(json, 'tvdbSeason')),
|
||||||
tmdbSeason: flexibleInt(_readTmdbSeason(json, 'tmdbSeason')),
|
tmdbSeason: flexibleInt(_readTmdbSeason(json, 'tmdbSeason')),
|
||||||
type: json['type'] as String?,
|
type: json['type'] as String?,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -202,8 +202,10 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
|||||||
|
|
||||||
if (isWatched) {
|
if (isWatched) {
|
||||||
await client.markUnwatched(metadata);
|
await client.markUnwatched(metadata);
|
||||||
|
unawaited(TrackerCoordinator.instance.markUnwatched(metadata, client));
|
||||||
} else {
|
} else {
|
||||||
await client.markWatched(metadata);
|
await client.markWatched(metadata);
|
||||||
|
unawaited(TrackerCoordinator.instance.markWatched(metadata, client));
|
||||||
}
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_watchStateChanged = true;
|
_watchStateChanged = true;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import '../utils/watch_state_notifier.dart';
|
|||||||
import 'multi_server_manager.dart';
|
import 'multi_server_manager.dart';
|
||||||
import 'plex_client.dart';
|
import 'plex_client.dart';
|
||||||
import 'settings_service.dart';
|
import 'settings_service.dart';
|
||||||
|
import 'trackers/tracker_coordinator.dart';
|
||||||
|
|
||||||
/// Service for managing offline watch progress and syncing it back to the
|
/// Service for managing offline watch progress and syncing it back to the
|
||||||
/// owning server. Backend-neutral over [MediaServerClient] — Plex actions
|
/// owning server. Backend-neutral over [MediaServerClient] — Plex actions
|
||||||
@@ -570,10 +571,12 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
|||||||
switch (action.actionType) {
|
switch (action.actionType) {
|
||||||
case 'watched':
|
case 'watched':
|
||||||
await client.markWatched(item);
|
await client.markWatched(item);
|
||||||
|
await TrackerCoordinator.instance.markWatched(item, client);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'unwatched':
|
case 'unwatched':
|
||||||
await client.markUnwatched(item);
|
await client.markUnwatched(item);
|
||||||
|
await TrackerCoordinator.instance.markUnwatched(item, client);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'progress':
|
case 'progress':
|
||||||
@@ -598,6 +601,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
|||||||
// If progress exceeded threshold, also mark as watched.
|
// If progress exceeded threshold, also mark as watched.
|
||||||
if (action.shouldMarkWatched) {
|
if (action.shouldMarkWatched) {
|
||||||
await client.markWatched(item);
|
await client.markWatched(item);
|
||||||
|
await TrackerCoordinator.instance.markWatched(item, client);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,34 @@ class AnilistClient {
|
|||||||
await query(mutation, variables: {'mediaId': mediaId, 'progress': progress, 'status': status});
|
await query(mutation, variables: {'mediaId': mediaId, 'progress': progress, 'status': status});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> deleteMediaListEntry(int mediaId) async {
|
||||||
|
const idQuery = '''
|
||||||
|
query(\$mediaId: Int) {
|
||||||
|
Media(id: \$mediaId, type: ANIME) {
|
||||||
|
mediaListEntry {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
final data = await query(idQuery, variables: {'mediaId': mediaId});
|
||||||
|
final media = data['Media'];
|
||||||
|
if (media is! Map) return;
|
||||||
|
final entry = media['mediaListEntry'];
|
||||||
|
if (entry is! Map) return;
|
||||||
|
final entryId = flexibleInt(entry['id']);
|
||||||
|
if (entryId == null) return;
|
||||||
|
|
||||||
|
const mutation = '''
|
||||||
|
mutation(\$id: Int) {
|
||||||
|
DeleteMediaListEntry(id: \$id) {
|
||||||
|
deleted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
await query(mutation, variables: {'id': entryId});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setMediaListScore({required int mediaId, required int score}) async {
|
Future<void> setMediaListScore({required int mediaId, required int score}) async {
|
||||||
const mutation = '''
|
const mutation = '''
|
||||||
mutation(\$mediaId: Int, \$scoreRaw: Int) {
|
mutation(\$mediaId: Int, \$scoreRaw: Int) {
|
||||||
|
|||||||
@@ -64,6 +64,21 @@ class AnilistTracker extends TrackerBase {
|
|||||||
appLogger.d('AniList: saved entry (anilist=$anilistId, progress=$watched, status=$status)');
|
appLogger.d('AniList: saved entry (anilist=$anilistId, progress=$watched, status=$status)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markUnwatched(TrackerContext ctx) async {
|
||||||
|
if (ctx.isMovie) {
|
||||||
|
await removeFromList(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeFromList(TrackerContext ctx) async {
|
||||||
|
final client = _client;
|
||||||
|
final anilistId = ctx.anime?.anilist;
|
||||||
|
if (client == null || anilistId == null) return;
|
||||||
|
await client.deleteMediaListEntry(anilistId);
|
||||||
|
appLogger.d('AniList: deleted entry (anilist=$anilistId)');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> rate(TrackerRatingContext ctx, int score) async {
|
Future<void> rate(TrackerRatingContext ctx, int score) async {
|
||||||
final client = _client;
|
final client = _client;
|
||||||
final anilistId = ctx.ids.anime?.anilist;
|
final anilistId = ctx.ids.anime?.anilist;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import '../../media/media_item.dart';
|
import '../../media/media_item.dart';
|
||||||
import '../../media/media_kind.dart';
|
import '../../media/media_kind.dart';
|
||||||
import '../../media/media_server_client.dart';
|
import '../../media/media_server_client.dart';
|
||||||
|
import '../../models/trackers/anime_lists_mapping.dart';
|
||||||
import '../../utils/app_logger.dart';
|
import '../../utils/app_logger.dart';
|
||||||
|
|
||||||
enum AnimeProgressScope { show, season }
|
enum AnimeProgressScope { show, season, mapped }
|
||||||
|
|
||||||
class ResolvedAnimeProgress {
|
class ResolvedAnimeProgress {
|
||||||
final int progress;
|
final int progress;
|
||||||
@@ -16,7 +17,14 @@ class ResolvedAnimeProgress {
|
|||||||
/// The coordinator builds tracker context before the current playback is marked
|
/// The coordinator builds tracker context before the current playback is marked
|
||||||
/// watched, so unwatched current episodes are added to the watched rollup.
|
/// watched, so unwatched current episodes are added to the watched rollup.
|
||||||
abstract interface class AnimeEpisodeProgressLookup {
|
abstract interface class AnimeEpisodeProgressLookup {
|
||||||
Future<ResolvedAnimeProgress?> resolve(MediaItem episode, {required AnimeProgressScope scope});
|
Future<ResolvedAnimeProgress?> resolve(
|
||||||
|
MediaItem episode, {
|
||||||
|
required AnimeProgressScope scope,
|
||||||
|
AnimeEpisodeMatch? animeMatch,
|
||||||
|
Future<AnimeEpisodeMatch?> Function(MediaItem episode)? episodeMatcher,
|
||||||
|
bool includeCurrentEpisode = true,
|
||||||
|
});
|
||||||
|
|
||||||
void clearCache();
|
void clearCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,22 +35,68 @@ class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup {
|
|||||||
AnimeEpisodeProgressResolver(this._client);
|
AnimeEpisodeProgressResolver(this._client);
|
||||||
|
|
||||||
@override
|
@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 {
|
||||||
final showId = episode.grandparentId;
|
final showId = episode.grandparentId;
|
||||||
final season = episode.parentIndex;
|
final season = episode.parentIndex;
|
||||||
if (showId == null || showId.isEmpty) return null;
|
if (showId == null || showId.isEmpty) return null;
|
||||||
if (season == null || season <= 0) return null;
|
if (season == null || season <= 0) return null;
|
||||||
|
|
||||||
|
if (scope == AnimeProgressScope.mapped && animeMatch != null) {
|
||||||
|
final mapped = episodeMatcher == null
|
||||||
|
? null
|
||||||
|
: await _mappedProgress(
|
||||||
|
showId,
|
||||||
|
episode,
|
||||||
|
animeMatch,
|
||||||
|
episodeMatcher,
|
||||||
|
includeCurrentEpisode: includeCurrentEpisode,
|
||||||
|
);
|
||||||
|
if (mapped != null) return mapped;
|
||||||
|
return includeCurrentEpisode ? ResolvedAnimeProgress(progress: animeMatch.anidbEpisode) : null;
|
||||||
|
}
|
||||||
|
|
||||||
final progressBySeason = await _seasonProgressFor(showId);
|
final progressBySeason = await _seasonProgressFor(showId);
|
||||||
if (progressBySeason == null) return null;
|
if (progressBySeason == null) return null;
|
||||||
|
|
||||||
final currentAlreadyWatched = (episode.viewCount ?? 0) > 0;
|
final currentAlreadyWatched = (episode.viewCount ?? 0) > 0 || !includeCurrentEpisode;
|
||||||
return switch (scope) {
|
return switch (scope) {
|
||||||
AnimeProgressScope.show => _showProgress(progressBySeason, currentAlreadyWatched),
|
AnimeProgressScope.show => _showProgress(progressBySeason, currentAlreadyWatched),
|
||||||
AnimeProgressScope.season => _seasonProgress(progressBySeason[season], currentAlreadyWatched),
|
AnimeProgressScope.season => _seasonProgress(progressBySeason[season], currentAlreadyWatched),
|
||||||
|
AnimeProgressScope.mapped => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<ResolvedAnimeProgress?> _mappedProgress(
|
||||||
|
String showId,
|
||||||
|
MediaItem current,
|
||||||
|
AnimeEpisodeMatch target,
|
||||||
|
Future<AnimeEpisodeMatch?> Function(MediaItem episode) episodeMatcher, {
|
||||||
|
required bool includeCurrentEpisode,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final episodes = await _client.fetchPlayableDescendants(showId);
|
||||||
|
var progress = includeCurrentEpisode ? target.anidbEpisode : 0;
|
||||||
|
for (final episode in episodes) {
|
||||||
|
if (episode.kind != MediaKind.episode) continue;
|
||||||
|
final isCurrent = episode.id == current.id;
|
||||||
|
if ((episode.viewCount ?? 0) <= 0 && !(includeCurrentEpisode && isCurrent)) continue;
|
||||||
|
final match = await episodeMatcher(episode);
|
||||||
|
if (match == null || !match.sameAnimeEntry(target)) continue;
|
||||||
|
if (match.anidbEpisode > progress) progress = match.anidbEpisode;
|
||||||
|
}
|
||||||
|
return progress > 0 ? ResolvedAnimeProgress(progress: progress) : null;
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.d('Anime progress: failed to load mapped episode watched state for $showId', error: e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<int, _SeasonProgress>?> _seasonProgressFor(String showId) async {
|
Future<Map<int, _SeasonProgress>?> _seasonProgressFor(String showId) async {
|
||||||
final existing = _seasonProgressLoads[showId];
|
final existing = _seasonProgressLoads[showId];
|
||||||
if (existing != null) return existing;
|
if (existing != null) return existing;
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
|
import '../../models/trackers/anime_lists_mapping.dart';
|
||||||
|
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;
|
||||||
|
import '../base_shared_preferences_service.dart';
|
||||||
|
|
||||||
|
class AnimeListsIndex {
|
||||||
|
final Map<int, List<AnimeListEntry>> byTvdb;
|
||||||
|
final Map<int, List<AnimeListEntry>> byTmdbTv;
|
||||||
|
|
||||||
|
const AnimeListsIndex({required this.byTvdb, required this.byTmdbTv});
|
||||||
|
|
||||||
|
bool get isEmpty => byTvdb.isEmpty && byTmdbTv.isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract interface class AnimeListsMappingLookup {
|
||||||
|
Future<AnimeEpisodeMatch?> lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber});
|
||||||
|
|
||||||
|
Future<Set<int>> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season});
|
||||||
|
|
||||||
|
Future<Set<int>> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AnimeListsMappingStore implements AnimeListsMappingLookup {
|
||||||
|
static const String _diskFileName = 'anime-list.xml';
|
||||||
|
static const String _prefsEtagKey = 'anime_lists_etag';
|
||||||
|
static const String _prefsLastCheckKey = 'anime_lists_last_check';
|
||||||
|
static const String _sourceUrl = 'https://cdn.jsdelivr.net/gh/Anime-Lists/anime-lists@master/anime-list.xml';
|
||||||
|
|
||||||
|
static const Duration _refreshInterval = Duration(days: 7);
|
||||||
|
static const Duration _requestTimeout = Duration(seconds: 60);
|
||||||
|
|
||||||
|
AnimeListsMappingStore._();
|
||||||
|
static final AnimeListsMappingStore instance = AnimeListsMappingStore._();
|
||||||
|
|
||||||
|
AnimeListsIndex? _index;
|
||||||
|
Future<AnimeListsIndex>? _loading;
|
||||||
|
bool _refreshRunning = false;
|
||||||
|
|
||||||
|
Future<AnimeListsIndex> _ensureLoaded() async {
|
||||||
|
final existing = _index;
|
||||||
|
if (existing != null) return existing;
|
||||||
|
final loading = _loading;
|
||||||
|
if (loading != null) return loading;
|
||||||
|
|
||||||
|
final fresh = _loadOrFetch();
|
||||||
|
_loading = fresh;
|
||||||
|
try {
|
||||||
|
final idx = await fresh;
|
||||||
|
if (!idx.isEmpty) {
|
||||||
|
_index = idx;
|
||||||
|
unawaited(maybeRefresh());
|
||||||
|
}
|
||||||
|
return idx;
|
||||||
|
} finally {
|
||||||
|
_loading = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AnimeListsIndex> _loadOrFetch() async {
|
||||||
|
final path = await _diskPath();
|
||||||
|
try {
|
||||||
|
return await compute(_readAndParseAnimeLists, path);
|
||||||
|
} on FileSystemException {
|
||||||
|
appLogger.d('Anime-Lists: no disk cache, downloading from jsDelivr');
|
||||||
|
final raw = await _download();
|
||||||
|
if (raw == null) return const AnimeListsIndex(byTvdb: {}, byTmdbTv: {});
|
||||||
|
return await compute(parseAnimeListsIndex, raw);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.w('Anime-Lists: parse failed - deleting disk copy so next lookup re-downloads', error: e);
|
||||||
|
await _deleteDiskCopy();
|
||||||
|
return const AnimeListsIndex(byTvdb: {}, byTmdbTv: {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _download() async {
|
||||||
|
final client = platform.createPlatformClient();
|
||||||
|
try {
|
||||||
|
final res = await sendAbortableHttpRequest(
|
||||||
|
client,
|
||||||
|
'GET',
|
||||||
|
Uri.parse(_sourceUrl),
|
||||||
|
headers: const {'Accept': 'application/xml,text/xml'},
|
||||||
|
timeout: _requestTimeout,
|
||||||
|
operation: 'Anime-Lists mapping download',
|
||||||
|
);
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
appLogger.d('Anime-Lists: download returned HTTP ${res.statusCode}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await _writeDiskCopy(res.body, etag: res.headers['etag']);
|
||||||
|
final prefs = await BaseSharedPreferencesService.sharedCache();
|
||||||
|
await prefs.setInt(_prefsLastCheckKey, DateTime.now().millisecondsSinceEpoch);
|
||||||
|
return res.body;
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.w('Anime-Lists: download failed', error: e);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AnimeEpisodeMatch?> lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber}) async {
|
||||||
|
final idx = await _ensureLoaded();
|
||||||
|
return lookupAnimeListEpisodeInIndex(
|
||||||
|
idx,
|
||||||
|
tvdbId: tvdbId,
|
||||||
|
tmdbId: tmdbId,
|
||||||
|
season: season,
|
||||||
|
episodeNumber: episodeNumber,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Set<int>> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season}) async {
|
||||||
|
final idx = await _ensureLoaded();
|
||||||
|
if (tvdbId != null) {
|
||||||
|
final ids = _seasonAnimeIds(idx.byTvdb[tvdbId], AnimeListProvider.tvdb, season);
|
||||||
|
if (ids.isNotEmpty) return ids;
|
||||||
|
}
|
||||||
|
if (tmdbId != null) {
|
||||||
|
return _seasonAnimeIds(idx.byTmdbTv[tmdbId], AnimeListProvider.tmdb, season);
|
||||||
|
}
|
||||||
|
return const <int>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Set<int>> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async {
|
||||||
|
final idx = await _ensureLoaded();
|
||||||
|
if (tvdbId != null) {
|
||||||
|
final entries = idx.byTvdb[tvdbId];
|
||||||
|
if (entries != null && entries.isNotEmpty) return {for (final entry in entries) entry.anidbId};
|
||||||
|
}
|
||||||
|
if (tmdbId != null) {
|
||||||
|
final entries = idx.byTmdbTv[tmdbId];
|
||||||
|
if (entries != null && entries.isNotEmpty) return {for (final entry in entries) entry.anidbId};
|
||||||
|
}
|
||||||
|
return const <int>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> maybeRefresh() async {
|
||||||
|
if (_refreshRunning) return;
|
||||||
|
if (_index == null) return;
|
||||||
|
_refreshRunning = true;
|
||||||
|
try {
|
||||||
|
final prefs = await BaseSharedPreferencesService.sharedCache();
|
||||||
|
final lastCheck = prefs.getInt(_prefsLastCheckKey) ?? 0;
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
if (now - lastCheck < _refreshInterval.inMilliseconds) return;
|
||||||
|
|
||||||
|
final etag = prefs.getString(_prefsEtagKey);
|
||||||
|
final client = platform.createPlatformClient();
|
||||||
|
try {
|
||||||
|
final res = await sendAbortableHttpRequest(
|
||||||
|
client,
|
||||||
|
'GET',
|
||||||
|
Uri.parse(_sourceUrl),
|
||||||
|
headers: {'If-None-Match': ?etag, 'Accept': 'application/xml,text/xml'},
|
||||||
|
timeout: _requestTimeout,
|
||||||
|
operation: 'Anime-Lists mapping refresh',
|
||||||
|
);
|
||||||
|
await prefs.setInt(_prefsLastCheckKey, now);
|
||||||
|
|
||||||
|
if (res.statusCode == 304) {
|
||||||
|
appLogger.d('Anime-Lists: mapping unchanged (304)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
appLogger.d('Anime-Lists: refresh returned HTTP ${res.statusCode}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _writeDiskCopy(res.body, etag: res.headers['etag']);
|
||||||
|
final fresh = await compute(parseAnimeListsIndex, res.body);
|
||||||
|
_index = fresh;
|
||||||
|
appLogger.d('Anime-Lists: mapping refreshed (${fresh.byTvdb.length} tvdb entries)');
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.d('Anime-Lists: refresh failed (non-fatal)', error: e);
|
||||||
|
} finally {
|
||||||
|
_refreshRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _writeDiskCopy(String body, {String? etag}) async {
|
||||||
|
await File(await _diskPath()).writeAsString(body, flush: true);
|
||||||
|
if (etag != null) {
|
||||||
|
final prefs = await BaseSharedPreferencesService.sharedCache();
|
||||||
|
await prefs.setString(_prefsEtagKey, etag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteDiskCopy() async {
|
||||||
|
try {
|
||||||
|
await File(await _diskPath()).delete();
|
||||||
|
} on FileSystemException {
|
||||||
|
// Already gone.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _diskPath() async {
|
||||||
|
final dir = await getApplicationSupportDirectory();
|
||||||
|
return p.join(dir.path, _diskFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
void resetForTesting() {
|
||||||
|
_index = null;
|
||||||
|
_loading = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
AnimeEpisodeMatch? lookupAnimeListEpisodeInIndex(
|
||||||
|
AnimeListsIndex idx, {
|
||||||
|
int? tvdbId,
|
||||||
|
int? tmdbId,
|
||||||
|
int? season,
|
||||||
|
int? episodeNumber,
|
||||||
|
}) {
|
||||||
|
if (season == null || episodeNumber == null || episodeNumber <= 0) return null;
|
||||||
|
if (tvdbId != null) {
|
||||||
|
final selected = _selectMatch(_matches(idx.byTvdb[tvdbId], AnimeListProvider.tvdb, season, episodeNumber));
|
||||||
|
if (selected != null) return selected;
|
||||||
|
}
|
||||||
|
if (tmdbId != null) {
|
||||||
|
return _selectMatch(_matches(idx.byTmdbTv[tmdbId], AnimeListProvider.tmdb, season, episodeNumber));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AnimeEpisodeMatch> _matches(
|
||||||
|
List<AnimeListEntry>? entries,
|
||||||
|
AnimeListProvider provider,
|
||||||
|
int season,
|
||||||
|
int episodeNumber,
|
||||||
|
) {
|
||||||
|
if (entries == null || entries.isEmpty) return const [];
|
||||||
|
return [
|
||||||
|
for (final entry in entries)
|
||||||
|
...entry.resolveEpisode(provider: provider, externalSeason: season, externalEpisode: episodeNumber),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimeEpisodeMatch? _selectMatch(List<AnimeEpisodeMatch> matches) {
|
||||||
|
if (matches.isEmpty) return null;
|
||||||
|
final bestPriority = matches.map(_matchPriority).reduce((a, b) => a < b ? a : b);
|
||||||
|
final best = matches.where((match) => _matchPriority(match) == bestPriority).toList(growable: false);
|
||||||
|
final first = best.first;
|
||||||
|
if (best.every(first.sameEpisode)) return first;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _matchPriority(AnimeEpisodeMatch match) => switch (match.kind) {
|
||||||
|
AnimeListMatchKind.explicit => 0,
|
||||||
|
AnimeListMatchKind.range => 1,
|
||||||
|
AnimeListMatchKind.defaultMapping => 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
Set<int> _seasonAnimeIds(List<AnimeListEntry>? entries, AnimeListProvider provider, int season) {
|
||||||
|
if (entries == null || entries.isEmpty) return const <int>{};
|
||||||
|
return {
|
||||||
|
for (final entry in entries)
|
||||||
|
if (entry.mapsSeason(provider: provider, externalSeason: season)) entry.anidbId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimeListsIndex _readAndParseAnimeLists(String path) {
|
||||||
|
final raw = File(path).readAsStringSync();
|
||||||
|
return parseAnimeListsIndex(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
AnimeListsIndex parseAnimeListsIndex(String raw) {
|
||||||
|
final document = XmlDocument.parse(raw);
|
||||||
|
final byTvdb = <int, List<AnimeListEntry>>{};
|
||||||
|
final byTmdbTv = <int, List<AnimeListEntry>>{};
|
||||||
|
|
||||||
|
for (final anime in document.findAllElements('anime')) {
|
||||||
|
final anidbId = flexibleInt(anime.getAttribute('anidbid'));
|
||||||
|
if (anidbId == null) continue;
|
||||||
|
final entry = AnimeListEntry(
|
||||||
|
anidbId: anidbId,
|
||||||
|
name: anime.getElement('name')?.innerText.trim(),
|
||||||
|
rawTvdbId: anime.getAttribute('tvdbid'),
|
||||||
|
tvdbId: flexibleInt(anime.getAttribute('tvdbid')),
|
||||||
|
defaultTvdbSeason: _seasonRef(anime.getAttribute('defaulttvdbseason')),
|
||||||
|
episodeOffset: flexibleInt(anime.getAttribute('episodeoffset')) ?? 0,
|
||||||
|
tmdbTvId: flexibleInt(anime.getAttribute('tmdbtv')),
|
||||||
|
tmdbSeason: _seasonRef(anime.getAttribute('tmdbseason')),
|
||||||
|
tmdbOffset: flexibleInt(anime.getAttribute('tmdboffset')) ?? 0,
|
||||||
|
tmdbMovieIds: _intList(anime.getAttribute('tmdbid')),
|
||||||
|
imdbIds: _stringList(anime.getAttribute('imdbid')),
|
||||||
|
mappings: _parseMappings(anime),
|
||||||
|
);
|
||||||
|
|
||||||
|
final tvdb = entry.tvdbId;
|
||||||
|
if (tvdb != null) (byTvdb[tvdb] ??= <AnimeListEntry>[]).add(entry);
|
||||||
|
final tmdbTv = entry.tmdbTvId;
|
||||||
|
if (tmdbTv != null) (byTmdbTv[tmdbTv] ??= <AnimeListEntry>[]).add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnimeListsIndex(byTvdb: byTvdb, byTmdbTv: byTmdbTv);
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimeListSeasonRef? _seasonRef(String? value) {
|
||||||
|
if (value == null || value.isEmpty) return null;
|
||||||
|
if (value == 'a') return const AnimeListSeasonRef.absolute();
|
||||||
|
final number = flexibleInt(value);
|
||||||
|
return number == null ? null : AnimeListSeasonRef.number(number);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _intList(String? value) {
|
||||||
|
if (value == null || value.isEmpty) return const [];
|
||||||
|
return [
|
||||||
|
for (final part in value.split(','))
|
||||||
|
if (flexibleInt(part.trim()) case final parsed?) parsed,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _stringList(String? value) {
|
||||||
|
if (value == null || value.isEmpty) return const [];
|
||||||
|
return [
|
||||||
|
for (final part in value.split(','))
|
||||||
|
if (part.trim().isNotEmpty) part.trim(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AnimeListEpisodeMapping> _parseMappings(XmlElement anime) {
|
||||||
|
final list = anime.getElement('mapping-list');
|
||||||
|
if (list == null) return const [];
|
||||||
|
final mappings = <AnimeListEpisodeMapping>[];
|
||||||
|
for (final mapping in list.findElements('mapping')) {
|
||||||
|
final anidbSeason = flexibleInt(mapping.getAttribute('anidbseason'));
|
||||||
|
if (anidbSeason == null) continue;
|
||||||
|
final start = flexibleInt(mapping.getAttribute('start'));
|
||||||
|
final end = flexibleInt(mapping.getAttribute('end'));
|
||||||
|
final offset = flexibleInt(mapping.getAttribute('offset')) ?? 0;
|
||||||
|
final explicit = _parseExplicitMappings(mapping.innerText);
|
||||||
|
|
||||||
|
final tvdbSeason = flexibleInt(mapping.getAttribute('tvdbseason'));
|
||||||
|
if (tvdbSeason != null) {
|
||||||
|
mappings.add(
|
||||||
|
AnimeListEpisodeMapping(
|
||||||
|
anidbSeason: anidbSeason,
|
||||||
|
provider: AnimeListProvider.tvdb,
|
||||||
|
externalSeason: tvdbSeason,
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
offset: offset,
|
||||||
|
explicit: explicit,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final tmdbSeason = flexibleInt(mapping.getAttribute('tmdbseason'));
|
||||||
|
if (tmdbSeason != null) {
|
||||||
|
mappings.add(
|
||||||
|
AnimeListEpisodeMapping(
|
||||||
|
anidbSeason: anidbSeason,
|
||||||
|
provider: AnimeListProvider.tmdb,
|
||||||
|
externalSeason: tmdbSeason,
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
offset: offset,
|
||||||
|
explicit: explicit,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mappings;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AnimeListExplicitEpisodeMapping> _parseExplicitMappings(String raw) {
|
||||||
|
final trimmed = raw.trim();
|
||||||
|
if (trimmed.isEmpty) return const [];
|
||||||
|
final mappings = <AnimeListExplicitEpisodeMapping>[];
|
||||||
|
for (final segment in trimmed.split(';')) {
|
||||||
|
final item = segment.trim();
|
||||||
|
if (item.isEmpty) continue;
|
||||||
|
final separator = item.indexOf('-');
|
||||||
|
if (separator <= 0 || separator == item.length - 1) continue;
|
||||||
|
final anidbEpisode = flexibleInt(item.substring(0, separator));
|
||||||
|
if (anidbEpisode == null) continue;
|
||||||
|
final externalEpisodes = <int>[];
|
||||||
|
for (final target in item.substring(separator + 1).split('+')) {
|
||||||
|
final externalEpisode = flexibleInt(target.trim());
|
||||||
|
if (externalEpisode == null || externalEpisode == 0) continue;
|
||||||
|
externalEpisodes.add(externalEpisode);
|
||||||
|
}
|
||||||
|
if (externalEpisodes.isEmpty) continue;
|
||||||
|
mappings.add(AnimeListExplicitEpisodeMapping(anidbEpisode: anidbEpisode, externalEpisodes: externalEpisodes));
|
||||||
|
}
|
||||||
|
return mappings;
|
||||||
|
}
|
||||||
@@ -60,6 +60,10 @@ class MalClient {
|
|||||||
await _request('PUT', '/anime/$animeId/my_list_status', formBody: fields);
|
await _request('PUT', '/anime/$animeId/my_list_status', formBody: fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> deleteMyListStatus(int animeId) async {
|
||||||
|
await _request('DELETE', '/anime/$animeId/my_list_status');
|
||||||
|
}
|
||||||
|
|
||||||
Future<int?> getMyListScore(int animeId) async {
|
Future<int?> getMyListScore(int animeId) async {
|
||||||
try {
|
try {
|
||||||
final res = await _request('GET', '/anime/$animeId?fields=my_list_status');
|
final res = await _request('GET', '/anime/$animeId?fields=my_list_status');
|
||||||
|
|||||||
@@ -81,6 +81,30 @@ class MalTracker extends TrackerBase {
|
|||||||
appLogger.d('MAL: updated list status (mal=$malId, fields=$fields)');
|
appLogger.d('MAL: updated list status (mal=$malId, fields=$fields)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markUnwatched(TrackerContext ctx) async {
|
||||||
|
if (ctx.isMovie) {
|
||||||
|
await removeFromList(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeFromList(TrackerContext ctx) async {
|
||||||
|
final client = _client;
|
||||||
|
final malId = ctx.anime?.mal;
|
||||||
|
if (client == null || malId == null) return;
|
||||||
|
await _deleteMyListStatus(client, malId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteMyListStatus(MalClient client, int malId) async {
|
||||||
|
try {
|
||||||
|
await client.deleteMyListStatus(malId);
|
||||||
|
appLogger.d('MAL: deleted list status (mal=$malId)');
|
||||||
|
} on MalApiException catch (e) {
|
||||||
|
if (e.statusCode == 404) return;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> rate(TrackerRatingContext ctx, int score) async {
|
Future<void> rate(TrackerRatingContext ctx, int score) async {
|
||||||
final client = _client;
|
final client = _client;
|
||||||
final malId = ctx.ids.anime?.mal;
|
final malId = ctx.ids.anime?.mal;
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ class SimklClient {
|
|||||||
/// ```
|
/// ```
|
||||||
Future<void> addToHistory(Map<String, dynamic> body) => _request('POST', '/sync/history', body: body);
|
Future<void> addToHistory(Map<String, dynamic> body) => _request('POST', '/sync/history', body: body);
|
||||||
|
|
||||||
|
Future<void> removeFromHistory(Map<String, dynamic> body) => _request('POST', '/sync/history/remove', body: body);
|
||||||
|
|
||||||
Future<void> addRatings(Map<String, dynamic> body) => _request('POST', '/sync/ratings', body: body);
|
Future<void> addRatings(Map<String, dynamic> body) => _request('POST', '/sync/ratings', body: body);
|
||||||
|
|
||||||
Future<void> removeRatings(Map<String, dynamic> body) => _request('POST', '/sync/ratings/remove', body: body);
|
Future<void> removeRatings(Map<String, dynamic> body) => _request('POST', '/sync/ratings/remove', body: body);
|
||||||
|
|||||||
@@ -56,7 +56,26 @@ class SimklTracker extends TrackerBase {
|
|||||||
final ids = _buildIds(external: ctx.external, anime: ctx.anime);
|
final ids = _buildIds(external: ctx.external, anime: ctx.anime);
|
||||||
if (ids.isEmpty) return;
|
if (ids.isEmpty) return;
|
||||||
|
|
||||||
final body = ctx.isMovie
|
final body = _historyBody(ctx, ids);
|
||||||
|
|
||||||
|
await client.addToHistory(body);
|
||||||
|
appLogger.d('Simkl: marked watched (ids=$ids, isMovie=${ctx.isMovie})');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markUnwatched(TrackerContext ctx) async {
|
||||||
|
final client = _client;
|
||||||
|
if (client == null) return;
|
||||||
|
|
||||||
|
final ids = _buildIds(external: ctx.external, anime: ctx.anime);
|
||||||
|
if (ids.isEmpty) return;
|
||||||
|
|
||||||
|
await client.removeFromHistory(_historyBody(ctx, ids));
|
||||||
|
appLogger.d('Simkl: marked unwatched (ids=$ids, isMovie=${ctx.isMovie})');
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _historyBody(TrackerContext ctx, Map<String, Object> ids) {
|
||||||
|
return ctx.isMovie
|
||||||
? {
|
? {
|
||||||
'movies': [
|
'movies': [
|
||||||
{'ids': ids},
|
{'ids': ids},
|
||||||
@@ -77,9 +96,6 @@ class SimklTracker extends TrackerBase {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
await client.addToHistory(body);
|
|
||||||
appLogger.d('Simkl: marked watched (ids=$ids, isMovie=${ctx.isMovie})');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int?> getRating(TrackerRatingContext ctx) async {
|
Future<int?> getRating(TrackerRatingContext ctx) async {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ abstract class Tracker {
|
|||||||
bool shouldScrobbleForLibrary(String? libraryGlobalKey);
|
bool shouldScrobbleForLibrary(String? libraryGlobalKey);
|
||||||
|
|
||||||
Future<void> markWatched(TrackerContext ctx);
|
Future<void> markWatched(TrackerContext ctx);
|
||||||
|
Future<void> markUnwatched(TrackerContext ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
class TrackerRatingUnavailableException implements Exception {
|
class TrackerRatingUnavailableException implements Exception {
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import '../../media/media_kind.dart';
|
|||||||
import '../../media/media_server_client.dart';
|
import '../../media/media_server_client.dart';
|
||||||
import '../../models/trackers/tracker_context.dart';
|
import '../../models/trackers/tracker_context.dart';
|
||||||
import '../../utils/app_logger.dart';
|
import '../../utils/app_logger.dart';
|
||||||
|
import '../../utils/episode_collection.dart';
|
||||||
|
import 'anime_episode_progress_resolver.dart';
|
||||||
|
import 'anime_lists_mapping_store.dart';
|
||||||
import 'anilist/anilist_tracker.dart';
|
import 'anilist/anilist_tracker.dart';
|
||||||
|
import 'fribb_mapping_store.dart';
|
||||||
import 'mal/mal_tracker.dart';
|
import 'mal/mal_tracker.dart';
|
||||||
import 'simkl/simkl_tracker.dart';
|
import 'simkl/simkl_tracker.dart';
|
||||||
import 'tracker.dart';
|
import 'tracker.dart';
|
||||||
@@ -27,7 +31,11 @@ class TrackerCoordinator {
|
|||||||
/// Resolver persists across episode swaps so back-to-back episodes of the
|
/// Resolver persists across episode swaps so back-to-back episodes of the
|
||||||
/// same show reuse the cached IDs. Cleared only on profile switch.
|
/// same show reuse the cached IDs. Cleared only on profile switch.
|
||||||
TrackerIdResolver? _resolver;
|
TrackerIdResolver? _resolver;
|
||||||
|
String? _resolverClientKey;
|
||||||
String? _activeLibraryGlobalKey;
|
String? _activeLibraryGlobalKey;
|
||||||
|
FribbMappingLookup? _debugFribbStore;
|
||||||
|
AnimeListsMappingLookup? _debugAnimeListsStore;
|
||||||
|
AnimeEpisodeProgressLookup? _debugAnimeProgress;
|
||||||
|
|
||||||
TrackerContext? _ctx;
|
TrackerContext? _ctx;
|
||||||
Duration _duration = Duration.zero;
|
Duration _duration = Duration.zero;
|
||||||
@@ -43,14 +51,19 @@ class TrackerCoordinator {
|
|||||||
final mediaType = metadata.kind;
|
final mediaType = metadata.kind;
|
||||||
if (mediaType != MediaKind.movie && mediaType != MediaKind.episode) return;
|
if (mediaType != MediaKind.movie && mediaType != MediaKind.episode) return;
|
||||||
final libraryGlobalKey = metadata.libraryGlobalKey;
|
final libraryGlobalKey = metadata.libraryGlobalKey;
|
||||||
if (!_trackers.any((t) => t.canScrobble && t.shouldScrobbleForLibrary(libraryGlobalKey))) {
|
if (!_hasActiveTrackerForLibrary(libraryGlobalKey)) {
|
||||||
_reset();
|
_reset();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_activeLibraryGlobalKey = libraryGlobalKey;
|
_activeLibraryGlobalKey = libraryGlobalKey;
|
||||||
_resolver ??= TrackerIdResolver(client, needsFribb: _anyTrackerNeedsFribb);
|
final clientKey = client.cacheServerId;
|
||||||
final ctx = await _buildContext(metadata);
|
if (_resolver == null || _resolverClientKey != clientKey) {
|
||||||
|
_resolver?.clearCache();
|
||||||
|
_resolver = _newResolver(client, needsFribb: _anyTrackerNeedsFribb);
|
||||||
|
_resolverClientKey = clientKey;
|
||||||
|
}
|
||||||
|
final ctx = await _buildContext(metadata, _resolver!);
|
||||||
if (ctx == null) {
|
if (ctx == null) {
|
||||||
appLogger.d('Trackers: no external IDs for ${metadata.id}');
|
appLogger.d('Trackers: no external IDs for ${metadata.id}');
|
||||||
_reset();
|
_reset();
|
||||||
@@ -60,8 +73,217 @@ class TrackerCoordinator {
|
|||||||
_ctx = ctx;
|
_ctx = ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _anyTrackerNeedsFribb() =>
|
bool _anyTrackerNeedsFribb() => _anyTrackerNeedsFribbForLibrary(_activeLibraryGlobalKey);
|
||||||
_trackers.any((t) => t.canScrobble && t.needsFribb && t.shouldScrobbleForLibrary(_activeLibraryGlobalKey));
|
|
||||||
|
bool _hasActiveTrackerForLibrary(String? libraryGlobalKey) =>
|
||||||
|
_trackers.any((t) => t.canScrobble && t.shouldScrobbleForLibrary(libraryGlobalKey));
|
||||||
|
|
||||||
|
bool _anyTrackerNeedsFribbForLibrary(String? libraryGlobalKey) =>
|
||||||
|
_trackers.any((t) => t.canScrobble && t.needsFribb && t.shouldScrobbleForLibrary(libraryGlobalKey));
|
||||||
|
|
||||||
|
void debugUseResolverDependencies({
|
||||||
|
FribbMappingLookup? store,
|
||||||
|
AnimeListsMappingLookup? animeLists,
|
||||||
|
AnimeEpisodeProgressLookup? animeProgress,
|
||||||
|
}) {
|
||||||
|
_debugFribbStore = store;
|
||||||
|
_debugAnimeListsStore = animeLists;
|
||||||
|
_debugAnimeProgress = animeProgress;
|
||||||
|
invalidateResolverCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
TrackerIdResolver _newResolver(MediaServerClient client, {required bool Function() needsFribb}) => TrackerIdResolver(
|
||||||
|
client,
|
||||||
|
needsFribb: needsFribb,
|
||||||
|
store: _debugFribbStore,
|
||||||
|
animeLists: _debugAnimeListsStore,
|
||||||
|
animeProgress: _debugAnimeProgress,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> markWatched(MediaItem item, MediaServerClient client) async {
|
||||||
|
try {
|
||||||
|
await _markWatched(item, client);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.d('Trackers: manual markWatched failed for ${item.id}', error: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markUnwatched(MediaItem item, MediaServerClient client) async {
|
||||||
|
try {
|
||||||
|
await _markUnwatched(item, client);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.d('Trackers: manual markUnwatched failed for ${item.id}', error: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markWatched(MediaItem item, MediaServerClient client) async {
|
||||||
|
final kind = item.kind;
|
||||||
|
if (kind != MediaKind.movie && kind != MediaKind.episode && kind != MediaKind.season && kind != MediaKind.show) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final libraryGlobalKey = item.libraryGlobalKey;
|
||||||
|
if (!_hasActiveTrackerForLibrary(libraryGlobalKey)) return;
|
||||||
|
|
||||||
|
final resolver = _newResolver(client, needsFribb: () => _anyTrackerNeedsFribbForLibrary(libraryGlobalKey));
|
||||||
|
|
||||||
|
if (kind == MediaKind.movie || kind == MediaKind.episode) {
|
||||||
|
await _markSingleWatched(item, resolver);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final episodes = <MediaItem>[];
|
||||||
|
if (kind == MediaKind.show) {
|
||||||
|
await collectEpisodesForShow(client, item.id, unwatchedOnly: false, out: episodes, fallback: item);
|
||||||
|
} else {
|
||||||
|
await collectEpisodesForSeason(client, item.id, unwatchedOnly: false, out: episodes, fallback: item);
|
||||||
|
}
|
||||||
|
appLogger.d('Trackers: manual ${kind.name} ${item.id} expanded to ${episodes.length} episodes');
|
||||||
|
|
||||||
|
await _markContainerEpisodesWatched(episodes, resolver);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markUnwatched(MediaItem item, MediaServerClient client) async {
|
||||||
|
final kind = item.kind;
|
||||||
|
if (kind != MediaKind.movie && kind != MediaKind.episode && kind != MediaKind.season && kind != MediaKind.show) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final libraryGlobalKey = item.libraryGlobalKey;
|
||||||
|
if (!_hasActiveTrackerForLibrary(libraryGlobalKey)) return;
|
||||||
|
|
||||||
|
final resolver = _newResolver(client, needsFribb: () => _anyTrackerNeedsFribbForLibrary(libraryGlobalKey));
|
||||||
|
|
||||||
|
if (kind == MediaKind.movie || kind == MediaKind.episode) {
|
||||||
|
await _markSingleUnwatched(item, resolver);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final episodes = <MediaItem>[];
|
||||||
|
if (kind == MediaKind.show) {
|
||||||
|
await collectEpisodesForShow(client, item.id, unwatchedOnly: false, out: episodes, fallback: item);
|
||||||
|
} else {
|
||||||
|
await collectEpisodesForSeason(client, item.id, unwatchedOnly: false, out: episodes, fallback: item);
|
||||||
|
}
|
||||||
|
appLogger.d('Trackers: manual ${kind.name} ${item.id} unwatched expanded to ${episodes.length} episodes');
|
||||||
|
|
||||||
|
await _markContainerEpisodesUnwatched(episodes, resolver);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markContainerEpisodesWatched(List<MediaItem> episodes, TrackerIdResolver resolver) async {
|
||||||
|
final animeGroups = <String, _ManualAnimeProgress>{};
|
||||||
|
var resolved = 0;
|
||||||
|
|
||||||
|
for (final episode in episodes) {
|
||||||
|
final ctx = await _buildContext(episode, resolver, includeAnimeProgress: false);
|
||||||
|
if (ctx == null) continue;
|
||||||
|
resolved++;
|
||||||
|
|
||||||
|
await _dispatchToTrackers([SimklTracker.instance], ctx);
|
||||||
|
|
||||||
|
final key = _animeGroupKey(ctx);
|
||||||
|
if (key == null) continue;
|
||||||
|
(animeGroups[key] ??= _ManualAnimeProgress(ctx, fallbackToCount: true)).add(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
appLogger.d('Trackers: manual container resolved $resolved/${episodes.length} episodes');
|
||||||
|
|
||||||
|
for (final group in animeGroups.values) {
|
||||||
|
final ctx = group.context;
|
||||||
|
if (ctx != null) await _dispatchToTrackers([MalTracker.instance, AnilistTracker.instance], ctx);
|
||||||
|
}
|
||||||
|
appLogger.d('Trackers: manual container resolved ${animeGroups.length} anime entries');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markContainerEpisodesUnwatched(List<MediaItem> episodes, TrackerIdResolver resolver) async {
|
||||||
|
final malEntries = <int, TrackerContext>{};
|
||||||
|
final anilistEntries = <int, TrackerContext>{};
|
||||||
|
var resolved = 0;
|
||||||
|
|
||||||
|
for (final episode in episodes) {
|
||||||
|
final ctx = await _buildContext(
|
||||||
|
episode,
|
||||||
|
resolver,
|
||||||
|
includeAnimeProgress: false,
|
||||||
|
fallbackToAnimeEpisodeNumber: false,
|
||||||
|
);
|
||||||
|
if (ctx == null) continue;
|
||||||
|
resolved++;
|
||||||
|
|
||||||
|
await _dispatchUnwatchedToTrackers([SimklTracker.instance], ctx);
|
||||||
|
|
||||||
|
final anime = ctx.anime;
|
||||||
|
if (anime == null) continue;
|
||||||
|
final malId = anime.mal;
|
||||||
|
if (malId != null && _isActive(MalTracker.instance, ctx.libraryGlobalKey)) {
|
||||||
|
malEntries[malId] = ctx;
|
||||||
|
}
|
||||||
|
final anilistId = anime.anilist;
|
||||||
|
if (anilistId != null && _isActive(AnilistTracker.instance, ctx.libraryGlobalKey)) {
|
||||||
|
anilistEntries[anilistId] = ctx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appLogger.d('Trackers: manual container unwatched resolved $resolved/${episodes.length} episodes');
|
||||||
|
|
||||||
|
await _removeAnimeEntriesFromLists(malEntries.values, anilistEntries.values);
|
||||||
|
appLogger.d(
|
||||||
|
'Trackers: manual container unwatched resolved ${malEntries.length} MAL and ${anilistEntries.length} AniList entries',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _removeAnimeEntriesFromLists(
|
||||||
|
Iterable<TrackerContext> malEntries,
|
||||||
|
Iterable<TrackerContext> anilistEntries,
|
||||||
|
) async {
|
||||||
|
await Future.wait([
|
||||||
|
...malEntries.map((ctx) async {
|
||||||
|
try {
|
||||||
|
await MalTracker.instance.removeFromList(ctx);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.d('mal: removeFromList failed', error: e);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
...anilistEntries.map((ctx) async {
|
||||||
|
try {
|
||||||
|
await AnilistTracker.instance.removeFromList(ctx);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.d('anilist: removeFromList failed', error: e);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _animeGroupKey(TrackerContext ctx) {
|
||||||
|
final anime = ctx.anime;
|
||||||
|
if (anime == null) return null;
|
||||||
|
final hasMal = anime.mal != null && _isActive(MalTracker.instance, ctx.libraryGlobalKey);
|
||||||
|
final hasAnilist = anime.anilist != null && _isActive(AnilistTracker.instance, ctx.libraryGlobalKey);
|
||||||
|
if (!hasMal && !hasAnilist) return null;
|
||||||
|
return '${hasMal ? anime.mal : ''}:${hasAnilist ? anime.anilist : ''}';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markSingleWatched(MediaItem item, TrackerIdResolver resolver) async {
|
||||||
|
final ctx = await _buildContext(item, resolver);
|
||||||
|
if (ctx == null) {
|
||||||
|
appLogger.d('Trackers: no external IDs for manually watched ${item.id}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _dispatchMarkWatched(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markSingleUnwatched(MediaItem item, TrackerIdResolver resolver) async {
|
||||||
|
final ctx = await _buildContext(item, resolver, includeAnimeProgress: false, fallbackToAnimeEpisodeNumber: false);
|
||||||
|
if (ctx == null) {
|
||||||
|
appLogger.d('Trackers: no external IDs for manually unwatched ${item.id}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ctx.isMovie) {
|
||||||
|
await _dispatchMarkUnwatched(ctx);
|
||||||
|
} else {
|
||||||
|
await _dispatchUnwatchedToTrackers([SimklTracker.instance], ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> stopPlayback() async {
|
Future<void> stopPlayback() async {
|
||||||
final ctx = _ctx;
|
final ctx = _ctx;
|
||||||
@@ -96,6 +318,7 @@ class TrackerCoordinator {
|
|||||||
_reset();
|
_reset();
|
||||||
_resolver?.clearCache();
|
_resolver?.clearCache();
|
||||||
_resolver = null;
|
_resolver = null;
|
||||||
|
_resolverClientKey = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop the resolver's ID cache without touching in-flight playback state.
|
/// Drop the resolver's ID cache without touching in-flight playback state.
|
||||||
@@ -119,6 +342,19 @@ class TrackerCoordinator {
|
|||||||
|
|
||||||
Future<void> _dispatchMarkWatched(TrackerContext ctx) async {
|
Future<void> _dispatchMarkWatched(TrackerContext ctx) async {
|
||||||
final active = _trackers.where((t) => t.canScrobble && t.shouldScrobbleForLibrary(ctx.libraryGlobalKey));
|
final active = _trackers.where((t) => t.canScrobble && t.shouldScrobbleForLibrary(ctx.libraryGlobalKey));
|
||||||
|
await _dispatchToTrackers(active, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _dispatchMarkUnwatched(TrackerContext ctx) async {
|
||||||
|
final active = _trackers.where((t) => t.canScrobble && t.shouldScrobbleForLibrary(ctx.libraryGlobalKey));
|
||||||
|
await _dispatchUnwatchedToTrackers(active, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isActive(Tracker tracker, String? libraryGlobalKey) =>
|
||||||
|
tracker.canScrobble && tracker.shouldScrobbleForLibrary(libraryGlobalKey);
|
||||||
|
|
||||||
|
Future<void> _dispatchToTrackers(Iterable<Tracker> trackers, TrackerContext ctx) async {
|
||||||
|
final active = trackers.where((t) => _isActive(t, ctx.libraryGlobalKey));
|
||||||
await Future.wait(
|
await Future.wait(
|
||||||
active.map((t) async {
|
active.map((t) async {
|
||||||
try {
|
try {
|
||||||
@@ -130,10 +366,26 @@ class TrackerCoordinator {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<TrackerContext?> _buildContext(MediaItem metadata) async {
|
Future<void> _dispatchUnwatchedToTrackers(Iterable<Tracker> trackers, TrackerContext ctx) async {
|
||||||
final resolver = _resolver;
|
final active = trackers.where((t) => _isActive(t, ctx.libraryGlobalKey));
|
||||||
if (resolver == null) return null;
|
await Future.wait(
|
||||||
|
active.map((t) async {
|
||||||
|
try {
|
||||||
|
await t.markUnwatched(ctx);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.d('${t.name}: markUnwatched failed', error: e);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<TrackerContext?> _buildContext(
|
||||||
|
MediaItem metadata,
|
||||||
|
TrackerIdResolver resolver, {
|
||||||
|
bool includeAnimeProgress = true,
|
||||||
|
bool includeCurrentEpisode = true,
|
||||||
|
bool fallbackToAnimeEpisodeNumber = true,
|
||||||
|
}) async {
|
||||||
final libraryKey = metadata.libraryGlobalKey;
|
final libraryKey = metadata.libraryGlobalKey;
|
||||||
|
|
||||||
if (metadata.kind == MediaKind.movie) {
|
if (metadata.kind == MediaKind.movie) {
|
||||||
@@ -151,8 +403,17 @@ class TrackerCoordinator {
|
|||||||
final number = metadata.index;
|
final number = metadata.index;
|
||||||
if (season == null || number == null) return null;
|
if (season == null || number == null) return null;
|
||||||
|
|
||||||
final ids = await resolver.resolveShowForEpisode(metadata);
|
final ids = await resolver.resolveShowForEpisode(
|
||||||
|
metadata,
|
||||||
|
includeAnimeProgress: includeAnimeProgress,
|
||||||
|
includeCurrentEpisode: includeCurrentEpisode,
|
||||||
|
);
|
||||||
if (ids == null) return null;
|
if (ids == null) return null;
|
||||||
|
final animeProgress = includeAnimeProgress
|
||||||
|
? ids.animeProgress ?? (fallbackToAnimeEpisodeNumber ? ids.animeEpisodeNumber : null)
|
||||||
|
: fallbackToAnimeEpisodeNumber
|
||||||
|
? ids.animeEpisodeNumber
|
||||||
|
: null;
|
||||||
return TrackerContext.episode(
|
return TrackerContext.episode(
|
||||||
external: ids.external,
|
external: ids.external,
|
||||||
anime: ids.anime,
|
anime: ids.anime,
|
||||||
@@ -160,7 +421,40 @@ class TrackerCoordinator {
|
|||||||
libraryGlobalKey: libraryKey,
|
libraryGlobalKey: libraryKey,
|
||||||
season: season,
|
season: season,
|
||||||
episodeNumber: number,
|
episodeNumber: number,
|
||||||
animeProgress: ids.animeProgress,
|
animeProgress: animeProgress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ManualAnimeProgress {
|
||||||
|
final TrackerContext _base;
|
||||||
|
final bool _fallbackToCount;
|
||||||
|
int _count = 0;
|
||||||
|
int? _maxMappedProgress;
|
||||||
|
|
||||||
|
_ManualAnimeProgress(this._base, {required bool fallbackToCount}) : _fallbackToCount = fallbackToCount;
|
||||||
|
|
||||||
|
void add(TrackerContext ctx) {
|
||||||
|
_count++;
|
||||||
|
final mapped = ctx.animeProgress;
|
||||||
|
if (mapped != null && (_maxMappedProgress == null || mapped > _maxMappedProgress!)) {
|
||||||
|
_maxMappedProgress = mapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int? get progress => _maxMappedProgress ?? (_fallbackToCount ? _count : null);
|
||||||
|
|
||||||
|
TrackerContext? get context {
|
||||||
|
final progress = this.progress;
|
||||||
|
if (progress == null) return null;
|
||||||
|
return TrackerContext.episode(
|
||||||
|
external: _base.external,
|
||||||
|
anime: _base.anime,
|
||||||
|
ratingKey: _base.ratingKey,
|
||||||
|
libraryGlobalKey: _base.libraryGlobalKey,
|
||||||
|
season: _base.season!,
|
||||||
|
episodeNumber: progress,
|
||||||
|
animeProgress: progress,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import '../../media/media_item.dart';
|
|||||||
import '../../media/media_kind.dart';
|
import '../../media/media_kind.dart';
|
||||||
import '../../media/media_server_client.dart';
|
import '../../media/media_server_client.dart';
|
||||||
import '../../models/trackers/anime_ids.dart';
|
import '../../models/trackers/anime_ids.dart';
|
||||||
|
import '../../models/trackers/anime_lists_mapping.dart';
|
||||||
import '../../models/trackers/fribb_mapping_row.dart';
|
import '../../models/trackers/fribb_mapping_row.dart';
|
||||||
import '../../utils/external_ids.dart';
|
import '../../utils/external_ids.dart';
|
||||||
import 'anime_episode_progress_resolver.dart';
|
import 'anime_episode_progress_resolver.dart';
|
||||||
|
import 'anime_lists_mapping_store.dart';
|
||||||
import 'fribb_mapping_store.dart';
|
import 'fribb_mapping_store.dart';
|
||||||
|
|
||||||
/// Paired ID output: always-present Plex external IDs (tvdb/imdb/tmdb) plus
|
/// Paired ID output: always-present Plex external IDs (tvdb/imdb/tmdb) plus
|
||||||
@@ -15,8 +17,17 @@ class TrackerIds {
|
|||||||
final AnimeIds? anime;
|
final AnimeIds? anime;
|
||||||
final AnimeProgressScope? animeProgressScope;
|
final AnimeProgressScope? animeProgressScope;
|
||||||
final int? animeProgress;
|
final int? animeProgress;
|
||||||
|
final AnimeEpisodeMatch? animeEpisodeMatch;
|
||||||
|
final int? animeEpisodeNumber;
|
||||||
|
|
||||||
const TrackerIds({required this.external, required this.anime, this.animeProgressScope, this.animeProgress});
|
const TrackerIds({
|
||||||
|
required this.external,
|
||||||
|
required this.anime,
|
||||||
|
this.animeProgressScope,
|
||||||
|
this.animeProgress,
|
||||||
|
this.animeEpisodeMatch,
|
||||||
|
this.animeEpisodeNumber,
|
||||||
|
});
|
||||||
|
|
||||||
TrackerIds withAnimeProgress(ResolvedAnimeProgress? animeProgress) {
|
TrackerIds withAnimeProgress(ResolvedAnimeProgress? animeProgress) {
|
||||||
return TrackerIds(
|
return TrackerIds(
|
||||||
@@ -24,6 +35,8 @@ class TrackerIds {
|
|||||||
anime: anime,
|
anime: anime,
|
||||||
animeProgressScope: animeProgressScope,
|
animeProgressScope: animeProgressScope,
|
||||||
animeProgress: animeProgress?.progress,
|
animeProgress: animeProgress?.progress,
|
||||||
|
animeEpisodeMatch: animeEpisodeMatch,
|
||||||
|
animeEpisodeNumber: animeEpisodeNumber,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,8 +61,8 @@ class TrackerRatingContext {
|
|||||||
/// external IDs (used by Trakt and by Simkl for non-anime matches) and Fribb
|
/// external IDs (used by Trakt and by Simkl for non-anime matches) and Fribb
|
||||||
/// anime IDs (used by MAL/AniList, and by Simkl for anime precision).
|
/// anime IDs (used by MAL/AniList, and by Simkl for anime precision).
|
||||||
/// Episodes resolve against the show's GUIDs because Fribb only maps
|
/// Episodes resolve against the show's GUIDs because Fribb only maps
|
||||||
/// show-level external IDs; split-cour disambiguation uses the season
|
/// show-level external IDs. Anime-Lists XML is used when available to
|
||||||
/// number.
|
/// disambiguate same-season split-cour episode ranges by AniDB id.
|
||||||
///
|
///
|
||||||
/// The Fribb lookup is skipped when [needsFribb] returns false — set this way
|
/// The Fribb lookup is skipped when [needsFribb] returns false — set this way
|
||||||
/// for Trakt (which never uses anime IDs) and for a Simkl-only configuration,
|
/// for Trakt (which never uses anime IDs) and for a Simkl-only configuration,
|
||||||
@@ -57,21 +70,25 @@ class TrackerRatingContext {
|
|||||||
class TrackerIdResolver {
|
class TrackerIdResolver {
|
||||||
final MediaServerClient _client;
|
final MediaServerClient _client;
|
||||||
final FribbMappingLookup _store;
|
final FribbMappingLookup _store;
|
||||||
|
final AnimeListsMappingLookup _animeLists;
|
||||||
final AnimeEpisodeProgressLookup _animeProgress;
|
final AnimeEpisodeProgressLookup _animeProgress;
|
||||||
final bool Function() _needsFribb;
|
final bool Function() _needsFribb;
|
||||||
|
|
||||||
/// Null entries mean "the server had no IDs" — cached so scrubbing on an
|
/// Null entries mean "the server had no IDs" — cached so scrubbing on an
|
||||||
/// un-matched item doesn't re-hit the server every position update.
|
/// un-matched item doesn't re-hit the server every position update.
|
||||||
final Map<String, TrackerIds?> _cache = {};
|
final Map<String, TrackerIds?> _cache = {};
|
||||||
|
final Map<String, Future<ExternalIds>> _externalIdLoads = {};
|
||||||
|
|
||||||
TrackerIdResolver(
|
TrackerIdResolver(
|
||||||
MediaServerClient client, {
|
MediaServerClient client, {
|
||||||
bool Function()? needsFribb,
|
bool Function()? needsFribb,
|
||||||
FribbMappingLookup? store,
|
FribbMappingLookup? store,
|
||||||
|
AnimeListsMappingLookup? animeLists,
|
||||||
AnimeEpisodeProgressLookup? animeProgress,
|
AnimeEpisodeProgressLookup? animeProgress,
|
||||||
}) : _client = client,
|
}) : _client = client,
|
||||||
_needsFribb = needsFribb ?? _returnTrue,
|
_needsFribb = needsFribb ?? _returnTrue,
|
||||||
_store = store ?? FribbMappingStore.instance,
|
_store = store ?? FribbMappingStore.instance,
|
||||||
|
_animeLists = animeLists ?? AnimeListsMappingStore.instance,
|
||||||
_animeProgress = animeProgress ?? AnimeEpisodeProgressResolver(client);
|
_animeProgress = animeProgress ?? AnimeEpisodeProgressResolver(client);
|
||||||
|
|
||||||
static bool _returnTrue() => true;
|
static bool _returnTrue() => true;
|
||||||
@@ -80,41 +97,67 @@ class TrackerIdResolver {
|
|||||||
/// [MediaServerClient.fetchExternalIds] surface — Plex hits
|
/// [MediaServerClient.fetchExternalIds] surface — Plex hits
|
||||||
/// `/library/metadata/{id}?includeGuids=1`, Jellyfin reads the inline
|
/// `/library/metadata/{id}?includeGuids=1`, Jellyfin reads the inline
|
||||||
/// `ProviderIds` map.
|
/// `ProviderIds` map.
|
||||||
Future<ExternalIds> _fetchExternalIds(String itemId) => _client.fetchExternalIds(itemId);
|
Future<ExternalIds> _fetchExternalIds(String itemId) {
|
||||||
|
final existing = _externalIdLoads[itemId];
|
||||||
|
if (existing != null) return existing;
|
||||||
|
late final Future<ExternalIds> loading;
|
||||||
|
loading = _client.fetchExternalIds(itemId).catchError((Object e) {
|
||||||
|
if (identical(_externalIdLoads[itemId], loading)) {
|
||||||
|
final _ = _externalIdLoads.remove(itemId);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
_externalIdLoads[itemId] = loading;
|
||||||
|
return loading;
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve IDs for a movie.
|
/// Resolve IDs for a movie.
|
||||||
Future<TrackerIds?> resolveForMovie(String itemId) async {
|
Future<TrackerIds?> resolveForMovie(String itemId) async {
|
||||||
if (_cache.containsKey(itemId)) return _cache[itemId];
|
if (_cache.containsKey(itemId)) return _cache[itemId];
|
||||||
|
|
||||||
final external = await _fetchExternalIds(itemId);
|
final external = await _fetchExternalIds(itemId);
|
||||||
final ids = await _build(external, isEpisodeSeason: null, isMovie: true);
|
final ids = await _build(external, isEpisodeSeason: null, episodeNumber: null, isMovie: true);
|
||||||
_cache[itemId] = ids;
|
_cache[itemId] = ids;
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve IDs for an episode. Looks up the *show's* external IDs (via
|
/// Resolve IDs for an episode. Looks up the *show's* external IDs (via
|
||||||
/// `grandparentId`), then disambiguates among candidate Fribb rows using
|
/// `grandparentId`), then disambiguates among candidate Fribb rows using
|
||||||
/// the episode's season number.
|
/// Anime-Lists episode mappings first and season mappings as fallback.
|
||||||
Future<TrackerIds?> resolveShowForEpisode(MediaItem episode) async {
|
Future<TrackerIds?> resolveShowForEpisode(
|
||||||
|
MediaItem episode, {
|
||||||
|
bool includeAnimeProgress = true,
|
||||||
|
bool includeCurrentEpisode = true,
|
||||||
|
}) async {
|
||||||
final showId = episode.grandparentId;
|
final showId = episode.grandparentId;
|
||||||
if (showId == null || showId.isEmpty) return null;
|
if (showId == null || showId.isEmpty) return null;
|
||||||
|
|
||||||
final season = episode.parentIndex;
|
final season = episode.parentIndex;
|
||||||
// Cache under the (showId, season) pair so a show with multiple Fribb
|
final number = episode.index;
|
||||||
// rows caches each season separately during a marathon.
|
// Cache under the full aired episode coordinate. Same-season split-cour
|
||||||
final cacheKey = season != null ? '$showId#s$season' : showId;
|
// mappings can point different episode ranges at different anime entries.
|
||||||
|
final cacheKey = season != null && number != null ? '$showId#s$season#e$number' : showId;
|
||||||
TrackerIds? ids;
|
TrackerIds? ids;
|
||||||
if (_cache.containsKey(cacheKey)) {
|
if (_cache.containsKey(cacheKey)) {
|
||||||
ids = _cache[cacheKey];
|
ids = _cache[cacheKey];
|
||||||
} else {
|
} else {
|
||||||
final external = await _fetchExternalIds(showId);
|
final external = await _fetchExternalIds(showId);
|
||||||
ids = await _build(external, isEpisodeSeason: season, isMovie: false);
|
ids = await _build(external, isEpisodeSeason: season, episodeNumber: number, isMovie: false);
|
||||||
_cache[cacheKey] = ids;
|
_cache[cacheKey] = ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ids == null || ids.animeProgressScope == null) return ids;
|
final resolvedIds = ids;
|
||||||
final progress = await _animeProgress.resolve(episode, scope: ids.animeProgressScope!);
|
if (!includeAnimeProgress || resolvedIds == null || resolvedIds.animeProgressScope == null) return resolvedIds;
|
||||||
return ids.withAnimeProgress(progress);
|
final progress = await _animeProgress.resolve(
|
||||||
|
episode,
|
||||||
|
scope: resolvedIds.animeProgressScope!,
|
||||||
|
animeMatch: resolvedIds.animeEpisodeMatch,
|
||||||
|
episodeMatcher: resolvedIds.animeEpisodeMatch == null
|
||||||
|
? null
|
||||||
|
: (item) => _lookupAnimeEpisodeMatch(resolvedIds.external, item),
|
||||||
|
includeCurrentEpisode: includeCurrentEpisode,
|
||||||
|
);
|
||||||
|
return resolvedIds.withAnimeProgress(progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve IDs for manual tracker ratings. Ratings can be attached to a
|
/// Resolve IDs for manual tracker ratings. Ratings can be attached to a
|
||||||
@@ -125,19 +168,19 @@ class TrackerIdResolver {
|
|||||||
final ids = await resolveForMovie(item.id);
|
final ids = await resolveForMovie(item.id);
|
||||||
return ids == null ? null : TrackerRatingContext(ids: ids, kind: MediaKind.movie);
|
return ids == null ? null : TrackerRatingContext(ids: ids, kind: MediaKind.movie);
|
||||||
case MediaKind.show:
|
case MediaKind.show:
|
||||||
final ids = await _resolveShow(item.id);
|
final ids = await _resolveShowForRating(item.id);
|
||||||
return ids == null ? null : TrackerRatingContext(ids: ids, kind: MediaKind.show);
|
return ids == null ? null : TrackerRatingContext(ids: ids, kind: MediaKind.show);
|
||||||
case MediaKind.season:
|
case MediaKind.season:
|
||||||
final showId = item.parentId;
|
final showId = item.parentId;
|
||||||
final season = item.index ?? item.parentIndex;
|
final season = item.index ?? item.parentIndex;
|
||||||
if (showId == null || showId.isEmpty || season == null) return null;
|
if (showId == null || showId.isEmpty || season == null) return null;
|
||||||
final ids = await _resolveShow(showId, season: season);
|
final ids = await _resolveShowForRating(showId, season: season);
|
||||||
return ids == null ? null : TrackerRatingContext(ids: ids, kind: MediaKind.season, season: season);
|
return ids == null ? null : TrackerRatingContext(ids: ids, kind: MediaKind.season, season: season);
|
||||||
case MediaKind.episode:
|
case MediaKind.episode:
|
||||||
final season = item.parentIndex;
|
final season = item.parentIndex;
|
||||||
final number = item.index;
|
final number = item.index;
|
||||||
if (season == null || number == null) return null;
|
if (season == null || number == null) return null;
|
||||||
final ids = await resolveShowForEpisode(item);
|
final ids = await resolveShowForEpisode(item, includeAnimeProgress: false);
|
||||||
return ids == null
|
return ids == null
|
||||||
? null
|
? null
|
||||||
: TrackerRatingContext(ids: ids, kind: MediaKind.episode, season: season, episodeNumber: number);
|
: TrackerRatingContext(ids: ids, kind: MediaKind.episode, season: season, episodeNumber: number);
|
||||||
@@ -146,35 +189,110 @@ class TrackerIdResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<TrackerIds?> _resolveShow(String showId, {int? season}) async {
|
Future<TrackerIds?> _resolveShowForRating(String showId, {int? season}) async {
|
||||||
if (showId.isEmpty) return null;
|
if (showId.isEmpty) return null;
|
||||||
final cacheKey = season != null ? '$showId#s$season' : showId;
|
final cacheKey = season != null ? '$showId#rating-s$season' : '$showId#rating';
|
||||||
if (_cache.containsKey(cacheKey)) return _cache[cacheKey];
|
if (_cache.containsKey(cacheKey)) return _cache[cacheKey];
|
||||||
|
|
||||||
final external = await _fetchExternalIds(showId);
|
final external = await _fetchExternalIds(showId);
|
||||||
final ids = await _build(external, isEpisodeSeason: season, isMovie: false);
|
final ids = await _buildShowRating(external, season: season);
|
||||||
_cache[cacheKey] = ids;
|
_cache[cacheKey] = ids;
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearCache() {
|
void clearCache() {
|
||||||
_cache.clear();
|
_cache.clear();
|
||||||
|
_externalIdLoads.clear();
|
||||||
_animeProgress.clearCache();
|
_animeProgress.clearCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<TrackerIds?> _build(ExternalIds external, {int? isEpisodeSeason, required bool isMovie}) async {
|
Future<TrackerIds?> _build(
|
||||||
|
ExternalIds external, {
|
||||||
|
int? isEpisodeSeason,
|
||||||
|
int? episodeNumber,
|
||||||
|
required bool isMovie,
|
||||||
|
}) async {
|
||||||
if (!external.hasAny) return null;
|
if (!external.hasAny) return null;
|
||||||
if (!_needsFribb()) return TrackerIds(external: external, anime: null);
|
if (!_needsFribb()) return TrackerIds(external: external, anime: null);
|
||||||
final rows = await _store.lookup(tvdbId: external.tvdb, tmdbId: external.tmdb, imdbId: external.imdb);
|
final rows = await _store.lookup(tvdbId: external.tvdb, tmdbId: external.tmdb, imdbId: external.imdb);
|
||||||
final row = isMovie ? _pickMovieRow(rows) : _pickShowRow(rows, season: isEpisodeSeason);
|
final animeMatch = isMovie || isEpisodeSeason == null || episodeNumber == null
|
||||||
|
? null
|
||||||
|
: await _lookupAnimeEpisodeMatchByCoordinate(external, isEpisodeSeason, episodeNumber);
|
||||||
|
final row = isMovie ? _pickMovieRow(rows) : _pickShowRow(rows, season: isEpisodeSeason, animeMatch: animeMatch);
|
||||||
final anime = row == null ? null : AnimeIds.fromFribb(row);
|
final anime = row == null ? null : AnimeIds.fromFribb(row);
|
||||||
return TrackerIds(
|
return TrackerIds(
|
||||||
external: external,
|
external: external,
|
||||||
anime: anime,
|
anime: anime,
|
||||||
animeProgressScope: _animeProgressScope(selected: row, rows: rows, season: isEpisodeSeason, isMovie: isMovie),
|
animeProgressScope: _animeProgressScope(
|
||||||
|
selected: row,
|
||||||
|
rows: rows,
|
||||||
|
season: isEpisodeSeason,
|
||||||
|
isMovie: isMovie,
|
||||||
|
animeMatch: animeMatch,
|
||||||
|
),
|
||||||
|
animeEpisodeMatch: animeMatch,
|
||||||
|
animeEpisodeNumber: animeMatch?.anidbEpisode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<TrackerIds?> _buildShowRating(ExternalIds external, {int? season}) async {
|
||||||
|
if (!external.hasAny) return null;
|
||||||
|
if (!_needsFribb()) return TrackerIds(external: external, anime: null);
|
||||||
|
final rows = await _store.lookup(tvdbId: external.tvdb, tmdbId: external.tmdb, imdbId: external.imdb);
|
||||||
|
FribbMappingRow? row;
|
||||||
|
|
||||||
|
final animeIds = season == null
|
||||||
|
? await _lookupAnimeIdsForShow(external)
|
||||||
|
: await _lookupAnimeIdsForSeason(external, season);
|
||||||
|
if (animeIds.length == 1) {
|
||||||
|
row = _rowForAnidb(rows, animeIds.single);
|
||||||
|
} else if (animeIds.isEmpty) {
|
||||||
|
row = _pickShowRow(rows, season: season, animeMatch: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TrackerIds(external: external, anime: row == null ? null : AnimeIds.fromFribb(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AnimeEpisodeMatch?> _lookupAnimeEpisodeMatch(ExternalIds external, MediaItem episode) async {
|
||||||
|
final season = episode.parentIndex;
|
||||||
|
final number = episode.index;
|
||||||
|
if (season == null || number == null) return null;
|
||||||
|
return _lookupAnimeEpisodeMatchByCoordinate(external, season, number);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AnimeEpisodeMatch?> _lookupAnimeEpisodeMatchByCoordinate(
|
||||||
|
ExternalIds external,
|
||||||
|
int season,
|
||||||
|
int episodeNumber,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
return await _animeLists.lookupEpisode(
|
||||||
|
tvdbId: external.tvdb,
|
||||||
|
tmdbId: external.tmdb,
|
||||||
|
season: season,
|
||||||
|
episodeNumber: episodeNumber,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Set<int>> _lookupAnimeIdsForSeason(ExternalIds external, int season) async {
|
||||||
|
try {
|
||||||
|
return await _animeLists.lookupAnimeIdsForSeason(tvdbId: external.tvdb, tmdbId: external.tmdb, season: season);
|
||||||
|
} catch (_) {
|
||||||
|
return const <int>{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Set<int>> _lookupAnimeIdsForShow(ExternalIds external) async {
|
||||||
|
try {
|
||||||
|
return await _animeLists.lookupAnimeIdsForShow(tvdbId: external.tvdb, tmdbId: external.tmdb);
|
||||||
|
} catch (_) {
|
||||||
|
return const <int>{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Pick the best row for a movie lookup — prefer rows marked `type: MOVIE`.
|
/// Pick the best row for a movie lookup — prefer rows marked `type: MOVIE`.
|
||||||
FribbMappingRow? _pickMovieRow(List<FribbMappingRow> rows) {
|
FribbMappingRow? _pickMovieRow(List<FribbMappingRow> rows) {
|
||||||
if (rows.isEmpty) return null;
|
if (rows.isEmpty) return null;
|
||||||
@@ -189,9 +307,12 @@ class TrackerIdResolver {
|
|||||||
/// sharing the same show-level external ID (split-cour anime), prefer the
|
/// sharing the same show-level external ID (split-cour anime), prefer the
|
||||||
/// one whose `season.tvdb` or `season.tmdb` matches the Plex episode's
|
/// one whose `season.tvdb` or `season.tmdb` matches the Plex episode's
|
||||||
/// season; otherwise prefer regular TV/ONA rows.
|
/// season; otherwise prefer regular TV/ONA rows.
|
||||||
FribbMappingRow? _pickShowRow(List<FribbMappingRow> rows, {int? season}) {
|
FribbMappingRow? _pickShowRow(List<FribbMappingRow> rows, {int? season, AnimeEpisodeMatch? animeMatch}) {
|
||||||
if (rows.isEmpty) return null;
|
if (rows.isEmpty) return null;
|
||||||
|
|
||||||
|
final match = animeMatch;
|
||||||
|
if (match != null) return _rowForAnidb(rows, match.anidbId);
|
||||||
|
|
||||||
if (season != null) {
|
if (season != null) {
|
||||||
for (final row in rows) {
|
for (final row in rows) {
|
||||||
if (row.tvdbSeason == season || row.tmdbSeason == season) return row;
|
if (row.tvdbSeason == season || row.tmdbSeason == season) return row;
|
||||||
@@ -210,15 +331,24 @@ class TrackerIdResolver {
|
|||||||
return rows.first;
|
return rows.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FribbMappingRow? _rowForAnidb(List<FribbMappingRow> rows, int anidbId) {
|
||||||
|
for (final row in rows) {
|
||||||
|
if (row.anidbId == anidbId) return row;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
AnimeProgressScope? _animeProgressScope({
|
AnimeProgressScope? _animeProgressScope({
|
||||||
required FribbMappingRow? selected,
|
required FribbMappingRow? selected,
|
||||||
required List<FribbMappingRow> rows,
|
required List<FribbMappingRow> rows,
|
||||||
required int? season,
|
required int? season,
|
||||||
required bool isMovie,
|
required bool isMovie,
|
||||||
|
required AnimeEpisodeMatch? animeMatch,
|
||||||
}) {
|
}) {
|
||||||
if (isMovie) return null;
|
if (isMovie) return null;
|
||||||
if (season == null || season <= 0) return null;
|
if (season == null || season <= 0) return null;
|
||||||
if (selected == null) return null;
|
if (selected == null) return null;
|
||||||
|
if (animeMatch != null && selected.anidbId == animeMatch.anidbId) return AnimeProgressScope.mapped;
|
||||||
if (_hasSeasonMapping(selected)) {
|
if (_hasSeasonMapping(selected)) {
|
||||||
final exactSeason = selected.tvdbSeason == season || selected.tmdbSeason == season;
|
final exactSeason = selected.tvdbSeason == season || selected.tmdbSeason == season;
|
||||||
return exactSeason && _isRegularSeriesRow(selected) ? AnimeProgressScope.season : null;
|
return exactSeason && _isRegularSeriesRow(selected) ? AnimeProgressScope.season : null;
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ class TraktScrobbleService {
|
|||||||
final number = metadata.index;
|
final number = metadata.index;
|
||||||
if (season == null || number == null) return null;
|
if (season == null || number == null) return null;
|
||||||
|
|
||||||
final showIds = await resolver.resolveShowForEpisode(metadata);
|
final showIds = await resolver.resolveShowForEpisode(metadata, includeAnimeProgress: false);
|
||||||
if (showIds == null) return null;
|
if (showIds == null) return null;
|
||||||
|
|
||||||
return TraktScrobbleRequest.episode(
|
return TraktScrobbleRequest.episode(
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import '../../media/media_item.dart';
|
||||||
|
import '../../media/media_kind.dart';
|
||||||
|
import '../../media/media_server_client.dart';
|
||||||
import '../../models/trakt/trakt_ids.dart';
|
import '../../models/trakt/trakt_ids.dart';
|
||||||
import '../../models/trakt/trakt_scrobble_request.dart';
|
import '../../models/trakt/trakt_scrobble_request.dart';
|
||||||
import '../../utils/app_logger.dart';
|
import '../../utils/app_logger.dart';
|
||||||
|
import '../../utils/episode_collection.dart';
|
||||||
import '../../utils/watch_state_notifier.dart';
|
import '../../utils/watch_state_notifier.dart';
|
||||||
import '../multi_server_manager.dart';
|
import '../multi_server_manager.dart';
|
||||||
import '../settings_service.dart';
|
import '../settings_service.dart';
|
||||||
@@ -17,8 +21,9 @@ import 'trakt_sync_queue.dart';
|
|||||||
/// One-way push of watched/unwatched events from Plezy to Trakt.
|
/// One-way push of watched/unwatched events from Plezy to Trakt.
|
||||||
///
|
///
|
||||||
/// Subscribes to `WatchStateNotifier` and filters to `{watched, unwatched}`
|
/// Subscribes to `WatchStateNotifier` and filters to `{watched, unwatched}`
|
||||||
/// events on movies/episodes. Failures are queued via `TraktSyncQueue` and
|
/// events on movies/episodes, expanding show/season events to their episodes.
|
||||||
/// drained on app foreground, network restore, and at startup.
|
/// Failures are queued via `TraktSyncQueue` and drained on app foreground,
|
||||||
|
/// network restore, and at startup.
|
||||||
class TraktSyncService {
|
class TraktSyncService {
|
||||||
/// Inter-request delay during queue drain to stay under Trakt's
|
/// Inter-request delay during queue drain to stay under Trakt's
|
||||||
/// 1000 req / 5 min rate limit.
|
/// 1000 req / 5 min rate limit.
|
||||||
@@ -99,7 +104,7 @@ class TraktSyncService {
|
|||||||
// Backend-neutral: TrackerIdResolver pulls external IDs through
|
// Backend-neutral: TrackerIdResolver pulls external IDs through
|
||||||
// MediaServerClient.fetchExternalIds — Plex hits `?includeGuids=1`,
|
// MediaServerClient.fetchExternalIds — Plex hits `?includeGuids=1`,
|
||||||
// Jellyfin reads the inline `ProviderIds` map.
|
// Jellyfin reads the inline `ProviderIds` map.
|
||||||
final mediaClient = _serverManager?.getClient(serverId);
|
final mediaClient = _clientFor(serverId);
|
||||||
if (mediaClient == null) return null;
|
if (mediaClient == null) return null;
|
||||||
|
|
||||||
final resolver = TrackerIdResolver(mediaClient, needsFribb: () => false);
|
final resolver = TrackerIdResolver(mediaClient, needsFribb: () => false);
|
||||||
@@ -107,27 +112,89 @@ class TraktSyncService {
|
|||||||
return resolver;
|
return resolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MediaServerClient? _clientFor(String serverId) => _serverManager?.getClient(serverId);
|
||||||
|
|
||||||
Future<void> _onWatchStateEvent(WatchStateEvent event) async {
|
Future<void> _onWatchStateEvent(WatchStateEvent event) async {
|
||||||
if (!_canPush) return;
|
if (!_canPush) return;
|
||||||
if (event.changeType != WatchStateChangeType.watched && event.changeType != WatchStateChangeType.unwatched) return;
|
if (event.changeType != WatchStateChangeType.watched && event.changeType != WatchStateChangeType.unwatched) return;
|
||||||
|
|
||||||
final kind = TraktMediaKind.tryFromMediaKindId(event.mediaType);
|
|
||||||
if (kind == null) return;
|
|
||||||
|
|
||||||
if (!_isLibraryAllowed(event.librarySectionGlobalKey)) {
|
if (!_isLibraryAllowed(event.librarySectionGlobalKey)) {
|
||||||
appLogger.d('Trakt sync: library filtered out for ${event.itemId}');
|
appLogger.d('Trakt sync: library filtered out for ${event.itemId}');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final op = event.changeType == WatchStateChangeType.watched ? TraktSyncOp.add : TraktSyncOp.remove;
|
final op = event.changeType == WatchStateChangeType.watched ? TraktSyncOp.add : TraktSyncOp.remove;
|
||||||
await _push(
|
final watchedAtIso = DateTime.now().toUtc().toIso8601String();
|
||||||
op: op,
|
|
||||||
ratingKey: event.itemId,
|
switch (event.mediaType) {
|
||||||
|
case 'movie':
|
||||||
|
await _push(
|
||||||
|
op: op,
|
||||||
|
ratingKey: event.itemId,
|
||||||
|
serverId: event.serverId,
|
||||||
|
libraryGlobalKey: event.librarySectionGlobalKey,
|
||||||
|
kind: TraktMediaKind.movie,
|
||||||
|
watchedAtIso: watchedAtIso,
|
||||||
|
);
|
||||||
|
case 'episode':
|
||||||
|
await _push(
|
||||||
|
op: op,
|
||||||
|
ratingKey: event.itemId,
|
||||||
|
serverId: event.serverId,
|
||||||
|
libraryGlobalKey: event.librarySectionGlobalKey,
|
||||||
|
kind: TraktMediaKind.episode,
|
||||||
|
watchedAtIso: watchedAtIso,
|
||||||
|
);
|
||||||
|
case 'show' || 'season':
|
||||||
|
await _pushPlayableDescendants(op: op, event: event, watchedAtIso: watchedAtIso);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pushPlayableDescendants({
|
||||||
|
required TraktSyncOp op,
|
||||||
|
required WatchStateEvent event,
|
||||||
|
required String watchedAtIso,
|
||||||
|
}) async {
|
||||||
|
final mediaClient = _clientFor(event.serverId);
|
||||||
|
if (mediaClient == null) {
|
||||||
|
appLogger.d('Trakt sync: no client registered for server ${event.serverId}, skipping ${event.mediaType}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final fallback = MediaItem(
|
||||||
|
id: event.itemId,
|
||||||
|
backend: mediaClient.backend,
|
||||||
|
kind: MediaKind.fromString(event.mediaType),
|
||||||
serverId: event.serverId,
|
serverId: event.serverId,
|
||||||
libraryGlobalKey: event.librarySectionGlobalKey,
|
serverName: mediaClient.serverName,
|
||||||
kind: kind,
|
libraryId: event.librarySectionID,
|
||||||
watchedAtIso: DateTime.now().toUtc().toIso8601String(),
|
parentId: event.mediaType == 'season' && event.parentChain.isNotEmpty ? event.parentChain.first : null,
|
||||||
);
|
);
|
||||||
|
final episodes = <MediaItem>[];
|
||||||
|
if (fallback.kind == MediaKind.show) {
|
||||||
|
await collectEpisodesForShow(mediaClient, event.itemId, unwatchedOnly: false, out: episodes, fallback: fallback);
|
||||||
|
} else {
|
||||||
|
await collectEpisodesForSeason(
|
||||||
|
mediaClient,
|
||||||
|
event.itemId,
|
||||||
|
unwatchedOnly: false,
|
||||||
|
out: episodes,
|
||||||
|
fallback: fallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final episode in episodes) {
|
||||||
|
if (episode.kind != MediaKind.episode) continue;
|
||||||
|
await _push(
|
||||||
|
op: op,
|
||||||
|
ratingKey: episode.id,
|
||||||
|
serverId: event.serverId,
|
||||||
|
libraryGlobalKey: episode.libraryGlobalKey ?? event.librarySectionGlobalKey,
|
||||||
|
kind: TraktMediaKind.episode,
|
||||||
|
watchedAtIso: watchedAtIso,
|
||||||
|
episodeMeta: episode,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _push({
|
Future<void> _push({
|
||||||
@@ -137,6 +204,7 @@ class TraktSyncService {
|
|||||||
required String? libraryGlobalKey,
|
required String? libraryGlobalKey,
|
||||||
required TraktMediaKind kind,
|
required TraktMediaKind kind,
|
||||||
required String watchedAtIso,
|
required String watchedAtIso,
|
||||||
|
MediaItem? episodeMeta,
|
||||||
}) async {
|
}) async {
|
||||||
final resolver = _resolverFor(serverId);
|
final resolver = _resolverFor(serverId);
|
||||||
if (resolver == null) {
|
if (resolver == null) {
|
||||||
@@ -155,14 +223,14 @@ class TraktSyncService {
|
|||||||
// doesn't carry the index, so fetch episode metadata via the neutral
|
// doesn't carry the index, so fetch episode metadata via the neutral
|
||||||
// MediaServerClient surface (Plex `/library/metadata`, Jellyfin
|
// MediaServerClient surface (Plex `/library/metadata`, Jellyfin
|
||||||
// `/Users/{id}/Items/{id}`).
|
// `/Users/{id}/Items/{id}`).
|
||||||
final mediaClient = _serverManager?.getClient(serverId);
|
final mediaClient = _clientFor(serverId);
|
||||||
if (mediaClient == null) return;
|
if (mediaClient == null) return;
|
||||||
final episodeMeta = await mediaClient.fetchItem(ratingKey);
|
final metadata = episodeMeta ?? await mediaClient.fetchItem(ratingKey);
|
||||||
if (episodeMeta == null) return;
|
if (metadata == null) return;
|
||||||
season = episodeMeta.parentIndex;
|
season = metadata.parentIndex;
|
||||||
number = episodeMeta.index;
|
number = metadata.index;
|
||||||
if (season == null || number == null) return;
|
if (season == null || number == null) return;
|
||||||
resolved = await resolver.resolveShowForEpisode(episodeMeta);
|
resolved = await resolver.resolveShowForEpisode(metadata, includeAnimeProgress: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolved == null) {
|
if (resolved == null) {
|
||||||
|
|||||||
@@ -53,10 +53,26 @@ Future<void> _collectPlayable(
|
|||||||
|
|
||||||
MediaItem _withFallbackLibrary(MediaItem item, MediaItem? fallback) {
|
MediaItem _withFallbackLibrary(MediaItem item, MediaItem? fallback) {
|
||||||
if (fallback == null) return item;
|
if (fallback == null) return item;
|
||||||
|
final fallbackIsSeason = fallback.kind == MediaKind.season;
|
||||||
|
final fallbackIsShow = fallback.kind == MediaKind.show;
|
||||||
return item.copyWith(
|
return item.copyWith(
|
||||||
serverId: item.serverId ?? fallback.serverId,
|
serverId: item.serverId ?? fallback.serverId,
|
||||||
serverName: item.serverName ?? fallback.serverName,
|
serverName: item.serverName ?? fallback.serverName,
|
||||||
libraryId: item.libraryId ?? fallback.libraryId,
|
libraryId: item.libraryId ?? fallback.libraryId,
|
||||||
libraryTitle: item.libraryTitle ?? fallback.libraryTitle,
|
libraryTitle: item.libraryTitle ?? fallback.libraryTitle,
|
||||||
|
parentId: item.parentId ?? (fallbackIsSeason ? fallback.id : null),
|
||||||
|
parentTitle: item.parentTitle ?? (fallbackIsSeason ? fallback.title : null),
|
||||||
|
grandparentId: item.grandparentId ?? _fallbackGrandparentId(fallback, isShow: fallbackIsShow),
|
||||||
|
grandparentTitle: item.grandparentTitle ?? _fallbackGrandparentTitle(fallback, isShow: fallbackIsShow),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _fallbackGrandparentId(MediaItem fallback, {required bool isShow}) {
|
||||||
|
if (isShow) return fallback.id;
|
||||||
|
return fallback.grandparentId ?? fallback.parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _fallbackGrandparentTitle(MediaItem fallback, {required bool isShow}) {
|
||||||
|
if (isShow) return fallback.title;
|
||||||
|
return fallback.grandparentTitle ?? fallback.parentTitle;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import '../mixins/controller_disposer_mixin.dart';
|
|||||||
import '../services/plex_client.dart';
|
import '../services/plex_client.dart';
|
||||||
import '../services/media_list_playback_launcher.dart';
|
import '../services/media_list_playback_launcher.dart';
|
||||||
import '../services/playlist_items_loader.dart';
|
import '../services/playlist_items_loader.dart';
|
||||||
|
import '../services/trackers/tracker_coordinator.dart';
|
||||||
import '../models/transcode_quality_preset.dart';
|
import '../models/transcode_quality_preset.dart';
|
||||||
import '../utils/download_version_utils.dart';
|
import '../utils/download_version_utils.dart';
|
||||||
import '../utils/download_utils.dart';
|
import '../utils/download_utils.dart';
|
||||||
@@ -549,8 +550,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
// hits /UserPlayedItems. WatchStateNotifier event is fired in both
|
// hits /UserPlayedItems. WatchStateNotifier event is fired in both
|
||||||
// paths so cross-screen UI updates regardless of backend.
|
// paths so cross-screen UI updates regardless of backend.
|
||||||
await _executeAction(context, () async {
|
await _executeAction(context, () async {
|
||||||
|
final item = mediaItem;
|
||||||
final client = context.tryGetMediaClientForServer(_itemServerId!);
|
final client = context.tryGetMediaClientForServer(_itemServerId!);
|
||||||
if (client != null) await client.markWatched(mediaItem!);
|
if (client != null && item != null) {
|
||||||
|
await client.markWatched(item);
|
||||||
|
unawaited(TrackerCoordinator.instance.markWatched(item, client));
|
||||||
|
}
|
||||||
}, t.messages.markedAsWatched);
|
}, t.messages.markedAsWatched);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -567,8 +572,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await _executeAction(context, () async {
|
await _executeAction(context, () async {
|
||||||
|
final item = mediaItem;
|
||||||
final client = context.tryGetMediaClientForServer(_itemServerId!);
|
final client = context.tryGetMediaClientForServer(_itemServerId!);
|
||||||
if (client != null) await client.markUnwatched(mediaItem!);
|
if (client != null && item != null) {
|
||||||
|
await client.markUnwatched(item);
|
||||||
|
unawaited(TrackerCoordinator.instance.markUnwatched(item, client));
|
||||||
|
}
|
||||||
}, t.messages.markedAsUnwatched);
|
}, t.messages.markedAsUnwatched);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
+1
-1
@@ -1485,7 +1485,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
xml:
|
xml:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: xml
|
name: xml
|
||||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ dependencies:
|
|||||||
win_http: ^0.2.0
|
win_http: ^0.2.0
|
||||||
collection: ^1.18.0
|
collection: ^1.18.0
|
||||||
freezed_annotation: ^3.1.0
|
freezed_annotation: ^3.1.0
|
||||||
|
xml: ^6.6.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -128,5 +128,58 @@ void main() {
|
|||||||
|
|
||||||
expect(saved.single, {'mediaId': 21, 'progress': 12, 'status': 'CURRENT'});
|
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_item.dart';
|
||||||
import 'package:plezy/media/media_kind.dart';
|
import 'package:plezy/media/media_kind.dart';
|
||||||
import 'package:plezy/media/media_server_client.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';
|
import 'package:plezy/services/trackers/anime_episode_progress_resolver.dart';
|
||||||
|
|
||||||
class _FakeMediaServerClient implements MediaServerClient {
|
class _FakeMediaServerClient implements MediaServerClient {
|
||||||
final Map<String, List<MediaItem>> childrenByParent;
|
final Map<String, List<MediaItem>> childrenByParent;
|
||||||
|
final Map<String, List<MediaItem>> playableByParent;
|
||||||
Object? throwOnFetchChildren;
|
Object? throwOnFetchChildren;
|
||||||
int fetchChildrenCalls = 0;
|
int fetchChildrenCalls = 0;
|
||||||
|
int fetchPlayableDescendantsCalls = 0;
|
||||||
|
|
||||||
_FakeMediaServerClient(this.childrenByParent);
|
_FakeMediaServerClient(this.childrenByParent, {this.playableByParent = const {}});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<MediaItem>> fetchChildren(String parentId) async {
|
Future<List<MediaItem>> fetchChildren(String parentId) async {
|
||||||
@@ -20,6 +23,12 @@ class _FakeMediaServerClient implements MediaServerClient {
|
|||||||
return childrenByParent[parentId] ?? const [];
|
return childrenByParent[parentId] ?? const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
|
||||||
|
fetchPlayableDescendantsCalls++;
|
||||||
|
return playableByParent[parentId] ?? const [];
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
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,
|
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() {
|
void main() {
|
||||||
group('AnimeEpisodeProgressResolver', () {
|
group('AnimeEpisodeProgressResolver', () {
|
||||||
test('show scope sums watched counts across regular seasons', () async {
|
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((await resolver.resolve(_episode(season: 1, number: 7), scope: AnimeProgressScope.season))?.progress, 7);
|
||||||
expect(client.fetchChildrenCalls, 2);
|
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');
|
final put = requests.singleWhere((request) => request.method == 'PUT');
|
||||||
expect(Uri.splitQueryString(put.body), {'status': 'watching', 'num_watched_episodes': '12'});
|
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_item.dart';
|
||||||
import 'package:plezy/media/media_kind.dart';
|
import 'package:plezy/media/media_kind.dart';
|
||||||
import 'package:plezy/media/media_server_client.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/models/trackers/fribb_mapping_row.dart';
|
||||||
import 'package:plezy/services/trackers/anime_episode_progress_resolver.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/fribb_mapping_store.dart';
|
||||||
import 'package:plezy/services/trackers/tracker_id_resolver.dart';
|
import 'package:plezy/services/trackers/tracker_id_resolver.dart';
|
||||||
import 'package:plezy/utils/external_ids.dart';
|
import 'package:plezy/utils/external_ids.dart';
|
||||||
@@ -44,15 +46,25 @@ class _FakeAnimeProgressLookup implements AnimeEpisodeProgressLookup {
|
|||||||
int clearCalls = 0;
|
int clearCalls = 0;
|
||||||
MediaItem? lastEpisode;
|
MediaItem? lastEpisode;
|
||||||
AnimeProgressScope? lastScope;
|
AnimeProgressScope? lastScope;
|
||||||
|
AnimeEpisodeMatch? lastMatch;
|
||||||
|
bool? lastIncludeCurrentEpisode;
|
||||||
|
|
||||||
_FakeAnimeProgressLookup(int? progress)
|
_FakeAnimeProgressLookup(int? progress)
|
||||||
: result = progress == null ? null : ResolvedAnimeProgress(progress: progress);
|
: result = progress == null ? null : ResolvedAnimeProgress(progress: progress);
|
||||||
|
|
||||||
@override
|
@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++;
|
resolveCalls++;
|
||||||
lastEpisode = episode;
|
lastEpisode = episode;
|
||||||
lastScope = scope;
|
lastScope = scope;
|
||||||
|
lastMatch = animeMatch;
|
||||||
|
lastIncludeCurrentEpisode = includeCurrentEpisode;
|
||||||
return result;
|
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(
|
MediaItem _episode({int season = 23, int number = 6}) => MediaItem(
|
||||||
id: 'episode-$season-$number',
|
id: 'episode-$season-$number',
|
||||||
backend: MediaBackend.plex,
|
backend: MediaBackend.plex,
|
||||||
@@ -76,14 +105,27 @@ TrackerIdResolver _resolver({
|
|||||||
required List<FribbMappingRow> rows,
|
required List<FribbMappingRow> rows,
|
||||||
required _FakeAnimeProgressLookup animeProgress,
|
required _FakeAnimeProgressLookup animeProgress,
|
||||||
_FakeFribbLookup? lookup,
|
_FakeFribbLookup? lookup,
|
||||||
|
AnimeListsMappingLookup animeLists = const _FakeAnimeListsLookup(),
|
||||||
}) {
|
}) {
|
||||||
return TrackerIdResolver(
|
return TrackerIdResolver(
|
||||||
_FakeMediaServerClient({'show-1': const ExternalIds(tvdb: 81797, tmdb: 37854, imdb: 'tt0388629')}),
|
_FakeMediaServerClient({'show-1': const ExternalIds(tvdb: 81797, tmdb: 37854, imdb: 'tt0388629')}),
|
||||||
store: lookup ?? _FakeFribbLookup(rows),
|
store: lookup ?? _FakeFribbLookup(rows),
|
||||||
|
animeLists: animeLists,
|
||||||
animeProgress: animeProgress,
|
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() {
|
void main() {
|
||||||
group('TrackerIdResolver anime progress', () {
|
group('TrackerIdResolver anime progress', () {
|
||||||
test('one unseasoned regular TV row uses show-scope progress', () async {
|
test('one unseasoned regular TV row uses show-scope progress', () async {
|
||||||
@@ -176,5 +218,68 @@ void main() {
|
|||||||
expect(animeProgress.clearCalls, 1);
|
expect(animeProgress.clearCalls, 1);
|
||||||
expect(animeProgress.resolveCalls, 2);
|
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