Refactor api (#48)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sun, 22 Nov 2020 09:45:55 +0000 (10:45 +0100)
committerGitHub <noreply@github.com>
Sun, 22 Nov 2020 09:45:55 +0000 (10:45 +0100)
* refactored api and authentication

41 files changed:
.github/workflows/publish-to-pypi.yml
music_assistant/__main__.py
music_assistant/constants.py
music_assistant/helpers/cache.py
music_assistant/helpers/images.py [new file with mode: 0644]
music_assistant/helpers/migration.py
music_assistant/helpers/repath.py [new file with mode: 0644]
music_assistant/helpers/web.py
music_assistant/managers/config.py
music_assistant/managers/database.py
music_assistant/managers/library.py
music_assistant/managers/music.py
music_assistant/managers/players.py
music_assistant/mass.py
music_assistant/models/config_entry.py
music_assistant/models/media_types.py
music_assistant/models/player.py
music_assistant/models/player_queue.py
music_assistant/providers/chromecast/player.py
music_assistant/providers/spotify/__init__.py
music_assistant/web/__init__.py
music_assistant/web/endpoints/__init__.py [deleted file]
music_assistant/web/endpoints/albums.py [deleted file]
music_assistant/web/endpoints/artists.py [deleted file]
music_assistant/web/endpoints/config.py [deleted file]
music_assistant/web/endpoints/images.py [deleted file]
music_assistant/web/endpoints/json_rpc.py [deleted file]
music_assistant/web/endpoints/library.py [deleted file]
music_assistant/web/endpoints/login.py [deleted file]
music_assistant/web/endpoints/players.py [deleted file]
music_assistant/web/endpoints/playlists.py [deleted file]
music_assistant/web/endpoints/radios.py [deleted file]
music_assistant/web/endpoints/search.py [deleted file]
music_assistant/web/endpoints/streams.py [deleted file]
music_assistant/web/endpoints/tracks.py [deleted file]
music_assistant/web/endpoints/websocket.py [deleted file]
music_assistant/web/json_rpc.py [new file with mode: 0644]
music_assistant/web/setup.html [new file with mode: 0644]
music_assistant/web/streams.py [new file with mode: 0644]
music_assistant/web/websocket.py [new file with mode: 0644]
requirements.txt

index 1ee29df005113e220b415b8fbed1fc5a37817ebc..10a0b604b52bfa2fd4a794843df2c4fc994d219b 100644 (file)
@@ -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
index d8658479a8b39d32fc3c5ab04fdf6eb9e3b93499..e18e03c59fdb5ba2d8b01028983373a4bf8c952c 100755 (executable)
@@ -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)
index 790a7d07e2d70b040be110651ac1adfcabdbe5af..2918c8badb22009eb22610602a88c0013bd0f9f3 100755 (executable)
@@ -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"
index 785fc7004c4417feac83105b4ca636707235cda8..e48aad8206f0a10805bbbc22e99a0aa0df2db09e 100644 (file)
@@ -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/helpers/images.py b/music_assistant/helpers/images.py
new file mode 100644 (file)
index 0000000..2e501fb
--- /dev/null
@@ -0,0 +1,76 @@
+"""Utilities for image manipulation and retrieval."""
+
+import os
+from io import BytesIO
+
+from music_assistant.helpers.typing import MusicAssistantType
+from music_assistant.models.media_types import MediaType
+from PIL import Image
+
+
+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)
+    cache_file = os.path.join(cache_folder, f"{cache_id}.png")
+    if os.path.isfile(cache_file):
+        # return file from cache
+        return cache_file
+    # no file in cache so we should get it
+    os.makedirs(cache_folder, exist_ok=True)
+    # download base image
+    async with mass.http_session.get(url, verify_ssl=False) as response:
+        assert response.status == 200
+        img_data = BytesIO(await response.read())
+
+    # save resized image
+    if size:
+        basewidth = size
+        img = Image.open(img_data)
+        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, format="png")
+    else:
+        with open(cache_file, "wb") as _file:
+            _file.write(img_data.getvalue())
+    # return file from cache
+    return cache_file
+
+
+async def async_get_image_url(
+    mass: MusicAssistantType, item_id: str, provider_id: str, media_type: MediaType
+):
+    """Get url to image for given media item."""
+    item = await mass.music.async_get_item(item_id, provider_id, media_type)
+    if not item:
+        return None
+    if item and item.metadata.get("image"):
+        return item.metadata["image"]
+    if (
+        hasattr(item, "album")
+        and hasattr(item.album, "metadata")
+        and item.album.metadata.get("image")
+    ):
+        return item.album.metadata["image"]
+    if hasattr(item, "albums"):
+        for album in item.albums:
+            if hasattr(album, "metadata") and album.metadata.get("image"):
+                return album.metadata["image"]
+    if (
+        hasattr(item, "artist")
+        and hasattr(item.artist, "metadata")
+        and item.artist.metadata.get("image")
+    ):
+        return item.album.metadata["image"]
+    if media_type == MediaType.Track and item.album:
+        # try album instead for tracks
+        return await async_get_image_url(
+            mass, item.album.item_id, item.album.provider, MediaType.Album
+        )
+    elif media_type == MediaType.Album and item.artist:
+        # try artist instead for albums
+        return await async_get_image_url(
+            mass, item.artist.item_id, item.artist.provider, MediaType.Artist
+        )
+    return None
index 926bb7062bec5e5ae4e1924a1325fa97f9b9e637..1a8039f89c328b6e879ccddcf7e7ef3375e27758 100644 (file)
@@ -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 (file)
index 0000000..12125a9
--- /dev/null
@@ -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)
index 77c3145b51b1bf01133c7e45e6b5a3306b0dcf97..77dae2b79d7e19f553e2410ced0c9bb42e49d199 100644 (file)
@@ -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)
index 3655974e3749609d9ba91ca28aae3088af308040..5f24ee128d4b6e9d4f9106347baba3ad6e72ed4b 100755 (executable)
@@ -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
index a62d5cc2b0fb98d59f34462778eb4fda07715be3..3e4cc527286a99485c6f11adb2066c78a507824b 100755 (executable)
@@ -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:
index 36dd882381abff06630276484a3a3fa5f19f6fca..10399d6ca737d700dc4b35cca31836b3fb894f2d 100755 (executable)
@@ -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
                 )
