Plugin source improvements (#2548)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sun, 26 Oct 2025 02:37:44 +0000 (03:37 +0100)
committerGitHub <noreply@github.com>
Sun, 26 Oct 2025 02:37:44 +0000 (03:37 +0100)
music_assistant/controllers/players/player_controller.py
music_assistant/controllers/streams.py
music_assistant/models/player.py
music_assistant/models/plugin.py
music_assistant/providers/sonos/player.py
music_assistant/providers/spotify/provider.py
music_assistant/providers/spotify_connect/ARCHITECTURE.md [new file with mode: 0644]
music_assistant/providers/spotify_connect/__init__.py
music_assistant/providers/squeezelite/player.py

index e0a3753c49ca8f3e5f9afa9026971b7cb275b1fb..9301424cb1a2b618b5fe4a9da0673c3ddca7fb3f 100644 (file)
@@ -365,6 +365,13 @@ class PlayerController(CoreController):
                 "Ignore PLAY request to player %s: player is already playing", player.display_name
             )
             return
+
+        # Check if a plugin source is active with a play callback
+        if plugin_source := self._get_active_plugin_source(player):
+            if plugin_source.can_play_pause and plugin_source.on_play:
+                await plugin_source.on_play()
+                return
+
         if player.playback_state == PlaybackState.PAUSED:
             # handle command on player directly
             async with self._player_throttlers[player.player_id]:
@@ -381,6 +388,13 @@ class PlayerController(CoreController):
         - player_id: player_id of the player to handle the command.
         """
         player = self._get_player_with_redirect(player_id)
+
+        # Check if a plugin source is active with a pause callback
+        if plugin_source := self._get_active_plugin_source(player):
+            if plugin_source.can_play_pause and plugin_source.on_pause:
+                await plugin_source.on_pause()
+                return
+
         # Redirect to queue controller if it is active
         if active_queue := self.get_active_queue(player):
             await self.mass.player_queues.pause(active_queue.queue_id)
@@ -456,6 +470,13 @@ class PlayerController(CoreController):
         - position: position in seconds to seek to in the current playing item.
         """
         player = self._get_player_with_redirect(player_id)
+
+        # Check if a plugin source is active with a seek callback
+        if plugin_source := self._get_active_plugin_source(player):
+            if plugin_source.can_seek and plugin_source.on_seek:
+                await plugin_source.on_seek(position)
+                return
+
         # Redirect to queue controller if it is active
         if active_queue := self.get_active_queue(player):
             await self.mass.player_queues.seek(active_queue.queue_id, position)
@@ -472,6 +493,12 @@ class PlayerController(CoreController):
         player = self._get_player_with_redirect(player_id)
         active_source_id = player.active_source or player.player_id
 
+        # Check if a plugin source is active with a next callback
+        if plugin_source := self._get_active_plugin_source(player):
+            if plugin_source.can_next_previous and plugin_source.on_next:
+                await plugin_source.on_next()
+                return
+
         # Redirect to queue controller if it is active
         if active_queue := self.get_active_queue(player):
             await self.mass.player_queues.next(active_queue.queue_id)
@@ -494,6 +521,13 @@ class PlayerController(CoreController):
         """Handle PREVIOUS TRACK command for given player."""
         player = self._get_player_with_redirect(player_id)
         active_source_id = player.active_source or player.player_id
+
+        # Check if a plugin source is active with a previous callback
+        if plugin_source := self._get_active_plugin_source(player):
+            if plugin_source.can_next_previous and plugin_source.on_previous:
+                await plugin_source.on_previous()
+                return
+
         # Redirect to queue controller if it is active
         if active_queue := self.get_active_queue(player):
             await self.mass.player_queues.previous(active_queue.queue_id)
@@ -1826,6 +1860,14 @@ class PlayerController(CoreController):
             return active_group
         return player
 
+    def _get_active_plugin_source(self, player: Player) -> PluginSource | None:
+        """Get the active PluginSource for a player if any."""
+        # Check if any plugin source is in use by this player
+        for plugin_source in self.get_plugin_sources():
+            if plugin_source.in_use_by == player.player_id:
+                return plugin_source
+        return None
+
     def _get_player_groups(
         self, player: Player, available_only: bool = True, powered_only: bool = False
     ) -> Iterator[Player]:
@@ -2050,9 +2092,7 @@ class PlayerController(CoreController):
     ) -> None:
         """Handle playback/select of given plugin source on player."""
         plugin_source = plugin_prov.get_source()
