Refactor config flow (#567)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 23 Mar 2023 20:09:51 +0000 (21:09 +0100)
committerGitHub <noreply@github.com>
Thu, 23 Mar 2023 20:09:51 +0000 (21:09 +0100)
* Refactor config entries and provider setup

* No more need to get the config entries from the manifest file

* split out websocket api and webserver

* fixes for the webserver

* store provider icons server side

64 files changed:
music_assistant/__main__.py
music_assistant/common/models/config_entries.py
music_assistant/common/models/provider.py
music_assistant/constants.py
music_assistant/server/controllers/config.py
music_assistant/server/controllers/metadata.py
music_assistant/server/controllers/streams.py
music_assistant/server/controllers/webserver.py [new file with mode: 0644]
music_assistant/server/helpers/api.py
music_assistant/server/helpers/images.py
music_assistant/server/helpers/util.py
music_assistant/server/models/__init__.py
music_assistant/server/models/provider.py
music_assistant/server/providers/airplay/__init__.py
music_assistant/server/providers/airplay/manifest.json
music_assistant/server/providers/chromecast/__init__.py
music_assistant/server/providers/chromecast/manifest.json
music_assistant/server/providers/dlna/__init__.py
music_assistant/server/providers/dlna/helpers.py
music_assistant/server/providers/dlna/icon.png [new file with mode: 0644]
music_assistant/server/providers/dlna/manifest.json
music_assistant/server/providers/fanarttv/__init__.py
music_assistant/server/providers/fanarttv/manifest.json
music_assistant/server/providers/filesystem_local/__init__.py
music_assistant/server/providers/filesystem_local/base.py
music_assistant/server/providers/filesystem_local/manifest.json
music_assistant/server/providers/filesystem_smb/__init__.py
music_assistant/server/providers/filesystem_smb/manifest.json
music_assistant/server/providers/frontend/__init__.py [deleted file]
music_assistant/server/providers/frontend/manifest.json [deleted file]
music_assistant/server/providers/lms_cli/__init__.py
music_assistant/server/providers/lms_cli/manifest.json
music_assistant/server/providers/musicbrainz/__init__.py
music_assistant/server/providers/musicbrainz/manifest.json
music_assistant/server/providers/qobuz/__init__.py
music_assistant/server/providers/qobuz/icon.png [new file with mode: 0644]
music_assistant/server/providers/qobuz/manifest.json
music_assistant/server/providers/slimproto/__init__.py
music_assistant/server/providers/slimproto/icon.png [new file with mode: 0644]
music_assistant/server/providers/slimproto/manifest.json
music_assistant/server/providers/sonos/__init__.py
music_assistant/server/providers/sonos/icon.png [new file with mode: 0644]
music_assistant/server/providers/sonos/manifest.json
music_assistant/server/providers/soundcloud/__init__.py
music_assistant/server/providers/soundcloud/icon.png [new file with mode: 0644]
music_assistant/server/providers/soundcloud/manifest.json
music_assistant/server/providers/soundcloud/soundcloudpy/asyncsoundcloudpy.py
music_assistant/server/providers/spotify/__init__.py
music_assistant/server/providers/spotify/icon.png [new file with mode: 0644]
music_assistant/server/providers/spotify/manifest.json
music_assistant/server/providers/theaudiodb/__init__.py
music_assistant/server/providers/theaudiodb/manifest.json
music_assistant/server/providers/tunein/__init__.py
music_assistant/server/providers/tunein/icon.png [new file with mode: 0644]
music_assistant/server/providers/tunein/manifest.json
music_assistant/server/providers/url/__init__.py
music_assistant/server/providers/url/manifest.json
music_assistant/server/providers/websocket_api/__init__.py [new file with mode: 0644]
music_assistant/server/providers/websocket_api/manifest.json [new file with mode: 0644]
music_assistant/server/providers/ytmusic/__init__.py
music_assistant/server/providers/ytmusic/icon.png [new file with mode: 0644]
music_assistant/server/providers/ytmusic/manifest.json
music_assistant/server/server.py
pyproject.toml

index 687a960615faf05ee241e3d4c58d75a7d9040078..10818b43c027a090dbec677e693478c6561da54a 100644 (file)
@@ -1,4 +1,6 @@
 """Run the Music Assistant Server."""
+from __future__ import annotations
+
 import argparse
 import asyncio
 import logging
index a9a5a7cb65445e38b58f99a54974fa83098d258e..6b2fcda29feb7ffc63574f1b03040cdde4bab773 100644 (file)
@@ -65,7 +65,7 @@ class ConfigEntry(DataClassDictMixin):
     default_value: ConfigValueType = None
     required: bool = True
     # options [optional]: select from list of possible values/options
-    options: list[ConfigValueOption] | None = None
+    options: tuple[ConfigValueOption] | None = None
     # range [optional]: select values within range
     range: tuple[int, int] | None = None
     # description [optional]: extended description of the setting.
index 58a166ed2f7a7f6fb68dee325fb3d83b19fe8dec..3cb68e3096999693ef8f53f238bbddfe305c6249 100644 (file)
@@ -8,7 +8,6 @@ from mashumaro.mixins.orjson import DataClassORJSONMixin
 
 from music_assistant.common.helpers.json import load_json_file
 
-from .config_entries import ConfigEntry
 from .enums import MediaType, ProviderFeature, ProviderType
 
 
@@ -23,15 +22,11 @@ class ProviderManifest(DataClassORJSONMixin):
     codeowners: list[str]
 
     # optional params
-    # config_entries: list of config entries required to configure/setup this provider
-    config_entries: list[ConfigEntry] = field(default_factory=list)
+
     # requirements: list of (pip style) python packages required for this provider
     requirements: list[str] = field(default_factory=list)
     # documentation: link/url to documentation.
     documentation: str | None = None
-    # init_class: class to initialize, within provider's package
-    # e.g. `SpotifyProvider`. (autodetect if None)
-    init_class: str | None = None
     # multi_instance: whether multiple instances of the same provider are allowed/possible
     multi_instance: bool = False
     # builtin: whether this provider is a system/builtin and can not disabled/removed
@@ -40,6 +35,11 @@ class ProviderManifest(DataClassORJSONMixin):
     load_by_default: bool = False
     # depends_on: depends on another provider to function
     depends_on: str | None = None
+    # icon: icon url (svg or transparent png) max 256 pixels
+    # may also be a direct base64 encoded image string
+    # if this attribute is omitted and an icon.svg or icon.png is found in the provider
+    # folder, it will be read instead.
+    icon: str | None = None
 
     @classmethod
     async def parse(cls: "ProviderManifest", manifest_file: str) -> "ProviderManifest":
@@ -56,6 +56,7 @@ class ProviderInstance(TypedDict):
     instance_id: str
     supported_features: list[ProviderFeature]
     available: bool
+    icon: str | None
 
 
 @dataclass
index f813b2ed643d5f6f912c3a3c08d6873f2defafc6..82c07a945e145b4ad820e33bca18e27e1e51cb20 100755 (executable)
@@ -24,10 +24,6 @@ SILENCE_FILE: Final[str] = str(RESOURCES_DIR.joinpath("silence.mp3"))
 # if duration is None (e.g. radio stream):Final[str] = 48 hours
 FALLBACK_DURATION: Final[int] = 172800
 
-# Name of the environment-variable to override base_url
-BASE_URL_OVERRIDE_ENVNAME: Final[str] = "MASS_BASE_URL"
-
-
 # config keys
 CONF_SERVER_ID: Final[str] = "server_id"
 CONF_WEB_IP: Final[str] = "webserver.ip"
index 369efc49fbcb64ec0ccacf0a1995557ab3d3e885..c3070a10c23382b36fa637c8aee92b12dc4ad6fd 100644 (file)
@@ -26,6 +26,7 @@ from music_assistant.common.models.enums import EventType, ProviderType
 from music_assistant.common.models.errors import InvalidDataError, PlayerUnavailableError
 from music_assistant.constants import CONF_PLAYERS, CONF_PROVIDERS, CONF_SERVER_ID, ENCRYPT_SUFFIX
 from music_assistant.server.helpers.api import api_command
+from music_assistant.server.helpers.util import get_provider_module
 from music_assistant.server.models.player_provider import PlayerProvider
 
 if TYPE_CHECKING:
@@ -143,16 +144,16 @@ class ConfigController:
         self.save()
 
     @api_command("config/providers")
-    def get_provider_configs(
+    async def get_provider_configs(
         self,
         provider_type: ProviderType | None = None,
         provider_domain: str | None = None,
     ) -> list[ProviderConfig]:
         """Return all known provider configurations, optionally filtered by ProviderType."""
         raw_values: dict[str, dict] = self.get(CONF_PROVIDERS, {})
-        prov_entries = {x.domain: x.config_entries for x in self.mass.get_available_providers()}
+        prov_entries = {x.domain for x in self.mass.get_available_providers()}
         return [
-            self.get_provider_config(prov_conf["instance_id"])
+            await self.get_provider_config(prov_conf["instance_id"])
             for prov_conf in raw_values.values()
             if (provider_type is None or prov_conf["type"] == provider_type)
             and (provider_domain is None or prov_conf["domain"] == provider_domain)
@@ -161,20 +162,22 @@ class ConfigController:
         ]
 
     @api_command("config/providers/get")
-    def get_provider_config(self, instance_id: str) -> ProviderConfig:
+    async def get_provider_config(self, instance_id: str) -> ProviderConfig:
         """Return configuration for a single provider."""
         if raw_conf := self.get(f"{CONF_PROVIDERS}/{instance_id}", {}):
             for prov in self.mass.get_available_providers():
                 if prov.domain != raw_conf["domain"]:
                     continue
-                config_entries = DEFAULT_PROVIDER_CONFIG_ENTRIES + tuple(prov.config_entries)
+                prov_mod = await get_provider_module(prov.domain)
+                prov_config_entries = await prov_mod.get_config_entries(self.mass, prov)
+                config_entries = DEFAULT_PROVIDER_CONFIG_ENTRIES + prov_config_entries
                 return ProviderConfig.parse(config_entries, raw_conf)
         raise KeyError(f"No config found for provider id {instance_id}")
 
     @api_command("config/providers/update")
     async def update_provider_config(self, instance_id: str, update: ConfigUpdate) -> None:
         """Update ProviderConfig."""
-        config = self.get_provider_config(instance_id)
+        config = await self.get_provider_config(instance_id)
         changed_keys = config.update(update)
         available = prov.available if (prov := self.mass.get_provider(instance_id)) else False
         if not changed_keys and (config.enabled == available):
@@ -195,7 +198,7 @@ class ConfigController:
     ) -> ProviderConfig:
         """Add new Provider (instance) Config Flow."""
         if not config:
-            return self._get_default_provider_config(provider_domain)
+            return await self._get_default_provider_config(provider_domain)
         # if provider config is provided, the frontend wants to submit a new provider instance
         # based on the earlier created template config.
         # try to load the provider first to catch errors before we save it.
@@ -221,7 +224,7 @@ class ConfigController:
     @api_command("config/providers/reload")
     async def reload_provider(self, instance_id: str) -> None:
         """Reload provider."""
-        config = self.get_provider_config(instance_id)
+        config = await self.get_provider_config(instance_id)
         await self.mass.load_provider(config)
 
     @api_command("config/players")
@@ -341,22 +344,22 @@ class ConfigController:
             default_conf.to_raw(),
         )
 
-    def create_default_provider_config(self, provider_domain: str) -> None:
+    async def create_default_provider_config(self, provider_domain: str) -> None:
         """
         Create default ProviderConfig.
 
         This is meant as helper to create default configs for default enabled providers.
         Called by the server initialization code which load all providers at startup.
         """
-        for conf in self.get_provider_configs(provider_domain=provider_domain):
+        for conf in await self.get_provider_configs(provider_domain=provider_domain):
             # return if there is already a config
             return
         # config does not yet exist, create a default one
-        default_config = self._get_default_provider_config(provider_domain)
+        default_config = await self._get_default_provider_config(provider_domain)
         conf_key = f"{CONF_PROVIDERS}/{default_config.instance_id}"
         self.set(conf_key, default_config.to_raw())
 
