From f87d6e5a52613ba9466d36d7e6b70d0301bc741b Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:12:36 +0200 Subject: [PATCH] fix(player): keep paused server sessions alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../video_player/parts/playback_services.dart | 6 ++- lib/services/playback_progress_tracker.dart | 42 +++++++++---------- lib/services/plex_client.dart | 14 +++++++ 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 47cb84c2..97349024 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -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(); + 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 diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index 5c3ddbe0..db6b175e 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -63,6 +63,13 @@ class PlaybackProgressTracker { /// un-scrobble the primary item. final Future 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 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()); } }); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 3b62fe78..3da0dc1f 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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 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 removeFromOnDeck(String ratingKey) async {