/
/
/
1"""Bose SoundTouch player implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import contextlib
7import time
8from typing import TYPE_CHECKING, cast
9
10import aiohttp
11from music_assistant_models.config_entries import ConfigActionResult, ConfigEntry
12from music_assistant_models.enums import (
13 ConfigEntryType,
14 IdentifierType,
15 MediaType,
16 PlaybackState,
17 PlayerFeature,
18 PlayerType,
19)
20from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError
21from music_assistant_models.player import (
22 DeviceInfo,
23 PlayerOption,
24 PlayerOptionType,
25 PlayerOptionValueType,
26 PlayerSource,
27)
28
29from music_assistant.constants import (
30 CONF_ENTRY_FLOW_MODE,
31 CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3,
32 create_sample_rates_config_entry,
33)
34from music_assistant.models.player import Player, PlayerMedia
35from music_assistant.providers.bose_soundtouch.avt_helpers import avt_play, avt_set_url, avt_stop
36
37from .client.schema.enums import Key, PlayStatus, SourceStatus
38from .client.schema.models import Info, NowPlaying, Zone, ZoneMember
39from .const import (
40 ACTION_OVERWRITE_PRESET_1,
41 ACTION_OVERWRITE_PRESET_2,
42 ACTION_OVERWRITE_PRESET_3,
43 ACTION_OVERWRITE_PRESET_4,
44 ACTION_OVERWRITE_PRESET_5,
45 ACTION_OVERWRITE_PRESET_6,
46 CONF_APP_KEY,
47 IDLE_POLL_INTERVAL,
48 NOTIFICATION_PORT,
49 PLAYBACK_POLL_INTERVAL,
50 PLAYER_ID_PREFIX,
51 PRESET_IDS,
52 RECONNECT_DELAY,
53 SOURCE_INVALID,
54 SOURCE_STANDBY,
55 WS_HEARTBEAT,
56 WS_SUBPROTOCOLS,
57 PlayerOptionKeys,
58)
59from .helpers import extract_preset_id, source_id
60
61if TYPE_CHECKING:
62 from .client import SoundtouchDevice
63 from .provider import BoseSoundTouchProvider
64
65
66class BoseSoundTouchPlayer(Player):
67 """Bose SoundTouch player in Music Assistant."""
68
69 def __init__(
70 self,
71 provider: BoseSoundTouchProvider,
72 player_id: str,
73 client: SoundtouchDevice,
74 info: Info,
75 ) -> None:
76 """Initialize the Player."""
77 super().__init__(provider, player_id)
78 self._client = client
79 self._device_id = info.device_id
80 # Native announcements require a Bose developer app key; when configured the
81 # speaker plays them as an overlay (ducking and resuming the current playback).
82 app_key = self.provider.get_setup_value(CONF_APP_KEY)
83 self._app_key = str(app_key) if app_key else None
84 self._update_lock = asyncio.Lock()
85
86 self._stop_event = asyncio.Event()
87 self._listener_task: asyncio.Task[None] | None = None
88
89 self._supported_player_options: set[PlayerOptionKeys] = set()
90
91 async def setup(self, info: Info) -> None:
92 """Fetch initial state and start listening for device updates."""
93 self.set_static_attributes(info)
94
95 # initial refresh no lock needed
96 await self._refresh_volume()
97 await self._refresh_now_playing()
98 await self._refresh_zone()
99 await self._refresh_sources()
100
101 # initialize options
102 if not self._supported_player_options:
103 self._supported_player_options.add(PlayerOptionKeys.NETWORK_NAME)
104 bass_capability = await self._client.get_bass_capabilities()
105 if bass_capability.available is not None and bass_capability.available:
106 self._supported_player_options.add(PlayerOptionKeys.BASS)
107 await self._refresh_options()
108
109 self.update_state()
110
111 self._listener_task = self.mass.create_task(self._listen())
112
113 def set_static_attributes(self, info: Info) -> None:
114 """Set static attributes."""
115 self._attr_needs_poll = True
116 self._attr_poll_interval = IDLE_POLL_INTERVAL
117 self._attr_available = True
118 self._attr_type = PlayerType.PLAYER
119 self._attr_supported_features = {
120 PlayerFeature.POWER,
121 PlayerFeature.VOLUME_SET,
122 PlayerFeature.VOLUME_MUTE,
123 PlayerFeature.PAUSE,
124 PlayerFeature.NEXT_PREVIOUS,
125 PlayerFeature.SELECT_SOURCE,
126 PlayerFeature.SET_MEMBERS,
127 PlayerFeature.OPTIONS,
128 PlayerFeature.PLAY_MEDIA,
129 }
130 if self._app_key:
131 self._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
132
133 self._attr_name = info.name
134 self._attr_can_group_with = {self.provider.instance_id}
135 self._attr_device_info = DeviceInfo(
136 model=info.model or "Bose SoundTouch",
137 manufacturer="Bose",
138 software_version=info.software_version,
139 )
140 self._attr_device_info.add_identifier(IdentifierType.UUID, info.device_id)
141 if info.mac_addresses:
142 for mac_address in info.mac_addresses:
143 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
144 if info.ip_addresses:
145 for ip_address in info.ip_addresses:
146 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, ip_address)
147
148 async def poll(self) -> None:
149 """Poll the speaker as a safety net for missed websocket events."""
150 async with self._update_lock:
151 try:
152 await self._refresh_now_playing()
153 await self._refresh_volume()
154 await self._refresh_zone()
155 except (aiohttp.ClientError, TimeoutError, OSError) as err:
156 self.logger.debug("Poll failed for %s: %s", self.name, err)
157 self._attr_available = False
158 self.update_state()
159 return
160 self._attr_available = True
161 self._attr_poll_interval = (
162 PLAYBACK_POLL_INTERVAL
163 if self._attr_playback_state == PlaybackState.PLAYING
164 else IDLE_POLL_INTERVAL
165 )
166 self.update_state()
167
168 async def on_unload(self) -> None:
169 """Handle logic when the player is unloaded from the Player controller."""
170 self._stop_event.set()
171 if self._listener_task:
172 self._listener_task.cancel()
173 with contextlib.suppress(asyncio.CancelledError):
174 await self._listener_task
175 self._listener_task = None
176 await super().on_unload()
177
178 # --- Player commands ---
179
180 async def power(self, powered: bool) -> None:
181 """Handle POWER command on the player."""
182 now_playing = await self._client.get_now_playing()
183 currently_on = False
184 if now_playing.content_item and now_playing.content_item.source:
185 currently_on = now_playing.content_item.source != SOURCE_STANDBY
186 if powered != currently_on:
187 # the POWER key toggles standby, so only send it when a change is needed
188 await self._client.press_key(Key.POWER)
189 self._attr_powered = powered
190 self.update_state()
191
192 async def volume_set(self, volume_level: int) -> None:
193 """Handle VOLUME_SET command on the player."""
194 # the device takes volume and mute in one call, so pass the mute it already
195 # holds: a volume change is not an unmute
196 await self._client.set_volume(volume_level, mute=bool(self.volume_muted))
197 self._attr_volume_level = volume_level
198 self.update_state()
199
200 async def volume_mute(self, muted: bool) -> None:
201 """Handle VOLUME_MUTE command on the player."""
202 volume = await self._client.get_volume()
203 if volume.mute_enabled != muted:
204 # the MUTE key toggles mute, so only send it when a change is needed
205 await self._client.press_key(Key.MUTE)
206 self._attr_volume_muted = muted
207 self.update_state()
208
209 async def play(self) -> None:
210 """Handle PLAY (resume) command on a native source."""
211 await self._client.press_key(Key.PLAY)
212 self._attr_playback_state = PlaybackState.PLAYING
213 self.update_state()
214
215 async def play_media(self, media: PlayerMedia) -> None:
216 """Play media."""
217 async with self._update_lock:
218 media.uri = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
219 # clear any pending AVT state to avoid wedging on rapid play_media
220 self.logger.debug("Starting to play %s.", media.title)
221 await avt_stop(self.mass.http_session, self)
222 await avt_set_url(self.mass.http_session, self, player_media=media)
223 await avt_play(self.mass.http_session, self)
224 self._attr_poll_interval = PLAYBACK_POLL_INTERVAL
225
226 async def stop(self) -> None:
227 """Stop command."""
228 await self._client.press_key(Key.STOP)
229
230 async def pause(self) -> None:
231 """Handle PAUSE command on a native source."""
232 await self._client.press_key(Key.PAUSE)
233 self._attr_playback_state = PlaybackState.PAUSED
234 self.update_state()
235
236 async def next_track(self) -> None:
237 """Handle NEXT_TRACK command on a native source."""
238 await self._client.press_key(Key.NEXT_TRACK)
239
240 async def previous_track(self) -> None:
241 """Handle PREVIOUS_TRACK command on a native source."""
242 await self._client.press_key(Key.PREV_TRACK)
243
244 async def select_source(self, source: str) -> None:
245 """Handle SELECT_SOURCE command on the player."""
246 source_name, _, source_account = source.partition(":")
247 await self._client.select_source(source_name, source_account or None)
248 await self._refresh_now_playing()
249
250 async def play_announcement(
251 self, announcement: PlayerMedia, volume_level: int | None = None
252 ) -> None:
253 """Handle (native) playback of an announcement on the player."""
254 if not self._app_key:
255 return
256 self.logger.debug("Playing announcement %s on %s", announcement.uri, self.name)
257 await self._client.play_notification(self._app_key, announcement.uri, volume_level)
258 # the notification API reports no completion, so wait out the announcement here:
259 # its audio is only served for as long as this call is running
260 duration = await self.mass.streams.get_announcement_duration(announcement)
261 await asyncio.sleep(duration or 10)
262
263 async def set_option(self, option_key: str, option_value: PlayerOptionValueType) -> None:
264 """Set player option."""
265 match option_key:
266 case PlayerOptionKeys.NETWORK_NAME:
267 await self._client.set_name(str(option_value))
268 case PlayerOptionKeys.BASS:
269 await self._client.set_bass(int(option_value))
270
271 await self._refresh_options()
272
273 async def set_members(
274 self,
275 player_ids_to_add: list[str] | None = None,
276 player_ids_to_remove: list[str] | None = None,
277 ) -> None:
278 """Handle SET_MEMBERS command using the SoundTouch multiroom zone API."""
279
280 def get_zone(player_ids: list[str]) -> Zone:
281 members: list[ZoneMember] = []
282 for player_id in player_ids:
283 player = self.mass.players.get_player(player_id)
284 if isinstance(player, BoseSoundTouchPlayer) and (
285 ip_address := player._client.session_config.ip
286 ):
287 members.append(ZoneMember(ip=ip_address, mac=player.device_id))
288 return Zone(
289 leader=ZoneMember(ip=self._client.session_config.ip, mac=self._device_id),
290 members=members,
291 )
292
293 current_zone = await self._client.get_zone()
294 if not current_zone.members and player_ids_to_add:
295 # create a new zone
296 self.logger.debug("Creating a new zone.")
297 zone = get_zone(player_ids_to_add)
298 await self._client.set_zone(zone)
299 await self._refresh_zone()
300 return
301 self.logger.debug("Updating an existing zone.")
302
303 # update a current zone
304 if player_ids_to_add:
305 zone = get_zone(player_ids_to_add)
306 await self._client.add_zone_members(zone)
307
308 if player_ids_to_remove:
309 zone = get_zone(player_ids_to_remove)
310 await self._client.remove_zone_members(zone)
311
312 await self._refresh_zone()
313
314 # --- Public helpers ---
315
316 @property
317 def device_id(self) -> str:
318 """Return the Bose SoundTouch device id of this player."""
319 return self._device_id
320
321 def update_ip_address(self, ip_address: str) -> None:
322 """Update the speaker's IP address after a (re)discovery."""
323 if ip_address == self._client.session_config.ip:
324 return
325 self.logger.debug("Address updated to %s for player %s", ip_address, self.name)
326 self._client.session_config.ip = ip_address
327 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, ip_address)
328 self.mass.players.trigger_player_update(self.player_id)
329
330 async def get_config_entries(self) -> list[ConfigEntry]:
331 """Get config entries."""
332 base_entries = await super().get_config_entries()
333
334 default_entries = [
335 # by far the most consistent results with mp3. flac is flakier.
336 CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3,
337 CONF_ENTRY_FLOW_MODE,
338 create_sample_rates_config_entry(max_sample_rate=192000, max_bit_depth=24),
339 ]
340
341 current_presets = await self._client.get_presets()
342 preset_ids = [x.id_ for x in current_presets.presets if isinstance(x.id_, int)]
343
344 preset_entries = [
345 ConfigEntry(
346 key=preset_action,
347 type=ConfigEntryType.ACTION,
348 translation_key="action_overwrite_preset"
349 if preset_id not in preset_ids
350 else "action_overwrite_preset_preset_available",
351 action=preset_action,
352 translation_params=[str(preset_id)],
353 )
354 for preset_id, preset_action in enumerate(
355 (
356 ACTION_OVERWRITE_PRESET_1,
357 ACTION_OVERWRITE_PRESET_2,
358 ACTION_OVERWRITE_PRESET_3,
359 ACTION_OVERWRITE_PRESET_4,
360 ACTION_OVERWRITE_PRESET_5,
361 ACTION_OVERWRITE_PRESET_6,
362 ),
363 1,
364 )
365 ]
366
367 # preset first, as the others are advanced
368 return preset_entries + base_entries + default_entries
369
370 async def handle_config_action(
371 self, action: str
372 ) -> list[ConfigEntry] | ConfigActionResult | None:
373 """Handle config actions."""
374 mapping_preset_int = {
375 ACTION_OVERWRITE_PRESET_1: 1,
376 ACTION_OVERWRITE_PRESET_2: 2,
377 ACTION_OVERWRITE_PRESET_3: 3,
378 ACTION_OVERWRITE_PRESET_4: 4,
379 ACTION_OVERWRITE_PRESET_5: 5,
380 ACTION_OVERWRITE_PRESET_6: 6,
381 }
382 if action in mapping_preset_int:
383 preset_id = mapping_preset_int.get(action, 1)
384 await self._client.store_preset(
385 preset_id,
386 f"{self.mass.webserver.base_url}/{self.provider.instance_id}",
387 )
388 return await self.get_config_entries()
389
390 return await super().handle_config_action(action)
391
392 # --- Private helpers ---
393
394 async def _listen(self) -> None:
395 """Connect to the speaker's notification websocket and handle push updates."""
396 while not self._stop_event.is_set():
397 uri = f"ws://{self._client.session_config.ip}:{NOTIFICATION_PORT}"
398 try:
399 async with self.mass.http_session.ws_connect(
400 uri, protocols=WS_SUBPROTOCOLS, heartbeat=WS_HEARTBEAT
401 ) as ws:
402 self.logger.debug("Connected to SoundTouch websocket: %s", uri)
403 if not self._attr_available:
404 self._attr_available = True
405 self.update_state()
406 async for msg in ws:
407 if self._stop_event.is_set():
408 break
409 if msg.type == aiohttp.WSMsgType.TEXT:
410 await self._handle_update_message(msg.data)
411 elif msg.type == aiohttp.WSMsgType.BINARY:
412 await self._handle_update_message(msg.data.decode())
413 elif msg.type in (
414 aiohttp.WSMsgType.ERROR,
415 aiohttp.WSMsgType.CLOSE,
416 aiohttp.WSMsgType.CLOSED,
417 ):
418 break
419 except asyncio.CancelledError:
420 raise
421 except (aiohttp.ClientError, OSError, TimeoutError, UnicodeDecodeError) as err:
422 self.logger.debug(
423 "SoundTouch websocket error for %s: %s. Reconnecting in %ss",
424 self.name,
425 err,
426 RECONNECT_DELAY,
427 )
428 if not self._stop_event.is_set():
429 with contextlib.suppress(TimeoutError):
430 await asyncio.wait_for(self._stop_event.wait(), timeout=RECONNECT_DELAY)
431
432 async def _handle_update_message(self, message: str) -> None:
433 """Handle a single websocket notification message."""
434 # a physical preset button press maps to the configured Music Assistant media;
435 # the bulk "presetsUpdated" notification (which lists all presets) is not a press
436 if (
437 "presetsUpdated" not in message
438 and (preset_id := extract_preset_id(message)) is not None
439 ):
440 await self._handle_preset(preset_id)
441 return
442 if self._update_lock.locked():
443 # ignore message as we poll regularly too, and polling is the ultimate truth
444 return
445 async with self._update_lock:
446 try:
447 if "volumeUpdated" in message:
448 await self._refresh_volume()
449 if "nowPlayingUpdated" in message:
450 await self._refresh_now_playing()
451 if "zoneUpdated" in message:
452 await self._refresh_zone()
453 if "bassUpdated" in message:
454 await self._refresh_options({PlayerOptionKeys.BASS})
455 if "infoUpdated" in message:
456 await self._refresh_options({PlayerOptionKeys.NETWORK_NAME})
457 self.update_state()
458 except (aiohttp.ClientError, TimeoutError, OSError) as err:
459 self.logger.debug("Failed to refresh state for %s: %s", self.name, err)
460
461 async def _handle_preset(self, preset_id: int) -> None:
462 """Play the Music Assistant media configured for the given preset button."""
463 if preset_id not in PRESET_IDS:
464 return
465 # preset buttons are mapped once on the provider, shared by all its speakers
466 media_id = cast("BoseSoundTouchProvider", self.provider).get_preset_media(preset_id)
467 if not media_id:
468 self.logger.warning(
469 "Preset %s pressed on %s but no media is configured", preset_id, self.name
470 )
471 return
472 self.logger.info("Preset %s pressed on %s, playing %s", preset_id, self.name, media_id)
473 player_id = self.player_id if self.synced_to is None else self.synced_to
474 try:
475 await self.mass.player_queues.play_media(queue_id=player_id, media=media_id)
476 except MediaNotFoundError:
477 self.logger.error(
478 "Unable to play media for preset %s, as the media does not exist.", preset_id
479 )
480 except MusicAssistantError:
481 self.logger.exception("Unable to play media for preset %s", preset_id)
482
483 async def _refresh_volume(self) -> None:
484 """Refresh volume state from the speaker."""
485 volume = await self._client.get_volume()
486 self._attr_volume_level = volume.actual_volume
487 self._attr_volume_muted = volume.mute_enabled
488
489 async def _refresh_now_playing(self) -> None:
490 """Refresh playback state from the speaker."""
491 self._update_state_from_now_playing(await self._client.get_now_playing())
492
493 async def _refresh_options(self, update_options: set[PlayerOptionKeys] | None = None) -> None:
494 """Refresh available player options."""
495 if update_options is None:
496 # update all
497 update_options = self._supported_player_options
498
499 def _update_option_attr(update_option: PlayerOption) -> None:
500 index: int | None = None
501 for idx, option in enumerate(self._attr_options):
502 if option.key == update_option.key:
503 index = idx
504 break
505 if index is None:
506 self._attr_options.append(update_option)
507 else:
508 self._attr_options[index] = update_option
509
510 for option in update_options:
511 # _updated_options: list[PlayerOption] = []
512 match option:
513 case PlayerOptionKeys.NETWORK_NAME:
514 info = await self._client.get_info()
515 _update_option_attr(
516 PlayerOption(
517 key=PlayerOptionKeys.NETWORK_NAME,
518 translation_key=PlayerOptionKeys.NETWORK_NAME,
519 name=PlayerOptionKeys.NETWORK_NAME,
520 type=PlayerOptionType.STRING,
521 value=info.name,
522 read_only=True, # TODO: make that non-read-only
523 )
524 )
525 case PlayerOptionKeys.BASS:
526 old_option: PlayerOption | None = None
527 for _option in self._attr_options:
528 if _option.key == PlayerOptionKeys.BASS:
529 old_option = _option
530 break
531 if not old_option:
532 bass_capability = await self._client.get_bass_capabilities()
533 assert bass_capability.available is not None
534 assert bass_capability.minimum is not None
535 assert bass_capability.maximum is not None
536 bass_min = bass_capability.minimum
537 bass_max = bass_capability.maximum
538 else:
539 assert old_option.min_value is not None
540 assert old_option.max_value is not None
541 bass_min = int(old_option.min_value)
542 bass_max = int(old_option.max_value)
543 bass = await self._client.get_bass()
544 assert bass.actual_bass is not None
545 _update_option_attr(
546 PlayerOption(
547 key=PlayerOptionKeys.BASS,
548 translation_key=PlayerOptionKeys.BASS,
549 name=PlayerOptionKeys.BASS,
550 type=PlayerOptionType.INTEGER,
551 value=bass.actual_bass,
552 min_value=bass_min,
553 max_value=bass_max,
554 step=1,
555 )
556 )
557
558 async def _refresh_sources(self) -> None:
559 """Refresh the list of selectable native sources from the speaker."""
560 try:
561 sources = await self._client.get_sources()
562 except (aiohttp.ClientError, TimeoutError, OSError) as err:
563 self.logger.debug("Failed to fetch sources for %s: %s", self.name, err)
564 return
565 self._attr_source_list = []
566 for source in sources.sources:
567 if source.source and source.source_name and source.status == SourceStatus.READY:
568 self._attr_source_list.append(
569 PlayerSource(
570 id=source_id(source.source, source.source_account),
571 name=source.source_name,
572 passive=False,
573 can_play_pause=True,
574 can_seek=False,
575 can_next_previous=True,
576 )
577 )
578
579 async def _refresh_zone(self) -> None:
580 """Refresh multiroom zone (group) membership from the speaker."""
581 zone = await self._client.get_zone()
582 if zone.leader and zone.leader.mac == self._device_id and zone.members:
583 members = [self.player_id]
584 members.extend(
585 f"{PLAYER_ID_PREFIX}{member_id}"
586 for member_id in [x.mac for x in zone.members]
587 if member_id != self._device_id
588 )
589 self._attr_group_members = members
590 else:
591 self._attr_group_members = []
592
593 def _update_state_from_now_playing(self, now_playing: NowPlaying) -> None:
594 """Update player state from a now_playing snapshot."""
595 source = (
596 now_playing.content_item.source if now_playing.content_item is not None else None
597 ) or now_playing.source
598 self._attr_powered = source != SOURCE_STANDBY
599 if not self._attr_powered:
600 self._attr_playback_state = PlaybackState.IDLE
601 self._attr_active_source = None
602 self._attr_current_media = None
603 return
604
605 if now_playing.play_status in [PlayStatus.PLAY_STATE, PlayStatus.BUFFERING_STATE]:
606 self._attr_playback_state = PlaybackState.PLAYING
607 elif now_playing.play_status == PlayStatus.PAUSE_STATE:
608 self._attr_playback_state = PlaybackState.PAUSED
609 else:
610 self._attr_playback_state = PlaybackState.IDLE
611
612 if now_playing.time_information and now_playing.time_information.position is not None:
613 self._attr_elapsed_time = float(now_playing.time_information.position)
614 self._attr_elapsed_time_last_updated = time.time()
615
616 active_queue = self.mass.player_queues.get(self.player_id)
617 if (
618 active_queue
619 and active_queue.current_item
620 and now_playing.content_item
621 and now_playing.content_item.source == "UPNP"
622 ):
623 # Music Assistant is the active source; audio is rendered via the linked
624 # protocol and Music Assistant owns the metadata, so don't override it here.
625 self._attr_active_source = self.player_id
626 elif now_playing.content_item and now_playing.content_item.source:
627 # a native source (Bluetooth, AUX, Spotify, ...) is playing on the speaker
628 if now_playing.content_item.source != SOURCE_INVALID:
629 # SOURCE_INVALID is some API flakiness
630 self._attr_active_source = source_id(
631 now_playing.content_item.source, now_playing.content_item.source_account
632 )
633 if (
634 now_playing.track
635 or now_playing.artist
636 or now_playing.album
637 or now_playing.content_item.item_name
638 ):
639 image_url = now_playing.art.url if now_playing.art is not None else None
640 duration = (
641 now_playing.time_information.total
642 if now_playing.time_information is not None
643 else None
644 )
645 self._attr_current_media = PlayerMedia(
646 uri=f"soundtouch://{now_playing.content_item.source}",
647 media_type=MediaType.UNKNOWN,
648 title=now_playing.track or now_playing.content_item.item_name,
649 artist=now_playing.artist,
650 album=now_playing.album,
651 image_url=image_url,
652 duration=duration,
653 source_id=self._attr_active_source,
654 )
655 else:
656 self._attr_current_media = None
657