From: Marcel van der Veldt Date: Fri, 7 Nov 2025 00:04:14 +0000 (+0100) Subject: Add Airport Receiver Plugin provider (#2604) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=fcc05078e9f07702eb0fb84bcd5a995cce90eb12;p=music-assistant-server.git Add Airport Receiver Plugin provider (#2604) --- diff --git a/Dockerfile.base b/Dockerfile.base index ead2079e..6975b5df 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -119,9 +119,20 @@ RUN set -x \ # cifs utils and libnfs are needed for smb and nfs support (file provider) cifs-utils \ libnfs13 \ - # openssl is needed for airplay + # airplay libraries openssl \ libssl-dev \ + libuuid1 \ + libcurl4 \ + libsodium23 \ + libconfuse2 \ + libunistring2 \ + libxml2 \ + libevent-dev \ + libjson-c5 \ + libplist3 \ + libgcrypt20 \ + libavfilter8 \ # libsndfile needed for librosa audio file support (smartfades) libsndfile1 \ # Audio codec runtime libraries (needed for FFmpeg) @@ -155,6 +166,9 @@ RUN set -x \ libflac12 \ libavahi-client3 \ libavahi-common3 \ + # AirPlay receiver dependencies (shairport-sync) + libconfig9 \ + libpopt0 \ # pkg-config needed for PyAV (for aioresonate) to find system FFmpeg pkg-config \ && apt-get clean \ diff --git a/music_assistant/controllers/players/player_controller.py b/music_assistant/controllers/players/player_controller.py index 7ab5abf4..fdfba724 100644 --- a/music_assistant/controllers/players/player_controller.py +++ b/music_assistant/controllers/players/player_controller.py @@ -43,7 +43,6 @@ from music_assistant_models.errors import ( PlayerCommandFailed, PlayerUnavailableError, ProviderUnavailableError, - QueueEmpty, UnsupportedFeaturedException, ) from music_assistant_models.player_control import PlayerControl # noqa: TC002 @@ -1004,13 +1003,15 @@ class PlayerController(CoreController): await player.play_media(media) @api_command("players/cmd/select_source") - async def select_source(self, player_id: str, source: str) -> None: + async def select_source(self, player_id: str, source: str | None) -> None: """ Handle SELECT SOURCE command on given player. - player_id: player_id of the player to handle the command. - source: The ID of the source that needs to be activated/selected. """ + if source is None: + source = player_id # default to MA queue source player = self.get(player_id, True) assert player is not None # for type checking if player.synced_to or player.active_group: @@ -1031,15 +1032,8 @@ class PlayerController(CoreController): return # check if source is a mass queue # this can be used to restore the queue after a source switch - if mass_queue := self.mass.player_queues.get(source): - try: - player.set_active_mass_source(mass_queue.queue_id) - await self.mass.player_queues.play(mass_queue.queue_id) - except QueueEmpty: - # queue is empty: we just set the active source optimistically - # this does not cover all edge cases, but is better than failing completely - player._attr_active_source = mass_queue.queue_id - player.update_state() + if self.mass.player_queues.get(source): + player.set_active_mass_source(source) return # basic check if player supports source selection if PlayerFeature.SELECT_SOURCE not in player.supported_features: diff --git a/music_assistant/helpers/images.py b/music_assistant/helpers/images.py index 1900bb51..9b7926a9 100644 --- a/music_assistant/helpers/images.py +++ b/music_assistant/helpers/images.py @@ -18,6 +18,7 @@ from PIL import Image, UnidentifiedImageError from music_assistant.helpers.tags import get_embedded_image from music_assistant.models.metadata_provider import MetadataProvider from music_assistant.models.music_provider import MusicProvider +from music_assistant.models.plugin import PluginProvider if TYPE_CHECKING: from music_assistant_models.media_items import MediaItemImage @@ -30,7 +31,7 @@ async def get_image_data(mass: MusicAssistant, path_or_url: str, provider: str) """Create thumbnail from image url.""" # TODO: add local cache here ! if prov := mass.get_provider(provider): - assert isinstance(prov, MusicProvider | MetadataProvider) + assert isinstance(prov, MusicProvider | MetadataProvider | PluginProvider) if resolved_image := await prov.resolve_image(path_or_url): if isinstance(resolved_image, bytes): return resolved_image diff --git a/music_assistant/helpers/process.py b/music_assistant/helpers/process.py index 46df59f8..c761ad31 100644 --- a/music_assistant/helpers/process.py +++ b/music_assistant/helpers/process.py @@ -45,8 +45,17 @@ class AsyncProcess: stdout: bool | int | None = None, stderr: bool | int | None = False, name: str | None = None, + env: dict[str, str] | None = None, ) -> None: - """Initialize AsyncProcess.""" + """Initialize AsyncProcess. + + :param args: Command and arguments to execute. + :param stdin: Stdin configuration (True for PIPE, False for None, or custom). + :param stdout: Stdout configuration (True for PIPE, False for None, or custom). + :param stderr: Stderr configuration (True for PIPE, False for DEVNULL, or custom). + :param name: Process name for logging. + :param env: Environment variables for the subprocess (None inherits parent env). + """ self.proc: asyncio.subprocess.Process | None = None if name is None: name = args[0].split(os.sep)[-1] @@ -56,6 +65,7 @@ class AsyncProcess: self._stdin = None if stdin is False else stdin self._stdout = None if stdout is False else stdout self._stderr = asyncio.subprocess.DEVNULL if stderr is False else stderr + self._env = env self._stderr_lock = asyncio.Lock() self._stdout_lock = asyncio.Lock() self._stdin_lock = asyncio.Lock() @@ -102,6 +112,7 @@ class AsyncProcess: stdin=asyncio.subprocess.PIPE if self._stdin is True else self._stdin, stdout=asyncio.subprocess.PIPE if self._stdout is True else self._stdout, stderr=asyncio.subprocess.PIPE if self._stderr is True else self._stderr, + env=self._env, ) self.logger.log( VERBOSE_LOG_LEVEL, "Process %s started with PID %s", self.name, self.proc.pid diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index 2a54514b..231275f7 100644 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -1436,6 +1436,7 @@ class Player(ABC): so we can restore it when needed (e.g. after switching to a plugin source). """ self.__active_mass_source = value + self.update_state() def __hash__(self) -> int: """Return a hash of the Player.""" diff --git a/music_assistant/models/plugin.py b/music_assistant/models/plugin.py index ca823a45..3a62ab48 100644 --- a/music_assistant/models/plugin.py +++ b/music_assistant/models/plugin.py @@ -166,3 +166,12 @@ class PluginProvider(Provider): """ yield b"" raise NotImplementedError + + async def resolve_image(self, path: str) -> str | bytes: + """ + Resolve an image from an image path. + + This either returns (a generator to get) raw bytes of the image or + a string with an http(s) URL or local path that is accessible from the server. + """ + return path diff --git a/music_assistant/providers/airplay/provider.py b/music_assistant/providers/airplay/provider.py index 0a9701f1..4190f753 100644 --- a/music_assistant/providers/airplay/provider.py +++ b/music_assistant/providers/airplay/provider.py @@ -144,13 +144,22 @@ class AirPlayProvider(PlayerProvider): else: manufacturer, model = "Unknown", "Unknown" - if not self.mass.config.get_raw_player_config_value(player_id, "enabled", True): - self.logger.debug("Ignoring %s in discovery as it is disabled.", display_name) - return - address = get_primary_ip_address_from_zeroconf(discovery_info) if not address: return # should not happen, but guard just in case + + # Filter out shairport-sync instances running on THIS Music Assistant server + # These are managed by the AirPlay Receiver provider, not the AirPlay provider + # We check both model name AND that it's a local address to avoid filtering + # shairport-sync instances running on other machines + if model == "ShairportSync": + # Check if this is a local address (127.x.x.x or matches our server's IP) + if address.startswith("127.") or address == self.mass.streams.publish_ip: + return + + if not self.mass.config.get_raw_player_config_value(player_id, "enabled", True): + self.logger.debug("Ignoring %s in discovery as it is disabled.", display_name) + return if not discovery_info: return # should not happen, but guard just in case diff --git a/music_assistant/providers/airplay_receiver/__init__.py b/music_assistant/providers/airplay_receiver/__init__.py new file mode 100644 index 00000000..005dcb0b --- /dev/null +++ b/music_assistant/providers/airplay_receiver/__init__.py @@ -0,0 +1,540 @@ +""" +AirPlay Receiver plugin for Music Assistant. + +This plugin allows Music Assistant to receive AirPlay audio streams +and use them as a source for any player. It uses shairport-sync to +receive the AirPlay streams and outputs them as PCM audio. + +The provider has multi-instance support, so multiple AirPlay receivers +can be configured with different names. +""" + +from __future__ import annotations + +import asyncio +import os +import time +from collections.abc import Callable +from contextlib import suppress +from typing import TYPE_CHECKING, Any, cast + +from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption +from music_assistant_models.enums import ( + ConfigEntryType, + ContentType, + EventType, + ImageType, + ProviderFeature, + StreamType, +) +from music_assistant_models.errors import UnsupportedFeaturedException +from music_assistant_models.media_items import AudioFormat, MediaItemImage +from music_assistant_models.streamdetails import StreamMetadata + +from music_assistant.constants import CONF_ENTRY_WARN_PREVIEW, VERBOSE_LOG_LEVEL +from music_assistant.helpers.named_pipe import AsyncNamedPipeWriter +from music_assistant.helpers.process import AsyncProcess, check_output +from music_assistant.models.plugin import PluginProvider, PluginSource +from music_assistant.providers.airplay_receiver.helpers import get_shairport_sync_binary +from music_assistant.providers.airplay_receiver.metadata import MetadataReader + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ConfigValueType, ProviderConfig + from music_assistant_models.event import MassEvent + from music_assistant_models.provider import ProviderManifest + + from music_assistant.mass import MusicAssistant + from music_assistant.models import ProviderInstanceType + +CONF_MASS_PLAYER_ID = "mass_player_id" +CONF_AIRPLAY_NAME = "airplay_name" + +SUPPORTED_FEATURES = {ProviderFeature.AUDIO_SOURCE} + + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: + """Initialize provider(instance) with given configuration.""" + return AirPlayReceiverProvider(mass, manifest, config) + + +async def get_config_entries( + mass: MusicAssistant, + instance_id: str | None = None, # noqa: ARG001 + action: str | None = None, # noqa: ARG001 + values: dict[str, ConfigValueType] | None = None, # noqa: ARG001 +) -> tuple[ConfigEntry, ...]: + """ + Return Config entries to setup this provider. + + instance_id: id of an existing provider instance (None if new instance setup). + action: [optional] action key called from config entries UI. + values: the (intermediate) raw values for config entries sent with the action. + """ + return ( + CONF_ENTRY_WARN_PREVIEW, + ConfigEntry( + key=CONF_MASS_PLAYER_ID, + type=ConfigEntryType.STRING, + label="Connected Music Assistant Player", + description="Select the player that will play the AirPlay audio stream.", + multi_value=False, + options=[ + ConfigValueOption(x.display_name, x.player_id) + for x in sorted( + mass.players.all(False, False), key=lambda p: p.display_name.lower() + ) + ], + required=True, + ), + ConfigEntry( + key=CONF_AIRPLAY_NAME, + type=ConfigEntryType.STRING, + label="AirPlay Device Name", + description="How should this AirPlay receiver be named in the AirPlay device list?", + default_value="Music Assistant", + ), + ) + + +class AirPlayReceiverProvider(PluginProvider): + """Implementation of an AirPlay Receiver Plugin.""" + + def __init__( + self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig + ) -> None: + """Initialize MusicProvider.""" + super().__init__(mass, manifest, config, SUPPORTED_FEATURES) + self.mass_player_id = cast("str", self.config.get_value(CONF_MASS_PLAYER_ID)) + self._shairport_bin: str | None = None + self._stop_called: bool = False + self._runner_task: asyncio.Task[None] | None = None + self._shairport_proc: AsyncProcess | None = None + self._shairport_started = asyncio.Event() + # Initialize named pipe helpers + audio_pipe_path = f"/tmp/ma_airplay_audio_{self.instance_id}" # noqa: S108 + metadata_pipe_path = f"/tmp/ma_airplay_metadata_{self.instance_id}" # noqa: S108 + self.audio_pipe = AsyncNamedPipeWriter(audio_pipe_path, self.logger) + self.metadata_pipe = AsyncNamedPipeWriter(metadata_pipe_path, self.logger) + self.config_file = f"/tmp/ma_shairport_sync_{self.instance_id}.conf" # noqa: S108 + # Use port 7000+ for AirPlay 2 compatibility + # Each instance gets a unique port: 7000, 7001, 7002, etc. + self.airplay_port = 7000 + (hash(self.instance_id) % 1000) + airplay_name = cast("str", self.config.get_value(CONF_AIRPLAY_NAME)) or self.name + self._source_details = PluginSource( + id=self.instance_id, + name=self.name, + # Set passive to true because we don't allow this source to be selected directly + # It will be automatically selected when AirPlay playback starts + passive=True, + can_play_pause=False, + can_seek=False, + can_next_previous=False, + audio_format=AudioFormat( + content_type=ContentType.PCM_S16LE, + codec_type=ContentType.PCM_S16LE, + sample_rate=44100, + bit_depth=16, + channels=2, + ), + metadata=StreamMetadata( + title=f"AirPlay | {airplay_name}", + ), + stream_type=StreamType.NAMED_PIPE, + path=self.audio_pipe.path, + ) + self._on_unload_callbacks: list[Callable[..., None]] = [] + self._runner_error_count = 0 + self._metadata_reader: MetadataReader | None = None + self._first_volume_event_received = False # Track if we've received the first volume event + + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + self._shairport_bin = await get_shairport_sync_binary() + self.player = self.mass.players.get(self.mass_player_id) + if self.player: + self._setup_shairport_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, + ) + ) + + async def _stop_shairport_daemon(self) -> None: + """Stop the shairport-sync daemon without unloading the provider. + + This allows the provider to restart shairport-sync later when needed. + """ + # Stop metadata reader + if self._metadata_reader: + await self._metadata_reader.stop() + self._metadata_reader = None + + # Stop shairport-sync process + if self._runner_task and not self._runner_task.done(): + self._runner_task.cancel() + with suppress(asyncio.CancelledError): + await self._runner_task + self._runner_task = None + + # Reset the shairport process reference + self._shairport_proc = None + self._shairport_started.clear() + + async def unload(self, is_removed: bool = False) -> None: + """Handle close/cleanup of the provider.""" + self._stop_called = True + + # Stop shairport-sync daemon + await self._stop_shairport_daemon() + + # Cleanup callbacks + for callback in self._on_unload_callbacks: + callback() + + def get_source(self) -> PluginSource: + """Get (audio)source details for this plugin.""" + return self._source_details + + async def _create_config_file(self) -> None: + """Create shairport-sync configuration file from template.""" + # Read template + template_path = os.path.join(os.path.dirname(__file__), "bin", "shairport-sync.conf") + + def _read_template() -> str: + with open(template_path, encoding="utf-8") as f: + return f.read() + + template = await asyncio.to_thread(_read_template) + + # Replace placeholders + airplay_name = cast("str", self.config.get_value(CONF_AIRPLAY_NAME)) or self.name + config_content = template.replace("{AIRPLAY_NAME}", airplay_name) + config_content = config_content.replace("{METADATA_PIPE}", self.metadata_pipe.path) + config_content = config_content.replace("{AUDIO_PIPE}", self.audio_pipe.path) + config_content = config_content.replace("{PORT}", str(self.airplay_port)) + + # Set default volume based on player's current volume + # Convert player volume (0-100) to AirPlay volume (-30.0 to 0.0 dB) + player_volume = 100 # Default to 100% + if self.player and self.player.volume_level is not None: + player_volume = self.player.volume_level + # Map 0-100 to -30.0...0.0 + airplay_volume = (player_volume / 100.0) * 30.0 - 30.0 + config_content = config_content.replace("{DEFAULT_VOLUME}", f"{airplay_volume:.1f}") + + # Write config file + def _write_config() -> None: + with open(self.config_file, "w", encoding="utf-8") as f: + f.write(config_content) + + await asyncio.to_thread(_write_config) + + async def _setup_pipes_and_config(self) -> None: + """Set up named pipes and configuration file for shairport-sync. + + :raises: OSError if pipe or config file creation fails. + """ + # Remove any existing pipes and config + await self._cleanup_pipes_and_config() + + # Create named pipes for audio and metadata + await self.audio_pipe.create() + await self.metadata_pipe.create() + + # Create configuration file + await self._create_config_file() + + async def _cleanup_pipes_and_config(self) -> None: + """Clean up named pipes and configuration file.""" + await self.audio_pipe.remove() + await self.metadata_pipe.remove() + await check_output("rm", "-f", self.config_file) + + async def _write_silence_to_unblock_stream(self) -> None: + """Write silence to the audio pipe to unblock ffmpeg. + + When shairport-sync stops writing but ffmpeg is still reading, + writing silence will cause ffmpeg to output a chunk, which will + then check in_use_by and break out of the loop. + + We write enough silence to ensure ffmpeg outputs at least one chunk. + PCM_S16LE format: 2 bytes per sample, 2 channels, 44100 Hz + Writing 1 second of silence = 44100 * 2 * 2 = 176400 bytes + """ + self.logger.debug("Writing silence to audio pipe to unblock stream") + silence = b"\x00" * 176400 # 1 second of silence in PCM_S16LE stereo 44.1kHz + await self.audio_pipe.write(silence, log_slow_writes=False) + + def _process_shairport_log_line(self, line: str) -> None: + """Process a log line from shairport-sync stderr. + + :param line: The log line to process. + """ + # Check for fatal errors (log them, but process will exit on its own) + if "fatal error:" in line.lower() or "unknown option" in line.lower(): + self.logger.error("Fatal error from shairport-sync: %s", line) + return + # Log connection messages at INFO level, everything else at DEBUG + if "connection from" in line: + self.logger.info("AirPlay client connected: %s", line) + else: + # Note: Play begin/stop events are now handled via sessioncontrol hooks + # through the metadata pipe, so we don't need to parse stderr logs + self.logger.debug(line) + if not self._shairport_started.is_set(): + self._shairport_started.set() + + async def _shairport_runner(self) -> None: + """Run the shairport-sync daemon in a background task.""" + assert self._shairport_bin + self.logger.info("Starting AirPlay Receiver background daemon") + await self._setup_pipes_and_config() + + try: + args: list[str] = [ + self._shairport_bin, + "--configfile", + self.config_file, + ] + self._shairport_proc = shairport = AsyncProcess( + args, stderr=True, name=f"shairport-sync[{self.name}]" + ) + await shairport.start() + + # Check if process started successfully + await asyncio.sleep(0.1) + if shairport.returncode is not None: + self.logger.error( + "shairport-sync exited immediately with code %s", shairport.returncode + ) + return + + # Start metadata reader + self._metadata_reader = MetadataReader( + self.metadata_pipe.path, self.logger, self._on_metadata_update + ) + await self._metadata_reader.start() + + # Keep reading logging from stderr until exit + self.logger.debug("Starting to read shairport-sync stderr") + async for stderr_line in shairport.iter_stderr(): + line = stderr_line.strip() + self._process_shairport_log_line(line) + + finally: + await shairport.close() + self.logger.info( + "AirPlay Receiver background daemon stopped for %s (exit code: %s)", + self.name, + shairport.returncode, + ) + + # Stop metadata reader + if self._metadata_reader: + await self._metadata_reader.stop() + + # Clean up pipes and config + await self._cleanup_pipes_and_config() + + if not self._shairport_started.is_set(): + self.unload_with_error("Unable to initialize shairport-sync daemon.") + # Auto restart if not stopped manually + elif not self._stop_called and self._runner_error_count >= 5: + self.unload_with_error("shairport-sync daemon failed to start multiple times.") + elif not self._stop_called: + self._runner_error_count += 1 + self.mass.call_later(2, self._setup_shairport_daemon) + + def _setup_shairport_daemon(self) -> None: + """Handle setup of the shairport-sync daemon for a player.""" + self._shairport_started.clear() + self._runner_task = self.mass.create_task(self._shairport_runner()) + + def _on_mass_player_event(self, event: MassEvent) -> None: + """Handle incoming event from linked player.""" + if event.object_id != self.mass_player_id: + return + if event.event == EventType.PLAYER_REMOVED: + # Stop shairport-sync but keep the provider loaded + # so it can restart when the player comes back + self.mass.create_task(self._stop_shairport_daemon()) + return + if event.event == EventType.PLAYER_ADDED: + # Restart shairport-sync when the player is added back + self._setup_shairport_daemon() + return + + def _on_metadata_update(self, metadata: dict[str, Any]) -> None: + """Handle metadata updates from shairport-sync. + + :param metadata: Dictionary containing metadata updates. + """ + self.logger.log(VERBOSE_LOG_LEVEL, "Received metadata update: %s", metadata) + + # Handle play state changes from sessioncontrol hooks + if "play_state" in metadata: + self._handle_play_state_change(metadata["play_state"]) + return + + # Handle metadata start (new track starting) + if "metadata_start" in metadata: + return + + # Handle volume changes from AirPlay client + if "volume" in metadata and self._source_details.in_use_by: + self._handle_volume_change(metadata["volume"]) + + # Update source metadata fields + self._update_source_metadata(metadata) + + # Handle cover art updates + self._update_cover_art(metadata) + + # Signal update to connected player + if self._source_details.in_use_by: + self.mass.players.trigger_player_update(self._source_details.in_use_by) + + def _handle_play_state_change(self, play_state: str) -> None: + """Handle play state changes from sessioncontrol hooks. + + :param play_state: The new play state ("playing" or "stopped"). + """ + if play_state == "playing": + # Reset volume event flag for new playback session + self._first_volume_event_received = False + # Initiate playback by selecting this source on the default player + if not self._source_details.in_use_by: + self.mass.create_task( + self.mass.players.select_source(self.mass_player_id, self.instance_id) + ) + self._source_details.in_use_by = self.mass_player_id + elif play_state == "stopped": + self.logger.info("AirPlay playback stopped") + # Reset volume event flag for next session + self._first_volume_event_received = False + # Setting in_use_by to None will signal the stream to stop + self._source_details.in_use_by = None + # Write silence to the pipe to unblock ffmpeg + # This will cause ffmpeg to output a chunk, which will then check in_use_by + # and break out of the loop when it sees it's None + self.mass.create_task(self._write_silence_to_unblock_stream()) + # Deselect source from player + self.mass.create_task(self.mass.players.select_source(self.mass_player_id, None)) + + def _handle_volume_change(self, volume: int) -> None: + """Handle volume changes from AirPlay client (iOS/macOS device). + + ignore_volume_control = "yes" means shairport-sync doesn't do software volume control, + but we still receive volume level changes from the client to apply to the player. + + :param volume: The new volume level (0-100). + """ + # Skip the first volume event as it's the initial sync from default_airplay_volume + # We don't want to override the player's current volume on startup + if not self._first_volume_event_received: + self._first_volume_event_received = True + self.logger.debug( + "Received initial AirPlay volume (%s%%), skipping to preserve player volume", + volume, + ) + return + + # Type check: ensure we have a valid player ID + player_id = self._source_details.in_use_by + if not player_id: + return + + self.logger.debug( + "AirPlay client volume changed to %s%%, applying to player %s", + volume, + player_id, + ) + try: + self.mass.create_task(self.mass.players.cmd_volume_set(player_id, volume)) + except UnsupportedFeaturedException: + self.logger.debug("Player %s does not support volume control", player_id) + + def _update_source_metadata(self, metadata: dict[str, Any]) -> None: + """Update source metadata fields from AirPlay metadata. + + :param metadata: Dictionary containing metadata updates. + """ + # Initialize metadata if needed + if self._source_details.metadata is None: + airplay_name = cast("str", self.config.get_value(CONF_AIRPLAY_NAME)) or self.name + self._source_details.metadata = StreamMetadata(title=f"AirPlay | {airplay_name}") + + # Update individual metadata fields + if "title" in metadata: + self._source_details.metadata.title = metadata["title"] + + if "artist" in metadata: + self._source_details.metadata.artist = metadata["artist"] + + if "album" in metadata: + self._source_details.metadata.album = metadata["album"] + + if "duration" in metadata: + self._source_details.metadata.duration = metadata["duration"] + + if "elapsed_time" in metadata: + self._source_details.metadata.elapsed_time = metadata["elapsed_time"] + # Always set elapsed_time_last_updated to current time when we receive elapsed_time + self._source_details.metadata.elapsed_time_last_updated = time.time() + + def _update_cover_art(self, metadata: dict[str, Any]) -> None: + """Update cover art image URL from AirPlay metadata. + + :param metadata: Dictionary containing metadata updates. + """ + # Ensure metadata is initialized + if self._source_details.metadata is None: + return + + if "cover_art_timestamp" in metadata: + # Use timestamp as query parameter to create a unique URL for each cover art update + # This prevents browser caching issues when switching between tracks + timestamp = metadata["cover_art_timestamp"] + # Build image proxy URL for the cover art + # The actual image bytes are stored in the metadata reader + image = MediaItemImage( + type=ImageType.THUMB, + path="cover_art", + provider=self.instance_id, + remotely_accessible=False, + ) + base_url = self.mass.metadata.get_image_url(image) + # Append timestamp as query parameter for cache-busting + self._source_details.metadata.image_url = f"{base_url}&t={timestamp}" + elif self._metadata_reader and self._metadata_reader.cover_art_bytes: + # Maintain image URL if we have cover art but didn't receive it in this update + # This ensures the image URL persists across metadata updates + if not self._source_details.metadata.image_url: + # Generate timestamp for cache-busting even in fallback case + timestamp = str(int(time.time() * 1000)) + image = MediaItemImage( + type=ImageType.THUMB, + path="cover_art", + provider=self.instance_id, + remotely_accessible=False, + ) + base_url = self.mass.metadata.get_image_url(image) + self._source_details.metadata.image_url = f"{base_url}&t={timestamp}" + + async def resolve_image(self, path: str) -> bytes: + """Resolve an image from an image path. + + This returns raw bytes of the cover art image received from AirPlay metadata. + + :param path: The image path (should be "cover_art" for AirPlay cover art). + """ + if path == "cover_art" and self._metadata_reader and self._metadata_reader.cover_art_bytes: + return self._metadata_reader.cover_art_bytes + # Return empty bytes if no cover art is available + return b"" diff --git a/music_assistant/providers/airplay_receiver/bin/.gitkeep b/music_assistant/providers/airplay_receiver/bin/.gitkeep new file mode 100644 index 00000000..0f88c6f2 --- /dev/null +++ b/music_assistant/providers/airplay_receiver/bin/.gitkeep @@ -0,0 +1,2 @@ +# Place shairport-sync binaries here +# See README.md for instructions diff --git a/music_assistant/providers/airplay_receiver/bin/README.md b/music_assistant/providers/airplay_receiver/bin/README.md new file mode 100644 index 00000000..cb04a261 --- /dev/null +++ b/music_assistant/providers/airplay_receiver/bin/README.md @@ -0,0 +1,176 @@ +# Shairport-Sync Binaries + +This directory should contain the shairport-sync binaries for different platforms. + +## Required Binaries + +- `shairport-sync-macos-arm64` - macOS Apple Silicon +- `shairport-sync-linux-x86_64` - Linux x86_64 +- `shairport-sync-linux-aarch64` - Linux ARM64 (Raspberry Pi, etc.) + +## Installation Options + +### Option 1: System Package Manager (Recommended) + +The easiest way to use this plugin is to install shairport-sync via your system's package manager: + +**Debian/Ubuntu:** +```bash +apt-get update +apt-get install -y shairport-sync +``` + +**macOS (Homebrew):** +```bash +brew install shairport-sync +``` + +**Arch Linux:** +```bash +pacman -S shairport-sync +``` + +### Option 2: Build Static Binaries + +If you want to include pre-built binaries with Music Assistant, you'll need to build them yourself. See `build_binaries.sh` for a script that helps with this process. + +## Building Shairport-Sync + +### Prerequisites + +Shairport-sync requires several dependencies: +- OpenSSL +- Avahi (for mDNS/Bonjour) +- ALSA (Linux only) +- libpopt +- libconfig +- libsndfile +- libsoxr (optional, for resampling) + +### Build Instructions + +#### Linux (Static Build with musl) + +```bash +# Install dependencies +apk add --no-cache \ + build-base \ + git \ + autoconf \ + automake \ + libtool \ + alsa-lib-dev \ + libconfig-dev \ + popt-dev \ + openssl-dev \ + avahi-dev \ + libsndfile-dev \ + libsoxr-dev + +# Clone and build +git clone https://github.com/mikebrady/shairport-sync.git +cd shairport-sync +git checkout tags/4.3.7 # Use latest stable version +autoreconf -fi +./configure \ + --with-pipe \ + --with-metadata \ + --with-avahi \ + --with-ssl=openssl \ + --with-stdout \ + --with-soxr \ + LDFLAGS="-static" +make +strip shairport-sync + +# Copy to provider bin directory +cp shairport-sync ../music_assistant/providers/airplay_receiver/bin/shairport-sync-linux-$(uname -m) +``` + +#### macOS + +```bash +# Install dependencies +brew install autoconf automake libtool pkg-config openssl libsodium libsoxr popt libconfig + +# Clone and build +git clone https://github.com/mikebrady/shairport-sync.git +cd shairport-sync +git checkout tags/4.3.7 +autoreconf -fi +./configure \ + --with-pipe \ + --with-metadata \ + --with-ssl=openssl \ + --with-stdout \ + --with-soxr \ + PKG_CONFIG_PATH="/opt/homebrew/opt/openssl/lib/pkgconfig" +make +strip shairport-sync + +# Copy to provider bin directory +cp shairport-sync ../music_assistant/providers/airplay_receiver/bin/shairport-sync-macos-$(uname -m) +``` + +## Docker Integration + +For Docker deployments, it's recommended to add shairport-sync to the Music Assistant base Docker image (`Dockerfile.base`) instead of bundling binaries: + +```dockerfile +# Add to Dockerfile.base runtime stage +RUN apk add --no-cache \ + shairport-sync +``` + +Alternatively, build from source in the Docker image for the latest version. + +## Bundled Binaries + +This directory contains pre-built shairport-sync binaries for **local development only**. + +### macOS Binary +- **shairport-sync-macos-arm64** (~262 KB) + +⚠️ **Important**: The macOS binary requires Homebrew libraries to be installed: +```bash +brew install openssl libdaemon libconfig popt libao pulseaudio libsoxr +``` + +For macOS development, it's easier to just install shairport-sync via Homebrew: +```bash +brew install shairport-sync +``` + +### Linux Binaries (Alpine/musl) +- **shairport-sync-linux-x86_64** (~225 KB) +- **shairport-sync-linux-aarch64** (~261 KB) + +These binaries are built with Alpine Linux (musl libc). While musl binaries CAN technically run on glibc systems (Debian/Ubuntu), they require the musl interpreter and musl versions of their dependencies to be installed. + +**Recommendation:** For simplest deployment, install shairport-sync via your system's package manager instead of using these binaries. + +**If using bundled binaries on Debian/Ubuntu:** +The plugin's helper will use these binaries if found, but they may require additional packages. If you encounter issues, install shairport-sync via apt instead: +```bash +sudo apt-get install shairport-sync +``` + +**For local Linux development:** +```bash +# Debian/Ubuntu (recommended) +sudo apt-get install shairport-sync + +# Arch Linux +sudo pacman -S shairport-sync + +# Fedora +sudo dnf install shairport-sync +``` + +## Notes + +- The helper code in `helpers.py` will automatically: + 1. Check for bundled binaries in this directory first (macOS only) + 2. Fall back to system-installed shairport-sync in PATH +- For production deployments, always use the system package manager +- Static linking is not feasible due to shairport-sync's numerous dependencies (avahi, openssl, etc.) diff --git a/music_assistant/providers/airplay_receiver/bin/build_binaries.sh b/music_assistant/providers/airplay_receiver/bin/build_binaries.sh new file mode 100755 index 00000000..dbb67205 --- /dev/null +++ b/music_assistant/providers/airplay_receiver/bin/build_binaries.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +set -e + +# Build script for shairport-sync binaries across different platforms +# This script uses Docker to build binaries in isolated environments + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SHAIRPORT_VERSION="${SHAIRPORT_VERSION:-4.3.7}" + +echo "Building shairport-sync ${SHAIRPORT_VERSION} binaries..." + +# Function to build Linux binaries using Docker +build_linux() { + local arch="$1" + local platform="$2" + + echo "Building for Linux ${arch}..." + + docker run --rm \ + --platform "${platform}" \ + -v "${SCRIPT_DIR}:/output" \ + debian:bookworm-slim \ + /bin/bash -c " + set -e + + # Install build dependencies + # NOTE: Do NOT install libavahi-client-dev - we want tinysvcmdns instead + apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + git \ + autoconf \ + automake \ + libtool \ + pkg-config \ + libconfig-dev \ + libpopt-dev \ + libssl-dev \ + libdbus-1-dev \ + libglib2.0-dev \ + ca-certificates + + # Clone and checkout specific version + cd /tmp + git clone --depth 1 --branch ${SHAIRPORT_VERSION} https://github.com/mikebrady/shairport-sync.git + cd shairport-sync + + # Configure and build + # Build with tinysvcmdns (lightweight embedded mDNS, no external daemon needed) + autoreconf -fi + ./configure \ + --with-pipe \ + --with-metadata \ + --without-avahi \ + --without-dns-sd \ + --with-tinysvcmdns \ + --with-ssl=openssl \ + --with-stdout \ + --sysconfdir=/etc + + make -j\$(nproc) + + # Strip binary to reduce size + strip shairport-sync + + # Copy to output + cp shairport-sync /output/shairport-sync-linux-${arch} + chmod +x /output/shairport-sync-linux-${arch} + + # Show size + ls -lh /output/shairport-sync-linux-${arch} + " + + echo "✓ Built shairport-sync-linux-${arch}" +} + +# Function to build macOS binary +build_macos() { + if [[ "$(uname)" != "Darwin" ]]; then + echo "⚠ Skipping macOS build (must run on macOS)" + return + fi + + echo "Building for macOS arm64..." + + # Check if Homebrew is installed + if ! command -v brew &> /dev/null; then + echo "Error: Homebrew is required to build on macOS" + exit 1 + fi + + # Install dependencies + echo "Installing dependencies via Homebrew..." + brew list autoconf &> /dev/null || brew install autoconf + brew list automake &> /dev/null || brew install automake + brew list libtool &> /dev/null || brew install libtool + brew list pkg-config &> /dev/null || brew install pkg-config + brew list openssl &> /dev/null || brew install openssl + brew list popt &> /dev/null || brew install popt + brew list libconfig &> /dev/null || brew install libconfig + brew list libdaemon &> /dev/null || brew install libdaemon + + # Create temp directory + TEMP_DIR=$(mktemp -d) + cd "${TEMP_DIR}" + + # Clone and build + git clone --depth 1 --branch "${SHAIRPORT_VERSION}" https://github.com/mikebrady/shairport-sync.git + cd shairport-sync + + autoreconf -fi + + # On macOS, librt is not needed and doesn't exist - patch configure to skip the check + sed -i.bak 's/as_fn_error $? "librt needed" "$LINENO" 5/echo "librt check skipped on macOS"/' configure + + # Build with tinysvcmdns (lightweight embedded mDNS) for macOS + # Note: We still register via Music Assistant's Zeroconf, but shairport-sync + # needs some mDNS backend present to function properly + ./configure \ + --with-pipe \ + --with-metadata \ + --with-ssl=openssl \ + --with-stdout \ + --without-avahi \ + --without-dns-sd \ + --with-tinysvcmdns \ + --with-libdaemon \ + PKG_CONFIG_PATH="$(brew --prefix openssl)/lib/pkgconfig:$(brew --prefix libconfig)/lib/pkgconfig" \ + LDFLAGS="-L$(brew --prefix)/lib" \ + CFLAGS="-I$(brew --prefix)/include" \ + LIBS="-lm -lpthread -lssl -lcrypto -lconfig -lpopt" + + make -j$(sysctl -n hw.ncpu) + + # Strip binary + strip shairport-sync + + # Copy to output + cp shairport-sync "${SCRIPT_DIR}/shairport-sync-macos-$(uname -m)" + chmod +x "${SCRIPT_DIR}/shairport-sync-macos-$(uname -m)" + + # Cleanup + cd "${SCRIPT_DIR}" + rm -rf "${TEMP_DIR}" + + ls -lh "${SCRIPT_DIR}/shairport-sync-macos-$(uname -m)" + echo "✓ Built shairport-sync-macos-$(uname -m)" +} + +# Main build process +case "${1:-all}" in + linux-x86_64) + build_linux "x86_64" "linux/amd64" + ;; + linux-aarch64) + build_linux "aarch64" "linux/arm64" + ;; + macos) + build_macos + ;; + all) + build_linux "x86_64" "linux/amd64" + build_linux "aarch64" "linux/arm64" + build_macos + ;; + *) + echo "Usage: $0 {linux-x86_64|linux-aarch64|macos|all}" + echo + echo "Environment variables:" + echo " SHAIRPORT_VERSION - Version to build (default: 4.3.7)" + exit 1 + ;; +esac + +echo +echo "Build complete! Binaries are in:" +ls -lh "${SCRIPT_DIR}"/shairport-sync-* 2>/dev/null || echo "No binaries found" +echo +echo "Note: These binaries are dynamically linked. For Docker deployments," +echo "it's recommended to install shairport-sync via apk/apt instead." diff --git a/music_assistant/providers/airplay_receiver/bin/shairport-sync-linux-aarch64 b/music_assistant/providers/airplay_receiver/bin/shairport-sync-linux-aarch64 new file mode 100755 index 00000000..9a2afbc3 Binary files /dev/null and b/music_assistant/providers/airplay_receiver/bin/shairport-sync-linux-aarch64 differ diff --git a/music_assistant/providers/airplay_receiver/bin/shairport-sync-linux-x86_64 b/music_assistant/providers/airplay_receiver/bin/shairport-sync-linux-x86_64 new file mode 100755 index 00000000..79f4f35c Binary files /dev/null and b/music_assistant/providers/airplay_receiver/bin/shairport-sync-linux-x86_64 differ diff --git a/music_assistant/providers/airplay_receiver/bin/shairport-sync-macos-arm64 b/music_assistant/providers/airplay_receiver/bin/shairport-sync-macos-arm64 new file mode 100755 index 00000000..9b3874b5 Binary files /dev/null and b/music_assistant/providers/airplay_receiver/bin/shairport-sync-macos-arm64 differ diff --git a/music_assistant/providers/airplay_receiver/bin/shairport-sync.conf b/music_assistant/providers/airplay_receiver/bin/shairport-sync.conf new file mode 100644 index 00000000..43f8b309 --- /dev/null +++ b/music_assistant/providers/airplay_receiver/bin/shairport-sync.conf @@ -0,0 +1,36 @@ +// Shairport Sync configuration file template for Music Assistant +// This file will be copied and customized for each AirPlay receiver instance + +general = +{ + name = "{AIRPLAY_NAME}"; + ignore_volume_control = "yes"; + output_backend = "pipe"; + default_airplay_volume = {DEFAULT_VOLUME}; + port = {PORT}; + // Using tinysvcmdns (embedded mDNS, no external daemon required) +}; + +metadata = +{ + enabled = "yes"; + include_cover_art = "yes"; + pipe_name = "{METADATA_PIPE}"; + pipe_timeout = 0; // Don't timeout, keep pipe open +}; + +pipe = +{ + name = "{AUDIO_PIPE}"; +}; + +// Session control - run commands on playback events +// We use these instead of parsing verbose logs for better reliability +sessioncontrol = +{ + // Send markers to metadata pipe for play start/stop events + run_this_before_entering_active_state = "/bin/sh -c 'echo MA_PLAY_BEGIN > {METADATA_PIPE}'"; + run_this_after_exiting_active_state = "/bin/sh -c 'echo MA_PLAY_END > {METADATA_PIPE}'"; + active_state_timeout = 10.0; // Seconds of silence before considering playback ended + wait_for_completion = "no"; // Don't block playback waiting for commands +}; diff --git a/music_assistant/providers/airplay_receiver/helpers.py b/music_assistant/providers/airplay_receiver/helpers.py new file mode 100644 index 00000000..6f2b35df --- /dev/null +++ b/music_assistant/providers/airplay_receiver/helpers.py @@ -0,0 +1,44 @@ +"""Helpers/utils for the AirPlay Receiver plugin.""" + +from __future__ import annotations + +import os +import platform +import shutil + +from music_assistant.helpers.process import check_output + + +async def get_shairport_sync_binary() -> str: + """Find the shairport-sync binary (bundled or system-installed).""" + + async def check_shairport_sync(shairport_path: str) -> str | None: + """Check if shairport-sync binary is valid.""" + try: + returncode, _ = await check_output(shairport_path, "--version") + if returncode == 0: + return shairport_path + return None + except OSError: + return None + + # First, check if bundled binary exists + base_path = os.path.join(os.path.dirname(__file__), "bin") + system = platform.system().lower().replace("darwin", "macos") + architecture = platform.machine().lower() + + if shairport_binary := await check_shairport_sync( + os.path.join(base_path, f"shairport-sync-{system}-{architecture}") + ): + return shairport_binary + + # If no bundled binary, check system PATH + if system_binary := shutil.which("shairport-sync"): + if shairport_binary := await check_shairport_sync(system_binary): + return shairport_binary + + msg = ( + f"Unable to locate shairport-sync for {system}/{architecture}. " + "Please install shairport-sync on your system or provide a bundled binary." + ) + raise RuntimeError(msg) diff --git a/music_assistant/providers/airplay_receiver/icon.svg b/music_assistant/providers/airplay_receiver/icon.svg new file mode 100644 index 00000000..9ea089a7 --- /dev/null +++ b/music_assistant/providers/airplay_receiver/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/music_assistant/providers/airplay_receiver/manifest.json b/music_assistant/providers/airplay_receiver/manifest.json new file mode 100644 index 00000000..c5667108 --- /dev/null +++ b/music_assistant/providers/airplay_receiver/manifest.json @@ -0,0 +1,11 @@ +{ + "type": "plugin", + "domain": "airplay_receiver", + "stage": "alpha", + "name": "AirPlay Receiver", + "description": "Receive AirPlay audio streams and use them as a source in Music Assistant.", + "codeowners": ["@music-assistant"], + "documentation": "https://music-assistant.io/plugins/airplay-receiver/", + "multi_instance": true, + "requirements": [] +} diff --git a/music_assistant/providers/airplay_receiver/metadata.py b/music_assistant/providers/airplay_receiver/metadata.py new file mode 100644 index 00000000..973f754c --- /dev/null +++ b/music_assistant/providers/airplay_receiver/metadata.py @@ -0,0 +1,374 @@ +"""Metadata reader for shairport-sync metadata pipe.""" + +from __future__ import annotations + +import asyncio +import base64 +import os +import re +import struct +import time +from contextlib import suppress +from typing import TYPE_CHECKING, Any + +from music_assistant.constants import VERBOSE_LOG_LEVEL + +if TYPE_CHECKING: + from collections.abc import Callable + from logging import Logger + + +class MetadataReader: + """Read and parse metadata from shairport-sync metadata pipe.""" + + def __init__( + self, + metadata_pipe: str, + logger: Logger, + on_metadata: Callable[[dict[str, Any]], None] | None = None, + ) -> None: + """Initialize metadata reader. + + :param metadata_pipe: Path to the metadata pipe. + :param logger: Logger instance. + :param on_metadata: Callback function for metadata updates. + """ + self.metadata_pipe = metadata_pipe + self.logger = logger + self.on_metadata = on_metadata + self._reader_task: asyncio.Task[None] | None = None + self._stop = False + self._current_metadata: dict[str, Any] = {} + self._fd: int | None = None + self._buffer = "" + self.cover_art_bytes: bytes | None = None + + async def start(self) -> None: + """Start reading metadata from the pipe.""" + self._stop = False + self._reader_task = asyncio.create_task(self._read_metadata()) + + async def stop(self) -> None: + """Stop reading metadata.""" + self._stop = True + if self._reader_task and not self._reader_task.done(): + self._reader_task.cancel() + with suppress(asyncio.CancelledError): + await self._reader_task + + async def _read_metadata(self) -> None: + """Read metadata from the pipe using async file descriptor.""" + loop = asyncio.get_event_loop() + try: + # Open the metadata pipe in non-blocking mode + # Use O_RDONLY | O_NONBLOCK to avoid blocking on open + self._fd = await loop.run_in_executor( + None, os.open, self.metadata_pipe, os.O_RDONLY | os.O_NONBLOCK + ) + + # Create an asyncio.Event to signal when data is available + data_available = asyncio.Event() + + def on_readable() -> None: + """Set data available flag when file descriptor is readable.""" + data_available.set() + + # Register the file descriptor with the event loop + loop.add_reader(self._fd, on_readable) + + try: + while not self._stop: + # Wait for data to be available + await data_available.wait() + data_available.clear() + + # Read available data from the pipe + try: + chunk = os.read(self._fd, 4096) + if chunk: + # Decode as text and add to buffer + self._buffer += chunk.decode("utf-8", errors="ignore") + # Process all complete metadata items in the buffer + self._process_buffer() + except BlockingIOError: + # No data available right now, wait for next notification + continue + except OSError as err: + self.logger.debug("Error reading from pipe: %s", err) + await asyncio.sleep(0.1) + + finally: + # Remove the reader callback + loop.remove_reader(self._fd) + + except Exception as err: + self.logger.error("Error reading metadata pipe: %s", err) + finally: + if self._fd is not None: + with suppress(OSError): + os.close(self._fd) + self._fd = None + + def _process_buffer(self) -> None: + """Process all complete metadata items in the buffer (XML format or plain text markers).""" + # First, check for plain text markers from sessioncontrol hooks + while "\n" in self._buffer: + # Check if we have a complete line before any XML + line_end = self._buffer.index("\n") + if "" not in self._buffer or self._buffer.index("") > line_end: + # We have a plain text line before any XML + line = self._buffer[:line_end].strip() + self._buffer = self._buffer[line_end + 1 :] + + # Handle our custom markers + if line == "MA_PLAY_BEGIN": + self.logger.info("Playback started (via sessioncontrol hook)") + if self.on_metadata: + self.on_metadata({"play_state": "playing"}) + elif line == "MA_PLAY_END": + self.logger.info("Playback ended (via sessioncontrol hook)") + if self.on_metadata: + self.on_metadata({"play_state": "stopped"}) + # Ignore other plain text lines + else: + # XML item comes first, stop looking for lines + break + + # Look for complete ... blocks + while "" in self._buffer and "" in self._buffer: + try: + # Find the boundaries of the next item + start_idx = self._buffer.index("") + end_idx = self._buffer.index("") + len("") + + # Extract the item + item_xml = self._buffer[start_idx:end_idx] + + # Remove processed item from buffer + self._buffer = self._buffer[end_idx:] + + # Parse the item + self._parse_xml_item(item_xml) + + except (ValueError, IndexError) as err: + self.logger.debug("Error processing buffer: %s", err) + # Clear malformed data + if "" in self._buffer: + # Skip to after the next + self._buffer = self._buffer[self._buffer.index("") + len("") :] + else: + # Wait for more data + break + except Exception as err: + self.logger.error("Unexpected error processing buffer: %s", err) + # Clear the buffer on unexpected error + self._buffer = "" + break + + def _parse_xml_item(self, item_xml: str) -> None: + """Parse a single XML metadata item. + + :param item_xml: XML string containing a metadata item. + """ + try: + # Extract type (hex format) + type_match = re.search(r"([0-9a-fA-F]{8})", item_xml) + code_match = re.search(r"([0-9a-fA-F]{8})", item_xml) + length_match = re.search(r"(\d+)", item_xml) + + if not type_match or not code_match or not length_match: + return + + # Convert hex type and code to ASCII strings + type_hex = int(type_match.group(1), 16) + code_hex = int(code_match.group(1), 16) + length = int(length_match.group(1)) + + # Convert hex to 4-character ASCII codes + type_str = type_hex.to_bytes(4, "big").decode("ascii", errors="ignore") + code_str = code_hex.to_bytes(4, "big").decode("ascii", errors="ignore") + + # Extract data if present + data: str | bytes | None = None + if length > 0: + data_match = re.search(r"([^<]+)", item_xml) + if data_match: + try: + # Decode base64 data + data_b64 = data_match.group(1).strip() + decoded_data = base64.b64decode(data_b64) + + # For binary fields (PICT, astm), keep as raw bytes + # For text fields, decode to UTF-8 + if code_str in ("PICT", "astm"): + # Cover art and duration: keep as raw bytes + data = decoded_data + else: + # Text metadata: decode to UTF-8 + data = decoded_data.decode("utf-8", errors="ignore") + except Exception as err: + self.logger.debug("Error decoding base64 data: %s", err) + + # Process the metadata item + asyncio.create_task(self._process_metadata_item(type_str, code_str, data)) + + except Exception as err: + self.logger.debug("Error parsing XML item: %s", err) + + async def _process_metadata_item( + self, item_type: str, code: str, data: str | bytes | None + ) -> None: + """Process a metadata item and update current metadata. + + :param item_type: Type of metadata (e.g., 'core' or 'ssnc'). + :param code: Metadata code identifier. + :param data: Optional metadata data (string, bytes, or None). + """ + # Don't log binary data (like cover art) + if code == "PICT": + self.logger.log( + VERBOSE_LOG_LEVEL, + "Metadata: type=%s, code=%s, data=", + item_type, + code, + ) + else: + self.logger.log( + VERBOSE_LOG_LEVEL, "Metadata: type=%s, code=%s, data=%s", item_type, code, data + ) + + # Handle metadata start/end markers + if item_type == "ssnc" and code == "mdst": + self._current_metadata = {} + # Note: We don't clear cover_art_bytes here because: + # 1. Cover art may arrive before mdst (at playback start) + # 2. New cover art will overwrite old bytes when it arrives + # 3. Cache-busting timestamp ensures browser gets correct image + if self.on_metadata: + self.on_metadata({"metadata_start": True}) + return + + if item_type == "ssnc" and code == "mden": + if self.on_metadata and self._current_metadata: + self.on_metadata(dict(self._current_metadata)) + return + + # Parse core metadata (from iTunes/iOS) + if item_type == "core" and data is not None: + self._parse_core_metadata(code, data) + + # Parse shairport-sync metadata + if item_type == "ssnc" and data is not None: + self._parse_ssnc_metadata(code, data) + + def _parse_core_metadata(self, code: str, data: str | bytes) -> None: + """Parse core metadata from iTunes/iOS. + + :param code: Metadata code identifier. + :param data: Metadata data. + """ + # Text metadata fields - expect string data + if isinstance(data, str): + if code == "asar": # Artist + self._current_metadata["artist"] = data + elif code == "asal": # Album + self._current_metadata["album"] = data + elif code == "minm": # Title + self._current_metadata["title"] = data + + # Binary metadata fields - expect bytes data + elif isinstance(data, bytes): + if code == "PICT": # Cover art (raw bytes) + # Store raw bytes for later retrieval via resolve_image + self.cover_art_bytes = data + self.logger.debug("Stored cover art: %d bytes", len(data)) + # Signal that cover art is available with timestamp for cache-busting + timestamp = str(int(time.time() * 1000)) + self._current_metadata["cover_art_timestamp"] = timestamp + # Send cover art update immediately (cover art often arrives in separate block) + if self.on_metadata: + self.on_metadata({"cover_art_timestamp": timestamp}) + elif code == "astm": # Track duration in milliseconds (stored as 32-bit big-endian int) + try: + # Duration is sent as 4-byte big-endian integer + if len(data) >= 4: + duration_ms = struct.unpack(">I", data[:4])[0] + self._current_metadata["duration"] = duration_ms // 1000 + except (ValueError, TypeError, struct.error) as err: + self.logger.debug("Error parsing duration: %s", err) + + def _parse_ssnc_metadata(self, code: str, data: str | bytes) -> None: + """Parse shairport-sync metadata. + + :param code: Metadata code identifier. + :param data: Metadata data. + """ + # Handle binary data (cover art can come as ssnc type) + if isinstance(data, bytes): + if code == "PICT": # Cover art (raw bytes) + # Store raw bytes for later retrieval via resolve_image + self.cover_art_bytes = data + self.logger.debug("Stored cover art: %d bytes", len(data)) + # Signal that cover art is available with timestamp for cache-busting + timestamp = str(int(time.time() * 1000)) + self._current_metadata["cover_art_timestamp"] = timestamp + # Send cover art update immediately (cover art often arrives in separate block) + if self.on_metadata: + self.on_metadata({"cover_art_timestamp": timestamp}) + return + + # Process string data for ssnc metadata (volume/progress are text-based) + if code == "pvol": # Volume + self._parse_volume(data) + # Send volume updates immediately (not batched with mden) + if self.on_metadata and "volume" in self._current_metadata: + self.on_metadata({"volume": self._current_metadata["volume"]}) + elif code == "prgr": # Progress + self._parse_progress(data) + # Send progress updates immediately (not batched with mden) + if self.on_metadata and "elapsed_time" in self._current_metadata: + self.on_metadata({"elapsed_time": self._current_metadata["elapsed_time"]}) + elif code == "paus": # Paused + self._current_metadata["paused"] = True + elif code == "prsm": # Playing/resumed + self._current_metadata["paused"] = False + + def _parse_volume(self, data: str) -> None: + """Parse volume metadata from shairport-sync. + + Format: airplay_volume,min_volume,max_volume,mute + AirPlay volume is in dB, typically ranging from -30.0 (silent) to 0.0 (max). + Special value -144.0 means muted. + + :param data: Volume data string (e.g., "-21.88,0.00,0.00,0.00"). + """ + try: + parts = data.split(",") + if len(parts) >= 1: + airplay_volume = float(parts[0]) + # -144.0 means muted + if airplay_volume <= -144.0: + volume_percent = 0 + else: + # Convert dB to percentage: -30dB = 0%, 0dB = 100% + volume_percent = int(((airplay_volume + 30.0) / 30.0) * 100) + volume_percent = max(0, min(100, volume_percent)) + self._current_metadata["volume"] = volume_percent + except (ValueError, IndexError) as err: + self.logger.debug("Error parsing volume: %s", err) + + def _parse_progress(self, data: str) -> None: + """Parse progress metadata. + + :param data: Progress data string. + """ + try: + parts = data.split("/") + if len(parts) >= 3: + start_rtp = int(parts[0]) + current_rtp = int(parts[1]) + elapsed_frames = current_rtp - start_rtp + elapsed_seconds = elapsed_frames / 44100 + self._current_metadata["elapsed_time"] = int(elapsed_seconds) + except (ValueError, IndexError) as err: + self.logger.debug("Error parsing progress: %s", err) diff --git a/music_assistant/providers/spotify/bin/librespot-macos-arm64 b/music_assistant/providers/spotify/bin/librespot-macos-arm64 index df63e624..a821f1f4 100755 Binary files a/music_assistant/providers/spotify/bin/librespot-macos-arm64 and b/music_assistant/providers/spotify/bin/librespot-macos-arm64 differ