Files
plezy/lib/providers/seerr_account_provider.dart
T
edde746 4c8272d5b1 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.
2026-07-30 14:51:32 +02:00

166 lines
6.3 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import '../connection/connection_registry.dart';
import '../mixins/disposable_change_notifier_mixin.dart';
import '../models/seerr/seerr_session.dart';
import '../profiles/active_plex_identity.dart';
import '../profiles/active_profile_provider.dart';
import '../profiles/profile_connection_registry.dart';
import '../services/seerr/seerr_auth_service.dart';
import '../services/seerr/seerr_client.dart';
import '../services/seerr/seerr_session_store.dart';
import '../utils/app_logger.dart';
/// Resolve the active profile's Plex token for Seerr sign-in/re-auth:
/// the profile's per-user token when a bind exists (a Home user's Seerr
/// account maps to their own plex.tv user), else the account token.
SeerrPlexTokenSupplier buildSeerrPlexTokenSupplier({
required ActiveProfileProvider activeProfile,
required ConnectionRegistry connections,
required ProfileConnectionRegistry profileConnections,
}) {
return () async {
final identity = await resolveActivePlexIdentity(
activeProfile: activeProfile,
connections: connections,
profileConnections: profileConnections,
);
if (identity == null) return null;
final profile = activeProfile.active;
if (profile != null) {
final pc = await profileConnections.get(profile.id, identity.account.id);
if (pc?.hasToken ?? false) return pc!.userToken;
}
return identity.account.accountToken;
};
}
/// Owns the active Seerr session for the currently-selected profile,
/// mirroring [TrackersProvider]'s rebind shape: `onActiveProfileChanged` loads
/// the profile's stored session and rebuilds the catalog client.
///
/// Unlike the OAuth trackers there is no in-provider connect flow — the
/// connect screen drives [SeerrAuthService] itself and hands the finished
/// session to [adoptSession].
class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
SeerrAccountProvider({SeerrSessionStore? store, SeerrAuthService? authService})
: _store = store ?? const SeerrSessionStore(),
authService = authService ?? SeerrAuthService();
final SeerrSessionStore _store;
final SeerrAuthService authService;
SeerrPlexTokenSupplier? _plexTokenSupplier;
/// Store writes go through one queue: save() awaits an AES-GCM protect
/// step, so two rapid unawaited writes could otherwise persist
/// last-started-first (and a clear could lose to a still-pending save).
Future<void> _pendingPersistence = Future<void>.value();
Future<void> _enqueuePersistence(Future<void> Function() op) {
final run = _pendingPersistence.then((_) => op());
_pendingPersistence = run.then<void>(
(_) {},
onError: (Object e) => appLogger.w('Seerr: session persistence failed', error: e),
);
return run;
}
SeerrSession? _session;
String _activeUserUuid = '';
int _bindingGeneration = 0;
SeerrClient? _catalogClient;
SeerrSession? get session => _session;
bool get isConnected => _session != null;
String? get displayName => _session?.displayName;
/// Client for the catalog/request surfaces; null when disconnected.
SeerrClient? get catalogClient => _catalogClient;
/// Wired once from the provider tree (the registries live above the
/// profile session subtree).
void bindPlexTokenSupplier(SeerrPlexTokenSupplier supplier) => _plexTokenSupplier = supplier;
/// The connect screen's "Sign in with Plex" needs the same token the
/// silent re-auth path would use. Null on Jellyfin-only setups.
Future<String?> resolvePlexToken() async {
try {
return await _plexTokenSupplier?.call();
} catch (e) {
appLogger.w('Seerr: Plex token resolution failed', error: e);
return null;
}
}
/// Called whenever the active 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);
}
/// Persist and bind a session the connect screen established.
Future<void> adoptSession(SeerrSession session) async {
final userUuid = _activeUserUuid;
await _enqueuePersistence(() => _store.save(userUuid, session));
_setSessionAndRebind(userUuid, ++_bindingGeneration, session);
}
/// Sign out server-side (best effort) and clear local state.
Future<void> disconnect() async {
final userUuid = _activeUserUuid;
final session = _session;
_setSessionAndRebind(userUuid, ++_bindingGeneration, null);
await _enqueuePersistence(() => _store.clear(userUuid));
if (session != null) await authService.signOut(session);
}
void _setSessionAndRebind(String userUuid, int generation, SeerrSession? session) {
if (!_isCurrentBinding(userUuid, generation)) return;
_session = session;
_catalogClient?.dispose();
_catalogClient = session == null
? null
: SeerrClient(
session,
onSessionInvalidated: () => _handleSessionInvalidated(userUuid, generation),
onSessionUpdated: (next) => _handleSessionUpdated(userUuid, generation, next),
plexTokenSupplier: () async => _plexTokenSupplier?.call(),
authService: authService,
);
safeNotifyListeners();
}
bool _isCurrentBinding(String userUuid, int generation) {
return !isDisposed && userUuid == _activeUserUuid && generation == _bindingGeneration;
}
void _handleSessionUpdated(String userUuid, int generation, SeerrSession session) {
if (!_isCurrentBinding(userUuid, generation)) return;
_session = session;
unawaited(_enqueuePersistence(() => _store.save(userUuid, session)));
safeNotifyListeners();
}
/// Called by [SeerrClient] when silent re-auth fails permanently: clear
/// 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(_enqueuePersistence(() => _store.clear(userUuid)));
_setSessionAndRebind(userUuid, nextGeneration, null);
}
@override
void dispose() {
_catalogClient?.dispose();
_catalogClient = null;
super.dispose();
}
}