music-assistant-server

29.2 KBPY
__init__.py
29.2 KB689 lines • python
1"""
2AirPlay Receiver plugin for Music Assistant.
3
4This plugin allows Music Assistant to receive AirPlay audio streams
5and use them as a source for any player. It uses shairport-sync to
6receive the AirPlay streams and outputs them as PCM audio.
7
8The provider has multi-instance support, so multiple AirPlay receivers
9can be configured with different names.
10"""
11
12from __future__ import annotations
13
14import asyncio
15import os
16import time
17from collections.abc import Callable
18from contextlib import suppress
19from typing import TYPE_CHECKING, Any, cast
20
21from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
22from music_assistant_models.enums import (
23    ConfigEntryType,
24    ContentType,
25    ImageType,
26    PlaybackState,
27    ProviderFeature,
28    StreamType,
29)
30from music_assistant_models.errors import UnsupportedFeaturedException
31from music_assistant_models.media_items import AudioFormat, MediaItemImage
32from music_assistant_models.streamdetails import StreamMetadata
33
34from music_assistant.constants import CONF_ENTRY_WARN_PREVIEW, VERBOSE_LOG_LEVEL
35from music_assistant.helpers.named_pipe import AsyncNamedPipeWriter
36from music_assistant.helpers.process import AsyncProcess, check_output
37from music_assistant.models.plugin import PluginProvider, PluginSource
38from music_assistant.providers.airplay_receiver.helpers import get_shairport_sync_binary
39from music_assistant.providers.airplay_receiver.metadata import MetadataReader
40
41if TYPE_CHECKING:
42    from music_assistant_models.config_entries import ConfigValueType, ProviderConfig
43    from music_assistant_models.provider import ProviderManifest
44
45    from music_assistant.mass import MusicAssistant
46    from music_assistant.models import ProviderInstanceType
47
48CONF_MASS_PLAYER_ID = "mass_player_id"
49CONF_AIRPLAY_NAME = "airplay_name"
50CONF_ALLOW_PLAYER_SWITCH = "allow_player_switch"
51
52# Special value for auto player selection
53PLAYER_ID_AUTO = "__auto__"
54
55SUPPORTED_FEATURES = {ProviderFeature.AUDIO_SOURCE}
56
57
58async def setup(
59    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
60) -> ProviderInstanceType:
61    """Initialize provider(instance) with given configuration."""
62    return AirPlayReceiverProvider(mass, manifest, config)
63
64
65async def get_config_entries(
66    mass: MusicAssistant,
67    instance_id: str | None = None,  # noqa: ARG001
68    action: str | None = None,  # noqa: ARG001
69    values: dict[str, ConfigValueType] | None = None,  # noqa: ARG001
70) -> tuple[ConfigEntry, ...]:
71    """
72    Return Config entries to setup this provider.
73
74    instance_id: id of an existing provider instance (None if new instance setup).
75    action: [optional] action key called from config entries UI.
76    values: the (intermediate) raw values for config entries sent with the action.
77    """
78    return (
79        CONF_ENTRY_WARN_PREVIEW,
80        ConfigEntry(
81            key=CONF_MASS_PLAYER_ID,
82            type=ConfigEntryType.STRING,
83            label="Connected Music Assistant Player",
84            description="The Music Assistant player connected to this AirPlay receiver plugin. "
85            "When you stream audio via AirPlay to this virtual speaker, "
86            "the audio will play on the selected player. "
87            "Set to 'Auto' to automatically select a currently playing player, "
88            "or the first available player if none is playing.",
89            multi_value=False,
90            default_value=PLAYER_ID_AUTO,
91            options=[
92                ConfigValueOption("Auto (prefer playing player)", PLAYER_ID_AUTO),
93                *(
94                    ConfigValueOption(x.display_name, x.player_id)
95                    for x in sorted(
96                        mass.players.all(False, False), key=lambda p: p.display_name.lower()
97                    )
98                ),
99            ],
100            required=True,
101        ),
102        ConfigEntry(
103            key=CONF_ALLOW_PLAYER_SWITCH,
104            type=ConfigEntryType.BOOLEAN,
105            label="Allow manual player switching",
106            description="When enabled, you can select this plugin as a source on any player "
107            "to switch playback to that player. When disabled, playback is fixed to the "
108            "configured default player.",
109            default_value=True,
110        ),
111        ConfigEntry(
112            key=CONF_AIRPLAY_NAME,
113            type=ConfigEntryType.STRING,
114            label="AirPlay Device Name",
115            description="How should this AirPlay receiver be named in the AirPlay device list?",
116            default_value="Music Assistant",
117        ),
118    )
119
120
121class AirPlayReceiverProvider(PluginProvider):
122    """Implementation of an AirPlay Receiver Plugin."""
123
124    def __init__(
125        self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
126    ) -> None:
127        """Initialize MusicProvider."""
128        super().__init__(mass, manifest, config, SUPPORTED_FEATURES)
129        # Default player ID from config (PLAYER_ID_AUTO or a specific player_id)
130        self._default_player_id: str = (
131            cast("str", self.config.get_value(CONF_MASS_PLAYER_ID)) or PLAYER_ID_AUTO
132        )
133        # Whether manual player switching is allowed (default to True for upgrades)
134        allow_switch_value = self.config.get_value(CONF_ALLOW_PLAYER_SWITCH)
135        self._allow_player_switch: bool = (
136            cast("bool", allow_switch_value) if allow_switch_value is not None else True
137        )
138        # Currently active player (the one currently playing or selected)
139        self._active_player_id: str | None = None
140        self._shairport_bin: str | None = None
141        self._stop_called: bool = False
142        self._runner_task: asyncio.Task[None] | None = None
143        self._shairport_proc: AsyncProcess | None = None
144        self._shairport_started = asyncio.Event()
145        # Initialize named pipe helpers
146        audio_pipe_path = f"/tmp/ma_airplay_audio_{self.instance_id}"  # noqa: S108
147        metadata_pipe_path = f"/tmp/ma_airplay_metadata_{self.instance_id}"  # noqa: S108
148        self.audio_pipe = AsyncNamedPipeWriter(audio_pipe_path)
149        self.metadata_pipe = AsyncNamedPipeWriter(metadata_pipe_path)
150        self.config_file = f"/tmp/ma_shairport_sync_{self.instance_id}.conf"  # noqa: S108
151        # Use port 7000+ for AirPlay 2 compatibility
152        # Each instance gets a unique port: 7000, 7001, 7002, etc.
153        self.airplay_port = 7000 + (hash(self.instance_id) % 1000)
154        airplay_name = cast("str", self.config.get_value(CONF_AIRPLAY_NAME)) or self.name
155        self._source_details = PluginSource(
156            id=self.instance_id,
157            name=self.name,
158            # passive=False allows this source to be selected on any player
159            # Only show in source list if player switching is allowed
160            passive=not self._allow_player_switch,
161            can_play_pause=False,
162            can_seek=False,
163            can_next_previous=False,
164            audio_format=AudioFormat(
165                content_type=ContentType.PCM_S16LE,
166                codec_type=ContentType.PCM_S16LE,
167                sample_rate=44100,
168                bit_depth=16,
169                channels=2,
170            ),
171            metadata=StreamMetadata(
172                title=f"AirPlay | {airplay_name}",
173            ),
174            stream_type=StreamType.NAMED_PIPE,
175            path=self.audio_pipe.path,
176        )
177        # Set the on_select callback for when the source is selected on a player
178        self._source_details.on_select = self._on_source_selected
179        self._on_unload_callbacks: list[Callable[..., None]] = []
180        self._runner_error_count = 0
181        self._metadata_reader: MetadataReader | None = None
182        self._first_volume_event_received = False  # Track if we've received the first volume event
183
184    async def handle_async_init(self) -> None:
185        """Handle async initialization of the provider."""
186        self._shairport_bin = await get_shairport_sync_binary()
187        # Always start the daemon - we always have a default player configured
188        self._setup_shairport_daemon()
189
190    async def _stop_shairport_daemon(self) -> None:
191        """Stop the shairport-sync daemon without unloading the provider.
192
193        This allows the provider to restart shairport-sync later when needed.
194        """
195        # Stop metadata reader
196        if self._metadata_reader:
197            await self._metadata_reader.stop()
198            self._metadata_reader = None
199
200        # Stop shairport-sync process
201        if self._runner_task and not self._runner_task.done():
202            self._runner_task.cancel()
203            with suppress(asyncio.CancelledError):
204                await self._runner_task
205            self._runner_task = None
206
207        # Reset the shairport process reference
208        self._shairport_proc = None
209        self._shairport_started.clear()
210
211    async def unload(self, is_removed: bool = False) -> None:
212        """Handle close/cleanup of the provider."""
213        self._stop_called = True
214
215        # Stop shairport-sync daemon
216        await self._stop_shairport_daemon()
217
218        # Cleanup callbacks
219        for callback in self._on_unload_callbacks:
220            callback()
221
222    def get_source(self) -> PluginSource:
223        """Get (audio)source details for this plugin."""
224        return self._source_details
225
226    @property
227    def active_player_id(self) -> str | None:
228        """Return the currently active player ID for this plugin."""
229        return self._active_player_id
230
231    def _get_target_player_id(self) -> str | None:
232        """
233        Determine the target player ID for playback.
234
235        Returns the player ID to use based on the following priority:
236        1. If a player was explicitly selected (source selected on a player), use that
237        2. If default is 'auto': prefer playing player, then first available
238        3. If a specific default player is configured, use that
239
240        :return: The player ID to use for playback, or None if no player available.
241        """
242        # If there's an active player (source was selected on a player), use it
243        if self._active_player_id:
244            # Validate that the active player still exists
245            if self.mass.players.get(self._active_player_id):
246                return self._active_player_id
247            # Active player no longer exists, clear it
248            self._active_player_id = None
249
250        # Handle auto selection
251        if self._default_player_id == PLAYER_ID_AUTO:
252            all_players = list(self.mass.players.all(False, False))
253            # First, try to find a playing player
254            for player in all_players:
255                if player.state.playback_state == PlaybackState.PLAYING:
256                    self.logger.debug("Auto-selecting playing player: %s", player.display_name)
257                    return player.player_id
258            # Fallback to first available player
259            if all_players:
260                first_player = all_players[0]
261                self.logger.debug(
262                    "Auto-selecting first available player: %s", first_player.display_name
263                )
264                return first_player.player_id
265            # No player available
266            return None
267
268        # Use the specific default player if configured and it still exists
269        if self.mass.players.get(self._default_player_id):
270            return self._default_player_id
271        self.logger.warning(
272            "Configured default player '%s' no longer exists", self._default_player_id
273        )
274        return None
275
276    async def _on_source_selected(self) -> None:
277        """
278        Handle callback when this source is selected on a player.
279
280        This is called by the player controller when a user selects this
281        plugin as a source on a specific player.
282        """
283        # The player that selected us is stored in in_use_by by the player controller
284        new_player_id = self._source_details.in_use_by
285        if not new_player_id:
286            return
287
288        # Check if manual player switching is allowed
289        if not self._allow_player_switch:
290            # Player switching disabled - only allow if it matches the current target
291            current_target = self._get_target_player_id()
292            if new_player_id != current_target:
293                self.logger.debug(
294                    "Manual player switching disabled, ignoring selection on %s",
295                    new_player_id,
296                )
297                # Revert in_use_by to reflect the rejection
298                self._source_details.in_use_by = current_target
299                self.mass.players.trigger_player_update(new_player_id)
300                return
301
302        # If there's already an active player and it's different, kick it out
303        if self._active_player_id and self._active_player_id != new_player_id:
304            self.logger.info(
305                "Source selected on player %s, stopping playback on %s",
306                new_player_id,
307                self._active_player_id,
308            )
309            # Stop the current player
310            try:
311                await self.mass.players.cmd_stop(self._active_player_id)
312            except Exception as err:
313                self.logger.debug(
314                    "Failed to stop previous player %s: %s", self._active_player_id, err
315                )
316
317        # Update the active player
318        self._active_player_id = new_player_id
319        self.logger.debug("Active player set to: %s", new_player_id)
320
321        # Only persist the selected player as the new default if not in auto mode
322        if self._default_player_id != PLAYER_ID_AUTO:
323            self._save_last_player_id(new_player_id)
324
325    def _clear_active_player(self) -> None:
326        """
327        Clear the active player and revert to default if configured.
328
329        Called when playback ends to reset the plugin state.
330        """
331        prev_player_id = self._active_player_id
332        self._active_player_id = None
333        self._source_details.in_use_by = None
334
335        if prev_player_id:
336            self.logger.debug("Playback ended on player %s, clearing active player", prev_player_id)
337            # Trigger update for the player that was using this source
338            self.mass.players.trigger_player_update(prev_player_id)
339
340    def _save_last_player_id(self, player_id: str) -> None:
341        """Persist the selected player ID to config as the new default."""
342        if self._default_player_id == player_id:
343            return  # No change needed
344        try:
345            self.mass.config.set_raw_provider_config_value(
346                self.instance_id, CONF_MASS_PLAYER_ID, player_id
347            )
348            self._default_player_id = player_id
349        except Exception as err:
350            self.logger.debug("Failed to persist player ID: %s", err)
351
352    async def _create_config_file(self) -> None:
353        """Create shairport-sync configuration file from template."""
354        # Read template
355        template_path = os.path.join(os.path.dirname(__file__), "bin", "shairport-sync.conf")
356
357        def _read_template() -> str:
358            with open(template_path, encoding="utf-8") as f:
359                return f.read()
360
361        template = await asyncio.to_thread(_read_template)
362
363        # Replace placeholders
364        airplay_name = cast("str", self.config.get_value(CONF_AIRPLAY_NAME)) or self.name
365        config_content = template.replace("{AIRPLAY_NAME}", airplay_name)
366        config_content = config_content.replace("{METADATA_PIPE}", self.metadata_pipe.path)
367        config_content = config_content.replace("{AUDIO_PIPE}", self.audio_pipe.path)
368        config_content = config_content.replace("{PORT}", str(self.airplay_port))
369
370        # Set default volume based on default player's current volume if available
371        # Convert player volume (0-100) to AirPlay volume (-30.0 to 0.0 dB)
372        player_volume = 100  # Default to 100%
373        if self._default_player_id and self._default_player_id != PLAYER_ID_AUTO:
374            if _player := self.mass.players.get(self._default_player_id):
375                if _player.volume_level is not None:
376                    player_volume = _player.volume_level
377        # Map 0-100 to -30.0...0.0
378        airplay_volume = (player_volume / 100.0) * 30.0 - 30.0
379        config_content = config_content.replace("{DEFAULT_VOLUME}", f"{airplay_volume:.1f}")
380
381        # Write config file
382        def _write_config() -> None:
383            with open(self.config_file, "w", encoding="utf-8") as f:
384                f.write(config_content)
385
386        await asyncio.to_thread(_write_config)
387
388    async def _setup_pipes_and_config(self) -> None:
389        """Set up named pipes and configuration file for shairport-sync.
390
391        :raises: OSError if pipe or config file creation fails.
392        """
393        # Remove any existing pipes and config
394        await self._cleanup_pipes_and_config()
395
396        # Create named pipes for audio and metadata
397        await self.audio_pipe.create()
398        await self.metadata_pipe.create()
399
400        # Create configuration file
401        await self._create_config_file()
402
403    async def _cleanup_pipes_and_config(self) -> None:
404        """Clean up named pipes and configuration file."""
405        await self.audio_pipe.remove()
406        await self.metadata_pipe.remove()
407        await check_output("rm", "-f", self.config_file)
408
409    async def _write_silence_to_unblock_stream(self) -> None:
410        """Write silence to the audio pipe to unblock ffmpeg.
411
412        When shairport-sync stops writing but ffmpeg is still reading,
413        writing silence will cause ffmpeg to output a chunk, which will
414        then check in_use_by and break out of the loop.
415
416        We write enough silence to ensure ffmpeg outputs at least one chunk.
417        PCM_S16LE format: 2 bytes per sample, 2 channels, 44100 Hz
418        Writing 1 second of silence = 44100 * 2 * 2 = 176400 bytes
419        """
420        self.logger.debug("Writing silence to audio pipe to unblock stream")
421        silence = b"\x00" * 176400  # 1 second of silence in PCM_S16LE stereo 44.1kHz
422        await self.audio_pipe.write(silence)
423
424    def _process_shairport_log_line(self, line: str) -> None:
425        """Process a log line from shairport-sync stderr.
426
427        :param line: The log line to process.
428        """
429        # Check for fatal errors (log them, but process will exit on its own)
430        if "fatal error:" in line.lower() or "unknown option" in line.lower():
431            self.logger.error("Fatal error from shairport-sync: %s", line)
432            return
433        # Log connection messages at INFO level, everything else at DEBUG
434        if "connection from" in line:
435            self.logger.info("AirPlay client connected: %s", line)
436        else:
437            # Note: Play begin/stop events are now handled via sessioncontrol hooks
438            # through the metadata pipe, so we don't need to parse stderr logs
439            self.logger.debug(line)
440        if not self._shairport_started.is_set():
441            self._shairport_started.set()
442
443    async def _shairport_runner(self) -> None:
444        """Run the shairport-sync daemon in a background task."""
445        assert self._shairport_bin
446        self.logger.info("Starting AirPlay Receiver background daemon")
447        await self._setup_pipes_and_config()
448
449        try:
450            args: list[str] = [
451                self._shairport_bin,
452                "--configfile",
453                self.config_file,
454            ]
455            self._shairport_proc = shairport = AsyncProcess(
456                args, stderr=True, name=f"shairport-sync[{self.name}]"
457            )
458            await shairport.start()
459
460            # Check if process started successfully
461            await asyncio.sleep(0.1)
462            if shairport.returncode is not None:
463                self.logger.error(
464                    "shairport-sync exited immediately with code %s", shairport.returncode
465                )
466                return
467
468            # Start metadata reader
469            self._metadata_reader = MetadataReader(
470                self.metadata_pipe.path, self.logger, self._on_metadata_update
471            )
472            await self._metadata_reader.start()
473
474            # Keep reading logging from stderr until exit
475            self.logger.debug("Starting to read shairport-sync stderr")
476            async for stderr_line in shairport.iter_stderr():
477                line = stderr_line.strip()
478                self._process_shairport_log_line(line)
479
480        finally:
481            await shairport.close()
482            self.logger.info(
483                "AirPlay Receiver background daemon stopped for %s (exit code: %s)",
484                self.name,
485                shairport.returncode,
486            )
487
488            # Stop metadata reader
489            if self._metadata_reader:
490                await self._metadata_reader.stop()
491
492            # Clean up pipes and config
493            await self._cleanup_pipes_and_config()
494
495            if not self._shairport_started.is_set():
496                self.unload_with_error("Unable to initialize shairport-sync daemon.")
497            # Auto restart if not stopped manually
498            elif not self._stop_called and self._runner_error_count >= 5:
499                self.unload_with_error("shairport-sync daemon failed to start multiple times.")
500            elif not self._stop_called:
501                self._runner_error_count += 1
502                self.mass.call_later(2, self._setup_shairport_daemon)
503
504    def _setup_shairport_daemon(self) -> None:
505        """Handle setup of the shairport-sync daemon for a player."""
506        self._shairport_started.clear()
507        self._runner_task = self.mass.create_task(self._shairport_runner())
508
509    def _on_metadata_update(self, metadata: dict[str, Any]) -> None:
510        """Handle metadata updates from shairport-sync.
511
512        :param metadata: Dictionary containing metadata updates.
513        """
514        self.logger.log(VERBOSE_LOG_LEVEL, "Received metadata update: %s", metadata)
515
516        # Handle play state changes from sessioncontrol hooks
517        if "play_state" in metadata:
518            self._handle_play_state_change(metadata["play_state"])
519            return
520
521        # Handle metadata start (new track starting)
522        if "metadata_start" in metadata:
523            return
524
525        # Handle volume changes from AirPlay client
526        if "volume" in metadata and self._source_details.in_use_by:
527            self._handle_volume_change(metadata["volume"])
528
529        # Update source metadata fields
530        self._update_source_metadata(metadata)
531
532        # Handle cover art updates
533        self._update_cover_art(metadata)
534
535        # Signal update to connected player
536        if self._source_details.in_use_by:
537            self.mass.players.trigger_player_update(self._source_details.in_use_by)
538
539    def _handle_play_state_change(self, play_state: str) -> None:
540        """Handle play state changes from sessioncontrol hooks.
541
542        :param play_state: The new play state ("playing" or "stopped").
543        """
544        if play_state == "playing":
545            # Reset volume event flag for new playback session
546            self._first_volume_event_received = False
547            # Initiate playback by selecting this source on the target player
548            if not self._source_details.in_use_by:
549                target_player_id = self._get_target_player_id()
550                if target_player_id:
551                    self.logger.info("Starting AirPlay playback on player %s", target_player_id)
552                    self._active_player_id = target_player_id
553                    self.mass.create_task(
554                        self.mass.players.select_source(target_player_id, self.instance_id)
555                    )
556                    self._source_details.in_use_by = target_player_id
557                else:
558                    self.logger.warning(
559                        "AirPlay playback started but no player available. "
560                        "Select this source on a player to start playback."
561                    )
562        elif play_state == "stopped":
563            self.logger.info("AirPlay playback stopped")
564            # Reset volume event flag for next session
565            self._first_volume_event_received = False
566            # Get the current player before clearing
567            current_player_id = self._source_details.in_use_by
568            # Clear active player state
569            self._clear_active_player()
570            # Write silence to the pipe to unblock ffmpeg
571            # This will cause ffmpeg to output a chunk, which will then check in_use_by
572            # and break out of the loop when it sees it's None
573            self.mass.create_task(self._write_silence_to_unblock_stream())
574            # Deselect source from player if there was one
575            if current_player_id:
576                self.mass.create_task(self.mass.players.select_source(current_player_id, None))
577
578    def _handle_volume_change(self, volume: int) -> None:
579        """Handle volume changes from AirPlay client (iOS/macOS device).
580
581        ignore_volume_control = "yes" means shairport-sync doesn't do software volume control,
582        but we still receive volume level changes from the client to apply to the player.
583
584        :param volume: The new volume level (0-100).
585        """
586        # Skip the first volume event as it's the initial sync from default_airplay_volume
587        # We don't want to override the player's current volume on startup
588        if not self._first_volume_event_received:
589            self._first_volume_event_received = True
590            self.logger.debug(
591                "Received initial AirPlay volume (%s%%), skipping to preserve player volume",
592                volume,
593            )
594            return
595
596        # Type check: ensure we have a valid player ID
597        player_id = self._source_details.in_use_by
598        if not player_id:
599            return
600
601        self.logger.debug(
602            "AirPlay client volume changed to %s%%, applying to player %s",
603            volume,
604            player_id,
605        )
606        try:
607            self.mass.create_task(self.mass.players.cmd_volume_set(player_id, volume))
608        except UnsupportedFeaturedException:
609            self.logger.debug("Player %s does not support volume control", player_id)
610
611    def _update_source_metadata(self, metadata: dict[str, Any]) -> None:
612        """Update source metadata fields from AirPlay metadata.
613
614        :param metadata: Dictionary containing metadata updates.
615        """
616        # Initialize metadata if needed
617        if self._source_details.metadata is None:
618            airplay_name = cast("str", self.config.get_value(CONF_AIRPLAY_NAME)) or self.name
619            self._source_details.metadata = StreamMetadata(title=f"AirPlay | {airplay_name}")
620
621        # Update individual metadata fields
622        if "title" in metadata:
623            self._source_details.metadata.title = metadata["title"]
624
625        if "artist" in metadata:
626            self._source_details.metadata.artist = metadata["artist"]
627
628        if "album" in metadata:
629            self._source_details.metadata.album = metadata["album"]
630
631        if "duration" in metadata:
632            self._source_details.metadata.duration = metadata["duration"]
633
634        if "elapsed_time" in metadata:
635            self._source_details.metadata.elapsed_time = metadata["elapsed_time"]
636            # Always set elapsed_time_last_updated to current time when we receive elapsed_time
637            self._source_details.metadata.elapsed_time_last_updated = time.time()
638
639    def _update_cover_art(self, metadata: dict[str, Any]) -> None:
640        """Update cover art image URL from AirPlay metadata.
641
642        :param metadata: Dictionary containing metadata updates.
643        """
644        # Ensure metadata is initialized
645        if self._source_details.metadata is None:
646            return
647
648        if "cover_art_timestamp" in metadata:
649            # Use timestamp as query parameter to create a unique URL for each cover art update
650            # This prevents browser caching issues when switching between tracks
651            timestamp = metadata["cover_art_timestamp"]
652            # Build image proxy URL for the cover art
653            # The actual image bytes are stored in the metadata reader
654            image = MediaItemImage(
655                type=ImageType.THUMB,
656                path="cover_art",
657                provider=self.instance_id,
658                remotely_accessible=False,
659            )
660            base_url = self.mass.metadata.get_image_url(image)
661            # Append timestamp as query parameter for cache-busting
662            self._source_details.metadata.image_url = f"{base_url}&t={timestamp}"
663        elif self._metadata_reader and self._metadata_reader.cover_art_bytes:
664            # Maintain image URL if we have cover art but didn't receive it in this update
665            # This ensures the image URL persists across metadata updates
666            if not self._source_details.metadata.image_url:
667                # Generate timestamp for cache-busting even in fallback case
668                timestamp = str(int(time.time() * 1000))
669                image = MediaItemImage(
670                    type=ImageType.THUMB,
671                    path="cover_art",
672                    provider=self.instance_id,
673                    remotely_accessible=False,
674                )
675                base_url = self.mass.metadata.get_image_url(image)
676                self._source_details.metadata.image_url = f"{base_url}&t={timestamp}"
677
678    async def resolve_image(self, path: str) -> bytes:
679        """Resolve an image from an image path.
680
681        This returns raw bytes of the cover art image received from AirPlay metadata.
682
683        :param path: The image path (should be "cover_art" for AirPlay cover art).
684        """
685        if path == "cover_art" and self._metadata_reader and self._metadata_reader.cover_art_bytes:
686            return self._metadata_reader.cover_art_bytes
687        # Return empty bytes if no cover art is available
688        return b""
689