-    def _get_default_provider_config(self, provider_domain: str) -> ProviderConfig:
+    async def _get_default_provider_config(self, provider_domain: str) -> ProviderConfig:
         """
         Return default/empty ProviderConfig.
 
@@ -374,7 +377,7 @@ class ConfigController:
 
         # determine instance id based on previous configs
         existing = {
-            x.instance_id for x in self.get_provider_configs(provider_domain=provider_domain)
+            x.instance_id for x in await self.get_provider_configs(provider_domain=provider_domain)
         }
 
         if existing and not manifest.multi_instance:
@@ -388,8 +391,10 @@ class ConfigController:
             name = f"{manifest.name} {len(existing)+1}"
 
         # all checks passed, return a default config
+        prov_mod = await get_provider_module(provider_domain)
+        config_entries = await prov_mod.get_config_entries(self.mass, manifest)
         return ProviderConfig.parse(
-            DEFAULT_PROVIDER_CONFIG_ENTRIES + tuple(prov.config_entries),
+            DEFAULT_PROVIDER_CONFIG_ENTRIES + config_entries,
             {
                 "type": manifest.type.value,
                 "domain": manifest.domain,
index 019593db1745ae54b01fafe1d3b708f7a8b9f6e0..835a1be4c7e8ef64f09a56732800b86121b51962 100755 (executable)
@@ -49,7 +49,7 @@ class MetaDataController:
 
     async def setup(self) -> None:
         """Async initialize of module."""
-        self.mass.webapp.router.add_get("/imageproxy", self._handle_imageproxy)
+        self.mass.webserver.register_route("/imageproxy", self._handle_imageproxy)
 
     async def close(self) -> None:
         """Handle logic on server stop."""
@@ -279,7 +279,7 @@ class MetaDataController:
                     # return imageproxy url for local filesystem items
                     # the original path is double encoded
                     encoded_url = urllib.parse.quote(urllib.parse.quote(img.url))
-                    return f"{self.mass.base_url}/imageproxy?path={encoded_url}"
+                    return f"{self.mass.webserver.base_url}/imageproxy?path={encoded_url}"
                 return img.url
 
         # retry with track's album
index 152321d22c498012fc5c513a8ab8d3728e5ab23c..445b0c4e112c1768fc6a4123fa8fab1b819bc2e5 100644 (file)
@@ -185,8 +185,8 @@ class StreamsController:
 
     async def setup(self) -> None:
         """Async initialize of module."""
-        self.mass.webapp.router.add_get("/stream/preview", self._serve_preview)
-        self.mass.webapp.router.add_get(
+        self.mass.webserver.register_route("/stream/preview", self._serve_preview)
+        self.mass.webserver.register_route(
             "/stream/{player_id}/{queue_item_id}/{stream_id}.{fmt}",
             self._serve_queue_stream,
         )
@@ -282,14 +282,14 @@ class StreamsController:
 
         # generate player-specific URL for the stream job
         fmt = content_type.value
-        url = f"{self.mass.base_url}/stream/{player_id}/{queue_item.queue_item_id}/{stream_job.stream_id}.{fmt}"  # noqa: E501
+        url = f"{self.mass.webserver.base_url}/stream/{player_id}/{queue_item.queue_item_id}/{stream_job.stream_id}.{fmt}"  # noqa: E501
         return url
 
     def get_preview_url(self, provider_domain_or_instance_id: str, track_id: str) -> str:
         """Return url to short preview sample."""
         enc_track_id = urllib.parse.quote(track_id)
         return (
-            f"{self.mass.base_url}/stream/preview?"
+            f"{self.mass.webserver.base_url}/stream/preview?"
             f"provider={provider_domain_or_instance_id}&item_id={enc_track_id}"
         )
 
diff --git a/music_assistant/server/controllers/webserver.py b/music_assistant/server/controllers/webserver.py
new file mode 100644 (file)
index 0000000..a9412d7
--- /dev/null
@@ -0,0 +1,116 @@
+"""Controller that manages the builtin webserver(s) needed for the music Assistant server."""
+from __future__ import annotations
+
+import logging
+import os
+from collections.abc import Awaitable, Callable
+from functools import partial
+from typing import TYPE_CHECKING
+
+from aiohttp import web
+from music_assistant_frontend import where as locate_frontend
+
+from music_assistant.common.helpers.util import select_free_port
+from music_assistant.constants import ROOT_LOGGER_NAME
+
+if TYPE_CHECKING:
+    from music_assistant.server import MusicAssistant
+
+
+LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.web")
+
+
+class WebserverController:
+    """Controller to stream audio to players."""
+
+    port: int
+    webapp: web.Application
+
+    def __init__(self, mass: MusicAssistant):
+        """Initialize instance."""
+        self.mass = mass
+        self._apprunner: web.AppRunner
+        self._tcp: web.TCPSite
+        self._route_handlers: dict[str, Callable] = {}
+
+    @property
+    def base_url(self) -> str:
+        """Return the (web)server's base url."""
+        return f"http://{self.mass.base_ip}:{self.port}"
+
+    async def setup(self) -> None:
+        """Async initialize of module."""
+        self.webapp = web.Application()
+        self.port = await select_free_port(8095, 9200)
+        LOGGER.info("Starting webserver on port %s", self.port)
+        self._apprunner = web.AppRunner(self.webapp, access_log=None)
+
+        # setup frontend
+        frontend_dir = locate_frontend()
+        for filename in next(os.walk(frontend_dir))[2]:
+            if filename.endswith(".py"):
+                continue
+            filepath = os.path.join(frontend_dir, filename)
+            handler = partial(self.serve_static, filepath)
+            self.webapp.router.add_get(f"/{filename}", handler)
+        # add assets subdir as static
+        self.webapp.router.add_static(
+            "/assets", os.path.join(frontend_dir, "assets"), name="assets"
+        )
+        # add index
+        index_path = os.path.join(frontend_dir, "index.html")
+        handler = partial(self.serve_static, index_path)
+        self.webapp.router.add_get("/", handler)
+        # register catch-all route to handle our custom paths
+        self.webapp.router.add_route("*", "/{tail:.*}", self._handle_catch_all)
+        await self._apprunner.setup()
+        # set host to None to bind to all addresses on both IPv4 and IPv6
+        host = None
+        self._tcp_site = web.TCPSite(self._apprunner, host=host, port=self.port)
+        await self._tcp_site.start()
+
+    async def close(self) -> None:
+        """Cleanup on exit."""
+        # stop/clean webserver
+        await self._tcp_site.stop()
+        await self._apprunner.cleanup()
+        await self.webapp.shutdown()
+        await self.webapp.cleanup()
+
+    def register_route(self, path: str, handler: Awaitable, method: str = "*") -> Callable:
+        """Register a route on the (main) webserver, returns handler to unregister."""
+        key = f"{method}.{path}"
+        if key in self._route_handlers:
+            raise RuntimeError(f"Route {path} already registered.")
+        self._route_handlers[key] = handler
+
+        def _remove():
+            return self._route_handlers.pop(key)
+
+        return _remove
+
+    def unregister_route(self, path: str, method: str = "*") -> None:
+        """Unregister a route from the (main) webserver."""
+        key = f"{method}.{path}"
+        self._route_handlers.pop(key)
+
+    async def serve_static(self, file_path: str, _request: web.Request) -> web.FileResponse:
+        """Serve file response."""
+        headers = {"Cache-Control": "no-cache"}
+        return web.FileResponse(file_path, headers=headers)
+
+    async def _handle_catch_all(self, request: web.Request) -> web.Response:
+        """Redirect request to correct destination."""
+        # find handler for the request
+        for key in (f"{request.method}.{request.path}", f"*.{request.path}"):
+            if handler := self._route_handlers.get(key):
+                return await handler(request)
+        # deny all other requests
+        LOGGER.debug(
+            "Received %s request to %s from %s\nheaders: %s\n",
+            request.method,
+            request.path,
+            request.remote,
+            request.headers,
+        )
+        return web.Response(status=404)
index 8c13604dc22f7f503d03cb0481a5d093d3be8ac3..1533253438b70bae4a444ff60544fdee4694125e 100644 (file)
@@ -1,41 +1,22 @@
-"""Several helpers for the WebSockets API."""
+"""Helpers for dealing with API's to interact with Music Assistant."""
 from __future__ import annotations
 
-import asyncio
 import inspect
 import logging
-import weakref
 from collections.abc import Callable, Coroutine
-from concurrent import futures
-from contextlib import suppress
 from dataclasses import MISSING, dataclass
 from datetime import datetime
 from enum import Enum
 from types import NoneType, UnionType
-from typing import TYPE_CHECKING, Any, Final, TypeVar, Union, get_args, get_origin, get_type_hints
-
-from aiohttp import WSMsgType, web
-
-from music_assistant.common.models.api import (
-    CommandMessage,
-    ErrorResultMessage,
-    MessageType,
-    ServerInfoMessage,
-    SuccessResultMessage,
-)
-from music_assistant.common.models.errors import InvalidCommand
-from music_assistant.common.models.event import MassEvent
-from music_assistant.constants import __version__
+from typing import TYPE_CHECKING, Any, TypeVar, Union, get_args, get_origin, get_type_hints
 
 if TYPE_CHECKING:
-    from music_assistant.server import MusicAssistant
+    pass
+
 
-MAX_PENDING_MSG = 512
-CANCELLATION_ERRORS: Final = (asyncio.CancelledError, futures.CancelledError)
 API_SCHEMA_VERSION = 1
 
 LOGGER = logging.getLogger(__name__)
-DEBUG = False  # Set to True to enable very verbose logging of all incoming/outgoing messages
 
 _F = TypeVar("_F", bound=Callable[..., Any])
 
@@ -95,205 +76,6 @@ def parse_arguments(
     return final_args
 
 
-def mount_websocket_api(mass: MusicAssistant, path: str) -> None:
-    """Mount the websocket endpoint."""
-    clients: weakref.WeakSet[WebsocketClientHandler] = weakref.WeakSet()
-
-    async def _handle_ws(request: web.Request) -> web.WebSocketResponse:
-        connection = WebsocketClientHandler(mass, request)
-        try:
-            clients.add(connection)
-            return await connection.handle_client()
-        finally:
-            clients.remove(connection)
-
-    async def _handle_shutdown(app: web.Application) -> None:  # noqa: ARG001
-        for client in set(clients):
-            await client.disconnect()
-
-    mass.webapp.on_shutdown.append(_handle_shutdown)
-    mass.webapp.router.add_route("GET", path, _handle_ws)
-
-
-class WebSocketLogAdapter(logging.LoggerAdapter):
-    """Add connection id to websocket log messages."""
-
-    def process(self, msg: str, kwargs: Any) -> tuple[str, Any]:
-        """Add connid to websocket log messages."""
-        return f'[{self.extra["connid"]}] {msg}', kwargs
-
-
-class WebsocketClientHandler:
-    """Handle an active websocket client connection."""
-
-    def __init__(self, mass: MusicAssistant, request: web.Request) -> None:
-        """Initialize an active connection."""
-        self.mass = mass
-        self.request = request
-        self.wsock = web.WebSocketResponse(heartbeat=55)
-        self._to_write: asyncio.Queue = asyncio.Queue(maxsize=MAX_PENDING_MSG)
-        self._handle_task: asyncio.Task | None = None
-        self._writer_task: asyncio.Task | None = None
-        self._logger = WebSocketLogAdapter(LOGGER, {"connid": id(self)})
-
-    async def disconnect(self) -> None:
-        """Disconnect client."""
-        self._cancel()
-        if self._writer_task is not None:
-            await self._writer_task
-
-    async def handle_client(self) -> web.WebSocketResponse:
-        """Handle a websocket response."""
-        # ruff: noqa: PLR0915
-        request = self.request
-        wsock = self.wsock
-        try:
-            async with asyncio.timeout(10):
-                await wsock.prepare(request)
-        except asyncio.TimeoutError:
-            self._logger.warning("Timeout preparing request from %s", request.remote)
-            return wsock
-
-        self._logger.debug("Connection from %s", request.remote)
-        self._handle_task = asyncio.current_task()
-        self._writer_task = asyncio.create_task(self._writer())
-
-        # send server(version) info when client connects
-        self._send_message(
-            ServerInfoMessage(server_version=__version__, schema_version=API_SCHEMA_VERSION)
-        )
-
-        # forward all events to clients
-        def handle_event(event: MassEvent) -> None:
-            self._send_message(event)
-
-        unsub_callback = self.mass.subscribe(handle_event)
-
-        disconnect_warn = None
-
-        try:
-            while not wsock.closed:
-                msg = await wsock.receive()
-
-                if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING):
-                    break
-
-                if msg.type != WSMsgType.TEXT:
-                    disconnect_warn = "Received non-Text message."
-                    break
-
-                if DEBUG:
-                    self._logger.debug("Received: %s", msg.data)
-
-                try:
-                    command_msg = CommandMessage.from_json(msg.data)
-                except ValueError:
-                    disconnect_warn = f"Received invalid JSON: {msg.data}"
-                    break
-
-                self._handle_command(command_msg)
-
-        except asyncio.CancelledError:
-            self._logger.debug("Connection closed by client")
-
-        except Exception:  # pylint: disable=broad-except
-            self._logger.exception("Unexpected error inside websocket API")
-
-        finally:
-            # Handle connection shutting down.
-            unsub_callback()
-            self._logger.debug("Unsubscribed from events")
-
-            try:
-                self._to_write.put_nowait(None)
-                # Make sure all error messages are written before closing
-                await self._writer_task
-                await wsock.close()
-            except asyncio.QueueFull:  # can be raised by put_nowait
-                self._writer_task.cancel()
-
-            finally:
-                if disconnect_warn is None:
-                    self._logger.debug("Disconnected")
-                else:
-                    self._logger.warning("Disconnected: %s", disconnect_warn)
-
-        return wsock
-
-    def _handle_command(self, msg: CommandMessage) -> None:
-        """Handle an incoming command from the client."""
-        self._logger.debug("Handling command %s", msg.command)
-
-        # work out handler for the given path/command
-        handler = self.mass.command_handlers.get(msg.command)
-
-        if handler is None:
-            self._send_message(
-                ErrorResultMessage(
-                    msg.message_id,
-                    InvalidCommand.error_code,
-                    f"Invalid command: {msg.command}",
-                )
-            )
-            self._logger.warning("Invalid command: %s", msg.command)
-            return
-
-        # schedule task to handle the command
-        asyncio.create_task(self._run_handler(handler, msg))
-
-    async def _run_handler(self, handler: APICommandHandler, msg: CommandMessage) -> None:
-        try:
-            args = parse_arguments(handler.signature, handler.type_hints, msg.args)
-            result = handler.target(**args)
-            if asyncio.iscoroutine(result):
-                result = await result
-            self._send_message(SuccessResultMessage(msg.message_id, result))
-        except Exception as err:  # pylint: disable=broad-except
-            self._logger.exception("Error handling message: %s", msg)
-            self._send_message(
-                ErrorResultMessage(msg.message_id, getattr(err, "error_code", 999), str(err))
-            )
-
-    async def _writer(self) -> None:
-        """Write outgoing messages."""
-        # Exceptions if Socket disconnected or cancelled by connection handler
-        with suppress(RuntimeError, ConnectionResetError, *CANCELLATION_ERRORS):
-            while not self.wsock.closed:
-                if (process := await self._to_write.get()) is None:
-                    break
-
-                if not isinstance(process, str):
-                    message: str = process()
-                else:
-                    message = process
-                if DEBUG:
-                    self._logger.debug("Writing: %s", message)
-                await self.wsock.send_str(message)
-
-    def _send_message(self, message: MessageType) -> None:
-        """Send a message to the client.
-
-        Closes connection if the client is not reading the messages.
-
-        Async friendly.
-        """
-        _message = message.to_json()
-
-        try:
-            self._to_write.put_nowait(_message)
-        except asyncio.QueueFull:
-            self._logger.error("Client exceeded max pending messages: %s", MAX_PENDING_MSG)
-
-            self._cancel()
-
-    def _cancel(self) -> None:
-        """Cancel the connection."""
-        if self._handle_task is not None:
-            self._handle_task.cancel()
-        if self._writer_task is not None:
-            self._writer_task.cancel()
-
-
 def parse_utc_timestamp(datetime_string: str) -> datetime:
     """Parse datetime from string."""
     return datetime.fromisoformat(datetime_string.replace("Z", "+00:00"))
index 79279551cb7b6676da99e4908e5d494e755e16d7..308bae29b3f2035ca2d1f8b56b3dae4935207b6f 100644 (file)
@@ -3,9 +3,11 @@ from __future__ import annotations
 
 import asyncio
 import random
+from base64 import b64encode
 from io import BytesIO
 from typing import TYPE_CHECKING
 
+import aiofiles
 from PIL import Image
 
 from music_assistant.server.helpers.tags import get_embedded_image
@@ -73,3 +75,15 @@ async def create_collage(mass: MusicAssistant, images: list[str]) -> bytes:
         return final_data.getvalue()
 
     return await asyncio.to_thread(_save_collage)
