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
+28
View File
@@ -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,
);
});
}