@@ -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