Files
plezy/lib/services/trackers/tracker_coordinator.dart
T
edde746 5c08862194 fix(trackers): purge queued writes on disconnect, evict stale anime snapshots, and cancel dismissed auth dialogs
Queued tracker writes carried no account identity, so rows created under account A replayed against account B after a reconnect; explicit disconnect now purges that service's queue. An in-flight markWatched/reconcile that failed after the purge was re-enqueued anyway (the write scope only tracked profile generation) and replayed into whichever account connected next: Tracker.accountBinding (client identity, rebound synchronously on every disconnect) is captured before each write and re-checked before queueing a retry, and because the check sits in the same synchronous segment as the enqueue, a row that passes it is claimed ahead of the purge in the queue mutex and is still removed by it.

The memoized anime-list snapshot survived writes, so sequential rewatch updates computed from pre-write state; successful writes now evict the entry.

A device-code dialog dismissed by system back never cancelled the poll (blocking new attempts until the deadline), and a user-cancelled sign-in showed the connection-failed snackbar; both now route through one cancel path.
2026-08-21 19:23:41 +02:00

1144 lines
47 KiB
Dart

import 'dart:async';
import '../../media/media_item.dart';
import '../../media/media_kind.dart';
import '../../media/media_server_client.dart';
import '../../media/playback_timeline.dart';
import '../../models/trackers/tracker_context.dart';
import '../../utils/app_logger.dart';
import '../../media/episode_collection.dart';
import 'anime_episode_progress_resolver.dart';
import 'anime_lists_mapping_store.dart';
import 'anilist/anilist_tracker.dart';
import 'fribb_mapping_store.dart';
import 'mal/mal_tracker.dart';
import 'mdblist/mdblist_tracker.dart';
import 'simkl/simkl_tracker.dart';
import 'tracker.dart';
import 'tracker_constants.dart';
import 'tracker_exceptions.dart';
import 'tracker_id_resolver.dart';
import 'tracker_write_queue.dart';
import 'trakt/trakt_tracker.dart';
/// The single seam through which every tracker write leaves the app.
///
/// Three mechanisms, chosen per tracker kind:
///
/// * [RealtimeScrobbleTracker]s (Simkl, Trakt, MDBList) receive the playback
/// lifecycle — start/resume, pause, seek, stop — with the current progress,
/// and decide watched state themselves. They are excluded from the threshold
/// fan-out so a single watch never produces two writes.
/// * Threshold trackers (MAL, AniList) are notified exactly once when progress
/// crosses the watched threshold, with a safety-net fire on stop if the
/// crossing was missed (e.g. the user stopped between ticks).
/// * Manual, container, offline-replay, external-player and server-observed
/// marks bypass playback entirely and go straight to [Tracker.markWatched] on
/// every tracker that can write.
///
/// A failed per-item history write is persisted in [TrackerWriteQueue] and
/// replayed by [flushWriteQueue], so a transient error does not silently drop a
/// watch.
class TrackerCoordinator {
static TrackerCoordinator? _instance;
static TrackerCoordinator get instance => _instance ??= TrackerCoordinator._();
TrackerCoordinator._();
/// The registry. Everything below partitions this list by capability rather
/// than naming services, so adding one means adding it here and nowhere else.
late final List<Tracker> _trackers = [
MalTracker.instance,
AnilistTracker.instance,
SimklTracker.instance,
TraktTracker.instance,
MdblistTracker.instance,
];
/// One transport per real-time tracker, created once and outliving individual
/// playbacks: an episode swap stops the old item and starts the new one back
/// to back, and both belong in the same per-service order.
late final List<_ScrobbleChannel> _channels = [
for (final tracker in _trackers.whereType<RealtimeScrobbleTracker>()) _ScrobbleChannel(tracker),
];
final TrackerWriteQueue _writeQueue = TrackerWriteQueue();
String _activeUserUuid = '';
int _profileGeneration = 0;
/// In-flight write chains, one per remote row per profile (see
/// [_sequencedByKey]). Entries are removed as soon as nothing is queued behind
/// them, so this never grows past the writes currently in flight.
final Map<String, Completer<void>> _writeChains = {};
/// Intent numbering for the same rows, so a failing write can tell whether a
/// newer one has already succeeded. Same lifetime as [_writeChains].
final Map<String, _RowIntents> _rowIntents = {};
/// Resolver persists across episode swaps so back-to-back episodes of the
/// same show reuse the cached IDs. Cleared only on profile switch.
TrackerIdResolver? _resolver;
String? _resolverClientKey;
String? _activeLibraryGlobalKey;
FribbMappingLookup? _debugFribbStore;
AnimeListsMappingLookup? _debugAnimeListsStore;
AnimeEpisodeProgressLookup? _debugAnimeProgress;
TrackerContext? _ctx;
/// Seed used before [startPlayback] captures the server's threshold; never
/// actually consulted (a crossing is only evaluated once `_ctx` is set,
/// after the client value is assigned).
static const double _fallbackWatchedThreshold = TrackerConstants.watchedThresholdPercent / 100.0;
/// Captures position, duration, and the active client's watched threshold so
/// tracker crossing semantics stay aligned with playback progress reporting.
final PlaybackTimeline _timeline = PlaybackTimeline(watchedThreshold: _fallbackWatchedThreshold);
bool _thresholdCrossed = false;
int _playbackRevision = 0;
/// Drop a duplicate state transition within this window — the player emits
/// several playing-state events per seek. This one is a player-side artifact,
/// so unlike [ScrobblePolicy] it is the same for every service.
static const Duration _duplicateStateDebounce = Duration(seconds: 1);
/// Real-time targets pinned when the current playback began. Every report for
/// this playback goes to these and no others.
List<_PlaybackTarget> _playbackTargets = const [];
DateTime Function() _clock = DateTime.now;
/// Test seam: drive the debounce/throttle windows from a fake clock. Passing
/// null restores the wall clock.
void debugUseScrobbleClock(DateTime Function()? clock) => _clock = clock ?? DateTime.now;
Future<void> initialize() async {
await Future.wait(_trackers.map((t) => t.initialize()));
}
/// Rebind to a profile: drops in-flight playback state and the resolver (which
/// holds a media client), and points the retry queue at the new profile's
/// items. Called before the per-service sessions are pushed to the trackers.
void onActiveProfileChanged(String userUuid) {
_activeUserUuid = userUuid;
++_profileGeneration;
cancelInFlight();
}
/// The profile a write belongs to, captured before its first await. Every
/// deferred step re-checks it, so a switch mid-write can neither file one
/// profile's retry under another nor replay a leftover row through the account
/// that replaced it.
_WriteScope get _currentScope => _WriteScope(_activeUserUuid, _profileGeneration);
bool _isCurrent(_WriteScope scope) => scope.generation == _profileGeneration;
/// Replay watched writes that failed earlier. Safe to call repeatedly —
/// concurrent calls coalesce, and an item whose service has no session, or
/// whose write the service will not take right now, is left untouched rather
/// than counted as a failed attempt.
///
/// Driven from profile bind, a successful connect, app foreground, and network
/// restore. Never throws: a retry pass is best-effort, its callers are
/// fire-and-forget, and a queue that cannot be read or written this time simply
/// waits for the next trigger.
Future<void> flushWriteQueue() async {
final scope = _currentScope;
try {
await _writeQueue.flush(scope.userUuid, send: (item) => _replayQueuedWrite(item, scope));
} catch (e, st) {
appLogger.w('Trackers: write queue flush failed', error: e, stackTrace: st);
}
}
/// Drop [service]'s queued writes for the active profile.
///
/// Called on explicit disconnect, before another account can bind: a queued
/// row created under the departing account would otherwise be replayed
/// through its successor, silently editing the wrong account's history.
/// Best-effort like [flushWriteQueue]: a purge that cannot be persisted is
/// logged and the rows simply keep waiting.
Future<void> purgeWriteQueueForService(TrackerService service) async {
try {
await _writeQueue.removeService(_activeUserUuid, service);
} catch (e, st) {
appLogger.w('Trackers: write queue purge failed for ${service.name}', error: e, stackTrace: st);
}
}
Future<void> startPlayback(MediaItem metadata, MediaServerClient client, {bool isLive = false}) async {
final revision = ++_playbackRevision;
if (isLive) {
_reset();
return;
}
final mediaType = metadata.kind;
if (mediaType != MediaKind.movie && mediaType != MediaKind.episode) {
_reset();
return;
}
final libraryGlobalKey = metadata.libraryGlobalKey;
if (!_hasPlaybackInterest(libraryGlobalKey)) {
_reset();
return;
}
_activeLibraryGlobalKey = libraryGlobalKey;
final clientKey = client.cacheServerId;
if (_resolver == null || _resolverClientKey != clientKey) {
_resolver?.clearCache();
_resolver = _newResolver(client, needsFribb: _anyTrackerNeedsFribb);
_resolverClientKey = clientKey;
}
final ctx = await _buildContext(metadata, _resolver!);
if (revision != _playbackRevision) return;
if (ctx == null) {
appLogger.d('Trackers: no external IDs for ${metadata.id}');
_reset();
return;
}
_reset();
_ctx = ctx;
// Seed from the server's resume offset so the first real-time report
// carries the true position instead of 0%.
_timeline.reset(
position: Duration(milliseconds: metadata.viewOffsetMs ?? 0),
duration: metadata.durationMs != null ? Duration(milliseconds: metadata.durationMs!) : null,
watchedThreshold: client.watchedThreshold,
);
// A playback session belongs to the account bound when it began. Pinning the
// targets here — rather than resolving them per report — keeps every later
// report on that account even if the user rebinds mid-playback.
_playbackTargets = [
for (final channel in _channels)
if (_canReport(channel.tracker, ctx.libraryGlobalKey)) _PlaybackTarget(channel, channel.tracker.accountBinding),
];
unawaited(_scrobble(TrackerScrobbleState.start));
}
bool _anyTrackerNeedsFribb() => _anyTrackerNeedsFribbForLibrary(_activeLibraryGlobalKey);
/// True when at least one tracker would act on this playback — either by
/// receiving reports or by recording the watch when progress crosses over.
/// Trakt can be in the second group without being in the first: its scrobble
/// and watched-sync settings are independent.
bool _hasPlaybackInterest(String? libraryGlobalKey) => _trackers.any(
(t) => _canWrite(t, libraryGlobalKey) || (t is RealtimeScrobbleTracker && _canReport(t, libraryGlobalKey)),
);
bool _hasWatchedInterest(String? libraryGlobalKey) => _trackers.any((t) => _canWrite(t, libraryGlobalKey));
bool _anyTrackerNeedsFribbForLibrary(String? libraryGlobalKey) =>
_trackers.any((t) => t.needsFribb && _canWrite(t, libraryGlobalKey));
void debugUseResolverDependencies({
FribbMappingLookup? store,
AnimeListsMappingLookup? animeLists,
AnimeEpisodeProgressLookup? animeProgress,
}) {
_debugFribbStore = store;
_debugAnimeListsStore = animeLists;
_debugAnimeProgress = animeProgress;
invalidateResolverCache();
}
TrackerIdResolver _newResolver(MediaServerClient client, {required bool Function() needsFribb}) => TrackerIdResolver(
client,
needsFribb: needsFribb,
store: _debugFribbStore,
animeLists: _debugAnimeListsStore,
animeProgress: _debugAnimeProgress,
);
Future<void> markWatched(MediaItem item, MediaServerClient client) => _markManual(item, client, watched: true);
Future<void> markUnwatched(MediaItem item, MediaServerClient client) => _markManual(item, client, watched: false);
Future<void> _markManual(MediaItem item, MediaServerClient client, {required bool watched}) async {
try {
await _applyManualMark(item, client, watched: watched);
} catch (e) {
appLogger.d('Trackers: manual ${watched ? 'markWatched' : 'markUnwatched'} failed for ${item.id}', error: e);
}
}
Future<void> _applyManualMark(MediaItem item, MediaServerClient client, {required bool watched}) async {
final kind = item.kind;
if (kind != MediaKind.movie && kind != MediaKind.episode && kind != MediaKind.season && kind != MediaKind.show) {
return;
}
final libraryGlobalKey = item.libraryGlobalKey;
if (!_hasWatchedInterest(libraryGlobalKey)) return;
final resolver = _newResolver(client, needsFribb: () => _anyTrackerNeedsFribbForLibrary(libraryGlobalKey));
final scope = _currentScope;
if (kind == MediaKind.movie || kind == MediaKind.episode) {
await (watched ? _markSingleWatched(item, resolver, scope) : _markSingleUnwatched(item, resolver, scope));
return;
}
final episodes = <MediaItem>[];
await collectEpisodes(client, item.id, unwatchedOnly: false, out: episodes, fallback: item);
if (!_isCurrent(scope)) return;
final expansion = watched ? 'expanded' : 'unwatched expanded';
appLogger.d('Trackers: manual ${kind.name} ${item.id} $expansion to ${episodes.length} episodes');
await (watched
? _markContainerEpisodesWatched(episodes, resolver, scope)
: _markContainerEpisodesUnwatched(episodes, resolver, scope));
}
Future<void> _markContainerEpisodesWatched(
List<MediaItem> episodes,
TrackerIdResolver resolver,
_WriteScope scope,
) async {
final seriesGroups = <String, _ManualSeriesProgress>{};
var resolved = 0;
for (final episode in episodes) {
final ctx = await _buildContext(episode, resolver, includeAnimeProgress: false);
if (!_isCurrent(scope)) return;
if (ctx == null) continue;
resolved++;
await _dispatch(_episodeHistoryTrackers, ctx, scope, watched: true);
final key = _seriesGroupKey(ctx);
if (key == null) continue;
(seriesGroups[key] ??= _ManualSeriesProgress(ctx, fallbackToCount: true)).add(ctx);
}
appLogger.d('Trackers: manual container resolved $resolved/${episodes.length} episodes');
for (final group in seriesGroups.values) {
final ctx = group.context;
if (ctx != null) await _dispatch(_seriesProgressTrackers, ctx, scope, watched: true);
}
appLogger.d('Trackers: manual container resolved ${seriesGroups.length} series entries');
}
Future<void> _markContainerEpisodesUnwatched(
List<MediaItem> episodes,
TrackerIdResolver resolver,
_WriteScope scope,
) async {
// One context per series entry per tracker: a show maps to a single list
// entry, so all of its episodes collapse into one removal.
final entriesByTracker = <SeriesProgressTracker, Map<Object, TrackerContext>>{};
var resolved = 0;
for (final episode in episodes) {
final ctx = await _buildContext(
episode,
resolver,
includeAnimeProgress: false,
fallbackToAnimeEpisodeNumber: false,
);
if (!_isCurrent(scope)) return;
if (ctx == null) continue;
resolved++;
await _dispatch(_episodeHistoryTrackers, ctx, scope, watched: false);
for (final tracker in _seriesProgressTrackers) {
if (!_canWrite(tracker, ctx.libraryGlobalKey)) continue;
final entryId = tracker.seriesEntryId(ctx);
if (entryId == null) continue;
(entriesByTracker[tracker] ??= {})[entryId] = ctx;
}
}
appLogger.d('Trackers: manual container unwatched resolved $resolved/${episodes.length} episodes');
await _removeSeriesEntries(entriesByTracker, scope);
}
/// Dropping a whole series entry has no queued-retry form — the queue replays
/// [Tracker] writes, and this is not one — so a failure here is logged and
/// dropped. A success invalidates any queued progress claim for that entry,
/// which would otherwise replay and resurrect the list entry the user just
/// cleared.
Future<void> _removeSeriesEntries(
Map<SeriesProgressTracker, Map<Object, TrackerContext>> entriesByTracker,
_WriteScope scope,
) async {
await Future.wait([
for (final entry in entriesByTracker.entries)
...entry.value.values.map((ctx) => _removeSeriesEntry(entry.key, ctx, scope)),
]);
for (final entry in entriesByTracker.entries) {
appLogger.d('Trackers: manual container unwatched removed ${entry.value.length} ${entry.key.name} entries');
}
}
Future<void> _removeSeriesEntry(SeriesProgressTracker tracker, TrackerContext ctx, _WriteScope scope) async {
final key = _coalesceKeyFor(tracker, ctx);
int? marker;
try {
await _sequencedByKey(scope, key, () async {
await tracker.removeFromList(ctx);
if (key != null) marker = _writeQueue.noteDirectWrite(scope.userUuid, key);
});
} catch (e) {
appLogger.d('${tracker.name}: removeFromList failed', error: e);
return;
}
// The entry is gone; a queued progress claim for it would resurrect it.
await _settleQueueAfterWrite(key, marker, scope);
}
/// Group key over the series entries a container's episodes would touch, so
/// one entry receives one write however many episodes map to it. Null when no
/// active series tracker can map the item.
String? _seriesGroupKey(TrackerContext ctx) {
final parts = <String>[];
for (final tracker in _seriesProgressTrackers) {
if (!_canWrite(tracker, ctx.libraryGlobalKey)) continue;
final entryId = tracker.seriesEntryId(ctx);
if (entryId == null) continue;
parts.add('${tracker.service.name}=$entryId');
}
return parts.isEmpty ? null : parts.join('|');
}
Future<void> _markSingleWatched(MediaItem item, TrackerIdResolver resolver, _WriteScope scope) async {
final ctx = await _buildContext(item, resolver);
if (ctx == null) {
appLogger.d('Trackers: no external IDs for manually watched ${item.id}');
return;
}
await _dispatch(_trackers, ctx, scope, watched: true);
}
Future<void> _markSingleUnwatched(MediaItem item, TrackerIdResolver resolver, _WriteScope scope) async {
final ctx = await _buildContext(item, resolver, includeAnimeProgress: false, fallbackToAnimeEpisodeNumber: false);
if (ctx == null) {
appLogger.d('Trackers: no external IDs for manually unwatched ${item.id}');
return;
}
// A single episode cannot be unwatched on a series-progress tracker: its
// entry counts episodes, so only whole-entry removal (the container path)
// means anything there.
await _dispatch(ctx.isMovie ? _trackers : _episodeHistoryTrackers, ctx, scope, watched: false);
}
/// Terminal report for the current playback.
///
/// Threshold trackers get the safety-net watched mark. Real-time trackers get
/// a `stop` carrying the true progress — which is also the user's resume
/// position, so it is never inflated to force a watched state — followed by
/// [RealtimeScrobbleTracker.reconcileWatchedAfterStop] when Plezy counts the
/// playback as watched, because only the tracker knows whether its own stop
/// already recorded that.
Future<void> stopPlayback() async {
++_playbackRevision;
final ctx = _ctx;
final watched = _thresholdCrossed || _timeline.watchedThresholdReached;
final missedThresholdMark = !_thresholdCrossed && _timeline.watchedThresholdReached;
final progress = _timeline.progressPercent;
// Snapshotted before [_reset] clears the field: the terminal report and the
// reconciliation that follows belong to the account this playback began on.
final targets = _playbackTargets;
_reset();
if (ctx == null) return;
final scope = _currentScope;
// Ownership is decided here, once, so the two ways of recording a watch stay
// mutually exclusive: whoever owns its own stop is not told about the
// crossing, and whoever was told is not reconciled.
final owners = _watchOwners(targets);
await Future.wait([
if (missedThresholdMark) _dispatch(_thresholdTrackers(owners), ctx, scope, watched: true),
_sendScrobble(ctx, TrackerScrobbleState.stop, progress, targets),
]);
await _settleScrobbles();
if (!watched) return;
// An owner only owns the watch if its terminal report actually landed. When
// the stop failed, nothing on that service recorded anything — its own
// completion rule never ran — so the watch falls back to a history write,
// queued for retry if that fails too.
final reconcilable = <_PlaybackTarget>[];
final unreported = <Tracker>[];
for (final owner in owners) {
if (!_bindingIntact(owner, 'watched reconciliation')) continue;
if (owner.stopConfirmed) {
reconcilable.add(owner);
} else {
unreported.add(owner.tracker);
}
}
await Future.wait([
_reconcileWatchedAfterStop(reconcilable, ctx, progress, scope),
if (unreported.isNotEmpty) _dispatch(unreported, ctx, scope, watched: true),
]);
}
/// The player paused, or the app was backgrounded. Saves resumable progress
/// on real-time trackers; threshold trackers are unaffected.
Future<void> pausePlayback() => _scrobble(TrackerScrobbleState.pause);
Future<void> resumePlayback() => _scrobble(TrackerScrobbleState.start);
void updatePosition(Duration position) {
final isSeek = _timeline.updatePosition(position);
final ctx = _ctx;
if (ctx == null) return;
// A seek is a checkpoint, not a transition: services that expose no seek
// event opt out through their [ScrobblePolicy] and never see one.
if (isSeek) unawaited(_scrobble(TrackerScrobbleState.seek));
if (_thresholdCrossed) return;
if (!_timeline.watchedThresholdReached) return;
_thresholdCrossed = true;
unawaited(_dispatch(_thresholdTrackers(_watchOwners(_playbackTargets)), ctx, _currentScope, watched: true));
}
void updateDuration(Duration duration) {
_timeline.updateDuration(duration);
}
/// Called on profile switch — drops in-flight state across all trackers and
/// invalidates the resolver so a fresh media client is used.
void cancelInFlight() {
++_playbackRevision;
// Queued reports belong to the profile being left; the client backing them
// is about to be disposed.
for (final channel in _channels) {
channel.clear();
}
_reset();
_resolver?.clearCache();
_resolver = null;
_resolverClientKey = null;
}
/// Drop the resolver's ID cache without touching in-flight playback state.
/// Called after a tracker is connected/disconnected so cached lookups
/// re-evaluate the `needsFribb` predicate.
void invalidateResolverCache() => _resolver?.clearCache();
void _reset() {
_ctx = null;
_activeLibraryGlobalKey = null;
_timeline.reset(watchedThreshold: _fallbackWatchedThreshold);
_thresholdCrossed = false;
// Per-playback report bookkeeping lives on the targets, so dropping them
// clears the debounce/throttle state with it.
_playbackTargets = const [];
}
/// The real-time targets that own the watched state of this playback: their own
/// terminal stop — plus [RealtimeScrobbleTracker.reconcileWatchedAfterStop] —
/// records the watch, so the threshold crossing must leave them alone.
///
/// Ownership follows the session, not the current settings. A target that never
/// got a session open is not an owner: no stop will be sent for it, so the
/// crossing is the only thing that would record its watch. One whose scrobbling
/// the user turned off mid-playback stays an owner — its stop simply never goes
/// out, and the unconfirmed-stop path then falls back to a history write. Were
/// ownership re-evaluated at stop time instead, a toggle flipped after the
/// crossing would leave the watch recorded by neither route.
List<_PlaybackTarget> _watchOwners(List<_PlaybackTarget> targets) => [
for (final target in targets)
if (target.sessionStarted) target,
];
/// Trackers that must be told about the threshold crossing: everyone that does
/// not own this playback's watched state. One watch, one write.
Iterable<Tracker> _thresholdTrackers(List<_PlaybackTarget> owners) =>
_trackers.where((tracker) => !owners.any((owner) => identical(owner.tracker, tracker)));
Iterable<EpisodeHistoryTracker> get _episodeHistoryTrackers => _trackers.whereType<EpisodeHistoryTracker>();
Iterable<SeriesProgressTracker> get _seriesProgressTrackers => _trackers.whereType<SeriesProgressTracker>();
bool _canWrite(Tracker tracker, String? libraryGlobalKey) =>
tracker.canWriteWatched && tracker.shouldScrobbleForLibrary(libraryGlobalKey);
bool _canReport(RealtimeScrobbleTracker tracker, String? libraryGlobalKey) =>
tracker.canReportPlayback && tracker.shouldScrobbleForLibrary(libraryGlobalKey);
Tracker? _trackerFor(TrackerService service) {
for (final tracker in _trackers) {
if (tracker.service == service) return tracker;
}
return null;
}
Future<void> _dispatch(
Iterable<Tracker> trackers,
TrackerContext ctx,
_WriteScope scope, {
required bool watched,
}) async {
if (!_isCurrent(scope)) return;
final active = [
for (final tracker in trackers)
if (_canWrite(tracker, ctx.libraryGlobalKey)) tracker,
];
if (active.isEmpty) return;
await Future.wait(active.map((tracker) => _applyWrite(tracker, ctx, scope, watched: watched)));
}
/// One watched/unwatched write plus the bookkeeping that keeps it consistent
/// with everything else targeting the same remote row.
///
/// The write is serialised against other writes for that row, and carries an
/// intent number claimed inside the row's channel — so writes are numbered in
/// the order they actually go out. A failure only becomes a queued retry if no
/// later intent for the row has succeeded meanwhile: without that check, a slow
/// failure could persist stale state moments after a newer write cleaned the
/// queue and moved the service on.
Future<void> _applyWrite(Tracker tracker, TrackerContext ctx, _WriteScope scope, {required bool watched}) async {
final key = _coalesceKeyFor(tracker, ctx);
final appliedProgress = watched ? _progressClaim(tracker, ctx) : null;
// Captured before the first await: a failure that completes after a rebind
// must not requeue this row under whichever account replaced the binding.
final binding = tracker.accountBinding;
int? marker;
var intent = 0;
try {
await _sequencedByKey(scope, key, () async {
if (key != null) intent = _beginIntent(scope, key);
await (watched ? tracker.markWatched(ctx) : tracker.markUnwatched(ctx));
if (key != null) {
_intentSucceeded(scope, key, intent, appliedProgress: appliedProgress);
// Marked inside the row's channel, so a replay waiting behind this
// write sees it before deciding whether it is still needed.
marker = _writeQueue.noteDirectWrite(scope.userUuid, key, appliedProgress: appliedProgress);
}
});
await _settleQueueAfterWrite(key, marker, scope, appliedProgress: appliedProgress);
} catch (e) {
final operation = watched ? 'markWatched' : 'markUnwatched';
if (key != null && _shouldDropFailedWrite(scope, key, intent, progressClaim: appliedProgress)) {
appLogger.d('${tracker.name}: $operation failed, superseded by a newer write', error: e);
return;
}
if (!identical(tracker.accountBinding, binding)) {
// The account this write belonged to was disconnected (or the profile
// rebound): its queue purge has already run or is ordered behind the
// queue mutex, so re-queueing would replay the row through the next
// account. Invariant: TrackerWriteQueue's _locked claims its slot
// synchronously and this check sits in the same synchronous segment as
// the enqueue call below, so a row that passes the check is always
// queued ahead of the disconnect purge's removeService and is
// therefore still removed by it.
appLogger.d('${tracker.name}: $operation failed, dropped — account rebound mid-write', error: e);
return;
}
appLogger.d('${tracker.name}: $operation failed, queued for retry', error: e);
await _enqueueWrite(tracker, ctx, scope, watched: watched);
} finally {
if (key != null && intent != 0) _endIntent(scope, key);
}
}
String _rowKey(_WriteScope scope, String key) => '${scope.userUuid}|$key';
/// Claim the next intent number for a row. Called inside the row's channel, so
/// intents are numbered in the order the writes reach the service.
int _beginIntent(_WriteScope scope, String key) {
final state = _rowIntents.putIfAbsent(_rowKey(scope, key), _RowIntents.new);
state.pending++;
return ++state.lastIntent;
}
void _intentSucceeded(_WriteScope scope, String key, int intent, {int? appliedProgress}) {
final state = _rowIntents[_rowKey(scope, key)];
if (state == null || intent < state.lastAppliedIntent) return;
state.lastAppliedIntent = intent;
state.lastAppliedProgress = appliedProgress;
state.hasApplied = true;
}
/// Whether a failed write should be dropped rather than queued for retry.
///
/// A history write states the current truth about an item, so any newer intent
/// for that row — however it ends — owns it and this one is stale. A series
/// claim is monotonic instead: only a completed write that already covers the
/// claim makes it pointless, and two queued claims coalesce to the higher, so
/// the outcome does not depend on which failure is persisted first.
bool _shouldDropFailedWrite(_WriteScope scope, String key, int intent, {required int? progressClaim}) {
final state = _rowIntents[_rowKey(scope, key)];
if (state == null) return false;
if (progressClaim == null) return state.lastIntent > intent;
if (!state.hasApplied) return false;
return TrackerWriteQueue.coversClaim(appliedProgress: state.lastAppliedProgress, claim: progressClaim);
}
/// Release an intent. The row's bookkeeping is dropped once nothing holds it,
/// so this only ever tracks writes in flight.
void _endIntent(_WriteScope scope, String key) {
final stateKey = _rowKey(scope, key);
final state = _rowIntents[stateKey];
if (state == null) return;
if (--state.pending <= 0) _rowIntents.remove(stateKey);
}
/// Drop the queued rows a landed write covers, and hold its marker until that
/// is done: a drain may already be holding one of those rows, and its sender
/// runs whether or not the row is still on disk.
///
/// Best-effort by construction. The write already landed, so a failure here
/// must never surface as a failed write — that would queue a retry and
/// duplicate it.
Future<void> _settleQueueAfterWrite(String? key, int? marker, _WriteScope scope, {int? appliedProgress}) async {
if (key == null) return;
try {
await _writeQueue.invalidate(scope.userUuid, key, appliedProgress: appliedProgress);
} catch (e) {
appLogger.d('Trackers: write queue cleanup failed for $key', error: e);
} finally {
if (marker != null) _writeQueue.clearDirectWrite(scope.userUuid, key, marker);
}
}
/// Serialises every write that targets the same remote row of the same profile
/// — live writes and queued replays alike.
///
/// Coalescing the queue is not enough on its own: a stale replay that is
/// already on the wire cannot be recalled, so it could land after a newer
/// direct write and undo it (walking a series counter backwards, or
/// resurrecting an item the user just un-watched). Chaining by row means the
/// newest write for it is always the last one to reach the service.
///
/// Scoped per profile: two profiles write to two accounts, so one must never
/// wait on — or be mistaken for — the other.
Future<T> _sequencedByKey<T>(_WriteScope scope, String? key, Future<T> Function() write) {
if (key == null) return write();
final chainKey = _rowKey(scope, key);
final previous = _writeChains[chainKey];
final link = Completer<void>();
_writeChains[chainKey] = link;
Future<T> run() async {
try {
return await write();
} finally {
link.complete();
if (identical(_writeChains[chainKey], link)) _writeChains.remove(chainKey);
}
}
return previous == null ? run() : previous.future.then((_) => run());
}
/// Identity a queued write coalesces on. A per-item history tracker keys on the
/// remote media row; a series-progress tracker keys on the remote list entry,
/// because every episode of one show restates the same counter. Both are
/// server-independent — a rating key is not, and would let two items on two
/// servers replace each other. Null when the tracker cannot name a remote
/// target, in which case the write could not have applied either.
String? _coalesceKeyFor(Tracker tracker, TrackerContext ctx) {
if (tracker is SeriesProgressTracker) {
final entryId = tracker.seriesEntryId(ctx);
return entryId == null ? null : trackerSeriesCoalesceKey(tracker.service, entryId);
}
if (tracker is EpisodeHistoryTracker) {
return trackerItemCoalesceKey(tracker.service, ctx, tracker.historyRowIdentity(ctx));
}
return null;
}
/// The absolute progress a watched write would claim, for the trackers that
/// store one. Null everywhere else: a history write is a statement about an
/// item, not a monotonic claim.
int? _progressClaim(Tracker tracker, TrackerContext ctx) =>
tracker is SeriesProgressTracker ? tracker.seriesProgress(ctx) : null;
Future<void> _enqueueWrite(Tracker tracker, TrackerContext ctx, _WriteScope scope, {required bool watched}) async {
final key = _coalesceKeyFor(tracker, ctx);
if (key == null) return;
await _writeQueue.enqueue(
scope.userUuid,
TrackerWriteQueueItem(
service: tracker.service,
watched: watched,
ctx: ctx,
coalesceKey: key,
progressClaim: watched ? _progressClaim(tracker, ctx) : null,
watchedAtIso: _clock().toUtc().toIso8601String(),
),
);
}
Future<TrackerWriteDisposition> _replayQueuedWrite(TrackerWriteQueueItem item, _WriteScope scope) async {
// The trackers now hold another profile's sessions; leave the rest of this
// profile's rows for its own flush rather than writing them to that account.
if (!_isCurrent(scope)) return TrackerWriteDisposition.skipped;
final tracker = _trackerFor(item.service);
if (tracker == null) return TrackerWriteDisposition.done;
if (!tracker.canWriteWatched) return TrackerWriteDisposition.skipped;
if (!tracker.shouldScrobbleForLibrary(item.ctx.libraryGlobalKey)) {
appLogger.d('${tracker.name}: queued write dropped — library filtered out');
return TrackerWriteDisposition.done;
}
try {
// Inside the row's channel: a direct write for the same row may have landed
// while this replay waited its turn, in which case replaying would undo it.
final replayed = await _sequencedByKey(scope, item.coalesceKey, () async {
if (_writeQueue.isSuperseded(scope.userUuid, item)) return false;
await (item.watched
? tracker.markWatched(item.ctx, watchedAt: item.watchedAt)
: tracker.markUnwatched(item.ctx));
return true;
});
appLogger.d(
replayed
? '${tracker.name}: replayed queued write for ${item.ctx.ratingKey}'
: '${tracker.name}: queued write for ${item.ctx.ratingKey} already covered by a newer write',
);
return TrackerWriteDisposition.done;
} catch (e) {
// Only an answer about this write may spend an attempt. A link that came
// back without reaching the endpoint, a rate limit, or the service failing
// on its own side are all reasons to ask again later — counting them would
// let a bad hour, or a few connectivity flaps, drop the watch for good. The
// drain also stops asking this service for the rest of the pass.
if (isTrackerFailureTransient(e)) {
appLogger.d('${tracker.name}: queued write deferred, service not taking writes', error: e);
return TrackerWriteDisposition.deferredService;
}
appLogger.d('${tracker.name}: queued write failed, will retry', error: e);
return TrackerWriteDisposition.failed;
}
}
Future<void> _scrobble(TrackerScrobbleState state) {
final ctx = _ctx;
if (ctx == null) return Future.value();
return _sendScrobble(ctx, state, _timeline.progressPercent, _playbackTargets);
}
/// [targets] is passed in rather than read from the field because the terminal
/// report is sent after [_reset] has already cleared the per-playback state,
/// on a snapshot of it.
///
/// Progress is deliberately not floored: a session that did start must be
/// closed even at 0%, or the service keeps showing the item as playing until
/// its runtime elapses.
Future<void> _sendScrobble(
TrackerContext ctx,
TrackerScrobbleState state,
double progressPercent,
List<_PlaybackTarget> targets,
) {
if (targets.isEmpty) return Future.value();
final now = _clock();
final sends = <Future<void>>[];
for (final target in targets) {
// Still the same account, and still enabled for this library: the user can
// turn a tracker off or filter the library out mid-playback.
if (!_bindingIntact(target, 'scrobble ${state.name}')) continue;
if (!_canReport(target.tracker, ctx.libraryGlobalKey)) continue;
if (!target.accepts(state, now, debounce: _duplicateStateDebounce)) continue;
target.record(state, now);
sends.add(target.channel.enqueue(state, () => _report(target, ctx, state, progressPercent)));
}
return sends.isEmpty ? Future.value() : Future.wait(sends);
}
Future<void> _report(
_PlaybackTarget target,
TrackerContext ctx,
TrackerScrobbleState state,
double progressPercent,
) async {
if (!_bindingIntact(target, 'scrobble ${state.name}')) return;
try {
await target.tracker.scrobble(ctx, state, progressPercent);
target.confirmReported(state);
} catch (e) {
// A tracker write must never disrupt playback.
appLogger.d('${target.tracker.name}: scrobble ${state.name} failed', error: e);
}
}
/// False when the tracker was rebound since [target] was pinned: the write
/// belongs to an account that is no longer bound (and whose client has been
/// disposed), so it is dropped rather than misfiled onto its replacement.
bool _bindingIntact(_PlaybackTarget target, String operation) {
if (!target.bindingChanged) return true;
appLogger.d('${target.tracker.name}: skipped $operation — account rebound');
return false;
}
Future<void> _reconcileWatchedAfterStop(
List<_PlaybackTarget> targets,
TrackerContext ctx,
double progressPercent,
_WriteScope scope,
) async {
if (targets.isEmpty) return;
await Future.wait(
targets.map((target) async {
if (!_bindingIntact(target, 'watched reconciliation')) return;
try {
await target.tracker.reconcileWatchedAfterStop(ctx, progressPercent);
} catch (e) {
// The pre-write check above only covers the write's start; queue the
// retry only if the account is still the one this playback was
// pinned to, otherwise the row would replay through its replacement.
if (!_bindingIntact(target, 'watched reconciliation retry')) return;
appLogger.d('${target.tracker.name}: reconcileWatchedAfterStop failed, queued for retry', error: e);
await _enqueueWrite(target.tracker, ctx, scope, watched: true);
}
}),
);
}
/// Await every queued report so a terminal stop is on the wire before the
/// caller (screen teardown, app shutdown) moves on.
Future<void> _settleScrobbles() => Future.wait(_channels.map((channel) => channel.settle()));
Future<TrackerContext?> _buildContext(
MediaItem metadata,
TrackerIdResolver resolver, {
bool includeAnimeProgress = true,
bool includeCurrentEpisode = true,
bool fallbackToAnimeEpisodeNumber = true,
}) async {
final libraryKey = metadata.libraryGlobalKey;
if (metadata.kind == MediaKind.movie) {
final ids = await resolver.resolveForMovie(metadata.id);
if (ids == null) return null;
return TrackerContext.movie(
external: ids.external,
anime: ids.anime,
ratingKey: metadata.id,
libraryGlobalKey: libraryKey,
);
}
final season = metadata.parentIndex;
final number = metadata.index;
if (season == null || number == null) return null;
final ids = await resolver.resolveShowForEpisode(
metadata,
includeAnimeProgress: includeAnimeProgress,
includeCurrentEpisode: includeCurrentEpisode,
);
if (ids == null) return null;
final animeProgress = includeAnimeProgress
? ids.animeProgress ?? (fallbackToAnimeEpisodeNumber ? ids.animeEpisodeNumber : null)
: fallbackToAnimeEpisodeNumber
? ids.animeEpisodeNumber
: null;
return TrackerContext.episode(
external: ids.external,
anime: ids.anime,
ratingKey: metadata.id,
libraryGlobalKey: libraryKey,
season: season,
episodeNumber: number,
animeProgress: animeProgress,
);
}
}
/// One queued real-time report. [state] is kept so overflow can tell a terminal
/// stop apart from a droppable start/pause.
class _QueuedScrobble {
final TrackerScrobbleState state;
final Future<void> Function() send;
const _QueuedScrobble(this.state, this.send);
}
/// One real-time tracker's report transport.
///
/// Reports are serialised per tracker, not globally: a service may accept one
/// write per user at a time (Simkl locks for 20 seconds and fails whatever
/// queued up behind it), while another has no such rule and must not wait on it.
class _ScrobbleChannel {
_ScrobbleChannel(this.tracker);
final RealtimeScrobbleTracker tracker;
/// Soft bound against a play/pause storm outrunning the remote lock: overflow
/// sheds the oldest non-terminal report. Terminal stops are never shed, so the
/// bound is deliberately soft — a burst of episode swaps behind one hung
/// request queues one stop per item, and each carries that item's own watch
/// and resume position.
static const int _maxQueued = 4;
final List<_QueuedScrobble> _queue = [];
Future<void>? _drain;
Future<void> enqueue(TrackerScrobbleState state, Future<void> Function() send) {
_queue.add(_QueuedScrobble(state, send));
if (_queue.length > _maxQueued) {
final victim = _queue.indexWhere((q) => q.state != TrackerScrobbleState.stop);
if (victim >= 0) _queue.removeAt(victim);
}
final draining = _drain;
if (draining != null) return draining;
final drain = _drainQueue();
_drain = drain;
return drain;
}
Future<void> _drainQueue() async {
try {
while (_queue.isNotEmpty) {
await _queue.removeAt(0).send();
}
} finally {
_drain = null;
}
}
/// Await the pending drain, including reports enqueued while it ran.
Future<void> settle() async {
var drain = _drain;
while (drain != null) {
await drain;
final next = _drain;
if (identical(next, drain)) break;
drain = next;
}
}
void clear() => _queue.clear();
}
/// One real-time tracker pinned to the account it was bound to when the current
/// playback began, plus that playback's report bookkeeping.
///
/// The bookkeeping is per playback *and* per tracker: two services can disagree
/// about whether a report is a duplicate, because their throttle windows are
/// their own.
class _PlaybackTarget {
_PlaybackTarget(this.channel, this.binding);
final _ScrobbleChannel channel;
/// Account identity captured at pin time, compared only by [identical].
final Object? binding;
TrackerScrobbleState? _lastState;
DateTime? _lastSentAt;
DateTime? _lastSeekAt;
bool _sessionStarted = false;
bool _stopConfirmed = false;
RealtimeScrobbleTracker get tracker => channel.tracker;
bool get sessionStarted => _sessionStarted;
/// True once the service accepted this playback's terminal stop. Until it
/// does, nothing on that side has seen the item finish — so nothing there has
/// applied the service's own completion rule either.
bool get stopConfirmed => _stopConfirmed;
/// Called after a report the service accepted.
void confirmReported(TrackerScrobbleState state) {
if (state == TrackerScrobbleState.stop) _stopConfirmed = true;
}
bool get bindingChanged => !identical(tracker.accountBinding, binding);
/// Whether this tracker should hear about [state] now.
bool accepts(TrackerScrobbleState state, DateTime now, {required Duration debounce}) {
final policy = tracker.scrobblePolicy;
if (state == TrackerScrobbleState.seek) {
final throttle = policy.seekThrottle;
// A checkpoint only means something while the service believes playback is
// running, and only as often as the service tolerates.
if (throttle == null || _lastState != TrackerScrobbleState.start) return false;
final last = _lastSeekAt;
return last == null || now.difference(last) >= throttle;
}
// A pause/stop with no session behind it would only invent one — that is the
// rolled-back player attempt, which never got as far as a start.
if (state != TrackerScrobbleState.start && !_sessionStarted) return false;
if (_lastState != state) return true;
final last = _lastSentAt;
if (last == null) return true;
final elapsed = now.difference(last);
if (elapsed < debounce) return false;
return state != TrackerScrobbleState.start || elapsed >= policy.resendThrottle;
}
void record(TrackerScrobbleState state, DateTime now) {
if (state == TrackerScrobbleState.seek) {
_lastSeekAt = now;
// A checkpoint leaves the session playing, so it counts as the latest
// start: a regular start straight after must not fire again.
_lastState = TrackerScrobbleState.start;
_lastSentAt = now;
return;
}
_lastState = state;
_lastSentAt = now;
if (state == TrackerScrobbleState.start) _sessionStarted = true;
}
}
/// Aggregates a container's episodes into the single progress value a
/// series-progress tracker should end up with.
class _ManualSeriesProgress {
final TrackerContext _base;
final bool _fallbackToCount;
int _count = 0;
int? _maxMappedProgress;
_ManualSeriesProgress(this._base, {required this._fallbackToCount});
void add(TrackerContext ctx) {
_count++;
final mapped = ctx.animeProgress;
if (mapped != null && (_maxMappedProgress == null || mapped > _maxMappedProgress!)) {
_maxMappedProgress = mapped;
}
}
int? get progress => _maxMappedProgress ?? (_fallbackToCount ? _count : null);
TrackerContext? get context {
final progress = this.progress;
if (progress == null) return null;
return TrackerContext.episode(
external: _base.external,
anime: _base.anime,
ratingKey: _base.ratingKey,
libraryGlobalKey: _base.libraryGlobalKey,
season: _base.season!,
episodeNumber: progress,
animeProgress: progress,
);
}
}
/// The profile a watched write belongs to.
///
/// Captured before the write's first await and re-checked at every deferred
/// step: a profile switch mid-write must neither file one profile's retry under
/// another nor push a leftover row through the account that replaced it.
class _WriteScope {
final String userUuid;
final int generation;
const _WriteScope(this.userUuid, this.generation);
}
/// Intent bookkeeping for one remote row, alive only while writes for it are.
class _RowIntents {
/// Number handed to the most recent write to enter the row's channel.
int lastIntent = 0;
/// The newest write that completed, and what it applied. [hasApplied]
/// distinguishes a completed non-claim write — null progress, covering the row
/// outright — from nothing having completed at all.
bool hasApplied = false;
int lastAppliedIntent = 0;
int? lastAppliedProgress;
/// Writes still holding this row; the entry is dropped when it hits zero.
int pending = 0;
}