Fix player.current_media callback for players
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 16 Dec 2025 01:20:03 +0000 (02:20 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 16 Dec 2025 01:20:03 +0000 (02:20 +0100)
music_assistant/controllers/player_queues.py
music_assistant/models/player.py
music_assistant/providers/airplay/player.py
music_assistant/providers/airplay/stream_session.py
music_assistant/providers/chromecast/player.py
music_assistant/providers/sendspin/player.py

index 9499243a77a70956fc4ae305d31f1f618d163378..34bb5136554be8994a0ce81ca0461b807b513c07 100644 (file)
@@ -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:
index dcc0092363769c98191dae33e8d4b5c0a59971f2..4670941914aef87d019de3ad6260b2bb416f89d0 100644 (file)
@@ -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]]:
index 62ceb4597c4d9ee40b0f5f8dc88965867e406295..18599f141b4b7911ed7ba0aec2f55ace2d5d35db 100644 (file)
@@ -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 = (
index 2a81a7c9087062a1e470f253106c199306fd8b0e..de209122dd3db315a12f426891f65047dbff1e0d 100644 (file)
@@ -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
index 82a931f4d916d07c99fc3dd57a4307c4e2c64854..dcdef06856fc0ca7cfe2ce6a8f70e27d2e4ebb3d 100644 (file)
@@ -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."""
index 587ce7f775a5028a0f42b538c3daab193abcbff1..2ba68770f206b6bfc9c9c38f7cca9eecd8b4258f 100644 (file)
@@ -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,