Some small follow up fixes for the configurable core controllers (#743)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 7 Jul 2023 14:19:13 +0000 (16:19 +0200)
committerGitHub <noreply@github.com>
Fri, 7 Jul 2023 14:19:13 +0000 (16:19 +0200)
* 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

22 files changed:
music_assistant/__main__.py
music_assistant/common/models/api.py
music_assistant/constants.py
music_assistant/server/controllers/cache.py
music_assistant/server/controllers/config.py
music_assistant/server/controllers/metadata.py
music_assistant/server/controllers/music.py
music_assistant/server/controllers/player_queues.py
music_assistant/server/controllers/players.py
music_assistant/server/controllers/streams.py
music_assistant/server/controllers/webserver.py
music_assistant/server/helpers/util.py
music_assistant/server/models/core_controller.py
music_assistant/server/providers/chromecast/__init__.py
music_assistant/server/providers/dlna/__init__.py
music_assistant/server/providers/radiobrowser/__init__.py
music_assistant/server/providers/slimproto/__init__.py
music_assistant/server/providers/slimproto/cli.py
music_assistant/server/providers/sonos/__init__.py
music_assistant/server/providers/ugp/__init__.py
music_assistant/server/server.py
pyproject.toml

index c8c13bcbbaffc6ce632b44d308ab044ac499c71a..49d33606e9627637c49e184ee3ce6f71388bc1ce 100644 (file)
@@ -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,
     )
 
 
index 74a8185f71aac6e7b87fe5d58e80536092245590..1894d20128536906bb4e9d39a6ca971172559511 100644 (file)
@@ -63,6 +63,7 @@ class ServerInfoMessage(DataClassORJSONMixin):
     schema_version: int
     min_supported_schema_version: int
     base_url: str
+    homeassistant_addon: bool = False
 
 
 MessageType = (
index 6d6f8cd01ea10e84c7a5fddea6589cd14e63afe3..4d213fd546688a3ff1c5f0672e264df4f40de4fc 100755 (executable)
@@ -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
 
index 6541868404b6ec7a5e32f93ffd9eef11b230cd8d..b002b7f79ffe15076d9f66e92fdc29b26b39ebc7 100644 (file)
@@ -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()
index 689ed25f349af50bec415c87fa8181979b2cddbb..5c10de81de4e7ec976378ab722cffae29333a67c 100644 (file)
@@ -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}"
index 07abbda5978c76c1f4c49ef5dbb328e809e2697d..0635d1cfffe2012e9ebf659785c4eb50c876a1bc 100755 (executable)
@@ -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]:
index 8c4db3eb97fbca963776074085ae25aa976f1992..9acebce1f1656c1af7373442940e3da1e75bb094 100755 (executable)
@@ -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
index 18d4673704e61e2c171f5b195d24c95aec2fefd6..59d73934ff2a7e6f695ba010e50f78a13bc2a18b 100755 (executable)
@@ -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
index cfbd3ad4c960eb03f71a0ed211491960aefd5711..05202852e72281883b25a477a18eab5d1c1c2613 100755 (executable)
@@ -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
index de81ffb201385ddc79ab34be12546402e47648b5..6d7ce67f405c114565884dd3e795315c8a777b40 100644 (file)
@@ -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
 
index b0c29fa673ff08b86bb632bc1b83ff2b4913aeab..5f621a984a64503477eda8949ca12cefe6f763fe 100644 (file)
@@ -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"),
index df90f51459bcf6e1b140f30a6ac481d6f2e49c10..504e74b17a1d78917531faaf94d363074883e8bc 100644 (file)
@@ -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
 
index a4646403336a5e8700408a381c96da8e99a5d246..782299cafeaf6108f43be42476695c0556934b6d 100644 (file)
@@ -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)
index 9528054682ee7896c5a9c0d7c1a5ece15c6a24c3..838b7067fe7e1398bf5cd065a30a0b90a22cf12d 100644 (file)
@@ -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:
index 9f44d4d81e10499270a44c469c8068c9337fb30e..4949e1694bcc798a86add8831265f1057e898991 100644 (file)
@@ -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
 
index de32515183cfeb91d1874dfe4c2f23d592018ef9..20907dfc2e1e4309de5aed302f02e574fd677bc6 100644 (file)
@@ -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
index 0e7a09cf2b2f4834d058c86d505fa6e15c5c0c9b..c2f1b37b01173b58972e396aaf76a5b8d8733b93 100644 (file)
@@ -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(
index 2e80f1a591c90c84d3f07a4c580f5d1ad0461f58..ca2841f95012da673e314a71dcf6dbdd84a0e001 100644 (file)
@@ -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
 
         # <playerid> playlist index <index|+index|-index|?> <fadeInSecs>
         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
 
index 883a21ae947a44ccd17fbcab6c0a5f5936a1cc82..2386a465b20be9c07e410f51e8e3be3793718301 100644 (file)
@@ -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:
index 5b9119a3794a4226efe18f8144efb9d31ba1529e..185fc593fb3dae05e0dfe56d84e48c66e7a171fa 100644 (file)
@@ -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
index 61f94080d56fccd7884ce6ac156636275699fa99..24a505cd35b0cbf2ac9b63ed9ce863bfe0254762 100644 (file)
@@ -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("__"):
index 7a09807244ebcafa7322b50ff81e8905f36b71bd..d7fb7a09fc50fddb864c469a10a5b107e342e6c9 100644 (file)
@@ -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