193 lines
7.0 KiB
Dart
193 lines
7.0 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import '../mixins/disposable_change_notifier_mixin.dart';
|
|
import '../models/trackers/device_code.dart';
|
|
import '../services/trackers/tracker_account_store.dart';
|
|
import '../services/trackers/tracker_connect_runner.dart';
|
|
import '../services/trackers/tracker_constants.dart';
|
|
import '../services/trackers/tracker_session.dart';
|
|
import '../services/trackers/tracker_username_enricher.dart';
|
|
import '../services/trakt/trakt_auth_service.dart';
|
|
import '../services/trakt/trakt_client.dart';
|
|
import '../services/trakt/trakt_scrobble_service.dart';
|
|
import '../services/trakt/trakt_sync_service.dart';
|
|
|
|
/// Owns the active Trakt session for the currently-selected Plex profile.
|
|
///
|
|
/// Single rebind seam: `onActiveProfileChanged` loads the new profile's
|
|
/// session and pushes it to both `TraktScrobbleService` and `TraktSyncService`.
|
|
class TraktAccountProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
|
|
/// Each client returned by [httpClientFactory] is owned by this provider and
|
|
/// closed when the provider is disposed.
|
|
TraktAccountProvider({http.Client Function()? httpClientFactory})
|
|
: _auth = httpClientFactory == null ? TraktAuthService() : TraktAuthService(httpClient: httpClientFactory());
|
|
|
|
final TraktAuthService _auth;
|
|
final TrackerAccountStore _store = trackerAccountStore(TrackerService.trakt);
|
|
|
|
TrackerSession? _session;
|
|
String _activeUserUuid = '';
|
|
int _bindingGeneration = 0;
|
|
bool _isConnecting = false;
|
|
Completer<void>? _cancelCompleter;
|
|
TraktClient? _catalogClient;
|
|
|
|
TrackerSession? get session => _session;
|
|
bool get isConnected => _session != null;
|
|
String? get username => _session?.username;
|
|
bool get isConnecting => _isConnecting;
|
|
|
|
/// Client for the catalog/watchlist surfaces (Explore tab). Owned and
|
|
/// rebound here alongside the scrobble/sync services; null when
|
|
/// disconnected.
|
|
TraktClient? get catalogClient => _catalogClient;
|
|
|
|
/// Cancel an in-flight `connect()` (e.g. user dismissed the device-code
|
|
/// dialog). Completing the completer both wakes the blocking `Future.any`
|
|
/// race and flips `isCompleted` for the next sync check.
|
|
void cancelConnect() {
|
|
final c = _cancelCompleter;
|
|
if (c != null && !c.isCompleted) c.complete();
|
|
}
|
|
|
|
/// Called whenever the active Plex profile changes (or on initial load).
|
|
Future<void> onActiveProfileChanged(String? newUserUuid) async {
|
|
if (isDisposed) return;
|
|
final userUuid = newUserUuid ?? '';
|
|
final generation = ++_bindingGeneration;
|
|
_activeUserUuid = userUuid;
|
|
final loaded = await _store.load(userUuid);
|
|
_setSessionAndRebind(userUuid, generation, loaded);
|
|
}
|
|
|
|
/// Run the device-code OAuth flow.
|
|
///
|
|
/// [onCodeReady] is invoked once with the user code + verification URL so
|
|
/// the UI can render the dialog.
|
|
Future<bool> connect({required void Function(DeviceCode code) onCodeReady}) async {
|
|
if (_isConnecting || isConnected) return false;
|
|
_isConnecting = true;
|
|
_cancelCompleter = Completer<void>();
|
|
notifyListeners();
|
|
try {
|
|
return await runConnectPipeline<TrackerSession>(
|
|
logLabel: 'Trakt',
|
|
authorize: () => _auth.authorize(
|
|
onCodeReady: onCodeReady,
|
|
shouldCancel: () => _cancelCompleter?.isCompleted ?? false,
|
|
onCancel: _cancelCompleter!.future,
|
|
),
|
|
enrich: _enrichUsername,
|
|
save: (s) => _store.save(_activeUserUuid, s),
|
|
assign: _bindCurrentSession,
|
|
);
|
|
} finally {
|
|
final c = _cancelCompleter;
|
|
if (c != null && !c.isCompleted) c.complete();
|
|
_cancelCompleter = null;
|
|
_isConnecting = false;
|
|
safeNotifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<TrackerSession> _enrichUsername(TrackerSession raw) => enrichTrackerSessionUsername(
|
|
session: raw,
|
|
failureMessage: 'Trakt: getUserSettings failed (non-fatal)',
|
|
createClient: () => TraktClient(raw, onSessionInvalidated: () {}),
|
|
fetchUsername: (client) async => (await client.getUserSettings()).username,
|
|
);
|
|
|
|
/// Revoke the access token and clear local state.
|
|
Future<void> disconnect() async {
|
|
final userUuid = _activeUserUuid;
|
|
final generation = ++_bindingGeneration;
|
|
final session = _session;
|
|
_setSessionAndRebind(userUuid, generation, null);
|
|
if (session != null) {
|
|
final client = TraktClient(session, onSessionInvalidated: () {});
|
|
try {
|
|
await client.revoke();
|
|
} finally {
|
|
client.dispose();
|
|
}
|
|
}
|
|
await _store.clear(userUuid);
|
|
}
|
|
|
|
void _bindCurrentSession(TrackerSession? session) {
|
|
_setSessionAndRebind(_activeUserUuid, ++_bindingGeneration, session);
|
|
}
|
|
|
|
void _setSessionAndRebind(String userUuid, int generation, TrackerSession? session) {
|
|
if (!_isCurrentBinding(userUuid, generation)) return;
|
|
_session = session;
|
|
|
|
void handleInvalidated() => _handleSessionInvalidated(userUuid, generation);
|
|
void handleUpdated(TrackerSession next) => _handleSessionUpdated(userUuid, generation, next);
|
|
|
|
TraktScrobbleService.instance.rebindToProfile(
|
|
session,
|
|
onSessionInvalidated: handleInvalidated,
|
|
onSessionUpdated: handleUpdated,
|
|
);
|
|
TraktSyncService.instance.rebindToProfile(
|
|
userUuid,
|
|
session,
|
|
onSessionInvalidated: handleInvalidated,
|
|
onSessionUpdated: handleUpdated,
|
|
);
|
|
_catalogClient?.dispose();
|
|
_catalogClient = session == null
|
|
? null
|
|
: TraktClient(session, onSessionInvalidated: handleInvalidated, onSessionUpdated: handleUpdated);
|
|
safeNotifyListeners();
|
|
}
|
|
|
|
bool _isCurrentBinding(String userUuid, int generation) {
|
|
return !isDisposed && userUuid == _activeUserUuid && generation == _bindingGeneration;
|
|
}
|
|
|
|
void _handleSessionUpdated(String userUuid, int generation, TrackerSession session) {
|
|
if (!_isCurrentBinding(userUuid, generation)) return;
|
|
_session = session;
|
|
TraktScrobbleService.instance.updateSession(session);
|
|
TraktSyncService.instance.updateSession(session);
|
|
_catalogClient?.updateSession(session);
|
|
unawaited(_store.save(userUuid, session));
|
|
safeNotifyListeners();
|
|
}
|
|
|
|
/// Called by [TraktClient] when refresh fails permanently. Clears local state
|
|
/// so the UI shows "not connected" and the user can re-link.
|
|
void _handleSessionInvalidated(String userUuid, int generation) {
|
|
if (!_isCurrentBinding(userUuid, generation)) return;
|
|
final nextGeneration = ++_bindingGeneration;
|
|
unawaited(_store.clear(userUuid));
|
|
_setSessionAndRebind(userUuid, nextGeneration, null);
|
|
}
|
|
|
|
@visibleForTesting
|
|
int get debugBindingGenerationForTesting => _bindingGeneration;
|
|
|
|
@visibleForTesting
|
|
void debugHandleSessionUpdatedForTesting(String userUuid, int generation, TrackerSession session) {
|
|
_handleSessionUpdated(userUuid, generation, session);
|
|
}
|
|
|
|
@visibleForTesting
|
|
void debugHandleSessionInvalidatedForTesting(String userUuid, int generation) {
|
|
_handleSessionInvalidated(userUuid, generation);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_auth.dispose();
|
|
_catalogClient?.dispose();
|
|
_catalogClient = null;
|
|
super.dispose();
|
|
}
|
|
}
|