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:
edde746
2026-07-30 14:51:32 +02:00
parent 5a25c1f9cc
commit 4c8272d5b1
52 changed files with 3786 additions and 1805 deletions
+85 -15
View File
@@ -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;
}