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