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
306 lines
9.5 KiB
Dart
306 lines
9.5 KiB
Dart
import 'dart:async';
|
|
|
|
import '../media/media_server_client.dart';
|
|
import '../media/playback_report_metadata.dart';
|
|
|
|
enum _PlaybackReportState { idle, starting, started, stopping, stopFailed, stopped }
|
|
|
|
class _PendingProgressReport {
|
|
_PendingProgressReport(this.snapshot);
|
|
|
|
final PlaybackReportSnapshot snapshot;
|
|
final Completer<bool> completer = Completer<bool>();
|
|
|
|
void complete(bool value) {
|
|
if (!completer.isCompleted) completer.complete(value);
|
|
}
|
|
|
|
void completeError(Object error, StackTrace stackTrace) {
|
|
if (!completer.isCompleted) completer.completeError(error, stackTrace);
|
|
}
|
|
}
|
|
|
|
class PlaybackStreamSelection {
|
|
final String? mediaSourceId;
|
|
final int? audioStreamIndex;
|
|
final int? subtitleStreamIndex;
|
|
|
|
const PlaybackStreamSelection({this.mediaSourceId, this.audioStreamIndex, this.subtitleStreamIndex});
|
|
|
|
static const none = PlaybackStreamSelection();
|
|
}
|
|
|
|
typedef PlaybackStreamSelectionResolver = FutureOr<PlaybackStreamSelection> Function();
|
|
|
|
class PlaybackReportSnapshot {
|
|
final String state;
|
|
final Duration position;
|
|
final Duration duration;
|
|
final PlaybackReportMetadata report;
|
|
final PlaybackStreamSelectionResolver resolveStreamSelection;
|
|
|
|
const PlaybackReportSnapshot({
|
|
required this.state,
|
|
required this.position,
|
|
required this.duration,
|
|
this.report = const PlaybackReportMetadata.live(),
|
|
this.resolveStreamSelection = _noStreamSelection,
|
|
});
|
|
|
|
bool get isStopped => state == 'stopped';
|
|
|
|
static PlaybackStreamSelection _noStreamSelection() => PlaybackStreamSelection.none;
|
|
}
|
|
|
|
/// Serializes backend playback-report calls for one media item.
|
|
///
|
|
/// This class owns the start/progress/stop lifecycle invariants. Callers may
|
|
/// fire reports concurrently, but state changes are recorded synchronously
|
|
/// before any async work such as settings lookup, track mapping, or HTTP calls.
|
|
class PlaybackReportSession {
|
|
PlaybackReportSession({
|
|
required this.client,
|
|
required this.itemId,
|
|
this.playSessionId,
|
|
this.playMethod,
|
|
this.liveStreamId,
|
|
this.onDelivered,
|
|
});
|
|
|
|
final MediaServerClient client;
|
|
final String itemId;
|
|
final String? playSessionId;
|
|
final String? playMethod;
|
|
final String? liveStreamId;
|
|
|
|
/// Invoked with each snapshot the backend actually received, right after its
|
|
/// report call returns.
|
|
///
|
|
/// Coalesced and superseded snapshots never reach this: [report] resolving
|
|
/// `true` is not delivery. A same-state heartbeat arriving while the start
|
|
/// report is in flight is dropped by [_reportProgress] yet still completes
|
|
/// its future, so callers that need to know what the server saw — such as
|
|
/// watched-threshold crossing detection — must key on this instead.
|
|
final void Function(PlaybackReportSnapshot snapshot)? onDelivered;
|
|
|
|
_PlaybackReportState _state = _PlaybackReportState.idle;
|
|
PlaybackReportSnapshot? _startSnapshot;
|
|
_PendingProgressReport? _pendingProgress;
|
|
Future<void>? _pumpFuture;
|
|
Future<void>? _stopFuture;
|
|
bool _resetAfterStopRequested = false;
|
|
|
|
bool get isIdle => _state == _PlaybackReportState.idle;
|
|
|
|
bool get isStopped => _state == _PlaybackReportState.stopped;
|
|
|
|
void resetAfterStop() {
|
|
if (_state == _PlaybackReportState.stopping) {
|
|
_resetAfterStopRequested = true;
|
|
return;
|
|
}
|
|
if (_state == _PlaybackReportState.stopped || _state == _PlaybackReportState.stopFailed) {
|
|
_state = _PlaybackReportState.idle;
|
|
_startSnapshot = null;
|
|
_resetAfterStopRequested = false;
|
|
_discardPendingProgress();
|
|
_pumpFuture = null;
|
|
_stopFuture = null;
|
|
}
|
|
}
|
|
|
|
bool get _isStoppingOrTerminal =>
|
|
_state == _PlaybackReportState.stopping ||
|
|
_state == _PlaybackReportState.stopFailed ||
|
|
_state == _PlaybackReportState.stopped;
|
|
|
|
Future<bool> report(PlaybackReportSnapshot snapshot) {
|
|
return snapshot.isStopped ? _reportStopped(snapshot) : _reportProgress(snapshot);
|
|
}
|
|
|
|
Future<bool> _reportProgress(PlaybackReportSnapshot snapshot) {
|
|
if (_isStoppingOrTerminal) {
|
|
return Future.value(false);
|
|
}
|
|
|
|
switch (_state) {
|
|
case _PlaybackReportState.idle:
|
|
_state = _PlaybackReportState.starting;
|
|
_startSnapshot = snapshot;
|
|
return _ensurePump().then((_) => true);
|
|
case _PlaybackReportState.starting:
|
|
// A duplicate playing heartbeat during startup does not need an
|
|
// immediate progress ping; a state change (playing -> paused) does.
|
|
if (_startSnapshot?.state != snapshot.state) {
|
|
return _setPendingProgress(snapshot);
|
|
}
|
|
final pump = _pumpFuture;
|
|
return (pump ?? Future<void>.value()).then((_) => true);
|
|
case _PlaybackReportState.started:
|
|
final pending = _setPendingProgress(snapshot);
|
|
_ensurePump();
|
|
return pending;
|
|
case _PlaybackReportState.stopping:
|
|
case _PlaybackReportState.stopFailed:
|
|
case _PlaybackReportState.stopped:
|
|
return Future.value(false);
|
|
}
|
|
}
|
|
|
|
Future<bool> _reportStopped(PlaybackReportSnapshot snapshot) {
|
|
if (_state == _PlaybackReportState.stopped) {
|
|
return Future.value(false);
|
|
}
|
|
if (_state == _PlaybackReportState.stopping) {
|
|
final pending = _stopFuture;
|
|
return (pending ?? Future<void>.value()).then((_) => false);
|
|
}
|
|
|
|
_state = _PlaybackReportState.stopping;
|
|
_discardPendingProgress();
|
|
final stopFuture = _runStop(snapshot);
|
|
_stopFuture = stopFuture;
|
|
return stopFuture.then((_) => true);
|
|
}
|
|
|
|
Future<void> _ensurePump() {
|
|
final existing = _pumpFuture;
|
|
if (existing != null) return existing;
|
|
|
|
final future = _runPump();
|
|
_pumpFuture = future;
|
|
future.then(
|
|
(_) {
|
|
if (identical(_pumpFuture, future)) _pumpFuture = null;
|
|
},
|
|
onError: (Object error, StackTrace stackTrace) {
|
|
if (identical(_pumpFuture, future)) _pumpFuture = null;
|
|
},
|
|
);
|
|
return future;
|
|
}
|
|
|
|
Future<void> _runPump() async {
|
|
try {
|
|
final start = _startSnapshot;
|
|
if (start != null) {
|
|
await _sendStarted(start);
|
|
_startSnapshot = null;
|
|
if (_state == _PlaybackReportState.starting) {
|
|
_state = _PlaybackReportState.started;
|
|
}
|
|
}
|
|
|
|
while (_state == _PlaybackReportState.started) {
|
|
final progress = _pendingProgress;
|
|
if (progress == null) break;
|
|
_pendingProgress = null;
|
|
try {
|
|
progress.complete(await _sendProgress(progress.snapshot));
|
|
} catch (e, st) {
|
|
progress.completeError(e, st);
|
|
rethrow;
|
|
}
|
|
}
|
|
} catch (_) {
|
|
if (_state == _PlaybackReportState.starting) {
|
|
_state = _PlaybackReportState.idle;
|
|
_startSnapshot = null;
|
|
}
|
|
_discardPendingProgress();
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> _runStop(PlaybackReportSnapshot snapshot) async {
|
|
var stopSucceeded = false;
|
|
try {
|
|
final pump = _pumpFuture;
|
|
if (pump != null) {
|
|
try {
|
|
await pump;
|
|
} catch (_) {
|
|
// Stop is terminal and best-effort. A failed start/progress report
|
|
// must not prevent the final stopped position from being reported.
|
|
}
|
|
}
|
|
await _sendStopped(snapshot);
|
|
stopSucceeded = true;
|
|
} finally {
|
|
final shouldReset = _resetAfterStopRequested;
|
|
_resetAfterStopRequested = false;
|
|
_stopFuture = null;
|
|
_discardPendingProgress();
|
|
_pumpFuture = null;
|
|
_state = shouldReset
|
|
? _PlaybackReportState.idle
|
|
: stopSucceeded
|
|
? _PlaybackReportState.stopped
|
|
: _PlaybackReportState.stopFailed;
|
|
}
|
|
}
|
|
|
|
Future<bool> _setPendingProgress(PlaybackReportSnapshot snapshot) {
|
|
_discardPendingProgress();
|
|
final pending = _PendingProgressReport(snapshot);
|
|
_pendingProgress = pending;
|
|
return pending.completer.future;
|
|
}
|
|
|
|
void _discardPendingProgress() {
|
|
final pending = _pendingProgress;
|
|
if (pending == null) return;
|
|
_pendingProgress = null;
|
|
pending.complete(false);
|
|
}
|
|
|
|
Future<void> _sendStarted(PlaybackReportSnapshot snapshot) async {
|
|
final selection = await snapshot.resolveStreamSelection();
|
|
await client.reportPlaybackStarted(
|
|
itemId: itemId,
|
|
position: snapshot.position,
|
|
duration: snapshot.duration,
|
|
playSessionId: playSessionId,
|
|
playMethod: playMethod,
|
|
liveStreamId: liveStreamId,
|
|
mediaSourceId: selection.mediaSourceId,
|
|
audioStreamIndex: selection.audioStreamIndex,
|
|
subtitleStreamIndex: selection.subtitleStreamIndex,
|
|
);
|
|
onDelivered?.call(snapshot);
|
|
}
|
|
|
|
Future<bool> _sendProgress(PlaybackReportSnapshot snapshot) async {
|
|
final selection = await snapshot.resolveStreamSelection();
|
|
if (_state != _PlaybackReportState.started) return false;
|
|
await client.reportPlaybackProgress(
|
|
itemId: itemId,
|
|
position: snapshot.position,
|
|
duration: snapshot.duration,
|
|
isPaused: snapshot.state == 'paused',
|
|
playSessionId: playSessionId,
|
|
playMethod: playMethod,
|
|
liveStreamId: liveStreamId,
|
|
mediaSourceId: selection.mediaSourceId,
|
|
audioStreamIndex: selection.audioStreamIndex,
|
|
subtitleStreamIndex: selection.subtitleStreamIndex,
|
|
);
|
|
onDelivered?.call(snapshot);
|
|
return true;
|
|
}
|
|
|
|
Future<void> _sendStopped(PlaybackReportSnapshot snapshot) async {
|
|
final selection = await snapshot.resolveStreamSelection();
|
|
await client.reportPlaybackStopped(
|
|
itemId: itemId,
|
|
position: snapshot.position,
|
|
duration: snapshot.duration,
|
|
playSessionId: playSessionId,
|
|
liveStreamId: liveStreamId,
|
|
mediaSourceId: selection.mediaSourceId,
|
|
report: snapshot.report,
|
|
);
|
|
onDelivered?.call(snapshot);
|
|
}
|
|
}
|