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