/
/
/
1"""Chromecast Player Provider implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import contextlib
7import logging
8import threading
9from typing import TYPE_CHECKING, cast
10
11import pychromecast
12from pychromecast.controllers.multizone import MultizoneManager
13from pychromecast.discovery import CastBrowser, SimpleCastListener
14
15from music_assistant.constants import CONF_ENTRY_MANUAL_DISCOVERY_IPS, VERBOSE_LOG_LEVEL
16from music_assistant.models.player_provider import PlayerProvider
17
18from .helpers import ChromecastInfo
19from .player import ChromecastPlayer
20
21if TYPE_CHECKING:
22 from music_assistant_models.config_entries import ProviderConfig
23 from music_assistant_models.enums import ProviderFeature
24 from music_assistant_models.provider import ProviderManifest
25 from pychromecast.models import CastInfo
26
27 from music_assistant.mass import MusicAssistant
28
29
30class ChromecastProvider(PlayerProvider):
31 """Player provider for Chromecast based players."""
32
33 mz_mgr: MultizoneManager | None = None
34 browser: CastBrowser | None = None
35 _discover_lock: threading.Lock
36
37 def __init__(
38 self,
39 mass: MusicAssistant,
40 manifest: ProviderManifest,
41 config: ProviderConfig,
42 supported_features: set[ProviderFeature],
43 ) -> None:
44 """Handle async initialization of the provider."""
45 super().__init__(mass, manifest, config, supported_features)
46 self._discover_lock = threading.Lock()
47 self.mz_mgr = MultizoneManager()
48 # Handle config option for manual IP's
49 manual_ip_config = cast("list[str]", config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key))
50 self.browser = CastBrowser(
51 SimpleCastListener(
52 add_callback=self._on_chromecast_discovered,
53 remove_callback=self._on_chromecast_removed,
54 update_callback=self._on_chromecast_discovered,
55 ),
56 self.mass.aiozc.zeroconf,
57 known_hosts=manual_ip_config,
58 )
59 self._discovery_running = False
60 # set-up pychromecast logging
61 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
62 logging.getLogger("pychromecast").setLevel(logging.DEBUG)
63 else:
64 logging.getLogger("pychromecast").setLevel(self.logger.level + 10)
65
66 async def discover_players(self) -> None:
67 """Discover Cast players on the network."""
68 if self._discovery_running:
69 return
70 self._discovery_running = True
71 assert self.browser is not None # for type checking
72 await self.mass.loop.run_in_executor(None, self.browser.start_discovery)
73
74 async def unload(self, is_removed: bool = False) -> None:
75 """Handle close/cleanup of the provider."""
76 if not self.browser:
77 return
78
79 # stop discovery
80 def stop_discovery() -> None:
81 """Stop the chromecast discovery threads."""
82 assert self.browser is not None # for type checking
83 if self.browser._zc_browser:
84 with contextlib.suppress(RuntimeError):
85 self.browser._zc_browser.cancel()
86
87 self.browser.host_browser.stop.set()
88 self.browser.host_browser.join()
89 self._discovery_running = False
90
91 await self.mass.loop.run_in_executor(None, stop_discovery)
92
93 ### Discovery callbacks
94
95 def _on_chromecast_discovered(self, uuid: str, _: object) -> None:
96 """
97 Handle Chromecast discovered callback.
98
99 NOTE: NOT async friendly!
100 """
101 if self.mass.closing:
102 return
103
104 assert self.browser is not None # for type checking
105 with self._discover_lock:
106 disc_info: CastInfo = self.browser.devices[uuid]
107
108 if disc_info.uuid is None:
109 self.logger.error("Discovered chromecast without uuid %s", disc_info)
110 return
111
112 player_id = str(disc_info.uuid)
113
114 enabled = self.mass.config.get(f"players/{player_id}/enabled", True)
115 if not enabled:
116 self.logger.debug("Ignoring disabled player: %s", player_id)
117 return
118
119 self.logger.debug("Discovered new or updated chromecast %s", disc_info)
120
121 castplayer = self.mass.players.get_player(player_id)
122 if castplayer:
123 assert isinstance(castplayer, ChromecastPlayer) # for type checking
124 # if player was already added, the player will take care of reconnects itself.
125 castplayer.cast_info.update(disc_info)
126 self.mass.loop.call_soon_threadsafe(castplayer.update_state)
127 return
128 # new player discovered
129
130 cast_info = ChromecastInfo.from_cast_info(disc_info)
131 cast_info.fill_out_missing_chromecast_info(self.mass.aiozc.zeroconf)
132 if cast_info.is_dynamic_group:
133 self.logger.debug("Discovered a dynamic cast group which will be ignored.")
134 return
135 if cast_info.is_multichannel_child:
136 self.logger.debug(
137 "Discovered a passive (multichannel) endpoint which will be ignored."
138 )
139 return
140 # create new Chromecast instance
141 chromecast = pychromecast.get_chromecast_from_cast_info(
142 disc_info,
143 self.mass.aiozc.zeroconf,
144 )
145 # create and register the new ChromeCastPlayer
146 asyncio.run_coroutine_threadsafe(
147 self._create_and_register_player(player_id, cast_info, chromecast),
148 loop=self.mass.loop,
149 )
150
151 async def _create_and_register_player(
152 self, player_id: str, cast_info: ChromecastInfo, chromecast: pychromecast.Chromecast
153 ) -> None:
154 """Create and register a new ChromecastPlayer."""
155 castplayer = ChromecastPlayer(self, player_id, cast_info=cast_info, chromecast=chromecast)
156 await self.mass.players.register_or_update(castplayer)
157
158 def _on_chromecast_removed(self, uuid: str, service: object, cast_info: object) -> None:
159 """Handle zeroconf discovery of a removed Chromecast."""
160 player_id = str(service[1])
161 friendly_name = service[3]
162 self.logger.debug("Chromecast removed: %s - %s", friendly_name, player_id)
163 # we ignore this event completely as the Chromecast socket client handles this itself
164