From 5a25c1f9cc3cc30e18f220370f45a6497210719b Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:46:52 +0200 Subject: [PATCH] feat(simkl): report playback progress while it happens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/main.dart | 7 + lib/screens/main_screen.dart | 13 + .../video_player/parts/playback_prompts.dart | 10 + .../video_player/parts/playback_services.dart | 4 +- lib/screens/video_player_screen.dart | 10 +- lib/services/app_exit_service.dart | 16 + lib/services/trackers/simkl/simkl_client.dart | 21 +- .../trackers/simkl/simkl_tracker.dart | 72 ++- lib/services/trackers/tracker.dart | 33 ++ .../trackers/tracker_coordinator.dart | 276 +++++++++++- test/services/app_exit_service_test.dart | 28 ++ .../trackers/simkl_scrobble_test.dart | 413 ++++++++++++++++++ .../tracker_coordinator_manual_test.dart | 70 ++- .../trackers/tracker_status_ladder_test.dart | 19 + 14 files changed, 958 insertions(+), 34 deletions(-) create mode 100644 test/services/trackers/simkl_scrobble_test.dart diff --git a/lib/main.dart b/lib/main.dart index 1dc0f348..9ab27e9e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -787,6 +787,13 @@ class _MainAppState extends State 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(); diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index c27c6885..2ac1bdfe 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -927,6 +927,19 @@ class _MainScreenState extends State @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 _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); } diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index d4d04b38..6aeffe1e 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -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)); } diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index a50c4068..0eefe628 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -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 diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index f503789c..5856cf3f 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -894,7 +894,15 @@ class VideoPlayerScreenState extends State 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; } } diff --git a/lib/services/app_exit_service.dart b/lib/services/app_exit_service.dart index 0668d258..7815cdb2 100644 --- a/lib/services/app_exit_service.dart +++ b/lib/services/app_exit_service.dart @@ -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 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; + } } diff --git a/lib/services/trackers/simkl/simkl_client.dart b/lib/services/trackers/simkl/simkl_client.dart index 59ea3315..86712d21 100644 --- a/lib/services/trackers/simkl/simkl_client.dart +++ b/lib/services/trackers/simkl/simkl_client.dart @@ -46,6 +46,15 @@ class SimklClient implements DisposableTrackerClient { Future removeFromHistory(Map 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 scrobble(String action, Map body, {bool allowConflict = false}) => + _request('POST', '/scrobble/$action', body: body, allowStatuses: allowConflict ? const {409} : const {}); + Future addRatings(Map body) => _request('POST', '/sync/ratings', body: body); Future removeRatings(Map body) => _request('POST', '/sync/ratings/remove', body: body); @@ -124,8 +133,16 @@ class SimklClient implements DisposableTrackerClient { Map? body, Map? query, String? baseOverride, + Set 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? body, Map? query, String? baseOverride, + Set 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); } diff --git a/lib/services/trackers/simkl/simkl_tracker.dart b/lib/services/trackers/simkl/simkl_tracker.dart index 44f13b28..af251c7f 100644 --- a/lib/services/trackers/simkl/simkl_tracker.dart +++ b/lib/services/trackers/simkl/simkl_tracker.dart @@ -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 implements TrackerRatingSource { +class SimklTracker extends TrackerBase + with ClientBackedTracker + implements TrackerRatingSource, RealtimeScrobbleTracker { static SimklTracker? _instance; static SimklTracker get instance => _instance ??= SimklTracker._(); SimklTracker._(); @@ -33,6 +41,15 @@ class SimklTracker extends TrackerBase with ClientBackedTracker 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 imp appLogger.d('Simkl: marked unwatched (ids=$ids, isMovie=${ctx.isMovie})'); } + @override + Future 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 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 _scrobbleBody(TrackerContext ctx, Map 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 _historyBody(TrackerContext ctx, Map ids) { return ctx.isMovie ? { diff --git a/lib/services/trackers/tracker.dart b/lib/services/trackers/tracker.dart index 44416b92..4e0b3453 100644 --- a/lib/services/trackers/tracker.dart +++ b/lib/services/trackers/tracker.dart @@ -41,6 +41,39 @@ abstract interface class TrackerRatingSource { Future 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 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 reconcileWatchedAfterStop(TrackerContext ctx, double progressPercent); +} + abstract interface class DisposableTrackerClient { void dispose(); } diff --git a/lib/services/trackers/tracker_coordinator.dart b/lib/services/trackers/tracker_coordinator.dart index d7530ac0..52e5165d 100644 --- a/lib/services/trackers/tracker_coordinator.dart +++ b/lib/services/trackers/tracker_coordinator.dart @@ -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? _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 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 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 pausePlayback() => _scrobble(TrackerScrobbleState.pause); + + Future 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 get _thresholdTrackers => _trackers.where((t) => t is! RealtimeScrobbleTracker); + + Iterable get _realtimeTrackers => _trackers.whereType(); + bool _isActive(Tracker tracker, String? libraryGlobalKey) => tracker.canScrobble && tracker.shouldScrobbleForLibrary(libraryGlobalKey); @@ -332,6 +434,151 @@ class TrackerCoordinator { ); } + Future _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 _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 _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 _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 _enqueueScrobble(TrackerScrobbleState state, Future Function() send) { + _scrobbleQueue.add(_QueuedScrobble(state, send)); + if (_scrobbleQueue.length > _maxQueuedScrobbles) { + final victim = _scrobbleQueue.indexWhere((q) => q.state != TrackerScrobbleState.stop); + if (victim >= 0) _scrobbleQueue.removeAt(victim); + } + final draining = _scrobbleDrain; + if (draining != null) return draining; + final drain = _drainScrobbleQueue(); + _scrobbleDrain = drain; + return drain; + } + + Future _drainScrobbleQueue() async { + try { + while (_scrobbleQueue.isNotEmpty) { + await _scrobbleQueue.removeAt(0).send(); + } + } finally { + _scrobbleDrain = null; + } + } + + /// Await every queued report so a terminal stop is on the wire before the + /// caller (screen teardown, app shutdown) moves on. + Future _drainScrobbles() async { + var drain = _scrobbleDrain; + while (drain != null) { + await drain; + final next = _scrobbleDrain; + if (identical(next, drain)) break; + drain = next; + } + } + Future _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 Function() send; + + const _QueuedScrobble(this.state, this.send); +} + class _ManualAnimeProgress { final TrackerContext _base; final bool _fallbackToCount; diff --git a/test/services/app_exit_service_test.dart b/test/services/app_exit_service_test.dart index dfea25bb..e74b45f3 100644 --- a/test/services/app_exit_service_test.dart +++ b/test/services/app_exit_service_test.dart @@ -21,4 +21,32 @@ void main() { expect(requestedType, ui.AppExitType.required); expect(requestedCode, 0); }); + + // The window's close button must run the registered onExitRequested handlers + // (app-level teardown, including the terminal playback report), which only a + // cancelable request does — `required` skips them. + test('graceful desktop exit uses a cancelable application exit', () async { + ui.AppExitType? requestedType; + int? requestedCode; + + expect( + await AppExitService.requestGracefulExit( + exitApplicationForTesting: (exitType, exitCode) async { + requestedType = exitType; + requestedCode = exitCode; + return ui.AppExitResponse.exit; + }, + ), + isTrue, + ); + expect(requestedType, ui.AppExitType.cancelable); + expect(requestedCode, 0); + }); + + test('a declined graceful exit reports failure so the caller can hard-exit', () async { + expect( + await AppExitService.requestGracefulExit(exitApplicationForTesting: (_, _) async => ui.AppExitResponse.cancel), + isFalse, + ); + }); } diff --git a/test/services/trackers/simkl_scrobble_test.dart b/test/services/trackers/simkl_scrobble_test.dart new file mode 100644 index 00000000..6ea38f82 --- /dev/null +++ b/test/services/trackers/simkl_scrobble_test.dart @@ -0,0 +1,413 @@ +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/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/utils/external_ids.dart'; +import '../../test_helpers/media_items.dart'; + +class _FakeMediaServerClient implements MediaServerClient { + @override + final ServerId serverId; + @override + String? get serverName => null; + + final Map externalIdsByItem; + + @override + final double watchedThreshold; + + _FakeMediaServerClient({required this.externalIdsByItem, this.watchedThreshold = 0.9}) + : serverId = ServerId('server-1'); + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + Future fetchExternalIds(String itemId) async => externalIdsByItem[itemId] ?? const ExternalIds(); + + @override + Future> fetchPlayableDescendants(String parentId) async => const []; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Call { + final String path; + final Map body; + + _Call(this.path, this.body); + + @override + String toString() => '$path ${json.encode(body)}'; +} + +/// Records every Simkl write and can hold one in flight, which is how the queue +/// ordering is driven without leaning on wall-clock timing. +class _SimklRecorder { + final List<_Call> calls = []; + Completer? gate; + int status = 200; + + http.Client get client => MockClient((request) async { + final body = request.body.isEmpty + ? {} + : (json.decode(request.body) as Map).cast(); + calls.add(_Call(request.url.path, body)); + final pending = gate; + if (pending != null) await pending.future; + return http.Response('{}', status); + }); + + List get paths => calls.map((c) => c.path).toList(); + + _Call callFor(String path) => calls.firstWhere((c) => c.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, +); + +MediaItem _movie() => testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Movie 1', + serverId: ServerId('server-1'), + libraryId: 'lib-1', +); + +TrackerSession _session() => + TrackerSession(accessToken: 'token', createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000); + +_FakeMediaServerClient _client({double watchedThreshold = 0.9}) => _FakeMediaServerClient( + externalIdsByItem: {'show-1': const ExternalIds(tvdb: 12345), 'movie-1': const ExternalIds(tmdb: 603)}, + watchedThreshold: watchedThreshold, +); + +void main() { + final coordinator = TrackerCoordinator.instance; + final simkl = SimklTracker.instance; + final mal = MalTracker.instance; + final anilist = AnilistTracker.instance; + + late _SimklRecorder recorder; + late DateTime now; + + setUp(() async { + recorder = _SimklRecorder(); + now = DateTime(2026, 7, 30, 12); + coordinator.debugUseScrobbleClock(() => now); + await mal.setEnabled(false); + await anilist.setEnabled(false); + await simkl.setEnabled(true); + simkl.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: recorder.client); + }); + + tearDown(() async { + if (recorder.gate?.isCompleted == false) recorder.gate!.complete(); + coordinator.cancelInFlight(); + coordinator.debugUseResolverDependencies(); + coordinator.debugUseScrobbleClock(null); + simkl.rebindSession(null, onSessionInvalidated: () {}); + await simkl.setEnabled(false); + }); + + /// Move the fake clock past the debounce and same-state throttle windows. + void settleThrottles() => now = now.add(const Duration(seconds: 30)); + + group('Simkl real-time playback', () { + test('reports the resume position when playback starts', () 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 saves progress and resume reopens the session', () async { + await coordinator.startPlayback(_episode(durationMs: 100000), _client()); + await pumpEventQueue(); + coordinator.updatePosition(const Duration(milliseconds: 40000)); + + settleThrottles(); + await coordinator.pausePlayback(); + settleThrottles(); + 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); + }); + + // Issue #1719: stopping before the item finished recorded nothing at all. + test('stopping unfinished playback saves the true position, without a history write', () async { + await coordinator.startPlayback(_episode(durationMs: 2526934), _client()); + await pumpEventQueue(); + coordinator.updatePosition(const Duration(milliseconds: 1292667)); + + settleThrottles(); + await coordinator.stopPlayback(); + + expect(recorder.paths, ['/scrobble/start', '/scrobble/stop']); + expect(recorder.callFor('/scrobble/stop').body['progress'], closeTo(51.16, 0.01)); + }); + + test('stopping a finished item reports completion and leaves watched to Simkl', () async { + await coordinator.startPlayback(_episode(durationMs: 100000), _client()); + await pumpEventQueue(); + coordinator.updatePosition(const Duration(milliseconds: 95000)); + + settleThrottles(); + await coordinator.stopPlayback(); + + expect(recorder.callFor('/scrobble/stop').body['progress'], 95.0); + // 95% is past Simkl's own 80% rule, so that stop is the single watched + // write — never a second one through history. + expect(recorder.paths, isNot(contains('/sync/history'))); + }); + + test('records the watch explicitly when a low server threshold beats Simkl own rule', () async { + // Plex offers 25/50/75%; at 75% a truthful stop sits below Simkl's 80% + // rule, so the stop alone would only file resumable progress. + await coordinator.startPlayback(_episode(durationMs: 100000), _client(watchedThreshold: 0.75)); + await pumpEventQueue(); + coordinator.updatePosition(const Duration(milliseconds: 76000)); + + settleThrottles(); + await coordinator.stopPlayback(); + await pumpEventQueue(); + + expect(recorder.paths, ['/scrobble/start', '/scrobble/stop', '/sync/history']); + // Progress is never inflated to force the watched state: it doubles as the + // user's resume position. + expect(recorder.callFor('/scrobble/stop').body['progress'], 76.0); + }); + + test('an account rebind during the terminal report cancels the watched fallback', () async { + await coordinator.startPlayback(_episode(durationMs: 100000), _client(watchedThreshold: 0.75)); + await pumpEventQueue(); + coordinator.updatePosition(const Duration(milliseconds: 76000)); + + recorder.gate = Completer(); + settleThrottles(); + final stopped = coordinator.stopPlayback(); + await pumpEventQueue(); + + // The account is replaced while the stop is still on the wire — a + // disconnect/reconnect or profile switch, neither of which the queued + // fallback may follow. + final replacement = _SimklRecorder(); + simkl.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: replacement.client); + recorder.gate!.complete(); + await stopped; + await pumpEventQueue(); + + // The stop reached the account it belonged to; the history fallback lands + // on neither account rather than on the wrong one. + expect(recorder.paths, ['/scrobble/start', '/scrobble/stop']); + expect(replacement.paths, isEmpty); + }); + + test('an account rebind drops a still-queued lifecycle report', () async { + await coordinator.startPlayback(_episode(durationMs: 100000), _client()); + await pumpEventQueue(); + coordinator.updatePosition(const Duration(milliseconds: 40000)); + + // The pause goes on the wire and blocks there; the stop only queues. + recorder.gate = Completer(); + settleThrottles(); + final paused = coordinator.pausePlayback(); + await pumpEventQueue(); + final stopped = coordinator.stopPlayback(); + await pumpEventQueue(); + + // Disconnect/reconnect replaces the client the queued stop would have + // used. Reading it at execution time would post this item to whichever + // account is bound by then. + final replacement = _SimklRecorder(); + simkl.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: replacement.client); + recorder.gate!.complete(); + await paused; + await stopped; + await pumpEventQueue(); + + // The pause was already bound when it went out, so it belongs to the + // original account. The queued stop is dropped, not redirected. + expect(recorder.paths, ['/scrobble/start', '/scrobble/pause']); + expect(replacement.paths, isEmpty); + }); + + test('an account rebind before the terminal report keeps it off the new account', () async { + await coordinator.startPlayback(_episode(durationMs: 100000), _client()); + await pumpEventQueue(); + coordinator.updatePosition(const Duration(milliseconds: 40000)); + + // Nothing is queued or in flight this time: the account is simply replaced + // between the start and the stop, so a target resolved at stop time would + // look perfectly valid — and would post this item to the wrong account. + final replacement = _SimklRecorder(); + simkl.rebindSession(_session(), onSessionInvalidated: () {}, httpClient: replacement.client); + + settleThrottles(); + await coordinator.pausePlayback(); + settleThrottles(); + await coordinator.stopPlayback(); + await pumpEventQueue(); + + expect(recorder.paths, ['/scrobble/start']); + expect(replacement.paths, isEmpty); + }); + + test('movies use the movie object with no episode', () async { + await coordinator.startPlayback(_movie(), _client()); + await pumpEventQueue(); + + expect(recorder.calls.single.body, { + 'progress': 0.0, + 'movie': { + 'ids': {'tmdb': 603}, + }, + }); + }); + + test('a queued stop survives the next item starting behind a gated request', () async { + await coordinator.startPlayback(_episode(durationMs: 100000), _client()); + await pumpEventQueue(); + coordinator.updatePosition(const Duration(milliseconds: 30000)); + + // Hold a request so the swap's stop and the new item's start both queue. + recorder.gate = Completer(); + settleThrottles(); + final paused = coordinator.pausePlayback(); + await pumpEventQueue(); + + final stopped = coordinator.stopPlayback(); + settleThrottles(); + final started = coordinator.startPlayback(_movie(), _client()); + + recorder.gate!.complete(); + await paused; + await stopped; + await started; + await pumpEventQueue(); + + expect(recorder.paths, ['/scrobble/start', '/scrobble/pause', '/scrobble/stop', '/scrobble/start']); + expect(recorder.calls[2].body['show'], { + 'ids': {'tvdb': 12345}, + }); + expect(recorder.calls[3].body['movie'], { + 'ids': {'tmdb': 603}, + }); + }); + + test('a repeated state inside the debounce window is sent once', () async { + await coordinator.startPlayback(_episode(durationMs: 100000), _client()); + await pumpEventQueue(); + coordinator.updatePosition(const Duration(milliseconds: 40000)); + + settleThrottles(); + await coordinator.pausePlayback(); + // Same state, same instant — the player emits several of these per seek. + await coordinator.pausePlayback(); + await pumpEventQueue(); + + expect(recorder.paths, ['/scrobble/start', '/scrobble/pause']); + }); + + test('seeking alone reports nothing', () async { + await coordinator.startPlayback(_episode(durationMs: 100000), _client()); + await pumpEventQueue(); + + coordinator.updatePosition(const Duration(milliseconds: 10000)); + coordinator.updatePosition(const Duration(milliseconds: 60000)); + coordinator.updatePosition(const Duration(milliseconds: 20000)); + await pumpEventQueue(); + + expect(recorder.paths, ['/scrobble/start']); + }); + + // Observed live: quitting seconds after an autoplayed episode opened a + // session left Simkl showing the item as playing until its runtime elapsed, + // because the terminal report was under a progress floor. + test('a session opened at zero progress is still closed on stop', () async { + await coordinator.startPlayback(_episode(durationMs: 100000), _client()); + await pumpEventQueue(); + expect(recorder.callFor('/scrobble/start').body['progress'], 0.0); + + settleThrottles(); + await coordinator.stopPlayback(); + + expect(recorder.paths, ['/scrobble/start', '/scrobble/stop']); + expect(recorder.callFor('/scrobble/stop').body['progress'], 0.0); + }); + + test('a stop with no session behind it is not sent', () async { + // No ids resolve for this item, so playback never opened a session. + await coordinator.startPlayback( + _episode(durationMs: 100000), + _FakeMediaServerClient(externalIdsByItem: const {}), + ); + coordinator.updatePosition(const Duration(milliseconds: 40000)); + await coordinator.stopPlayback(); + await pumpEventQueue(); + + expect(recorder.calls, isEmpty); + }); + + test('a disabled tracker reports nothing', () async { + await simkl.setEnabled(false); + + await coordinator.startPlayback(_episode(durationMs: 100000), _client()); + coordinator.updatePosition(const Duration(milliseconds: 40000)); + await coordinator.stopPlayback(); + await pumpEventQueue(); + + expect(recorder.calls, isEmpty); + }); + + test('a failing report never escapes to the caller', () async { + recorder.status = 500; + + await coordinator.startPlayback(_episode(durationMs: 100000), _client()); + await pumpEventQueue(); + coordinator.updatePosition(const Duration(milliseconds: 40000)); + settleThrottles(); + await coordinator.stopPlayback(); + + expect(recorder.paths, ['/scrobble/start', '/scrobble/stop']); + }); + }); +} diff --git a/test/services/trackers/tracker_coordinator_manual_test.dart b/test/services/trackers/tracker_coordinator_manual_test.dart index 5a043c6e..f03c7645 100644 --- a/test/services/trackers/tracker_coordinator_manual_test.dart +++ b/test/services/trackers/tracker_coordinator_manual_test.dart @@ -524,51 +524,85 @@ void main() { final anilist = AnilistTracker.instance; setUp(() async { - await mal.setEnabled(false); + // MAL is a threshold tracker: the crossing owns its watched write. Simkl + // is excluded from that fan-out (it reports playback in real time), so it + // stays off here — see simkl_scrobble_test.dart. + await simkl.setEnabled(false); await anilist.setEnabled(false); - await simkl.setEnabled(true); + await mal.setEnabled(true); }); tearDown(() async { coordinator.cancelInFlight(); coordinator.debugUseResolverDependencies(); - simkl.rebindSession(null, onSessionInvalidated: () {}); - await simkl.setEnabled(false); + mal.rebindSession(null, onSessionInvalidated: () {}); + await mal.setEnabled(false); }); test('marks watched at the server threshold, not the tracker default', () async { - final posts = >[]; + coordinator.debugUseResolverDependencies( + store: const _FakeFribbLookup([FribbMappingRow(tvdbId: 12345, malId: 101, tvdbSeason: 1, type: 'TV')]), + animeLists: const _FakeAnimeListsLookup(), + ); + + final updates = >[]; final httpClient = MockClient((request) async { - expect(request.method, 'POST'); - expect(request.url.path, '/sync/history'); - posts.add((json.decode(request.body) as Map).cast()); + if (request.method == 'GET') return http.Response(json.encode({'num_episodes': 1}), 200); + expect(request.method, 'PUT'); + updates.add(Uri.splitQueryString(request.body)); return http.Response('{}', 200); }); - simkl.rebindSession(_simklSession(), onSessionInvalidated: () {}, httpClient: httpClient); + mal.rebindSession(_malSession(), onSessionInvalidated: () {}, httpClient: httpClient); final client = _FakeMediaServerClient( - externalIdsByItem: {'movie-1': const ExternalIds(tmdb: 603)}, + externalIdsByItem: {'show-1': const ExternalIds(tvdb: 12345)}, descendantsByParent: const {}, watchedThreshold: 0.95, ); - await coordinator.startPlayback(_movie(), client); + // The player always hands the coordinator an episode carrying its show + // link; container paths fill it in from the parent instead. + await coordinator.startPlayback(_episode(1).copyWith(grandparentId: 'show-1'), client); coordinator.updateDuration(const Duration(seconds: 100)); // 90% — past the old hardcoded 80% tracker default but below the server's 95%. coordinator.updatePosition(const Duration(seconds: 90)); await pumpEventQueue(); - expect(posts, isEmpty); + expect(updates, isEmpty); // 95% — crosses the server threshold; fires exactly once. coordinator.updatePosition(const Duration(seconds: 95)); await pumpEventQueue(); - expect(posts, hasLength(1)); - expect(posts.single['movies'], [ - { - 'ids': {'tmdb': 603}, - }, - ]); + expect(updates, hasLength(1)); + expect(updates.single['num_watched_episodes'], '1'); + }); + + test('leaves real-time trackers out of the threshold watched write', () async { + final requests = []; + final httpClient = MockClient((request) async { + requests.add(request.url.path); + return http.Response('{}', 200); + }); + await mal.setEnabled(false); + await simkl.setEnabled(true); + simkl.rebindSession(_simklSession(), onSessionInvalidated: () {}, httpClient: httpClient); + addTearDown(() async { + simkl.rebindSession(null, onSessionInvalidated: () {}); + await simkl.setEnabled(false); + }); + + final client = _FakeMediaServerClient( + externalIdsByItem: {'movie-1': const ExternalIds(tmdb: 603)}, + descendantsByParent: const {}, + watchedThreshold: 0.9, + ); + + await coordinator.startPlayback(_movie(), client); + coordinator.updateDuration(const Duration(seconds: 100)); + coordinator.updatePosition(const Duration(seconds: 95)); + await pumpEventQueue(); + + expect(requests, isNot(contains('/sync/history'))); }); }); } diff --git a/test/services/trackers/tracker_status_ladder_test.dart b/test/services/trackers/tracker_status_ladder_test.dart index 28b81a9c..79c4a440 100644 --- a/test/services/trackers/tracker_status_ladder_test.dart +++ b/test/services/trackers/tracker_status_ladder_test.dart @@ -102,5 +102,24 @@ void main() { ), ); }); + + // Simkl documents 409 for /scrobble/stop only: the item was already marked + // watched within the last hour, which is a success for our purposes. Every + // other endpoint, scrobble or not, still treats it as a failure. + test('accepts 409 on a scrobble stop but not on start, pause or anything else', () async { + final client = SimklClient( + _session(), + onSessionInvalidated: () => fail('409 should not invalidate the session'), + httpClient: MockClient((_) async => http.Response('{"watched_at":"2026-07-30T10:30:00.000Z"}', 409)), + ); + addTearDown(client.dispose); + + await client.scrobble('stop', const {'progress': 90}, allowConflict: true); + + final conflict = isA().having((e) => e.statusCode, 'statusCode', 409); + await expectLater(client.scrobble('start', const {'progress': 0}), throwsA(conflict)); + await expectLater(client.scrobble('pause', const {'progress': 50}), throwsA(conflict)); + await expectLater(client.addToHistory(const {}), throwsA(conflict)); + }); }); }