Add Airport Receiver Plugin provider (#2604)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 7 Nov 2025 00:04:14 +0000 (01:04 +0100)
committerGitHub <noreply@github.com>
Fri, 7 Nov 2025 00:04:14 +0000 (01:04 +0100)
20 files changed:
Dockerfile.base
music_assistant/controllers/players/player_controller.py
music_assistant/helpers/images.py
music_assistant/helpers/process.py
music_assistant/models/player.py
music_assistant/models/plugin.py
music_assistant/providers/airplay/provider.py
music_assistant/providers/airplay_receiver/__init__.py [new file with mode: 0644]
music_assistant/providers/airplay_receiver/bin/.gitkeep [new file with mode: 0644]
music_assistant/providers/airplay_receiver/bin/README.md [new file with mode: 0644]
music_assistant/providers/airplay_receiver/bin/build_binaries.sh [new file with mode: 0755]
music_assistant/providers/airplay_receiver/bin/shairport-sync-linux-aarch64 [new file with mode: 0755]
music_assistant/providers/airplay_receiver/bin/shairport-sync-linux-x86_64 [new file with mode: 0755]
music_assistant/providers/airplay_receiver/bin/shairport-sync-macos-arm64 [new file with mode: 0755]
music_assistant/providers/airplay_receiver/bin/shairport-sync.conf [new file with mode: 0644]
music_assistant/providers/airplay_receiver/helpers.py [new file with mode: 0644]
music_assistant/providers/airplay_receiver/icon.svg [new file with mode: 0644]
music_assistant/providers/airplay_receiver/manifest.json [new file with mode: 0644]
music_assistant/providers/airplay_receiver/metadata.py [new file with mode: 0644]
music_assistant/providers/spotify/bin/librespot-macos-arm64

index ead2079e52a67c6d03a9e0c0337c12890148795b..6975b5df68da6f9ddb6427253ad7e890c2941144 100644 (file)
@@ -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 \
index 7ab5abf4f1542dfad0c795199dda86e828ee76c1..fdfba72449caaeb7ff7ba01563d9c91c412f43c5 100644 (file)
@@ -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:
index 1900bb51a96e4af5819f8144330b3600191ecad1..9b7926a9cf2c3f87785fb06b0e4af6cd9d1ec2b5 100644 (file)
@@ -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
index 46df59f8d68d9a2a53ed1be622d81b46711edfc5..c761ad3151a5d6742c957c044779072790488cda 100644 (file)
@@ -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
index 2a54514bceca68e62737cb00fc2622e328d589ab..231275f7b1fb463a6b92f9b77633fb7ee94d5a83 100644 (file)
@@ -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."""
index ca823a455e6bc9f99a88d51bd85f0c69127538b8..3a62ab48d34c2190fee78f842b8626fdd7305092 100644 (file)
@@ -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
index 0a9701f1a034bd3e7b22359e9aa8b80dfc023049..4190f753e77a42e8bba205f02c127946178e9c22 100644 (file)
@@ -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 (file)
index 0000000..005dcb0
--- /dev/null
@@ -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 (file)
index 0000000..0f88c6f
--- /dev/null
@@ -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 (file)
index 0000000..cb04a26
--- /dev/null
@@ -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 (executable)
index 0000000..dbb6720
--- /dev/null
@@ -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 (executable)
index 0000000..9a2afbc
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 (executable)
index 0000000..79f4f35
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 (executable)
index 0000000..9b3874b
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 (file)
index 0000000..43f8b30
--- /dev/null
@@ -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 (file)
index 0000000..6f2b35d
--- /dev/null
@@ -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 (file)
index 0000000..9ea089a
--- /dev/null
@@ -0,0 +1,4 @@
+<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>
diff --git a/music_assistant/providers/airplay_receiver/manifest.json b/music_assistant/providers/airplay_receiver/manifest.json
new file mode 100644 (file)
index 0000000..c566710
--- /dev/null
@@ -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 (file)
index 0000000..973f754
--- /dev/null
@@ -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 "<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)
index df63e6248563f6f1999731f4f9bb76bbf509731f..a821f1f44385155401b3ec46747c70468896fc87 100755 (executable)
Binary files a/music_assistant/providers/spotify/bin/librespot-macos-arm64 and b/music_assistant/providers/spotify/bin/librespot-macos-arm64 differ