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).
|
||||
@JsonSerializable(createToJson: false)
|
||||
class FribbMappingRow {
|
||||
@JsonKey(name: 'anidb_id', fromJson: flexibleInt)
|
||||
final int? anidbId;
|
||||
@JsonKey(name: 'anilist_id', fromJson: flexibleInt)
|
||||
final int? anilistId;
|
||||
@JsonKey(name: 'imdb_id')
|
||||
@@ -43,6 +45,7 @@ class FribbMappingRow {
|
||||
final String? type;
|
||||
|
||||
const FribbMappingRow({
|
||||
this.anidbId,
|
||||
this.anilistId,
|
||||
this.imdbId,
|
||||
this.malId,
|
||||
|
||||
@@ -6,15 +6,15 @@ part of 'fribb_mapping_row.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
FribbMappingRow _$FribbMappingRowFromJson(Map<String, dynamic> json) =>
|
||||
FribbMappingRow(
|
||||
anilistId: flexibleInt(json['anilist_id']),
|
||||
imdbId: json['imdb_id'] as String?,
|
||||
malId: flexibleInt(json['mal_id']),
|
||||
simklId: flexibleInt(json['simkl_id']),
|
||||
tmdbId: flexibleInt(json['themoviedb_id']),
|
||||
tvdbId: flexibleInt(json['tvdb_id']),
|
||||
tvdbSeason: flexibleInt(_readTvdbSeason(json, 'tvdbSeason')),
|
||||
tmdbSeason: flexibleInt(_readTmdbSeason(json, 'tmdbSeason')),
|
||||
type: json['type'] as String?,
|
||||
);
|
||||
FribbMappingRow _$FribbMappingRowFromJson(Map<String, dynamic> json) => FribbMappingRow(
|
||||
anidbId: flexibleInt(json['anidb_id']),
|
||||
anilistId: flexibleInt(json['anilist_id']),
|
||||
imdbId: json['imdb_id'] as String?,
|
||||
malId: flexibleInt(json['mal_id']),
|
||||
simklId: flexibleInt(json['simkl_id']),
|
||||
tmdbId: flexibleInt(json['themoviedb_id']),
|
||||
tvdbId: flexibleInt(json['tvdb_id']),
|
||||
tvdbSeason: flexibleInt(_readTvdbSeason(json, 'tvdbSeason')),
|
||||
tmdbSeason: flexibleInt(_readTmdbSeason(json, 'tmdbSeason')),
|
||||
type: json['type'] as String?,
|
||||
);
|
||||
|
||||
@@ -202,8 +202,10 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
|
||||
if (isWatched) {
|
||||
await client.markUnwatched(metadata);
|
||||
unawaited(TrackerCoordinator.instance.markUnwatched(metadata, client));
|
||||
} else {
|
||||
await client.markWatched(metadata);
|
||||
unawaited(TrackerCoordinator.instance.markWatched(metadata, client));
|
||||
}
|
||||
if (mounted) {
|
||||
_watchStateChanged = true;
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../utils/watch_state_notifier.dart';
|
||||
import 'multi_server_manager.dart';
|
||||
import 'plex_client.dart';
|
||||
import 'settings_service.dart';
|
||||
import 'trackers/tracker_coordinator.dart';
|
||||
|
||||
/// Service for managing offline watch progress and syncing it back to the
|
||||
/// owning server. Backend-neutral over [MediaServerClient] — Plex actions
|
||||
@@ -570,10 +571,12 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
switch (action.actionType) {
|
||||
case 'watched':
|
||||
await client.markWatched(item);
|
||||
await TrackerCoordinator.instance.markWatched(item, client);
|
||||
break;
|
||||
|
||||
case 'unwatched':
|
||||
await client.markUnwatched(item);
|
||||
await TrackerCoordinator.instance.markUnwatched(item, client);
|
||||
break;
|
||||
|
||||
case 'progress':
|
||||
@@ -598,6 +601,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
// If progress exceeded threshold, also mark as watched.
|
||||
if (action.shouldMarkWatched) {
|
||||
await client.markWatched(item);
|
||||
await TrackerCoordinator.instance.markWatched(item, client);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,34 @@ class AnilistClient {
|
||||
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 {
|
||||
const mutation = '''
|
||||
mutation(\$mediaId: Int, \$scoreRaw: Int) {
|
||||
|
||||
@@ -64,6 +64,21 @@ class AnilistTracker extends TrackerBase {
|
||||
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 {
|
||||
final client = _client;
|
||||
final anilistId = ctx.ids.anime?.anilist;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import '../../media/media_item.dart';
|
||||
import '../../media/media_kind.dart';
|
||||
import '../../media/media_server_client.dart';
|
||||
import '../../models/trackers/anime_lists_mapping.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
|
||||
enum AnimeProgressScope { show, season }
|
||||
enum AnimeProgressScope { show, season, mapped }
|
||||
|
||||
class ResolvedAnimeProgress {
|
||||
final int progress;
|
||||
@@ -16,7 +17,14 @@ class ResolvedAnimeProgress {
|
||||
/// The coordinator builds tracker context before the current playback is marked
|
||||
/// watched, so unwatched current episodes are added to the watched rollup.
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -27,22 +35,68 @@ class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup {
|
||||
AnimeEpisodeProgressResolver(this._client);
|
||||
|
||||
@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 season = episode.parentIndex;
|
||||
if (showId == null || showId.isEmpty) 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);
|
||||
if (progressBySeason == null) return null;
|
||||
|
||||
final currentAlreadyWatched = (episode.viewCount ?? 0) > 0;
|
||||
final currentAlreadyWatched = (episode.viewCount ?? 0) > 0 || !includeCurrentEpisode;
|
||||
return switch (scope) {
|
||||
AnimeProgressScope.show => _showProgress(progressBySeason, 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 {
|
||||
final existing = _seasonProgressLoads[showId];
|
||||
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);
|
||||
}
|
||||
|
||||
Future<void> deleteMyListStatus(int animeId) async {
|
||||
await _request('DELETE', '/anime/$animeId/my_list_status');
|
||||
}
|
||||
|
||||
Future<int?> getMyListScore(int animeId) async {
|
||||
try {
|
||||
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)');
|
||||
}
|
||||
|
||||
@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 {
|
||||
final client = _client;
|
||||
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> 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> 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);
|
||||
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': [
|
||||
{'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 {
|
||||
|
||||
@@ -30,6 +30,7 @@ abstract class Tracker {
|
||||
bool shouldScrobbleForLibrary(String? libraryGlobalKey);
|
||||
|
||||
Future<void> markWatched(TrackerContext ctx);
|
||||
Future<void> markUnwatched(TrackerContext ctx);
|
||||
}
|
||||
|
||||
class TrackerRatingUnavailableException implements Exception {
|
||||
|
||||
@@ -5,7 +5,11 @@ import '../../media/media_kind.dart';
|
||||
import '../../media/media_server_client.dart';
|
||||
import '../../models/trackers/tracker_context.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 'fribb_mapping_store.dart';
|
||||
import 'mal/mal_tracker.dart';
|
||||
import 'simkl/simkl_tracker.dart';
|
||||
import 'tracker.dart';
|
||||
@@ -27,7 +31,11 @@ class TrackerCoordinator {
|
||||
/// Resolver persists across episode swaps so back-to-back episodes of the
|
||||
/// same show reuse the cached IDs. Cleared only on profile switch.
|
||||
TrackerIdResolver? _resolver;
|
||||
String? _resolverClientKey;
|
||||
String? _activeLibraryGlobalKey;
|
||||
FribbMappingLookup? _debugFribbStore;
|
||||
AnimeListsMappingLookup? _debugAnimeListsStore;
|
||||
AnimeEpisodeProgressLookup? _debugAnimeProgress;
|
||||
|
||||
TrackerContext? _ctx;
|
||||
Duration _duration = Duration.zero;
|
||||
@@ -43,14 +51,19 @@ class TrackerCoordinator {
|
||||
final mediaType = metadata.kind;
|
||||
if (mediaType != MediaKind.movie && mediaType != MediaKind.episode) return;
|
||||
final libraryGlobalKey = metadata.libraryGlobalKey;
|
||||
if (!_trackers.any((t) => t.canScrobble && t.shouldScrobbleForLibrary(libraryGlobalKey))) {
|
||||
if (!_hasActiveTrackerForLibrary(libraryGlobalKey)) {
|
||||
_reset();
|
||||
return;
|
||||
}
|
||||
|
||||
_activeLibraryGlobalKey = libraryGlobalKey;
|
||||
_resolver ??= TrackerIdResolver(client, needsFribb: _anyTrackerNeedsFribb);
|
||||
final ctx = await _buildContext(metadata);
|
||||
final clientKey = client.cacheServerId;
|
||||
if (_resolver == null || _resolverClientKey != clientKey) {
|
||||
_resolver?.clearCache();
|
||||
_resolver = _newResolver(client, needsFribb: _anyTrackerNeedsFribb);
|
||||
_resolverClientKey = clientKey;
|
||||
}
|
||||
final ctx = await _buildContext(metadata, _resolver!);
|
||||
if (ctx == null) {
|
||||
appLogger.d('Trackers: no external IDs for ${metadata.id}');
|
||||
_reset();
|
||||
@@ -60,8 +73,217 @@ class TrackerCoordinator {
|
||||
_ctx = ctx;
|
||||
}
|
||||
|
||||
bool _anyTrackerNeedsFribb() =>
|
||||
_trackers.any((t) => t.canScrobble && t.needsFribb && t.shouldScrobbleForLibrary(_activeLibraryGlobalKey));
|
||||
bool _anyTrackerNeedsFribb() => _anyTrackerNeedsFribbForLibrary(_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 {
|
||||
final ctx = _ctx;
|
||||
@@ -96,6 +318,7 @@ class TrackerCoordinator {
|
||||
_reset();
|
||||
_resolver?.clearCache();
|
||||
_resolver = null;
|
||||
_resolverClientKey = null;
|
||||
}
|
||||
|
||||
/// Drop the resolver's ID cache without touching in-flight playback state.
|
||||
@@ -119,6 +342,19 @@ class TrackerCoordinator {
|
||||
|
||||
Future<void> _dispatchMarkWatched(TrackerContext ctx) async {
|
||||
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(
|
||||
active.map((t) async {
|
||||
try {
|
||||
@@ -130,10 +366,26 @@ class TrackerCoordinator {
|
||||
);
|
||||
}
|
||||
|
||||
Future<TrackerContext?> _buildContext(MediaItem metadata) async {
|
||||
final resolver = _resolver;
|
||||
if (resolver == null) return null;
|
||||
Future<void> _dispatchUnwatchedToTrackers(Iterable<Tracker> trackers, TrackerContext ctx) async {
|
||||
final active = trackers.where((t) => _isActive(t, ctx.libraryGlobalKey));
|
||||
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;
|
||||
|
||||
if (metadata.kind == MediaKind.movie) {
|
||||
@@ -151,8 +403,17 @@ class TrackerCoordinator {
|
||||
final number = metadata.index;
|
||||
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;
|
||||
final animeProgress = includeAnimeProgress
|
||||
? ids.animeProgress ?? (fallbackToAnimeEpisodeNumber ? ids.animeEpisodeNumber : null)
|
||||
: fallbackToAnimeEpisodeNumber
|
||||
? ids.animeEpisodeNumber
|
||||
: null;
|
||||
return TrackerContext.episode(
|
||||
external: ids.external,
|
||||
anime: ids.anime,
|
||||
@@ -160,7 +421,40 @@ class TrackerCoordinator {
|
||||
libraryGlobalKey: libraryKey,
|
||||
season: season,
|
||||
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_server_client.dart';
|
||||
import '../../models/trackers/anime_ids.dart';
|
||||
import '../../models/trackers/anime_lists_mapping.dart';
|
||||
import '../../models/trackers/fribb_mapping_row.dart';
|
||||
import '../../utils/external_ids.dart';
|
||||
import 'anime_episode_progress_resolver.dart';
|
||||
import 'anime_lists_mapping_store.dart';
|
||||
import 'fribb_mapping_store.dart';
|
||||
|
||||
/// Paired ID output: always-present Plex external IDs (tvdb/imdb/tmdb) plus
|
||||
@@ -15,8 +17,17 @@ class TrackerIds {
|
||||
final AnimeIds? anime;
|
||||
final AnimeProgressScope? animeProgressScope;
|
||||
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) {
|
||||
return TrackerIds(
|
||||
@@ -24,6 +35,8 @@ class TrackerIds {
|
||||
anime: anime,
|
||||
animeProgressScope: animeProgressScope,
|
||||
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
|
||||
/// anime IDs (used by MAL/AniList, and by Simkl for anime precision).
|
||||
/// Episodes resolve against the show's GUIDs because Fribb only maps
|
||||
/// show-level external IDs; split-cour disambiguation uses the season
|
||||
/// number.
|
||||
/// show-level external IDs. Anime-Lists XML is used when available to
|
||||
/// disambiguate same-season split-cour episode ranges by AniDB id.
|
||||
///
|
||||
/// 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,
|
||||
@@ -57,21 +70,25 @@ class TrackerRatingContext {
|
||||
class TrackerIdResolver {
|
||||
final MediaServerClient _client;
|
||||
final FribbMappingLookup _store;
|
||||
final AnimeListsMappingLookup _animeLists;
|
||||
final AnimeEpisodeProgressLookup _animeProgress;
|
||||
final bool Function() _needsFribb;
|
||||
|
||||
/// 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.
|
||||
final Map<String, TrackerIds?> _cache = {};
|
||||
final Map<String, Future<ExternalIds>> _externalIdLoads = {};
|
||||
|
||||
TrackerIdResolver(
|
||||
MediaServerClient client, {
|
||||
bool Function()? needsFribb,
|
||||
FribbMappingLookup? store,
|
||||
AnimeListsMappingLookup? animeLists,
|
||||
AnimeEpisodeProgressLookup? animeProgress,
|
||||
}) : _client = client,
|
||||
_needsFribb = needsFribb ?? _returnTrue,
|
||||
_store = store ?? FribbMappingStore.instance,
|
||||
_animeLists = animeLists ?? AnimeListsMappingStore.instance,
|
||||
_animeProgress = animeProgress ?? AnimeEpisodeProgressResolver(client);
|
||||
|
||||
static bool _returnTrue() => true;
|
||||
@@ -80,41 +97,67 @@ class TrackerIdResolver {
|
||||
/// [MediaServerClient.fetchExternalIds] surface — Plex hits
|
||||
/// `/library/metadata/{id}?includeGuids=1`, Jellyfin reads the inline
|
||||
/// `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.
|
||||
Future<TrackerIds?> resolveForMovie(String itemId) async {
|
||||
if (_cache.containsKey(itemId)) return _cache[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;
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// Resolve IDs for an episode. Looks up the *show's* external IDs (via
|
||||
/// `grandparentId`), then disambiguates among candidate Fribb rows using
|
||||
/// the episode's season number.
|
||||
Future<TrackerIds?> resolveShowForEpisode(MediaItem episode) async {
|
||||
/// Anime-Lists episode mappings first and season mappings as fallback.
|
||||
Future<TrackerIds?> resolveShowForEpisode(
|
||||
MediaItem episode, {
|
||||
bool includeAnimeProgress = true,
|
||||
bool includeCurrentEpisode = true,
|
||||
}) async {
|
||||
final showId = episode.grandparentId;
|
||||
if (showId == null || showId.isEmpty) return null;
|
||||
|
||||
final season = episode.parentIndex;
|
||||
// Cache under the (showId, season) pair so a show with multiple Fribb
|
||||
// rows caches each season separately during a marathon.
|
||||
final cacheKey = season != null ? '$showId#s$season' : showId;
|
||||
final number = episode.index;
|
||||
// Cache under the full aired episode coordinate. Same-season split-cour
|
||||
// mappings can point different episode ranges at different anime entries.
|
||||
final cacheKey = season != null && number != null ? '$showId#s$season#e$number' : showId;
|
||||
TrackerIds? ids;
|
||||
if (_cache.containsKey(cacheKey)) {
|
||||
ids = _cache[cacheKey];
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
|
||||
if (ids == null || ids.animeProgressScope == null) return ids;
|
||||
final progress = await _animeProgress.resolve(episode, scope: ids.animeProgressScope!);
|
||||
return ids.withAnimeProgress(progress);
|
||||
final resolvedIds = ids;
|
||||
if (!includeAnimeProgress || resolvedIds == null || resolvedIds.animeProgressScope == null) return resolvedIds;
|
||||
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
|
||||
@@ -125,19 +168,19 @@ class TrackerIdResolver {
|
||||
final ids = await resolveForMovie(item.id);
|
||||
return ids == null ? null : TrackerRatingContext(ids: ids, kind: MediaKind.movie);
|
||||
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);
|
||||
case MediaKind.season:
|
||||
final showId = item.parentId;
|
||||
final season = item.index ?? item.parentIndex;
|
||||
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);
|
||||
case MediaKind.episode:
|
||||
final season = item.parentIndex;
|
||||
final number = item.index;
|
||||
if (season == null || number == null) return null;
|
||||
final ids = await resolveShowForEpisode(item);
|
||||
final ids = await resolveShowForEpisode(item, includeAnimeProgress: false);
|
||||
return ids == null
|
||||
? null
|
||||
: 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;
|
||||
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];
|
||||
|
||||
final external = await _fetchExternalIds(showId);
|
||||
final ids = await _build(external, isEpisodeSeason: season, isMovie: false);
|
||||
final ids = await _buildShowRating(external, season: season);
|
||||
_cache[cacheKey] = ids;
|
||||
return ids;
|
||||
}
|
||||
|
||||
void clearCache() {
|
||||
_cache.clear();
|
||||
_externalIdLoads.clear();
|
||||
_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 (!_needsFribb()) return TrackerIds(external: external, anime: null);
|
||||
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);
|
||||
return TrackerIds(
|
||||
external: external,
|
||||
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`.
|
||||
FribbMappingRow? _pickMovieRow(List<FribbMappingRow> rows) {
|
||||
if (rows.isEmpty) return null;
|
||||
@@ -189,9 +307,12 @@ class TrackerIdResolver {
|
||||
/// sharing the same show-level external ID (split-cour anime), prefer the
|
||||
/// one whose `season.tvdb` or `season.tmdb` matches the Plex episode's
|
||||
/// 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;
|
||||
|
||||
final match = animeMatch;
|
||||
if (match != null) return _rowForAnidb(rows, match.anidbId);
|
||||
|
||||
if (season != null) {
|
||||
for (final row in rows) {
|
||||
if (row.tvdbSeason == season || row.tmdbSeason == season) return row;
|
||||
@@ -210,15 +331,24 @@ class TrackerIdResolver {
|
||||
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({
|
||||
required FribbMappingRow? selected,
|
||||
required List<FribbMappingRow> rows,
|
||||
required int? season,
|
||||
required bool isMovie,
|
||||
required AnimeEpisodeMatch? animeMatch,
|
||||
}) {
|
||||
if (isMovie) return null;
|
||||
if (season == null || season <= 0) return null;
|
||||
if (selected == null) return null;
|
||||
if (animeMatch != null && selected.anidbId == animeMatch.anidbId) return AnimeProgressScope.mapped;
|
||||
if (_hasSeasonMapping(selected)) {
|
||||
final exactSeason = selected.tvdbSeason == season || selected.tmdbSeason == season;
|
||||
return exactSeason && _isRegularSeriesRow(selected) ? AnimeProgressScope.season : null;
|
||||
|
||||
@@ -302,7 +302,7 @@ class TraktScrobbleService {
|
||||
final number = metadata.index;
|
||||
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;
|
||||
|
||||
return TraktScrobbleRequest.episode(
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import 'dart:async';
|
||||
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_scrobble_request.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/episode_collection.dart';
|
||||
import '../../utils/watch_state_notifier.dart';
|
||||
import '../multi_server_manager.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.
|
||||
///
|
||||
/// Subscribes to `WatchStateNotifier` and filters to `{watched, unwatched}`
|
||||
/// events on movies/episodes. Failures are queued via `TraktSyncQueue` and
|
||||
/// drained on app foreground, network restore, and at startup.
|
||||
/// events on movies/episodes, expanding show/season events to their episodes.
|
||||
/// Failures are queued via `TraktSyncQueue` and drained on app foreground,
|
||||
/// network restore, and at startup.
|
||||
class TraktSyncService {
|
||||
/// Inter-request delay during queue drain to stay under Trakt's
|
||||
/// 1000 req / 5 min rate limit.
|
||||
@@ -99,7 +104,7 @@ class TraktSyncService {
|
||||
// Backend-neutral: TrackerIdResolver pulls external IDs through
|
||||
// MediaServerClient.fetchExternalIds — Plex hits `?includeGuids=1`,
|
||||
// Jellyfin reads the inline `ProviderIds` map.
|
||||
final mediaClient = _serverManager?.getClient(serverId);
|
||||
final mediaClient = _clientFor(serverId);
|
||||
if (mediaClient == null) return null;
|
||||
|
||||
final resolver = TrackerIdResolver(mediaClient, needsFribb: () => false);
|
||||
@@ -107,27 +112,89 @@ class TraktSyncService {
|
||||
return resolver;
|
||||
}
|
||||
|
||||
MediaServerClient? _clientFor(String serverId) => _serverManager?.getClient(serverId);
|
||||
|
||||
Future<void> _onWatchStateEvent(WatchStateEvent event) async {
|
||||
if (!_canPush) 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)) {
|
||||
appLogger.d('Trakt sync: library filtered out for ${event.itemId}');
|
||||
return;
|
||||
}
|
||||
|
||||
final op = event.changeType == WatchStateChangeType.watched ? TraktSyncOp.add : TraktSyncOp.remove;
|
||||
await _push(
|
||||
op: op,
|
||||
ratingKey: event.itemId,
|
||||
final watchedAtIso = DateTime.now().toUtc().toIso8601String();
|
||||
|
||||
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,
|
||||
libraryGlobalKey: event.librarySectionGlobalKey,
|
||||
kind: kind,
|
||||
watchedAtIso: DateTime.now().toUtc().toIso8601String(),
|
||||
serverName: mediaClient.serverName,
|
||||
libraryId: event.librarySectionID,
|
||||
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({
|
||||
@@ -137,6 +204,7 @@ class TraktSyncService {
|
||||
required String? libraryGlobalKey,
|
||||
required TraktMediaKind kind,
|
||||
required String watchedAtIso,
|
||||
MediaItem? episodeMeta,
|
||||
}) async {
|
||||
final resolver = _resolverFor(serverId);
|
||||
if (resolver == null) {
|
||||
@@ -155,14 +223,14 @@ class TraktSyncService {
|
||||
// doesn't carry the index, so fetch episode metadata via the neutral
|
||||
// MediaServerClient surface (Plex `/library/metadata`, Jellyfin
|
||||
// `/Users/{id}/Items/{id}`).
|
||||
final mediaClient = _serverManager?.getClient(serverId);
|
||||
final mediaClient = _clientFor(serverId);
|
||||
if (mediaClient == null) return;
|
||||
final episodeMeta = await mediaClient.fetchItem(ratingKey);
|
||||
if (episodeMeta == null) return;
|
||||
season = episodeMeta.parentIndex;
|
||||
number = episodeMeta.index;
|
||||
final metadata = episodeMeta ?? await mediaClient.fetchItem(ratingKey);
|
||||
if (metadata == null) return;
|
||||
season = metadata.parentIndex;
|
||||
number = metadata.index;
|
||||
if (season == null || number == null) return;
|
||||
resolved = await resolver.resolveShowForEpisode(episodeMeta);
|
||||
resolved = await resolver.resolveShowForEpisode(metadata, includeAnimeProgress: false);
|
||||
}
|
||||
|
||||
if (resolved == null) {
|
||||
|
||||
@@ -53,10 +53,26 @@ Future<void> _collectPlayable(
|
||||
|
||||
MediaItem _withFallbackLibrary(MediaItem item, MediaItem? fallback) {
|
||||
if (fallback == null) return item;
|
||||
final fallbackIsSeason = fallback.kind == MediaKind.season;
|
||||
final fallbackIsShow = fallback.kind == MediaKind.show;
|
||||
return item.copyWith(
|
||||
serverId: item.serverId ?? fallback.serverId,
|
||||
serverName: item.serverName ?? fallback.serverName,
|
||||
libraryId: item.libraryId ?? fallback.libraryId,
|
||||
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/media_list_playback_launcher.dart';
|
||||
import '../services/playlist_items_loader.dart';
|
||||
import '../services/trackers/tracker_coordinator.dart';
|
||||
import '../models/transcode_quality_preset.dart';
|
||||
import '../utils/download_version_utils.dart';
|
||||
import '../utils/download_utils.dart';
|
||||
@@ -549,8 +550,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
// hits /UserPlayedItems. WatchStateNotifier event is fired in both
|
||||
// paths so cross-screen UI updates regardless of backend.
|
||||
await _executeAction(context, () async {
|
||||
final item = mediaItem;
|
||||
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);
|
||||
}
|
||||
break;
|
||||
@@ -567,8 +572,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
}
|
||||
} else {
|
||||
await _executeAction(context, () async {
|
||||
final item = mediaItem;
|
||||
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);
|
||||
}
|
||||
break;
|
||||
|
||||
+1
-1
@@ -1485,7 +1485,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
|
||||
@@ -71,6 +71,7 @@ dependencies:
|
||||
win_http: ^0.2.0
|
||||
collection: ^1.18.0
|
||||
freezed_annotation: ^3.1.0
|
||||
xml: ^6.6.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -128,5 +128,58 @@ void main() {
|
||||
|
||||
expect(saved.single, {'mediaId': 21, 'progress': 12, 'status': 'CURRENT'});
|
||||
});
|
||||
|
||||
test('episode unwatch is a no-op', () async {
|
||||
final requests = <http.Request>[];
|
||||
final client = MockClient((request) async {
|
||||
requests.add(request);
|
||||
fail('Unexpected ${request.method} ${request.url}');
|
||||
});
|
||||
tracker.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: client);
|
||||
|
||||
await tracker.markUnwatched(_episode(animeProgress: 1));
|
||||
|
||||
expect(requests, isEmpty);
|
||||
});
|
||||
|
||||
test('removeFromList removes anime entry', () async {
|
||||
final variables = <Map<String, dynamic>>[];
|
||||
final client = MockClient((request) async {
|
||||
final body = json.decode(request.body) as Map<String, dynamic>;
|
||||
final query = body['query'] as String;
|
||||
variables.add((body['variables'] as Map).cast<String, dynamic>());
|
||||
if (query.contains('mediaListEntry')) {
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'data': {
|
||||
'Media': {
|
||||
'mediaListEntry': {'id': 99},
|
||||
},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (query.contains('DeleteMediaListEntry')) {
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'data': {
|
||||
'DeleteMediaListEntry': {'deleted': true},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
fail('Unexpected AniList query: $query');
|
||||
});
|
||||
tracker.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: client);
|
||||
|
||||
await tracker.removeFromList(_episode());
|
||||
|
||||
expect(variables, [
|
||||
{'mediaId': 21},
|
||||
{'id': 99},
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@ import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/models/trackers/anime_lists_mapping.dart';
|
||||
import 'package:plezy/services/trackers/anime_episode_progress_resolver.dart';
|
||||
|
||||
class _FakeMediaServerClient implements MediaServerClient {
|
||||
final Map<String, List<MediaItem>> childrenByParent;
|
||||
final Map<String, List<MediaItem>> playableByParent;
|
||||
Object? throwOnFetchChildren;
|
||||
int fetchChildrenCalls = 0;
|
||||
int fetchPlayableDescendantsCalls = 0;
|
||||
|
||||
_FakeMediaServerClient(this.childrenByParent);
|
||||
_FakeMediaServerClient(this.childrenByParent, {this.playableByParent = const {}});
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchChildren(String parentId) async {
|
||||
@@ -20,6 +23,12 @@ class _FakeMediaServerClient implements MediaServerClient {
|
||||
return childrenByParent[parentId] ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
|
||||
fetchPlayableDescendantsCalls++;
|
||||
return playableByParent[parentId] ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
@@ -45,6 +54,17 @@ MediaItem _episode({int season = 2, int number = 6, String showId = 'show-1', in
|
||||
viewCount: viewCount,
|
||||
);
|
||||
|
||||
AnimeEpisodeMatch _match({required int anidbId, required int serverEpisode, required int animeEpisode}) =>
|
||||
AnimeEpisodeMatch(
|
||||
anidbId: anidbId,
|
||||
anidbSeason: 1,
|
||||
anidbEpisode: animeEpisode,
|
||||
provider: AnimeListProvider.tvdb,
|
||||
externalSeason: 1,
|
||||
externalEpisode: serverEpisode,
|
||||
kind: AnimeListMatchKind.range,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('AnimeEpisodeProgressResolver', () {
|
||||
test('show scope sums watched counts across regular seasons', () async {
|
||||
@@ -178,5 +198,58 @@ void main() {
|
||||
expect((await resolver.resolve(_episode(season: 1, number: 7), scope: AnimeProgressScope.season))?.progress, 7);
|
||||
expect(client.fetchChildrenCalls, 2);
|
||||
});
|
||||
|
||||
test('mapped scope counts only watched episodes in the selected anime entry', () async {
|
||||
final client = _FakeMediaServerClient(
|
||||
const {},
|
||||
playableByParent: {
|
||||
'show-1': [
|
||||
_episode(season: 1, number: 12, viewCount: 1),
|
||||
_episode(season: 1, number: 13, viewCount: 1),
|
||||
_episode(season: 1, number: 14),
|
||||
],
|
||||
},
|
||||
);
|
||||
final resolver = AnimeEpisodeProgressResolver(client);
|
||||
|
||||
final result = await resolver.resolve(
|
||||
_episode(season: 1, number: 14),
|
||||
scope: AnimeProgressScope.mapped,
|
||||
animeMatch: _match(anidbId: 2, serverEpisode: 14, animeEpisode: 2),
|
||||
episodeMatcher: (episode) async => switch (episode.index) {
|
||||
12 => _match(anidbId: 1, serverEpisode: 12, animeEpisode: 12),
|
||||
13 => _match(anidbId: 2, serverEpisode: 13, animeEpisode: 1),
|
||||
14 => _match(anidbId: 2, serverEpisode: 14, animeEpisode: 2),
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.progress, 2);
|
||||
expect(client.fetchPlayableDescendantsCalls, 1);
|
||||
});
|
||||
|
||||
test('mapped scope can exclude the current episode for unwatch progress', () async {
|
||||
final client = _FakeMediaServerClient(
|
||||
const {},
|
||||
playableByParent: {
|
||||
'show-1': [_episode(season: 1, number: 13, viewCount: 1), _episode(season: 1, number: 14)],
|
||||
},
|
||||
);
|
||||
final resolver = AnimeEpisodeProgressResolver(client);
|
||||
|
||||
final result = await resolver.resolve(
|
||||
_episode(season: 1, number: 14),
|
||||
scope: AnimeProgressScope.mapped,
|
||||
animeMatch: _match(anidbId: 2, serverEpisode: 14, animeEpisode: 2),
|
||||
includeCurrentEpisode: false,
|
||||
episodeMatcher: (episode) async => switch (episode.index) {
|
||||
13 => _match(anidbId: 2, serverEpisode: 13, animeEpisode: 1),
|
||||
14 => _match(anidbId: 2, serverEpisode: 14, animeEpisode: 2),
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.progress, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/models/trackers/anime_lists_mapping.dart';
|
||||
import 'package:plezy/services/trackers/anime_lists_mapping_store.dart';
|
||||
|
||||
void main() {
|
||||
group('AnimeListsMappingStore parser', () {
|
||||
test('parses defaults, offsets, ranges, explicit mappings, and absolute seasons', () {
|
||||
final index = parseAnimeListsIndex('''
|
||||
<anime-list>
|
||||
<anime anidbid="1" tvdbid="123" defaulttvdbseason="1" episodeoffset="2" tmdbtv="456" tmdbseason="a" tmdbid="10,11" imdbid="tt1,tt2">
|
||||
<name>First</name>
|
||||
<mapping-list>
|
||||
<mapping anidbseason="1" tvdbseason="1" start="1" end="12" offset="12" />
|
||||
<mapping anidbseason="0" tvdbseason="0">;1-3;2-0;3-4+5;</mapping>
|
||||
</mapping-list>
|
||||
</anime>
|
||||
</anime-list>
|
||||
''');
|
||||
|
||||
final entry = index.byTvdb[123]!.single;
|
||||
|
||||
expect(entry.anidbId, 1);
|
||||
expect(entry.name, 'First');
|
||||
expect(entry.defaultTvdbSeason?.number, 1);
|
||||
expect(entry.episodeOffset, 2);
|
||||
expect(entry.tmdbSeason?.isAbsolute, isTrue);
|
||||
expect(entry.tmdbMovieIds, [10, 11]);
|
||||
expect(entry.imdbIds, ['tt1', 'tt2']);
|
||||
|
||||
final range = entry
|
||||
.resolveEpisode(provider: AnimeListProvider.tvdb, externalSeason: 1, externalEpisode: 14)
|
||||
.single;
|
||||
expect(range.anidbEpisode, 2);
|
||||
expect(range.kind, AnimeListMatchKind.range);
|
||||
|
||||
final explicit = entry
|
||||
.resolveEpisode(provider: AnimeListProvider.tvdb, externalSeason: 0, externalEpisode: 5)
|
||||
.single;
|
||||
expect(explicit.anidbSeason, 0);
|
||||
expect(explicit.anidbEpisode, 3);
|
||||
expect(explicit.kind, AnimeListMatchKind.explicit);
|
||||
});
|
||||
|
||||
test('default offset maps external episodes back to AniDB local episodes', () {
|
||||
final index = parseAnimeListsIndex('''
|
||||
<anime-list>
|
||||
<anime anidbid="1" tvdbid="123" defaulttvdbseason="1" episodeoffset="12">
|
||||
<name>Second Cour</name>
|
||||
</anime>
|
||||
</anime-list>
|
||||
''');
|
||||
|
||||
final match = lookupAnimeListEpisodeInIndex(index, tvdbId: 123, season: 1, episodeNumber: 14);
|
||||
|
||||
expect(match?.anidbId, 1);
|
||||
expect(match?.anidbEpisode, 2);
|
||||
expect(match?.kind, AnimeListMatchKind.defaultMapping);
|
||||
});
|
||||
|
||||
test('same TVDB season split across two AniDB entries resolves by range', () {
|
||||
final index = parseAnimeListsIndex('''
|
||||
<anime-list>
|
||||
<anime anidbid="1" tvdbid="123" defaulttvdbseason="1">
|
||||
<name>Cour 1</name>
|
||||
<mapping-list>
|
||||
<mapping anidbseason="1" tvdbseason="1" start="1" end="12" />
|
||||
</mapping-list>
|
||||
</anime>
|
||||
<anime anidbid="2" tvdbid="123" defaulttvdbseason="1">
|
||||
<name>Cour 2</name>
|
||||
<mapping-list>
|
||||
<mapping anidbseason="1" tvdbseason="1" start="1" end="12" offset="12" />
|
||||
</mapping-list>
|
||||
</anime>
|
||||
</anime-list>
|
||||
''');
|
||||
|
||||
final first = lookupAnimeListEpisodeInIndex(index, tvdbId: 123, season: 1, episodeNumber: 12);
|
||||
final second = lookupAnimeListEpisodeInIndex(index, tvdbId: 123, season: 1, episodeNumber: 14);
|
||||
|
||||
expect(first?.anidbId, 1);
|
||||
expect(first?.anidbEpisode, 12);
|
||||
expect(second?.anidbId, 2);
|
||||
expect(second?.anidbEpisode, 2);
|
||||
});
|
||||
|
||||
test('ambiguous same-priority matches do not guess', () {
|
||||
final index = parseAnimeListsIndex('''
|
||||
<anime-list>
|
||||
<anime anidbid="1" tvdbid="123" defaulttvdbseason="1"><name>A</name></anime>
|
||||
<anime anidbid="2" tvdbid="123" defaulttvdbseason="1"><name>B</name></anime>
|
||||
</anime-list>
|
||||
''');
|
||||
|
||||
final match = lookupAnimeListEpisodeInIndex(index, tvdbId: 123, season: 1, episodeNumber: 1);
|
||||
|
||||
expect(match, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -84,5 +84,34 @@ void main() {
|
||||
final put = requests.singleWhere((request) => request.method == 'PUT');
|
||||
expect(Uri.splitQueryString(put.body), {'status': 'watching', 'num_watched_episodes': '12'});
|
||||
});
|
||||
|
||||
test('episode unwatch is a no-op', () async {
|
||||
final requests = <http.Request>[];
|
||||
final client = MockClient((request) async {
|
||||
requests.add(request);
|
||||
fail('Unexpected ${request.method} ${request.url}');
|
||||
});
|
||||
tracker.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: client);
|
||||
|
||||
await tracker.markUnwatched(_episode(animeProgress: 1));
|
||||
|
||||
expect(requests, isEmpty);
|
||||
});
|
||||
|
||||
test('removeFromList removes anime entry', () async {
|
||||
final requests = <http.Request>[];
|
||||
final client = MockClient((request) async {
|
||||
requests.add(request);
|
||||
if (request.method == 'DELETE') return http.Response('{}', 200);
|
||||
fail('Unexpected ${request.method} ${request.url}');
|
||||
});
|
||||
tracker.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: client);
|
||||
|
||||
await tracker.removeFromList(_episode());
|
||||
|
||||
final delete = requests.single;
|
||||
expect(delete.method, 'DELETE');
|
||||
expect(delete.url.path, '/v2/anime/21/my_list_status');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/models/trackers/anime_lists_mapping.dart';
|
||||
import 'package:plezy/models/trackers/fribb_mapping_row.dart';
|
||||
import 'package:plezy/services/trackers/anime_lists_mapping_store.dart';
|
||||
import 'package:plezy/services/trackers/anilist/anilist_session.dart';
|
||||
import 'package:plezy/services/trackers/anilist/anilist_tracker.dart';
|
||||
import 'package:plezy/services/trackers/fribb_mapping_store.dart';
|
||||
import 'package:plezy/services/trackers/mal/mal_session.dart';
|
||||
import 'package:plezy/services/trackers/mal/mal_tracker.dart';
|
||||
import 'package:plezy/services/trackers/simkl/simkl_session.dart';
|
||||
import 'package:plezy/services/trackers/simkl/simkl_tracker.dart';
|
||||
import 'package:plezy/services/trackers/tracker_coordinator.dart';
|
||||
import 'package:plezy/utils/external_ids.dart';
|
||||
|
||||
class _FakeMediaServerClient implements MediaServerClient {
|
||||
@override
|
||||
final String serverId;
|
||||
@override
|
||||
String? get serverName => null;
|
||||
|
||||
final Map<String, ExternalIds> externalIdsByItem;
|
||||
final Map<String, List<MediaItem>> descendantsByParent;
|
||||
final List<String> externalIdCalls = [];
|
||||
final List<String> descendantCalls = [];
|
||||
|
||||
_FakeMediaServerClient({
|
||||
this.serverId = 'server-1',
|
||||
required this.externalIdsByItem,
|
||||
required this.descendantsByParent,
|
||||
});
|
||||
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.plex;
|
||||
|
||||
@override
|
||||
Future<ExternalIds> fetchExternalIds(String itemId) async {
|
||||
externalIdCalls.add(itemId);
|
||||
return externalIdsByItem[itemId] ?? const ExternalIds();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
|
||||
descendantCalls.add(parentId);
|
||||
return descendantsByParent[parentId] ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeFribbLookup implements FribbMappingLookup {
|
||||
final List<FribbMappingRow> rows;
|
||||
|
||||
const _FakeFribbLookup(this.rows);
|
||||
|
||||
@override
|
||||
Future<List<FribbMappingRow>> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async => rows;
|
||||
}
|
||||
|
||||
class _FakeAnimeListsLookup implements AnimeListsMappingLookup {
|
||||
final Map<String, AnimeEpisodeMatch> matches;
|
||||
|
||||
const _FakeAnimeListsLookup({this.matches = const {}});
|
||||
|
||||
@override
|
||||
Future<AnimeEpisodeMatch?> lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber}) async {
|
||||
return matches['$season-$episodeNumber'];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<int>> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season}) async => const <int>{};
|
||||
|
||||
@override
|
||||
Future<Set<int>> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async => const <int>{};
|
||||
}
|
||||
|
||||
MediaItem _season() => MediaItem(
|
||||
id: 'season-1',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.season,
|
||||
title: 'Season 1',
|
||||
serverId: 'server-1',
|
||||
libraryId: 'lib-1',
|
||||
index: 1,
|
||||
parentId: 'show-1',
|
||||
);
|
||||
|
||||
MediaItem _episode(int number, {int season = 1}) => MediaItem(
|
||||
id: 'episode-$season-$number',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.episode,
|
||||
title: 'Episode $number',
|
||||
serverId: 'server-1',
|
||||
libraryId: 'lib-1',
|
||||
parentIndex: season,
|
||||
index: number,
|
||||
);
|
||||
|
||||
MediaItem _show() => MediaItem(
|
||||
id: 'show-1',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.show,
|
||||
title: 'Show 1',
|
||||
serverId: 'server-1',
|
||||
libraryId: 'lib-1',
|
||||
);
|
||||
|
||||
AnimeEpisodeMatch _match({required int anidbId, required int serverEpisode, required int animeEpisode}) =>
|
||||
AnimeEpisodeMatch(
|
||||
anidbId: anidbId,
|
||||
anidbSeason: 1,
|
||||
anidbEpisode: animeEpisode,
|
||||
provider: AnimeListProvider.tvdb,
|
||||
externalSeason: 1,
|
||||
externalEpisode: serverEpisode,
|
||||
kind: AnimeListMatchKind.range,
|
||||
);
|
||||
|
||||
SimklSession _simklSession() {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return SimklSession(accessToken: 'token', createdAt: now);
|
||||
}
|
||||
|
||||
MalSession _malSession() {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return MalSession(accessToken: 'token', refreshToken: 'refresh', expiresAt: now + 86400, createdAt: now);
|
||||
}
|
||||
|
||||
AnilistSession _anilistSession() {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return AnilistSession(accessToken: 'token', expiresAt: now + 86400, createdAt: now);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('TrackerCoordinator manual watched sync', () {
|
||||
final coordinator = TrackerCoordinator.instance;
|
||||
final simkl = SimklTracker.instance;
|
||||
final mal = MalTracker.instance;
|
||||
final anilist = AnilistTracker.instance;
|
||||
|
||||
setUp(() async {
|
||||
await mal.setEnabled(false);
|
||||
await anilist.setEnabled(false);
|
||||
await simkl.setEnabled(true);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
coordinator.cancelInFlight();
|
||||
coordinator.debugUseResolverDependencies();
|
||||
mal.rebindSession(null, onSessionInvalidated: () {});
|
||||
anilist.rebindSession(null, onSessionInvalidated: () {});
|
||||
simkl.rebindSession(null, onSessionInvalidated: () {});
|
||||
await mal.setEnabled(false);
|
||||
await anilist.setEnabled(false);
|
||||
await simkl.setEnabled(false);
|
||||
});
|
||||
|
||||
test('expands a manually watched season and fills missing episode show context', () async {
|
||||
final bodies = <Map<String, dynamic>>[];
|
||||
final httpClient = MockClient((request) async {
|
||||
expect(request.method, 'POST');
|
||||
expect(request.url.path, '/sync/history');
|
||||
bodies.add((json.decode(request.body) as Map).cast<String, dynamic>());
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
simkl.rebindSession(_simklSession(), onSessionInvalidated: () {}, httpClient: httpClient);
|
||||
|
||||
final client = _FakeMediaServerClient(
|
||||
externalIdsByItem: {'show-1': const ExternalIds(tvdb: 12345)},
|
||||
descendantsByParent: {
|
||||
'season-1': [_episode(1), _episode(2)],
|
||||
},
|
||||
);
|
||||
|
||||
await coordinator.markWatched(_season(), client);
|
||||
|
||||
expect(client.descendantCalls, ['season-1']);
|
||||
expect(client.externalIdCalls, ['show-1']);
|
||||
expect(bodies, hasLength(2));
|
||||
expect(bodies[0]['shows'], [
|
||||
{
|
||||
'ids': {'tvdb': 12345},
|
||||
'seasons': [
|
||||
{
|
||||
'number': 1,
|
||||
'episodes': [
|
||||
{'number': 1},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(bodies[1]['shows'], [
|
||||
{
|
||||
'ids': {'tvdb': 12345},
|
||||
'seasons': [
|
||||
{
|
||||
'number': 1,
|
||||
'episodes': [
|
||||
{'number': 2},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('groups manually watched split seasons into separate anime entries', () async {
|
||||
await simkl.setEnabled(false);
|
||||
await mal.setEnabled(true);
|
||||
await anilist.setEnabled(true);
|
||||
coordinator.debugUseResolverDependencies(
|
||||
store: const _FakeFribbLookup([
|
||||
FribbMappingRow(tvdbId: 12345, malId: 101, anilistId: 201, tvdbSeason: 1, type: 'TV'),
|
||||
FribbMappingRow(tvdbId: 12345, malId: 102, anilistId: 202, tvdbSeason: 2, type: 'TV'),
|
||||
]),
|
||||
animeLists: const _FakeAnimeListsLookup(),
|
||||
);
|
||||
|
||||
final malUpdates = <int, Map<String, String>>{};
|
||||
final malHttp = MockClient((request) async {
|
||||
final malId = int.parse(request.url.pathSegments[2]);
|
||||
if (request.method == 'GET') {
|
||||
return http.Response(json.encode({'num_episodes': 2}), 200);
|
||||
}
|
||||
expect(request.method, 'PUT');
|
||||
malUpdates[malId] = Uri.splitQueryString(request.body);
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
mal.rebindSession(_malSession(), onSessionInvalidated: () {}, httpClient: malHttp);
|
||||
|
||||
final anilistSaves = <Map<String, dynamic>>[];
|
||||
final anilistHttp = MockClient((request) async {
|
||||
final body = json.decode(request.body) as Map<String, dynamic>;
|
||||
final query = body['query'] as String;
|
||||
if (query.contains('Media(id:')) {
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'data': {
|
||||
'Media': {'episodes': 2},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (query.contains('SaveMediaListEntry')) {
|
||||
anilistSaves.add((body['variables'] as Map).cast<String, dynamic>());
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'data': {
|
||||
'SaveMediaListEntry': {'id': 1},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
fail('Unexpected AniList query: $query');
|
||||
});
|
||||
anilist.rebindSession(_anilistSession(), onSessionInvalidated: () {}, httpClient: anilistHttp);
|
||||
|
||||
final client = _FakeMediaServerClient(
|
||||
externalIdsByItem: {'show-1': const ExternalIds(tvdb: 12345)},
|
||||
descendantsByParent: {
|
||||
'show-1': [_episode(1, season: 1), _episode(2, season: 1), _episode(1, season: 2), _episode(2, season: 2)],
|
||||
},
|
||||
);
|
||||
|
||||
await coordinator.markWatched(_show(), client);
|
||||
|
||||
expect(malUpdates, {
|
||||
101: {'status': 'completed', 'num_watched_episodes': '2'},
|
||||
102: {'status': 'completed', 'num_watched_episodes': '2'},
|
||||
});
|
||||
expect(anilistSaves, contains(equals({'mediaId': 201, 'progress': 2, 'status': 'COMPLETED'})));
|
||||
expect(anilistSaves, contains(equals({'mediaId': 202, 'progress': 2, 'status': 'COMPLETED'})));
|
||||
});
|
||||
|
||||
test('groups manually watched same-season split cours by Anime-Lists ranges', () async {
|
||||
await simkl.setEnabled(false);
|
||||
await mal.setEnabled(true);
|
||||
await anilist.setEnabled(true);
|
||||
coordinator.debugUseResolverDependencies(
|
||||
store: const _FakeFribbLookup([
|
||||
FribbMappingRow(anidbId: 111, tvdbId: 12345, malId: 101, anilistId: 201, tvdbSeason: 1, type: 'TV'),
|
||||
FribbMappingRow(anidbId: 222, tvdbId: 12345, malId: 102, anilistId: 202, tvdbSeason: 1, type: 'TV'),
|
||||
]),
|
||||
animeLists: _FakeAnimeListsLookup(
|
||||
matches: {
|
||||
'1-1': _match(anidbId: 111, serverEpisode: 1, animeEpisode: 1),
|
||||
'1-2': _match(anidbId: 111, serverEpisode: 2, animeEpisode: 2),
|
||||
'1-13': _match(anidbId: 222, serverEpisode: 13, animeEpisode: 1),
|
||||
'1-14': _match(anidbId: 222, serverEpisode: 14, animeEpisode: 2),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final malUpdates = <int, Map<String, String>>{};
|
||||
final malHttp = MockClient((request) async {
|
||||
final malId = int.parse(request.url.pathSegments[2]);
|
||||
if (request.method == 'GET') {
|
||||
return http.Response(json.encode({'num_episodes': 2}), 200);
|
||||
}
|
||||
expect(request.method, 'PUT');
|
||||
malUpdates[malId] = Uri.splitQueryString(request.body);
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
mal.rebindSession(_malSession(), onSessionInvalidated: () {}, httpClient: malHttp);
|
||||
|
||||
final anilistSaves = <Map<String, dynamic>>[];
|
||||
final anilistHttp = MockClient((request) async {
|
||||
final body = json.decode(request.body) as Map<String, dynamic>;
|
||||
final query = body['query'] as String;
|
||||
if (query.contains('Media(id:')) {
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'data': {
|
||||
'Media': {'episodes': 2},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (query.contains('SaveMediaListEntry')) {
|
||||
anilistSaves.add((body['variables'] as Map).cast<String, dynamic>());
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'data': {
|
||||
'SaveMediaListEntry': {'id': 1},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
fail('Unexpected AniList query: $query');
|
||||
});
|
||||
anilist.rebindSession(_anilistSession(), onSessionInvalidated: () {}, httpClient: anilistHttp);
|
||||
|
||||
final client = _FakeMediaServerClient(
|
||||
externalIdsByItem: {'show-1': const ExternalIds(tvdb: 12345)},
|
||||
descendantsByParent: {
|
||||
'show-1': [_episode(1), _episode(2), _episode(13), _episode(14)],
|
||||
},
|
||||
);
|
||||
|
||||
await coordinator.markWatched(_show(), client);
|
||||
|
||||
expect(malUpdates, {
|
||||
101: {'status': 'completed', 'num_watched_episodes': '2'},
|
||||
102: {'status': 'completed', 'num_watched_episodes': '2'},
|
||||
});
|
||||
expect(anilistSaves, contains(equals({'mediaId': 201, 'progress': 2, 'status': 'COMPLETED'})));
|
||||
expect(anilistSaves, contains(equals({'mediaId': 202, 'progress': 2, 'status': 'COMPLETED'})));
|
||||
});
|
||||
|
||||
test('removes manually unwatched season episodes from Simkl history', () async {
|
||||
final bodies = <Map<String, dynamic>>[];
|
||||
final httpClient = MockClient((request) async {
|
||||
expect(request.method, 'POST');
|
||||
expect(request.url.path, '/sync/history/remove');
|
||||
bodies.add((json.decode(request.body) as Map).cast<String, dynamic>());
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
simkl.rebindSession(_simklSession(), onSessionInvalidated: () {}, httpClient: httpClient);
|
||||
|
||||
final client = _FakeMediaServerClient(
|
||||
externalIdsByItem: {'show-1': const ExternalIds(tvdb: 12345)},
|
||||
descendantsByParent: {
|
||||
'season-1': [_episode(1), _episode(2)],
|
||||
},
|
||||
);
|
||||
|
||||
await coordinator.markUnwatched(_season(), client);
|
||||
|
||||
expect(client.descendantCalls, ['season-1']);
|
||||
expect(bodies, hasLength(2));
|
||||
expect(bodies.first['shows'], [
|
||||
{
|
||||
'ids': {'tvdb': 12345},
|
||||
'seasons': [
|
||||
{
|
||||
'number': 1,
|
||||
'episodes': [
|
||||
{'number': 1},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('removes manually unwatched split seasons from MAL and AniList lists', () async {
|
||||
await simkl.setEnabled(false);
|
||||
await mal.setEnabled(true);
|
||||
await anilist.setEnabled(true);
|
||||
coordinator.debugUseResolverDependencies(
|
||||
store: const _FakeFribbLookup([
|
||||
FribbMappingRow(anidbId: 111, tvdbId: 12345, malId: 101, anilistId: 201, tvdbSeason: 1, type: 'TV'),
|
||||
FribbMappingRow(anidbId: 222, tvdbId: 12345, malId: 102, anilistId: 202, tvdbSeason: 1, type: 'TV'),
|
||||
]),
|
||||
animeLists: _FakeAnimeListsLookup(
|
||||
matches: {
|
||||
'1-1': _match(anidbId: 111, serverEpisode: 1, animeEpisode: 1),
|
||||
'1-2': _match(anidbId: 111, serverEpisode: 2, animeEpisode: 2),
|
||||
'1-13': _match(anidbId: 222, serverEpisode: 13, animeEpisode: 1),
|
||||
'1-14': _match(anidbId: 222, serverEpisode: 14, animeEpisode: 2),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final malDeletes = <int>[];
|
||||
final malHttp = MockClient((request) async {
|
||||
expect(request.method, 'DELETE');
|
||||
malDeletes.add(int.parse(request.url.pathSegments[2]));
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
mal.rebindSession(_malSession(), onSessionInvalidated: () {}, httpClient: malHttp);
|
||||
|
||||
final anilistDeletes = <int>[];
|
||||
final anilistHttp = MockClient((request) async {
|
||||
final body = json.decode(request.body) as Map<String, dynamic>;
|
||||
final query = body['query'] as String;
|
||||
final variables = (body['variables'] as Map).cast<String, dynamic>();
|
||||
if (query.contains('mediaListEntry')) {
|
||||
final mediaId = variables['mediaId'] as int;
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'data': {
|
||||
'Media': {
|
||||
'mediaListEntry': {'id': mediaId + 100},
|
||||
},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (query.contains('DeleteMediaListEntry')) {
|
||||
anilistDeletes.add(variables['id'] as int);
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'data': {
|
||||
'DeleteMediaListEntry': {'deleted': true},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
fail('Unexpected AniList query: $query');
|
||||
});
|
||||
anilist.rebindSession(_anilistSession(), onSessionInvalidated: () {}, httpClient: anilistHttp);
|
||||
|
||||
final client = _FakeMediaServerClient(
|
||||
externalIdsByItem: {'show-1': const ExternalIds(tvdb: 12345)},
|
||||
descendantsByParent: {
|
||||
'show-1': [_episode(1), _episode(2), _episode(13), _episode(14)],
|
||||
},
|
||||
);
|
||||
|
||||
await coordinator.markUnwatched(_show(), client);
|
||||
|
||||
expect(malDeletes, unorderedEquals([101, 102]));
|
||||
expect(anilistDeletes, unorderedEquals([301, 302]));
|
||||
});
|
||||
|
||||
test('playback resolver is recreated when the server client changes', () async {
|
||||
simkl.rebindSession(
|
||||
_simklSession(),
|
||||
onSessionInvalidated: () {},
|
||||
httpClient: MockClient((_) async => http.Response('{}', 200)),
|
||||
);
|
||||
|
||||
final firstClient = _FakeMediaServerClient(
|
||||
serverId: 'server-a',
|
||||
externalIdsByItem: {'show-a': const ExternalIds(tvdb: 111)},
|
||||
descendantsByParent: const {},
|
||||
);
|
||||
final secondClient = _FakeMediaServerClient(
|
||||
serverId: 'server-b',
|
||||
externalIdsByItem: {'show-b': const ExternalIds(tvdb: 222)},
|
||||
descendantsByParent: const {},
|
||||
);
|
||||
final firstEpisode = _episode(1).copyWith(id: 'episode-a', serverId: 'server-a', grandparentId: 'show-a');
|
||||
final secondEpisode = _episode(1).copyWith(id: 'episode-b', serverId: 'server-b', grandparentId: 'show-b');
|
||||
|
||||
await coordinator.startPlayback(firstEpisode, firstClient);
|
||||
await coordinator.startPlayback(secondEpisode, secondClient);
|
||||
|
||||
expect(firstClient.externalIdCalls, ['show-a']);
|
||||
expect(secondClient.externalIdCalls, ['show-b']);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/models/trackers/anime_lists_mapping.dart';
|
||||
import 'package:plezy/models/trackers/fribb_mapping_row.dart';
|
||||
import 'package:plezy/services/trackers/anime_episode_progress_resolver.dart';
|
||||
import 'package:plezy/services/trackers/anime_lists_mapping_store.dart';
|
||||
import 'package:plezy/services/trackers/fribb_mapping_store.dart';
|
||||
import 'package:plezy/services/trackers/tracker_id_resolver.dart';
|
||||
import 'package:plezy/utils/external_ids.dart';
|
||||
@@ -44,15 +46,25 @@ class _FakeAnimeProgressLookup implements AnimeEpisodeProgressLookup {
|
||||
int clearCalls = 0;
|
||||
MediaItem? lastEpisode;
|
||||
AnimeProgressScope? lastScope;
|
||||
AnimeEpisodeMatch? lastMatch;
|
||||
bool? lastIncludeCurrentEpisode;
|
||||
|
||||
_FakeAnimeProgressLookup(int? progress)
|
||||
: result = progress == null ? null : ResolvedAnimeProgress(progress: progress);
|
||||
|
||||
@override
|
||||
Future<ResolvedAnimeProgress?> resolve(MediaItem episode, {required AnimeProgressScope scope}) async {
|
||||
Future<ResolvedAnimeProgress?> resolve(
|
||||
MediaItem episode, {
|
||||
required AnimeProgressScope scope,
|
||||
AnimeEpisodeMatch? animeMatch,
|
||||
Future<AnimeEpisodeMatch?> Function(MediaItem episode)? episodeMatcher,
|
||||
bool includeCurrentEpisode = true,
|
||||
}) async {
|
||||
resolveCalls++;
|
||||
lastEpisode = episode;
|
||||
lastScope = scope;
|
||||
lastMatch = animeMatch;
|
||||
lastIncludeCurrentEpisode = includeCurrentEpisode;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -62,6 +74,23 @@ class _FakeAnimeProgressLookup implements AnimeEpisodeProgressLookup {
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeAnimeListsLookup implements AnimeListsMappingLookup {
|
||||
final Map<String, AnimeEpisodeMatch> matches;
|
||||
|
||||
const _FakeAnimeListsLookup({this.matches = const {}});
|
||||
|
||||
@override
|
||||
Future<AnimeEpisodeMatch?> lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber}) async {
|
||||
return matches['$season-$episodeNumber'];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<int>> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season}) async => const <int>{};
|
||||
|
||||
@override
|
||||
Future<Set<int>> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async => const <int>{};
|
||||
}
|
||||
|
||||
MediaItem _episode({int season = 23, int number = 6}) => MediaItem(
|
||||
id: 'episode-$season-$number',
|
||||
backend: MediaBackend.plex,
|
||||
@@ -76,14 +105,27 @@ TrackerIdResolver _resolver({
|
||||
required List<FribbMappingRow> rows,
|
||||
required _FakeAnimeProgressLookup animeProgress,
|
||||
_FakeFribbLookup? lookup,
|
||||
AnimeListsMappingLookup animeLists = const _FakeAnimeListsLookup(),
|
||||
}) {
|
||||
return TrackerIdResolver(
|
||||
_FakeMediaServerClient({'show-1': const ExternalIds(tvdb: 81797, tmdb: 37854, imdb: 'tt0388629')}),
|
||||
store: lookup ?? _FakeFribbLookup(rows),
|
||||
animeLists: animeLists,
|
||||
animeProgress: animeProgress,
|
||||
);
|
||||
}
|
||||
|
||||
AnimeEpisodeMatch _match({required int anidbId, required int serverEpisode, required int animeEpisode}) =>
|
||||
AnimeEpisodeMatch(
|
||||
anidbId: anidbId,
|
||||
anidbSeason: 1,
|
||||
anidbEpisode: animeEpisode,
|
||||
provider: AnimeListProvider.tvdb,
|
||||
externalSeason: 1,
|
||||
externalEpisode: serverEpisode,
|
||||
kind: AnimeListMatchKind.range,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('TrackerIdResolver anime progress', () {
|
||||
test('one unseasoned regular TV row uses show-scope progress', () async {
|
||||
@@ -176,5 +218,68 @@ void main() {
|
||||
expect(animeProgress.clearCalls, 1);
|
||||
expect(animeProgress.resolveCalls, 2);
|
||||
});
|
||||
|
||||
test('same server season can select different anime entries by episode range', () async {
|
||||
final animeProgress = _FakeAnimeProgressLookup(2);
|
||||
final resolver = _resolver(
|
||||
animeProgress: animeProgress,
|
||||
animeLists: _FakeAnimeListsLookup(matches: {'1-14': _match(anidbId: 222, serverEpisode: 14, animeEpisode: 2)}),
|
||||
rows: const [
|
||||
FribbMappingRow(anidbId: 111, tvdbId: 81797, malId: 101, tvdbSeason: 1, type: 'TV'),
|
||||
FribbMappingRow(anidbId: 222, tvdbId: 81797, malId: 102, tvdbSeason: 1, type: 'TV'),
|
||||
],
|
||||
);
|
||||
|
||||
final ids = await resolver.resolveShowForEpisode(_episode(season: 1, number: 14));
|
||||
|
||||
expect(ids?.anime?.mal, 102);
|
||||
expect(ids?.animeProgressScope, AnimeProgressScope.mapped);
|
||||
expect(ids?.animeEpisodeNumber, 2);
|
||||
expect(ids?.animeProgress, 2);
|
||||
expect(animeProgress.lastScope, AnimeProgressScope.mapped);
|
||||
expect(animeProgress.lastMatch?.anidbId, 222);
|
||||
});
|
||||
|
||||
test('passes includeCurrentEpisode through for unwatch progress', () async {
|
||||
final animeProgress = _FakeAnimeProgressLookup(1);
|
||||
final resolver = _resolver(
|
||||
animeProgress: animeProgress,
|
||||
animeLists: _FakeAnimeListsLookup(matches: {'1-14': _match(anidbId: 222, serverEpisode: 14, animeEpisode: 2)}),
|
||||
rows: const [FribbMappingRow(anidbId: 222, tvdbId: 81797, malId: 102, tvdbSeason: 1, type: 'TV')],
|
||||
);
|
||||
|
||||
final ids = await resolver.resolveShowForEpisode(_episode(season: 1, number: 14), includeCurrentEpisode: false);
|
||||
|
||||
expect(ids?.animeProgress, 1);
|
||||
expect(animeProgress.lastIncludeCurrentEpisode, isFalse);
|
||||
});
|
||||
|
||||
test('episode-aware cache does not reuse a same-season split-cour row', () async {
|
||||
final animeProgress = _FakeAnimeProgressLookup(null);
|
||||
final lookup = _FakeFribbLookup(const [
|
||||
FribbMappingRow(anidbId: 111, tvdbId: 81797, malId: 101, tvdbSeason: 1, type: 'TV'),
|
||||
FribbMappingRow(anidbId: 222, tvdbId: 81797, malId: 102, tvdbSeason: 1, type: 'TV'),
|
||||
]);
|
||||
final client = _FakeMediaServerClient({'show-1': const ExternalIds(tvdb: 81797)});
|
||||
final resolver = TrackerIdResolver(
|
||||
client,
|
||||
store: lookup,
|
||||
animeLists: _FakeAnimeListsLookup(
|
||||
matches: {
|
||||
'1-12': _match(anidbId: 111, serverEpisode: 12, animeEpisode: 12),
|
||||
'1-13': _match(anidbId: 222, serverEpisode: 13, animeEpisode: 1),
|
||||
},
|
||||
),
|
||||
animeProgress: animeProgress,
|
||||
);
|
||||
|
||||
final first = await resolver.resolveShowForEpisode(_episode(season: 1, number: 12), includeAnimeProgress: false);
|
||||
final second = await resolver.resolveShowForEpisode(_episode(season: 1, number: 13), includeAnimeProgress: false);
|
||||
|
||||
expect(first?.anime?.mal, 101);
|
||||
expect(second?.anime?.mal, 102);
|
||||
expect(client.externalIdCalls, ['show-1']);
|
||||
expect(lookup.lookups, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user