232 lines
7.4 KiB
Dart
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!,
|
|
),
|
|
};
|
|
}
|
|
}
|