-        stream_url = await self.mass.streams.get_plugin_source_url(
-            plugin_source.id, player.player_id
-        )
+        stream_url = await self.mass.streams.get_plugin_source_url(plugin_source, player.player_id)
         await self.play_media(
             player_id=player.player_id,
             media=PlayerMedia(
index 5774005e173ca9e2f456205adc50673560df8602..b3dbdbb16ad8ec37c2dd207b7168f7666e488f30 100644 (file)
@@ -78,7 +78,7 @@ from music_assistant.helpers.util import get_ip_addresses, get_total_system_memo
 from music_assistant.helpers.webserver import Webserver
 from music_assistant.models.core_controller import CoreController
 from music_assistant.models.music_provider import MusicProvider
-from music_assistant.models.plugin import PluginProvider
+from music_assistant.models.plugin import PluginProvider, PluginSource
 
 if TYPE_CHECKING:
     from music_assistant_models.config_entries import CoreConfig
@@ -348,18 +348,15 @@ class StreamsController(CoreController):
 
     async def get_plugin_source_url(
         self,
-        plugin_source: str,
+        plugin_source: PluginSource,
         player_id: str,
     ) -> str:
         """Get the url for the Plugin Source stream/proxy."""
-        output_codec = ContentType.try_parse(
-            await self.mass.config.get_player_config_value(player_id, CONF_OUTPUT_CODEC)
-        )
-        fmt = output_codec.value
-        # handle raw pcm without exact format specifiers
-        if output_codec.is_pcm() and ";" not in fmt:
-            fmt += f";codec=pcm;rate={44100};bitrate={16};channels={2}"
-        return f"{self._server.base_url}/pluginsource/{plugin_source}/{player_id}.{fmt}"
+        if plugin_source.audio_format.content_type.is_pcm():
+            fmt = ContentType.WAV.value
+        else:
+            fmt = plugin_source.audio_format.content_type.value
+        return f"{self._server.base_url}/pluginsource/{plugin_source.id}/{player_id}.{fmt}"
 
     async def serve_queue_item_stream(self, request: web.Request) -> web.Response:
         """Stream single queueitem audio to a player."""
@@ -739,12 +736,8 @@ class StreamsController(CoreController):
         output_format = await self.get_output_format(
             output_format_str=request.match_info["fmt"],
             player=player,
-            content_sample_rate=plugin_source.audio_format.sample_rate
-            if plugin_source.audio_format
-            else 44100,
-            content_bit_depth=plugin_source.audio_format.bit_depth
-            if plugin_source.audio_format
-            else 16,
+            content_sample_rate=plugin_source.audio_format.sample_rate,
+            content_bit_depth=plugin_source.audio_format.bit_depth,
         )
         headers = {
             **DEFAULT_STREAM_HEADERS,
@@ -1059,37 +1052,36 @@ class StreamsController(CoreController):
         player_filter_params: list[str] | None = None,
     ) -> AsyncGenerator[bytes, None]:
         """Get the special plugin source stream."""
-        player = self.mass.players.get(player_id)
         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:
             raise RuntimeError(
                 f"PluginSource plugin_source.name is already in use by {plugin_source.in_use_by}"
             )
-        self.logger.debug("Start streaming PluginSource %s to %s", plugin_source_id, player_id)
-        audio_input = (
-            plugin_prov.get_audio_stream(player_id)
-            if plugin_source.stream_type == StreamType.CUSTOM
-            else plugin_source.path
+        self.logger.debug(
+            "Start streaming PluginSource %s to %s using output format %s",
+            plugin_source_id,
+            player_id,
+            output_format,
         )
-        player.state.active_source = plugin_source_id
         plugin_source.in_use_by = player_id
         try:
             async for chunk in get_ffmpeg_stream(
-                audio_input=audio_input,
+                audio_input=(
+                    plugin_prov.get_audio_stream(player_id)
+                    if plugin_source.stream_type == StreamType.CUSTOM
+                    else plugin_source.path
+                ),
                 input_format=plugin_source.audio_format,
                 output_format=output_format,
                 filter_params=player_filter_params,
-                extra_input_args=["-re"],
-                chunk_size=int(get_chunksize(output_format) / 10),
+                extra_input_args=["-y", "-re"],
             ):
                 yield chunk
         finally:
             self.logger.debug(
                 "Finished streaming PluginSource %s to %s", plugin_source_id, player_id
             )
-            await asyncio.sleep(0.5)
-            player.state.active_source = player.player_id
             plugin_source.in_use_by = None
 
     async def get_queue_item_stream(
index ad59cf1cc4a12857fcb64f5e5f402583824bd3e0..a7b6454ce75993c0d423c58a9417097924fd8d5f 100644 (file)
@@ -826,6 +826,9 @@ class Player(ABC):
         if parent_player_id := (self.active_group or self.synced_to):
             if parent_player := self.mass.players.get(parent_player_id):
                 return parent_player.active_source
+        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
 
@@ -853,23 +856,12 @@ class Player(ABC):
                 can_next_previous=True,
             )
             sources.append(mass_source)
-        # if the player is grouped/synced, add the active source list of the group/parent player
-        if parent_player_id := (self.active_group or self.synced_to):
-            if parent_player := self.mass.players.get(parent_player_id):
-                for source in parent_player.source_list:
-                    if source.id == parent_player.active_source:
-                        sources.append(
-                            PlayerSource(
-                                id=source.id,
-                                name=f"{source.name} ({parent_player.display_name})",
-                                passive=source.passive,
-                                can_play_pause=source.can_play_pause,
-                                can_seek=source.can_seek,
-                                can_next_previous=source.can_next_previous,
-                            )
-                        )
-        # append all/any plugin sources
-        sources.extend(self.mass.players.get_plugin_sources())
+        # append all/any plugin sources (convert to PlayerSource to avoid deepcopy issues)
+        for plugin_source in self.mass.players.get_plugin_sources():
+            if hasattr(plugin_source, "as_player_source"):
+                sources.append(plugin_source.as_player_source())
+            else:
+                sources.append(plugin_source)
         return sources
 
     @cached_property
@@ -935,7 +927,7 @@ class Player(ABC):
         if self.active_source and (
             source := self.mass.players.get_plugin_source(self.active_source)
         ):
-            return source.metadata
+            return deepcopy(source.metadata)
 
         return self._current_media
 
index 55f24aa81f9162cbbb5f0586d18a71efe2053843..35006823628c26d9f835f5adb24575c62b423833 100644 (file)
@@ -2,12 +2,12 @@
 
 from __future__ import annotations
 
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Awaitable, Callable
 from dataclasses import dataclass, field
 
 from mashumaro import field_options, pass_through
-from music_assistant_models.enums import StreamType
-from music_assistant_models.media_items.audio_format import AudioFormat  # noqa: TC002
+from music_assistant_models.enums import ContentType, StreamType
+from music_assistant_models.media_items.audio_format import AudioFormat
 
 from music_assistant.models.player import PlayerMedia, PlayerSource
 
@@ -19,14 +19,21 @@ class PluginSource(PlayerSource):
     """
     Model for a PluginSource, which is a player (audio)source provided by a plugin.
 
+    A PluginSource is for example a live audio stream such as a aux/microphone input.
+
     This (intermediate)  model is not exposed on the api,
     but is used internally by the plugin provider.
     """
 
-    # The output format that is sent to the player
-    # (or to the library/application that is used to send audio to the player)
-    audio_format: AudioFormat | None = field(
-        default=None,
+    # The PCM audio format provided by this source
+    # for realtime audio, we recommend using PCM 16bit 44.1kHz stereo
+    audio_format: AudioFormat = field(
+        default=AudioFormat(
+            content_type=ContentType.PCM_S16LE,
+            sample_rate=44100,
+            bit_depth=16,
+            channels=2,
+        ),
         compare=False,
         metadata=field_options(serialize="omit", deserialize=pass_through),
         repr=False,
@@ -63,6 +70,61 @@ class PluginSource(PlayerSource):
         repr=False,
     )
 
+    # Optional callbacks for playback control
+    # These callbacks will be called by the player controller when control commands are issued
+    # and the source reports the corresponding capability (can_play_pause, can_seek, etc.)
+
+    # Callback for play command: async def callback() -> None
+    on_play: Callable[[], Awaitable[None]] | None = field(
+        default=None,
+        compare=False,
+        metadata=field_options(serialize="omit", deserialize=pass_through),
+        repr=False,
+    )
+
+    # Callback for pause command: async def callback() -> None
+    on_pause: Callable[[], Awaitable[None]] | None = field(
+        default=None,
+        compare=False,
+        metadata=field_options(serialize="omit", deserialize=pass_through),
+        repr=False,
+    )
+
+    # Callback for next track command: async def callback() -> None
+    on_next: Callable[[], Awaitable[None]] | None = field(
+        default=None,
+        compare=False,
+        metadata=field_options(serialize="omit", deserialize=pass_through),
+        repr=False,
+    )
+
+    # Callback for previous track command: async def callback() -> None
+    on_previous: Callable[[], Awaitable[None]] | None = field(
+        default=None,
+        compare=False,
+        metadata=field_options(serialize="omit", deserialize=pass_through),
+        repr=False,
+    )
+
+    # Callback for seek command: async def callback(position: int) -> None
+    on_seek: Callable[[int], 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(
+            id=self.id,
+            name=self.name,
+            passive=self.passive,
+            can_play_pause=self.can_play_pause,
+            can_seek=self.can_seek,
+            can_next_previous=self.can_next_previous,
+        )
+
 
 class PluginProvider(Provider):
     """
@@ -87,6 +149,8 @@ class PluginProvider(Provider):
         the ProviderFeature.AUDIO_SOURCE is declared AND if the streamtype is StreamType.CUSTOM.
 
         The player_id is the id of the player that is requesting the stream.
+
+        Must return audio data as bytes generator (in the format specified by the audio_format).
         """
         yield b""
         raise NotImplementedError
index 1e252148d7e48a2c245a4916daacb26d473beffd..be99014b88b98697a90ae2985380689d71bed7f6 100644 (file)
@@ -12,7 +12,6 @@ from __future__ import annotations
 import asyncio
 import time
 from collections import deque
-from copy import deepcopy
 from dataclasses import dataclass, field
 from typing import TYPE_CHECKING
 
@@ -26,6 +25,7 @@ from music_assistant_models.config_entries import ConfigEntry
 from music_assistant_models.enums import (
     ConfigEntryType,
     EventType,
+    MediaType,
     PlaybackState,
     PlayerFeature,
     RepeatMode,
@@ -406,7 +406,6 @@ class SonosPlayer(Player):
         :param media: Details of the item that needs to be played on the player.
         """
         self.sonos_queue.set_items([media])
-        self._attr_current_media = deepcopy(media)
 
         if self.client.player.is_passive:
             # this should be already handled by the player manager, but just in case...
@@ -423,7 +422,7 @@ class SonosPlayer(Player):
             await self._play_media_airplay(airplay_player, media)
             return
 
-        if media.source_id and media.queue_item_id and media.duration:
+        if (media.source_id and media.queue_item_id) or media.media_type == MediaType.PLUGIN_SOURCE:
             # Regular Queue item playback
             # create a sonos cloud queue and load it
             cloud_queue_url = f"{self.mass.streams.base_url}/sonos_queue/v2.3/"
@@ -433,12 +432,6 @@ class SonosPlayer(Player):
             )
             return
 
-        # All other playback types
-        if media.duration:
-            # use legacy playback for files with known duration
-            await self._play_media_legacy(media)
-            return
-
         # play duration-less (long running) radio streams
         # enforce AAC here because Sonos really does not support FLAC streams without duration
         media.uri = media.uri.replace(".flac", ".aac").replace(".wav", ".aac")
index 9da24abbf96c92091e7de47be8e8dbb2f3bd402b..48a7f1d372c0b701c173e9759f6621084befb9c4 100644 (file)
@@ -1077,7 +1077,9 @@ class SpotifyProvider(MusicProvider):
             response.raise_for_status()
 
     @throttle_with_retries
-    async def _post_data(self, endpoint: str, data: Any = None, **kwargs: Any) -> dict[str, Any]:
+    async def _post_data(
+        self, endpoint: str, data: Any = None, want_result: bool = True, **kwargs: Any
+    ) -> dict[str, Any]:
         """Post data on api."""
         url = f"https://api.spotify.com/v1/{endpoint}"
         auth_info = kwargs.pop("auth_info", await self.login())
@@ -1100,6 +1102,8 @@ class SpotifyProvider(MusicProvider):
             if response.status in (502, 503):
                 raise ResourceTemporarilyUnavailable(backoff_time=30)
             response.raise_for_status()
+            if not want_result:
+                return {}
             result: dict[str, Any] = await response.json(loads=json_loads)
             return result
 
diff --git a/music_assistant/providers/spotify_connect/ARCHITECTURE.md b/music_assistant/providers/spotify_connect/ARCHITECTURE.md
new file mode 100644 (file)
index 0000000..c739a9c
--- /dev/null
@@ -0,0 +1,410 @@
+# Spotify Connect Provider - Architecture
+
+## Overview
+
+The Spotify Connect provider enables Music Assistant to integrate with Spotify's Connect protocol, allowing any Music Assistant player to appear as a Spotify Connect device in the Spotify app. This provider acts as a bridge between Spotify's proprietary Connect protocol and Music Assistant's audio streaming infrastructure.
+
+## What is Spotify Connect?
+
+Spotify Connect is Spotify's proprietary protocol that allows users to:
+- Control playback on various devices from the Spotify app
+- Transfer playback seamlessly between devices
+- See what's playing with rich metadata (artwork, artist, album)
+- Control volume and playback state
+
+Unlike traditional Spotify integrations that require Web API authentication, Spotify Connect uses librespot - a reverse-engineered implementation of Spotify's audio streaming protocol.
+
+## How It Works
+
+### Architecture Components
+
+```
+┌─────────────────┐
+│   Spotify App   │  (Mobile/Desktop/Web)
+└────────┬────────┘
+         │ Spotify Connect Protocol
+         ▼
+┌─────────────────────────────────────┐
+│  Spotify Connect Provider           │
+│  ┌───────────────────────────────┐  │
+│  │  librespot Process            │  │  Handles:
+│  │  - Authentication             │  │  - Spotify protocol
+│  │  - Audio streaming            │  │  - Audio decoding
+│  │  - Metadata extraction        │  │  - Session management
+│  └───────────────────────────────┘  │
+│  ┌───────────────────────────────┐  │
+│  │  events.py Webservice         │  │  Receives:
+│  │  - Session events             │  │  - Connected/disconnected
+│  │  - Metadata updates           │  │  - Playback state changes
+│  │  - Volume changes             │  │  - Track metadata
+│  └───────────────────────────────┘  │
+│  ┌───────────────────────────────┐  │
+│  │  PluginSource                 │  │  Provides:
+│  │  - Dynamic capabilities       │  │  - Playback control
+│  │  - Callback routing           │  │  - Metadata display
+│  │  - Web API integration        │  │  - Source selection
+│  └───────────────────────────────┘  │
+└─────────────────┬───────────────────┘
+                  │
+                  ▼
+┌─────────────────────────────────────┐
+│  Music Assistant Player             │
+│  - Receives audio stream            │
+│  - Displays metadata                │
+│  - Reports state changes            │
+└─────────────────────────────────────┘
+```
+
+### Key Components
+
+#### 1. **librespot Process**
+- External binary that implements Spotify's Connect protocol
+- Runs as a subprocess managed by the provider
+- Handles all Spotify-specific communication:
+  - Authentication using Spotify credentials
+  - Audio streaming and decoding to PCM
+  - Session management (connect/disconnect)
+- Outputs raw PCM audio to stdout (piped to ffmpeg)
+- Sends events to the custom webservice via HTTP
+
+#### 2. **events.py Webservice**
+- Python script that receives event callbacks from librespot
+- Runs on a custom port for each provider instance
+- Provides an HTTP endpoint that librespot calls with:
+  - Session connected/disconnected events
+  - Track metadata (title, artist, album, artwork)
+  - Playback state changes (playing, paused, stopped)
+  - Volume changes from Spotify app
+
+#### 3. **PluginSource Model**
+The provider creates a `PluginSource` that represents the Spotify Connect audio source:
+
+**Static Properties:**
+- `id`: Provider instance ID
+- `name`: Display name (e.g., "Music Assistant")
+- `passive`: False (active audio source)
+
+**Dynamic Capabilities:**
+- `can_play_pause`: Enabled when Web API control available
+- `can_seek`: Enabled when Web API control available
+- `can_next_previous`: Enabled when Web API control available
+
+**Metadata:**
+- Updated in real-time from librespot events
+- Includes URI, title, artist, album, artwork URL
+
+#### 4. **Audio Pipeline**
+```
+librespot → PCM audio → ffmpeg → format conversion → Music Assistant Player
+```
+
+The provider streams audio through an async generator that:
+1. Starts librespot process
+2. Pipes audio through ffmpeg for format conversion
+3. Yields audio chunks to the player
+4. Handles cleanup on stream end
+
+## Multi-Instance Support
+
+Each Spotify Connect provider instance:
+- Runs its own librespot process
+- Has its own cache directory for credentials
+- Binds to a unique webservice port
+- Links to a specific Music Assistant player
+- Appears as a separate device in Spotify app
+
+This allows multiple Spotify Connect devices in one Music Assistant installation.
+
+## Authentication & Credentials
+
+### Credential Storage
+- **Location**: `{cache_dir}/credentials.json`
+- **Format**: Librespot proprietary format
+- **Contents**:
+  - `username`: Spotify account username/email
+  - Encrypted authentication tokens
+  - Device information
+
+### Authentication Flow
+1. User opens Spotify app and selects the Music Assistant device
+2. Spotify authenticates and establishes a Connect session
+3. librespot receives credentials and caches them locally
+4. Future connections reuse cached credentials automatically
+
+### Username Extraction
+The provider reads `credentials.json` to extract the logged-in username, which is used for matching with the Spotify music provider (see Playback Control below).
+
+## Playback Control Integration
+
+### Problem Statement
+By default, Spotify Connect is a **passive source** - it receives audio but Music Assistant cannot control playback (play/pause/next/previous/seek) because the Connect protocol is one-way.
+
+### Solution: Web API Integration
+When the Spotify account logged into Connect matches a configured Spotify music provider, the provider enables bidirectional control by using Spotify's Web API.
+
+### Architecture
+
+#### Username Matching Process
+1. **On Session Connected**: librespot reports username via events
+2. **Provider Lookup**: Search all providers for Spotify music provider
+3. **Username Comparison**: Match `credentials.json` username with Web API user
+4. **Capability Update**: Enable control callbacks if match found
+
+#### Timing Considerations
+- Spotify music provider may not be loaded during Connect initialization
+- Username match check happens when playback starts (`sink`/`playing` events)
+- This ensures music provider has time to initialize
+
+#### Callback Architecture
+
+**PluginSource Callbacks** (defined in `models/plugin.py`):
+```python
+on_play: Callable[[], Awaitable[None]] | None
+on_pause: Callable[[], Awaitable[None]] | None
+on_next: Callable[[], Awaitable[None]] | None
+on_previous: Callable[[], Awaitable[None]] | None
+on_seek: Callable[[int], Awaitable[None]] | None
+```
+
+**Flow:**
+1. User presses play/pause in Music Assistant UI
+2. Player controller checks if active source has callbacks
+3. If callbacks present, invoke them instead of player methods
+4. Callbacks forward commands to Spotify Web API
+5. Spotify app receives command and updates state
+
+#### Implementation Details
+
+**Provider Methods:**
+- `_check_spotify_provider_match()`: Finds matching Spotify provider
+- `_update_source_capabilities()`: Enables/disables capabilities and registers callbacks
+- `_on_play/pause/next/previous/seek()`: Callback implementations
+
+**Capability Flags:**
+```python
+# When Web API available:
+source.can_play_pause = True
+source.can_seek = True
+source.can_next_previous = True
+
+# Callbacks registered:
+source.on_play = self._on_play
+source.on_pause = self._on_pause
+# ... etc
+```
+
+**Web API Commands:**
+- `PUT /me/player/play` - Resume playback
+- `PUT /me/player/pause` - Pause playback
+- `POST /me/player/next` - Skip to next track
+- `POST /me/player/previous` - Skip to previous track
+- `PUT /me/player/seek?position_ms={ms}` - Seek to position
+
+### Event-Driven Updates
+
+The provider subscribes to events to maintain accurate state:
+
+**Events Monitored:**
+- `EventType.PROVIDERS_UPDATED`: Check for new Spotify provider
+- Custom session events: Update username and check for matches
+- Playback events (`sink`, `playing`): Trigger provider matching
+
+**State Changes:**
+- Session connected → Check for provider match
+- Session disconnected → Disable Web API control
+- Provider added/removed → Re-check matches
+
+### Deepcopy Handling
+
+The `PluginSource` contains unpicklable callbacks (functions, futures). To support player state serialization:
+
+**Problem**: Default `deepcopy` fails on callbacks
+**Solution**: `as_player_source()` method returns base `PlayerSource` without callbacks
+
+```python
+def as_player_source(self) -> PlayerSource:
+    """Return as basic PlayerSource without callbacks."""
+    return PlayerSource(
+        id=self.id,
+        name=self.name,
+        passive=self.passive,
+        can_play_pause=self.can_play_pause,
+        can_seek=self.can_seek,
+        can_next_previous=self.can_next_previous,
+    )
+```
+
+## Event Handling
+
+### Session Events
+
+**`session_connected`**
+- Triggered when Spotify app connects
+- Payload includes `user_name`
+- Actions:
+  - Store username
+  - Check for matching Spotify provider
+  - Enable Web API control if match found
+
+**`session_disconnected`**
+- Triggered when Spotify app disconnects
+- Actions:
+  - Clear username
+  - Disable Web API control
+  - Clear provider reference
+
+### Playback Events
+
+**`sink` / `playing`**
+- Indicates playback is starting
+- Actions:
+  - Check for provider match (if not already matched)
+  - Select this source on the player
+  - Mark source as in use
+
+### Metadata Events
+
+**`common_metadata_fields`**
+- Provides track information
+- Updates:
+  - URI (spotify:track:...)
+  - Title
+  - Artist
+  - Album
+  - Album artwork URL
+- Triggers player update to refresh UI
+
+**`volume_changed`**
+- Spotify app changed volume
+- Converts from Spotify scale (0-65535) to percentage (0-100)
+- Applies to linked Music Assistant player
+
+## Configuration
+
+### Provider Settings
+
+**`mass_player_id`** (required)
+- Music Assistant player to link with this Spotify Connect device
+- Only one Connect provider per player
+
+**`publish_name`** (optional)
+- Name displayed in Spotify app
+- Default: "Music Assistant"
+- Helps identify device when multiple instances exist
+
+
+### Cache Directory
+- Location: `{data_path}/spotify_connect/{instance_id}/`
+- Contains:
+  - `credentials.json`: Cached Spotify credentials
+  - `audio-cache/`: Temporary audio files
+  - Logs from librespot
+
+## Error Handling
+
+### librespot Process
+- Process crashes: Automatically cleaned up
+- Authentication failures: Logged as warnings
+- Network issues: librespot handles reconnection
+
+### Web API Commands
+- All commands wrapped in try/except
+- Failures logged as warnings
+- Raises exception to notify player controller
+
+### Volume Control
+- Unsupported on player: Logged at debug level
+- Invalid volume values: Clamped to 0-100 range
+
+## Code Organization
+
+### Main Class: `SpotifyConnectProvider`
+Inherits from `PluginProvider`
+
+**Key Methods:**
+- `handle_async_init()`: Setup provider, start webservice, load credentials
+- `unload()`: Cleanup, stop processes
+- `get_audio_stream()`: Provide audio to player
+- `get_source()`: Return PluginSource details
+
+**Event Handlers:**
+- `_handle_session_connected()`: Process session connect
+- `_handle_session_disconnected()`: Process session disconnect
+- `_handle_playback_started()`: Initialize playback
+- `_handle_metadata_update()`: Update track info
+- `_handle_volume_changed()`: Sync volume
+- `_handle_custom_webservice()`: Main event dispatcher
+
+**Playback Control:**
+- `_check_spotify_provider_match()`: Find matching provider
+- `_update_source_capabilities()`: Toggle control features
+- `_on_play/pause/next/previous/seek()`: Control callbacks
+
+**Utilities:**
+- `_load_cached_username()`: Read credentials file
+- `_get_active_plugin_source()`: Find active source by `in_use_by`
+
+## Dependencies
+
+### External Binaries
+- **librespot**: Spotify Connect client implementation
+- **ffmpeg**: Audio format conversion
+
+### Python Packages
+- **aiohttp**: Async HTTP for webservice
+- **music_assistant_models**: Data models and enums
+
+### Music Assistant Integration
+- Player controller for command routing
+- Provider framework for lifecycle management
+- Event system for state synchronization
+
+## Testing
+
+### Basic Functionality
+1. Configure Spotify Connect provider with a Music Assistant player
+2. Open Spotify app and select the device
+3. Verify audio plays through the player
+4. Check metadata displays correctly
+
+### Web API Control
+1. Configure both Spotify Connect and Spotify music providers
+2. Use the same Spotify account for both
+3. Start playback from Spotify app
+4. Look for "Found matching Spotify music provider" in logs
+5. Verify control buttons are enabled in Music Assistant UI
+6. Test play/pause/next/previous/seek from Music Assistant
+
+### Multi-Instance
+1. Create multiple Spotify Connect providers
+2. Link each to different players
+3. Verify each appears as separate device in Spotify app
+4. Test simultaneous playback on different devices
+
+## Future Enhancements
+
+### Potential Improvements
+1. **Queue Sync**: Sync Spotify queue with Music Assistant queue
+2. **Crossfade Support**: Enable crossfade if supported by player
+3. **Audio Quality**: Make bitrate configurable
+4. **Multi-Account**: Support multiple Spotify accounts per device
+5. **Enhanced Metadata**: Chapter markers, lyrics integration
+6. **Gapless Playback**: Improve transitions between tracks
+
+### Known Limitations
+1. Cannot control playback without matching Spotify provider
+2. No access to user's Spotify playlists/library (use Spotify provider)
+3. Volume control only works if player supports it
+4. Seek requires Web API (not available in passive mode)
+5. No native gapless playback support
+
+## Related Documentation
+
+- **PluginSource Model**: See `music_assistant/models/plugin.py`
+- **Player Controller**: See `music_assistant/controllers/players/player_controller.py`
+- **Spotify Provider**: See `music_assistant/providers/spotify/`
+- **librespot**: https://github.com/librespot-org/librespot
+
+---
+
+*This architecture document is maintained alongside the code and should be updated when significant changes are made to the provider's design or functionality.*
index bdb23b2e9e6e4f9cae4cdbce7a2753f53dfa8f2a..e6f8e016f15e1328bef50bb6f9cac95c98512b89 100644 (file)
@@ -23,6 +23,7 @@ from music_assistant_models.enums import (
     EventType,
     MediaType,
     ProviderFeature,
+    ProviderType,
     StreamType,
 )
 from music_assistant_models.errors import UnsupportedFeaturedException
@@ -42,10 +43,12 @@ if TYPE_CHECKING:
 
     from music_assistant.mass import MusicAssistant
     from music_assistant.models import ProviderInstanceType
+    from music_assistant.providers.spotify.provider import SpotifyProvider
 
 CONF_MASS_PLAYER_ID = "mass_player_id"
 CONF_HANDOFF_MODE = "handoff_mode"
 CONNECT_ITEM_ID = "spotify_connect"
+CONF_PUBLISH_NAME = "publish_name"
 
 EVENTS_SCRIPT = pathlib.Path(__file__).parent.resolve().joinpath("events.py")
 
@@ -82,10 +85,19 @@ async def get_config_entries(
             multi_value=False,
             options=[
                 ConfigValueOption(x.display_name, x.player_id)
-                for x in mass.players.all(False, False)
+                for x in sorted(
+                    mass.players.all(False, False), key=lambda p: p.display_name.lower()
+                )
             ],
             required=True,
         ),
+        ConfigEntry(
+            key=CONF_PUBLISH_NAME,
+            type=ConfigEntryType.STRING,
+            label="Name to display in the Spotify app",
+            description="How should this Spotify Connect device be named in the Spotify app?",
+            default_value="Music Assistant",
+        ),
         # ConfigEntry(
         #     key=CONF_HANDOFF_MODE,
         #     type=ConfigEntryType.BOOLEAN,
@@ -124,13 +136,14 @@ class SpotifyConnectProvider(PluginProvider):
         self._librespot_proc: AsyncProcess | None = None
         self._librespot_started = asyncio.Event()
         self.named_pipe = f"/tmp/{self.instance_id}"  # noqa: S108
+        connect_name = cast("str", self.config.get_value(CONF_PUBLISH_NAME)) or self.name
         self._source_details = PluginSource(
             id=self.instance_id,
-            name=self.manifest.name,
+            name=self.name,
             # we set passive to true because we
             # dont allow this source to be selected directly
             passive=True,
-            # TODO: implement controlling spotify from MA itself
+            # Playback control capabilities will be enabled when Spotify Web API is available
             can_play_pause=False,
             can_seek=False,
             can_next_previous=False,
@@ -142,23 +155,16 @@ class SpotifyConnectProvider(PluginProvider):
                 channels=2,
             ),
             metadata=PlayerMedia(
-                "Spotify Connect",
+                f"Spotify Connect | {connect_name}",
             ),
             stream_type=StreamType.NAMED_PIPE,
             path=self.named_pipe,
         )
         self._audio_buffer: asyncio.Queue[bytes] = asyncio.Queue(10)
-        self._on_unload_callbacks: list[Callable[..., None]] = [
-            self.mass.subscribe(
-                self._on_mass_player_event,
-                (EventType.PLAYER_ADDED, EventType.PLAYER_REMOVED),
-                id_filter=self.mass_player_id,
-            ),
-            self.mass.streams.register_dynamic_route(
-                f"/{self.instance_id}",
-                self._handle_custom_webservice,
-            ),
-        ]
+        # Web API integration for playback control
+        self._connected_spotify_username: str | None = None
+        self._spotify_provider: SpotifyProvider | None = None
+        self._on_unload_callbacks: list[Callable[..., None]] = []
         self._runner_error_count = 0
 
     async def handle_async_init(self) -> None:
@@ -168,6 +174,27 @@ class SpotifyConnectProvider(PluginProvider):
         if self.player:
             self._setup_player_daemon()
 
+        # Subscribe to events
+        self._on_unload_callbacks.append(
+            self.mass.subscribe(
+                self._on_mass_player_event,
+                (EventType.PLAYER_ADDED, EventType.PLAYER_REMOVED),
+                id_filter=self.mass_player_id,
+            )
+        )
+        self._on_unload_callbacks.append(
+            self.mass.subscribe(
+                self._on_provider_event,
+                (EventType.PROVIDERS_UPDATED),
+            )
+        )
+        self._on_unload_callbacks.append(
+            self.mass.streams.register_dynamic_route(
+                f"/{self.instance_id}",
+                self._handle_custom_webservice,
+            )
+        )
+
     async def unload(self, is_removed: bool = False) -> None:
         """Handle close/cleanup of the provider."""
         self._stop_called = True
@@ -182,6 +209,128 @@ class SpotifyConnectProvider(PluginProvider):
         """Get (audio)source details for this plugin."""
         return self._source_details
 
+    async def _check_spotify_provider_match(self) -> None:
+        """Check if a Spotify music provider is available with matching username."""
+        # Username must be available (set from librespot output)
+        if not self._connected_spotify_username:
+            return
+
+        # Look for a Spotify music provider with matching username
+        for provider in self.mass.get_providers():
+            if provider.domain == "spotify" and provider.type == ProviderType.MUSIC:
+                # Check if the username matches
+                if hasattr(provider, "_sp_user") and provider._sp_user:
+                    spotify_username = provider._sp_user.get("id")
+                    if spotify_username == self._connected_spotify_username:
+                        self.logger.debug(
+                            "Found matching Spotify music provider - "
+                            "enabling playback control via Web API"
+                        )
+                        self._spotify_provider = cast("SpotifyProvider", provider)
+                        self._update_source_capabilities()
+                        return
+
+        # No matching provider found
+        if self._spotify_provider is not None:
+            self.logger.debug(
+                "Spotify music provider no longer available - disabling playback control"
+            )
+            self._spotify_provider = None
+            self._update_source_capabilities()
+
+    def _update_source_capabilities(self) -> None:
+        """Update source capabilities based on Web API availability."""
+        has_web_api = self._spotify_provider is not None
+        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
+
+        # Register or unregister callbacks based on availability
+        if has_web_api:
+            self._source_details.on_play = self._on_play
+            self._source_details.on_pause = self._on_pause
+            self._source_details.on_next = self._on_next
+            self._source_details.on_previous = self._on_previous
+            self._source_details.on_seek = self._on_seek
+        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
+
+        # 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_play(self) -> None:
+        """Handle play command via Spotify Web API."""
+        if not self._spotify_provider:
+            raise UnsupportedFeaturedException(
+                "Playback control requires a matching Spotify music provider"
+            )
+        try:
+            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)
+            raise
+
+    async def _on_pause(self) -> None:
+        """Handle pause command via Spotify Web API."""
+        if not self._spotify_provider:
+            raise UnsupportedFeaturedException(
+                "Playback control requires a matching Spotify music provider"
+            )
+        try:
+            await self._spotify_provider._put_data("me/player/pause")
+        except Exception as err:
+            self.logger.warning("Failed to send pause command via Spotify Web API: %s", err)
+            raise
+
+    async def _on_next(self) -> None:
+        """Handle next track command via Spotify Web API."""
+        if not self._spotify_provider:
+            raise UnsupportedFeaturedException(
+                "Playback control requires a matching Spotify music provider"
+            )
+        try:
+            await self._spotify_provider._post_data("me/player/next", want_result=False)
+        except Exception as err:
+            self.logger.warning("Failed to send next track command via Spotify Web API: %s", err)
+            raise
+
+    async def _on_previous(self) -> None:
+        """Handle previous track command via Spotify Web API."""
+        if not self._spotify_provider:
+            raise UnsupportedFeaturedException(
+                "Playback control requires a matching Spotify music provider"
+            )
+        try:
+            await self._spotify_provider._post_data("me/player/previous")
+        except Exception as err:
+            self.logger.warning("Failed to send previous command via Spotify Web API: %s", err)
+            raise
+
+    async def _on_seek(self, position: int) -> None:
+        """Handle seek command via Spotify Web API."""
+        if not self._spotify_provider:
+            raise UnsupportedFeaturedException(
+                "Playback control requires a matching Spotify music provider"
+            )
+        try:
+            # Spotify Web API expects position in milliseconds
+            position_ms = position * 1000
+            await self._spotify_provider._put_data(f"me/player/seek?position_ms={position_ms}")
+        except Exception as err:
+            self.logger.warning("Failed to send seek command via Spotify Web API: %s", err)
+            raise
+
+    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
+        if self._connected_spotify_username:
+            self.mass.create_task(self._check_spotify_provider_match())
+
     async def _librespot_runner(self) -> None:
         """Run the spotify connect daemon in a background task."""
         assert self._librespot_bin
@@ -195,7 +344,7 @@ class SpotifyConnectProvider(PluginProvider):
             args: list[str] = [
                 self._librespot_bin,
                 "--name",
-                self.name,
+                cast("str", self.config.get_value(CONF_PUBLISH_NAME)) or self.name,
                 "--cache",
                 self.cache_dir,
                 "--disable-audio-cache",
@@ -238,6 +387,32 @@ class SpotifyConnectProvider(PluginProvider):
                     continue
                 if "couldn't parse packet from " in line:
                     continue
+                if "Authenticated as '" in line:
+                    # Extract username from librespot authentication message
+                    # Format: "Authenticated as 'username'"
+                    try:
+                        parts = line.split("Authenticated as '")
+                        if len(parts) > 1:
+                            username_part = parts[1].split("'")
+                            if len(username_part) > 0 and username_part[0]:
+                                username = username_part[0]
+                                self._connected_spotify_username = username
+                                self.logger.debug("Authenticated to Spotify as: %s", username)
+                                # Check for provider match now that we have the username
+                                self.mass.create_task(self._check_spotify_provider_match())
+                            else:
+                                self.logger.warning(
+                                    "Could not parse Spotify username from line: %s", line
+                                )
+                        else:
+                            self.logger.warning(
+                                "Could not parse Spotify username from line: %s", line
+                            )
+                    except Exception as err:
+                        self.logger.warning(
+                            "Error parsing Spotify username from line: %s - %s", line, err
+                        )
+                    continue
                 self.logger.debug(line)
         finally:
             await librespot.close(True)
@@ -269,19 +444,47 @@ class SpotifyConnectProvider(PluginProvider):
             self._setup_player_daemon()
             return
 
-    async def _handle_custom_webservice(self, request: Request) -> Response:
+    async def _handle_custom_webservice(self, request: Request) -> Response:  # noqa: PLR0915
         """Handle incoming requests on the custom webservice."""
         json_data = await request.json()
         self.logger.debug("Received metadata on webservice: \n%s", json_data)
 
         event_name = json_data.get("event")
 
+        # handle session connected event
+        # extract the connected username and check for matching Spotify provider
+        if event_name == "session_connected":
+            username = json_data.get("user_name")
+            self.logger.debug(
+                "Session connected event - username from event: %s, current username: %s",
+                username,
+                self._connected_spotify_username,
+            )
+            if username and username != self._connected_spotify_username:
+                self.logger.info("Spotify Connect session connected for user: %s", username)
+                self._connected_spotify_username = username
+                await self._check_spotify_provider_match()
+            elif not username:
+                self.logger.warning("Session connected event received but no username in payload")
+
+        # handle session disconnected event
+        if event_name == "session_disconnected":
+            self.logger.info("Spotify Connect session disconnected")
+            self._connected_spotify_username = None
+            if self._spotify_provider is not None:
+                self._spotify_provider = None
+                self._update_source_capabilities()
+
         # handle session connected event
         # this player has become the active spotify connect player
         # we need to start the playback
         if event_name in ("sink", "playing") and (not self._source_details.in_use_by):
+            # Check for matching Spotify provider now that playback is starting
+            # This ensures the Spotify music provider has had time to initialize
+            if not self._connected_spotify_username or not self._spotify_provider:
+                await self._check_spotify_provider_match()
+
             # initiate playback by selecting this source on the default player
-            self.logger.debug("Initiating playback on %s", self.mass_player_id)
             self.mass.create_task(
                 self.mass.players.select_source(self.mass_player_id, self.instance_id)
             )
@@ -307,6 +510,9 @@ class SpotifyConnectProvider(PluginProvider):
             self._source_details.metadata.artist = artist
             self._source_details.metadata.album = album
             self._source_details.metadata.image_url = image_url
+            if self._source_details.in_use_by:
+                # tell connected player to update metadata
+                self.mass.players.trigger_player_update(self._source_details.in_use_by)
 
         if event_name == "volume_changed" and (volume := json_data.get("volume")):
             # Spotify Connect volume is 0-65535
index 16a4617e9c12c15c7f9e45823415a4c3a5d2ab8c..8dfadfc33611bd0b9ef1506535d84a83b38a1883 100644 (file)
@@ -4,6 +4,7 @@ from __future__ import annotations
 
 import asyncio
 import statistics
+import struct
 import time
 from collections import deque
 from collections.abc import Iterator
@@ -99,6 +100,10 @@ class SqueezelitePlayer(Player):
         self.multi_client_stream: MultiClientStream | None = None
         self._sync_playpoints: deque[SyncPlayPoint] = deque(maxlen=MIN_REQ_PLAYPOINTS)
         self._do_not_resync_before: float = 0.0
+        # TEMP: patch slimclient send_strm to adjust buffer thresholds
+        # this can be removed when we did a new release of aioslimproto with this change
+        # after this has been tested in beta for a while
+        client._send_strm = lambda *args, **kwargs: _patched_send_strm(client, *args, **kwargs)
 
     async def on_config_updated(self) -> None:
         """Handle logic when the player is registered or the config was updated."""
@@ -671,3 +676,41 @@ class SqueezelitePlayer(Player):
         for member_id in self.group_members:
             if slimplayer := self.provider.slimproto.get_player(member_id):
                 yield slimplayer
+
+
+async def _patched_send_strm(  # noqa: PLR0913
+    self,
+    command: bytes = b"q",
+    autostart: bytes = b"0",
+    codec_details: bytes = b"p1321",
+    threshold: int = 0,
+    spdif: bytes = b"0",
+    trans_duration: int = 0,
+    trans_type: bytes = b"0",
+    flags: int = 0x20,
+    output_threshold: int = 0,
+    replay_gain: int = 0,
+    server_port: int = 0,
+    server_ip: int = 0,
+    httpreq: bytes = b"",
+) -> None:
+    """Create stream request message based on given arguments."""
+    threshold = 64  # KB of input buffer data before autostart or notify
+    output_threshold = 1  # amount of output buffer data before playback starts, in tenths of second
+    data = struct.pack(
+        "!cc5sBcBcBBBLHL",
+        command,
+        autostart,
+        codec_details,
+        threshold,
+        spdif,
+        trans_duration,
+        trans_type,
+        flags,
+        output_threshold,
+        0,
+        replay_gain,
+        server_port,
+        server_ip,
+    )
+    await self.send_frame(b"strm", data + httpreq)