/
/
/
1"""Media Assistant Provider implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7from typing import TYPE_CHECKING, cast
8
9from async_upnp_client.search import async_search
10from music_assistant_models.player import DeviceInfo
11from rokuecp import Roku
12
13from music_assistant.constants import CONF_ENTRY_MANUAL_DISCOVERY_IPS, VERBOSE_LOG_LEVEL
14from music_assistant.helpers.util import TaskManager
15from music_assistant.models.player_provider import PlayerProvider
16
17from .constants import CONF_AUTO_DISCOVER
18from .player import MediaAssistantPlayer
19
20if TYPE_CHECKING:
21 from async_upnp_client.utils import CaseInsensitiveDict
22 from music_assistant_models.enums import ProviderFeature
23
24SUPPORTED_FEATURES: set[ProviderFeature] = set()
25
26
27class MediaAssistantprovider(PlayerProvider):
28 """Media Assistant Player provider."""
29
30 roku_players: dict[str, MediaAssistantPlayer] = {}
31 _discovery_running: bool = False
32 lock: asyncio.Lock
33
34 @property
35 def supported_features(self) -> set[ProviderFeature]:
36 """Return the features supported by this Provider."""
37 return SUPPORTED_FEATURES
38
39 async def handle_async_init(self) -> None:
40 """Handle async initialization of the provider."""
41 self.lock = asyncio.Lock()
42 # silence the async_upnp_client logger
43 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
44 logging.getLogger("async_upnp_client").setLevel(logging.DEBUG)
45 else:
46 logging.getLogger("async_upnp_client").setLevel(self.logger.level + 10)
47 # silence the rokuecp logger
48 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
49 logging.getLogger("rokuecp").setLevel(logging.DEBUG)
50 else:
51 logging.getLogger("rokuecp").setLevel(self.logger.level + 10)
52
53 async def loaded_in_mass(self) -> None:
54 """Call after the provider has been loaded."""
55 manual_ip_config = cast(
56 "list[str]", self.config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key)
57 )
58
59 for ip in manual_ip_config:
60 await self._device_discovered(ip)
61
62 self.logger.info("MediaAssistantProvider loaded")
63 await self.discover_players()
64
65 async def unload(self, is_removed: bool = False) -> None:
66 """Handle unload/close of the provider."""
67 if self.roku_players is None:
68 return # type: ignore[unreachable]
69 async with TaskManager(self.mass) as tg:
70 for roku_player in self.roku_players.values():
71 tg.create_task(self._device_disconnect(roku_player))
72
73 async def discover_players(self) -> None:
74 """Discover Roku players on the network."""
75 if not self.config.get_value(CONF_AUTO_DISCOVER):
76 return
77 if self._discovery_running:
78 return
79 try:
80 self._discovery_running = True
81 self.logger.debug("Roku discovery started...")
82 discovered_devices: set[str] = set()
83
84 async def on_response(discovery_info: CaseInsensitiveDict) -> None:
85 """Process discovered device from ssdp search."""
86 ssdp_st: str | None = discovery_info.get("st")
87 if not ssdp_st:
88 return
89
90 if "roku:ecp" not in ssdp_st:
91 # we're only interested in Roku devices
92 return
93
94 ssdp_usn: str = discovery_info["usn"]
95 ssdp_udn: str | None = discovery_info.get("_udn")
96 if not ssdp_udn and ssdp_usn.startswith("uuid:"):
97 ssdp_udn = "ROKU_" + ssdp_usn.split(":")[-1]
98 elif ssdp_udn:
99 ssdp_udn = "ROKU_" + ssdp_udn.split(":")[-1]
100 else:
101 return
102
103 if ssdp_udn in discovered_devices:
104 # already processed this device
105 return
106
107 discovered_devices.add(ssdp_udn)
108
109 await self._device_discovered(discovery_info["_host"])
110
111 await async_search(on_response, search_target="roku:ecp")
112
113 finally:
114 self._discovery_running = False
115
116 def reschedule() -> None:
117 self.mass.create_task(self.discover_players())
118
119 # reschedule self once finished
120 self.mass.loop.call_later(300, reschedule)
121
122 async def _device_disconnect(self, roku_player: MediaAssistantPlayer) -> None:
123 """Destroy connections to the device."""
124 async with roku_player.lock:
125 if not roku_player.roku:
126 self.logger.debug("Disconnecting from device that's not connected")
127 return
128
129 self.logger.debug("Disconnecting from %s", roku_player.name)
130
131 old_device = roku_player.roku
132 self.roku_players.pop(roku_player.player_id)
133 await old_device.close_session()
134
135 async def _device_discovered(self, ip: str) -> None:
136 """Handle discovered Roku."""
137 async with self.lock:
138 # connecting to Roku to retrieve device Info
139 roku = Roku(ip)
140 try:
141 device = await roku.update()
142 await roku.close_session()
143 except Exception:
144 self.logger.error("Failed to retrieve device info from Roku at: %s", ip)
145 await roku.close_session()
146 return
147
148 if device.info.serial_number is None:
149 return
150
151 player_id = "ROKU_" + device.info.serial_number
152
153 if roku_player := self.roku_players.get(player_id):
154 # existing player
155 if roku_player.device_info.ip_address == ip and roku_player.available:
156 # nothing to do, device is already connected
157 return
158 # update description url to newly discovered one
159 roku_player.device_info.ip_address = ip
160 else:
161 roku_player = MediaAssistantPlayer(
162 provider=self,
163 player_id=player_id,
164 roku_name=device.info.name if device.info.name is not None else "",
165 roku=Roku(ip),
166 )
167
168 roku_player._attr_device_info = DeviceInfo(
169 model=device.info.model_name if device.info.model_name is not None else "",
170 model_id=device.info.model_number,
171 manufacturer=device.info.brand,
172 )
173 roku_player._attr_device_info.ip_address = ip
174
175 self.roku_players[player_id] = roku_player
176 await roku_player.setup()
177