"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]:
- 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)
- 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)
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)
"""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)
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]:
) -> 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(
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
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."""
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,
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(
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
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
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
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
"""
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,
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):
"""
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
import asyncio
import time
from collections import deque
-from copy import deepcopy
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from music_assistant_models.enums import (
ConfigEntryType,
EventType,
+ MediaType,
PlaybackState,
PlayerFeature,
RepeatMode,
: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...
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/"
)
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")
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())
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
--- /dev/null
+# 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.*
EventType,
MediaType,
ProviderFeature,
+ ProviderType,
StreamType,
)
from music_assistant_models.errors import UnsupportedFeaturedException
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")
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,
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,
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:
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
"""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
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",
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)
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)
)
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
import asyncio
import statistics
+import struct
import time
from collections import deque
from collections.abc import Iterator
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."""
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)