+
+
+async def get_icon_string(icon_path: str) -> str:
+    """Get icon as (base64 encoded) string."""
+    ext = icon_path.rsplit(".")[-1]
+    assert ext in ("png", "svg", "ico", "jpg")
+    async with aiofiles.open(icon_path, "rb") as _file:
+        img_data = await _file.read()
+    enc_image = b64encode(img_data).decode()
+    if ext == "svg":
+        return f"data:image/svg+xml;base64,{enc_image}"
+    return f"data:image/{ext};base64,{enc_image}"
index 742ada6995acb2df8c2cd85a28dcf9d47556c26a..3a6f3b18bca83480af940d025778a1ee58d2363c 100644 (file)
@@ -1,7 +1,14 @@
 """Various (server-only) tools and helpers."""
+from __future__ import annotations
 
 import asyncio
+import importlib
 import logging
+from functools import lru_cache
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from music_assistant.server.models import ProviderModuleType
 
 LOGGER = logging.getLogger(__name__)
 
@@ -20,3 +27,13 @@ async def install_package(package: str) -> None:
     if proc.returncode != 0:
         msg = f"Failed to install package {package}\n{stderr.decode()}"
         raise RuntimeError(msg)
+
+
+async def get_provider_module(domain: str) -> ProviderModuleType:
+    """Return module for given provider domain."""
+
+    @lru_cache
+    def _get_provider_module(domain: str) -> ProviderModuleType:
+        return importlib.import_module(f".{domain}", "music_assistant.server.providers")
+
+    return await asyncio.to_thread(_get_provider_module, domain)
index 11da8527c97bd3bfe56408afa803801f0d4535af..9f08b64db3e9aaa724673f14a8d23a32b46bd751 100644 (file)
@@ -1 +1,33 @@
 """Server specific/only models."""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Protocol
+
+from .metadata_provider import MetadataProvider
+from .music_provider import MusicProvider
+from .player_provider import PlayerProvider
+from .plugin import PluginProvider
+
+if TYPE_CHECKING:
+    from music_assistant.common.models.config_entries import ConfigEntry, ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+
+
+ProviderInstanceType = MetadataProvider | MusicProvider | PlayerProvider | PluginProvider
+
+
+class ProviderModuleType(Protocol):
+    """Model for a provider module to support type hints."""
+
+    @staticmethod
+    async def setup(
+        mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+    ) -> ProviderInstanceType:
+        """Initialize provider(instance) with given configuration."""
+
+    @staticmethod
+    async def get_config_entries(
+        mass: MusicAssistant, manifest: ProviderManifest
+    ) -> tuple[ConfigEntry, ...]:
+        """Return Config entries to setup this provider."""
index 49063a6357e7252ebcc591fd3f20dd44ce854327..5e777c9164dff4ee0ab11254fce2ae7cc0dc45ca 100644 (file)
@@ -4,7 +4,7 @@ from __future__ import annotations
 import logging
 from typing import TYPE_CHECKING
 
-from music_assistant.common.models.config_entries import ConfigEntryValue, ProviderConfig
+from music_assistant.common.models.config_entries import ProviderConfig
 from music_assistant.common.models.enums import ProviderFeature, ProviderType
 from music_assistant.common.models.provider import ProviderInstance, ProviderManifest
 from music_assistant.constants import CONF_LOG_LEVEL, ROOT_LOGGER_NAME
@@ -37,14 +37,9 @@ class Provider:
         """Return the features supported by this Provider."""
         return tuple()
 
-    async def setup(self) -> None:
-        """Handle async initialization of the provider.
-
-        Called when provider is registered (or its config updated).
+    async def unload(self) -> None:
         """
-
-    async def close(self) -> None:
-        """Handle close/cleanup of the provider.
+        Handle unload/close of the provider.
 
         Called when provider is deregistered (e.g. MA exiting or config reloading).
         """
@@ -75,14 +70,6 @@ class Provider:
             return f"{self.manifest.name}.{postfix}"
         return self.manifest.name
 
-    @property
-    def config_entries(self) -> list[ConfigEntryValue]:
-        """Return list of all ConfigEntries including values for this provider(instance)."""
-        return [
-            ConfigEntryValue.parse(x, self.config.values.get(x.key))
-            for x in self.manifest.config_entries
-        ]
-
     def to_dict(self, *args, **kwargs) -> ProviderInstance:  # noqa: ARG002
         """Return Provider(instance) as serializable dict."""
         return {
index d565f5751a3a5d335b26a41a93c81e31ba7a9158..2416ab2f6a03763ae0de0a72e65b9abc142a7c2c 100644 (file)
@@ -22,7 +22,10 @@ from music_assistant.constants import CONF_PLAYERS
 from music_assistant.server.models.player_provider import PlayerProvider
 
 if TYPE_CHECKING:
-    from music_assistant.common.models.config_entries import PlayerConfig
+    from music_assistant.common.models.config_entries import PlayerConfig, ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
     from music_assistant.server.providers.slimproto import SlimprotoProvider
 
 
@@ -70,6 +73,22 @@ PLAYER_CONFIG_ENTRIES = (
 NEED_BRIDGE_RESTART = {"values/read_ahead", "values/encryption", "values/alac_encode"}
 
 
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = AirplayProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant, manifest: ProviderManifest  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return tuple()  # we do not have any config entries (yet)
+
+
 class AirplayProvider(PlayerProvider):
     """Player provider for Airplay based players, using the slimproto bridge."""
 
@@ -79,7 +98,7 @@ class AirplayProvider(PlayerProvider):
     _closing: bool = False
     _config_file: str | None = None
 
-    async def setup(self) -> None:
+    async def handle_setup(self) -> None:
         """Handle async initialization of the provider."""
         self._config_file = os.path.join(self.mass.storage_path, "airplay_bridge.xml")
         # locate the raopbridge binary (will raise if that fails)
@@ -97,7 +116,7 @@ class AirplayProvider(PlayerProvider):
         # start running the bridge
         asyncio.create_task(self._bridge_process_runner())
 
-    async def close(self) -> None:
+    async def unload(self) -> None:
         """Handle close/cleanup of the provider."""
         self._closing = True
         await self._stop_bridge()
index 7a0d8c54d9f363bad0e71a1ba30a413624080686..1b7bb7ee61bc40cad530ebfc90ef4e34b9f6d263 100644 (file)
@@ -3,13 +3,12 @@
   "domain": "airplay",
   "name": "Airplay",
   "description": "Support for players that support the Airplay protocol.",
-  "codeowners": ["@marcelveldt"],
-  "config_entries": [
-  ],
+  "codeowners": ["@music-assistant"],
   "requirements": [],
   "documentation": "",
   "multi_instance": false,
   "builtin": false,
   "load_by_default": true,
-  "depends_on": "slimproto"
+  "depends_on": "slimproto",
+  "icon": "md:airplay"
 }
index c127b86c47abdc53d7e05a726c8d469366f6cf45..22ec5b13e62180d60854bd5d9389a272a81c11f7 100644 (file)
@@ -41,9 +41,31 @@ if TYPE_CHECKING:
     from pychromecast.controllers.receiver import CastStatus
     from pychromecast.socket_client import ConnectionStatus
 
+    from music_assistant.common.models.config_entries import ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
+
+
 CONF_ALT_APP = "alt_app"
 
 
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = ChromecastProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant, manifest: ProviderManifest  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return tuple()  # we do not have any config entries (yet)
+
+
 @dataclass
 class CastPlayer:
     """Wrapper around Chromecast with some additional attributes."""
@@ -68,7 +90,7 @@ class ChromecastProvider(PlayerProvider):
     castplayers: dict[str, CastPlayer]
     _discover_lock: threading.Lock
 
-    async def setup(self) -> None:
+    async def handle_setup(self) -> None:
         """Handle async initialization of the provider."""
         self._discover_lock = threading.Lock()
         self.castplayers = {}
@@ -87,7 +109,7 @@ class ChromecastProvider(PlayerProvider):
         # start discovery in executor
         await self.mass.loop.run_in_executor(None, self.browser.start_discovery)
 
-    async def close(self) -> None:
+    async def unload(self) -> None:
         """Handle close/cleanup of the provider."""
         if not self.browser:
             return
index 680f164e90818177f38c8cc171cef0673caf523c..bc7cc2587a30476e800c5876f6f0614ec1c92c55 100644 (file)
@@ -3,12 +3,11 @@
   "domain": "chromecast",
   "name": "Chromecast",
   "description": "Support for Chromecast based players.",
-  "codeowners": ["@marcelveldt"],
-  "config_entries": [
-  ],
+  "codeowners": ["@music-assistant"],
   "requirements": ["PyChromecast==13.0.5"],
   "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/1138",
   "multi_instance": false,
   "builtin": false,
-  "load_by_default": true
+  "load_by_default": true,
+  "icon": "md:cast"
 }
index 60500a310f6e08863c56a26982ea0529e86ab2aa..3990506c229f606f8aa36e836a74b78bdecfa102 100644 (file)
@@ -23,6 +23,7 @@ from async_upnp_client.profiles.dlna import DmrDevice, TransportState
 from async_upnp_client.search import async_search
 from async_upnp_client.utils import CaseInsensitiveDict
 
+from music_assistant.common.models.config_entries import ConfigEntry
 from music_assistant.common.models.enums import ContentType, PlayerFeature, PlayerState, PlayerType
 from music_assistant.common.models.errors import PlayerUnavailableError, QueueEmpty
 from music_assistant.common.models.player import DeviceInfo, Player
@@ -34,7 +35,10 @@ from music_assistant.server.models.player_provider import PlayerProvider
 from .helpers import DLNANotifyServer
 
 if TYPE_CHECKING:
