"""Run the Music Assistant Server."""
+from __future__ import annotations
+
import argparse
import asyncio
import logging
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.
from music_assistant.common.helpers.json import load_json_file
-from .config_entries import ConfigEntry
from .enums import MediaType, ProviderFeature, ProviderType
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
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":
instance_id: str
supported_features: list[ProviderFeature]
available: bool
+ icon: str | None
@dataclass
# 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"
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:
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)
]
@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):
) -> 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.
@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")
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.
# 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:
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,
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."""
# 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
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,
)
# 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}"
)
--- /dev/null
+"""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)
-"""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])
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"))
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
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}"
"""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__)
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)
"""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."""
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
"""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).
"""
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 {
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
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."""
_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)
# 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()
"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"
}
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."""
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 = {}
# 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
"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"
}
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
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,
_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]]:
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()
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:
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:
"""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."""
@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"
"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,
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
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,
}
+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)
"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"
}
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."""
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):
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,
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
return SUPPORTED_FEATURES
@abstractmethod
- async def setup(self) -> None:
+ async def handle_setup(self) -> None:
"""Handle async initialization of the provider."""
@abstractmethod
"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"
}
"""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,
)
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)
_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")
"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"
}
+++ /dev/null
-"""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)
+++ /dev/null
-{
- "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
-}
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
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.
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
# 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,
"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"
}
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'([+\-&|!(){}\[\]\^"~*?:\\\/])'
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)
"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"
}
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,
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,
)
+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)
"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
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
)
+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)."""
_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 = {}
# 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()):
"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,
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,
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 = (
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."""
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
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():
"name": "SONOS",
"description": "SONOS Playerprovider for Music Assistant.",
"codeowners": ["@music-assistant"],
- "config_entries": [
- ],
"requirements": ["soco==0.29.1"],
"documentation": "",
"multi_instance": false,
"""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,
)
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"
)
+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."""
_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
"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
-"""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):
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()
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,
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,
)
+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."""
_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
@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
"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
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,
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,
}
+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)
"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"
}
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,
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."""
"""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."""
"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
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,
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.
"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"
}
--- /dev/null
+"""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()
--- /dev/null
+{
+ "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"
+}
"""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,
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"
# 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."""
_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")
"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
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
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]
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)
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,
)
# 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:
# 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()
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."""
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(
)
# 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
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())
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."""
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))
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(
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.",
)
"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",