Fix several issues with enqueueing of next track (#1653)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sun, 8 Sep 2024 12:31:44 +0000 (14:31 +0200)
committerGitHub <noreply@github.com>
Sun, 8 Sep 2024 12:31:44 +0000 (14:31 +0200)
13 files changed:
music_assistant/common/models/config_entries.py
music_assistant/common/models/enums.py
music_assistant/server/controllers/player_queues.py
music_assistant/server/controllers/streams.py
music_assistant/server/models/player_provider.py
music_assistant/server/providers/_template_player_provider/__init__.py
music_assistant/server/providers/chromecast/__init__.py
music_assistant/server/providers/dlna/__init__.py
music_assistant/server/providers/hass_players/__init__.py
music_assistant/server/providers/slimproto/__init__.py
music_assistant/server/providers/sonos/__init__.py
music_assistant/server/providers/sonos_s1/__init__.py
music_assistant/server/providers/sonos_s1/player.py

index 064ca3bedd6fb5cda43fc7a1469e0f9daeeb2e64..4c2a404b32a1a3d4ff3890c1edc0f98efd09b641 100644 (file)
@@ -359,6 +359,7 @@ CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED = ConfigEntry.from_dict(
     {**CONF_ENTRY_FLOW_MODE.to_dict(), "default_value": False, "value": False, "hidden": True}
 )
 
+
 CONF_ENTRY_AUTO_PLAY = ConfigEntry(
     key=CONF_AUTO_PLAY,
     type=ConfigEntryType.BOOLEAN,
index 7229c57fe89a804a7604537edfbc78668bcebcc7..8374afa9c97debac1a057860945c6a070e675dc8 100644 (file)
@@ -292,7 +292,6 @@ class PlayerFeature(StrEnum):
     PAUSE = "pause"
     SYNC = "sync"
     SEEK = "seek"
-    ENQUEUE_NEXT = "enqueue_next"
     PLAY_ANNOUNCEMENT = "play_announcement"
     UNKNOWN = "unknown"
 
index 9fdc0407a0b333022170515ba8fb197ce932b2c0..b373cbf209bce93d900eb57dd20cb505a5878dfe 100644 (file)
@@ -19,7 +19,6 @@ from music_assistant.common.models.enums import (
     ConfigEntryType,
     EventType,
     MediaType,
-    PlayerFeature,
     PlayerState,
     QueueOption,
     RepeatMode,
@@ -276,7 +275,20 @@ class PlayerQueuesController(CoreController):
         if queue.repeat_mode == repeat_mode:
             return  # no change
         queue.repeat_mode = repeat_mode
+        # ensure that we restart playback or trigger enqueue next if repeat mode changed
         self.signal_update(queue_id)
+        if (
+            repeat_mode == RepeatMode.ONE
+            and queue.flow_mode
+            and queue.state == PlayerState.PLAYING
+            and queue.current_index != queue.index_in_buffer
+        ):
+            # edge case; repeat one enabled in flow mode but the
+            # flow stream had already loaded a new item in the buffer,
+            # we need to restart playback
+            self.mass.create_task(self.resume(queue_id))
+        else:
+            self.mass.create_task(self._enqueue_next(queue, queue.current_index))
 
     @api_command("player_queues/play_media")
     async def play_media(
@@ -955,9 +967,18 @@ class PlayerQueuesController(CoreController):
         elif prev_state["current_index"] != new_state["current_index"]:
             queue.end_of_track_reached = False
 
-        # handle enqueuing of next item to play
-        if not queue.flow_mode or queue.stream_finished:
-            self._check_enqueue_next(player, queue, prev_state, new_state)
+        # handle auto restart of queue in flow mode when repeat is enabled
+        if (
+            queue.flow_mode
+            and queue.repeat_mode != RepeatMode.OFF
+            and queue.stream_finished
+            and prev_state["state"] == PlayerState.PLAYING
+            and new_state["state"] == PlayerState.IDLE
+        ):
+            # flow mode and repeat mode is on, restart the queue
+            next_index = self._get_next_index(queue_id, queue.current_index, allow_repeat=True)
+            if next_index is not None:
+                self.mass.create_task(self.play_index(queue_id, next_index))
 
         # do not send full updates if only time was updated
         if changed_keys == {"elapsed_time"}:
@@ -1064,6 +1085,20 @@ class PlayerQueuesController(CoreController):
             raise QueueEmpty("No more (playable) tracks left in the queue.")
         return next_item
 
+    def track_loaded_in_buffer(self, queue_id: str, item_id: str) -> None:
+        """Call when a player has (started) loading a track in the buffer."""
+        queue = self.get(queue_id)
+        if not queue:
+            msg = f"PlayerQueue {queue_id} is not available"
+            raise PlayerUnavailableError(msg)
+        queue.index_in_buffer = self.index_by_id(queue_id, item_id)
+        if queue.flow_mode:
+            return  # nothing to do when flow mode is active
+        self.signal_update(queue_id)
+        # enqueue the next track as soon as the player reports
+        # it has started buffering the given queue item
+        self.mass.create_task(self._enqueue_next(queue, item_id))
+
     # Main queue manipulation methods
 
     def load(
@@ -1131,6 +1166,13 @@ class PlayerQueuesController(CoreController):
                     base_key=queue_id,
                 )
             )
+            # signal preload of next item (to ensure the player loads the correct next item)
+            if queue.index_in_buffer is not None:
+                task_id = f"enqueue_next_{queue.queue_id}"
+                self.mass.call_later(
+                    1, self._enqueue_next(queue, queue.index_in_buffer), task_id=task_id
+                )
+
         # always send the base event
         self.mass.signal_event(EventType.QUEUE_UPDATED, object_id=queue_id, data=queue)
         # save state
@@ -1221,88 +1263,19 @@ class PlayerQueuesController(CoreController):
         await asyncio.sleep(5)
         setattr(self, debounce_key, None)
 
-    def _check_enqueue_next(
-        self,
-        player: Player,
-        queue: PlayerQueue,
-        prev_state: CompareState,
-        new_state: CompareState,
-    ) -> None:
-        """Check if we need to enqueue the next item to the player itself."""
-        if not queue.active:
-            return
-        if prev_state["state"] != PlayerState.PLAYING:
-            return
+    async def _enqueue_next(self, queue: PlayerQueue, current_index: int | str) -> None:
+        """Enqueue the next item in the queue."""
         if (player := self.mass.players.get(queue.queue_id)) and player.announcement_in_progress:
             self.logger.warning("Ignore queue command: An announcement is in progress")
             return
-        current_item = self.get_item(queue.queue_id, queue.current_index)
-        if not current_item:
-            return  # guard, just in case something bad happened
-        if not current_item.duration:
-            return
-        # NOTE: 'seconds_streamed' can actually be 0 if there was a stream error!
-        if current_item.streamdetails and current_item.streamdetails.seconds_streamed is not None:
-            duration = current_item.streamdetails.seconds_streamed
-        else:
-            duration = current_item.duration
-        seconds_remaining = int(duration - player.corrected_elapsed_time)
-
-        async def _enqueue_next(current_index: int, supports_enqueue: bool = False) -> None:
-            if (
-                player := self.mass.players.get(queue.queue_id)
-            ) and player.announcement_in_progress:
-                self.logger.warning("Ignore queue command: An announcement is in progress")
-                return
-            with suppress(QueueEmpty):
-                next_item = await self.preload_next_item(queue.queue_id, current_index)
-                if supports_enqueue:
-                    await self.mass.players.enqueue_next_media(
-                        player_id=player.player_id,
-                        media=self.player_media_from_queue_item(next_item, queue.flow_mode),
-                    )
-                    return
-                await self.play_index(queue.queue_id, next_item.queue_item_id)
-
-        # handle queue fully played - clear it completely once the player stopped
-        if (
-            queue.stream_finished
-            and queue.state == PlayerState.IDLE
-            and self._get_next_index(queue.queue_id, queue.current_index) is None
-        ):
-            self.logger.debug("End of queue reached for %s", queue.display_name)
-            self.clear(queue.queue_id)
-            return
-
-        # handle native enqueue next support of player
-        if PlayerFeature.ENQUEUE_NEXT in player.supported_features:
-            # we enqueue the next track after a new track
-            # has started playing and (repeat) before the current track ends
-            new_track_started = (
-                new_state["state"] == PlayerState.PLAYING
-                and prev_state["current_index"] != new_state["current_index"]
+        if isinstance(current_index, str):
+            current_index = self.index_by_id(queue.queue_id, current_index)
+        with suppress(QueueEmpty):
+            next_item = await self.preload_next_item(queue.queue_id, current_index)
+            await self.mass.players.enqueue_next_media(
+                player_id=player.player_id,
+                media=self.player_media_from_queue_item(next_item, queue.flow_mode),
             )
-            if (
-                new_track_started
-                or seconds_remaining == 15
-                or int(player.corrected_elapsed_time) == 1
-            ):
-                self.mass.create_task(_enqueue_next(queue.current_index, True))
-            return
-
-        # player does not support enqueue next feature.
-        # we wait for the player to stop after it reaches the end of the track
-        if (
-            (not queue.flow_mode or queue.repeat_mode in (RepeatMode.ALL, RepeatMode.ONE))
-            # we have a couple of guards here to prevent the player starting
-            # playback again when its stopped outside of MA's control
-            and queue.stream_finished
-            and queue.end_of_track_reached
-            and queue.state == PlayerState.IDLE
-        ):
-            queue.stream_finished = None
-            self.mass.create_task(_enqueue_next(queue.current_index, False))
-            return
 
     async def _get_radio_tracks(self, queue_id: str) -> list[MediaItemType]:
         """Call the registered music providers for dynamic tracks."""
index c505ee14c868ee674fbdae633e69243d9a23d00f..6aed2e99f81ba4bfa523a8f9e4324638307ef714 100644 (file)
@@ -338,7 +338,7 @@ class StreamsController(CoreController):
             queue_item.uri,
             queue.display_name,
         )
-        queue.index_in_buffer = self.mass.player_queues.index_by_id(queue_id, queue_item_id)
+        self.mass.player_queues.track_loaded_in_buffer(queue_id, queue_item_id)
         pcm_format = AudioFormat(
             content_type=ContentType.from_bit_depth(output_format.bit_depth),
             sample_rate=queue_item.streamdetails.audio_format.sample_rate,
@@ -617,7 +617,7 @@ class StreamsController(CoreController):
                 queue_track.name,
                 queue.display_name,
             )
-            queue.index_in_buffer = self.mass.player_queues.index_by_id(
+            self.mass.player_queues.track_loaded_in_buffer(
                 queue.queue_id, queue_track.queue_item_id
             )
 
index f16467526925c9b2f6aa375e3bc5d69599e8b339..534673ebcdac6ab5a296f5441242f6b7c69036bc 100644 (file)
@@ -167,9 +167,8 @@ class PlayerProvider(Provider):
         """
         Handle enqueuing of the next (queue) item on the player.
 
-        Only called if the player supports PlayerFeature.ENQUE_NEXT.
-        Called about 1 second after a new track started playing.
-        Called about 15 seconds before the end of the current track.
+        Called when player reports it started buffering a queue item
+        and when the queue items updated.
 
         A PlayerProvider implementation is in itself responsible for handling this
         so that the queue items keep playing until its empty or the player stopped.
index 268247bff9d4354ce1287e48cc6a108a7ca0af56..7d2cfaa9f57afd096861a7e4594a528eddd36acd 100644 (file)
@@ -215,7 +215,6 @@ class MyDemoPlayerprovider(PlayerProvider):
                 PlayerFeature.VOLUME_SET,
                 PlayerFeature.VOLUME_MUTE,
                 PlayerFeature.PLAY_ANNOUNCEMENT,  # see play_announcement method
-                PlayerFeature.ENQUEUE_NEXT,  # see play_media/enqueue_next_media methods
             ),
         )
         # register the player with the player manager
@@ -333,9 +332,8 @@ class MyDemoPlayerprovider(PlayerProvider):
         """
         Handle enqueuing of the next (queue) item on the player.
 
-        Only called if the player supports PlayerFeature.ENQUE_NEXT.
-        Called about 1 second after a new track started playing.
-        Called about 15 seconds before the end of the current track.
+        Called when player reports it started buffering a queue item
+        and when the queue items updated.
 
         A PlayerProvider implementation is in itself responsible for handling this
         so that the queue items keep playing until its empty or the player stopped.
@@ -343,7 +341,6 @@ class MyDemoPlayerprovider(PlayerProvider):
         This will NOT be called if the end of the queue is reached (and repeat disabled).
         This will NOT be called if the player is using flow mode to playback the queue.
         """
-        # OPTIONAL - required only if you specified PlayerFeature.ENQUEUE_NEXT
         # this method should handle the enqueuing of the next queue item on the player.
 
     async def cmd_sync(self, player_id: str, target_player: str) -> None:
index ca040528696d6dadccafcb3fce3d126af2fa98a1..9c54500ec99ff7f1037930d5830b8c1a88e1193a 100644 (file)
@@ -372,7 +372,6 @@ class ChromecastProvider(PlayerProvider):
                         PlayerFeature.POWER,
                         PlayerFeature.VOLUME_MUTE,
                         PlayerFeature.VOLUME_SET,
-                        PlayerFeature.ENQUEUE_NEXT,
                         PlayerFeature.PAUSE,
                     ),
                     enabled_by_default=enabled_by_default,
@@ -431,7 +430,6 @@ class ChromecastProvider(PlayerProvider):
             castplayer.player.supported_features = (
                 PlayerFeature.POWER,
                 PlayerFeature.VOLUME_SET,
-                PlayerFeature.ENQUEUE_NEXT,
                 PlayerFeature.PAUSE,
             )
 
index 94901754f49e5503775fee2a9287bc21d5c8ff38..7566825e1d4060cd8c4c5ceaa67385da8fe51f8d 100644 (file)
@@ -64,20 +64,8 @@ BASE_PLAYER_FEATURES = (
     PlayerFeature.VOLUME_SET,
 )
 
-CONF_ENQUEUE_NEXT = "enqueue_next"
-
 
 PLAYER_CONFIG_ENTRIES = (
-    ConfigEntry(
-        key=CONF_ENQUEUE_NEXT,
-        type=ConfigEntryType.BOOLEAN,
-        label="Player supports enqueue next/gapless",
-        default_value=False,
-        description="If the player supports enqueuing the next item for fluid/gapless playback. "
-        "\n\nUnfortunately this feature is missing or broken on many DLNA players. \n"
-        "Enable it with care. If music stops after one song, "
-        "disable this setting (and use flow-mode instead).",
-    ),
     CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
     CONF_ENTRY_CROSSFADE_DURATION,
     CONF_ENTRY_ENFORCE_MP3,
@@ -627,9 +615,3 @@ class DLNAPlayerProvider(PlayerProvider):
     def _set_player_features(self, dlna_player: DLNAPlayer) -> None:
         """Set Player Features based on config values and capabilities."""
         dlna_player.player.supported_features = BASE_PLAYER_FEATURES
-        player_id = dlna_player.player.player_id
-        if self.mass.config.get_raw_player_config_value(player_id, CONF_ENQUEUE_NEXT, False):
-            dlna_player.player.supported_features = (
-                *dlna_player.player.supported_features,
-                PlayerFeature.ENQUEUE_NEXT,
-            )
index 708bb711622ca2f207f3aea3680ac4da68cca377..15454d200d8b94f4d264cef7271c36b659ab964d 100644 (file)
@@ -17,7 +17,7 @@ from music_assistant.common.models.config_entries import (
     CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
     CONF_ENTRY_ENABLE_ICY_METADATA,
     CONF_ENTRY_ENFORCE_MP3_DEFAULT_ENABLED,
-    CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED,
+    CONF_ENTRY_FLOW_MODE_ENFORCED,
     CONF_ENTRY_HTTP_PROFILE,
     ConfigEntry,
     ConfigValueOption,
@@ -120,6 +120,16 @@ async def _get_hass_media_players(
         yield state
 
 
+async def _get_hass_media_player(
+    hass_prov: HomeAssistantProvider, entity_id: str
+) -> HassState | None:
+    """Return Hass state object for a single media_player entity."""
+    for state in await hass_prov.hass.get_states():
+        if state["entity_id"] == entity_id:
+            return state
+    return None
+
+
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
@@ -198,9 +208,13 @@ class HomeAssistantPlayers(PlayerProvider):
         """Return all (provider/player specific) Config Entries for the given player (if any)."""
         entries = await super().get_player_config_entries(player_id)
         entries = entries + PLAYER_CONFIG_ENTRIES
-        if player := self.mass.players.get(player_id):
-            if PlayerFeature.ENQUEUE_NEXT not in player.supported_features:
-                entries += (CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED,)
+        if hass_state := await _get_hass_media_player(self.hass_prov, player_id):
+            hass_supported_features = MediaPlayerEntityFeature(
+                hass_state["attributes"]["supported_features"]
+            )
+            if MediaPlayerEntityFeature.MEDIA_ENQUEUE not in hass_supported_features:
+                entries += (CONF_ENTRY_FLOW_MODE_ENFORCED,)
+
         return entries
 
     async def cmd_stop(self, player_id: str) -> None:
@@ -371,8 +385,6 @@ class HomeAssistantPlayers(PlayerProvider):
             supported_features.append(PlayerFeature.SYNC)
         if MediaPlayerEntityFeature.PAUSE in hass_supported_features:
             supported_features.append(PlayerFeature.PAUSE)
-        if MediaPlayerEntityFeature.MEDIA_ENQUEUE in hass_supported_features:
-            supported_features.append(PlayerFeature.ENQUEUE_NEXT)
         if MediaPlayerEntityFeature.VOLUME_SET in hass_supported_features:
             supported_features.append(PlayerFeature.VOLUME_SET)
         if MediaPlayerEntityFeature.VOLUME_MUTE in hass_supported_features:
index f908529155ab7c35b80b5486d0939704649dd71f..7dcbfd53faa27ad624744b3060e15c004876c6bc 100644 (file)
@@ -465,12 +465,14 @@ class SlimprotoProvider(PlayerProvider):
             transition_duration = 0
 
         metadata = {
-            "item_id": media.queue_item_id or media.uri,
+            "item_id": media.uri,
             "title": media.title,
             "album": media.album,
             "artist": media.artist,
             "image_url": media.image_url,
             "duration": media.duration,
+            "queue_id": media.queue_id,
+            "queue_item_id": media.queue_item_id,
         }
         queue = self.mass.player_queues.get(media.queue_id or player_id)
         slimplayer.extra_data["playlist repeat"] = REPEATMODE_MAP[queue.repeat_mode]
@@ -643,7 +645,6 @@ class SlimprotoProvider(PlayerProvider):
                     PlayerFeature.VOLUME_SET,
                     PlayerFeature.PAUSE,
                     PlayerFeature.VOLUME_MUTE,
-                    PlayerFeature.ENQUEUE_NEXT,
                 ),
                 can_sync_with=tuple(
                     x.player_id for x in self.slimproto.players if x.player_id != player_id
@@ -653,11 +654,19 @@ class SlimprotoProvider(PlayerProvider):
 
         # update player state on player events
         player.available = True
-        player.current_item_id = (
-            slimplayer.current_media.metadata.get("item_id")
-            if slimplayer.current_media and slimplayer.current_media.metadata
-            else slimplayer.current_url
-        )
+        if slimplayer.current_media and (metadata := slimplayer.current_media.metadata):
+            player.current_media = PlayerMedia(
+                uri=metadata.get("item_id"),
+                title=metadata.get("title"),
+                album=metadata.get("album"),
+                artist=metadata.get("artist"),
+                image_url=metadata.get("image_url"),
+                duration=metadata.get("duration"),
+                queue_id=metadata.get("queue_id"),
+                queue_item_id=metadata.get("queue_item_id"),
+            )
+        else:
+            player.current_media = None
         player.active_source = player.player_id
         player.name = slimplayer.name
         player.powered = slimplayer.powered
index ab8d8334d6656daafea1b55d4b8ae208764013b4..1e8b03fe54ab7755254fe03dfecec237205f4e02 100644 (file)
@@ -65,7 +65,6 @@ PLAYBACK_STATE_MAP = {
 PLAYER_FEATURES_BASE = {
     PlayerFeature.SYNC,
     PlayerFeature.VOLUME_MUTE,
-    PlayerFeature.ENQUEUE_NEXT,
     PlayerFeature.PAUSE,
 }
 
index b4ffeef0f5db00dc1a1216492f50ed3c452910dc..7f388f3f3275a222416a1f10183c9e20b3d7bb24 100644 (file)
@@ -55,7 +55,6 @@ if TYPE_CHECKING:
 PLAYER_FEATURES = (
     PlayerFeature.SYNC,
     PlayerFeature.VOLUME_MUTE,
-    PlayerFeature.ENQUEUE_NEXT,
     PlayerFeature.PAUSE,
 )
 
@@ -179,7 +178,12 @@ class SonosPlayerProvider(PlayerProvider):
         base_entries = await super().get_player_config_entries(player_id)
         if not (self.sonosplayers.get(player_id)):
             # most probably a syncgroup
-            return (*base_entries, CONF_ENTRY_CROSSFADE, CONF_ENTRY_ENFORCE_MP3)
+            return (
+                *base_entries,
+                CONF_ENTRY_CROSSFADE,
+                CONF_ENTRY_ENFORCE_MP3,
+                CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
+            )
         return (
             *base_entries,
             CONF_ENTRY_CROSSFADE,
index 6732a3ab7288bdc626ae325690bb10b658324c58..92d5016a718a1be92bb4d62e85affb9669456d1a 100644 (file)
@@ -47,7 +47,6 @@ PLAYER_FEATURES = (
     PlayerFeature.SYNC,
     PlayerFeature.VOLUME_MUTE,
     PlayerFeature.VOLUME_SET,
-    PlayerFeature.ENQUEUE_NEXT,
 )
 DURATION_SECONDS = "duration_in_s"
 POSITION_SECONDS = "position_in_s"