Files
plezy/lib/services/playback_report_session.dart
T
edde746 9c08c78f6d fix(plex): stop recording a second play when the server already logged one
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
2026-08-02 16:16:19 +02:00

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);
}
}