music-assistant-server

20.4 KBPY
provider.py
20.4 KB490 lines • python
1"""Universal Player Provider implementation.
2
3This provider manages UniversalPlayer instances that are auto-created for devices
4that have no native (vendor-specific) provider in Music Assistant but support one
5or more generic streaming protocols such as AirPlay, Chromecast, or DLNA.
6
7The Universal Player acts as a virtual player wrapper that provides a unified
8interface while delegating actual playback to the underlying protocol player(s).
9"""
10
11from __future__ import annotations
12
13import asyncio
14from typing import TYPE_CHECKING
15
16from music_assistant_models.enums import IdentifierType, PlayerType
17
18from music_assistant.constants import CONF_PLAYERS
19from music_assistant.helpers.util import normalize_mac_for_matching
20from music_assistant.models.player import DeviceInfo
21from music_assistant.models.player_provider import PlayerProvider
22
23from .constants import (
24    CONF_DEVICE_IDENTIFIERS,
25    CONF_DEVICE_INFO,
26    CONF_LINKED_PROTOCOL_IDS,
27    UNIVERSAL_PLAYER_PREFIX,
28)
29from .player import UniversalPlayer
30
31if TYPE_CHECKING:
32    from music_assistant.models.player import Player
33
34
35class UniversalPlayerProvider(PlayerProvider):
36    """
37    Universal Player Provider.
38
39    Manages virtual players for devices that have no native (vendor-specific) provider
40    but support generic streaming protocols like AirPlay, Chromecast, or DLNA.
41    These players are automatically created when protocol players with PlayerType.PROTOCOL
42    are registered, providing a unified interface while delegating playback to the
43    underlying protocol player(s).
44    """
45
46    async def handle_async_init(self) -> None:
47        """Handle async initialization of the provider."""
48        # Lock to prevent race conditions during universal player creation
49        self._universal_player_locks: dict[str, asyncio.Lock] = {}
50
51    async def discover_players(self) -> None:
52        """
53        Discover players.
54
55        Universal players are created dynamically by the PlayerController,
56        not through discovery. However, we restore previously created
57        universal players from config.
58        """
59        for player_conf in await self.mass.config.get_player_configs(
60            self.instance_id, include_unavailable=True, include_disabled=True
61        ):
62            if player_conf.player_id.startswith(UNIVERSAL_PLAYER_PREFIX):
63                # Restore universal player from config
64                # The stored protocol IDs enable fast matching when protocols register
65                await self._restore_player(player_conf.player_id)
66
67    async def _restore_player(self, player_id: str) -> None:
68        """
69        Restore a universal player from config.
70
71        The stored protocol_player_ids enable fast matching when protocol players
72        register - they can be linked immediately without waiting for identifier matching.
73        Device identifiers are also restored to enable matching new protocol players.
74        """
75        # Get stored config values
76        config = self.mass.config.get(f"{CONF_PLAYERS}/{player_id}")
77        if not config:
78            return
79
80        # Get stored values
81        values = config.get("values", {})
82        stored_protocol_ids = list(values.get(CONF_LINKED_PROTOCOL_IDS, []))
83        stored_identifiers = values.get(CONF_DEVICE_IDENTIFIERS, {})
84        stored_device_info = values.get(CONF_DEVICE_INFO, {})
85
86        # Filter out protocol IDs that are no longer PROTOCOL type players
87        valid_protocol_ids = []
88        for protocol_id in stored_protocol_ids:
89            protocol_config = self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}")
90            if not protocol_config:
91                # Config doesn't exist, keep it for now (player may register later)
92                valid_protocol_ids.append(protocol_id)
93                continue
94            protocol_player_type = protocol_config.get("player_type")
95            if protocol_player_type == "protocol":
96                valid_protocol_ids.append(protocol_id)
97            else:
98                self.logger.info(
99                    "Removing %s from universal player %s - player type changed to %s",
100                    protocol_id,
101                    player_id,
102                    protocol_player_type,
103                )
104
105        # If no valid protocol IDs remain, delete this stale universal player
106        if not valid_protocol_ids:
107            self.logger.info(
108                "Deleting stale universal player %s - no valid protocol players remain",
109                player_id,
110            )
111            await self.mass.config.remove_player_config(player_id)
112            return
113
114        stored_protocol_ids = valid_protocol_ids
115
116        # Persist the filtered protocol IDs to config if they changed
117        if len(valid_protocol_ids) != len(values.get(CONF_LINKED_PROTOCOL_IDS, [])):
118            self.mass.config.set(
119                f"{CONF_PLAYERS}/{player_id}/values/{CONF_LINKED_PROTOCOL_IDS}",
120                valid_protocol_ids,
121            )
122
123        # Check if protocols have been linked to a native player (stale universal player)
124        for protocol_id in stored_protocol_ids:
125            protocol_config = self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}")
126            if protocol_config:
127                protocol_values = protocol_config.get("values", {})
128                protocol_parent_id = protocol_values.get("protocol_parent_id")
129                if protocol_parent_id and protocol_parent_id != player_id:
130                    self.logger.info(
131                        "Deleting stale universal player %s - protocol %s has moved to parent %s",
132                        player_id,
133                        protocol_id,
134                        protocol_parent_id,
135                    )
136                    await self.mass.config.remove_player_config(player_id)
137                    return
138
139            # Check if native player has this protocol in linked_protocol_player_ids
140            all_player_configs = self.mass.config.get(CONF_PLAYERS, {})
141            for other_player_id, other_config in all_player_configs.items():
142                if other_player_id == player_id:
143                    continue
144                if other_config.get("provider") == "universal_player":
145                    continue
146                other_values = other_config.get("values", {})
147                linked_protocols = other_values.get("linked_protocol_player_ids", [])
148                if protocol_id in linked_protocols:
149                    self.logger.info(
150                        "Deleting stale universal player %s - "
151                        "protocol %s is linked to native player %s",
152                        player_id,
153                        protocol_id,
154                        other_player_id,
155                    )
156                    await self.mass.config.remove_player_config(player_id)
157                    return
158
159        # Restore device info with stored values or defaults
160        device_info = DeviceInfo(
161            model=stored_device_info.get("model", "Universal Player"),
162            manufacturer=stored_device_info.get("manufacturer", "Music Assistant"),
163        )
164
165        # Restore identifiers (convert string keys back to IdentifierType enum)
166        for id_type_str, value in stored_identifiers.items():
167            try:
168                id_type = IdentifierType(id_type_str)
169                device_info.add_identifier(id_type, value)
170            except ValueError:
171                self.logger.warning(
172                    "Unknown identifier type %s for player %s", id_type_str, player_id
173                )
174
175        name = config.get("name", f"Universal Player {player_id}")
176
177        self.logger.debug(
178            "Restoring universal player %s with %d protocol IDs and %d identifiers",
179            player_id,
180            len(stored_protocol_ids),
181            len(stored_identifiers),
182        )
183
184        player = UniversalPlayer(
185            provider=self,
186            player_id=player_id,
187            name=name,
188            device_info=device_info,
189            protocol_player_ids=list(stored_protocol_ids),
190        )
191        await self.mass.players.register_or_update(player)
192
193    async def create_universal_player(
194        self,
195        device_key: str,
196        name: str,
197        device_info: DeviceInfo,
198        protocol_player_ids: list[str],
199    ) -> Player:
200        """
201        Create a new UniversalPlayer.
202
203        Called by the PlayerController when multiple protocol players are
204        detected for a device without a native player.
205
206        :param device_key: Unique device key (typically MAC address).
207        :param name: Display name for the player.
208        :param device_info: Aggregated device information.
209        :param protocol_player_ids: List of protocol player IDs to link.
210        :return: The created UniversalPlayer instance.
211        """
212        # Generate player_id from device_key
213        player_id = f"{UNIVERSAL_PLAYER_PREFIX}{device_key}"
214
215        # Check if player already exists
216        if existing := self.mass.players.get_player(player_id):
217            # Update existing player with new protocol players
218            if isinstance(existing, UniversalPlayer):
219                for pid in protocol_player_ids:
220                    existing.add_protocol_player(pid)
221                # Merge identifiers from new device_info
222                for id_type, value in device_info.identifiers.items():
223                    existing.device_info.add_identifier(id_type, value)
224                # Persist updated data to config
225                await self._save_player_data(player_id, existing)
226                existing.update_state()
227            return existing
228
229        # Create config for the new player (complex values saved separately after)
230        self.mass.config.create_default_player_config(
231            player_id=player_id,
232            provider=self.instance_id,
233            player_type=PlayerType.GROUP,
234            name=name,
235            enabled=True,
236            values={
237                CONF_LINKED_PROTOCOL_IDS: protocol_player_ids,
238            },
239        )
240
241        # Save device identifiers and info to config (these are nested dicts,
242        # not supported by ConfigValueType, so we save them directly)
243        base_key = f"{CONF_PLAYERS}/{player_id}/values"
244        self.mass.config.set(
245            f"{base_key}/{CONF_DEVICE_IDENTIFIERS}",
246            {k.value: v for k, v in device_info.identifiers.items()},
247        )
248        self.mass.config.set(
249            f"{base_key}/{CONF_DEVICE_INFO}",
250            {"model": device_info.model, "manufacturer": device_info.manufacturer},
251        )
252
253        self.logger.info(
254            "Creating universal player %s with protocol players: %s",
255            player_id,
256            protocol_player_ids,
257        )
258
259        # Create the player instance
260        player = UniversalPlayer(
261            provider=self,
262            player_id=player_id,
263            name=name,
264            device_info=device_info,
265            protocol_player_ids=protocol_player_ids,
266        )
267
268        await self.mass.players.register_or_update(player)
269        return player
270
271    async def _save_protocol_ids(self, player_id: str, protocol_player_ids: list[str]) -> None:
272        """Save protocol player IDs to config for persistence across restarts."""
273        conf_key = f"{CONF_PLAYERS}/{player_id}/values/{CONF_LINKED_PROTOCOL_IDS}"
274        self.mass.config.set(conf_key, protocol_player_ids)
275        self.logger.debug(
276            "Saved protocol IDs for %s: %s",
277            player_id,
278            protocol_player_ids,
279        )
280
281    async def _save_player_data(self, player_id: str, player: UniversalPlayer) -> None:
282        """Save all player data to config for persistence across restarts."""
283        base_key = f"{CONF_PLAYERS}/{player_id}/values"
284
285        # Save protocol IDs
286        self.mass.config.set(
287            f"{base_key}/{CONF_LINKED_PROTOCOL_IDS}",
288            player._protocol_player_ids,
289        )
290
291        # Save identifiers (convert IdentifierType enum keys to strings)
292        self.mass.config.set(
293            f"{base_key}/{CONF_DEVICE_IDENTIFIERS}",
294            {k.value: v for k, v in player.device_info.identifiers.items()},
295        )
296
297        # Save device info (model, manufacturer)
298        self.mass.config.set(
299            f"{base_key}/{CONF_DEVICE_INFO}",
300            {
301                "model": player.device_info.model,
302                "manufacturer": player.device_info.manufacturer,
303            },
304        )
305
306        self.logger.debug(
307            "Saved player data for %s: %d protocols, %d identifiers",
308            player_id,
309            len(player._protocol_player_ids),
310            len(player.device_info.identifiers),
311        )
312
313    async def add_protocol_to_universal_player(
314        self, player_id: str, protocol_player_id: str
315    ) -> None:
316        """
317        Add a protocol player to an existing universal player.
318
319        Called when a new protocol player is discovered that matches an existing
320        universal player.
321
322        :param player_id: ID of the universal player.
323        :param protocol_player_id: ID of the protocol player to add.
324        """
325        if player := self.get_universal_player(player_id):
326            player.add_protocol_player(protocol_player_id)
327            # Save all player data (protocol IDs, identifiers, device info)
328            await self._save_player_data(player_id, player)
329            player.update_state()
330
331    async def remove_universal_player(self, player_id: str) -> None:
332        """
333        Remove a universal player.
334
335        Called when all protocol players for a device are removed.
336
337        :param player_id: ID of the universal player to remove.
338        """
339        await self.mass.players.unregister(player_id, permanent=True)
340
341    async def ensure_universal_player_for_protocols(
342        self, protocol_players: list[Player]
343    ) -> Player | None:
344        """
345        Ensure a universal player exists for a set of protocol players.
346
347        This method handles the orchestration of creating or updating a universal player
348        for the given protocol players. It uses per-device locking to prevent race
349        conditions when multiple protocols for the same device register simultaneously.
350
351        :param protocol_players: List of protocol players for the same device.
352        :return: The created or updated universal player, or None if operation failed.
353        """
354        device_key = self._get_device_key_from_players(protocol_players)
355        if not device_key:
356            return None
357
358        universal_player_id = f"{UNIVERSAL_PLAYER_PREFIX}{device_key}"
359
360        # Use a per-device lock to prevent race conditions
361        if device_key not in self._universal_player_locks:
362            self._universal_player_locks[device_key] = asyncio.Lock()
363
364        async with self._universal_player_locks[device_key]:
365            # Re-check - another task may have already handled these players
366            # Filter out players that are already linked to a parent
367            protocol_players = [p for p in protocol_players if not p.protocol_parent_id]
368            if not protocol_players:
369                return None
370
371            # Check if universal player already exists
372            if existing := self.mass.players.get_player(universal_player_id):
373                # Update existing universal player with new protocol players
374                protocol_player_ids = [p.player_id for p in protocol_players]
375                for player_id in protocol_player_ids:
376                    if isinstance(existing, UniversalPlayer):
377                        await self.add_protocol_to_universal_player(universal_player_id, player_id)
378                return existing
379
380            # Create new universal player
381            device_info = self._aggregate_device_info(protocol_players)
382            name = self._get_clean_player_name(protocol_players)
383            protocol_player_ids = [p.player_id for p in protocol_players]
384
385            return await self.create_universal_player(
386                device_key=device_key,
387                name=name,
388                device_info=device_info,
389                protocol_player_ids=protocol_player_ids,
390            )
391
392    def get_universal_player(self, player_id: str) -> UniversalPlayer | None:
393        """Get a UniversalPlayer by ID if it exists and is managed by this provider."""
394        if player := self.mass.players.get_player(player_id):
395            if isinstance(player, UniversalPlayer):
396                return player
397        return None
398
399    def _get_device_key_from_players(self, protocol_players: list[Player]) -> str | None:
400        """
401        Generate a device key from protocol players' identifiers.
402
403        Prefers MAC address (most stable), falls back to UUID, then player_id.
404        IP address is not used as it can change with DHCP and cause incorrect matches.
405        """
406        uuid_key: str | None = None
407        for player in protocol_players:
408            identifiers = player.device_info.identifiers
409            # Prefer MAC address (most reliable)
410            # Use normalize_mac_for_matching to handle locally-administered MAC variants
411            # Some protocols (like AirPlay) report a variant where bit 1 of the first octet
412            # is set (e.g., 54:78:... vs 56:78:...), but they represent the same device
413            if mac := identifiers.get(IdentifierType.MAC_ADDRESS):
414                return normalize_mac_for_matching(mac)
415            # Fall back to UUID (reliable for DLNA, Chromecast)
416            if not uuid_key and (uuid := identifiers.get(IdentifierType.UUID)):
417                # Normalize UUID: remove special characters, lowercase
418                uuid_key = uuid.replace("-", "").replace(":", "").replace("_", "").lower()
419        if uuid_key:
420            return uuid_key
421        # Last resort: use player_id as device key for protocol players without identifiers
422        # (e.g., Sendspin players that don't expose IP/MAC)
423        if protocol_players:
424            return protocol_players[0].player_id.replace(":", "").replace("-", "").lower()
425        return None
426
427    def _aggregate_device_info(self, protocol_players: list[Player]) -> DeviceInfo:
428        """Aggregate device info from protocol players."""
429        first_player = protocol_players[0]
430        device_info = DeviceInfo(
431            model=first_player.device_info.model,
432            manufacturer=first_player.device_info.manufacturer,
433        )
434        # Merge identifiers from all protocol players
435        for player in protocol_players:
436            for conn_type, value in player.device_info.identifiers.items():
437                device_info.add_identifier(conn_type, value)
438        return device_info
439
440    def _get_clean_player_name(self, protocol_players: list[Player]) -> str:
441        """
442        Get the best display name from protocol players.
443
444        Prefers names from protocols that typically provide user-friendly names
445        (Chromecast, DLNA, AirPlay) over those that may use technical identifiers
446        (Squeezelite, SendSpin). Filters out names that look like MAC addresses,
447        UUIDs, or player IDs.
448        """
449        # Protocol priority for name selection (higher priority = better names typically)
450        # Chromecast and DLNA usually have good user-configured names
451        # AirPlay also provides sensible names
452        # Squeezelite and SendSpin may use MAC addresses or technical IDs
453        name_priority = {
454            "chromecast": 1,
455            "airplay": 2,
456            "dlna": 3,
457            "squeezelite": 4,
458            "sendspin": 5,
459        }
460
461        def is_valid_name(name: str) -> bool:
462            """Check if a name looks like a real user-friendly name, not a technical ID."""
463            if not name or len(name) < 2:
464                return False
465            name_lower = name.lower().replace(":", "").replace("-", "").replace("_", "")
466            # Filter out names that look like MAC addresses (12 hex chars)
467            if len(name_lower) == 12 and all(c in "0123456789abcdef" for c in name_lower):
468                return False
469            # Filter out names that look like UUIDs
470            if len(name_lower) >= 32 and all(c in "0123456789abcdef" for c in name_lower[:32]):
471                return False
472            # Filter out names that start with common player ID prefixes
473            return not name_lower.startswith(
474                ("ap_", "cc_", "dlna_", "sq_", "sendspin_", "universal_")
475            )
476
477        # Sort players by protocol priority, then find the first valid name
478        sorted_players = sorted(
479            protocol_players,
480            key=lambda p: name_priority.get(p.provider.domain, 10),
481        )
482
483        for player in sorted_players:
484            player_name = player.state.name
485            if is_valid_name(player_name):
486                return player_name
487
488        # Fallback to first player's name if no valid name found
489        return protocol_players[0].display_name
490