feat(simkl): report playback progress while it happens

Simkl only heard about an item once playback crossed the media server's
watched threshold, so stopping partway recorded nothing at all: no resumable
position, no watch. Drive Simkl's /scrobble/start, /pause and /stop from the
player lifecycle instead, carrying the measured progress. Seeks report
nothing, as Simkl asks.

The terminal stop owns watched state for in-player playback, so real-time
trackers are excluded from the threshold markWatched fan-out and one watch
never produces two writes. Progress is reported as measured — it doubles as
the user's resume position — so when a server threshold configured below
Simkl's own 80% rule would leave the watch unrecorded, the tracker records it
through /sync/history rather than inflating progress. Manual, container,
offline-replay and external-player marks keep using /sync/history. Only
/scrobble/stop accepts a 409, which is the sole action documented to return
one.

Reports go out one at a time because Simkl serialises scrobble writes per
user and fails queued ones with a 400; overflow sheds the oldest non-terminal
report so an episode swap cannot drop the previous item's stop. A playback
session is pinned to the account bound when it began and every send re-checks
that binding, so a profile switch or a disconnect/reconnect can neither
redirect a queued report nor misfile the watched fallback.

Also close the paths that lost the terminal report entirely: app exit flushes
it instead of dropping it, the desktop window button goes through the app
shutdown rather than exit(0), a detached VOD player reports a stop, and a
finished item reports completion at EOF instead of waiting for teardown. A
session that opened at 0% is still closed on stop, or Simkl keeps showing the
item as playing until its runtime elapses.