index 0457165b692aeb928d94019e484eacfac8187735..7ac446bb448b9d0631ec7df1736755a3b6c5f12b 100755 (executable)
@@ -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.
         """
index cf51dab78b41918e7b7dfa2336090da942826c6a..a277e30d484b91e3df51e3dbaef4503be20044dc 100755 (executable)
@@ -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)
index e22ddea7d2bcf4bac102d2ec9431bf86cf4dc80b..8b979dc9cb97c2bee81a67cad2f4d6ac726e07e6 100644 (file)
@@ -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,
index 0ff6850b3247360d139c1fa7340db49c1dae58cf..1458406171fe9792deda5688de8d80c560ccfee4 100644 (file)
@@ -16,6 +16,7 @@ class ConfigEntryType(Enum):
     INT = "integer"
     FLOAT = "float"
     LABEL = "label"
+    DICT = "dict"
 
 
 @dataclass
index 7543e95d5f3c57ee78d86c151fd82cde7ccb6836..ff02abf2bbaa4c3d3107d2d81541c448ddd20209 100755 (executable)
@@ -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
index 3b7cf7b601a756f521434e7ada3f7809032f63c1..633b41f59c7c731491796b323517c9199d3c3b4a 100755 (executable)
@@ -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)
index f3290650ccdef92e9516f37bc04f6e2649612977..02cba3e6532590c0240043ba2114fd199a8062be 100755 (executable)
@@ -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)
index 723e6bf6971c1e7b1d53466279b0600630674c73..e3be4d72004542a960c95cdf6d027c11268b0f89 100644 (file)
@@ -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)
index 1053b4682374acb9fbbc34c4c60f71178921fc81..9c02374c5456314d4c26e57d151c11a174069d3a 100644 (file)
@@ -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"])
         ]
index 391152480f9e91905afe5135befb7ce8d1db4d78..12762e0e64b762a41a775c56ab182d1bd349b410 100755 (executable)
@@ -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 (file)
index fc1c4cc..0000000
+++ /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 (file)
index d7abacb..0000000
+++ /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 (file)
index b165ca1..0000000
+++ /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 (file)
index 949dc0e..0000000
+++ /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/images.py b/music_assistant/web/endpoints/images.py
deleted file mode 100644 (file)
index 7f4c58c..0000000
+++ /dev/null
@@ -1,114 +0,0 @@
-"""Images API endpoints."""
-
-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):
-    """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)
-    cache_file = os.path.join(cache_folder, f"{cache_id}.png")
-    if os.path.isfile(cache_file):
-        # return file from cache
-        return cache_file
-    # no file in cache so we should get it
-    os.makedirs(cache_folder, exist_ok=True)
-    # download base image
-    async with mass.http_session.get(url, verify_ssl=False) as response:
-        assert response.status == 200
-        img_data = BytesIO(await response.read())
-
-    # save resized image
-    if size:
-        basewidth = size
-        img = Image.open(img_data)
-        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)
-    else:
-        with open(cache_file, "wb") as _file:
-            _file.write(img_data.getvalue())
-    # return file from cache
-    return cache_file
-
-
-async def async_get_image_url(
-    mass: MusicAssistantType, item_id: str, provider_id: str, media_type: MediaType
-):
-    """Get url to image for given media item."""
-    item = await mass.music.async_get_item(item_id, provider_id, media_type)
-    if not item:
-        return None
-    if item and item.metadata.get("image"):
-        return item.metadata["image"]
-    if (
-        hasattr(item, "album")
-        and hasattr(item.album, "metadata")
-        and item.album.metadata.get("image")
-    ):
-        return item.album.metadata["image"]
-    if hasattr(item, "albums"):
-        for album in item.albums:
-            if hasattr(album, "metadata") and album.metadata.get("image"):
-                return album.metadata["image"]
-    if (
-        hasattr(item, "artist")
-        and hasattr(item.artist, "metadata")
-        and item.artist.metadata.get("image")
-    ):
-        return item.album.metadata["image"]
-    if media_type == MediaType.Track and item.album:
-        # try album instead for tracks
-        return await async_get_image_url(
-            mass, item.album.item_id, item.album.provider, MediaType.Album
-        )
-    elif media_type == MediaType.Album and item.artist:
-        # try artist instead for albums
-        return await async_get_image_url(
-            mass, item.artist.item_id, item.artist.provider, MediaType.Artist
-        )
-    return None
diff --git a/music_assistant/web/endpoints/json_rpc.py b/music_assistant/web/endpoints/json_rpc.py
deleted file mode 100644 (file)
index ffc96dc..0000000
+++ /dev/null
@@ -1,67 +0,0 @@
-"""JSON RPC API endpoint."""
-
-from aiohttp.web import Request, Response, RouteTableDef
-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):
-    """
-    Implement LMS jsonrpc interface.
-
-    for some compatability with tools that talk to lms
-    only support for basic commands
-    """
-    # pylint: disable=too-many-branches
-    data = await request.json()
-    params = data["params"]
-    player_id = params[0]
-    cmds = params[1]
-    cmd_str = " ".join(cmds)
-    if cmd_str == "play":
-        await request.app["mass"].players.async_cmd_play(player_id)
-    elif cmd_str == "pause":
-        await request.app["mass"].players.async_cmd_pause(player_id)
-    elif cmd_str == "stop":
-        await request.app["mass"].players.async_cmd_stop(player_id)
-    elif cmd_str == "next":
-        await request.app["mass"].players.async_cmd_next(player_id)
-    elif cmd_str == "previous":
-        await request.app["mass"].players.async_cmd_previous(player_id)
-    elif "power" in cmd_str:
-        powered = cmds[1] if len(cmds) > 1 else False
-        if powered:
-            await request.app["mass"].players.async_cmd_power_on(player_id)
-        else:
-            await request.app["mass"].players.async_cmd_power_off(player_id)
-    elif cmd_str == "playlist index +1":
-        await request.app["mass"].players.async_cmd_next(player_id)
-    elif cmd_str == "playlist index -1":
-        await request.app["mass"].players.async_cmd_previous(player_id)
-    elif "mixer volume" in cmd_str and "+" in cmds[2]:
-        player_state = request.app["mass"].players.get_player_state(player_id)
-        volume_level = player_state.volume_level + int(cmds[2].split("+")[1])
-        await request.app["mass"].players.async_cmd_volume_set(player_id, volume_level)
-    elif "mixer volume" in cmd_str and "-" in cmds[2]:
-        player_state = request.app["mass"].players.get_player_state(player_id)
-        volume_level = player_state.volume_level - int(cmds[2].split("-")[1])
-        await request.app["mass"].players.async_cmd_volume_set(player_id, volume_level)
-    elif "mixer volume" in cmd_str:
-        await request.app["mass"].players.async_cmd_volume_set(player_id, cmds[2])
-    elif cmd_str == "mixer muting 1":
-        await request.app["mass"].players.async_cmd_volume_mute(player_id, True)
-    elif cmd_str == "mixer muting 0":
-        await request.app["mass"].players.async_cmd_volume_mute(player_id, False)
-    elif cmd_str == "button volup":
-        await request.app["mass"].players.async_cmd_volume_up(player_id)
-    elif cmd_str == "button voldown":
-        await request.app["mass"].players.async_cmd_volume_down(player_id)
-    elif cmd_str == "button power":
-        await request.app["mass"].players.async_cmd_power_toggle(player_id)
-    else:
-        return Response(text="command not supported")
-    return Response(text="success")
diff --git a/music_assistant/web/endpoints/library.py b/music_assistant/web/endpoints/library.py
deleted file mode 100644 (file)
index 32f6cae..0000000
+++ /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 (file)
index 2348050..0000000
+++ /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 (file)
index ebe0ba7..0000000
+++ /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 (file)
index 3873e0a..0000000
+++ /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 (file)
index 1db1a16..0000000
+++ /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 (file)
index 2fd3d1d..0000000
+++ /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/streams.py b/music_assistant/web/endpoints/streams.py
deleted file mode 100644 (file)
index 62e110e..0000000
+++ /dev/null
@@ -1,106 +0,0 @@
-"""Players API endpoints."""
-
-from aiohttp.web import Request, Response, RouteTableDef, StreamResponse
-from music_assistant.helpers.web import require_local_subnet
-from music_assistant.models.media_types import MediaType
-
-routes = RouteTableDef()
-
-
-@routes.get("/stream/media/{media_type}/{item_id}")
-async def stream_media(request: Request):
-    """Stream a single audio track."""
-    media_type = MediaType(request.match_info["media_type"])
-    if media_type not in [MediaType.Track, MediaType.Radio]:
-        return Response(status=404, reason="Media item is not playable!")
-    item_id = request.match_info["item_id"]
-    provider = request.rel_url.query.get("provider", "database")
-    media_item = await request.app["mass"].music.async_get_item(
-        item_id, provider, media_type
-    )
-    streamdetails = await request.app["mass"].music.async_get_stream_details(media_item)
-
-    # prepare request
-    content_type = streamdetails.content_type.value
-    resp = StreamResponse(
-        status=200, reason="OK", headers={"Content-Type": f"audio/{content_type}"}
-    )
-
-    resp.enable_chunked_encoding()
-    resp.enable_compression()
-    await resp.prepare(request)
-
-    # stream track
-    async for audio_chunk in request.app["mass"].streams.async_get_media_stream(
-        streamdetails
-    ):
-        await resp.write(audio_chunk)
-    return resp
-
-
-@routes.get("/stream/queue/{player_id}")
-@require_local_subnet
-async def stream_queue(request: Request):
-    """Stream a player's queue."""
-    player_id = request.match_info["player_id"]
-    if not request.app["mass"].players.get_player_queue(player_id):
-        return Response(text="invalid queue", status=404)
-
-    # prepare request
-    resp = StreamResponse(
-        status=200, reason="OK", headers={"Content-Type": "audio/flac"}
-    )
-    resp.enable_chunked_encoding()
-    resp.enable_compression()
-    await resp.prepare(request)
-
-    # stream queue
-    async for audio_chunk in request.app["mass"].streams.async_queue_stream_flac(
-        player_id
-    ):
-        await resp.write(audio_chunk)
-    return resp
-
-
-@routes.get("/stream/queue/{player_id}/{queue_item_id}")
-@require_local_subnet
-async def stream_queue_item(request: Request):
-    """Stream a single queue item."""
-    player_id = request.match_info["player_id"]
-    queue_item_id = request.match_info["queue_item_id"]
-
-    # prepare request
-    resp = StreamResponse(
-        status=200, reason="OK", headers={"Content-Type": "audio/flac"}
-    )
-    resp.enable_chunked_encoding()
-    resp.enable_compression()
-    await resp.prepare(request)
-
-    async for audio_chunk in request.app["mass"].streams.async_stream_queue_item(
-        player_id, queue_item_id
-    ):
-        await resp.write(audio_chunk)
-    return resp
-
-
-@routes.get("/stream/group/{group_player_id}")
-@require_local_subnet
-async def stream_group(request: Request):
-    """Handle streaming to all players of a group. Highly experimental."""
-    group_player_id = request.match_info["group_player_id"]
-    if not request.app["mass"].players.get_player_queue(group_player_id):
-        return Response(text="invalid player id", status=404)
-    child_player_id = request.rel_url.query.get("player_id", request.remote)
-
-    # prepare request
-    resp = StreamResponse(
-        status=200, reason="OK", headers={"Content-Type": "audio/flac"}
-    )
-    await resp.prepare(request)
-
-    # stream queue
-    player_state = request.app["mass"].players.get_player(group_player_id)
-    async for audio_chunk in player_state.subscribe_stream_client(child_player_id):
-        await resp.write(audio_chunk)
-    return resp
diff --git a/music_assistant/web/endpoints/tracks.py b/music_assistant/web/endpoints/tracks.py
deleted file mode 100644 (file)
index ec1aa8a..0000000
+++ /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 (file)
index 1e4c9b7..0000000
+++ /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/json_rpc.py b/music_assistant/web/json_rpc.py
new file mode 100644 (file)
index 0000000..3d442c8
--- /dev/null
@@ -0,0 +1,63 @@
+"""JSON RPC API endpoint."""
+
+from aiohttp.web import Request, Response
+from music_assistant.helpers.web import require_local_subnet
+
+
+@require_local_subnet
+async def json_rpc_endpoint(request: Request):
+    """
+    Implement basic jsonrpc interface compatible with LMS.
+
+    for some compatability with tools that talk to LMS
+    only support for basic commands
+    """
+    # pylint: disable=too-many-branches
+    data = await request.json()
+    params = data["params"]
+    player_id = params[0]
+    cmds = params[1]
+    cmd_str = " ".join(cmds)
+    if cmd_str == "play":
+        await request.app["mass"].players.async_cmd_play(player_id)
+    elif cmd_str == "pause":
+        await request.app["mass"].players.async_cmd_pause(player_id)
+    elif cmd_str == "stop":
+        await request.app["mass"].players.async_cmd_stop(player_id)
+    elif cmd_str == "next":
+        await request.app["mass"].players.async_cmd_next(player_id)
+    elif cmd_str == "previous":
+        await request.app["mass"].players.async_cmd_previous(player_id)
+    elif "power" in cmd_str:
+        powered = cmds[1] if len(cmds) > 1 else False
+        if powered:
+            await request.app["mass"].players.async_cmd_power_on(player_id)
+        else:
+            await request.app["mass"].players.async_cmd_power_off(player_id)
+    elif cmd_str == "playlist index +1":
+        await request.app["mass"].players.async_cmd_next(player_id)
+    elif cmd_str == "playlist index -1":
+        await request.app["mass"].players.async_cmd_previous(player_id)
+    elif "mixer volume" in cmd_str and "+" in cmds[2]:
+        player_state = request.app["mass"].players.get_player_state(player_id)
+        volume_level = player_state.volume_level + int(cmds[2].split("+")[1])
+        await request.app["mass"].players.async_cmd_volume_set(player_id, volume_level)
+    elif "mixer volume" in cmd_str and "-" in cmds[2]:
+        player_state = request.app["mass"].players.get_player_state(player_id)
+        volume_level = player_state.volume_level - int(cmds[2].split("-")[1])
+        await request.app["mass"].players.async_cmd_volume_set(player_id, volume_level)
+    elif "mixer volume" in cmd_str:
+        await request.app["mass"].players.async_cmd_volume_set(player_id, cmds[2])
+    elif cmd_str == "mixer muting 1":
+        await request.app["mass"].players.async_cmd_volume_mute(player_id, True)
+    elif cmd_str == "mixer muting 0":
+        await request.app["mass"].players.async_cmd_volume_mute(player_id, False)
+    elif cmd_str == "button volup":
+        await request.app["mass"].players.async_cmd_volume_up(player_id)
+    elif cmd_str == "button voldown":
+        await request.app["mass"].players.async_cmd_volume_down(player_id)
+    elif cmd_str == "button power":
+        await request.app["mass"].players.async_cmd_power_toggle(player_id)
+    else:
+        return Response(text="command not supported")
+    return Response(text="success")
diff --git a/music_assistant/web/setup.html b/music_assistant/web/setup.html
new file mode 100644 (file)
index 0000000..29de572
--- /dev/null
@@ -0,0 +1,114 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
+  <link href="https://cdn.jsdelivr.net/npm/@mdi/font@4.x/css/materialdesignicons.min.css" rel="stylesheet">
+  <link href="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.min.css" rel="stylesheet">
+  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
+</head>
+<body>
+  <div id="app">
+    <v-app>
+        <v-main>
+            <v-container fluid fill-height>
+              <v-layout align-center justify-center>
+                <v-flex sm8>
+                  <v-card class="elevation-12">
+                    <v-toolbar dark color="black">
+                      <v-toolbar-title>Setup MusicAssistant server</v-toolbar-title>
+                      <v-spacer></v-spacer>
+                    </v-toolbar>
+                    <v-card-text>
+                        <span>In order to use the MusicAssistant server, you must setup a username and password to protect the server.</span>
+                        <span>When you click submit, the server will be setup and you can login with the created credentials.</span>
+                        <br /><br />
+                        <v-form ref="form" v-model="valid" method="post">
+                        <v-text-field
+                          @keyup.enter="submit"
+                          v-model="username"
+                          prepend-icon="mdi-account"
+                          name="login"
+                          label="Username"
+                          type="text"
+                          required
+                          :rules="[v => !!v || 'Username is required']"
+                        ></v-text-field>
+                        <v-text-field
+                          @keyup.enter="submit"
+                          v-model="password1"
+                          prepend-icon="mdi-lock"
+                          name="password1"
+                          label="Password"
+                          id="password1"
+                          type="password"
+                          required
+                          :rules="[v => v.length >= 8 || 'Password must have minimum of 8 characters']"
+                        ></v-text-field>
+                        <v-text-field
+                          @keyup.enter="submit"
+                          v-model="password2"
+                          prepend-icon="mdi-lock"
+                          name="password2"
+                          label="Repeat password"
+                          id="password"
+                          type="password"
+                          required
+                          :rules="[
+                                password1 === password2 || 'Passwords do not match'
+                            ]"
+                        ></v-text-field>
+                      </v-form>
+                      <v-alert type="success" v-if="success"
+                        >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!
+                        </v-alert>
+                    </v-card-text>
+                    <v-card-actions>
+                      <v-spacer></v-spacer>
+                      <v-btn type="submit" :disabled="!valid" color="success" @click="submit"
+                      class="mr-4">Submit</v-btn>
+                    </v-card-actions>
+                  </v-card>
+                </v-flex>
+              </v-layout>
+            </v-container>
+          </v-main>
+    </v-app>
+  </div>
+
+  <script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script>
+  <script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script>
+  <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
+  <script>
+    new Vue({
+      el: '#app',
+      vuetify: new Vuetify(),
+      data: () => ({
+            username: '',
+            password1: '',
+            password2: '',
+            valid: true,
+            success: false
+        }),
+      methods: {
+          submit () {
+            const formData = new FormData()
+            formData.append('username', this.username)
+            formData.append('password', this.password1)
+            axios.post('/setup', formData)
+                .then(function (response) {
+                this.success = true
+                // refresh page, server will redirect to webapp
+                setTimeout(function() {
+                        document.location.reload()
+                    }, 5000);
+            }.bind(this))
+            .catch(function (error) {
+                console.log('error', error)
+            });
+        }
+      }
+    })
+  </script>
+</body>
+</html>
\ No newline at end of file
diff --git a/music_assistant/web/streams.py b/music_assistant/web/streams.py
new file mode 100644 (file)
index 0000000..365d19d
--- /dev/null
@@ -0,0 +1,106 @@
+"""Audio streaming endpoints."""
+
+from aiohttp.web import Request, Response, RouteTableDef, StreamResponse
+from music_assistant.helpers.web import require_local_subnet
+from music_assistant.models.media_types import MediaType
+
+routes = RouteTableDef()
+
+
+@routes.get("/stream/media/{media_type}/{item_id}")
+async def stream_media(request: Request):
+    """Stream a single audio track."""
+    media_type = MediaType(request.match_info["media_type"])
+    if media_type not in [MediaType.Track, MediaType.Radio]:
+        return Response(status=404, reason="Media item is not playable!")
+    item_id = request.match_info["item_id"]
+    provider = request.rel_url.query.get("provider", "database")
+    media_item = await request.app["mass"].music.async_get_item(
+        item_id, provider, media_type
+    )
+    streamdetails = await request.app["mass"].music.async_get_stream_details(media_item)
+
+    # prepare request
+    content_type = streamdetails.content_type.value
+    resp = StreamResponse(
+        status=200, reason="OK", headers={"Content-Type": f"audio/{content_type}"}
+    )
+
+    resp.enable_chunked_encoding()
+    resp.enable_compression()
+    await resp.prepare(request)
+
+    # stream track
+    async for audio_chunk in request.app["mass"].streams.async_get_media_stream(
+        streamdetails
+    ):
+        await resp.write(audio_chunk)
+    return resp
+
+
+@routes.get("/stream/queue/{player_id}")
+@require_local_subnet
+async def stream_queue(request: Request):
+    """Stream a player's queue."""
+    player_id = request.match_info["player_id"]
+    if not request.app["mass"].players.get_player_queue(player_id):
+        return Response(text="invalid queue", status=404)
+
+    # prepare request
+    resp = StreamResponse(
+        status=200, reason="OK", headers={"Content-Type": "audio/flac"}
+    )
+    resp.enable_chunked_encoding()
+    resp.enable_compression()
+    await resp.prepare(request)
+
+    # stream queue
+    async for audio_chunk in request.app["mass"].streams.async_queue_stream_flac(
+        player_id
+    ):
+        await resp.write(audio_chunk)
+    return resp
+
+
+@routes.get("/stream/queue/{player_id}/{queue_item_id}")
+@require_local_subnet
+async def stream_queue_item(request: Request):
+    """Stream a single queue item."""
+    player_id = request.match_info["player_id"]
+    queue_item_id = request.match_info["queue_item_id"]
+
+    # prepare request
+    resp = StreamResponse(
+        status=200, reason="OK", headers={"Content-Type": "audio/flac"}
+    )
+    resp.enable_chunked_encoding()
+    resp.enable_compression()
+    await resp.prepare(request)
+
+    async for audio_chunk in request.app["mass"].streams.async_stream_queue_item(
+        player_id, queue_item_id
+    ):
+        await resp.write(audio_chunk)
+    return resp
+
+
+@routes.get("/stream/group/{group_player_id}")
+@require_local_subnet
+async def stream_group(request: Request):
+    """Handle streaming to all players of a group. Highly experimental."""
+    group_player_id = request.match_info["group_player_id"]
+    if not request.app["mass"].players.get_player_queue(group_player_id):
+        return Response(text="invalid player id", status=404)
+    child_player_id = request.rel_url.query.get("player_id", request.remote)
+
+    # prepare request
+    resp = StreamResponse(
+        status=200, reason="OK", headers={"Content-Type": "audio/flac"}
+    )
+    await resp.prepare(request)
+
+    # stream queue
+    player_state = request.app["mass"].players.get_player(group_player_id)
+    async for audio_chunk in player_state.subscribe_stream_client(child_player_id):
+        await resp.write(audio_chunk)
+    return resp
diff --git a/music_assistant/web/websocket.py b/music_assistant/web/websocket.py
new file mode 100644 (file)
index 0000000..345dde6
--- /dev/null
@@ -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
index b89449ae3e9808ca2bbdd212feb0cc3f097bc95f..10001700df9b7767e6088be7b61ae3328b1dc1b5 100644 (file)
@@ -19,3 +19,4 @@ passlib==1.7.4
 cryptography==3.2
 ujson==4.0.1
 mashumaro==1.13
+repath==0.9.0