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
53 lines
1.6 KiB
Dart
53 lines
1.6 KiB
Dart
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/services/app_exit_service.dart';
|
|
|
|
void main() {
|
|
test('desktop exit uses the required application exit API', () async {
|
|
ui.AppExitType? requestedType;
|
|
int? requestedCode;
|
|
|
|
expect(
|
|
await AppExitService.requestExit(
|
|
exitApplicationForTesting: (exitType, exitCode) async {
|
|
requestedType = exitType;
|
|
requestedCode = exitCode;
|
|
return ui.AppExitResponse.exit;
|
|
},
|
|
),
|
|
isTrue,
|
|
);
|
|
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,
|
|
);
|
|
});
|
|
}
|