Skip to content

Commit

Permalink
Fix several issues with enqueueing of next track (#1653)
Browse files Browse the repository at this point in the history
  • Loading branch information
marcelveldt authored Sep 8, 2024
1 parent ad5ac99 commit 9b30c40
Show file tree
Hide file tree
Showing 13 changed files with 102 additions and 130 deletions.
1 change: 1 addition & 0 deletions music_assistant/common/models/config_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion music_assistant/common/models/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,6 @@ class PlayerFeature(StrEnum):
PAUSE = "pause"
SYNC = "sync"
SEEK = "seek"
ENQUEUE_NEXT = "enqueue_next"
PLAY_ANNOUNCEMENT = "play_announcement"
UNKNOWN = "unknown"

Expand Down
137 changes: 55 additions & 82 deletions music_assistant/server/controllers/player_queues.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
ConfigEntryType,
EventType,
MediaType,
PlayerFeature,
PlayerState,
QueueOption,
RepeatMode,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"}:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
4 changes: 2 additions & 2 deletions music_assistant/server/controllers/streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
)

Expand Down
5 changes: 2 additions & 3 deletions music_assistant/server/models/player_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -333,17 +332,15 @@ 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.
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:
Expand Down
2 changes: 0 additions & 2 deletions music_assistant/server/providers/chromecast/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)

Expand Down
18 changes: 0 additions & 18 deletions music_assistant/server/providers/dlna/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
24 changes: 18 additions & 6 deletions music_assistant/server/providers/hass_players/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading

0 comments on commit 9b30c40

Please sign in to comment.