refactor(trackers): drive Trakt through the tracker coordinator
Trakt was the one service outside the tracker abstraction. TraktScrobbleService re-implemented the whole playback lifecycle beside TrackerCoordinator, and TraktSyncService pushed watched state from its own WatchStateNotifier subscription, so the player called two objects at every lifecycle point and one watch could be written twice. TraktTracker now implements RealtimeScrobbleTracker like Simkl; the duplicated player call sites collapse to one each, and Trakt shares the coordinator's ID resolver instead of re-fetching show ids every episode. Capabilities are split so a tracker declares what it is rather than being special-cased: ScrobblePolicy carries each service's own resend/seek rules, EpisodeHistoryTracker names the remote row a per-item history write targets, and SeriesProgressTracker covers one-counter-per-series services. Writes from all four trackers go through a shared TrackerWriteQueue, generalised from the Trakt-only queue, with the legacy Trakt payload migrated on load. Trakt becomes the fourth TrackersProvider slot and TraktAccountProvider is deleted, so one object owns the active session per profile. Two failure paths found while consolidating are fixed here too. The queue's retries only ran on profile bind, connect and app foreground, so a network blip mid-session left queued watches waiting for the next foreground. OfflineModeProvider now notifies on connectivity changes, not just offline-state or WiFi-flag changes, and main.dart flushes the queue when the network returns. The queue also counted every failure toward the five attempts that permanently drop an item, so a rate limit or a service having a bad hour could discard a pending watch - the loss the queue exists to prevent. Only an answer about the write itself now spends an attempt: 4xx counts, while rate limits, 5xx, recoverable token-refresh failures and requests that never arrived do not. A back-off answer also defers that service for the rest of the flush, so a queue holding many rows does not fire all of them at a service that just asked for quiet.
This commit is contained in:
+24
-20
@@ -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<MainApp> with WidgetsBindingObserver {
|
||||
final Set<String> _pendingSyncKeys = <String>{};
|
||||
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<MainApp> 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<MainApp> 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<MainApp> 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<MainApp> 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.
|
||||
|
||||
@@ -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<String, Object?> toJson() => {
|
||||
if (mal != null) 'mal': mal,
|
||||
if (anilist != null) 'anilist': anilist,
|
||||
if (simkl != null) 'simkl': simkl,
|
||||
};
|
||||
|
||||
factory AnimeIds.fromJson(Map<String, Object?> json) => AnimeIds(
|
||||
mal: (json['mal'] as num?)?.toInt(),
|
||||
anilist: (json['anilist'] as num?)?.toInt(),
|
||||
simkl: (json['simkl'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<String, Object?> 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<String, Object?> json) {
|
||||
final anime = json['anime'];
|
||||
return TrackerContext._(
|
||||
external: ExternalIds.fromJson((json['external'] as Map).cast<String, Object?>()),
|
||||
anime: anime == null ? null : AnimeIds.fromJson((anime as Map).cast<String, Object?>()),
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ProfileSessionScreen> {
|
||||
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<ProfileSessionScreen> {
|
||||
return provider;
|
||||
},
|
||||
),
|
||||
ChangeNotifierProxyProvider4<
|
||||
TraktAccountProvider,
|
||||
ChangeNotifierProxyProvider3<
|
||||
TrackersProvider,
|
||||
SeerrAccountProvider,
|
||||
ActiveProfileProvider,
|
||||
@@ -204,9 +191,9 @@ class _ProfileSessionScreenState extends State<ProfileSessionScreen> {
|
||||
);
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ConnectivityResult> 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<void> 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;
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,12 +14,16 @@ import '../services/trackers/oauth_proxy_client.dart';
|
||||
import '../services/trackers/simkl/simkl_auth_service.dart';
|
||||
import '../services/trackers/simkl/simkl_client.dart';
|
||||
import '../services/trackers/simkl/simkl_tracker.dart';
|
||||
import '../services/trackers/trakt/trakt_auth_service.dart';
|
||||
import '../services/trackers/trakt/trakt_client.dart';
|
||||
import '../services/trackers/trakt/trakt_tracker.dart';
|
||||
import '../services/trackers/tracker_account_store.dart';
|
||||
import '../services/trackers/tracker_connect_runner.dart';
|
||||
import '../services/trackers/tracker_constants.dart';
|
||||
import '../services/trackers/tracker_coordinator.dart';
|
||||
import '../services/trackers/tracker_session.dart';
|
||||
import '../services/trackers/tracker_username_enricher.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../mixins/disposable_change_notifier_mixin.dart';
|
||||
|
||||
typedef TrackerSessionConnectPipeline =
|
||||
@@ -31,9 +35,10 @@ typedef TrackerSessionConnectPipeline =
|
||||
required void Function(TrackerSession enriched) assign,
|
||||
});
|
||||
|
||||
/// Owns the active MAL / AniList / Simkl sessions for the currently-selected
|
||||
/// Plex profile. Single rebind seam: [onActiveProfileChanged] loads all three
|
||||
/// sessions from their stores and pushes them to their trackers.
|
||||
/// Owns the active MAL / AniList / Simkl / Trakt sessions for the
|
||||
/// currently-selected Plex profile. Single rebind seam:
|
||||
/// [onActiveProfileChanged] loads all four sessions from their stores and
|
||||
/// pushes them to their trackers.
|
||||
class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
|
||||
/// [httpClientFactory] must return a fresh client for each eager auth owner.
|
||||
/// Every returned client is closed when this provider is disposed.
|
||||
@@ -56,12 +61,14 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
_anilistAuth = httpClientFactory == null
|
||||
? AnilistAuthService()
|
||||
: AnilistAuthService(proxy: OAuthProxyClient(httpClient: httpClientFactory())),
|
||||
_simklAuth = httpClientFactory == null ? SimklAuthService() : SimklAuthService(httpClient: httpClientFactory());
|
||||
_simklAuth = httpClientFactory == null ? SimklAuthService() : SimklAuthService(httpClient: httpClientFactory()),
|
||||
_traktAuth = httpClientFactory == null ? TraktAuthService() : TraktAuthService(httpClient: httpClientFactory());
|
||||
|
||||
final TrackerSessionConnectPipeline _connectPipeline;
|
||||
final MalAuthService _malAuth;
|
||||
final AnilistAuthService _anilistAuth;
|
||||
final SimklAuthService _simklAuth;
|
||||
final TraktAuthService _traktAuth;
|
||||
|
||||
final _TrackerSlot _mal = _TrackerSlot(
|
||||
TrackerService.mal,
|
||||
@@ -78,7 +85,12 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
(session, {required onInvalidated, onUpdated}) =>
|
||||
SimklTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated),
|
||||
);
|
||||
late final List<_TrackerSlot> _slots = [_mal, _anilist, _simkl];
|
||||
final _TrackerSlot _trakt = _TrackerSlot(
|
||||
TrackerService.trakt,
|
||||
(session, {required onInvalidated, onUpdated}) =>
|
||||
TraktTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated, onSessionUpdated: onUpdated),
|
||||
);
|
||||
late final List<_TrackerSlot> _slots = [_mal, _anilist, _simkl, _trakt];
|
||||
|
||||
String _activeUserUuid = '';
|
||||
int _profileBindingGeneration = 0;
|
||||
@@ -89,10 +101,12 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
TrackerSession? get mal => _mal.session;
|
||||
TrackerSession? get anilist => _anilist.session;
|
||||
TrackerSession? get simkl => _simkl.session;
|
||||
TrackerSession? get trakt => _trakt.session;
|
||||
|
||||
bool get isMalConnected => _mal.session != null;
|
||||
bool get isAnilistConnected => _anilist.session != null;
|
||||
bool get isSimklConnected => _simkl.session != null;
|
||||
bool get isTraktConnected => _trakt.session != null;
|
||||
|
||||
/// The live MAL client for the Explore catalog, shared with the scrobble
|
||||
/// tracker so both ride one session (MAL rotates refresh tokens — a second
|
||||
@@ -102,6 +116,12 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
/// every rebind is followed by a notify, so proxy consumers track identity.
|
||||
MalClient? get malCatalogClient => _mal.session == null ? null : MalTracker.instance.client;
|
||||
|
||||
/// The live Trakt client for the Explore catalog, shared with the tracker so
|
||||
/// both ride one session (Trakt rotates refresh tokens — a second client
|
||||
/// would race refreshes and log the user out). Like [malCatalogClient], this
|
||||
/// is gated on the provider's profile-bound session.
|
||||
TraktClient? get traktCatalogClient => _trakt.session == null ? null : TraktTracker.instance.client;
|
||||
|
||||
/// Live AniList and Simkl clients for Explore. Like [malCatalogClient],
|
||||
/// these are gated on this provider's profile-bound sessions so a fresh
|
||||
/// profile subtree cannot observe clients still bound to the prior profile.
|
||||
@@ -111,6 +131,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
String? get malUsername => _mal.session?.username;
|
||||
String? get anilistUsername => _anilist.session?.username;
|
||||
String? get simklUsername => _simkl.session?.username;
|
||||
String? get traktUsername => _trakt.session?.username;
|
||||
|
||||
bool isConnecting(TrackerService service) => _connecting == service;
|
||||
|
||||
@@ -122,14 +143,26 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
|
||||
Future<void> onActiveProfileChanged(String? newUserUuid) async {
|
||||
_invalidateConnect();
|
||||
final userUuid = newUserUuid ?? '';
|
||||
// Drop any in-flight scrobble state and release the resolver (which
|
||||
// holds a PlexClient + session cache) before binding to the new profile.
|
||||
TrackerCoordinator.instance.cancelInFlight();
|
||||
TrackerCoordinator.instance.onActiveProfileChanged(userUuid);
|
||||
|
||||
final userUuid = newUserUuid ?? '';
|
||||
final generation = ++_profileBindingGeneration;
|
||||
_activeUserUuid = userUuid;
|
||||
// Snapshot each service's rebind generation before the await so a disconnect
|
||||
// Detach the previous profile's clients before loading anything. Until the new
|
||||
// sessions arrive, no tracker may hold a session: a write landing in that gap
|
||||
// would reach the account we just left while being filed under this profile's
|
||||
// retry queue.
|
||||
for (final slot in _slots) {
|
||||
slot.session = null;
|
||||
_rebind(slot);
|
||||
}
|
||||
// Publish the detach before awaiting: proxy consumers cache the client they
|
||||
// were handed, and would otherwise keep using a disposed one until hydration
|
||||
// finished.
|
||||
safeNotifyListeners();
|
||||
// Snapshot each service's rebind generation after that detach, so a disconnect
|
||||
// that races this load only suppresses its own service (whose generation
|
||||
// moves) rather than dropping the freshly-loaded sessions for the others.
|
||||
final rebinds = [for (final slot in _slots) slot.rebindGeneration];
|
||||
@@ -144,6 +177,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
// Connect/disconnect may flip `needsFribb` — drop cached resolver IDs so
|
||||
// the next lookup re-evaluates whether to consult Fribb.
|
||||
TrackerCoordinator.instance.invalidateResolverCache();
|
||||
unawaited(TrackerCoordinator.instance.flushWriteQueue());
|
||||
safeNotifyListeners();
|
||||
}
|
||||
|
||||
@@ -183,6 +217,34 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
|
||||
Future<void> disconnectSimkl() => _clearAndRebind(_simkl);
|
||||
|
||||
Future<bool> connectTrakt({required void Function(DeviceCode code) onCodeReady}) => _runConnect(
|
||||
_trakt,
|
||||
authorize: () => _traktAuth.authorize(
|
||||
onCodeReady: onCodeReady,
|
||||
shouldCancel: _isConnectCancelled,
|
||||
onCancel: _cancelCompleter!.future,
|
||||
),
|
||||
enrich: _enrichTrakt,
|
||||
);
|
||||
|
||||
/// Trakt is the one service that can revoke its token server-side. Local state
|
||||
/// is cleared first, so a failed revoke still leaves the user disconnected
|
||||
/// here — the token just stays valid on Trakt's side until it expires.
|
||||
Future<void> disconnectTrakt() async {
|
||||
final session = _trakt.session;
|
||||
await _clearAndRebind(_trakt);
|
||||
if (session == null) return;
|
||||
|
||||
final client = TraktClient(session, onSessionInvalidated: () {});
|
||||
try {
|
||||
await client.revoke();
|
||||
} catch (e) {
|
||||
appLogger.w('Trakt: token revoke failed (already disconnected locally)', error: e);
|
||||
} finally {
|
||||
client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
bool _isConnectCancelled() => _cancelCompleter?.isCompleted ?? false;
|
||||
|
||||
Future<bool> _runConnect(
|
||||
@@ -217,6 +279,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
slot.session = session;
|
||||
_rebind(slot);
|
||||
TrackerCoordinator.instance.invalidateResolverCache();
|
||||
unawaited(TrackerCoordinator.instance.flushWriteQueue());
|
||||
assigned = true;
|
||||
},
|
||||
);
|
||||
@@ -236,7 +299,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
// The rebind bumps the affected service's generation, which is what stops
|
||||
// an in-flight profile load from resurrecting the cleared session — so we
|
||||
// no longer touch the shared profile-binding generation (which would also
|
||||
// abort that load for the other two services).
|
||||
// abort that load for the other services).
|
||||
slot.session = null;
|
||||
_rebind(slot);
|
||||
safeNotifyListeners();
|
||||
@@ -282,6 +345,13 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
},
|
||||
);
|
||||
|
||||
Future<TrackerSession> _enrichTrakt(TrackerSession raw) => enrichTrackerSessionUsername(
|
||||
session: raw,
|
||||
failureMessage: 'Trakt: getUserSettings failed (non-fatal)',
|
||||
createClient: () => TraktClient(raw, onSessionInvalidated: () {}),
|
||||
fetchUsername: (client) async => (await client.getUserSettings()).username,
|
||||
);
|
||||
|
||||
/// Push a slot's session to its tracker, snapshotting the active profile and
|
||||
/// bumping the slot's rebind generation first. Bumping here is what lets a
|
||||
/// stale client callback — or a racing profile load — detect that it has been
|
||||
@@ -315,12 +385,13 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
_malAuth.dispose();
|
||||
_anilistAuth.dispose();
|
||||
_simklAuth.dispose();
|
||||
_traktAuth.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes a session to one service's tracker singleton. `onUpdated` is only
|
||||
/// wired for MAL, the one service that rotates its refresh token.
|
||||
/// Pushes a session to one service's tracker singleton. `onUpdated` is wired
|
||||
/// for MAL and Trakt, the services that rotate their refresh tokens.
|
||||
typedef _TrackerBind =
|
||||
void Function(
|
||||
TrackerSession? session, {
|
||||
@@ -339,9 +410,8 @@ class _TrackerSlot {
|
||||
TrackerSession? session;
|
||||
|
||||
/// Bumped on every rebind so a late callback from a disposed client (e.g. an
|
||||
/// in-flight MAL token refresh that resolves after a profile switch) can't
|
||||
/// persist or clear a session under the wrong profile, and so a disconnect
|
||||
/// racing an in-flight profile load only suppresses its own service. Mirrors
|
||||
/// TraktAccountProvider's binding-generation guard, but per service.
|
||||
/// in-flight refresh that resolves after a profile switch) can't persist or
|
||||
/// clear a session under the wrong profile, and so a disconnect racing an
|
||||
/// in-flight profile load only suppresses its own service.
|
||||
int rebindGeneration = 0;
|
||||
}
|
||||
|
||||
@@ -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<void>? _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<void> onActiveProfileChanged(String? newUserUuid) async {
|
||||
if (isDisposed) return;
|
||||
final userUuid = newUserUuid ?? '';
|
||||
final generation = ++_bindingGeneration;
|
||||
_activeUserUuid = userUuid;
|
||||
final loaded = await _store.load(userUuid);
|
||||
_setSessionAndRebind(userUuid, generation, loaded);
|
||||
}
|
||||
|
||||
/// Run the device-code OAuth flow.
|
||||
///
|
||||
/// [onCodeReady] is invoked once with the user code + verification URL so
|
||||
/// the UI can render the dialog.
|
||||
Future<bool> connect({required void Function(DeviceCode code) onCodeReady}) async {
|
||||
if (_isConnecting || isConnected) return false;
|
||||
_isConnecting = true;
|
||||
_cancelCompleter = Completer<void>();
|
||||
notifyListeners();
|
||||
try {
|
||||
return await runConnectPipeline<TrackerSession>(
|
||||
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<TrackerSession> _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<void> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<void> launchTrackerConnect<T>(
|
||||
BuildContext context, {
|
||||
required bool isBusyOrConnected,
|
||||
|
||||
@@ -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<TraktAccountProvider>().isConnected,
|
||||
username: (context) => context.watch<TraktAccountProvider>().username,
|
||||
ratingSource: TraktTracker.instance,
|
||||
isConnected: (context) => context.watch<TrackersProvider>().isTraktConnected,
|
||||
username: (context) => context.watch<TrackersProvider>().traktUsername,
|
||||
startConnection: startTraktConnection,
|
||||
buildSettingsScreen: () => const TraktSettingsScreen(),
|
||||
),
|
||||
|
||||
@@ -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<void> startTraktConnection(BuildContext context) {
|
||||
final account = context.read<TraktAccountProvider>();
|
||||
final account = context.read<TrackersProvider>();
|
||||
final name = t.trakt.title;
|
||||
return launchTrackerConnect<DeviceCode>(
|
||||
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<void> startTraktConnection(BuildContext context) {
|
||||
class TraktSettingsScreen extends StatelessWidget {
|
||||
const TraktSettingsScreen({super.key});
|
||||
|
||||
Future<void> _disconnect(BuildContext context, TraktAccountProvider account) async {
|
||||
Future<void> _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<TraktAccountProvider>(
|
||||
return Consumer<TrackersProvider>(
|
||||
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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -311,7 +311,6 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
try {
|
||||
await Future.wait<void>([
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin
|
||||
_mediaControlsManager?.dispose();
|
||||
|
||||
DiscordRPCService.instance.stopPlayback();
|
||||
TraktScrobbleService.instance.stopPlayback();
|
||||
TrackerCoordinator.instance.stopPlayback();
|
||||
|
||||
if (_fullscreenListenerAttached) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> _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) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'tracker.dart';
|
||||
import 'tracker_id_resolver.dart';
|
||||
|
||||
mixin AnimeListTrackerBase<TClient extends DisposableTrackerClient> on TrackerBase, ClientBackedTracker<TClient>
|
||||
implements TrackerRatingSource {
|
||||
implements TrackerRatingSource, SeriesProgressTracker {
|
||||
final KeyedFutureCache<int, int?> _episodeCountLoads = KeyedFutureCache();
|
||||
|
||||
@override
|
||||
@@ -31,12 +31,22 @@ mixin AnimeListTrackerBase<TClient extends DisposableTrackerClient> on TrackerBa
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<void> 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<TClient extends DisposableTrackerClient> on TrackerBa
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeFromList(TrackerContext ctx) async {
|
||||
final activeClient = client;
|
||||
final id = animeId(ctx.anime);
|
||||
|
||||
@@ -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<TraktPage<SimklSearchResult>> searchCatalog(
|
||||
Future<TrackerPage<SimklSearchResult>> 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<String, dynamic>) SimklSearchResult.fromJson(item),
|
||||
];
|
||||
return TraktPage.fromResponse(response, items);
|
||||
return TrackerPage.fromResponse(response, items);
|
||||
}
|
||||
|
||||
Future<List<SimklBestItem>> getBest(SimklCatalogType type, {String filter = 'watched'}) async {
|
||||
|
||||
@@ -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<SimklClient>
|
||||
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<void> markWatched(TrackerContext ctx) async {
|
||||
Future<void> 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),
|
||||
|
||||
@@ -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<void> initialize();
|
||||
@@ -31,7 +38,11 @@ abstract class Tracker {
|
||||
/// configured for this tracker.
|
||||
bool shouldScrobbleForLibrary(String? libraryGlobalKey);
|
||||
|
||||
Future<void> 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<void> markWatched(TrackerContext ctx, {DateTime? watchedAt});
|
||||
|
||||
Future<void> markUnwatched(TrackerContext ctx);
|
||||
}
|
||||
|
||||
@@ -41,12 +52,67 @@ abstract interface class TrackerRatingSource {
|
||||
Future<void> 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<void> 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<void> 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<void> initialize() async {
|
||||
|
||||
@@ -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<bool> runConnectPipeline<T>({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<T> {
|
||||
/// 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<T> {
|
||||
final List<T> 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<T> items) => TraktPage(
|
||||
factory TrackerPage.fromResponse(http.Response res, List<T> items) => TrackerPage(
|
||||
items: items,
|
||||
page: int.tryParse(res.headers['x-pagination-page'] ?? '') ?? 1,
|
||||
pageCount: int.tryParse(res.headers['x-pagination-page-count'] ?? '') ?? 1,
|
||||
@@ -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<String, dynamic> toJson() => {
|
||||
'service': service.name,
|
||||
'watched': watched,
|
||||
'ctx': ctx.toJson(),
|
||||
'coalesceKey': coalesceKey,
|
||||
if (progressClaim != null) 'progressClaim': progressClaim,
|
||||
'watchedAtIso': watchedAtIso,
|
||||
'attempts': attempts,
|
||||
};
|
||||
|
||||
factory TrackerWriteQueueItem.fromJson(Map<String, dynamic> 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<String, Object?>()),
|
||||
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<String, Queue<TrackerWriteQueueItem>> _inMemoryFallbackByUser = {};
|
||||
final Set<String> _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<String, Set<String>> _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<String, _AppliedWrite> _appliedByKey = {};
|
||||
int _appliedToken = 0;
|
||||
|
||||
Future<void> _writeLock = Future<void>.value();
|
||||
Future<void>? _flushFuture;
|
||||
String? _flushUserUuid;
|
||||
bool _flushRequested = false;
|
||||
|
||||
Future<T> _locked<T>(Future<T> Function() action) {
|
||||
final previous = _writeLock;
|
||||
final completer = Completer<void>();
|
||||
_writeLock = completer.future;
|
||||
return previous.then((_) => action()).whenComplete(completer.complete);
|
||||
}
|
||||
|
||||
Future<List<TrackerWriteQueueItem>> 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<dynamic>;
|
||||
final items = list.map((e) => TrackerWriteQueueItem.fromJson(e as Map<String, dynamic>)).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<TrackerWriteQueueItem> items) {
|
||||
_pendingKeysByUser[userUuid] = {for (final item in items) item.coalesceKey};
|
||||
}
|
||||
|
||||
Future<void> _save(String userUuid, List<TrackerWriteQueueItem> 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<void> 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<TrackerWriteQueueItem>.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<void> 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<void> flush(String userUuid, {required Future<TrackerWriteDisposition> 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<void> _runFlushLoop(
|
||||
String userUuid,
|
||||
Future<TrackerWriteDisposition> 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 = <TrackerService>{};
|
||||
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<void> _flushOnce(
|
||||
String userUuid,
|
||||
Future<TrackerWriteDisposition> Function(TrackerWriteQueueItem) send,
|
||||
Set<TrackerService> deferredServices,
|
||||
) async {
|
||||
await _recoverInMemoryFallback(userUuid);
|
||||
await _locked(() async {
|
||||
final items = await load(userUuid);
|
||||
if (items.isEmpty) return;
|
||||
final remaining = <TrackerWriteQueueItem>[];
|
||||
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<void>.delayed(_requestSpacing);
|
||||
case TrackerWriteDisposition.failed:
|
||||
remaining.add(item.incrementAttempts());
|
||||
await Future<void>.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<void> _recoverInMemoryFallback(String userUuid) async {
|
||||
final fallback = _inMemoryFallbackByUser[userUuid];
|
||||
if (fallback == null || fallback.isEmpty) return;
|
||||
final snapshot = List<TrackerWriteQueueItem>.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<void> _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<TrackerWriteQueueItem> converted;
|
||||
try {
|
||||
converted = [
|
||||
for (final entry in json.decode(raw) as List<dynamic>)
|
||||
?_legacyTraktItem((entry as Map).cast<String, dynamic>()),
|
||||
];
|
||||
} 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 <dynamic>[];
|
||||
if (existingRaw != null) {
|
||||
try {
|
||||
existing = json.decode(existingRaw) as List<dynamic>;
|
||||
} 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<String, dynamic> json) {
|
||||
final external = ExternalIds.fromJson((json['ids'] as Map).cast<String, Object?>());
|
||||
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);
|
||||
}
|
||||
+6
-6
@@ -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).
|
||||
+21
-21
@@ -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<TraktPage<TraktCatalogEntry>> getWatchlist({
|
||||
Future<TrackerPage<TraktCatalogEntry>> 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<TraktPage<TraktCatalogEntry>> getTrending(TraktCatalogType type, {int page = 1, int limit = 25}) async {
|
||||
Future<TrackerPage<TraktCatalogEntry>> 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<TraktPage<TraktCatalogMedia>> getPopular(TraktCatalogType type, {int page = 1, int limit = 25}) async {
|
||||
Future<TrackerPage<TraktCatalogMedia>> 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<TraktPage<TraktCatalogEntry>> searchCatalog(String query, {int page = 1, int limit = 25}) async {
|
||||
Future<TrackerPage<TraktCatalogEntry>> 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
|
||||
-22
@@ -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'));
|
||||
}
|
||||
@@ -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<TraktClient>
|
||||
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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<int?> 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<String, dynamic>(), localIds)) continue;
|
||||
final rating = flexibleInt(entry['rating']);
|
||||
return rating != null && rating > 0 ? rating.clamp(1, 10).toInt() : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<void> 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<String, dynamic> entry, Map<String, dynamic> 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<String, dynamic> _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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
_isInitialized = true;
|
||||
final settings = await SettingsService.getInstance();
|
||||
_isEnabled = settings.read(SettingsService.scrobblePref(TrackerService.trakt));
|
||||
}
|
||||
|
||||
Future<void> 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<int?> 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<String, dynamic>(), localIds)) continue;
|
||||
final rating = flexibleInt(entry['rating']);
|
||||
return rating != null && rating > 0 ? rating.clamp(1, 10).toInt() : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<void> 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<String, dynamic> entry, Map<String, dynamic> 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<String, dynamic> _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<void> 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<void> pausePlayback() async {
|
||||
if (_currentBody == null) return;
|
||||
await _send(TraktScrobbleState.pause, progress: _progressPercent());
|
||||
}
|
||||
|
||||
Future<void> resumePlayback() async {
|
||||
if (_currentBody == null) return;
|
||||
await _send(TraktScrobbleState.start, progress: _progressPercent());
|
||||
}
|
||||
|
||||
Future<void> stopPlayback() async {
|
||||
final revision = ++_playbackRevision;
|
||||
if (_currentBody == null) {
|
||||
_clearPlaybackState();
|
||||
return;
|
||||
}
|
||||
await _send(TraktScrobbleState.stop, progress: _progressPercent());
|
||||
if (revision == _playbackRevision) _clearPlaybackState();
|
||||
}
|
||||
|
||||
Future<TraktScrobbleRequest?> _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<void> _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<void> _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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic>),
|
||||
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<void> _writeLock = Future<void>.value();
|
||||
|
||||
Future<T> _locked<T>(Future<T> Function() action) {
|
||||
final previous = _writeLock;
|
||||
final completer = Completer<void>();
|
||||
_writeLock = completer.future;
|
||||
return previous.then((_) => action()).whenComplete(completer.complete);
|
||||
}
|
||||
|
||||
Future<List<TraktSyncQueueItem>> 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<dynamic>;
|
||||
return list.map((e) => TraktSyncQueueItem.fromJson(e as Map<String, dynamic>)).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<void> save(String userUuid, List<TraktSyncQueueItem> items) {
|
||||
return _locked(() => _saveRaw(userUuid, items));
|
||||
}
|
||||
|
||||
Future<void> _saveRaw(String userUuid, List<TraktSyncQueueItem> 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<void> 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<void> drainWith(String userUuid, Future<TraktSyncQueueItem?> Function(TraktSyncQueueItem) processor) {
|
||||
return _locked(() async {
|
||||
final items = await load(userUuid);
|
||||
if (items.isEmpty) return;
|
||||
final remaining = <TraktSyncQueueItem>[];
|
||||
for (final item in items) {
|
||||
final keep = await processor(item);
|
||||
if (keep != null) remaining.add(keep);
|
||||
}
|
||||
await _saveRaw(userUuid, remaining);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<WatchStateEvent>? _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<String, TrackerIdResolver> _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<String, Queue<TraktSyncQueueItem>> _inMemoryFallbackByUser = {};
|
||||
|
||||
Future<void>? _flushFuture;
|
||||
bool _flushRequested = false;
|
||||
|
||||
Future<void> 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<void> 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<void> 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<void> _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<void> _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 = <MediaItem>[];
|
||||
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<void> _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<void> _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<void> _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<TraktSyncQueueItem>.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<void> _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<void> flushQueue() {
|
||||
final active = _flushFuture;
|
||||
if (active != null) {
|
||||
_flushRequested = true;
|
||||
return active;
|
||||
}
|
||||
if (_client == null) return Future<void>.value();
|
||||
|
||||
final future = _runFlushLoop();
|
||||
_flushFuture = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void>.delayed(_queueRequestSpacing);
|
||||
return null;
|
||||
} catch (e) {
|
||||
appLogger.d('Trakt sync: drain failed for ${item.ratingKey}, will retry', error: e);
|
||||
await Future<void>.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<void> _recoverInMemoryFallback(String userUuid) async {
|
||||
final fallback = _inMemoryFallbackByUser[userUuid];
|
||||
if (fallback == null || fallback.isEmpty) return;
|
||||
final snapshot = List<TraktSyncQueueItem>.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!,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object?> toJson() => {
|
||||
if (imdb != null) 'imdb': imdb,
|
||||
if (tmdb != null) 'tmdb': tmdb,
|
||||
if (tvdb != null) 'tvdb': tvdb,
|
||||
};
|
||||
|
||||
factory ExternalIds.fromJson(Map<String, Object?> json) => ExternalIds(
|
||||
imdb: json['imdb'] as String?,
|
||||
tmdb: (json['tmdb'] as num?)?.toInt(),
|
||||
tvdb: (json['tvdb'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
factory ExternalIds.fromGuids(List<dynamic> guids) {
|
||||
String? imdb;
|
||||
int? tmdb;
|
||||
|
||||
@@ -20,7 +20,7 @@ const _fixedEndpointSourcePaths = <String>[
|
||||
'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',
|
||||
|
||||
@@ -62,9 +62,9 @@ void main() {
|
||||
final companionProviders = <CompanionRemoteProvider>[];
|
||||
final disposedActiveIds = <String>[];
|
||||
final trackerHttpClients = <FakeHttpClient>[];
|
||||
// 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 <int>[]);
|
||||
trackerHttpClients.add(client);
|
||||
|
||||
@@ -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<void>.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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> _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<bool> _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 {
|
||||
|
||||
@@ -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<void> _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 = <FakeHttpClient>[];
|
||||
final p = TrackersProvider(
|
||||
httpClientFactory: () {
|
||||
final client = FakeHttpClient(200, const <int>[]);
|
||||
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<void>.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<void>.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<void>.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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 = <FakeHttpClient>[];
|
||||
final p = TraktAccountProvider(
|
||||
httpClientFactory: () {
|
||||
final client = FakeHttpClient(200, const <int>[]);
|
||||
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<void>.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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<FakeHttpClient> 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<LibrariesProvider>.value(value: libraries),
|
||||
ChangeNotifierProvider<HiddenLibrariesProvider>.value(value: hiddenLibraries),
|
||||
ChangeNotifierProvider<ThemeProvider>.value(value: theme),
|
||||
ChangeNotifierProvider<TraktAccountProvider>.value(value: trakt),
|
||||
ChangeNotifierProvider<TrackersProvider>.value(value: trackers),
|
||||
ChangeNotifierProvider<SeerrAccountProvider>.value(value: seerr),
|
||||
ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = <TrackerWriteQueueItem>[];
|
||||
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 = <String>[];
|
||||
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 = <TrackerWriteQueueItem>[];
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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<String, ExternalIds> 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<ExternalIds> fetchExternalIds(String itemId) async => externalIdsByItem[itemId] ?? const ExternalIds();
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchChildren(String parentId) async => const [];
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async => const [];
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeFribbLookup implements FribbMappingLookup {
|
||||
const _FakeFribbLookup(this.rows);
|
||||
|
||||
final List<FribbMappingRow> rows;
|
||||
|
||||
/// Filters by tvdb id so distinct shows map to distinct anime entries, which is
|
||||
/// what makes their queued rows distinct.
|
||||
@override
|
||||
Future<List<FribbMappingRow>> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async =>
|
||||
rows.where((row) => tvdbId == null || row.tvdbId == tvdbId).toList();
|
||||
|
||||
@override
|
||||
Future<FribbMappingRow?> 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<ResolvedAnimeProgress?> resolve(
|
||||
MediaItem episode, {
|
||||
required AnimeProgressScope scope,
|
||||
AnimeEpisodeMatch? animeMatch,
|
||||
Future<AnimeEpisodeMatch?> Function(MediaItem episode)? episodeMatcher,
|
||||
bool includeCurrentEpisode = true,
|
||||
}) async => null;
|
||||
|
||||
@override
|
||||
void clearCache() {}
|
||||
}
|
||||
|
||||
class _FakeAnimeListsLookup implements AnimeListsMappingLookup {
|
||||
const _FakeAnimeListsLookup();
|
||||
|
||||
@override
|
||||
Future<AnimeEpisodeMatch?> lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber}) async => null;
|
||||
|
||||
@override
|
||||
Future<Set<int>> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season}) async => const <int>{};
|
||||
|
||||
@override
|
||||
Future<Set<int>> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async => const <int>{};
|
||||
}
|
||||
|
||||
/// MAL posts form-encoded list updates; every other service posts JSON.
|
||||
Map<String, dynamic> _decodeBody(String body) {
|
||||
if (body.isEmpty) return <String, dynamic>{};
|
||||
if (body.startsWith('{') || body.startsWith('[')) {
|
||||
final decoded = json.decode(body);
|
||||
return decoded is Map ? decoded.cast<String, dynamic>() : <String, dynamic>{'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<String> paths = [];
|
||||
final List<Map<String, dynamic>> bodies = [];
|
||||
Completer<void>? 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<int> _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<void>();
|
||||
// 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<void>();
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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<String, ExternalIds> 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<ExternalIds> fetchExternalIds(String itemId) async => externalIdsByItem[itemId] ?? const ExternalIds();
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async => const [];
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Call {
|
||||
final String path;
|
||||
final Map<String, dynamic> body;
|
||||
|
||||
_Call(this.path, this.body);
|
||||
|
||||
@override
|
||||
String toString() => '$path ${json.encode(body)}';
|
||||
}
|
||||
|
||||
class _TraktRecorder {
|
||||
final List<_Call> calls = [];
|
||||
final Map<String, int> statuses = {};
|
||||
Completer<void>? gate;
|
||||
|
||||
http.Client get client => MockClient((request) async {
|
||||
final body = request.body.isEmpty
|
||||
? <String, dynamic>{}
|
||||
: (json.decode(request.body) as Map).cast<String, dynamic>();
|
||||
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<String> 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<void> 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<void> 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user