Various small fixes and improvements (#684)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 26 May 2023 22:46:01 +0000 (00:46 +0200)
committerGitHub <noreply@github.com>
Fri, 26 May 2023 22:46:01 +0000 (00:46 +0200)
* do not send stop to synced player

* auto close http connections

prevent open sockets

* improve tcp connector

* add info endpoint

* bump airplay binaries

* typo in docstring

* cache single value retrieval

* fix slimproto sync delay setting

* typo

* improvements for universal group

25 files changed:
music_assistant/server/controllers/config.py
music_assistant/server/controllers/players.py
music_assistant/server/controllers/webserver.py
music_assistant/server/providers/airplay/__init__.py
music_assistant/server/providers/airplay/bin/libcrypto-1_1.dll [changed mode: 0755->0644]
music_assistant/server/providers/airplay/bin/libssl-1_1.dll [changed mode: 0755->0644]
music_assistant/server/providers/airplay/bin/squeeze2raop-freebsd-x86_64-static
music_assistant/server/providers/airplay/bin/squeeze2raop-linux-aarch64-static
music_assistant/server/providers/airplay/bin/squeeze2raop-linux-arm-static
music_assistant/server/providers/airplay/bin/squeeze2raop-linux-armv6-static
music_assistant/server/providers/airplay/bin/squeeze2raop-linux-mips-static
music_assistant/server/providers/airplay/bin/squeeze2raop-linux-powerpc-static
music_assistant/server/providers/airplay/bin/squeeze2raop-linux-sparc64-static
music_assistant/server/providers/airplay/bin/squeeze2raop-linux-x86-static
music_assistant/server/providers/airplay/bin/squeeze2raop-linux-x86_64-static
music_assistant/server/providers/airplay/bin/squeeze2raop-macos-arm64-static
music_assistant/server/providers/airplay/bin/squeeze2raop-macos-static
music_assistant/server/providers/airplay/bin/squeeze2raop-macos-x86_64-static
music_assistant/server/providers/airplay/bin/squeeze2raop-solaris-x86_64-static
music_assistant/server/providers/airplay/bin/squeeze2raop-static.exe
music_assistant/server/providers/slimproto/__init__.py
music_assistant/server/providers/universal_group/__init__.py
music_assistant/server/server.py
pyproject.toml
requirements_all.txt

index b44e4961976f5c27ee5c5097dd4aa11b1ca3a706..0821ba2d4e5b445235706bb604a4a1c2094a7831 100644 (file)
@@ -54,6 +54,7 @@ class ConfigController:
         self._data: dict[str, Any] = {}
         self.filename = os.path.join(self.mass.storage_path, "settings.json")
         self._timer_handle: asyncio.TimerHandle | None = None
+        self._value_cache: dict[str, ConfigValueType] = {}
 
     async def setup(self) -> None:
         """Async initialize of controller."""
@@ -172,6 +173,22 @@ class ConfigController:
             return ProviderConfig.parse(config_entries, raw_conf)
         raise KeyError(f"No config found for provider id {instance_id}")
 
+    @api_command("config/providers/get_value")
+    def get_provider_config_value(self, instance_id: str, key: str) -> ConfigValueType:
+        """Return single configentry value for a provider."""
+        cache_key = f"prov_conf_value_{instance_id}.{key}"
+        if cached_value := self._value_cache.get(cache_key) is not None:
+            return cached_value
+        conf = self.get_provider_config(instance_id)
+        val = (
+            conf.values[key].value
+            if conf.values[key].value is not None
+            else conf.values[key].default_value
+        )
+        # store value in cache because this method can potentially be called very often
+        self._value_cache[cache_key] = val
+        return val
+
     @api_command("config/providers/get_entries")
     async def get_provider_config_entries(
         self,
@@ -297,13 +314,18 @@ class ConfigController:
     @api_command("config/players/get_value")
     def get_player_config_value(self, player_id: str, key: str) -> ConfigValueType:
         """Return single configentry value for a player."""
+        cache_key = f"player_conf_value_{player_id}.{key}"
+        if (cached_value := self._value_cache.get(cache_key)) and cached_value is not None:
+            return cached_value
         conf = self.get_player_config(player_id)
-        # always create a copy to prevent we're altering the base object
-        return (
+        val = (
             conf.values[key].value
             if conf.values[key].value is not None
             else conf.values[key].default_value
         )
+        # store value in cache because this method can potentially be called very often
+        self._value_cache[cache_key] = val
+        return val
 
     @api_command("config/players/save")
     def save_player_config(
@@ -407,6 +429,7 @@ class ConfigController:
 
     def save(self, immediate: bool = False) -> None:
         """Schedule save of data to disk."""
+        self._value_cache = {}
         if self._timer_handle is not None:
             self._timer_handle.cancel()
             self._timer_handle = None
index 3fe841ad8e39ac65aaf70f6002c225f3faccbe04..9fc925c5090c5ec5fcbf2a4a90f387266e1b6b91 100755 (executable)
@@ -321,11 +321,20 @@ class PlayerController:
         if player.powered == powered:
             return
         # stop player at power off
-        if not powered and player.state in (PlayerState.PLAYING, PlayerState.PAUSED):
+        if (
+            not powered
+            and player.state in (PlayerState.PLAYING, PlayerState.PAUSED)
+            and not player.synced_to
+        ):
             await self.cmd_stop(player_id)
         # unsync player at power off
-        if not powered and player.synced_to is not None:
-            await self.cmd_unsync(player_id)
+        if not powered:
+            if player.synced_to is not None:
+                await self.cmd_unsync(player_id)
+            for child in self._get_child_players(player):
+                if not child.synced_to:
+                    continue
+                await self.cmd_unsync(child.player_id)
         if PlayerFeature.POWER not in player.supported_features:
             player.powered = powered
             self.update(player_id)
index 7b5bcc19945b8f98ce87dcd2944c847a725a9775..da6cd28ab64e6b19eca36901080abe67c3f5c8c1 100644 (file)
@@ -67,6 +67,8 @@ class WebserverController:
         index_path = os.path.join(frontend_dir, "index.html")
         handler = partial(self.serve_static, index_path)
         self.webapp.router.add_get("/", handler)
+        # add info
+        self.webapp.router.add_get("/info", self._handle_server_info)
         # register catch-all route to handle our custom paths
         self.webapp.router.add_route("*", "/{tail:.*}", self._handle_catch_all)
         await self._apprunner.setup()
@@ -120,3 +122,7 @@ class WebserverController:
             request.headers,
         )
         return web.Response(status=404)
+
+    async def _handle_server_info(self, request: web.Request) -> web.Response:  # noqa: ARG002
+        """Handle request for server info."""
+        return web.json_response(self.mass.get_server_info().to_dict())
index 5a7a9d227cb9f70bce0ec2db8f8617111468b66c..a32cb60ba04f62303941f0793e067d26ba9752fe 100644 (file)
@@ -1,7 +1,7 @@
 """Airplay Player provider.
 
 This is more like a "virtual" player provider, running on top of slimproto.
-It uses the amazing work of Philippe44 who created a bridge from airplay to slimoproto.
+It uses the amazing work of Philippe44 who created a bridge from airplay to slimproto.
 https://github.com/philippe44/LMS-Raop
 """
 from __future__ import annotations
index ca9f28f5bbe460261bc7f87665b26755c2ff73c8..3db4ad0fdb6ae9cf988e97ce35c2e3e585873989 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/squeeze2raop-freebsd-x86_64-static and b/music_assistant/server/providers/airplay/bin/squeeze2raop-freebsd-x86_64-static differ
index b7be884f81ef380fdc96d38d0dd86cb50b6a31de..826549dafba6155b928fb84a512a40fa69c36d8a 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-aarch64-static and b/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-aarch64-static differ
index dc0e0d3889b32ee0cf8b806d164291412fcd63ac..334b07103c671ebb9e4d868adf26e254eced63ce 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-arm-static and b/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-arm-static differ
index ac3fc9e3287bd5fc2c73c35c314519d1685eea93..f92327023da140aa115cc7551893d666b04d15da 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-armv6-static and b/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-armv6-static differ
index 5184eba451d467ec7cf3701f70c28d40d9dac74e..d9f8ef8c26af0f2c08e10b9b7d1b716af982dfdb 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-mips-static and b/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-mips-static differ
index 5de83585999753a631bb714bd636ecedf2db9b42..f6660777ca3cef2aa411e2b9b5ab7ec9c0516839 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-powerpc-static and b/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-powerpc-static differ
index 62c6acb45baa57f426f24c346a64c48181eecbbe..8b97f498491de2904e12ae4febf4f4e7ef9c3412 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-sparc64-static and b/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-sparc64-static differ
index 54066495d38149a3a7f2d9123f5f9045d6eea887..c61c7bca1c3cbcb785175b9d5058da97ce8ec856 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-x86-static and b/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-x86-static differ
index 52aabc00cea6cd4ca1f80e711e2728b505a28dfe..7f53f75442175a75e42ec49ccebec251c2e92219 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-x86_64-static and b/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-x86_64-static differ
index b1b834eda8183ee543c897e009e604389a9d8f13..f4a100bdbb44557157529211f9e15cd311e08b8a 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/squeeze2raop-macos-arm64-static and b/music_assistant/server/providers/airplay/bin/squeeze2raop-macos-arm64-static differ
index 5aba575435ae41a3c9a0df8e29713bdb17db9caa..1f6f73ea4b23713b1c7ae46607b681fb1d057e40 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/squeeze2raop-macos-static and b/music_assistant/server/providers/airplay/bin/squeeze2raop-macos-static differ
index 89b7ed637b531cc93099d849b16088c436c8ade4..1e4404ea8ac19402f1e4f145b649149a1c766d79 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/squeeze2raop-macos-x86_64-static and b/music_assistant/server/providers/airplay/bin/squeeze2raop-macos-x86_64-static differ
index 1893e41761ea325933cfbd1b67ed0587f7524081..f25fcce227d18708065529de4a8f113fb6d20ac2 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/squeeze2raop-solaris-x86_64-static and b/music_assistant/server/providers/airplay/bin/squeeze2raop-solaris-x86_64-static differ
index 74820de3470dba94ad51dc0b08322415a1d25e7e..e3b3865a8d2033a04698c1e4779f3b66a6fb8e89 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/squeeze2raop-static.exe and b/music_assistant/server/providers/airplay/bin/squeeze2raop-static.exe differ
index 27c0fd48da6cd35c1de5a643f76581d3c9310063..66fee624abdaed7c9d9851eee7b44ba5328c920b 100644 (file)
@@ -420,7 +420,7 @@ class SlimprotoProvider(PlayerProvider):
         player = self.mass.players.get(client.player_id)
         sync_master_id = player.synced_to
 
-        # elapsed time change on the time will be auto picked up
+        # elapsed time change on the player will be auto picked up
         # by the player manager.
         player.elapsed_time = client.elapsed_seconds
         player.elapsed_time_last_updated = time.time()
@@ -557,9 +557,7 @@ class SlimprotoProvider(PlayerProvider):
 
     def _get_corrected_elapsed_milliseconds(self, client: SlimClient) -> int:
         """Return corrected elapsed milliseconds."""
-        sync_delay = self.mass.config.get(
-            f"{CONF_PLAYERS}/{client.player_id}/{CONF_SYNC_ADJUST}", 0
-        )
+        sync_delay = self.mass.config.get_player_config_value(client.player_id, CONF_SYNC_ADJUST)
         if sync_delay != 0:
             return client.elapsed_milliseconds - sync_delay
         return client.elapsed_milliseconds
index e42a00376b60f04fef9484e85f7c51e82aff86ff..17508af439b7c10f326dbe2fe493e3b6362d7c2f 100644 (file)
@@ -26,7 +26,7 @@ from music_assistant.common.models.enums import (
 )
 from music_assistant.common.models.player import DeviceInfo, Player
 from music_assistant.common.models.queue_item import QueueItem
-from music_assistant.constants import CONF_GROUPED_POWER_ON
+from music_assistant.constants import CONF_GROUPED_POWER_ON, CONF_PROVIDERS
 from music_assistant.server.models.player_provider import PlayerProvider
 
 if TYPE_CHECKING:
@@ -47,8 +47,9 @@ CONF_ENTRY_OUTPUT_CHANNELS_FORCED_STEREO = ConfigEntry.from_dict(
     }
 )
 CONF_ENTRY_FORCED_FLOW_MODE = ConfigEntry.from_dict(
-    {**CONF_ENTRY_FLOW_MODE.to_dict(), "hidden": True, "default_value": True, "value": True}
+    {**CONF_ENTRY_FLOW_MODE.to_dict(), "default_value": True, "value": True}
 )
+SUPPORTS_NATIVE_SYNC = ("sonos",)
 # ruff: noqa: ARG002
 
 
@@ -97,10 +98,11 @@ class UniversalGroupProvider(PlayerProvider):
     """Base/builtin provider for universally grouping players."""
 
     prev_sync_leaders: tuple[str] | None = None
+    optimistic_state: PlayerState | None = None
 
     async def handle_setup(self) -> None:
         """Handle async initialization of the provider."""
-        self.player = Player(
+        self.player = player = Player(
             player_id=self.instance_id,
             provider=self.domain,
             type=PlayerType.GROUP,
@@ -119,10 +121,13 @@ class UniversalGroupProvider(PlayerProvider):
             active_source=self.instance_id,
             group_childs=self.config.get_value(CONF_GROUP_MEMBERS),
         )
-        self.mass.players.register_or_update(self.player)
+        self.mass.players.register_or_update(player)
 
     async def unload(self) -> None:
         """Handle close/cleanup of the provider."""
+        # cleanup player config if provider is removed
+        if self.mass.config.get(f"{CONF_PROVIDERS}/{self.instance_id}") is not None:
+            return
         self.mass.players.remove(self.instance_id)
 
     def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry]:  # noqa: ARG002
@@ -136,6 +141,7 @@ class UniversalGroupProvider(PlayerProvider):
 
     async def cmd_stop(self, player_id: str) -> None:
         """Send STOP command to given player."""
+        self.optimistic_state = PlayerState.IDLE
         # forward command to player and any connected sync child's
         async with asyncio.TaskGroup() as tg:
             for member in self._get_active_members(only_powered=True, skip_sync_childs=True):
@@ -145,6 +151,7 @@ class UniversalGroupProvider(PlayerProvider):
 
     async def cmd_play(self, player_id: str) -> None:
         """Send PLAY command to given player."""
+        self.optimistic_state = PlayerState.PLAYING
         async with asyncio.TaskGroup() as tg:
             for member in self._get_active_members(only_powered=True, skip_sync_childs=True):
                 tg.create_task(self.mass.players.cmd_play(member.player_id))
@@ -172,8 +179,7 @@ class UniversalGroupProvider(PlayerProvider):
         await self.cmd_stop(player_id)
         # power ON
         await self.cmd_power(player_id, True)
-        # issue sync command (just in case)
-        await self._sync_players()
+        self.optimistic_state = PlayerState.PLAYING
         # forward command to all (powered) group child's
         async with asyncio.TaskGroup() as tg:
             for member in self._get_active_members(only_powered=True, skip_sync_childs=True):
@@ -190,34 +196,37 @@ class UniversalGroupProvider(PlayerProvider):
 
     async def cmd_pause(self, player_id: str) -> None:
         """Send PAUSE command to given player."""
+        self.optimistic_state = PlayerState.PAUSED
         async with asyncio.TaskGroup() as tg:
             for member in self._get_active_members(only_powered=True, skip_sync_childs=True):
                 tg.create_task(self.mass.players.cmd_pause(member.player_id))
 
     async def cmd_power(self, player_id: str, powered: bool) -> None:
         """Send POWER command to given player."""
-        if self.player.powered == powered:
-            return  # nothing to do
         group_power_on = self.mass.config.get_player_config_value(player_id, CONF_GROUPED_POWER_ON)
-        if powered and not group_power_on:
-            return  # nothing to do
 
         async def set_child_power(child_player: Player) -> None:
             await self.mass.players.cmd_power(child_player.player_id, powered)
             # set optimistic state on child player to prevent race conditions in other actions
             child_player.powered = powered
 
-        async with asyncio.TaskGroup() as tg:
-            for member in self._get_active_members(
-                only_powered=not powered, skip_sync_childs=False
-            ):
-                tg.create_task(set_child_power(member))
+        if not powered or group_power_on:
+            # turn on/off child players
+            async with asyncio.TaskGroup() as tg:
+                for member in self._get_active_members(
+                    only_powered=not powered, skip_sync_childs=False
+                ):
+                    if member.powered == member:
+                        continue
+                    tg.create_task(set_child_power(member))
 
         self.player.powered = powered
         self.mass.players.update(self.instance_id)
         if powered:
             # sync all players on power on
             await self._sync_players()
+        else:
+            self.optimistic_state = PlayerState.OFF
 
     async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
         """Send VOLUME_SET command to given player."""
@@ -255,17 +264,39 @@ class UniversalGroupProvider(PlayerProvider):
 
     def on_child_state(self, player_id: str, child_player: Player, changed_keys: set[str]) -> None:
         """Call when the state of a child player updates."""
-        # TODO: handle a sync leader powerin off
         powered_players = self._get_active_members(True, False)
         if "powered" in changed_keys:
-            if child_player.powered and self.player.state == PlayerState.PLAYING:
-                # a child player turned ON while the group player is already playing
-                # we need to resync/resume
-                self.mass.create_task(self.mass.players.queues.resume, player_id)
-            elif not child_player.powered and len(powered_players) == 0:
+            if not child_player.powered and len(powered_players) == 0:
                 # the last player of a group turned off
                 # turn off the group
                 self.mass.create_task(self.cmd_power, player_id, False)
+            # ruff: noqa: SIM114
+            elif child_player.powered and self.optimistic_state == PlayerState.PLAYING:
+                # a child player turned ON while the group player is already playing
+                # we need to resync/resume
+                if (
+                    child_player.provider in SUPPORTS_NATIVE_SYNC
+                    and self.player.state == PlayerState.PLAYING
+                    and (
+                        sync_leader := next(
+                            (x for x in child_player.can_sync_with if x in self.prev_sync_leaders),
+                            None,
+                        )
+                    )
+                ):
+                    # prevent resume when ecosystem supports native sync
+                    # and one of its players is already playing
+                    self.mass.create_task(self.mass.players.cmd_sync, player_id, sync_leader)
+                else:
+                    self.mass.create_task(self.mass.players.queues.resume, player_id)
+            elif (
+                not child_player.powered
+                and self.optimistic_state == PlayerState.PLAYING
+                and child_player.player_id in self.prev_sync_leaders
+            ):
+                # a sync master player turned OFF while the group player
+                # should still be playing - we need to resync/resume
+                self.mass.create_task(self.mass.players.queues.resume, player_id)
         self.update_attributes()
         self.mass.players.update(player_id, skip_forward=True)
 
index a87bf31318902048900ef27606ad064765caef8c..0a97df389ab6f189065178c4dc553c1b1a1da0ae 100644 (file)
@@ -4,6 +4,7 @@ from __future__ import annotations
 import asyncio
 import logging
 import os
+import sys
 from collections.abc import Awaitable, Callable, Coroutine
 from typing import TYPE_CHECKING, Any
 from uuid import uuid4
@@ -53,6 +54,12 @@ LOGGER = logging.getLogger(ROOT_LOGGER_NAME)
 BASE_DIR = os.path.dirname(os.path.abspath(__file__))
 PROVIDERS_PATH = os.path.join(BASE_DIR, "providers")
 
+ENABLE_HTTP_CLEANUP_CLOSED = not (3, 11, 1) <= sys.version_info < (3, 11, 4)
+# Enabling cleanup closed on python 3.11.1+ leaks memory relatively quickly
+# see https://github.com/aio-libs/aiohttp/issues/7252
+# aiohttp interacts poorly with https://github.com/python/cpython/pull/98540
+# The issue was fixed in 3.11.4 via https://github.com/python/cpython/pull/104485
+
 
 class MusicAssistant:
     """Main MusicAssistant (Server) object."""
@@ -90,7 +97,12 @@ class MusicAssistant:
         # create shared aiohttp ClientSession
         self.http_session = ClientSession(
             loop=self.loop,
-            connector=TCPConnector(ssl=False),
+            connector=TCPConnector(
+                ssl=False,
+                enable_cleanup_closed=ENABLE_HTTP_CLEANUP_CLOSED,
+                limit=4096,
+                limit_per_host=100,
+            ),
         )
         # setup config controller first and fetch important config values
         await self.config.setup()
index e7ef47a37cd422ad8f5dc8251e7765219a0b86ea..aa0d4aa7dd5d5c0f61e7d0945d04a94a2d0a18d9 100644 (file)
@@ -36,7 +36,7 @@ server = [
   "python-slugify==8.0.1",
   "mashumaro==3.7",
   "memory-tempfile==2.2.3",
-  "music-assistant-frontend==20230510.0",
+  "music-assistant-frontend==20230527.0",
   "pillow==9.5.0",
   "unidecode==1.3.6",
   "xmltodict==0.13.0",
index 748bf3026150084ed6edea889d612b77ef83ae03..80a0acc7864a1ce8ee18135ab1589282641768bc 100644 (file)
@@ -18,7 +18,7 @@ git+https://github.com/jozefKruszynski/python-tidal.git@v0.7.1
 git+https://github.com/pytube/pytube.git@refs/pull/1501/head
 mashumaro==3.7
 memory-tempfile==2.2.3
-music-assistant-frontend==20230510.0
+music-assistant-frontend==20230527.0
 orjson==3.8.12
 pillow==9.5.0
 plexapi==4.13.4