From: Marcel van der Veldt Date: Fri, 26 May 2023 22:46:01 +0000 (+0200) Subject: Various small fixes and improvements (#684) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=5b624a356766f7dedadd56a7d69f1bd03343a5dd;p=music-assistant-server.git Various small fixes and improvements (#684) * 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 --- diff --git a/music_assistant/server/controllers/config.py b/music_assistant/server/controllers/config.py index b44e4961..0821ba2d 100644 --- a/music_assistant/server/controllers/config.py +++ b/music_assistant/server/controllers/config.py @@ -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 diff --git a/music_assistant/server/controllers/players.py b/music_assistant/server/controllers/players.py index 3fe841ad..9fc925c5 100755 --- a/music_assistant/server/controllers/players.py +++ b/music_assistant/server/controllers/players.py @@ -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) diff --git a/music_assistant/server/controllers/webserver.py b/music_assistant/server/controllers/webserver.py index 7b5bcc19..da6cd28a 100644 --- a/music_assistant/server/controllers/webserver.py +++ b/music_assistant/server/controllers/webserver.py @@ -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()) diff --git a/music_assistant/server/providers/airplay/__init__.py b/music_assistant/server/providers/airplay/__init__.py index 5a7a9d22..a32cb60b 100644 --- a/music_assistant/server/providers/airplay/__init__.py +++ b/music_assistant/server/providers/airplay/__init__.py @@ -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 diff --git a/music_assistant/server/providers/airplay/bin/libcrypto-1_1.dll b/music_assistant/server/providers/airplay/bin/libcrypto-1_1.dll old mode 100755 new mode 100644 diff --git a/music_assistant/server/providers/airplay/bin/libssl-1_1.dll b/music_assistant/server/providers/airplay/bin/libssl-1_1.dll old mode 100755 new mode 100644 diff --git a/music_assistant/server/providers/airplay/bin/squeeze2raop-freebsd-x86_64-static b/music_assistant/server/providers/airplay/bin/squeeze2raop-freebsd-x86_64-static index ca9f28f5..3db4ad0f 100755 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 diff --git a/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-aarch64-static b/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-aarch64-static index b7be884f..826549da 100755 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 diff --git a/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-arm-static b/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-arm-static index dc0e0d38..334b0710 100755 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 diff --git a/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-armv6-static b/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-armv6-static index ac3fc9e3..f9232702 100755 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 diff --git a/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-mips-static b/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-mips-static index 5184eba4..d9f8ef8c 100755 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 diff --git a/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-powerpc-static b/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-powerpc-static index 5de83585..f6660777 100755 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 diff --git a/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-sparc64-static b/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-sparc64-static index 62c6acb4..8b97f498 100755 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 diff --git a/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-x86-static b/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-x86-static index 54066495..c61c7bca 100755 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 diff --git a/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-x86_64-static b/music_assistant/server/providers/airplay/bin/squeeze2raop-linux-x86_64-static index 52aabc00..7f53f754 100755 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 diff --git a/music_assistant/server/providers/airplay/bin/squeeze2raop-macos-arm64-static b/music_assistant/server/providers/airplay/bin/squeeze2raop-macos-arm64-static index b1b834ed..f4a100bd 100755 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 diff --git a/music_assistant/server/providers/airplay/bin/squeeze2raop-macos-static b/music_assistant/server/providers/airplay/bin/squeeze2raop-macos-static index 5aba5754..1f6f73ea 100755 Binary files a/music_assistant/server/providers/airplay/bin/squeeze2raop-macos-static and b/music_assistant/server/providers/airplay/bin/squeeze2raop-macos-static differ diff --git a/music_assistant/server/providers/airplay/bin/squeeze2raop-macos-x86_64-static b/music_assistant/server/providers/airplay/bin/squeeze2raop-macos-x86_64-static index 89b7ed63..1e4404ea 100755 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 diff --git a/music_assistant/server/providers/airplay/bin/squeeze2raop-solaris-x86_64-static b/music_assistant/server/providers/airplay/bin/squeeze2raop-solaris-x86_64-static index 1893e417..f25fcce2 100755 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 diff --git a/music_assistant/server/providers/airplay/bin/squeeze2raop-static.exe b/music_assistant/server/providers/airplay/bin/squeeze2raop-static.exe index 74820de3..e3b3865a 100755 Binary files a/music_assistant/server/providers/airplay/bin/squeeze2raop-static.exe and b/music_assistant/server/providers/airplay/bin/squeeze2raop-static.exe differ diff --git a/music_assistant/server/providers/slimproto/__init__.py b/music_assistant/server/providers/slimproto/__init__.py index 27c0fd48..66fee624 100644 --- a/music_assistant/server/providers/slimproto/__init__.py +++ b/music_assistant/server/providers/slimproto/__init__.py @@ -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 diff --git a/music_assistant/server/providers/universal_group/__init__.py b/music_assistant/server/providers/universal_group/__init__.py index e42a0037..17508af4 100644 --- a/music_assistant/server/providers/universal_group/__init__.py +++ b/music_assistant/server/providers/universal_group/__init__.py @@ -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) diff --git a/music_assistant/server/server.py b/music_assistant/server/server.py index a87bf313..0a97df38 100644 --- a/music_assistant/server/server.py +++ b/music_assistant/server/server.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index e7ef47a3..aa0d4aa7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/requirements_all.txt b/requirements_all.txt index 748bf302..80a0acc7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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