close #1719
This commit is contained in:
edde746
2026-07-30 11:55:13 +02:00
parent 97cb98526e
commit 5a25c1f9cc
14 changed files with 958 additions and 34 deletions
+7
View File
@@ -787,6 +787,13 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
_memoryCheckTimer?.cancel();
_downloadManager.dispose();
// 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 []);
TrackerCoordinator.instance.cancelInFlight();
TraktScrobbleService.instance.cancelInFlight();
await TraktSyncService.instance.dispose();
+13
View File
@@ -927,6 +927,19 @@ class _MainScreenState extends State<MainScreen>
@override
void onWindowClose() {
unawaited(_exitOnWindowClose());
}
/// `setPreventClose(true)` hands the window's close button to us, so the app
/// has to shut itself down. A bare `exit(0)` killed the process before the
/// app-level teardown could run — including the terminal playback report that
/// trackers owning their own watched semantics depend on.
Future<void> _exitOnWindowClose() async {
try {
await AppExitService.requestGracefulExit().timeout(const Duration(seconds: 5));
} catch (e, st) {
appLogger.w('Graceful window close failed; exiting immediately', error: e, stackTrace: st);
}
exit(0);
}
@@ -23,6 +23,16 @@ 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
// the service would still show the item as playing. Seed the known duration
// first — the position stream can stop a beat short of it on EOF. A later
// dispose or in-place reload finds no context and does nothing.
if (duration != null && duration.inMilliseconds > 0) {
TrackerCoordinator.instance.updatePosition(duration);
}
unawaited(TrackerCoordinator.instance.stopPlayback());
if (_autoPipEnabled) {
unawaited(_updateAutoPipState(isPlaying: false));
}
@@ -645,13 +645,15 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
// Update OS media controls playback state
_updateMediaControlsPlaybackState();
// Update Discord Rich Presence + Trakt scrobble
// 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();
}
// Update auto-PiP readiness
+9 -1
View File
@@ -894,7 +894,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
break;
case AppLifecycleState.detached:
_recordLifecycleState('detached');
if (widget.isLive) unawaited(_sendStoppedProgressOnce());
if (widget.isLive) {
unawaited(_sendStoppedProgressOnce());
} else {
// Last chance for VOD: dispose may never run on a terminate, and the
// trackers that own their own watched semantics need the terminal
// report.
unawaited(TrackerCoordinator.instance.stopPlayback());
unawaited(TraktScrobbleService.instance.stopPlayback());
}
break;
}
}
+16
View File
@@ -41,4 +41,20 @@ class AppExitService {
await SystemNavigator.pop();
return true;
}
/// Requests a *cancelable* exit so registered `onExitRequested` handlers run
/// before the process goes away — app-level teardown depends on it, including
/// the terminal playback report for trackers that own their own watched
/// semantics.
///
/// Desktop only; returns false elsewhere, and when the platform declined, so
/// the caller can fall back to a hard exit.
static Future<bool> requestGracefulExit({AppExitApplication? exitApplicationForTesting}) async {
if (!PlatformDetector.isDesktopOS()) return false;
final exitApplication =
exitApplicationForTesting ??
(exitType, exitCode) => ServicesBinding.instance.exitApplication(exitType, exitCode);
final response = await exitApplication(ui.AppExitType.cancelable, 0);
return response == ui.AppExitResponse.exit;
}
}
+20 -1
View File
@@ -46,6 +46,15 @@ class SimklClient implements DisposableTrackerClient {
Future<void> removeFromHistory(Map<String, dynamic> body) => _request('POST', '/sync/history/remove', body: body);
/// Report real-time playback. [action] is `start`, `pause` or `stop`.
///
/// Simkl's own rules, not ours: a `stop` at >= 80% progress marks the item
/// watched, below that it saves a resumable playback. Only `stop` documents a
/// 409 (the item was already marked watched within the last hour), so it is
/// the only action that accepts one as success.
Future<void> scrobble(String action, Map<String, dynamic> body, {bool allowConflict = false}) =>
_request('POST', '/scrobble/$action', body: body, allowStatuses: allowConflict ? const {409} : const {});
Future<void> addRatings(Map<String, dynamic> body) => _request('POST', '/sync/ratings', body: body);
Future<void> removeRatings(Map<String, dynamic> body) => _request('POST', '/sync/ratings/remove', body: body);
@@ -124,8 +133,16 @@ class SimklClient implements DisposableTrackerClient {
Map<String, dynamic>? body,
Map<String, String>? query,
String? baseOverride,
Set<int> allowStatuses = const {},
}) async {
final response = await _requestResponse(method, path, body: body, query: query, baseOverride: baseOverride);
final response = await _requestResponse(
method,
path,
body: body,
query: query,
baseOverride: baseOverride,
allowStatuses: allowStatuses,
);
return TrackerHttpClient.decodeJson(response.body);
}
@@ -135,6 +152,7 @@ class SimklClient implements DisposableTrackerClient {
Map<String, dynamic>? body,
Map<String, String>? query,
String? baseOverride,
Set<int> allowStatuses = const {},
}) async {
final base = baseOverride ?? SimklConstants.apiBase;
final uri = Uri.parse('$base$path').replace(queryParameters: SimklConstants.queryParameters(query));
@@ -159,6 +177,7 @@ class SimklClient implements DisposableTrackerClient {
isPermanent: true,
);
}
if (allowStatuses.contains(response.statusCode)) return response;
if (response.statusCode < 200 || response.statusCode >= 300) {
throw TrackerApiException(service: TrackerService.simkl, statusCode: response.statusCode);
}
+69 -3
View File
@@ -12,14 +12,22 @@ import '../tracker_rating_match.dart';
import '../tracker_session.dart';
import 'simkl_client.dart';
/// Simkl scrobble tracker. Fires `POST /sync/history` once playback crosses
/// the watched threshold (Simkl has no real-time `/scrobble/*` endpoints).
/// Simkl tracker.
///
/// In-player playback is reported in real time through `POST /scrobble/start`,
/// `/pause` and `/stop` — Simkl's own rules then decide watched state: a `stop`
/// at >= 80% progress marks the item watched, below that it saves a resumable
/// playback so partially watched items survive (issue #1719). `POST
/// /sync/history` stays for the marks that never pass through the player:
/// manual, container, offline replay and external players.
///
/// General-purpose: accepts any Plex external ID (tvdb/imdb/tmdb) directly,
/// so it fires for non-anime TV and movies too. Prefers Fribb's simkl_id
/// when present for stricter anime match, otherwise falls back to whatever
/// Plex exposes.
class SimklTracker extends TrackerBase with ClientBackedTracker<SimklClient> implements TrackerRatingSource {
class SimklTracker extends TrackerBase
with ClientBackedTracker<SimklClient>
implements TrackerRatingSource, RealtimeScrobbleTracker {
static SimklTracker? _instance;
static SimklTracker get instance => _instance ??= SimklTracker._();
SimklTracker._();
@@ -33,6 +41,15 @@ class SimklTracker extends TrackerBase with ClientBackedTracker<SimklClient> imp
@override
bool get needsFribb => false;
/// Simkl counts a `/scrobble/stop` as a watch from this progress upwards and
/// files anything below it as resumable playback instead.
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;
void rebindSession(
TrackerSession? session, {
required void Function() onSessionInvalidated,
@@ -71,6 +88,55 @@ class SimklTracker extends TrackerBase with ClientBackedTracker<SimklClient> imp
appLogger.d('Simkl: marked unwatched (ids=$ids, isMovie=${ctx.isMovie})');
}
@override
Future<void> scrobble(TrackerContext ctx, TrackerScrobbleState state, double progressPercent) async {
final client = this.client;
if (client == null) return;
final ids = _buildIds(external: ctx.external, anime: ctx.anime);
if (ids.isEmpty) return;
final action = switch (state) {
TrackerScrobbleState.start => 'start',
TrackerScrobbleState.pause => 'pause',
TrackerScrobbleState.stop => 'stop',
};
await client.scrobble(
action,
_scrobbleBody(ctx, ids, progressPercent),
allowConflict: state == TrackerScrobbleState.stop,
);
appLogger.d('Simkl: scrobble $action @ ${progressPercent.toStringAsFixed(1)}% (ids=$ids)');
}
@override
Future<void> reconcileWatchedAfterStop(TrackerContext ctx, double progressPercent) async {
// At or above Simkl's own rule the stop already marked it watched; sending
// history too would write the same watch twice.
if (progressPercent >= _scrobbleWatchedPercent) return;
appLogger.d('Simkl: stop below ${_scrobbleWatchedPercent.toStringAsFixed(0)}% — recording watch explicitly');
await markWatched(ctx);
}
/// Scrobble takes a single `movie`/`show` object plus a sibling `episode`,
/// unlike the plural history/ratings shapes. `show` also covers anime:
/// Simkl routes by id and maps TVDB season/episode numbering to AniDB
/// itself.
Map<String, dynamic> _scrobbleBody(TrackerContext ctx, Map<String, Object> ids, double progressPercent) {
// Simkl accepts at most two decimal places on `progress`.
final progress = double.parse(progressPercent.toStringAsFixed(2));
return ctx.isMovie
? {
'progress': progress,
'movie': {'ids': ids},
}
: {
'progress': progress,
'show': {'ids': ids},
'episode': {'season': ctx.season, 'number': ctx.episodeNumber},
};
}
Map<String, dynamic> _historyBody(TrackerContext ctx, Map<String, Object> ids) {
return ctx.isMovie
? {
+33
View File
@@ -41,6 +41,39 @@ abstract interface class TrackerRatingSource {
Future<void> clearRating(TrackerRatingContext ctx);
}
/// Playback state reported to trackers that accept real-time progress.
enum TrackerScrobbleState { start, pause, stop }
/// 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.
///
/// A real-time tracker owns its own watched semantics for in-player playback:
/// the coordinator deliberately excludes it from the watched-threshold
/// [Tracker.markWatched] fan-out so one watch never produces two writes.
/// Manual, container, offline-replay and external-player marks still go
/// through [Tracker.markWatched].
abstract interface class RealtimeScrobbleTracker implements Tracker {
/// Identity of the account binding this tracker currently writes through,
/// compared only by [identical]. Deferred work captures it and re-checks
/// before writing, so a rebind — profile switch, disconnect, reconnect —
/// cannot redirect a write to whichever account replaced it.
Object? get scrobbleBinding;
/// Report a playback lifecycle event with the current progress percentage.
Future<void> scrobble(TrackerContext ctx, TrackerScrobbleState state, double progressPercent);
/// Called after a terminal [TrackerScrobbleState.stop] whose progress Plezy
/// counts as watched (the media server's threshold was crossed).
///
/// Services apply their own completion rule to a stop, which can be stricter
/// than a server threshold the user configured lower. Only the tracker knows
/// whether its stop already recorded the watch, so it decides here: no-op, or
/// record it. [progressPercent] is reported as measured — it doubles as the
/// user's resume position and is never inflated to force a watched state.
Future<void> reconcileWatchedAfterStop(TrackerContext ctx, double progressPercent);
}
abstract interface class DisposableTrackerClient {
void dispose();
}
+266 -10
View File
@@ -17,10 +17,24 @@ import 'tracker.dart';
import 'tracker_constants.dart';
import 'tracker_id_resolver.dart';
/// Fan-out for non-Trakt trackers (MAL, AniList, Simkl). Owns the per-playback
/// threshold state: each connected tracker is notified exactly once when
/// progress crosses the watched threshold, with a safety-net fire on stop if
/// the crossing was missed (e.g. user stopped between ticks).
/// A real-time tracker paired with the account binding it was captured against,
/// so a deferred write can tell whether it is still writing to the same account.
typedef _BoundScrobbleTarget = (RealtimeScrobbleTracker, Object?);
/// Fan-out for non-Trakt trackers (MAL, AniList, Simkl).
///
/// Two mechanisms, one per tracker kind:
///
/// * Threshold trackers (MAL, AniList) are notified exactly once when progress
/// crosses the watched threshold, with a safety-net fire on stop if the
/// crossing was missed (e.g. user stopped between ticks).
/// * [RealtimeScrobbleTracker]s (Simkl) instead receive the playback lifecycle
/// — start/resume, pause, stop — with the current progress, and decide
/// watched state themselves. They are excluded from the threshold fan-out so
/// a single watch never produces two writes.
///
/// Manual, container, offline-replay and external-player marks bypass all of
/// this and go straight to [Tracker.markWatched] on every tracker.
class TrackerCoordinator {
static TrackerCoordinator? _instance;
static TrackerCoordinator get instance => _instance ??= TrackerCoordinator._();
@@ -51,6 +65,43 @@ class TrackerCoordinator {
bool _thresholdCrossed = false;
int _playbackRevision = 0;
/// Drop a duplicate state transition within this window — the player emits
/// several playing-state events per seek.
static const Duration _duplicateStateDebounce = Duration(seconds: 1);
/// Drop a same-state re-send inside this window. Simkl serialises scrobble
/// writes behind a 20-second per-user lock and fails queued requests with a
/// 400, so rapid pause/play cycles must not each ship a `start`.
static const Duration _startResendThrottle = Duration(seconds: 20);
TrackerScrobbleState? _lastScrobbleState;
DateTime? _lastScrobbleAt;
bool _scrobbleStarted = false;
/// Real-time targets pinned when the current playback began, each with the
/// account binding it was pinned against. Every report for this playback goes
/// to these and no others.
List<_BoundScrobbleTarget> _playbackTargets = const [];
/// Real-time services process one write per user at a time, so reports queue
/// and go out in order. An episode swap stops the old item and starts the new
/// one back to back, so both can be waiting behind one gated request.
final List<_QueuedScrobble> _scrobbleQueue = [];
Future<void>? _scrobbleDrain;
/// Soft bound against a play/pause storm outrunning the remote lock: overflow
/// sheds the oldest non-terminal report. Terminal stops are never shed, so the
/// bound is deliberately soft — a burst of episode swaps behind one hung
/// request queues one stop per item, and each carries that item's own watch
/// and resume position.
static const int _maxQueuedScrobbles = 4;
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()));
}
@@ -88,7 +139,18 @@ class TrackerCoordinator {
}
_reset();
_ctx = ctx;
_timeline.watchedThreshold = client.watchedThreshold;
// 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 = _activeRealtimeTargets(ctx);
unawaited(_scrobble(TrackerScrobbleState.start));
}
bool _anyTrackerNeedsFribb() => _anyTrackerNeedsFribbForLibrary(_activeLibraryGlobalKey);
@@ -271,23 +333,49 @@ class TrackerCoordinator {
}
}
/// 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 shouldMarkWatched = ctx != null && !_thresholdCrossed && _timeline.watchedThresholdReached;
final watched = _thresholdCrossed || _timeline.watchedThresholdReached;
final missedThresholdMark = !_thresholdCrossed && _timeline.watchedThresholdReached;
final progress = _timeline.progressPercent;
final started = _scrobbleStarted;
// Snapshotted before [_reset] clears them: the terminal report and the
// reconciliation that follows belong to the account this playback began on.
final targets = _playbackTargets;
final reconcilers = watched && started ? targets : const <_BoundScrobbleTarget>[];
_reset();
if (ctx != null && shouldMarkWatched) {
await _dispatch(_trackers, ctx, watched: true);
}
if (ctx == null) return;
await Future.wait([
if (missedThresholdMark) _dispatch(_thresholdTrackers, ctx, watched: true),
_sendScrobble(ctx, TrackerScrobbleState.stop, progress, sessionStarted: started, targets: targets),
]);
await _drainScrobbles();
await _reconcileWatchedAfterStop(reconcilers, ctx, progress);
}
/// 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) {
_timeline.updatePosition(position);
final ctx = _ctx;
if (ctx == null || _thresholdCrossed) return;
if (!_timeline.watchedThresholdReached) return;
_thresholdCrossed = true;
unawaited(_dispatch(_trackers, ctx, watched: true));
unawaited(_dispatch(_thresholdTrackers, ctx, watched: true));
}
void updateDuration(Duration duration) {
@@ -298,6 +386,9 @@ class TrackerCoordinator {
/// trackers and invalidates the resolver so a fresh Plex client is used.
void cancelInFlight() {
++_playbackRevision;
// Queued reports belong to the profile being left; the client backing them
// is about to be disposed.
_scrobbleQueue.clear();
_reset();
_resolver?.clearCache();
_resolver = null;
@@ -314,8 +405,19 @@ class TrackerCoordinator {
_activeLibraryGlobalKey = null;
_timeline.reset(watchedThreshold: _fallbackWatchedThreshold);
_thresholdCrossed = false;
_lastScrobbleState = null;
_lastScrobbleAt = null;
_scrobbleStarted = false;
_playbackTargets = const [];
}
/// Trackers whose watch is recorded by the threshold crossing. Real-time
/// trackers are excluded — their own playback lifecycle owns it, and one
/// watch must never produce two writes.
Iterable<Tracker> get _thresholdTrackers => _trackers.where((t) => t is! RealtimeScrobbleTracker);
Iterable<RealtimeScrobbleTracker> get _realtimeTrackers => _trackers.whereType<RealtimeScrobbleTracker>();
bool _isActive(Tracker tracker, String? libraryGlobalKey) =>
tracker.canScrobble && tracker.shouldScrobbleForLibrary(libraryGlobalKey);
@@ -332,6 +434,151 @@ class TrackerCoordinator {
);
}
Future<void> _scrobble(TrackerScrobbleState state) {
final ctx = _ctx;
if (ctx == null) return Future.value();
return _sendScrobble(
ctx,
state,
_timeline.progressPercent,
sessionStarted: _scrobbleStarted,
targets: _playbackTargets,
);
}
/// [sessionStarted] and [targets] are passed in rather than read from the
/// fields because the terminal report is sent after [_reset] has already
/// cleared the per-playback state, on a snapshot of it.
Future<void> _sendScrobble(
TrackerContext ctx,
TrackerScrobbleState state,
double progressPercent, {
required bool sessionStarted,
required List<_BoundScrobbleTarget> targets,
}) {
// A pause/stop with no session behind it would only invent one — that is the
// rolled-back player attempt, which never got as far as a start. Progress is
// deliberately not floored here: a session that did start must be closed
// even at 0%, or the service keeps showing the item as playing until its
// runtime elapses.
if (state != TrackerScrobbleState.start && !sessionStarted) return Future.value();
final now = _clock();
final last = _lastScrobbleAt;
if (_lastScrobbleState == state && last != null) {
final elapsed = now.difference(last);
if (elapsed < _duplicateStateDebounce) return Future.value();
if (state == TrackerScrobbleState.start && elapsed < _startResendThrottle) return Future.value();
}
// Still the same account, and still enabled for this library: the user can
// turn a tracker off or filter the library out mid-playback.
final sendable = [
for (final target in targets)
if (_bindingIntact(target, 'scrobble ${state.name}') && _isActive(target.$1, ctx.libraryGlobalKey)) target,
];
if (sendable.isEmpty) return Future.value();
_lastScrobbleState = state;
_lastScrobbleAt = now;
if (state == TrackerScrobbleState.start) _scrobbleStarted = true;
return _enqueueScrobble(state, () => _fanOutScrobble(sendable, ctx, state, progressPercent));
}
/// Pins each active real-time tracker to the account binding it is being
/// captured against, once per playback.
List<_BoundScrobbleTarget> _activeRealtimeTargets(TrackerContext ctx) => [
for (final t in _realtimeTrackers)
if (_isActive(t, ctx.libraryGlobalKey)) (t, t.scrobbleBinding),
];
/// False when the tracker was rebound since [target] was captured: the write
/// belongs to an account that is no longer bound (and whose client has been
/// disposed), so it is dropped rather than misfiled onto its replacement.
bool _bindingIntact(_BoundScrobbleTarget target, String operation) {
final (tracker, binding) = target;
if (identical(tracker.scrobbleBinding, binding)) return true;
appLogger.d('${tracker.name}: skipped $operation — account rebound');
return false;
}
Future<void> _fanOutScrobble(
List<_BoundScrobbleTarget> targets,
TrackerContext ctx,
TrackerScrobbleState state,
double progressPercent,
) {
return Future.wait(
targets.map((target) async {
if (!_bindingIntact(target, 'scrobble ${state.name}')) return;
final (tracker, _) = target;
try {
await tracker.scrobble(ctx, state, progressPercent);
} catch (e) {
// A tracker write must never disrupt playback.
appLogger.d('${tracker.name}: scrobble ${state.name} failed', error: e);
}
}),
);
}
Future<void> _reconcileWatchedAfterStop(
List<_BoundScrobbleTarget> reconcilers,
TrackerContext ctx,
double progressPercent,
) {
return Future.wait(
reconcilers.map((target) async {
if (!_bindingIntact(target, 'watched reconciliation')) return;
final (tracker, _) = target;
try {
await tracker.reconcileWatchedAfterStop(ctx, progressPercent);
} catch (e) {
appLogger.d('${tracker.name}: reconcileWatchedAfterStop failed', error: e);
}
}),
);
}
/// Queue a report behind whatever is already going out. Overflow sheds the
/// oldest non-terminal entry so a stop for an item the player has already
/// swapped away from still reaches the service.
Future<void> _enqueueScrobble(TrackerScrobbleState state, Future<void> Function() send) {
_scrobbleQueue.add(_QueuedScrobble(state, send));
if (_scrobbleQueue.length > _maxQueuedScrobbles) {
final victim = _scrobbleQueue.indexWhere((q) => q.state != TrackerScrobbleState.stop);
if (victim >= 0) _scrobbleQueue.removeAt(victim);
}
final draining = _scrobbleDrain;
if (draining != null) return draining;
final drain = _drainScrobbleQueue();
_scrobbleDrain = drain;
return drain;
}
Future<void> _drainScrobbleQueue() async {
try {
while (_scrobbleQueue.isNotEmpty) {
await _scrobbleQueue.removeAt(0).send();
}
} finally {
_scrobbleDrain = null;
}
}
/// Await every queued report so a terminal stop is on the wire before the
/// caller (screen teardown, app shutdown) moves on.
Future<void> _drainScrobbles() async {
var drain = _scrobbleDrain;
while (drain != null) {
await drain;
final next = _scrobbleDrain;
if (identical(next, drain)) break;
drain = next;
}
}
Future<TrackerContext?> _buildContext(
MediaItem metadata,
TrackerIdResolver resolver, {
@@ -379,6 +626,15 @@ class TrackerCoordinator {
}
}
/// 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);
}
class _ManualAnimeProgress {
final TrackerContext _base;
final bool _fallbackToCount;