167 lines
5.3 KiB
Dart
167 lines
5.3 KiB
Dart
import 'dart:async';
|
|
|
|
import '../../media/media_item.dart';
|
|
import '../../media/media_kind.dart';
|
|
import '../../media/media_server_client.dart';
|
|
import '../../models/trackers/tracker_context.dart';
|
|
import '../../utils/app_logger.dart';
|
|
import 'anilist/anilist_tracker.dart';
|
|
import 'mal/mal_tracker.dart';
|
|
import 'simkl/simkl_tracker.dart';
|
|
import 'tracker.dart';
|
|
import 'tracker_constants.dart';
|
|
import 'tracker_id_resolver.dart';
|
|
|
|
/// Fan-out for non-Trakt trackers (MAL, AniList, Simkl). Owns the per-playback
|
|
/// threshold state: each connected tracker is notified exactly once when
|
|
/// progress crosses the watched threshold, with a safety-net fire on stop if
|
|
/// the crossing was missed (e.g. user stopped between ticks).
|
|
class TrackerCoordinator {
|
|
static TrackerCoordinator? _instance;
|
|
static TrackerCoordinator get instance => _instance ??= TrackerCoordinator._();
|
|
|
|
TrackerCoordinator._();
|
|
|
|
late final List<Tracker> _trackers = [MalTracker.instance, AnilistTracker.instance, SimklTracker.instance];
|
|
|
|
/// 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? _activeLibraryGlobalKey;
|
|
|
|
TrackerContext? _ctx;
|
|
Duration _duration = Duration.zero;
|
|
Duration _lastPosition = Duration.zero;
|
|
bool _thresholdCrossed = false;
|
|
|
|
Future<void> initialize() async {
|
|
await Future.wait(_trackers.map((t) => t.initialize()));
|
|
}
|
|
|
|
Future<void> startPlayback(MediaItem metadata, MediaServerClient client, {bool isLive = false}) async {
|
|
if (isLive) return;
|
|
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))) {
|
|
_reset();
|
|
return;
|
|
}
|
|
|
|
_activeLibraryGlobalKey = libraryGlobalKey;
|
|
_resolver ??= TrackerIdResolver(client, needsFribb: _anyTrackerNeedsFribb);
|
|
final ctx = await _buildContext(metadata);
|
|
if (ctx == null) {
|
|
appLogger.d('Trackers: no external IDs for ${metadata.id}');
|
|
_reset();
|
|
return;
|
|
}
|
|
_reset();
|
|
_ctx = ctx;
|
|
}
|
|
|
|
bool _anyTrackerNeedsFribb() =>
|
|
_trackers.any((t) => t.canScrobble && t.needsFribb && t.shouldScrobbleForLibrary(_activeLibraryGlobalKey));
|
|
|
|
Future<void> stopPlayback() async {
|
|
final ctx = _ctx;
|
|
if (ctx == null) {
|
|
_reset();
|
|
return;
|
|
}
|
|
// Safety net: fire if we passed the threshold but missed the tick.
|
|
if (!_thresholdCrossed && _crossed(_duration, _lastPosition)) {
|
|
await _dispatchMarkWatched(ctx);
|
|
}
|
|
_reset();
|
|
}
|
|
|
|
void updatePosition(Duration position) {
|
|
_lastPosition = position;
|
|
final ctx = _ctx;
|
|
if (ctx == null || _thresholdCrossed) return;
|
|
if (!_crossed(_duration, position)) return;
|
|
_thresholdCrossed = true;
|
|
unawaited(_dispatchMarkWatched(ctx));
|
|
}
|
|
|
|
void updateDuration(Duration duration) {
|
|
if (duration == _duration) return;
|
|
_duration = duration;
|
|
}
|
|
|
|
/// Called on Plex profile switch — drops in-flight state across all
|
|
/// trackers and invalidates the resolver so a fresh Plex client is used.
|
|
void cancelInFlight() {
|
|
_reset();
|
|
_resolver?.clearCache();
|
|
_resolver = null;
|
|
}
|
|
|
|
/// Drop the resolver's ID cache without touching in-flight playback state.
|
|
/// Called after a tracker is connected/disconnected so cached lookups
|
|
/// re-evaluate the `needsFribb` predicate.
|
|
void invalidateResolverCache() => _resolver?.clearCache();
|
|
|
|
void _reset() {
|
|
_ctx = null;
|
|
_activeLibraryGlobalKey = null;
|
|
_duration = Duration.zero;
|
|
_lastPosition = Duration.zero;
|
|
_thresholdCrossed = false;
|
|
}
|
|
|
|
static bool _crossed(Duration duration, Duration position) {
|
|
final dMs = duration.inMilliseconds;
|
|
if (dMs == 0) return false;
|
|
return position.inMilliseconds * 100 >= dMs * TrackerConstants.watchedThresholdPercent;
|
|
}
|
|
|
|
Future<void> _dispatchMarkWatched(TrackerContext ctx) async {
|
|
final active = _trackers.where((t) => t.canScrobble && t.shouldScrobbleForLibrary(ctx.libraryGlobalKey));
|
|
await Future.wait(
|
|
active.map((t) async {
|
|
try {
|
|
await t.markWatched(ctx);
|
|
} catch (e) {
|
|
appLogger.d('${t.name}: markWatched failed', error: e);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
Future<TrackerContext?> _buildContext(MediaItem metadata) async {
|
|
final resolver = _resolver;
|
|
if (resolver == null) return null;
|
|
|
|
final libraryKey = metadata.libraryGlobalKey;
|
|
|
|
if (metadata.kind == MediaKind.movie) {
|
|
final ids = await resolver.resolveForMovie(metadata.id);
|
|
if (ids == null) return null;
|
|
return TrackerContext.movie(
|
|
external: ids.external,
|
|
anime: ids.anime,
|
|
ratingKey: metadata.id,
|
|
libraryGlobalKey: libraryKey,
|
|
);
|
|
}
|
|
|
|
final season = metadata.parentIndex;
|
|
final number = metadata.index;
|
|
if (season == null || number == null) return null;
|
|
|
|
final ids = await resolver.resolveShowForEpisode(metadata);
|
|
if (ids == null) return null;
|
|
return TrackerContext.episode(
|
|
external: ids.external,
|
|
anime: ids.anime,
|
|
ratingKey: metadata.id,
|
|
libraryGlobalKey: libraryKey,
|
|
season: season,
|
|
episodeNumber: number,
|
|
animeProgress: ids.animeProgress,
|
|
);
|
|
}
|
|
}
|