/
/
/
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_players(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_player(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_players(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_player(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_player(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