Fixed various issues with (plugin)sources (#2600)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 5 Nov 2025 01:52:45 +0000 (02:52 +0100)
committerGitHub <noreply@github.com>
Wed, 5 Nov 2025 01:52:45 +0000 (02:52 +0100)
20 files changed:
music_assistant/controllers/players/player_controller.py
music_assistant/controllers/players/sync_groups.py
music_assistant/controllers/streams.py
music_assistant/helpers/smart_fades.py
music_assistant/models/player.py
music_assistant/models/plugin.py
music_assistant/providers/airplay/player.py
music_assistant/providers/alexa/__init__.py
music_assistant/providers/bluesound/player.py
music_assistant/providers/builtin_player/player.py
music_assistant/providers/dlna/player.py
music_assistant/providers/fully_kiosk/player.py
music_assistant/providers/hass_players/player.py
music_assistant/providers/resonate/player.py
music_assistant/providers/roku_media_assistant/player.py
music_assistant/providers/snapcast/player.py
music_assistant/providers/sonos_s1/player.py
music_assistant/providers/spotify_connect/__init__.py
music_assistant/providers/squeezelite/player.py
music_assistant/providers/universal_group/player.py

index 750e6919f7c511bed771d8fe134db12c15f1e51f..24cd9950e033b52795c37547268ab080e4b9566f 100644 (file)
@@ -353,10 +353,10 @@ class PlayerController(CoreController):
         # Redirect to queue controller if it is active
         if active_queue := self.get_active_queue(player):
             await self.mass.player_queues.stop(active_queue.queue_id)
-            return
-        # handle command on player directly
-        async with self._player_throttlers[player.player_id]:
-            await player.stop()
+        else:
+            # handle command on player directly
+            async with self._player_throttlers[player.player_id]:
+                await player.stop()
 
     @api_command("players/cmd/play")
     @handle_player_command
@@ -477,7 +477,7 @@ class PlayerController(CoreController):
             await player.play()
             return
         if active_source and not active_source.passive:
-            await player.select_source(active_source.id)
+            await self.select_source(player_id, active_source.id)
             return
         if media:
             # try to re-play the current media item
@@ -997,6 +997,8 @@ class PlayerController(CoreController):
         # power on the player if needed
         if player.powered is False and player.power_control != PLAYER_CONTROL_NONE:
             await self.cmd_power(player.player_id, True)
+        if media.source_id:
+            player.set_active_mass_source(media.source_id)
         await player.play_media(media)
 
     @api_command("players/cmd/select_source")
@@ -1019,17 +1021,17 @@ class PlayerController(CoreController):
                 # just try to stop (regardless of state)
                 await self.cmd_stop(player_id)
                 await asyncio.sleep(0.5)  # small delay to allow stop to process
-            player.state.active_source = None
-            player.state.current_media = None
         # check if source is a pluginsource
         # in that case the source id is the instance_id of the plugin provider
         if plugin_prov := self.mass.get_provider(source):
+            player.set_active_mass_source(source)
             await self._handle_select_plugin_source(player, cast("PluginProvider", plugin_prov))
             return
         # check if source is a mass queue
         # this can be used to restore the queue after a source switch
         if mass_queue := self.mass.player_queues.get(source):
             try:
+                player.set_active_mass_source(mass_queue.queue_id)
                 await self.mass.player_queues.play(mass_queue.queue_id)
             except QueueEmpty:
                 # queue is empty: we just set the active source optimistically
@@ -1910,6 +1912,8 @@ class PlayerController(CoreController):
         for plugin_source in self.get_plugin_sources():
             if plugin_source.in_use_by == player.player_id:
                 return plugin_source
+            if player.active_source == plugin_source.id:
+                return plugin_source
         return None
 
     def _get_player_groups(
@@ -2142,7 +2146,18 @@ class PlayerController(CoreController):
     ) -> None:
         """Handle playback/select of given plugin source on player."""
         plugin_source = plugin_prov.get_source()
+        if plugin_source.in_use_by and (current_player := self.get(plugin_source.in_use_by)):
+            self.logger.debug(
+                "Plugin source %s is already in use by player %s, stopping playback there first.",
+                plugin_source.name,
+                current_player.display_name,
+            )
+            await self.cmd_stop(current_player.player_id)
         stream_url = await self.mass.streams.get_plugin_source_url(plugin_source, player.player_id)
+        plugin_source.in_use_by = player.player_id
+        # Call on_select callback if available
+        if plugin_source.on_select:
+            await plugin_source.on_select()
         await self.play_media(
             player_id=player.player_id,
             media=PlayerMedia(
index 9b186752556c9ae8878af172467f5bf8f1d1b929..781bf4de1e871447da5f55060b529c9d04b97aee 100644 (file)
@@ -91,7 +91,6 @@ class SyncGroupPlayer(GroupPlayer):
         self._attr_name = self.config.name or self.config.default_name or f"SyncGroup {player_id}"
         self._attr_available = True
         self._attr_powered = False  # group players are always powered off by default
-        self._attr_active_source = None
         self._attr_device_info = DeviceInfo(model="Sync Group", manufacturer=provider.name)
         self._attr_supported_features = {
             PlayerFeature.POWER,
@@ -263,7 +262,6 @@ class SyncGroupPlayer(GroupPlayer):
         if not powered and self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
             await self.stop()
             self._attr_current_media = None
-            self._attr_active_source = None
 
         # optimistically set the group state
 
@@ -305,7 +303,6 @@ class SyncGroupPlayer(GroupPlayer):
             # Reset to unfiltered static members list when powered off
             # (the frontend will hide unavailable members)
             self._attr_group_members = self._attr_static_group_members.copy()
-            self._attr_active_source = None
             # and clear the sync leader
             self.sync_leader = None
         self.update_state()
@@ -322,7 +319,6 @@ class SyncGroupPlayer(GroupPlayer):
         if sync_leader := self.sync_leader:
             await sync_leader.play_media(media)
             self._attr_current_media = deepcopy(media)
-            self._attr_active_source = media.source_id
             self.update_state()
         else:
             raise RuntimeError("an empty group cannot play media, consider adding members first")
@@ -342,7 +338,6 @@ class SyncGroupPlayer(GroupPlayer):
         """
         if sync_leader := self.sync_leader:
             await sync_leader.select_source(source)
-            self._attr_active_source = source
             self.update_state()
 
     async def set_members(
index 0eb89d40848a98202a6ffd16a4f1fb3fc1e4da54..f6e2189c768f3dc5f856a7d6d48b7742414f192e 100644 (file)
@@ -858,7 +858,6 @@ class StreamsController(CoreController):
                 # need to pass player_id from the PlayerMedia object
                 # because this could have been a group
                 player_id=media.custom_data["player_id"],
-                chunk_size=get_chunksize(pcm_format, 1),  # ensure 1 second chunks
             )
         elif media.source_id and media.source_id.startswith(UGP_PREFIX):
             # special case: UGP stream
@@ -1182,23 +1181,19 @@ class StreamsController(CoreController):
         output_format: AudioFormat,
         player_id: str,
         player_filter_params: list[str] | None = None,
-        chunk_size: int | None = None,
     ) -> AsyncGenerator[bytes, None]:
         """Get the special plugin source stream."""
         plugin_prov: PluginProvider = self.mass.get_provider(plugin_source_id)
         plugin_source = plugin_prov.get_source()
-        if plugin_source.in_use_by and plugin_source.in_use_by != player_id:
-            # kick out existing player using this source
-            plugin_source.in_use_by = player_id
-            await asyncio.sleep(0.5)  # give some time to the other player to stop
-
         self.logger.debug(
             "Start streaming PluginSource %s to %s using output format %s",
             plugin_source_id,
             player_id,
             output_format,
         )
+        # this should already be set by the player controller, but just to be sure
         plugin_source.in_use_by = player_id
+
         try:
             async for chunk in get_ffmpeg_stream(
                 audio_input=(
@@ -1210,10 +1205,9 @@ class StreamsController(CoreController):
                 output_format=output_format,
                 filter_params=player_filter_params,
                 extra_input_args=["-y", "-re"],
-                chunk_size=chunk_size,
             ):
                 if plugin_source.in_use_by != player_id:
-                    self.logger.info(
+                    self.logger.debug(
                         "Aborting streaming PluginSource %s to %s "
                         "- another player took over control",
                         plugin_source_id,
@@ -1225,7 +1219,10 @@ class StreamsController(CoreController):
             self.logger.debug(
                 "Finished streaming PluginSource %s to %s", plugin_source_id, player_id
             )
-            plugin_source.in_use_by = None
+            await asyncio.sleep(1)  # prevent race conditions when selecting source
+            if plugin_source.in_use_by == player_id:
+                # release control
+                plugin_source.in_use_by = None
 
     async def get_queue_item_stream(
         self,
index 89f36ec126c3c251f3a073d17e94ba18da2670a5..de74f150884b3d621e538edcb00b1d2ff7ced3e4 100644 (file)
@@ -9,6 +9,7 @@ from __future__ import annotations
 import asyncio
 import logging
 import time
+import warnings
 from typing import TYPE_CHECKING
 
 import aiofiles
@@ -164,11 +165,19 @@ class SmartFadesAnalyzer:
     ) -> SmartFadesAnalysis | None:
         """Perform beat analysis using librosa."""
         try:
-            tempo, beats_array = librosa.beat.beat_track(
-                y=audio_array,
-                sr=sample_rate,
-                units="time",
-            )
+            # Suppress librosa UserWarnings about empty mel filters
+            # These warnings are harmless and occur with certain audio characteristics
+            with warnings.catch_warnings():
+                warnings.filterwarnings(
+                    "ignore",
+                    message="Empty filters detected in mel frequency basis",
+                    category=UserWarning,
+                )
+                tempo, beats_array = librosa.beat.beat_track(
+                    y=audio_array,
+                    sr=sample_rate,
+                    units="time",
+                )
             # librosa returns np.float64 arrays when units="time"
 
             if len(beats_array) < 2:
index f9b78276998dcbf652ccc86a56f536d433f578fc..484ae3f0f4f082290e6e221323b6e4ce616fb4d7 100644 (file)
@@ -173,6 +173,7 @@ class Player(ABC):
         self._extra_data: dict[str, Any] = {}
         self._extra_attributes: dict[str, Any] = {}
         self._on_unload_callbacks: list[Callable[[], None]] = []
+        self.__active_mass_source = player_id
         # The PlayerState is the (snapshotted) final state of the player
         # after applying any config overrides and other transformations,
         # such as the display name and player controls.
@@ -366,6 +367,8 @@ class Player(ABC):
         """
         Return the (id of) the active source of the player.
 
+        Only required if the player supports PlayerFeature.SELECT_SOURCE.
+
         Set to None if the player is not currently playing a source or
         the player_id if the player is currently playing a MA queue.
 
@@ -841,8 +844,12 @@ class Player(ABC):
         for plugin_source in self.mass.players.get_plugin_sources():
             if plugin_source.in_use_by == self.player_id:
                 return plugin_source.id
-        # in case player's source is None, return the player_id (to indicate MA is active source)
-        return self._active_source or self.player_id
+        if self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
+            # active source as reported by the player itself
+            # but only if playing/paused, otherwise we always prefer the MA source
+            return self._active_source
+        # return the (last) known MA source
+        return self.__active_mass_source
 
     @cached_property
     @final
@@ -1413,6 +1420,20 @@ class Player(ABC):
                 sources.append(plugin_source)
         return sources
 
+    # The id of the (last) active mass source.
+    # This is to keep track of the last active MA source for the player,
+    # so we can restore it when needed (e.g. after switching to a plugin source).
+    __active_mass_source: str = ""
+
+    def set_active_mass_source(self, value: str) -> None:
+        """
+        Set the id of the (last) active mass source.
+
+        This is to keep track of the last active MA source for the player,
+        so we can restore it when needed (e.g. after switching to a plugin source).
+        """
+        self.__active_mass_source = value
+
     def __hash__(self) -> int:
         """Return a hash of the Player."""
         return hash(self.player_id)
index 51480763256f977ff757590e1bd1eb15d05f4e62..ca823a455e6bc9f99a88d51bd85f0c69127538b8 100644 (file)
@@ -118,6 +118,14 @@ class PluginSource(PlayerSource):
         repr=False,
     )
 
+    # Callback for when this source is selected: async def callback() -> None
+    on_select: Callable[[], Awaitable[None]] | None = field(
+        default=None,
+        compare=False,
+        metadata=field_options(serialize="omit", deserialize=pass_through),
+        repr=False,
+    )
+
     def as_player_source(self) -> PlayerSource:
         """Return a basic PlayerSource representation without unpicklable callbacks."""
         return PlayerSource(
index 78486ba220fb7d48dcd8d3eed89ca7871b1c73c4..634ee59dbb0c804efa1499932aa29395eb554a65 100644 (file)
@@ -400,7 +400,6 @@ class AirPlayPlayer(Player):
         if self.stream and self.stream.session:
             # forward stop to the entire stream session
             await self.stream.session.stop()
-        self._attr_active_source = None
         self._attr_current_media = None
         self.update_state()
 
@@ -428,10 +427,6 @@ class AirPlayPlayer(Player):
         if self.synced_to:
             # this should not happen, but guard anyways
             raise RuntimeError("Player is synced")
-
-        # set the active source for the player to the media queue
-        # this accounts for syncgroups and linked players (e.g. sonos)
-        self._attr_active_source = media.source_id
         self._attr_current_media = media
 
         # select audio source
index e98072e60a81ec4541153afe3bb670baf402ac5c..86eb4f1c4ece2d6c62ade10ecf4b6dc7d3076296 100644 (file)
@@ -288,7 +288,6 @@ class AlexaPlayer(Player):
     async def stop(self) -> None:
         """Handle STOP command on the player."""
         await self.api.stop()
-        self._attr_active_source = None
         self._attr_current_media = None
         self._attr_playback_state = PlaybackState.IDLE
         self.update_state()
index 11d44b6e12440074e9e97ef0ed3663d12600641a..50dba51fde58ea09b66f0faeea8d5b792480a078 100644 (file)
@@ -121,7 +121,6 @@ class BluesoundPlayer(Player):
         if play_state == "stop":
             self._set_polling_dynamic()
         self._attr_playback_state = PlaybackState.IDLE
-        self._attr_active_source = None
         self._attr_current_media = None
         self.update_state()
 
@@ -196,7 +195,6 @@ class BluesoundPlayer(Player):
 
         # Optimistically update state
         self._attr_current_media = media
-        self._attr_active_source = media.source_id
         self._attr_elapsed_time = 0
         self._attr_elapsed_time_last_updated = time.time()
         self.update_state()
@@ -229,7 +227,6 @@ class BluesoundPlayer(Player):
                 if removed_player:
                     removed_player._set_polling_dynamic()
                     removed_player._attr_current_media = None
-                    removed_player._attr_active_source = None
                     removed_player.update_state()
 
         if player_ids_to_add:
index 2b0b79dc565be3aa0ff4281dbae43ce90a4de02a..2bd69746a2185eafda913ddb8503cee5218e66e8 100644 (file)
@@ -139,7 +139,6 @@ class BuiltinPlayer(Player):
             self.player_id,
             BuiltinPlayerEvent(type=BuiltinPlayerEventType.STOP),
         )
-        self._attr_active_source = None
         self._attr_current_media = None
         self.update_state()
 
@@ -182,7 +181,6 @@ class BuiltinPlayer(Player):
         url = f"builtin_player/flow/{self.player_id}.mp3"
         self._attr_current_media = media
         self._attr_playback_state = PlaybackState.PLAYING
-        self._attr_active_source = media.source_id
         self.update_state()
         self.mass.signal_event(
             EventType.BUILTIN_PLAYER,
index 1bfa31a4bc08bc1e8589aad809ac8be8781ba0b0..4f956943542e57db3180521f9e603ddbf40270a2 100644 (file)
@@ -219,14 +219,12 @@ class DLNAPlayer(Player):
         _device_uri = self.device.current_track_uri or ""
         self.set_current_media(uri=_device_uri, clear_all=True)
 
-        if self.player_id in _device_uri:
-            self._attr_active_source = self.player_id
-        elif "spotify" in _device_uri:
+        if "spotify" in _device_uri:
             self._attr_active_source = "spotify"
         elif _device_uri.startswith("http"):
             self._attr_active_source = "http"
         else:
-            # TODO: handle other possible sources here
+            # TODO: extend this list with other possible sources
             self._attr_active_source = None
         if self.device.media_position:
             # only update elapsed_time if the device actually reports it
index 4efdb0263539835f7a079e5602c2d54123d155ef..6b046de70ab865d5d8652b0901fce163f71a41fc 100644 (file)
@@ -88,7 +88,6 @@ class FullyKioskPlayer(Player):
         """Send STOP command to given player."""
         await self.fully_kiosk.stopSound()
         self._attr_playback_state = PlaybackState.IDLE
-        self._attr_active_source = None
         self._attr_current_media = None
         self.update_state()
 
index 948fa099984600dfd947efe515c83f086b235e4b..2e16aaaec7a7f4b1b8a652c5ebf9c714fc7c1b1b 100644 (file)
@@ -206,7 +206,6 @@ class HomeAssistantPlayer(Player):
                 await self.pause()
         finally:
             self._attr_current_media = None
-            self._attr_active_source = None
             self.update_state()
 
     async def volume_set(self, volume_level: int) -> None:
@@ -274,7 +273,6 @@ class HomeAssistantPlayer(Player):
 
         # Optimistically update state
         self._attr_current_media = media
-        self._attr_active_source = media.source_id
         self._attr_elapsed_time = 0
         self._attr_elapsed_time_last_updated = time.time()
         self._attr_playback_state = PlaybackState.PLAYING
index 2d38f3e19a19c9cbcbc5dded97ef26157e9c28ac..39becc4d876546f2d9f8276948269b06f775a55e 100644 (file)
@@ -182,7 +182,6 @@ class ResonatePlayer(Player):
         await self.api.group.stop()
         # Clear the playback task reference (group.stop() handles stopping the stream)
         self._playback_task = None
-        self._attr_active_source = None
         self._attr_current_media = None
         self.update_state()
 
@@ -196,7 +195,6 @@ class ResonatePlayer(Player):
         self._attr_current_media = media
         self._attr_elapsed_time = 0
         self._attr_elapsed_time_last_updated = time.time()
-        self._attr_active_source = media.source_id
         # playback_state will be set by the group state change event
 
         # Stop previous stream in case we were already playing something
index b5f1ef56cddfa9006e84f6ec6495c1c3831ebfde..c5999609d816ad1d2ddb3c83ec63ab8803fd0cf8 100644 (file)
@@ -90,7 +90,6 @@ class MediaAssistantPlayer(Player):
             # There's no real way to "Power" on the app since device wake up / app start
             # is handled by The roku once it receives the Play Media request
             if not powered:
-                self._attr_active_source = None
                 if app_running:
                     await self.roku.remote("home")
                     await self.roku.remote("power")
@@ -150,7 +149,6 @@ class MediaAssistantPlayer(Player):
             logger = self.provider.logger.getChild(self.player_id)
             logger.info("Received STOP command on player %s", self.display_name)
             self._attr_playback_state = PlaybackState.IDLE
-            self._attr_active_source = None
             self._attr_current_media = None
             self.update_state()
         except Exception:
@@ -217,7 +215,6 @@ class MediaAssistantPlayer(Player):
             )
             self._attr_powered = True
             self._attr_current_media = media
-            self._attr_active_source = self.player_id
             self.update_state()
         except Exception:
             self.logger.error("Failed to Play Media on: %s", self.name)
@@ -271,10 +268,6 @@ class MediaAssistantPlayer(Player):
         if device_info.app is not None:
             app_running = device_info.app.app_id == self.provider.config.get_value(CONF_ROKU_APP_ID)
 
-        # Update Device State
-        if not app_running:
-            self._attr_active_source = None
-
         self._attr_powered = app_running
 
         # If Media's Playing update its state
index 0a2ce20b4f4e71c692f085f7593f6cf82a2e384d..8dff0dd6bffb2ad284cf7e76749340fe757a3d10 100644 (file)
@@ -96,7 +96,6 @@ class SnapCastPlayer(Player):
         # finishes the player.state should be IDLE.
         self._attr_playback_state = PlaybackState.IDLE
         self._attr_current_media = None
-        self._attr_active_source = None
         self._set_childs_state()
 
         self.update_state()
@@ -179,7 +178,6 @@ class SnapCastPlayer(Player):
             await snap_group.set_stream(stream.identifier)
 
         self._attr_current_media = media
-        self._attr_active_source = media.source_id
 
         # select audio source
         audio_source = self.mass.streams.get_stream(media, DEFAULT_SNAPCAST_FORMAT)
index a3fbbe8319d4b9aba36ea676bb33a123aff0746d..12c11661ca37a9e29eeaca6ed6d1f18532e1d93d 100644 (file)
@@ -152,7 +152,6 @@ class SonosPlayer(Player):
             return
         await asyncio.to_thread(self.soco.stop)
         self.mass.call_later(2, self.poll)
-        self._attr_active_source = None
         self.update_state()
 
     async def play(self) -> None:
index 2c95b1e40a31a06220eae05e8def21f1e81851e8..44862cbf25511dee3f4db1d25887ca08f39e44b5 100644 (file)
@@ -22,6 +22,7 @@ from music_assistant_models.enums import (
     ConfigEntryType,
     ContentType,
     EventType,
+    PlaybackState,
     ProviderFeature,
     ProviderType,
     StreamType,
@@ -142,7 +143,7 @@ class SpotifyConnectProvider(PluginProvider):
             name=self.name,
             # we set passive to true because we
             # dont allow this source to be selected directly
-            passive=True,
+            passive=False,
             # Playback control capabilities will be enabled when Spotify Web API is available
             can_play_pause=False,
             can_seek=False,
@@ -166,6 +167,7 @@ class SpotifyConnectProvider(PluginProvider):
         self._spotify_provider: SpotifyProvider | None = None
         self._on_unload_callbacks: list[Callable[..., None]] = []
         self._runner_error_count = 0
+        self._spotify_device_id: str | None = None
 
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
@@ -244,6 +246,7 @@ class SpotifyConnectProvider(PluginProvider):
         self._source_details.can_play_pause = has_web_api
         self._source_details.can_seek = has_web_api
         self._source_details.can_next_previous = has_web_api
+        self._source_details.passive = not has_web_api
 
         # Register or unregister callbacks based on availability
         if has_web_api:
@@ -252,24 +255,43 @@ class SpotifyConnectProvider(PluginProvider):
             self._source_details.on_next = self._on_next
             self._source_details.on_previous = self._on_previous
             self._source_details.on_seek = self._on_seek
+            self._source_details.on_select = self._on_select
         else:
             self._source_details.on_play = None
             self._source_details.on_pause = None
             self._source_details.on_next = None
             self._source_details.on_previous = None
             self._source_details.on_seek = None
+            self._source_details.on_select = None
 
         # Trigger player update to reflect capability changes
         if self._source_details.in_use_by:
             self.mass.players.trigger_player_update(self._source_details.in_use_by)
 
+    async def _on_select(self) -> None:
+        """Handle source selection - transfer Spotify playback to this device."""
+        if not self._spotify_provider:
+            return
+        try:
+            # Transfer playback to this device when it's selected
+            await self._ensure_active_device()
+            await self._spotify_provider._put_data("me/player/play")
+        except Exception as err:
+            self.logger.debug("Failed to transfer playback on source selection: %s", err)
+
     async def _on_play(self) -> None:
         """Handle play command via Spotify Web API."""
+        attached_player = self.mass.players.get(self.mass_player_id)
+        if attached_player and attached_player.playback_state == PlaybackState.IDLE:
+            # edge case: player is not paused, so we need to select this source first
+            await self.mass.players.select_source(self.mass_player_id, self.instance_id)
         if not self._spotify_provider:
             raise UnsupportedFeaturedException(
                 "Playback control requires a matching Spotify music provider"
             )
         try:
+            # First try to transfer playback to this device if needed
+            await self._ensure_active_device()
             await self._spotify_provider._put_data("me/player/play")
         except Exception as err:
             self.logger.warning("Failed to send play command via Spotify Web API: %s", err)
@@ -325,6 +347,81 @@ class SpotifyConnectProvider(PluginProvider):
             self.logger.warning("Failed to send seek command via Spotify Web API: %s", err)
             raise
 
+    async def _get_spotify_device_id(self) -> str | None:
+        """Get the Spotify Connect device ID for this instance.
+
+        :return: Device ID if found, None otherwise.
+        """
+        if not self._spotify_provider:
+            return None
+
+        try:
+            # Get list of available devices from Spotify Web API
+            devices_data = await self._spotify_provider._get_data("me/player/devices")
+            devices = devices_data.get("devices", [])
+
+            # Look for our device by name
+            connect_name = cast("str", self.config.get_value(CONF_PUBLISH_NAME)) or self.name
+            for device in devices:
+                if device.get("name") == connect_name and device.get("type") == "Speaker":
+                    device_id: str | None = device.get("id")
+                    self.logger.debug("Found Spotify Connect device ID: %s", device_id)
+                    return device_id
+
+            self.logger.debug(
+                "Could not find Spotify Connect device '%s' in available devices", connect_name
+            )
+            return None
+        except Exception as err:
+            self.logger.debug("Failed to get Spotify devices: %s", err)
+            return None
+
+    async def _ensure_active_device(self) -> None:
+        """
+        Ensure this Spotify Connect device is the active player on Spotify.
+
+        Transfers playback to this device if it's not already active.
+        """
+        if not self._spotify_provider:
+            return
+
+        try:
+            # Get current playback state
+            try:
+                playback_data = await self._spotify_provider._get_data("me/player")
+                current_device = playback_data.get("device", {}) if playback_data else {}
+                current_device_id = current_device.get("id")
+            except Exception as err:
+                if getattr(err, "status", None) == 204:
+                    # No active device
+                    current_device_id = None
+                else:
+                    raise
+
+            # Get our device ID if we don't have it cached
+            if not self._spotify_device_id:
+                self._spotify_device_id = await self._get_spotify_device_id()
+
+            # If we couldn't find our device ID, we can't transfer
+            if not self._spotify_device_id:
+                self.logger.debug("Cannot transfer playback - device ID not found")
+                return
+
+            # Check if we're already the active device
+            if current_device_id == self._spotify_device_id:
+                self.logger.debug("Already the active Spotify device")
+                return
+
+            # Transfer playback to this device
+            self.logger.info("Transferring Spotify playback to this device")
+            await self._spotify_provider._put_data(
+                "me/player",
+                data={"device_ids": [self._spotify_device_id], "play": False},
+            )
+        except Exception as err:
+            self.logger.debug("Failed to ensure active device: %s", err)
+            # Don't raise - this is a best-effort operation
+
     def _on_provider_event(self, event: MassEvent) -> None:
         """Handle provider added/removed events to check for Spotify provider."""
         # Re-check for matching Spotify provider when providers change
@@ -484,6 +581,10 @@ class SpotifyConnectProvider(PluginProvider):
             if not self._connected_spotify_username or not self._spotify_provider:
                 await self._check_spotify_provider_match()
 
+            # Make this device the active Spotify player via Web API
+            if self._spotify_provider:
+                self.mass.create_task(self._ensure_active_device())
+
             # initiate playback by selecting this source on the default player
             self.mass.create_task(
                 self.mass.players.select_source(self.mass_player_id, self.instance_id)
@@ -497,16 +598,16 @@ class SpotifyConnectProvider(PluginProvider):
             image_url = images[0] if (images := common_meta.get("covers")) else None
             if self._source_details.metadata is None:
                 self._source_details.metadata = StreamMetadata(uri=uri, title=title)
-                self._source_details.metadata.uri = uri
-                self._source_details.metadata.title = title
-                self._source_details.metadata.artist = None
-                self._source_details.metadata.album = None
-                self._source_details.metadata.image_url = image_url
-                self._source_details.metadata.description = None
-                duration_ms = common_meta.get("duration_ms", 0)
-                self._source_details.metadata.duration = (
-                    int(duration_ms) // 1000 if duration_ms is not None else None
-                )
+            self._source_details.metadata.uri = uri
+            self._source_details.metadata.title = title
+            self._source_details.metadata.artist = None
+            self._source_details.metadata.album = None
+            self._source_details.metadata.image_url = image_url
+            self._source_details.metadata.description = None
+            duration_ms = common_meta.get("duration_ms", 0)
+            self._source_details.metadata.duration = (
+                int(duration_ms) // 1000 if duration_ms is not None else None
+            )
 
         if track_meta := json_data.get("track_metadata_fields", {}):
             if artists := track_meta.get("artists"):
index 93c2a1ea9062a8af84b90e7af6f9f3ca08079c40..a8fb0ffd061426f70e3b06a4407e3586a075fa39 100644 (file)
@@ -202,7 +202,6 @@ class SqueezelitePlayer(Player):
         async with TaskManager(self.mass) as tg:
             for client in self._get_sync_clients():
                 tg.create_task(client.stop())
-        self._attr_active_source = None
         self.update_state()
 
     async def play(self) -> None:
index fcb9819daa7cc2ad8d12e592aa8ad03934d8a9ef..23a4fc5723998d8e234c2e43f0ce2b7d7cbaeb7c 100644 (file)
@@ -57,7 +57,6 @@ class UniversalGroupPlayer(GroupPlayer):
         self._attr_name = self.config.name or f"Universal Group {player_id}"
         self._attr_available = True
         self._attr_powered = False  # group players are always powered off by default
-        self._attr_active_source = player_id
         self._attr_device_info = DeviceInfo(model="Universal Group", manufacturer=provider.name)
         self._attr_supported_features = {*BASE_FEATURES}
         self._attr_needs_poll = True
@@ -236,7 +235,6 @@ class UniversalGroupPlayer(GroupPlayer):
         self._attr_elapsed_time = 0
         self._attr_elapsed_time_last_updated = time() - 1
         self._attr_playback_state = PlaybackState.PLAYING
-        self._attr_active_source = media.source_id
         self.update_state()
 
         # forward to downstream play_media commands