-    from music_assistant.common.models.config_entries import PlayerConfig
+    from music_assistant.common.models.config_entries import PlayerConfig, ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
 
 PLAYER_FEATURES = (
     PlayerFeature.SET_MEMBERS,
@@ -49,6 +53,22 @@ _R = TypeVar("_R")
 _P = ParamSpec("_P")
 
 
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = DLNAPlayerProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant, manifest: ProviderManifest  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return tuple()  # we do not have any config entries (yet)
+
+
 def catch_request_errors(
     func: Callable[Concatenate[_DLNAPlayerProviderT, _P], Awaitable[_R]]
 ) -> Callable[Concatenate[_DLNAPlayerProviderT, _P], Coroutine[Any, Any, _R | None]]:
@@ -176,7 +196,7 @@ class DLNAPlayerProvider(PlayerProvider):
     upnp_factory: UpnpFactory
     notify_server: DLNANotifyServer
 
-    async def setup(self) -> None:
+    async def handle_setup(self) -> None:
         """Handle async initialization of the provider."""
         self.dlnaplayers = {}
         self.lock = asyncio.Lock()
@@ -189,6 +209,17 @@ class DLNAPlayerProvider(PlayerProvider):
         self.notify_server = DLNANotifyServer(self.requester, self.mass)
         self.mass.create_task(self._run_discovery())
 
+    async def unload(self) -> None:
+        """
+        Handle unload/close of the provider.
+
+        Called when provider is deregistered (e.g. MA exiting or config reloading).
+        """
+        self.mass.webserver.unregister_route("/notify", "NOTIFY")
+        async with asyncio.TaskGroup() as tg:
+            for dlna_player in self.dlnaplayers.values():
+                tg.create_task(self._device_disconnect(dlna_player))
+
     def on_player_config_changed(
         self, config: PlayerConfig, changed_keys: set[str]  # noqa: ARG002
     ) -> None:
@@ -373,8 +404,6 @@ class DLNAPlayerProvider(PlayerProvider):
             dlna_player.device = None
             await old_device.async_unsubscribe_services()
 
-        await self._async_release_event_notifier(dlna_player.event_addr)
-
     async def _device_discovered(self, udn: str, description_url: str) -> None:
         """Handle discovered DLNA player."""
         async with self.lock:
index cae0f31b259cb5389f67edf8c414f316486008ec..bc3b9114f079c2800aae6a03a1c289836178f1eb 100644 (file)
@@ -23,7 +23,7 @@ class DLNANotifyServer(UpnpNotifyServer):
         """Initialize."""
         self.mass = mass
         self.event_handler = UpnpEventHandler(self, requester)
-        self.mass.webapp.router.add_route("NOTIFY", "/notify", self._handle_request)
+        self.mass.webserver.register_route("/notify", self._handle_request, method="NOTIFY")
 
     async def _handle_request(self, request: Request) -> Response:
         """Handle incoming requests."""
@@ -40,4 +40,4 @@ class DLNANotifyServer(UpnpNotifyServer):
     @property
     def callback_url(self) -> str:
         """Return callback URL on which we are callable."""
-        return f"{self.mass.base_url}/notify"
+        return f"{self.mass.webserver.base_url}/notify"
diff --git a/music_assistant/server/providers/dlna/icon.png b/music_assistant/server/providers/dlna/icon.png
new file mode 100644 (file)
index 0000000..14c34a4
Binary files /dev/null and b/music_assistant/server/providers/dlna/icon.png differ
index 43abc53a1508d49baaedcc5241098a9b7125ac9e..bf4c4cc5f7b4b02226715886896b3306f04d89a0 100644 (file)
@@ -4,8 +4,6 @@
   "name": "UPnP/DLNA Player provider",
   "description": "Support for players that are compatible with the UPnP/DLNA (DMR) standard.",
   "codeowners": ["@music-assistant"],
-  "config_entries": [
-  ],
   "requirements": ["async-upnp-client==0.33.1"],
   "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/1139",
   "multi_instance": false,
index cf499a2244e2baae0cd04726dd748e99d9ce4ee3..62999fda6f151eef90ec044b16bf6fa329af46a6 100644 (file)
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
 import aiohttp.client_exceptions
 from asyncio_throttle import Throttler
 
+from music_assistant.common.models.config_entries import ConfigEntry
 from music_assistant.common.models.enums import ProviderFeature
 from music_assistant.common.models.media_items import ImageType, MediaItemImage, MediaItemMetadata
 from music_assistant.server.controllers.cache import use_cache
@@ -14,7 +15,11 @@ from music_assistant.server.helpers.app_vars import app_var  # pylint: disable=n
 from music_assistant.server.models.metadata_provider import MetadataProvider
 
 if TYPE_CHECKING:
+    from music_assistant.common.models.config_entries import ProviderConfig
     from music_assistant.common.models.media_items import Album, Artist
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
 
 SUPPORTED_FEATURES = (
     ProviderFeature.ARTIST_METADATA,
@@ -32,12 +37,28 @@ IMG_MAPPING = {
 }
 
 
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = FanartTvMetadataProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant, manifest: ProviderManifest  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return tuple()  # we do not have any config entries (yet)
+
+
 class FanartTvMetadataProvider(MetadataProvider):
     """Fanart.tv Metadata provider."""
 
     throttler: Throttler
 
-    async def setup(self) -> None:
+    async def handle_setup(self) -> None:
         """Handle async initialization of the provider."""
         self.cache = self.mass.cache
         self.throttler = Throttler(rate_limit=2, period=1)
index fb38df5433c0e4f6f3b1ba1fca5689ce75e7a63a..092c12278758ad4659133ba6c6908feac8291c6e 100644 (file)
@@ -4,11 +4,10 @@
   "name": "fanart.tv Metadata provider",
   "description": "fanart.tv is a community database of artwork for movies, tv series and music.",
   "codeowners": ["@music-assistant"],
-  "config_entries": [
-  ],
   "requirements": [],
   "documentation": "",
   "multi_instance": false,
   "builtin": true,
-  "load_by_default": true
+  "load_by_default": true,
+  "icon": "mdi-folder-information"
 }
index 1812b73233109e6c2d12ea9e3a149392fc8d4b26..63da998d0228ac909a1b9bb5122e5b2c32948296 100644 (file)
@@ -5,22 +5,51 @@ import asyncio
 import os
 import os.path
 from collections.abc import AsyncGenerator
+from typing import TYPE_CHECKING
 
 import aiofiles
 from aiofiles.os import wrap
 
+from music_assistant.common.models.config_entries import ConfigEntry
+from music_assistant.common.models.enums import ConfigEntryType
 from music_assistant.common.models.errors import SetupFailedError
 from music_assistant.constants import CONF_PATH
 
-from .base import FileSystemItem, FileSystemProviderBase
+from .base import CONF_ENTRY_MISSING_ALBUM_ARTIST, FileSystemItem, FileSystemProviderBase
 from .helpers import get_absolute_path, get_relative_path
 
+if TYPE_CHECKING:
+    from music_assistant.common.models.config_entries import ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
+
+
 listdir = wrap(os.listdir)
 isdir = wrap(os.path.isdir)
 isfile = wrap(os.path.isfile)
 exists = wrap(os.path.exists)
 
 
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = LocalFileSystemProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant, manifest: ProviderManifest  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return (
+        ConfigEntry(key="path", type=ConfigEntryType.STRING, label="Path", default_value="/media"),
+        CONF_ENTRY_MISSING_ALBUM_ARTIST,
+    )
+
+
 async def create_item(base_path: str, entry: os.DirEntry) -> FileSystemItem:
     """Create FileSystemItem from os.DirEntry."""
 
@@ -46,7 +75,7 @@ async def create_item(base_path: str, entry: os.DirEntry) -> FileSystemItem:
 class LocalFileSystemProvider(FileSystemProviderBase):
     """Implementation of a musicprovider for local files."""
 
-    async def setup(self) -> None:
+    async def handle_setup(self) -> None:
         """Handle async initialization of the provider."""
         conf_path = self.config.get_value(CONF_PATH)
         if not await isdir(conf_path):
index b1eceda5d41068a140e9dd5fb851bd2681995da0..f4724d6766709a8332660cf8910822d72df0877a 100644 (file)
@@ -12,6 +12,11 @@ from time import time
 import xmltodict
 
 from music_assistant.common.helpers.util import parse_title_and_version
+from music_assistant.common.models.config_entries import (
+    ConfigEntry,
+    ConfigEntryType,
+    ConfigValueOption,
+)
 from music_assistant.common.models.enums import ProviderFeature
 from music_assistant.common.models.errors import (
     InvalidDataError,
@@ -44,6 +49,23 @@ from .helpers import get_parentdir
 
 CONF_MISSING_ALBUM_ARTIST_ACTION = "missing_album_artist_action"
 
+CONF_ENTRY_MISSING_ALBUM_ARTIST = ConfigEntry(
+    key=CONF_MISSING_ALBUM_ARTIST_ACTION,
+    type=ConfigEntryType.STRING,
+    label="Action when a track is missing the Albumartist ID3 tag",
+    default_value="skip",
+    description="Music Assistant prefers information stored in ID3 tags and only uses"
+    " online sources for additional metadata. This means that the ID3 tags need to be "
+    "accurate, preferably tagged with MusicBrainz Picard.",
+    advanced=True,
+    required=False,
+    options=(
+        ConfigValueOption("Skip track and log warning", "skip"),
+        ConfigValueOption("Use Track artist(s)", "track_artist"),
+        ConfigValueOption("Use Various Artists", "various_artists"),
+    ),
+)
+
 TRACK_EXTENSIONS = ("mp3", "m4a", "mp4", "flac", "wav", "ogg", "aiff", "wma", "dsf")
 PLAYLIST_EXTENSIONS = ("m3u", "pls")
 SUPPORTED_EXTENSIONS = TRACK_EXTENSIONS + PLAYLIST_EXTENSIONS
@@ -109,7 +131,7 @@ class FileSystemProviderBase(MusicProvider):
         return SUPPORTED_FEATURES
 
     @abstractmethod
-    async def setup(self) -> None:
+    async def handle_setup(self) -> None:
         """Handle async initialization of the provider."""
 
     @abstractmethod
index 7290b5d8b94e582beab3aca765a8ef4d715ffd44..e0d1cce56bd479cb55d1e0d833a40f184776157e 100644 (file)
@@ -3,32 +3,9 @@
   "domain": "filesystem_local",
   "name": "Local Filesystem",
   "description": "Support for music files that are present on a local accessible disk/folder.",
-  "codeowners": ["@marcelveldt"],
-  "config_entries": [
-    {
-      "key": "path",
-      "type": "string",
-      "label": "Path",
-      "default_value": "/media"
-    },
-    {
-      "key": "missing_album_artist_action",
-      "type": "string",
-      "label": "Action when a track is missing the Albumartist ID3 tag",
-      "default_value": "skip",
-      "description": "Music Assistant prefers information stored in ID3 tags and only uses online sources for additional metadata. This means that the ID3 tags need to be accurate, preferably tagged with MusicBrainz Picard.",
-      "advanced": true,
-      "required": false,
-      "options": [
-        { "title": "Skip track and log warning", "value": "skip" },
-        { "title": "Use Track artist(s)", "value": "track_artist" },
-        { "title": "Use Various Artists", "value": "various_artists" }
-      ]
-    }
-  ],
-
+  "codeowners": ["@music-assistant"],
   "requirements": [],
   "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/820",
   "multi_instance": true,
-  "init_class": "LocalFileSystemProvider"
+  "icon": "mdi:mdi-harddisk"
 }
index aa77683b3514ffb30aa7a57f52dabae7a99ea1e8..d40a15d8fd501bb6aa1f2ea0a3bd425eeb34dfc3 100644 (file)
@@ -1,17 +1,22 @@
 """SMB filesystem provider for Music Assistant."""
+from __future__ import annotations
 
 import logging
 import os
 from collections.abc import AsyncGenerator
 from contextlib import asynccontextmanager
+from typing import TYPE_CHECKING
 
 from smb.base import SharedFile
 
 from music_assistant.common.helpers.util import get_ip_from_host
+from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueOption
+from music_assistant.common.models.enums import ConfigEntryType
 from music_assistant.common.models.errors import LoginFailed
 from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME
 from music_assistant.server.controllers.cache import use_cache
 from music_assistant.server.providers.filesystem_local.base import (
+    CONF_ENTRY_MISSING_ALBUM_ARTIST,
     FileSystemItem,
     FileSystemProviderBase,
 )
@@ -22,11 +27,135 @@ from music_assistant.server.providers.filesystem_local.helpers import (
 
 from .helpers import AsyncSMB
 
+if TYPE_CHECKING:
+    from music_assistant.common.models.config_entries import ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
+
 CONF_HOST = "host"
 CONF_SHARE = "share"
 CONF_SUBFOLDER = "subfolder"
 
 
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = SMBFileSystemProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant, manifest: ProviderManifest  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return (
+        ConfigEntry(
+            key="host",
+            type=ConfigEntryType.STRING,
+            label="Remote host",
+            required=True,
+            description="The (fqdn) hostname of the SMB/CIFS server to connect to."
+            "For example mynas.local.",
+        ),
+        ConfigEntry(
+            key="share",
+            type=ConfigEntryType.STRING,
+            label="Share",
+            required=True,
+            description="The name of the share/service you'd like to connect to on "
+            "the remote host, For example 'media'.",
+        ),
+        ConfigEntry(
+            key="username",
+            type=ConfigEntryType.STRING,
+            label="Username",
+            required=True,
+            default_value="guest",
+            description="The username to authenticate to the remote server. "
+            "For anynymous access you may want to try with the user `guest`.",
+        ),
+        ConfigEntry(
+            key="password",
+            type=ConfigEntryType.SECURE_STRING,
+            label="Username",
+            required=True,
+            default_value="guest",
+            description="The username to authenticate to the remote server. "
+            "For anynymous access you may want to try with the user `guest`.",
+        ),
+        ConfigEntry(
+            key="subfolder",
+            type=ConfigEntryType.STRING,
+            label="Subfolder",
+            required=False,
+            default_value="",
+            description="[optional] Use if your music is stored in a sublevel of the share. "
+            "E.g. 'collections' or 'albums/A-K'.",
+        ),
+        ConfigEntry(
+            key="domain",
+            type=ConfigEntryType.STRING,
+            label="Domain",
+            required=False,
+            advanced=True,
+            default_value="",
+            description="The network domain. On windows, it is known as the workgroup. "
+            "Usually, it is safe to leave this parameter as an empty string.",
+        ),
+        ConfigEntry(
+            key="use_ntlm_v2",
+            type=ConfigEntryType.BOOLEAN,
+            label="Use NTLM v2",
+            required=False,
+            advanced=True,
+            default_value="",
+            description="Indicates whether NTLMv1 or NTLMv2 authentication algorithm should "
+            "be used for authentication. The choice of NTLMv1 and NTLMv2 is configured on "
+            "the remote server, and there is no mechanism to auto-detect which algorithm has "
+            "been configured. Hence, we can only “guess” or try both algorithms. On Sambda, "
+            "Windows Vista and Windows 7, NTLMv2 is enabled by default. "
+            "On Windows XP, we can use NTLMv1 before NTLMv2.",
+        ),
+        ConfigEntry(
+            key="sign_options",
+            type=ConfigEntryType.INTEGER,
+            label="Sign Options",
+            required=False,
+            advanced=True,
+            default_value=2,
+            options=(
+                ConfigValueOption("SIGN_NEVER", 0),
+                ConfigValueOption("SIGN_WHEN_SUPPORTED", 1),
+                ConfigValueOption("SIGN_WHEN_REQUIRED", 2),
+            ),
+            description="Determines whether SMB messages will be signed. "
+            "Default is SIGN_WHEN_REQUIRED. If SIGN_WHEN_REQUIRED (value=2), "
+            "SMB messages will only be signed when remote server requires signing. "
+            "If SIGN_WHEN_SUPPORTED (value=1), SMB messages will be signed when "
+            "remote server supports signing but not requires signing. "
+            "If SIGN_NEVER (value=0), SMB messages will never be signed regardless "
+            "of remote server’s configurations; access errors will occur if the "
+            "remote server requires signing.",
+        ),
+        ConfigEntry(
+            key="is_direct_tcp",
+            type=ConfigEntryType.BOOLEAN,
+            label="Use Direct TCP",
+            required=False,
+            advanced=True,
+            default_value=False,
+            description="Controls whether the NetBIOS over TCP/IP (is_direct_tcp=False) "
+            "or the newer Direct hosting of SMB over TCP/IP (is_direct_tcp=True) will "
+            "be used for the communication. The default parameter is False which will "
+            "use NetBIOS over TCP/IP for wider compatibility (TCP port: 139).",
+        ),
+        CONF_ENTRY_MISSING_ALBUM_ARTIST,
+    )
+
+
 async def create_item(file_path: str, entry: SharedFile, root_path: str) -> FileSystemItem:
     """Create FileSystemItem from smb.SharedFile."""
     rel_path = get_relative_path(root_path, file_path)
@@ -50,7 +179,7 @@ class SMBFileSystemProvider(FileSystemProviderBase):
     _remote_name = ""
     _target_ip = ""
 
-    async def setup(self) -> None:
+    async def handle_setup(self) -> None:
         """Handle async initialization of the provider."""
         # silence SMB.SMBConnection logger a bit
         logging.getLogger("SMB.SMBConnection").setLevel("WARNING")
index 15d366b7387dbb486c036e5527a7861cdef1469d..cbba85f5f9c277a73ec024f4111633b2bb86b083 100644 (file)
@@ -3,101 +3,9 @@
   "domain": "filesystem_smb",
   "name": "SMB Filesystem",
   "description": "Support for music files that are present on remote SMB/CIFS share.",
-  "codeowners": ["@MarvinSchenkel", "@marcelveldt"],
-  "config_entries": [
-    {
-      "key": "host",
-      "type": "string",
-      "label": "Remote host",
-      "description": "The hostname of the SMB/CIFS server to connect to. For example mynas.local. You may need to use the IP address instead of DNS name.",
-      "required": true
-    },
-    {
-      "key": "share",
-      "type": "string",
-      "label": "Share",
-      "description": "The name of the share/service you'd like to connect to on the remote host, For example 'media'.",
-      "required": true
-    },
-    {
-      "key": "subfolder",
-      "type": "string",
-      "label": "Subfolder",
-      "description": "[optional] Use if your music is stored in a sublevel of the share. E.g. 'music' or 'music/collection'.",
-      "default_value": "",
-      "required": false
-    },
-    {
-      "key": "username",
-      "type": "string",
-      "label": "Username",
-      "default_value": "anonymous",
-      "required": true
-    },
-    {
-      "key": "password",
-      "type": "secure_string",
-      "label": "Password"
-    },
-    {
-      "key": "domain",
-      "type": "string",
-      "label": "Domain",
-      "default_value": "",
-      "description": "The network domain. On windows, it is known as the workgroup. Usually, it is safe to leave this parameter as an empty string.",
-      "advanced": true,
-      "required": false
-    },
-    {
-      "key": "use_ntlm_v2",
-      "type": "boolean",
-      "label": "Use NTLM v2",
-      "default_value": true,
-      "description": "Indicates whether NTLMv1 or NTLMv2 authentication algorithm should be used for authentication. The choice of NTLMv1 and NTLMv2 is configured on the remote server, and there is no mechanism to auto-detect which algorithm has been configured. Hence, we can only “guess” or try both algorithms. On Sambda, Windows Vista and Windows 7, NTLMv2 is enabled by default. On Windows XP, we can use NTLMv1 before NTLMv2.",
-      "advanced": true,
-      "required": false
-    },
-    {
-      "key": "sign_options",
-      "type": "integer",
-      "label": "Sign Options",
-      "default_value": 2,
-      "description": "Determines whether SMB messages will be signed. Default is SIGN_WHEN_REQUIRED. If SIGN_WHEN_REQUIRED (value=2), SMB messages will only be signed when remote server requires signing. If SIGN_WHEN_SUPPORTED (value=1), SMB messages will be signed when remote server supports signing but not requires signing. If SIGN_NEVER (value=0), SMB messages will never be signed regardless of remote server’s configurations; access errors will occur if the remote server requires signing.",
-      "advanced": true,
-      "required": false,
-      "options": [
-        { "title": "SIGN_NEVER", "value": 0 },
-        { "title": "SIGN_WHEN_SUPPORTED", "value": 1 },
-        { "title": "SIGN_WHEN_REQUIRED", "value": 2 }
-      ]
-    },
-    {
-      "key": "is_direct_tcp",
-      "type": "boolean",
-      "label": "Use Direct TCP",
-      "default_value": false,
-      "description": "Controls whether the NetBIOS over TCP/IP (is_direct_tcp=False) or the newer Direct hosting of SMB over TCP/IP (is_direct_tcp=True) will be used for the communication. The default parameter is False which will use NetBIOS over TCP/IP for wider compatibility (TCP port: 139).",
-      "advanced": true,
-      "required": false
-    },
-    {
-      "key": "missing_album_artist_action",
-      "type": "string",
-      "label": "Action when a track is missing the Albumartist ID3 tag",
-      "default_value": "skip",
-      "description": "Music Assistant prefers information stored in ID3 tags and only uses online sources for additional metadata. This means that the ID3 tags need to be accurate, preferably tagged with MusicBrainz Picard.",
-      "advanced": true,
-      "required": false,
-      "options": [
-        { "title": "Skip track and log warning", "value": "skip" },
-        { "title": "Use Track artist(s)", "value": "track_artist" },
-        { "title": "Use Various Artists", "value": "various_artists" }
-      ]
-    }
-  ],
-
+  "codeowners": ["@MarvinSchenkel"],
   "requirements": ["pysmb==1.2.9.1"],
   "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/820",
   "multi_instance": true,
-  "init_class": "SMBFileSystemProvider"
+  "icon": "mdi:mdi-network"
 }
diff --git a/music_assistant/server/providers/frontend/__init__.py b/music_assistant/server/providers/frontend/__init__.py
deleted file mode 100644 (file)
index 3269e11..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-"""The default Music Assistant (web) frontend, hosted within the server."""
-from __future__ import annotations
-
-import os
-from functools import partial
-
-from aiohttp import web
-from music_assistant_frontend import where
-
-from music_assistant.server.models.plugin import PluginProvider
-
-
-class Frontend(PluginProvider):
-    """The default Music Assistant (web) frontend, hosted within the server."""
-
-    async def setup(self) -> None:
-        """Handle async initialization of the plugin."""
-        frontend_dir = where()
-        for filename in next(os.walk(frontend_dir))[2]:
-            if filename.endswith(".py"):
-                continue
-            filepath = os.path.join(frontend_dir, filename)
-            handler = partial(self.serve_static, filepath)
-            self.mass.webapp.router.add_get(f"/{filename}", handler)
-
-        # add assets subdir as static
-        self.mass.webapp.router.add_static(
-            "/assets", os.path.join(frontend_dir, "assets"), name="assets"
-        )
-
-        # add index
-        handler = partial(self.serve_static, os.path.join(frontend_dir, "index.html"))
-        self.mass.webapp.router.add_get("/", handler)
-
-    async def serve_static(self, file_path: str, _request: web.Request) -> web.FileResponse:
-        """Serve file response."""
-        headers = {"Cache-Control": "no-cache"}
-        return web.FileResponse(file_path, headers=headers)
diff --git a/music_assistant/server/providers/frontend/manifest.json b/music_assistant/server/providers/frontend/manifest.json
deleted file mode 100644 (file)
index 481a9aa..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-{
-  "type": "plugin",
-  "domain": "frontend",
-  "name": "Frontend",
-  "description": "The default Music Assistant (web) frontend, written in Vue, hosted within the Music Assistant server.",
-  "codeowners": ["@marcelveldt"],
-  "config_entries": [
-  ],
-
-  "requirements": ["music-assistant-frontend==20230319.1"],
-  "documentation": "",
-  "multi_instance": false,
-  "builtin": true,
-  "load_by_default": true
-}
index c09d1e75833ecbb461b7f2c6c884503fd439a4f9..7b0a2b34418ad2519ecb86dd4e2a9f97e83b0f84 100644 (file)
@@ -3,12 +3,13 @@ from __future__ import annotations
 
 import asyncio
 import urllib.parse
-from typing import Any
+from typing import TYPE_CHECKING, Any
 
 from aiohttp import web
 
 from music_assistant.common.helpers.json import json_dumps, json_loads
 from music_assistant.common.helpers.util import select_free_port
+from music_assistant.common.models.config_entries import ConfigEntry
 from music_assistant.common.models.enums import PlayerState
 from music_assistant.server.models.plugin import PluginProvider
 
@@ -23,12 +24,35 @@ from .models import (
     player_status_from_mass,
 )
 
+if TYPE_CHECKING:
+    from music_assistant.common.models.config_entries import ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
+
+
 # ruff: noqa: ARG002, E501
 
 ArgsType = list[int | str]
 KwargsType = dict[str, Any]
 
 
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = LmsCli(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant, manifest: ProviderManifest  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return tuple()  # we do not have any config entries (yet)
+
+
 def parse_value(raw_value: int | str) -> int | str | tuple[str, int | str]:
     """
     Transform API param into a usable value.
@@ -64,15 +88,23 @@ class LmsCli(PluginProvider):
 
     cli_port: int = 9090
 
-    async def setup(self) -> None:
+    async def handle_setup(self) -> None:
         """Handle async initialization of the plugin."""
         self.logger.info("Registering jsonrpc endpoints on the webserver")
-        self.mass.webapp.router.add_get("/jsonrpc.js", self._handle_jsonrpc)
-        self.mass.webapp.router.add_post("/jsonrpc.js", self._handle_jsonrpc)
+        self.mass.webserver.register_route("/jsonrpc.js", self._handle_jsonrpc)
+        self.mass.webserver.register_route("/cometd", self._handle_cometd)
         # setup (telnet) cli for players requesting basic info on that port
         self.cli_port = await select_free_port(9090, 9190)
         self.logger.info("Starting (telnet) CLI on port %s", self.cli_port)
-        await asyncio.start_server(self._handle_cli_client, "0.0.0.0", self.cli_port),
+        await asyncio.start_server(self._handle_cli_client, "0.0.0.0", self.cli_port)
+
+    async def unload(self) -> None:
+        """
+        Handle unload/close of the provider.
+
+        Called when provider is deregistered (e.g. MA exiting or config reloading).
+        """
+        self.mass.webserver.unregister_route("/jsonrpc.js")
 
     async def _handle_cli_client(
         self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
@@ -181,6 +213,10 @@ class LmsCli(PluginProvider):
             # return the response to the client
             return web.json_response(result, dumps=json_dumps)
 
+    async def _handle_cometd(self, request: web.Request) -> web.Response:
+        """Handle request for image proxy."""
+        return web.Response(status=404)
+
     def _handle_players(
         self,
         player_id: str,
index dee5fec2cb342ab8f1057c6360512626cbdcc833..a6cde0043d7e7190292ba53d43a2efca763ad9e2 100644 (file)
@@ -3,12 +3,11 @@
   "domain": "lms_cli",
   "name": "LMS CLI",
   "description": "Basic CLI implementation (classic + JSON-RPC), which is (partly) compatible with Logitech Media Server to maximize compatibility with Squeezebox players.",
-  "codeowners": ["@marcelveldt"],
-  "config_entries": [
-  ],
+  "codeowners": ["@music-assistant"],
   "requirements": [],
   "documentation": "",
   "multi_instance": false,
   "builtin": true,
-  "load_by_default": true
+  "load_by_default": true,
+  "icon": "md:api"
 }
index 59231976636f3deca5e8baf52cfb8a4df42bc1d1..4a57bdf92836f50a6fd397289aca552b6e7d33e5 100644 (file)
@@ -13,13 +13,18 @@ import aiohttp.client_exceptions
 from asyncio_throttle import Throttler
 
 from music_assistant.common.helpers.util import create_sort_name
+from music_assistant.common.models.config_entries import ConfigEntry
 from music_assistant.common.models.enums import ProviderFeature
 from music_assistant.server.controllers.cache import use_cache
 from music_assistant.server.helpers.compare import compare_strings
 from music_assistant.server.models.metadata_provider import MetadataProvider
 
 if TYPE_CHECKING:
+    from music_assistant.common.models.config_entries import ProviderConfig
     from music_assistant.common.models.media_items import Album, Artist, Track
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
 
 
 LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])'
@@ -27,12 +32,28 @@ LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])'
 SUPPORTED_FEATURES = (ProviderFeature.GET_ARTIST_MBID,)
 
 
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = MusicbrainzProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant, manifest: ProviderManifest  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return tuple()  # we do not have any config entries (yet)
+
+
 class MusicbrainzProvider(MetadataProvider):
     """The Musicbrainz Metadata provider."""
 
     throttler: Throttler
 
-    async def setup(self) -> None:
+    async def handle_setup(self) -> None:
         """Handle async initialization of the provider."""
         self.cache = self.mass.cache
         self.throttler = Throttler(rate_limit=1, period=1)
index de08def8da8a3e237e41e4283388b6c69bc226f2..28dd9cd1db0787627631d2819d3e2fec3170ba89 100644 (file)
@@ -4,11 +4,10 @@
   "name": "MusicBrainz Metadata provider",
   "description": "MusicBrainz is an open music encyclopedia that collects music metadata and makes it available to the public.",
   "codeowners": ["@music-assistant"],
-  "config_entries": [
-  ],
   "requirements": [],
   "documentation": "",
   "multi_instance": false,
   "builtin": true,
-  "load_by_default": true
+  "load_by_default": true,
+  "icon": "mdi-folder-information"
 }
index f56c0d5cb91136afb7620be057dabe44e95d595f..678b1be56e8f67cfe2381a50b258a66e14cb8871 100644 (file)
@@ -6,12 +6,14 @@ import hashlib
 import time
 from collections.abc import AsyncGenerator
 from json import JSONDecodeError
+from typing import TYPE_CHECKING
 
 import aiohttp
 from asyncio_throttle import Throttler
 
 from music_assistant.common.helpers.util import parse_title_and_version, try_parse_int
-from music_assistant.common.models.enums import ProviderFeature
+from music_assistant.common.models.config_entries import ConfigEntry
+from music_assistant.common.models.enums import ConfigEntryType, ProviderFeature
 from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError
 from music_assistant.common.models.media_items import (
     Album,
@@ -31,6 +33,13 @@ from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME
 from music_assistant.server.helpers.app_vars import app_var  # pylint: disable=no-name-in-module
 from music_assistant.server.models.music_provider import MusicProvider
 
+if TYPE_CHECKING:
+    from music_assistant.common.models.config_entries import ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
+
+
 SUPPORTED_FEATURES = (
     ProviderFeature.LIBRARY_ARTISTS,
     ProviderFeature.LIBRARY_ALBUMS,
@@ -48,13 +57,36 @@ SUPPORTED_FEATURES = (
 )
 
 
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = QobuzProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant, manifest: ProviderManifest  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return (
+        ConfigEntry(
+            key=CONF_USERNAME, type=ConfigEntryType.STRING, label="Username", required=True
+        ),
+        ConfigEntry(
+            key=CONF_PASSWORD, type=ConfigEntryType.SECURE_STRING, label="Password", required=True
+        ),
+    )
+
+
 class QobuzProvider(MusicProvider):
     """Provider for the Qobux music service."""
 
     _user_auth_info: str | None = None
     _throttler: Throttler
 
-    async def setup(self) -> None:
+    async def handle_setup(self) -> None:
         """Handle async initialization of the provider."""
         self._throttler = Throttler(rate_limit=4, period=1)
 
diff --git a/music_assistant/server/providers/qobuz/icon.png b/music_assistant/server/providers/qobuz/icon.png
new file mode 100644 (file)
index 0000000..9d7b726
Binary files /dev/null and b/music_assistant/server/providers/qobuz/icon.png differ
index f63c1baaf01a1374f6d1aede7f7dd9cc6e86567a..fe0e9898254caec38ca46e1e3f1492743a42e18d 100644 (file)
@@ -3,20 +3,7 @@
   "domain": "qobuz",
   "name": "Qobuz",
   "description": "Qobuz support for Music Assistant: Lossless (and hi-res) Music provider.",
-  "codeowners": ["@marcelveldt"],
-  "config_entries": [
-    {
-      "key": "username",
-      "type": "string",
-      "label": "Username"
-    },
-    {
-      "key": "password",
-      "type": "secure_string",
-      "label": "Password"
-    }
-  ],
-
+  "codeowners": ["@music-assistant"],
   "requirements": [],
   "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/817",
   "multi_instance": true
index 59f32c89637105c8d48977162b70a386ef0cf601..509edd7527ed65ec8094fb94161fc1000017b2e8 100644 (file)
@@ -29,7 +29,10 @@ from music_assistant.constants import CONF_PLAYERS
 from music_assistant.server.models.player_provider import PlayerProvider
 
 if TYPE_CHECKING:
-    pass
+    from music_assistant.common.models.config_entries import ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
 
 # sync constants
 MIN_DEVIATION_ADJUST = 10  # 10 milliseconds
@@ -81,6 +84,22 @@ SLIM_PLAYER_CONFIG_ENTRIES = (
 )
 
 
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = SlimprotoProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant, manifest: ProviderManifest  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return tuple()  # we do not have any config entries (yet)
+
+
 class SlimprotoProvider(PlayerProvider):
     """Base/builtin provider for players using the SLIM protocol (aka slimproto)."""
 
@@ -89,7 +108,7 @@ class SlimprotoProvider(PlayerProvider):
     _sync_playpoints: dict[str, deque[SyncPlayPoint]]
     _virtual_providers: dict[str, tuple[Callable, Callable]]
 
-    async def setup(self) -> None:
+    async def handle_setup(self) -> None:
         """Handle async initialization of the provider."""
         self._socket_clients = {}
         self._sync_playpoints = {}
@@ -103,10 +122,10 @@ class SlimprotoProvider(PlayerProvider):
             # start slimproto server
             await asyncio.start_server(self._create_client, "0.0.0.0", slimproto_port),
             # setup discovery
-            await start_discovery(slimproto_port, cli_port, self.mass.port),
+            await start_discovery(slimproto_port, cli_port, self.mass.webserver.port),
         )
 
-    async def close(self) -> None:
+    async def unload(self) -> None:
         """Handle close/cleanup of the provider."""
         if hasattr(self, "_socket_clients"):
             for client in list(self._socket_clients.values()):
diff --git a/music_assistant/server/providers/slimproto/icon.png b/music_assistant/server/providers/slimproto/icon.png
new file mode 100644 (file)
index 0000000..18531d7
Binary files /dev/null and b/music_assistant/server/providers/slimproto/icon.png differ
index 3955d8b773910e433687c578b575d2291818b700..b949b92f752cf02cb59f4f2c22c8d1fc8b7621b3 100644 (file)
@@ -3,9 +3,7 @@
   "domain": "slimproto",
   "name": "Slimproto",
   "description": "Support for slimproto based players (e.g. squeezebox, squeezelite).",
-  "codeowners": ["@marcelveldt"],
-  "config_entries": [
-  ],
+  "codeowners": ["@music-assistant"],
   "requirements": ["aioslimproto==2.2.0"],
   "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/1123",
   "multi_instance": false,
index 066bc2c4ad8b31b6f674ef2d7a2e8aab5278c994..1f8a15f4d2a7a04d502f6bacf8d319a72216e700 100644 (file)
@@ -14,6 +14,7 @@ from soco.events_base import Event as SonosEvent
 from soco.events_base import SubscriptionBase
 from soco.groups import ZoneGroup
 
+from music_assistant.common.models.config_entries import ConfigEntry
 from music_assistant.common.models.enums import (
     ContentType,
     MediaType,
@@ -29,7 +30,10 @@ from music_assistant.server.helpers.didl_lite import create_didl_metadata
 from music_assistant.server.models.player_provider import PlayerProvider
 
 if TYPE_CHECKING:
-    from music_assistant.common.models.config_entries import PlayerConfig
+    from music_assistant.common.models.config_entries import PlayerConfig, ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
 
 
 PLAYER_FEATURES = (
@@ -41,6 +45,22 @@ PLAYER_FEATURES = (
 PLAYER_CONFIG_ENTRIES = tuple()  # we don't have any player config entries (for now)
 
 
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = SonosPlayerProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant, manifest: ProviderManifest  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return tuple()  # we do not have any config entries (yet)
+
+
 @dataclass
 class SonosPlayer:
     """Wrapper around Sonos/SoCo with some additional attributes."""
@@ -196,7 +216,7 @@ class SonosPlayerProvider(PlayerProvider):
     sonosplayers: dict[str, SonosPlayer]
     _discovery_running: bool
 
-    async def setup(self) -> None:
+    async def handle_setup(self) -> None:
         """Handle async initialization of the provider."""
         self.sonosplayers = {}
         self._discovery_running = False
@@ -205,7 +225,7 @@ class SonosPlayerProvider(PlayerProvider):
         logging.getLogger("urllib3.connectionpool").setLevel(logging.INFO)
         self.mass.create_task(self._run_discovery())
 
-    async def close(self) -> None:
+    async def unload(self) -> None:
         """Handle close/cleanup of the provider."""
         if hasattr(self, "sonosplayers"):
             for player in self.sonosplayers.values():
diff --git a/music_assistant/server/providers/sonos/icon.png b/music_assistant/server/providers/sonos/icon.png
new file mode 100644 (file)
index 0000000..d00f12a
Binary files /dev/null and b/music_assistant/server/providers/sonos/icon.png differ
index 1f8f3fde695fd393baf9dc8e287b16d9bc9fab63..9a303336fa2e6b6499f96e76f0e9dc81dc24bb59 100644 (file)
@@ -4,8 +4,6 @@
   "name": "SONOS",
   "description": "SONOS Playerprovider for Music Assistant.",
   "codeowners": ["@music-assistant"],
-  "config_entries": [
-  ],
   "requirements": ["soco==0.29.1"],
   "documentation": "",
   "multi_instance": false,
index 292985ad557413f843f9a9004e19e72396345f4c..9e0fad0e1fa3b76c81ab888fcae99770db0c85f9 100644 (file)
@@ -1,9 +1,11 @@
 """Soundcloud support for MusicAssistant."""
 import asyncio
 from collections.abc import AsyncGenerator, Callable
+from typing import TYPE_CHECKING
 
 from music_assistant.common.helpers.util import parse_title_and_version
-from music_assistant.common.models.enums import ProviderFeature
+from music_assistant.common.models.config_entries import ConfigEntry
+from music_assistant.common.models.enums import ConfigEntryType, ProviderFeature
 from music_assistant.common.models.errors import InvalidDataError, LoginFailed
 from music_assistant.common.models.media_items import (
     Artist,
@@ -19,7 +21,7 @@ from music_assistant.common.models.media_items import (
 )
 from music_assistant.server.models.music_provider import MusicProvider
 
-from .soundcloudpy.asyncsoundcloudpy import SoundcloudAsync
+from .soundcloudpy.asyncsoundcloudpy import SoundcloudAsyncAPI
 
 CONF_CLIENT_ID = "client_id"
 CONF_AUTHORIZATION = "authorization"
@@ -35,6 +37,41 @@ SUPPORTED_FEATURES = (
 )
 
 
+if TYPE_CHECKING:
+    from music_assistant.common.models.config_entries import ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    if not config.get_value(CONF_CLIENT_ID) or not config.get_value(CONF_AUTHORIZATION):
+        raise LoginFailed("Invalid login credentials")
+    prov = SoundcloudMusicProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant, manifest: ProviderManifest  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return (
+        ConfigEntry(
+            key=CONF_CLIENT_ID, type=ConfigEntryType.SECURE_STRING, label="Client ID", required=True
+        ),
+        ConfigEntry(
+            key=CONF_AUTHORIZATION,
+            type=ConfigEntryType.SECURE_STRING,
+            label="Authorization",
+            required=True,
+        ),
+    )
+
+
 class SoundcloudMusicProvider(MusicProvider):
     """Provider for Soundcloud."""
 
@@ -47,21 +84,13 @@ class SoundcloudMusicProvider(MusicProvider):
     _soundcloud = None
     _me = None
 
-    async def setup(self) -> None:
+    async def handle_setup(self) -> None:
         """Set up the Soundcloud provider."""
-        if not self.config.get_value(CONF_CLIENT_ID) or not self.config.get_value(
-            CONF_AUTHORIZATION
-        ):
-            raise LoginFailed("Invalid login credentials")
-
         client_id = self.config.get_value(CONF_CLIENT_ID)
         auth_token = self.config.get_value(CONF_AUTHORIZATION)
-
-        async with SoundcloudAsync(auth_token, client_id) as account:
-            await account.login()
-
-        self._soundcloud = account
-        self._me = await account.get_account_details()
+        self._soundcloud = SoundcloudAsyncAPI(auth_token, client_id, self.mass.http_session)
+        await self._soundcloud.login()
+        self._me = await self._soundcloud.get_account_details()
         self._user_id = self._me["id"]
 
     @property
diff --git a/music_assistant/server/providers/soundcloud/icon.png b/music_assistant/server/providers/soundcloud/icon.png
new file mode 100644 (file)
index 0000000..e9dde20
Binary files /dev/null and b/music_assistant/server/providers/soundcloud/icon.png differ
index 7e930a1f8a583efd3db2d6406a6865aef04511dd..b56ebd1c6bd91759e056fcb14160cfc6ee8bdb44 100644 (file)
@@ -4,19 +4,6 @@
   "name": "Soundcloud",
   "description": "Support for the Soundcloud streaming provider in Music Assistant.",
   "codeowners": ["@gieljnssns"],
-  "config_entries": [
-    {
-      "key": "client_id",
-      "type": "secure_string",
-      "label": "Client id"
-    },
-    {
-      "key": "authorization",
-      "type": "secure_string",
-      "label": "Authorization"
-    }
-  ],
-
   "requirements": [],
   "documentation": "https://github.com/orgs/music-assistant/discussions/1160",
   "multi_instance": true
index da780631f67fb9d5a3cff940505e08ae73fe864f..a1a22a7c036fc1ead1d00227ebb324d2ac8d12a1 100644 (file)
@@ -1,34 +1,38 @@
-"""Async helpers for connecting to the Soundcloud API.
+"""
+Async helpers for connecting to the Soundcloud API.
 
 This file is based on soundcloudpy from Naím Rodríguez https://github.com/naim-prog
 Original package https://github.com/naim-prog/soundcloud-py
 """
 from __future__ import annotations
 
-import aiohttp
-from aiohttp.client import ClientSession
-from attr import dataclass
+from typing import TYPE_CHECKING
 
 BASE_URL = "https://api-v2.soundcloud.com"
 
+if TYPE_CHECKING:
+    from aiohttp.client import ClientSession
 
-@dataclass
-class SoundcloudAsync:
-    """Soundcloud."""
 
-    o_auth: str
-    client_id: str
-    headers = None
-    app_version = None
-    firefox_version = None
-    request_timeout: float = 8.0
+class SoundcloudAsyncAPI:
+    """Soundcloud."""
 
     session: ClientSession | None = None
 
+    def __init__(self, auth_token: str, client_id: str, http_session: ClientSession) -> None:
+        """Initialize SoundcloudAsyncAPI."""
+        self.o_auth = auth_token
+        self.client_id = client_id
+        self.http_session = http_session
+        self.headers = None
+        self.app_version = None
+        self.firefox_version = None
+        self.request_timeout: float = 8.0
+
     async def get(self, url, headers=None, params=None):
         """Async get."""
-        async with aiohttp.ClientSession(headers=headers) as session:
-            async with session.get(url=url, params=params) as response:
+        async with self.http_session as session:
+            async with session.get(url=url, params=params, headers=headers) as response:
                 return await response.json()
 
     async def login(self):
@@ -299,24 +303,3 @@ class SoundcloudAsync:
             f"&limit={limit}&offset=0&linked_partitioning=1&app_version={self.app_version}",
             headers=self.headers,
         )
-
-    async def close(self) -> None:
-        """Close open client session."""
-        if self.session:
-            await self.session.close()
-
-    async def __aenter__(self) -> SoundcloudAsync:
-        """Async enter.
-
-        Returns:
-            The SoundcloudAsync object.
-        """
-        return self
-
-    async def __aexit__(self, *_exc_info) -> None:
-        """Async exit.
-
-        Args:
-            _exc_info: Exec type.
-        """
-        await self.close()
index 2de9230508446666ccf7900781b55cbaa541a86e..3e894d286c851e410e80c81aa33cb5fbb26b0ee4 100644 (file)
@@ -10,12 +10,14 @@ import time
 from collections.abc import AsyncGenerator
 from json.decoder import JSONDecodeError
 from tempfile import gettempdir
+from typing import TYPE_CHECKING
 
 import aiohttp
 from asyncio_throttle import Throttler
 
 from music_assistant.common.helpers.util import parse_title_and_version
-from music_assistant.common.models.enums import ProviderFeature
+from music_assistant.common.models.config_entries import ConfigEntry
+from music_assistant.common.models.enums import ConfigEntryType, ProviderFeature
 from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError
 from music_assistant.common.models.media_items import (
     Album,
@@ -36,6 +38,13 @@ from music_assistant.server.helpers.app_vars import app_var
 from music_assistant.server.helpers.process import AsyncProcess
 from music_assistant.server.models.music_provider import MusicProvider
 
+if TYPE_CHECKING:
+    from music_assistant.common.models.config_entries import ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
+
+
 CACHE_DIR = gettempdir()
 SUPPORTED_FEATURES = (
     ProviderFeature.LIBRARY_ARTISTS,
@@ -55,6 +64,29 @@ SUPPORTED_FEATURES = (
 )
 
 
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = SpotifyProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant, manifest: ProviderManifest  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return (
+        ConfigEntry(
+            key=CONF_USERNAME, type=ConfigEntryType.STRING, label="Username", required=True
+        ),
+        ConfigEntry(
+            key=CONF_PASSWORD, type=ConfigEntryType.SECURE_STRING, label="Password", required=True
+        ),
+    )
+
+
 class SpotifyProvider(MusicProvider):
     """Implementation of a Spotify MusicProvider."""
 
@@ -62,7 +94,7 @@ class SpotifyProvider(MusicProvider):
     _sp_user: str | None = None
     _librespot_bin: str | None = None
 
-    async def setup(self) -> None:
+    async def handle_setup(self) -> None:
         """Handle async initialization of the provider."""
         self._throttler = Throttler(rate_limit=1, period=0.1)
         self._cache_dir = CACHE_DIR
@@ -76,7 +108,22 @@ class SpotifyProvider(MusicProvider):
     @property
     def supported_features(self) -> tuple[ProviderFeature, ...]:
         """Return the features supported by this Provider."""
-        return SUPPORTED_FEATURES
+        return (
+            ProviderFeature.LIBRARY_ARTISTS,
+            ProviderFeature.LIBRARY_ALBUMS,
+            ProviderFeature.LIBRARY_TRACKS,
+            ProviderFeature.LIBRARY_PLAYLISTS,
+            ProviderFeature.LIBRARY_ARTISTS_EDIT,
+            ProviderFeature.LIBRARY_ALBUMS_EDIT,
+            ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
+            ProviderFeature.LIBRARY_TRACKS_EDIT,
+            ProviderFeature.PLAYLIST_TRACKS_EDIT,
+            ProviderFeature.BROWSE,
+            ProviderFeature.SEARCH,
+            ProviderFeature.ARTIST_ALBUMS,
+            ProviderFeature.ARTIST_TOPTRACKS,
+            ProviderFeature.SIMILAR_TRACKS,
+        )
 
     async def search(
         self, search_query: str, media_types=list[MediaType] | None, limit: int = 5
diff --git a/music_assistant/server/providers/spotify/icon.png b/music_assistant/server/providers/spotify/icon.png
new file mode 100644 (file)
index 0000000..1ed4049
Binary files /dev/null and b/music_assistant/server/providers/spotify/icon.png differ
index 31495ba5de00274005e675f82ab9c8c528a470ad..35be965ed125734facbd3a2f60a3ecefe91a68ee 100644 (file)
@@ -3,20 +3,7 @@
   "domain": "spotify",
   "name": "Spotify",
   "description": "Support for the Spotify streaming provider in Music Assistant.",
-  "codeowners": ["@marcelveldt"],
-  "config_entries": [
-    {
-      "key": "username",
-      "type": "string",
-      "label": "Username"
-    },
-    {
-      "key": "password",
-      "type": "secure_string",
-      "label": "Password"
-    }
-  ],
-
+  "codeowners": ["@music-assistant"],
   "requirements": [],
   "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/816",
   "multi_instance": true
index 107a7e6cf40ca0eca407c394913223b49c745614..6e8236d07e390568defe562ead4290b678f1d748 100644 (file)
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any
 import aiohttp.client_exceptions
 from asyncio_throttle import Throttler
 
+from music_assistant.common.models.config_entries import ConfigEntry
 from music_assistant.common.models.enums import ProviderFeature
 from music_assistant.common.models.media_items import (
     Album,
@@ -27,6 +28,11 @@ from music_assistant.server.models.metadata_provider import MetadataProvider
 if TYPE_CHECKING:
     from collections.abc import Iterable
 
+    from music_assistant.common.models.config_entries import ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
+
 SUPPORTED_FEATURES = (
     ProviderFeature.ARTIST_METADATA,
     ProviderFeature.ALBUM_METADATA,
@@ -68,12 +74,28 @@ ALBUMTYPE_MAPPING = {
 }
 
 
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = AudioDbMetadataProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant, manifest: ProviderManifest  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return tuple()  # we do not have any config entries (yet)
+
+
 class AudioDbMetadataProvider(MetadataProvider):
     """The AudioDB Metadata provider."""
 
     throttler: Throttler
 
-    async def setup(self) -> None:
+    async def handle_setup(self) -> None:
         """Handle async initialization of the provider."""
         self.cache = self.mass.cache
         self.throttler = Throttler(rate_limit=2, period=1)
index 82174b439d011820df6adbc32008ec0407b267a0..832c4a5b5599694650a3f598ae7d976c29b3b7db 100644 (file)
@@ -4,11 +4,10 @@
   "name": "TheAudioDB Metadata provider",
   "description": "TheAudioDB is a community Database of audio artwork and metadata with a JSON API.",
   "codeowners": ["@music-assistant"],
-  "config_entries": [
-  ],
   "requirements": [],
   "documentation": "",
   "multi_instance": false,
   "builtin": true,
-  "load_by_default": true
+  "load_by_default": true,
+  "icon": "mdi-folder-information"
 }
index 1a380c73d0543b82efb5033329f9ff478b781990..9d1fa7787f44c0a553c381e2555c7733bf910b06 100644 (file)
@@ -3,11 +3,13 @@ from __future__ import annotations
 
 from collections.abc import AsyncGenerator
 from time import time
+from typing import TYPE_CHECKING
 
 from asyncio_throttle import Throttler
 
 from music_assistant.common.helpers.util import create_sort_name
-from music_assistant.common.models.enums import ProviderFeature
+from music_assistant.common.models.config_entries import ConfigEntry
+from music_assistant.common.models.enums import ConfigEntryType, ProviderFeature
 from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError
 from music_assistant.common.models.media_items import (
     ContentType,
@@ -29,6 +31,40 @@ SUPPORTED_FEATURES = (
     ProviderFeature.BROWSE,
 )
 
+if TYPE_CHECKING:
+    from music_assistant.common.models.config_entries import ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    if not config.get_value(CONF_USERNAME):
+        raise LoginFailed("Username is invalid")
+
+    prov = TuneInProvider(mass, manifest, config)
+    if "@" in config.get_value(CONF_USERNAME):
+        prov.logger.warning(
+            "Emailadress detected instead of username, "
+            "it is advised to use the tunein username instead of email."
+        )
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant, manifest: ProviderManifest  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return (
+        ConfigEntry(
+            key=CONF_USERNAME, type=ConfigEntryType.STRING, label="Username", required=True
+        ),
+    )
+
 
 class TuneInProvider(MusicProvider):
     """Provider implementation for Tune In."""
@@ -40,18 +76,10 @@ class TuneInProvider(MusicProvider):
         """Return the features supported by this Provider."""
         return SUPPORTED_FEATURES
 
-    async def setup(self) -> None:
+    async def handle_setup(self) -> None:
         """Handle async initialization of the provider."""
         self._throttler = Throttler(rate_limit=1, period=1)
 
-        if not self.config.get_value(CONF_USERNAME):
-            raise LoginFailed("Username is invalid")
-        if "@" in self.config.get_value(CONF_USERNAME):
-            self.logger.warning(
-                "Emailadress detected instead of username, "
-                "it is advised to use the tunein username instead of email."
-            )
-
     async def get_library_radios(self) -> AsyncGenerator[Radio, None]:
         """Retrieve library/subscribed radio stations from the provider."""
 
diff --git a/music_assistant/server/providers/tunein/icon.png b/music_assistant/server/providers/tunein/icon.png
new file mode 100644 (file)
index 0000000..18c537c
Binary files /dev/null and b/music_assistant/server/providers/tunein/icon.png differ
index fcf388dd28e11596036c1386f71ea1be65034a69..bd6a56aca9651799fe5f08bb552275e8df9a2680 100644 (file)
@@ -3,15 +3,7 @@
   "domain": "tunein",
   "name": "Tune-In Radio",
   "description": "Play your favorite radio stations from Tune-In in Music Assistant.",
-  "codeowners": ["@marcelveldt"],
-  "config_entries": [
-    {
-      "key": "username",
-      "type": "string",
-      "label": "Username"
-    }
-  ],
-
+  "codeowners": ["@music-assistant"],
   "requirements": [],
   "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/categories/music-providers",
   "multi_instance": true
index aeb248affa76aeb30f6506c33ac6b8f57f38b9a9..a9e8497e41e2819cf64c035f3a6ba4b8ea90cd83 100644 (file)
@@ -3,7 +3,9 @@ from __future__ import annotations
 
 import os
 from collections.abc import AsyncGenerator
+from typing import TYPE_CHECKING
 
+from music_assistant.common.models.config_entries import ConfigEntry
 from music_assistant.common.models.enums import ContentType, ImageType, MediaType
 from music_assistant.common.models.media_items import (
     Artist,
@@ -19,11 +21,33 @@ from music_assistant.server.helpers.playlists import fetch_playlist
 from music_assistant.server.helpers.tags import AudioTags, parse_tags
 from music_assistant.server.models.music_provider import MusicProvider
 
+if TYPE_CHECKING:
+    from music_assistant.common.models.config_entries import ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = URLProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant, manifest: ProviderManifest  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return tuple()  # we do not have any config entries (yet)
+
 
 class URLProvider(MusicProvider):
     """Music Provider for manual URL's/files added to the queue."""
 
-    async def setup(self) -> None:
+    async def handle_setup(self) -> None:
         """Handle async initialization of the provider.
 
         Called when provider is registered.
index 0c2f7626ba3cfe1478010d672bf3bab3d8d211de..4c3ab2497059dd4ce416ec9c7d527df6668ac9a4 100644 (file)
@@ -3,13 +3,11 @@
   "domain": "url",
   "name": "URL",
   "description": "Built-in/generic provider to play music (or playlists) from a remote URL.",
-  "codeowners": ["@marcelveldt"],
-  "config_entries": [
-  ],
-
+  "codeowners": ["@music-assistant"],
   "requirements": [],
   "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/categories/music-providers",
   "multi_instance": false,
   "builtin": true,
-  "load_by_default": true
+  "load_by_default": true,
+  "icon": "mdi:mdi-web"
 }
diff --git a/music_assistant/server/providers/websocket_api/__init__.py b/music_assistant/server/providers/websocket_api/__init__.py
new file mode 100644 (file)
index 0000000..6364bb1
--- /dev/null
@@ -0,0 +1,264 @@
+"""Default Music Assistant Websocket API."""
+from __future__ import annotations
+
+import asyncio
+import logging
+import weakref
+from concurrent import futures
+from contextlib import suppress
+from typing import TYPE_CHECKING, Any, Final
+
+from aiohttp import WSMsgType, web
+
+from music_assistant.common.models.api import (
+    CommandMessage,
+    ErrorResultMessage,
+    MessageType,
+    ServerInfoMessage,
+    SuccessResultMessage,
+)
+from music_assistant.common.models.config_entries import ConfigEntry
+from music_assistant.common.models.errors import InvalidCommand
+from music_assistant.common.models.event import MassEvent
+from music_assistant.constants import ROOT_LOGGER_NAME, __version__
+from music_assistant.server.helpers.api import (
+    API_SCHEMA_VERSION,
+    APICommandHandler,
+    parse_arguments,
+)
+from music_assistant.server.models.plugin import PluginProvider
+
+if TYPE_CHECKING:
+    from music_assistant.common.models.config_entries import ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
+
+
+DEBUG = False  # Set to True to enable very verbose logging of all incoming/outgoing messages
+MAX_PENDING_MSG = 512
+CANCELLATION_ERRORS: Final = (asyncio.CancelledError, futures.CancelledError)
+LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.websocket_api")
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = WebsocketAPI(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant, manifest: ProviderManifest  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return tuple()  # we do not have any config entries (yet)
+
+
+class WebsocketAPI(PluginProvider):
+    """Default Music Assistant Websocket API."""
+
+    clients: weakref.WeakSet[WebsocketClientHandler] = weakref.WeakSet()
+
+    async def handle_setup(self) -> None:
+        """Handle async initialization of the plugin."""
+        self.mass.webserver.register_route("/ws", self._handle_ws_client)
+
+    async def _handle_ws_client(self, request: web.Request) -> web.WebSocketResponse:
+        connection = WebsocketClientHandler(self.mass, request)
+        try:
+            self.clients.add(connection)
+            return await connection.handle_client()
+        finally:
+            self.clients.remove(connection)
+
+    async def unload(self) -> None:
+        """
+        Handle unload/close of the provider.
+
+        Called when provider is deregistered (e.g. MA exiting or config reloading).
+        """
+        self.mass.webserver.unregister_route("/ws")
+        for client in set(self.clients):
+            await client.disconnect()
+
+
+class WebSocketLogAdapter(logging.LoggerAdapter):
+    """Add connection id to websocket log messages."""
+
+    def process(self, msg: str, kwargs: Any) -> tuple[str, Any]:
+        """Add connid to websocket log messages."""
+        return f'[{self.extra["connid"]}] {msg}', kwargs
+
+
+class WebsocketClientHandler:
+    """Handle an active websocket client connection."""
+
+    def __init__(self, mass: MusicAssistant, request: web.Request) -> None:
+        """Initialize an active connection."""
+        self.mass = mass
+        self.request = request
+        self.wsock = web.WebSocketResponse(heartbeat=55)
+        self._to_write: asyncio.Queue = asyncio.Queue(maxsize=MAX_PENDING_MSG)
+        self._handle_task: asyncio.Task | None = None
+        self._writer_task: asyncio.Task | None = None
+        self._logger = WebSocketLogAdapter(LOGGER, {"connid": id(self)})
+
+    async def disconnect(self) -> None:
+        """Disconnect client."""
+        self._cancel()
+        if self._writer_task is not None:
+            await self._writer_task
+
+    async def handle_client(self) -> web.WebSocketResponse:
+        """Handle a websocket response."""
+        # ruff: noqa: PLR0915
+        request = self.request
+        wsock = self.wsock
+        try:
+            async with asyncio.timeout(10):
+                await wsock.prepare(request)
+        except asyncio.TimeoutError:
+            self._logger.warning("Timeout preparing request from %s", request.remote)
+            return wsock
+
+        self._logger.debug("Connection from %s", request.remote)
+        self._handle_task = asyncio.current_task()
+        self._writer_task = asyncio.create_task(self._writer())
+
+        # send server(version) info when client connects
+        self._send_message(
+            ServerInfoMessage(server_version=__version__, schema_version=API_SCHEMA_VERSION)
+        )
+
+        # forward all events to clients
+        def handle_event(event: MassEvent) -> None:
+            self._send_message(event)
+
+        unsub_callback = self.mass.subscribe(handle_event)
+
+        disconnect_warn = None
+
+        try:
+            while not wsock.closed:
+                msg = await wsock.receive()
+
+                if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING):
+                    break
+
+                if msg.type != WSMsgType.TEXT:
+                    disconnect_warn = "Received non-Text message."
+                    break
+
+                if DEBUG:
+                    self._logger.debug("Received: %s", msg.data)
+
+                try:
+                    command_msg = CommandMessage.from_json(msg.data)
+                except ValueError:
+                    disconnect_warn = f"Received invalid JSON: {msg.data}"
+                    break
+
+                self._handle_command(command_msg)
+
+        except asyncio.CancelledError:
+            self._logger.debug("Connection closed by client")
+
+        except Exception:  # pylint: disable=broad-except
+            self._logger.exception("Unexpected error inside websocket API")
+
+        finally:
+            # Handle connection shutting down.
+            unsub_callback()
+            self._logger.debug("Unsubscribed from events")
+
+            try:
+                self._to_write.put_nowait(None)
+                # Make sure all error messages are written before closing
+                await self._writer_task
+                await wsock.close()
+            except asyncio.QueueFull:  # can be raised by put_nowait
+                self._writer_task.cancel()
+
+            finally:
+                if disconnect_warn is None:
+                    self._logger.debug("Disconnected")
+                else:
+                    self._logger.warning("Disconnected: %s", disconnect_warn)
+
+        return wsock
+
+    def _handle_command(self, msg: CommandMessage) -> None:
+        """Handle an incoming command from the client."""
+        self._logger.debug("Handling command %s", msg.command)
+
+        # work out handler for the given path/command
+        handler = self.mass.command_handlers.get(msg.command)
+
+        if handler is None:
+            self._send_message(
+                ErrorResultMessage(
+                    msg.message_id,
+                    InvalidCommand.error_code,
+                    f"Invalid command: {msg.command}",
+                )
+            )
+            self._logger.warning("Invalid command: %s", msg.command)
+            return
+
+        # schedule task to handle the command
+        asyncio.create_task(self._run_handler(handler, msg))
+
+    async def _run_handler(self, handler: APICommandHandler, msg: CommandMessage) -> None:
+        try:
+            args = parse_arguments(handler.signature, handler.type_hints, msg.args)
+            result = handler.target(**args)
+            if asyncio.iscoroutine(result):
+                result = await result
+            self._send_message(SuccessResultMessage(msg.message_id, result))
+        except Exception as err:  # pylint: disable=broad-except
+            self._logger.exception("Error handling message: %s", msg)
+            self._send_message(
+                ErrorResultMessage(msg.message_id, getattr(err, "error_code", 999), str(err))
+            )
+
+    async def _writer(self) -> None:
+        """Write outgoing messages."""
+        # Exceptions if Socket disconnected or cancelled by connection handler
+        with suppress(RuntimeError, ConnectionResetError, *CANCELLATION_ERRORS):
+            while not self.wsock.closed:
+                if (process := await self._to_write.get()) is None:
+                    break
+
+                if not isinstance(process, str):
+                    message: str = process()
+                else:
+                    message = process
+                if DEBUG:
+                    self._logger.debug("Writing: %s", message)
+                await self.wsock.send_str(message)
+
+    def _send_message(self, message: MessageType) -> None:
+        """Send a message to the client.
+
+        Closes connection if the client is not reading the messages.
+
+        Async friendly.
+        """
+        _message = message.to_json()
+
+        try:
+            self._to_write.put_nowait(_message)
+        except asyncio.QueueFull:
+            self._logger.error("Client exceeded max pending messages: %s", MAX_PENDING_MSG)
+
+            self._cancel()
+
+    def _cancel(self) -> None:
+        """Cancel the connection."""
+        if self._handle_task is not None:
+            self._handle_task.cancel()
+        if self._writer_task is not None:
+            self._writer_task.cancel()
diff --git a/music_assistant/server/providers/websocket_api/manifest.json b/music_assistant/server/providers/websocket_api/manifest.json
new file mode 100644 (file)
index 0000000..96830fe
--- /dev/null
@@ -0,0 +1,13 @@
+{
+  "type": "plugin",
+  "domain": "websocket_api",
+  "name": "Websocket API",
+  "description": "The default Websocket API for interacting with Music Assistant which is also used by the Music Assistant frontend.",
+  "codeowners": ["@music-assistant"],
+  "requirements": [],
+  "documentation": "",
+  "multi_instance": false,
+  "builtin": true,
+  "load_by_default": true,
+  "icon": "md:webhook"
+}
index 0cdf7349686fe0831d015e8aec1da9a5a4d9f4ce..a05b6e72ad9ed525b81bfac379d2bf15d76cf4b7 100644 (file)
@@ -1,15 +1,18 @@
 """Youtube Music support for MusicAssistant."""
+from __future__ import annotations
+
 import asyncio
 import re
 from operator import itemgetter
 from time import time
-from typing import AsyncGenerator  # noqa: UP035
+from typing import TYPE_CHECKING, AsyncGenerator  # noqa: UP035
 from urllib.parse import unquote
 
 import pytube
 import ytmusicapi
 
-from music_assistant.common.models.enums import ProviderFeature
+from music_assistant.common.models.config_entries import ConfigEntry
+from music_assistant.common.models.enums import ConfigEntryType, ProviderFeature
 from music_assistant.common.models.errors import (
     InvalidDataError,
     LoginFailed,
@@ -50,6 +53,13 @@ from .helpers import (
     search,
 )
 
+if TYPE_CHECKING:
+    from music_assistant.common.models.config_entries import ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
+
+
 CONF_COOKIE = "cookie"
 
 YT_DOMAIN = "https://www.youtube.com"
@@ -72,6 +82,34 @@ SUPPORTED_FEATURES = (
 # ruff: noqa: PLW2901, RET504
 
 
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = YoutubeMusicProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant, manifest: ProviderManifest  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return (
+        ConfigEntry(
+            key=CONF_USERNAME, type=ConfigEntryType.STRING, label="Username", required=True
+        ),
+        ConfigEntry(
+            key=CONF_COOKIE,
+            type=ConfigEntryType.SECURE_STRING,
+            label="Login Cookie",
+            required=True,
+            description="The Login cookie you grabbed from an existing session, "
+            "see the documentation.",
+        ),
+    )
+
+
 class YoutubeMusicProvider(MusicProvider):
     """Provider for Youtube Music."""
 
@@ -81,7 +119,7 @@ class YoutubeMusicProvider(MusicProvider):
     _signature_timestamp = 0
     _cipher = None
 
-    async def setup(self) -> None:
+    async def handle_setup(self) -> None:
         """Set up the YTMusic provider."""
         if not self.config.get_value(CONF_USERNAME) or not self.config.get_value(CONF_COOKIE):
             raise LoginFailed("Invalid login credentials")
diff --git a/music_assistant/server/providers/ytmusic/icon.png b/music_assistant/server/providers/ytmusic/icon.png
new file mode 100644 (file)
index 0000000..cf6726d
Binary files /dev/null and b/music_assistant/server/providers/ytmusic/icon.png differ
index 697d06f73b31ce51a3cdaac1976cb977f588ed03..3bfe16aa7a7dad004f19a98b31ab979548e00c13 100644 (file)
@@ -4,19 +4,6 @@
   "name": "YouTube Music",
   "description": "Support for the YouTube Music streaming provider in Music Assistant.",
   "codeowners": ["@MarvinSchenkel"],
-  "config_entries": [
-    {
-      "key": "username",
-      "type": "string",
-      "label": "Username"
-    },
-    {
-      "key": "cookie",
-      "type": "secure_string",
-      "label": "Cookie"
-    }
-  ],
-
   "requirements": ["ytmusicapi==0.25.0", "git+https://github.com/pytube/pytube.git@refs/pull/1501/head"],
   "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/606",
   "multi_instance": true
index 07239709d12c6f717abbf7fe7ed13186eb3a1eec..9310a4e5c0e817237ed6a931d167e385761dd5bf 100644 (file)
@@ -2,18 +2,16 @@
 from __future__ import annotations
 
 import asyncio
-import importlib
-import inspect
 import logging
 import os
 from collections.abc import Awaitable, Callable, Coroutine
 from typing import TYPE_CHECKING, Any
 from uuid import uuid4
 
-from aiohttp import ClientSession, TCPConnector, web
+from aiohttp import ClientSession, TCPConnector
 from zeroconf import InterfaceChoice, NonUniqueNameException, ServiceInfo, Zeroconf
 
-from music_assistant.common.helpers.util import get_ip, get_ip_pton, select_free_port
+from music_assistant.common.helpers.util import get_ip, get_ip_pton
 from music_assistant.common.models.config_entries import ProviderConfig
 from music_assistant.common.models.enums import EventType, ProviderType
 from music_assistant.common.models.errors import SetupFailedError
@@ -26,18 +24,16 @@ from music_assistant.server.controllers.metadata import MetaDataController
 from music_assistant.server.controllers.music import MusicController
 from music_assistant.server.controllers.players import PlayerController
 from music_assistant.server.controllers.streams import StreamsController
-from music_assistant.server.helpers.api import APICommandHandler, api_command, mount_websocket_api
-from music_assistant.server.helpers.util import install_package
-from music_assistant.server.models.plugin import PluginProvider
+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 .models.metadata_provider import MetadataProvider
-from .models.music_provider import MusicProvider
-from .models.player_provider import PlayerProvider
+from .models import ProviderInstanceType
 
 if TYPE_CHECKING:
     from types import TracebackType
 
-ProviderInstanceType = MetadataProvider | MusicProvider | PlayerProvider
 EventCallBackType = Callable[[MassEvent], None]
 EventSubscriptionType = tuple[EventCallBackType, tuple[EventType] | None, tuple[str] | None]
 
@@ -52,25 +48,21 @@ class MusicAssistant:
 
     loop: asyncio.AbstractEventLoop
     http_session: ClientSession
-    _web_apprunner: web.AppRunner
-    _web_tcp: web.TCPSite
 
-    def __init__(self, storage_path: str, port: int | None = None) -> None:
+    def __init__(self, storage_path: str) -> None:
         """Initialize the MusicAssistant Server."""
         self.storage_path = storage_path
-        self.port = port
         self.base_ip = get_ip()
         # shared zeroconf instance
         self.zeroconf = Zeroconf(interfaces=InterfaceChoice.All)
-        # we dynamically register command handlers
-        self.webapp = web.Application()
+        # we dynamically register command handlers which can be consumed by the apis
         self.command_handlers: dict[str, APICommandHandler] = {}
         self._subscribers: set[EventSubscriptionType] = set()
         self._available_providers: dict[str, ProviderManifest] = {}
         self._providers: dict[str, ProviderInstanceType] = {}
-
         # init core controllers
         self.config = ConfigController(self)
+        self.webserver = WebserverController(self)
         self.cache = CacheController(self)
         self.metadata = MetaDataController(self)
         self.music = MusicController(self)
@@ -84,7 +76,6 @@ class MusicAssistant:
     async def start(self) -> None:
         """Start running the Music Assistant server."""
         self.loop = asyncio.get_running_loop()
-
         # create shared aiohttp ClientSession
         self.http_session = ClientSession(
             loop=self.loop,
@@ -92,35 +83,21 @@ class MusicAssistant:
         )
         # setup config controller first and fetch important config values
         await self.config.setup()
-        if self.port is None:
-            # if port is None, we need to autoselect it
-            self.port = await select_free_port(8095, 9200)
-        # allow overriding of the base_ip if autodetect failed
         self.base_ip = self.config.get(CONF_WEB_IP, self.base_ip)
         LOGGER.info(
-            "Starting Music Assistant Server (%s) on port: %s - autodetected IP-address: %s",
+            "Starting Music Assistant Server (%s) - autodetected IP-address: %s",
             self.server_id,
-            self.port,
             self.base_ip,
         )
-
         # setup other core controllers
         await self.cache.setup()
+        await self.webserver.setup()
         await self.music.setup()
         await self.metadata.setup()
         await self.players.setup()
         await self.streams.setup()
-
         # load providers
         await self._load_providers()
-        # setup web server
-        mount_websocket_api(self, "/ws")
-        self._web_apprunner = web.AppRunner(self.webapp, access_log=None)
-        await self._web_apprunner.setup()
-        # set host to None to bind to all addresses on both IPv4 and IPv6
-        host = None
-        self._web_tcp = web.TCPSite(self._web_apprunner, host=host, port=self.port)
-        await self._web_tcp.start()
         self._setup_discovery()
 
     async def stop(self) -> None:
@@ -131,20 +108,15 @@ class MusicAssistant:
         # cancel all running tasks
         for task in self._tracked_tasks.values():
             task.cancel()
-        # stop/clean streams controller
-        await self.streams.close()
-        # stop/clean webserver
-        await self._web_tcp.stop()
-        await self._web_apprunner.cleanup()
-        await self.webapp.shutdown()
-        await self.webapp.cleanup()
+        # cleanup all providers
+        for prov_id in list(self._providers.keys()):
+            await self.unload_provider(prov_id)
         # stop core controllers
+        await self.streams.close()
         await self.metadata.close()
         await self.music.close()
         await self.players.close()
-        # cleanup all providers
-        for prov in self._providers.values():
-            await prov.close()
+        await self.webserver.close()
         # cleanup cache and config
         await self.config.close()
         await self.cache.close()
@@ -152,11 +124,6 @@ class MusicAssistant:
         if self.http_session:
             await self.http_session.close()
 
-    @property
-    def base_url(self) -> str:
-        """Return the (web)server's base url."""
-        return f"http://{self.base_ip}:{self.port}"
-
     @property
     def server_id(self) -> str:
         """Return unique ID of this server."""
@@ -191,9 +158,11 @@ class MusicAssistant:
         if prov is not None and (return_unavailable or prov.available):
             return prov
         for prov in self._providers.values():
-            if prov.domain == provider_instance_or_domain and return_unavailable or prov.available:
+            if prov.domain != provider_instance_or_domain:
+                continue
+            if return_unavailable or prov.available:
                 return prov
-        LOGGER.warning("Provider {provider_instance_or_domain} is not available")
+        LOGGER.debug("Provider %s is not available", provider_instance_or_domain)
         return None
 
     def signal_event(
@@ -337,29 +306,9 @@ class MusicAssistant:
                 )
 
         # try to load the module
-        prov_mod = importlib.import_module(f".{domain}", "music_assistant.server.providers")
-        for name, obj in inspect.getmembers(prov_mod):
-            if not inspect.isclass(obj):
-                continue
-            # lookup class to initialize
-            if name == prov_manifest.init_class or (
-                not prov_manifest.init_class
-                and issubclass(
-                    obj, MusicProvider | PlayerProvider | MetadataProvider | PluginProvider
-                )
-                and obj != MusicProvider
-                and obj != PlayerProvider
-                and obj != MetadataProvider
-                and obj != PluginProvider
-            ):
-                prov_cls = obj
-                break
-        else:
-            raise AttributeError("Unable to locate Provider class")
-        provider: ProviderInstanceType = prov_cls(self, prov_manifest, conf)
-
+        prov_mod = await get_provider_module(domain)
         try:
-            await asyncio.wait_for(provider.setup(), 30)
+            provider = await asyncio.wait_for(prov_mod.setup(self, prov_manifest, conf), 30)
         except TimeoutError as err:
             raise SetupFailedError(f"Provider {domain} did not load within 30 seconds") from err
         # if we reach this point, the provider loaded successfully
@@ -384,7 +333,7 @@ class MusicAssistant:
                 if sync_task.provider_instance == instance_id:
                     sync_task.task.cancel()
                     await sync_task.task
-            await provider.close()
+            await provider.unload()
             self._providers.pop(instance_id)
             self.signal_event(EventType.PROVIDERS_UPDATED, data=self.get_providers())
 
@@ -415,7 +364,7 @@ class MusicAssistant:
         for prov_manifest in self._available_providers.values():
             if not prov_manifest.load_by_default:
                 continue
-            self.config.create_default_provider_config(prov_manifest.domain)
+            await self.config.create_default_provider_config(prov_manifest.domain)
 
         async def load_provider(prov_conf: ProviderConfig) -> None:
             """Try to load a provider and catch errors."""
@@ -434,8 +383,9 @@ class MusicAssistant:
                 self.config.set(f"{CONF_PROVIDERS}/{prov_conf.instance_id}/last_error", str(exc))
 
         # load all configured (and enabled) providers
+        prov_configs = await self.config.get_provider_configs()
         async with asyncio.TaskGroup() as tg:
-            for prov_conf in self.config.get_provider_configs():
+            for prov_conf in prov_configs:
                 if not prov_conf.enabled:
                     continue
                 tg.create_task(load_provider(prov_conf))
@@ -455,10 +405,14 @@ class MusicAssistant:
                     continue
                 try:
                     provider_manifest = await ProviderManifest.parse(file_path)
+                    # check for icon file
+                    if not provider_manifest.icon:
+                        for icon_file in ("icon.svg", "icon.png"):
+                            icon_path = os.path.join(dir_path, icon_file)
+                            if os.path.isfile(icon_path):
+                                provider_manifest.icon = await get_icon_string(icon_path)
+                                break
                     self._available_providers[provider_manifest.domain] = provider_manifest
-                    # install requirement/dependencies
-                    for requirement in provider_manifest.requirements:
-                        await install_package(requirement)
                     LOGGER.debug("Loaded manifest for provider %s", dir_str)
                 except Exception as exc:  # pylint: disable=broad-except
                     LOGGER.exception(
@@ -476,7 +430,7 @@ class MusicAssistant:
             zeroconf_type,
             name=f"{server_id}.{zeroconf_type}",
             addresses=[get_ip_pton()],
-            port=self.port,
+            port=self.webserver.port,
             properties={},
             server=f"mass_{server_id}.local.",
         )
index 2b37664404031d06296eac8739ece4e11f053919..c35ec5134436e5d61fa12db297b426c64063b927 100644 (file)
@@ -34,6 +34,7 @@ server = [
   "python-slugify==8.0.1",
   "mashumaro==3.5.0",
   "memory-tempfile==2.2.3",
+  "music-assistant-frontend==20230319.1",
   "pillow==9.4.0",
   "unidecode==1.3.6",
   "xmltodict==0.13.0",