Skip to content

Commit 586e48f

Browse files
fix: prevent stale Plex stop from killing auto-next playback
Race condition: when a track ends naturally and auto-next fires, the subscriber may report STOPPED to Plex before the state updates to TRANSITIONING. Plex then sends a redundant stop command that kills the auto-next playback. Two-pronged fix: 1. Set no_notice=True and _auto_next_in_flight=True IMMEDIATELY in check_auto_next() (on the state thread) before scheduling auto_next on the main loop. This prevents the subscriber from sending the STOPPED timeline to Plex. 2. Guard stop() to ignore non-forced stops while _auto_next_in_flight is True, catching cases where the subscriber notification was already in-flight when no_notice was set.
1 parent 059838d commit 586e48f

1 file changed

Lines changed: 48 additions & 14 deletions

File tree

plex/adapters.py

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,8 @@ def __init__(self, dlna, query: QueryParams = None):
508508
self._last_operation_finish_time: Optional[float] = None
509509
self._post_operation_protection_window = 2.0 # seconds
510510
self._last_finished_target_uri: Optional[str] = None
511+
self._in_false_stop_recovery = False
512+
self._auto_next_in_flight = False
511513
# Premature STOPPED filtering (LMS-uPnP #63, go2tv #43)
512514
self._seen_playing_since_operation = False
513515
self._operation_start_time: Optional[float] = None
@@ -519,6 +521,7 @@ async def _with_no_notice(self, coro):
519521
await coro
520522
finally:
521523
self.no_notice = False
524+
self._auto_next_in_flight = False
522525

