refactor(trackers): drive Trakt through the tracker coordinator
Trakt was the one service outside the tracker abstraction. TraktScrobbleService re-implemented the whole playback lifecycle beside TrackerCoordinator, and TraktSyncService pushed watched state from its own WatchStateNotifier subscription, so the player called two objects at every lifecycle point and one watch could be written twice. TraktTracker now implements RealtimeScrobbleTracker like Simkl; the duplicated player call sites collapse to one each, and Trakt shares the coordinator's ID resolver instead of re-fetching show ids every episode. Capabilities are split so a tracker declares what it is rather than being special-cased: ScrobblePolicy carries each service's own resend/seek rules, EpisodeHistoryTracker names the remote row a per-item history write targets, and SeriesProgressTracker covers one-counter-per-series services. Writes from all four trackers go through a shared TrackerWriteQueue, generalised from the Trakt-only queue, with the legacy Trakt payload migrated on load. Trakt becomes the fourth TrackersProvider slot and TraktAccountProvider is deleted, so one object owns the active session per profile. Two failure paths found while consolidating are fixed here too. The queue's retries only ran on profile bind, connect and app foreground, so a network blip mid-session left queued watches waiting for the next foreground. OfflineModeProvider now notifies on connectivity changes, not just offline-state or WiFi-flag changes, and main.dart flushes the queue when the network returns. The queue also counted every failure toward the five attempts that permanently drop an item, so a rate limit or a service having a bad hour could discard a pending watch - the loss the queue exists to prevent. Only an answer about the write itself now spends an attempt: 4xx counts, while rate limits, 5xx, recoverable token-refresh failures and requests that never arrived do not. A back-off answer also defers that service for the rest of the flush, so a queue holding many rows does not fire all of them at a service that just asked for quiet.
This commit is contained in:
@@ -14,12 +14,16 @@ import '../services/trackers/oauth_proxy_client.dart';
|
||||
import '../services/trackers/simkl/simkl_auth_service.dart';
|
||||
import '../services/trackers/simkl/simkl_client.dart';
|
||||
import '../services/trackers/simkl/simkl_tracker.dart';
|
||||
import '../services/trackers/trakt/trakt_auth_service.dart';
|
||||
import '../services/trackers/trakt/trakt_client.dart';
|
||||
import '../services/trackers/trakt/trakt_tracker.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_coordinator.dart';
|
||||
import '../services/trackers/tracker_session.dart';
|
||||
import '../services/trackers/tracker_username_enricher.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../mixins/disposable_change_notifier_mixin.dart';
|
||||
|
||||
typedef TrackerSessionConnectPipeline =
|
||||
@@ -31,9 +35,10 @@ typedef TrackerSessionConnectPipeline =
|
||||
required void Function(TrackerSession enriched) assign,
|
||||
});
|
||||
|
||||
/// Owns the active MAL / AniList / Simkl sessions for the currently-selected
|
||||
/// Plex profile. Single rebind seam: [onActiveProfileChanged] loads all three
|
||||
/// sessions from their stores and pushes them to their trackers.
|
||||
/// Owns the active MAL / AniList / Simkl / Trakt sessions for the
|
||||
/// currently-selected Plex profile. Single rebind seam:
|
||||
/// [onActiveProfileChanged] loads all four sessions from their stores and
|
||||
/// pushes them to their trackers.
|
||||
class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
|
||||
/// [httpClientFactory] must return a fresh client for each eager auth owner.
|
||||
/// Every returned client is closed when this provider is disposed.
|
||||
@@ -56,12 +61,14 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
_anilistAuth = httpClientFactory == null
|
||||
? AnilistAuthService()
|
||||
: AnilistAuthService(proxy: OAuthProxyClient(httpClient: httpClientFactory())),
|
||||
_simklAuth = httpClientFactory == null ? SimklAuthService() : SimklAuthService(httpClient: httpClientFactory());
|
||||
_simklAuth = httpClientFactory == null ? SimklAuthService() : SimklAuthService(httpClient: httpClientFactory()),
|
||||
_traktAuth = httpClientFactory == null ? TraktAuthService() : TraktAuthService(httpClient: httpClientFactory());
|
||||
|
||||
final TrackerSessionConnectPipeline _connectPipeline;
|
||||
final MalAuthService _malAuth;
|
||||
final AnilistAuthService _anilistAuth;
|
||||
final SimklAuthService _simklAuth;
|
||||
final TraktAuthService _traktAuth;
|
||||
|
||||
final _TrackerSlot _mal = _TrackerSlot(
|
||||
TrackerService.mal,
|
||||
@@ -78,7 +85,12 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
(session, {required onInvalidated, onUpdated}) =>
|
||||
SimklTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated),
|
||||
);
|
||||
late final List<_TrackerSlot> _slots = [_mal, _anilist, _simkl];
|
||||
final _TrackerSlot _trakt = _TrackerSlot(
|
||||
TrackerService.trakt,
|
||||
(session, {required onInvalidated, onUpdated}) =>
|
||||
TraktTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated, onSessionUpdated: onUpdated),
|
||||
);
|
||||
late final List<_TrackerSlot> _slots = [_mal, _anilist, _simkl, _trakt];
|
||||
|
||||
String _activeUserUuid = '';
|
||||
int _profileBindingGeneration = 0;
|
||||
@@ -89,10 +101,12 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
TrackerSession? get mal => _mal.session;
|
||||
TrackerSession? get anilist => _anilist.session;
|
||||
TrackerSession? get simkl => _simkl.session;
|
||||
TrackerSession? get trakt => _trakt.session;
|
||||
|
||||
bool get isMalConnected => _mal.session != null;
|
||||
bool get isAnilistConnected => _anilist.session != null;
|
||||
bool get isSimklConnected => _simkl.session != null;
|
||||
bool get isTraktConnected => _trakt.session != null;
|
||||
|
||||
/// The live MAL client for the Explore catalog, shared with the scrobble
|
||||
/// tracker so both ride one session (MAL rotates refresh tokens — a second
|
||||
@@ -102,6 +116,12 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
/// every rebind is followed by a notify, so proxy consumers track identity.
|
||||
MalClient? get malCatalogClient => _mal.session == null ? null : MalTracker.instance.client;
|
||||
|
||||
/// The live Trakt client for the Explore catalog, shared with the tracker so
|
||||
/// both ride one session (Trakt rotates refresh tokens — a second client
|
||||
/// would race refreshes and log the user out). Like [malCatalogClient], this
|
||||
/// is gated on the provider's profile-bound session.
|
||||
TraktClient? get traktCatalogClient => _trakt.session == null ? null : TraktTracker.instance.client;
|
||||
|
||||
/// Live AniList and Simkl clients for Explore. Like [malCatalogClient],
|
||||
/// these are gated on this provider's profile-bound sessions so a fresh
|
||||
/// profile subtree cannot observe clients still bound to the prior profile.
|
||||
@@ -111,6 +131,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
String? get malUsername => _mal.session?.username;
|
||||
String? get anilistUsername => _anilist.session?.username;
|
||||
String? get simklUsername => _simkl.session?.username;
|
||||
String? get traktUsername => _trakt.session?.username;
|
||||
|
||||
bool isConnecting(TrackerService service) => _connecting == service;
|
||||
|
||||
@@ -122,14 +143,26 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
|
||||
Future<void> onActiveProfileChanged(String? newUserUuid) async {
|
||||
_invalidateConnect();
|
||||
final userUuid = newUserUuid ?? '';
|
||||
// Drop any in-flight scrobble state and release the resolver (which
|
||||
// holds a PlexClient + session cache) before binding to the new profile.
|
||||
TrackerCoordinator.instance.cancelInFlight();
|
||||
TrackerCoordinator.instance.onActiveProfileChanged(userUuid);
|
||||
|
||||
final userUuid = newUserUuid ?? '';
|
||||
final generation = ++_profileBindingGeneration;
|
||||
_activeUserUuid = userUuid;
|
||||
// Snapshot each service's rebind generation before the await so a disconnect
|
||||
// Detach the previous profile's clients before loading anything. Until the new
|
||||
// sessions arrive, no tracker may hold a session: a write landing in that gap
|
||||
// would reach the account we just left while being filed under this profile's
|
||||
// retry queue.
|
||||
for (final slot in _slots) {
|
||||
slot.session = null;
|
||||
_rebind(slot);
|
||||
}
|
||||
// Publish the detach before awaiting: proxy consumers cache the client they
|
||||
// were handed, and would otherwise keep using a disposed one until hydration
|
||||
// finished.
|
||||
safeNotifyListeners();
|
||||
// Snapshot each service's rebind generation after that detach, so a disconnect
|
||||
// that races this load only suppresses its own service (whose generation
|
||||
// moves) rather than dropping the freshly-loaded sessions for the others.
|
||||
final rebinds = [for (final slot in _slots) slot.rebindGeneration];
|
||||
@@ -144,6 +177,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
// Connect/disconnect may flip `needsFribb` — drop cached resolver IDs so
|
||||
// the next lookup re-evaluates whether to consult Fribb.
|
||||
TrackerCoordinator.instance.invalidateResolverCache();
|
||||
unawaited(TrackerCoordinator.instance.flushWriteQueue());
|
||||
safeNotifyListeners();
|
||||
}
|
||||
|
||||
@@ -183,6 +217,34 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
|
||||
Future<void> disconnectSimkl() => _clearAndRebind(_simkl);
|
||||
|
||||
Future<bool> connectTrakt({required void Function(DeviceCode code) onCodeReady}) => _runConnect(
|
||||
_trakt,
|
||||
authorize: () => _traktAuth.authorize(
|
||||
onCodeReady: onCodeReady,
|
||||
shouldCancel: _isConnectCancelled,
|
||||
onCancel: _cancelCompleter!.future,
|
||||
),
|
||||
enrich: _enrichTrakt,
|
||||
);
|
||||
|
||||
/// Trakt is the one service that can revoke its token server-side. Local state
|
||||
/// is cleared first, so a failed revoke still leaves the user disconnected
|
||||
/// here — the token just stays valid on Trakt's side until it expires.
|
||||
Future<void> disconnectTrakt() async {
|
||||
final session = _trakt.session;
|
||||
await _clearAndRebind(_trakt);
|
||||
if (session == null) return;
|
||||
|
||||
final client = TraktClient(session, onSessionInvalidated: () {});
|
||||
try {
|
||||
await client.revoke();
|
||||
} catch (e) {
|
||||
appLogger.w('Trakt: token revoke failed (already disconnected locally)', error: e);
|
||||
} finally {
|
||||
client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
bool _isConnectCancelled() => _cancelCompleter?.isCompleted ?? false;
|
||||
|
||||
Future<bool> _runConnect(
|
||||
@@ -217,6 +279,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
slot.session = session;
|
||||
_rebind(slot);
|
||||
TrackerCoordinator.instance.invalidateResolverCache();
|
||||
unawaited(TrackerCoordinator.instance.flushWriteQueue());
|
||||
assigned = true;
|
||||
},
|
||||
);
|
||||
@@ -236,7 +299,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
// The rebind bumps the affected service's generation, which is what stops
|
||||
// an in-flight profile load from resurrecting the cleared session — so we
|
||||
// no longer touch the shared profile-binding generation (which would also
|
||||
// abort that load for the other two services).
|
||||
// abort that load for the other services).
|
||||
slot.session = null;
|
||||
_rebind(slot);
|
||||
safeNotifyListeners();
|
||||
@@ -282,6 +345,13 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
},
|
||||
);
|
||||
|
||||
Future<TrackerSession> _enrichTrakt(TrackerSession raw) => enrichTrackerSessionUsername(
|
||||
session: raw,
|
||||
failureMessage: 'Trakt: getUserSettings failed (non-fatal)',
|
||||
createClient: () => TraktClient(raw, onSessionInvalidated: () {}),
|
||||
fetchUsername: (client) async => (await client.getUserSettings()).username,
|
||||
);
|
||||
|
||||
/// Push a slot's session to its tracker, snapshotting the active profile and
|
||||
/// bumping the slot's rebind generation first. Bumping here is what lets a
|
||||
/// stale client callback — or a racing profile load — detect that it has been
|
||||
@@ -315,12 +385,13 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
_malAuth.dispose();
|
||||
_anilistAuth.dispose();
|
||||
_simklAuth.dispose();
|
||||
_traktAuth.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes a session to one service's tracker singleton. `onUpdated` is only
|
||||
/// wired for MAL, the one service that rotates its refresh token.
|
||||
/// Pushes a session to one service's tracker singleton. `onUpdated` is wired
|
||||
/// for MAL and Trakt, the services that rotate their refresh tokens.
|
||||
typedef _TrackerBind =
|
||||
void Function(
|
||||
TrackerSession? session, {
|
||||
@@ -339,9 +410,8 @@ class _TrackerSlot {
|
||||
TrackerSession? session;
|
||||
|
||||
/// Bumped on every rebind so a late callback from a disposed client (e.g. an
|
||||
/// in-flight MAL token refresh that resolves after a profile switch) can't
|
||||
/// persist or clear a session under the wrong profile, and so a disconnect
|
||||
/// racing an in-flight profile load only suppresses its own service. Mirrors
|
||||
/// TraktAccountProvider's binding-generation guard, but per service.
|
||||
/// in-flight refresh that resolves after a profile switch) can't persist or
|
||||
/// clear a session under the wrong profile, and so a disconnect racing an
|
||||
/// in-flight profile load only suppresses its own service.
|
||||
int rebindGeneration = 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user