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
# 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)
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"
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"
import os
import pickle
import time
-from functools import reduce
from typing import Awaitable
import aiosqlite
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(
--- /dev/null
+"""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
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
# 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)
--- /dev/null
+"""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)
"""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):
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()
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)
import logging
import os
import shutil
-from typing import List
+from typing import Any, List
from music_assistant.constants import (
CONF_CROSSFADE_DURATION,
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,
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
)
]
-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,
store_hashed=True,
),
],
+ CONF_KEY_SECURITY_APP_TOKENS: [],
}
"""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."""
"""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:
"""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)
"""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
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
"""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()
}
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."""
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."""
"""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
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)
}
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, {})
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)
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
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:
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,
################ 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)
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)
)
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)
)
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)
# 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(
)
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)
# 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
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:
# 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
# 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
)
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,
async def async_setup(self):
"""Async initialize of module."""
- # nothing to do
@property
def providers(self) -> List[MusicProvider]:
################ 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
):
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:
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
)
return artist
+ @api_route("albums/:provider_id/:item_id")
async def async_get_album(
self, item_id: str, provider_id: str, refresh=False
) -> Album:
)
return album
+ @api_route("tracks/:provider_id/:item_id")
async def async_get_track(
self,
item_id: str,
)
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
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
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]:
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]:
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]:
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]:
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
)
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,
limit,
)
+ @api_route("search")
async def async_global_search(
self, search_query, media_types: List[MediaType], limit: int = 10
) -> SearchResult:
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([], [], [], [], [])
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:
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:
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:
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:
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.
"""
"""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,
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."""
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)
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)
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:
return self._controls[control_id]
@callback
+ @api_route("players/controls")
def get_player_controls(
self, filter_type: Optional[PlayerControlType] = None
) -> List[PlayerControl]:
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,
# 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),
]:
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,
# 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),
]:
# 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
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(
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
# 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(
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)
)
# 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()
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(
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
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,
INT = "integer"
FLOAT = "float"
LABEL = "label"
+ DICT = "dict"
@dataclass
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):
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
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
@dataclass
-class PlayerControl:
+class PlayerControl(DataClassDictMixin):
"""
Model for a player control.
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."""
# 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)
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
@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
"""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
"""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
@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
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:
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
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:
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,
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"]
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,
"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)
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)."""
"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()
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."""
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."""
# 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"
)
"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:])
"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."""
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)
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"])
]
-"""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:
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):
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)
+++ /dev/null
-"""Web endpoints package."""
+++ /dev/null
-"""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)
- )
+++ /dev/null
-"""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)
+++ /dev/null
-"""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]
- )
+++ /dev/null
-"""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
+++ /dev/null
-"""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")
+++ /dev/null
-"""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)
+++ /dev/null
-"""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
+++ /dev/null
-"""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)
+++ /dev/null
-"""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)
+++ /dev/null
-"""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)
- )
+++ /dev/null
-"""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
- )
- )
+++ /dev/null
-"""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
+++ /dev/null
-"""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)
+++ /dev/null
-"""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
--- /dev/null
+"""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")
--- /dev/null
+<!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
--- /dev/null
+"""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
--- /dev/null
+"""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
cryptography==3.2
ujson==4.0.1
mashumaro==1.13
+repath==0.9.0