From 4c8272d5b12f9de6207db811a8ea3ebba98a535f Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:51:32 +0200 Subject: [PATCH] refactor(trackers): drive Trakt through the tracker coordinator Trakt was the one service outside the tracker abstraction. TraktScrobbleService re-implemented the whole playback lifecycle beside TrackerCoordinator, and TraktSyncService pushed watched state from its own WatchStateNotifier subscription, so the player called two objects at every lifecycle point and one watch could be written twice. TraktTracker now implements RealtimeScrobbleTracker like Simkl; the duplicated player call sites collapse to one each, and Trakt shares the coordinator's ID resolver instead of re-fetching show ids every episode. Capabilities are split so a tracker declares what it is rather than being special-cased: ScrobblePolicy carries each service's own resend/seek rules, EpisodeHistoryTracker names the remote row a per-item history write targets, and SeriesProgressTracker covers one-counter-per-series services. Writes from all four trackers go through a shared TrackerWriteQueue, generalised from the Trakt-only queue, with the legacy Trakt payload migrated on load. Trakt becomes the fourth TrackersProvider slot and TraktAccountProvider is deleted, so one object owns the active session per profile. Two failure paths found while consolidating are fixed here too. The queue's retries only ran on profile bind, connect and app foreground, so a network blip mid-session left queued watches waiting for the next foreground. OfflineModeProvider now notifies on connectivity changes, not just offline-state or WiFi-flag changes, and main.dart flushes the queue when the network returns. The queue also counted every failure toward the five attempts that permanently drop an item, so a rate limit or a service having a bad hour could discard a pending watch - the loss the queue exists to prevent. Only an answer about the write itself now spends an attempt: 4xx counts, while rate limits, 5xx, recoverable token-refresh failures and requests that never arrived do not. A back-off answer also defers that service for the rest of the flush, so a queue holding many rows does not fire all of them at a service that just asked for quiet. --- lib/main.dart | 44 +- lib/models/trackers/anime_ids.dart | 13 + lib/models/trackers/tracker_context.dart | 29 + lib/navigation/profile_session_screen.dart | 19 +- lib/providers/catalog_sources_provider.dart | 7 +- lib/providers/offline_mode_provider.dart | 48 +- lib/providers/seerr_account_provider.dart | 4 +- lib/providers/trackers_provider.dart | 100 +- lib/providers/trakt_account_provider.dart | 192 ---- .../settings/tracker_connect_launcher.dart | 3 +- .../settings/tracker_service_info.dart | 16 +- .../settings/trakt_settings_screen.dart | 25 +- .../parts/episode_navigation.dart | 1 - .../video_player/parts/playback_prompts.dart | 1 - .../video_player/parts/playback_services.dart | 21 +- lib/screens/video_player_screen.dart | 3 - .../catalog/trakt_catalog_source.dart | 8 +- lib/services/offline_watch_sync_service.dart | 16 + .../trackers/anime_list_tracker_base.dart | 17 +- lib/services/trackers/simkl/simkl_client.dart | 6 +- .../trackers/simkl/simkl_tracker.dart | 32 +- lib/services/trackers/tracker.dart | 97 +- .../trackers/tracker_connect_runner.dart | 4 +- .../trackers/tracker_coordinator.dart | 951 +++++++++++++----- lib/services/trackers/tracker_exceptions.dart | 36 + .../tracker_page.dart} | 11 +- .../trackers/tracker_write_queue.dart | 578 +++++++++++ .../trakt/trakt_auth_service.dart | 12 +- .../{ => trackers}/trakt/trakt_client.dart | 42 +- .../{ => trackers}/trakt/trakt_constants.dart | 22 - .../trackers/trakt/trakt_tracker.dart | 284 ++++++ .../trakt/trakt_scrobble_service.dart | 372 ------- lib/services/trakt/trakt_sync_queue.dart | 155 --- lib/services/trakt/trakt_sync_service.dart | 398 -------- lib/utils/external_ids.dart | 14 + .../android/network_security_config_test.dart | 2 +- .../profile_session_screen_test.dart | 6 +- .../providers/offline_mode_provider_test.dart | 49 + test/providers/trackers_provider_test.dart | 93 +- .../trackers_provider_trakt_test.dart | 270 +++++ .../trakt_account_provider_test.dart | 163 --- .../settings/settings_screen_test.dart | 7 - .../catalog/trakt_catalog_source_test.dart | 2 +- test/services/trackers/rating_fetch_test.dart | 10 +- .../tracker_error_diagnostics_test.dart | 4 +- .../trackers/tracker_status_ladder_test.dart | 2 +- .../trackers/tracker_write_queue_test.dart | 343 +++++++ .../trackers/tracker_write_retry_test.dart | 673 +++++++++++++ .../{ => trackers}/trakt_catalog_test.dart | 4 +- .../{ => trackers}/trakt_client_test.dart | 2 +- .../trackers/trakt_scrobble_test.dart | 355 +++++++ test/services/trakt_sync_queue_test.dart | 25 - 52 files changed, 3786 insertions(+), 1805 deletions(-) delete mode 100644 lib/providers/trakt_account_provider.dart rename lib/services/{trakt/trakt_page.dart => trackers/tracker_page.dart} (55%) create mode 100644 lib/services/trackers/tracker_write_queue.dart rename lib/services/{ => trackers}/trakt/trakt_auth_service.dart (91%) rename lib/services/{ => trackers}/trakt/trakt_client.dart (91%) rename lib/services/{ => trackers}/trakt/trakt_constants.dart (71%) create mode 100644 lib/services/trackers/trakt/trakt_tracker.dart delete mode 100644 lib/services/trakt/trakt_scrobble_service.dart delete mode 100644 lib/services/trakt/trakt_sync_queue.dart delete mode 100644 lib/services/trakt/trakt_sync_service.dart create mode 100644 test/providers/trackers_provider_trakt_test.dart delete mode 100644 test/providers/trakt_account_provider_test.dart create mode 100644 test/services/trackers/tracker_write_queue_test.dart create mode 100644 test/services/trackers/tracker_write_retry_test.dart rename test/services/{ => trackers}/trakt_catalog_test.dart (98%) rename test/services/{ => trackers}/trakt_client_test.dart (99%) create mode 100644 test/services/trackers/trakt_scrobble_test.dart delete mode 100644 test/services/trakt_sync_queue_test.dart 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'); - }); -}