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