Fix playback issues on Sonos and DLNA (#747)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 7 Jul 2023 22:59:56 +0000 (00:59 +0200)
committerGitHub <noreply@github.com>
Fri, 7 Jul 2023 22:59:56 +0000 (00:59 +0200)
* Fix enqueue of next track on sonos

* fix hiding of sonos players by default in dlna

* improve player names on airplay

* fix playback flow mode on dlna players

* disable TV's by default

* fix error in logs from dlna

music_assistant/server/controllers/config.py
music_assistant/server/controllers/players.py
music_assistant/server/helpers/didl_lite.py
music_assistant/server/providers/airplay/__init__.py
music_assistant/server/providers/chromecast/__init__.py
music_assistant/server/providers/dlna/__init__.py
music_assistant/server/providers/slimproto/__init__.py
music_assistant/server/providers/sonos/__init__.py

index 5c10de81de4e7ec976378ab722cffae29333a67c..af7718bc4c3ab92f8c5446e7edbabcbd51a6c950 100644 (file)
@@ -412,7 +412,9 @@ class ConfigController:
             assert isinstance(provider, PlayerProvider)
             provider.on_player_config_removed(player_id)
 
-    def create_default_player_config(self, player_id: str, provider: str, name: str) -> None:
+    def create_default_player_config(
+        self, player_id: str, provider: str, name: str, enabled: bool
+    ) -> None:
         """
         Create default/empty PlayerConfig.
 
@@ -427,7 +429,7 @@ class ConfigController:
         # config does not yet exist, create a default one
         conf_key = f"{CONF_PLAYERS}/{player_id}"
         default_conf = PlayerConfig(
-            values={}, provider=provider, player_id=player_id, default_name=name
+            values={}, provider=provider, player_id=player_id, enabled=enabled, default_name=name
         )
         self.set(
             conf_key,
index 428105dfb75ebf4f8110da75c9cf2b4fc616ca04..605e6d8ee934a2698c79edb2a27f7cc607ed7700 100755 (executable)
@@ -124,7 +124,9 @@ class PlayerController(CoreController):
             raise AlreadyRegisteredError(f"Player {player_id} is already registered")
 
         # make sure a default config exists
-        self.mass.config.create_default_player_config(player_id, player.provider, player.name)
+        self.mass.config.create_default_player_config(
+            player_id, player.provider, player.name, player.enabled_by_default
+        )
 
         player.enabled = self.mass.config.get(f"{CONF_PLAYERS}/{player_id}/enabled", True)
 
index b537d4a4032b2f79235bc04b46c0ac7ec60ca14f..7a8f0edd650de1e4fe4ca550da2e02f1d9183539 100644 (file)
@@ -22,6 +22,7 @@ def create_didl_metadata(
     if queue_item is None:
         return (
             '<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">'
+            f'<item id="flowmode" parentID="0" restricted="1">'
             "<dc:title>Music Assistant</dc:title>"
             f"<upnp:albumArtURI>{escape_string(MASS_LOGO_ONLINE)}</upnp:albumArtURI>"
             "<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>"
index 6e92d7ac42ff547c9654cb1c74e955e3bf713910..7254e5fae1fd3492609a068c4ef7b111f9aa9343 100644 (file)
@@ -10,6 +10,7 @@ import asyncio
 import os
 import platform
 import xml.etree.ElementTree as ET  # noqa: N817
+from contextlib import suppress
 from typing import TYPE_CHECKING
 
 import aiofiles
@@ -238,7 +239,7 @@ class AirplayProvider(PlayerProvider):
         slimproto_prov = self.mass.get_provider("slimproto")
         await slimproto_prov.cmd_unsync(player_id)
 
-    def _handle_player_register_callback(self, player: Player) -> None:
+    async def _handle_player_register_callback(self, player: Player) -> None:
         """Handle player register callback from slimproto source player."""
         # TODO: Can we get better device info from mDNS ?
         player.provider = self.domain
@@ -249,6 +250,30 @@ class AirplayProvider(PlayerProvider):
         )
         player.supports_24bit = False
 
+        # extend info from the discovery xml
+        async with aiofiles.open(self._config_file, "r") as _file:
+            xml_data = await _file.read()
+            with suppress(ET.ParseError):
+                xml_root = ET.XML(xml_data)
+                for device_elem in xml_root.findall("device"):
+                    player_id = device_elem.find("mac").text
+                    if player_id != player.player_id:
+                        continue
+                    # prefer name from UDN because default name is often wrong
+                    udn = device_elem.find("udn").text
+                    udn_name = udn.split("@")[1].split("._")[0]
+                    player.name = udn_name
+                    # disable sonos by default
+                    if "sonos" in (device_elem.find("friendly_name").text or "").lower():
+                        player.enabled_by_default = False
+                        # TODO: query more info directly from the device
+                        player.device_info = DeviceInfo(
+                            model="Airplay device",
+                            address=player.device_info.address,
+                            manufacturer="SONOS",
+                        )
+                    break
+
     def _handle_player_update_callback(self, player: Player) -> None:
         """Handle player update callback from slimproto source player."""
         # we could override anything on the player object here
index 5461d29d55b38987283e584f5f6b9cb06c429073..97cf07f5c2902b40f4e4559422da17d119396ebe 100644 (file)
@@ -329,6 +329,13 @@ class ChromecastProvider(PlayerProvider):
                 )
                 return
 
+            # Disable TV's by default
+            # (can be enabled manually by the user)
+            enabled_by_default = True
+            for exclude in ("tv", "/12", "PUS", "OLED"):
+                if exclude.lower() in cast_info.friendly_name.lower():
+                    enabled_by_default = False
+
             # Instantiate chromecast object
             castplayer = CastPlayer(
                 player_id,
@@ -355,6 +362,7 @@ class ChromecastProvider(PlayerProvider):
                         PlayerFeature.VOLUME_SET,
                     ),
                     max_sample_rate=96000,
+                    enabled_by_default=enabled_by_default,
                 ),
                 logger=self.logger.getChild(cast_info.friendly_name),
             )
index 4949e1694bcc798a86add8831265f1057e898991..c7848eece32f2382d1fefe617f8a1dfdc4cd06d2 100644 (file)
@@ -276,7 +276,8 @@ class DLNAPlayerProvider(PlayerProvider):
             await self.cmd_stop(player_id)
 
         didl_metadata = create_didl_metadata(self.mass, url, queue_item)
-        await dlna_player.device.async_set_transport_uri(url, queue_item.name, didl_metadata)
+        title = queue_item.name if queue_item else "Music Assistant"
+        await dlna_player.device.async_set_transport_uri(url, title, didl_metadata)
         # Play it
         await dlna_player.device.async_wait_for_can_play(10)
         await dlna_player.device.async_play()
index 7e841f16041675e901dd044740f0390fc1911a00..9a6c704c4b61574915b48c27d51a668f6b0c4c5e 100644 (file)
@@ -5,7 +5,7 @@ import asyncio
 import statistics
 import time
 from collections import deque
-from collections.abc import Callable, Generator
+from collections.abc import Callable, Coroutine, Generator
 from contextlib import suppress
 from dataclasses import dataclass
 from typing import TYPE_CHECKING, Any
@@ -170,7 +170,7 @@ class SlimprotoProvider(PlayerProvider):
     _socket_servers: list[asyncio.Server | asyncio.BaseTransport]
     _socket_clients: dict[str, SlimClient]
     _sync_playpoints: dict[str, deque[SyncPlayPoint]]
-    _virtual_providers: dict[str, tuple[Callable, Callable]]
+    _virtual_providers: dict[str, tuple[Coroutine, Callable]]
     _do_not_resync_before: dict[str, float]
     _cli: LmsCli
     port: int = DEFAULT_SLIMPROTO_PORT
@@ -271,7 +271,7 @@ class SlimprotoProvider(PlayerProvider):
                 return
 
             # forward player update to MA player controller
-            self._handle_player_update(client)
+            self.mass.create_task(self._handle_player_update(client))
 
         # construct SlimClient from socket client
         SlimClient(reader, writer, client_callback)
@@ -535,7 +535,7 @@ class SlimprotoProvider(PlayerProvider):
     def register_virtual_provider(
         self,
         player_model: str,
-        register_callback: Callable,
+        register_callback: Coroutine,
         update_callback: Callable,
     ) -> None:
         """Register a virtual provider based on slimproto, such as the airplay bridge."""
@@ -551,7 +551,7 @@ class SlimprotoProvider(PlayerProvider):
         """Unregister a virtual provider."""
         self._virtual_providers.pop(player_model, None)
 
-    def _handle_player_update(self, client: SlimClient) -> None:
+    async def _handle_player_update(self, client: SlimClient) -> None:
         """Process SlimClient update/add to Player controller."""
         player_id = client.player_id
         virtual_provider_info = self._virtual_providers.get(client.device_model)
@@ -580,7 +580,7 @@ class SlimprotoProvider(PlayerProvider):
             )
             if virtual_provider_info:
                 # if this player is part of a virtual provider run the callback
-                virtual_provider_info[0](player)
+                await virtual_provider_info[0](player)
             self.mass.players.register_or_update(player)
 
         # update player state on player events
@@ -767,12 +767,12 @@ class SlimprotoProvider(PlayerProvider):
 
         self._socket_clients[player_id] = client
         # update all attributes
-        self._handle_player_update(client)
+        await self._handle_player_update(client)
         # update existing players so they can update their `can_sync_with` field
         for item in self._socket_clients.values():
             if item.player_id == player_id:
                 continue
-            self._handle_player_update(item)
+            await self._handle_player_update(item)
         # restore volume and power state
         if last_state := await self.mass.cache.get(f"{CACHE_KEY_PREV_STATE}.{player_id}"):
             init_power = last_state[0]
index 2386a465b20be9c07e410f51e8e3be3793718301..fb0ef2db681cb0a63e32710510f1acb501666ddd 100644 (file)
@@ -582,7 +582,8 @@ class SonosPlayerProvider(PlayerProvider):
 
         # enqueue next item if needed
         if sonos_player.player.state == PlayerState.PLAYING and (
-            sonos_player.next_url or sonos_player.next_url == sonos_player.player.current_url
+            sonos_player.next_url is None
+            or sonos_player.next_url == sonos_player.player.current_url
         ):
             self.mass.create_task(self._enqueue_next_track(sonos_player))