399 lines
14 KiB
Dart
399 lines
14 KiB
Dart
import 'dart:async';
|
|
import '../../media/ids.dart';
|
|
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 '../../media/episode_collection.dart';
|
|
import '../../utils/watch_state_notifier.dart';
|
|
import '../multi_server_manager.dart';
|
|
import '../settings_service.dart';
|
|
import '../trackers/tracker_constants.dart';
|
|
import '../trackers/tracker_id_resolver.dart';
|
|
import '../trackers/tracker_session.dart';
|
|
import 'trakt_client.dart';
|
|
import 'trakt_constants.dart';
|
|
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, 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.
|
|
static const Duration _queueRequestSpacing = Duration(milliseconds: 50);
|
|
|
|
static TraktSyncService? _instance;
|
|
static TraktSyncService get instance => _instance ??= TraktSyncService._();
|
|
|
|
TraktSyncService._();
|
|
|
|
bool _isInitialized = false;
|
|
bool _isEnabled = false;
|
|
String _activeUserUuid = '';
|
|
|
|
TraktClient? _client;
|
|
MultiServerManager? _serverManager;
|
|
StreamSubscription<WatchStateEvent>? _subscription;
|
|
final TraktSyncQueue _queue = TraktSyncQueue();
|
|
|
|
/// One resolver per server, kept alive across events so the per-item
|
|
/// external-id cache survives a binge-watch session. Backend-neutral —
|
|
/// Plex resolves via `?includeGuids=1`, Jellyfin reads inline `ProviderIds`.
|
|
final Map<String, TrackerIdResolver> _resolvers = {};
|
|
|
|
/// Fallback buffers for items that failed to persist to the on-disk queue
|
|
/// (e.g. SharedPreferences write threw). Keyed by profile so a profile switch
|
|
/// cannot replay one user's failed writes through another user's Trakt client.
|
|
/// Bounded per profile to keep memory pressure finite; oldest items drop first.
|
|
static const int _maxInMemoryFallback = 100;
|
|
final Map<String, Queue<TraktSyncQueueItem>> _inMemoryFallbackByUser = {};
|
|
|
|
Future<void>? _flushFuture;
|
|
bool _flushRequested = false;
|
|
|
|
Future<void> initialize({required MultiServerManager serverManager}) async {
|
|
if (_isInitialized) return;
|
|
_isInitialized = true;
|
|
_serverManager = serverManager;
|
|
|
|
final settings = await SettingsService.getInstance();
|
|
_isEnabled = settings.read(SettingsService.enableTraktWatchedSync);
|
|
|
|
_subscription = WatchStateNotifier().stream.listen(
|
|
_onWatchStateEvent,
|
|
onError: (Object e, StackTrace st) =>
|
|
appLogger.w('Trakt sync: watch event handler error', error: e, stackTrace: st),
|
|
);
|
|
}
|
|
|
|
Future<void> setEnabled(bool enabled) async {
|
|
_isEnabled = enabled;
|
|
}
|
|
|
|
/// Switch to a different account. Drops cached resolvers (their backing
|
|
/// clients are tied to the previous user's tokens) and rebinds the queue.
|
|
void rebindToProfile(
|
|
String userUuid,
|
|
TrackerSession? session, {
|
|
required void Function() onSessionInvalidated,
|
|
void Function(TrackerSession session)? onSessionUpdated,
|
|
}) {
|
|
_client?.dispose();
|
|
_client = session != null
|
|
? TraktClient(session, onSessionInvalidated: onSessionInvalidated, onSessionUpdated: onSessionUpdated)
|
|
: null;
|
|
_activeUserUuid = userUuid;
|
|
_resolvers.clear();
|
|
if (_client != null) unawaited(flushQueue());
|
|
}
|
|
|
|
void updateSession(TrackerSession session) {
|
|
_client?.updateSession(session);
|
|
}
|
|
|
|
Future<void> dispose() async {
|
|
await _subscription?.cancel();
|
|
_subscription = null;
|
|
_client?.dispose();
|
|
_client = null;
|
|
_resolvers.clear();
|
|
}
|
|
|
|
bool get _canPush => _isEnabled && _client != null;
|
|
|
|
TrackerIdResolver? _resolverFor(ServerId serverId) {
|
|
final cached = _resolvers[serverId];
|
|
if (cached != null) return cached;
|
|
|
|
// Backend-neutral: TrackerIdResolver pulls external IDs through
|
|
// MediaServerClient.fetchExternalIds — Plex hits `?includeGuids=1`,
|
|
// Jellyfin reads the inline `ProviderIds` map.
|
|
final mediaClient = _clientFor(serverId);
|
|
if (mediaClient == null) return null;
|
|
|
|
final resolver = TrackerIdResolver(mediaClient, needsFribb: () => false);
|
|
_resolvers[serverId] = resolver;
|
|
return resolver;
|
|
}
|
|
|
|
MediaServerClient? _clientFor(ServerId serverId) => _serverManager?.getClient(serverId);
|
|
|
|
Future<void> _onWatchStateEvent(WatchStateEvent event) async {
|
|
if (!_canPush) return;
|
|
if (event.changeType != WatchStateChangeType.watched && event.changeType != WatchStateChangeType.unwatched) 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;
|
|
final watchedAtIso = DateTime.now().toUtc().toIso8601String();
|
|
|
|
switch (event.mediaType) {
|
|
case 'movie':
|
|
await _push(
|
|
op: op,
|
|
ratingKey: event.itemId,
|
|
serverId: ServerId(event.serverId),
|
|
libraryGlobalKey: event.librarySectionGlobalKey,
|
|
kind: TraktMediaKind.movie,
|
|
watchedAtIso: watchedAtIso,
|
|
);
|
|
case 'episode':
|
|
await _push(
|
|
op: op,
|
|
ratingKey: event.itemId,
|
|
serverId: 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(ServerId(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,
|
|
serverName: mediaClient.serverName,
|
|
libraryId: event.librarySectionID,
|
|
parentId: event.mediaType == 'season' && event.parentChain.isNotEmpty ? event.parentChain.first : null,
|
|
);
|
|
final episodes = <MediaItem>[];
|
|
await collectEpisodes(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: ServerId(event.serverId),
|
|
libraryGlobalKey: episode.libraryGlobalKey ?? event.librarySectionGlobalKey,
|
|
kind: TraktMediaKind.episode,
|
|
watchedAtIso: watchedAtIso,
|
|
episodeMeta: episode,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _push({
|
|
required TraktSyncOp op,
|
|
required String ratingKey,
|
|
required ServerId serverId,
|
|
required String? libraryGlobalKey,
|
|
required TraktMediaKind kind,
|
|
required String watchedAtIso,
|
|
MediaItem? episodeMeta,
|
|
}) async {
|
|
final resolver = _resolverFor(ServerId(serverId));
|
|
if (resolver == null) {
|
|
appLogger.d('Trakt sync: no client registered for server $serverId, skipping');
|
|
return;
|
|
}
|
|
|
|
TrackerIds? resolved;
|
|
int? season;
|
|
int? number;
|
|
|
|
if (kind == TraktMediaKind.movie) {
|
|
resolved = await resolver.resolveForMovie(ratingKey);
|
|
} else {
|
|
// Episode — need show IDs + season/episode index. The WatchStateEvent
|
|
// doesn't carry the index, so fetch episode metadata via the neutral
|
|
// MediaServerClient surface (Plex `/library/metadata`, Jellyfin
|
|
// `/Users/{id}/Items/{id}`).
|
|
final mediaClient = _clientFor(ServerId(serverId));
|
|
if (mediaClient == null) return;
|
|
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(metadata, includeAnimeProgress: false);
|
|
}
|
|
|
|
if (resolved == null) {
|
|
appLogger.d('Trakt sync: no IDs for ${kind.name} $ratingKey, dropping');
|
|
return;
|
|
}
|
|
|
|
final ids = TraktIds.fromExternal(resolved.external);
|
|
final body = kind == TraktMediaKind.movie
|
|
? TraktScrobbleRequest.movie(ids: ids)
|
|
: TraktScrobbleRequest.episode(showIds: ids, season: season!, number: number!);
|
|
|
|
final item = TraktSyncQueueItem(
|
|
op: op,
|
|
ratingKey: ratingKey,
|
|
serverId: serverId,
|
|
libraryGlobalKey: libraryGlobalKey,
|
|
kind: kind,
|
|
ids: ids,
|
|
season: season,
|
|
number: number,
|
|
watchedAtIso: watchedAtIso,
|
|
);
|
|
|
|
await _trySendOrQueue(item, body);
|
|
}
|
|
|
|
Future<void> _trySendOrQueue(TraktSyncQueueItem item, TraktScrobbleRequest body) async {
|
|
final userUuid = _activeUserUuid;
|
|
final client = _client;
|
|
if (client == null) {
|
|
await _persistOrBuffer(userUuid, item);
|
|
return;
|
|
}
|
|
try {
|
|
await _dispatch(client, item, body);
|
|
appLogger.d('Trakt sync: ${item.op.name} ${item.ratingKey} → ok');
|
|
} catch (e) {
|
|
appLogger.d('Trakt sync: ${item.op.name} ${item.ratingKey} failed, queuing', error: e);
|
|
await _persistOrBuffer(userUuid, item);
|
|
}
|
|
}
|
|
|
|
/// Persist an item to the on-disk queue; fall back to a bounded in-memory
|
|
/// buffer if the disk write throws (e.g. disk full, SAF permission revoked).
|
|
/// Retried at the start of the next `flushQueue` run.
|
|
Future<void> _persistOrBuffer(String userUuid, TraktSyncQueueItem item) async {
|
|
try {
|
|
await _queue.add(userUuid, item);
|
|
} catch (e, st) {
|
|
appLogger.e(
|
|
'Trakt sync: queue persist failed for ${item.op.name} ${item.ratingKey}, buffering in memory',
|
|
error: e,
|
|
stackTrace: st,
|
|
);
|
|
final fallback = _inMemoryFallbackByUser.putIfAbsent(userUuid, Queue<TraktSyncQueueItem>.new);
|
|
if (fallback.length >= _maxInMemoryFallback) {
|
|
final dropped = fallback.removeFirst();
|
|
appLogger.w('Trakt sync: in-memory fallback full, dropping ${dropped.op.name} ${dropped.ratingKey}');
|
|
}
|
|
fallback.addLast(item);
|
|
}
|
|
}
|
|
|
|
Future<void> _dispatch(TraktClient client, TraktSyncQueueItem item, TraktScrobbleRequest body) {
|
|
return switch (item.op) {
|
|
TraktSyncOp.add => client.addToHistory(body, watchedAt: item.watchedAtIso),
|
|
TraktSyncOp.remove => client.removeFromHistory(body),
|
|
};
|
|
}
|
|
|
|
/// Drain the persisted queue. Called on init, on app foreground, and when
|
|
/// `OfflineModeProvider.isOffline` flips false.
|
|
Future<void> flushQueue() {
|
|
final active = _flushFuture;
|
|
if (active != null) {
|
|
_flushRequested = true;
|
|
return active;
|
|
}
|
|
if (_client == null) return Future<void>.value();
|
|
|
|
final future = _runFlushLoop();
|
|
_flushFuture = future;
|
|
return future;
|
|
}
|
|
|
|
Future<void> _runFlushLoop() async {
|
|
try {
|
|
do {
|
|
_flushRequested = false;
|
|
await _flushQueueOnce();
|
|
} while (_flushRequested && _client != null);
|
|
} finally {
|
|
_flushFuture = null;
|
|
if (_flushRequested && _client != null) {
|
|
scheduleMicrotask(() {
|
|
unawaited(
|
|
flushQueue().catchError((Object error, StackTrace stackTrace) {
|
|
appLogger.w('Trakt sync: requested follow-up flush failed', error: error, stackTrace: stackTrace);
|
|
}),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _flushQueueOnce() async {
|
|
final client = _client;
|
|
if (client == null) return;
|
|
final userUuid = _activeUserUuid;
|
|
await _recoverInMemoryFallback(userUuid);
|
|
|
|
await _queue.drainWith(userUuid, (item) async {
|
|
if (!_isLibraryAllowed(item.libraryGlobalKey)) {
|
|
appLogger.d('Trakt sync: queued library filtered out for ${item.ratingKey}');
|
|
return null;
|
|
}
|
|
if (item.attempts >= TraktSyncQueue.maxAttempts) {
|
|
appLogger.w('Trakt sync: dropping ${item.op.name} ${item.ratingKey} after ${item.attempts} attempts');
|
|
return null;
|
|
}
|
|
try {
|
|
await _dispatch(client, item, _bodyFor(item));
|
|
appLogger.d('Trakt sync: drained ${item.op.name} ${item.ratingKey}');
|
|
await Future<void>.delayed(_queueRequestSpacing);
|
|
return null;
|
|
} catch (e) {
|
|
appLogger.d('Trakt sync: drain failed for ${item.ratingKey}, will retry', error: e);
|
|
await Future<void>.delayed(_queueRequestSpacing);
|
|
return item.incrementAttempts();
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Try to move items buffered in memory (because prior disk writes failed)
|
|
/// back onto the persistent queue. Best-effort; items that still can't be
|
|
/// persisted stay in the buffer for the next flush.
|
|
Future<void> _recoverInMemoryFallback(String userUuid) async {
|
|
final fallback = _inMemoryFallbackByUser[userUuid];
|
|
if (fallback == null || fallback.isEmpty) return;
|
|
final snapshot = List<TraktSyncQueueItem>.from(fallback);
|
|
fallback.clear();
|
|
if (fallback.isEmpty) _inMemoryFallbackByUser.remove(userUuid);
|
|
for (final item in snapshot) {
|
|
await _persistOrBuffer(userUuid, item);
|
|
}
|
|
}
|
|
|
|
bool _isLibraryAllowed(String? libraryGlobalKey) {
|
|
return SettingsService.instanceOrNull?.isLibraryAllowedForTracker(TrackerService.trakt, libraryGlobalKey) ?? true;
|
|
}
|
|
|
|
TraktScrobbleRequest _bodyFor(TraktSyncQueueItem item) {
|
|
return switch (item.kind) {
|
|
TraktMediaKind.movie => TraktScrobbleRequest.movie(ids: item.ids),
|
|
TraktMediaKind.episode => TraktScrobbleRequest.episode(
|
|
showIds: item.ids,
|
|
season: item.season!,
|
|
number: item.number!,
|
|
),
|
|
};
|
|
}
|
|
}
|