Plezy reported a completed playback twice: the /:/timeline heartbeats let the server mark the item played on its own, and the in-player auto-scrobble then sent an explicit /:/scrobble for the same watch. On PMS 1.30 that adds a second Play History row; on 1.43 the row is suppressed but viewCount still lands on 2 for one playback. Measured against PMS 1.43 to find what the server acts on: a watched-threshold crossing observed inside one session. Consecutive above-threshold reports mark nothing, a resume point left by an earlier session does not arm a new one, and a report at position zero is inert while one at a single second is enough. So the explicit mark now goes out only for sessions that gave the server no crossing to observe. That decision cannot be made while the session is live. A session beginning past the threshold has no crossing yet, but rewinding and playing forward creates one, and the server records it — marking eagerly and then hitting that path leaves viewCount at 2 again. The mark is therefore deferred to the terminal stop, and rides its future so callers that await the stop before tearing the player down do not drop it. Deferring also covers a crossing coalesced away during startup and a seek back below the threshold before stopping. Crossing state is tracked from reports the backend actually received rather than from PlaybackReportSession.report()'s bool, which resolves true for a same-state snapshot dropped during startup. The same-file sibling hook (#1500) still runs exactly once, on the transition to a settled mark rather than at the local crossing, so sibling episodes are never marked watched while the episode actually played is not. Local watched state and Continue Watching removal still happen on the observed crossing, so the only behaviour that moves is the redundant server call. close #1740
214 lines
8.5 KiB
Dart
214 lines
8.5 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/media/media_server_client.dart';
|
|
import 'package:plezy/services/playback_report_session.dart';
|
|
|
|
import '../test_helpers/playback_report_fakes.dart';
|
|
|
|
class _RecordingClient with PlaybackReportRecorder implements MediaServerClient {
|
|
final calls = <String>[];
|
|
Completer<void>? startGate;
|
|
Completer<void>? stopGate;
|
|
bool failNextStop = false;
|
|
|
|
@override
|
|
Future<void> onPlaybackReport(PlaybackReportCall call) async {
|
|
final positionMs = call.position.inMilliseconds;
|
|
switch (call.kind) {
|
|
case PlaybackReportKind.started:
|
|
final start = startGate;
|
|
if (start != null) await start.future;
|
|
calls.add('started:$positionMs:${call.mediaSourceId}:${call.audioStreamIndex}:${call.subtitleStreamIndex}');
|
|
case PlaybackReportKind.progress:
|
|
calls.add('${call.isPaused ? 'paused' : 'playing'}:$positionMs');
|
|
case PlaybackReportKind.stopped:
|
|
calls.add('stopped-attempt:$positionMs:${call.mediaSourceId}');
|
|
final stop = stopGate;
|
|
if (stop != null) await stop.future;
|
|
if (failNextStop) {
|
|
failNextStop = false;
|
|
throw StateError('stop failed');
|
|
}
|
|
calls.add('stopped:$positionMs:${call.mediaSourceId}');
|
|
}
|
|
}
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
PlaybackReportSnapshot _snapshot(
|
|
String state, {
|
|
int positionMs = 1000,
|
|
PlaybackStreamSelectionResolver resolveStreamSelection = _noStreamSelection,
|
|
}) {
|
|
return PlaybackReportSnapshot(
|
|
state: state,
|
|
position: Duration(milliseconds: positionMs),
|
|
duration: const Duration(minutes: 1),
|
|
resolveStreamSelection: resolveStreamSelection,
|
|
);
|
|
}
|
|
|
|
PlaybackStreamSelection _noStreamSelection() => PlaybackStreamSelection.none;
|
|
|
|
void main() {
|
|
test('orders stopped after start even when stream selection is still resolving', () async {
|
|
final client = _RecordingClient();
|
|
final session = PlaybackReportSession(client: client, itemId: 'item-1');
|
|
final selectionGate = Completer<PlaybackStreamSelection>();
|
|
|
|
final startFuture = session.report(_snapshot('playing', resolveStreamSelection: () => selectionGate.future));
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
final stopFuture = session.report(_snapshot('stopped', positionMs: 5000));
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(client.calls, isEmpty);
|
|
|
|
selectionGate.complete(const PlaybackStreamSelection(mediaSourceId: 'source-1', audioStreamIndex: 2));
|
|
await stopFuture;
|
|
await startFuture;
|
|
|
|
expect(client.calls, ['started:1000:source-1:2:null', 'stopped-attempt:5000:null', 'stopped:5000:null']);
|
|
});
|
|
|
|
test('coalesces duplicate starts while start report is in flight', () async {
|
|
final client = _RecordingClient()..startGate = Completer<void>();
|
|
final session = PlaybackReportSession(client: client, itemId: 'item-1');
|
|
|
|
final first = session.report(_snapshot('playing', positionMs: 1000));
|
|
final second = session.report(_snapshot('playing', positionMs: 2000));
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(client.calls, isEmpty);
|
|
|
|
client.startGate!.complete();
|
|
await Future.wait([first, second]);
|
|
|
|
expect(client.calls, ['started:1000:null:null:null']);
|
|
});
|
|
|
|
test('coalesces a state change during start into one progress report after start', () async {
|
|
final client = _RecordingClient()..startGate = Completer<void>();
|
|
final session = PlaybackReportSession(client: client, itemId: 'item-1');
|
|
|
|
final first = session.report(_snapshot('playing', positionMs: 1000));
|
|
final second = session.report(_snapshot('paused', positionMs: 2000));
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
client.startGate!.complete();
|
|
await Future.wait([first, second]);
|
|
|
|
expect(client.calls, ['started:1000:null:null:null', 'paused:2000']);
|
|
});
|
|
|
|
test('terminal stop suppresses in-flight progress after its stream selection resolves', () async {
|
|
final client = _RecordingClient();
|
|
final session = PlaybackReportSession(client: client, itemId: 'item-1');
|
|
await session.report(_snapshot('playing', positionMs: 1000));
|
|
client.calls.clear();
|
|
|
|
final selectionGate = Completer<PlaybackStreamSelection>();
|
|
final progressFuture = session.report(
|
|
_snapshot('playing', positionMs: 2000, resolveStreamSelection: () => selectionGate.future),
|
|
);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
final stopFuture = session.report(_snapshot('stopped', positionMs: 3000));
|
|
selectionGate.complete(PlaybackStreamSelection.none);
|
|
await stopFuture;
|
|
expect(await progressFuture, isFalse);
|
|
|
|
expect(client.calls, ['stopped-attempt:3000:null', 'stopped:3000:null']);
|
|
});
|
|
|
|
test('queued progress resolves false when terminal stop suppresses it during startup', () async {
|
|
final client = _RecordingClient()..startGate = Completer<void>();
|
|
final session = PlaybackReportSession(client: client, itemId: 'item-1');
|
|
|
|
final startFuture = session.report(_snapshot('playing', positionMs: 1000));
|
|
final progressFuture = session.report(_snapshot('paused', positionMs: 2000));
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
final stopFuture = session.report(_snapshot('stopped', positionMs: 3000));
|
|
client.startGate!.complete();
|
|
|
|
await stopFuture;
|
|
await startFuture;
|
|
|
|
expect(await progressFuture, isFalse);
|
|
expect(client.calls, ['started:1000:null:null:null', 'stopped-attempt:3000:null', 'stopped:3000:null']);
|
|
});
|
|
|
|
test('stop failure allows explicit stopped retry but ignores non-stop reports', () async {
|
|
final client = _RecordingClient()..failNextStop = true;
|
|
final session = PlaybackReportSession(client: client, itemId: 'item-1');
|
|
|
|
await expectLater(session.report(_snapshot('stopped', positionMs: 1000)), throwsStateError);
|
|
await session.report(_snapshot('playing', positionMs: 2000));
|
|
await session.report(_snapshot('stopped', positionMs: 3000));
|
|
|
|
expect(client.calls, ['stopped-attempt:1000:null', 'stopped-attempt:3000:null', 'stopped:3000:null']);
|
|
});
|
|
|
|
test('resetAfterStop during in-flight stop reopens reporting after stop completes', () async {
|
|
final client = _RecordingClient()..stopGate = Completer<void>();
|
|
final session = PlaybackReportSession(client: client, itemId: 'item-1');
|
|
|
|
await session.report(_snapshot('playing', positionMs: 1000));
|
|
client.calls.clear();
|
|
|
|
final stopFuture = session.report(_snapshot('stopped', positionMs: 3000));
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(client.calls, ['stopped-attempt:3000:null']);
|
|
|
|
session.resetAfterStop();
|
|
client.stopGate!.complete();
|
|
await stopFuture;
|
|
|
|
expect(session.isIdle, isTrue);
|
|
expect(await session.report(_snapshot('playing', positionMs: 4000)), isTrue);
|
|
expect(client.calls, ['stopped-attempt:3000:null', 'stopped:3000:null', 'started:4000:null:null:null']);
|
|
});
|
|
|
|
test('onDelivered fires only for snapshots the backend actually received', () async {
|
|
// Callers that must know what the server saw — watched-threshold crossing
|
|
// detection (#1740) — cannot use report()'s bool: a same-state heartbeat
|
|
// arriving while the start report is in flight resolves true but is
|
|
// coalesced away.
|
|
final client = _RecordingClient()..startGate = Completer<void>();
|
|
final delivered = <int>[];
|
|
final session = PlaybackReportSession(
|
|
client: client,
|
|
itemId: 'item-1',
|
|
onDelivered: (snapshot) => delivered.add(snapshot.position.inMilliseconds),
|
|
);
|
|
|
|
final first = session.report(_snapshot('playing', positionMs: 1000));
|
|
final second = session.report(_snapshot('playing', positionMs: 2000));
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
client.startGate!.complete();
|
|
expect(await Future.wait([first, second]), [true, true]);
|
|
|
|
expect(client.calls, ['started:1000:null:null:null']);
|
|
expect(delivered, [1000], reason: 'the 2000ms snapshot was dropped, so it was never delivered');
|
|
});
|
|
|
|
test('onDelivered reports progress and stopped snapshots as they are sent', () async {
|
|
final client = _RecordingClient();
|
|
final delivered = <String>[];
|
|
final session = PlaybackReportSession(
|
|
client: client,
|
|
itemId: 'item-1',
|
|
onDelivered: (snapshot) => delivered.add('${snapshot.state}:${snapshot.position.inMilliseconds}'),
|
|
);
|
|
|
|
await session.report(_snapshot('playing', positionMs: 1000));
|
|
await session.report(_snapshot('paused', positionMs: 2000));
|
|
await session.report(_snapshot('stopped', positionMs: 3000));
|
|
|
|
expect(delivered, ['playing:1000', 'paused:2000', 'stopped:3000']);
|
|
});
|
|
}
|