From: Marcel van der Veldt Date: Tue, 16 Dec 2025 01:20:03 +0000 (+0100) Subject: Fix player.current_media callback for players X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=f30b7523990746d9f92dec534984fb290c3debd4;p=music-assistant-server.git Fix player.current_media callback for players --- diff --git a/music_assistant/controllers/player_queues.py b/music_assistant/controllers/player_queues.py index 9499243a..34bb5136 100644 --- a/music_assistant/controllers/player_queues.py +++ b/music_assistant/controllers/player_queues.py @@ -2075,9 +2075,13 @@ class PlayerQueuesController(CoreController): object_id=queue_id, data=queue.elapsed_time, ) + # also signal update to the player itself so it can update its current_media + self.mass.players.trigger_player_update(queue_id) if send_update: self.signal_update(queue_id) + # also signal update to the player itself so it can update its current_media + self.mass.players.trigger_player_update(queue_id) # store the new state if queue.active: diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index dcc00923..46709419 100644 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -707,6 +707,12 @@ class Player(ABC): return player.player_id return None + def _on_player_media_updated(self) -> None: # noqa: B027 + """Handle callback when the current media of the player is updated.""" + # optional callback for players that want to be informed when the final + # current media is updated (after applying group/sync membership logic). + # for instance to update any display information on the physical player. + # DO NOT OVERWRITE BELOW ! # These properties and methods are either managed by core logic or they # are used to perform a very specific function. Overwriting these may @@ -1029,7 +1035,11 @@ class Player(ABC): # clear the dict for the cached properties self._cache.clear() # calculate the new state + prev_media_checksum = self._get_player_media_checksum() changed_values = self.__calculate_state() + if prev_media_checksum != self._get_player_media_checksum(): + # current media changed, call the media updated callback + self._on_player_media_updated() # ignore some values that are not relevant for the state changed_values.pop("elapsed_time_last_updated", None) changed_values.pop("extra_attributes.seq_no", None) @@ -1196,6 +1206,15 @@ class Player(ABC): ), ] + def _get_player_media_checksum(self) -> str: + """Return a checksum for the current media.""" + if not (media := self.current_media): + return "" + return ( + f"{media.uri}|{media.title}|{media.source_id}|{media.queue_item_id}|" + f"{media.image_url}|{media.duration}|{media.elapsed_time}" + ) + def __calculate_state( self, ) -> dict[str, tuple[Any, Any]]: diff --git a/music_assistant/providers/airplay/player.py b/music_assistant/providers/airplay/player.py index 62ceb459..18599f14 100644 --- a/music_assistant/providers/airplay/player.py +++ b/music_assistant/providers/airplay/player.py @@ -540,6 +540,16 @@ class AirPlayPlayer(Player): # always update the state after modifying group members self.update_state() + def _on_player_media_updated(self) -> None: + """Handle callback when the current media of the player is updated.""" + if not self.stream or not self.stream.running or not self.stream.session: + return + metadata = self.current_media + if not metadata: + return + progress = int(metadata.corrected_elapsed_time or 0) + self.mass.create_task(self.stream.send_metadata(progress, metadata)) + def update_volume_from_device(self, volume: int) -> None: """Update volume from device feedback.""" ignore_volume_report = ( diff --git a/music_assistant/providers/airplay/stream_session.py b/music_assistant/providers/airplay/stream_session.py index 2a81a7c9..de209122 100644 --- a/music_assistant/providers/airplay/stream_session.py +++ b/music_assistant/providers/airplay/stream_session.py @@ -30,7 +30,6 @@ from .protocols.raop import RaopStream if TYPE_CHECKING: from music_assistant_models.media_items import AudioFormat - from music_assistant_models.player import PlayerMedia from .player import AirPlayPlayer from .provider import AirPlayProvider @@ -217,8 +216,6 @@ class AirPlayStreamSession: async def _audio_streamer(self, audio_source: AsyncGenerator[bytes, None]) -> None: """Stream audio to all players.""" - _last_metadata: str | None = None - prev_progress_report: float = 0.0 pcm_sample_size = self.pcm_format.pcm_sample_size stream_start_time = time.time() first_chunk_received = False @@ -277,27 +274,6 @@ class AirPlayStreamSession: chunk_seconds = len(chunk) / pcm_sample_size self.seconds_streamed += chunk_seconds - # send metadata if changed - # do this in a separate task to not disturb audio streaming - # NOTE: we should probably move this out of the audio stream task into it's own task - metadata: PlayerMedia | None - if ( - self.sync_clients - and (_leader := self.sync_clients[0]) - and (_leader.corrected_elapsed_time or 0) > 2 - and (metadata := _leader.current_media) is not None - ): - now = time.time() - metadata_checksum = f"{metadata.uri}.{metadata.title}.{metadata.image_url}" - progress = int(metadata.corrected_elapsed_time or 0) - if _last_metadata != metadata_checksum: - _last_metadata = metadata_checksum - prev_progress_report = now - self.mass.create_task(self._send_metadata(progress, metadata)) - # send the progress report every 5 seconds - elif now - prev_progress_report >= 5: - prev_progress_report = now - self.mass.create_task(self._send_metadata(progress, None)) # Entire stream consumed: send EOF self.prov.logger.debug("Audio source stream exhausted") async with self._lock: @@ -343,18 +319,6 @@ class AirPlayStreamSession: await ffmpeg.wait_with_timeout(30) del ffmpeg - async def _send_metadata(self, progress: int | None, metadata: PlayerMedia | None) -> None: - """Send metadata to all players.""" - async with self._lock: - await asyncio.gather( - *[ - x.stream.send_metadata(progress, metadata) - for x in self.sync_clients - if x.stream and x.stream.running - ], - return_exceptions=True, - ) - async def _prepare_client(self, airplay_player: AirPlayPlayer) -> None: """Prepare stream for a single client.""" # Stop existing stream if running diff --git a/music_assistant/providers/chromecast/player.py b/music_assistant/providers/chromecast/player.py index 82a931f4..dcdef068 100644 --- a/music_assistant/providers/chromecast/player.py +++ b/music_assistant/providers/chromecast/player.py @@ -12,6 +12,7 @@ from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption if TYPE_CHECKING: from music_assistant_models.config_entries import ConfigValueType from music_assistant_models.event import MassEvent + from music_assistant_models.enums import ( ConfigEntryType, EventType, @@ -27,11 +28,7 @@ from pychromecast.controllers.media import STREAM_TYPE_BUFFERED, STREAM_TYPE_LIV from pychromecast.controllers.multizone import MultizoneController from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED -from music_assistant.constants import ( - ATTR_ANNOUNCEMENT_IN_PROGRESS, - MASS_LOGO_ONLINE, - VERBOSE_LOG_LEVEL, -) +from music_assistant.constants import MASS_LOGO_ONLINE, VERBOSE_LOG_LEVEL from music_assistant.models.player import DeviceInfo, Player, PlayerMedia from .constants import ( @@ -434,10 +431,6 @@ class ChromecastPlayer(Player): # send queue info to the CC media_controller = self.cc.media_controller await asyncio.to_thread(media_controller.send_message, data=queuedata, inc_session_id=True) - if media.media_type in (MediaType.RADIO, MediaType.FLOW_STREAM): - # in flow/radio mode we want to update the metadata more frequently - # so we can show the current track info - self._attr_poll_interval = 2 async def enqueue_next_media(self, media: PlayerMedia) -> None: """Handle enqueuing of the next item on the player.""" @@ -485,7 +478,6 @@ class ChromecastPlayer(Player): if (now - self.last_poll) >= 60: self.last_poll = now await asyncio.to_thread(self.cc.media_controller.update_status) - await self.update_flow_metadata() except ConnectionResetError as err: raise PlayerUnavailableError from err @@ -499,10 +491,9 @@ class ChromecastPlayer(Player): self.status_listener.invalidate() self.status_listener = None - async def update_flow_metadata(self) -> None: - """Update the metadata of a cast player running the flow (or radio) stream.""" + def _on_player_media_updated(self) -> None: + """Handle callback when the current media of the player is updated.""" if not self.powered: - self._attr_poll_interval = 300 return if not self.cc.media_controller.status.player_is_playing: return @@ -510,8 +501,6 @@ class ChromecastPlayer(Player): return if self.playback_state != PlaybackState.PLAYING: return - if self.extra_attributes.get(ATTR_ANNOUNCEMENT_IN_PROGRESS): - return if not (current_media := self.current_media): return if not ( @@ -524,62 +513,68 @@ class ChromecastPlayer(Player): ): # only update metadata for streams without known duration return - self._attr_poll_interval = 2 - media_controller = self.cc.media_controller - # update metadata of current item chromecast - title = current_media.title or "Music Assistant" - artist = current_media.artist or "" - album = current_media.album or "" - image_url = current_media.image_url or MASS_LOGO_ONLINE - flow_meta_checksum = f"{current_media.uri}-{album}-{artist}-{title}-{image_url}" - if self.flow_meta_checksum != flow_meta_checksum: - # only update if something changed - self.flow_meta_checksum = flow_meta_checksum - queuedata = { - "type": "PLAY", - "mediaSessionId": media_controller.status.media_session_id, - "customData": { - "metadata": { - "metadataType": 3, - "albumName": album, - "songName": title, - "artist": artist, - "title": title, - "images": [{"url": image_url}], - } - }, - } - await asyncio.to_thread( - media_controller.send_message, data=queuedata, inc_session_id=True - ) - if len(getattr(media_controller.status, "items", [])) < 2: - # In flow mode, all queue tracks are sent to the player as continuous stream. - # add a special 'command' item to the queue - # this allows for on-player next buttons/commands to still work - cmd_next_url = self.mass.streams.get_command_url(self.player_id, "next") - msg = { - "type": "QUEUE_INSERT", - "mediaSessionId": media_controller.status.media_session_id, - "items": [ - { - "media": { - "contentId": cmd_next_url, - "customData": { - "uri": cmd_next_url, - "queue_item_id": cmd_next_url, + async def update_flow_metadata() -> None: + """Update the metadata of a cast player running the flow (or radio) stream.""" + media_controller = self.cc.media_controller + # update metadata of current item chromecast + title = current_media.title or "Music Assistant" + artist = current_media.artist or "" + album = current_media.album or "" + image_url = current_media.image_url or MASS_LOGO_ONLINE + flow_meta_checksum = f"{current_media.uri}-{album}-{artist}-{title}-{image_url}" + if self.flow_meta_checksum != flow_meta_checksum: + # only update if something changed + self.flow_meta_checksum = flow_meta_checksum + queuedata = { + "type": "PLAY", + "mediaSessionId": media_controller.status.media_session_id, + "customData": { + "metadata": { + "metadataType": 3, + "albumName": album, + "songName": title, + "artist": artist, + "title": title, + "images": [{"url": image_url}], + } + }, + } + await asyncio.to_thread( + media_controller.send_message, data=queuedata, inc_session_id=True + ) + + if len(getattr(media_controller.status, "items", [])) < 2: + # In flow mode, all queue tracks are sent to the player as continuous stream. + # add a special 'command' item to the queue + # this allows for on-player next buttons/commands to still work + cmd_next_url = self.mass.streams.get_command_url(self.player_id, "next") + msg = { + "type": "QUEUE_INSERT", + "mediaSessionId": media_controller.status.media_session_id, + "items": [ + { + "media": { + "contentId": cmd_next_url, + "customData": { + "uri": cmd_next_url, + "queue_item_id": cmd_next_url, + }, + "contentType": "audio/flac", + "streamType": STREAM_TYPE_LIVE, + "metadata": {}, }, - "contentType": "audio/flac", - "streamType": STREAM_TYPE_LIVE, - "metadata": {}, - }, - "autoplay": True, - "startTime": 0, - "preloadTime": 0, - } - ], - } - await asyncio.to_thread(media_controller.send_message, data=msg, inc_session_id=True) + "autoplay": True, + "startTime": 0, + "preloadTime": 0, + } + ], + } + await asyncio.to_thread( + media_controller.send_message, data=msg, inc_session_id=True + ) + + self.mass.create_task(update_flow_metadata()) async def _launch_app(self) -> None: """Launch the default Media Receiver App on a Chromecast.""" diff --git a/music_assistant/providers/sendspin/player.py b/music_assistant/providers/sendspin/player.py index 587ce7f7..2ba68770 100644 --- a/music_assistant/providers/sendspin/player.py +++ b/music_assistant/providers/sendspin/player.py @@ -33,7 +33,6 @@ from music_assistant_models.config_entries import ConfigEntry from music_assistant_models.constants import PLAYER_CONTROL_NONE from music_assistant_models.enums import ( ContentType, - EventType, ImageType, PlaybackState, PlayerFeature, @@ -75,7 +74,7 @@ SUPPORTED_GROUP_COMMANDS = [ if TYPE_CHECKING: from aiosendspin.server.client import SendspinClient from music_assistant_models.config_entries import ConfigValueType - from music_assistant_models.event import MassEvent + from music_assistant_models.player_queue import PlayerQueue from music_assistant_models.queue_item import QueueItem from .provider import SendspinProvider @@ -228,12 +227,6 @@ class SendspinPlayer(Player): self._attr_volume_level = player_client.volume self._attr_volume_muted = player_client.muted self._attr_available = True - self._on_unload_callbacks.append( - self.mass.subscribe( - self._on_queue_update, - (EventType.QUEUE_UPDATED), - ) - ) self.is_web_player = sendspin_client.name.startswith( "Music Assistant Web (" # The regular Web Interface ) or sendspin_client.name.startswith( @@ -537,71 +530,58 @@ class SendspinPlayer(Player): # Clear artist artwork if none available await self.api.group.set_media_art(None, source=ArtworkSource.ARTIST) - async def _on_queue_update(self, event: MassEvent) -> None: - """Extract and send current media metadata to sendspin players on queue updates.""" + def _on_player_media_updated(self) -> None: + """Handle callback when the current media of the player is updated.""" if self.synced_to is not None: # Only leader sends metadata return - queue = self.mass.player_queues.get_active_queue(self.player_id) - if not queue or not queue.current_item: - # Clear metadata when queue has no current item + + if self.current_media is None: + # Clear metadata when no media loaded self.api.group.set_metadata(Metadata()) return + self.mass.create_task(self.send_current_media_metadata()) - current_item = queue.current_item - - title = current_item.name - artist = None - album_artist = None - album = None - track = None - artwork_url = None - year = None - - if (streamdetails := current_item.streamdetails) and streamdetails.stream_title: - # stream title/metadata from radio/live stream - if " - " in streamdetails.stream_title: - artist, title = streamdetails.stream_title.split(" - ", 1) - else: - title = streamdetails.stream_title - artist = "" - # set album to radio station name - album = current_item.name - elif media_item := current_item.media_item: - title = media_item.name - if artist_str := getattr(media_item, "artist_str", None): - artist = artist_str - if _album := getattr(media_item, "album", None): - album = _album.name - year = getattr(_album, "year", None) - album_artist = getattr(_album, "artist_str", None) - if _track_number := getattr(media_item, "track_number", None): - track = _track_number + async def send_current_media_metadata(self) -> None: + """Send the current media metadata to the sendspin group.""" + current_media = self.current_media + if current_media is None: + return + # check if we are playing a MA queue item + queue_item: QueueItem | None = None + queue: PlayerQueue | None = None + if current_media.source_id and current_media.queue_item_id: + queue = self.mass.player_queues.get(current_media.source_id) + queue_item = self.mass.player_queues.get_item( + current_media.source_id, current_media.queue_item_id + ) # Send album and artist artwork - artwork_url = await self._send_album_artwork(current_item) - await self._send_artist_artwork(current_item) - - track_duration = current_item.duration + if queue_item: + await self._send_album_artwork(queue_item) + await self._send_artist_artwork(queue_item) + track_duration = current_media.duration or 0 repeat = SendspinRepeatMode.OFF - if queue.repeat_mode == RepeatMode.ALL: + if queue and queue.repeat_mode == RepeatMode.ALL: repeat = SendspinRepeatMode.ALL - elif queue.repeat_mode == RepeatMode.ONE: + elif queue and queue.repeat_mode == RepeatMode.ONE: repeat = SendspinRepeatMode.ONE - shuffle = queue.shuffle_enabled + shuffle = queue.shuffle_enabled if queue else False metadata = Metadata( - title=title, - artist=artist, - album_artist=album_artist, - album=album, - artwork_url=artwork_url, - year=year, - track=track, + title=current_media.title, + artist=current_media.artist, + album_artist=None, # TODO: extract from optional queue item + album=current_media.album, + artwork_url=current_media.image_url, + year=None, # TODO: extract from optional queue item + track=None, # TODO: extract from optional queue item track_duration=track_duration * 1000 if track_duration is not None else None, - track_progress=int(queue.corrected_elapsed_time * 1000), + track_progress=int(current_media.corrected_elapsed_time * 1000) + if current_media.corrected_elapsed_time + else 0, playback_speed=1000, repeat=repeat, shuffle=shuffle,