From a5c5ca90884c8f35903587ad4e23f35f0c1ae43a Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 7 Jul 2023 16:19:13 +0200 Subject: [PATCH] Some small follow up fixes for the configurable core controllers (#743) * Fix the check if server is hass supervisor * expose running_as_hass_addon as variable * dynamically load the version * set some options as advanced * do not enable loop debug mode by default * make sync interval configurable * Update config.py * make player queues a core controller * send new config when changing it --- music_assistant/__main__.py | 4 +- music_assistant/common/models/api.py | 1 + music_assistant/constants.py | 3 - music_assistant/server/controllers/cache.py | 45 ++++++++++++- music_assistant/server/controllers/config.py | 12 +++- .../server/controllers/metadata.py | 4 +- music_assistant/server/controllers/music.py | 42 ++++++++++-- .../server/controllers/player_queues.py | 26 +++++--- music_assistant/server/controllers/players.py | 20 +++--- music_assistant/server/controllers/streams.py | 24 +++---- .../server/controllers/webserver.py | 15 +++-- music_assistant/server/helpers/util.py | 20 +++++- .../server/models/core_controller.py | 18 ++++-- .../server/providers/chromecast/__init__.py | 2 +- .../server/providers/dlna/__init__.py | 2 +- .../server/providers/radiobrowser/__init__.py | 3 +- .../server/providers/slimproto/__init__.py | 10 +-- .../server/providers/slimproto/cli.py | 64 +++++++++---------- .../server/providers/sonos/__init__.py | 2 +- .../server/providers/ugp/__init__.py | 4 +- music_assistant/server/server.py | 38 +++++++---- pyproject.toml | 8 +-- 22 files changed, 242 insertions(+), 125 deletions(-) diff --git a/music_assistant/__main__.py b/music_assistant/__main__.py index c8c13bcb..49d33606 100644 --- a/music_assistant/__main__.py +++ b/music_assistant/__main__.py @@ -89,7 +89,7 @@ def main(): hass_options = {} log_level = hass_options.get("log_level", args.log_level).upper() - dev_mode = bool(os.environ.get("PYTHONDEVMODE", "0")) + dev_mode = os.environ.get("PYTHONDEVMODE", "0") == "1" # setup logger logger = setup_logger(data_dir, log_level) @@ -109,7 +109,7 @@ def main(): start_mass(), use_uvloop=False, shutdown_callback=on_shutdown, - executor_workers=32, + executor_workers=64, ) diff --git a/music_assistant/common/models/api.py b/music_assistant/common/models/api.py index 74a8185f..1894d201 100644 --- a/music_assistant/common/models/api.py +++ b/music_assistant/common/models/api.py @@ -63,6 +63,7 @@ class ServerInfoMessage(DataClassORJSONMixin): schema_version: int min_supported_schema_version: int base_url: str + homeassistant_addon: bool = False MessageType = ( diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 6d6f8cd0..4d213fd5 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -3,9 +3,6 @@ import pathlib from typing import Final -__version__: Final[str] = "2.0.0b41" - -VERSION: Final[int] = __version__ SCHEMA_VERSION: Final[int] = 22 MIN_SCHEMA_VERSION = 22 diff --git a/music_assistant/server/controllers/cache.py b/music_assistant/server/controllers/cache.py index 65418684..b002b7f7 100644 --- a/music_assistant/server/controllers/cache.py +++ b/music_assistant/server/controllers/cache.py @@ -11,6 +11,8 @@ from collections.abc import Iterator, MutableMapping from typing import TYPE_CHECKING, Any from music_assistant.common.helpers.json import json_dumps, json_loads +from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType +from music_assistant.common.models.enums import ConfigEntryType from music_assistant.constants import ( DB_TABLE_CACHE, DB_TABLE_SETTINGS, @@ -21,9 +23,10 @@ from music_assistant.server.helpers.database import DatabaseConnection from music_assistant.server.models.core_controller import CoreController if TYPE_CHECKING: - pass + from music_assistant.common.models.config_entries import CoreConfig LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.cache") +CONF_CLEAR_CACHE = "clear_cache" class CacheController(CoreController): @@ -42,7 +45,45 @@ class CacheController(CoreController): ) self.manifest.icon = "mdi-memory" - async def setup(self) -> None: + async def get_config_entries( + self, + action: str | None = None, # noqa: ARG002 + values: dict[str, ConfigValueType] | None = None, # noqa: ARG002 + ) -> tuple[ConfigEntry, ...]: + """Return all Config Entries for this core module (if any).""" + if action == CONF_CLEAR_CACHE: + await self.clear() + return ( + ConfigEntry( + key=CONF_CLEAR_CACHE, + type=ConfigEntryType.LABEL, + label="The cache has been cleared", + ), + ) + return ( + ConfigEntry( + key=CONF_CLEAR_CACHE, + type=ConfigEntryType.ACTION, + label="Clear cache", + description="Reset/clear all items in the cache. ", + ), + # ConfigEntry( + # key=CONF_BIND_IP, + # type=ConfigEntryType.STRING, + # default_value=default_ip, + # label="Bind to IP/interface", + # description="Start the streamserver on this specific interface. \n" + # "This IP address is communicated to players where to find this server. " + # "Override the default in advanced scenarios, such as multi NIC configurations. \n" + # "Make sure that this server can be reached " + # "on the given IP and TCP port by players on the local network. \n" + # "This is an advanced setting that should normally " + # "not be adjusted in regular setups.", + # advanced=True, + # ), + ) + + async def setup(self, config: CoreConfig) -> None: # noqa: ARG002 """Async initialize of cache module.""" await self._setup_database() self.__schedule_cleanup_task() diff --git a/music_assistant/server/controllers/config.py b/music_assistant/server/controllers/config.py index 689ed25f..5c10de81 100644 --- a/music_assistant/server/controllers/config.py +++ b/music_assistant/server/controllers/config.py @@ -50,7 +50,15 @@ isfile = wrap(os.path.isfile) remove = wrap(os.remove) rename = wrap(os.rename) -CONFIGURABLE_CORE_CONTROLLERS = ("streams", "webserver", "players", "metadata", "cache") +CONFIGURABLE_CORE_CONTROLLERS = ( + "streams", + "webserver", + "players", + "metadata", + "cache", + "music", + "player_queues", +) class ConfigController: @@ -528,7 +536,7 @@ class ConfigController: return config # try to load the provider first to catch errors before we save it. controller: CoreController = getattr(self.mass, domain) - await controller.reload() + await controller.reload(config) # reload succeeded, save new config config.last_error = None conf_key = f"{CONF_CORE}/{domain}" diff --git a/music_assistant/server/controllers/metadata.py b/music_assistant/server/controllers/metadata.py index 07abbda5..0635d1cf 100755 --- a/music_assistant/server/controllers/metadata.py +++ b/music_assistant/server/controllers/metadata.py @@ -32,6 +32,7 @@ from music_assistant.server.helpers.images import create_collage, get_image_thum from music_assistant.server.models.core_controller import CoreController if TYPE_CHECKING: + from music_assistant.common.models.config_entries import CoreConfig from music_assistant.server.models.metadata_provider import MetadataProvider LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.metadata") @@ -54,12 +55,13 @@ class MetaDataController(CoreController): ) self.manifest.icon = "mdi-book-information-variant" - async def setup(self) -> None: + async def setup(self, config: CoreConfig) -> None: # noqa: ARG002 """Async initialize of module.""" self.mass.streams.register_dynamic_route("/imageproxy", self._handle_imageproxy) async def close(self) -> None: """Handle logic on server stop.""" + self.mass.streams.unregister_dynamic_route("/imageproxy") @property def providers(self) -> list[MetadataProvider]: diff --git a/music_assistant/server/controllers/music.py b/music_assistant/server/controllers/music.py index 8c4db3eb..9acebce1 100755 --- a/music_assistant/server/controllers/music.py +++ b/music_assistant/server/controllers/music.py @@ -10,7 +10,14 @@ from typing import TYPE_CHECKING from music_assistant.common.helpers.datetime import utc_timestamp from music_assistant.common.helpers.uri import parse_uri -from music_assistant.common.models.enums import EventType, MediaType, ProviderFeature, ProviderType +from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType +from music_assistant.common.models.enums import ( + ConfigEntryType, + EventType, + MediaType, + ProviderFeature, + ProviderType, +) from music_assistant.common.models.errors import MusicAssistantError from music_assistant.common.models.media_items import BrowseFolder, MediaItemType, SearchResults from music_assistant.common.models.provider import SyncTask @@ -39,17 +46,17 @@ from .media.radio import RadioController from .media.tracks import TracksController if TYPE_CHECKING: - pass + from music_assistant.common.models.config_entries import CoreConfig LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.music") -SYNC_INTERVAL = 3 * 3600 +DEFAULT_SYNC_INTERVAL = 3 * 60 # default sync interval in minutes +CONF_SYNC_INTERVAL = "sync_interval" class MusicController(CoreController): """Several helpers around the musicproviders.""" domain: str = "music" - database: DatabaseConnection | None = None def __init__(self, *args, **kwargs) -> None: @@ -68,15 +75,38 @@ class MusicController(CoreController): "Music Assistant's core controller which manages all music from all providers." ) self.manifest.icon = "mdi-archive-music" + self._sync_task: asyncio.Task | None = None - async def setup(self): + async def get_config_entries( + self, + action: str | None = None, # noqa: ARG002 + values: dict[str, ConfigValueType] | None = None, # noqa: ARG002 + ) -> tuple[ConfigEntry, ...]: + """Return all Config Entries for this core module (if any).""" + return ( + ConfigEntry( + key=CONF_SYNC_INTERVAL, + type=ConfigEntryType.INTEGER, + range=(5, 720), + default_value=DEFAULT_SYNC_INTERVAL, + label="Sync interval", + description="Interval (in minutes) that a (delta) sync " + "of all providers should be performed.", + ), + ) + + async def setup(self, config: CoreConfig) -> None: """Async initialize of module.""" # setup library database await self._setup_database() - self.mass.create_task(self.start_sync(reschedule=SYNC_INTERVAL)) + sync_interval = config.get_value(CONF_SYNC_INTERVAL) + self.logger.info("Setting up the sync interval to %s minutes.", sync_interval) + self._sync_task = self.mass.create_task(self.start_sync(reschedule=sync_interval)) async def close(self) -> None: """Cleanup on exit.""" + if self._sync_task and not self._sync_task.done(): + self._sync_task.cancel() await self.database.close() @property diff --git a/music_assistant/server/controllers/player_queues.py b/music_assistant/server/controllers/player_queues.py index 18d46737..59d73934 100755 --- a/music_assistant/server/controllers/player_queues.py +++ b/music_assistant/server/controllers/player_queues.py @@ -23,27 +23,33 @@ from music_assistant.common.models.queue_item import QueueItem from music_assistant.constants import CONF_FLOW_MODE, FALLBACK_DURATION, ROOT_LOGGER_NAME from music_assistant.server.helpers.api import api_command from music_assistant.server.helpers.audio import get_stream_details +from music_assistant.server.models.core_controller import CoreController if TYPE_CHECKING: from collections.abc import Iterator from music_assistant.common.models.player import Player - from .players import PlayerController LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.players.queue") -class PlayerQueuesController: +class PlayerQueuesController(CoreController): """Controller holding all logic to enqueue music for players.""" - def __init__(self, players: PlayerController) -> None: - """Initialize class.""" - self.players = players - self.mass = players.mass + domain: str = "player_queues" + + def __init__(self, *args, **kwargs) -> None: + """Initialize core controller.""" + super().__init__(*args, **kwargs) self._queues: dict[str, PlayerQueue] = {} self._queue_items: dict[str, list[QueueItem]] = {} self._prev_states: dict[str, dict] = {} + self.manifest.name = "Player Queues controller" + self.manifest.description = ( + "Music Assistant's core controller which manages the queues for all players." + ) + self.manifest.icon = "mdi-playlist-music" async def close(self) -> None: """Cleanup on exit.""" @@ -340,7 +346,7 @@ class PlayerQueuesController: LOGGER.warning("Ignore queue command for %s because an announcement is in progress.") return # simply forward the command to underlying player - await self.players.cmd_stop(queue_id) + await self.mass.players.cmd_stop(queue_id) @api_command("players/queue/play") async def play(self, queue_id: str) -> None: @@ -354,7 +360,7 @@ class PlayerQueuesController: return if self._queues[queue_id].state == PlayerState.PAUSED: # simply forward the command to underlying player - await self.players.cmd_play(queue_id) + await self.mass.players.cmd_play(queue_id) else: await self.resume(queue_id) @@ -368,7 +374,7 @@ class PlayerQueuesController: LOGGER.warning("Ignore queue command for %s because an announcement is in progress.") return # simply forward the command to underlying player - await self.players.cmd_pause(queue_id) + await self.mass.players.cmd_pause(queue_id) @api_command("players/queue/play_pause") async def play_pause(self, queue_id: str) -> None: @@ -561,7 +567,7 @@ class PlayerQueuesController: # race condition return queue_id = player.player_id - player = self.players.get(queue_id) + player = self.mass.players.get(queue_id) queue = self._queues[queue_id] # basic properties diff --git a/music_assistant/server/controllers/players.py b/music_assistant/server/controllers/players.py index cfbd3ad4..05202852 100755 --- a/music_assistant/server/controllers/players.py +++ b/music_assistant/server/controllers/players.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import logging from collections.abc import Iterator -from typing import cast +from typing import TYPE_CHECKING, cast from music_assistant.common.helpers.util import get_changed_values from music_assistant.common.models.enums import ( @@ -26,7 +26,8 @@ from music_assistant.server.helpers.api import api_command from music_assistant.server.models.core_controller import CoreController from music_assistant.server.models.player_provider import PlayerProvider -from .player_queues import PlayerQueuesController +if TYPE_CHECKING: + from music_assistant.common.models.config_entries import CoreConfig LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.players") @@ -41,20 +42,21 @@ class PlayerController(CoreController): super().__init__(*args, **kwargs) self._players: dict[str, Player] = {} self._prev_states: dict[str, dict] = {} - self.queues = PlayerQueuesController(self) self.manifest.name = "Players controller" self.manifest.description = ( "Music Assistant's core controller which manages all players from all providers." ) self.manifest.icon = "mdi-speaker-multiple" + self._poll_task: asyncio.Task | None = None - async def setup(self) -> None: + async def setup(self, config: CoreConfig) -> None: # noqa: ARG002 """Async initialize of module.""" - self.mass.create_task(self._poll_players()) + self._poll_task = self.mass.create_task(self._poll_players()) async def close(self) -> None: """Cleanup on exit.""" - await self.queues.close() + if self._poll_task and not self._poll_task.done(): + self._poll_task.cancel() @property def providers(self) -> list[PlayerProvider]: @@ -127,7 +129,7 @@ class PlayerController(CoreController): player.enabled = self.mass.config.get(f"{CONF_PLAYERS}/{player_id}/enabled", True) # register playerqueue for this player - self.mass.create_task(self.queues.on_player_register(player)) + self.mass.create_task(self.mass.player_queues.on_player_register(player)) self._players[player_id] = player @@ -163,7 +165,7 @@ class PlayerController(CoreController): if player is None: return LOGGER.info("Player removed: %s", player.name) - self.queues.on_player_remove(player_id) + self.mass.player_queues.on_player_remove(player_id) self.mass.config.remove(f"players/{player_id}") self._prev_states.pop(player_id, None) self.mass.signal_event(EventType.PLAYER_REMOVED, player_id) @@ -208,7 +210,7 @@ class PlayerController(CoreController): return # always signal update to the playerqueue - self.queues.on_player_update(player, changed_values) + self.mass.player_queues.on_player_update(player, changed_values) if len(changed_values) == 0 and not force_update: return diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index de81ffb2..6d7ce67f 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -45,6 +45,7 @@ from music_assistant.server.helpers.webserver import Webserver from music_assistant.server.models.core_controller import CoreController if TYPE_CHECKING: + from music_assistant.common.models.config_entries import CoreConfig from music_assistant.common.models.player import Player @@ -81,7 +82,7 @@ class MultiClientStreamJob: """Initialize MultiClientStreamJob instance.""" self.stream_controller = stream_controller self.queue_id = queue_id - self.queue = self.stream_controller.mass.players.queues.get(queue_id) + self.queue = self.stream_controller.mass.player_queues.get(queue_id) assert self.queue # just in case self.pcm_format = pcm_format self.start_queue_item = start_queue_item @@ -301,10 +302,11 @@ class StreamsController(CoreController): "on the given IP and TCP port by players on the local network. \n" "This is an advanced setting that should normally " "not be adjusted in regular setups.", + advanced=True, ), ) - async def setup(self) -> None: + async def setup(self, config: CoreConfig) -> None: """Async initialize of module.""" ffmpeg_present, libsoxr_support, version = await check_audio_support() if not ffmpeg_present: @@ -320,10 +322,8 @@ class StreamsController(CoreController): "with libsoxr support" if libsoxr_support else "", ) # start the webserver - self.publish_port = await self.mass.config.get_core_config_value( - self.domain, CONF_BIND_PORT - ) - self.publish_ip = await self.mass.config.get_core_config_value(self.domain, CONF_BIND_IP) + self.publish_port = config.get_value(CONF_BIND_PORT) + self.publish_ip = config.get_value(CONF_BIND_IP) await self._server.setup( bind_ip=self.publish_ip, bind_port=self.publish_port, @@ -446,12 +446,12 @@ class StreamsController(CoreController): """Stream single queueitem audio to a player.""" self._log_request(request) queue_id = request.match_info["queue_id"] - queue = self.mass.players.queues.get(queue_id) + queue = self.mass.player_queues.get(queue_id) if not queue: raise web.HTTPNotFound(reason=f"Unknown Queue: {queue_id}") queue_player = self.mass.players.get(queue_id) queue_item_id = request.match_info["queue_item_id"] - queue_item = self.mass.players.queues.get_item(queue_id, queue_item_id) + queue_item = self.mass.player_queues.get_item(queue_id, queue_item_id) if not queue_item: raise web.HTTPNotFound(reason=f"Unknown Queue item: {queue_item_id}") try: @@ -537,11 +537,11 @@ class StreamsController(CoreController): """Stream Queue Flow audio to player.""" self._log_request(request) queue_id = request.match_info["queue_id"] - queue = self.mass.players.queues.get(queue_id) + queue = self.mass.player_queues.get(queue_id) if not queue: raise web.HTTPNotFound(reason=f"Unknown Queue: {queue_id}") start_queue_item_id = request.match_info["queue_item_id"] - start_queue_item = self.mass.players.queues.get_item(queue_id, start_queue_item_id) + start_queue_item = self.mass.player_queues.get_item(queue_id, start_queue_item_id) if not start_queue_item: raise web.HTTPNotFound(reason=f"Unknown Queue item: {start_queue_item_id}") seek_position = int(request.query.get("seek_position", 0)) @@ -628,7 +628,7 @@ class StreamsController(CoreController): continue # if icy metadata is enabled, send the icy metadata after the chunk - current_item = self.mass.players.queues.get_item( + current_item = self.mass.player_queues.get_item( queue.queue_id, queue.index_in_buffer ) if ( @@ -776,7 +776,7 @@ class StreamsController(CoreController): _, queue_track, use_crossfade, - ) = await self.mass.players.queues.preload_next_url(queue.queue_id) + ) = await self.mass.player_queues.preload_next_url(queue.queue_id) except QueueEmpty: break diff --git a/music_assistant/server/controllers/webserver.py b/music_assistant/server/controllers/webserver.py index b0c29fa6..5f621a98 100644 --- a/music_assistant/server/controllers/webserver.py +++ b/music_assistant/server/controllers/webserver.py @@ -33,12 +33,12 @@ from music_assistant.common.models.errors import InvalidCommand from music_assistant.common.models.event import MassEvent from music_assistant.constants import CONF_BIND_IP, CONF_BIND_PORT from music_assistant.server.helpers.api import APICommandHandler, parse_arguments -from music_assistant.server.helpers.util import get_ips, is_hass_supervisor +from music_assistant.server.helpers.util import get_ips from music_assistant.server.helpers.webserver import Webserver from music_assistant.server.models.core_controller import CoreController if TYPE_CHECKING: - from music_assistant.common.models.config_entries import ConfigValueType + from music_assistant.common.models.config_entries import ConfigValueType, CoreConfig CONF_BASE_URL = "base_url" @@ -74,7 +74,7 @@ class WebserverController(CoreController): values: dict[str, ConfigValueType] | None = None, # noqa: ARG002 ) -> tuple[ConfigEntry, ...]: """Return all Config Entries for this core module (if any).""" - if await is_hass_supervisor(): + if self.mass.running_as_hass_addon: # if we're running on the HA supervisor the webserver is secured by HA ingress # we only start the webserver on the internal docker network and ingress connects # to that internally and exposes the webUI securely @@ -147,10 +147,11 @@ class WebserverController(CoreController): "to enhance security and protect outside access to the webinterface and API. \n\n" "This is an advanced setting that should normally " "not be adjusted in regular setups.", + advanced=True, ), ) - async def setup(self) -> None: + async def setup(self, config: CoreConfig) -> None: """Async initialize of module.""" # work out all routes routes: list[tuple[str, str, Awaitable]] = [] @@ -172,9 +173,9 @@ class WebserverController(CoreController): routes.append(("GET", "/ws", self._handle_ws_client)) # start the webserver await self._server.setup( - bind_ip=await self.mass.config.get_core_config_value(self.domain, CONF_BIND_IP), - bind_port=await self.mass.config.get_core_config_value(self.domain, CONF_BIND_PORT), - base_url=await self.mass.config.get_core_config_value(self.domain, CONF_BASE_URL), + bind_ip=config.get_value(CONF_BIND_IP), + bind_port=config.get_value(CONF_BIND_PORT), + base_url=config.get_value(CONF_BASE_URL), static_routes=routes, # add assets subdir as static_content static_content=("/assets", os.path.join(frontend_dir, "assets"), "assets"), diff --git a/music_assistant/server/helpers/util.py b/music_assistant/server/helpers/util.py index df90f514..504e74b1 100644 --- a/music_assistant/server/helpers/util.py +++ b/music_assistant/server/helpers/util.py @@ -12,6 +12,8 @@ import urllib.parse import urllib.request from contextlib import suppress from functools import lru_cache +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as pkg_version from typing import TYPE_CHECKING import memory_tempfile @@ -38,6 +40,21 @@ async def install_package(package: str) -> None: raise RuntimeError(msg) +async def get_package_version(pkg_name: str) -> str: + """ + Return the version of an installed (python) package. + + Will return `0.0.0` if the package is not found. + """ + try: + installed_version = await asyncio.to_thread(pkg_version, pkg_name) + if installed_version is None: + return "0.0.0" # type: ignore[unreachable] + return installed_version + except PackageNotFoundError: + return "0.0.0" + + async def get_ips(include_ipv6: bool = False) -> set[str]: """Return all IP-adresses of all network interfaces.""" @@ -57,7 +74,8 @@ async def get_ips(include_ipv6: bool = False) -> set[str]: async def is_hass_supervisor() -> bool: """Return if we're running inside the HA Supervisor (e.g. HAOS).""" with suppress(urllib.error.URLError): - res = await asyncio.to_thread(urllib.request.urlopen, "ws://supervisor/core/websocket") + res = await asyncio.to_thread(urllib.request.urlopen, "http://supervisor/core") + # this should return a 401 unauthorized if it exists return res.code == 401 return False diff --git a/music_assistant/server/models/core_controller.py b/music_assistant/server/models/core_controller.py index a4646403..782299ca 100644 --- a/music_assistant/server/models/core_controller.py +++ b/music_assistant/server/models/core_controller.py @@ -9,7 +9,11 @@ from music_assistant.common.models.provider import ProviderManifest from music_assistant.constants import CONF_LOG_LEVEL, ROOT_LOGGER_NAME if TYPE_CHECKING: - from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType + from music_assistant.common.models.config_entries import ( + ConfigEntry, + ConfigValueType, + CoreConfig, + ) from music_assistant.server import MusicAssistant @@ -45,19 +49,19 @@ class CoreController: """Return all Config Entries for this core module (if any).""" return tuple() - async def setup(self) -> None: + async def setup(self, config: CoreConfig) -> None: """Async initialize of module.""" async def close(self) -> None: """Handle logic on server stop.""" - async def reload(self) -> None: + async def reload(self, config: CoreConfig | None = None) -> None: """Reload this core controller.""" await self.close() - log_level = self.mass.config.get_raw_core_config_value( - self.domain, CONF_LOG_LEVEL, "GLOBAL" - ) + if config is None: + config = await self.mass.config.get_core_config(self.domain) + log_level = config.get_value(CONF_LOG_LEVEL) if log_level == "GLOBAL": log_level = logging.getLogger(ROOT_LOGGER_NAME).level self.logger.setLevel(log_level) - await self.setup() + await self.setup(config) diff --git a/music_assistant/server/providers/chromecast/__init__.py b/music_assistant/server/providers/chromecast/__init__.py index 95280546..838b7067 100644 --- a/music_assistant/server/providers/chromecast/__init__.py +++ b/music_assistant/server/providers/chromecast/__init__.py @@ -481,7 +481,7 @@ class ChromecastProvider(PlayerProvider): async def _enqueue_next_track(self, castplayer: CastPlayer) -> None: """Enqueue the next track of the MA queue on the CC queue.""" try: - next_url, next_item, _ = await self.mass.players.queues.preload_next_url( + next_url, next_item, _ = await self.mass.player_queues.preload_next_url( castplayer.player_id ) except QueueEmpty: diff --git a/music_assistant/server/providers/dlna/__init__.py b/music_assistant/server/providers/dlna/__init__.py index 9f44d4d8..4949e169 100644 --- a/music_assistant/server/providers/dlna/__init__.py +++ b/music_assistant/server/providers/dlna/__init__.py @@ -538,7 +538,7 @@ class DLNAPlayerProvider(PlayerProvider): next_url, next_item, _, - ) = await self.mass.players.queues.preload_next_url(dlna_player.udn) + ) = await self.mass.player_queues.preload_next_url(dlna_player.udn) except QueueEmpty: return diff --git a/music_assistant/server/providers/radiobrowser/__init__.py b/music_assistant/server/providers/radiobrowser/__init__.py index de325151..20907dfc 100644 --- a/music_assistant/server/providers/radiobrowser/__init__.py +++ b/music_assistant/server/providers/radiobrowser/__init__.py @@ -22,7 +22,6 @@ from music_assistant.common.models.media_items import ( SearchResults, StreamDetails, ) -from music_assistant.constants import __version__ as MASS_VERSION # noqa: N812 from music_assistant.server.helpers.audio import get_radio_stream from music_assistant.server.models.music_provider import MusicProvider @@ -72,7 +71,7 @@ class RadioBrowserProvider(MusicProvider): async def handle_setup(self) -> None: """Handle async initialization of the provider.""" self.radios = RadioBrowser( - session=self.mass.http_session, user_agent=f"MusicAssistant/{MASS_VERSION}" + session=self.mass.http_session, user_agent=f"MusicAssistant/{self.mass.version}" ) try: # Try to get some stats to check connection to RadioBrowser API diff --git a/music_assistant/server/providers/slimproto/__init__.py b/music_assistant/server/providers/slimproto/__init__.py index 0e7a09cf..c2f1b37b 100644 --- a/music_assistant/server/providers/slimproto/__init__.py +++ b/music_assistant/server/providers/slimproto/__init__.py @@ -501,7 +501,7 @@ class SlimprotoProvider(PlayerProvider): parent_player.group_childs.add(child_player.player_id) child_player.synced_to = parent_player.player_id # check if we should (re)start or join a stream session - active_queue = self.mass.players.queues.get_active_queue(parent_player.player_id) + active_queue = self.mass.player_queues.get_active_queue(parent_player.player_id) if ( ENABLE_EXPERIMENTAL_SYNC_JOIN and (stream_job := self.mass.streams.multi_client_jobs.get(active_queue.queue_id)) @@ -516,7 +516,7 @@ class SlimprotoProvider(PlayerProvider): await self._handle_play_url(client, url, None, auto_play=True) elif parent_player.state == PlayerState.PLAYING: # playback needs to be restarted to form a new multi client stream session - await self.mass.players.queues.resume(active_queue.queue_id, fade_in=False) + await self.mass.player_queues.resume(active_queue.queue_id, fade_in=False) else: # make sure that the player manager gets an update self.mass.players.update(child_player.player_id) @@ -661,7 +661,7 @@ class SlimprotoProvider(PlayerProvider): client.player_id, deque(maxlen=MIN_REQ_PLAYPOINTS) ) - active_queue = self.mass.players.queues.get_active_queue(client.player_id) + active_queue = self.mass.player_queues.get_active_queue(client.player_id) stream_job = self.mass.streams.multi_client_jobs.get(active_queue.queue_id) if not stream_job: # should not happen, but just in case @@ -719,7 +719,7 @@ class SlimprotoProvider(PlayerProvider): if player.active_source != player.player_id: return try: - next_url, next_item, crossfade = await self.mass.players.queues.preload_next_url( + next_url, next_item, crossfade = await self.mass.player_queues.preload_next_url( client.player_id ) async with asyncio.TaskGroup() as tg: @@ -830,7 +830,7 @@ class SlimprotoProvider(PlayerProvider): def _get_corrected_elapsed_milliseconds(self, client: SlimClient) -> int: """Return corrected elapsed milliseconds.""" skipped_millis = 0 - active_queue = self.mass.players.queues.get_active_queue(client.player_id) + active_queue = self.mass.player_queues.get_active_queue(client.player_id) if stream_job := self.mass.streams.multi_client_jobs.get(active_queue.queue_id): skipped_millis = stream_job.client_seconds_skipped.get(client.player_id, 0) * 1000 sync_delay = self.mass.config.get_raw_player_config_value( diff --git a/music_assistant/server/providers/slimproto/cli.py b/music_assistant/server/providers/slimproto/cli.py index 2e80f1a5..ca2841f9 100644 --- a/music_assistant/server/providers/slimproto/cli.py +++ b/music_assistant/server/providers/slimproto/cli.py @@ -610,12 +610,12 @@ class LmsCli: player = self.mass.players.get(player_id) if player is None: return None - queue = self.mass.players.queues.get_active_queue(player_id) + queue = self.mass.player_queues.get_active_queue(player_id) assert queue is not None start_index = queue.current_index or 0 if offset == "-" else offset queue_items: list[QueueItem] = [] index = 0 - async for item in self.mass.players.queues.items(queue.queue_id): + async for item in self.mass.player_queues.items(queue.queue_id): if index >= start_index: queue_items.append(item) if len(queue_items) == limit: @@ -841,7 +841,7 @@ class LmsCli: # You may jump to a particular position in a song by specifying a number of seconds # to seek to. You may also jump to a relative position within a song by putting an # explicit "-" or "+" character before a number of seconds you would like to seek. - player_queue = self.mass.players.queues.get_active_queue(player_id) + player_queue = self.mass.player_queues.get_active_queue(player_id) assert player_queue is not None if number == "?": @@ -849,9 +849,9 @@ class LmsCli: if isinstance(number, str) and ("+" in number or "-" in number): jump = int(number.split("+")[1]) - self.mass.create_task(self.mass.players.queues.skip, player_queue.queue_id, jump) + self.mass.create_task(self.mass.player_queues.skip, player_queue.queue_id, jump) else: - self.mass.create_task(self.mass.players.queues.seek, player_queue.queue_id, number) + self.mass.create_task(self.mass.player_queues.seek, player_queue.queue_id, number) def _handle_power(self, player_id: str, value: str | int, *args, **kwargs) -> int | None: """Handle player `time` command.""" @@ -882,25 +882,25 @@ class LmsCli: ) -> int | None: """Handle player `playlist` command.""" arg = args[0] if args else "?" - queue = self.mass.players.queues.get_active_queue(player_id) + queue = self.mass.player_queues.get_active_queue(player_id) assert queue is not None # playlist index if subcommand == "index" and isinstance(arg, int): - self.mass.create_task(self.mass.players.queues.play_index, player_id, arg) + self.mass.create_task(self.mass.player_queues.play_index, player_id, arg) return if subcommand == "index" and arg == "?": return queue.current_index if subcommand == "index" and "+" in arg: next_index = (queue.current_index or 0) + int(arg.split("+")[1]) - self.mass.create_task(self.mass.players.queues.play_index, player_id, next_index) + self.mass.create_task(self.mass.player_queues.play_index, player_id, next_index) return if subcommand == "index" and "-" in arg: next_index = (queue.current_index or 0) - int(arg.split("-")[1]) - self.mass.create_task(self.mass.players.queues.play_index, player_id, next_index) + self.mass.create_task(self.mass.player_queues.play_index, player_id, next_index) return if subcommand == "shuffle": - self.mass.players.queues.set_shuffle(queue.queue_id, not queue.shuffle_enabled) + self.mass.player_queues.set_shuffle(queue.queue_id, not queue.shuffle_enabled) return if subcommand == "repeat": if queue.repeat_mode == RepeatMode.ALL: @@ -909,10 +909,10 @@ class LmsCli: new_repeat_mode = RepeatMode.ONE else: new_repeat_mode = RepeatMode.ALL - self.mass.players.queues.set_repeat(queue.queue_id, new_repeat_mode) + self.mass.player_queues.set_repeat(queue.queue_id, new_repeat_mode) return if subcommand == "crossfade": - self.mass.players.queues.set_crossfade(queue.queue_id, not queue.crossfade_enabled) + self.mass.player_queues.set_crossfade(queue.queue_id, not queue.crossfade_enabled) return self.logger.warning("Unhandled command: playlist/%s", subcommand) @@ -926,25 +926,25 @@ class LmsCli: **kwargs, ) -> int | None: """Handle player `playlistcontrol` command.""" - queue = self.mass.players.queues.get_active_queue(player_id) + queue = self.mass.player_queues.get_active_queue(player_id) if cmd == "play": self.mass.create_task( - self.mass.players.queues.play_media(queue.queue_id, uri, QueueOption.PLAY) + self.mass.player_queues.play_media(queue.queue_id, uri, QueueOption.PLAY) ) return if cmd == "load": self.mass.create_task( - self.mass.players.queues.play_media(queue.queue_id, uri, QueueOption.REPLACE) + self.mass.player_queues.play_media(queue.queue_id, uri, QueueOption.REPLACE) ) return if cmd == "add": self.mass.create_task( - self.mass.players.queues.play_media(queue.queue_id, uri, QueueOption.ADD) + self.mass.player_queues.play_media(queue.queue_id, uri, QueueOption.ADD) ) return if cmd == "insert": self.mass.create_task( - self.mass.players.queues.play_media(queue.queue_id, uri, QueueOption.IN) + self.mass.player_queues.play_media(queue.queue_id, uri, QueueOption.IN) ) return self.logger.warning("Unhandled command: playlistcontrol/%s", cmd) @@ -956,9 +956,9 @@ class LmsCli: **kwargs, ) -> int | None: """Handle player `play` command.""" - queue = self.mass.players.queues.get_active_queue(player_id) + queue = self.mass.player_queues.get_active_queue(player_id) assert queue is not None - self.mass.create_task(self.mass.players.queues.play, player_id) + self.mass.create_task(self.mass.player_queues.play, player_id) def _handle_stop( self, @@ -967,9 +967,9 @@ class LmsCli: **kwargs, ) -> int | None: """Handle player `stop` command.""" - queue = self.mass.players.queues.get_active_queue(player_id) + queue = self.mass.player_queues.get_active_queue(player_id) assert queue is not None - self.mass.create_task(self.mass.players.queues.stop, player_id) + self.mass.create_task(self.mass.player_queues.stop, player_id) def _handle_pause( self, @@ -979,13 +979,13 @@ class LmsCli: **kwargs, ) -> int | None: """Handle player `stop` command.""" - queue = self.mass.players.queues.get_active_queue(player_id) + queue = self.mass.player_queues.get_active_queue(player_id) assert queue is not None if force or queue.state == PlayerState.PLAYING: - self.mass.create_task(self.mass.players.queues.pause, player_id) + self.mass.create_task(self.mass.player_queues.pause, player_id) else: - self.mass.create_task(self.mass.players.queues.play, player_id) + self.mass.create_task(self.mass.player_queues.play, player_id) def _handle_mode( self, @@ -1031,21 +1031,21 @@ class LmsCli: self.mass.create_task(self.mass.players.cmd_power, player_id, not player.powered) return # queue related button commands - queue = self.mass.players.queues.get_active_queue(player_id) + queue = self.mass.player_queues.get_active_queue(player_id) if subcommand == "jump_fwd": - self.mass.create_task(self.mass.players.queues.next, queue.queue_id) + self.mass.create_task(self.mass.player_queues.next, queue.queue_id) return if subcommand == "jump_rew": - self.mass.create_task(self.mass.players.queues.previous, queue.queue_id) + self.mass.create_task(self.mass.player_queues.previous, queue.queue_id) return if subcommand == "fwd": - self.mass.create_task(self.mass.players.queues.skip, queue.queue_id, 10) + self.mass.create_task(self.mass.player_queues.skip, queue.queue_id, 10) return if subcommand == "rew": - self.mass.create_task(self.mass.players.queues.skip, queue.queue_id, -10) + self.mass.create_task(self.mass.player_queues.skip, queue.queue_id, -10) return if subcommand == "shuffle": - self.mass.players.queues.set_shuffle(queue.queue_id, not queue.shuffle_enabled) + self.mass.player_queues.set_shuffle(queue.queue_id, not queue.shuffle_enabled) return if subcommand == "repeat": if queue.repeat_mode == RepeatMode.ALL: @@ -1054,7 +1054,7 @@ class LmsCli: new_repeat_mode = RepeatMode.ONE else: new_repeat_mode = RepeatMode.ALL - self.mass.players.queues.set_repeat(queue.queue_id, new_repeat_mode) + self.mass.player_queues.set_repeat(queue.queue_id, new_repeat_mode) return if subcommand.startswith("preset_"): preset_index = subcommand.split("preset_")[1].split(".")[0] @@ -1063,7 +1063,7 @@ class LmsCli: ): option = QueueOption.REPLACE if "playlist" in preset_uri else QueueOption.PLAY self.mass.create_task( - self.mass.players.queues.play_media, queue.queue_id, preset_uri, option + self.mass.player_queues.play_media, queue.queue_id, preset_uri, option ) return diff --git a/music_assistant/server/providers/sonos/__init__.py b/music_assistant/server/providers/sonos/__init__.py index 883a21ae..2386a465 100644 --- a/music_assistant/server/providers/sonos/__init__.py +++ b/music_assistant/server/providers/sonos/__init__.py @@ -508,7 +508,7 @@ class SonosPlayerProvider(PlayerProvider): async def _enqueue_next_track(self, sonos_player: SonosPlayer) -> None: """Enqueue the next track of the MA queue on the CC queue.""" try: - next_url, next_item, crossfade = await self.mass.players.queues.preload_next_url( + next_url, next_item, crossfade = await self.mass.player_queues.preload_next_url( sonos_player.player_id ) except QueueEmpty: diff --git a/music_assistant/server/providers/ugp/__init__.py b/music_assistant/server/providers/ugp/__init__.py index 5b9119a3..185fc593 100644 --- a/music_assistant/server/providers/ugp/__init__.py +++ b/music_assistant/server/providers/ugp/__init__.py @@ -348,7 +348,7 @@ class UniversalGroupProvider(PlayerProvider): self.mass.players.cmd_sync, child_player.player_id, sync_leader ) else: - self.mass.create_task(self.mass.players.queues.resume, player_id) + self.mass.create_task(self.mass.player_queues.resume, player_id) elif ( not child_player.powered and group_player.extra_data["optimistic_state"] == PlayerState.PLAYING @@ -356,7 +356,7 @@ class UniversalGroupProvider(PlayerProvider): ): # 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.mass.create_task(self.mass.player_queues.resume, player_id) def _get_active_members( self, player_id: str, only_powered: bool = False, skip_sync_childs: bool = True diff --git a/music_assistant/server/server.py b/music_assistant/server/server.py index 61f94080..24a505cd 100644 --- a/music_assistant/server/server.py +++ b/music_assistant/server/server.py @@ -25,18 +25,22 @@ from music_assistant.constants import ( MIN_SCHEMA_VERSION, ROOT_LOGGER_NAME, SCHEMA_VERSION, - VERSION, ) from music_assistant.server.controllers.cache import CacheController from music_assistant.server.controllers.config import ConfigController from music_assistant.server.controllers.metadata import MetaDataController from music_assistant.server.controllers.music import MusicController +from music_assistant.server.controllers.player_queues import PlayerQueuesController from music_assistant.server.controllers.players import PlayerController from music_assistant.server.controllers.streams import StreamsController from music_assistant.server.controllers.webserver import WebserverController from music_assistant.server.helpers.api import APICommandHandler, api_command from music_assistant.server.helpers.images import get_icon_string -from music_assistant.server.helpers.util import get_provider_module +from music_assistant.server.helpers.util import ( + get_package_version, + get_provider_module, + is_hass_supervisor, +) from .models import ProviderInstanceType @@ -72,6 +76,7 @@ class MusicAssistant: metadata: MetaDataController music: MusicController players: PlayerController + player_queues: PlayerQueuesController streams: StreamsController def __init__(self, storage_path: str) -> None: @@ -84,10 +89,14 @@ class MusicAssistant: self._providers: dict[str, ProviderInstanceType] = {} self._tracked_tasks: dict[str, asyncio.Task] = {} self.closing = False + self.running_as_hass_addon: bool = False + self.version: str = "0.0.0" async def start(self) -> None: """Start running the Music Assistant server.""" self.loop = asyncio.get_running_loop() + self.running_as_hass_addon = await is_hass_supervisor() + self.version = await get_package_version("music_assistant") # create shared zeroconf instance self.zeroconf = Zeroconf(interfaces=InterfaceChoice.All) # create shared aiohttp ClientSession @@ -103,25 +112,24 @@ class MusicAssistant: # setup config controller first and fetch important config values self.config = ConfigController(self) await self.config.setup() - LOGGER.info( - "Starting Music Assistant Server (%s)", - self.server_id, - ) + LOGGER.info("Starting Music Assistant Server (%s) version %s", self.server_id, self.version) # setup other core controllers self.cache = CacheController(self) self.webserver = WebserverController(self) self.metadata = MetaDataController(self) self.music = MusicController(self) self.players = PlayerController(self) + self.player_queues = PlayerQueuesController(self) self.streams = StreamsController(self) + await self.cache.setup(await self.config.get_core_config("cache")) + await self.webserver.setup(await self.config.get_core_config("webserver")) + await self.music.setup(await self.config.get_core_config("music")) + await self.metadata.setup(await self.config.get_core_config("metadata")) + await self.players.setup(await self.config.get_core_config("players")) + await self.player_queues.setup(await self.config.get_core_config("player_queues")) + await self.streams.setup(await self.config.get_core_config("streams")) # register all api commands (methods with decorator) self._register_api_commands() - await self.cache.setup() - await self.webserver.setup() - await self.music.setup() - await self.metadata.setup() - await self.players.setup() - await self.streams.setup() # setup discovery self.create_task(self._setup_discovery()) # load providers @@ -143,6 +151,7 @@ class MusicAssistant: await self.webserver.close() await self.metadata.close() await self.music.close() + await self.player_queues.close() await self.players.close() # cleanup cache and config await self.config.close() @@ -163,10 +172,11 @@ class MusicAssistant: """Return Info of this server.""" return ServerInfoMessage( server_id=self.server_id, - server_version=VERSION, + server_version=self.version, schema_version=SCHEMA_VERSION, min_supported_schema_version=MIN_SCHEMA_VERSION, base_url=self.webserver.base_url, + homeassistant_addon=self.running_as_hass_addon, ) @api_command("providers/available") @@ -416,7 +426,7 @@ class MusicAssistant: self.metadata, self.music, self.players, - self.players.queues, + self.player_queues, ): for attr_name in dir(cls): if attr_name.startswith("__"): diff --git a/pyproject.toml b/pyproject.toml index 7a098072..d7fb7a09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "music_assistant" -dynamic = ["version"] +# The version is set by GH action on release +version = "0.0.0" license = {text = "Apache-2.0"} description = "Music Assistant" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [ {name = "The Music Assistant Authors", email = "marcelveldt@users.noreply.github.com"} ] @@ -60,9 +61,6 @@ test = [ [project.scripts] mass = "music_assistant.__main__:main" -[tool.setuptools.dynamic] -version = {attr = "music_assistant.constants.__version__"} - [tool.black] target-version = ['py311'] line-length = 100 -- 2.34.1