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