From: Marcel van der Veldt Date: Thu, 23 Mar 2023 20:09:51 +0000 (+0100) Subject: Refactor config flow (#567) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=81cf206d1177654d419644dae6d322704df60c36;p=music-assistant-server.git Refactor config flow (#567) * 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 --- diff --git a/music_assistant/__main__.py b/music_assistant/__main__.py index 687a9606..10818b43 100644 --- a/music_assistant/__main__.py +++ b/music_assistant/__main__.py @@ -1,4 +1,6 @@ """Run the Music Assistant Server.""" +from __future__ import annotations + import argparse import asyncio import logging diff --git a/music_assistant/common/models/config_entries.py b/music_assistant/common/models/config_entries.py index a9a5a7cb..6b2fcda2 100644 --- a/music_assistant/common/models/config_entries.py +++ b/music_assistant/common/models/config_entries.py @@ -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. diff --git a/music_assistant/common/models/provider.py b/music_assistant/common/models/provider.py index 58a166ed..3cb68e30 100644 --- a/music_assistant/common/models/provider.py +++ b/music_assistant/common/models/provider.py @@ -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 diff --git a/music_assistant/constants.py b/music_assistant/constants.py index f813b2ed..82c07a94 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -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" diff --git a/music_assistant/server/controllers/config.py b/music_assistant/server/controllers/config.py index 369efc49..c3070a10 100644 --- a/music_assistant/server/controllers/config.py +++ b/music_assistant/server/controllers/config.py @@ -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, diff --git a/music_assistant/server/controllers/metadata.py b/music_assistant/server/controllers/metadata.py index 019593db..835a1be4 100755 --- a/music_assistant/server/controllers/metadata.py +++ b/music_assistant/server/controllers/metadata.py @@ -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 diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index 152321d2..445b0c4e 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -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 index 00000000..a9412d7b --- /dev/null +++ b/music_assistant/server/controllers/webserver.py @@ -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) diff --git a/music_assistant/server/helpers/api.py b/music_assistant/server/helpers/api.py index 8c13604d..15332534 100644 --- a/music_assistant/server/helpers/api.py +++ b/music_assistant/server/helpers/api.py @@ -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")) diff --git a/music_assistant/server/helpers/images.py b/music_assistant/server/helpers/images.py index 79279551..308bae29 100644 --- a/music_assistant/server/helpers/images.py +++ b/music_assistant/server/helpers/images.py @@ -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}" diff --git a/music_assistant/server/helpers/util.py b/music_assistant/server/helpers/util.py index 742ada69..3a6f3b18 100644 --- a/music_assistant/server/helpers/util.py +++ b/music_assistant/server/helpers/util.py @@ -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) diff --git a/music_assistant/server/models/__init__.py b/music_assistant/server/models/__init__.py index 11da8527..9f08b64d 100644 --- a/music_assistant/server/models/__init__.py +++ b/music_assistant/server/models/__init__.py @@ -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.""" diff --git a/music_assistant/server/models/provider.py b/music_assistant/server/models/provider.py index 49063a63..5e777c91 100644 --- a/music_assistant/server/models/provider.py +++ b/music_assistant/server/models/provider.py @@ -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 { diff --git a/music_assistant/server/providers/airplay/__init__.py b/music_assistant/server/providers/airplay/__init__.py index d565f575..2416ab2f 100644 --- a/music_assistant/server/providers/airplay/__init__.py +++ b/music_assistant/server/providers/airplay/__init__.py @@ -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() diff --git a/music_assistant/server/providers/airplay/manifest.json b/music_assistant/server/providers/airplay/manifest.json index 7a0d8c54..1b7bb7ee 100644 --- a/music_assistant/server/providers/airplay/manifest.json +++ b/music_assistant/server/providers/airplay/manifest.json @@ -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" } diff --git a/music_assistant/server/providers/chromecast/__init__.py b/music_assistant/server/providers/chromecast/__init__.py index c127b86c..22ec5b13 100644 --- a/music_assistant/server/providers/chromecast/__init__.py +++ b/music_assistant/server/providers/chromecast/__init__.py @@ -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 diff --git a/music_assistant/server/providers/chromecast/manifest.json b/music_assistant/server/providers/chromecast/manifest.json index 680f164e..bc7cc258 100644 --- a/music_assistant/server/providers/chromecast/manifest.json +++ b/music_assistant/server/providers/chromecast/manifest.json @@ -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" } diff --git a/music_assistant/server/providers/dlna/__init__.py b/music_assistant/server/providers/dlna/__init__.py index 60500a31..3990506c 100644 --- a/music_assistant/server/providers/dlna/__init__.py +++ b/music_assistant/server/providers/dlna/__init__.py @@ -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: diff --git a/music_assistant/server/providers/dlna/helpers.py b/music_assistant/server/providers/dlna/helpers.py index cae0f31b..bc3b9114 100644 --- a/music_assistant/server/providers/dlna/helpers.py +++ b/music_assistant/server/providers/dlna/helpers.py @@ -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 index 00000000..14c34a41 Binary files /dev/null and b/music_assistant/server/providers/dlna/icon.png differ diff --git a/music_assistant/server/providers/dlna/manifest.json b/music_assistant/server/providers/dlna/manifest.json index 43abc53a..bf4c4cc5 100644 --- a/music_assistant/server/providers/dlna/manifest.json +++ b/music_assistant/server/providers/dlna/manifest.json @@ -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, diff --git a/music_assistant/server/providers/fanarttv/__init__.py b/music_assistant/server/providers/fanarttv/__init__.py index cf499a22..62999fda 100644 --- a/music_assistant/server/providers/fanarttv/__init__.py +++ b/music_assistant/server/providers/fanarttv/__init__.py @@ -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) diff --git a/music_assistant/server/providers/fanarttv/manifest.json b/music_assistant/server/providers/fanarttv/manifest.json index fb38df54..092c1227 100644 --- a/music_assistant/server/providers/fanarttv/manifest.json +++ b/music_assistant/server/providers/fanarttv/manifest.json @@ -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" } diff --git a/music_assistant/server/providers/filesystem_local/__init__.py b/music_assistant/server/providers/filesystem_local/__init__.py index 1812b732..63da998d 100644 --- a/music_assistant/server/providers/filesystem_local/__init__.py +++ b/music_assistant/server/providers/filesystem_local/__init__.py @@ -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): diff --git a/music_assistant/server/providers/filesystem_local/base.py b/music_assistant/server/providers/filesystem_local/base.py index b1eceda5..f4724d67 100644 --- a/music_assistant/server/providers/filesystem_local/base.py +++ b/music_assistant/server/providers/filesystem_local/base.py @@ -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 diff --git a/music_assistant/server/providers/filesystem_local/manifest.json b/music_assistant/server/providers/filesystem_local/manifest.json index 7290b5d8..e0d1cce5 100644 --- a/music_assistant/server/providers/filesystem_local/manifest.json +++ b/music_assistant/server/providers/filesystem_local/manifest.json @@ -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" } diff --git a/music_assistant/server/providers/filesystem_smb/__init__.py b/music_assistant/server/providers/filesystem_smb/__init__.py index aa77683b..d40a15d8 100644 --- a/music_assistant/server/providers/filesystem_smb/__init__.py +++ b/music_assistant/server/providers/filesystem_smb/__init__.py @@ -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") diff --git a/music_assistant/server/providers/filesystem_smb/manifest.json b/music_assistant/server/providers/filesystem_smb/manifest.json index 15d366b7..cbba85f5 100644 --- a/music_assistant/server/providers/filesystem_smb/manifest.json +++ b/music_assistant/server/providers/filesystem_smb/manifest.json @@ -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 index 3269e112..00000000 --- a/music_assistant/server/providers/frontend/__init__.py +++ /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 index 481a9aae..00000000 --- a/music_assistant/server/providers/frontend/manifest.json +++ /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 -} diff --git a/music_assistant/server/providers/lms_cli/__init__.py b/music_assistant/server/providers/lms_cli/__init__.py index c09d1e75..7b0a2b34 100644 --- a/music_assistant/server/providers/lms_cli/__init__.py +++ b/music_assistant/server/providers/lms_cli/__init__.py @@ -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, diff --git a/music_assistant/server/providers/lms_cli/manifest.json b/music_assistant/server/providers/lms_cli/manifest.json index dee5fec2..a6cde004 100644 --- a/music_assistant/server/providers/lms_cli/manifest.json +++ b/music_assistant/server/providers/lms_cli/manifest.json @@ -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" } diff --git a/music_assistant/server/providers/musicbrainz/__init__.py b/music_assistant/server/providers/musicbrainz/__init__.py index 59231976..4a57bdf9 100644 --- a/music_assistant/server/providers/musicbrainz/__init__.py +++ b/music_assistant/server/providers/musicbrainz/__init__.py @@ -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) diff --git a/music_assistant/server/providers/musicbrainz/manifest.json b/music_assistant/server/providers/musicbrainz/manifest.json index de08def8..28dd9cd1 100644 --- a/music_assistant/server/providers/musicbrainz/manifest.json +++ b/music_assistant/server/providers/musicbrainz/manifest.json @@ -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" } diff --git a/music_assistant/server/providers/qobuz/__init__.py b/music_assistant/server/providers/qobuz/__init__.py index f56c0d5c..678b1be5 100644 --- a/music_assistant/server/providers/qobuz/__init__.py +++ b/music_assistant/server/providers/qobuz/__init__.py @@ -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 index 00000000..9d7b726c Binary files /dev/null and b/music_assistant/server/providers/qobuz/icon.png differ diff --git a/music_assistant/server/providers/qobuz/manifest.json b/music_assistant/server/providers/qobuz/manifest.json index f63c1baa..fe0e9898 100644 --- a/music_assistant/server/providers/qobuz/manifest.json +++ b/music_assistant/server/providers/qobuz/manifest.json @@ -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 diff --git a/music_assistant/server/providers/slimproto/__init__.py b/music_assistant/server/providers/slimproto/__init__.py index 59f32c89..509edd75 100644 --- a/music_assistant/server/providers/slimproto/__init__.py +++ b/music_assistant/server/providers/slimproto/__init__.py @@ -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 index 00000000..18531d79 Binary files /dev/null and b/music_assistant/server/providers/slimproto/icon.png differ diff --git a/music_assistant/server/providers/slimproto/manifest.json b/music_assistant/server/providers/slimproto/manifest.json index 3955d8b7..b949b92f 100644 --- a/music_assistant/server/providers/slimproto/manifest.json +++ b/music_assistant/server/providers/slimproto/manifest.json @@ -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, diff --git a/music_assistant/server/providers/sonos/__init__.py b/music_assistant/server/providers/sonos/__init__.py index 066bc2c4..1f8a15f4 100644 --- a/music_assistant/server/providers/sonos/__init__.py +++ b/music_assistant/server/providers/sonos/__init__.py @@ -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 index 00000000..d00f12ac Binary files /dev/null and b/music_assistant/server/providers/sonos/icon.png differ diff --git a/music_assistant/server/providers/sonos/manifest.json b/music_assistant/server/providers/sonos/manifest.json index 1f8f3fde..9a303336 100644 --- a/music_assistant/server/providers/sonos/manifest.json +++ b/music_assistant/server/providers/sonos/manifest.json @@ -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, diff --git a/music_assistant/server/providers/soundcloud/__init__.py b/music_assistant/server/providers/soundcloud/__init__.py index 292985ad..9e0fad0e 100644 --- a/music_assistant/server/providers/soundcloud/__init__.py +++ b/music_assistant/server/providers/soundcloud/__init__.py @@ -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 index 00000000..e9dde20b Binary files /dev/null and b/music_assistant/server/providers/soundcloud/icon.png differ diff --git a/music_assistant/server/providers/soundcloud/manifest.json b/music_assistant/server/providers/soundcloud/manifest.json index 7e930a1f..b56ebd1c 100644 --- a/music_assistant/server/providers/soundcloud/manifest.json +++ b/music_assistant/server/providers/soundcloud/manifest.json @@ -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 diff --git a/music_assistant/server/providers/soundcloud/soundcloudpy/asyncsoundcloudpy.py b/music_assistant/server/providers/soundcloud/soundcloudpy/asyncsoundcloudpy.py index da780631..a1a22a7c 100644 --- a/music_assistant/server/providers/soundcloud/soundcloudpy/asyncsoundcloudpy.py +++ b/music_assistant/server/providers/soundcloud/soundcloudpy/asyncsoundcloudpy.py @@ -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() diff --git a/music_assistant/server/providers/spotify/__init__.py b/music_assistant/server/providers/spotify/__init__.py index 2de92305..3e894d28 100644 --- a/music_assistant/server/providers/spotify/__init__.py +++ b/music_assistant/server/providers/spotify/__init__.py @@ -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 index 00000000..1ed40491 Binary files /dev/null and b/music_assistant/server/providers/spotify/icon.png differ diff --git a/music_assistant/server/providers/spotify/manifest.json b/music_assistant/server/providers/spotify/manifest.json index 31495ba5..35be965e 100644 --- a/music_assistant/server/providers/spotify/manifest.json +++ b/music_assistant/server/providers/spotify/manifest.json @@ -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 diff --git a/music_assistant/server/providers/theaudiodb/__init__.py b/music_assistant/server/providers/theaudiodb/__init__.py index 107a7e6c..6e8236d0 100644 --- a/music_assistant/server/providers/theaudiodb/__init__.py +++ b/music_assistant/server/providers/theaudiodb/__init__.py @@ -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) diff --git a/music_assistant/server/providers/theaudiodb/manifest.json b/music_assistant/server/providers/theaudiodb/manifest.json index 82174b43..832c4a5b 100644 --- a/music_assistant/server/providers/theaudiodb/manifest.json +++ b/music_assistant/server/providers/theaudiodb/manifest.json @@ -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" } diff --git a/music_assistant/server/providers/tunein/__init__.py b/music_assistant/server/providers/tunein/__init__.py index 1a380c73..9d1fa778 100644 --- a/music_assistant/server/providers/tunein/__init__.py +++ b/music_assistant/server/providers/tunein/__init__.py @@ -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 index 00000000..18c537c3 Binary files /dev/null and b/music_assistant/server/providers/tunein/icon.png differ diff --git a/music_assistant/server/providers/tunein/manifest.json b/music_assistant/server/providers/tunein/manifest.json index fcf388dd..bd6a56ac 100644 --- a/music_assistant/server/providers/tunein/manifest.json +++ b/music_assistant/server/providers/tunein/manifest.json @@ -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 diff --git a/music_assistant/server/providers/url/__init__.py b/music_assistant/server/providers/url/__init__.py index aeb248af..a9e8497e 100644 --- a/music_assistant/server/providers/url/__init__.py +++ b/music_assistant/server/providers/url/__init__.py @@ -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. diff --git a/music_assistant/server/providers/url/manifest.json b/music_assistant/server/providers/url/manifest.json index 0c2f7626..4c3ab249 100644 --- a/music_assistant/server/providers/url/manifest.json +++ b/music_assistant/server/providers/url/manifest.json @@ -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 index 00000000..6364bb1b --- /dev/null +++ b/music_assistant/server/providers/websocket_api/__init__.py @@ -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 index 00000000..96830fe1 --- /dev/null +++ b/music_assistant/server/providers/websocket_api/manifest.json @@ -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" +} diff --git a/music_assistant/server/providers/ytmusic/__init__.py b/music_assistant/server/providers/ytmusic/__init__.py index 0cdf7349..a05b6e72 100644 --- a/music_assistant/server/providers/ytmusic/__init__.py +++ b/music_assistant/server/providers/ytmusic/__init__.py @@ -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 index 00000000..cf6726df Binary files /dev/null and b/music_assistant/server/providers/ytmusic/icon.png differ diff --git a/music_assistant/server/providers/ytmusic/manifest.json b/music_assistant/server/providers/ytmusic/manifest.json index 697d06f7..3bfe16aa 100644 --- a/music_assistant/server/providers/ytmusic/manifest.json +++ b/music_assistant/server/providers/ytmusic/manifest.json @@ -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 diff --git a/music_assistant/server/server.py b/music_assistant/server/server.py index 07239709..9310a4e5 100644 --- a/music_assistant/server/server.py +++ b/music_assistant/server/server.py @@ -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.", ) diff --git a/pyproject.toml b/pyproject.toml index 2b376644..c35ec513 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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",