/
/
/
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 await self._client.set_volume(volume_level, mute=False)
195 self._attr_volume_level = volume_level
196 self._attr_volume_muted = False
197 self.update_state()
198
199 async def volume_mute(self, muted: bool) -> None:
200 """Handle VOLUME_MUTE command on the player."""
201 volume = await self._client.get_volume()
202 if volume.mute_enabled != muted:
203 # the MUTE key toggles mute, so only send it when a change is needed
204 await self._client.press_key(Key.MUTE)
205 self._attr_volume_muted = muted
206 self.update_state()
207
208 async def play(self) -> None:
209 """Handle PLAY (resume) command on a native source."""
210 await self._client.press_key(Key.PLAY)
211 self._attr_playback_state = PlaybackState.PLAYING
212 self.update_state()
213
214 async def play_media(self, media: PlayerMedia) -> None:
215 """Play media."""
216 async with self._update_lock:
217 media.uri = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
218 # clear any pending AVT state to avoid wedging on rapid play_media
219 self.logger.debug("Starting to play %s.", media.title)
220 await avt_stop(self.mass.http_session, self)
221 await avt_set_url(self.mass.http_session, self, player_media=media)
222 await avt_play(self.mass.http_session, self)
223 self._attr_poll_interval = PLAYBACK_POLL_INTERVAL
224
225 async def stop(self) -> None:
226 """Stop command."""
227 await self._client.press_key(Key.STOP)
228
229 async def pause(self) -> None:
230 """Handle PAUSE command on a native source."""
231 await self._client.press_key(Key.PAUSE)
232 self._attr_playback_state = PlaybackState.PAUSED
233 self.update_state()
234
235 async def next_track(self) -> None:
236 """Handle NEXT_TRACK command on a native source."""
237 await self._client.press_key(Key.NEXT_TRACK)
238
239 async def previous_track(self) -> None:
240 """Handle PREVIOUS_TRACK command on a native source."""
241 await self._client.press_key(Key.PREV_TRACK)
242
243 async def select_source(self, source: str) -> None:
244 """Handle SELECT_SOURCE command on the player."""
245 source_name, _, source_account = source.partition(":")
246 await self._client.select_source(source_name, source_account or None)
247 await self._refresh_now_playing()
248
249 async def play_announcement(
250 self, announcement: PlayerMedia, volume_level: int | None = None
251 ) -> None:
252 """Handle (native) playback of an announcement on the player."""
253 if not self._app_key:
254 return
255 self.logger.debug("Playing announcement %s on %s", announcement.uri, self.name)
256 await self._client.play_notification(self._app_key, announcement.uri, volume_level)
257 # the notification API reports no completion, so wait out the announcement here:
258 # its audio is only served for as long as this call is running
259 duration = await self.mass.streams.get_announcement_duration(announcement)
260 await asyncio.sleep(duration or 10)
261
262 async def set_option(self, option_key: str, option_value: PlayerOptionValueType) -> None:
263 """Set player option."""
264 match option_key:
265 case PlayerOptionKeys.NETWORK_NAME:
266 await self._client.set_name(str(option_value))
267 case PlayerOptionKeys.BASS:
268 await self._client.set_bass(int(option_value))
269
270 await self._refresh_options()
271
272 async def set_members(
273 self,
274 player_ids_to_add: list[str] | None = None,
275 player_ids_to_remove: list[str] | None = None,
276 ) -> None:
277 """Handle SET_MEMBERS command using the SoundTouch multiroom zone API."""
278
279 def get_zone(player_ids: list[str]) -> Zone:
280 members: list[ZoneMember] = []
281 for player_id in player_ids:
282 player = self.mass.players.get_player(player_id)
283 if isinstance(player, BoseSoundTouchPlayer) and (
284 ip_address := player._client.session_config.ip
285 ):
286 members.append(ZoneMember(ip=ip_address, mac=player.device_id))
287 return Zone(
288 leader=ZoneMember(ip=self._client.session_config.ip, mac=self._device_id),
289 members=members,
290 )
291
292 current_zone = await self._client.get_zone()
293 if not current_zone.members and player_ids_to_add:
294 # create a new zone
295 self.logger.debug("Creating a new zone.")
296 zone = get_zone(player_ids_to_add)
297 await self._client.set_zone(zone)
298 await self._refresh_zone()
299 return
300 self.logger.debug("Updating an existing zone.")
301
302 # update a current zone
303 if player_ids_to_add:
304 zone = get_zone(player_ids_to_add)
305 await self._client.add_zone_members(zone)
306
307 if player_ids_to_remove:
308 zone = get_zone(player_ids_to_remove)
309 await self._client.remove_zone_members(zone)
310
311 await self._refresh_zone()
312
313 # --- Public helpers ---
314
315 @property
316 def device_id(self) -> str:
317 """Return the Bose SoundTouch device id of this player."""
318 return self._device_id
319
320 def update_ip_address(self, ip_address: str) -> None:
321 """Update the speaker's IP address after a (re)discovery."""
322 if ip_address == self._client.session_config.ip:
323 return
324 self.logger.debug("Address updated to %s for player %s", ip_address, self.name)
325 self._client.session_config.ip = ip_address
326 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, ip_address)
327 self.mass.players.trigger_player_update(self.player_id)
328
329 async def get_config_entries(self) -> list[ConfigEntry]:
330 """Get config entries."""
331 base_entries = await super().get_config_entries()
332
333 default_entries = [
334 # by far the most consistent results with mp3. flac is flakier.
335 CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3,
336 CONF_ENTRY_FLOW_MODE,
337 create_sample_rates_config_entry(max_sample_rate=192000, max_bit_depth=24),
338 ]
339
340 current_presets = await self._client.get_presets()
341 preset_ids = [x.id_ for x in current_presets.presets if isinstance(x.id_, int)]
342
343 preset_entries = [
344 ConfigEntry(
345 key=preset_action,
346 type=ConfigEntryType.ACTION,
347 translation_key="action_overwrite_preset"
348 if preset_id not in preset_ids
349 else "action_overwrite_preset_preset_available",
350 action=preset_action,
351 translation_params=[str(preset_id)],
352 )
353 for preset_id, preset_action in enumerate(
354 (
355 ACTION_OVERWRITE_PRESET_1,
356 ACTION_OVERWRITE_PRESET_2,
357 ACTION_OVERWRITE_PRESET_3,
358 ACTION_OVERWRITE_PRESET_4,
359 ACTION_OVERWRITE_PRESET_5,
360 ACTION_OVERWRITE_PRESET_6,
361 ),
362 1,
363 )
364 ]
365
366 # preset first, as the others are advanced
367 return preset_entries + base_entries + default_entries
368
369 async def handle_config_action(
370 self, action: str
371 ) -> list[ConfigEntry] | ConfigActionResult | None:
372 """Handle config actions."""
373 mapping_preset_int = {
374 ACTION_OVERWRITE_PRESET_1: 1,
375 ACTION_OVERWRITE_PRESET_2: 2,
376 ACTION_OVERWRITE_PRESET_3: 3,
377 ACTION_OVERWRITE_PRESET_4: 4,
378 ACTION_OVERWRITE_PRESET_5: 5,
379 ACTION_OVERWRITE_PRESET_6: 6,
380 }
381 if action in mapping_preset_int:
382 preset_id = mapping_preset_int.get(action, 1)
383 await self._client.store_preset(
384 preset_id,
385 f"{self.mass.webserver.base_url}/{self.provider.instance_id}",
386 )
387 return await self.get_config_entries()
388
389 return await super().handle_config_action(action)
390
391 # --- Private helpers ---
392
393 async def _listen(self) -> None:
394 """Connect to the speaker's notification websocket and handle push updates."""
395 while not self._stop_event.is_set():
396 uri = f"ws://{self._client.session_config.ip}:{NOTIFICATION_PORT}"
397 try:
398 async with self.mass.http_session.ws_connect(
399 uri, protocols=WS_SUBPROTOCOLS, heartbeat=WS_HEARTBEAT
400 ) as ws:
401 self.logger.debug("Connected to SoundTouch websocket: %s", uri)
402 if not self._attr_available:
403 self._attr_available = True
404 self.update_state()
405 async for msg in ws:
406 if self._stop_event.is_set():
407 break
408 if msg.type == aiohttp.WSMsgType.TEXT:
409 await self._handle_update_message(msg.data)
410 elif msg.type == aiohttp.WSMsgType.BINARY:
411 await self._handle_update_message(msg.data.decode())
412 elif msg.type in (
413 aiohttp.WSMsgType.ERROR,
414 aiohttp.WSMsgType.CLOSE,
415 aiohttp.WSMsgType.CLOSED,
416 ):
417 break
418 except asyncio.CancelledError:
419 raise
420 except (aiohttp.ClientError, OSError, TimeoutError, UnicodeDecodeError) as err:
421 self.logger.debug(
422 "SoundTouch websocket error for %s: %s. Reconnecting in %ss",
423 self.name,
424 err,
425 RECONNECT_DELAY,
426 )
427 if not self._stop_event.is_set():
428 with contextlib.suppress(TimeoutError):
429 await asyncio.wait_for(self._stop_event.wait(), timeout=RECONNECT_DELAY)
430
431 async def _handle_update_message(self, message: str) -> None:
432 """Handle a single websocket notification message."""
433 # a physical preset button press maps to the configured Music Assistant media;
434 # the bulk "presetsUpdated" notification (which lists all presets) is not a press
435 if (
436 "presetsUpdated" not in message
437 and (preset_id := extract_preset_id(message)) is not None
438 ):
439 await self._handle_preset(preset_id)
440 return
441 if self._update_lock.locked():
442 # ignore message as we poll regularly too, and polling is the ultimate truth
443 return
444 async with self._update_lock:
445 try:
446 if "volumeUpdated" in message:
447 await self._refresh_volume()
448 if "nowPlayingUpdated" in message:
449 await self._refresh_now_playing()
450 if "zoneUpdated" in message:
451 await self._refresh_zone()
452 if "bassUpdated" in message:
453 await self._refresh_options({PlayerOptionKeys.BASS})
454 if "infoUpdated" in message:
455 await self._refresh_options({PlayerOptionKeys.NETWORK_NAME})
456 self.update_state()
457 except (aiohttp.ClientError, TimeoutError, OSError) as err:
458 self.logger.debug("Failed to refresh state for %s: %s", self.name, err)
459
460 async def _handle_preset(self, preset_id: int) -> None:
461 """Play the Music Assistant media configured for the given preset button."""
462 if preset_id not in PRESET_IDS:
463 return
464 # preset buttons are mapped once on the provider, shared by all its speakers
465 media_id = cast("BoseSoundTouchProvider", self.provider).get_preset_media(preset_id)
466 if not media_id:
467 self.logger.warning(
468 "Preset %s pressed on %s but no media is configured", preset_id, self.name
469 )
470 return
471 self.logger.info("Preset %s pressed on %s, playing %s", preset_id, self.name, media_id)
472 player_id = self.player_id if self.synced_to is None else self.synced_to
473 try:
474 await self.mass.player_queues.play_media(queue_id=player_id, media=media_id)
475 except MediaNotFoundError:
476 self.logger.error(
477 "Unable to play media for preset %s, as the media does not exist.", preset_id
478 )
479 except MusicAssistantError:
480 self.logger.exception("Unable to play media for preset %s", preset_id)
481
482 async def _refresh_volume(self) -> None:
483 """Refresh volume state from the speaker."""
484 volume = await self._client.get_volume()
485 self._attr_volume_level = volume.actual_volume
486 self._attr_volume_muted = volume.mute_enabled
487
488 async def _refresh_now_playing(self) -> None:
489 """Refresh playback state from the speaker."""
490 self._update_state_from_now_playing(await self._client.get_now_playing())
491
492 async def _refresh_options(self, update_options: set[PlayerOptionKeys] | None = None) -> None:
493 """Refresh available player options."""
494 if update_options is None:
495 # update all
496 update_options = self._supported_player_options
497
498 def _update_option_attr(update_option: PlayerOption) -> None:
499 index: int | None = None
500 for idx, option in enumerate(self._attr_options):
501 if option.key == update_option.key:
502 index = idx
503 break
504 if index is None:
505 self._attr_options.append(update_option)
506 else:
507 self._attr_options[index] = update_option
508
509 for option in update_options:
510 # _updated_options: list[PlayerOption] = []
511 match option:
512 case PlayerOptionKeys.NETWORK_NAME:
513 info = await self._client.get_info()
514 _update_option_attr(
515 PlayerOption(
516 key=PlayerOptionKeys.NETWORK_NAME,
517 translation_key=PlayerOptionKeys.NETWORK_NAME,
518 name=PlayerOptionKeys.NETWORK_NAME,
519 type=PlayerOptionType.STRING,
520 value=info.name,
521 read_only=True, # TODO: make that non-read-only
522 )
523 )
524 case PlayerOptionKeys.BASS:
525 old_option: PlayerOption | None = None
526 for _option in self._attr_options:
527 if _option.key == PlayerOptionKeys.BASS:
528 old_option = _option
529 break
530 if not old_option:
531 bass_capability = await self._client.get_bass_capabilities()
532 assert bass_capability.available is not None
533 assert bass_capability.minimum is not None
534 assert bass_capability.maximum is not None
535 bass_min = bass_capability.minimum
536 bass_max = bass_capability.maximum
537 else:
538 assert old_option.min_value is not None
539 assert old_option.max_value is not None
540 bass_min = int(old_option.min_value)
541 bass_max = int(old_option.max_value)
542 bass = await self._client.get_bass()
543 assert bass.actual_bass is not None
544 _update_option_attr(
545 PlayerOption(
546 key=PlayerOptionKeys.BASS,
547 translation_key=PlayerOptionKeys.BASS,
548 name=PlayerOptionKeys.BASS,
549 type=PlayerOptionType.INTEGER,
550 value=bass.actual_bass,
551 min_value=bass_min,
552 max_value=bass_max,
553 step=1,
554 )
555 )
556
557 async def _refresh_sources(self) -> None:
558 """Refresh the list of selectable native sources from the speaker."""
559 try:
560 sources = await self._client.get_sources()
561 except (aiohttp.ClientError, TimeoutError, OSError) as err:
562 self.logger.debug("Failed to fetch sources for %s: %s", self.name, err)
563 return
564 self._attr_source_list = []
565 for source in sources.sources:
566 if source.source and source.source_name and source.status == SourceStatus.READY:
567 self._attr_source_list.append(
568 PlayerSource(
569 id=source_id(source.source, source.source_account),
570 name=source.source_name,
571 passive=False,
572 can_play_pause=True,
573 can_seek=False,
574 can_next_previous=True,
575 )
576 )
577
578 async def _refresh_zone(self) -> None:
579 """Refresh multiroom zone (group) membership from the speaker."""
580 zone = await self._client.get_zone()
581 if zone.leader and zone.leader.mac == self._device_id and zone.members:
582 members = [self.player_id]
583 members.extend(
584 f"{PLAYER_ID_PREFIX}{member_id}"
585 for member_id in [x.mac for x in zone.members]
586 if member_id != self._device_id
587 )
588 self._attr_group_members = members
589 else:
590 self._attr_group_members = []
591
592 def _update_state_from_now_playing(self, now_playing: NowPlaying) -> None:
593 """Update player state from a now_playing snapshot."""
594 source = (
595 now_playing.content_item.source if now_playing.content_item is not None else None
596 ) or now_playing.source
597 self._attr_powered = source != SOURCE_STANDBY
598 if not self._attr_powered:
599 self._attr_playback_state = PlaybackState.IDLE
600 self._attr_active_source = None
601 self._attr_current_media = None
602 return
603
604 if now_playing.play_status in [PlayStatus.PLAY_STATE, PlayStatus.BUFFERING_STATE]:
605 self._attr_playback_state = PlaybackState.PLAYING
606 elif now_playing.play_status == PlayStatus.PAUSE_STATE:
607 self._attr_playback_state = PlaybackState.PAUSED
608 else:
609 self._attr_playback_state = PlaybackState.IDLE
610
611 if now_playing.time_information and now_playing.time_information.position is not None:
612 self._attr_elapsed_time = float(now_playing.time_information.position)
613 self._attr_elapsed_time_last_updated = time.time()
614
615 active_queue = self.mass.player_queues.get(self.player_id)
616 if (
617 active_queue
618 and active_queue.current_item
619 and now_playing.content_item
620 and now_playing.content_item.source == "UPNP"
621 ):
622 # Music Assistant is the active source; audio is rendered via the linked
623 # protocol and Music Assistant owns the metadata, so don't override it here.
624 self._attr_active_source = self.player_id
625 elif now_playing.content_item and now_playing.content_item.source:
626 # a native source (Bluetooth, AUX, Spotify, ...) is playing on the speaker
627 if now_playing.content_item.source != SOURCE_INVALID:
628 # SOURCE_INVALID is some API flakiness
629 self._attr_active_source = source_id(
630 now_playing.content_item.source, now_playing.content_item.source_account
631 )
632 if (
633 now_playing.track
634 or now_playing.artist
635 or now_playing.album
636 or now_playing.content_item.item_name
637 ):
638 image_url = now_playing.art.url if now_playing.art is not None else None
639 duration = (
640 now_playing.time_information.total
641 if now_playing.time_information is not None
642 else None
643 )
644 self._attr_current_media = PlayerMedia(
645 uri=f"soundtouch://{now_playing.content_item.source}",
646 media_type=MediaType.UNKNOWN,
647 title=now_playing.track or now_playing.content_item.item_name,
648 artist=now_playing.artist,
649 album=now_playing.album,
650 image_url=image_url,
651 duration=duration,
652 source_id=self._attr_active_source,
653 )
654 else:
655 self._attr_current_media = None
656