fix(player): keep paused server sessions alive

Report the paused timeline every tick (~10s, matching official clients)
instead of every ~60s, and ping the Plex transcoder keepalive endpoint
alongside it while transcoding — PMS reaps idle transcode sessions that
timeline reports alone historically have not kept alive. Prevents the
transcode-variant of the #1520 stream death; the reporter's direct-play
case is covered by EOF classification and reconnect.
This commit is contained in:
edde746
2026-07-10 19:12:36 +02:00
parent c885c0c6bc
commit f87d6e5a52
3 changed files with 40 additions and 22 deletions
@@ -256,15 +256,19 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
// play queue holding the siblings is created fire-and-forget and may
// not exist yet when the tracker is wired.
final playbackState = context.read<PlaybackStateProvider>();
final effectivePlayMethod = playMethod ?? (_isTranscoding ? 'Transcode' : 'DirectPlay');
_progressTracker = PlaybackProgressTracker(
client: mediaClient,
metadata: metadata,
player: currentPlayer,
offlineWatchService: offlineWatchService,
queueOnOnlineFailure: _playbackContext?.shouldQueueOnReportFailure ?? _usesLocalPlaybackSource,
playMethod: playMethod ?? (_isTranscoding ? 'Transcode' : 'DirectPlay'),
playMethod: effectivePlayMethod,
playSessionId: playSessionId,
mediaInfo: mediaInfo,
onPausedKeepalive: mediaClient is PlexClient && effectivePlayMethod == 'Transcode'
? () => mediaClient.pingTranscodeSession(_playbackTranscodeSessionId)
: null,
onScrobbled: () async {
// Other episodes of a Plex multi-episode file share this item's
// part — watching the file watched them too (#1500). Reusing
+21 -21
View File
@@ -63,6 +63,13 @@ class PlaybackProgressTracker {
/// un-scrobble the primary item.
final Future<void> Function()? onScrobbled;
/// Invoked on every paused progress tick. The player wires this to the
/// Plex transcoder keepalive ping (`/video/:/transcode/universal/ping`) —
/// timeline reports alone historically have not been enough to stop PMS
/// from reaping an idle transcode, so official Plex clients send both
/// while paused. Best-effort; failures are the callee's to swallow.
final Future<void> Function()? onPausedKeepalive;
/// Timer for periodic progress updates
Timer? _progressTimer;
@@ -77,9 +84,6 @@ class PlaybackProgressTracker {
/// Timer ticks to skip before retrying after failures (exponential backoff).
int _ticksToSkip = 0;
/// Counts timer ticks while paused to send periodic "paused" heartbeats.
int _pausedTickCounter = 0;
/// Whether we've already scrobbled (marked as watched) for this playback session.
bool _scrobbled = false;
@@ -105,6 +109,7 @@ class PlaybackProgressTracker {
this.playSessionId,
this.mediaInfo,
this.onScrobbled,
this.onPausedKeepalive,
this.updateInterval = const Duration(seconds: 10),
}) : assert(!isOffline || offlineWatchService != null, 'offlineWatchService is required when isOffline is true'),
assert(isOffline || client != null, 'client is required when isOffline is false'),
@@ -137,27 +142,22 @@ class PlaybackProgressTracker {
}
_progressTimer = Timer.periodic(updateInterval, (timer) {
// Skip ticks when backing off after consecutive failures to avoid
// flooding the network with doomed requests during an outage.
if (_ticksToSkip > 0) {
_ticksToSkip--;
return;
}
if (player.state.isActive) {
_pausedTickCounter = 0;
// Skip ticks when backing off after consecutive failures to avoid
// flooding the network with doomed requests during an outage.
if (_ticksToSkip > 0) {
_ticksToSkip--;
return;
}
_sendProgress('playing');
} else {
// Send periodic "paused" updates to keep the server session alive
// (~60s with default 10s interval)
_pausedTickCounter++;
if (_pausedTickCounter >= 6) {
_pausedTickCounter = 0;
if (_ticksToSkip > 0) {
_ticksToSkip--;
return;
}
_sendProgress('paused');
}
// Report every tick while paused too — official clients do the
// same (~10s); the timeline heartbeat is what keeps the server
// session and its transcoder from being reaped during a long
// pause (#1520).
_sendProgress('paused');
final keepalive = onPausedKeepalive;
if (keepalive != null) unawaited(keepalive());
}
});
+14
View File
@@ -1682,6 +1682,20 @@ class PlexClient
throwIfHttpError(response);
}
/// Keep a paused transcode session alive. Timeline updates alone have not
/// historically stopped PMS from reaping an idle transcoder, so Plex
/// clients send this alongside every paused timeline (see OpenPHT's
/// SendTranscoderPing). [transcodeSessionId] is the `session` param the
/// transcode was started with. Best-effort: a failed ping must never
/// disturb playback, so errors are logged and swallowed.
Future<void> pingTranscodeSession(String transcodeSessionId) async {
try {
await _http.get('/video/:/transcode/universal/ping', queryParameters: {'session': transcodeSessionId});
} catch (e) {
appLogger.d('Transcode keepalive ping failed', error: e);
}
}
/// Remove item from Continue Watching (On Deck) without affecting watch status or progress
/// This uses the same endpoint Plex Web uses to hide items from Continue Watching
Future<void> removeFromOnDeck(String ratingKey) async {