# 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)
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 \
PlayerCommandFailed,
PlayerUnavailableError,
ProviderUnavailableError,
- QueueEmpty,
UnsupportedFeaturedException,
)
from music_assistant_models.player_control import PlayerControl # noqa: TC002
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:
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:
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
"""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
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]
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()
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
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."""
"""
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
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
--- /dev/null
+"""
+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""
--- /dev/null
+# Place shairport-sync binaries here
+# See README.md for instructions
--- /dev/null
+# 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.)
--- /dev/null
+#!/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."
--- /dev/null
+// 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
+};
--- /dev/null
+"""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)
--- /dev/null
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
+ <!-- AirPlay icon - triangle pointing up to a screen -->
+ <path d="M6 22h12l-6-6-6 6zm.84-4h10.32L12 12.84 6.84 18zM21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h4v-2H3V5h18v12h-4v2h4c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/>
+</svg>
--- /dev/null
+{
+ "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": []
+}
--- /dev/null
+"""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 "<item>" not in self._buffer or self._buffer.index("<item>") > 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 <item>...</item> blocks
+ while "<item>" in self._buffer and "</item>" in self._buffer:
+ try:
+ # Find the boundaries of the next item
+ start_idx = self._buffer.index("<item>")
+ end_idx = self._buffer.index("</item>") + len("</item>")
+
+ # 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 "</item>" in self._buffer:
+ # Skip to after the next </item>
+ self._buffer = self._buffer[self._buffer.index("</item>") + len("</item>") :]
+ 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"<type>([0-9a-fA-F]{8})</type>", item_xml)
+ code_match = re.search(r"<code>([0-9a-fA-F]{8})</code>", item_xml)
+ length_match = re.search(r"<length>(\d+)</length>", 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"<data encoding=\"base64\">([^<]+)</data>", 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=<binary image 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)