523526
def _start_transport_operation(self, target_uri: str) -> int:
524527
self._operation_sequence += 1
@@ -609,6 +612,10 @@ def _check_post_operation_false_stop(self, changed_state: DotMap) -> bool:
609612
When detected, it triggers a retry of the current track.
610613
Returns True if false stop was detected and handled, False otherwise.
611614
"""
615+
# Don't re-trigger while a recovery is already in progress
616+
if self._in_false_stop_recovery:
617+
return False
618+
612619
# Only check for PLAYING -> STOPPED transitions
613620
if 'state' not in changed_state:
614621
return False
@@ -640,8 +647,9 @@ def _check_post_operation_false_stop(self, changed_state: DotMap) -> bool:
640647
# Clear the finish time to prevent infinite retry loops
641648
self._last_operation_finish_time = None
642649

643-
# Suppress auto-next during recovery
650+
# Suppress auto-next during recovery and guard against re-trigger
644651
self._suppress_auto_next = True
652+
self._in_false_stop_recovery = True
645653

646654
# Schedule recovery: replay the current track
647655
async def recover_playback():
@@ -653,6 +661,7 @@ async def recover_playback():
653661
logger.info("%s recovery: no queue, cannot replay", self.dlna.name)
654662
finally:
655663
self._suppress_auto_next = False
664+
self._in_false_stop_recovery = False
656665

657666
asyncio.run_coroutine_threadsafe(recover_playback(), self.loop)
658667
return True
@@ -706,11 +715,19 @@ async def auto_next():
706715
self.current_track_info.duration // 1000 * 1000 <= changed.elapsed <= self.current_track_info.duration):
707716
logger.info("auto next stopped %s, elapsed: %s -> %s, %s",
708717
self.state.state, changed.old.elapsed, changed.elapsed, self.current_track_info.duration)
718+
# Set no_notice and auto_next flag IMMEDIATELY (before scheduling)
719+
# to prevent the subscriber from reporting STOPPED to Plex,
720+
# which would cause Plex to send a stale stop command that
721+
# kills the auto-next playback.
722+
self.no_notice = True
723+
self._auto_next_in_flight = True
709724
self.state.update(state="TRANSITIONING", uri=None)
710725
asyncio.run_coroutine_threadsafe(self._with_no_notice(auto_next()), self.loop)
711726
return True
712727
elif not changed.uri and changed.old.state == "PLAYING" and changed.state == "STOPPED" and self.state.current_track_duration - self.state.elapsed <= 1:
713728
logger.info("auto next transitioning %s %s", changed.old.state, changed.state)
729+
self.no_notice = True
730+
self._auto_next_in_flight = True
714731
self.state.update(state="TRANSITIONING", uri=None)
715732
asyncio.run_coroutine_threadsafe(self._with_no_notice(auto_next()), self.loop)
716733
return True
@@ -748,19 +765,25 @@ def state_changed_callback(self, changed_state: DotMap):
748765
return
749766
elif changed_state.current_uri and changed_state.current_uri != self._active_target_uri:
750767
if changed_state.old.get('current_uri') == self._active_target_uri:
751-
# Sonos reverted from our target URI - device likely rejected it
752-
# Check if current track is playable before trying to restore
753-
# This is a fallback in case transcoding fails or other issues occur
754-
if hasattr(self, 'current_track_info') and self.current_track_info:
755-
if not self.queue.is_track_playable(self.current_track_info):
756-
logger.warning("%s device rejected track even after transcode attempt, skipping to next", self.dlna.name)
757-
# Cancel the active operation and skip to next track
758-
self._active_operation_id = None
759-
self._active_operation_event = None
760-
self._transport_state_override = None
761-
# Schedule next() to run in the event loop
762-
asyncio.run_coroutine_threadsafe(self.next(), self.loop)
763-
return
768+
if self._active_operation_uri_confirmed:
769+
# Device previously confirmed our target URI, then reverted —
770+
# genuine rejection. Check if the original track was already
771+
# un-playable (high-bitrate) to decide whether to skip.
772+
if hasattr(self, 'current_track_info') and self.current_track_info:
773+
if not self.queue.is_track_playable(self.current_track_info):
774+
logger.warning("%s device rejected track even after transcode attempt, skipping to next", self.dlna.name)
775+
# Cancel the active operation and skip to next track
776+
self._active_operation_id = None
777+
self._active_operation_event = None
778+
self._transport_state_override = None
779+
# Schedule next() to run in the event loop
780+
asyncio.run_coroutine_threadsafe(self.next(), self.loop)
781+
return
782+
else:
783+
# URI was never confirmed by the device — this "reversion"
784+
# is a phantom from our internal state update racing with
785+
# the polling cycle. Restore the target and keep waiting.
786+
logger.debug("%s ignoring phantom URI reversion (device never confirmed target)", self.dlna.name)
764787
logger.debug("%s reverting URI %s -> restoring target %s", self.dlna.name, changed_state.current_uri, self._active_target_uri)
765788
self.state.update(uri=self._active_target_uri)
766789
return
@@ -981,6 +1004,14 @@ async def play(self):
9811004
self.state.check_all_next_loop = True
9821005

9831006
async def stop(self, *, force: bool = False):
1007+
# Guard against stale Plex stop commands during auto-next transition.
1008+
# When a track ends naturally and auto-next fires, there's a race:
1009+
# the subscriber may report STOPPED to Plex before the state updates
1010+
# to TRANSITIONING, causing Plex to send a redundant stop that kills
1011+
# the auto-next playback.
1012+
if not force and self._auto_next_in_flight:
1013+
logger.info("%s ignoring stale stop command during auto-next transition", self.dlna.name)
1014+
return
9841015
controller = self.virtual_controller()
9851016
if controller is not None and not force:
9861017
logger.info("%s stop request rerouted to virtual device %s", self.dlna.name, controller.name)
@@ -991,6 +1022,9 @@ async def stop(self, *, force: bool = False):
9911022
if active_id:
9921023
self._finish_transport_operation(active_id)
9931024
self._suppress_auto_next = True
1025+
# Intentional stop: clear post-operation timestamp so false-STOP
1026+
# detection does not misinterpret the resulting STOPPED event
1027+
self._last_operation_finish_time = None
9941028
self.state.update(state="STOPPED", uri=None)
9951029
self.current_track_info = None
9961030
if force:

0 commit comments

Comments
 (0)