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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user