music-assistant-server

6.3 KBPY
provider.py
6.3 KB162 lines • python
1"""DLNA Player Provider."""
2
3import asyncio
4import logging
5from ipaddress import IPv4Address
6
7from async_upnp_client.aiohttp import AiohttpSessionRequester
8from async_upnp_client.client import UpnpRequester
9from async_upnp_client.client_factory import UpnpFactory
10from async_upnp_client.search import async_search
11from async_upnp_client.utils import CaseInsensitiveDict
12from music_assistant_models.player import DeviceInfo
13
14from music_assistant.constants import CONF_PLAYERS, VERBOSE_LOG_LEVEL
15from music_assistant.helpers.util import TaskManager
16from music_assistant.models.player_provider import PlayerProvider
17
18from .constants import CONF_NETWORK_SCAN
19from .helpers import DLNANotifyServer
20from .player import DLNAPlayer
21
22
23class DLNAPlayerProvider(PlayerProvider):
24    """DLNA Player provider."""
25
26    dlnaplayers: dict[str, DLNAPlayer] = {}
27    _discovery_running: bool = False
28
29    lock: asyncio.Lock
30    requester: UpnpRequester
31    upnp_factory: UpnpFactory
32    notify_server: DLNANotifyServer
33
34    async def handle_async_init(self) -> None:
35        """Handle async initialization of the provider."""
36        self.lock = asyncio.Lock()
37        # silence the async_upnp_client logger
38        if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
39            logging.getLogger("async_upnp_client").setLevel(logging.DEBUG)
40        else:
41            logging.getLogger("async_upnp_client").setLevel(self.logger.level + 10)
42        self.requester = AiohttpSessionRequester(self.mass.http_session, with_sleep=True)
43        self.upnp_factory = UpnpFactory(self.requester, non_strict=True)
44        self.notify_server = DLNANotifyServer(self.requester, self.mass)
45
46    async def unload(self, is_removed: bool = False) -> None:
47        """
48        Handle unload/close of the provider.
49
50        Called when provider is deregistered (e.g. MA exiting or config reloading).
51        """
52        self.mass.streams.unregister_dynamic_route("/notify", "NOTIFY")
53
54        async with TaskManager(self.mass) as tg:
55            for dlna_player in self.dlnaplayers.values():
56                tg.create_task(self._device_disconnect(dlna_player))
57
58    async def discover_players(self, use_multicast: bool = False) -> None:
59        """Discover DLNA players on the network."""
60        if self._discovery_running:
61            return
62        try:
63            self._discovery_running = True
64            self.logger.debug("DLNA discovery started...")
65            allow_network_scan = self.config.get_value(CONF_NETWORK_SCAN)
66            discovered_devices: set[str] = set()
67
68            async def on_response(discovery_info: CaseInsensitiveDict) -> None:
69                """Process discovered device from ssdp search."""
70                ssdp_st: str = discovery_info.get("st", discovery_info.get("nt"))
71                if not ssdp_st:
72                    return
73
74                if "MediaRenderer" not in ssdp_st:
75                    # we're only interested in MediaRenderer devices
76                    return
77
78                ssdp_usn: str = discovery_info["usn"]
79                ssdp_udn: str | None = discovery_info.get("_udn")
80                if not ssdp_udn and ssdp_usn.startswith("uuid:"):
81                    ssdp_udn = ssdp_usn.split("::")[0]
82
83                if ssdp_udn in discovered_devices:
84                    # already processed this device
85                    return
86
87                assert ssdp_udn is not None  # for type checking
88
89                if "rincon" in ssdp_udn.lower():
90                    # ignore Sonos devices
91                    return
92
93                discovered_devices.add(ssdp_udn)
94
95                await self._device_discovered(ssdp_udn, discovery_info["location"])
96
97            # we iterate between using a regular and multicast search (if enabled)
98            if allow_network_scan and use_multicast:
99                await async_search(on_response, target=(str(IPv4Address("255.255.255.255")), 1900))
100            else:
101                await async_search(on_response)
102
103        finally:
104            self._discovery_running = False
105
106            def reschedule() -> None:
107                self.mass.create_task(self.discover_players(use_multicast=not use_multicast))
108
109            # reschedule self once finished
110            self.mass.loop.call_later(300, reschedule)
111
112    async def _device_disconnect(self, dlna_player: DLNAPlayer) -> None:
113        """
114        Destroy connections to the device now that it's not available.
115
116        Also call when removing this entity from MA to clean up connections.
117        """
118        async with dlna_player.lock:
119            if not dlna_player.device:
120                self.logger.debug("Disconnecting from device that's not connected")
121                return
122
123            self.logger.debug("Disconnecting from %s", dlna_player.device.name)
124
125            dlna_player.device.on_event = None
126            old_device = dlna_player.device
127            dlna_player.device = None
128            dlna_player.set_available(False)
129            await old_device.async_unsubscribe_services()
130
131    async def _device_discovered(self, udn: str, description_url: str) -> None:
132        """Handle discovered DLNA player."""
133        async with self.lock:
134            if dlna_player := self.dlnaplayers.get(udn):
135                # existing player
136                if dlna_player.description_url == description_url and dlna_player.available:
137                    # nothing to do, device is already connected
138                    return
139                # update description url to newly discovered one
140                dlna_player.description_url = description_url
141            else:
142                # new player detected, setup our DLNAPlayer wrapper
143                conf_key = f"{CONF_PLAYERS}/{udn}/enabled"
144                enabled = self.mass.config.get(conf_key, True)
145                # ignore disabled players
146                if not enabled:
147                    self.logger.debug("Ignoring disabled player: %s", udn)
148                    return
149
150                dlna_player = DLNAPlayer(
151                    provider=self,
152                    player_id=udn,
153                    description_url=description_url,
154                )
155                # will be updated later.
156                dlna_player._attr_device_info = DeviceInfo(
157                    model="unknown",
158                    manufacturer="unknown",
159                )
160                self.dlnaplayers[udn] = dlna_player
161            await dlna_player.setup()
162