diff --git a/music_assistant/common/models/config_entries.py b/music_assistant/common/models/config_entries.py index 064ca3bed..4c2a404b3 100644 --- a/music_assistant/common/models/config_entries.py +++ b/music_assistant/common/models/config_entries.py @@ -359,6 +359,7 @@ class CoreConfig(Config): {**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, diff --git a/music_assistant/common/models/enums.py b/music_assistant/common/models/enums.py index 7229c57fe..8374afa9c 100644 --- a/music_assistant/common/models/enums.py +++ b/music_assistant/common/models/enums.py @@ -292,7 +292,6 @@ class PlayerFeature(StrEnum): PAUSE = "pause" SYNC = "sync" SEEK = "seek" - ENQUEUE_NEXT = "enqueue_next" PLAY_ANNOUNCEMENT = "play_announcement" UNKNOWN = "unknown" diff --git a/music_assistant/server/controllers/player_queues.py b/music_assistant/server/controllers/player_queues.py index 9fdc0407a..b373cbf20 100644 --- a/music_assistant/server/controllers/player_queues.py +++ b/music_assistant/server/controllers/player_queues.py @@ -19,7 +19,6 @@ ConfigEntryType, EventType, MediaType, - PlayerFeature, PlayerState, QueueOption, RepeatMode, @@ -276,7 +275,20 @@ def set_repeat(self, queue_id: str, repeat_mode: RepeatMode) -> None: 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 @@ def on_player_update( 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 @@ async def preload_next_item( 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 @@ def signal_update(self, queue_id: str, items_changed: bool = False) -> None: 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 @@ async def _fill_radio_tracks(self, queue_id: str) -> None: 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.""" diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index c505ee14c..6aed2e99f 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -338,7 +338,7 @@ async def serve_queue_item_stream(self, request: web.Request) -> web.Response: 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 @@ async def get_flow_stream( 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 ) diff --git a/music_assistant/server/models/player_provider.py b/music_assistant/server/models/player_provider.py index f16467526..534673ebc 100644 --- a/music_assistant/server/models/player_provider.py +++ b/music_assistant/server/models/player_provider.py @@ -167,9 +167,8 @@ async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: """ 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. diff --git a/music_assistant/server/providers/_template_player_provider/__init__.py b/music_assistant/server/providers/_template_player_provider/__init__.py index 268247bff..7d2cfaa9f 100644 --- a/music_assistant/server/providers/_template_player_provider/__init__.py +++ b/music_assistant/server/providers/_template_player_provider/__init__.py @@ -215,7 +215,6 @@ async def on_mdns_service_state_change( 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 @@ async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: """ 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 @@ async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: 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: diff --git a/music_assistant/server/providers/chromecast/__init__.py b/music_assistant/server/providers/chromecast/__init__.py index ca0405286..9c54500ec 100644 --- a/music_assistant/server/providers/chromecast/__init__.py +++ b/music_assistant/server/providers/chromecast/__init__.py @@ -372,7 +372,6 @@ def _on_chromecast_discovered(self, uuid, _) -> None: PlayerFeature.POWER, PlayerFeature.VOLUME_MUTE, PlayerFeature.VOLUME_SET, - PlayerFeature.ENQUEUE_NEXT, PlayerFeature.PAUSE, ), enabled_by_default=enabled_by_default, @@ -431,7 +430,6 @@ def on_new_cast_status(self, castplayer: CastPlayer, status: CastStatus) -> None castplayer.player.supported_features = ( PlayerFeature.POWER, PlayerFeature.VOLUME_SET, - PlayerFeature.ENQUEUE_NEXT, PlayerFeature.PAUSE, ) diff --git a/music_assistant/server/providers/dlna/__init__.py b/music_assistant/server/providers/dlna/__init__.py index 94901754f..7566825e1 100644 --- a/music_assistant/server/providers/dlna/__init__.py +++ b/music_assistant/server/providers/dlna/__init__.py @@ -64,20 +64,8 @@ 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 @@ async def _update_player(self, dlna_player: DLNAPlayer) -> None: 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, - ) diff --git a/music_assistant/server/providers/hass_players/__init__.py b/music_assistant/server/providers/hass_players/__init__.py index 708bb7116..15454d200 100644 --- a/music_assistant/server/providers/hass_players/__init__.py +++ b/music_assistant/server/providers/hass_players/__init__.py @@ -17,7 +17,7 @@ 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 @@ async def get_player_config_entries( """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 @@ async def _setup_player( 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: diff --git a/music_assistant/server/providers/slimproto/__init__.py b/music_assistant/server/providers/slimproto/__init__.py index f90852915..7dcbfd53f 100644 --- a/music_assistant/server/providers/slimproto/__init__.py +++ b/music_assistant/server/providers/slimproto/__init__.py @@ -465,12 +465,14 @@ async def _handle_play_url( 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 @@ async def _handle_player_update(self, slimplayer: SlimClient) -> None: 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 @@ async def _handle_player_update(self, slimplayer: SlimClient) -> None: # 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 diff --git a/music_assistant/server/providers/sonos/__init__.py b/music_assistant/server/providers/sonos/__init__.py index ab8d8334d..1e8b03fe5 100644 --- a/music_assistant/server/providers/sonos/__init__.py +++ b/music_assistant/server/providers/sonos/__init__.py @@ -65,7 +65,6 @@ PLAYER_FEATURES_BASE = { PlayerFeature.SYNC, PlayerFeature.VOLUME_MUTE, - PlayerFeature.ENQUEUE_NEXT, PlayerFeature.PAUSE, } diff --git a/music_assistant/server/providers/sonos_s1/__init__.py b/music_assistant/server/providers/sonos_s1/__init__.py index b4ffeef0f..7f388f3f3 100644 --- a/music_assistant/server/providers/sonos_s1/__init__.py +++ b/music_assistant/server/providers/sonos_s1/__init__.py @@ -55,7 +55,6 @@ PLAYER_FEATURES = ( PlayerFeature.SYNC, PlayerFeature.VOLUME_MUTE, - PlayerFeature.ENQUEUE_NEXT, PlayerFeature.PAUSE, ) @@ -179,7 +178,12 @@ async def get_player_config_entries( 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, diff --git a/music_assistant/server/providers/sonos_s1/player.py b/music_assistant/server/providers/sonos_s1/player.py index 6732a3ab7..92d5016a7 100644 --- a/music_assistant/server/providers/sonos_s1/player.py +++ b/music_assistant/server/providers/sonos_s1/player.py @@ -47,7 +47,6 @@ PlayerFeature.SYNC, PlayerFeature.VOLUME_MUTE, PlayerFeature.VOLUME_SET, - PlayerFeature.ENQUEUE_NEXT, ) DURATION_SECONDS = "duration_in_s" POSITION_SECONDS = "position_in_s"