/
/
/
1"""Chromecast Player implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import time
7from collections.abc import Callable
8from typing import TYPE_CHECKING, Any, cast
9from uuid import UUID
10
11if TYPE_CHECKING:
12 from music_assistant_models.config_entries import ConfigEntry
13
14from music_assistant_models.enums import (
15 IdentifierType,
16 MediaType,
17 PlaybackState,
18 PlayerFeature,
19 PlayerType,
20)
21from music_assistant_models.errors import PlayerUnavailableError
22from music_assistant_models.player import PlayerSource
23from pychromecast import IDLE_APP_ID
24from pychromecast.controllers.media import MEDIA_PLAYER_STATE_BUFFERING, STREAM_TYPE_LIVE
25from pychromecast.controllers.multizone import MultizoneController
26from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED
27
28from music_assistant.constants import MASS_LOGO_ONLINE, VERBOSE_LOG_LEVEL
29from music_assistant.helpers.util import is_valid_mac_address
30from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
31
32from .constants import (
33 APP_LAUNCH_TIMEOUT,
34 APP_MEDIA_RECEIVER,
35 CAST_PLAYER_CONFIG_ENTRIES,
36 CONF_ENTRY_SAMPLE_RATES_CAST,
37 CONF_ENTRY_SAMPLE_RATES_CAST_GROUP,
38 CONF_USE_MASS_APP,
39 DASHBOARD_KEEPALIVE_SUFFIXES,
40 MASS_APP_ID,
41 SENDSPIN_CAST_APP_ID,
42)
43from .helpers import CastStatusListener, ChromecastInfo
44
45if TYPE_CHECKING:
46 from pychromecast import Chromecast
47 from pychromecast.controllers.media import MediaStatus
48 from pychromecast.controllers.receiver import CastStatus
49 from pychromecast.socket_client import ConnectionStatus
50
51 from .provider import ChromecastProvider
52
53
54class ChromecastPlayer(Player):
55 """Chromecast Player."""
56
57 active_cast_group: str | None = None
58
59 def __init__(
60 self,
61 provider: ChromecastProvider,
62 player_id: str,
63 cast_info: ChromecastInfo,
64 chromecast: Chromecast,
65 ) -> None:
66 """Init."""
67 super().__init__(provider, player_id)
68 if cast_info.is_audio_group and cast_info.is_multichannel_group:
69 player_type = PlayerType.STEREO_PAIR
70 elif cast_info.is_audio_group:
71 player_type = PlayerType.GROUP
72 elif self._is_google_device(cast_info):
73 # Google devices (Chromecast, Nest, Google Home) have native Cast support
74 player_type = PlayerType.PLAYER
75 else:
76 # Non-Google devices are generic Chromecast receivers
77 # Will be wrapped in a UniversalPlayer
78 player_type = PlayerType.PROTOCOL
79 self.cc = chromecast
80 self.status_listener: CastStatusListener | None
81 self.cast_info = cast_info
82 self.mz_controller: MultizoneController | None = None
83 self.on_app_status_changed: Callable[[str | None], None] | None = None
84 self.last_poll = 0.0
85 self.last_multichannel_check = 0.0
86 self.flow_meta_checksum: str | None = None
87 # set static variables
88 self._attr_supported_features = {
89 PlayerFeature.PLAY_MEDIA,
90 PlayerFeature.VOLUME_SET,
91 PlayerFeature.VOLUME_MUTE,
92 PlayerFeature.PAUSE,
93 PlayerFeature.NEXT_PREVIOUS,
94 PlayerFeature.ENQUEUE,
95 PlayerFeature.SEEK,
96 }
97 self._attr_name = self.cast_info.friendly_name
98 self._attr_available = False
99 self._attr_needs_poll = True
100 self._attr_type = player_type
101 # Disable TV's by default
102 # (can be enabled manually by the user)
103 enabled_by_default = True
104 for exclude in ("tv", "/12", "PUS", "OLED"):
105 if exclude.lower() in cast_info.friendly_name.lower():
106 enabled_by_default = False
107 self._attr_enabled_by_default = enabled_by_default
108
109 self._attr_device_info = DeviceInfo(
110 model=self.cast_info.model_name,
111 manufacturer=self.cast_info.manufacturer or "",
112 )
113 # add mac/IP identifiers for protocol-matching
114 # (but skip for groups since they don't have a real IP/MAC)
115 if not cast_info.is_audio_group:
116 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, self.cast_info.host)
117 # Only add MAC address if it's valid (not 00:00:00:00:00:00)
118 if is_valid_mac_address(self.cast_info.mac_address):
119 self._attr_device_info.add_identifier(
120 IdentifierType.MAC_ADDRESS, self.cast_info.mac_address
121 )
122 self._attr_device_info.add_identifier(IdentifierType.UUID, str(self.cast_info.uuid))
123 self._attr_device_info.add_identifier(IdentifierType.CAST_UUID, str(self.cast_info.uuid))
124 assert provider.mz_mgr is not None # for type checking
125 status_listener = CastStatusListener(self, provider.mz_mgr)
126 self.status_listener = status_listener
127 if player_type == PlayerType.GROUP:
128 mz_controller = MultizoneController(cast_info.uuid)
129 self.cc.register_handler(mz_controller)
130 self.mz_controller = mz_controller
131
132 async def async_setup(self) -> None:
133 """Start the chromecast socket client (must be called after __init__)."""
134 await asyncio.to_thread(self.cc.start)
135
136 async def get_config_entries(self) -> list[ConfigEntry]:
137 """Return all (provider/player specific) Config Entries for the given player (if any)."""
138 if self.type == PlayerType.GROUP:
139 return [
140 *CAST_PLAYER_CONFIG_ENTRIES,
141 CONF_ENTRY_SAMPLE_RATES_CAST_GROUP,
142 ]
143
144 return [
145 *CAST_PLAYER_CONFIG_ENTRIES,
146 CONF_ENTRY_SAMPLE_RATES_CAST,
147 ]
148
149 async def stop(self) -> None:
150 """Send STOP command to given player."""
151 if self.type == PlayerType.GROUP:
152 await asyncio.to_thread(self.cc.media_controller.stop)
153 else:
154 await asyncio.to_thread(self.cc.quit_app)
155
156 async def play(self) -> None:
157 """Send PLAY command to given player."""
158 await asyncio.to_thread(self.cc.media_controller.play)
159
160 async def pause(self) -> None:
161 """Send PAUSE command to given player."""
162 await asyncio.to_thread(self.cc.media_controller.pause)
163
164 async def next_track(self) -> None:
165 """Handle NEXT TRACK command for given player."""
166 await asyncio.to_thread(self.cc.media_controller.queue_next)
167
168 async def previous_track(self) -> None:
169 """Handle PREVIOUS TRACK command for given player."""
170 await asyncio.to_thread(self.cc.media_controller.queue_prev)
171
172 async def seek(self, position: int) -> None:
173 """Handle SEEK command on the player."""
174 await asyncio.to_thread(self.cc.media_controller.seek, position)
175
176 async def power(self, powered: bool) -> None:
177 """Send POWER command to given player (only for Cast Groups)."""
178 if powered:
179 await self._launch_app()
180 self._attr_active_source = None
181 else:
182 self._attr_active_source = None
183 await asyncio.to_thread(self.cc.quit_app)
184 # optimistically update the state
185 self.update_state()
186
187 async def volume_set(self, volume_level: int) -> None:
188 """Send VOLUME_SET command to given player."""
189 # Round to 2 decimal places to avoid floating-point precision issues
190 await asyncio.to_thread(self.cc.set_volume, round(volume_level / 100, 2))
191
192 async def volume_mute(self, muted: bool) -> None:
193 """Send VOLUME MUTE command to given player."""
194 await asyncio.to_thread(self.cc.set_volume_muted, muted)
195
196 async def play_media(
197 self,
198 media: PlayerMedia,
199 ) -> None:
200 """Handle PLAY MEDIA on given player."""
201 stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
202 queuedata = {
203 "type": "LOAD",
204 "media": self._create_cc_media_item(media, stream_url),
205 }
206 # make sure that our media controller app is launched
207 await self._launch_app()
208 # send queue info to the CC
209 media_controller = self.cc.media_controller
210 await asyncio.to_thread(media_controller.send_message, data=queuedata, inc_session_id=True)
211
212 async def enqueue_next_media(self, media: PlayerMedia) -> None:
213 """Handle enqueuing of the next item on the player."""
214 next_item_id = None
215 status = self.cc.media_controller.status
216 stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
217 # lookup position of current track in cast queue
218 cast_current_item_id = getattr(status, "current_item_id", 0)
219 cast_queue_items = getattr(status, "items", [])
220 cur_item_found = False
221 for item in cast_queue_items:
222 if item["itemId"] == cast_current_item_id:
223 cur_item_found = True
224 continue
225 if not cur_item_found:
226 continue
227 next_item_id = item["itemId"]
228 # check if the next queue item isn't already queued
229 if item.get("media", {}).get("customData", {}).get("uri") == stream_url:
230 return
231 queuedata = {
232 "type": "QUEUE_INSERT",
233 "insertBefore": next_item_id,
234 "items": [
235 {
236 "autoplay": True,
237 "startTime": 0,
238 "preloadTime": 0,
239 "media": self._create_cc_media_item(media, stream_url),
240 }
241 ],
242 }
243 media_controller = self.cc.media_controller
244 queuedata["mediaSessionId"] = media_controller.status.media_session_id
245 await asyncio.to_thread(media_controller.send_message, data=queuedata, inc_session_id=True)
246
247 async def poll(self) -> None:
248 """Poll player for state updates."""
249 # only update status of media controller if media controller is active
250 if not self.cc.media_controller.is_active:
251 return
252 try:
253 now = time.time()
254 if (now - self.last_poll) >= 60:
255 self.last_poll = now
256 await asyncio.to_thread(self.cc.media_controller.update_status)
257 except ConnectionResetError as err:
258 raise PlayerUnavailableError from err
259
260 async def on_unload(self) -> None:
261 """Handle logic when the player is unloaded from the Player controller."""
262 await super().on_unload()
263 self.mz_controller = None
264 if self.status_listener is not None:
265 self.status_listener.invalidate()
266 self.status_listener = None
267 self.logger.debug("Disconnecting from chromecast socket %s", self.display_name)
268 if self.mass.closing:
269 # Non-blocking disconnect: close socket, don't wait for thread.
270 # Socket threads are daemon threads and die on process exit.
271 # Blocking disconnect can stall shutdown if threads are slow to exit.
272 self.cc.disconnect(0)
273 else:
274 await asyncio.to_thread(self.cc.disconnect, 10)
275
276 ### Callbacks from Chromecast Statuslistener
277
278 def on_new_cast_status(self, status: CastStatus) -> None:
279 """Handle updated CastStatus (called from pychromecast socket thread)."""
280 if status is None or self.mass.closing:
281 return
282 # Dispatch to event loop for thread-safe attribute mutation
283 self.mass.loop.call_soon_threadsafe(self._handle_cast_status, status)
284
285 def on_new_media_status(self, status: MediaStatus) -> None:
286 """Handle updated MediaStatus (called from pychromecast socket thread)."""
287 if self.mass.closing:
288 return
289 # Dispatch to event loop for thread-safe attribute mutation
290 self.mass.loop.call_soon_threadsafe(self._handle_media_status, status)
291
292 def on_new_connection_status(self, status: ConnectionStatus) -> None:
293 """Handle updated ConnectionStatus (called from pychromecast socket thread)."""
294 if self.mass.closing:
295 return
296 # Dispatch to event loop for thread-safe attribute mutation
297 self.mass.loop.call_soon_threadsafe(self._handle_connection_status, status)
298
299 @staticmethod
300 def _is_google_device(cast_info: ChromecastInfo) -> bool:
301 """
302 Check if a device is a Google device with native Cast support.
303
304 Google devices (Chromecast, Nest, Google Home) have native Cast support
305 and should be exposed as PlayerType.PLAYER. Non-Google devices with Cast
306 support should be exposed as PlayerType.PROTOCOL.
307 """
308 if not cast_info.manufacturer:
309 # If no manufacturer, check model name for Google devices
310 model = cast_info.model_name.lower() if cast_info.model_name else ""
311 return any(google in model for google in ("chromecast", "google", "nest", "home"))
312 return cast_info.manufacturer.lower() in ("google", "google inc.")
313
314 def _on_player_media_updated(self) -> None:
315 """Handle callback when the current media of the player is updated."""
316 if self.powered is False:
317 return
318 if not self.cc.media_controller.status.player_is_playing:
319 return
320 if self.active_cast_group:
321 return
322 if self._attr_playback_state != PlaybackState.PLAYING:
323 return
324 if not (current_media := self.state.current_media):
325 return
326 if not (
327 (self._attr_current_media and "/flow/" in self._attr_current_media.uri)
328 or current_media.media_type
329 in (
330 MediaType.RADIO,
331 MediaType.AUDIO_SOURCE,
332 )
333 ):
334 # only update metadata for streams without known duration
335 return
336
337 async def update_flow_metadata() -> None:
338 """Update the metadata of a cast player running the flow (or radio) stream."""
339 media_controller = self.cc.media_controller
340 # update metadata of current item chromecast
341 title = current_media.title or "Music Assistant"
342 artist = current_media.artist or ""
343 album = current_media.album or ""
344 image_url = current_media.image_url or MASS_LOGO_ONLINE
345 flow_meta_checksum = f"{current_media.uri}-{album}-{artist}-{title}-{image_url}"
346 if self.flow_meta_checksum != flow_meta_checksum:
347 # only update if something changed
348 self.flow_meta_checksum = flow_meta_checksum
349 queuedata = {
350 "type": "PLAY",
351 "mediaSessionId": media_controller.status.media_session_id,
352 "customData": {
353 "metadata": {
354 "metadataType": 3,
355 "albumName": album,
356 "songName": title,
357 "artist": artist,
358 "title": title,
359 "images": [{"url": image_url}],
360 }
361 },
362 }
363 await asyncio.to_thread(
364 media_controller.send_message, data=queuedata, inc_session_id=True
365 )
366
367 if len(getattr(media_controller.status, "items", [])) < 2:
368 # In flow mode, all queue tracks are sent to the player as continuous stream.
369 # add a special 'command' item to the queue
370 # this allows for on-player next buttons/commands to still work
371 cmd_next_url = self.mass.streams.get_command_url(self.player_id, "next")
372 msg = {
373 "type": "QUEUE_INSERT",
374 "mediaSessionId": media_controller.status.media_session_id,
375 "items": [
376 {
377 "media": {
378 "contentId": cmd_next_url,
379 "customData": {
380 "uri": cmd_next_url,
381 "queue_item_id": cmd_next_url,
382 },
383 "contentType": "audio/flac",
384 "streamType": STREAM_TYPE_LIVE,
385 "metadata": {},
386 },
387 "autoplay": True,
388 "startTime": 0,
389 "preloadTime": 0,
390 }
391 ],
392 }
393 await asyncio.to_thread(
394 media_controller.send_message, data=msg, inc_session_id=True
395 )
396
397 self.mass.create_task(update_flow_metadata())
398
399 async def _launch_app(self) -> None:
400 """Launch the configured Media Receiver App on a Chromecast."""
401 if self.cc.app_id in (MASS_APP_ID, APP_MEDIA_RECEIVER):
402 return # already active with a compatible media receiver app
403
404 if self.config.get_value(CONF_USE_MASS_APP, True):
405 app_id = MASS_APP_ID
406 else:
407 app_id = APP_MEDIA_RECEIVER
408
409 event = asyncio.Event()
410 launched = False
411
412 def launched_callback(success: bool, response: dict[str, Any] | None) -> None: # noqa: ARG001
413 nonlocal launched
414 launched = success
415 self.mass.loop.call_soon_threadsafe(event.set)
416
417 def launch() -> None:
418 self.logger.debug("Launching App %s.", app_id)
419 self.cc.socket_client.receiver_controller.launch_app(
420 app_id,
421 force_launch=True,
422 callback_function=launched_callback,
423 )
424
425 await self.mass.loop.run_in_executor(None, launch)
426 try:
427 await asyncio.wait_for(event.wait(), timeout=APP_LAUNCH_TIMEOUT)
428 except TimeoutError:
429 # pychromecast resolves the launch callback only on a reply with a matching
430 # request id, so an ignored LAUNCH never completes on its own.
431 self._log_launch_failure(app_id, "the receiver did not respond")
432 raise PlayerUnavailableError(
433 f"Timed out launching app on {self.display_name}",
434 translation_key="app_launch_timeout",
435 translation_owner=self.translation_owner,
436 translation_args=[self.display_name],
437 ) from None
438
439 if not launched:
440 # not via register_launch_error_listener: a registered listener makes
441 # pychromecast skip its retry of a CANCELLED launch
442 failure = self.cc.socket_client.receiver_controller.launch_failure
443 reason = getattr(failure, "reason", None) or "no reason given"
444 self._log_launch_failure(app_id, reason)
445 raise PlayerUnavailableError(
446 f"Launching app on {self.display_name} was refused: {reason}",
447 translation_key="app_launch_refused",
448 translation_owner=self.translation_owner,
449 translation_args=[self.display_name],
450 )
451
452 if self.cc.app_id != app_id:
453 # a receiver can acknowledge the launch without starting the app;
454 # pychromecast applies the status before the callback, so app_id is current
455 self._log_launch_failure(app_id, "the receiver did not start the app")
456 raise PlayerUnavailableError(
457 f"App did not start on {self.display_name}",
458 translation_key="app_launch_refused",
459 translation_owner=self.translation_owner,
460 translation_args=[self.display_name],
461 )
462
463 def _log_launch_failure(self, app_id: str, reason: str) -> None:
464 """
465 Log a failed receiver app launch and which config option to try instead.
466
467 :param app_id: Cast application id that failed to launch.
468 :param reason: Why the launch failed, as reported by the receiver.
469 """
470 # Cast emulators in TV boxes and phone apps often implement only one of the two
471 # receiver apps, so the opposite setting is the first thing to try.
472 suggestion = "disabling" if app_id == MASS_APP_ID else "enabling"
473 self.logger.warning(
474 "%s did not launch app %s: %s. If this player keeps failing to start "
475 "playback, try %s the 'Use Music Assistant Cast App' option in its settings.",
476 self.display_name,
477 app_id,
478 reason,
479 suggestion,
480 )
481
482 def _handle_cast_status(self, status: CastStatus) -> None:
483 """Process CastStatus on the event loop thread."""
484 if self.mass.closing:
485 return
486 self.logger.log(
487 VERBOSE_LOG_LEVEL,
488 "Received cast status for %s - app_id: %s - volume: %s",
489 self.display_name,
490 status.app_id,
491 status.volume_level,
492 )
493 # handle stereo pairs
494 if self.cast_info.is_multichannel_group:
495 self._attr_type = PlayerType.STEREO_PAIR
496 self._attr_group_members.clear()
497 # handle cast groups
498 if self.cast_info.is_audio_group and not self.cast_info.is_multichannel_group:
499 assert self.mz_controller is not None # for type checking
500 self._attr_type = PlayerType.GROUP
501 self._attr_group_members = [str(UUID(x)) for x in self.mz_controller.members]
502 self._attr_static_group_members = self._attr_group_members.copy()
503 self._attr_supported_features = {
504 PlayerFeature.PLAY_MEDIA,
505 # only cast groups can be powered on/off as a group,
506 # so only add the POWER feature for groups
507 PlayerFeature.POWER,
508 PlayerFeature.VOLUME_SET,
509 PlayerFeature.VOLUME_MUTE,
510 PlayerFeature.PAUSE,
511 PlayerFeature.ENQUEUE,
512 }
513 self._attr_powered = self.cc.app_id is not None and self.cc.app_id != IDLE_APP_ID
514
515 # update player status
516 self._attr_name = self.cast_info.friendly_name
517 self._attr_volume_level = round(status.volume_level * 100)
518 self._attr_volume_muted = status.volume_muted
519 self.update_state()
520 if self.on_app_status_changed is not None:
521 try:
522 self.on_app_status_changed(status.app_id)
523 except Exception:
524 self.logger.exception("Error in app status callback for %s", self.display_name)
525
526 def _handle_media_status(self, status: MediaStatus) -> None: # noqa: PLR0915
527 """Process MediaStatus on the event loop thread."""
528 self.logger.log(
529 VERBOSE_LOG_LEVEL,
530 "Received media status for %s update: %s",
531 self.display_name,
532 status.player_state,
533 )
534 # handle player playing from a group
535 group_player: ChromecastPlayer | None = None
536 if self.active_cast_group is not None:
537 player_obj = self.mass.players.get_player(self.active_cast_group)
538 if not player_obj:
539 return
540 # Now assert/check the type to satisfy MyPy
541 if not isinstance(player_obj, ChromecastPlayer):
542 return
543 group_player = player_obj
544 status = group_player.cc.media_controller.status
545
546 # never surface the receiver's dashboard keepalive as actual playback
547 if status.content_id and status.content_id.endswith(DASHBOARD_KEEPALIVE_SUFFIXES):
548 self._attr_playback_state = PlaybackState.IDLE
549 self._attr_current_media = None
550 self._attr_active_source = None
551 self._attr_elapsed_time = 0
552 self._attr_elapsed_time_last_updated = time.time()
553 self.update_state()
554 return
555
556 # player state
557 # pychromecast reports BUFFERING as 'playing', so a Cast group that underruns the
558 # LIVE flow stream at EOF never goes idle. Treat that case as idle so the queue
559 # can resume/restart.
560 flow_underrun = (
561 status.player_state == MEDIA_PLAYER_STATE_BUFFERING and self._flow_stream_underrun()
562 )
563 is_playing = status.player_is_playing and not flow_underrun
564 is_idle = status.player_is_idle or flow_underrun
565 self._attr_elapsed_time_last_updated = time.time()
566 if is_playing:
567 self._attr_playback_state = PlaybackState.PLAYING
568 self.set_current_media(uri=status.content_id or "", clear_all=True)
569 elif status.player_is_paused:
570 self._attr_playback_state = PlaybackState.PAUSED
571 self._attr_current_media = None
572 self._attr_active_source = None
573 else:
574 self._attr_playback_state = PlaybackState.IDLE
575 self._attr_current_media = None
576 self._attr_active_source = None
577
578 # elapsed time
579 self._attr_elapsed_time_last_updated = time.time()
580 self._attr_elapsed_time = status.adjusted_current_time
581 if is_playing:
582 self._attr_elapsed_time = status.adjusted_current_time
583 else:
584 self._attr_elapsed_time = status.current_time
585
586 # active source
587 if group_player:
588 self._attr_active_source = group_player.active_source or group_player.player_id
589 elif self.cc.app_id in (MASS_APP_ID, APP_MEDIA_RECEIVER, SENDSPIN_CAST_APP_ID):
590 self._attr_active_source = None
591 else:
592 app_name = self.cc.app_display_name or "Unknown App"
593 app_id = app_name.lower().replace(" ", "_")
594 self._attr_active_source = app_id
595 has_controls = app_name in ("Spotify", "Qobuz", "YouTube Music", "Deezer", "Tidal")
596 if not any(source.id == app_id for source in self._attr_source_list):
597 self._attr_source_list.append(
598 PlayerSource(
599 id=app_id,
600 name=app_name,
601 passive=True,
602 can_play_pause=has_controls,
603 can_seek=has_controls,
604 can_next_previous=has_controls,
605 )
606 )
607
608 if status.content_id and not is_idle:
609 self.set_current_media(
610 uri=status.content_id,
611 title=status.title,
612 artist=status.artist,
613 album=status.album_name,
614 image_url=status.images[0].url if status.images else None,
615 duration=int(status.duration) if status.duration is not None else None,
616 media_type=MediaType.TRACK,
617 )
618 else:
619 self._attr_current_media = None
620
621 # weird workaround which is needed for multichannel group childs
622 # (e.g. a stereo pair within a cast group)
623 # where it does not receive updates from the group,
624 # so we need to update the group child(s) manually
625 if self.type == PlayerType.GROUP and self.powered:
626 for child_id in self.group_members:
627 if child := self.mass.players.get_player(child_id):
628 assert isinstance(child, ChromecastPlayer) # for type checking
629 if not child.cast_info.is_multichannel_group:
630 continue
631 child._attr_playback_state = self._attr_playback_state
632 child._attr_current_media = self._attr_current_media
633 child._attr_elapsed_time = self._attr_elapsed_time
634 child._attr_elapsed_time_last_updated = self._attr_elapsed_time_last_updated
635 child._attr_active_source = self.active_source
636 child.update_state()
637 self.update_state()
638
639 def _handle_connection_status(self, status: ConnectionStatus) -> None:
640 """Process ConnectionStatus on the event loop thread."""
641 self.logger.log(
642 VERBOSE_LOG_LEVEL,
643 "Received connection status update for %s - status: %s",
644 self.display_name,
645 status.status,
646 )
647
648 if status.status == CONNECTION_STATUS_DISCONNECTED:
649 self._attr_available = False
650 self.update_state()
651 if self.on_app_status_changed is not None:
652 try:
653 self.on_app_status_changed(None)
654 except Exception:
655 self.logger.exception("Error in app status callback for %s", self.display_name)
656 return
657
658 new_available = status.status == CONNECTION_STATUS_CONNECTED
659 if new_available != self.available:
660 self.logger.debug(
661 "[%s] Cast device availability changed: %s",
662 self.cast_info.friendly_name,
663 status.status,
664 )
665 self._attr_available = new_available
666 self._attr_device_info.model = self.cast_info.model_name
667 self._attr_device_info.manufacturer = self.cast_info.manufacturer or ""
668 # Groups share a member device's IP/MAC, skip to avoid false protocol matches
669 if not self.cast_info.is_audio_group:
670 self._attr_device_info.add_identifier(
671 IdentifierType.IP_ADDRESS, self.cast_info.host
672 )
673 if is_valid_mac_address(self.cast_info.mac_address):
674 self._attr_device_info.add_identifier(
675 IdentifierType.MAC_ADDRESS, self.cast_info.mac_address
676 )
677 self._attr_device_info.add_identifier(IdentifierType.UUID, str(self.cast_info.uuid))
678 self._attr_device_info.add_identifier(
679 IdentifierType.CAST_UUID, str(self.cast_info.uuid)
680 )
681 self.update_state()
682
683 if new_available and self.type == PlayerType.PLAYER:
684 # Poll current group status
685 provider = cast("ChromecastProvider", self.provider)
686 mz_mgr = provider.mz_mgr
687 assert mz_mgr is not None # for type checking
688 for group_uuid in mz_mgr.get_multizone_memberships(self.cast_info.uuid):
689 group_media_controller = mz_mgr.get_multizone_mediacontroller(UUID(group_uuid))
690 if not group_media_controller:
691 continue
692
693 def _create_cc_media_item(self, media: PlayerMedia, stream_url: str) -> dict[str, Any]:
694 """Create CC media item from MA PlayerMedia."""
695 # Always use LIVE stream type because MA streams are real-time encoded by FFmpeg,
696 # so they are not seekable and don't have a known content length.
697 stream_type = STREAM_TYPE_LIVE
698 metadata = {
699 "metadataType": 3,
700 "albumName": media.album or "",
701 "songName": media.title or "",
702 "artist": media.artist or "",
703 "title": media.title or "",
704 "images": [{"url": media.image_url}] if media.image_url else None,
705 }
706 file_ext = stream_url.split("?", maxsplit=1)[0].rsplit(".", maxsplit=1)[-1].lower()
707 return {
708 "contentId": stream_url,
709 "customData": {
710 "uri": media.uri,
711 "queue_item_id": media.queue_item_id or stream_url,
712 },
713 "contentType": f"audio/{file_ext}",
714 "streamType": stream_type,
715 "metadata": metadata,
716 "duration": media.stream_duration or media.duration,
717 }
718
719 def _flow_stream_underrun(self) -> bool:
720 """Return whether the active queue's flow stream has been fully consumed."""
721 # Resolve the queue-owning player: a Cast group child mirrors the group's
722 # status, and a Cast exposed as a protocol player is wrapped by a universal
723 # player that owns the queue. Only a native/standalone Cast owns its own queue.
724 queue_id = self.active_cast_group or self.protocol_parent_id or self.player_id
725 return self.mass.player_queues.flow_stream_finished(queue_id)
726