Files
plezy/lib/services/trakt/trakt_sync_service.dart
T
2026-04-19 11:31:27 +02:00

232 lines
7.4 KiB
Dart

import 'dart:async';
import '../../models/trakt/trakt_ids.dart';
import '../../models/trakt/trakt_scrobble_request.dart';
import '../../utils/app_logger.dart';
import '../../utils/watch_state_notifier.dart';
import '../multi_server_manager.dart';
import '../settings_service.dart';
import 'trakt_client.dart';
import 'trakt_constants.dart';
import 'trakt_guid_resolver.dart';
import 'trakt_session.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. 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 Plex server, kept alive across events so the per-rating-
/// key GUID cache survives a binge-watch session.
final Map<String, TraktGuidResolver> _resolvers = {};
bool _isFlushing = false;
Future<void> initialize({required MultiServerManager serverManager}) async {
if (_isInitialized) return;
_isInitialized = true;
_serverManager = serverManager;
final settings = await SettingsService.getInstance();
_isEnabled = settings.getEnableTraktWatchedSync();
_subscription = WatchStateNotifier().stream.listen(_onWatchStateEvent);
}
Future<void> setEnabled(bool enabled) async {
_isEnabled = enabled;
}
/// Switch to a different account. Drops cached resolvers (their PlexClients
/// are tied to the previous user's tokens) and rebinds the queue.
void rebindToProfile(String userUuid, TraktSession? session, {required void Function() onSessionInvalidated}) {
_client?.dispose();
_client = session != null ? TraktClient(session, onSessionInvalidated: onSessionInvalidated) : null;
_activeUserUuid = userUuid;
_resolvers.clear();
if (_client != null) {
unawaited(flushQueue());
}
}
Future<void> dispose() async {
await _subscription?.cancel();
_subscription = null;
_client?.dispose();
_client = null;
_resolvers.clear();
}
bool get _canPush => _isEnabled && _client != null;
TraktGuidResolver? _resolverFor(String serverId) {
final cached = _resolvers[serverId];
if (cached != null) return cached;
final plexClient = _serverManager?.getClient(serverId);
if (plexClient == null) return null;
final resolver = TraktGuidResolver(plexClient);
_resolvers[serverId] = resolver;
return resolver;
}
Future<void> _onWatchStateEvent(WatchStateEvent event) async {
if (!_canPush) return;
if (event.changeType == WatchStateChangeType.progressUpdate) return;
final kind = TraktMediaKind.tryFromPlexType(event.mediaType);
if (kind == null) return;
final op = event.changeType == WatchStateChangeType.watched ? TraktSyncOp.add : TraktSyncOp.remove;
await _push(
op: op,
ratingKey: event.ratingKey,
serverId: event.serverId,
kind: kind,
watchedAtIso: DateTime.now().toUtc().toIso8601String(),
);
}
Future<void> _push({
required TraktSyncOp op,
required String ratingKey,
required String serverId,
required TraktMediaKind kind,
required String watchedAtIso,
}) async {
final resolver = _resolverFor(serverId);
if (resolver == null) {
appLogger.d('Trakt sync: no PlexClient for server $serverId, skipping');
return;
}
TraktIds? ids;
int? season;
int? number;
if (kind == TraktMediaKind.movie) {
ids = await resolver.resolveForMovie(ratingKey);
} else {
// Episode — need show IDs + season/episode index. The WatchStateEvent
// doesn't carry the index, so fetch episode metadata.
final plexClient = _serverManager?.getClient(serverId);
if (plexClient == null) return;
final episodeMeta = await plexClient.getMetadataWithImages(ratingKey);
if (episodeMeta == null) return;
season = episodeMeta.parentIndex;
number = episodeMeta.index;
if (season == null || number == null) return;
ids = await resolver.resolveShowForEpisode(episodeMeta);
}
if (ids == null || !ids.hasAny) {
appLogger.d('Trakt sync: no IDs for ${kind.name} $ratingKey, dropping');
return;
}
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,
kind: kind,
ids: ids,
season: season,
number: number,
watchedAtIso: watchedAtIso,
);
await _trySendOrQueue(item, body);
}
Future<void> _trySendOrQueue(TraktSyncQueueItem item, TraktScrobbleRequest body) async {
final client = _client;
if (client == null) {
await _queue.add(_activeUserUuid, 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 _queue.add(_activeUserUuid, 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() async {
if (_isFlushing) return;
final client = _client;
if (client == null) return;
_isFlushing = true;
try {
final items = await _queue.load(_activeUserUuid);
if (items.isEmpty) return;
final remaining = <TraktSyncQueueItem>[];
for (final item in items) {
if (item.attempts >= TraktSyncQueue.maxAttempts) {
appLogger.w('Trakt sync: dropping ${item.op.name} ${item.ratingKey} after ${item.attempts} attempts');
continue;
}
try {
await _dispatch(client, item, _bodyFor(item));
appLogger.d('Trakt sync: drained ${item.op.name} ${item.ratingKey}');
} catch (e) {
appLogger.d('Trakt sync: drain failed for ${item.ratingKey}, will retry', error: e);
remaining.add(item.incrementAttempts());
}
await Future<void>.delayed(_queueRequestSpacing);
}
await _queue.save(_activeUserUuid, remaining);
} finally {
_isFlushing = false;
}
}
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!,
),
};
}
}