From b88d16d439b852287b8603bd630ff474de2b2135 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sun, 22 Nov 2020 10:45:55 +0100 Subject: [PATCH] Refactor api (#48) * refactored api and authentication --- .github/workflows/publish-to-pypi.yml | 4 +- music_assistant/__main__.py | 2 +- music_assistant/constants.py | 15 +- music_assistant/helpers/cache.py | 3 +- .../{web/endpoints => helpers}/images.py | 44 +-- music_assistant/helpers/migration.py | 9 + music_assistant/helpers/repath.py | 273 +++++++++++++ music_assistant/helpers/web.py | 112 ++++-- music_assistant/managers/config.py | 247 ++++++++---- music_assistant/managers/database.py | 14 + music_assistant/managers/library.py | 44 ++- music_assistant/managers/music.py | 114 +++--- music_assistant/managers/players.py | 164 +++++--- music_assistant/mass.py | 11 +- music_assistant/models/config_entry.py | 1 + music_assistant/models/media_types.py | 18 + music_assistant/models/player.py | 21 +- music_assistant/models/player_queue.py | 38 +- .../providers/chromecast/player.py | 97 ++--- music_assistant/providers/spotify/__init__.py | 2 +- music_assistant/web/__init__.py | 358 ++++++++++++++---- music_assistant/web/endpoints/__init__.py | 1 - music_assistant/web/endpoints/albums.py | 55 --- music_assistant/web/endpoints/artists.py | 53 --- music_assistant/web/endpoints/config.py | 78 ---- music_assistant/web/endpoints/library.py | 82 ---- music_assistant/web/endpoints/login.py | 46 --- music_assistant/web/endpoints/players.py | 142 ------- music_assistant/web/endpoints/playlists.py | 58 --- music_assistant/web/endpoints/radios.py | 29 -- music_assistant/web/endpoints/search.py | 33 -- music_assistant/web/endpoints/tracks.py | 39 -- music_assistant/web/endpoints/websocket.py | 176 --------- .../web/{endpoints => }/json_rpc.py | 12 +- music_assistant/web/setup.html | 114 ++++++ .../web/{endpoints => }/streams.py | 2 +- music_assistant/web/websocket.py | 133 +++++++ requirements.txt | 1 + 38 files changed, 1368 insertions(+), 1277 deletions(-) rename music_assistant/{web/endpoints => helpers}/images.py (60%) create mode 100644 music_assistant/helpers/repath.py delete mode 100644 music_assistant/web/endpoints/__init__.py delete mode 100644 music_assistant/web/endpoints/albums.py delete mode 100644 music_assistant/web/endpoints/artists.py delete mode 100644 music_assistant/web/endpoints/config.py delete mode 100644 music_assistant/web/endpoints/library.py delete mode 100644 music_assistant/web/endpoints/login.py delete mode 100644 music_assistant/web/endpoints/players.py delete mode 100644 music_assistant/web/endpoints/playlists.py delete mode 100644 music_assistant/web/endpoints/radios.py delete mode 100644 music_assistant/web/endpoints/search.py delete mode 100644 music_assistant/web/endpoints/tracks.py delete mode 100644 music_assistant/web/endpoints/websocket.py rename music_assistant/web/{endpoints => }/json_rpc.py (90%) create mode 100644 music_assistant/web/setup.html rename music_assistant/web/{endpoints => }/streams.py (99%) create mode 100644 music_assistant/web/websocket.py diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 1ee29df0..10a0b604 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -10,10 +10,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - - name: Set up Python 3.7 + - name: Set up Python 3.8 uses: actions/setup-python@v2.1.4 with: - python-version: 3.7 + python-version: 3.8 - name: Include frontend-app in the release package run: | cd /tmp diff --git a/music_assistant/__main__.py b/music_assistant/__main__.py index d8658479..e18e03c5 100755 --- a/music_assistant/__main__.py +++ b/music_assistant/__main__.py @@ -37,7 +37,7 @@ def main(): # setup logger logger = logging.getLogger() logformat = logging.Formatter( - "%(asctime)-15s %(levelname)-5s %(name)s -- %(message)s" + "%(asctime)-15s %(levelname)-5s %(name)s.%(funcName)s -- %(message)s" ) consolehandler = logging.StreamHandler() consolehandler.setFormatter(logformat) diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 790a7d07..2918c8ba 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -32,8 +32,10 @@ CONF_KEY_MUSIC_PROVIDERS = "music_providers" CONF_KEY_PLAYER_PROVIDERS = "player_providers" CONF_KEY_METADATA_PROVIDERS = "metadata_providers" CONF_KEY_PLUGINS = "plugins" -CONF_KEY_BASE_WEBSERVER = "web" -CONF_KEY_BASE_SECURITY = "security" +CONF_KEY_SECURITY = "security" +CONF_KEY_SECURITY_LOGIN = "login" +CONF_KEY_SECURITY_APP_TOKENS = "app_tokens" +CONF_KEY_BASE_INFO = "info" # events EVENT_PLAYER_ADDED = "player added" @@ -49,15 +51,6 @@ EVENT_QUEUE_TIME_UPDATED = "queue time updated" EVENT_SHUTDOWN = "application shutdown" EVENT_PROVIDER_REGISTERED = "provider registered" EVENT_PROVIDER_UNREGISTERED = "provider unregistered" -EVENT_PLAYER_CONTROL_REGISTERED = "player control registered" -EVENT_PLAYER_CONTROL_UNREGISTERED = "player control unregistered" -EVENT_PLAYER_CONTROL_UPDATED = "player control updated" -EVENT_SET_PLAYER_CONTROL_STATE = "set player control state" - -# websocket commands -EVENT_REGISTER_PLAYER_CONTROL = "register player control" -EVENT_UNREGISTER_PLAYER_CONTROL = "unregister player control" -EVENT_UPDATE_PLAYER_CONTROL = "update player control" # player attributes ATTR_PLAYER_ID = "player_id" diff --git a/music_assistant/helpers/cache.py b/music_assistant/helpers/cache.py index 785fc700..e48aad82 100644 --- a/music_assistant/helpers/cache.py +++ b/music_assistant/helpers/cache.py @@ -6,7 +6,6 @@ import logging import os import pickle import time -from functools import reduce from typing import Awaitable import aiosqlite @@ -131,7 +130,7 @@ class Cache: if not stringinput: return 0 stringinput = str(stringinput) - return reduce(lambda x, y: x + y, map(ord, stringinput)) + return functools.reduce(lambda x, y: x + y, map(ord, stringinput)) async def async_cached( diff --git a/music_assistant/web/endpoints/images.py b/music_assistant/helpers/images.py similarity index 60% rename from music_assistant/web/endpoints/images.py rename to music_assistant/helpers/images.py index 7f4c58cc..2e501fb2 100644 --- a/music_assistant/web/endpoints/images.py +++ b/music_assistant/helpers/images.py @@ -1,52 +1,14 @@ -"""Images API endpoints.""" +"""Utilities for image manipulation and retrieval.""" import os from io import BytesIO -from aiohttp.web import FileResponse, Request, Response, RouteTableDef from music_assistant.helpers.typing import MusicAssistantType from music_assistant.models.media_types import MediaType from PIL import Image -routes = RouteTableDef() - -@routes.get("/api/images/provider-icon/{provider_id}") -async def async_get_provider_icon(request: Request): - """Get Provider icon.""" - provider_id = request.match_info.get("provider_id") - base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - icon_path = os.path.join(base_dir, "..", "providers", provider_id, "icon.png") - if os.path.isfile(icon_path): - headers = {"Cache-Control": "max-age=86400, public", "Pragma": "public"} - return FileResponse(icon_path, headers=headers) - return Response(status=404) - - -@routes.get("/api/images/thumb") -async def async_get_image_thumb(request: Request): - """Get (resized) thumb image.""" - mass = request.app["mass"] - size = int(request.rel_url.query.get("size", 0)) - provider = request.rel_url.query.get("provider") - item_id = request.rel_url.query.get("item_id") - - if provider and item_id: - media_type = MediaType(request.rel_url.query.get("media_type")) - url = await async_get_image_url(mass, item_id, provider, media_type) - else: - url = request.rel_url.query.get("url") - if not url: - return Response(status=404, text="Invalid URL OR media details given") - - img_file = await async_get_image_file(mass, url, size) - if not img_file or not os.path.isfile(img_file): - return Response(status=404) - headers = {"Cache-Control": "max-age=86400, public", "Pragma": "public"} - return FileResponse(img_file, headers=headers) - - -async def async_get_image_file(mass: MusicAssistantType, url, size: int = 150): +async def async_get_thumb_file(mass: MusicAssistantType, url, size: int = 150): """Get path to (resized) thumbnail image for given image url.""" cache_folder = os.path.join(mass.config.data_path, ".thumbs") cache_id = await mass.database.async_get_thumbnail_id(url, size) @@ -68,7 +30,7 @@ async def async_get_image_file(mass: MusicAssistantType, url, size: int = 150): wpercent = basewidth / float(img.size[0]) hsize = int((float(img.size[1]) * float(wpercent))) img = img.resize((basewidth, hsize), Image.ANTIALIAS) - img.save(cache_file) + img.save(cache_file, format="png") else: with open(cache_file, "wb") as _file: _file.write(img_data.getvalue()) diff --git a/music_assistant/helpers/migration.py b/music_assistant/helpers/migration.py index 926bb706..1a8039f8 100644 --- a/music_assistant/helpers/migration.py +++ b/music_assistant/helpers/migration.py @@ -2,11 +2,13 @@ import os import shutil +import uuid from pkg_resources import packaging import aiosqlite from music_assistant.constants import __version__ as app_version +from music_assistant.helpers.encryption import encrypt_string from music_assistant.helpers.typing import MusicAssistantType @@ -22,6 +24,13 @@ async def check_migrations(mass: MusicAssistantType): # store version in config mass.config.stored_config["version"] = app_version + # create unique server id from machine id + if "server_id" not in mass.config.stored_config: + mass.config.stored_config["server_id"] = str(uuid.getnode()) + if "jwt_key" not in mass.config.stored_config: + mass.config.stored_config["jwt_key"] = encrypt_string(str(uuid.uuid4())) + if "initialized" not in mass.config.stored_config: + mass.config.stored_config["initialized"] = False mass.config.save() # create default db tables (if needed) diff --git a/music_assistant/helpers/repath.py b/music_assistant/helpers/repath.py new file mode 100644 index 00000000..12125a9f --- /dev/null +++ b/music_assistant/helpers/repath.py @@ -0,0 +1,273 @@ +"""Helper functionalities for path detection in api routes.""" +import re +import urllib +import urllib.parse + +REGEXP_TYPE = type(re.compile("")) +PATH_REGEXP = re.compile( + "|".join( + [ + # Match escaped characters that would otherwise appear in future matches. + # This allows the user to escape special characters that won't transform. + "(\\\\.)", + # Match Express-style parameters and un-named parameters with a prefix + # and optional suffixes. Matches appear as: + # + # "/:test(\\d+)?" => ["/", "test", "\d+", undefined, "?", undefined] + # "/route(\\d+)" => [undefined, undefined, undefined, "\d+", undefined, undefined] + # "/*" => ["/", undefined, undefined, undefined, undefined, "*"] + "([\\/.])?(?:(?:\\:(\\w+)(?:\\(((?:\\\\.|[^()])+)\\))?|\\(((?:\\\\.|[^()])+)\\))([+*?])?|(\\*))", + ] + ) +) + + +def escape_string(string): + """Escape URL-acceptable regex special-characters.""" + return re.sub("([.+*?=^!:${}()[\\]|])", r"\\\1", string) + + +def escape_group(group): + """Escape group.""" + return re.sub("([=!:$()])", r"\\\1", group) + + +def parse(string): + """Parse a string for the raw tokens.""" + tokens = [] + key = 0 + index = 0 + path = "" + + for match in PATH_REGEXP.finditer(string): + matched = match.group(0) + escaped = match.group(1) + offset = match.start(0) + path += string[index:offset] + index = offset + len(matched) + + if escaped: + path += escaped[1] + continue + + if path: + tokens.append(path) + path = "" + + prefix, name, capture, group, suffix, asterisk = match.groups()[1:] + repeat = suffix in ("+", "*") + optional = suffix in ("?", "*") + delimiter = prefix or "/" + pattern = capture or group or (".*" if asterisk else "[^%s]+?" % delimiter) + + if not name: + name = key + key += 1 + + token = { + "name": str(name), + "prefix": prefix or "", + "delimiter": delimiter, + "optional": optional, + "repeat": repeat, + "pattern": escape_group(pattern), + } + + tokens.append(token) + + if index < len(string): + path += string[index:] + + if path: + tokens.append(path) + + return tokens + + +def tokens_to_function(tokens): + """Expose a method for transforming tokens into the path function.""" + + def transform(obj): + path = "" + obj = obj or {} + + for key in tokens: + if isinstance(key, str): + path += key + continue + + regexp = re.compile("^%s$" % key["pattern"]) + + value = obj.get(key["name"]) + if value is None: + if key["optional"]: + continue + else: + raise KeyError('Expected "{name}" to be defined'.format(**key)) + + if isinstance(value, list): + if not key["repeat"]: + raise TypeError('Expected "{name}" to not repeat'.format(**key)) + + if not value: + if key["optional"]: + continue + else: + raise ValueError( + 'Expected "{name}" to not be empty'.format(**key) + ) + + for i, val in enumerate(value): + val = str(val) + if not regexp.search(val): + raise ValueError( + 'Expected all "{name}" to match "{pattern}"'.format(**key) + ) + + path += key["prefix"] if i == 0 else key["delimiter"] + path += urllib.parse.quote(val, "") + + continue + + value = str(value) + if not regexp.search(value): + raise ValueError('Expected "{name}" to match "{pattern}"'.format(**key)) + + path += key["prefix"] + urllib.parse.quote( + value.encode("utf8"), "-_.!~*'()" + ) + + return path + + return transform + + +def regexp_to_pattern(regexp, keys): + """ + Generate a pattern based on a compiled regular expression. + + This function exists for a semblance of compatibility with pathToRegexp + and serves basically no purpose beyond making sure the pre-existing tests + continue to pass. + + """ + _match = re.search(r"\((?!\?)", regexp.pattern) + + if _match: + keys.extend( + [ + { + "name": i, + "prefix": None, + "delimiter": None, + "optional": False, + "repeat": False, + "pattern": None, + } + for i in range(len(_match.groups())) + ] + ) + + return regexp.pattern + + +def tokens_to_pattern(tokens, options=None): + """Generate a pattern for the given list of tokens.""" + options = options or {} + + strict = options.get("strict") + end = options.get("end") is not False + route = "" + lastToken = tokens[-1] + endsWithSlash = isinstance(lastToken, str) and lastToken.endswith("/") + + PATTERNS = dict( + REPEAT="(?:{prefix}{capture})*", + OPTIONAL="(?:{prefix}({name}{capture}))?", + REQUIRED="{prefix}({name}{capture})", + ) + + for token in tokens: + if isinstance(token, str): + route += escape_string(token) + continue + + parts = { + "prefix": escape_string(token["prefix"]), + "capture": token["pattern"], + "name": "", + } + + if token["name"] and re.search("[a-zA-Z]", token["name"]): + parts["name"] = "?P<%s>" % re.escape(token["name"]) + + if token["repeat"]: + parts["capture"] += PATTERNS["REPEAT"].format(**parts) + + template = PATTERNS["OPTIONAL" if token["optional"] else "REQUIRED"] + route += template.format(**parts) + + if not strict: + route = route[:-1] if endsWithSlash else route + route += "(?:/(?=$))?" + + if end: + route += "$" + else: + route += "" if strict and endsWithSlash else "(?=/|$)" + + return "^%s" % route + + +def array_to_pattern(paths, keys, options): + """Generate a single pattern from an array of path pattern values.""" + parts = [path_to_pattern(path, keys, options) for path in paths] + + return "(?:%s)" % ("|".join(parts)) + + +def string_to_pattern(path, keys, options): + """ + Generate pattern for a string. + + Equivalent to `tokens_to_pattern(parse(string))`. + """ + tokens = parse(path) + pattern = tokens_to_pattern(tokens, options) + + tokens = filter(lambda t: not isinstance(t, str), tokens) + keys.extend(tokens) + + return pattern + + +def path_to_pattern(path, keys=None, options=None): + """ + Generate a pattern from any kind of path value. + + This function selects the appropriate function array/regex/string paths, + and calls it with the provided values. + """ + keys = keys if keys is not None else [] + options = options if options is not None else {} + + if isinstance(path, REGEXP_TYPE): + return regexp_to_pattern(path, keys) + if isinstance(path, list): + return array_to_pattern(path, keys, options) + return string_to_pattern(path, keys, options) + + +def compile(string): + """Compile a string to a template function for the path.""" + return tokens_to_function(parse(string)) + + +def match(pattrn, requested_url_path): + """Return shorthand to match function.""" + return re.match(pattrn, requested_url_path) + + +def pattern(pathstr): + """Return shorthand to pattern function.""" + return path_to_pattern(pathstr) diff --git a/music_assistant/helpers/web.py b/music_assistant/helpers/web.py index 77c3145b..77dae2b7 100644 --- a/music_assistant/helpers/web.py +++ b/music_assistant/helpers/web.py @@ -1,50 +1,14 @@ """Various helpers for web requests.""" import asyncio +import inspect import ipaddress from datetime import datetime from functools import wraps -from typing import Any +from typing import Any, Callable, Union, get_args, get_origin import ujson from aiohttp import web -from mashumaro.exceptions import MissingField -from music_assistant.helpers.typing import MusicAssistantType -from music_assistant.models.media_types import ( - Album, - Artist, - FullAlbum, - FullTrack, - Playlist, - Radio, - Track, -) - - -async def async_media_items_from_body(mass: MusicAssistantType, data: dict): - """Convert posted body data into media items.""" - if not isinstance(data, list): - data = [data] - - def media_item_from_dict(media_item): - if media_item["media_type"] == "artist": - return Artist.from_dict(media_item) - if media_item["media_type"] == "album": - try: - return FullAlbum.from_dict(media_item) - except MissingField: - return Album.from_dict(media_item) - if media_item["media_type"] == "track": - try: - return FullTrack.from_dict(media_item) - except MissingField: - return Track.from_dict(media_item) - if media_item["media_type"] == "playlist": - return Playlist.from_dict(media_item) - if media_item["media_type"] == "radio": - return Radio.from_dict(media_item) - - return [media_item_from_dict(x) for x in data] def require_local_subnet(func): @@ -76,7 +40,7 @@ def serialize_values(obj): def get_val(val): if hasattr(val, "to_dict"): return val.to_dict() - if isinstance(val, (list, set, filter)): + if isinstance(val, (list, set, filter, {}.values().__class__)): return [get_val(x) for x in val] if isinstance(val, datetime): return val.isoformat() @@ -109,3 +73,73 @@ async def async_json_response(data: Any, status: int = 200): None, json_response, data ) return json_response(data) + + +def api_route(ws_cmd_path): + """Decorate a function as websocket command.""" + + def decorate(func): + func.ws_cmd_path = ws_cmd_path + return func + + return decorate + + +def get_typed_signature(call: Callable) -> inspect.Signature: + """Parse signature of function to do type vaildation and/or api spec generation.""" + signature = inspect.signature(call) + typed_params = [ + inspect.Parameter( + name=param.name, + kind=param.kind, + default=param.default, + annotation=param.annotation, + ) + for param in signature.parameters.values() + ] + typed_signature = inspect.Signature(typed_params) + return typed_signature + + +def parse_arguments(call: Callable, args: dict): + """Parse (and convert) incoming arguments to correct types.""" + final_args = {} + if isinstance(call, type({}.values)): + return args + func_sig = get_typed_signature(call) + for key, value in args.items(): + if key not in func_sig.parameters: + raise KeyError("Invalid parameter: '%s'" % key) + arg_type = func_sig.parameters[key].annotation + final_args[key] = convert_value(key, value, arg_type) + # check for missing args + for key, value in func_sig.parameters.items(): + if value.default is inspect.Parameter.empty: + if key not in final_args: + raise KeyError("Missing parameter: '%s'" % key) + return final_args + + +def convert_value(arg_key, value, arg_type): + """Convert dict value to one of our models.""" + if arg_type == inspect.Parameter.empty: + return value + if get_origin(arg_type) is list: + return [ + convert_value(arg_key, subval, get_args(arg_type)[0]) for subval in value + ] + if get_origin(arg_type) is Union: + # try all possible types + for sub_arg_type in get_args(arg_type): + try: + return convert_value(arg_key, value, sub_arg_type) + except Exception: # pylint: disable=broad-except + pass + raise ValueError("Error parsing '%s', possibly wrong type?" % arg_key) + if hasattr(arg_type, "from_dict"): + return arg_type.from_dict(value) + if value is None: + return value + if arg_type is Any: + return value + return arg_type(value) diff --git a/music_assistant/managers/config.py b/music_assistant/managers/config.py index 3655974e..5f24ee12 100755 --- a/music_assistant/managers/config.py +++ b/music_assistant/managers/config.py @@ -5,7 +5,7 @@ import json import logging import os import shutil -from typing import List +from typing import Any, List from music_assistant.constants import ( CONF_CROSSFADE_DURATION, @@ -13,12 +13,14 @@ from music_assistant.constants import ( CONF_FALLBACK_GAIN_CORRECT, CONF_GROUP_DELAY, CONF_KEY_BASE, - CONF_KEY_BASE_SECURITY, CONF_KEY_METADATA_PROVIDERS, CONF_KEY_MUSIC_PROVIDERS, CONF_KEY_PLAYER_PROVIDERS, CONF_KEY_PLAYER_SETTINGS, CONF_KEY_PLUGINS, + CONF_KEY_SECURITY, + CONF_KEY_SECURITY_APP_TOKENS, + CONF_KEY_SECURITY_LOGIN, CONF_MAX_SAMPLE_RATE, CONF_NAME, CONF_PASSWORD, @@ -30,8 +32,8 @@ from music_assistant.constants import ( EVENT_CONFIG_CHANGED, ) from music_assistant.helpers.encryption import decrypt_string, encrypt_string -from music_assistant.helpers.typing import MusicAssistantType from music_assistant.helpers.util import merge_dict, try_load_json_file +from music_assistant.helpers.web import api_route from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType from music_assistant.models.player import PlayerControlType from music_assistant.models.provider import ProviderType @@ -106,14 +108,10 @@ DEFAULT_PROVIDER_CONFIG_ENTRIES = [ ) ] -DEFAULT_BASE_CONFIG_ENTRIES = { - CONF_KEY_BASE_SECURITY: [ - ConfigEntry( - entry_key="__name__", - entry_type=ConfigEntryType.LABEL, - label=CONF_KEY_BASE_SECURITY, - hidden=True, - ), +DEFAULT_BASE_CONFIG_ENTRIES = {} + +DEFAULT_SECURITY_CONFIG_ENTRIES = { + CONF_KEY_SECURITY_LOGIN: [ ConfigEntry( entry_key=CONF_USERNAME, entry_type=ConfigEntryType.STRING, @@ -130,6 +128,7 @@ DEFAULT_BASE_CONFIG_ENTRIES = { store_hashed=True, ), ], + CONF_KEY_SECURITY_APP_TOKENS: [], } @@ -148,28 +147,74 @@ class ConfigManager: """Initialize class.""" self._data_path = data_path self._stored_config = {} + self._translations = {} self.loading = False self.mass = mass if not os.path.isdir(data_path): raise FileNotFoundError(f"data directory {data_path} does not exist!") - self._translations = self.__get_all_translations() self.__load() + async def async_setup(self): + """Async initialize of module.""" + self._translations = await self.__async_fetch_translations() + + @api_route("config/:conf_base?/:conf_key?") + def all_items(self, conf_base: str = "", conf_key: str = "") -> dict: + """Return entire config as dict.""" + if conf_base and conf_key: + obj = getattr(self, conf_base)[conf_key] + if isinstance(obj, dict): + return obj + return obj.all_items() + if conf_base: + obj = getattr(self, conf_base) + if isinstance(obj, dict): + return obj + return obj.all_items() + return { + key: getattr(self, key).all_items() + for key in [ + CONF_KEY_BASE, + CONF_KEY_SECURITY, + CONF_KEY_MUSIC_PROVIDERS, + CONF_KEY_PLAYER_PROVIDERS, + CONF_KEY_METADATA_PROVIDERS, + CONF_KEY_PLUGINS, + CONF_KEY_PLAYER_SETTINGS, + ] + } + + @api_route("config/:conf_base/:conf_key/:conf_val") + def set_config( + self, conf_base: str, conf_key: str, conf_val: str, new_value: Any + ) -> dict: + """Set value of the given config item.""" + if new_value is None: + self[conf_base][conf_key].pop(conf_val) + else: + self[conf_base][conf_key][conf_val] = new_value + return self[conf_base][conf_key].all_items() + @property def data_path(self): """Return the path where all (configuration) data is stored.""" return self._data_path @property - def translations(self): - """Return all translations.""" - return self._translations + def server_id(self): + """Return the unique identifier for this server.""" + return self.stored_config["server_id"] @property def base(self): """Return base config.""" return BaseSettings(self) + @property + def security(self): + """Return security config.""" + return SecuritySettings(self) + @property def player_settings(self): """Return all player configs.""" @@ -200,6 +245,11 @@ class ConfigManager: """Return the config that is actually stored on disk.""" return self._stored_config + @property + def translations(self): + """Return all translations.""" + return self._translations + def get_provider_config(self, provider_id: str, provider_type: ProviderType = None): """Return config for given provider.""" if not provider_type: @@ -220,17 +270,6 @@ class ConfigManager: """Return config for given player.""" return self.player_settings[player_id] - def validate_credentials(self, username: str, password: str) -> bool: - """Check if credentials matches.""" - if username != self.base["security"]["username"]: - return False - if not password and not self.base["security"]["password"]: - return True - try: - return pbkdf2_sha256.verify(password, self.base["security"]["password"]) - except ValueError: - return False - def __getitem__(self, item_key): """Return item value by key.""" return getattr(self, item_key) @@ -239,16 +278,25 @@ class ConfigManager: """Save config on exit.""" self.save() - def get_translation(self, org_string: str, language: str): - """Get translated value for a string, fallback to english.""" - for lang in [language, "en"]: - translated_value = self._translations.get(lang, {}).get(org_string) - if translated_value: - return translated_value - return org_string + def save(self): + """Save config to file.""" + if self.loading: + LOGGER.warning("save already running") + return + self.loading = True + # backup existing file + conf_file = os.path.join(self.data_path, "config.json") + conf_file_backup = os.path.join(self.data_path, "config.json.backup") + if os.path.isfile(conf_file): + shutil.move(conf_file, conf_file_backup) + # write current config to file + with open(conf_file, "w") as _file: + _file.write(json.dumps(self._stored_config, indent=4)) + LOGGER.info("Config saved!") + self.loading = False @staticmethod - def __get_all_translations() -> dict: + async def __async_fetch_translations() -> dict: """Build a list of all translations.""" base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # get base translations @@ -271,23 +319,6 @@ class ConfigManager: translations = merge_dict(translations, res) return translations - def save(self): - """Save config to file.""" - if self.loading: - LOGGER.warning("save already running") - return - self.loading = True - # backup existing file - conf_file = os.path.join(self.data_path, "config.json") - conf_file_backup = os.path.join(self.data_path, "config.json.backup") - if os.path.isfile(conf_file): - shutil.move(conf_file, conf_file_backup) - # write current config to file - with open(conf_file, "w") as _file: - _file.write(json.dumps(self._stored_config, indent=4)) - LOGGER.info("Config saved!") - self.loading = False - def __load(self): """Load stored config from file.""" self.loading = True @@ -320,10 +351,10 @@ class ConfigBaseItem: """Return ConfigSubItem for given key.""" return ConfigSubItem(self, item_key) - def all_items(self, translation="en") -> dict: + def all_items(self) -> dict: """Return entire config as dict.""" return { - key: copy.deepcopy(ConfigSubItem(self, key).all_items(translation)) + key: copy.deepcopy(ConfigSubItem(self, key).all_items()) for key in self.all_keys() } @@ -331,9 +362,9 @@ class ConfigBaseItem: class BaseSettings(ConfigBaseItem): """Configuration class that holds the base settings.""" - def __init__(self, mass: MusicAssistantType): + def __init__(self, conf_mgr: ConfigManager): """Initialize class.""" - super().__init__(mass, CONF_KEY_BASE) + super().__init__(conf_mgr, CONF_KEY_BASE) def all_keys(self): """Return all possible keys of this Config object.""" @@ -345,12 +376,79 @@ class BaseSettings(ConfigBaseItem): return list(DEFAULT_BASE_CONFIG_ENTRIES[child_key]) +class SecuritySettings(ConfigBaseItem): + """Configuration class that holds the security settings.""" + + def __init__(self, conf_mgr: ConfigManager): + """Initialize class.""" + super().__init__(conf_mgr, CONF_KEY_SECURITY) + # make sure the keys exist in config dict + if CONF_KEY_SECURITY not in conf_mgr.stored_config: + conf_mgr.stored_config[CONF_KEY_SECURITY][CONF_KEY_SECURITY_APP_TOKENS] = {} + if ( + CONF_KEY_SECURITY_APP_TOKENS + not in conf_mgr.stored_config[CONF_KEY_SECURITY] + ): + conf_mgr.stored_config[CONF_KEY_SECURITY][CONF_KEY_SECURITY_APP_TOKENS] = {} + + def all_keys(self): + """Return all possible keys of this Config object.""" + return [CONF_KEY_SECURITY_LOGIN, CONF_KEY_SECURITY_APP_TOKENS] + + def add_app_token(self, token_info: dict): + """Add token to config.""" + client_id = token_info["client_id"] + self[CONF_KEY_SECURITY_APP_TOKENS][client_id] = token_info + + def revoke_app_token(self, client_id): + """Revoke a token registered for an app.""" + self[CONF_KEY_SECURITY_APP_TOKENS].pop(client_id) + + def is_token_revoked(self, token_info: dict): + """Return bool is token is revoked.""" + if not token_info.get("app_id"): + # short lived token does not have app_id and is not stored so can't be revoked + return False + return self[CONF_KEY_SECURITY_APP_TOKENS].get(token_info["client_id"]) is None + + def validate_credentials(self, username: str, password: str) -> bool: + """Check if credentials matches.""" + if username != self[CONF_KEY_SECURITY_LOGIN][CONF_USERNAME]: + return False + try: + return pbkdf2_sha256.verify( + password, self[CONF_KEY_SECURITY_LOGIN][CONF_PASSWORD] + ) + except ValueError: + return False + + def get_config_entries(self, child_key) -> List[ConfigEntry]: + """Return all base config entries.""" + if child_key == CONF_KEY_SECURITY_LOGIN: + return list(DEFAULT_SECURITY_CONFIG_ENTRIES[CONF_KEY_SECURITY_LOGIN]) + if child_key == CONF_KEY_SECURITY_APP_TOKENS: + return [ + ConfigEntry( + entry_key=client_id, + entry_type=ConfigEntryType.DICT, + default_value={}, + label=token_info["app_id"], + description="App connected to MusicAssistant API", + store_hashed=False, + ) + for client_id, token_info in self.conf_mgr.stored_config[ + CONF_KEY_SECURITY + ][CONF_KEY_SECURITY_APP_TOKENS].items() + ] + return [] + + class PlayerSettings(ConfigBaseItem): """Configuration class that holds the player settings.""" - def __init__(self, mass: MusicAssistantType): + def __init__(self, conf_mgr: ConfigManager): """Initialize class.""" - super().__init__(mass, CONF_KEY_PLAYER_SETTINGS) + super().__init__(conf_mgr, CONF_KEY_PLAYER_SETTINGS) def all_keys(self): """Return all possible keys of this Config object.""" @@ -432,13 +530,7 @@ class ProviderSettings(ConfigBaseItem): """Return all config entries for the given provider.""" provider = self.mass.get_provider(child_key) if provider: - # append a hidden label with the provider's name - specials = [ - ConfigEntry( - "__name__", ConfigEntryType.LABEL, label=provider.name, hidden=True - ) - ] - return specials + DEFAULT_PROVIDER_CONFIG_ENTRIES + provider.config_entries + return DEFAULT_PROVIDER_CONFIG_ENTRIES + provider.config_entries return DEFAULT_PROVIDER_CONFIG_ENTRIES @@ -456,10 +548,10 @@ class ConfigSubItem: self.conf_mgr = conf_parent.conf_mgr self.parent_conf_key = conf_parent.conf_key - def all_items(self, translation="en") -> dict: + def all_items(self) -> dict: """Return entire config as dict.""" return { - item.entry_key: self.get_entry(item.entry_key, translation) + item.entry_key: self.get_entry(item.entry_key) for item in self.conf_parent.get_config_entries(self.conf_key) } @@ -482,7 +574,7 @@ class ConfigSubItem: return decrypted_value return entry.value - def get_entry(self, key, translation=None): + def get_entry(self, key): """Return complete ConfigEntry for specified key.""" stored_config = self.conf_mgr.stored_config.get(self.conf_parent.conf_key, {}) stored_config = stored_config.get(self.conf_key, {}) @@ -494,17 +586,6 @@ class ConfigSubItem: else: # use default value for config entry conf_entry.value = conf_entry.default_value - # get translated labels - if translation is not None: - for entry_subkey in ["label", "description", "__name__"]: - org_value = getattr(conf_entry, entry_subkey, None) - if not org_value: - org_value = conf_entry.entry_key - translated_value = self.conf_parent.conf_mgr.get_translation( - org_value, translation - ) - if translated_value and translated_value != org_value: - setattr(conf_entry, entry_subkey, translated_value) return conf_entry raise KeyError( "%s\\%s has no key %s!" % (self.conf_parent.conf_key, self.conf_key, key) @@ -572,3 +653,13 @@ class ConfigSubItem: return # raise KeyError if we're trying to set a value not defined as ConfigEntry raise KeyError + + def pop(self, key): + """Delete ConfigEntry for specified key if exists.""" + stored_config = self.conf_mgr.stored_config.get(self.conf_parent.conf_key, {}) + stored_config = stored_config.get(self.conf_key, {}) + cur_val = stored_config.get(key, None) + if cur_val: + del stored_config[key] + self.conf_mgr.save() + return cur_val diff --git a/music_assistant/managers/database.py b/music_assistant/managers/database.py index a62d5cc2..3e4cc527 100755 --- a/music_assistant/managers/database.py +++ b/music_assistant/managers/database.py @@ -545,6 +545,20 @@ class DatabaseManager: return item return None + async def async_get_albums_from_provider_ids( + self, provider_id: Union[str, List[str]], prov_item_ids: List[str] + ) -> dict: + """Get album records for the given prov_ids.""" + provider_ids = provider_id if isinstance(provider_id, list) else [provider_id] + prov_id_str = ",".join([f'"{x}"' for x in provider_ids]) + prov_item_id_str = ",".join([f'"{x}"' for x in prov_item_ids]) + sql_query = f"""WHERE item_id in + (SELECT item_id FROM provider_mappings + WHERE provider in ({prov_id_str}) AND media_type = 'album' + AND prov_item_id in ({prov_item_id_str}) + )""" + return await self.async_get_albums(sql_query) + async def async_add_album(self, album: Album): """Add a new album record to the database.""" async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn: diff --git a/music_assistant/managers/library.py b/music_assistant/managers/library.py index 36dd8823..10399d6c 100755 --- a/music_assistant/managers/library.py +++ b/music_assistant/managers/library.py @@ -7,6 +7,7 @@ from typing import Any, List from music_assistant.constants import EVENT_MUSIC_SYNC_STATUS, EVENT_PROVIDER_REGISTERED from music_assistant.helpers.util import callback, run_periodic +from music_assistant.helpers.web import api_route from music_assistant.models.media_types import ( Album, Artist, @@ -80,24 +81,29 @@ class LibraryManager: ################ GET MediaItems that are added in the library ################ + @api_route("library/artists") async def async_get_library_artists(self, orderby: str = "name") -> List[Artist]: """Return all library artists, optionally filtered by provider.""" return await self.mass.database.async_get_library_artists(orderby=orderby) + @api_route("library/albums") async def async_get_library_albums(self, orderby: str = "name") -> List[Album]: """Return all library albums, optionally filtered by provider.""" return await self.mass.database.async_get_library_albums(orderby=orderby) + @api_route("library/tracks") async def async_get_library_tracks(self, orderby: str = "name") -> List[Track]: """Return all library tracks, optionally filtered by provider.""" return await self.mass.database.async_get_library_tracks(orderby=orderby) + @api_route("library/playlists") async def async_get_library_playlists( self, orderby: str = "name" ) -> List[Playlist]: """Return all library playlists, optionally filtered by provider.""" return await self.mass.database.async_get_library_playlists(orderby=orderby) + @api_route("library/radios") async def async_get_library_radios(self, orderby: str = "name") -> List[Playlist]: """Return all library radios, optionally filtered by provider.""" return await self.mass.database.async_get_library_radios(orderby=orderby) @@ -116,10 +122,11 @@ class LibraryManager: return radio return None - async def async_library_add(self, media_items: List[MediaItem]): + @api_route("library/add") + async def async_library_add(self, items: List[MediaItem]): """Add media item(s) to the library.""" result = False - for media_item in media_items: + for media_item in items: # add to provider's libraries for prov in media_item.provider_ids: provider = self.mass.get_provider(prov.provider) @@ -134,10 +141,11 @@ class LibraryManager: ) return result - async def async_library_remove(self, media_items: List[MediaItem]): + @api_route("library/remove") + async def async_library_remove(self, items: List[MediaItem]): """Remove media item(s) from the library.""" result = False - for media_item in media_items: + for media_item in items: # remove from provider's libraries for prov in media_item.provider_ids: provider = self.mass.get_provider(prov.provider) @@ -152,6 +160,7 @@ class LibraryManager: ) return result + @api_route("library/playlists/:db_playlist_id/tracks/add") async def async_add_playlist_tracks(self, db_playlist_id: int, tracks: List[Track]): """Add tracks to playlist - make sure we dont add duplicates.""" # we can only edit playlists that are in the database (marked as editable) @@ -194,9 +203,8 @@ class LibraryManager: # actually add the tracks to the playlist on the provider if track_ids_to_add: # invalidate cache - await self.mass.database.async_update_playlist( - playlist.item_id, "checksum", str(time.time()) - ) + playlist.checksum = str(time.time()) + await self.mass.database.async_update_playlist(playlist.item_id, playlist) # return result of the action on the provider provider = self.mass.get_provider(playlist_prov.provider) return await provider.async_add_playlist_tracks( @@ -204,6 +212,7 @@ class LibraryManager: ) return False + @api_route("library/playlists/:db_playlist_id/tracks/remove") async def async_remove_playlist_tracks(self, db_playlist_id, tracks: List[Track]): """Remove tracks from playlist.""" # we can only edit playlists that are in the database (marked as editable) @@ -221,9 +230,8 @@ class LibraryManager: # actually remove the tracks from the playlist on the provider if track_ids_to_remove: # invalidate cache - await self.mass.database.async_update_playlist( - playlist.item_id, "checksum", str(time.time()) - ) + playlist.checksum = str(time.time()) + await self.mass.database.async_update_playlist(playlist.item_id, playlist) provider = self.mass.get_provider(prov_playlist.provider) return await provider.async_remove_playlist_tracks( prov_playlist.item_id, track_ids_to_remove @@ -266,9 +274,10 @@ class LibraryManager: for item in await music_provider.async_get_library_artists(): db_item = await self.mass.music.async_get_artist(item.item_id, provider_id) cur_db_ids.append(db_item.item_id) - await self.mass.database.async_add_to_library( - db_item.item_id, MediaType.Artist, provider_id - ) + if not db_item.in_library: + await self.mass.database.async_add_to_library( + db_item.item_id, MediaType.Artist, provider_id + ) # process deletions for db_id in prev_db_ids: if db_id not in cur_db_ids: @@ -291,9 +300,10 @@ class LibraryManager: # album availability changed, sort this out with auto matching magic db_album = await self.mass.music.async_match_album(db_album) cur_db_ids.append(db_album.item_id) - await self.mass.database.async_add_to_library( - db_album.item_id, MediaType.Album, provider_id - ) + if not db_album.in_library: + await self.mass.database.async_add_to_library( + db_album.item_id, MediaType.Album, provider_id + ) # precache album tracks for album_track in await self.mass.music.async_get_album_tracks( item.item_id, provider_id @@ -326,7 +336,7 @@ class LibraryManager: # track availability changed, sort this out with auto matching magic db_item = await self.mass.music.async_add_track(item) cur_db_ids.append(db_item.item_id) - if db_item.item_id not in prev_db_ids: + if not db_item.in_library: await self.mass.database.async_add_to_library( db_item.item_id, MediaType.Track, provider_id ) diff --git a/music_assistant/managers/music.py b/music_assistant/managers/music.py index 0457165b..7ac446bb 100755 --- a/music_assistant/managers/music.py +++ b/music_assistant/managers/music.py @@ -13,6 +13,7 @@ from music_assistant.helpers.compare import ( from music_assistant.helpers.encryption import async_encrypt_string from music_assistant.helpers.musicbrainz import MusicBrainz from music_assistant.helpers.util import unique_item_ids +from music_assistant.helpers.web import api_route from music_assistant.models.media_types import ( Album, Artist, @@ -42,7 +43,6 @@ class MusicManager: async def async_setup(self): """Async initialize of module.""" - # nothing to do @property def providers(self) -> List[MusicProvider]: @@ -51,6 +51,7 @@ class MusicManager: ################ GET MediaItem(s) by id and provider ################# + @api_route("items/:media_type/:provider_id/:item_id") async def async_get_item( self, item_id: str, provider_id: str, media_type: MediaType ): @@ -67,6 +68,7 @@ class MusicManager: return await self.async_get_radio(item_id, provider_id) return None + @api_route("artists/:provider_id/:item_id") async def async_get_artist( self, item_id: str, provider_id: str, refresh=False ) -> Artist: @@ -81,7 +83,10 @@ class MusicManager: elif db_item: return db_item artist = await self.__async_get_provider_artist(item_id, provider_id) - return await self.async_add_artist(artist) + # fetching an artist is slow because of musicbrainz and metadata lookup + # so we return the provider object + self.mass.add_job(self.async_add_artist(artist)) + return artist async def __async_get_provider_artist( self, item_id: str, provider_id: str @@ -100,6 +105,7 @@ class MusicManager: ) return artist + @api_route("albums/:provider_id/:item_id") async def async_get_album( self, item_id: str, provider_id: str, refresh=False ) -> Album: @@ -131,6 +137,7 @@ class MusicManager: ) return album + @api_route("tracks/:provider_id/:item_id") async def async_get_track( self, item_id: str, @@ -174,6 +181,7 @@ class MusicManager: ) return track + @api_route("playlists/:provider_id/:item_id") async def async_get_playlist(self, item_id: str, provider_id: str) -> Playlist: """Return playlist details for the given provider playlist id.""" assert item_id and provider_id @@ -189,6 +197,7 @@ class MusicManager: db_item = await self.mass.database.async_add_playlist(item_details) return db_item + @api_route("radios/:provider_id/:item_id") async def async_get_radio(self, item_id: str, provider_id: str) -> Radio: """Return radio details for the given provider playlist id.""" assert item_id and provider_id @@ -204,6 +213,7 @@ class MusicManager: db_item = await self.mass.database.async_add_radio(item_details) return db_item + @api_route("albums/:provider_id/:item_id/tracks") async def async_get_album_tracks( self, item_id: str, provider_id: str ) -> List[Track]: @@ -236,6 +246,7 @@ class MusicManager: for item in all_prov_tracks ] + @api_route("albums/:provider_id/:item_id/versions") async def async_get_album_versions( self, item_id: str, provider_id: str ) -> List[Album]: @@ -255,6 +266,7 @@ class MusicManager: result.append(item) return result + @api_route("tracks/:provider_id/:item_id/versions") async def async_get_track_versions( self, item_id: str, provider_id: str ) -> List[Track]: @@ -279,6 +291,7 @@ class MusicManager: break return result + @api_route("playlists/:provider_id/:item_id/tracks") async def async_get_playlist_tracks( self, item_id: str, provider_id: str ) -> List[Track]: @@ -338,11 +351,12 @@ class MusicManager: item.artists = unique_item_ids(item.artists) return item + @api_route("artists/:provider_id/:item_id/tracks") async def async_get_artist_toptracks( - self, artist_id: str, provider_id: str + self, item_id: str, provider_id: str ) -> List[Track]: """Return top tracks for an artist.""" - artist = await self.async_get_artist(artist_id, provider_id) + artist = await self.async_get_artist(item_id, provider_id) # get results from all providers all_prov_tracks = [ track @@ -367,60 +381,65 @@ class MusicManager: ) async def __async_get_provider_artist_toptracks( - self, artist_id: str, provider_id: str + self, item_id: str, provider_id: str ) -> List[Track]: """Return top tracks for an artist on given provider.""" provider = self.mass.get_provider(provider_id) if not provider or not provider.available: LOGGER.error("Provider %s is not available", provider_id) return [] - cache_key = f"{provider_id}.artist_toptracks.{artist_id}" + cache_key = f"{provider_id}.artist_toptracks.{item_id}" return await async_cached( self.cache, cache_key, provider.async_get_artist_toptracks, - artist_id, + item_id, ) + @api_route("artists/:provider_id/:item_id/albums") async def async_get_artist_albums( - self, artist_id: str, provider_id: str + self, item_id: str, provider_id: str ) -> List[Album]: """Return (all) albums for an artist.""" - if provider_id == "database": - # albums from all providers - item_ids = [] - result = [] - artist = await self.mass.database.async_get_artist(artist_id) - for prov_id in artist.provider_ids: - provider = self.mass.get_provider(prov_id.provider) - if not provider or MediaType.Album not in provider.supported_mediatypes: - continue - for item in await self.async_get_artist_albums( - prov_id.item_id, prov_id.provider - ): - if item.item_id not in item_ids: - result.append(item) - item_ids.append(item.item_id) - return result - else: - # items from provider - provider = self.mass.get_provider(provider_id) - cache_key = f"{provider_id}.artist_albums.{artist_id}" - result = [] - for item in await async_cached( - self.cache, cache_key, provider.async_get_artist_albums, artist_id - ): - assert item.item_id and item.provider and item.artist - db_item = await self.mass.database.async_get_album_by_prov_id( - item.provider, item.item_id - ) - if db_item: - # return database album instead if we have a match - result.append(db_item) - else: - result.append(item) - return result + artist = await self.async_get_artist(item_id, provider_id) + # get results from all providers + all_prov_albums = [ + album + for prov_albums in await asyncio.gather( + *[ + self.__async_get_provider_artist_albums(item.item_id, item.provider) + for item in artist.provider_ids + ] + ) + for album in prov_albums + ] + # retrieve list of db items + db_tracks = await self.mass.database.async_get_albums_from_provider_ids( + [x.provider for x in artist.provider_ids], + [x.item_id for x in all_prov_albums], + ) + # combine provider tracks with db tracks and filter duplicate itemid's + return unique_item_ids( + [await self.__process_item(item, db_tracks) for item in all_prov_albums] + ) + async def __async_get_provider_artist_albums( + self, item_id: str, provider_id: str + ) -> List[Album]: + """Return albums for an artist on given provider.""" + provider = self.mass.get_provider(provider_id) + if not provider or not provider.available: + LOGGER.error("Provider %s is not available", provider_id) + return [] + cache_key = f"{provider_id}.artist_albums.{item_id}" + return await async_cached( + self.cache, + cache_key, + provider.async_get_artist_albums, + item_id, + ) + + @api_route("search/:provider_id") async def async_search_provider( self, search_query: str, @@ -450,6 +469,7 @@ class MusicManager: limit, ) + @api_route("search") async def async_global_search( self, search_query, media_types: List[MediaType], limit: int = 10 ) -> SearchResult: @@ -457,7 +477,7 @@ class MusicManager: Perform global search for media items on all providers. :param search_query: Search query. - :param media_types: A list of media_types to include. All types if None. + :param media_types: A list of media_types to include. :param limit: number of items to return in the search (per type). """ result = SearchResult([], [], [], [], []) @@ -556,6 +576,7 @@ class MusicManager: db_item = await self.mass.database.async_add_artist(artist) # also fetch same artist on all providers self.mass.add_background_task(self.async_match_artist(db_item)) + self.mass.signal_event("artist added", db_item) return db_item async def async_add_album(self, album: Album) -> int: @@ -565,6 +586,7 @@ class MusicManager: db_item = await self.mass.database.async_add_album(album) # also fetch same album on all providers self.mass.add_background_task(self.async_match_album(db_item)) + self.mass.signal_event("album added", db_item) return db_item async def async_add_track(self, track: Track) -> int: @@ -581,7 +603,7 @@ class MusicManager: async def __async_get_artist_musicbrainz_id(self, artist: Artist): """Fetch musicbrainz id by performing search using the artist name, albums and tracks.""" # try with album first - for lookup_album in await self.async_get_artist_albums( + for lookup_album in await self.__async_get_provider_artist_albums( artist.item_id, artist.provider ): if not lookup_album: @@ -594,7 +616,7 @@ class MusicManager: if musicbrainz_id: return musicbrainz_id # fallback to track - for lookup_track in await self.async_get_artist_toptracks( + for lookup_track in await self.__async_get_provider_artist_toptracks( artist.item_id, artist.provider ): if not lookup_track: @@ -612,7 +634,7 @@ class MusicManager: async def async_match_artist(self, db_artist: Artist): """ - Try to find matching artists on all providers for the provided (database) artist_id. + Try to find matching artists on all providers for the provided (database) item_id. This is used to link objects of different providers together. """ diff --git a/music_assistant/managers/players.py b/music_assistant/managers/players.py index cf51dab7..a277e30d 100755 --- a/music_assistant/managers/players.py +++ b/music_assistant/managers/players.py @@ -1,20 +1,17 @@ """PlayerManager: Orchestrates all players from player providers.""" import logging -from typing import List, Optional +from typing import List, Optional, Union from music_assistant.constants import ( CONF_POWER_CONTROL, CONF_VOLUME_CONTROL, EVENT_PLAYER_ADDED, - EVENT_PLAYER_CONTROL_REGISTERED, - EVENT_PLAYER_CONTROL_UPDATED, EVENT_PLAYER_REMOVED, - EVENT_REGISTER_PLAYER_CONTROL, - EVENT_UNREGISTER_PLAYER_CONTROL, ) from music_assistant.helpers.typing import MusicAssistantType from music_assistant.helpers.util import callback, run_periodic, try_parse_int +from music_assistant.helpers.web import api_route from music_assistant.models.media_types import MediaItem, MediaType from music_assistant.models.player import ( PlaybackState, @@ -42,18 +39,20 @@ class PlayerManager: self._player_queues = {} self._poll_ticks = 0 self._controls = {} - self.mass.add_event_listener( - self.__handle_websocket_player_control_event, - [ - EVENT_REGISTER_PLAYER_CONTROL, - EVENT_UNREGISTER_PLAYER_CONTROL, - EVENT_PLAYER_CONTROL_UPDATED, - ], - ) + # self.mass.add_event_listener( + # self.__handle_websocket_player_control_event, + # [ + # EVENT_REGISTER_PLAYER_CONTROL, + # EVENT_UNREGISTER_PLAYER_CONTROL, + # EVENT_PLAYER_CONTROL_UPDATED, + # ], + # ) async def async_setup(self): """Async initialize of module.""" self.mass.add_job(self.poll_task()) + self.mass.web.register_api_route("players", self._player_states.values) + self.mass.web.register_api_route("players/queues", self._player_queues.values) async def async_close(self): """Handle stop/shutdown.""" @@ -97,6 +96,7 @@ class PlayerManager: return self.mass.get_providers(ProviderType.PLAYER_PROVIDER) @callback + @api_route("players/:player_id") def get_player_state(self, player_id: str) -> PlayerState: """Return PlayerState by player_id or None if player does not exist.""" return self._player_states.get(player_id) @@ -116,6 +116,7 @@ class PlayerManager: return self.mass.get_provider(player.provider_id) if player else None @callback + @api_route("players/:player_id/queue") def get_player_queue(self, player_id: str) -> PlayerQueue: """Return player's queue by player_id or None if player does not exist.""" player_state = self.get_player_state(player_id) @@ -125,6 +126,13 @@ class PlayerManager: return self._player_queues.get(player_state.active_queue) @callback + @api_route("players/:queue_id/queue/items") + def get_player_queue_items(self, queue_id: str) -> List[QueueItem]: + """Return player's queueitems by player_id or None if player does not exist.""" + return self.get_player_queue(queue_id).items + + @callback + @api_route("players/controls/:control_id") def get_player_control(self, control_id: str) -> PlayerControl: """Return PlayerControl by id.""" if control_id not in self._controls: @@ -133,6 +141,7 @@ class PlayerManager: return self._controls[control_id] @callback + @api_route("players/controls") def get_player_controls( self, filter_type: Optional[PlayerControlType] = None ) -> List[PlayerControl]: @@ -189,12 +198,14 @@ class PlayerManager: if player: await self._player_states[player.player_id].async_update(player) - async def async_register_player_control(self, control: PlayerControl): + @api_route("players/controls/:control_id/register") + async def async_register_player_control( + self, control_id: str, control: PlayerControl + ): """Register a playercontrol with the player manager.""" - # control.mass = self.mass control.mass = self.mass control.type = PlayerControlType(control.type) - self._controls[control.control_id] = control + self._controls[control_id] = control LOGGER.info( "New PlayerControl (%s) registered: %s\\%s", control.type, @@ -204,7 +215,7 @@ class PlayerManager: # update all players using this playercontrol for player_state in self.player_states: conf = self.mass.config.player_settings[player_state.player_id] - if control.control_id in [ + if control_id in [ conf.get(CONF_POWER_CONTROL), conf.get(CONF_VOLUME_CONTROL), ]: @@ -212,14 +223,17 @@ class PlayerManager: self.async_trigger_player_update(player_state.player_id) ) - async def async_update_player_control(self, control: PlayerControl): + @api_route("players/controls/:control_id/update") + async def async_update_player_control( + self, control_id: str, control: PlayerControl + ): """Update a playercontrol's state on the player manager.""" - if control.control_id not in self._controls: - return await self.async_register_player_control(control) + if control_id not in self._controls: + return await self.async_register_player_control(control_id, control) new_state = control.state - if self._controls[control.control_id].state == new_state: + if self._controls[control_id].state == new_state: return - self._controls[control.control_id].state = new_state + self._controls[control_id].state = new_state LOGGER.debug( "PlayerControl %s\\%s updated - new state: %s", control.provider, @@ -229,7 +243,7 @@ class PlayerManager: # update all players using this playercontrol for player_state in self.player_states: conf = self.mass.config.player_settings[player_state.player_id] - if control.control_id in [ + if control_id in [ conf.get(CONF_POWER_CONTROL), conf.get(CONF_VOLUME_CONTROL), ]: @@ -239,17 +253,18 @@ class PlayerManager: # SERVICE CALLS / PLAYER COMMANDS + @api_route("players/:player_id/play_media") async def async_play_media( self, player_id: str, - media_items: List[MediaItem], + items: Union[MediaItem, List[MediaItem]], queue_opt: QueueOption = QueueOption.Play, ): """ Play media item(s) on the given player. :param player_id: player_id of the player to handle the command. - :param media_item: media item(s) that should be played (single item or list of items) + :param items: media item(s) that should be played (single item or list of items) :param queue_opt: QueueOption.Play -> Insert new items in queue and start playing at inserted position QueueOption.Replace -> Replace queue contents with these items @@ -257,8 +272,10 @@ class PlayerManager: QueueOption.Add -> Append new items at end of the queue """ # a single item or list of items may be provided + if not isinstance(items, list): + items = [items] queue_items = [] - for media_item in media_items: + for media_item in items: # collect tracks to play if media_item.media_type == MediaType.Artist: tracks = await self.mass.music.async_get_artist_toptracks( @@ -273,7 +290,12 @@ class PlayerManager: media_item.item_id, provider_id=media_item.provider ) else: - tracks = [media_item] # single track + # single track + tracks = [ + await self.mass.music.async_get_track( + media_item.item_id, provider_id=media_item.provider + ) + ] for track in tracks: if not track.available: continue @@ -300,6 +322,7 @@ class PlayerManager: if queue_opt == QueueOption.Add: return await player_queue.async_append(queue_items) + @api_route("players/:player_id/play_uri") async def async_cmd_play_uri(self, player_id: str, uri: str): """ Play the specified uri/url on the given player. @@ -327,6 +350,7 @@ class PlayerManager: player_queue = self.get_player_queue(player_id) return await player_queue.async_insert([queue_item], 0) + @api_route("players/:player_id/cmd/stop") async def async_cmd_stop(self, player_id: str) -> None: """ Send STOP command to given player. @@ -336,10 +360,11 @@ class PlayerManager: player_state = self.get_player_state(player_id) if not player_state: return - queue_player_id = player_state.active_queue - queue_player = self.get_player(queue_player_id) + queue_id = player_state.active_queue + queue_player = self.get_player(queue_id) return await queue_player.async_cmd_stop() + @api_route("players/:player_id/cmd/play") async def async_cmd_play(self, player_id: str) -> None: """ Send PLAY command to given player. @@ -349,15 +374,16 @@ class PlayerManager: player_state = self.get_player_state(player_id) if not player_state: return - queue_player_id = player_state.active_queue - queue_player = self.get_player(queue_player_id) + queue_id = player_state.active_queue + queue_player = self.get_player(queue_id) # unpause if paused else resume queue if queue_player.state == PlaybackState.Paused: return await queue_player.async_cmd_play() # power on at play request await self.async_cmd_power_on(player_id) - return await self._player_queues[queue_player_id].async_resume() + return await self._player_queues[queue_id].async_resume() + @api_route("players/:player_id/cmd/pause") async def async_cmd_pause(self, player_id: str): """ Send PAUSE command to given player. @@ -367,10 +393,11 @@ class PlayerManager: player_state = self.get_player_state(player_id) if not player_state: return - queue_player_id = player_state.active_queue - queue_player = self.get_player(queue_player_id) + queue_id = player_state.active_queue + queue_player = self.get_player(queue_id) return await queue_player.async_cmd_pause() + @api_route("players/:player_id/cmd/play_pause") async def async_cmd_play_pause(self, player_id: str): """ Toggle play/pause on given player. @@ -384,6 +411,7 @@ class PlayerManager: return await self.async_cmd_pause(player_id) return await self.async_cmd_play(player_id) + @api_route("players/:player_id/cmd/next") async def async_cmd_next(self, player_id: str): """ Send NEXT TRACK command to given player. @@ -393,9 +421,10 @@ class PlayerManager: player_state = self.get_player_state(player_id) if not player_state: return - queue_player_id = player_state.active_queue - return await self.get_player_queue(queue_player_id).async_next() + queue_id = player_state.active_queue + return await self.get_player_queue(queue_id).async_next() + @api_route("players/:player_id/cmd/previous") async def async_cmd_previous(self, player_id: str): """ Send PREVIOUS TRACK command to given player. @@ -405,9 +434,10 @@ class PlayerManager: player_state = self.get_player_state(player_id) if not player_state: return - queue_player_id = player_state.active_queue - return await self.get_player_queue(queue_player_id).async_previous() + queue_id = player_state.active_queue + return await self.get_player_queue(queue_id).async_previous() + @api_route("players/:player_id/cmd/power_on") async def async_cmd_power_on(self, player_id: str) -> None: """ Send POWER ON command to given player. @@ -426,6 +456,7 @@ class PlayerManager: if control: await control.async_set_state(True) + @api_route("players/:player_id/cmd/power_off") async def async_cmd_power_off(self, player_id: str) -> None: """ Send POWER OFF command to given player. @@ -472,6 +503,7 @@ class PlayerManager: if not has_powered_players: self.mass.add_job(self.async_cmd_power_off(parent_player_id)) + @api_route("players/:player_id/cmd/power_toggle") async def async_cmd_power_toggle(self, player_id: str): """ Send POWER TOGGLE command to given player. @@ -485,6 +517,7 @@ class PlayerManager: return await self.async_cmd_power_off(player_id) return await self.async_cmd_power_on(player_id) + @api_route("players/:player_id/cmd/volume_set/:volume_level") async def async_cmd_volume_set(self, player_id: str, volume_level: int) -> None: """ Send volume level command to given player. @@ -518,6 +551,8 @@ class PlayerManager: else: volume_dif_percent = volume_dif / cur_volume for child_player_id in player_state.group_childs: + if child_player_id == player_id: + continue child_player = self.get_player_state(child_player_id) if child_player and child_player.available and child_player.powered: cur_child_volume = child_player.volume_level @@ -529,6 +564,7 @@ class PlayerManager: else: await player_state.player.async_cmd_volume_set(volume_level) + @api_route("players/:player_id/cmd/volume_up") async def async_cmd_volume_up(self, player_id: str): """ Send volume UP command to given player. @@ -543,6 +579,7 @@ class PlayerManager: new_level = 100 return await self.async_cmd_volume_set(player_id, new_level) + @api_route("players/:player_id/cmd/volume_down") async def async_cmd_volume_down(self, player_id: str): """ Send volume DOWN command to given player. @@ -557,7 +594,8 @@ class PlayerManager: new_level = 0 return await self.async_cmd_volume_set(player_id, new_level) - async def async_cmd_volume_mute(self, player_id: str, is_muted=False): + @api_route("players/:player_id/cmd/volume_mute/:is_muted") + async def async_cmd_volume_mute(self, player_id: str, is_muted: bool = False): """ Send MUTE command to given player. @@ -570,6 +608,36 @@ class PlayerManager: # TODO: handle mute on volumecontrol? return await player_state.player.async_cmd_volume_mute(is_muted) + @api_route("players/:queue_id/queue/cmd/shuffle_enabled/:enable_shuffle") + async def async_player_queue_cmd_set_shuffle( + self, queue_id: str, enable_shuffle: bool = False + ): + """ + Send enable/disable shuffle command to given playerqueue. + + :param queue_id: player_id of the playerqueue to handle the command. + :param enable_shuffle: bool with the new ahuffle state. + """ + player_queue = self.get_player_queue(queue_id) + if not player_queue: + return + return await player_queue.async_set_shuffle_enabled(enable_shuffle) + + @api_route("players/:queue_id/queue/cmd/repeat_enabled/:enable_repeat") + async def async_player_queue_cmd_set_repeat( + self, queue_id: str, enable_repeat: bool = False + ): + """ + Send enable/disable repeat command to given playerqueue. + + :param queue_id: player_id of the playerqueue to handle the command. + :param enable_repeat: bool with the new ahuffle state. + """ + player_queue = self.get_player_queue(queue_id) + if not player_queue: + return + return await player_queue.async_set_repeat_enabled(enable_repeat) + # OTHER/HELPER FUNCTIONS async def async_get_gain_correct( @@ -591,12 +659,12 @@ class PlayerManager: gain_correct = round(gain_correct, 2) return gain_correct - async def __handle_websocket_player_control_event(self, msg, msg_details): - """Handle player controls over the websockets api.""" - if msg in [EVENT_REGISTER_PLAYER_CONTROL, EVENT_PLAYER_CONTROL_UPDATED]: - # create or update a playercontrol registered through the websockets api - control = PlayerControl(**msg_details) - await self.async_update_player_control(control) - # send confirmation to the client that the register was successful - if msg == EVENT_PLAYER_CONTROL_REGISTERED: - self.mass.signal_event(EVENT_PLAYER_CONTROL_REGISTERED, control) + # async def __handle_websocket_player_control_event(self, msg, msg_details): + # """Handle player controls over the websockets api.""" + # if msg in [EVENT_REGISTER_PLAYER_CONTROL, EVENT_PLAYER_CONTROL_UPDATED]: + # # create or update a playercontrol registered through the websockets api + # control = PlayerControl(**msg_details) + # await self.async_update_player_control(control) + # # send confirmation to the client that the register was successful + # if msg == EVENT_PLAYER_CONTROL_REGISTERED: + # self.mass.signal_event(EVENT_PLAYER_CONTROL_REGISTERED, control) diff --git a/music_assistant/mass.py b/music_assistant/mass.py index e22ddea7..8b979dc9 100644 --- a/music_assistant/mass.py +++ b/music_assistant/mass.py @@ -84,6 +84,7 @@ class MusicAssistant: ) # run migrations if needed await check_migrations(self) + await self._config.async_setup() await self._cache.async_setup() await self._music.async_setup() await self._players.async_setup() @@ -268,8 +269,9 @@ class MusicAssistant: target: target to call. args: parameters for method to call. """ - if self._background_tasks: - self._background_tasks.put_nowait(task) + if self._background_tasks is None: + self._background_tasks = asyncio.Queue() + self._background_tasks.put_nowait(task) @callback def add_job( @@ -316,7 +318,8 @@ class MusicAssistant: async def __process_background_tasks(self): """Background tasks that takes care of slowly handling jobs in the queue.""" - self._background_tasks = asyncio.Queue() + if self._background_tasks is None: + self._background_tasks = asyncio.Queue() while not self.exit: task = await self._background_tasks.get() await task @@ -325,7 +328,7 @@ class MusicAssistant: async def __async_setup_discovery(self) -> None: """Make this Music Assistant instance discoverable on the network.""" zeroconf_type = "_music-assistant._tcp.local." - discovery_info = self.web.discovery_info + discovery_info = await self.web.discovery_info() name = discovery_info["id"].lower() info = ServiceInfo( zeroconf_type, diff --git a/music_assistant/models/config_entry.py b/music_assistant/models/config_entry.py index 0ff6850b..14584061 100644 --- a/music_assistant/models/config_entry.py +++ b/music_assistant/models/config_entry.py @@ -16,6 +16,7 @@ class ConfigEntryType(Enum): INT = "integer" FLOAT = "float" LABEL = "label" + DICT = "dict" @dataclass diff --git a/music_assistant/models/media_types.py b/music_assistant/models/media_types.py index 7543e95d..ff02abf2 100755 --- a/music_assistant/models/media_types.py +++ b/music_assistant/models/media_types.py @@ -70,6 +70,23 @@ class MediaItem(DataClassDictMixin): metadata: Any = field(default_factory=dict) provider_ids: List[MediaItemProviderId] = field(default_factory=list) in_library: bool = False + media_type: MediaType = MediaType.Track + + @classmethod + def from_dict(cls, dict_obj): + # pylint: disable=arguments-differ + """Parse MediaItem from dict.""" + if dict_obj["media_type"] == "artist": + return Artist.from_dict(dict_obj) + if dict_obj["media_type"] == "album": + return Album.from_dict(dict_obj) + if dict_obj["media_type"] == "track": + return Track.from_dict(dict_obj) + if dict_obj["media_type"] == "playlist": + return Playlist.from_dict(dict_obj) + if dict_obj["media_type"] == "radio": + return Radio.from_dict(dict_obj) + return super().from_dict(dict_obj) @classmethod def from_db_row(cls, db_row: Mapping): @@ -83,6 +100,7 @@ class MediaItem(DataClassDictMixin): db_row["in_library"] = bool(db_row["in_library"]) if db_row.get("albums"): db_row["album"] = db_row["albums"][0] + db_row["item_id"] = str(db_row["item_id"]) return cls.from_dict(db_row) @property diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index 3b7cf7b6..633b41f5 100755 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -6,7 +6,6 @@ from enum import Enum, IntEnum from typing import Any, List, Optional from mashumaro import DataClassDictMixin -from music_assistant.constants import EVENT_SET_PLAYER_CONTROL_STATE from music_assistant.helpers.typing import MusicAssistantType, QueueItems from music_assistant.helpers.util import callback from music_assistant.models.config_entry import ConfigEntry @@ -279,7 +278,7 @@ class PlayerControlType(Enum): @dataclass -class PlayerControl: +class PlayerControl(DataClassDictMixin): """ Model for a player control. @@ -287,12 +286,13 @@ class PlayerControl: structure to override common player commands. """ + # pylint: disable=no-member + type: PlayerControlType = PlayerControlType.UNKNOWN control_id: str = "" provider: str = "" name: str = "" state: Any = None - mass: MusicAssistantType = None # will be set by player manager async def async_set_state(self, new_state: Any) -> None: """Handle command to set the state for a player control.""" @@ -300,17 +300,4 @@ class PlayerControl: # pickup this event (e.g. from the websocket api) # or override this method with your own implementation. - self.mass.signal_event( - EVENT_SET_PLAYER_CONTROL_STATE, - {"control_id": self.control_id, "state": new_state}, - ) - - def to_dict(self) -> dict: - """Return dict representation of this playercontrol.""" - return { - "type": int(self.type), - "control_id": self.control_id, - "provider": self.provider, - "name": self.name, - "state": self.state, - } + self.mass.signal_event(f"players/controls/{self.control_id}/state", new_state) diff --git a/music_assistant/models/player_queue.py b/music_assistant/models/player_queue.py index f3290650..02cba3e6 100755 --- a/music_assistant/models/player_queue.py +++ b/music_assistant/models/player_queue.py @@ -65,7 +65,7 @@ class PlayerQueue: def __init__(self, mass: MusicAssistantType, player_id: str) -> None: """Initialize class.""" self.mass = mass - self._player_id = player_id + self._queue_id = player_id self._items = [] self._shuffle_enabled = False self._repeat_enabled = False @@ -85,22 +85,22 @@ class PlayerQueue: @property def player(self) -> PlayerType: - """Return handle to player.""" - return self.mass.players.get_player(self._player_id) + """Return handle to (master) player of this queue.""" + return self.mass.players.get_player(self._queue_id) @property def player_state(self) -> PlayerType: """Return handle to player state.""" - return self.mass.players.get_player_state(self._player_id) + return self.mass.players.get_player_state(self._queue_id) @property - def player_id(self) -> str: - """Return the player's id.""" - return self._player_id + def queue_id(self) -> str: + """Return the Queue's id.""" + return self._queue_id def get_stream_url(self) -> str: """Return the full stream url for the player's Queue Stream.""" - uri = f"{self.mass.web.url}/stream/queue/{self.player_id}" + uri = f"{self.mass.web.url}/stream/queue/{self.queue_id}" # we set the checksum just to invalidate cache stuf uri += f"?checksum={time.time()}" return uri @@ -110,8 +110,7 @@ class PlayerQueue: """Return shuffle enabled property.""" return self._shuffle_enabled - @shuffle_enabled.setter - def shuffle_enabled(self, enable_shuffle: bool) -> None: + async def async_set_shuffle_enabled(self, enable_shuffle: bool) -> None: """Set shuffle.""" if not self._shuffle_enabled and enable_shuffle: # shuffle requested @@ -137,8 +136,7 @@ class PlayerQueue: """Return if crossfade is enabled for this player.""" return self._repeat_enabled - @repeat_enabled.setter - def repeat_enabled(self, enable_repeat: bool) -> None: + async def async_set_repeat_enabled(self, enable_repeat: bool) -> None: """Set the repeat mode for this queue.""" if self._repeat_enabled != enable_repeat: self._repeat_enabled = enable_repeat @@ -240,7 +238,7 @@ class PlayerQueue: @property def crossfade_duration(self) -> int: """Return crossfade duration (if enabled).""" - player_settings = self.mass.config.get_player_config(self.player_id) + player_settings = self.mass.config.get_player_config(self.queue_id) if player_settings: return player_settings.get(CONF_CROSSFADE_DURATION, 0) return 0 @@ -306,7 +304,7 @@ class PlayerQueue: await self.async_play_index(prev_index) else: LOGGER.warning( - "resume queue requested for %s but queue is empty", self.player_id + "resume queue requested for %s but queue is empty", self.queue_id ) async def async_play_index(self, index: int) -> None: @@ -466,7 +464,7 @@ class PlayerQueue: async def async_clear(self) -> None: """Clear all items in the queue.""" - await self.mass.players.async_cmd_stop(self.player_id) + await self.mass.players.async_cmd_stop(self.queue_id) self._items = [] if self.supports_queue: # send queue cmd to player's own implementation @@ -521,7 +519,7 @@ class PlayerQueue: self._cur_item_time = track_time self.mass.signal_event( EVENT_QUEUE_TIME_UPDATED, - {"player_id": self.player_id, "cur_item_time": track_time}, + {"queue_id": self.queue_id, "cur_item_time": track_time}, ) async def async_start_queue_stream(self) -> None: @@ -533,7 +531,7 @@ class PlayerQueue: def to_dict(self) -> dict: """Instance attributes as dict so it can be serialized to json.""" return { - "player_id": self.player.player_id, + "queue_id": self.player.player_id, "shuffle_enabled": self.shuffle_enabled, "repeat_enabled": self.repeat_enabled, "crossfade_enabled": self.crossfade_enabled, @@ -587,7 +585,7 @@ class PlayerQueue: async def __async_restore_saved_state(self) -> None: """Try to load the saved queue for this player from cache file.""" - cache_str = "queue_state_%s" % self.player.player_id + cache_str = "queue_state_%s" % self.queue_id cache_data = await self.mass.cache.async_get(cache_str) if cache_data: self._shuffle_enabled = cache_data["shuffle_enabled"] @@ -600,7 +598,7 @@ class PlayerQueue: async def __async_save_state(self) -> None: """Save current queue settings to file.""" - cache_str = "queue_state_%s" % self.player_id + cache_str = "queue_state_%s" % self.queue_id cache_data = { "shuffle_enabled": self._shuffle_enabled, "repeat_enabled": self._repeat_enabled, @@ -609,4 +607,4 @@ class PlayerQueue: "next_queue_index": self._next_queue_startindex, } await self.mass.cache.async_set(cache_str, cache_data) - LOGGER.info("queue state saved to file for player %s", self.player_id) + LOGGER.info("queue state saved to file for player %s", self.queue_id) diff --git a/music_assistant/providers/chromecast/player.py b/music_assistant/providers/chromecast/player.py index 723e6bf6..e3be4d72 100644 --- a/music_assistant/providers/chromecast/player.py +++ b/music_assistant/providers/chromecast/player.py @@ -301,9 +301,7 @@ class ChromecastPlayer(Player): self._available = new_available self.update_state() if self._cast_info.is_audio_group and new_available: - self.__try_chromecast_command( - self._chromecast.mz_controller.update_members - ) + self.chromecast_command(self._chromecast.mz_controller.update_members) async def async_on_update(self) -> None: """Call when player is periodically polled by the player manager (should_poll=True).""" @@ -311,7 +309,7 @@ class ChromecastPlayer(Player): "group_player" ): # the group player wants very accurate elapsed_time state so we request it very often - await self.__async_try_chromecast_command( + await self.async_chromecast_command( self._chromecast.media_controller.update_status ) self.update_state() @@ -321,43 +319,35 @@ class ChromecastPlayer(Player): async def async_cmd_stop(self) -> None: """Send stop command to player.""" if self._chromecast and self._chromecast.media_controller: - await self.__async_try_chromecast_command( - self._chromecast.media_controller.stop - ) + await self.async_chromecast_command(self._chromecast.media_controller.stop) async def async_cmd_play(self) -> None: """Send play command to player.""" if self._chromecast.media_controller: - await self.__async_try_chromecast_command( - self._chromecast.media_controller.play - ) + await self.async_chromecast_command(self._chromecast.media_controller.play) async def async_cmd_pause(self) -> None: """Send pause command to player.""" if self._chromecast.media_controller: - await self.__async_try_chromecast_command( - self._chromecast.media_controller.pause - ) + await self.async_chromecast_command(self._chromecast.media_controller.pause) async def async_cmd_next(self) -> None: """Send next track command to player.""" if self._chromecast.media_controller: - await self.__async_try_chromecast_command( + await self.async_chromecast_command( self._chromecast.media_controller.queue_next ) async def async_cmd_previous(self) -> None: """Send previous track command to player.""" if self._chromecast.media_controller: - await self.__async_try_chromecast_command( + await self.async_chromecast_command( self._chromecast.media_controller.queue_prev ) async def async_cmd_power_on(self) -> None: """Send power ON command to player.""" - await self.__async_try_chromecast_command( - self._chromecast.set_volume_muted, False - ) + await self.async_chromecast_command(self._chromecast.set_volume_muted, False) async def async_cmd_power_off(self) -> None: """Send power OFF command to player.""" @@ -366,25 +356,19 @@ class ChromecastPlayer(Player): or self.media_status.player_is_paused or self.media_status.player_is_idle ): - await self.__async_try_chromecast_command( - self._chromecast.media_controller.stop - ) + await self.async_chromecast_command(self._chromecast.media_controller.stop) # chromecast has no real poweroff so we send mute instead - await self.__async_try_chromecast_command( - self._chromecast.set_volume_muted, True - ) + await self.async_chromecast_command(self._chromecast.set_volume_muted, True) async def async_cmd_volume_set(self, volume_level: int) -> None: """Send new volume level command to player.""" - await self.__async_try_chromecast_command( + await self.async_chromecast_command( self._chromecast.set_volume, volume_level / 100 ) async def async_cmd_volume_mute(self, is_muted: bool = False) -> None: """Send mute command to player.""" - await self.__async_try_chromecast_command( - self._chromecast.set_volume_muted, is_muted - ) + await self.async_chromecast_command(self._chromecast.set_volume_muted, is_muted) async def async_cmd_play_uri(self, uri: str) -> None: """Play single uri on player.""" @@ -393,7 +377,7 @@ class ChromecastPlayer(Player): # create CC queue so that skip and previous will work queue_item = QueueItem(name="Music Assistant", uri=uri) return await self.async_cmd_queue_load([queue_item, queue_item]) - await self.__async_try_chromecast_command( + await self.async_chromecast_command( self._chromecast.play_media, uri, "audio/flac" ) @@ -410,7 +394,7 @@ class ChromecastPlayer(Player): "startIndex": 0, # Item index to play after this request or keep same item if undefined "items": cc_queue_items, # only load 50 tracks at once or the socket will crash } - await self.__async_try_chromecast_command(self.__send_player_queue, queuedata) + await self.async_chromecast_command(self.__send_player_queue, queuedata) if len(queue_items) > 50: await self.async_cmd_queue_append(queue_items[51:]) @@ -423,9 +407,7 @@ class ChromecastPlayer(Player): "insertBefore": None, "items": chunk, } - await self.__async_try_chromecast_command( - self.__send_player_queue, queuedata - ) + await self.async_chromecast_command(self.__send_player_queue, queuedata) def __create_queue_items(self, tracks) -> None: """Create list of CC queue items from tracks.""" @@ -481,41 +463,18 @@ class ChromecastPlayer(Player): else: send_queue() - def __try_chromecast_command(self, func, *args, **kwargs): + def chromecast_command(self, func, *args, **kwargs): """Try to execute Chromecast command.""" - self.mass.add_job(self.__async_try_chromecast_command(func, *args, **kwargs)) - - async def __async_try_chromecast_command(self, func, *args, **kwargs): - """Try to execute Chromecast command.""" - - def handle_command(func, *args, **kwarg): - if ( - not self._chromecast - or not self._chromecast.socket_client - or not self._available - ): - LOGGER.error( - "Error while executing command %s on player %s: Chromecast is not available!", - func.__name__, - self.name, - ) - return - try: - return func(*args, **kwargs) - except ( - pychromecast.NotConnected, - pychromecast.ChromecastConnectionError, - pychromecast.error.PyChromecastStopped, - ) as exc: - LOGGER.warning( - "Error while executing command %s on player %s: %s", - func.__name__, - self.name, - str(exc), - ) - self._available = False - except Exception as exc: # pylint: disable=broad-except - LOGGER.exception(exc) - + self.mass.add_job(self.async_chromecast_command(func, *args, **kwargs)) + + async def async_chromecast_command(self, func, *args, **kwargs): + """Execute command on Chromecast.""" + # Chromecast socket really doesn't like multiple commands arriving at the same time + # so we apply some throtling. + if not self.available: + LOGGER.warning( + "Player %s is not available, command can't be executed", self.name + ) + return async with self._throttler: - self.mass.add_job(handle_command, func, *args, **kwargs) + self.mass.add_job(func, *args, **kwargs) diff --git a/music_assistant/providers/spotify/__init__.py b/music_assistant/providers/spotify/__init__.py index 1053b468..9c02374c 100644 --- a/music_assistant/providers/spotify/__init__.py +++ b/music_assistant/providers/spotify/__init__.py @@ -242,7 +242,7 @@ class SpotifyProvider(MusicProvider): return [ await self.__async_parse_album(item) for item in await self.__async_get_all_items( - f"artists/{prov_artist_id}/albums" + f"artists/{prov_artist_id}/albums?include_groups=album,single,compilation" ) if (item and item["id"]) ] diff --git a/music_assistant/web/__init__.py b/music_assistant/web/__init__.py index 39115248..12762e0e 100755 --- a/music_assistant/web/__init__.py +++ b/music_assistant/web/__init__.py @@ -1,37 +1,52 @@ -"""The web module handles serving the frontend and the rest/websocket api's.""" +""" +The web module handles serving the frontend and the rest/websocket api's. + +API is available with both HTTP json rest endpoints AND WebSockets. +All MusicAssistant clients communicate with the websockets api. +For now, we do not yet support SSL/HTTPS directly, to prevent messing with certificates etc. +The server is intended to be used locally only and not exposed outside. +Users may use reverse proxy etc. to add ssl themselves. +""" +import asyncio +import datetime import logging import os import uuid +from base64 import b64encode +from typing import Any, Awaitable, Optional, Union import aiohttp_cors +import jwt +import ujson from aiohttp import web -from aiohttp_jwt import JWTMiddleware +from aiohttp.web_request import Request +from aiohttp_jwt import JWTMiddleware, login_required +from music_assistant.constants import ( + CONF_KEY_SECURITY, + CONF_KEY_SECURITY_APP_TOKENS, + CONF_KEY_SECURITY_LOGIN, + CONF_PASSWORD, + CONF_USERNAME, +) from music_assistant.constants import __version__ as MASS_VERSION +from music_assistant.helpers import repath +from music_assistant.helpers.encryption import decrypt_string +from music_assistant.helpers.images import async_get_image_url, async_get_thumb_file from music_assistant.helpers.typing import MusicAssistantType from music_assistant.helpers.util import get_hostname, get_ip -from music_assistant.helpers.web import json_serializer - -from .endpoints import ( - albums, - artists, - config, - images, - json_rpc, - library, - login, - players, - playlists, - radios, - search, - streams, - tracks, - websocket, +from music_assistant.helpers.web import ( + api_route, + async_json_response, + json_serializer, + parse_arguments, ) +from music_assistant.models.media_types import ItemMapping, MediaItem -LOGGER = logging.getLogger("webserver") - +from .json_rpc import json_rpc_endpoint +from .streams import routes as stream_routes +from .websocket import WebSocketHandler -routes = web.RouteTableDef() +LOGGER = logging.getLogger("webserver") class WebServer: @@ -39,76 +54,117 @@ class WebServer: def __init__(self, mass: MusicAssistantType, port: int): """Initialize class.""" + self.jwt_key = None + self.app = None self.mass = mass self._port = port # load/create/update config - # self._hostname = get_hostname() or get_ip() - self._hostname = get_ip() - self._device_id = f"{uuid.getnode()}_{get_hostname()}" + self._host = get_ip() self.config = mass.config.base["web"] self._runner = None + self.api_routes = {} async def async_setup(self): """Perform async setup.""" - + self.jwt_key = decrypt_string(self.mass.config.stored_config["jwt_key"]) jwt_middleware = JWTMiddleware( - self.device_id, request_property="user", credentials_required=False + self.jwt_key, + request_property="user", + credentials_required=False, + is_revoked=self.is_token_revoked, + ) + self.app = web.Application(middlewares=[jwt_middleware]) + self.app["mass"] = self.mass + self.app["websockets"] = [] + # add all routes routes + self.app.add_routes(stream_routes) + if not self.mass.config.stored_config["initialized"]: + self.app.router.add_post("/setup", self.setup) + self.app.router.add_post("/login", self.login) + self.app.router.add_get("/jsonrpc.js", json_rpc_endpoint) + self.app.router.add_post("/jsonrpc.js", json_rpc_endpoint) + self.app.router.add_get("/ws", WebSocketHandler) + self.app.router.add_get("/", self.index) + self.app.router.add_put("/api/library/{tail:.*}/add", self.handle_api_request) + self.app.router.add_delete( + "/api/library/{tail:.*}/remove", self.handle_api_request ) - app = web.Application(middlewares=[jwt_middleware]) - app["mass"] = self.mass - # add routes - app.add_routes(albums.routes) - app.add_routes(artists.routes) - app.add_routes(config.routes) - app.add_routes(images.routes) - app.add_routes(json_rpc.routes) - app.add_routes(library.routes) - app.add_routes(login.routes) - app.add_routes(players.routes) - app.add_routes(playlists.routes) - app.add_routes(radios.routes) - app.add_routes(search.routes) - app.add_routes(streams.routes) - app.add_routes(tracks.routes) - app.add_routes(websocket.routes) - app.add_routes(routes) + self.app.router.add_put( + "/api/players/{tail:.*}/play_media", self.handle_api_request + ) + self.app.router.add_put( + "/api/players/{tail:.*}/play_uri", self.handle_api_request + ) + # catch-all for all api routes is handled by our special method + self.app.router.add_get("/api/{tail:.*}", self.handle_api_request) + + # register all methods decorated as api_route + for cls in [ + self, + self.mass.music, + self.mass.players, + self.mass.config, + self.mass.library, + ]: + self.register_api_routes(cls) webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static/") if os.path.isdir(webdir): - app.router.add_static("/", webdir, append_version=True) + self.app.router.add_static("/", webdir, append_version=True) else: # The (minified) build of the frontend(app) is included in the pypi releases LOGGER.warning("Loaded without frontend support.") # Add CORS support to all routes cors = aiohttp_cors.setup( - app, + self.app, defaults={ "*": aiohttp_cors.ResourceOptions( allow_credentials=True, - expose_headers="*", allow_headers="*", - allow_methods=["POST", "PUT", "DELETE", "GET"], ) }, ) - for route in list(app.router.routes()): + for route in list(self.app.router.routes()): cors.add(route) - self._runner = web.AppRunner(app, access_log=None) + + # set custom server header + async def on_prepare(request, response): + response.headers[ + "Server" + ] = f'MusicAssistant/{MASS_VERSION} {response.headers["Server"]}' + + self.app.on_response_prepare.append(on_prepare) + self._runner = web.AppRunner(self.app, access_log=None) await self._runner.setup() http_site = web.TCPSite(self._runner, "0.0.0.0", self.port) await http_site.start() - LOGGER.info("Started HTTP webserver on port %s", self.port) + LOGGER.info("Started Music Assistant server on %s", self.url) + self.mass.add_event_listener(self.__async_handle_mass_events) async def async_stop(self): """Stop the webserver.""" - # if self._runner: - # await self._runner.cleanup() + for ws_client in self.app["websockets"]: + await ws_client.close("server shutdown") + + def register_api_route(self, cmd: str, func: Awaitable): + """Register a command(handler) to the websocket api.""" + pattern = repath.pattern(cmd) + self.api_routes[pattern] = func + + def register_api_routes(self, cls: Any): + """Register all methods of a class (instance) that are decorated with api_route.""" + for item in dir(cls): + func = getattr(cls, item) + if not hasattr(func, "ws_cmd_path"): + continue + # method is decorated with our websocket decorator + self.register_api_route(func.ws_cmd_path, func) @property def host(self): """Return the local IP address/host for this Music Assistant instance.""" - return self._hostname + return self._host @property def port(self): @@ -121,39 +177,181 @@ class WebServer: return f"http://{self.host}:{self.port}" @property - def device_id(self): + def server_id(self): """Return the device ID for this Music Assistant Server.""" - return self._device_id + return self.mass.config.stored_config["server_id"] - @property - def discovery_info(self): + @api_route("info") + async def discovery_info(self): """Return (discovery) info about this instance.""" return { - "id": self._device_id, + "id": self.server_id, "url": self.url, "host": self.host, "port": self.port, "version": MASS_VERSION, + "friendly_name": get_hostname(), + "initialized": self.mass.config.stored_config["initialized"], } + async def login(self, request: Request): + """Handle user login by form/json post. Will issue JWT token.""" + form = await request.post() + try: + username = form["username"] + password = form["password"] + app_id = form.get("app_id") + except KeyError: + data = await request.json() + username = data["username"] + password = data["password"] + app_id = data.get("app_id") + token_info = await self.get_token(username, password, app_id) + if token_info: + return web.Response( + body=json_serializer(token_info), content_type="application/json" + ) + return web.HTTPUnauthorized(body="Invalid username and/or password provided!") + + async def get_token(self, username: str, password: str, app_id: str = "") -> dict: + """ + Validate given credentials and return JWT token. + + If app_id is provided, a long lived token will be issued which can be withdrawn by the user. + """ + verified = self.mass.config.security.validate_credentials(username, password) + if verified: + client_id = str(uuid.uuid4()) + token_info = { + "username": username, + "server_id": self.server_id, + "client_id": client_id, + "app_id": app_id, + } + if app_id: + token_info["exp"] = datetime.datetime.utcnow() + datetime.timedelta( + days=365 * 10 + ) + else: + token_info["exp"] = datetime.datetime.utcnow() + datetime.timedelta( + hours=8 + ) + token = jwt.encode(token_info, self.jwt_key).decode() + if app_id: + self.mass.config.stored_config[CONF_KEY_SECURITY][ + CONF_KEY_SECURITY_APP_TOKENS + ][client_id] = token_info + self.mass.config.save() + token_info["token"] = token + return token_info + return None + + async def setup(self, request: Request): + """Handle first-time server setup through onboarding wizard.""" + if self.mass.config.stored_config["initialized"]: + return web.HTTPUnauthorized() + form = await request.post() + username = form["username"] + password = form["password"] + # save credentials in config + self.mass.config.security[CONF_KEY_SECURITY_LOGIN][CONF_USERNAME] = username + self.mass.config.security[CONF_KEY_SECURITY_LOGIN][CONF_PASSWORD] = password + self.mass.config.stored_config["initialized"] = True + self.mass.config.save() + return web.Response(status=200) + + @login_required + async def handle_api_request(self, request: Request): + """Handle API route/command.""" + api_path = request.path.replace("/api/", "") + LOGGER.debug("Handling %s - %s", api_path, request.get("user")) + try: + # TODO: parse mediaitems from body if needed + data = await request.json(loads=ujson.loads) + except Exception: # pylint: disable=broad-except + data = {} + # work out handler for the given path/command + for key in self.api_routes: + match = repath.match(key, api_path) + if match: + try: + params = match.groupdict() + handler = self.mass.web.api_routes[key] + params = parse_arguments(handler, {**params, **data}) + res = handler(**params) + if asyncio.iscoroutine(res): + res = await res + # return result of command to client + return await async_json_response(res) + except Exception as exc: # pylint: disable=broad-except + return web.Response(status=500, text=str(exc)) + return web.Response(status=404) + + async def index(self, request: web.Request): + """Get the index page, redirect if we do not have a web directory.""" + # pylint: disable=unused-argument + if not self.mass.config.stored_config["initialized"]: + return web.FileResponse( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "setup.html") + ) + html_app = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "static/index.html" + ) + if not os.path.isfile(html_app): + raise web.HTTPFound("https://music-assistant.github.io/app") + return web.FileResponse(html_app) + + async def __async_handle_mass_events(self, event, event_data): + """Broadcast events to connected websocket clients.""" + for ws_client in self.app["websockets"]: + if not ws_client.authenticated: + continue + try: + await ws_client.send(event=event, data=event_data) + except ConnectionResetError: + # connection lost to this client, cleanup + await ws_client.close() + except Exception as exc: # pylint: disable=broad-except + # log all other errors but continue sending to all other clients + LOGGER.exception(exc) + + @api_route("images/thumb") + async def async_get_image_thumb( + self, + size: int, + url: Optional[str] = "", + item: Union[None, ItemMapping, MediaItem] = None, + ): + """Get (resized) thumb image for given URL or media item as base64 encoded string.""" + if not url and item: + url = await async_get_image_url( + self.mass, item.item_id, item.provider, item.media_type + ) + img_file = await async_get_thumb_file(self.mass, url, size) + if img_file: + with open(img_file, "rb") as _file: + icon_data = _file.read() + icon_data = b64encode(icon_data) + return "data:image/png;base64," + icon_data.decode() + raise KeyError("Invalid item or url") + + @api_route("images/provider-icons/:provider_id?") + async def async_get_provider_icon(self, provider_id: Optional[str]): + """Get Provider icon as base64 encoded string.""" + if not provider_id: + return { + prov.id: await self.async_get_provider_icon(prov.id) + for prov in self.mass.get_providers(include_unavailable=True) + } + base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + icon_path = os.path.join(base_dir, "providers", provider_id, "icon.png") + if os.path.isfile(icon_path): + with open(icon_path, "rb") as _file: + icon_data = _file.read() + icon_data = b64encode(icon_data) + return "data:image/png;base64," + icon_data.decode() + raise KeyError("Invalid provider: %s" % provider_id) -@routes.get("/api/info") -async def async_discovery_info(request: web.Request): - # pylint: disable=unused-argument - """Return (discovery) info about this instance.""" - return web.Response( - body=json_serializer(request.app["mass"].web.discovery_info), - content_type="application/json", - ) - - -@routes.get("/") -async def async_index(request: web.Request): - """Get the index page, redirect if we do not have a web directory.""" - # pylint: disable=unused-argument - html_app = os.path.join( - os.path.dirname(os.path.abspath(__file__)), "static/index.html" - ) - if not os.path.isfile(html_app): - raise web.HTTPFound("https://music-assistant.github.io/app") - return web.FileResponse(html_app) + def is_token_revoked(self, request: Request, token_info: dict): + """Return bool is token is revoked.""" + return self.mass.config.security.is_token_revoked(token_info) diff --git a/music_assistant/web/endpoints/__init__.py b/music_assistant/web/endpoints/__init__.py deleted file mode 100644 index fc1c4cc7..00000000 --- a/music_assistant/web/endpoints/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Web endpoints package.""" diff --git a/music_assistant/web/endpoints/albums.py b/music_assistant/web/endpoints/albums.py deleted file mode 100644 index d7abacb6..00000000 --- a/music_assistant/web/endpoints/albums.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Albums API endpoints.""" - -from aiohttp.web import Request, Response, RouteTableDef -from aiohttp_jwt import login_required -from music_assistant.helpers.web import async_json_response - -routes = RouteTableDef() - - -@routes.get("/api/albums") -@login_required -async def async_albums(request: Request): - """Get all albums known in the database.""" - return await async_json_response( - await request.app["mass"].database.async_get_albums() - ) - - -@routes.get("/api/albums/{item_id}") -@login_required -async def async_album(request: Request): - """Get full album details.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return Response(text="invalid item or provider", status=501) - return await async_json_response( - await request.app["mass"].music.async_get_album(item_id, provider) - ) - - -@routes.get("/api/albums/{item_id}/tracks") -@login_required -async def async_album_tracks(request: Request): - """Get album tracks from provider.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return Response(text="invalid item_id or provider", status=501) - return await async_json_response( - await request.app["mass"].music.async_get_album_tracks(item_id, provider) - ) - - -@routes.get("/api/albums/{item_id}/versions") -@login_required -async def async_album_versions(request): - """Get all versions of an album.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return Response(text="invalid item_id or provider", status=501) - return await async_json_response( - await request.app["mass"].music.async_get_album_versions(item_id, provider) - ) diff --git a/music_assistant/web/endpoints/artists.py b/music_assistant/web/endpoints/artists.py deleted file mode 100644 index b165ca1b..00000000 --- a/music_assistant/web/endpoints/artists.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Artists API endpoints.""" - -from aiohttp.web import Request, Response, RouteTableDef -from aiohttp_jwt import login_required -from music_assistant.helpers.web import async_json_response - -routes = RouteTableDef() - - -@routes.get("/api/artists") -@login_required -async def async_artists(request: Request): - """Get all artists known in the database.""" - result = await request.app["mass"].database.async_get_artists() - return await async_json_response(result) - - -@routes.get("/api/artists/{item_id}") -@login_required -async def async_artist(request: Request): - """Get full artist details.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return Response(text="invalid item or provider", status=501) - result = await request.app["mass"].music.async_get_artist(item_id, provider) - return await async_json_response(result) - - -@routes.get("/api/artists/{item_id}/toptracks") -@login_required -async def async_artist_toptracks(request: Request): - """Get top tracks for given artist.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return Response(text="invalid item_id or provider", status=501) - result = await request.app["mass"].music.async_get_artist_toptracks( - item_id, provider - ) - return await async_json_response(result) - - -@routes.get("/api/artists/{item_id}/albums") -@login_required -async def async_artist_albums(request: Request): - """Get (all) albums for given artist.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return Response(text="invalid item_id or provider", status=501) - result = await request.app["mass"].music.async_get_artist_albums(item_id, provider) - return await async_json_response(result) diff --git a/music_assistant/web/endpoints/config.py b/music_assistant/web/endpoints/config.py deleted file mode 100644 index 949dc0e7..00000000 --- a/music_assistant/web/endpoints/config.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Config API endpoints.""" - -from json.decoder import JSONDecodeError - -from aiohttp.web import Request, RouteTableDef -from aiohttp_jwt import login_required -from music_assistant.constants import ( - CONF_KEY_BASE, - CONF_KEY_METADATA_PROVIDERS, - CONF_KEY_MUSIC_PROVIDERS, - CONF_KEY_PLAYER_PROVIDERS, - CONF_KEY_PLAYER_SETTINGS, - CONF_KEY_PLUGINS, -) -from music_assistant.helpers.web import async_json_response - -routes = RouteTableDef() - - -@routes.get("/api/config") -@login_required -async def async_get_config(request: Request): - """Get the full config.""" - conf = { - key: f"/api/config/{key}" - for key in [ - CONF_KEY_BASE, - CONF_KEY_MUSIC_PROVIDERS, - CONF_KEY_PLAYER_PROVIDERS, - CONF_KEY_METADATA_PROVIDERS, - CONF_KEY_PLUGINS, - CONF_KEY_PLAYER_SETTINGS, - ] - } - return await async_json_response(conf) - - -@routes.get("/api/config/{base}") -@login_required -async def async_get_config_base_item(request: Request): - """Get the config by base type.""" - language = request.rel_url.query.get("lang", "en") - conf_base = request.match_info.get("base") - conf = request.app["mass"].config[conf_base].all_items(language) - return await async_json_response(conf) - - -@routes.get("/api/config/{base}/{item}") -@login_required -async def async_get_config_item(request: Request): - """Get the config by base and item type.""" - language = request.rel_url.query.get("lang", "en") - conf_base = request.match_info.get("base") - conf_item = request.match_info.get("item") - conf = request.app["mass"].config[conf_base][conf_item].all_items(language) - return await async_json_response(conf) - - -@routes.put("/api/config/{base}/{key}/{entry_key}") -@login_required -async def async_put_config(request: Request): - """Save the given config item.""" - conf_key = request.match_info.get("key") - conf_base = request.match_info.get("base") - entry_key = request.match_info.get("entry_key") - try: - new_value = await request.json() - except JSONDecodeError: - new_value = ( - request.app["mass"] - .config[conf_base][conf_key] - .get_entry(entry_key) - .default_value - ) - request.app["mass"].config[conf_base][conf_key][entry_key] = new_value - return await async_json_response( - request.app["mass"].config[conf_base][conf_key][entry_key] - ) diff --git a/music_assistant/web/endpoints/library.py b/music_assistant/web/endpoints/library.py deleted file mode 100644 index 32f6caea..00000000 --- a/music_assistant/web/endpoints/library.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Library API endpoints.""" - -from aiohttp.web import Request, RouteTableDef -from aiohttp_jwt import login_required -from music_assistant.helpers.web import async_json_response, async_media_items_from_body - -routes = RouteTableDef() - - -@routes.get("/api/library/artists") -@login_required -async def async_library_artists(request: Request): - """Get all library artists.""" - orderby = request.query.get("orderby", "name") - - return await async_json_response( - await request.app["mass"].library.async_get_library_artists(orderby=orderby) - ) - - -@routes.get("/api/library/albums") -@login_required -async def async_library_albums(request: Request): - """Get all library albums.""" - orderby = request.query.get("orderby", "name") - - return await async_json_response( - await request.app["mass"].library.async_get_library_albums(orderby=orderby) - ) - - -@routes.get("/api/library/tracks") -@login_required -async def async_library_tracks(request: Request): - """Get all library tracks.""" - orderby = request.query.get("orderby", "name") - - return await async_json_response( - await request.app["mass"].library.async_get_library_tracks(orderby=orderby) - ) - - -@routes.get("/api/library/radios") -@login_required -async def async_library_radios(request: Request): - """Get all library radios.""" - orderby = request.query.get("orderby", "name") - - return await async_json_response( - await request.app["mass"].library.async_get_library_radios(orderby=orderby) - ) - - -@routes.get("/api/library/playlists") -@login_required -async def async_library_playlists(request: Request): - """Get all library playlists.""" - orderby = request.query.get("orderby", "name") - - return await async_json_response( - await request.app["mass"].library.async_get_library_playlists(orderby=orderby) - ) - - -@routes.put("/api/library") -@login_required -async def async_library_add(request: Request): - """Add item(s) to the library.""" - body = await request.json() - media_items = await async_media_items_from_body(request.app["mass"], body) - result = await request.app["mass"].library.async_library_add(media_items) - return await async_json_response(result) - - -@routes.delete("/api/library") -@login_required -async def async_library_remove(request: Request): - """Remove item(s) from the library.""" - body = await request.json() - media_items = await async_media_items_from_body(request.app["mass"], body) - result = await request.app["mass"].library.async_library_remove(media_items) - return await async_json_response(result) diff --git a/music_assistant/web/endpoints/login.py b/music_assistant/web/endpoints/login.py deleted file mode 100644 index 23480509..00000000 --- a/music_assistant/web/endpoints/login.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Login API endpoints.""" - -import datetime - -import jwt -from aiohttp.web import HTTPUnauthorized, Request, Response, RouteTableDef -from music_assistant.helpers.typing import MusicAssistantType -from music_assistant.helpers.web import json_serializer - -routes = RouteTableDef() - - -@routes.post("/login") -@routes.post("/api/login") -async def async_login(request: Request): - """Handle the retrieval of a JWT token.""" - form = await request.json() - username = form.get("username") - password = form.get("password") - token_info = await async_get_token(request.app["mass"], username, password) - if token_info: - return Response( - body=json_serializer(token_info), content_type="application/json" - ) - return HTTPUnauthorized(body="Invalid username and/or password provided!") - - -async def async_get_token( - mass: MusicAssistantType, username: str, password: str -) -> dict: - """Validate given credentials and return JWT token.""" - verified = mass.config.validate_credentials(username, password) - if verified: - token_expires = datetime.datetime.utcnow() + datetime.timedelta(hours=8) - scopes = ["user:admin"] # scopes not yet implemented - token = jwt.encode( - {"username": username, "scopes": scopes, "exp": token_expires}, - mass.web.device_id, - ) - return { - "user": username, - "token": token.decode(), - "expires": token_expires.isoformat(), - "scopes": scopes, - } - return None diff --git a/music_assistant/web/endpoints/players.py b/music_assistant/web/endpoints/players.py deleted file mode 100644 index ebe0ba7e..00000000 --- a/music_assistant/web/endpoints/players.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Players API endpoints.""" - -from json.decoder import JSONDecodeError - -from aiohttp.web import Request, Response, RouteTableDef -from aiohttp_jwt import login_required -from music_assistant.helpers.web import async_json_response, async_media_items_from_body -from music_assistant.models.player_queue import QueueOption - -routes = RouteTableDef() - - -@routes.get("/api/players") -@login_required -async def async_players(request: Request): - # pylint: disable=unused-argument - """Get all playerstates.""" - player_states = request.app["mass"].players.player_states - player_states.sort(key=lambda x: str(x.name), reverse=False) - return await async_json_response( - [player_state.to_dict() for player_state in player_states] - ) - - -@routes.post("/api/players/{player_id}/cmd/{cmd}") -@login_required -async def async_player_command(request: Request): - """Issue player command.""" - success = False - player_id = request.match_info.get("player_id") - cmd = request.match_info.get("cmd") - try: - cmd_args = await request.json() - if cmd_args in ["", {}, []]: - cmd_args = None - except JSONDecodeError: - cmd_args = None - player_cmd = getattr(request.app["mass"].players, f"async_cmd_{cmd}", None) - if player_cmd and cmd_args is not None: - success = await player_cmd(player_id, cmd_args) - elif player_cmd: - success = await player_cmd(player_id) - else: - return Response(text="invalid command", status=501) - result = {"success": success in [True, None]} - return await async_json_response(result) - - -@routes.post("/api/players/{player_id}/play_media/{queue_opt}") -@login_required -async def async_player_play_media(request: Request): - """Issue player play media command.""" - player_id = request.match_info.get("player_id") - player_state = request.app["mass"].players.get_player_state(player_id) - if not player_state: - return Response(status=404) - queue_opt = QueueOption(request.match_info.get("queue_opt", "play")) - body = await request.json() - media_items = await async_media_items_from_body(request.app["mass"], body) - success = await request.app["mass"].players.async_play_media( - player_id, media_items, queue_opt - ) - result = {"success": success in [True, None]} - return await async_json_response(result) - - -@routes.get("/api/players/{player_id}/queue/items/{queue_item}") -@login_required -async def async_player_queue_item(request: Request): - """Return item (by index or queue item id) from the player's queue.""" - player_id = request.match_info.get("player_id") - item_id = request.match_info.get("queue_item") - player_queue = request.app["mass"].players.get_player_queue(player_id) - if not player_queue: - return Response(text="invalid player", status=404) - try: - item_id = int(item_id) - queue_item = player_queue.get_item(item_id) - except ValueError: - queue_item = player_queue.by_item_id(item_id) - return await async_json_response(queue_item) - - -@routes.get("/api/players/{player_id}/queue/items") -@login_required -async def async_player_queue_items(request: Request): - """Return the items in the player's queue.""" - player_id = request.match_info.get("player_id") - player_queue = request.app["mass"].players.get_player_queue(player_id) - if not player_queue: - return Response(text="invalid player", status=404) - return await async_json_response(player_queue.items) - - -@routes.get("/api/players/{player_id}/queue") -@login_required -async def async_player_queue(request: Request): - """Return the player queue details.""" - player_id = request.match_info.get("player_id") - player_queue = request.app["mass"].players.get_player_queue(player_id) - if not player_queue: - return Response(text="invalid player", status=404) - return await async_json_response(player_queue) - - -@routes.put("/api/players/{player_id}/queue/{cmd}") -@login_required -async def async_player_queue_cmd(request: Request): - """Change the player queue details.""" - player_id = request.match_info.get("player_id") - player_queue = request.app["mass"].players.get_player_queue(player_id) - cmd = request.match_info.get("cmd") - try: - cmd_args = await request.json() - except JSONDecodeError: - cmd_args = None - if cmd == "repeat_enabled": - player_queue.repeat_enabled = cmd_args - elif cmd == "shuffle_enabled": - player_queue.shuffle_enabled = cmd_args - elif cmd == "clear": - await player_queue.async_clear() - elif cmd == "index": - await player_queue.async_play_index(cmd_args) - elif cmd == "move_up": - await player_queue.async_move_item(cmd_args, -1) - elif cmd == "move_down": - await player_queue.async_move_item(cmd_args, 1) - elif cmd == "next": - await player_queue.async_move_item(cmd_args, 0) - return await async_json_response(player_queue) - - -@routes.get("/api/players/{player_id}") -@login_required -async def async_player(request: Request): - """Get state of single player.""" - player_id = request.match_info.get("player_id") - player_state = request.app["mass"].players.get_player_state(player_id) - if not player_state: - return Response(text="invalid player", status=404) - return await async_json_response(player_state) diff --git a/music_assistant/web/endpoints/playlists.py b/music_assistant/web/endpoints/playlists.py deleted file mode 100644 index 3873e0ad..00000000 --- a/music_assistant/web/endpoints/playlists.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Playlists API endpoints.""" - -import ujson -from aiohttp.web import Request, Response, RouteTableDef -from aiohttp_jwt import login_required -from music_assistant.helpers.web import async_json_response, async_media_items_from_body - -routes = RouteTableDef() - - -@routes.get("/api/playlists/{item_id}") -@login_required -async def async_playlist(request: Request): - """Get full playlist details.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return Response(text="invalid item or provider", status=501) - result = await request.app["mass"].music.async_get_playlist(item_id, provider) - return await async_json_response(result) - - -@routes.get("/api/playlists/{item_id}/tracks") -@login_required -async def async_playlist_tracks(request: Request): - """Get playlist tracks from provider.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return Response(text="invalid item_id or provider", status=501) - result = await request.app["mass"].music.async_get_playlist_tracks( - item_id, provider - ) - return await async_json_response(result) - - -@routes.put("/api/playlists/{item_id}/tracks") -@login_required -async def async_add_playlist_tracks(request: Request): - """Add tracks to (editable) playlist.""" - item_id = request.match_info.get("item_id") - body = await request.json(loads=ujson.loads) - tracks = await async_media_items_from_body(request.app["mass"], body) - result = await request.app["mass"].music.async_add_playlist_tracks(item_id, tracks) - return await async_json_response(result) - - -@routes.delete("/api/playlists/{item_id}/tracks") -@login_required -async def async_remove_playlist_tracks(request: Request): - """Remove tracks from (editable) playlist.""" - item_id = request.match_info.get("item_id") - body = await request.json(loads=ujson.loads) - tracks = await async_media_items_from_body(request.app["mass"], body) - result = await request.app["mass"].music.async_remove_playlist_tracks( - item_id, tracks - ) - return await async_json_response(result) diff --git a/music_assistant/web/endpoints/radios.py b/music_assistant/web/endpoints/radios.py deleted file mode 100644 index 1db1a163..00000000 --- a/music_assistant/web/endpoints/radios.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Tracks API endpoints.""" - -from aiohttp.web import Request, Response, RouteTableDef -from aiohttp_jwt import login_required -from music_assistant.helpers.web import async_json_response - -routes = RouteTableDef() - - -@routes.get("/api/radios") -@login_required -async def async_radios(request: Request): - """Get all radios known in the database.""" - return await async_json_response( - await request.app["mass"].database.async_get_radios() - ) - - -@routes.get("/api/radios/{item_id}") -@login_required -async def async_radio(request: Request): - """Get full radio details.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return Response(text="invalid item_id or provider", status=501) - return await async_json_response( - await request.app["mass"].music.async_get_radio(item_id, provider) - ) diff --git a/music_assistant/web/endpoints/search.py b/music_assistant/web/endpoints/search.py deleted file mode 100644 index 2fd3d1dc..00000000 --- a/music_assistant/web/endpoints/search.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Search API endpoints.""" - -from aiohttp.web import Request, RouteTableDef -from aiohttp_jwt import login_required -from music_assistant.helpers.web import async_json_response -from music_assistant.models.media_types import MediaType - -routes = RouteTableDef() - - -@routes.get("/api/search") -@login_required -async def async_search(request: Request): - """Search database and/or providers.""" - searchquery = request.rel_url.query.get("query") - media_types_query = request.rel_url.query.get("media_types") - limit = request.rel_url.query.get("limit", 5) - media_types = [] - if not media_types_query or "artists" in media_types_query: - media_types.append(MediaType.Artist) - if not media_types_query or "albums" in media_types_query: - media_types.append(MediaType.Album) - if not media_types_query or "tracks" in media_types_query: - media_types.append(MediaType.Track) - if not media_types_query or "playlists" in media_types_query: - media_types.append(MediaType.Playlist) - if not media_types_query or "radios" in media_types_query: - media_types.append(MediaType.Radio) - return await async_json_response( - await request.app["mass"].music.async_global_search( - searchquery, media_types, limit=limit - ) - ) diff --git a/music_assistant/web/endpoints/tracks.py b/music_assistant/web/endpoints/tracks.py deleted file mode 100644 index ec1aa8ae..00000000 --- a/music_assistant/web/endpoints/tracks.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Radio's API endpoints.""" - -from aiohttp.web import Request, Response, RouteTableDef -from aiohttp_jwt import login_required -from music_assistant.helpers.web import async_json_response - -routes = RouteTableDef() - - -@routes.get("/api/tracks") -@login_required -async def async_tracks(request: Request): - """Get all tracks known in the database.""" - result = await request.app["mass"].database.async_get_tracks() - return await async_json_response(result) - - -@routes.get("/api/tracks/{item_id}/versions") -@login_required -async def async_track_versions(request: Request): - """Get all versions of an track.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return Response(text="invalid item_id or provider", status=501) - result = await request.app["mass"].music.async_get_track_versions(item_id, provider) - return await async_json_response(result) - - -@routes.get("/api/tracks/{item_id}") -@login_required -async def async_track(request: Request): - """Get full track details.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return Response(text="invalid item or provider", status=501) - result = await request.app["mass"].music.async_get_track(item_id, provider) - return await async_json_response(result) diff --git a/music_assistant/web/endpoints/websocket.py b/music_assistant/web/endpoints/websocket.py deleted file mode 100644 index 1e4c9b78..00000000 --- a/music_assistant/web/endpoints/websocket.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Websocket API endpoint.""" - -import logging -from typing import Union - -import jwt -import ujson -from aiohttp import WSMsgType -from aiohttp.web import Request, RouteTableDef, WebSocketResponse -from music_assistant.helpers.typing import MusicAssistantType -from music_assistant.helpers.web import json_serializer - -from .login import async_get_token - -routes = RouteTableDef() -ws_commands = dict() - -LOGGER = logging.getLogger("web.endpoints.websocket") - - -def ws_command(cmd): - """Register a websocket command.""" - - def decorate(func): - ws_commands[cmd] = func - return func - - return decorate - - -@routes.get("/ws") -async def async_websocket_handler(request: Request): - """Handle websockets connection.""" - ws_response = None - authenticated = False - _callbacks = [] - mass = request.app["mass"] - try: - ws_response = WebSocketResponse() - await ws_response.prepare(request) - - # callback for internal events - async def async_send_message( - msg: str, msg_details: Union[None, dict, str] = None - ): - """Send message (back) to websocket client.""" - ws_msg = {"message": msg, "message_details": msg_details} - try: - await ws_response.send_str(json_serializer(ws_msg)) - # pylint: disable=broad-except - except Exception as exc: - LOGGER.debug( - "Error while trying to send message to websocket (probably disconnected): %s", - str(exc), - ) - - # process incoming messages - async for msg in ws_response: - if msg.type != WSMsgType.TEXT: - continue - try: - data = msg.json(loads=ujson.loads) - msg = data["message"] - msg_details = data["message_details"] - except (KeyError, ValueError): - await async_send_message( - "error", - 'commands must be issued in json format \ - {"message": "command", "message_details":" optional details"}', - ) - continue - if not authenticated and not msg == "login": - # make sure client is authenticated - await async_send_message("error", "authentication required") - elif msg == "login" and isinstance(msg_details, str): - # handle login with token - try: - token_info = jwt.decode(msg_details, mass.web.device_id) - await async_send_message("login", token_info) - authenticated = True - except jwt.InvalidTokenError as exc: - await async_send_message( - "error", "Invalid authorization token, " + str(exc) - ) - authenticated = False - elif msg == "login" and isinstance(msg_details, dict): - # handle login with username/password - token_info = await async_get_token( - mass, msg_details["username"], msg_details["password"] - ) - if token_info: - await async_send_message("login", token_info) - authenticated = True - else: - await async_send_message("error", "Invalid credentials") - authenticated = False - elif msg in ws_commands: - res = await ws_commands[msg](mass, msg_details) - if res is not None: - await async_send_message(res) - elif msg == "add_event_listener": - _callbacks.append( - mass.add_event_listener(async_send_message, msg_details) - ) - await async_send_message("event listener subscribed", msg_details) - else: - # simply echo the message on the eventbus - request.app["mass"].signal_event(msg, msg_details) - finally: - LOGGER.debug("Websocket disconnected") - for remove_callback in _callbacks: - remove_callback() - return ws_response - - -@ws_command("players") -async def async_players(mass: MusicAssistantType, msg_details: dict): - """Return players.""" - if msg_details and msg_details.get("player_id"): - return mass.players.get_player_state(msg_details["player_id"]) - return mass.players.player_states - - -@ws_command("tracks") -async def tracks(mass: MusicAssistantType, msg_details: dict): - """Return tracks.""" - if msg_details and msg_details.get("item_id"): - return await mass.music.async_get_track(msg_details["item_id"]) - return await mass.music.async_get_library_tracks() - - -@ws_command("albums") -async def albums(mass: MusicAssistantType, msg_details: dict): - """Return albums.""" - if msg_details and msg_details.get("item_id"): - return await mass.music.async_get_album(msg_details["item_id"]) - return await mass.music.async_get_library_albums() - - -@ws_command("artists") -async def artists(mass: MusicAssistantType, msg_details: dict): - """Return artists.""" - if msg_details and msg_details.get("item_id"): - return await mass.music.async_get_artist(msg_details["item_id"]) - return await mass.music.async_get_library_artists() - - -@ws_command("playlists") -async def playlists(mass: MusicAssistantType, msg_details: dict): - """Return playlists.""" - if msg_details and msg_details.get("item_id"): - return await mass.music.async_get_playlist(msg_details["item_id"]) - return await mass.music.async_get_library_playlists() - - -@ws_command("radios") -async def radios(mass: MusicAssistantType, msg_details: dict): - """Return radios.""" - if msg_details and msg_details.get("item_id"): - return await mass.music.async_get_radio(msg_details["item_id"]) - return await mass.music.async_get_library_radios() - - -@ws_command("player_command") -async def async_player_command(mass: MusicAssistantType, msg_details: dict): - """Handle player command.""" - player_id = msg_details.get("player_id") - cmd = msg_details.get("cmd") - cmd_args = msg_details.get("cmd_args") - player_cmd = getattr(mass.players, f"async_cmd_{cmd}", None) - if player_cmd and cmd_args is not None: - result = await player_cmd(player_id, cmd_args) - elif player_cmd: - result = await player_cmd(player_id) - msg_details = {"cmd": cmd, "result": result} - return msg_details diff --git a/music_assistant/web/endpoints/json_rpc.py b/music_assistant/web/json_rpc.py similarity index 90% rename from music_assistant/web/endpoints/json_rpc.py rename to music_assistant/web/json_rpc.py index ffc96dc6..3d442c89 100644 --- a/music_assistant/web/endpoints/json_rpc.py +++ b/music_assistant/web/json_rpc.py @@ -1,19 +1,15 @@ """JSON RPC API endpoint.""" -from aiohttp.web import Request, Response, RouteTableDef +from aiohttp.web import Request, Response from music_assistant.helpers.web import require_local_subnet -routes = RouteTableDef() - -@routes.route("get", "/jsonrpc.js") -@routes.route("post", "/jsonrpc.js") @require_local_subnet -async def async_json_rpc(request: Request): +async def json_rpc_endpoint(request: Request): """ - Implement LMS jsonrpc interface. + Implement basic jsonrpc interface compatible with LMS. - for some compatability with tools that talk to lms + for some compatability with tools that talk to LMS only support for basic commands """ # pylint: disable=too-many-branches diff --git a/music_assistant/web/setup.html b/music_assistant/web/setup.html new file mode 100644 index 00000000..29de572a --- /dev/null +++ b/music_assistant/web/setup.html @@ -0,0 +1,114 @@ + + + + + + + + + +
+ + + + + + + + Setup MusicAssistant server + + + + In order to use the MusicAssistant server, you must setup a username and password to protect the server. + When you click submit, the server will be setup and you can login with the created credentials. +

+ + + + + + Useraccount created and server is ready. You will be redirected to the webinterface. + Login with your newly created credentials and configure the other aspects of the server to get going! + +
+ + + Submit + +
+
+
+
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/music_assistant/web/endpoints/streams.py b/music_assistant/web/streams.py similarity index 99% rename from music_assistant/web/endpoints/streams.py rename to music_assistant/web/streams.py index 62e110eb..365d19dd 100644 --- a/music_assistant/web/endpoints/streams.py +++ b/music_assistant/web/streams.py @@ -1,4 +1,4 @@ -"""Players API endpoints.""" +"""Audio streaming endpoints.""" from aiohttp.web import Request, Response, RouteTableDef, StreamResponse from music_assistant.helpers.web import require_local_subnet diff --git a/music_assistant/web/websocket.py b/music_assistant/web/websocket.py new file mode 100644 index 00000000..345dde6a --- /dev/null +++ b/music_assistant/web/websocket.py @@ -0,0 +1,133 @@ +"""Websocket API endpoint.""" +import asyncio +import logging +from typing import Any, Optional + +import jwt +import ujson +from aiohttp import WSMsgType +from aiohttp.web import View, WebSocketResponse +from music_assistant.helpers import repath +from music_assistant.helpers.typing import MusicAssistantType +from music_assistant.helpers.web import json_serializer, parse_arguments + +LOGGER = logging.getLogger("web.endpoints.websocket") + + +class WebSocketHandler(View): + """Handler for websockets API.""" + + authenticated = False + _ws = None + mass: MusicAssistantType = None + + def __init__(self, *args, **kwargs): + """Initialize.""" + super().__init__(*args, **kwargs) + self.mass: MusicAssistantType = self.request.app["mass"] + + async def get(self): + """Handle main ws entrypoint.""" + websocket = WebSocketResponse() + await websocket.prepare(self.request) + + self.request.app["websockets"].append(self) + self._ws = websocket + + LOGGER.debug("new client connected: %s", self.request.remote) + + async for msg in websocket: + if msg.type == WSMsgType.text: + if msg.data == "close": + await websocket.close() + break + try: + json_msg = msg.json(loads=ujson.loads) + if "command" in json_msg and "data" in json_msg: + # handle command + await self.handle_command( + json_msg["command"], + json_msg["data"], + json_msg.get("id"), + ) + elif "event" in json_msg: + # handle event + await self.handle_event(json_msg["event"], json_msg.get("data")) + else: + raise KeyError + except (KeyError, ValueError): + await self.send( + error='commands must be issued in json format \ + {"command": "command", "data":" optional data"}', + ) + elif msg.type == WSMsgType.error: + LOGGER.warning( + "ws connection closed with exception %s", websocket.exception() + ) + + # websocket disconnected + await self.close() + return websocket + + async def send(self, **kwargs): + """Send message (back) to websocket client.""" + ws_msg = kwargs + await self._ws.send_str(json_serializer(ws_msg)) + + async def close(self, reason=""): + """Close websocket connection.""" + try: + await self._ws.close(message=reason.encode()) + finally: + self.request.app["websockets"].remove(self) + LOGGER.debug("websocket connection closed: %s", self.request.remote) + + async def handle_command(self, command: str, data: Optional[dict], id: Any = None): + """Handle websocket command.""" + res = None + try: + if command == "auth": + res = await self.auth(data) + return await self.send(id=id, result=command, data=res) + if command == "get_token": + res = await self.mass.web.get_token(**data) + if not res: + raise Exception("Invalid credentials") + return await self.send(id=id, result=command, data=res) + if not self.authenticated: + return await self.send( + id=id, + result=command, + error="Not authenticated, please login first.", + ) + # work out handler for the given path/command + for key in self.mass.web.api_routes: + match = repath.match(key, command) + if match: + params = match.groupdict() + handler = self.mass.web.api_routes[key] + if not data: + data = {} + params = parse_arguments(handler, {**params, **data}) + res = handler(**params) + if asyncio.iscoroutine(res): + res = await res + # return result of command to client + return await self.send(id=id, result=command, data=res) + raise KeyError("Unknown command") + except Exception as exc: # pylint:disable=broad-except + return await self.send(result=command, error=str(exc)) + + async def handle_event(self, event: str, data: Any): + """Handle command message.""" + LOGGER.info("received event %s", event) + if self.authenticated: + self.mass.signal_event(event, data) + + async def auth(self, token: str): + """Handle authentication with JWT token.""" + token_info = jwt.decode(token, self.mass.web.jwt_key) + if self.mass.web.is_token_revoked(None, token_info): + raise Exception("Token is revoked") + self.authenticated = True + return token_info diff --git a/requirements.txt b/requirements.txt index b89449ae..10001700 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,3 +19,4 @@ passlib==1.7.4 cryptography==3.2 ujson==4.0.1 mashumaro==1.13 +repath==0.9.0 -- 2.34.1