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