music-assistant-server

6.3 KBPY
provider.py
6.3 KB164 lines • python
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