From: Marcel van der Veldt Date: Sun, 26 Oct 2025 02:37:44 +0000 (+0100) Subject: Plugin source improvements (#2548) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=c17e048d25c6faf5da04345ef19c868235379801;p=music-assistant-server.git Plugin source improvements (#2548) --- diff --git a/music_assistant/controllers/players/player_controller.py b/music_assistant/controllers/players/player_controller.py index e0a3753c..9301424c 100644 --- a/music_assistant/controllers/players/player_controller.py +++ b/music_assistant/controllers/players/player_controller.py @@ -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( diff --git a/music_assistant/controllers/streams.py b/music_assistant/controllers/streams.py index 5774005e..b3dbdbb1 100644 --- a/music_assistant/controllers/streams.py +++ b/music_assistant/controllers/streams.py @@ -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( diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index ad59cf1c..a7b6454c 100644 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -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 diff --git a/music_assistant/models/plugin.py b/music_assistant/models/plugin.py index 55f24aa8..35006823 100644 --- a/music_assistant/models/plugin.py +++ b/music_assistant/models/plugin.py @@ -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 diff --git a/music_assistant/providers/sonos/player.py b/music_assistant/providers/sonos/player.py index 1e252148..be99014b 100644 --- a/music_assistant/providers/sonos/player.py +++ b/music_assistant/providers/sonos/player.py @@ -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") diff --git a/music_assistant/providers/spotify/provider.py b/music_assistant/providers/spotify/provider.py index 9da24abb..48a7f1d3 100644 --- a/music_assistant/providers/spotify/provider.py +++ b/music_assistant/providers/spotify/provider.py @@ -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 index 00000000..c739a9c8 --- /dev/null +++ b/music_assistant/providers/spotify_connect/ARCHITECTURE.md @@ -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.* diff --git a/music_assistant/providers/spotify_connect/__init__.py b/music_assistant/providers/spotify_connect/__init__.py index bdb23b2e..e6f8e016 100644 --- a/music_assistant/providers/spotify_connect/__init__.py +++ b/music_assistant/providers/spotify_connect/__init__.py @@ -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 diff --git a/music_assistant/providers/squeezelite/player.py b/music_assistant/providers/squeezelite/player.py index 16a4617e..8dfadfc3 100644 --- a/music_assistant/providers/squeezelite/player.py +++ b/music_assistant/providers/squeezelite/player.py @@ -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)