diff --git a/lib/main.dart b/lib/main.dart index 9ab27e9e..0bcf31b5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -44,8 +44,6 @@ import 'services/discord_rpc_service.dart'; import 'package:path_provider/path_provider.dart'; import 'services/image_cache_service.dart'; import 'services/gamepad_service.dart'; -import 'services/trakt/trakt_scrobble_service.dart'; -import 'services/trakt/trakt_sync_service.dart'; import 'services/trackers/tracker_coordinator.dart'; import 'providers/user_profile_provider.dart'; import 'providers/multi_server_provider.dart'; @@ -497,7 +495,6 @@ void _startNonessentialInitialization(SettingsService settings) { unawaited(AndroidExitDiagnostics.logPreviousExit()); } - bestEffort('Trakt scrobble', TraktScrobbleService.instance.initialize); bestEffort('Shader licenses', _registerShaderLicenses); // The startup-gate application can precede the engine's first metrics // report, which reads as a 1.0 display budget; re-derive it now that the @@ -726,6 +723,7 @@ class _MainAppState extends State with WidgetsBindingObserver { final Set _pendingSyncKeys = {}; bool _isAutoDeleteRunning = false; bool _lastConnectivityWasWifi = false; + bool _lastConnectivityHadNetwork = true; bool _shutdownStarted = false; /// Last time server health probes ran from a resume event (cooldown for desktop) @@ -762,9 +760,6 @@ class _MainAppState extends State with WidgetsBindingObserver { _offlineWatchSyncService = OfflineWatchSyncService(database: _appDatabase, serverManager: _serverManager); - // Trakt sync service (subscribes to WatchStateNotifier, requires serverManager - // to resolve PlexClients for GUID lookups). - TraktSyncService.instance.initialize(serverManager: _serverManager); // Tracker singletons init once per app; per-profile hydration happens in // the profile-scoped provider subtree's create callbacks. unawaited(TrackerCoordinator.instance.initialize()); @@ -790,13 +785,8 @@ class _MainAppState extends State with WidgetsBindingObserver { // Quitting straight from the player is a real stop: the trackers that own // their own watched semantics need the terminal report before the process // goes away. Bounded — a hung tracker must not hold the app open. - await Future.wait([ - TrackerCoordinator.instance.stopPlayback(), - TraktScrobbleService.instance.stopPlayback(), - ]).timeout(const Duration(seconds: 3), onTimeout: () => const []); + await TrackerCoordinator.instance.stopPlayback().timeout(const Duration(seconds: 3), onTimeout: () {}); TrackerCoordinator.instance.cancelInFlight(); - TraktScrobbleService.instance.cancelInFlight(); - await TraktSyncService.instance.dispose(); await _serverManager.disconnectAllGracefully(); await Future.wait([ @@ -876,20 +866,34 @@ class _MainAppState extends State with WidgetsBindingObserver { PaintingBinding.instance.imageCache.clearLiveImages(); } - /// Fires [_autoDeleteAndSync] on each WiFi/Ethernet reconnect so rules run - /// as soon as the device is back online. Listens on [OfflineModeProvider], - /// which owns the app's single connectivity subscription and notifies on - /// connection-type changes. Rapid flapping is bounded by the executor's - /// cooldown. + /// Two connectivity-driven triggers, both listening on [OfflineModeProvider], + /// which owns the app's single connectivity subscription: + /// + /// * [_autoDeleteAndSync] on each WiFi/Ethernet reconnect, so download rules + /// run as soon as the device is back on an unmetered link. Rapid flapping is + /// bounded by the executor's cooldown. + /// * A tracker write-queue flush whenever the network comes back at all, + /// metered included: tracker history writes are internet calls, so they can + /// land while media servers are still unreachable and the app stays offline. void _startConnectivitySyncTrigger(DownloadProvider downloadProvider, OfflineModeProvider offlineModeProvider) { _removeConnectivitySyncListener(); _lastConnectivityWasWifi = offlineModeProvider.hasWifiOrEthernet; + _lastConnectivityHadNetwork = offlineModeProvider.hasNetworkConnection; _connectivitySyncProvider = offlineModeProvider; _connectivitySyncListener = () { final hasWifi = offlineModeProvider.hasWifiOrEthernet; - final transitioned = hasWifi && !_lastConnectivityWasWifi; + final movedOntoWifi = hasWifi && !_lastConnectivityWasWifi; _lastConnectivityWasWifi = hasWifi; - if (transitioned) { + + final hasNetwork = offlineModeProvider.hasNetworkConnection; + final networkRestored = hasNetwork && !_lastConnectivityHadNetwork; + _lastConnectivityHadNetwork = hasNetwork; + + if (networkRestored) { + appLogger.d('Network restored — replaying queued tracker writes'); + unawaited(TrackerCoordinator.instance.flushWriteQueue()); + } + if (movedOntoWifi) { appLogger.d('Connectivity moved onto WiFi/Ethernet — triggering sync pass'); _autoDeleteAndSync(downloadProvider); } @@ -966,7 +970,7 @@ class _MainAppState extends State with WidgetsBindingObserver { case AppLifecycleState.resumed: // App came back to foreground - trigger sync check _offlineWatchSyncService.onAppResumed(); - TraktSyncService.instance.flushQueue(); + unawaited(TrackerCoordinator.instance.flushWriteQueue()); // Re-probe servers — mobile OS may have dropped TCP connections during doze/sleep. // On desktop, resumed fires on every window focus (alt-tab), so apply a cooldown // to avoid piling up network probes from rapid alt-tabbing. diff --git a/lib/models/trackers/anime_ids.dart b/lib/models/trackers/anime_ids.dart index ad686f5e..f38ecd99 100644 --- a/lib/models/trackers/anime_ids.dart +++ b/lib/models/trackers/anime_ids.dart @@ -14,4 +14,17 @@ class AnimeIds { factory AnimeIds.fromFribb(FribbMappingRow row) => AnimeIds(mal: row.malId, anilist: row.anilistId, simkl: row.simklId); + + /// Round-trips through the persisted tracker write queue. + Map toJson() => { + if (mal != null) 'mal': mal, + if (anilist != null) 'anilist': anilist, + if (simkl != null) 'simkl': simkl, + }; + + factory AnimeIds.fromJson(Map json) => AnimeIds( + mal: (json['mal'] as num?)?.toInt(), + anilist: (json['anilist'] as num?)?.toInt(), + simkl: (json['simkl'] as num?)?.toInt(), + ); } diff --git a/lib/models/trackers/tracker_context.dart b/lib/models/trackers/tracker_context.dart index 3470d50d..28e5ff7f 100644 --- a/lib/models/trackers/tracker_context.dart +++ b/lib/models/trackers/tracker_context.dart @@ -71,4 +71,33 @@ class TrackerContext { animeProgress: animeProgress, ); } + + /// Serialized into the persisted tracker write queue so a failed watched + /// write replays against exactly the item it was built for — no second + /// metadata fetch, no re-resolution against a library that may have changed. + Map toJson() => { + 'external': external.toJson(), + if (anime != null) 'anime': anime!.toJson(), + 'isMovie': isMovie, + 'ratingKey': ratingKey, + if (libraryGlobalKey != null) 'libraryGlobalKey': libraryGlobalKey, + if (season != null) 'season': season, + if (episodeNumber != null) 'episodeNumber': episodeNumber, + if (animeProgress != null) 'animeProgress': animeProgress, + }; + + /// Throws on a malformed row; the queue archives and discards the batch. + factory TrackerContext.fromJson(Map json) { + final anime = json['anime']; + return TrackerContext._( + external: ExternalIds.fromJson((json['external'] as Map).cast()), + anime: anime == null ? null : AnimeIds.fromJson((anime as Map).cast()), + isMovie: json['isMovie'] as bool, + ratingKey: json['ratingKey'] as String, + libraryGlobalKey: json['libraryGlobalKey'] as String?, + season: (json['season'] as num?)?.toInt(), + episodeNumber: (json['episodeNumber'] as num?)?.toInt(), + animeProgress: (json['animeProgress'] as num?)?.toInt(), + ); + } } diff --git a/lib/navigation/profile_session_screen.dart b/lib/navigation/profile_session_screen.dart index 3a659e3c..2a1a493c 100644 --- a/lib/navigation/profile_session_screen.dart +++ b/lib/navigation/profile_session_screen.dart @@ -19,7 +19,6 @@ import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/playback_state_provider.dart'; -import '../providers/trakt_account_provider.dart'; import '../providers/seerr_account_provider.dart'; import '../providers/trackers_provider.dart'; import '../providers/watch_state_store.dart'; @@ -148,17 +147,6 @@ class _ProfileSessionScreenState extends State { return provider; }, ), - ChangeNotifierProvider( - create: (context) { - final provider = TraktAccountProvider(httpClientFactory: widget.trackerHttpClientFactory); - unawaited( - provider.onActiveProfileChanged(activeId).catchError((Object e, StackTrace s) { - appLogger.w('Trakt profile hydrate failed', error: e, stackTrace: s); - }), - ); - return provider; - }, - ), ChangeNotifierProvider( create: (context) { final provider = TrackersProvider(httpClientFactory: widget.trackerHttpClientFactory); @@ -188,8 +176,7 @@ class _ProfileSessionScreenState extends State { return provider; }, ), - ChangeNotifierProxyProvider4< - TraktAccountProvider, + ChangeNotifierProxyProvider3< TrackersProvider, SeerrAccountProvider, ActiveProfileProvider, @@ -204,9 +191,9 @@ class _ProfileSessionScreenState extends State { ); return provider; }, - update: (context, trakt, trackers, seerr, activeProfile, previous) { + update: (context, trackers, seerr, activeProfile, previous) { final provider = previous ?? _createCatalogSourcesProvider(context); - provider.update(trakt, trackers, seerr); + provider.update(trackers, seerr); unawaited(provider.onProfileBindingStateChanged(activeProfile.isBinding)); return provider; }, diff --git a/lib/providers/catalog_sources_provider.dart b/lib/providers/catalog_sources_provider.dart index a3dca70b..1f67d9a8 100644 --- a/lib/providers/catalog_sources_provider.dart +++ b/lib/providers/catalog_sources_provider.dart @@ -23,9 +23,8 @@ import '../services/seerr/seerr_client.dart'; import '../services/trackers/anilist/anilist_client.dart'; import '../services/trackers/mal/mal_client.dart'; import '../services/trackers/simkl/simkl_client.dart'; -import '../services/trakt/trakt_client.dart'; +import '../services/trackers/trakt/trakt_client.dart'; import 'seerr_account_provider.dart'; -import 'trakt_account_provider.dart'; import 'trackers_provider.dart'; import '../utils/platform_detector.dart'; import '../utils/app_logger.dart'; @@ -219,9 +218,9 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie /// Proxy-provider update hook: rebuild a source when its catalog client /// was rebound (connect/disconnect/profile switch). - void update(TraktAccountProvider trakt, TrackersProvider trackers, SeerrAccountProvider seerr) { + void update(TrackersProvider trackers, SeerrAccountProvider seerr) { var changed = false; - changed = _trakt.update(trakt.catalogClient) || changed; + changed = _trakt.update(trackers.traktCatalogClient) || changed; changed = _mal.update(trackers.malCatalogClient) || changed; changed = _anilist.update(trackers.anilistCatalogClient) || changed; changed = _simkl.update(trackers.simklCatalogClient) || changed; diff --git a/lib/providers/offline_mode_provider.dart b/lib/providers/offline_mode_provider.dart index 81b3c52a..a0416fbb 100644 --- a/lib/providers/offline_mode_provider.dart +++ b/lib/providers/offline_mode_provider.dart @@ -74,8 +74,13 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi return OfflineModeReason.online; } - /// Whether there is network connectivity (WiFi, mobile data, etc.) - @visibleForTesting + /// Whether there is network connectivity at all (WiFi, Ethernet, cellular…). + /// + /// Public alongside [hasWifiOrEthernet] because this provider owns the app's + /// single connectivity subscription: consumers that care about reaching the + /// internet rather than a media server — the tracker write-queue retry, for + /// one — read it here instead of subscribing themselves. Changes to it notify, + /// even when the composite [isOffline] does not move. bool get hasNetworkConnection => _hasNetworkConnection; /// Whether at least one media server (Plex or Jellyfin) is reachable @@ -141,6 +146,30 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi safeNotifyListeners(); } + /// Apply a connectivity snapshot and notify when anything observable moved. + /// + /// All three observable answers count, not just [isOffline]: regaining + /// cellular while every media server stays unreachable leaves [isOffline] true + /// through `noServerConnection` and [hasWifiOrEthernet] false, yet + /// [hasNetworkConnection] has flipped — and consumers that only need the + /// internet (tracker history writes) can act on exactly that. + @visibleForTesting + void applyConnectivityResults(List results) { + final hadNetwork = _hasNetworkConnection; + _lastConnectivityResults = results; + _hasNetworkConnection = !results.contains(ConnectivityResult.none); + + final wifiNow = hasWifiOrEthernet; + final offline = isOffline; + final changed = + _hasNetworkConnection != hadNetwork || wifiNow != _lastWifiOrEthernetState || offline != _lastOfflineState; + if (!changed) return; + + _lastWifiOrEthernetState = wifiNow; + _lastOfflineState = offline; + safeNotifyListeners(); + } + /// Initialize the provider and start monitoring Future initialize() async { if (_isInitialized) return; @@ -154,20 +183,7 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi runZonedGuarded( () { _connectivitySubscription = Connectivity().onConnectivityChanged.listen( - (results) { - _lastConnectivityResults = results; - _hasNetworkConnection = !results.contains(ConnectivityResult.none); - // Notify on connection-type changes too (WiFi <-> cellular), not - // just offline flips — type consumers listen through this provider. - final wifiNow = hasWifiOrEthernet; - if (wifiNow != _lastWifiOrEthernetState) { - _lastWifiOrEthernetState = wifiNow; - _lastOfflineState = isOffline; - safeNotifyListeners(); - } else { - _notifyIfOfflineChanged(); - } - }, + applyConnectivityResults, onError: (e) { _hasNetworkConnection = true; }, diff --git a/lib/providers/seerr_account_provider.dart b/lib/providers/seerr_account_provider.dart index 5781deb9..f8de8774 100644 --- a/lib/providers/seerr_account_provider.dart +++ b/lib/providers/seerr_account_provider.dart @@ -38,8 +38,8 @@ SeerrPlexTokenSupplier buildSeerrPlexTokenSupplier({ } /// Owns the active Seerr session for the currently-selected profile, -/// mirroring [TraktAccountProvider]'s rebind shape: `onActiveProfileChanged` -/// loads the profile's stored session and rebuilds the catalog client. +/// 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 diff --git a/lib/providers/trackers_provider.dart b/lib/providers/trackers_provider.dart index d81dad1a..cefaef4f 100644 --- a/lib/providers/trackers_provider.dart +++ b/lib/providers/trackers_provider.dart @@ -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 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 disconnectSimkl() => _clearAndRebind(_simkl); + Future 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 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 _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 _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; } diff --git a/lib/providers/trakt_account_provider.dart b/lib/providers/trakt_account_provider.dart deleted file mode 100644 index 6ab25e81..00000000 --- a/lib/providers/trakt_account_provider.dart +++ /dev/null @@ -1,192 +0,0 @@ -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? _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 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 connect({required void Function(DeviceCode code) onCodeReady}) async { - if (_isConnecting || isConnected) return false; - _isConnecting = true; - _cancelCompleter = Completer(); - notifyListeners(); - try { - return await runConnectPipeline( - 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 _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 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(); - } -} diff --git a/lib/screens/settings/tracker_connect_launcher.dart b/lib/screens/settings/tracker_connect_launcher.dart index 2dfb9b05..1757af34 100644 --- a/lib/screens/settings/tracker_connect_launcher.dart +++ b/lib/screens/settings/tracker_connect_launcher.dart @@ -15,8 +15,7 @@ import '../../utils/snackbar_helper.dart'; /// once `connect` hands us a payload, auto-launches the browser on pointer /// platforms, closes the dialog when the flow resolves, and surfaces a failure /// snack. Service-specific pieces are supplied via [connect], [buildDialog], -/// and [urlFor] so both `TrackersProvider`-backed and `TraktAccountProvider`- -/// backed flows share one code path. +/// and [urlFor] so every `TrackersProvider`-backed flow shares one code path. Future launchTrackerConnect( BuildContext context, { required bool isBusyOrConnected, diff --git a/lib/screens/settings/tracker_service_info.dart b/lib/screens/settings/tracker_service_info.dart index 63c81222..250a0e57 100644 --- a/lib/screens/settings/tracker_service_info.dart +++ b/lib/screens/settings/tracker_service_info.dart @@ -4,23 +4,21 @@ import 'package:provider/provider.dart'; import '../../i18n/strings.g.dart'; import '../../models/catalog/catalog_item.dart'; import '../../providers/trackers_provider.dart'; -import '../../providers/trakt_account_provider.dart'; import '../../services/trackers/anilist/anilist_tracker.dart'; import '../../services/trackers/mal/mal_tracker.dart'; import '../../services/trackers/simkl/simkl_tracker.dart'; import '../../services/trackers/tracker.dart'; import '../../services/trackers/tracker_constants.dart'; -import '../../services/trakt/trakt_scrobble_service.dart'; +import '../../services/trackers/trakt/trakt_tracker.dart'; import 'tracker_settings_screen.dart'; import 'trakt_settings_screen.dart'; /// One watch tracker, described once for every place that lists services: the /// services hub, the rating sheet, and the settings summary line. /// -/// [isConnected] and [username] take a [BuildContext] because each service -/// keeps its account state on a different provider; they read it with `watch`, -/// so the calling element rebuilds exactly like the per-service `Consumer` -/// these entries replaced. +/// [isConnected] and [username] take a [BuildContext] and read the account +/// state with `watch`, so the calling element rebuilds exactly like the +/// per-service `Consumer` these entries replaced. class TrackerServiceInfo { final TrackerService service; final String displayName; @@ -66,9 +64,9 @@ class TrackerServiceInfo { service: TrackerService.trakt, displayName: t.trakt.title, logoSource: CatalogSourceId.trakt, - ratingSource: TraktScrobbleService.instance, - isConnected: (context) => context.watch().isConnected, - username: (context) => context.watch().username, + ratingSource: TraktTracker.instance, + isConnected: (context) => context.watch().isTraktConnected, + username: (context) => context.watch().traktUsername, startConnection: startTraktConnection, buildSettingsScreen: () => const TraktSettingsScreen(), ), diff --git a/lib/screens/settings/trakt_settings_screen.dart b/lib/screens/settings/trakt_settings_screen.dart index e1aab594..bc78773c 100644 --- a/lib/screens/settings/trakt_settings_screen.dart +++ b/lib/screens/settings/trakt_settings_screen.dart @@ -4,11 +4,10 @@ import 'package:provider/provider.dart'; import '../../i18n/strings.g.dart'; import '../../models/trackers/device_code.dart'; -import '../../providers/trakt_account_provider.dart'; +import '../../providers/trackers_provider.dart'; import '../../services/settings_service.dart'; import '../../services/trackers/tracker_constants.dart'; -import '../../services/trakt/trakt_scrobble_service.dart'; -import '../../services/trakt/trakt_sync_service.dart'; +import '../../services/trackers/trakt/trakt_tracker.dart'; import '../../utils/dialogs.dart'; import '../../widgets/device_code_dialog.dart'; import '../../widgets/settings_page.dart'; @@ -16,13 +15,13 @@ import 'tracker_account_settings_body.dart'; import 'tracker_connect_launcher.dart'; Future startTraktConnection(BuildContext context) { - final account = context.read(); + final account = context.read(); final name = t.trakt.title; return launchTrackerConnect( context, - isBusyOrConnected: account.isConnecting || account.isConnected, + isBusyOrConnected: account.isConnecting(TrackerService.trakt) || account.isTraktConnected, serviceName: name, - connect: (cb) => account.connect(onCodeReady: cb), + connect: (cb) => account.connectTrakt(onCodeReady: cb), onCancel: account.cancelConnect, buildDialog: (code, cancel) => DeviceCodeDialog(code: code, serviceName: name, onCancel: cancel), urlFor: (code) => code.verificationUrlComplete ?? code.verificationUrl, @@ -32,7 +31,7 @@ Future startTraktConnection(BuildContext context) { class TraktSettingsScreen extends StatelessWidget { const TraktSettingsScreen({super.key}); - Future _disconnect(BuildContext context, TraktAccountProvider account) async { + Future _disconnect(BuildContext context, TrackersProvider account) async { final confirmed = await showConfirmDialog( context, title: t.trakt.disconnectConfirm, @@ -41,19 +40,19 @@ class TraktSettingsScreen extends StatelessWidget { isDestructive: true, ); if (!confirmed) return; - await account.disconnect(); + await account.disconnectTrakt(); // build()'s post-frame handler pops the screen once the provider rebuilds // with isConnected == false — don't pop here too. } @override Widget build(BuildContext context) { - return Consumer( + return Consumer( builder: (context, account, _) { // Safety net: if we end up here while not connected (e.g. refresh failed // in the background and cleared the session), bail out. The settings // tile is the only supported entry point for the unauthed flow. - if (!account.isConnected) { + if (!account.isTraktConnected) { WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) Navigator.of(context).pop(); }); @@ -63,7 +62,7 @@ class TraktSettingsScreen extends StatelessWidget { ); } - final username = account.username; + final username = account.traktUsername; return TrackerAccountSettingsBody( title: Text(t.trakt.title), accountTitle: username != null ? t.trakt.connectedAs(username: username) : t.trakt.connected, @@ -75,14 +74,14 @@ class TraktSettingsScreen extends StatelessWidget { icon: Symbols.auto_timer_rounded, title: t.trakt.scrobble, subtitle: t.trakt.scrobbleDescription, - onAfterWrite: TraktScrobbleService.instance.setEnabled, + onAfterWrite: TraktTracker.instance.setEnabled, ), TrackerSettingsToggle( pref: SettingsService.enableTraktWatchedSync, icon: Symbols.check_circle_rounded, title: t.trakt.watchedSync, subtitle: t.trakt.watchedSyncDescription, - onAfterWrite: TraktSyncService.instance.setEnabled, + onAfterWrite: TraktTracker.instance.setWatchedSyncEnabled, ), ], onDisconnect: () => _disconnect(context, account), diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 5c614208..2ec0cbde 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -686,7 +686,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { _progressTracker?.dispose(); _progressTracker = null; unawaited(DiscordRPCService.instance.stopPlayback()); - unawaited(TraktScrobbleService.instance.stopPlayback()); unawaited(TrackerCoordinator.instance.stopPlayback()); if (!isCurrentReload()) return _MediaReloadOutcome.superseded; diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index 6aeffe1e..85a0b48c 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -22,7 +22,6 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { ); _updateMediaControlsPlaybackState(); unawaited(DiscordRPCService.instance.pausePlayback()); - unawaited(TraktScrobbleService.instance.pausePlayback()); // The item finished, so real-time trackers get a terminal report now rather // than whenever the screen happens to tear down: a completion prompt or // end-of-video sleep timer can leave it open for minutes, and until then diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 0eefe628..23bd683c 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -311,7 +311,6 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { try { await Future.wait([ DiscordRPCService.instance.stopPlayback(), - TraktScrobbleService.instance.stopPlayback(), TrackerCoordinator.instance.stopPlayback(), ]); } catch (e, st) { @@ -371,11 +370,11 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { ); } - // Scrobblers — Discord RPC, Trakt, unified tracker. All accept the - // neutral [MediaServerClient]; null short-circuits cleanly. + // Scrobblers — Discord RPC plus the tracker coordinator, which fans out to + // every connected service. Both accept the neutral [MediaServerClient]; null + // short-circuits cleanly. if (mediaClient != null) { unawaited(DiscordRPCService.instance.startPlayback(metadata, mediaClient)); - unawaited(TraktScrobbleService.instance.startPlayback(metadata, mediaClient, isLive: widget.isLive)); unawaited(TrackerCoordinator.instance.startPlayback(metadata, mediaClient, isLive: widget.isLive)); } } @@ -422,11 +421,14 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { // Other episodes of a Plex multi-episode file share this item's // part — watching the file watched them too (#1500). Reusing // markWatchedFromPlaybackStop keeps the local watched-event - // emission and the Jellyfin double-scrobble guard (#1287). + // emission and the Jellyfin double-scrobble guard (#1287); the + // trackers hear about a sibling the same way any other watched mark + // reaches them, since no playback session was ever opened for it. final siblings = playbackState.sameFileSiblings(metadata, playedPartId: mediaInfo?.partId?.toString()); for (final sibling in siblings) { if (sibling.isWatched) continue; await mediaClient.markWatchedFromPlaybackStop(sibling); + await TrackerCoordinator.instance.markWatched(sibling, mediaClient); appLogger.d('Scrobbled same-file sibling ${sibling.id} of ${metadata.id}'); } }, @@ -576,11 +578,10 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { speed: currentPlayer.state.rate, ); DiscordRPCService.instance.updatePosition(position); - TraktScrobbleService.instance.updatePosition(position); TrackerCoordinator.instance.updatePosition(position); - // Keep Trakt's known duration current — mpv only emits on the duration - // stream once per load, but this is cheap and avoids an extra listener. - TraktScrobbleService.instance.updateDuration(currentPlayer.state.duration); + // Keep the trackers' known duration current — mpv only emits on the + // duration stream once per load, but this is cheap and avoids an extra + // listener. TrackerCoordinator.instance.updateDuration(currentPlayer.state.duration); }); @@ -648,11 +649,9 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { // Update Discord Rich Presence + real-time trackers if (isPlaying) { DiscordRPCService.instance.resumePlayback(); - TraktScrobbleService.instance.resumePlayback(); TrackerCoordinator.instance.resumePlayback(); } else { DiscordRPCService.instance.pausePlayback(); - TraktScrobbleService.instance.pausePlayback(); TrackerCoordinator.instance.pausePlayback(); } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 5856cf3f..e2fc296e 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -43,7 +43,6 @@ import '../services/fullscreen_state_manager.dart'; import '../services/driver_distraction.dart'; import '../services/discord_rpc_service.dart'; import '../services/trackers/tracker_coordinator.dart'; -import '../services/trakt/trakt_scrobble_service.dart'; import '../services/episode_navigation_service.dart'; import '../services/apple_tv_remote_touch_service.dart'; import '../services/media_controls_manager.dart'; @@ -901,7 +900,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin // trackers that own their own watched semantics need the terminal // report. unawaited(TrackerCoordinator.instance.stopPlayback()); - unawaited(TraktScrobbleService.instance.stopPlayback()); } break; } @@ -1589,7 +1587,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin _mediaControlsManager?.dispose(); DiscordRPCService.instance.stopPlayback(); - TraktScrobbleService.instance.stopPlayback(); TrackerCoordinator.instance.stopPlayback(); if (_fullscreenListenerAttached) { diff --git a/lib/services/catalog/trakt_catalog_source.dart b/lib/services/catalog/trakt_catalog_source.dart index b261fd4f..e76e6572 100644 --- a/lib/services/catalog/trakt_catalog_source.dart +++ b/lib/services/catalog/trakt_catalog_source.dart @@ -10,15 +10,15 @@ import '../../models/trakt/trakt_ids.dart'; import '../../utils/app_logger.dart'; import '../../utils/country_codes.dart'; import '../../utils/external_ids.dart'; -import '../trakt/trakt_client.dart'; -import '../trakt/trakt_constants.dart'; +import '../trackers/trakt/trakt_client.dart'; +import '../trackers/trakt/trakt_constants.dart'; import 'catalog_source.dart'; import 'catalog_watchlist_machinery.dart'; /// [CatalogSource] backed by the Trakt API. /// -/// Wraps the catalog [TraktClient] owned by `TraktAccountProvider` (not owned -/// here — never disposed by this class). Watchlist membership rides +/// Wraps the catalog [TraktClient] owned by `TrackersProvider` (not owned here +/// — never disposed by this class). Watchlist membership rides /// [CatalogWatchlistMachinery] with kind-namespaced keys over every id form. class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource { final TraktClient _client; diff --git a/lib/services/offline_watch_sync_service.dart b/lib/services/offline_watch_sync_service.dart index 95709c86..72d93f64 100644 --- a/lib/services/offline_watch_sync_service.dart +++ b/lib/services/offline_watch_sync_service.dart @@ -614,6 +614,18 @@ class OfflineWatchSyncService extends ChangeNotifier { } } + /// Push a watch-state change Plezy observed on the server to the trackers. + /// + /// Only for genuine transitions detected during a download sync: the item was + /// watched (or un-watched) somewhere else, so no playback, manual mark or + /// offline replay has reported it. Best-effort by construction — the + /// coordinator swallows per-tracker errors and queues what it can retry. + Future _mirrorWatchStateToTrackers(MediaItem item, MediaServerClient client, {required bool isWatched}) async { + await (isWatched + ? TrackerCoordinator.instance.markWatched(item, client) + : TrackerCoordinator.instance.markUnwatched(item, client)); + } + /// Sync watch states for all episodes in a single season. /// /// Returns the number of episodes synced, or -1 on failure. @@ -652,6 +664,9 @@ class OfflineWatchSyncService extends ChangeNotifier { isNowWatched: isWatched, cacheServerId: client.cacheServerId, ); + // The change came from the server (watched on another client), so no + // playback or manual path has told the trackers about it. + await _mirrorWatchStateToTrackers(episode, client, isWatched: isWatched); } } else { // No cached row yet — populate the canonical row via fetchItem @@ -787,6 +802,7 @@ class OfflineWatchSyncService extends ChangeNotifier { isNowWatched: isWatched, cacheServerId: client.cacheServerId, ); + await _mirrorWatchStateToTrackers(metadata, client, isWatched: isWatched); } } } catch (e) { diff --git a/lib/services/trackers/anime_list_tracker_base.dart b/lib/services/trackers/anime_list_tracker_base.dart index d0f62adf..2f7d9a73 100644 --- a/lib/services/trackers/anime_list_tracker_base.dart +++ b/lib/services/trackers/anime_list_tracker_base.dart @@ -6,7 +6,7 @@ import 'tracker.dart'; import 'tracker_id_resolver.dart'; mixin AnimeListTrackerBase on TrackerBase, ClientBackedTracker - implements TrackerRatingSource { + implements TrackerRatingSource, SeriesProgressTracker { final KeyedFutureCache _episodeCountLoads = KeyedFutureCache(); @override @@ -31,12 +31,22 @@ mixin AnimeListTrackerBase on TrackerBa } @override - Future markWatched(TrackerContext ctx) async { + Object? seriesEntryId(TrackerContext ctx) => animeId(ctx.anime); + + /// A movie entry is a single unit; an episode claims the mapped anime progress + /// when Fribb defined that scope, else its own episode number. + @override + int? seriesProgress(TrackerContext ctx) => ctx.isMovie ? 1 : (ctx.animeProgress ?? ctx.episodeNumber); + + /// [watchedAt] is ignored: a list entry stores a progress counter, not dated + /// plays, so a replayed write is indistinguishable from a fresh one. + @override + Future markWatched(TrackerContext ctx, {DateTime? watchedAt}) async { final activeClient = client; final id = animeId(ctx.anime); if (activeClient == null || id == null) return; - final progress = ctx.isMovie ? 1 : (ctx.animeProgress ?? ctx.episodeNumber); + final progress = seriesProgress(ctx); if (progress == null || progress <= 0) return; final total = ctx.isMovie || ctx.animeProgress == null ? null : await _episodeCount(activeClient, id); final watched = total != null && progress > total ? total : progress; @@ -52,6 +62,7 @@ mixin AnimeListTrackerBase on TrackerBa } } + @override Future removeFromList(TrackerContext ctx) async { final activeClient = client; final id = animeId(ctx.anime); diff --git a/lib/services/trackers/simkl/simkl_client.dart b/lib/services/trackers/simkl/simkl_client.dart index 86712d21..6bdf06e6 100644 --- a/lib/services/trackers/simkl/simkl_client.dart +++ b/lib/services/trackers/simkl/simkl_client.dart @@ -7,7 +7,7 @@ import '../../../models/simkl/simkl_best_item.dart'; import '../../../models/simkl/simkl_detail.dart'; import '../../../models/simkl/simkl_search_result.dart'; import '../../../models/simkl/simkl_trending_item.dart'; -import '../../trakt/trakt_page.dart'; +import '../tracker_page.dart'; import '../tracker.dart'; import '../tracker_constants.dart'; @@ -81,7 +81,7 @@ class SimklClient implements DisposableTrackerClient { ]; } - Future> searchCatalog( + Future> searchCatalog( SimklCatalogType type, String search, { int page = 1, @@ -98,7 +98,7 @@ class SimklClient implements DisposableTrackerClient { for (final item in decoded) if (item is Map) SimklSearchResult.fromJson(item), ]; - return TraktPage.fromResponse(response, items); + return TrackerPage.fromResponse(response, items); } Future> getBest(SimklCatalogType type, {String filter = 'watched'}) async { diff --git a/lib/services/trackers/simkl/simkl_tracker.dart b/lib/services/trackers/simkl/simkl_tracker.dart index af251c7f..139d317c 100644 --- a/lib/services/trackers/simkl/simkl_tracker.dart +++ b/lib/services/trackers/simkl/simkl_tracker.dart @@ -8,6 +8,7 @@ import '../../../utils/json_utils.dart'; import '../tracker.dart'; import '../tracker_constants.dart'; import '../tracker_id_resolver.dart'; +import '../tracker_write_queue.dart'; import '../tracker_rating_match.dart'; import '../tracker_session.dart'; import 'simkl_client.dart'; @@ -27,7 +28,7 @@ import 'simkl_client.dart'; /// Plex exposes. class SimklTracker extends TrackerBase with ClientBackedTracker - implements TrackerRatingSource, RealtimeScrobbleTracker { + implements TrackerRatingSource, RealtimeScrobbleTracker, EpisodeHistoryTracker { static SimklTracker? _instance; static SimklTracker get instance => _instance ??= SimklTracker._(); SimklTracker._(); @@ -50,6 +51,29 @@ class SimklTracker extends TrackerBase @override Object? get scrobbleBinding => client; + @override + bool get canReportPlayback => isEnabledWithSession; + + @override + ScrobblePolicy get scrobblePolicy => const ScrobblePolicy( + // Simkl serialises scrobble writes behind a 20-second per-user lock and + // fails whatever queues up with a 400, so a re-sent `start` waits it out. + resendThrottle: Duration(seconds: 20), + // Simkl asks for nothing on a seek, so it receives no seek checkpoints. + seekThrottle: null, + ); + + /// Prefers the server's external ids, which are always present when Simkl can + /// write at all; its own id is a fallback, not part of the identity, because it + /// only appears once an anime mapping has been downloaded. + @override + String? historyRowIdentity(TrackerContext ctx) { + final external = trackerExternalRowIdentity(ctx.external); + if (external != null) return external; + final simklId = ctx.anime?.simkl; + return simklId == null ? null : 'simkl=$simklId'; + } + void rebindSession( TrackerSession? session, { required void Function() onSessionInvalidated, @@ -62,8 +86,10 @@ class SimklTracker extends TrackerBase ); } + /// [watchedAt] is ignored: the history body Simkl accepts here carries no + /// timestamp, so a replayed write records as "now". @override - Future markWatched(TrackerContext ctx) async { + Future markWatched(TrackerContext ctx, {DateTime? watchedAt}) async { final client = this.client; if (client == null) return; @@ -100,7 +126,9 @@ class SimklTracker extends TrackerBase TrackerScrobbleState.start => 'start', TrackerScrobbleState.pause => 'pause', TrackerScrobbleState.stop => 'stop', + TrackerScrobbleState.seek => null, }; + if (action == null) return; await client.scrobble( action, _scrobbleBody(ctx, ids, progressPercent), diff --git a/lib/services/trackers/tracker.dart b/lib/services/trackers/tracker.dart index 4e0b3453..13b1a920 100644 --- a/lib/services/trackers/tracker.dart +++ b/lib/services/trackers/tracker.dart @@ -4,9 +4,10 @@ import 'tracker_constants.dart'; import 'tracker_id_resolver.dart'; import 'tracker_session.dart'; -/// Abstract tracker contract: the coordinator calls [markWatched] once per -/// playback when progress crosses the watched threshold. Enabled/auth gating -/// lives in [TrackerBase]. +/// Abstract tracker contract. Every write a tracker performs enters through +/// this interface, and [TrackerCoordinator] is the only caller: playback +/// lifecycle for [RealtimeScrobbleTracker]s, watched-threshold and manual +/// marks for everyone else. Enabled/auth gating lives in [TrackerBase]. abstract class Tracker { String get name; @@ -14,12 +15,18 @@ abstract class Tracker { /// scrobble enabled, etc.). TrackerService get service; - bool get canScrobble; + /// True when a watched/unwatched history write may go out right now — the + /// service's own toggle is on and a session is bound. + /// + /// Separate from [RealtimeScrobbleTracker.canReportPlayback] because Trakt + /// exposes the two as independent user settings; for every other service the + /// two answers are the same. + bool get canWriteWatched; /// True if this tracker's IDs only come from the Fribb anime mapping - /// (MAL, AniList). Simkl returns false because it accepts Plex tvdb/imdb/ - /// tmdb directly; when Simkl is the only active tracker we skip the 5.6 MB - /// Fribb download entirely. + /// (MAL, AniList). Simkl and Trakt return false because they accept Plex + /// tvdb/imdb/tmdb directly; when no active tracker needs Fribb we skip the + /// 5.6 MB mapping download entirely. bool get needsFribb; Future initialize(); @@ -31,7 +38,11 @@ abstract class Tracker { /// configured for this tracker. bool shouldScrobbleForLibrary(String? libraryGlobalKey); - Future markWatched(TrackerContext ctx); + /// [watchedAt] carries the moment the watch actually happened, set only when + /// replaying a queued write whose original attempt failed. Services that + /// cannot express a historical timestamp ignore it and record "now". + Future markWatched(TrackerContext ctx, {DateTime? watchedAt}); + Future markUnwatched(TrackerContext ctx); } @@ -41,12 +52,67 @@ abstract interface class TrackerRatingSource { Future clearRating(TrackerRatingContext ctx); } +/// A tracker whose history is a per-item record: every movie and episode is +/// added or removed on its own (Simkl, Trakt). The coordinator can therefore +/// hand it one item at a time, including a single episode of a container. +abstract interface class EpisodeHistoryTracker implements Tracker { + /// A stable identifier for the remote row this tracker's history writes target, + /// or null when it cannot name one — in which case no write could apply either. + /// + /// Queued writes coalesce on this, so it has to stay the same for one row over + /// time. That rules out both the media-server rating key (server-local, so two + /// unrelated items collide) and the full outbound id set (which grows an anime + /// id as soon as some other tracker's mapping is downloaded, leaving rows + /// already queued unmatchable). Prefer [trackerExternalRowIdentity]. + String? historyRowIdentity(TrackerContext ctx); +} + +/// A tracker that keeps one progress counter per series instead of per-episode +/// rows (MAL, AniList). The coordinator aggregates a container's episodes into +/// a single entry update, and unwatching means dropping the whole entry. +abstract interface class SeriesProgressTracker implements Tracker { + /// Identity of the series entry this tracker would write for [ctx], or null + /// when it cannot map the item. Episodes sharing an entry id collapse into + /// one write. + Object? seriesEntryId(TrackerContext ctx); + + /// The absolute progress a watched write for [ctx] would claim on that entry. + /// + /// Because the write is absolute rather than incremental, this is what makes a + /// deferred retry safe: a claim is monotonic, so two claims about one entry + /// coalesce to the higher, and a claim already covered by a completed write is + /// dropped instead of walking the counter backwards. + int? seriesProgress(TrackerContext ctx); + + Future removeFromList(TrackerContext ctx); +} + /// Playback state reported to trackers that accept real-time progress. -enum TrackerScrobbleState { start, pause, stop } +/// +/// [seek] is a checkpoint rather than a transition: playback is still running, +/// the position just jumped. A service that has no seek concept declares so +/// through [ScrobblePolicy.seekThrottle] and never receives one. +enum TrackerScrobbleState { start, pause, seek, stop } + +/// How closely one service tolerates repeated playback reports. Each rule +/// exists because of a documented server-side constraint, so it lives with the +/// tracker that knows it rather than in the coordinator. +class ScrobblePolicy { + /// Minimum gap before the same state may be reported again. Guards against a + /// pause/play storm turning into a burst of identical writes. + final Duration resendThrottle; + + /// Minimum gap between two seek checkpoints, or null when the service wants + /// no seek reports at all. + final Duration? seekThrottle; + + const ScrobblePolicy({required this.resendThrottle, this.seekThrottle}); +} /// Trackers that record playback progress as it happens, not just a terminal /// watched mark. [TrackerCoordinator] drives these from player lifecycle -/// events (start/resume, pause, stop) with the current progress percentage. +/// events (start/resume, pause, seek, stop) with the current progress +/// percentage. /// /// A real-time tracker owns its own watched semantics for in-player playback: /// the coordinator deliberately excludes it from the watched-threshold @@ -60,6 +126,11 @@ abstract interface class RealtimeScrobbleTracker implements Tracker { /// cannot redirect a write to whichever account replaced it. Object? get scrobbleBinding; + /// True when a playback lifecycle report may go out right now. + bool get canReportPlayback; + + ScrobblePolicy get scrobblePolicy; + /// Report a playback lifecycle event with the current progress percentage. Future scrobble(TrackerContext ctx, TrackerScrobbleState state, double progressPercent); @@ -95,8 +166,12 @@ abstract class TrackerBase implements Tracker { bool get hasActiveClient; + /// The service's own scrobble toggle ANDed with a bound session. The default + /// answer for both tracker capabilities; Trakt splits them. + bool get isEnabledWithSession => _isEnabled && hasActiveClient; + @override - bool get canScrobble => _isEnabled && hasActiveClient; + bool get canWriteWatched => isEnabledWithSession; @override Future initialize() async { diff --git a/lib/services/trackers/tracker_connect_runner.dart b/lib/services/trackers/tracker_connect_runner.dart index d9684061..cd3ff014 100644 --- a/lib/services/trackers/tracker_connect_runner.dart +++ b/lib/services/trackers/tracker_connect_runner.dart @@ -3,8 +3,8 @@ import '../../utils/app_logger.dart'; /// Shared "authorize → enrich → save → assign" pipeline. /// /// Callers manage the in-flight flag + `notifyListeners` around this call; -/// this helper only owns the inner steps so `TrackersProvider` and -/// `TraktAccountProvider` can't drift. Returns `true` only on a fully applied +/// this helper only owns the inner steps so the `TrackersProvider` service +/// flows can't drift. Returns `true` only on a fully applied /// session — null-from-authorize (cancel/denied/expired) and any exception /// both surface as `false`. Future runConnectPipeline({ diff --git a/lib/services/trackers/tracker_coordinator.dart b/lib/services/trackers/tracker_coordinator.dart index 52e5165d..bdee96e4 100644 --- a/lib/services/trackers/tracker_coordinator.dart +++ b/lib/services/trackers/tracker_coordinator.dart @@ -15,33 +15,63 @@ import 'mal/mal_tracker.dart'; import 'simkl/simkl_tracker.dart'; import 'tracker.dart'; import 'tracker_constants.dart'; +import 'tracker_exceptions.dart'; import 'tracker_id_resolver.dart'; +import 'tracker_write_queue.dart'; +import 'trakt/trakt_tracker.dart'; -/// A real-time tracker paired with the account binding it was captured against, -/// so a deferred write can tell whether it is still writing to the same account. -typedef _BoundScrobbleTarget = (RealtimeScrobbleTracker, Object?); - -/// Fan-out for non-Trakt trackers (MAL, AniList, Simkl). +/// The single seam through which every tracker write leaves the app. /// -/// Two mechanisms, one per tracker kind: +/// Three mechanisms, chosen per tracker kind: /// -/// * Threshold trackers (MAL, AniList) are notified exactly once when progress -/// crosses the watched threshold, with a safety-net fire on stop if the -/// crossing was missed (e.g. user stopped between ticks). -/// * [RealtimeScrobbleTracker]s (Simkl) instead receive the playback lifecycle -/// — start/resume, pause, stop — with the current progress, and decide +/// * [RealtimeScrobbleTracker]s (Simkl, Trakt) receive the playback lifecycle — +/// start/resume, pause, seek, stop — with the current progress, and decide /// watched state themselves. They are excluded from the threshold fan-out so /// a single watch never produces two writes. +/// * Threshold trackers (MAL, AniList) are notified exactly once when progress +/// crosses the watched threshold, with a safety-net fire on stop if the +/// crossing was missed (e.g. the user stopped between ticks). +/// * Manual, container, offline-replay, external-player and server-observed +/// marks bypass playback entirely and go straight to [Tracker.markWatched] on +/// every tracker that can write. /// -/// Manual, container, offline-replay and external-player marks bypass all of -/// this and go straight to [Tracker.markWatched] on every tracker. +/// A failed per-item history write is persisted in [TrackerWriteQueue] and +/// replayed by [flushWriteQueue], so a transient error does not silently drop a +/// watch. class TrackerCoordinator { static TrackerCoordinator? _instance; static TrackerCoordinator get instance => _instance ??= TrackerCoordinator._(); TrackerCoordinator._(); - late final List _trackers = [MalTracker.instance, AnilistTracker.instance, SimklTracker.instance]; + /// The registry. Everything below partitions this list by capability rather + /// than naming services, so adding one means adding it here and nowhere else. + late final List _trackers = [ + MalTracker.instance, + AnilistTracker.instance, + SimklTracker.instance, + TraktTracker.instance, + ]; + + /// One transport per real-time tracker, created once and outliving individual + /// playbacks: an episode swap stops the old item and starts the new one back + /// to back, and both belong in the same per-service order. + late final List<_ScrobbleChannel> _channels = [ + for (final tracker in _trackers.whereType()) _ScrobbleChannel(tracker), + ]; + + final TrackerWriteQueue _writeQueue = TrackerWriteQueue(); + String _activeUserUuid = ''; + int _profileGeneration = 0; + + /// In-flight write chains, one per remote row per profile (see + /// [_sequencedByKey]). Entries are removed as soon as nothing is queued behind + /// them, so this never grows past the writes currently in flight. + final Map> _writeChains = {}; + + /// Intent numbering for the same rows, so a failing write can tell whether a + /// newer one has already succeeded. Same lifetime as [_writeChains]. + final Map _rowIntents = {}; /// Resolver persists across episode swaps so back-to-back episodes of the /// same show reuse the cached IDs. Cleared only on profile switch. @@ -66,35 +96,13 @@ class TrackerCoordinator { int _playbackRevision = 0; /// Drop a duplicate state transition within this window — the player emits - /// several playing-state events per seek. + /// several playing-state events per seek. This one is a player-side artifact, + /// so unlike [ScrobblePolicy] it is the same for every service. static const Duration _duplicateStateDebounce = Duration(seconds: 1); - /// Drop a same-state re-send inside this window. Simkl serialises scrobble - /// writes behind a 20-second per-user lock and fails queued requests with a - /// 400, so rapid pause/play cycles must not each ship a `start`. - static const Duration _startResendThrottle = Duration(seconds: 20); - - TrackerScrobbleState? _lastScrobbleState; - DateTime? _lastScrobbleAt; - bool _scrobbleStarted = false; - - /// Real-time targets pinned when the current playback began, each with the - /// account binding it was pinned against. Every report for this playback goes - /// to these and no others. - List<_BoundScrobbleTarget> _playbackTargets = const []; - - /// Real-time services process one write per user at a time, so reports queue - /// and go out in order. An episode swap stops the old item and starts the new - /// one back to back, so both can be waiting behind one gated request. - final List<_QueuedScrobble> _scrobbleQueue = []; - Future? _scrobbleDrain; - - /// Soft bound against a play/pause storm outrunning the remote lock: overflow - /// sheds the oldest non-terminal report. Terminal stops are never shed, so the - /// bound is deliberately soft — a burst of episode swaps behind one hung - /// request queues one stop per item, and each carries that item's own watch - /// and resume position. - static const int _maxQueuedScrobbles = 4; + /// Real-time targets pinned when the current playback began. Every report for + /// this playback goes to these and no others. + List<_PlaybackTarget> _playbackTargets = const []; DateTime Function() _clock = DateTime.now; @@ -106,6 +114,41 @@ class TrackerCoordinator { await Future.wait(_trackers.map((t) => t.initialize())); } + /// Rebind to a profile: drops in-flight playback state and the resolver (which + /// holds a media client), and points the retry queue at the new profile's + /// items. Called before the per-service sessions are pushed to the trackers. + void onActiveProfileChanged(String userUuid) { + _activeUserUuid = userUuid; + ++_profileGeneration; + cancelInFlight(); + } + + /// The profile a write belongs to, captured before its first await. Every + /// deferred step re-checks it, so a switch mid-write can neither file one + /// profile's retry under another nor replay a leftover row through the account + /// that replaced it. + _WriteScope get _currentScope => _WriteScope(_activeUserUuid, _profileGeneration); + + bool _isCurrent(_WriteScope scope) => scope.generation == _profileGeneration; + + /// Replay watched writes that failed earlier. Safe to call repeatedly — + /// concurrent calls coalesce, and an item whose service has no session, or + /// whose write the service will not take right now, is left untouched rather + /// than counted as a failed attempt. + /// + /// Driven from profile bind, a successful connect, app foreground, and network + /// restore. Never throws: a retry pass is best-effort, its callers are + /// fire-and-forget, and a queue that cannot be read or written this time simply + /// waits for the next trigger. + Future flushWriteQueue() async { + final scope = _currentScope; + try { + await _writeQueue.flush(scope.userUuid, send: (item) => _replayQueuedWrite(item, scope)); + } catch (e, st) { + appLogger.w('Trackers: write queue flush failed', error: e, stackTrace: st); + } + } + Future startPlayback(MediaItem metadata, MediaServerClient client, {bool isLive = false}) async { final revision = ++_playbackRevision; if (isLive) { @@ -118,7 +161,7 @@ class TrackerCoordinator { return; } final libraryGlobalKey = metadata.libraryGlobalKey; - if (!_hasActiveTrackerForLibrary(libraryGlobalKey)) { + if (!_hasPlaybackInterest(libraryGlobalKey)) { _reset(); return; } @@ -149,17 +192,28 @@ class TrackerCoordinator { // A playback session belongs to the account bound when it began. Pinning the // targets here — rather than resolving them per report — keeps every later // report on that account even if the user rebinds mid-playback. - _playbackTargets = _activeRealtimeTargets(ctx); + _playbackTargets = [ + for (final channel in _channels) + if (_canReport(channel.tracker, ctx.libraryGlobalKey)) + _PlaybackTarget(channel, channel.tracker.scrobbleBinding), + ]; unawaited(_scrobble(TrackerScrobbleState.start)); } bool _anyTrackerNeedsFribb() => _anyTrackerNeedsFribbForLibrary(_activeLibraryGlobalKey); - bool _hasActiveTrackerForLibrary(String? libraryGlobalKey) => - _trackers.any((t) => t.canScrobble && t.shouldScrobbleForLibrary(libraryGlobalKey)); + /// True when at least one tracker would act on this playback — either by + /// receiving reports or by recording the watch when progress crosses over. + /// Trakt can be in the second group without being in the first: its scrobble + /// and watched-sync settings are independent. + bool _hasPlaybackInterest(String? libraryGlobalKey) => _trackers.any( + (t) => _canWrite(t, libraryGlobalKey) || (t is RealtimeScrobbleTracker && _canReport(t, libraryGlobalKey)), + ); + + bool _hasWatchedInterest(String? libraryGlobalKey) => _trackers.any((t) => _canWrite(t, libraryGlobalKey)); bool _anyTrackerNeedsFribbForLibrary(String? libraryGlobalKey) => - _trackers.any((t) => t.canScrobble && t.needsFribb && t.shouldScrobbleForLibrary(libraryGlobalKey)); + _trackers.any((t) => t.needsFribb && _canWrite(t, libraryGlobalKey)); void debugUseResolverDependencies({ FribbMappingLookup? store, @@ -199,53 +253,65 @@ class TrackerCoordinator { } final libraryGlobalKey = item.libraryGlobalKey; - if (!_hasActiveTrackerForLibrary(libraryGlobalKey)) return; + if (!_hasWatchedInterest(libraryGlobalKey)) return; final resolver = _newResolver(client, needsFribb: () => _anyTrackerNeedsFribbForLibrary(libraryGlobalKey)); + final scope = _currentScope; if (kind == MediaKind.movie || kind == MediaKind.episode) { - await (watched ? _markSingleWatched(item, resolver) : _markSingleUnwatched(item, resolver)); + await (watched ? _markSingleWatched(item, resolver, scope) : _markSingleUnwatched(item, resolver, scope)); return; } final episodes = []; await collectEpisodes(client, item.id, unwatchedOnly: false, out: episodes, fallback: item); + if (!_isCurrent(scope)) return; final expansion = watched ? 'expanded' : 'unwatched expanded'; appLogger.d('Trackers: manual ${kind.name} ${item.id} $expansion to ${episodes.length} episodes'); await (watched - ? _markContainerEpisodesWatched(episodes, resolver) - : _markContainerEpisodesUnwatched(episodes, resolver)); + ? _markContainerEpisodesWatched(episodes, resolver, scope) + : _markContainerEpisodesUnwatched(episodes, resolver, scope)); } - Future _markContainerEpisodesWatched(List episodes, TrackerIdResolver resolver) async { - final animeGroups = {}; + Future _markContainerEpisodesWatched( + List episodes, + TrackerIdResolver resolver, + _WriteScope scope, + ) async { + final seriesGroups = {}; var resolved = 0; for (final episode in episodes) { final ctx = await _buildContext(episode, resolver, includeAnimeProgress: false); + if (!_isCurrent(scope)) return; if (ctx == null) continue; resolved++; - await _dispatch([SimklTracker.instance], ctx, watched: true); + await _dispatch(_episodeHistoryTrackers, ctx, scope, watched: true); - final key = _animeGroupKey(ctx); + final key = _seriesGroupKey(ctx); if (key == null) continue; - (animeGroups[key] ??= _ManualAnimeProgress(ctx, fallbackToCount: true)).add(ctx); + (seriesGroups[key] ??= _ManualSeriesProgress(ctx, fallbackToCount: true)).add(ctx); } appLogger.d('Trackers: manual container resolved $resolved/${episodes.length} episodes'); - for (final group in animeGroups.values) { + for (final group in seriesGroups.values) { final ctx = group.context; - if (ctx != null) await _dispatch([MalTracker.instance, AnilistTracker.instance], ctx, watched: true); + if (ctx != null) await _dispatch(_seriesProgressTrackers, ctx, scope, watched: true); } - appLogger.d('Trackers: manual container resolved ${animeGroups.length} anime entries'); + appLogger.d('Trackers: manual container resolved ${seriesGroups.length} series entries'); } - Future _markContainerEpisodesUnwatched(List episodes, TrackerIdResolver resolver) async { - final malEntries = {}; - final anilistEntries = {}; + Future _markContainerEpisodesUnwatched( + List episodes, + TrackerIdResolver resolver, + _WriteScope scope, + ) async { + // One context per series entry per tracker: a show maps to a single list + // entry, so all of its episodes collapse into one removal. + final entriesByTracker = >{}; var resolved = 0; for (final episode in episodes) { @@ -255,82 +321,91 @@ class TrackerCoordinator { includeAnimeProgress: false, fallbackToAnimeEpisodeNumber: false, ); + if (!_isCurrent(scope)) return; if (ctx == null) continue; resolved++; - await _dispatch([SimklTracker.instance], ctx, watched: false); + await _dispatch(_episodeHistoryTrackers, ctx, scope, watched: false); - final anime = ctx.anime; - if (anime == null) continue; - final malId = anime.mal; - if (malId != null && _isActive(MalTracker.instance, ctx.libraryGlobalKey)) { - malEntries[malId] = ctx; - } - final anilistId = anime.anilist; - if (anilistId != null && _isActive(AnilistTracker.instance, ctx.libraryGlobalKey)) { - anilistEntries[anilistId] = ctx; + for (final tracker in _seriesProgressTrackers) { + if (!_canWrite(tracker, ctx.libraryGlobalKey)) continue; + final entryId = tracker.seriesEntryId(ctx); + if (entryId == null) continue; + (entriesByTracker[tracker] ??= {})[entryId] = ctx; } } appLogger.d('Trackers: manual container unwatched resolved $resolved/${episodes.length} episodes'); - - await _removeAnimeEntriesFromLists(malEntries.values, anilistEntries.values); - appLogger.d( - 'Trackers: manual container unwatched resolved ${malEntries.length} MAL and ${anilistEntries.length} AniList entries', - ); + await _removeSeriesEntries(entriesByTracker, scope); } - Future _removeAnimeEntriesFromLists( - Iterable malEntries, - Iterable anilistEntries, + /// Dropping a whole series entry has no queued-retry form — the queue replays + /// [Tracker] writes, and this is not one — so a failure here is logged and + /// dropped. A success invalidates any queued progress claim for that entry, + /// which would otherwise replay and resurrect the list entry the user just + /// cleared. + Future _removeSeriesEntries( + Map> entriesByTracker, + _WriteScope scope, ) async { await Future.wait([ - ...malEntries.map((ctx) async { - try { - await MalTracker.instance.removeFromList(ctx); - } catch (e) { - appLogger.d('mal: removeFromList failed', error: e); - } - }), - ...anilistEntries.map((ctx) async { - try { - await AnilistTracker.instance.removeFromList(ctx); - } catch (e) { - appLogger.d('anilist: removeFromList failed', error: e); - } - }), + for (final entry in entriesByTracker.entries) + ...entry.value.values.map((ctx) => _removeSeriesEntry(entry.key, ctx, scope)), ]); + for (final entry in entriesByTracker.entries) { + appLogger.d('Trackers: manual container unwatched removed ${entry.value.length} ${entry.key.name} entries'); + } } - String? _animeGroupKey(TrackerContext ctx) { - final anime = ctx.anime; - if (anime == null) return null; - final hasMal = anime.mal != null && _isActive(MalTracker.instance, ctx.libraryGlobalKey); - final hasAnilist = anime.anilist != null && _isActive(AnilistTracker.instance, ctx.libraryGlobalKey); - if (!hasMal && !hasAnilist) return null; - return '${hasMal ? anime.mal : ''}:${hasAnilist ? anime.anilist : ''}'; + Future _removeSeriesEntry(SeriesProgressTracker tracker, TrackerContext ctx, _WriteScope scope) async { + final key = _coalesceKeyFor(tracker, ctx); + int? marker; + try { + await _sequencedByKey(scope, key, () async { + await tracker.removeFromList(ctx); + if (key != null) marker = _writeQueue.noteDirectWrite(scope.userUuid, key); + }); + } catch (e) { + appLogger.d('${tracker.name}: removeFromList failed', error: e); + return; + } + // The entry is gone; a queued progress claim for it would resurrect it. + await _settleQueueAfterWrite(key, marker, scope); } - Future _markSingleWatched(MediaItem item, TrackerIdResolver resolver) async { + /// Group key over the series entries a container's episodes would touch, so + /// one entry receives one write however many episodes map to it. Null when no + /// active series tracker can map the item. + String? _seriesGroupKey(TrackerContext ctx) { + final parts = []; + for (final tracker in _seriesProgressTrackers) { + if (!_canWrite(tracker, ctx.libraryGlobalKey)) continue; + final entryId = tracker.seriesEntryId(ctx); + if (entryId == null) continue; + parts.add('${tracker.service.name}=$entryId'); + } + return parts.isEmpty ? null : parts.join('|'); + } + + Future _markSingleWatched(MediaItem item, TrackerIdResolver resolver, _WriteScope scope) async { final ctx = await _buildContext(item, resolver); if (ctx == null) { appLogger.d('Trackers: no external IDs for manually watched ${item.id}'); return; } - await _dispatch(_trackers, ctx, watched: true); + await _dispatch(_trackers, ctx, scope, watched: true); } - Future _markSingleUnwatched(MediaItem item, TrackerIdResolver resolver) async { + Future _markSingleUnwatched(MediaItem item, TrackerIdResolver resolver, _WriteScope scope) async { final ctx = await _buildContext(item, resolver, includeAnimeProgress: false, fallbackToAnimeEpisodeNumber: false); if (ctx == null) { appLogger.d('Trackers: no external IDs for manually unwatched ${item.id}'); return; } - if (ctx.isMovie) { - await _dispatch(_trackers, ctx, watched: false); - } else { - await _dispatch([SimklTracker.instance], ctx, watched: false); - } + // A single episode cannot be unwatched on a series-progress tracker: its + // entry counts episodes, so only whole-entry removal (the container path) + // means anything there. + await _dispatch(ctx.isMovie ? _trackers : _episodeHistoryTrackers, ctx, scope, watched: false); } /// Terminal report for the current playback. @@ -347,20 +422,42 @@ class TrackerCoordinator { final watched = _thresholdCrossed || _timeline.watchedThresholdReached; final missedThresholdMark = !_thresholdCrossed && _timeline.watchedThresholdReached; final progress = _timeline.progressPercent; - final started = _scrobbleStarted; - // Snapshotted before [_reset] clears them: the terminal report and the + // Snapshotted before [_reset] clears the field: the terminal report and the // reconciliation that follows belong to the account this playback began on. final targets = _playbackTargets; - final reconcilers = watched && started ? targets : const <_BoundScrobbleTarget>[]; _reset(); if (ctx == null) return; + final scope = _currentScope; + // Ownership is decided here, once, so the two ways of recording a watch stay + // mutually exclusive: whoever owns its own stop is not told about the + // crossing, and whoever was told is not reconciled. + final owners = _watchOwners(targets); await Future.wait([ - if (missedThresholdMark) _dispatch(_thresholdTrackers, ctx, watched: true), - _sendScrobble(ctx, TrackerScrobbleState.stop, progress, sessionStarted: started, targets: targets), + if (missedThresholdMark) _dispatch(_thresholdTrackers(owners), ctx, scope, watched: true), + _sendScrobble(ctx, TrackerScrobbleState.stop, progress, targets), + ]); + await _settleScrobbles(); + if (!watched) return; + + // An owner only owns the watch if its terminal report actually landed. When + // the stop failed, nothing on that service recorded anything — its own + // completion rule never ran — so the watch falls back to a history write, + // queued for retry if that fails too. + final reconcilable = <_PlaybackTarget>[]; + final unreported = []; + for (final owner in owners) { + if (!_bindingIntact(owner, 'watched reconciliation')) continue; + if (owner.stopConfirmed) { + reconcilable.add(owner); + } else { + unreported.add(owner.tracker); + } + } + await Future.wait([ + _reconcileWatchedAfterStop(reconcilable, ctx, progress, scope), + if (unreported.isNotEmpty) _dispatch(unreported, ctx, scope, watched: true), ]); - await _drainScrobbles(); - await _reconcileWatchedAfterStop(reconcilers, ctx, progress); } /// The player paused, or the app was backgrounded. Saves resumable progress @@ -370,25 +467,31 @@ class TrackerCoordinator { Future resumePlayback() => _scrobble(TrackerScrobbleState.start); void updatePosition(Duration position) { - _timeline.updatePosition(position); + final isSeek = _timeline.updatePosition(position); final ctx = _ctx; - if (ctx == null || _thresholdCrossed) return; + if (ctx == null) return; + // A seek is a checkpoint, not a transition: services that expose no seek + // event opt out through their [ScrobblePolicy] and never see one. + if (isSeek) unawaited(_scrobble(TrackerScrobbleState.seek)); + if (_thresholdCrossed) return; if (!_timeline.watchedThresholdReached) return; _thresholdCrossed = true; - unawaited(_dispatch(_thresholdTrackers, ctx, watched: true)); + unawaited(_dispatch(_thresholdTrackers(_watchOwners(_playbackTargets)), ctx, _currentScope, watched: true)); } void updateDuration(Duration duration) { _timeline.updateDuration(duration); } - /// Called on Plex profile switch — drops in-flight state across all - /// trackers and invalidates the resolver so a fresh Plex client is used. + /// Called on profile switch — drops in-flight state across all trackers and + /// invalidates the resolver so a fresh media client is used. void cancelInFlight() { ++_playbackRevision; // Queued reports belong to the profile being left; the client backing them // is about to be disposed. - _scrobbleQueue.clear(); + for (final channel in _channels) { + channel.clear(); + } _reset(); _resolver?.clearCache(); _resolver = null; @@ -405,179 +508,355 @@ class TrackerCoordinator { _activeLibraryGlobalKey = null; _timeline.reset(watchedThreshold: _fallbackWatchedThreshold); _thresholdCrossed = false; - _lastScrobbleState = null; - _lastScrobbleAt = null; - _scrobbleStarted = false; + // Per-playback report bookkeeping lives on the targets, so dropping them + // clears the debounce/throttle state with it. _playbackTargets = const []; } - /// Trackers whose watch is recorded by the threshold crossing. Real-time - /// trackers are excluded — their own playback lifecycle owns it, and one - /// watch must never produce two writes. - Iterable get _thresholdTrackers => _trackers.where((t) => t is! RealtimeScrobbleTracker); + /// The real-time targets that own the watched state of this playback: their own + /// terminal stop — plus [RealtimeScrobbleTracker.reconcileWatchedAfterStop] — + /// records the watch, so the threshold crossing must leave them alone. + /// + /// Ownership follows the session, not the current settings. A target that never + /// got a session open is not an owner: no stop will be sent for it, so the + /// crossing is the only thing that would record its watch. One whose scrobbling + /// the user turned off mid-playback stays an owner — its stop simply never goes + /// out, and the unconfirmed-stop path then falls back to a history write. Were + /// ownership re-evaluated at stop time instead, a toggle flipped after the + /// crossing would leave the watch recorded by neither route. + List<_PlaybackTarget> _watchOwners(List<_PlaybackTarget> targets) => [ + for (final target in targets) + if (target.sessionStarted) target, + ]; - Iterable get _realtimeTrackers => _trackers.whereType(); + /// Trackers that must be told about the threshold crossing: everyone that does + /// not own this playback's watched state. One watch, one write. + Iterable _thresholdTrackers(List<_PlaybackTarget> owners) => + _trackers.where((tracker) => !owners.any((owner) => identical(owner.tracker, tracker))); - bool _isActive(Tracker tracker, String? libraryGlobalKey) => - tracker.canScrobble && tracker.shouldScrobbleForLibrary(libraryGlobalKey); + Iterable get _episodeHistoryTrackers => _trackers.whereType(); - Future _dispatch(Iterable trackers, TrackerContext ctx, {required bool watched}) async { - final active = trackers.where((t) => _isActive(t, ctx.libraryGlobalKey)); - await Future.wait( - active.map((t) async { - try { - await (watched ? t.markWatched(ctx) : t.markUnwatched(ctx)); - } catch (e) { - appLogger.d('${t.name}: ${watched ? 'markWatched' : 'markUnwatched'} failed', error: e); + Iterable get _seriesProgressTrackers => _trackers.whereType(); + + bool _canWrite(Tracker tracker, String? libraryGlobalKey) => + tracker.canWriteWatched && tracker.shouldScrobbleForLibrary(libraryGlobalKey); + + bool _canReport(RealtimeScrobbleTracker tracker, String? libraryGlobalKey) => + tracker.canReportPlayback && tracker.shouldScrobbleForLibrary(libraryGlobalKey); + + Tracker? _trackerFor(TrackerService service) { + for (final tracker in _trackers) { + if (tracker.service == service) return tracker; + } + return null; + } + + Future _dispatch( + Iterable trackers, + TrackerContext ctx, + _WriteScope scope, { + required bool watched, + }) async { + if (!_isCurrent(scope)) return; + final active = [ + for (final tracker in trackers) + if (_canWrite(tracker, ctx.libraryGlobalKey)) tracker, + ]; + if (active.isEmpty) return; + await Future.wait(active.map((tracker) => _applyWrite(tracker, ctx, scope, watched: watched))); + } + + /// One watched/unwatched write plus the bookkeeping that keeps it consistent + /// with everything else targeting the same remote row. + /// + /// The write is serialised against other writes for that row, and carries an + /// intent number claimed inside the row's channel — so writes are numbered in + /// the order they actually go out. A failure only becomes a queued retry if no + /// later intent for the row has succeeded meanwhile: without that check, a slow + /// failure could persist stale state moments after a newer write cleaned the + /// queue and moved the service on. + Future _applyWrite(Tracker tracker, TrackerContext ctx, _WriteScope scope, {required bool watched}) async { + final key = _coalesceKeyFor(tracker, ctx); + final appliedProgress = watched ? _progressClaim(tracker, ctx) : null; + int? marker; + var intent = 0; + try { + await _sequencedByKey(scope, key, () async { + if (key != null) intent = _beginIntent(scope, key); + await (watched ? tracker.markWatched(ctx) : tracker.markUnwatched(ctx)); + if (key != null) { + _intentSucceeded(scope, key, intent, appliedProgress: appliedProgress); + // Marked inside the row's channel, so a replay waiting behind this + // write sees it before deciding whether it is still needed. + marker = _writeQueue.noteDirectWrite(scope.userUuid, key, appliedProgress: appliedProgress); } - }), + }); + await _settleQueueAfterWrite(key, marker, scope, appliedProgress: appliedProgress); + } catch (e) { + final operation = watched ? 'markWatched' : 'markUnwatched'; + if (key != null && _shouldDropFailedWrite(scope, key, intent, progressClaim: appliedProgress)) { + appLogger.d('${tracker.name}: $operation failed, superseded by a newer write', error: e); + return; + } + appLogger.d('${tracker.name}: $operation failed, queued for retry', error: e); + await _enqueueWrite(tracker, ctx, scope, watched: watched); + } finally { + if (key != null && intent != 0) _endIntent(scope, key); + } + } + + String _rowKey(_WriteScope scope, String key) => '${scope.userUuid}|$key'; + + /// Claim the next intent number for a row. Called inside the row's channel, so + /// intents are numbered in the order the writes reach the service. + int _beginIntent(_WriteScope scope, String key) { + final state = _rowIntents.putIfAbsent(_rowKey(scope, key), _RowIntents.new); + state.pending++; + return ++state.lastIntent; + } + + void _intentSucceeded(_WriteScope scope, String key, int intent, {int? appliedProgress}) { + final state = _rowIntents[_rowKey(scope, key)]; + if (state == null || intent < state.lastAppliedIntent) return; + state.lastAppliedIntent = intent; + state.lastAppliedProgress = appliedProgress; + state.hasApplied = true; + } + + /// Whether a failed write should be dropped rather than queued for retry. + /// + /// A history write states the current truth about an item, so any newer intent + /// for that row — however it ends — owns it and this one is stale. A series + /// claim is monotonic instead: only a completed write that already covers the + /// claim makes it pointless, and two queued claims coalesce to the higher, so + /// the outcome does not depend on which failure is persisted first. + bool _shouldDropFailedWrite(_WriteScope scope, String key, int intent, {required int? progressClaim}) { + final state = _rowIntents[_rowKey(scope, key)]; + if (state == null) return false; + if (progressClaim == null) return state.lastIntent > intent; + if (!state.hasApplied) return false; + return TrackerWriteQueue.coversClaim(appliedProgress: state.lastAppliedProgress, claim: progressClaim); + } + + /// Release an intent. The row's bookkeeping is dropped once nothing holds it, + /// so this only ever tracks writes in flight. + void _endIntent(_WriteScope scope, String key) { + final stateKey = _rowKey(scope, key); + final state = _rowIntents[stateKey]; + if (state == null) return; + if (--state.pending <= 0) _rowIntents.remove(stateKey); + } + + /// Drop the queued rows a landed write covers, and hold its marker until that + /// is done: a drain may already be holding one of those rows, and its sender + /// runs whether or not the row is still on disk. + /// + /// Best-effort by construction. The write already landed, so a failure here + /// must never surface as a failed write — that would queue a retry and + /// duplicate it. + Future _settleQueueAfterWrite(String? key, int? marker, _WriteScope scope, {int? appliedProgress}) async { + if (key == null) return; + try { + await _writeQueue.invalidate(scope.userUuid, key, appliedProgress: appliedProgress); + } catch (e) { + appLogger.d('Trackers: write queue cleanup failed for $key', error: e); + } finally { + if (marker != null) _writeQueue.clearDirectWrite(scope.userUuid, key, marker); + } + } + + /// Serialises every write that targets the same remote row of the same profile + /// — live writes and queued replays alike. + /// + /// Coalescing the queue is not enough on its own: a stale replay that is + /// already on the wire cannot be recalled, so it could land after a newer + /// direct write and undo it (walking a series counter backwards, or + /// resurrecting an item the user just un-watched). Chaining by row means the + /// newest write for it is always the last one to reach the service. + /// + /// Scoped per profile: two profiles write to two accounts, so one must never + /// wait on — or be mistaken for — the other. + Future _sequencedByKey(_WriteScope scope, String? key, Future Function() write) { + if (key == null) return write(); + final chainKey = _rowKey(scope, key); + final previous = _writeChains[chainKey]; + final link = Completer(); + _writeChains[chainKey] = link; + Future run() async { + try { + return await write(); + } finally { + link.complete(); + if (identical(_writeChains[chainKey], link)) _writeChains.remove(chainKey); + } + } + + return previous == null ? run() : previous.future.then((_) => run()); + } + + /// Identity a queued write coalesces on. A per-item history tracker keys on the + /// remote media row; a series-progress tracker keys on the remote list entry, + /// because every episode of one show restates the same counter. Both are + /// server-independent — a rating key is not, and would let two items on two + /// servers replace each other. Null when the tracker cannot name a remote + /// target, in which case the write could not have applied either. + String? _coalesceKeyFor(Tracker tracker, TrackerContext ctx) { + if (tracker is SeriesProgressTracker) { + final entryId = tracker.seriesEntryId(ctx); + return entryId == null ? null : trackerSeriesCoalesceKey(tracker.service, entryId); + } + if (tracker is EpisodeHistoryTracker) { + return trackerItemCoalesceKey(tracker.service, ctx, tracker.historyRowIdentity(ctx)); + } + return null; + } + + /// The absolute progress a watched write would claim, for the trackers that + /// store one. Null everywhere else: a history write is a statement about an + /// item, not a monotonic claim. + int? _progressClaim(Tracker tracker, TrackerContext ctx) => + tracker is SeriesProgressTracker ? tracker.seriesProgress(ctx) : null; + + Future _enqueueWrite(Tracker tracker, TrackerContext ctx, _WriteScope scope, {required bool watched}) async { + final key = _coalesceKeyFor(tracker, ctx); + if (key == null) return; + await _writeQueue.enqueue( + scope.userUuid, + TrackerWriteQueueItem( + service: tracker.service, + watched: watched, + ctx: ctx, + coalesceKey: key, + progressClaim: watched ? _progressClaim(tracker, ctx) : null, + watchedAtIso: _clock().toUtc().toIso8601String(), + ), ); } + Future _replayQueuedWrite(TrackerWriteQueueItem item, _WriteScope scope) async { + // The trackers now hold another profile's sessions; leave the rest of this + // profile's rows for its own flush rather than writing them to that account. + if (!_isCurrent(scope)) return TrackerWriteDisposition.skipped; + final tracker = _trackerFor(item.service); + if (tracker == null) return TrackerWriteDisposition.done; + if (!tracker.canWriteWatched) return TrackerWriteDisposition.skipped; + if (!tracker.shouldScrobbleForLibrary(item.ctx.libraryGlobalKey)) { + appLogger.d('${tracker.name}: queued write dropped — library filtered out'); + return TrackerWriteDisposition.done; + } + try { + // Inside the row's channel: a direct write for the same row may have landed + // while this replay waited its turn, in which case replaying would undo it. + final replayed = await _sequencedByKey(scope, item.coalesceKey, () async { + if (_writeQueue.isSuperseded(scope.userUuid, item)) return false; + await (item.watched + ? tracker.markWatched(item.ctx, watchedAt: item.watchedAt) + : tracker.markUnwatched(item.ctx)); + return true; + }); + appLogger.d( + replayed + ? '${tracker.name}: replayed queued write for ${item.ctx.ratingKey}' + : '${tracker.name}: queued write for ${item.ctx.ratingKey} already covered by a newer write', + ); + return TrackerWriteDisposition.done; + } catch (e) { + // Only an answer about this write may spend an attempt. A link that came + // back without reaching the endpoint, a rate limit, or the service failing + // on its own side are all reasons to ask again later — counting them would + // let a bad hour, or a few connectivity flaps, drop the watch for good. The + // drain also stops asking this service for the rest of the pass. + if (isTrackerFailureTransient(e)) { + appLogger.d('${tracker.name}: queued write deferred, service not taking writes', error: e); + return TrackerWriteDisposition.deferredService; + } + appLogger.d('${tracker.name}: queued write failed, will retry', error: e); + return TrackerWriteDisposition.failed; + } + } + Future _scrobble(TrackerScrobbleState state) { final ctx = _ctx; if (ctx == null) return Future.value(); - return _sendScrobble( - ctx, - state, - _timeline.progressPercent, - sessionStarted: _scrobbleStarted, - targets: _playbackTargets, - ); + return _sendScrobble(ctx, state, _timeline.progressPercent, _playbackTargets); } - /// [sessionStarted] and [targets] are passed in rather than read from the - /// fields because the terminal report is sent after [_reset] has already - /// cleared the per-playback state, on a snapshot of it. + /// [targets] is passed in rather than read from the field because the terminal + /// report is sent after [_reset] has already cleared the per-playback state, + /// on a snapshot of it. + /// + /// Progress is deliberately not floored: a session that did start must be + /// closed even at 0%, or the service keeps showing the item as playing until + /// its runtime elapses. Future _sendScrobble( TrackerContext ctx, TrackerScrobbleState state, - double progressPercent, { - required bool sessionStarted, - required List<_BoundScrobbleTarget> targets, - }) { - // A pause/stop with no session behind it would only invent one — that is the - // rolled-back player attempt, which never got as far as a start. Progress is - // deliberately not floored here: a session that did start must be closed - // even at 0%, or the service keeps showing the item as playing until its - // runtime elapses. - if (state != TrackerScrobbleState.start && !sessionStarted) return Future.value(); - + double progressPercent, + List<_PlaybackTarget> targets, + ) { + if (targets.isEmpty) return Future.value(); final now = _clock(); - final last = _lastScrobbleAt; - if (_lastScrobbleState == state && last != null) { - final elapsed = now.difference(last); - if (elapsed < _duplicateStateDebounce) return Future.value(); - if (state == TrackerScrobbleState.start && elapsed < _startResendThrottle) return Future.value(); + final sends = >[]; + for (final target in targets) { + // Still the same account, and still enabled for this library: the user can + // turn a tracker off or filter the library out mid-playback. + if (!_bindingIntact(target, 'scrobble ${state.name}')) continue; + if (!_canReport(target.tracker, ctx.libraryGlobalKey)) continue; + if (!target.accepts(state, now, debounce: _duplicateStateDebounce)) continue; + target.record(state, now); + sends.add(target.channel.enqueue(state, () => _report(target, ctx, state, progressPercent))); } - - // Still the same account, and still enabled for this library: the user can - // turn a tracker off or filter the library out mid-playback. - final sendable = [ - for (final target in targets) - if (_bindingIntact(target, 'scrobble ${state.name}') && _isActive(target.$1, ctx.libraryGlobalKey)) target, - ]; - if (sendable.isEmpty) return Future.value(); - - _lastScrobbleState = state; - _lastScrobbleAt = now; - if (state == TrackerScrobbleState.start) _scrobbleStarted = true; - - return _enqueueScrobble(state, () => _fanOutScrobble(sendable, ctx, state, progressPercent)); + return sends.isEmpty ? Future.value() : Future.wait(sends); } - /// Pins each active real-time tracker to the account binding it is being - /// captured against, once per playback. - List<_BoundScrobbleTarget> _activeRealtimeTargets(TrackerContext ctx) => [ - for (final t in _realtimeTrackers) - if (_isActive(t, ctx.libraryGlobalKey)) (t, t.scrobbleBinding), - ]; - - /// False when the tracker was rebound since [target] was captured: the write - /// belongs to an account that is no longer bound (and whose client has been - /// disposed), so it is dropped rather than misfiled onto its replacement. - bool _bindingIntact(_BoundScrobbleTarget target, String operation) { - final (tracker, binding) = target; - if (identical(tracker.scrobbleBinding, binding)) return true; - appLogger.d('${tracker.name}: skipped $operation — account rebound'); - return false; - } - - Future _fanOutScrobble( - List<_BoundScrobbleTarget> targets, + Future _report( + _PlaybackTarget target, TrackerContext ctx, TrackerScrobbleState state, double progressPercent, - ) { - return Future.wait( - targets.map((target) async { - if (!_bindingIntact(target, 'scrobble ${state.name}')) return; - final (tracker, _) = target; - try { - await tracker.scrobble(ctx, state, progressPercent); - } catch (e) { - // A tracker write must never disrupt playback. - appLogger.d('${tracker.name}: scrobble ${state.name} failed', error: e); - } - }), - ); + ) async { + if (!_bindingIntact(target, 'scrobble ${state.name}')) return; + try { + await target.tracker.scrobble(ctx, state, progressPercent); + target.confirmReported(state); + } catch (e) { + // A tracker write must never disrupt playback. + appLogger.d('${target.tracker.name}: scrobble ${state.name} failed', error: e); + } + } + + /// False when the tracker was rebound since [target] was pinned: the write + /// belongs to an account that is no longer bound (and whose client has been + /// disposed), so it is dropped rather than misfiled onto its replacement. + bool _bindingIntact(_PlaybackTarget target, String operation) { + if (!target.bindingChanged) return true; + appLogger.d('${target.tracker.name}: skipped $operation — account rebound'); + return false; } Future _reconcileWatchedAfterStop( - List<_BoundScrobbleTarget> reconcilers, + List<_PlaybackTarget> targets, TrackerContext ctx, double progressPercent, - ) { - return Future.wait( - reconcilers.map((target) async { + _WriteScope scope, + ) async { + if (targets.isEmpty) return; + await Future.wait( + targets.map((target) async { if (!_bindingIntact(target, 'watched reconciliation')) return; - final (tracker, _) = target; try { - await tracker.reconcileWatchedAfterStop(ctx, progressPercent); + await target.tracker.reconcileWatchedAfterStop(ctx, progressPercent); } catch (e) { - appLogger.d('${tracker.name}: reconcileWatchedAfterStop failed', error: e); + appLogger.d('${target.tracker.name}: reconcileWatchedAfterStop failed, queued for retry', error: e); + await _enqueueWrite(target.tracker, ctx, scope, watched: true); } }), ); } - /// Queue a report behind whatever is already going out. Overflow sheds the - /// oldest non-terminal entry so a stop for an item the player has already - /// swapped away from still reaches the service. - Future _enqueueScrobble(TrackerScrobbleState state, Future Function() send) { - _scrobbleQueue.add(_QueuedScrobble(state, send)); - if (_scrobbleQueue.length > _maxQueuedScrobbles) { - final victim = _scrobbleQueue.indexWhere((q) => q.state != TrackerScrobbleState.stop); - if (victim >= 0) _scrobbleQueue.removeAt(victim); - } - final draining = _scrobbleDrain; - if (draining != null) return draining; - final drain = _drainScrobbleQueue(); - _scrobbleDrain = drain; - return drain; - } - - Future _drainScrobbleQueue() async { - try { - while (_scrobbleQueue.isNotEmpty) { - await _scrobbleQueue.removeAt(0).send(); - } - } finally { - _scrobbleDrain = null; - } - } - /// Await every queued report so a terminal stop is on the wire before the /// caller (screen teardown, app shutdown) moves on. - Future _drainScrobbles() async { - var drain = _scrobbleDrain; - while (drain != null) { - await drain; - final next = _scrobbleDrain; - if (identical(next, drain)) break; - drain = next; - } - } + Future _settleScrobbles() => Future.wait(_channels.map((channel) => channel.settle())); Future _buildContext( MediaItem metadata, @@ -635,13 +914,145 @@ class _QueuedScrobble { const _QueuedScrobble(this.state, this.send); } -class _ManualAnimeProgress { +/// One real-time tracker's report transport. +/// +/// Reports are serialised per tracker, not globally: a service may accept one +/// write per user at a time (Simkl locks for 20 seconds and fails whatever +/// queued up behind it), while another has no such rule and must not wait on it. +class _ScrobbleChannel { + _ScrobbleChannel(this.tracker); + + final RealtimeScrobbleTracker tracker; + + /// Soft bound against a play/pause storm outrunning the remote lock: overflow + /// sheds the oldest non-terminal report. Terminal stops are never shed, so the + /// bound is deliberately soft — a burst of episode swaps behind one hung + /// request queues one stop per item, and each carries that item's own watch + /// and resume position. + static const int _maxQueued = 4; + + final List<_QueuedScrobble> _queue = []; + Future? _drain; + + Future enqueue(TrackerScrobbleState state, Future Function() send) { + _queue.add(_QueuedScrobble(state, send)); + if (_queue.length > _maxQueued) { + final victim = _queue.indexWhere((q) => q.state != TrackerScrobbleState.stop); + if (victim >= 0) _queue.removeAt(victim); + } + final draining = _drain; + if (draining != null) return draining; + final drain = _drainQueue(); + _drain = drain; + return drain; + } + + Future _drainQueue() async { + try { + while (_queue.isNotEmpty) { + await _queue.removeAt(0).send(); + } + } finally { + _drain = null; + } + } + + /// Await the pending drain, including reports enqueued while it ran. + Future settle() async { + var drain = _drain; + while (drain != null) { + await drain; + final next = _drain; + if (identical(next, drain)) break; + drain = next; + } + } + + void clear() => _queue.clear(); +} + +/// One real-time tracker pinned to the account it was bound to when the current +/// playback began, plus that playback's report bookkeeping. +/// +/// The bookkeeping is per playback *and* per tracker: two services can disagree +/// about whether a report is a duplicate, because their throttle windows are +/// their own. +class _PlaybackTarget { + _PlaybackTarget(this.channel, this.binding); + + final _ScrobbleChannel channel; + + /// Account identity captured at pin time, compared only by [identical]. + final Object? binding; + + TrackerScrobbleState? _lastState; + DateTime? _lastSentAt; + DateTime? _lastSeekAt; + bool _sessionStarted = false; + bool _stopConfirmed = false; + + RealtimeScrobbleTracker get tracker => channel.tracker; + + bool get sessionStarted => _sessionStarted; + + /// True once the service accepted this playback's terminal stop. Until it + /// does, nothing on that side has seen the item finish — so nothing there has + /// applied the service's own completion rule either. + bool get stopConfirmed => _stopConfirmed; + + /// Called after a report the service accepted. + void confirmReported(TrackerScrobbleState state) { + if (state == TrackerScrobbleState.stop) _stopConfirmed = true; + } + + bool get bindingChanged => !identical(tracker.scrobbleBinding, binding); + + /// Whether this tracker should hear about [state] now. + bool accepts(TrackerScrobbleState state, DateTime now, {required Duration debounce}) { + final policy = tracker.scrobblePolicy; + if (state == TrackerScrobbleState.seek) { + final throttle = policy.seekThrottle; + // A checkpoint only means something while the service believes playback is + // running, and only as often as the service tolerates. + if (throttle == null || _lastState != TrackerScrobbleState.start) return false; + final last = _lastSeekAt; + return last == null || now.difference(last) >= throttle; + } + // A pause/stop with no session behind it would only invent one — that is the + // rolled-back player attempt, which never got as far as a start. + if (state != TrackerScrobbleState.start && !_sessionStarted) return false; + if (_lastState != state) return true; + final last = _lastSentAt; + if (last == null) return true; + final elapsed = now.difference(last); + if (elapsed < debounce) return false; + return state != TrackerScrobbleState.start || elapsed >= policy.resendThrottle; + } + + void record(TrackerScrobbleState state, DateTime now) { + if (state == TrackerScrobbleState.seek) { + _lastSeekAt = now; + // A checkpoint leaves the session playing, so it counts as the latest + // start: a regular start straight after must not fire again. + _lastState = TrackerScrobbleState.start; + _lastSentAt = now; + return; + } + _lastState = state; + _lastSentAt = now; + if (state == TrackerScrobbleState.start) _sessionStarted = true; + } +} + +/// Aggregates a container's episodes into the single progress value a +/// series-progress tracker should end up with. +class _ManualSeriesProgress { final TrackerContext _base; final bool _fallbackToCount; int _count = 0; int? _maxMappedProgress; - _ManualAnimeProgress(this._base, {required this._fallbackToCount}); + _ManualSeriesProgress(this._base, {required this._fallbackToCount}); void add(TrackerContext ctx) { _count++; @@ -667,3 +1078,31 @@ class _ManualAnimeProgress { ); } } + +/// The profile a watched write belongs to. +/// +/// Captured before the write's first await and re-checked at every deferred +/// step: a profile switch mid-write must neither file one profile's retry under +/// another nor push a leftover row through the account that replaced it. +class _WriteScope { + final String userUuid; + final int generation; + + const _WriteScope(this.userUuid, this.generation); +} + +/// Intent bookkeeping for one remote row, alive only while writes for it are. +class _RowIntents { + /// Number handed to the most recent write to enter the row's channel. + int lastIntent = 0; + + /// The newest write that completed, and what it applied. [hasApplied] + /// distinguishes a completed non-claim write — null progress, covering the row + /// outright — from nothing having completed at all. + bool hasApplied = false; + int lastAppliedIntent = 0; + int? lastAppliedProgress; + + /// Writes still holding this row; the entry is dropped when it hits zero. + int pending = 0; +} diff --git a/lib/services/trackers/tracker_exceptions.dart b/lib/services/trackers/tracker_exceptions.dart index 1646efc5..349dd358 100644 --- a/lib/services/trackers/tracker_exceptions.dart +++ b/lib/services/trackers/tracker_exceptions.dart @@ -1,3 +1,8 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + import 'tracker_constants.dart'; enum TrackerApiFailureCategory { graphqlErrors } @@ -37,3 +42,34 @@ class TrackerRateLimitException implements Exception { @override String toString() => 'TrackerRateLimitException(${service.name}, retry-after: $retryAfterSeconds s)'; } + +/// True when a failed write says nothing about the write itself, so retrying it +/// later is the right answer and it must not spend one of a queued item's +/// attempts. +/// +/// Four shapes qualify: +/// +/// * The request never arrived — a timeout or transport error. A link coming +/// back up is no proof the endpoint is reachable, so counting these would let +/// a handful of connectivity flaps drop a watch. +/// * The service rate-limited us. Explicitly retryable, and typed only by Trakt +/// and AniList — MAL and Simkl surface a 429 as a plain +/// [TrackerApiException]. +/// * The service failed on its own side (5xx). A bad hour for a service is not +/// evidence that this watch is unwritable. +/// * A token refresh failed recoverably. [TrackerAuthException.isPermanent] is +/// the services' own verdict: false means the refresh endpoint misbehaved +/// (5xx, network), true means the session is genuinely dead. +/// +/// What is left counting is an answer *about the write*: 4xx says this item, on +/// this account, will not be written however often we ask. The consequence is +/// that a service returning 5xx indefinitely keeps its rows queued rather than +/// dropping them — deliberate, since the alternative is losing watches to an +/// outage, and rows coalesce per remote row so repeats do not accumulate. +bool isTrackerFailureTransient(Object error) { + if (error is TimeoutException || error is SocketException || error is http.ClientException) return true; + if (error is TrackerRateLimitException) return true; + if (error is TrackerAuthException) return !error.isPermanent; + if (error is TrackerApiException) return error.statusCode == 429 || error.statusCode >= 500; + return false; +} diff --git a/lib/services/trakt/trakt_page.dart b/lib/services/trackers/tracker_page.dart similarity index 55% rename from lib/services/trakt/trakt_page.dart rename to lib/services/trackers/tracker_page.dart index 9e517481..b88b806b 100644 --- a/lib/services/trakt/trakt_page.dart +++ b/lib/services/trackers/tracker_page.dart @@ -1,20 +1,21 @@ import 'package:http/http.dart' as http; -/// One page of a paginated Trakt response, parsed from the -/// `X-Pagination-*` headers. -class TraktPage { +/// One page of a paginated tracker response, parsed from the `X-Pagination-*` +/// headers. Shared by the services that paginate that way (Trakt, Simkl); MAL +/// paginates in the body and uses [MalPage] instead. +class TrackerPage { final List items; final int page; final int pageCount; final int? itemCount; - const TraktPage({required this.items, required this.page, required this.pageCount, required this.itemCount}); + const TrackerPage({required this.items, required this.page, required this.pageCount, required this.itemCount}); bool get hasMore => page < pageCount; /// Endpoints where pagination is optional omit the headers; default to a /// single page so callers never loop. - factory TraktPage.fromResponse(http.Response res, List items) => TraktPage( + factory TrackerPage.fromResponse(http.Response res, List items) => TrackerPage( items: items, page: int.tryParse(res.headers['x-pagination-page'] ?? '') ?? 1, pageCount: int.tryParse(res.headers['x-pagination-page-count'] ?? '') ?? 1, diff --git a/lib/services/trackers/tracker_write_queue.dart b/lib/services/trackers/tracker_write_queue.dart new file mode 100644 index 00000000..4b760199 --- /dev/null +++ b/lib/services/trackers/tracker_write_queue.dart @@ -0,0 +1,578 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; + +import '../../models/trackers/tracker_context.dart'; +import '../../profiles/profile.dart'; +import '../../utils/app_logger.dart'; +import '../../utils/external_ids.dart'; +import '../base_shared_preferences_service.dart'; +import 'tracker_constants.dart'; + +/// One watched/unwatched write that failed and is waiting for a retry. +/// +/// The full [TrackerContext] is stored, so a replay writes exactly the item the +/// original attempt was built for — no second metadata fetch, no re-resolution +/// against a library whose mapping may have moved on. +class TrackerWriteQueueItem { + final TrackerService service; + + /// True for a watched write, false for its removal. + final bool watched; + + final TrackerContext ctx; + + /// Identity this write coalesces on — the remote thing it changes. Two items + /// sharing a key are two statements about one remote value, and only the + /// surviving intent is kept (see [TrackerWriteQueue]). + final String coalesceKey; + + /// For a tracker that stores absolute progress: the value this write claims. + /// Null for per-item history writes and for removals, which are not claims. + final int? progressClaim; + + /// When the watch actually happened, so a replay days later is still filed + /// under the right date on services that accept a timestamp. + final String watchedAtIso; + + final int attempts; + + const TrackerWriteQueueItem({ + required this.service, + required this.watched, + required this.ctx, + required this.coalesceKey, + required this.watchedAtIso, + this.progressClaim, + this.attempts = 0, + }); + + DateTime? get watchedAt => DateTime.tryParse(watchedAtIso); + + TrackerWriteQueueItem incrementAttempts() => TrackerWriteQueueItem( + service: service, + watched: watched, + ctx: ctx, + coalesceKey: coalesceKey, + progressClaim: progressClaim, + watchedAtIso: watchedAtIso, + attempts: attempts + 1, + ); + + Map toJson() => { + 'service': service.name, + 'watched': watched, + 'ctx': ctx.toJson(), + 'coalesceKey': coalesceKey, + if (progressClaim != null) 'progressClaim': progressClaim, + 'watchedAtIso': watchedAtIso, + 'attempts': attempts, + }; + + factory TrackerWriteQueueItem.fromJson(Map json) => TrackerWriteQueueItem( + service: TrackerService.values.firstWhere( + (s) => s.name == json['service'], + orElse: () => throw ArgumentError('Unknown TrackerService: ${json['service']}'), + ), + watched: json['watched'] as bool, + ctx: TrackerContext.fromJson((json['ctx'] as Map).cast()), + coalesceKey: json['coalesceKey'] as String, + progressClaim: (json['progressClaim'] as num?)?.toInt(), + watchedAtIso: json['watchedAtIso'] as String, + attempts: (json['attempts'] as num?)?.toInt() ?? 0, + ); +} + +/// What the drain should do with an item after the sender looked at it. +enum TrackerWriteDisposition { + /// Written, or no longer applicable (library filtered out) — drop it. + done, + + /// The write was attempted and failed; keep it and count the attempt. + failed, + + /// Attempted, and the service is not taking writes right now — it rate-limited + /// us, failed on its own side, or could not be reached. The item is kept + /// untouched and every remaining row for that service is left for a later + /// drain, so one back-off answer cannot turn a full queue into a burst of + /// requests at a service already asking for quiet. + deferredService, + + /// Not attempted at all — the service has no session, or the active profile + /// moved while the drain was running. Kept untouched so nothing burns an item's + /// retries and no row is written through the wrong account. + skipped, +} + +/// Per-profile persisted retry queue for failed tracker watched writes, shared +/// by every service. +/// +/// Ordering here is intent-based, not temporal, because a tracker write is not +/// an increment: +/// +/// * A per-item history write (Simkl, Trakt) states "this item is watched" or +/// "is not". The newest statement about an item replaces any queued one, so a +/// failed watched write can never replay on top of a later successful +/// un-watch. +/// * A series-progress write (MAL, AniList) claims "this entry is at least at +/// progress N". Claims are monotonic: two queued claims for one entry coalesce +/// to the higher, and [invalidate] drops any claim a completed write already +/// covers. Without that a queued episode 5 could land after episode 6 +/// succeeded and walk the list backwards. +/// +/// Items carry their own [TrackerWriteQueueItem.service], so one disconnected +/// tracker never blocks another's replay, and are dropped after [maxAttempts] +/// tries — matching `OfflineWatchSyncService.maxSyncAttempts`. +/// +/// All state changes run under one Completer chain, so concurrent enqueues never +/// interleave read-modify-write and lose items. +class TrackerWriteQueue { + static const String _baseKey = 'tracker_write_queue'; + + /// Pre-consolidation key holding Trakt-only items. Converted once per profile + /// so pushes queued by an older build are not silently dropped. + static const String _legacyTraktKey = 'trakt_sync_queue'; + + static const int maxAttempts = 5; + + /// Inter-request delay during a drain, to stay under Trakt's + /// 1000 requests / 5 minutes budget. + static const Duration _requestSpacing = Duration(milliseconds: 50); + + /// Bound for items whose disk write threw (disk full, revoked SAF + /// permission). Keyed by profile so a profile switch cannot replay one user's + /// failed writes through another user's account; oldest drop first. + static const int _maxInMemoryFallback = 100; + + final Map> _inMemoryFallbackByUser = {}; + final Set _migratedUsers = {}; + + /// Coalesce keys known to be queued per profile, so the common "nothing + /// pending for this item" case costs a set lookup instead of a disk read. A + /// profile with no entry here has not been loaded yet and is never assumed + /// empty. + final Map> _pendingKeysByUser = {}; + + /// Rows a direct write has landed on whose queued rows are not cleaned up yet, + /// keyed by coalesce key. A marker lives only for the interval between a + /// successful direct write and its [invalidate] finishing — precisely the + /// window in which a drain can already be holding a row that write covers. + final Map _appliedByKey = {}; + int _appliedToken = 0; + + Future _writeLock = Future.value(); + Future? _flushFuture; + String? _flushUserUuid; + bool _flushRequested = false; + + Future _locked(Future Function() action) { + final previous = _writeLock; + final completer = Completer(); + _writeLock = completer.future; + return previous.then((_) => action()).whenComplete(completer.complete); + } + + Future> load(String userUuid) async { + await _migrateLegacyTraktQueue(userUuid); + final prefs = await BaseSharedPreferencesService.sharedCache(); + final key = profileScopedPrefsKey(userUuid, _baseKey); + final raw = prefs.getString(key); + if (raw == null) { + _trackPending(userUuid, const []); + return []; + } + try { + final list = json.decode(raw) as List; + final items = list.map((e) => TrackerWriteQueueItem.fromJson(e as Map)).toList(); + _trackPending(userUuid, items); + return items; + } catch (e, st) { + appLogger.e('Tracker write queue parse failed, discarding', error: e, stackTrace: st); + await prefs.setString(profileScopedPrefsKey(userUuid, '${_baseKey}_corrupt'), raw); + await prefs.remove(key); + _trackPending(userUuid, const []); + return []; + } + } + + void _trackPending(String userUuid, List items) { + _pendingKeysByUser[userUuid] = {for (final item in items) item.coalesceKey}; + } + + Future _save(String userUuid, List items) async { + final prefs = await BaseSharedPreferencesService.sharedCache(); + final key = profileScopedPrefsKey(userUuid, _baseKey); + _trackPending(userUuid, items); + if (items.isEmpty) { + await prefs.remove(key); + } else { + await prefs.setString(key, json.encode(items.map((e) => e.toJson()).toList())); + } + } + + /// Persist [item] as the surviving intent for its coalesce key; fall back to a + /// bounded in-memory buffer when the disk write throws. Buffered items are + /// retried at the start of the next [flush]. + Future enqueue(String userUuid, TrackerWriteQueueItem item) async { + try { + await _locked(() async { + final items = await load(userUuid); + final claim = item.progressClaim; + if (claim != null && + items.any((queued) => queued.coalesceKey == item.coalesceKey && (queued.progressClaim ?? -1) > claim)) { + // A higher claim for the same entry is already waiting; this one would + // only walk it backwards. + return; + } + items.removeWhere((queued) => queued.coalesceKey == item.coalesceKey); + items.add(item); + await _save(userUuid, items); + }); + } catch (e, st) { + appLogger.e( + 'Tracker write queue: persist failed for ${item.service.name} ${item.ctx.ratingKey}, buffering in memory', + error: e, + stackTrace: st, + ); + final fallback = _inMemoryFallbackByUser.putIfAbsent(userUuid, Queue.new); + if (fallback.length >= _maxInMemoryFallback) { + final dropped = fallback.removeFirst(); + appLogger.w('Tracker write queue: in-memory buffer full, dropping ${dropped.service.name}'); + } + fallback.addLast(item); + } + } + + /// Drop queued writes a completed direct write has superseded. + /// + /// [appliedProgress] null means the write superseded the key outright (a + /// history add/remove, or a removed series entry). Otherwise only claims at or + /// below the applied progress are covered; a queued higher claim is still a + /// pending advance and survives. + Future invalidate(String userUuid, String coalesceKey, {int? appliedProgress}) async { + // A profile whose queue has never been loaded is not assumed empty. + if (_pendingKeysByUser[userUuid]?.contains(coalesceKey) == false) return; + await _locked(() async { + final items = await load(userUuid); + final before = items.length; + items.removeWhere( + (queued) => queued.coalesceKey == coalesceKey && covers(appliedProgress: appliedProgress, item: queued), + ); + if (items.length == before) return; + appLogger.d('Tracker write queue: dropped superseded $coalesceKey'); + await _save(userUuid, items); + }); + } + + /// Whether a completed write that applied [appliedProgress] covers [item]. + static bool covers({required int? appliedProgress, required TrackerWriteQueueItem item}) => + coversClaim(appliedProgress: appliedProgress, claim: item.progressClaim); + + /// Whether a completed write that applied [appliedProgress] covers a write + /// claiming [claim]. + /// + /// A null [appliedProgress] is not a progress claim — a history add/remove or + /// an entry removal — and covers the row outright. A null [claim] is likewise + /// not monotonic, so anything completing after it covers it. + static bool coversClaim({required int? appliedProgress, required int? claim}) { + if (appliedProgress == null) return true; + return claim == null || claim <= appliedProgress; + } + + /// Record a direct write that just landed for [userUuid], returning a token + /// that identifies this marker. + /// + /// [invalidate] alone cannot close the race it guards: a drain that has already + /// read a row calls its sender regardless, and the newer write's invalidation + /// may still be waiting for the queue lock the drain holds. The marker makes + /// that row visibly covered for exactly the interval between the write landing + /// and its invalidation finishing — [clearDirectWrite] ends it. + int noteDirectWrite(String userUuid, String coalesceKey, {int? appliedProgress}) { + final token = ++_appliedToken; + _appliedByKey[_markerKey(userUuid, coalesceKey)] = _AppliedWrite(token, appliedProgress); + return token; + } + + /// Drop the marker [token] created for [coalesceKey]. A newer overlapping + /// write's marker holds a different token and survives. + void clearDirectWrite(String userUuid, String coalesceKey, int token) { + final key = _markerKey(userUuid, coalesceKey); + if (_appliedByKey[key]?.token == token) _appliedByKey.remove(key); + } + + /// True when a direct write for this profile already covers [item], so + /// replaying it would undo newer state. + /// + /// Scoped per profile: the same film watched under two profiles is two + /// independent remote rows, and one profile's pending write must never make the + /// other's queued row look redundant. + bool isSuperseded(String userUuid, TrackerWriteQueueItem item) { + final applied = _appliedByKey[_markerKey(userUuid, item.coalesceKey)]; + return applied != null && covers(appliedProgress: applied.progress, item: item); + } + + static String _markerKey(String userUuid, String coalesceKey) => '$userUuid|$coalesceKey'; + + /// Drain [userUuid]'s queue through [send]. + /// + /// Concurrent calls for the same profile coalesce: a flush requested while one + /// runs re-runs the loop once instead of interleaving two drains over the same + /// items. A call for a different profile waits its turn instead, so two + /// profiles' drains never merge. + Future flush(String userUuid, {required Future Function(TrackerWriteQueueItem) send}) { + final active = _flushFuture; + if (active != null) { + if (_flushUserUuid == userUuid) { + _flushRequested = true; + return active; + } + return active.then((_) => flush(userUuid, send: send)); + } + final future = _runFlushLoop(userUuid, send); + _flushFuture = future; + _flushUserUuid = userUuid; + return future; + } + + Future _runFlushLoop( + String userUuid, + Future Function(TrackerWriteQueueItem) send, + ) async { + // Spans every pass of this loop, not just one: a flush requested while the + // first pass ran re-enters immediately, and a service that just asked us to + // back off must not be asked again in that same burst. + final deferredServices = {}; + try { + do { + _flushRequested = false; + await _flushOnce(userUuid, send, deferredServices); + } while (_flushRequested); + } finally { + _flushFuture = null; + _flushUserUuid = null; + if (_flushRequested) { + scheduleMicrotask(() { + unawaited( + flush(userUuid, send: send).catchError((Object e, StackTrace st) { + appLogger.w('Tracker write queue: follow-up flush failed', error: e, stackTrace: st); + }), + ); + }); + } + } + } + + /// Holds the write lock for the whole cycle so concurrent enqueues wait until + /// the drain has saved its remainder (no lost items). Items are attempted in + /// insertion order, which is the order the surviving intents were expressed. + /// + /// Once a service answers [TrackerWriteDisposition.deferredService], the rest of + /// its rows are left untouched without a request. A queue holding many rows for + /// one service would otherwise fire all of them at a service that has just told + /// us to back off, deepening a rate limit. Other services keep draining. + /// + /// [deferredServices] is owned by the caller so the deferral spans a coalesced + /// flush burst, not one pass. That is enough because drains are driven by + /// user-scale events — profile bind, connect, foreground, network restore — not + /// a timer; a timer-driven flush would need the advertised retry-after window + /// persisted instead. + Future _flushOnce( + String userUuid, + Future Function(TrackerWriteQueueItem) send, + Set deferredServices, + ) async { + await _recoverInMemoryFallback(userUuid); + await _locked(() async { + final items = await load(userUuid); + if (items.isEmpty) return; + final remaining = []; + for (final item in items) { + if (item.attempts >= maxAttempts) { + appLogger.w( + 'Tracker write queue: dropping ${item.service.name} ${item.ctx.ratingKey} after ${item.attempts} attempts', + ); + continue; + } + if (deferredServices.contains(item.service)) { + remaining.add(item); + continue; + } + final disposition = await send(item); + switch (disposition) { + case TrackerWriteDisposition.done: + await Future.delayed(_requestSpacing); + case TrackerWriteDisposition.failed: + remaining.add(item.incrementAttempts()); + await Future.delayed(_requestSpacing); + case TrackerWriteDisposition.deferredService: + deferredServices.add(item.service); + remaining.add(item); + case TrackerWriteDisposition.skipped: + remaining.add(item); + } + } + await _save(userUuid, remaining); + }); + } + + /// Move items buffered because a prior disk write failed back onto the + /// persistent queue. Best-effort: anything that still won't persist stays + /// buffered for the next flush. + Future _recoverInMemoryFallback(String userUuid) async { + final fallback = _inMemoryFallbackByUser[userUuid]; + if (fallback == null || fallback.isEmpty) return; + final snapshot = List.from(fallback); + fallback.clear(); + _inMemoryFallbackByUser.remove(userUuid); + for (final item in snapshot) { + await enqueue(userUuid, item); + } + } + + /// Convert the pre-consolidation Trakt-only queue into shared items. + /// + /// The converted payload is written before the legacy key is dropped, so a + /// failed write leaves the pending writes where they are for the next attempt + /// rather than losing them. That ordering means a re-run is possible, so the + /// merge replaces rows sharing a converted row's coalesce key instead of + /// appending duplicates. + Future _migrateLegacyTraktQueue(String userUuid) async { + if (_migratedUsers.contains(userUuid)) return; + final prefs = await BaseSharedPreferencesService.sharedCache(); + final legacyKey = profileScopedPrefsKey(userUuid, _legacyTraktKey); + final raw = prefs.getString(legacyKey); + if (raw == null) { + _migratedUsers.add(userUuid); + return; + } + + final List converted; + try { + converted = [ + for (final entry in json.decode(raw) as List) + ?_legacyTraktItem((entry as Map).cast()), + ]; + } catch (e, st) { + // Unreadable: drop it, or every later load would retry the same failure. + appLogger.e('Tracker write queue: legacy Trakt queue unreadable, discarding', error: e, stackTrace: st); + await prefs.remove(legacyKey); + _migratedUsers.add(userUuid); + return; + } + + try { + if (converted.isNotEmpty) { + // A corrupt payload already under the shared key must not sink the + // migration: archive it the way [load] would and merge into a clean list. + final key = profileScopedPrefsKey(userUuid, _baseKey); + final existingRaw = prefs.getString(key); + var existing = const []; + if (existingRaw != null) { + try { + existing = json.decode(existingRaw) as List; + } catch (e, st) { + appLogger.e( + 'Tracker write queue: unreadable payload during migration, archiving', + error: e, + stackTrace: st, + ); + await prefs.setString(profileScopedPrefsKey(userUuid, '${_baseKey}_corrupt'), existingRaw); + } + } + final convertedKeys = {for (final item in converted) item.coalesceKey}; + final merged = [ + for (final row in existing) + if (!(row is Map && convertedKeys.contains(row['coalesceKey']))) row, + ...converted.map((e) => e.toJson()), + ]; + await prefs.setString(key, json.encode(merged)); + } + // Only once the converted rows are durable. + await prefs.remove(legacyKey); + _migratedUsers.add(userUuid); + appLogger.i('Tracker write queue: migrated ${converted.length} legacy Trakt writes'); + } catch (e, st) { + appLogger.e( + 'Tracker write queue: legacy Trakt migration could not be persisted, keeping it for the next attempt', + error: e, + stackTrace: st, + ); + } + } + + /// Null when the row names no remote media — nothing could have been written + /// for it, so there is nothing to retry. + static TrackerWriteQueueItem? _legacyTraktItem(Map json) { + final external = ExternalIds.fromJson((json['ids'] as Map).cast()); + final ratingKey = json['ratingKey'] as String; + final libraryGlobalKey = json['libraryGlobalKey'] as String?; + final ctx = json['kind'] == 'movie' + ? TrackerContext.movie( + external: external, + anime: null, + ratingKey: ratingKey, + libraryGlobalKey: libraryGlobalKey, + ) + : TrackerContext.episode( + external: external, + anime: null, + ratingKey: ratingKey, + libraryGlobalKey: libraryGlobalKey, + season: (json['season'] as num).toInt(), + episodeNumber: (json['number'] as num).toInt(), + ); + // The legacy queue was Trakt-only, and Trakt identifies a row by the media + // server's external ids. + final coalesceKey = trackerItemCoalesceKey(TrackerService.trakt, ctx, trackerExternalRowIdentity(external)); + if (coalesceKey == null) return null; + return TrackerWriteQueueItem( + service: TrackerService.trakt, + watched: json['op'] == 'add', + ctx: ctx, + coalesceKey: coalesceKey, + watchedAtIso: json['watchedAtIso'] as String, + attempts: (json['attempts'] as num?)?.toInt() ?? 0, + ); + } +} + +/// Coalesce key for a per-item history write: the remote media row it changes, on +/// that service. +/// +/// [rowIdentity] comes from [EpisodeHistoryTracker.historyRowIdentity]; null means +/// the service cannot name a row, so no write could have applied either. +String? trackerItemCoalesceKey(TrackerService service, TrackerContext ctx, String? rowIdentity) { + if (rowIdentity == null) return null; + final coordinate = ctx.isMovie ? 'movie' : 's${ctx.season}e${ctx.episodeNumber}'; + return '${service.name}|$coordinate|$rowIdentity'; +} + +/// The media server's external ids in a fixed preference order, as one stable +/// identifier. +/// +/// One id rather than all of them: which ids an item exposes varies by server and +/// by whether an anime mapping has been downloaded, and a key that moved with that +/// would stop matching rows already queued. +String? trackerExternalRowIdentity(ExternalIds ids) { + if (ids.imdb case final imdb?) return 'imdb=$imdb'; + if (ids.tmdb case final tmdb?) return 'tmdb=$tmdb'; + if (ids.tvdb case final tvdb?) return 'tvdb=$tvdb'; + return null; +} + +/// Coalesce key for a series-progress write: the remote list entry on that +/// service, because every episode of one show restates the same counter. +String trackerSeriesCoalesceKey(TrackerService service, Object entryId) => '${service.name}|series|$entryId'; + +/// A direct write whose queued rows have not been cleaned up yet. +class _AppliedWrite { + /// Identifies this marker so a newer overlapping write's marker is not cleared + /// by an older write finishing its invalidation. + final int token; + + /// Progress the write applied, or null when it was not a progress claim and so + /// covers the row outright. + final int? progress; + + const _AppliedWrite(this.token, this.progress); +} diff --git a/lib/services/trakt/trakt_auth_service.dart b/lib/services/trackers/trakt/trakt_auth_service.dart similarity index 91% rename from lib/services/trakt/trakt_auth_service.dart rename to lib/services/trackers/trakt/trakt_auth_service.dart index 43db6dcf..8c9c82be 100644 --- a/lib/services/trakt/trakt_auth_service.dart +++ b/lib/services/trackers/trakt/trakt_auth_service.dart @@ -2,12 +2,12 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import '../../models/trackers/device_code.dart'; -import '../../utils/abortable_http_request.dart'; -import '../../utils/app_logger.dart'; -import '../trackers/device_code_auth_service.dart'; -import '../trackers/tracker_constants.dart'; -import '../trackers/tracker_session.dart'; +import '../../../models/trackers/device_code.dart'; +import '../../../utils/abortable_http_request.dart'; +import '../../../utils/app_logger.dart'; +import '../device_code_auth_service.dart'; +import '../tracker_constants.dart'; +import '../tracker_session.dart'; import 'trakt_constants.dart'; /// Trakt OAuth Device Authorization Grant flow (RFC 8628). diff --git a/lib/services/trakt/trakt_client.dart b/lib/services/trackers/trakt/trakt_client.dart similarity index 91% rename from lib/services/trakt/trakt_client.dart rename to lib/services/trackers/trakt/trakt_client.dart index 3fa2c030..98844ac1 100644 --- a/lib/services/trakt/trakt_client.dart +++ b/lib/services/trackers/trakt/trakt_client.dart @@ -3,20 +3,20 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import '../../models/trakt/trakt_cast_entry.dart'; -import '../../models/trakt/trakt_catalog_entry.dart'; -import '../../models/trakt/trakt_catalog_media.dart'; -import '../../models/trakt/trakt_scrobble_request.dart'; -import '../../models/trakt/trakt_user.dart'; -import '../../utils/app_logger.dart'; -import '../trackers/future_coalescer.dart'; -import '../trackers/tracker.dart'; -import '../trackers/tracker_constants.dart'; -import '../trackers/tracker_exceptions.dart'; -import '../trackers/tracker_http_client.dart'; -import '../trackers/tracker_session.dart'; +import '../../../models/trakt/trakt_cast_entry.dart'; +import '../../../models/trakt/trakt_catalog_entry.dart'; +import '../../../models/trakt/trakt_catalog_media.dart'; +import '../../../models/trakt/trakt_scrobble_request.dart'; +import '../../../models/trakt/trakt_user.dart'; +import '../../../utils/app_logger.dart'; +import '../future_coalescer.dart'; +import '../tracker.dart'; +import '../tracker_constants.dart'; +import '../tracker_exceptions.dart'; +import '../tracker_http_client.dart'; +import '../tracker_session.dart'; import 'trakt_constants.dart'; -import 'trakt_page.dart'; +import '../tracker_page.dart'; /// HTTP wrapper for the Trakt REST API. /// @@ -93,7 +93,7 @@ class TraktClient implements DisposableTrackerClient { /// types mixed, in the user's rank order. Pagination is currently optional /// on this endpoint; sending page/limit makes Trakt echo X-Pagination /// headers. - Future> getWatchlist({ + Future> getWatchlist({ TraktCatalogType? type, String sort = 'added', int page = 1, @@ -101,21 +101,21 @@ class TraktClient implements DisposableTrackerClient { }) async { final path = type == null ? '/sync/watchlist' : '/sync/watchlist/${type.name}/$sort'; final res = await _requestResponse('GET', '$path?$_catalogExtended&page=$page&limit=$limit'); - return TraktPage.fromResponse(res, _decodeEntries(res.body)); + return TrackerPage.fromResponse(res, _decodeEntries(res.body)); } /// Items are wrapped as `{watchers, movie|show}`. Public endpoint, but sent /// authenticated: the tab only exists with a session and per-user rate /// limiting is cleaner than app-level. - Future> getTrending(TraktCatalogType type, {int page = 1, int limit = 25}) async { + Future> getTrending(TraktCatalogType type, {int page = 1, int limit = 25}) async { final res = await _requestResponse('GET', '/${type.name}/trending?$_catalogExtended&page=$page&limit=$limit'); - return TraktPage.fromResponse(res, _decodeEntries(res.body)); + return TrackerPage.fromResponse(res, _decodeEntries(res.body)); } /// Returns bare movie/show objects (not wrapped like trending). - Future> getPopular(TraktCatalogType type, {int page = 1, int limit = 25}) async { + Future> getPopular(TraktCatalogType type, {int page = 1, int limit = 25}) async { final res = await _requestResponse('GET', '/${type.name}/popular?$_catalogExtended&page=$page&limit=$limit'); - return TraktPage.fromResponse(res, _decodeMedia(res.body)); + return TrackerPage.fromResponse(res, _decodeMedia(res.body)); } /// Personalized recommendations. OAuth-required, limit-only (no pagination). @@ -135,12 +135,12 @@ class TraktClient implements DisposableTrackerClient { /// Title search across movies and shows (`GET /search/movie,show`). /// Results are wrapped `{type, score, movie|show}` like watchlist entries. - Future> searchCatalog(String query, {int page = 1, int limit = 25}) async { + Future> searchCatalog(String query, {int page = 1, int limit = 25}) async { final res = await _requestResponse( 'GET', '/search/movie,show?query=${Uri.encodeQueryComponent(query)}&$_catalogExtended&page=$page&limit=$limit', ); - return TraktPage.fromResponse(res, _decodeEntries(res.body)); + return TrackerPage.fromResponse(res, _decodeEntries(res.body)); } /// Similar titles (`GET /{movies|shows}/{id}/related`) — bare media diff --git a/lib/services/trakt/trakt_constants.dart b/lib/services/trackers/trakt/trakt_constants.dart similarity index 71% rename from lib/services/trakt/trakt_constants.dart rename to lib/services/trackers/trakt/trakt_constants.dart index 6afc0304..205e2ce1 100644 --- a/lib/services/trakt/trakt_constants.dart +++ b/lib/services/trackers/trakt/trakt_constants.dart @@ -37,28 +37,6 @@ class TraktConstants { } } -/// Scrobble lifecycle state sent to Trakt's `/scrobble/{name}` endpoints. -enum TraktScrobbleState { start, pause, stop } - /// Catalog list flavor for Trakt's discover/watchlist endpoints, named after /// the URL path segment (`/movies/trending`, `/sync/watchlist/shows/...`). enum TraktCatalogType { movies, shows } - -/// Direction of a watched-status sync push. -enum TraktSyncOp { - add, - remove; - - static TraktSyncOp fromName(String name) => - values.firstWhere((v) => v.name == name, orElse: () => throw ArgumentError('Unknown TraktSyncOp: $name')); -} - -/// Trakt-relevant media types. Accepts the neutral [MediaKind.id] string -/// (`'movie'`, `'episode'`) used across both Plex and Jellyfin watch events. -enum TraktMediaKind { - movie, - episode; - - static TraktMediaKind fromName(String name) => - values.firstWhere((v) => v.name == name, orElse: () => throw ArgumentError('Unknown TraktMediaKind: $name')); -} diff --git a/lib/services/trackers/trakt/trakt_tracker.dart b/lib/services/trackers/trakt/trakt_tracker.dart new file mode 100644 index 00000000..aa69f44c --- /dev/null +++ b/lib/services/trackers/trakt/trakt_tracker.dart @@ -0,0 +1,284 @@ +import 'package:http/http.dart' as http; + +import '../../../media/media_kind.dart'; +import '../../../models/trackers/tracker_context.dart'; +import '../../../models/trakt/trakt_ids.dart'; +import '../../../models/trakt/trakt_scrobble_request.dart'; +import '../../../utils/app_logger.dart'; +import '../../../utils/json_utils.dart'; +import '../../settings_service.dart'; +import '../tracker.dart'; +import '../tracker_constants.dart'; +import '../tracker_id_resolver.dart'; +import '../tracker_rating_match.dart'; +import '../tracker_session.dart'; +import '../tracker_write_queue.dart'; +import 'trakt_client.dart'; + +/// Trakt tracker. +/// +/// In-player playback is reported in real time through `POST /scrobble/start`, +/// `/pause` and `/stop`; Trakt's own rule then decides watched state — a `stop` +/// at or above 80% progress records a play, below that it stores a resumable +/// position. `POST /sync/history` covers the marks that never pass through the +/// player: manual, container, offline replay, external players, and watch state +/// Plezy observes changing on the server. +/// +/// Unlike the other services Trakt splits its user settings in two: the +/// scrobble toggle gates real-time reports ([canReportPlayback]) and a separate +/// watched-sync toggle gates history writes ([canWriteWatched]). +class TraktTracker extends TrackerBase + with ClientBackedTracker + implements TrackerRatingSource, RealtimeScrobbleTracker, EpisodeHistoryTracker { + static TraktTracker? _instance; + static TraktTracker get instance => _instance ??= TraktTracker._(); + TraktTracker._(); + + @override + String get name => 'trakt'; + + @override + TrackerService get service => TrackerService.trakt; + + @override + bool get needsFribb => false; + + /// Trakt counts a `/scrobble/stop` as a watch from this progress upwards. + static const double _scrobbleWatchedPercent = 80.0; + + /// The bound client is replaced on every session rebind, so its identity is + /// the account identity. + @override + Object? get scrobbleBinding => client; + + @override + bool get canReportPlayback => isEnabledWithSession; + + bool _watchedSyncEnabled = false; + + @override + bool get canWriteWatched => _watchedSyncEnabled && hasActiveClient; + + @override + ScrobblePolicy get scrobblePolicy => const ScrobblePolicy( + // Trakt allows one scrobble per item per 15 minutes and 409s the rest, so a + // re-sent `start` waits out this window instead of collecting conflicts. + resendThrottle: Duration(seconds: 30), + // A slider drag emits many position updates; only one checkpoint per window + // reaches Trakt. + seekThrottle: Duration(seconds: 5), + ); + + @override + Future initialize() async { + await super.initialize(); + final settings = await SettingsService.getInstance(); + _watchedSyncEnabled = settings.read(SettingsService.enableTraktWatchedSync); + } + + /// Paired with the watched-sync settings toggle, mirroring [setEnabled] for + /// the scrobble toggle. + Future setWatchedSyncEnabled(bool enabled) async { + _watchedSyncEnabled = enabled; + } + + void rebindSession( + TrackerSession? session, { + required void Function() onSessionInvalidated, + void Function(TrackerSession session)? onSessionUpdated, + http.Client? httpClient, + }) { + rebindTrackerClient( + session, + createClient: (session) => TraktClient( + session, + onSessionInvalidated: onSessionInvalidated, + onSessionUpdated: onSessionUpdated, + httpClient: httpClient, + ), + ); + } + + /// Trakt matches on the media server's own external ids and nothing else — the + /// anime mappings other trackers use never reach its requests. + @override + String? historyRowIdentity(TrackerContext ctx) => trackerExternalRowIdentity(ctx.external); + + /// Push a rotated token pair into the live client instead of rebuilding it — + /// a second client would race the next refresh. + void updateSession(TrackerSession session) => client?.updateSession(session); + + @override + Future markWatched(TrackerContext ctx, {DateTime? watchedAt}) async { + final client = this.client; + if (client == null || !canWriteWatched) return; + final body = _requestFor(ctx); + if (body == null) return; + + await client.addToHistory(body, watchedAt: watchedAt?.toUtc().toIso8601String()); + appLogger.d('Trakt: marked watched (${ctx.ratingKey}, isMovie=${ctx.isMovie})'); + } + + @override + Future markUnwatched(TrackerContext ctx) async { + final client = this.client; + if (client == null || !canWriteWatched) return; + final body = _requestFor(ctx); + if (body == null) return; + + await client.removeFromHistory(body); + appLogger.d('Trakt: marked unwatched (${ctx.ratingKey}, isMovie=${ctx.isMovie})'); + } + + @override + Future scrobble(TrackerContext ctx, TrackerScrobbleState state, double progressPercent) async { + final client = this.client; + if (client == null) return; + final body = _requestFor(ctx)?.copyWith(progress: progressPercent); + if (body == null) return; + + switch (state) { + case TrackerScrobbleState.start: + await client.scrobbleStart(body); + case TrackerScrobbleState.pause: + await client.scrobblePause(body); + case TrackerScrobbleState.seek: + // Trakt has no seek event. Official clients checkpoint with pause+start + // at the new position; without it "resume on another device" stays stuck + // on the pre-seek position until the next pause or stop. + await client.scrobblePause(body); + await client.scrobbleStart(body); + case TrackerScrobbleState.stop: + await client.scrobbleStop(body); + } + appLogger.d('Trakt: scrobble ${state.name} @ ${progressPercent.toStringAsFixed(1)}%'); + } + + @override + Future reconcileWatchedAfterStop(TrackerContext ctx, double progressPercent) async { + // At or above Trakt's own rule the stop already recorded the play; a history + // write would record a second one. + if (progressPercent >= _scrobbleWatchedPercent) return; + appLogger.d('Trakt: stop below ${_scrobbleWatchedPercent.toStringAsFixed(0)}% — recording watch explicitly'); + await markWatched(ctx); + } + + /// Trakt matches an episode through the show's ids plus the aired + /// season/episode index — that shape works even when the episode itself is not + /// in its catalog yet. Null when the item carries no usable ids. + TraktScrobbleRequest? _requestFor(TrackerContext ctx) { + final ids = TraktIds.fromExternal(ctx.external); + if (!ids.hasAny) return null; + if (ctx.isMovie) return TraktScrobbleRequest.movie(ids: ids); + + final season = ctx.season; + final number = ctx.episodeNumber; + if (season == null || number == null) return null; + return TraktScrobbleRequest.episode(showIds: ids, season: season, number: number); + } + + @override + Future getRating(TrackerRatingContext ctx) async { + final client = this.client; + if (client == null) throw const TrackerRatingUnavailableException('Trakt'); + final localIds = TraktIds.fromExternal(ctx.ids.external).toJson(); + if (localIds.isEmpty) throw const TrackerRatingUnavailableException('Trakt'); + + final entries = await client.getRatings(_ratingType(ctx)); + for (final entry in entries) { + if (entry is! Map) continue; + if (!_ratingEntryMatches(ctx, entry.cast(), localIds)) continue; + final rating = flexibleInt(entry['rating']); + return rating != null && rating > 0 ? rating.clamp(1, 10).toInt() : null; + } + return null; + } + + @override + Future rate(TrackerRatingContext ctx, int score) async { + final client = this.client; + if (client == null) throw const TrackerRatingUnavailableException('Trakt'); + await client.addRatings(_ratingBody(ctx, rating: score.clamp(1, 10).toInt())); + } + + @override + Future clearRating(TrackerRatingContext ctx) async { + final client = this.client; + if (client == null) throw const TrackerRatingUnavailableException('Trakt'); + await client.removeRatings(_ratingBody(ctx)); + } + + String _ratingType(TrackerRatingContext ctx) => switch (ctx.kind) { + MediaKind.movie => 'movies', + MediaKind.show => 'shows', + MediaKind.season => 'seasons', + MediaKind.episode => 'episodes', + _ => throw const TrackerRatingUnavailableException('Trakt'), + }; + + bool _ratingEntryMatches(TrackerRatingContext ctx, Map entry, Map localIds) { + final show = entry['show']; + final movie = entry['movie']; + return switch (ctx.kind) { + MediaKind.movie => trackerIdsMatch(trackerNestedIds(movie), localIds), + MediaKind.show => trackerIdsMatch(trackerNestedIds(show), localIds), + MediaKind.season => + trackerIdsMatch(trackerNestedIds(show), localIds) && _numberMatches(entry['season'], ctx.season), + MediaKind.episode => + trackerIdsMatch(trackerNestedIds(show), localIds) && + _numberMatches(entry['episode'], ctx.episodeNumber) && + _seasonMatches(entry['episode'], ctx.season), + _ => false, + }; + } + + bool _numberMatches(Object? value, int? expected) { + if (expected == null || value is! Map) return false; + return flexibleInt(value['number']) == expected; + } + + bool _seasonMatches(Object? value, int? expected) { + if (expected == null || value is! Map) return false; + return flexibleInt(value['season']) == expected; + } + + Map _ratingBody(TrackerRatingContext ctx, {int? rating}) { + final ids = TraktIds.fromExternal(ctx.ids.external).toJson(); + final item = {'ids': ids, 'rating': ?rating}; + + return switch (ctx.kind) { + MediaKind.movie => { + 'movies': [item], + }, + MediaKind.show => { + 'shows': [item], + }, + MediaKind.season => { + 'shows': [ + { + 'ids': ids, + 'seasons': [ + {'number': ctx.season, 'rating': ?rating}, + ], + }, + ], + }, + MediaKind.episode => { + 'shows': [ + { + 'ids': ids, + 'seasons': [ + { + 'number': ctx.season, + 'episodes': [ + {'number': ctx.episodeNumber, 'rating': ?rating}, + ], + }, + ], + }, + ], + }, + _ => throw const TrackerRatingUnavailableException('Trakt'), + }; + } +} diff --git a/lib/services/trakt/trakt_scrobble_service.dart b/lib/services/trakt/trakt_scrobble_service.dart deleted file mode 100644 index e407e8a0..00000000 --- a/lib/services/trakt/trakt_scrobble_service.dart +++ /dev/null @@ -1,372 +0,0 @@ -import 'dart:async'; - -import 'package:http/http.dart' as http; - -import '../../media/media_item.dart'; -import '../../media/media_kind.dart'; -import '../../media/media_server_client.dart'; -import '../../media/playback_timeline.dart'; -import '../../models/trakt/trakt_ids.dart'; -import '../../models/trakt/trakt_scrobble_request.dart'; -import '../../utils/app_logger.dart'; -import '../../utils/json_utils.dart'; -import '../settings_service.dart'; -import '../trackers/tracker.dart'; -import '../trackers/tracker_constants.dart'; -import '../trackers/tracker_id_resolver.dart'; -import '../trackers/tracker_rating_match.dart'; -import '../trackers/tracker_session.dart'; -import 'trakt_client.dart'; -import 'trakt_constants.dart'; - -/// Real-time scrobble service for Trakt. -/// -/// Mirrors the lifecycle shape of `DiscordRPCService`: invoked from -/// `video_player_screen.dart` at the same call sites (start/pause/resume/stop, -/// position updates). -class TraktScrobbleService implements TrackerRatingSource { - /// Drop a duplicate state transition within this window — mpv emits multiple - /// playing-state events on seek. - static const Duration _duplicateStateDebounce = Duration(seconds: 1); - - /// Drop a `start` re-send within this window of the previous start. - /// Trakt enforces "max one scrobble per 15 min per item"; this avoids - /// spamming 409s during rapid pause/play cycles. - static const Duration _startResendThrottle = Duration(seconds: 30); - - /// Max one seek-checkpoint per this window — slider drag fires many position - /// updates per second; we only want to ship one to Trakt. - static const Duration _seekCheckpointThrottle = Duration(seconds: 5); - - static TraktScrobbleService? _instance; - static TraktScrobbleService get instance => _instance ??= TraktScrobbleService._(); - - TraktScrobbleService._(); - - bool _isInitialized = false; - bool _isEnabled = false; - - TraktClient? _client; - TrackerIdResolver? _resolver; - TraktScrobbleRequest? _currentBody; - final PlaybackTimeline _timeline = PlaybackTimeline(); - TraktScrobbleState? _lastSentState; - DateTime? _lastSentAt; - DateTime? _lastSeekCheckpointAt; - int _playbackRevision = 0; - - Future initialize() async { - if (_isInitialized) return; - _isInitialized = true; - final settings = await SettingsService.getInstance(); - _isEnabled = settings.read(SettingsService.scrobblePref(TrackerService.trakt)); - } - - Future setEnabled(bool enabled) async { - _isEnabled = enabled; - if (!enabled) cancelInFlight(); - } - - /// Switch to a different account. Cancels any in-flight scrobble for the - /// previous account so we don't send a stop event to the wrong user. - void rebindToProfile( - TrackerSession? session, { - required void Function() onSessionInvalidated, - void Function(TrackerSession session)? onSessionUpdated, - http.Client? httpClient, - }) { - _client?.dispose(); - _client = session != null - ? TraktClient( - session, - onSessionInvalidated: onSessionInvalidated, - onSessionUpdated: onSessionUpdated, - httpClient: httpClient, - ) - : null; - cancelInFlight(); - } - - void updateSession(TrackerSession session) { - _client?.updateSession(session); - } - - @override - Future getRating(TrackerRatingContext ctx) async { - final client = _client; - if (client == null) throw const TrackerRatingUnavailableException('Trakt'); - final localIds = TraktIds.fromExternal(ctx.ids.external).toJson(); - if (localIds.isEmpty) throw const TrackerRatingUnavailableException('Trakt'); - - final entries = await client.getRatings(_ratingType(ctx)); - for (final entry in entries) { - if (entry is! Map) continue; - if (!_ratingEntryMatches(ctx, entry.cast(), localIds)) continue; - final rating = flexibleInt(entry['rating']); - return rating != null && rating > 0 ? rating.clamp(1, 10).toInt() : null; - } - return null; - } - - @override - Future rate(TrackerRatingContext ctx, int score) async { - final client = _client; - if (client == null) throw const TrackerRatingUnavailableException('Trakt'); - await client.addRatings(_ratingBody(ctx, rating: score.clamp(1, 10).toInt())); - } - - @override - Future clearRating(TrackerRatingContext ctx) async { - final client = _client; - if (client == null) throw const TrackerRatingUnavailableException('Trakt'); - await client.removeRatings(_ratingBody(ctx)); - } - - /// Drop the current scrobble state without sending a stop. Called on profile - /// switch and when the service is disabled mid-playback. - void cancelInFlight() { - ++_playbackRevision; - _clearPlaybackState(); - } - - void _clearPlaybackState() { - _currentBody = null; - _lastSentState = null; - _lastSentAt = null; - _lastSeekCheckpointAt = null; - _resolver?.clearCache(); - _resolver = null; - _timeline.reset(); - } - - bool get _canScrobble => _isEnabled && _client != null; - - String _ratingType(TrackerRatingContext ctx) => switch (ctx.kind) { - MediaKind.movie => 'movies', - MediaKind.show => 'shows', - MediaKind.season => 'seasons', - MediaKind.episode => 'episodes', - _ => throw const TrackerRatingUnavailableException('Trakt'), - }; - - bool _ratingEntryMatches(TrackerRatingContext ctx, Map entry, Map localIds) { - final show = entry['show']; - final movie = entry['movie']; - return switch (ctx.kind) { - MediaKind.movie => trackerIdsMatch(trackerNestedIds(movie), localIds), - MediaKind.show => trackerIdsMatch(trackerNestedIds(show), localIds), - MediaKind.season => - trackerIdsMatch(trackerNestedIds(show), localIds) && _numberMatches(entry['season'], ctx.season), - MediaKind.episode => - trackerIdsMatch(trackerNestedIds(show), localIds) && - _numberMatches(entry['episode'], ctx.episodeNumber) && - _seasonMatches(entry['episode'], ctx.season), - _ => false, - }; - } - - bool _numberMatches(Object? value, int? expected) { - if (expected == null || value is! Map) return false; - return flexibleInt(value['number']) == expected; - } - - bool _seasonMatches(Object? value, int? expected) { - if (expected == null || value is! Map) return false; - return flexibleInt(value['season']) == expected; - } - - Map _ratingBody(TrackerRatingContext ctx, {int? rating}) { - final ids = TraktIds.fromExternal(ctx.ids.external).toJson(); - final item = {'ids': ids, 'rating': ?rating}; - - return switch (ctx.kind) { - MediaKind.movie => { - 'movies': [item], - }, - MediaKind.show => { - 'shows': [item], - }, - MediaKind.season => { - 'shows': [ - { - 'ids': ids, - 'seasons': [ - {'number': ctx.season, 'rating': ?rating}, - ], - }, - ], - }, - MediaKind.episode => { - 'shows': [ - { - 'ids': ids, - 'seasons': [ - { - 'number': ctx.season, - 'episodes': [ - {'number': ctx.episodeNumber, 'rating': ?rating}, - ], - }, - ], - }, - ], - }, - _ => throw const TrackerRatingUnavailableException('Trakt'), - }; - } - - Future startPlayback(MediaItem metadata, MediaServerClient client, {bool isLive = false}) async { - final revision = ++_playbackRevision; - _clearPlaybackState(); - if (!_canScrobble || isLive) return; - - final type = metadata.kind; - if (type != MediaKind.movie && type != MediaKind.episode) return; - - final settings = SettingsService.instanceOrNull; - if (settings != null && !settings.isLibraryAllowedForTracker(TrackerService.trakt, metadata.libraryGlobalKey)) { - appLogger.d('Trakt: library filtered out for ${metadata.id}'); - return; - } - - // Seed with the resume offset so the first real position update doesn't - // look like a seek when resuming mid-item. - _timeline.reset( - position: metadata.viewOffsetMs != null ? Duration(milliseconds: metadata.viewOffsetMs!) : Duration.zero, - duration: metadata.durationMs != null ? Duration(milliseconds: metadata.durationMs!) : null, - ); - _resolver = TrackerIdResolver(client, needsFribb: () => false); - - final body = await _buildBody(metadata); - if (revision != _playbackRevision) return; - if (body == null) { - appLogger.d('Trakt: skipping scrobble — no usable IDs for ${metadata.id}'); - _clearPlaybackState(); - return; - } - _currentBody = body; - await _send(TraktScrobbleState.start, progress: _progressPercent()); - } - - void updatePosition(Duration position) { - final isSeek = _timeline.updatePosition(position); - - // Trakt has no seek event — instead, official apps send pause+start with - // the new progress to checkpoint. Without this, the "resume on another - // device" feature is stuck on the pre-seek position until the next - // pause/stop. - if (_currentBody == null) return; - if (_lastSentState != TraktScrobbleState.start) return; - if (!isSeek) return; - - final now = DateTime.now(); - if (_lastSeekCheckpointAt != null && now.difference(_lastSeekCheckpointAt!) < _seekCheckpointThrottle) { - return; - } - _lastSeekCheckpointAt = now; - unawaited(_sendSeekCheckpoint()); - } - - void updateDuration(Duration duration) { - _timeline.updateDuration(duration); - } - - Future pausePlayback() async { - if (_currentBody == null) return; - await _send(TraktScrobbleState.pause, progress: _progressPercent()); - } - - Future resumePlayback() async { - if (_currentBody == null) return; - await _send(TraktScrobbleState.start, progress: _progressPercent()); - } - - Future stopPlayback() async { - final revision = ++_playbackRevision; - if (_currentBody == null) { - _clearPlaybackState(); - return; - } - await _send(TraktScrobbleState.stop, progress: _progressPercent()); - if (revision == _playbackRevision) _clearPlaybackState(); - } - - Future _buildBody(MediaItem metadata) async { - final resolver = _resolver; - if (resolver == null) return null; - - if (metadata.kind == MediaKind.movie) { - final ids = await resolver.resolveForMovie(metadata.id); - if (ids == null) return null; - return TraktScrobbleRequest.movie(ids: TraktIds.fromExternal(ids.external)); - } - - final season = metadata.parentIndex; - final number = metadata.index; - if (season == null || number == null) return null; - - final showIds = await resolver.resolveShowForEpisode(metadata, includeAnimeProgress: false); - if (showIds == null) return null; - - return TraktScrobbleRequest.episode( - showIds: TraktIds.fromExternal(showIds.external), - season: season, - number: number, - ); - } - - double _progressPercent() => _timeline.progressPercent; - - /// Send pause→start to Trakt so the playback-progress endpoint reflects the - /// new position. Bypasses [_send]'s state throttle (this is a checkpoint, - /// not a state change) but updates the throttle bookkeeping so a regular - /// `start` immediately after won't double-fire. - Future _sendSeekCheckpoint() async { - final client = _client; - final body = _currentBody; - if (client == null || body == null) return; - - final progress = _progressPercent(); - final scrobble = body.copyWith(progress: progress); - try { - await client.scrobblePause(scrobble); - await client.scrobbleStart(scrobble); - _lastSentState = TraktScrobbleState.start; - _lastSentAt = DateTime.now(); - appLogger.d('Trakt: seek checkpoint @ ${progress.toStringAsFixed(1)}%'); - } catch (e) { - appLogger.d('Trakt: seek checkpoint failed', error: e); - } - } - - Future _send(TraktScrobbleState state, {required double progress}) async { - final client = _client; - final body = _currentBody; - if (client == null || body == null) return; - - final now = DateTime.now(); - if (_lastSentState == state && _lastSentAt != null) { - final elapsed = now.difference(_lastSentAt!); - if (elapsed < _duplicateStateDebounce) return; - if (state == TraktScrobbleState.start && elapsed < _startResendThrottle) return; - } - _lastSentState = state; - _lastSentAt = now; - - final scrobble = body.copyWith(progress: progress); - try { - switch (state) { - case TraktScrobbleState.start: - await client.scrobbleStart(scrobble); - case TraktScrobbleState.pause: - await client.scrobblePause(scrobble); - case TraktScrobbleState.stop: - await client.scrobbleStop(scrobble); - } - appLogger.d('Trakt: scrobble ${state.name} @ ${progress.toStringAsFixed(1)}%'); - } catch (e) { - // Never let scrobble errors block playback. - appLogger.d('Trakt: scrobble ${state.name} failed', error: e); - } - } -} diff --git a/lib/services/trakt/trakt_sync_queue.dart b/lib/services/trakt/trakt_sync_queue.dart deleted file mode 100644 index 4b59fa38..00000000 --- a/lib/services/trakt/trakt_sync_queue.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import '../../models/trakt/trakt_ids.dart'; -import '../../profiles/profile.dart'; -import '../base_shared_preferences_service.dart'; -import '../../utils/app_logger.dart'; -import 'trakt_constants.dart'; - -/// One pending watched/unwatched push waiting to be drained to Trakt. -class TraktSyncQueueItem { - final TraktSyncOp op; - final String ratingKey; - final String serverId; - final String? libraryGlobalKey; - final TraktMediaKind kind; - final TraktIds ids; - - /// For episodes only. - final int? season; - final int? number; - - final String watchedAtIso; - final int attempts; - - const TraktSyncQueueItem({ - required this.op, - required this.ratingKey, - required this.serverId, - required this.kind, - required this.ids, - required this.watchedAtIso, - this.libraryGlobalKey, - this.season, - this.number, - this.attempts = 0, - }); - - TraktSyncQueueItem incrementAttempts() => TraktSyncQueueItem( - op: op, - ratingKey: ratingKey, - serverId: serverId, - libraryGlobalKey: libraryGlobalKey, - kind: kind, - ids: ids, - watchedAtIso: watchedAtIso, - season: season, - number: number, - attempts: attempts + 1, - ); - - Map toJson() => { - 'op': op.name, - 'ratingKey': ratingKey, - 'serverId': serverId, - if (libraryGlobalKey != null) 'libraryGlobalKey': libraryGlobalKey, - 'kind': kind.name, - 'ids': ids.toJson(), - if (season != null) 'season': season, - if (number != null) 'number': number, - 'watchedAtIso': watchedAtIso, - 'attempts': attempts, - }; - - factory TraktSyncQueueItem.fromJson(Map json) => TraktSyncQueueItem( - op: TraktSyncOp.fromName(json['op'] as String), - ratingKey: json['ratingKey'] as String, - serverId: json['serverId'] as String, - libraryGlobalKey: json['libraryGlobalKey'] as String?, - kind: TraktMediaKind.fromName(json['kind'] as String), - ids: TraktIds.fromJson(json['ids'] as Map), - season: (json['season'] as num?)?.toInt(), - number: (json['number'] as num?)?.toInt(), - watchedAtIso: json['watchedAtIso'] as String, - attempts: (json['attempts'] as num?)?.toInt() ?? 0, - ); -} - -/// Per-profile persisted retry queue for failed Trakt history pushes. -/// -/// Cap at [maxAttempts] before dropping permanently — matches -/// `OfflineWatchSyncService.maxSyncAttempts`. -/// -/// Serialises all writes (`add`, `save`, `drainWith`) through a Completer chain -/// so concurrent `add()` calls don't interleave read-modify-write and lose items. -class TraktSyncQueue { - static const String _baseKey = 'trakt_sync_queue'; - static const int maxAttempts = 5; - - Future _writeLock = Future.value(); - - Future _locked(Future Function() action) { - final previous = _writeLock; - final completer = Completer(); - _writeLock = completer.future; - return previous.then((_) => action()).whenComplete(completer.complete); - } - - Future> load(String userUuid) async { - final prefs = await BaseSharedPreferencesService.sharedCache(); - final key = profileScopedPrefsKey(userUuid, _baseKey); - final raw = prefs.getString(key); - if (raw == null) return []; - try { - final list = json.decode(raw) as List; - return list.map((e) => TraktSyncQueueItem.fromJson(e as Map)).toList(); - } catch (e, st) { - appLogger.e('Trakt sync queue parse failed, discarding', error: e, stackTrace: st); - await prefs.setString(profileScopedPrefsKey(userUuid, '${_baseKey}_corrupt'), raw); - await prefs.remove(key); - return []; - } - } - - Future save(String userUuid, List items) { - return _locked(() => _saveRaw(userUuid, items)); - } - - Future _saveRaw(String userUuid, List items) async { - final prefs = await BaseSharedPreferencesService.sharedCache(); - final key = profileScopedPrefsKey(userUuid, _baseKey); - if (items.isEmpty) { - await prefs.remove(key); - } else { - await prefs.setString(key, json.encode(items.map((e) => e.toJson()).toList())); - } - } - - Future add(String userUuid, TraktSyncQueueItem item) { - return _locked(() async { - final items = await load(userUuid); - items.add(item); - await _saveRaw(userUuid, items); - }); - } - - /// Atomic drain: load the queue, run [processor] for each item, and save the - /// items the processor decided to retain. Holds the write lock for the whole - /// cycle so concurrent `add()`s wait until the drain completes (no lost items). - /// - /// [processor] returns `null` to drop the item, or a (possibly mutated) item - /// to retain for the next drain (e.g. `item.incrementAttempts()`). - Future drainWith(String userUuid, Future Function(TraktSyncQueueItem) processor) { - return _locked(() async { - final items = await load(userUuid); - if (items.isEmpty) return; - final remaining = []; - for (final item in items) { - final keep = await processor(item); - if (keep != null) remaining.add(keep); - } - await _saveRaw(userUuid, remaining); - }); - } -} diff --git a/lib/services/trakt/trakt_sync_service.dart b/lib/services/trakt/trakt_sync_service.dart deleted file mode 100644 index edeebe92..00000000 --- a/lib/services/trakt/trakt_sync_service.dart +++ /dev/null @@ -1,398 +0,0 @@ -import 'dart:async'; -import '../../media/ids.dart'; -import 'dart:collection'; - -import '../../media/media_item.dart'; -import '../../media/media_kind.dart'; -import '../../media/media_server_client.dart'; -import '../../models/trakt/trakt_ids.dart'; -import '../../models/trakt/trakt_scrobble_request.dart'; -import '../../utils/app_logger.dart'; -import '../../media/episode_collection.dart'; -import '../../utils/watch_state_notifier.dart'; -import '../multi_server_manager.dart'; -import '../settings_service.dart'; -import '../trackers/tracker_constants.dart'; -import '../trackers/tracker_id_resolver.dart'; -import '../trackers/tracker_session.dart'; -import 'trakt_client.dart'; -import 'trakt_constants.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, expanding show/season events to their 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? _subscription; - final TraktSyncQueue _queue = TraktSyncQueue(); - - /// One resolver per server, kept alive across events so the per-item - /// external-id cache survives a binge-watch session. Backend-neutral — - /// Plex resolves via `?includeGuids=1`, Jellyfin reads inline `ProviderIds`. - final Map _resolvers = {}; - - /// Fallback buffers for items that failed to persist to the on-disk queue - /// (e.g. SharedPreferences write threw). Keyed by profile so a profile switch - /// cannot replay one user's failed writes through another user's Trakt client. - /// Bounded per profile to keep memory pressure finite; oldest items drop first. - static const int _maxInMemoryFallback = 100; - final Map> _inMemoryFallbackByUser = {}; - - Future? _flushFuture; - bool _flushRequested = false; - - Future initialize({required MultiServerManager serverManager}) async { - if (_isInitialized) return; - _isInitialized = true; - _serverManager = serverManager; - - final settings = await SettingsService.getInstance(); - _isEnabled = settings.read(SettingsService.enableTraktWatchedSync); - - _subscription = WatchStateNotifier().stream.listen( - _onWatchStateEvent, - onError: (Object e, StackTrace st) => - appLogger.w('Trakt sync: watch event handler error', error: e, stackTrace: st), - ); - } - - Future setEnabled(bool enabled) async { - _isEnabled = enabled; - } - - /// Switch to a different account. Drops cached resolvers (their backing - /// clients are tied to the previous user's tokens) and rebinds the queue. - void rebindToProfile( - String userUuid, - TrackerSession? session, { - required void Function() onSessionInvalidated, - void Function(TrackerSession session)? onSessionUpdated, - }) { - _client?.dispose(); - _client = session != null - ? TraktClient(session, onSessionInvalidated: onSessionInvalidated, onSessionUpdated: onSessionUpdated) - : null; - _activeUserUuid = userUuid; - _resolvers.clear(); - if (_client != null) unawaited(flushQueue()); - } - - void updateSession(TrackerSession session) { - _client?.updateSession(session); - } - - Future dispose() async { - await _subscription?.cancel(); - _subscription = null; - _client?.dispose(); - _client = null; - _resolvers.clear(); - } - - bool get _canPush => _isEnabled && _client != null; - - TrackerIdResolver? _resolverFor(ServerId serverId) { - final cached = _resolvers[serverId]; - if (cached != null) return cached; - - // Backend-neutral: TrackerIdResolver pulls external IDs through - // MediaServerClient.fetchExternalIds — Plex hits `?includeGuids=1`, - // Jellyfin reads the inline `ProviderIds` map. - final mediaClient = _clientFor(serverId); - if (mediaClient == null) return null; - - final resolver = TrackerIdResolver(mediaClient, needsFribb: () => false); - _resolvers[serverId] = resolver; - return resolver; - } - - MediaServerClient? _clientFor(ServerId serverId) => _serverManager?.getClient(serverId); - - Future _onWatchStateEvent(WatchStateEvent event) async { - if (!_canPush) return; - if (event.changeType != WatchStateChangeType.watched && event.changeType != WatchStateChangeType.unwatched) return; - - if (!_isLibraryAllowed(event.librarySectionGlobalKey)) { - appLogger.d('Trakt sync: library filtered out for ${event.itemId}'); - return; - } - - final op = event.changeType == WatchStateChangeType.watched ? TraktSyncOp.add : TraktSyncOp.remove; - final watchedAtIso = DateTime.now().toUtc().toIso8601String(); - - switch (event.mediaType) { - case 'movie': - await _push( - op: op, - ratingKey: event.itemId, - serverId: ServerId(event.serverId), - libraryGlobalKey: event.librarySectionGlobalKey, - kind: TraktMediaKind.movie, - watchedAtIso: watchedAtIso, - ); - case 'episode': - await _push( - op: op, - ratingKey: event.itemId, - serverId: ServerId(event.serverId), - libraryGlobalKey: event.librarySectionGlobalKey, - kind: TraktMediaKind.episode, - watchedAtIso: watchedAtIso, - ); - case 'show' || 'season': - await _pushPlayableDescendants(op: op, event: event, watchedAtIso: watchedAtIso); - } - } - - Future _pushPlayableDescendants({ - required TraktSyncOp op, - required WatchStateEvent event, - required String watchedAtIso, - }) async { - final mediaClient = _clientFor(ServerId(event.serverId)); - if (mediaClient == null) { - appLogger.d('Trakt sync: no client registered for server ${event.serverId}, skipping ${event.mediaType}'); - return; - } - - final fallback = MediaItem( - id: event.itemId, - backend: mediaClient.backend, - kind: MediaKind.fromString(event.mediaType), - serverId: event.serverId, - serverName: mediaClient.serverName, - libraryId: event.librarySectionID, - parentId: event.mediaType == 'season' && event.parentChain.isNotEmpty ? event.parentChain.first : null, - ); - final episodes = []; - await collectEpisodes(mediaClient, event.itemId, unwatchedOnly: false, out: episodes, fallback: fallback); - - for (final episode in episodes) { - if (episode.kind != MediaKind.episode) continue; - await _push( - op: op, - ratingKey: episode.id, - serverId: ServerId(event.serverId), - libraryGlobalKey: episode.libraryGlobalKey ?? event.librarySectionGlobalKey, - kind: TraktMediaKind.episode, - watchedAtIso: watchedAtIso, - episodeMeta: episode, - ); - } - } - - Future _push({ - required TraktSyncOp op, - required String ratingKey, - required ServerId serverId, - required String? libraryGlobalKey, - required TraktMediaKind kind, - required String watchedAtIso, - MediaItem? episodeMeta, - }) async { - final resolver = _resolverFor(ServerId(serverId)); - if (resolver == null) { - appLogger.d('Trakt sync: no client registered for server $serverId, skipping'); - return; - } - - TrackerIds? resolved; - int? season; - int? number; - - if (kind == TraktMediaKind.movie) { - resolved = await resolver.resolveForMovie(ratingKey); - } else { - // Episode — need show IDs + season/episode index. The WatchStateEvent - // doesn't carry the index, so fetch episode metadata via the neutral - // MediaServerClient surface (Plex `/library/metadata`, Jellyfin - // `/Users/{id}/Items/{id}`). - final mediaClient = _clientFor(ServerId(serverId)); - if (mediaClient == null) return; - final metadata = episodeMeta ?? await mediaClient.fetchItem(ratingKey); - if (metadata == null) return; - season = metadata.parentIndex; - number = metadata.index; - if (season == null || number == null) return; - resolved = await resolver.resolveShowForEpisode(metadata, includeAnimeProgress: false); - } - - if (resolved == null) { - appLogger.d('Trakt sync: no IDs for ${kind.name} $ratingKey, dropping'); - return; - } - - final ids = TraktIds.fromExternal(resolved.external); - 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, - libraryGlobalKey: libraryGlobalKey, - kind: kind, - ids: ids, - season: season, - number: number, - watchedAtIso: watchedAtIso, - ); - - await _trySendOrQueue(item, body); - } - - Future _trySendOrQueue(TraktSyncQueueItem item, TraktScrobbleRequest body) async { - final userUuid = _activeUserUuid; - final client = _client; - if (client == null) { - await _persistOrBuffer(userUuid, 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 _persistOrBuffer(userUuid, item); - } - } - - /// Persist an item to the on-disk queue; fall back to a bounded in-memory - /// buffer if the disk write throws (e.g. disk full, SAF permission revoked). - /// Retried at the start of the next `flushQueue` run. - Future _persistOrBuffer(String userUuid, TraktSyncQueueItem item) async { - try { - await _queue.add(userUuid, item); - } catch (e, st) { - appLogger.e( - 'Trakt sync: queue persist failed for ${item.op.name} ${item.ratingKey}, buffering in memory', - error: e, - stackTrace: st, - ); - final fallback = _inMemoryFallbackByUser.putIfAbsent(userUuid, Queue.new); - if (fallback.length >= _maxInMemoryFallback) { - final dropped = fallback.removeFirst(); - appLogger.w('Trakt sync: in-memory fallback full, dropping ${dropped.op.name} ${dropped.ratingKey}'); - } - fallback.addLast(item); - } - } - - Future _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 flushQueue() { - final active = _flushFuture; - if (active != null) { - _flushRequested = true; - return active; - } - if (_client == null) return Future.value(); - - final future = _runFlushLoop(); - _flushFuture = future; - return future; - } - - Future _runFlushLoop() async { - try { - do { - _flushRequested = false; - await _flushQueueOnce(); - } while (_flushRequested && _client != null); - } finally { - _flushFuture = null; - if (_flushRequested && _client != null) { - scheduleMicrotask(() { - unawaited( - flushQueue().catchError((Object error, StackTrace stackTrace) { - appLogger.w('Trakt sync: requested follow-up flush failed', error: error, stackTrace: stackTrace); - }), - ); - }); - } - } - } - - Future _flushQueueOnce() async { - final client = _client; - if (client == null) return; - final userUuid = _activeUserUuid; - await _recoverInMemoryFallback(userUuid); - - await _queue.drainWith(userUuid, (item) async { - if (!_isLibraryAllowed(item.libraryGlobalKey)) { - appLogger.d('Trakt sync: queued library filtered out for ${item.ratingKey}'); - return null; - } - if (item.attempts >= TraktSyncQueue.maxAttempts) { - appLogger.w('Trakt sync: dropping ${item.op.name} ${item.ratingKey} after ${item.attempts} attempts'); - return null; - } - try { - await _dispatch(client, item, _bodyFor(item)); - appLogger.d('Trakt sync: drained ${item.op.name} ${item.ratingKey}'); - await Future.delayed(_queueRequestSpacing); - return null; - } catch (e) { - appLogger.d('Trakt sync: drain failed for ${item.ratingKey}, will retry', error: e); - await Future.delayed(_queueRequestSpacing); - return item.incrementAttempts(); - } - }); - } - - /// Try to move items buffered in memory (because prior disk writes failed) - /// back onto the persistent queue. Best-effort; items that still can't be - /// persisted stay in the buffer for the next flush. - Future _recoverInMemoryFallback(String userUuid) async { - final fallback = _inMemoryFallbackByUser[userUuid]; - if (fallback == null || fallback.isEmpty) return; - final snapshot = List.from(fallback); - fallback.clear(); - if (fallback.isEmpty) _inMemoryFallbackByUser.remove(userUuid); - for (final item in snapshot) { - await _persistOrBuffer(userUuid, item); - } - } - - bool _isLibraryAllowed(String? libraryGlobalKey) { - return SettingsService.instanceOrNull?.isLibraryAllowedForTracker(TrackerService.trakt, libraryGlobalKey) ?? true; - } - - 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!, - ), - }; - } -} diff --git a/lib/utils/external_ids.dart b/lib/utils/external_ids.dart index 6a25731c..2a0e1556 100644 --- a/lib/utils/external_ids.dart +++ b/lib/utils/external_ids.dart @@ -69,6 +69,20 @@ class ExternalIds { (tmdb != null && tmdb == other.tmdb) || (tvdb != null && tvdb == other.tvdb); + /// Round-trips through the persisted tracker write queue. Absent ids stay + /// absent so a re-read yields the same [hasAny]/[intersects] answers. + Map toJson() => { + if (imdb != null) 'imdb': imdb, + if (tmdb != null) 'tmdb': tmdb, + if (tvdb != null) 'tvdb': tvdb, + }; + + factory ExternalIds.fromJson(Map json) => ExternalIds( + imdb: json['imdb'] as String?, + tmdb: (json['tmdb'] as num?)?.toInt(), + tvdb: (json['tvdb'] as num?)?.toInt(), + ); + factory ExternalIds.fromGuids(List guids) { String? imdb; int? tmdb; diff --git a/test/android/network_security_config_test.dart b/test/android/network_security_config_test.dart index 9f4aaaa3..2e197601 100644 --- a/test/android/network_security_config_test.dart +++ b/test/android/network_security_config_test.dart @@ -20,7 +20,7 @@ const _fixedEndpointSourcePaths = [ 'lib/services/plex_auth_service.dart', 'lib/services/plex_discover_client.dart', 'lib/services/plex_client/parts/live_tv.dart', - 'lib/services/trakt/trakt_constants.dart', + 'lib/services/trackers/trakt/trakt_constants.dart', 'lib/services/trackers/mal/mal_constants.dart', 'lib/services/trackers/anilist/anilist_constants.dart', 'lib/services/trackers/simkl/simkl_constants.dart', diff --git a/test/navigation/profile_session_screen_test.dart b/test/navigation/profile_session_screen_test.dart index 7e771db7..64de0fd1 100644 --- a/test/navigation/profile_session_screen_test.dart +++ b/test/navigation/profile_session_screen_test.dart @@ -62,9 +62,9 @@ void main() { final companionProviders = []; final disposedActiveIds = []; final trackerHttpClients = []; - // The probe instantiates TrackersProvider (four eager auth owners); the - // separate Trakt provider remains lazy in this reduced shell. - const trackerAuthClientsPerProfile = 4; + // TrackersProvider owns five eager auth HTTP clients across the four + // services (MAL's proxy and token exchange use separate clients). + const trackerAuthClientsPerProfile = 5; FakeHttpClient trackerHttpClientFactory() { final client = FakeHttpClient(200, const []); trackerHttpClients.add(client); diff --git a/test/providers/offline_mode_provider_test.dart b/test/providers/offline_mode_provider_test.dart index 12f80141..2edeb9cb 100644 --- a/test/providers/offline_mode_provider_test.dart +++ b/test/providers/offline_mode_provider_test.dart @@ -1,3 +1,4 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/ids.dart'; import 'package:http/http.dart' as http; @@ -188,6 +189,54 @@ void main() { multi.dispose(); manager.dispose(); }); + + group('connectivity transitions', () { + test('regaining any network notifies even while servers stay unreachable', () async { + final manager = MultiServerManager(); + final multi = testMultiServerProvider(manager); + final p = OfflineModeProvider(manager, multiServerProvider: multi); + // Settle visibility with nothing reachable, so offline is owned by + // `noServerConnection` rather than the network flag or startup warmup. + multi.setExpectedVisibleServerIds({'plex-server'}); + multi.setVisibleServerIds({'plex-server'}); + await Future.delayed(Duration.zero); + p.applyConnectivityResults(const [ConnectivityResult.none]); + expect(p.hasNetworkConnection, isFalse); + expect(p.isOffline, isTrue); + + var notifications = 0; + p.addListener(() => notifications++); + + // Cellular comes back but no server is reachable, so neither the composite + // offline state nor the WiFi/Ethernet flag moves. Consumers that only need + // the internet — queued tracker history writes — still have to hear it. + p.applyConnectivityResults(const [ConnectivityResult.mobile]); + + expect(p.hasNetworkConnection, isTrue); + expect(p.hasWifiOrEthernet, isFalse); + expect(p.isOffline, isTrue, reason: 'servers are still unreachable'); + expect(notifications, 1); + + p.dispose(); + multi.dispose(); + manager.dispose(); + }); + + test('an unchanged connectivity snapshot notifies nobody', () async { + final manager = MultiServerManager(); + final p = OfflineModeProvider(manager); + p.applyConnectivityResults(const [ConnectivityResult.wifi]); + + var notifications = 0; + p.addListener(() => notifications++); + p.applyConnectivityResults(const [ConnectivityResult.wifi]); + + expect(notifications, isZero); + + p.dispose(); + manager.dispose(); + }); + }); }); } diff --git a/test/providers/trackers_provider_test.dart b/test/providers/trackers_provider_test.dart index 87a817ad..e072fa74 100644 --- a/test/providers/trackers_provider_test.dart +++ b/test/providers/trackers_provider_test.dart @@ -6,9 +6,11 @@ import 'package:plezy/services/base_shared_preferences_service.dart'; import 'package:plezy/services/trackers/anilist/anilist_tracker.dart'; import 'package:plezy/services/trackers/tracker_account_store.dart'; import 'package:plezy/services/trackers/tracker_constants.dart'; +import 'package:plezy/services/trackers/tracker_coordinator.dart'; import 'package:plezy/services/trackers/tracker_session.dart'; import 'package:plezy/services/trackers/mal/mal_tracker.dart'; import 'package:plezy/services/trackers/simkl/simkl_tracker.dart'; +import 'package:plezy/services/trackers/trakt/trakt_tracker.dart'; import '../test_helpers/io_fakes.dart'; import '../test_helpers/prefs.dart'; @@ -16,6 +18,7 @@ import '../test_helpers/prefs.dart'; final _malStore = trackerAccountStore(TrackerService.mal); final _anilistStore = trackerAccountStore(TrackerService.anilist); final _simklStore = trackerAccountStore(TrackerService.simkl); +final _traktStore = trackerAccountStore(TrackerService.trakt); TrackerSession _mal({String? username}) => TrackerSession( accessToken: 'mal-at', @@ -38,6 +41,19 @@ TrackerSession _simkl({String? username}) => TrackerSession( username: username, ); +TrackerSession _trakt({String? username}) => TrackerSession( + accessToken: 'trakt-at', + refreshToken: 'trakt-rt', + expiresAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + username: username, +); + +Future _bindProfile(TrackersProvider provider, String? userUuid) async { + await provider.onActiveProfileChanged(userUuid); + await TrackerCoordinator.instance.flushWriteQueue(); +} + void main() { setUp(() { resetSharedPreferencesForTest(); @@ -51,15 +67,19 @@ void main() { expect(p.mal, isNull); expect(p.anilist, isNull); expect(p.simkl, isNull); + expect(p.trakt, isNull); expect(p.isMalConnected, isFalse); expect(p.isAnilistConnected, isFalse); expect(p.isSimklConnected, isFalse); + expect(p.isTraktConnected, isFalse); expect(p.malUsername, isNull); expect(p.anilistUsername, isNull); expect(p.simklUsername, isNull); + expect(p.traktUsername, isNull); expect(p.isConnecting(TrackerService.mal), isFalse); expect(p.isConnecting(TrackerService.anilist), isFalse); expect(p.isConnecting(TrackerService.simkl), isFalse); + expect(p.isConnecting(TrackerService.trakt), isFalse); p.dispose(); }); @@ -74,8 +94,8 @@ void main() { }, ); - expect(clients, hasLength(4)); - expect(clients.toSet(), hasLength(4)); + expect(clients, hasLength(5)); + expect(clients.toSet(), hasLength(5)); for (final client in clients) { expect(client.closeCount, 0); } @@ -93,6 +113,7 @@ void main() { await _malStore.save(uuid, _mal(username: 'alice')); await _anilistStore.save(uuid, _anilist(username: 'bob')); await _simklStore.save(uuid, _simkl(username: 'carol')); + await _traktStore.save(uuid, _trakt(username: 'dave')); // Reset cached singletons so the provider reads fresh prefs state. BaseSharedPreferencesService.resetForTesting(); @@ -101,13 +122,15 @@ void main() { var notified = 0; p.addListener(() => notified++); - await p.onActiveProfileChanged(uuid); + await _bindProfile(p, uuid); expect(p.isMalConnected, isTrue); expect(p.isAnilistConnected, isTrue); expect(p.isSimklConnected, isTrue); + expect(p.isTraktConnected, isTrue); expect(p.malUsername, 'alice'); expect(p.anilistUsername, 'bob'); expect(p.simklUsername, 'carol'); + expect(p.traktUsername, 'dave'); expect(notified, greaterThanOrEqualTo(1)); p.dispose(); @@ -118,32 +141,35 @@ void main() { await _malStore.save(uuid, _mal(username: 'alice')); await _anilistStore.save(uuid, _anilist(username: 'bob')); await _simklStore.save(uuid, _simkl(username: 'carol')); + await _traktStore.save(uuid, _trakt(username: 'dave')); BaseSharedPreferencesService.resetForTesting(); final p = TrackersProvider(); - await p.onActiveProfileChanged(uuid); + await _bindProfile(p, uuid); expect(p.isMalConnected, isTrue); - await p.onActiveProfileChanged('other-profile'); + await _bindProfile(p, 'other-profile'); expect(p.isMalConnected, isFalse); expect(p.isAnilistConnected, isFalse); expect(p.isSimklConnected, isFalse); + expect(p.isTraktConnected, isFalse); p.dispose(); }); test('onActiveProfileChanged loads only the populated stores', () async { const uuid = 'profile-2'; - // Only AniList is set up — MAL and Simkl remain absent. + // Only AniList is set up — MAL, Simkl, and Trakt remain absent. await _anilistStore.save(uuid, _anilist(username: 'bob')); BaseSharedPreferencesService.resetForTesting(); final p = TrackersProvider(); - await p.onActiveProfileChanged(uuid); + await _bindProfile(p, uuid); expect(p.isAnilistConnected, isTrue); expect(p.anilistUsername, 'bob'); expect(p.isMalConnected, isFalse); expect(p.isSimklConnected, isFalse); + expect(p.isTraktConnected, isFalse); p.dispose(); }); @@ -153,7 +179,7 @@ void main() { BaseSharedPreferencesService.resetForTesting(); final p = TrackersProvider(); - await p.onActiveProfileChanged(uuid); + await _bindProfile(p, uuid); expect(p.isMalConnected, isTrue); var notified = 0; @@ -178,7 +204,7 @@ void main() { BaseSharedPreferencesService.resetForTesting(); final p = TrackersProvider(); - await p.onActiveProfileChanged(uuid); + await _bindProfile(p, uuid); await p.disconnectAnilist(); expect(p.isAnilistConnected, isFalse); @@ -193,22 +219,25 @@ void main() { await _malStore.save(uuid, _mal(username: 'alice')); await _anilistStore.save(uuid, _anilist(username: 'bob')); await _simklStore.save(uuid, _simkl(username: 'carol')); + await _traktStore.save(uuid, _trakt(username: 'dave')); BaseSharedPreferencesService.resetForTesting(); final p = TrackersProvider(); // Start the load, then disconnect MAL before it resolves. - final load = p.onActiveProfileChanged(uuid); + final load = _bindProfile(p, uuid); await p.disconnectMal(); await load; // MAL stays disconnected (and cleared) — the racing load must not - // resurrect it — but it also must not drop AniList/Simkl. + // resurrect it — but it also must not drop AniList/Simkl/Trakt. expect(p.isMalConnected, isFalse); expect(await _malStore.load(uuid), isNull); expect(p.isAnilistConnected, isTrue); expect(p.anilistUsername, 'bob'); expect(p.isSimklConnected, isTrue); expect(p.simklUsername, 'carol'); + expect(p.isTraktConnected, isTrue); + expect(p.traktUsername, 'dave'); p.dispose(); }); @@ -233,9 +262,9 @@ void main() { final p = TrackersProvider(); p.dispose(); // Post-dispose rebind should not throw. - await p.onActiveProfileChanged('any-uuid'); + await _bindProfile(p, 'any-uuid'); }); - for (final service in [TrackerService.mal, TrackerService.anilist, TrackerService.simkl]) { + for (final service in [TrackerService.mal, TrackerService.anilist, TrackerService.simkl, TrackerService.trakt]) { test('$service stale connect cannot save or replace a newer binding after dispose', () async { const oldUuid = 'profile-old'; const newUuid = 'profile-new'; @@ -246,13 +275,13 @@ void main() { final pipeline = _ControlledConnectPipeline(oldSession); final oldProvider = TrackersProvider.forTesting(connectPipeline: pipeline.call); - await oldProvider.onActiveProfileChanged(oldUuid); + await _bindProfile(oldProvider, oldUuid); final connect = _connect(oldProvider, service); await pipeline.beforeSave.future; oldProvider.dispose(); final newProvider = TrackersProvider(); - await newProvider.onActiveProfileChanged(newUuid); + await _bindProfile(newProvider, newUuid); final newBinding = _boundClient(service); expect(newBinding, isNotNull); expect(_providerSession(newProvider, service)?.accessToken, newSession.accessToken); @@ -273,7 +302,7 @@ void main() { const uuid = 'profile-cancel'; final pipeline = _ControlledConnectPipeline(_session(TrackerService.mal, 'cancelled')); final p = TrackersProvider.forTesting(connectPipeline: pipeline.call); - await p.onActiveProfileChanged(uuid); + await _bindProfile(p, uuid); final connect = _connect(p, TrackerService.mal); await pipeline.beforeSave.future; @@ -298,11 +327,11 @@ void main() { final pipeline = _ControlledConnectPipeline(oldSession); final p = TrackersProvider.forTesting(connectPipeline: pipeline.call); - await p.onActiveProfileChanged(oldUuid); + await _bindProfile(p, oldUuid); final connect = _connect(p, TrackerService.anilist); await pipeline.beforeSave.future; - await p.onActiveProfileChanged(newUuid); + await _bindProfile(p, newUuid); final newBinding = AnilistTracker.instance.client; pipeline.releaseBeforeSave.complete(); @@ -319,7 +348,7 @@ void main() { const uuid = 'profile-same-disconnect'; final pipeline = _ControlledConnectPipeline(_session(TrackerService.simkl, 'late')); final p = TrackersProvider.forTesting(connectPipeline: pipeline.call); - await p.onActiveProfileChanged(uuid); + await _bindProfile(p, uuid); final connect = _connect(p, TrackerService.simkl); await pipeline.beforeSave.future; @@ -342,7 +371,7 @@ void main() { final pipeline = _ControlledConnectPipeline(connectedMal); final p = TrackersProvider.forTesting(connectPipeline: pipeline.call); - await p.onActiveProfileChanged(uuid); + await _bindProfile(p, uuid); final connect = _connect(p, TrackerService.mal); await pipeline.beforeSave.future; @@ -350,6 +379,7 @@ void main() { pipeline.releaseBeforeSave.complete(); expect(await connect, isTrue); + await TrackerCoordinator.instance.flushWriteQueue(); expect(p.anilist, isNull); expect(p.mal?.accessToken, connectedMal.accessToken); expect((await _malStore.load(uuid))?.accessToken, connectedMal.accessToken); @@ -363,7 +393,7 @@ void main() { final freshSession = _session(TrackerService.mal, 'fresh'); final pipeline = _ControlledConnectPipeline(staleSession, pauseAfterSave: true); final staleProvider = TrackersProvider.forTesting(connectPipeline: pipeline.call); - await staleProvider.onActiveProfileChanged(uuid); + await _bindProfile(staleProvider, uuid); final connect = _connect(staleProvider, TrackerService.mal); await pipeline.beforeSave.future; pipeline.releaseBeforeSave.complete(); @@ -373,7 +403,7 @@ void main() { await _malStore.save(uuid, freshSession); BaseSharedPreferencesService.resetForTesting(); final freshProvider = TrackersProvider(); - await freshProvider.onActiveProfileChanged(uuid); + await _bindProfile(freshProvider, uuid); final freshBinding = MalTracker.instance.client; pipeline.releaseAfterSave.complete(); @@ -391,13 +421,14 @@ void _resetTrackerBindings() { MalTracker.instance.rebindSession(null, onSessionInvalidated: () {}); AnilistTracker.instance.rebindSession(null, onSessionInvalidated: () {}); SimklTracker.instance.rebindSession(null, onSessionInvalidated: () {}); + TraktTracker.instance.rebindSession(null, onSessionInvalidated: () {}); } TrackerAccountStore _store(TrackerService service) => switch (service) { TrackerService.mal => _malStore, TrackerService.anilist => _anilistStore, TrackerService.simkl => _simklStore, - _ => throw ArgumentError.value(service), + TrackerService.trakt => _traktStore, }; TrackerSession _session(TrackerService service, String owner) => switch (service) { @@ -415,35 +446,41 @@ TrackerSession _session(TrackerService service, String owner) => switch (service username: owner, ), TrackerService.simkl => TrackerSession(accessToken: '$owner-simkl-at', createdAt: 1900000000, username: owner), - _ => throw ArgumentError.value(service), + TrackerService.trakt => TrackerSession( + accessToken: '$owner-trakt-at', + refreshToken: '$owner-trakt-rt', + expiresAt: 2000000000, + createdAt: 1900000000, + username: owner, + ), }; Future _connect(TrackersProvider provider, TrackerService service) => switch (service) { TrackerService.mal => provider.connectMal(onCodeReady: (_) {}), TrackerService.anilist => provider.connectAnilist(onCodeReady: (_) {}), TrackerService.simkl => provider.connectSimkl(onCodeReady: (_) {}), - _ => throw ArgumentError.value(service), + TrackerService.trakt => provider.connectTrakt(onCodeReady: (_) {}), }; TrackerSession? _providerSession(TrackersProvider provider, TrackerService service) => switch (service) { TrackerService.mal => provider.mal, TrackerService.anilist => provider.anilist, TrackerService.simkl => provider.simkl, - _ => throw ArgumentError.value(service), + TrackerService.trakt => provider.trakt, }; Object? _boundClient(TrackerService service) => switch (service) { TrackerService.mal => MalTracker.instance.client, TrackerService.anilist => AnilistTracker.instance.client, TrackerService.simkl => SimklTracker.instance.client, - _ => throw ArgumentError.value(service), + TrackerService.trakt => TraktTracker.instance.client, }; TrackerSession? _boundSession(TrackerService service) => switch (service) { TrackerService.mal => MalTracker.instance.client?.session, TrackerService.anilist => AnilistTracker.instance.client?.session, TrackerService.simkl => SimklTracker.instance.client?.session, - _ => throw ArgumentError.value(service), + TrackerService.trakt => TraktTracker.instance.client?.session, }; class _ControlledConnectPipeline { diff --git a/test/providers/trackers_provider_trakt_test.dart b/test/providers/trackers_provider_trakt_test.dart new file mode 100644 index 00000000..ba7d9afc --- /dev/null +++ b/test/providers/trackers_provider_trakt_test.dart @@ -0,0 +1,270 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/providers/trackers_provider.dart'; +import 'package:plezy/services/base_shared_preferences_service.dart'; +import 'package:plezy/services/trackers/tracker_account_store.dart'; +import 'package:plezy/services/trackers/tracker_constants.dart'; +import 'package:plezy/services/trackers/tracker_coordinator.dart'; +import 'package:plezy/services/trackers/tracker_session.dart'; +import 'package:plezy/services/trackers/trakt/trakt_tracker.dart'; + +import '../test_helpers/io_fakes.dart'; +import '../test_helpers/prefs.dart'; + +final _store = trackerAccountStore(TrackerService.trakt); + +TrackerSession _session({String? username, String accessToken = 'at', String refreshToken = 'rt'}) { + return TrackerSession( + accessToken: accessToken, + refreshToken: refreshToken, + expiresAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600, + scope: 'public', + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + username: username, + ); +} + +Future _bindProfile(TrackersProvider provider, String? userUuid) async { + await provider.onActiveProfileChanged(userUuid); + await TrackerCoordinator.instance.flushWriteQueue(); +} + +void main() { + setUp(() { + resetSharedPreferencesForTest(); + TraktTracker.instance.rebindSession(null, onSessionInvalidated: () {}); + }); + tearDown(() => TraktTracker.instance.rebindSession(null, onSessionInvalidated: () {})); + + group('TrackersProvider Trakt account', () { + test('starts disconnected with null session and catalog client', () { + final p = TrackersProvider(); + expect(p.trakt, isNull); + expect(p.isTraktConnected, isFalse); + expect(p.traktUsername, isNull); + expect(p.traktCatalogClient, isNull); + expect(p.isConnecting(TrackerService.trakt), isFalse); + p.dispose(); + }); + + test('owns every injected auth client until disposal', () { + final clients = []; + final p = TrackersProvider( + httpClientFactory: () { + final client = FakeHttpClient(200, const []); + clients.add(client); + return client; + }, + ); + + expect(clients, hasLength(5)); + for (final client in clients) { + expect(client.closeCount, 0); + } + + p.dispose(); + + for (final client in clients) { + expect(client.closeCount, 1); + expect(client.isClosed, isTrue); + } + }); + + test('onActiveProfileChanged loads stored session into the shared Trakt client', () async { + const uuid = 'profile-1'; + await _store.save(uuid, _session(username: 'alice')); + BaseSharedPreferencesService.resetForTesting(); + + final p = TrackersProvider(); + var notified = 0; + p.addListener(() => notified++); + + await _bindProfile(p, uuid); + + expect(p.isTraktConnected, isTrue); + expect(p.traktUsername, 'alice'); + expect(p.trakt?.accessToken, 'at'); + expect(p.traktCatalogClient, same(TraktTracker.instance.client)); + expect(p.traktCatalogClient?.session.accessToken, 'at'); + expect(notified, greaterThanOrEqualTo(1)); + + p.dispose(); + }); + + test('onActiveProfileChanged with unknown uuid clears the Trakt binding', () async { + const uuid = 'profile-1'; + await _store.save(uuid, _session(username: 'alice')); + BaseSharedPreferencesService.resetForTesting(); + + final p = TrackersProvider(); + await _bindProfile(p, uuid); + expect(p.isTraktConnected, isTrue); + + await _bindProfile(p, 'other-profile'); + + expect(p.isTraktConnected, isFalse); + expect(p.traktUsername, isNull); + expect(p.traktCatalogClient, isNull); + expect(TraktTracker.instance.client, isNull); + + p.dispose(); + }); + + test('a profile switch detaches the previous session before the new one loads', () async { + const uuid = 'profile-1'; + await _store.save(uuid, _session(username: 'alice')); + BaseSharedPreferencesService.resetForTesting(); + + final p = TrackersProvider(); + await _bindProfile(p, uuid); + expect(TraktTracker.instance.client, isNotNull); + + var observedWhileDetached = 0; + p.addListener(() { + if (!p.isTraktConnected) observedWhileDetached++; + }); + + // Not awaited: the store load has not resolved yet. No tracker may still be + // holding the previous profile's account at this point, or a write landing + // in the gap would reach it under the new profile's identity. + final pending = p.onActiveProfileChanged('other-profile'); + + expect(TraktTracker.instance.client, isNull); + expect(p.isTraktConnected, isFalse); + expect(observedWhileDetached, greaterThan(0), reason: 'consumers must see the detach before hydration finishes'); + + await pending; + // The provider fires a queue flush per bind; settle it before the prefs + // mock is torn down. + await TrackerCoordinator.instance.flushWriteQueue(); + p.dispose(); + }); + + test('onActiveProfileChanged with null uuid loads from empty global slot', () async { + final p = TrackersProvider(); + await _bindProfile(p, null); + expect(p.isTraktConnected, isFalse); + p.dispose(); + }); + + test('connectTrakt assigns and persists the session through the shared pipeline', () async { + const uuid = 'profile-connect'; + final connected = _session(username: 'alice', accessToken: 'connected-at'); + final p = TrackersProvider.forTesting( + connectPipeline: + ({required logLabel, required authorize, required enrich, required save, required assign}) async { + await save(connected); + assign(connected); + return true; + }, + ); + await _bindProfile(p, uuid); + + expect(await p.connectTrakt(onCodeReady: (_) {}), isTrue); + await TrackerCoordinator.instance.flushWriteQueue(); + expect(p.trakt, same(connected)); + expect(p.traktCatalogClient, same(TraktTracker.instance.client)); + expect((await _store.load(uuid))?.accessToken, 'connected-at'); + + p.dispose(); + }); + + test('disconnect with no session clears state and notifies', () async { + final p = TrackersProvider(); + var notified = 0; + p.addListener(() => notified++); + + await p.disconnectTrakt(); + + expect(p.isTraktConnected, isFalse); + expect(p.trakt, isNull); + expect(notified, 1); + + p.dispose(); + }); + + test('late refresh update after disconnect does not restore the Trakt session', () async { + const uuid = 'profile-disconnect'; + await _store.save(uuid, _session(username: 'alice')); + BaseSharedPreferencesService.resetForTesting(); + + final p = TrackersProvider(); + await _bindProfile(p, uuid); + final staleClient = TraktTracker.instance.client!; + + await p.disconnectTrakt(); + expect(p.isTraktConnected, isFalse); + expect(await _store.load(uuid), isNull); + + staleClient.onSessionUpdated?.call(_session(accessToken: 'late-at', refreshToken: 'late-rt', username: 'alice')); + await Future.delayed(Duration.zero); + + expect(p.isTraktConnected, isFalse); + expect(await _store.load(uuid), isNull); + expect(TraktTracker.instance.client, isNull); + + p.dispose(); + }); + + test('stale callbacks after a profile switch cannot replace or clear the new binding', () async { + const oldUuid = 'profile-old'; + const newUuid = 'profile-new'; + final oldSession = _session(username: 'old', accessToken: 'old-at'); + final newSession = _session(username: 'new', accessToken: 'new-at'); + await _store.save(oldUuid, oldSession); + await _store.save(newUuid, newSession); + BaseSharedPreferencesService.resetForTesting(); + + final p = TrackersProvider(); + await _bindProfile(p, oldUuid); + final staleClient = TraktTracker.instance.client!; + await _bindProfile(p, newUuid); + final currentClient = TraktTracker.instance.client; + + staleClient.onSessionUpdated?.call(_session(username: 'late', accessToken: 'late-at')); + staleClient.onSessionInvalidated(); + await Future.delayed(Duration.zero); + + expect(p.trakt?.accessToken, 'new-at'); + expect(p.traktUsername, 'new'); + expect(TraktTracker.instance.client, same(currentClient)); + expect((await _store.load(newUuid))?.accessToken, 'new-at'); + + p.dispose(); + }); + + test('current refresh update persists rotated tokens without replacing the shared client', () async { + const uuid = 'profile-refresh'; + await _store.save(uuid, _session(username: 'alice')); + BaseSharedPreferencesService.resetForTesting(); + + final p = TrackersProvider(); + await _bindProfile(p, uuid); + final client = TraktTracker.instance.client!; + final rotated = _session(username: 'alice', accessToken: 'rotated-at', refreshToken: 'rotated-rt'); + + client.updateSession(rotated); + client.onSessionUpdated?.call(rotated); + await Future.delayed(Duration.zero); + + expect(p.trakt?.accessToken, 'rotated-at'); + expect((await _store.load(uuid))?.refreshToken, 'rotated-rt'); + expect(TraktTracker.instance.client, same(client)); + expect(p.traktCatalogClient, same(client)); + + p.dispose(); + }); + + test('cancelConnect is a no-op when not connecting', () { + final p = TrackersProvider(); + expect(() => p.cancelConnect(), returnsNormally); + expect(p.isConnecting(TrackerService.trakt), isFalse); + p.dispose(); + }); + + test('onActiveProfileChanged after dispose is a no-op', () async { + final p = TrackersProvider(); + p.dispose(); + await p.onActiveProfileChanged('any-uuid'); + }); + }); +} diff --git a/test/providers/trakt_account_provider_test.dart b/test/providers/trakt_account_provider_test.dart deleted file mode 100644 index d9614576..00000000 --- a/test/providers/trakt_account_provider_test.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/providers/trakt_account_provider.dart'; -import 'package:plezy/services/base_shared_preferences_service.dart'; -import 'package:plezy/services/trackers/tracker_account_store.dart'; -import 'package:plezy/services/trackers/tracker_constants.dart'; -import 'package:plezy/services/trackers/tracker_session.dart'; -import 'package:plezy/services/trakt/trakt_sync_service.dart'; - -import '../test_helpers/io_fakes.dart'; -import '../test_helpers/prefs.dart'; - -final _store = trackerAccountStore(TrackerService.trakt); - -TrackerSession _session({String? username, String accessToken = 'at', String refreshToken = 'rt'}) { - return TrackerSession( - accessToken: accessToken, - refreshToken: refreshToken, - expiresAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600, - scope: 'public', - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, - username: username, - ); -} - -void main() { - setUp(resetSharedPreferencesForTest); - - group('TraktAccountProvider', () { - test('starts disconnected with null session', () { - final p = TraktAccountProvider(); - expect(p.session, isNull); - expect(p.isConnected, isFalse); - expect(p.username, isNull); - expect(p.isConnecting, isFalse); - p.dispose(); - }); - - test('owns the injected auth client until disposal', () { - final clients = []; - final p = TraktAccountProvider( - httpClientFactory: () { - final client = FakeHttpClient(200, const []); - clients.add(client); - return client; - }, - ); - - expect(clients, hasLength(1)); - expect(clients.single.closeCount, 0); - - p.dispose(); - - expect(clients.single.closeCount, 1); - expect(clients.single.isClosed, isTrue); - }); - - test('onActiveProfileChanged loads stored session and notifies', () async { - // Pre-seed the store for a specific profile uuid. - const uuid = 'profile-1'; - await _store.save(uuid, _session(username: 'alice')); - - // Reset cached singletons so the provider reads fresh prefs state. - BaseSharedPreferencesService.resetForTesting(); - - final p = TraktAccountProvider(); - var notified = 0; - p.addListener(() => notified++); - - await p.onActiveProfileChanged(uuid); - await TraktSyncService.instance.flushQueue(); - expect(p.isConnected, isTrue); - expect(p.username, 'alice'); - expect(p.session?.accessToken, 'at'); - // _setSessionAndRebind notifies once. - expect(notified, greaterThanOrEqualTo(1)); - - p.dispose(); - }); - - test('onActiveProfileChanged with unknown uuid clears session', () async { - const uuid = 'profile-1'; - await _store.save(uuid, _session(username: 'alice')); - BaseSharedPreferencesService.resetForTesting(); - - final p = TraktAccountProvider(); - await p.onActiveProfileChanged(uuid); - expect(p.isConnected, isTrue); - - // Switch to a profile with no stored session. - await p.onActiveProfileChanged('other-profile'); - await TraktSyncService.instance.flushQueue(); - expect(p.isConnected, isFalse); - expect(p.username, isNull); - - p.dispose(); - }); - - test('onActiveProfileChanged with null uuid loads from empty/global slot', () async { - final p = TraktAccountProvider(); - await p.onActiveProfileChanged(null); - expect(p.isConnected, isFalse); - p.dispose(); - }); - - test('disconnect with no session clears state and notifies', () async { - final p = TraktAccountProvider(); - var notified = 0; - p.addListener(() => notified++); - - await p.disconnect(); - expect(p.isConnected, isFalse); - expect(p.session, isNull); - // _setSessionAndRebind always notifies. - expect(notified, 1); - - p.dispose(); - }); - - test('late refresh update after disconnect does not restore session', () async { - const uuid = 'profile-1'; - await _store.save(uuid, _session(username: 'alice')); - BaseSharedPreferencesService.resetForTesting(); - - final p = TraktAccountProvider(); - await p.onActiveProfileChanged(uuid); - final staleGeneration = p.debugBindingGenerationForTesting; - - await p.disconnect(); - await TraktSyncService.instance.flushQueue(); - expect(p.isConnected, isFalse); - expect(await _store.load(uuid), isNull); - - p.debugHandleSessionUpdatedForTesting( - uuid, - staleGeneration, - _session(accessToken: 'late-at', refreshToken: 'late-rt', username: 'alice'), - ); - await Future.delayed(Duration.zero); - - expect(p.isConnected, isFalse); - expect(await _store.load(uuid), isNull); - - p.dispose(); - }); - - test('cancelConnect is a no-op when not connecting', () { - final p = TraktAccountProvider(); - // Should not throw when no completer exists. - expect(() => p.cancelConnect(), returnsNormally); - expect(p.isConnecting, isFalse); - p.dispose(); - }); - - test('safeNotifyListeners after dispose is a no-op', () async { - final p = TraktAccountProvider(); - p.dispose(); - // After dispose, calling onActiveProfileChanged still runs the rebind - // path; safeNotifyListeners must swallow the post-dispose notification - // without throwing. - await p.onActiveProfileChanged('any-uuid'); - }); - }); -} diff --git a/test/screens/settings/settings_screen_test.dart b/test/screens/settings/settings_screen_test.dart index 8728149d..209cd5a7 100644 --- a/test/screens/settings/settings_screen_test.dart +++ b/test/screens/settings/settings_screen_test.dart @@ -23,7 +23,6 @@ import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/seerr_account_provider.dart'; import 'package:plezy/providers/theme_provider.dart'; import 'package:plezy/providers/trackers_provider.dart'; -import 'package:plezy/providers/trakt_account_provider.dart'; import 'package:plezy/screens/settings/settings_screen.dart'; import 'package:plezy/services/background_work_diagnostics_service.dart'; import 'package:plezy/services/donation_service.dart'; @@ -579,7 +578,6 @@ class _SettingsHarness { required this.libraries, required this.hiddenLibraries, required this.theme, - required this.trakt, required this.trackers, required this.trackerHttpClients, required this.seerr, @@ -594,7 +592,6 @@ class _SettingsHarness { final LibrariesProvider libraries; final HiddenLibrariesProvider hiddenLibraries; final ThemeProvider theme; - final TraktAccountProvider trakt; final TrackersProvider trackers; final List trackerHttpClients; final SeerrAccountProvider seerr; @@ -610,7 +607,6 @@ class _SettingsHarness { hiddenLibraries.dispose(); libraries.dispose(); theme.dispose(); - trakt.dispose(); trackers.dispose(); seerr.dispose(); activeProfile.dispose(); @@ -658,7 +654,6 @@ Future<_SettingsHarness> _pumpSettingsScreen( return client; } - final trakt = TraktAccountProvider(httpClientFactory: trackerHttpClientFactory); final trackers = TrackersProvider(httpClientFactory: trackerHttpClientFactory); final seerr = SeerrAccountProvider(); final settingsService = SettingsService.instance; @@ -694,7 +689,6 @@ Future<_SettingsHarness> _pumpSettingsScreen( libraries: libraries, hiddenLibraries: hiddenLibraries, theme: theme, - trakt: trakt, trackers: trackers, trackerHttpClients: trackerHttpClients, seerr: seerr, @@ -711,7 +705,6 @@ Future<_SettingsHarness> _pumpSettingsScreen( ChangeNotifierProvider.value(value: libraries), ChangeNotifierProvider.value(value: hiddenLibraries), ChangeNotifierProvider.value(value: theme), - ChangeNotifierProvider.value(value: trakt), ChangeNotifierProvider.value(value: trackers), ChangeNotifierProvider.value(value: seerr), ChangeNotifierProvider.value(value: downloadProvider), diff --git a/test/services/catalog/trakt_catalog_source_test.dart b/test/services/catalog/trakt_catalog_source_test.dart index 13b0297b..b02458f6 100644 --- a/test/services/catalog/trakt_catalog_source_test.dart +++ b/test/services/catalog/trakt_catalog_source_test.dart @@ -11,7 +11,7 @@ import 'package:plezy/models/catalog/catalog_metadata.dart'; import 'package:plezy/services/catalog/catalog_source.dart'; import 'package:plezy/services/catalog/trakt_catalog_source.dart'; import 'package:plezy/services/trackers/tracker_session.dart'; -import 'package:plezy/services/trakt/trakt_client.dart'; +import 'package:plezy/services/trackers/trakt/trakt_client.dart'; TrackerSession _session() { final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; diff --git a/test/services/trackers/rating_fetch_test.dart b/test/services/trackers/rating_fetch_test.dart index 46893688..0ee28818 100644 --- a/test/services/trackers/rating_fetch_test.dart +++ b/test/services/trackers/rating_fetch_test.dart @@ -10,7 +10,7 @@ import 'package:plezy/services/trackers/mal/mal_tracker.dart'; import 'package:plezy/services/trackers/simkl/simkl_tracker.dart'; import 'package:plezy/services/trackers/tracker_id_resolver.dart'; import 'package:plezy/services/trackers/tracker_session.dart'; -import 'package:plezy/services/trakt/trakt_scrobble_service.dart'; +import 'package:plezy/services/trackers/trakt/trakt_tracker.dart'; import 'package:plezy/utils/external_ids.dart'; int _now() => DateTime.now().millisecondsSinceEpoch ~/ 1000; @@ -47,7 +47,7 @@ TrackerRatingContext _ctx({ void main() { tearDown(() { - TraktScrobbleService.instance.rebindToProfile(null, onSessionInvalidated: () {}); + TraktTracker.instance.rebindSession(null, onSessionInvalidated: () {}); SimklTracker.instance.rebindSession(null, onSessionInvalidated: () {}); MalTracker.instance.rebindSession(null, onSessionInvalidated: () {}); AnilistTracker.instance.rebindSession(null, onSessionInvalidated: () {}); @@ -70,11 +70,9 @@ void main() { 200, ); }); - TraktScrobbleService.instance.rebindToProfile(_traktSession(), onSessionInvalidated: () {}, httpClient: client); + TraktTracker.instance.rebindSession(_traktSession(), onSessionInvalidated: () {}, httpClient: client); - final score = await TraktScrobbleService.instance.getRating( - _ctx(kind: MediaKind.episode, season: 1, episodeNumber: 2), - ); + final score = await TraktTracker.instance.getRating(_ctx(kind: MediaKind.episode, season: 1, episodeNumber: 2)); expect(score, 8); }); diff --git a/test/services/trackers/tracker_error_diagnostics_test.dart b/test/services/trackers/tracker_error_diagnostics_test.dart index 8cfa1429..b3d3e661 100644 --- a/test/services/trackers/tracker_error_diagnostics_test.dart +++ b/test/services/trackers/tracker_error_diagnostics_test.dart @@ -14,8 +14,8 @@ import 'package:plezy/services/trackers/tracker_connect_runner.dart'; import 'package:plezy/services/trackers/tracker_exceptions.dart'; import 'package:plezy/services/trackers/tracker_constants.dart'; import 'package:plezy/services/trackers/tracker_session.dart'; -import 'package:plezy/services/trakt/trakt_auth_service.dart'; -import 'package:plezy/services/trakt/trakt_client.dart'; +import 'package:plezy/services/trackers/trakt/trakt_auth_service.dart'; +import 'package:plezy/services/trackers/trakt/trakt_client.dart'; import 'package:plezy/utils/app_logger.dart'; import 'package:plezy/utils/log_redaction_manager.dart'; diff --git a/test/services/trackers/tracker_status_ladder_test.dart b/test/services/trackers/tracker_status_ladder_test.dart index 79c4a440..12f4536d 100644 --- a/test/services/trackers/tracker_status_ladder_test.dart +++ b/test/services/trackers/tracker_status_ladder_test.dart @@ -9,7 +9,7 @@ import 'package:plezy/services/trackers/simkl/simkl_client.dart'; import 'package:plezy/services/trackers/simkl/simkl_constants.dart'; import 'package:plezy/services/trackers/tracker_exceptions.dart'; import 'package:plezy/services/trackers/tracker_session.dart'; -import 'package:plezy/services/trakt/trakt_client.dart'; +import 'package:plezy/services/trackers/trakt/trakt_client.dart'; TrackerSession _session({String refreshToken = 'refresh-old'}) { final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; diff --git a/test/services/trackers/tracker_write_queue_test.dart b/test/services/trackers/tracker_write_queue_test.dart new file mode 100644 index 00000000..c8302172 --- /dev/null +++ b/test/services/trackers/tracker_write_queue_test.dart @@ -0,0 +1,343 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/trackers/tracker_context.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/services/base_shared_preferences_service.dart'; +import 'package:plezy/services/trackers/tracker_constants.dart'; +import 'package:plezy/services/trackers/tracker_write_queue.dart'; +import 'package:plezy/utils/external_ids.dart'; + +import '../../test_helpers/prefs.dart'; + +const _watchedAt = '2026-05-12T00:00:00.000Z'; + +TrackerContext _episode({ + String ratingKey = 'episode-1', + String? libraryGlobalKey = 'server-1:7', + ExternalIds external = const ExternalIds(tvdb: 123), + int season = 1, + int episodeNumber = 2, +}) => TrackerContext.episode( + external: external, + anime: null, + ratingKey: ratingKey, + libraryGlobalKey: libraryGlobalKey, + season: season, + episodeNumber: episodeNumber, +); + +TrackerContext _movie({ + String ratingKey = 'movie-1', + String? libraryGlobalKey = 'server-1:8', + ExternalIds external = const ExternalIds(tmdb: 456), +}) => TrackerContext.movie(external: external, anime: null, ratingKey: ratingKey, libraryGlobalKey: libraryGlobalKey); + +TrackerWriteQueueItem _item({ + required TrackerContext ctx, + required String coalesceKey, + TrackerService service = TrackerService.trakt, + bool watched = true, + int? progressClaim, + String watchedAtIso = _watchedAt, + int attempts = 0, +}) => TrackerWriteQueueItem( + service: service, + watched: watched, + ctx: ctx, + coalesceKey: coalesceKey, + progressClaim: progressClaim, + watchedAtIso: watchedAtIso, + attempts: attempts, +); + +void main() { + setUp(resetSharedPreferencesForTest); + + test('done flush sends and removes the queued write', () async { + final queue = TrackerWriteQueue(); + final ctx = _episode(); + final key = trackerItemCoalesceKey(TrackerService.trakt, ctx, trackerExternalRowIdentity(ctx.external))!; + await queue.enqueue('user-a', _item(ctx: ctx, coalesceKey: key)); + + final sent = []; + await queue.flush( + 'user-a', + send: (item) async { + sent.add(item); + return TrackerWriteDisposition.done; + }, + ); + + expect(sent, hasLength(1)); + expect(sent.single.ctx.ratingKey, 'episode-1'); + expect(sent.single.watched, isTrue); + expect(await queue.load('user-a'), isEmpty); + }); + + test('failed flush increments attempts and a later flush drops an exhausted write without sending', () async { + final queue = TrackerWriteQueue(); + final ctx = _episode(); + final key = trackerItemCoalesceKey(TrackerService.trakt, ctx, trackerExternalRowIdentity(ctx.external))!; + await queue.enqueue('user-a', _item(ctx: ctx, coalesceKey: key, attempts: TrackerWriteQueue.maxAttempts - 1)); + + var sendCalls = 0; + await queue.flush( + 'user-a', + send: (item) async { + sendCalls++; + return TrackerWriteDisposition.failed; + }, + ); + final exhausted = await queue.load('user-a'); + expect(sendCalls, 1); + expect(exhausted.single.attempts, TrackerWriteQueue.maxAttempts); + + await queue.flush( + 'user-a', + send: (item) async { + sendCalls++; + return TrackerWriteDisposition.done; + }, + ); + expect(sendCalls, 1); + expect(await queue.load('user-a'), isEmpty); + }); + + test('skipped flush keeps the write without burning an attempt', () async { + final queue = TrackerWriteQueue(); + final ctx = _episode(); + final key = trackerItemCoalesceKey(TrackerService.trakt, ctx, trackerExternalRowIdentity(ctx.external))!; + await queue.enqueue('user-a', _item(ctx: ctx, coalesceKey: key, attempts: 2)); + + await queue.flush('user-a', send: (item) async => TrackerWriteDisposition.skipped); + + final remaining = await queue.load('user-a'); + expect(remaining, hasLength(1)); + expect(remaining.single.attempts, 2); + }); + + test('newer per-item history intent replaces the older intent for its coalesce key', () async { + final queue = TrackerWriteQueue(); + final ctx = _episode(); + final key = trackerItemCoalesceKey(TrackerService.trakt, ctx, trackerExternalRowIdentity(ctx.external))!; + await queue.enqueue('user-a', _item(ctx: ctx, coalesceKey: key)); + await queue.enqueue( + 'user-a', + _item(ctx: ctx, coalesceKey: key, watched: false, watchedAtIso: '2026-05-13T00:00:00.000Z'), + ); + + final remaining = await queue.load('user-a'); + expect(remaining, hasLength(1)); + expect(remaining.single.watched, isFalse); + expect(remaining.single.watchedAtIso, '2026-05-13T00:00:00.000Z'); + }); + + test('series progress coalescing retains the greatest monotonic claim', () async { + final queue = TrackerWriteQueue(); + final ctx = _episode(); + final key = trackerSeriesCoalesceKey(TrackerService.mal, 42); + await queue.enqueue('user-a', _item(ctx: ctx, coalesceKey: key, service: TrackerService.mal, progressClaim: 5)); + await queue.enqueue('user-a', _item(ctx: ctx, coalesceKey: key, service: TrackerService.mal, progressClaim: 6)); + expect((await queue.load('user-a')).single.progressClaim, 6); + + await queue.enqueue('user-a', _item(ctx: ctx, coalesceKey: key, service: TrackerService.mal, progressClaim: 5)); + final remaining = await queue.load('user-a'); + expect(remaining, hasLength(1)); + expect(remaining.single.progressClaim, 6); + }); + + test('invalidate drops outright or only claims covered by applied progress', () async { + final queue = TrackerWriteQueue(); + final ctx = _episode(); + final key = trackerSeriesCoalesceKey(TrackerService.anilist, 42); + TrackerWriteQueueItem claim(int progress) => + _item(ctx: ctx, coalesceKey: key, service: TrackerService.anilist, progressClaim: progress); + + await queue.enqueue('user-a', claim(5)); + await queue.invalidate('user-a', key); + expect(await queue.load('user-a'), isEmpty); + + await queue.enqueue('user-a', claim(5)); + await queue.invalidate('user-a', key, appliedProgress: 6); + expect(await queue.load('user-a'), isEmpty); + + await queue.enqueue('user-a', claim(7)); + await queue.invalidate('user-a', key, appliedProgress: 6); + final remaining = await queue.load('user-a'); + expect(remaining, hasLength(1)); + expect(remaining.single.progressClaim, 7); + }); + + test('external identity and media coordinates prevent server-local rating-key collisions', () async { + final queue = TrackerWriteQueue(); + final first = _episode(ratingKey: 'shared-rating-key', external: const ExternalIds(tvdb: 100)); + final second = _episode(ratingKey: 'shared-rating-key', external: const ExternalIds(tvdb: 200)); + final sameRemoteEpisode = _episode(ratingKey: 'different-local-key', external: const ExternalIds(tvdb: 100)); + final movie = _movie(ratingKey: 'shared-rating-key', external: const ExternalIds(tvdb: 100)); + + final firstKey = trackerItemCoalesceKey(TrackerService.trakt, first, trackerExternalRowIdentity(first.external))!; + final secondKey = trackerItemCoalesceKey( + TrackerService.trakt, + second, + trackerExternalRowIdentity(second.external), + )!; + expect(firstKey, isNot(secondKey)); + expect( + trackerItemCoalesceKey( + TrackerService.trakt, + sameRemoteEpisode, + trackerExternalRowIdentity(sameRemoteEpisode.external), + ), + firstKey, + ); + expect( + trackerItemCoalesceKey(TrackerService.trakt, movie, trackerExternalRowIdentity(movie.external)), + isNot(firstKey), + ); + + await queue.enqueue('user-a', _item(ctx: first, coalesceKey: firstKey)); + await queue.enqueue('user-a', _item(ctx: second, coalesceKey: secondKey)); + expect(await queue.load('user-a'), hasLength(2)); + }); + + test('profile queues are isolated and flushing one never sends another profile writes', () async { + final queue = TrackerWriteQueue(); + final first = _episode(ratingKey: 'first', external: const ExternalIds(tvdb: 100)); + final second = _episode(ratingKey: 'second', external: const ExternalIds(tvdb: 200)); + await queue.enqueue( + 'user-a', + _item( + ctx: first, + coalesceKey: trackerItemCoalesceKey(TrackerService.trakt, first, trackerExternalRowIdentity(first.external))!, + ), + ); + await queue.enqueue( + 'user-b', + _item( + ctx: second, + coalesceKey: trackerItemCoalesceKey(TrackerService.trakt, second, trackerExternalRowIdentity(second.external))!, + ), + ); + + expect(await queue.load('user-a'), hasLength(1)); + expect(await queue.load('user-b'), hasLength(1)); + final sent = []; + await queue.flush( + 'user-a', + send: (item) async { + sent.add(item.ctx.ratingKey); + return TrackerWriteDisposition.done; + }, + ); + + expect(sent, ['first']); + expect(await queue.load('user-a'), isEmpty); + expect((await queue.load('user-b')).single.ctx.ratingKey, 'second'); + }); + + test('legacy Trakt rows migrate once with their intent and episode metadata intact', () async { + final prefs = await BaseSharedPreferencesService.sharedCache(); + const user = 'legacy-user'; + final legacyKey = profileScopedPrefsKey(user, 'trakt_sync_queue'); + await prefs.setString( + legacyKey, + json.encode([ + { + 'op': 'add', + 'ratingKey': 'legacy-episode', + 'serverId': 'server-1', + 'libraryGlobalKey': 'server-1:7', + 'kind': 'episode', + 'ids': {'tvdb': 123, 'tmdb': 456, 'imdb': 'tt789'}, + 'season': 3, + 'number': 4, + 'watchedAtIso': '2026-05-12T00:00:00.000Z', + 'attempts': 2, + }, + { + 'op': 'remove', + 'ratingKey': 'legacy-movie', + 'serverId': 'server-2', + 'libraryGlobalKey': 'server-2:8', + 'kind': 'movie', + 'ids': {'tmdb': 999}, + 'watchedAtIso': '2026-05-13T00:00:00.000Z', + 'attempts': 0, + }, + ]), + ); + + final queue = TrackerWriteQueue(); + final sent = []; + await queue.flush( + user, + send: (item) async { + sent.add(item); + return TrackerWriteDisposition.done; + }, + ); + + expect(sent, hasLength(2)); + expect(sent.map((item) => item.service), everyElement(TrackerService.trakt)); + expect(sent[0].watched, isTrue); + expect(sent[0].ctx.season, 3); + expect(sent[0].ctx.episodeNumber, 4); + expect(sent[0].watchedAtIso, '2026-05-12T00:00:00.000Z'); + expect(sent[0].attempts, 2); + expect(sent[1].watched, isFalse); + expect(sent[1].ctx.isMovie, isTrue); + expect(sent[1].watchedAtIso, '2026-05-13T00:00:00.000Z'); + expect(prefs.getString(legacyKey), isNull); + expect(await queue.load(user), isEmpty); + + const malformedUser = 'malformed-legacy-user'; + final malformedKey = profileScopedPrefsKey(malformedUser, 'trakt_sync_queue'); + await prefs.setString(malformedKey, '{not valid json'); + expect(await queue.load(malformedUser), isEmpty); + expect(prefs.getString(malformedKey), isNull); + expect(await queue.load(malformedUser), isEmpty); + }); + + test('a legacy queue left behind by an interrupted migration does not duplicate rows', () async { + final prefs = await BaseSharedPreferencesService.sharedCache(); + const user = 'interrupted-user'; + final legacyRow = { + 'op': 'add', + 'ratingKey': 'legacy-episode', + 'serverId': 'server-1', + 'libraryGlobalKey': 'server-1:7', + 'kind': 'episode', + 'ids': {'tvdb': 123}, + 'season': 3, + 'number': 4, + 'watchedAtIso': '2026-05-12T00:00:00.000Z', + 'attempts': 0, + }; + await prefs.setString(profileScopedPrefsKey(user, 'trakt_sync_queue'), json.encode([legacyRow])); + + // First pass converts the row. A fresh queue instance then finds the legacy + // key again, as it would after a crash between the write and the removal. + expect(await TrackerWriteQueue().load(user), hasLength(1)); + await prefs.setString(profileScopedPrefsKey(user, 'trakt_sync_queue'), json.encode([legacyRow])); + + final migrated = await TrackerWriteQueue().load(user); + + expect(migrated, hasLength(1), reason: 'the row is replaced, not appended a second time'); + expect(migrated.single.ctx.episodeNumber, 4); + }); + + test('corrupt tracker queue payload is archived and discarded without throwing', () async { + final prefs = await BaseSharedPreferencesService.sharedCache(); + const user = 'corrupt-user'; + const corruptPayload = '{not valid json'; + final queueKey = profileScopedPrefsKey(user, 'tracker_write_queue'); + final archiveKey = profileScopedPrefsKey(user, 'tracker_write_queue_corrupt'); + await prefs.setString(queueKey, corruptPayload); + + final queue = TrackerWriteQueue(); + expect(await queue.load(user), isEmpty); + expect(prefs.getString(queueKey), isNull); + expect(prefs.getString(archiveKey), corruptPayload); + }); +} diff --git a/test/services/trackers/tracker_write_retry_test.dart b/test/services/trackers/tracker_write_retry_test.dart new file mode 100644 index 00000000..7b72463b --- /dev/null +++ b/test/services/trackers/tracker_write_retry_test.dart @@ -0,0 +1,673 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/models/trackers/anime_lists_mapping.dart'; +import 'package:plezy/models/trackers/fribb_mapping_row.dart'; +import 'package:plezy/models/trackers/tracker_context.dart'; +import 'package:plezy/services/trackers/anilist/anilist_tracker.dart'; +import 'package:plezy/services/trackers/anime_episode_progress_resolver.dart'; +import 'package:plezy/services/trackers/anime_lists_mapping_store.dart'; +import 'package:plezy/services/trackers/fribb_mapping_store.dart'; +import 'package:plezy/services/trackers/mal/mal_tracker.dart'; +import 'package:plezy/services/trackers/simkl/simkl_tracker.dart'; +import 'package:plezy/services/trackers/tracker_constants.dart'; +import 'package:plezy/services/trackers/tracker_exceptions.dart'; +import 'package:plezy/services/trackers/tracker_coordinator.dart'; +import 'package:plezy/services/trackers/tracker_session.dart'; +import 'package:plezy/services/trackers/tracker_write_queue.dart'; +import 'package:plezy/services/trackers/trakt/trakt_tracker.dart'; +import 'package:plezy/utils/external_ids.dart'; + +import '../../test_helpers/media_items.dart'; +import '../../test_helpers/prefs.dart'; + +/// Media server that only answers what the tracker resolver asks for. +class _FakeMediaServerClient implements MediaServerClient { + @override + final ServerId serverId; + @override + String? get serverName => null; + + final Map externalIdsByItem; + + @override + final double watchedThreshold; + + _FakeMediaServerClient({required this.externalIdsByItem, this.watchedThreshold = 0.9}) + : serverId = ServerId('server-1'); + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + Future fetchExternalIds(String itemId) async => externalIdsByItem[itemId] ?? const ExternalIds(); + + @override + Future> fetchChildren(String parentId) async => const []; + + @override + Future> fetchPlayableDescendants(String parentId) async => const []; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeFribbLookup implements FribbMappingLookup { + const _FakeFribbLookup(this.rows); + + final List rows; + + /// Filters by tvdb id so distinct shows map to distinct anime entries, which is + /// what makes their queued rows distinct. + @override + Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async => + rows.where((row) => tvdbId == null || row.tvdbId == tvdbId).toList(); + + @override + Future lookupByMal(int malId) async => rows.where((row) => row.malId == malId).firstOrNull; +} + +/// Rollup resolution has its own suite; here the episode's own number is the claim. +class _FakeAnimeProgressLookup implements AnimeEpisodeProgressLookup { + const _FakeAnimeProgressLookup(); + + @override + Future resolve( + MediaItem episode, { + required AnimeProgressScope scope, + AnimeEpisodeMatch? animeMatch, + Future Function(MediaItem episode)? episodeMatcher, + bool includeCurrentEpisode = true, + }) async => null; + + @override + void clearCache() {} +} + +class _FakeAnimeListsLookup implements AnimeListsMappingLookup { + const _FakeAnimeListsLookup(); + + @override + Future lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber}) async => null; + + @override + Future> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season}) async => const {}; + + @override + Future> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async => const {}; +} + +/// MAL posts form-encoded list updates; every other service posts JSON. +Map _decodeBody(String body) { + if (body.isEmpty) return {}; + if (body.startsWith('{') || body.startsWith('[')) { + final decoded = json.decode(body); + return decoded is Map ? decoded.cast() : {'body': decoded}; + } + return Uri.splitQueryString(body); +} + +/// Records every write and can hold one in flight, which is how request ordering +/// is driven without leaning on wall-clock timing. +class _Recorder { + final List paths = []; + final List> bodies = []; + Completer? gate; + int status = 200; + + http.Client get client => MockClient((request) async { + paths.add(request.url.path); + bodies.add(_decodeBody(request.body)); + final pending = gate; + if (pending != null) await pending.future; + return http.Response('{}', status); + }); +} + +/// Stands in for an endpoint that cannot be reached at all, as opposed to one +/// that answers with an error. +http.Client _unreachableClient() => MockClient((_) async => throw http.ClientException('no route to host')); + +MediaItem _episodeItem(int number) => testMediaItem( + id: 'episode-1-$number', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode $number', + serverId: ServerId('server-1'), + libraryId: 'lib-1', + parentIndex: 1, + index: number, + grandparentId: 'show-1', +); + +MediaItem _episodeItemOfShow(String showId, int number) => testMediaItem( + id: '$showId-episode-$number', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode $number', + serverId: ServerId('server-1'), + libraryId: 'lib-1', + parentIndex: 1, + index: number, + grandparentId: showId, +); + +MediaItem _movieItem({int? viewOffsetMs, int? durationMs}) => testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Movie 1', + serverId: ServerId('server-1'), + libraryId: 'lib-1', + viewOffsetMs: viewOffsetMs, + durationMs: durationMs, +); + +/// Shows with their own tvdb id, each mapping to its own anime entry below. +const _showTvdbIds = {'show-a': 20001, 'show-b': 20002, 'show-c': 20003}; + +_FakeMediaServerClient _client({double watchedThreshold = 0.9}) => _FakeMediaServerClient( + externalIdsByItem: { + 'show-1': const ExternalIds(tvdb: 12345), + 'movie-1': const ExternalIds(tmdb: 603), + for (final show in _showTvdbIds.entries) show.key: ExternalIds(tvdb: show.value), + }, + watchedThreshold: watchedThreshold, +); + +TrackerSession _session() => + TrackerSession(accessToken: 'token', createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000); + +/// The list-status writes MAL received, newest last. +List _malProgressWrites(_Recorder recorder) => [ + for (var i = 0; i < recorder.paths.length; i++) + if (recorder.paths[i].contains('my_list_status')) int.parse(recorder.bodies[i]['num_watched_episodes'].toString()), +]; + +void main() { + final coordinator = TrackerCoordinator.instance; + final mal = MalTracker.instance; + final anilist = AnilistTracker.instance; + final simkl = SimklTracker.instance; + final trakt = TraktTracker.instance; + + setUp(() async { + resetSharedPreferencesForTest(); + coordinator.onActiveProfileChanged('user-a'); + coordinator.debugUseResolverDependencies( + store: const _FakeFribbLookup([ + FribbMappingRow(tvdbId: 12345, malId: 101, anilistId: 201, type: 'TV'), + FribbMappingRow(tvdbId: 20001, malId: 301, anilistId: 401, type: 'TV'), + FribbMappingRow(tvdbId: 20002, malId: 302, anilistId: 402, type: 'TV'), + FribbMappingRow(tvdbId: 20003, malId: 303, anilistId: 403, type: 'TV'), + ]), + animeLists: const _FakeAnimeListsLookup(), + animeProgress: const _FakeAnimeProgressLookup(), + ); + await anilist.setEnabled(false); + await simkl.setEnabled(false); + await trakt.setEnabled(false); + await trakt.setWatchedSyncEnabled(false); + await mal.setEnabled(true); + }); + + tearDown(() async { + coordinator.cancelInFlight(); + coordinator.debugUseResolverDependencies(); + coordinator.onActiveProfileChanged(''); + mal.rebindSession(null, onSessionInvalidated: () {}); + anilist.rebindSession(null, onSessionInvalidated: () {}); + simkl.rebindSession(null, onSessionInvalidated: () {}); + trakt.rebindSession(null, onSessionInvalidated: () {}); + await mal.setEnabled(false); + await trakt.setWatchedSyncEnabled(false); + }); + + group('failed watched writes are retried', () { + test('a failed series-progress write is replayed on the next flush', () async { + final recorder = _Recorder()..status = 500; + mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client); + + await coordinator.markWatched(_episodeItem(5), _client()); + expect(_malProgressWrites(recorder), [5], reason: 'the first attempt goes out and fails'); + + recorder.status = 200; + await coordinator.flushWriteQueue(); + + expect(_malProgressWrites(recorder), [5, 5], reason: 'the queued claim is replayed once the service recovers'); + }); + + test('a replayed write that already succeeded is not sent twice', () async { + final recorder = _Recorder(); + mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client); + + await coordinator.markWatched(_episodeItem(5), _client()); + await coordinator.flushWriteQueue(); + + expect(_malProgressWrites(recorder), [5]); + }); + + test('a newer completed claim drops the stale queued one', () async { + final recorder = _Recorder()..status = 500; + mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client); + + // Episode 5 fails and is queued as a claim of 5. + await coordinator.markWatched(_episodeItem(5), _client()); + recorder.status = 200; + // Episode 6 lands directly: the queued claim is now behind the counter. + await coordinator.markWatched(_episodeItem(6), _client()); + await coordinator.flushWriteQueue(); + + expect(_malProgressWrites(recorder), [ + 5, + 6, + ], reason: 'replaying the claim of 5 after 6 landed would walk the list backwards'); + }); + + test('a queued claim still ahead of the applied progress survives', () async { + final recorder = _Recorder()..status = 500; + mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client); + + await coordinator.markWatched(_episodeItem(6), _client()); + recorder.status = 200; + await coordinator.markWatched(_episodeItem(5), _client()); + await coordinator.flushWriteQueue(); + + expect(_malProgressWrites(recorder), [6, 5, 6], reason: 'the queued 6 is a pending advance, not a stale claim'); + }); + }); + + group('a failure racing a newer write is not persisted', () { + test('an older history failure never replaces a newer one', () async { + await mal.setEnabled(false); + await trakt.setEnabled(true); + await trakt.setWatchedSyncEnabled(true); + final recorder = _Recorder()..status = 500; + trakt.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client); + + final client = _client(); + // Both fail, newest last: the queue must end up holding the un-watch. + await coordinator.markWatched(_movieItem(), client); + await coordinator.markUnwatched(_movieItem(), client); + + recorder + ..status = 200 + ..paths.clear(); + await coordinator.flushWriteQueue(); + + expect(recorder.paths, ['/sync/history/remove'], reason: 'the newest intent for the row is the only one queued'); + }); + + test('a queued higher claim survives a newer lower one', () async { + final recorder = _Recorder()..status = 500; + mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client); + + // Episode 6 first, then 5: progress claims are monotonic, so the queue must + // keep the higher one whichever order the failures arrive in. + await coordinator.markWatched(_episodeItem(6), _client()); + await coordinator.markWatched(_episodeItem(5), _client()); + + recorder.status = 200; + await coordinator.flushWriteQueue(); + + expect(_malProgressWrites(recorder).last, 6); + }); + }); + + group('a rate-limited service', () { + test('one back-off answer stops the drain asking again for that service', () async { + final recorder = _Recorder()..status = 429; + mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client); + + // Three separate shows, so three queued rows for one service. + for (final show in ['show-a', 'show-b', 'show-c']) { + await coordinator.markWatched(_episodeItemOfShow(show, 5), _client()); + } + expect(_malProgressWrites(recorder), hasLength(3), reason: 'each live write tried once'); + + recorder.paths.clear(); + await coordinator.flushWriteQueue(); + + expect( + _malProgressWrites(recorder), + hasLength(1), + reason: 'after the first 429 the drain leaves the rest of the service alone', + ); + + // Once it recovers, every row still drains. + final recovered = _Recorder(); + mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recovered.client); + await coordinator.flushWriteQueue(); + + expect(_malProgressWrites(recovered), hasLength(3)); + }); + + test('a coalesced second flush does not re-ask during the same burst', () async { + final recorder = _Recorder()..status = 429; + mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client); + for (final show in ['show-a', 'show-b']) { + await coordinator.markWatched(_episodeItemOfShow(show, 5), _client()); + } + + recorder + ..paths.clear() + ..gate = Completer(); + // Two triggers landing together — network restore and app resume can — so + // the second coalesces onto the running drain and re-enters its loop. + final first = coordinator.flushWriteQueue(); + await pumpEventQueue(); + final second = coordinator.flushWriteQueue(); + recorder.gate!.complete(); + await first; + await second; + + expect(_malProgressWrites(recorder), hasLength(1), reason: 'the deferral spans the whole burst'); + }); + }); + + group('isTrackerFailureTransient', () { + test('separates non-verdicts from an answer about the write', () { + // Never reached the service. + expect(isTrackerFailureTransient(TimeoutException('timed out')), isTrue); + expect(isTrackerFailureTransient(const SocketException('no route')), isTrue); + expect(isTrackerFailureTransient(http.ClientException('closed')), isTrue); + + // The service asked us to come back. + expect( + isTrackerFailureTransient( + const TrackerRateLimitException(service: TrackerService.trakt, retryAfterSeconds: 60), + ), + isTrue, + ); + expect( + isTrackerFailureTransient(const TrackerApiException(service: TrackerService.mal, statusCode: 429)), + isTrue, + reason: 'MAL and Simkl surface a 429 untyped', + ); + + // The service broke on its own side. + expect( + isTrackerFailureTransient(const TrackerApiException(service: TrackerService.simkl, statusCode: 500)), + isTrue, + ); + expect( + isTrackerFailureTransient(const TrackerApiException(service: TrackerService.simkl, statusCode: 503)), + isTrue, + ); + + // A refresh that can still succeed, versus a session that is really gone. + expect( + isTrackerFailureTransient( + const TrackerAuthException(service: TrackerService.mal, message: 'Refresh failed: HTTP 503', statusCode: 503), + ), + isTrue, + ); + expect( + isTrackerFailureTransient( + const TrackerAuthException( + service: TrackerService.mal, + message: 'Session invalidated (401)', + statusCode: 401, + isPermanent: true, + ), + ), + isFalse, + ); + + // Answers about the write itself. + for (final status in [400, 401, 403, 404, 409, 422]) { + expect( + isTrackerFailureTransient(TrackerApiException(service: TrackerService.trakt, statusCode: status)), + isFalse, + reason: 'HTTP $status is the service answering about this write', + ); + } + }); + }); + + group('a service that cannot answer for the write', () { + test('a rate limit never spends the retry budget', () async { + final recorder = _Recorder()..status = 429; + mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client); + await coordinator.markWatched(_episodeItem(5), _client()); + + for (var i = 0; i < TrackerWriteQueue.maxAttempts + 2; i++) { + await coordinator.flushWriteQueue(); + } + + final recovered = _Recorder(); + mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recovered.client); + await coordinator.flushWriteQueue(); + + expect(_malProgressWrites(recovered), [5], reason: 'a 429 is explicitly retryable, not a verdict on the write'); + }); + + test('a server-side failure never spends the retry budget', () async { + final recorder = _Recorder()..status = 503; + mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client); + await coordinator.markWatched(_episodeItem(5), _client()); + + for (var i = 0; i < TrackerWriteQueue.maxAttempts + 2; i++) { + await coordinator.flushWriteQueue(); + } + + final recovered = _Recorder(); + mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recovered.client); + await coordinator.flushWriteQueue(); + + expect(_malProgressWrites(recovered), [5], reason: 'a bad hour for the service is not a bad watch'); + }); + + test('a rejected write is dropped once its attempts are spent', () async { + // 422 is the service answering about this write: asking again cannot help, + // so the item must not be retried forever. + final recorder = _Recorder()..status = 422; + mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client); + await coordinator.markWatched(_episodeItem(5), _client()); + + for (var i = 0; i < TrackerWriteQueue.maxAttempts + 1; i++) { + await coordinator.flushWriteQueue(); + } + + final recovered = _Recorder(); + mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recovered.client); + await coordinator.flushWriteQueue(); + + expect(_malProgressWrites(recovered), isEmpty, reason: 'the answered rejection exhausted the budget'); + }); + + test('an unreachable service never spends the retry budget', () async { + final recorder = _Recorder()..status = 500; + mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client); + await coordinator.markWatched(_episodeItem(5), _client()); + expect(_malProgressWrites(recorder), [5], reason: 'the first attempt is answered and fails'); + + // Now the endpoint is unreachable rather than answering. A connectivity flap + // can drive many flushes; none of them may exhaust the item's attempts, + // because nothing was learned about the write. + mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: _unreachableClient()); + for (var i = 0; i < TrackerWriteQueue.maxAttempts + 2; i++) { + await coordinator.flushWriteQueue(); + } + + // The service answers again: the watch is still queued and still lands. + final recovered = _Recorder(); + mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recovered.client); + await coordinator.flushWriteQueue(); + + expect(_malProgressWrites(recovered), [5], reason: 'the queued claim survived every unreachable flush'); + }); + }); + + group('writes to one remote row are serialised', () { + test('a replay already in flight cannot land after a newer direct write', () async { + final recorder = _Recorder()..status = 500; + mal.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client); + await coordinator.markWatched(_episodeItem(5), _client()); + expect(_malProgressWrites(recorder), [5]); + + // Hold the replay's request open, then let a direct write for the same + // entry arrive while it is still on the wire. + recorder + ..status = 200 + ..gate = Completer(); + final flush = coordinator.flushWriteQueue(); + await pumpEventQueue(); + expect(_malProgressWrites(recorder), [5, 5], reason: 'the replay is in flight'); + + final live = coordinator.markWatched(_episodeItem(6), _client()); + await pumpEventQueue(); + expect(_malProgressWrites(recorder), [5, 5], reason: 'the direct write waits for the row to be free'); + + recorder.gate!.complete(); + await flush; + await live; + + expect(_malProgressWrites(recorder).last, 6, reason: 'the newest write is the last one to reach the service'); + }); + }); + + group('queued rows a completed write already covers', () { + final ctx = TrackerContext.episode( + external: const ExternalIds(tvdb: 12345), + anime: null, + ratingKey: 'episode-1-5', + libraryGlobalKey: 'server-1:lib-1', + season: 1, + episodeNumber: 5, + ); + + TrackerWriteQueueItem item(String key) => TrackerWriteQueueItem( + service: TrackerService.mal, + watched: true, + ctx: ctx, + coalesceKey: key, + progressClaim: 5, + watchedAtIso: '2026-05-12T00:00:00.000Z', + ); + + test('a marked row is reported superseded until its marker is cleared', () async { + final queue = TrackerWriteQueue(); + final key = trackerSeriesCoalesceKey(TrackerService.mal, 101); + final queued = item(key); + + expect(queue.isSuperseded('user-a', queued), isFalse); + + final token = queue.noteDirectWrite('user-a', key, appliedProgress: 6); + expect(queue.isSuperseded('user-a', queued), isTrue, reason: 'progress 6 covers a claim of 5'); + + queue.clearDirectWrite('user-a', key, token); + expect(queue.isSuperseded('user-a', queued), isFalse); + }); + + test('a marker only covers claims at or below the progress it applied', () async { + final queue = TrackerWriteQueue(); + final key = trackerSeriesCoalesceKey(TrackerService.mal, 101); + queue.noteDirectWrite('user-a', key, appliedProgress: 4); + + expect(queue.isSuperseded('user-a', item(key)), isFalse, reason: 'a claim of 5 is still a pending advance'); + }); + + test('one profile\'s marker never covers another profile\'s queued row', () async { + final queue = TrackerWriteQueue(); + final key = trackerSeriesCoalesceKey(TrackerService.mal, 101); + queue.noteDirectWrite('user-a', key, appliedProgress: 6); + + expect(queue.isSuperseded('user-b', item(key)), isFalse); + expect(queue.isSuperseded('user-a', item(key)), isTrue); + }); + + test('a stale marker cannot be cleared by an older write finishing', () async { + final queue = TrackerWriteQueue(); + final key = trackerSeriesCoalesceKey(TrackerService.mal, 101); + final first = queue.noteDirectWrite('user-a', key, appliedProgress: 6); + queue.noteDirectWrite('user-a', key, appliedProgress: 7); + + queue.clearDirectWrite('user-a', key, first); + + expect(queue.isSuperseded('user-a', item(key)), isTrue, reason: 'the newer marker must survive'); + }); + }); + + group('scrobbling turned off mid-playback', () { + test('the watch still reaches history when the owner can no longer report', () async { + await mal.setEnabled(false); + await trakt.setEnabled(true); + await trakt.setWatchedSyncEnabled(true); + final recorder = _Recorder(); + trakt.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client); + + final client = _client(watchedThreshold: 0.5); + await coordinator.startPlayback(_movieItem(durationMs: 100000), client); + coordinator.updateDuration(const Duration(milliseconds: 100000)); + // Crossing the threshold hands the watch to Trakt's own stop... + coordinator.updatePosition(const Duration(milliseconds: 60000)); + await pumpEventQueue(); + // ...and then the user turns scrobbling off, so that stop never goes out. + await trakt.setEnabled(false); + recorder.paths.clear(); + await coordinator.stopPlayback(); + await pumpEventQueue(); + + expect(recorder.paths, isNot(contains('/scrobble/stop'))); + expect( + recorder.paths, + contains('/sync/history'), + reason: 'neither the crossing nor the stop recorded it, so the fallback must', + ); + }); + }); + + group('a terminal stop the service never acknowledged', () { + test('Simkl records the watch through history when the stop fails', () async { + await mal.setEnabled(false); + await simkl.setEnabled(true); + final recorder = _Recorder(); + simkl.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client); + + final client = _client(); + await coordinator.startPlayback(_movieItem(durationMs: 100000), client); + // Past the server threshold, and past Simkl's own 80% completion rule, so a + // confirmed stop would have recorded the watch by itself. + coordinator.updateDuration(const Duration(milliseconds: 100000)); + coordinator.updatePosition(const Duration(milliseconds: 95000)); + recorder.status = 500; + await coordinator.stopPlayback(); + recorder.status = 200; + await pumpEventQueue(); + + expect(recorder.paths, contains('/scrobble/stop')); + expect( + recorder.paths.where((path) => path == '/sync/history'), + hasLength(1), + reason: 'nothing on Simkl saw the item finish, so the watch falls back to history', + ); + }); + + test('a confirmed stop above the completion rule writes no history', () async { + await mal.setEnabled(false); + await simkl.setEnabled(true); + final recorder = _Recorder(); + simkl.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client); + + final client = _client(); + await coordinator.startPlayback(_movieItem(durationMs: 100000), client); + coordinator.updateDuration(const Duration(milliseconds: 100000)); + coordinator.updatePosition(const Duration(milliseconds: 95000)); + await coordinator.stopPlayback(); + await pumpEventQueue(); + + expect(recorder.paths, contains('/scrobble/stop')); + expect(recorder.paths, isNot(contains('/sync/history')), reason: 'the stop already recorded the watch'); + }); + }); +} diff --git a/test/services/trakt_catalog_test.dart b/test/services/trackers/trakt_catalog_test.dart similarity index 98% rename from test/services/trakt_catalog_test.dart rename to test/services/trackers/trakt_catalog_test.dart index ecefb0eb..cae4c308 100644 --- a/test/services/trakt_catalog_test.dart +++ b/test/services/trackers/trakt_catalog_test.dart @@ -7,8 +7,8 @@ import 'package:plezy/models/trakt/trakt_catalog_entry.dart'; import 'package:plezy/models/trakt/trakt_catalog_media.dart'; import 'package:plezy/models/trakt/trakt_images.dart'; import 'package:plezy/services/trackers/tracker_session.dart'; -import 'package:plezy/services/trakt/trakt_client.dart'; -import 'package:plezy/services/trakt/trakt_constants.dart'; +import 'package:plezy/services/trackers/trakt/trakt_client.dart'; +import 'package:plezy/services/trackers/trakt/trakt_constants.dart'; int _now() => DateTime.now().millisecondsSinceEpoch ~/ 1000; diff --git a/test/services/trakt_client_test.dart b/test/services/trackers/trakt_client_test.dart similarity index 99% rename from test/services/trakt_client_test.dart rename to test/services/trackers/trakt_client_test.dart index 9fa63f59..58c0b132 100644 --- a/test/services/trakt_client_test.dart +++ b/test/services/trackers/trakt_client_test.dart @@ -6,7 +6,7 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/services/trackers/tracker_exceptions.dart'; import 'package:plezy/services/trackers/tracker_session.dart'; -import 'package:plezy/services/trakt/trakt_client.dart'; +import 'package:plezy/services/trackers/trakt/trakt_client.dart'; int _now() => DateTime.now().millisecondsSinceEpoch ~/ 1000; diff --git a/test/services/trackers/trakt_scrobble_test.dart b/test/services/trackers/trakt_scrobble_test.dart new file mode 100644 index 00000000..4bbc4208 --- /dev/null +++ b/test/services/trackers/trakt_scrobble_test.dart @@ -0,0 +1,355 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/services/trackers/anilist/anilist_tracker.dart'; +import 'package:plezy/services/trackers/mal/mal_tracker.dart'; +import 'package:plezy/services/trackers/simkl/simkl_tracker.dart'; +import 'package:plezy/services/trackers/tracker_coordinator.dart'; +import 'package:plezy/services/trackers/tracker_session.dart'; +import 'package:plezy/services/trackers/trakt/trakt_tracker.dart'; +import 'package:plezy/utils/external_ids.dart'; + +import '../../test_helpers/media_items.dart'; +import '../../test_helpers/prefs.dart'; + +class _FakeMediaServerClient implements MediaServerClient { + @override + final ServerId serverId; + @override + String? get serverName => null; + + final Map externalIdsByItem; + + @override + final double watchedThreshold; + + _FakeMediaServerClient({required this.externalIdsByItem, this.watchedThreshold = 0.9}) + : serverId = ServerId('server-1'); + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + Future fetchExternalIds(String itemId) async => externalIdsByItem[itemId] ?? const ExternalIds(); + + @override + Future> fetchPlayableDescendants(String parentId) async => const []; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Call { + final String path; + final Map body; + + _Call(this.path, this.body); + + @override + String toString() => '$path ${json.encode(body)}'; +} + +class _TraktRecorder { + final List<_Call> calls = []; + final Map statuses = {}; + Completer? gate; + + http.Client get client => MockClient((request) async { + final body = request.body.isEmpty + ? {} + : (json.decode(request.body) as Map).cast(); + calls.add(_Call(request.url.path, body)); + final pending = gate; + if (pending != null) await pending.future; + return http.Response('{}', statuses[request.url.path] ?? 200); + }); + + List get paths => calls.map((call) => call.path).toList(); + + List<_Call> callsFor(String path) => calls.where((call) => call.path == path).toList(); + + _Call callFor(String path) => calls.firstWhere((call) => call.path == path, orElse: () => fail('no $path in $paths')); +} + +MediaItem _episode({int? viewOffsetMs, int? durationMs}) => testMediaItem( + id: 'episode-1-3', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode 3', + serverId: ServerId('server-1'), + libraryId: 'lib-1', + parentIndex: 1, + index: 3, + grandparentId: 'show-1', + viewOffsetMs: viewOffsetMs, + durationMs: durationMs, +); + +TrackerSession _session([String token = 'token']) => + TrackerSession(accessToken: token, createdAt: DateTime(2026, 7, 30).millisecondsSinceEpoch ~/ 1000); + +_FakeMediaServerClient _client({double watchedThreshold = 0.9}) => _FakeMediaServerClient( + externalIdsByItem: {'show-1': const ExternalIds(tvdb: 12345)}, + watchedThreshold: watchedThreshold, +); + +void main() { + final coordinator = TrackerCoordinator.instance; + final trakt = TraktTracker.instance; + final simkl = SimklTracker.instance; + final mal = MalTracker.instance; + final anilist = AnilistTracker.instance; + + late _TraktRecorder recorder; + late DateTime now; + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + + recorder = _TraktRecorder(); + now = DateTime(2026, 7, 30, 12); + coordinator.debugUseScrobbleClock(() => now); + + simkl.rebindSession(null, onSessionInvalidated: () {}); + mal.rebindSession(null, onSessionInvalidated: () {}); + anilist.rebindSession(null, onSessionInvalidated: () {}); + trakt.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client); + + await simkl.setEnabled(false); + await mal.setEnabled(false); + await anilist.setEnabled(false); + await trakt.setEnabled(true); + await trakt.setWatchedSyncEnabled(true); + }); + + tearDown(() async { + if (recorder.gate?.isCompleted == false) recorder.gate!.complete(); + coordinator.cancelInFlight(); + coordinator.debugUseResolverDependencies(); + coordinator.debugUseScrobbleClock(null); + + trakt.rebindSession(null, onSessionInvalidated: () {}); + simkl.rebindSession(null, onSessionInvalidated: () {}); + mal.rebindSession(null, onSessionInvalidated: () {}); + anilist.rebindSession(null, onSessionInvalidated: () {}); + + await trakt.setEnabled(false); + await trakt.setWatchedSyncEnabled(false); + await simkl.setEnabled(false); + await mal.setEnabled(false); + await anilist.setEnabled(false); + SettingsService.resetForTesting(); + }); + + void moveWithoutSeeking(Duration target) { + for (var milliseconds = 5000; milliseconds < target.inMilliseconds; milliseconds += 5000) { + coordinator.updatePosition(Duration(milliseconds: milliseconds)); + } + coordinator.updatePosition(target); + } + + Future startAtZero({_FakeMediaServerClient? client}) async { + await coordinator.startPlayback(_episode(durationMs: 100000), client ?? _client()); + await pumpEventQueue(); + } + + group('Trakt real-time playback', () { + test('start posts the resume offset and episode identity', () async { + await coordinator.startPlayback( + _episode(viewOffsetMs: const Duration(minutes: 10).inMilliseconds, durationMs: 2000000), + _client(), + ); + await pumpEventQueue(); + + expect(recorder.paths, ['/scrobble/start']); + expect(recorder.calls.single.body, { + 'progress': 30.0, + 'show': { + 'ids': {'tvdb': 12345}, + }, + 'episode': {'season': 1, 'number': 3}, + }); + }); + + test('pause checkpoints progress and resume starts again', () async { + await startAtZero(); + moveWithoutSeeking(const Duration(seconds: 40)); + + await coordinator.pausePlayback(); + await coordinator.resumePlayback(); + await pumpEventQueue(); + + expect(recorder.paths, ['/scrobble/start', '/scrobble/pause', '/scrobble/start']); + expect(recorder.calls[1].body['progress'], 40.0); + expect(recorder.calls[2].body['progress'], 40.0); + }); + + test('same-state start obeys the thirty-second resend throttle', () async { + await startAtZero(); + + now = now.add(const Duration(seconds: 5)); + await coordinator.resumePlayback(); + await pumpEventQueue(); + expect(recorder.paths, ['/scrobble/start']); + + now = now.add(const Duration(seconds: 25)); + await coordinator.resumePlayback(); + await pumpEventQueue(); + expect(recorder.paths, ['/scrobble/start', '/scrobble/start']); + }); + + test('seek checkpoints are throttled and ignored while paused', () async { + await startAtZero(); + coordinator.updatePosition(const Duration(seconds: 4)); + coordinator.updatePosition(const Duration(seconds: 40)); + await pumpEventQueue(); + + expect(recorder.paths, ['/scrobble/start', '/scrobble/pause', '/scrobble/start']); + expect(recorder.calls[1].body['progress'], 40.0); + expect(recorder.calls[2].body['progress'], 40.0); + + coordinator.updatePosition(const Duration(seconds: 70)); + await pumpEventQueue(); + expect(recorder.paths, ['/scrobble/start', '/scrobble/pause', '/scrobble/start']); + + now = now.add(const Duration(seconds: 5)); + coordinator.updatePosition(const Duration(seconds: 20)); + await pumpEventQueue(); + expect(recorder.paths, [ + '/scrobble/start', + '/scrobble/pause', + '/scrobble/start', + '/scrobble/pause', + '/scrobble/start', + ]); + expect(recorder.calls[3].body['progress'], 20.0); + expect(recorder.calls[4].body['progress'], 20.0); + + await coordinator.pausePlayback(); + final callsBeforePausedJump = recorder.calls.length; + coordinator.updatePosition(const Duration(seconds: 80)); + await pumpEventQueue(); + expect(recorder.calls, hasLength(callsBeforePausedJump)); + }); + + test('stop reports measured progress without inflating it to watched', () async { + await startAtZero(); + moveWithoutSeeking(const Duration(seconds: 60)); + + await coordinator.stopPlayback(); + + expect(recorder.paths, ['/scrobble/start', '/scrobble/stop']); + expect(recorder.callFor('/scrobble/stop').body['progress'], 60.0); + }); + + test('a low server watched threshold falls back to Trakt history', () async { + await startAtZero(client: _client(watchedThreshold: 0.5)); + moveWithoutSeeking(const Duration(seconds: 60)); + await pumpEventQueue(); + + await coordinator.stopPlayback(); + await pumpEventQueue(); + + expect(recorder.paths, ['/scrobble/start', '/scrobble/stop', '/sync/history']); + expect(recorder.callFor('/scrobble/stop').body['progress'], 60.0); + expect(recorder.callFor('/sync/history').body, { + 'shows': [ + { + 'ids': {'tvdb': 12345}, + 'seasons': [ + { + 'number': 1, + 'episodes': [ + {'number': 3}, + ], + }, + ], + }, + ], + }); + }); + + test('an unconfirmed completed stop falls back to Trakt history', () async { + recorder.statuses['/scrobble/stop'] = 500; + await startAtZero(client: _client(watchedThreshold: 0.8)); + moveWithoutSeeking(const Duration(seconds: 85)); + await pumpEventQueue(); + + await coordinator.stopPlayback(); + await pumpEventQueue(); + + expect(recorder.paths, ['/scrobble/start', '/scrobble/stop', '/sync/history']); + expect(recorder.callFor('/scrobble/stop').body['progress'], 85.0); + expect(recorder.callsFor('/sync/history'), hasLength(1)); + }); + + Future crossThresholdThenStopBelowTraktRule() async { + await startAtZero(); + moveWithoutSeeking(const Duration(seconds: 95)); + await pumpEventQueue(); + coordinator.updateDuration(const Duration(seconds: 200)); + await coordinator.stopPlayback(); + await pumpEventQueue(); + } + + test('scrobble and watched sync together record one watch', () async { + await crossThresholdThenStopBelowTraktRule(); + + expect(recorder.paths, ['/scrobble/start', '/scrobble/stop', '/sync/history']); + expect(recorder.callFor('/scrobble/stop').body['progress'], 47.5); + expect(recorder.callsFor('/sync/history'), hasLength(1)); + }); + + test('watched sync works with real-time scrobbling disabled', () async { + await trakt.setEnabled(false); + + await crossThresholdThenStopBelowTraktRule(); + + expect(recorder.paths.where((path) => path.startsWith('/scrobble/')), isEmpty); + expect(recorder.paths, ['/sync/history']); + }); + + test('real-time scrobbling never writes history when watched sync is disabled', () async { + await trakt.setWatchedSyncEnabled(false); + + await crossThresholdThenStopBelowTraktRule(); + + expect(recorder.paths, ['/scrobble/start', '/scrobble/stop']); + expect(recorder.callFor('/scrobble/stop').body['progress'], 47.5); + expect(recorder.callsFor('/sync/history'), isEmpty); + }); + + test('disabling both Trakt toggles suppresses every request', () async { + await trakt.setEnabled(false); + await trakt.setWatchedSyncEnabled(false); + + await crossThresholdThenStopBelowTraktRule(); + + expect(recorder.calls, isEmpty); + }); + + test('an account rebind before stop keeps the terminal report off the new account', () async { + await startAtZero(); + moveWithoutSeeking(const Duration(seconds: 40)); + + final replacement = _TraktRecorder(); + trakt.rebindSession(_session('replacement-token'), onSessionInvalidated: () {}, httpClient: replacement.client); + await coordinator.stopPlayback(); + await pumpEventQueue(); + + expect(recorder.paths, ['/scrobble/start']); + expect(replacement.calls, isEmpty); + }); + }); +} diff --git a/test/services/trakt_sync_queue_test.dart b/test/services/trakt_sync_queue_test.dart deleted file mode 100644 index 5a5a6280..00000000 --- a/test/services/trakt_sync_queue_test.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/models/trakt/trakt_ids.dart'; -import 'package:plezy/services/trakt/trakt_constants.dart'; -import 'package:plezy/services/trakt/trakt_sync_queue.dart'; - -void main() { - test('TraktSyncQueueItem preserves library context in JSON', () { - const item = TraktSyncQueueItem( - op: TraktSyncOp.add, - ratingKey: 'episode-1', - serverId: 'server-1', - libraryGlobalKey: 'server-1:7', - kind: TraktMediaKind.episode, - ids: TraktIds(tvdb: 123), - watchedAtIso: '2026-05-12T00:00:00.000Z', - season: 1, - number: 2, - ); - - final decoded = TraktSyncQueueItem.fromJson(item.toJson()); - - expect(decoded.libraryGlobalKey, 'server-1:7'); - expect(decoded.incrementAttempts().libraryGlobalKey, 'server-1:7'); - }); -}