refactor part 2 finished (#24)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 1 Oct 2020 23:26:30 +0000 (01:26 +0200)
committerGitHub <noreply@github.com>
Thu, 1 Oct 2020 23:26:30 +0000 (01:26 +0200)
84 files changed:
.github/workflows/publish-to-pypi.yml
.vscode/settings.json
music_assistant/app_vars.py [deleted file]
music_assistant/cache.py [deleted file]
music_assistant/config.py [deleted file]
music_assistant/constants.py
music_assistant/database.py [deleted file]
music_assistant/helpers/app_vars.py [new file with mode: 0644]
music_assistant/helpers/cache.py [new file with mode: 0644]
music_assistant/helpers/musicbrainz.py [new file with mode: 0644]
music_assistant/helpers/util.py [new file with mode: 0755]
music_assistant/helpers/web.py [new file with mode: 0644]
music_assistant/managers/__init__.py [new file with mode: 0644]
music_assistant/managers/config.py [new file with mode: 0755]
music_assistant/managers/database.py [new file with mode: 0755]
music_assistant/managers/metadata.py [new file with mode: 0755]
music_assistant/managers/music.py [new file with mode: 0755]
music_assistant/managers/players.py [new file with mode: 0755]
music_assistant/managers/streams.py [new file with mode: 0755]
music_assistant/mass.py
music_assistant/metadata.py [deleted file]
music_assistant/models/config_entry.py
music_assistant/models/media_types.py
music_assistant/models/musicprovider.py [deleted file]
music_assistant/models/player.py
music_assistant/models/player_queue.py
music_assistant/models/player_state.py
music_assistant/models/playerprovider.py [deleted file]
music_assistant/models/provider.py
music_assistant/models/streamdetails.py
music_assistant/music_manager.py [deleted file]
music_assistant/player_manager.py [deleted file]
music_assistant/providers/builtin/__init__.py [new file with mode: 0644]
music_assistant/providers/builtin/icon.png [new file with mode: 0644]
music_assistant/providers/builtin/translations.json [new file with mode: 0644]
music_assistant/providers/chromecast/__init__.py
music_assistant/providers/chromecast/icon.png [new file with mode: 0644]
music_assistant/providers/chromecast/player.py
music_assistant/providers/demo/__init__.py [deleted file]
music_assistant/providers/demo/demo_musicprovider.py [deleted file]
music_assistant/providers/demo/demo_playerprovider.py [deleted file]
music_assistant/providers/fanarttv/__init__.py [new file with mode: 0755]
music_assistant/providers/fanarttv/icon.png [new file with mode: 0644]
music_assistant/providers/file/__init__.py
music_assistant/providers/file/icon.png [new file with mode: 0644]
music_assistant/providers/group_player/__init__.py
music_assistant/providers/group_player/icon.png [new file with mode: 0644]
music_assistant/providers/qobuz/__init__.py
music_assistant/providers/qobuz/icon.png [new file with mode: 0644]
music_assistant/providers/sonos/icon.png [new file with mode: 0644]
music_assistant/providers/sonos/sonos.py
music_assistant/providers/spotify/__init__.py
music_assistant/providers/spotify/icon.png [new file with mode: 0644]
music_assistant/providers/spotify/translations.json [new file with mode: 0644]
music_assistant/providers/squeezebox/__init__.py
music_assistant/providers/squeezebox/discovery.py
music_assistant/providers/squeezebox/icon.png [new file with mode: 0644]
music_assistant/providers/squeezebox/socket_client.py
music_assistant/providers/tunein/__init__.py
music_assistant/providers/tunein/icon.png [new file with mode: 0644]
music_assistant/providers/webplayer/icon.png [new file with mode: 0644]
music_assistant/stream_manager.py [deleted file]
music_assistant/translations.json [new file with mode: 0644]
music_assistant/utils.py [deleted file]
music_assistant/web.py [deleted file]
music_assistant/web/__init__.py [new file with mode: 0755]
music_assistant/web/endpoints/__init__.py [new file with mode: 0644]
music_assistant/web/endpoints/albums.py [new file with mode: 0644]
music_assistant/web/endpoints/artists.py [new file with mode: 0644]
music_assistant/web/endpoints/config.py [new file with mode: 0644]
music_assistant/web/endpoints/images.py [new file with mode: 0644]
music_assistant/web/endpoints/json_rpc.py [new file with mode: 0644]
music_assistant/web/endpoints/library.py [new file with mode: 0644]
music_assistant/web/endpoints/login.py [new file with mode: 0644]
music_assistant/web/endpoints/players.py [new file with mode: 0644]
music_assistant/web/endpoints/playlists.py [new file with mode: 0644]
music_assistant/web/endpoints/radios.py [new file with mode: 0644]
music_assistant/web/endpoints/search.py [new file with mode: 0644]
music_assistant/web/endpoints/streams.py [new file with mode: 0644]
music_assistant/web/endpoints/tracks.py [new file with mode: 0644]
music_assistant/web/endpoints/websocket.py [new file with mode: 0644]
pylintrc [deleted file]
requirements.txt
setup.cfg

index 5b4119addd51409db88516e2352a1d593c8a14f0..9a6e48d69d1d5dc24e038225cbbfe2539b9cffab 100644 (file)
@@ -20,7 +20,7 @@ jobs:
           curl https://github.com/music-assistant/app/archive/master.zip -LOk
           unzip master.zip
           cd /tmp/app-master
-          mv docs /home/runner/work/server/server/music_assistant/web
+          mv docs /home/runner/work/server/server/music_assistant/web/static
           cd /home/runner/work/server/server/
       - name: Install wheel
         run: >-
index 9cbb697426cdcf308ed2f1198778fa497a9d8c26..3b0a5ec13211048e776973ed43ac7bd723da74bf 100644 (file)
@@ -1,6 +1,9 @@
 {
     "python.linting.pylintEnabled": true,
+    "python.linting.pylintArgs": ["--rcfile=${workspaceFolder}/setup.cfg"],
     "python.linting.enabled": true,
     "python.pythonPath": "venv/bin/python3",
-    "python.linting.flake8Enabled": false
+    "python.linting.flake8Enabled": true,
+    "python.linting.flake8Args": ["--config=${workspaceFolder}/setup.cfg"],
+    "python.linting.mypyEnabled": false,
 }
\ No newline at end of file
diff --git a/music_assistant/app_vars.py b/music_assistant/app_vars.py
deleted file mode 100644 (file)
index b010396..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-"""Some magic to store some appvars."""
-# pylint: skip-file
-# flake8: noqa
-(
-    lambda __g: [
-        [
-            [
-                [
-                    None
-                    for __g["get_app_var"], get_app_var.__name__ in [
-                        (
-                            lambda index: (
-                                lambda __l: [
-                                    APP_VARS[__l["index"]] for __l["index"] in [(index)]
-                                ][0]
-                            )({}),
-                            "get_app_var",
-                        )
-                    ]
-                ][0]
-                for __g["APP_VARS"] in [
-                    (base64.b64decode(VARS_ENC).decode("utf-8").split(","))
-                ]
-            ][0]
-            for __g["VARS_ENC"] in [
-                (
-                    b"OTQyODUyNTY3LDc2MTczMGQzZjk1ZTRhZjA5YWM2M2I5YTM3Y2NjOTZhLDJlYjk2ZjliMzc0OTRiZTE4MjQ5OTlkNTgwMjhhMzA1LFNTcnRNMnhlM2wwMDNnOEh4RmVUUUtub3BaNklCaUwzRTlPc1QxODFYMDA9"
-                )
-            ]
-        ][0]
-        for __g["base64"] in [(__import__("base64", __g, __g))]
-    ][0]
-)(globals())
diff --git a/music_assistant/cache.py b/music_assistant/cache.py
deleted file mode 100644 (file)
index 7c36d04..0000000
+++ /dev/null
@@ -1,186 +0,0 @@
-"""Provides a simple stateless caching system."""
-
-import functools
-import logging
-import os
-import pickle
-import time
-from functools import reduce
-
-import aiosqlite
-from music_assistant.utils import run_periodic
-
-LOGGER = logging.getLogger("mass")
-
-
-class Cache:
-    """Basic stateless caching system."""
-
-    _db = None
-
-    def __init__(self, mass):
-        """Initialize our caching class."""
-        self.mass = mass
-        self._dbfile = os.path.join(mass.config.data_path, "cache.db")
-
-    async def async_setup(self):
-        """Async initialize of cache module."""
-        async with aiosqlite.connect(self._dbfile, timeout=180) as db_conn:
-            await db_conn.execute(
-                """CREATE TABLE IF NOT EXISTS simplecache(
-                id TEXT UNIQUE, expires INTEGER, data TEXT, checksum INTEGER)"""
-            )
-            await db_conn.commit()
-            await db_conn.execute("VACUUM;")
-            await db_conn.commit()
-        self.mass.add_job(self.async_auto_cleanup())
-
-    async def async_get(self, cache_key, checksum=""):
-        """
-        Get object from cache and return the results.
-
-        cache_key: the (unique) name of the cache object as reference
-        checkum: optional argument to check if the checksum in the
-                    cacheobject matches the checkum provided
-        """
-        result = None
-        cur_time = int(time.time())
-        checksum = self._get_checksum(checksum)
-        sql_query = "SELECT expires, data, checksum FROM simplecache WHERE id = ?"
-        async with aiosqlite.connect(self._dbfile, timeout=180) as db_conn:
-            db_conn.row_factory = aiosqlite.Row
-            async with db_conn.execute(sql_query, (cache_key,)) as cursor:
-                cache_data = await cursor.fetchone()
-                if not cache_data:
-                    LOGGER.debug("no cache data for %s", cache_key)
-                elif cache_data["expires"] < cur_time:
-                    LOGGER.debug("cache expired for %s", cache_key)
-                elif checksum and cache_data["checksum"] != checksum:
-                    LOGGER.debug("cache checksum mismatch for %s", cache_key)
-                if cache_data and cache_data["expires"] > cur_time:
-                    if checksum is None or cache_data["checksum"] == checksum:
-                        LOGGER.debug("return cache data for %s", cache_key)
-                        result = pickle.loads(cache_data[1])
-        return result
-
-    async def async_set(self, cache_key, data, checksum="", expiration=(86400 * 30)):
-        """Set data in cache."""
-        checksum = self._get_checksum(checksum)
-        expires = int(time.time() + expiration)
-        data = pickle.dumps(data)
-        sql_query = """INSERT OR REPLACE INTO simplecache
-            (id, expires, data, checksum) VALUES (?, ?, ?, ?)"""
-        async with aiosqlite.connect(self._dbfile, timeout=180) as db_conn:
-            await db_conn.execute(sql_query, (cache_key, expires, data, checksum))
-            await db_conn.commit()
-
-    @run_periodic(3600)
-    async def async_auto_cleanup(self):
-        """Sceduled auto cleanup task."""
-        cur_timestamp = int(time.time())
-        LOGGER.debug("Running cleanup...")
-        sql_query = "SELECT id, expires FROM simplecache"
-        async with aiosqlite.connect(self._dbfile, timeout=600) as db_conn:
-            db_conn.row_factory = aiosqlite.Row
-            async with db_conn.execute(sql_query) as cursor:
-                cache_objects = await cursor.fetchall()
-            for cache_data in cache_objects:
-                cache_id = cache_data["id"]
-                # clean up db cache object only if expired
-                if cache_data["expires"] < cur_timestamp:
-                    sql_query = "DELETE FROM simplecache WHERE id = ?"
-                    await db_conn.execute(sql_query, (cache_id,))
-                    LOGGER.debug("delete from db %s", cache_id)
-            # compact db
-            await db_conn.commit()
-        LOGGER.debug("Auto cleanup done")
-
-    @staticmethod
-    def _get_checksum(stringinput):
-        """Get int checksum from string."""
-        if not stringinput:
-            return 0
-        stringinput = str(stringinput)
-        return reduce(lambda x, y: x + y, map(ord, stringinput))
-
-
-async def async_cached_generator(
-    cache, cache_key, coro_func, expires=(86400 * 30), checksum=None
-):
-    """Return helper method to store results of a async generator in the cache."""
-    cache_result = await cache.async_get(cache_key, checksum)
-    if cache_result is not None:
-        for item in cache_result:
-            yield item
-    else:
-        # nothing in cache, yield from iterator and store in cache when complete
-        cache_result = []
-        async for item in coro_func:
-            yield item
-            cache_result.append(item)
-        # store results in cache
-        await cache.async_set(cache_key, cache_result, checksum, expires)
-
-
-async def async_cached(
-    cache, cache_key, coro_func, expires=(86400 * 30), checksum=None
-):
-    """Return helper method to store results of a coroutine in the cache."""
-    cache_result = await cache.async_get(cache_key, checksum)
-    # normal async function
-    if cache_result is not None:
-        return cache_result
-    result = await coro_func
-    await cache.async_set(cache_key, cache_result, checksum, expires)
-    return result
-
-
-def async_use_cache(cache_days=14, cache_checksum=None):
-    """Return decorator that can be used to cache a method's result."""
-
-    def wrapper(func):
-        @functools.wraps(func)
-        async def async_wrapped(*args, **kwargs):
-            method_class = args[0]
-            method_class_name = method_class.__class__.__name__
-            cache_str = "%s.%s" % (method_class_name, func.__name__)
-            cache_str += __cache_id_from_args(*args, **kwargs)
-            cache_str = cache_str.lower()
-            cachedata = await method_class.cache.async_get(cache_str)
-            if cachedata is not None:
-                return cachedata
-            result = await func(*args, **kwargs)
-            await method_class.cache.async_set(
-                cache_str,
-                result,
-                checksum=cache_checksum,
-                expiration=(86400 * cache_days),
-            )
-            return result
-
-        return async_wrapped
-
-    return wrapper
-
-
-def __cache_id_from_args(*args, **kwargs):
-    """Parse arguments to build cache id."""
-    cache_str = ""
-    # append args to cache identifier
-    for item in args[1:]:
-        if isinstance(item, dict):
-            for subkey in sorted(list(item.keys())):
-                subvalue = item[subkey]
-                cache_str += ".%s%s" % (subkey, subvalue)
-        else:
-            cache_str += ".%s" % item
-    # append kwargs to cache identifier
-    for key in sorted(list(kwargs.keys())):
-        value = kwargs[key]
-        if isinstance(value, dict):
-            for subkey in sorted(list(value.keys())):
-                subvalue = value[subkey]
-                cache_str += ".%s%s" % (subkey, subvalue)
-        else:
-            cache_str += ".%s%s" % (key, value)
-    return cache_str
diff --git a/music_assistant/config.py b/music_assistant/config.py
deleted file mode 100755 (executable)
index f711b82..0000000
+++ /dev/null
@@ -1,432 +0,0 @@
-"""All classes and helpers for the Configuration."""
-
-import logging
-import os
-import shutil
-from collections import OrderedDict
-from enum import Enum
-from typing import List
-
-from music_assistant.constants import (
-    CONF_CROSSFADE_DURATION,
-    CONF_ENABLED,
-    CONF_FALLBACK_GAIN_CORRECT,
-    CONF_KEY_BASE,
-    CONF_KEY_PLAYERSETTINGS,
-    CONF_KEY_PROVIDERS,
-    CONF_NAME,
-    EVENT_CONFIG_CHANGED,
-)
-from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType
-from music_assistant.utils import (
-    decrypt_string,
-    encrypt_string,
-    get_external_ip,
-    json,
-    try_load_json_file,
-)
-from passlib.hash import pbkdf2_sha256
-
-LOGGER = logging.getLogger("mass")
-
-DEFAULT_PLAYER_CONFIG_ENTRIES = [
-    ConfigEntry(
-        entry_key=CONF_ENABLED,
-        entry_type=ConfigEntryType.BOOL,
-        default_value=True,
-        description_key="player_enabled",
-    ),
-    ConfigEntry(
-        entry_key=CONF_NAME,
-        entry_type=ConfigEntryType.STRING,
-        default_value=None,
-        description_key="player_name",
-    ),
-    ConfigEntry(
-        entry_key="max_sample_rate",
-        entry_type=ConfigEntryType.INT,
-        values=[41000, 48000, 96000, 176000, 192000, 384000],
-        default_value=96000,
-        description_key="max_sample_rate",
-    ),
-    ConfigEntry(
-        entry_key="volume_normalisation",
-        entry_type=ConfigEntryType.BOOL,
-        default_value=True,
-        description_key="enable_r128_volume_normalisation",
-    ),
-    ConfigEntry(
-        entry_key="target_volume",
-        entry_type=ConfigEntryType.INT,
-        range=(-30, 0),
-        default_value=-23,
-        description_key="target_volume_lufs",
-        depends_on="volume_normalisation",
-    ),
-    ConfigEntry(
-        entry_key=CONF_FALLBACK_GAIN_CORRECT,
-        entry_type=ConfigEntryType.INT,
-        range=(-20, 0),
-        default_value=-12,
-        description_key=CONF_FALLBACK_GAIN_CORRECT,
-        depends_on="volume_normalisation",
-    ),
-    ConfigEntry(
-        entry_key=CONF_CROSSFADE_DURATION,
-        entry_type=ConfigEntryType.INT,
-        range=(0, 10),
-        default_value=0,
-        description_key=CONF_CROSSFADE_DURATION,
-    ),
-]
-
-DEFAULT_PROVIDER_CONFIG_ENTRIES = [
-    ConfigEntry(
-        entry_key=CONF_ENABLED,
-        entry_type=ConfigEntryType.BOOL,
-        default_value=True,
-        description_key="enabled",
-    )
-]
-
-DEFAULT_BASE_CONFIG_ENTRIES = {
-    "web": [
-        ConfigEntry(
-            entry_key="http_port",
-            entry_type=ConfigEntryType.INT,
-            default_value=8095,
-            description_key="web_http_port",
-        ),
-        ConfigEntry(
-            entry_key="https_port",
-            entry_type=ConfigEntryType.INT,
-            default_value=8096,
-            description_key="web_https_port",
-        ),
-        ConfigEntry(
-            entry_key="ssl_certificate",
-            entry_type=ConfigEntryType.STRING,
-            default_value="",
-            description_key="web_ssl_cert",
-        ),
-        ConfigEntry(
-            entry_key="ssl_key",
-            entry_type=ConfigEntryType.STRING,
-            default_value="",
-            description_key="web_ssl_key",
-        ),
-        ConfigEntry(
-            entry_key="external_url",
-            entry_type=ConfigEntryType.STRING,
-            default_value=f"http://{get_external_ip()}:8095",
-            description_key="web_external_url",
-        ),
-    ],
-    "security": [
-        ConfigEntry(
-            entry_key="username",
-            entry_type=ConfigEntryType.STRING,
-            default_value="admin",
-            description_key="security_username",
-        ),
-        ConfigEntry(
-            entry_key="password",
-            entry_type=ConfigEntryType.PASSWORD,
-            default_value="",
-            description_key="security_password",
-            store_hashed=True,
-        ),
-    ],
-}
-
-
-class ConfigBaseType(Enum):
-    """Enum with config base types."""
-
-    BASE = CONF_KEY_BASE
-    PLAYER = CONF_KEY_PLAYERSETTINGS
-    PROVIDER = CONF_KEY_PROVIDERS
-
-
-class ConfigItem:
-    """
-    Configuration Item connected to Config Entries.
-
-    Returns default value from config entry if no value present.
-    """
-
-    def __init__(self, mass, parent_item_key: str, base_type: ConfigBaseType):
-        """Initialize class."""
-        self._parent_item_key = parent_item_key
-        self._base_type = base_type
-        self.mass = mass
-        self.stored_config = OrderedDict()
-
-    def __repr__(self):
-        """Print class."""
-        return f"{OrderedDict}({self.to_dict()})"
-
-    def to_dict(self) -> dict:
-        """Return entire config as dict."""
-        result = OrderedDict()
-        for entry in self.get_config_entries():
-            if entry.entry_key in self.stored_config:
-                # use saved value
-                entry.value = self.stored_config[entry.entry_key]
-            else:
-                # use default value for config entry
-                entry.value = entry.default_value
-            result[entry.entry_key] = entry
-        return result
-
-    def get(self, key, default=None):
-        """Return value if key exists, default if not."""
-        try:
-            return self[key]
-        except KeyError:
-            return default
-
-    def get_entry(self, key):
-        """Return complete ConfigEntry for specified key."""
-        for entry in self.get_config_entries():
-            if entry.entry_key == key:
-                if key in self.stored_config:
-                    # use saved value
-                    entry.value = self.stored_config[key]
-                else:
-                    # use default value for config entry
-                    entry.value = entry.default_value
-                return entry
-        raise KeyError(
-            "%s\\%s has no key %s!" % (self._base_type, self._parent_item_key, key)
-        )
-
-    def __getitem__(self, key) -> ConfigEntry:
-        """Return default value from ConfigEntry if needed."""
-        entry = self.get_entry(key)
-        if entry.entry_type == ConfigEntryType.PASSWORD:
-            # decrypted password is only returned if explicitly asked for this key
-            decrypted_value = decrypt_string(entry.value)
-            if decrypted_value:
-                return decrypted_value
-        return entry.value
-
-    def __setitem__(self, key, value):
-        """Store value and validate."""
-        for entry in self.get_config_entries():
-            if entry.entry_key != key:
-                continue
-            # do some simple type checking
-            if entry.multi_value:
-                # multi value item
-                if not isinstance(value, list):
-                    raise ValueError
-            else:
-                # single value item
-                if entry.entry_type == ConfigEntryType.STRING and not isinstance(
-                    value, str
-                ):
-                    if not value:
-                        value = ""
-                    else:
-                        raise ValueError
-                if entry.entry_type == ConfigEntryType.BOOL and not isinstance(
-                    value, bool
-                ):
-                    raise ValueError
-                if entry.entry_type == ConfigEntryType.FLOAT and not isinstance(
-                    value, (float, int)
-                ):
-                    raise ValueError
-            if value != self[key]:
-                if entry.store_hashed:
-                    value = pbkdf2_sha256.hash(value)
-                if entry.entry_type == ConfigEntryType.PASSWORD:
-                    value = encrypt_string(value)
-                self.stored_config[key] = value
-                self.mass.signal_event(
-                    EVENT_CONFIG_CHANGED, (self._base_type, self._parent_item_key)
-                )
-                self.mass.add_job(self.mass.config.save)
-                # reload provider if value changed
-                if self._base_type == ConfigBaseType.PROVIDER:
-                    self.mass.add_job(
-                        self.mass.get_provider(self._parent_item_key).async_on_reload()
-                    )
-                if self._base_type == ConfigBaseType.PLAYER:
-                    # force update of player if it's config changed
-                    self.mass.add_job(
-                        self.mass.player_manager.async_trigger_player_update(
-                            self._parent_item_key
-                        )
-                    )
-            return
-        # raise KeyError if we're trying to set a value not defined as ConfigEntry
-        raise KeyError
-
-    def get_config_entries(self) -> List[ConfigEntry]:
-        """Return config entries for this item."""
-        if self._base_type == ConfigBaseType.PLAYER:
-            return self.mass.config.get_player_config_entries(self._parent_item_key)
-        if self._base_type == ConfigBaseType.PROVIDER:
-            return self.mass.config.get_provider_config_entries(self._parent_item_key)
-        return self.mass.config.get_base_config_entries(self._parent_item_key)
-
-
-class ConfigBase(OrderedDict):
-    """Configuration class with ConfigItem items."""
-
-    def __init__(self, mass, base_type=ConfigBaseType):
-        """Initialize class."""
-        self.mass = mass
-        self._base_type = base_type
-        super().__init__()
-
-    def __getitem__(self, item_key):
-        """Return convenience method for get."""
-        if item_key not in self:
-            # create new ConfigDictItem on the fly
-            super().__setitem__(
-                item_key, ConfigItem(self.mass, item_key, self._base_type)
-            )
-        return super().__getitem__(item_key)
-
-
-class MassConfig:
-    """Class which holds our configuration."""
-
-    def __init__(self, mass, data_path: str):
-        """Initialize class."""
-        self._data_path = data_path
-        self.loading = False
-        self.mass = mass
-        self._conf_base = ConfigBase(mass, ConfigBaseType.BASE)
-        self._conf_players = ConfigBase(mass, ConfigBaseType.PLAYER)
-        self._conf_providers = ConfigBase(mass, ConfigBaseType.PROVIDER)
-        if not os.path.isdir(data_path):
-            raise FileNotFoundError(f"data directory {data_path} does not exist!")
-        self.__load()
-
-    @property
-    def data_path(self):
-        """Return the path where all (configuration) data is stored."""
-        return self._data_path
-
-    @property
-    def base(self):
-        """Return base config."""
-        return self._conf_base
-
-    @property
-    def player_settings(self):
-        """Return all player configs."""
-        return self._conf_players
-
-    @property
-    def providers(self):
-        """Return all provider configs."""
-        return self._conf_providers
-
-    def get_provider_config(self, provider_id):
-        """Return config for given provider."""
-        return self._conf_providers[provider_id]
-
-    def get_player_config(self, player_id):
-        """Return config for given player."""
-        return self._conf_players[player_id]
-
-    def get_provider_config_entries(self, provider_id: str) -> List[ConfigEntry]:
-        """Return all config entries for the given provider."""
-        provider = self.mass.get_provider(provider_id)
-        if provider:
-            return DEFAULT_PROVIDER_CONFIG_ENTRIES + provider.config_entries
-        return DEFAULT_PROVIDER_CONFIG_ENTRIES
-
-    def get_player_config_entries(self, player_id: str) -> List[ConfigEntry]:
-        """Return all config entries for the given player."""
-        player = self.mass.player_manager.get_player(player_id)
-        if player:
-            return DEFAULT_PLAYER_CONFIG_ENTRIES + player.config_entries
-        return DEFAULT_PLAYER_CONFIG_ENTRIES
-
-    @staticmethod
-    def get_base_config_entries(base_key) -> List[ConfigEntry]:
-        """Return all base config entries."""
-        return DEFAULT_BASE_CONFIG_ENTRIES[base_key]
-
-    def validate_credentials(self, username, password):
-        """Check if credentials matches."""
-        if username != self.base["security"]["username"]:
-            return False
-        if not password and not self.base["security"]["password"]:
-            return True
-        return pbkdf2_sha256.verify(password, self.base["security"]["password"])
-
-    def __getitem__(self, item_key):
-        """Return item value by key."""
-        return getattr(self, item_key)
-
-    async def async_close(self):
-        """Save config on exit."""
-        self.save()
-
-    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)
-        # create dict for stored config
-        stored_conf = {
-            CONF_KEY_BASE: {},
-            CONF_KEY_PLAYERSETTINGS: {},
-            CONF_KEY_PROVIDERS: {},
-        }
-        for conf_key in stored_conf:
-            for key, value in self[conf_key].items():
-                stored_conf[conf_key][key] = value.stored_config
-
-        # write current config to file
-        with open(conf_file, "w") as _file:
-            _file.write(json.dumps(stored_conf, indent=4))
-        LOGGER.info("Config saved!")
-        self.loading = False
-
-    def __load(self):
-        """Load config from file."""
-        self.loading = True
-        conf_file = os.path.join(self.data_path, "config.json")
-        data = try_load_json_file(conf_file)
-        if not data:
-            # might be a corrupt config file, retry with backup file
-            conf_file_backup = os.path.join(self.data_path, "config.json.backup")
-            data = try_load_json_file(conf_file_backup)
-        if data:
-
-            if data.get(CONF_KEY_BASE):
-                for base_key, base_value in data[CONF_KEY_BASE].items():
-                    if base_key in ["homeassistant"]:
-                        continue  # legacy - to be removed later
-                    for key, value in base_value.items():
-                        if key == "__desc__":
-                            continue
-                        self.base[base_key].stored_config[key] = value
-            if data.get(CONF_KEY_PLAYERSETTINGS):
-                for player_id, player in data[CONF_KEY_PLAYERSETTINGS].items():
-                    for key, value in player.items():
-                        if key == "__desc__":
-                            continue
-                        self.player_settings[player_id].stored_config[key] = value
-            if data.get(CONF_KEY_PROVIDERS):
-                for provider_id, provider in data[CONF_KEY_PROVIDERS].items():
-                    for key, value in provider.items():
-                        if key == "__desc__":
-                            continue
-                        self.providers[provider_id].stored_config[key] = value
-
-        self.loading = False
index 47f6aca23a3acba619194d1b79a2fbe770ef3305..f5fcb890d8a2e47eaf79606e807b7cfaab2d54b8 100755 (executable)
@@ -1,8 +1,9 @@
 """All constants for Music Assistant."""
 
-__version__ = "0.0.47"
+__version__ = "0.0.48"
 REQUIRED_PYTHON_VER = "3.7"
 
+# configuration keys/attributes
 CONF_USERNAME = "username"
 CONF_PASSWORD = "password"
 CONF_ENABLED = "enabled"
@@ -16,11 +17,27 @@ CONF_FALLBACK_GAIN_CORRECT = "fallback_gain_correct"
 CONF_GROUP_DELAY = "group_delay"
 CONF_VOLUME_CONTROL = "volume_control"
 CONF_POWER_CONTROL = "power_control"
+CONF_HTTP_PORT = "http_port"
+CONF_HTTPS_PORT = "https_port"
+CONF_MAX_SAMPLE_RATE = "max_sample_rate"
+CONF_VOLUME_NORMALISATION = "volume_normalisation"
+CONF_TARGET_VOLUME = "target_volume"
+CONF_SSL_CERTIFICATE = "ssl_certificate"
+CONF_SSL_KEY = "ssl_key"
+CONF_EXTERNAL_URL = "external_url"
 
+
+# configuration base keys/attributes
 CONF_KEY_BASE = "base"
-CONF_KEY_PLAYERSETTINGS = "player_settings"
-CONF_KEY_PROVIDERS = "providers"
+CONF_KEY_PLAYER_SETTINGS = "player_settings"
+CONF_KEY_MUSIC_PROVIDERS = "music_providers"
+CONF_KEY_PLAYER_PROVIDERS = "player_providers"
+CONF_KEY_METADATA_PROVIDERS = "metadata_providers"
+CONF_KEY_PLUGINS = "plugins"
+CONF_KEY_BASE_WEBSERVER = "web"
+CONF_KEY_BASE_SECURITY = "security"
 
+# events
 EVENT_PLAYER_ADDED = "player added"
 EVENT_PLAYER_REMOVED = "player removed"
 EVENT_PLAYER_CHANGED = "player changed"
@@ -33,6 +50,7 @@ EVENT_QUEUE_ITEMS_UPDATED = "queue items updated"
 EVENT_QUEUE_TIME_UPDATED = "queue time updated"
 EVENT_SHUTDOWN = "application shutdown"
 EVENT_PROVIDER_REGISTERED = "provider registered"
+EVENT_PROVIDER_UNREGISTERED = "provider unregistered"
 EVENT_PLAYER_CONTROL_REGISTERED = "player control registered"
 EVENT_PLAYER_CONTROL_UNREGISTERED = "player control unregistered"
 EVENT_PLAYER_CONTROL_UPDATED = "player control updated"
@@ -42,3 +60,24 @@ EVENT_SET_PLAYER_CONTROL_STATE = "set player control state"
 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"
+ATTR_PROVIDER_ID = "provider_id"
+ATTR_NAME = "name"
+ATTR_POWERED = "powered"
+ATTR_ELAPSED_TIME = "elapsed_time"
+ATTR_STATE = "state"
+ATTR_AVAILABLE = "available"
+ATTR_CURRENT_URI = "current_uri"
+ATTR_VOLUME_LEVEL = "volume_level"
+ATTR_MUTED = "muted"
+ATTR_IS_GROUP_PLAYER = "is_group_player"
+ATTR_GROUP_CHILDS = "group_childs"
+ATTR_DEVICE_INFO = "device_info"
+ATTR_SHOULD_POLL = "should_poll"
+ATTR_FEATURES = "features"
+ATTR_CONFIG_ENTRIES = "config_entries"
+ATTR_UPDATED_AT = "updated_at"
+ATTR_ACTIVE_QUEUE = "active_queue"
+ATTR_GROUP_PARENTS = "group_parents"
diff --git a/music_assistant/database.py b/music_assistant/database.py
deleted file mode 100755 (executable)
index 0ed0bd8..0000000
+++ /dev/null
@@ -1,1140 +0,0 @@
-"""Database logic."""
-# pylint: disable=too-many-lines
-import logging
-import os
-import sqlite3
-from functools import partial
-from typing import List
-
-import aiosqlite
-from music_assistant.models.media_types import (
-    Album,
-    AlbumType,
-    Artist,
-    ExternalId,
-    MediaItem,
-    MediaItemProviderId,
-    MediaType,
-    Playlist,
-    Radio,
-    SearchResult,
-    Track,
-    TrackQuality,
-)
-from music_assistant.utils import compare_strings, get_sort_name, try_parse_int
-
-LOGGER = logging.getLogger("mass")
-
-
-class DbConnect:
-    """Helper to initialize the db connection or utilize an existing one."""
-
-    def __init__(self, dbfile: str, db_conn: sqlite3.Connection = None):
-        """Initialize class."""
-        self._db_conn_provided = db_conn is not None
-        self._db_conn = db_conn
-        self._dbfile = dbfile
-
-    async def __aenter__(self):
-        """Enter."""
-        if not self._db_conn_provided:
-            self._db_conn = await aiosqlite.connect(self._dbfile, timeout=120)
-        return self._db_conn
-
-    async def __aexit__(self, exc_type, exc_value, traceback):
-        """Exit."""
-        if not self._db_conn_provided:
-            await self._db_conn.close()
-        return False
-
-
-class Database:
-    """Class that holds the (logic to the) database."""
-
-    def __init__(self, mass):
-        """Initialize class."""
-        self.mass = mass
-        self._dbfile = os.path.join(mass.config.data_path, "database.db")
-        self.db_conn = partial(DbConnect, self._dbfile)
-        # logging.getLogger("aiosqlite").setLevel(logging.INFO)
-
-    async def async_setup(self):
-        """Async initialization."""
-        async with DbConnect(self._dbfile) as db_conn:
-
-            await db_conn.execute(
-                """CREATE TABLE IF NOT EXISTS library_items(
-                    item_id INTEGER NOT NULL, provider TEXT NOT NULL,
-                    media_type INTEGER NOT NULL, UNIQUE(item_id, provider, media_type)
-                );"""
-            )
-
-            await db_conn.execute(
-                """CREATE TABLE IF NOT EXISTS artists(
-                    artist_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL,
-                    sort_name TEXT, musicbrainz_id TEXT NOT NULL UNIQUE);"""
-            )
-
-            await db_conn.execute(
-                """CREATE TABLE IF NOT EXISTS albums(
-                    album_id INTEGER PRIMARY KEY AUTOINCREMENT, artist_id INTEGER NOT NULL,
-                    name TEXT NOT NULL, albumtype TEXT, year INTEGER, version TEXT,
-                    UNIQUE(artist_id, name, version, year)
-                );"""
-            )
-
-            await db_conn.execute(
-                """CREATE TABLE IF NOT EXISTS labels(
-                    label_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE);"""
-            )
-            await db_conn.execute(
-                """CREATE TABLE IF NOT EXISTS album_labels(
-                    album_id INTEGER, label_id INTEGER, UNIQUE(album_id, label_id));"""
-            )
-
-            await db_conn.execute(
-                """CREATE TABLE IF NOT EXISTS tracks(
-                    track_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL,
-                    album_id INTEGER, version TEXT, duration INTEGER,
-                    UNIQUE(name, version, album_id, duration)
-                );"""
-            )
-            await db_conn.execute(
-                """CREATE TABLE IF NOT EXISTS track_artists(
-                    track_id INTEGER, artist_id INTEGER, UNIQUE(track_id, artist_id));"""
-            )
-
-            await db_conn.execute(
-                """CREATE TABLE IF NOT EXISTS tags(
-                    tag_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE);"""
-            )
-            await db_conn.execute(
-                """CREATE TABLE IF NOT EXISTS media_tags(
-                    item_id INTEGER, media_type INTEGER, tag_id,
-                    UNIQUE(item_id, media_type, tag_id)
-                );"""
-            )
-
-            await db_conn.execute(
-                """CREATE TABLE IF NOT EXISTS provider_mappings(
-                    item_id INTEGER NOT NULL, media_type INTEGER NOT NULL,
-                    prov_item_id TEXT NOT NULL,
-                    provider TEXT NOT NULL, quality INTEGER NOT NULL, details TEXT NULL,
-                    UNIQUE(item_id, media_type, prov_item_id, provider, quality)
-                    );"""
-            )
-
-            await db_conn.execute(
-                """CREATE TABLE IF NOT EXISTS metadata(
-                    item_id INTEGER NOT NULL, media_type INTEGER NOT NULL, key TEXT NOT NULL,
-                    value TEXT, UNIQUE(item_id, media_type, key));"""
-            )
-
-            await db_conn.execute(
-                """CREATE TABLE IF NOT EXISTS external_ids(
-                    item_id INTEGER NOT NULL, media_type INTEGER NOT NULL, key TEXT NOT NULL,
-                    value TEXT, UNIQUE(item_id, media_type, key, value));"""
-            )
-
-            await db_conn.execute(
-                """CREATE TABLE IF NOT EXISTS playlists(
-                    playlist_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL,
-                    owner TEXT NOT NULL, is_editable BOOLEAN NOT NULL, checksum TEXT NOT NULL,
-                    UNIQUE(name, owner)
-                    );"""
-            )
-
-            await db_conn.execute(
-                """CREATE TABLE IF NOT EXISTS radios(
-                    radio_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE);"""
-            )
-
-            await db_conn.execute(
-                """CREATE TABLE IF NOT EXISTS track_loudness(
-                    provider_track_id INTEGER NOT NULL, provider TEXT NOT NULL, loudness REAL,
-                    UNIQUE(provider_track_id, provider));"""
-            )
-
-            await db_conn.commit()
-            await db_conn.execute("VACUUM;")
-            await db_conn.commit()
-
-    async def async_get_database_id(
-        self,
-        provider_id: str,
-        prov_item_id: str,
-        media_type: MediaType,
-        db_conn: sqlite3.Connection = None,
-    ) -> int:
-        """Get the database id for the given prov_id."""
-        async with DbConnect(self._dbfile, db_conn) as db_conn:
-            if provider_id == "database":
-                return prov_item_id
-            sql_query = """SELECT item_id FROM provider_mappings
-                WHERE prov_item_id = ? AND provider = ? AND media_type = ?;"""
-            async with db_conn.execute(
-                sql_query, (prov_item_id, provider_id, int(media_type))
-            ) as cursor:
-                item_id = await cursor.fetchone()
-            if item_id:
-                return item_id[0]
-        return None
-
-    async def async_search(
-        self, searchquery: str, media_types: List[MediaType]
-    ) -> SearchResult:
-        """Search library for the given searchphrase."""
-        async with DbConnect(self._dbfile) as db_conn:
-            result = SearchResult([], [], [], [], [])
-            searchquery = "%" + searchquery + "%"
-            if media_types is None or MediaType.Artist in media_types:
-                sql_query = ' WHERE name LIKE "%s"' % searchquery
-                result.artists = [
-                    item
-                    async for item in self.async_get_artists(sql_query, db_conn=db_conn)
-                ]
-            if media_types is None or MediaType.Album in media_types:
-                sql_query = ' WHERE name LIKE "%s"' % searchquery
-                result.albums = [
-                    item
-                    async for item in self.async_get_albums(sql_query, db_conn=db_conn)
-                ]
-            if media_types is None or MediaType.Track in media_types:
-                sql_query = ' WHERE name LIKE "%s"' % searchquery
-                result.tracks = [
-                    item
-                    async for item in self.async_get_tracks(sql_query, db_conn=db_conn)
-                ]
-            if media_types is None or MediaType.Playlist in media_types:
-                sql_query = ' WHERE name LIKE "%s"' % searchquery
-                result.playlists = [
-                    item
-                    async for item in self.async_get_playlists(
-                        sql_query, db_conn=db_conn
-                    )
-                ]
-            if media_types is None or MediaType.Radio in media_types:
-                sql_query = ' WHERE name LIKE "%s"' % searchquery
-                result.radios = [
-                    item
-                    async for item in self.async_get_radios(sql_query, db_conn=db_conn)
-                ]
-            return result
-
-    async def async_get_library_artists(
-        self, provider_id: str = None, orderby: str = "name"
-    ) -> List[Artist]:
-        """Get all library artists, optionally filtered by provider."""
-        if provider_id is not None:
-            sql_query = f"""WHERE artist_id in (SELECT item_id FROM library_items WHERE
-                provider = "{provider_id}" AND media_type = {int(MediaType.Artist)})"""
-        else:
-            sql_query = f"""WHERE artist_id in
-                    (SELECT item_id FROM library_items
-                    WHERE media_type = {int(MediaType.Artist)})"""
-        async for item in self.async_get_artists(
-            sql_query, orderby=orderby, fulldata=True
-        ):
-            yield item
-
-    async def async_get_library_albums(
-        self, provider_id: str = None, orderby: str = "name"
-    ) -> List[Album]:
-        """Get all library albums, optionally filtered by provider."""
-        if provider_id is not None:
-            sql_query = f"""WHERE album_id in (SELECT item_id FROM library_items
-                WHERE provider = "{provider_id}" AND media_type = {int(MediaType.Album)})"""
-        else:
-            sql_query = f"""WHERE album_id in
-                (SELECT item_id FROM library_items WHERE media_type = {int(MediaType.Album)})"""
-        async for item in self.async_get_albums(
-            sql_query, orderby=orderby, fulldata=True
-        ):
-            yield item
-
-    async def async_get_library_tracks(
-        self, provider_id: str = None, orderby: str = "name"
-    ) -> List[Track]:
-        """Get all library tracks, optionally filtered by provider."""
-        if provider_id is not None:
-            sql_query = f"""WHERE track_id in
-                (SELECT item_id FROM library_items WHERE provider = "{provider_id}"
-                AND media_type = {int(MediaType.Track)})"""
-        else:
-            sql_query = f"""WHERE track_id in
-                (SELECT item_id FROM library_items WHERE media_type = {int(MediaType.Track)})"""
-        async for item in self.async_get_tracks(sql_query, orderby=orderby):
-            yield item
-
-    async def async_get_library_playlists(
-        self, provider_id: str = None, orderby: str = "name"
-    ) -> List[Playlist]:
-        """Fetch all playlist records from table."""
-        if provider_id is not None:
-            sql_query = f"""WHERE playlist_id in
-                (SELECT item_id FROM library_items WHERE provider = "{provider_id}"
-                AND media_type = {int(MediaType.Playlist)})"""
-        else:
-            sql_query = f"""WHERE playlist_id in
-                (SELECT item_id FROM library_items WHERE media_type = {int(MediaType.Playlist)})"""
-        async for item in self.async_get_playlists(sql_query, orderby=orderby):
-            yield item
-
-    async def async_get_library_radios(
-        self, provider_id: str = None, orderby: str = "name"
-    ) -> List[Radio]:
-        """Fetch all radio records from table."""
-        if provider_id is not None:
-            sql_query = f"""WHERE radio_id in
-                (SELECT item_id FROM library_items WHERE provider = "{provider_id}"
-                AND media_type = { int(MediaType.Radio)})"""
-        else:
-            sql_query = f"""WHERE radio_id in
-                (SELECT item_id FROM library_items WHERE media_type = {int(MediaType.Radio)})"""
-        async for item in self.async_get_radios(sql_query, orderby=orderby):
-            yield item
-
-    async def async_get_playlists(
-        self,
-        filter_query: str = None,
-        orderby: str = "name",
-        db_conn: sqlite3.Connection = None,
-    ) -> List[Playlist]:
-        """Get all playlists from database."""
-        async with DbConnect(self._dbfile, db_conn) as db_conn:
-            db_conn.row_factory = aiosqlite.Row
-            sql_query = "SELECT * FROM playlists"
-            if filter_query:
-                sql_query += " " + filter_query
-            sql_query += " ORDER BY %s" % orderby
-            async with db_conn.execute(sql_query) as cursor:
-                db_rows = await cursor.fetchall()
-            for db_row in db_rows:
-                playlist = Playlist(
-                    item_id=db_row["playlist_id"],
-                    provider="database",
-                    name=db_row["name"],
-                    metadata=await self.__async_get_metadata(
-                        db_row["playlist_id"], MediaType.Playlist, db_conn
-                    ),
-                    tags=await self.__async_get_tags(
-                        db_row["playlist_id"], int(MediaType.Playlist), db_conn
-                    ),
-                    external_ids=await self.__async_get_external_ids(
-                        db_row["playlist_id"], MediaType.Playlist, db_conn
-                    ),
-                    provider_ids=await self.__async_get_prov_ids(
-                        db_row["playlist_id"], MediaType.Playlist, db_conn
-                    ),
-                    in_library=await self.__async_get_library_providers(
-                        db_row["playlist_id"], MediaType.Playlist, db_conn
-                    ),
-                    is_lazy=False,
-                    available=True,
-                    owner=db_row["owner"],
-                    checksum=db_row["checksum"],
-                    is_editable=db_row["is_editable"],
-                )
-                yield playlist
-
-    async def async_get_playlist(self, playlist_id: int) -> Playlist:
-        """Get playlist record by id."""
-        playlist_id = try_parse_int(playlist_id)
-        async for item in self.async_get_playlists(
-            f"WHERE playlist_id = {playlist_id}"
-        ):
-            return item
-        return None
-
-    async def async_get_radios(
-        self,
-        filter_query: str = None,
-        orderby: str = "name",
-        db_conn: sqlite3.Connection = None,
-    ) -> List[Radio]:
-        """Fetch radio records from database."""
-        sql_query = "SELECT * FROM radios"
-        if filter_query:
-            sql_query += " " + filter_query
-        sql_query += " ORDER BY %s" % orderby
-        async with DbConnect(self._dbfile, db_conn) as db_conn:
-            db_conn.row_factory = aiosqlite.Row
-            async with db_conn.execute(sql_query) as cursor:
-                db_rows = await cursor.fetchall()
-            for db_row in db_rows:
-                radio = Radio(
-                    item_id=db_row["radio_id"],
-                    provider="database",
-                    name=db_row["name"],
-                    metadata=await self.__async_get_metadata(
-                        db_row["radio_id"], MediaType.Radio, db_conn
-                    ),
-                    tags=await self.__async_get_tags(
-                        db_row["radio_id"], MediaType.Radio, db_conn
-                    ),
-                    external_ids=await self.__async_get_external_ids(
-                        db_row["radio_id"], MediaType.Radio, db_conn
-                    ),
-                    provider_ids=await self.__async_get_prov_ids(
-                        db_row["radio_id"], MediaType.Radio, db_conn
-                    ),
-                    in_library=await self.__async_get_library_providers(
-                        db_row["radio_id"], MediaType.Radio, db_conn
-                    ),
-                    is_lazy=False,
-                    available=True,
-                )
-                yield radio
-
-    async def async_get_radio(self, radio_id: int) -> Playlist:
-        """Get radio record by id."""
-        radio_id = try_parse_int(radio_id)
-        async for item in self.async_get_radios(f"WHERE radio_id = {radio_id}"):
-            return item
-        return None
-
-    async def async_add_playlist(self, playlist: Playlist):
-        """Add a new playlist record to the database."""
-        assert playlist.name
-        async with DbConnect(self._dbfile) as db_conn:
-            async with db_conn.execute(
-                "SELECT (playlist_id) FROM playlists WHERE name=? AND owner=?;",
-                (playlist.name, playlist.owner),
-            ) as cursor:
-                result = await cursor.fetchone()
-            if result:
-                playlist_id = result[0]
-                # update existing
-                sql_query = "UPDATE playlists SET is_editable=?, checksum=? WHERE playlist_id=?;"
-                await db_conn.execute(
-                    sql_query, (playlist.is_editable, playlist.checksum, playlist_id)
-                )
-            else:
-                # insert playlist
-                sql_query = """INSERT INTO playlists (name, owner, is_editable, checksum)
-                VALUES(?,?,?,?);"""
-                async with db_conn.execute(
-                    sql_query,
-                    (
-                        playlist.name,
-                        playlist.owner,
-                        playlist.is_editable,
-                        playlist.checksum,
-                    ),
-                ) as cursor:
-                    last_row_id = cursor.lastrowid
-                # get id from newly created item
-                sql_query = "SELECT (playlist_id) FROM playlists WHERE ROWID=?"
-                async with db_conn.execute(sql_query, (last_row_id,)) as cursor:
-                    playlist_id = await cursor.fetchone()
-                    playlist_id = playlist_id[0]
-                LOGGER.debug(
-                    "added playlist %s to database: %s", playlist.name, playlist_id
-                )
-            # add/update metadata
-            await self.__async_add_prov_ids(
-                playlist_id, MediaType.Playlist, playlist.provider_ids, db_conn
-            )
-            await self.__async_add_metadata(
-                playlist_id, MediaType.Playlist, playlist.metadata, db_conn
-            )
-            # save
-            await db_conn.commit()
-        return playlist_id
-
-    async def async_add_radio(self, radio: Radio):
-        """Add a new radio record to the database."""
-        assert radio.name
-        async with DbConnect(self._dbfile) as db_conn:
-            async with db_conn.execute(
-                "SELECT (radio_id) FROM radios WHERE name=?;", (radio.name,)
-            ) as cursor:
-                result = await cursor.fetchone()
-            if result:
-                radio_id = result[0]
-            else:
-                # insert radio
-                sql_query = "INSERT INTO radios (name) VALUES(?);"
-                async with db_conn.execute(sql_query, (radio.name,)) as cursor:
-                    last_row_id = cursor.lastrowid
-                    # await db_conn.commit()
-                # get id from newly created item
-                sql_query = "SELECT (radio_id) FROM radios WHERE ROWID=?"
-                async with db_conn.execute(sql_query, (last_row_id,)) as cursor:
-                    radio_id = await cursor.fetchone()
-                    radio_id = radio_id[0]
-                LOGGER.debug(
-                    "added radio station %s to database: %s", radio.name, radio_id
-                )
-            # add/update metadata
-            await self.__async_add_prov_ids(
-                radio_id, MediaType.Radio, radio.provider_ids, db_conn
-            )
-            await self.__async_add_metadata(
-                radio_id, MediaType.Radio, radio.metadata, db_conn
-            )
-            # save
-            await db_conn.commit()
-        return radio_id
-
-    async def async_add_to_library(
-        self, item_id: int, media_type: MediaType, provider: str
-    ):
-        """Add an item to the library (item must already be present in the db!)."""
-        async with DbConnect(self._dbfile) as db_conn:
-            item_id = try_parse_int(item_id)
-            sql_query = """INSERT or REPLACE INTO library_items
-                (item_id, provider, media_type) VALUES(?,?,?);"""
-            await db_conn.execute(sql_query, (item_id, provider, int(media_type)))
-            await db_conn.commit()
-
-    async def async_remove_from_library(
-        self, item_id: int, media_type: MediaType, provider: str
-    ):
-        """Remove item from the library."""
-        async with DbConnect(self._dbfile) as db_conn:
-            item_id = try_parse_int(item_id)
-            sql_query = "DELETE FROM library_items WHERE item_id=? AND provider=? AND media_type=?;"
-            await db_conn.execute(sql_query, (item_id, provider, int(media_type)))
-            if media_type == MediaType.Playlist:
-                sql_query = "DELETE FROM playlists WHERE playlist_id=?;"
-                await db_conn.execute(sql_query, (item_id,))
-                sql_query = """DELETE FROM provider_mappings WHERE
-                    item_id=? AND media_type=? AND provider=?;"""
-                await db_conn.execute(sql_query, (item_id, int(media_type), provider))
-                await db_conn.commit()
-
-    async def async_get_artists(
-        self,
-        filter_query: str = None,
-        orderby: str = "name",
-        fulldata=False,
-        db_conn: sqlite3.Connection = None,
-    ) -> List[Artist]:
-        """Fetch artist records from database."""
-        async with DbConnect(self._dbfile, db_conn) as db_conn:
-            db_conn.row_factory = aiosqlite.Row
-            sql_query = "SELECT * FROM artists"
-            if filter_query:
-                sql_query += " " + filter_query
-            sql_query += " ORDER BY %s" % orderby
-            for db_row in await db_conn.execute_fetchall(sql_query):
-                artist = Artist(
-                    item_id=db_row["artist_id"],
-                    provider="database",
-                    name=db_row["name"],
-                    sort_name=db_row["sort_name"],
-                )
-                if fulldata:
-                    artist.provider_ids = await self.__async_get_prov_ids(
-                        db_row["artist_id"], MediaType.Artist, db_conn
-                    )
-                    artist.in_library = await self.__async_get_library_providers(
-                        db_row["artist_id"], MediaType.Artist, db_conn
-                    )
-                    artist.external_ids = await self.__async_get_external_ids(
-                        artist.item_id, MediaType.Artist, db_conn
-                    )
-                    artist.metadata = await self.__async_get_metadata(
-                        artist.item_id, MediaType.Artist, db_conn
-                    )
-                    artist.tags = await self.__async_get_tags(
-                        artist.item_id, MediaType.Artist, db_conn
-                    )
-                yield artist
-
-    async def async_get_artist(
-        self, artist_id: int, fulldata=True, db_conn: sqlite3.Connection = None
-    ) -> Artist:
-        """Get artist record by id."""
-        artist_id = try_parse_int(artist_id)
-        async for item in self.async_get_artists(
-            "WHERE artist_id = %d" % artist_id, fulldata=fulldata, db_conn=db_conn
-        ):
-            return item
-        return None
-
-    async def async_add_artist(self, artist: Artist) -> int:
-        """Add a new artist record to the database."""
-        artist_id = None
-        async with DbConnect(self._dbfile) as db_conn:
-            # always prefer to grab existing artist with external_id (=musicbrainz_id)
-            artist_id = await self.__async_get_item_by_external_id(artist, db_conn)
-            if not artist_id:
-                # insert artist
-                musicbrainz_id = artist.external_ids.get(ExternalId.MUSICBRAINZ)
-                assert musicbrainz_id  # musicbrainz id is required
-                if not artist.sort_name:
-                    artist.sort_name = get_sort_name(artist.name)
-                sql_query = "INSERT INTO artists (name, sort_name, musicbrainz_id) VALUES(?,?,?);"
-                async with db_conn.execute(
-                    sql_query, (artist.name, artist.sort_name, musicbrainz_id)
-                ) as cursor:
-                    last_row_id = cursor.lastrowid
-                await db_conn.commit()
-                # get id from (newly created) item
-                async with db_conn.execute(
-                    "SELECT artist_id FROM artists WHERE ROWID=?;", (last_row_id,)
-                ) as cursor:
-                    artist_id = await cursor.fetchone()
-                    artist_id = artist_id[0]
-            # always add metadata and tags etc. because we might have received
-            # additional info or a match from other provider
-            await self.__async_add_prov_ids(
-                artist_id, MediaType.Artist, artist.provider_ids, db_conn
-            )
-            await self.__async_add_metadata(
-                artist_id, MediaType.Artist, artist.metadata, db_conn
-            )
-            await self.__async_add_tags(
-                artist_id, MediaType.Artist, artist.tags, db_conn
-            )
-            await self.__async_add_external_ids(
-                artist_id, MediaType.Artist, artist.external_ids, db_conn
-            )
-            # save
-            await db_conn.commit()
-            LOGGER.debug(
-                "added artist %s (%s) to database: %s",
-                artist.name,
-                artist.provider_ids,
-                artist_id,
-            )
-        return artist_id
-
-    async def async_get_albums(
-        self,
-        filter_query: str = None,
-        orderby: str = "name",
-        fulldata=False,
-        db_conn: sqlite3.Connection = None,
-    ) -> List[Album]:
-        """Fetch all album records from the database."""
-        sql_query = "SELECT * FROM albums"
-        if filter_query:
-            sql_query += " " + filter_query
-        sql_query += " ORDER BY %s" % orderby
-        async with DbConnect(self._dbfile, db_conn) as db_conn:
-            db_conn.row_factory = aiosqlite.Row
-            for db_row in await db_conn.execute_fetchall(sql_query):
-                album = Album(
-                    item_id=db_row["album_id"],
-                    provider="database",
-                    name=db_row["name"],
-                    album_type=AlbumType(int(db_row["albumtype"])),
-                    year=db_row["year"],
-                    version=db_row["version"],
-                    artist=await self.async_get_artist(
-                        db_row["artist_id"], fulldata=fulldata, db_conn=db_conn
-                    ),
-                )
-                if fulldata:
-                    album.provider_ids = await self.__async_get_prov_ids(
-                        db_row["album_id"], MediaType.Album, db_conn
-                    )
-                    album.in_library = await self.__async_get_library_providers(
-                        db_row["album_id"], MediaType.Album, db_conn
-                    )
-                    album.external_ids = await self.__async_get_external_ids(
-                        album.item_id, MediaType.Album, db_conn
-                    )
-                    album.metadata = await self.__async_get_metadata(
-                        album.item_id, MediaType.Album, db_conn
-                    )
-                    album.tags = await self.__async_get_tags(
-                        album.item_id, MediaType.Album, db_conn
-                    )
-                    album.labels = await self.__async_get_album_labels(
-                        album.item_id, db_conn
-                    )
-                yield album
-
-    async def async_get_album(
-        self, album_id: int, fulldata=True, db_conn: sqlite3.Connection = None
-    ) -> Album:
-        """Get album record by id."""
-        album_id = try_parse_int(album_id)
-        async for item in self.async_get_albums(
-            "WHERE album_id = %d" % album_id, fulldata=fulldata, db_conn=db_conn
-        ):
-            return item
-        return None
-
-    async def async_add_album(self, album: Album) -> int:
-        """Add a new album record to the database."""
-        assert album.name and album.artist
-        assert album.artist.provider == "database"
-        album_id = None
-        async with DbConnect(self._dbfile) as db_conn:
-            db_conn.row_factory = aiosqlite.Row
-            # always try to grab existing album with external_id
-            album_id = await self.__async_get_item_by_external_id(album, db_conn)
-            # fallback to matching on artist_id, name and version
-            if not album_id:
-                sql_query = """SELECT album_id FROM albums WHERE
-                    artist_id=? AND name=? AND version=? AND year=? AND albumtype=?"""
-                async with db_conn.execute(
-                    sql_query,
-                    (
-                        album.artist.item_id,
-                        album.name,
-                        album.version,
-                        int(album.year),
-                        int(album.album_type),
-                    ),
-                ) as cursor:
-                    res = await cursor.fetchone()
-                    if res:
-                        album_id = res["album_id"]
-            # fallback to almost exact match
-            if not album_id:
-                sql_query = """SELECT album_id, year, version, albumtype FROM
-                    albums WHERE artist_id=? AND name=?"""
-                async with db_conn.execute(
-                    sql_query, (album.artist.item_id, album.name)
-                ) as cursor:
-                    albums = await cursor.fetchall()
-                for result in albums:
-                    if (not album.version and result["year"] == album.year) or (
-                        album.version and result["version"] == album.version
-                    ):
-                        album_id = result["album_id"]
-                        break
-            # no match: insert album
-            if not album_id:
-                sql_query = """INSERT INTO albums (artist_id, name, albumtype, year, version)
-                    VALUES(?,?,?,?,?);"""
-                query_params = (
-                    album.artist.item_id,
-                    album.name,
-                    int(album.album_type),
-                    album.year,
-                    album.version,
-                )
-                async with db_conn.execute(sql_query, query_params) as cursor:
-                    last_row_id = cursor.lastrowid
-                # get id from newly created item
-                sql_query = "SELECT (album_id) FROM albums WHERE ROWID=?"
-                async with db_conn.execute(sql_query, (last_row_id,)) as cursor:
-                    album_id = await cursor.fetchone()
-                    album_id = album_id[0]
-                await db_conn.commit()
-            # always add metadata and tags etc. because we might have received
-            # additional info or a match from other provider
-            await self.__async_add_prov_ids(
-                album_id, MediaType.Album, album.provider_ids, db_conn
-            )
-            await self.__async_add_metadata(
-                album_id, MediaType.Album, album.metadata, db_conn
-            )
-            await self.__async_add_tags(album_id, MediaType.Album, album.tags, db_conn)
-            await self.__async_add_album_labels(album_id, album.labels, db_conn)
-            await self.__async_add_external_ids(
-                album_id, MediaType.Album, album.external_ids, db_conn
-            )
-            # save
-            await db_conn.commit()
-            LOGGER.debug(
-                "added album %s (%s) to database: %s",
-                album.name,
-                album.provider_ids,
-                album_id,
-            )
-        return album_id
-
-    async def async_get_tracks(
-        self,
-        filter_query: str = None,
-        orderby: str = "name",
-        fulldata=False,
-        db_conn: sqlite3.Connection = None,
-    ) -> List[Track]:
-        """Return all track records from the database."""
-        async with DbConnect(self._dbfile, db_conn) as db_conn:
-            db_conn.row_factory = aiosqlite.Row
-            sql_query = "SELECT * FROM tracks"
-            if filter_query:
-                sql_query += " " + filter_query
-            sql_query += " ORDER BY %s" % orderby
-            for db_row in await db_conn.execute_fetchall(sql_query, ()):
-                track = Track(
-                    item_id=db_row["track_id"],
-                    provider="database",
-                    name=db_row["name"],
-                    external_ids=await self.__async_get_external_ids(
-                        db_row["track_id"], MediaType.Track, db_conn
-                    ),
-                    provider_ids=await self.__async_get_prov_ids(
-                        db_row["track_id"], MediaType.Track, db_conn
-                    ),
-                    in_library=await self.__async_get_library_providers(
-                        db_row["track_id"], MediaType.Track, db_conn
-                    ),
-                    duration=db_row["duration"],
-                    version=db_row["version"],
-                    album=await self.async_get_album(
-                        db_row["album_id"], fulldata=fulldata, db_conn=db_conn
-                    ),
-                    artists=await self.__async_get_track_artists(
-                        db_row["track_id"], db_conn=db_conn, fulldata=fulldata
-                    ),
-                )
-                if fulldata:
-                    track.metadata = await self.__async_get_metadata(
-                        db_row["track_id"], MediaType.Track, db_conn
-                    )
-                    track.tags = await self.__async_get_tags(
-                        db_row["track_id"], MediaType.Track, db_conn
-                    )
-                yield track
-
-    async def async_get_track(
-        self, track_id: int, fulldata=True, db_conn: sqlite3.Connection = None
-    ) -> Track:
-        """Get track record by id."""
-        track_id = try_parse_int(track_id)
-        async for item in self.async_get_tracks(
-            "WHERE track_id = %d" % track_id, fulldata=fulldata, db_conn=db_conn
-        ):
-            return item
-        return None
-
-    async def async_add_track(self, track: Track) -> int:
-        """Add a new track record to the database."""
-        assert track.name and track.album
-        assert track.album.provider == "database"
-        assert track.artists
-        for artist in track.artists:
-            assert artist.provider == "database"
-        async with DbConnect(self._dbfile) as db_conn:
-            db_conn.row_factory = aiosqlite.Row
-            # always try to grab existing track with external_id
-            track_id = await self.__async_get_item_by_external_id(track, db_conn)
-            # fallback to matching on album_id, name and version
-            if not track_id:
-                sql_query = "SELECT track_id, duration, version \
-                    FROM tracks WHERE album_id=? AND name=?"
-                async with db_conn.execute(
-                    sql_query, (track.album.item_id, track.name)
-                ) as cursor:
-                    results = await cursor.fetchall()
-                for result in results:
-                    # we perform an additional safety check on the duration or version
-                    if (
-                        track.version
-                        and compare_strings(result["version"], track.version)
-                    ) or (
-                        (
-                            not track.version
-                            and not result["version"]
-                            and abs(result["duration"] - track.duration) < 10
-                        )
-                    ):
-                        track_id = result["track_id"]
-                        break
-            # no match found: insert track
-            if not track_id:
-                assert track.name and track.album.item_id
-                sql_query = "INSERT INTO tracks (name, album_id, duration, version) \
-                        VALUES(?,?,?,?);"
-                query_params = (
-                    track.name,
-                    track.album.item_id,
-                    track.duration,
-                    track.version,
-                )
-                async with db_conn.execute(sql_query, query_params) as cursor:
-                    last_row_id = cursor.lastrowid
-                await db_conn.commit()
-                # get id from newly created item (the safe way)
-                async with db_conn.execute(
-                    "SELECT track_id FROM tracks WHERE ROWID=?", (last_row_id,)
-                ) as cursor:
-                    track_id = await cursor.fetchone()
-                    track_id = track_id[0]
-            # always add metadata and tags etc. because we might have received
-            # additional info or a match from other provider
-            for artist in track.artists:
-                sql_query = "INSERT or IGNORE INTO track_artists (track_id, artist_id) VALUES(?,?);"
-                await db_conn.execute(sql_query, (track_id, artist.item_id))
-            await self.__async_add_prov_ids(
-                track_id, MediaType.Track, track.provider_ids, db_conn
-            )
-            await self.__async_add_metadata(
-                track_id, MediaType.Track, track.metadata, db_conn
-            )
-            await self.__async_add_tags(track_id, MediaType.Track, track.tags, db_conn)
-            await self.__async_add_external_ids(
-                track_id, MediaType.Track, track.external_ids, db_conn
-            )
-            # save to db
-            await db_conn.commit()
-            LOGGER.debug(
-                "added track %s (%s) to database: %s",
-                track.name,
-                track.provider_ids,
-                track_id,
-            )
-        return track_id
-
-    async def async_update_playlist(
-        self, playlist_id: int, column_key: str, column_value: str
-    ):
-        """Update column of existing playlist."""
-        async with DbConnect(self._dbfile) as db_conn:
-            sql_query = f"UPDATE playlists SET {column_key}=? WHERE playlist_id=?;"
-            await db_conn.execute(sql_query, (column_value, playlist_id))
-            await db_conn.commit()
-
-    async def async_get_artist_tracks(
-        self, artist_id: int, orderby: str = "name"
-    ) -> List[Track]:
-        """Get all library tracks for the given artist."""
-        artist_id = try_parse_int(artist_id)
-        sql_query = f"""WHERE track_id in
-            (SELECT track_id FROM track_artists WHERE artist_id = {artist_id})"""
-        async for item in self.async_get_tracks(
-            sql_query, orderby=orderby, fulldata=False
-        ):
-            yield item
-
-    async def async_get_artist_albums(
-        self, artist_id: int, orderby: str = "name"
-    ) -> List[Album]:
-        """Get all library albums for the given artist."""
-        sql_query = " WHERE artist_id = %s" % artist_id
-        async for item in self.async_get_albums(
-            sql_query, orderby=orderby, fulldata=False
-        ):
-            yield item
-
-    async def async_set_track_loudness(
-        self, provider_track_id: str, provider: str, loudness: int
-    ):
-        """Set integrated loudness for a track in db."""
-        async with DbConnect(self._dbfile) as db_conn:
-            sql_query = """INSERT or REPLACE INTO track_loudness
-                (provider_track_id, provider, loudness) VALUES(?,?,?);"""
-            await db_conn.execute(sql_query, (provider_track_id, provider, loudness))
-            await db_conn.commit()
-
-    async def async_get_track_loudness(self, provider_track_id, provider):
-        """Get integrated loudness for a track in db."""
-        async with DbConnect(self._dbfile) as db_conn:
-            sql_query = """SELECT loudness FROM track_loudness WHERE
-                provider_track_id = ? AND provider = ?"""
-            async with db_conn.execute(
-                sql_query, (provider_track_id, provider)
-            ) as cursor:
-                result = await cursor.fetchone()
-            if result:
-                return result[0]
-        return None
-
-    async def __async_add_metadata(
-        self,
-        item_id: int,
-        media_type: MediaType,
-        metadata: dict,
-        db_conn: sqlite3.Connection,
-    ):
-        """Add or update metadata."""
-        for key, value in metadata.items():
-            if value:
-                sql_query = """INSERT or REPLACE INTO metadata
-                    (item_id, media_type, key, value) VALUES(?,?,?,?);"""
-                await db_conn.execute(sql_query, (item_id, int(media_type), key, value))
-
-    async def __async_get_metadata(
-        self,
-        item_id: int,
-        media_type: MediaType,
-        db_conn: sqlite3.Connection,
-        filter_key: str = None,
-    ) -> dict:
-        """Get metadata for media item."""
-        metadata = {}
-        sql_query = (
-            "SELECT key, value FROM metadata WHERE item_id = ? AND media_type = ?"
-        )
-        if filter_key:
-            sql_query += ' AND key = "%s"' % filter_key
-        async with db_conn.execute(sql_query, (item_id, int(media_type))) as cursor:
-            db_rows = await cursor.fetchall()
-        for db_row in db_rows:
-            key = db_row[0]
-            value = db_row[1]
-            metadata[key] = value
-        return metadata
-
-    async def __async_add_tags(
-        self,
-        item_id: int,
-        media_type: MediaType,
-        tags: List[str],
-        db_conn: sqlite3.Connection,
-    ):
-        """Add tags to db."""
-        for tag in tags:
-            sql_query = "INSERT or IGNORE INTO tags (name) VALUES(?);"
-            async with db_conn.execute(sql_query, (tag,)) as cursor:
-                tag_id = cursor.lastrowid
-            sql_query = """INSERT or IGNORE INTO media_tags
-                (item_id, media_type, tag_id) VALUES(?,?,?);"""
-            await db_conn.execute(sql_query, (item_id, int(media_type), tag_id))
-
-    async def __async_get_tags(
-        self, item_id: int, media_type: MediaType, db_conn: sqlite3.Connection
-    ) -> List[str]:
-        """Get tags for media item."""
-        tags = []
-        sql_query = """SELECT name FROM tags INNER JOIN media_tags ON
-            tags.tag_id = media_tags.tag_id WHERE item_id = ? AND media_type = ?"""
-        async with db_conn.execute(sql_query, (item_id, int(media_type))) as cursor:
-            db_rows = await cursor.fetchall()
-        for db_row in db_rows:
-            tags.append(db_row[0])
-        return tags
-
-    async def __async_add_album_labels(
-        self, album_id: int, labels: List[str], db_conn: sqlite3.Connection
-    ):
-        """Add labels to album in db."""
-        for label in labels:
-            sql_query = "INSERT or IGNORE INTO labels (name) VALUES(?);"
-            async with db_conn.execute(sql_query, (label,)) as cursor:
-                label_id = cursor.lastrowid
-            sql_query = (
-                "INSERT or IGNORE INTO album_labels (album_id, label_id) VALUES(?,?);"
-            )
-            await db_conn.execute(sql_query, (album_id, label_id))
-
-    async def __async_get_album_labels(
-        self, album_id: int, db_conn: sqlite3.Connection
-    ) -> List[str]:
-        """Get labels for album item."""
-        labels = []
-        sql_query = """SELECT name FROM labels INNER JOIN album_labels
-            ON labels.label_id = album_labels.label_id WHERE album_id = ?"""
-        async with db_conn.execute(sql_query, (album_id,)) as cursor:
-            db_rows = await cursor.fetchall()
-        for db_row in db_rows:
-            labels.append(db_row[0])
-        return labels
-
-    async def __async_get_track_artists(
-        self, track_id: int, db_conn: sqlite3.Connection, fulldata: bool = False
-    ) -> List[Artist]:
-        """Get artists for track."""
-        sql_query = (
-            "WHERE artist_id in (SELECT artist_id FROM track_artists WHERE track_id = %s)"
-            % track_id
-        )
-        return [
-            item
-            async for item in self.async_get_artists(
-                sql_query, fulldata=fulldata, db_conn=db_conn
-            )
-        ]
-
-    async def __async_add_external_ids(
-        self,
-        item_id: int,
-        media_type: MediaType,
-        external_ids: dict,
-        db_conn: sqlite3.Connection,
-    ):
-        """Add or update external_ids."""
-        for key, value in external_ids.items():
-            sql_query = """INSERT or REPLACE INTO external_ids
-                (item_id, media_type, key, value) VALUES(?,?,?,?);"""
-            await db_conn.execute(
-                sql_query, (item_id, int(media_type), str(key), value)
-            )
-
-    async def __async_get_external_ids(
-        self, item_id: int, media_type: MediaType, db_conn: sqlite3.Connection
-    ) -> dict:
-        """Get external_ids for media item."""
-        external_ids = {}
-        sql_query = (
-            "SELECT key, value FROM external_ids WHERE item_id = ? AND media_type = ?"
-        )
-        for db_row in await db_conn.execute_fetchall(
-            sql_query, (item_id, int(media_type))
-        ):
-            external_ids[db_row[0]] = db_row[1]
-        return external_ids
-
-    async def __async_add_prov_ids(
-        self,
-        item_id: int,
-        media_type: MediaType,
-        provider_ids: List[MediaItemProviderId],
-        db_conn: sqlite3.Connection,
-    ):
-        """Add provider ids for media item to db_conn."""
-
-        for prov in provider_ids:
-            sql_query = """INSERT OR REPLACE INTO provider_mappings
-                (item_id, media_type, prov_item_id, provider, quality, details)
-                VALUES(?,?,?,?,?,?);"""
-            await db_conn.execute(
-                sql_query,
-                (
-                    item_id,
-                    int(media_type),
-                    prov.item_id,
-                    prov.provider,
-                    int(prov.quality),
-                    prov.details,
-                ),
-            )
-
-    async def __async_get_prov_ids(
-        self, item_id: int, media_type: MediaType, db_conn: sqlite3.Connection
-    ) -> List[MediaItemProviderId]:
-        """Get all provider id's for media item."""
-        provider_ids = []
-        sql_query = "SELECT prov_item_id, provider, quality, details \
-            FROM provider_mappings \
-            WHERE item_id = ? AND media_type = ?"
-        for db_row in await db_conn.execute_fetchall(
-            sql_query, (item_id, int(media_type))
-        ):
-            prov_mapping = MediaItemProviderId(
-                provider=db_row["provider"],
-                item_id=db_row["prov_item_id"],
-                quality=TrackQuality(db_row["quality"]),
-                details=db_row["details"],
-            )
-            provider_ids.append(prov_mapping)
-        return provider_ids
-
-    async def __async_get_library_providers(
-        self, db_item_id: int, media_type: MediaType, db_conn: sqlite3.Connection
-    ) -> List[str]:
-        """Get the providers that have this media_item added to the library."""
-        providers = []
-        sql_query = (
-            "SELECT provider FROM library_items WHERE item_id = ? AND media_type = ?"
-        )
-        for db_row in await db_conn.execute_fetchall(
-            sql_query, (db_item_id, int(media_type))
-        ):
-            providers.append(db_row[0])
-        return providers
-
-    async def __async_get_item_by_external_id(
-        self, media_item: MediaItem, db_conn: sqlite3.Connection
-    ) -> int:
-        """Try to get existing item in db by matching the new item's external id's."""
-        for key, value in media_item.external_ids.items():
-            sql_query = "SELECT (item_id) FROM external_ids \
-                    WHERE media_type=? AND key=? AND value=?;"
-            for db_row in await db_conn.execute_fetchall(
-                sql_query, (int(media_item.media_type), str(key), value)
-            ):
-                if db_row:
-                    return db_row[0]
-        return None
diff --git a/music_assistant/helpers/app_vars.py b/music_assistant/helpers/app_vars.py
new file mode 100644 (file)
index 0000000..b010396
--- /dev/null
@@ -0,0 +1,33 @@
+"""Some magic to store some appvars."""
+# pylint: skip-file
+# flake8: noqa
+(
+    lambda __g: [
+        [
+            [
+                [
+                    None
+                    for __g["get_app_var"], get_app_var.__name__ in [
+                        (
+                            lambda index: (
+                                lambda __l: [
+                                    APP_VARS[__l["index"]] for __l["index"] in [(index)]
+                                ][0]
+                            )({}),
+                            "get_app_var",
+                        )
+                    ]
+                ][0]
+                for __g["APP_VARS"] in [
+                    (base64.b64decode(VARS_ENC).decode("utf-8").split(","))
+                ]
+            ][0]
+            for __g["VARS_ENC"] in [
+                (
+                    b"OTQyODUyNTY3LDc2MTczMGQzZjk1ZTRhZjA5YWM2M2I5YTM3Y2NjOTZhLDJlYjk2ZjliMzc0OTRiZTE4MjQ5OTlkNTgwMjhhMzA1LFNTcnRNMnhlM2wwMDNnOEh4RmVUUUtub3BaNklCaUwzRTlPc1QxODFYMDA9"
+                )
+            ]
+        ][0]
+        for __g["base64"] in [(__import__("base64", __g, __g))]
+    ][0]
+)(globals())
diff --git a/music_assistant/helpers/cache.py b/music_assistant/helpers/cache.py
new file mode 100644 (file)
index 0000000..f5c6366
--- /dev/null
@@ -0,0 +1,186 @@
+"""Provides a simple stateless caching system."""
+
+import functools
+import logging
+import os
+import pickle
+import time
+from functools import reduce
+
+import aiosqlite
+from music_assistant.helpers.util import run_periodic
+
+LOGGER = logging.getLogger("mass")
+
+
+class Cache:
+    """Basic stateless caching system."""
+
+    _db = None
+
+    def __init__(self, mass):
+        """Initialize our caching class."""
+        self.mass = mass
+        self._dbfile = os.path.join(mass.config.data_path, "cache.db")
+
+    async def async_setup(self):
+        """Async initialize of cache module."""
+        async with aiosqlite.connect(self._dbfile, timeout=180) as db_conn:
+            await db_conn.execute(
+                """CREATE TABLE IF NOT EXISTS simplecache(
+                id TEXT UNIQUE, expires INTEGER, data TEXT, checksum INTEGER)"""
+            )
+            await db_conn.commit()
+            await db_conn.execute("VACUUM;")
+            await db_conn.commit()
+        self.mass.add_job(self.async_auto_cleanup())
+
+    async def async_get(self, cache_key, checksum=""):
+        """
+        Get object from cache and return the results.
+
+        cache_key: the (unique) name of the cache object as reference
+        checkum: optional argument to check if the checksum in the
+                    cacheobject matches the checkum provided
+        """
+        result = None
+        cur_time = int(time.time())
+        checksum = self._get_checksum(checksum)
+        sql_query = "SELECT expires, data, checksum FROM simplecache WHERE id = ?"
+        async with aiosqlite.connect(self._dbfile, timeout=180) as db_conn:
+            db_conn.row_factory = aiosqlite.Row
+            async with db_conn.execute(sql_query, (cache_key,)) as cursor:
+                cache_data = await cursor.fetchone()
+                if not cache_data:
+                    LOGGER.debug("no cache data for %s", cache_key)
+                elif cache_data["expires"] < cur_time:
+                    LOGGER.debug("cache expired for %s", cache_key)
+                elif checksum and cache_data["checksum"] != checksum:
+                    LOGGER.debug("cache checksum mismatch for %s", cache_key)
+                if cache_data and cache_data["expires"] > cur_time:
+                    if checksum is None or cache_data["checksum"] == checksum:
+                        LOGGER.debug("return cache data for %s", cache_key)
+                        result = pickle.loads(cache_data[1])
+        return result
+
+    async def async_set(self, cache_key, data, checksum="", expiration=(86400 * 30)):
+        """Set data in cache."""
+        checksum = self._get_checksum(checksum)
+        expires = int(time.time() + expiration)
+        data = pickle.dumps(data)
+        sql_query = """INSERT OR REPLACE INTO simplecache
+            (id, expires, data, checksum) VALUES (?, ?, ?, ?)"""
+        async with aiosqlite.connect(self._dbfile, timeout=180) as db_conn:
+            await db_conn.execute(sql_query, (cache_key, expires, data, checksum))
+            await db_conn.commit()
+
+    @run_periodic(3600)
+    async def async_auto_cleanup(self):
+        """Sceduled auto cleanup task."""
+        cur_timestamp = int(time.time())
+        LOGGER.debug("Running cleanup...")
+        sql_query = "SELECT id, expires FROM simplecache"
+        async with aiosqlite.connect(self._dbfile, timeout=600) as db_conn:
+            db_conn.row_factory = aiosqlite.Row
+            async with db_conn.execute(sql_query) as cursor:
+                cache_objects = await cursor.fetchall()
+            for cache_data in cache_objects:
+                cache_id = cache_data["id"]
+                # clean up db cache object only if expired
+                if cache_data["expires"] < cur_timestamp:
+                    sql_query = "DELETE FROM simplecache WHERE id = ?"
+                    await db_conn.execute(sql_query, (cache_id,))
+                    LOGGER.debug("delete from db %s", cache_id)
+            # compact db
+            await db_conn.commit()
+        LOGGER.debug("Auto cleanup done")
+
+    @staticmethod
+    def _get_checksum(stringinput):
+        """Get int checksum from string."""
+        if not stringinput:
+            return 0
+        stringinput = str(stringinput)
+        return reduce(lambda x, y: x + y, map(ord, stringinput))
+
+
+async def async_cached_generator(
+    cache, cache_key, coro_func, expires=(86400 * 30), checksum=None
+):
+    """Return helper method to store results of a async generator in the cache."""
+    cache_result = await cache.async_get(cache_key, checksum)
+    if cache_result is not None:
+        for item in cache_result:
+            yield item
+    else:
+        # nothing in cache, yield from generator and store in cache when complete
+        cache_result = []
+        async for item in coro_func:
+            yield item
+            cache_result.append(item)
+        # store results in cache
+        await cache.async_set(cache_key, cache_result, checksum, expires)
+
+
+async def async_cached(
+    cache, cache_key, coro_func, expires=(86400 * 30), checksum=None
+):
+    """Return helper method to store results of a coroutine in the cache."""
+    cache_result = await cache.async_get(cache_key, checksum)
+    # normal async function
+    if cache_result is not None:
+        return cache_result
+    result = await coro_func
+    await cache.async_set(cache_key, cache_result, checksum, expires)
+    return result
+
+
+def async_use_cache(cache_days=14, cache_checksum=None):
+    """Return decorator that can be used to cache a method's result."""
+
+    def wrapper(func):
+        @functools.wraps(func)
+        async def async_wrapped(*args, **kwargs):
+            method_class = args[0]
+            method_class_name = method_class.__class__.__name__
+            cache_str = "%s.%s" % (method_class_name, func.__name__)
+            cache_str += __cache_id_from_args(*args, **kwargs)
+            cache_str = cache_str.lower()
+            cachedata = await method_class.cache.async_get(cache_str)
+            if cachedata is not None:
+                return cachedata
+            result = await func(*args, **kwargs)
+            await method_class.cache.async_set(
+                cache_str,
+                result,
+                checksum=cache_checksum,
+                expiration=(86400 * cache_days),
+            )
+            return result
+
+        return async_wrapped
+
+    return wrapper
+
+
+def __cache_id_from_args(*args, **kwargs):
+    """Parse arguments to build cache id."""
+    cache_str = ""
+    # append args to cache identifier
+    for item in args[1:]:
+        if isinstance(item, dict):
+            for subkey in sorted(list(item.keys())):
+                subvalue = item[subkey]
+                cache_str += ".%s%s" % (subkey, subvalue)
+        else:
+            cache_str += ".%s" % item
+    # append kwargs to cache identifier
+    for key in sorted(list(kwargs.keys())):
+        value = kwargs[key]
+        if isinstance(value, dict):
+            for subkey in sorted(list(value.keys())):
+                subvalue = value[subkey]
+                cache_str += ".%s%s" % (subkey, subvalue)
+        else:
+            cache_str += ".%s%s" % (key, value)
+    return cache_str
diff --git a/music_assistant/helpers/musicbrainz.py b/music_assistant/helpers/musicbrainz.py
new file mode 100644 (file)
index 0000000..b3fcc03
--- /dev/null
@@ -0,0 +1,183 @@
+"""Handle getting Id's from MusicBrainz."""
+
+import logging
+import re
+from typing import Optional
+
+import aiohttp
+import orjson
+from asyncio_throttle import Throttler
+from music_assistant.helpers.cache import async_use_cache
+from music_assistant.helpers.util import compare_strings, get_compare_string
+
+LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])'
+
+LOGGER = logging.getLogger("musicbrainz")
+
+
+class MusicBrainz:
+    """Handle getting Id's from MusicBrainz."""
+
+    def __init__(self, mass):
+        """Initialize class."""
+        self.mass = mass
+        self.cache = mass.cache
+        self.throttler = Throttler(rate_limit=1, period=1)
+
+    async def async_get_mb_artist_id(
+        self,
+        artistname,
+        albumname=None,
+        album_upc=None,
+        trackname=None,
+        track_isrc=None,
+    ):
+        """Retrieve musicbrainz artist id for the given details."""
+        LOGGER.debug(
+            "searching musicbrainz for %s \
+                (albumname: %s - album_upc: %s - trackname: %s - track_isrc: %s)",
+            artistname,
+            albumname,
+            album_upc,
+            trackname,
+            track_isrc,
+        )
+        mb_artist_id = None
+        if album_upc:
+            mb_artist_id = await self.async_search_artist_by_album(
+                artistname, None, album_upc
+            )
+            if mb_artist_id:
+                LOGGER.debug(
+                    "Got MusicbrainzArtistId for %s after search on upc %s --> %s",
+                    artistname,
+                    album_upc,
+                    mb_artist_id,
+                )
+        if not mb_artist_id and track_isrc:
+            mb_artist_id = await self.async_search_artist_by_track(
+                artistname, None, track_isrc
+            )
+            if mb_artist_id:
+                LOGGER.debug(
+                    "Got MusicbrainzArtistId for %s after search on isrc %s --> %s",
+                    artistname,
+                    track_isrc,
+                    mb_artist_id,
+                )
+        if not mb_artist_id and albumname:
+            mb_artist_id = await self.async_search_artist_by_album(
+                artistname, albumname
+            )
+            if mb_artist_id:
+                LOGGER.debug(
+                    "Got MusicbrainzArtistId for %s after search on albumname %s --> %s",
+                    artistname,
+                    albumname,
+                    mb_artist_id,
+                )
+        if not mb_artist_id and trackname:
+            mb_artist_id = await self.async_search_artist_by_track(
+                artistname, trackname
+            )
+            if mb_artist_id:
+                LOGGER.debug(
+                    "Got MusicbrainzArtistId for %s after search on trackname %s --> %s",
+                    artistname,
+                    trackname,
+                    mb_artist_id,
+                )
+        return mb_artist_id
+
+    async def async_search_artist_by_album(
+        self, artistname, albumname=None, album_upc=None
+    ):
+        """Retrieve musicbrainz artist id by providing the artist name and albumname or upc."""
+        for searchartist in [
+            re.sub(LUCENE_SPECIAL, r"\\\1", artistname),
+            get_compare_string(artistname),
+        ]:
+            if album_upc:
+                endpoint = "release"
+                params = {"query": "barcode:%s" % album_upc}
+            else:
+                searchalbum = re.sub(LUCENE_SPECIAL, r"\\\1", albumname)
+                endpoint = "release"
+                params = {
+                    "query": 'artist:"%s" AND release:"%s"'
+                    % (searchartist, searchalbum)
+                }
+            result = await self.async_get_data(endpoint, params)
+            if result and "releases" in result:
+                for strictness in [True, False]:
+                    for item in result["releases"]:
+                        if album_upc or compare_strings(
+                            item["title"], albumname, strictness
+                        ):
+                            for artist in item["artist-credit"]:
+                                if compare_strings(
+                                    artist["artist"]["name"], artistname, strictness
+                                ):
+                                    return artist["artist"]["id"]
+                                for alias in artist.get("aliases", []):
+                                    if compare_strings(
+                                        alias["name"], artistname, strictness
+                                    ):
+                                        return artist["id"]
+        return ""
+
+    async def async_search_artist_by_track(
+        self, artistname, trackname=None, track_isrc=None
+    ):
+        """Retrieve artist id by providing the artist name and trackname or track isrc."""
+        endpoint = "recording"
+        searchartist = re.sub(LUCENE_SPECIAL, r"\\\1", artistname)
+        # searchartist = searchartist.replace('/','').replace('\\','').replace('-', '')
+        if track_isrc:
+            endpoint = "isrc/%s" % track_isrc
+            params = {"inc": "artist-credits"}
+        else:
+            searchtrack = re.sub(LUCENE_SPECIAL, r"\\\1", trackname)
+            endpoint = "recording"
+            params = {"query": '"%s" AND artist:"%s"' % (searchtrack, searchartist)}
+        result = await self.async_get_data(endpoint, params)
+        if result and "recordings" in result:
+            for strictness in [True, False]:
+                for item in result["recordings"]:
+                    if track_isrc or compare_strings(
+                        item["title"], trackname, strictness
+                    ):
+                        for artist in item["artist-credit"]:
+                            if compare_strings(
+                                artist["artist"]["name"], artistname, strictness
+                            ):
+                                return artist["artist"]["id"]
+                            for alias in artist.get("aliases", []):
+                                if compare_strings(
+                                    alias["name"], artistname, strictness
+                                ):
+                                    return artist["id"]
+        return ""
+
+    @async_use_cache(2)
+    async def async_get_data(self, endpoint: str, params: Optional[dict] = None):
+        """Get data from api."""
+        if params is None:
+            params = {}
+        url = "http://musicbrainz.org/ws/2/%s" % endpoint
+        headers = {"User-Agent": "Music Assistant/1.0.0 https://github.com/marcelveldt"}
+        params["fmt"] = "json"
+        async with self.throttler:
+            async with self.mass.http_session.get(
+                url, headers=headers, params=params, verify_ssl=False
+            ) as response:
+                try:
+                    result = await response.json(loads=orjson.loads)
+                except (
+                    aiohttp.client_exceptions.ContentTypeError,
+                    orjson.decoder.JSONDecodeError,
+                ) as exc:
+                    msg = await response.text()
+                    LOGGER.error("%s - %s", str(exc), msg)
+                    result = None
+                return result
diff --git a/music_assistant/helpers/util.py b/music_assistant/helpers/util.py
new file mode 100755 (executable)
index 0000000..4c6c92d
--- /dev/null
@@ -0,0 +1,397 @@
+"""Helper and utility functions."""
+import asyncio
+import functools
+import logging
+import os
+import platform
+import re
+import socket
+import struct
+import tempfile
+import urllib.request
+from enum import Enum
+from io import BytesIO
+from typing import Any, Callable, TypeVar
+
+import memory_tempfile
+import orjson
+import unidecode
+from cryptography.fernet import Fernet, InvalidToken
+from music_assistant.helpers.app_vars import get_app_var  # noqa # pylint: disable=all
+
+# pylint: disable=invalid-name
+T = TypeVar("T")
+_UNDEF: dict = {}
+CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable)
+CALLBACK_TYPE = Callable[[], None]
+# pylint: enable=invalid-name
+
+
+def callback(func: CALLABLE_T) -> CALLABLE_T:
+    """Annotation to mark method as safe to call from within the event loop."""
+    setattr(func, "_mass_callback", True)
+    return func
+
+
+def is_callback(func: Callable[..., Any]) -> bool:
+    """Check if function is safe to be called in the event loop."""
+    return getattr(func, "_mass_callback", False) is True
+
+
+def run_periodic(period):
+    """Run a coroutine at interval."""
+
+    def scheduler(fcn):
+        async def async_wrapper(*args, **kwargs):
+            while True:
+                asyncio.create_task(fcn(*args, **kwargs))
+                await asyncio.sleep(period)
+
+        return async_wrapper
+
+    return scheduler
+
+
+def get_external_ip():
+    """Try to get the external (WAN) IP address."""
+    # pylint: disable=broad-except
+    try:
+        return urllib.request.urlopen("https://ident.me").read().decode("utf8")
+    except Exception:
+        return None
+
+
+def filename_from_string(string):
+    """Create filename from unsafe string."""
+    keepcharacters = (" ", ".", "_")
+    return "".join(c for c in string if c.isalnum() or c in keepcharacters).rstrip()
+
+
+def run_background_task(corofn, *args, executor=None):
+    """Run non-async task in background."""
+    return asyncio.get_event_loop().run_in_executor(executor, corofn, *args)
+
+
+def run_async_background_task(executor, corofn, *args):
+    """Run async task in background."""
+
+    def run_task(corofn, *args):
+        new_loop = asyncio.new_event_loop()
+        asyncio.set_event_loop(new_loop)
+        coro = corofn(*args)
+        res = new_loop.run_until_complete(coro)
+        new_loop.close()
+        return res
+
+    return asyncio.get_event_loop().run_in_executor(executor, run_task, corofn, *args)
+
+
+def get_sort_name(name):
+    """Create a sort name for an artist/title."""
+    sort_name = name
+    for item in ["The ", "De ", "de ", "Les "]:
+        if name.startswith(item):
+            sort_name = "".join(name.split(item)[1:])
+    return sort_name
+
+
+def try_parse_int(possible_int):
+    """Try to parse an int."""
+    try:
+        return int(possible_int)
+    except (TypeError, ValueError):
+        return 0
+
+
+async def async_iter_items(items):
+    """Fake async iterator for compatability reasons."""
+    if not isinstance(items, list):
+        yield items
+    else:
+        for item in items:
+            yield item
+
+
+def try_parse_float(possible_float):
+    """Try to parse a float."""
+    try:
+        return float(possible_float)
+    except (TypeError, ValueError):
+        return 0.0
+
+
+def try_parse_bool(possible_bool):
+    """Try to parse a bool."""
+    if isinstance(possible_bool, bool):
+        return possible_bool
+    return possible_bool in ["true", "True", "1", "on", "ON", 1]
+
+
+def parse_title_and_version(track_title, track_version=None):
+    """Try to parse clean track title and version from the title."""
+    title = track_title.lower()
+    version = ""
+    for splitter in [" (", " [", " - ", " (", " [", "-"]:
+        if splitter in title:
+            title_parts = title.split(splitter)
+            for title_part in title_parts:
+                # look for the end splitter
+                for end_splitter in [")", "]"]:
+                    if end_splitter in title_part:
+                        title_part = title_part.split(end_splitter)[0]
+                for ignore_str in [
+                    "feat.",
+                    "featuring",
+                    "ft.",
+                    "with ",
+                    " & ",
+                    "explicit",
+                ]:
+                    if ignore_str in title_part:
+                        title = title.split(splitter + title_part)[0]
+                for version_str in [
+                    "version",
+                    "live",
+                    "edit",
+                    "remix",
+                    "mix",
+                    "acoustic",
+                    " instrumental",
+                    "karaoke",
+                    "remaster",
+                    "versie",
+                    "radio",
+                    "unplugged",
+                    "disco",
+                ]:
+                    if version_str in title_part:
+                        version = title_part
+                        title = title.split(splitter + version)[0]
+    title = title.strip().title()
+    if not version and track_version:
+        version = track_version
+    version = get_version_substitute(version).title()
+    return title, version
+
+
+def get_version_substitute(version_str):
+    """Transform provider version str to universal version type."""
+    version_str = version_str.lower()
+    # substitute edit and edition with version
+    if "edition" in version_str or "edit" in version_str:
+        version_str = version_str.replace(" edition", " version")
+        version_str = version_str.replace(" edit ", " version")
+    if version_str.startswith("the "):
+        version_str = version_str.split("the ")[1]
+    if "radio mix" in version_str:
+        version_str = "radio version"
+    elif "video mix" in version_str:
+        version_str = "video version"
+    elif "spanglish" in version_str or "spanish" in version_str:
+        version_str = "spanish version"
+    elif version_str.endswith("remaster"):
+        version_str = "remaster"
+    return version_str.strip()
+
+
+def get_ip():
+    """Get primary IP-address for this host."""
+    # pylint: disable=broad-except
+    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+    try:
+        # doesn't even have to be reachable
+        sock.connect(("10.255.255.255", 1))
+        _ip = sock.getsockname()[0]
+    except Exception:
+        _ip = "127.0.0.1"
+    finally:
+        sock.close()
+    return _ip
+
+
+def get_ip_pton():
+    """Return socket pton for local ip."""
+    try:
+        return socket.inet_pton(socket.AF_INET, get_ip())
+    except OSError:
+        return socket.inet_pton(socket.AF_INET6, get_ip())
+
+
+# pylint: enable=broad-except
+
+
+def get_hostname():
+    """Get hostname for this machine."""
+    return socket.gethostname()
+
+
+def get_folder_size(folderpath):
+    """Return folder size in gb."""
+    total_size = 0
+    # pylint: disable=unused-variable
+    for dirpath, dirnames, filenames in os.walk(folderpath):
+        for _file in filenames:
+            _fp = os.path.join(dirpath, _file)
+            total_size += os.path.getsize(_fp)
+    # pylint: enable=unused-variable
+    total_size_gb = total_size / float(1 << 30)
+    return total_size_gb
+
+
+# pylint: disable=invalid-name
+json_serializer = functools.partial(orjson.dumps, option=orjson.OPT_NAIVE_UTC)
+# pylint: enable=invalid-name
+
+
+def get_compare_string(input_str):
+    """Return clean lowered string for compare actions."""
+    unaccented_string = unidecode.unidecode(input_str)
+    return re.sub(r"[^a-zA-Z0-9]", "", unaccented_string).lower()
+
+
+def compare_strings(str1, str2, strict=False):
+    """Compare strings and return True if we have an (almost) perfect match."""
+    match = str1.lower() == str2.lower()
+    if not match and not strict:
+        match = get_compare_string(str1) == get_compare_string(str2)
+    return match
+
+
+def merge_dict(base_dict: dict, new_dict: dict):
+    """Merge dict without overwriting existing values."""
+    for key, value in new_dict.items():
+        if isinstance(value, dict):
+            base_dict[key] = merge_dict(base_dict[key], value)
+        elif not base_dict.get(key):
+            base_dict[key] = value
+    return base_dict
+
+
+def try_load_json_file(jsonfile):
+    """Try to load json from file."""
+    try:
+        with open(jsonfile, "rb") as _file:
+            return orjson.loads(_file.read())
+    except (FileNotFoundError, orjson.JSONDecodeError) as exc:
+        logging.getLogger().debug(
+            "Could not load json from file %s", jsonfile, exc_info=exc
+        )
+        return None
+
+
+def create_tempfile():
+    """Return a (named) temporary file."""
+    if platform.system() == "Linux":
+        return memory_tempfile.MemoryTempfile(fallback=True).NamedTemporaryFile(
+            buffering=0
+        )
+    return tempfile.NamedTemporaryFile(buffering=0)
+
+
+def encrypt_string(str_value):
+    """Encrypt a string with Fernet."""
+    return Fernet(get_app_var(3)).encrypt(str_value.encode()).decode()
+
+
+def encrypt_bytes(bytes_value):
+    """Encrypt bytes with Fernet."""
+    return Fernet(get_app_var(3)).encrypt(bytes_value)
+
+
+def yield_chunks(_obj, chunk_size):
+    """Yield successive n-sized chunks from list/str/bytes."""
+    chunk_size = int(chunk_size)
+    for i in range(0, len(_obj), chunk_size):
+        yield _obj[i : i + chunk_size]
+
+
+def decrypt_string(str_value):
+    """Decrypt a string with Fernet."""
+    try:
+        return Fernet(get_app_var(3)).decrypt(str_value.encode()).decode()
+    except (InvalidToken, AttributeError):
+        return None
+
+
+def decrypt_bytes(bytes_value):
+    """Decrypt bytes with Fernet."""
+    try:
+        return Fernet(get_app_var(3)).decrypt(bytes_value)
+    except (InvalidToken, AttributeError):
+        return None
+
+
+class CustomIntEnum(int, Enum):
+    """Base for IntEnum with some helpers."""
+
+    # when serializing we prefer the string (name) representation
+    # internally (database) we use the int value
+
+    def __int__(self):
+        """Return integer value."""
+        return super().value
+
+    def __str__(self):
+        """Return string value."""
+        # pylint: disable=no-member
+        return self._name_.lower()
+
+    @property
+    def value(self):
+        """Return the (json friendly) string name."""
+        return self.__str__()
+
+    @classmethod
+    def from_string(cls, string):
+        """Create IntEnum from it's string equivalent."""
+        for key, value in cls.__dict__.items():
+            if key.lower() == string or value == try_parse_int(string):
+                return value
+        return KeyError
+
+
+def create_wave_header(samplerate=44100, channels=2, bitspersample=16, duration=3600):
+    """Generate a wave header from given params."""
+    file = BytesIO()
+    numsamples = samplerate * duration
+
+    # Generate format chunk
+    format_chunk_spec = b"<4sLHHLLHH"
+    format_chunk = struct.pack(
+        format_chunk_spec,
+        b"fmt ",  # Chunk id
+        16,  # Size of this chunk (excluding chunk id and this field)
+        1,  # Audio format, 1 for PCM
+        channels,  # Number of channels
+        int(samplerate),  # Samplerate, 44100, 48000, etc.
+        int(samplerate * channels * (bitspersample / 8)),  # Byterate
+        int(channels * (bitspersample / 8)),  # Blockalign
+        bitspersample,  # 16 bits for two byte samples, etc.
+    )
+    # Generate data chunk
+    data_chunk_spec = b"<4sL"
+    datasize = int(numsamples * channels * (bitspersample / 8))
+    data_chunk = struct.pack(
+        data_chunk_spec,
+        b"data",  # Chunk id
+        int(datasize),  # Chunk size (excluding chunk id and this field)
+    )
+    sum_items = [
+        # "WAVE" string following size field
+        4,
+        # "fmt " + chunk size field + chunk size
+        struct.calcsize(format_chunk_spec),
+        # Size of data chunk spec + data size
+        struct.calcsize(data_chunk_spec) + datasize,
+    ]
+    # Generate main header
+    all_chunks_size = int(sum(sum_items))
+    main_header_spec = b"<4sL4s"
+    main_header = struct.pack(main_header_spec, b"RIFF", all_chunks_size, b"WAVE")
+    # Write all the contents in
+    file.write(main_header)
+    file.write(format_chunk)
+    file.write(data_chunk)
+
+    # return file.getvalue(), all_chunks_size + 8
+    return file.getvalue()
diff --git a/music_assistant/helpers/web.py b/music_assistant/helpers/web.py
new file mode 100644 (file)
index 0000000..207d930
--- /dev/null
@@ -0,0 +1,73 @@
+"""Various helpers for web requests."""
+
+import ipaddress
+from functools import wraps
+from typing import AsyncGenerator
+
+from aiohttp import web
+from music_assistant.helpers.typing import MusicAssistantType
+from music_assistant.helpers.util import json_serializer
+from music_assistant.models.media_types import MediaType
+
+
+async def async_stream_json(request: web.Request, generator: AsyncGenerator):
+    """Stream items from async generator as json object."""
+    resp = web.StreamResponse(
+        status=200, reason="OK", headers={"Content-Type": "application/json"}
+    )
+    await resp.prepare(request)
+    # write json open tag
+    await resp.write(b'{ "items": [')
+    count = 0
+    async for item in generator:
+        # write each item into the items object of the json
+        if count:
+            json_response = b"," + json_serializer(item)
+        else:
+            json_response = json_serializer(item)
+        await resp.write(json_response)
+        count += 1
+    # write json close tag
+    msg = '], "count": %s }' % count
+    await resp.write(msg.encode())
+    await resp.write_eof()
+    return resp
+
+
+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]
+    media_items = []
+    for item in data:
+        media_item = await mass.music.async_get_item(
+            item["item_id"],
+            item["provider"],
+            MediaType.from_string(item["media_type"]),
+            lazy=True,
+        )
+        media_items.append(media_item)
+    return media_items
+
+
+def require_local_subnet(func):
+    """Return decorator to specify web method as available locally only."""
+
+    @wraps(func)
+    async def wrapped(*args, **kwargs):
+        request = args[-1]
+
+        if isinstance(request, web.View):
+            request = request.request
+
+        if not isinstance(request, web.BaseRequest):  # pragma: no cover
+            raise RuntimeError(
+                "Incorrect usage of decorator." "Expect web.BaseRequest as an argument"
+            )
+
+        if not ipaddress.ip_address(request.remote).is_private:
+            raise web.HTTPUnauthorized(reason="Not remote available")
+
+        return await func(*args, **kwargs)
+
+    return wrapped
diff --git a/music_assistant/managers/__init__.py b/music_assistant/managers/__init__.py
new file mode 100644 (file)
index 0000000..bc6f8f9
--- /dev/null
@@ -0,0 +1 @@
+"""Controllers/managers for Music Assistant entities."""
diff --git a/music_assistant/managers/config.py b/music_assistant/managers/config.py
new file mode 100755 (executable)
index 0000000..a1487cd
--- /dev/null
@@ -0,0 +1,563 @@
+"""All classes and helpers for the Configuration."""
+
+import logging
+import os
+import shutil
+from collections import OrderedDict
+from enum import Enum
+from typing import List
+
+import orjson
+from music_assistant.constants import (
+    CONF_CROSSFADE_DURATION,
+    CONF_ENABLED,
+    CONF_EXTERNAL_URL,
+    CONF_FALLBACK_GAIN_CORRECT,
+    CONF_HTTP_PORT,
+    CONF_HTTPS_PORT,
+    CONF_KEY_BASE,
+    CONF_KEY_BASE_SECURITY,
+    CONF_KEY_BASE_WEBSERVER,
+    CONF_KEY_METADATA_PROVIDERS,
+    CONF_KEY_MUSIC_PROVIDERS,
+    CONF_KEY_PLAYER_PROVIDERS,
+    CONF_KEY_PLAYER_SETTINGS,
+    CONF_KEY_PLUGINS,
+    CONF_MAX_SAMPLE_RATE,
+    CONF_NAME,
+    CONF_PASSWORD,
+    CONF_SSL_CERTIFICATE,
+    CONF_SSL_KEY,
+    CONF_TARGET_VOLUME,
+    CONF_USERNAME,
+    CONF_VOLUME_NORMALISATION,
+    EVENT_CONFIG_CHANGED,
+)
+from music_assistant.helpers.util import (
+    decrypt_string,
+    encrypt_string,
+    get_external_ip,
+    merge_dict,
+    try_load_json_file,
+)
+from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType
+from music_assistant.models.provider import ProviderType
+from passlib.hash import pbkdf2_sha256
+
+LOGGER = logging.getLogger("mass")
+
+DEFAULT_PLAYER_CONFIG_ENTRIES = [
+    ConfigEntry(
+        entry_key=CONF_ENABLED,
+        entry_type=ConfigEntryType.BOOL,
+        default_value=True,
+        label="enable_player",
+    ),
+    ConfigEntry(
+        entry_key=CONF_NAME,
+        entry_type=ConfigEntryType.STRING,
+        default_value=None,
+        label=CONF_NAME,
+        description="desc_player_name",
+    ),
+    ConfigEntry(
+        entry_key=CONF_MAX_SAMPLE_RATE,
+        entry_type=ConfigEntryType.INT,
+        values=[41000, 48000, 96000, 176000, 192000, 384000],
+        default_value=96000,
+        label=CONF_MAX_SAMPLE_RATE,
+        description="desc_sample_rate",
+    ),
+    ConfigEntry(
+        entry_key=CONF_VOLUME_NORMALISATION,
+        entry_type=ConfigEntryType.BOOL,
+        default_value=True,
+        label=CONF_VOLUME_NORMALISATION,
+        description="desc_volume_normalisation",
+    ),
+    ConfigEntry(
+        entry_key=CONF_TARGET_VOLUME,
+        entry_type=ConfigEntryType.INT,
+        range=(-30, 0),
+        default_value=-23,
+        label=CONF_TARGET_VOLUME,
+        description="desc_target_volume",
+        depends_on=CONF_VOLUME_NORMALISATION,
+    ),
+    ConfigEntry(
+        entry_key=CONF_FALLBACK_GAIN_CORRECT,
+        entry_type=ConfigEntryType.INT,
+        range=(-20, 0),
+        default_value=-12,
+        label=CONF_FALLBACK_GAIN_CORRECT,
+        description="desc_gain_correct",
+        depends_on=CONF_VOLUME_NORMALISATION,
+    ),
+    ConfigEntry(
+        entry_key=CONF_CROSSFADE_DURATION,
+        entry_type=ConfigEntryType.INT,
+        range=(0, 10),
+        default_value=0,
+        label=CONF_CROSSFADE_DURATION,
+        description="desc_crossfade",
+    ),
+]
+
+DEFAULT_PROVIDER_CONFIG_ENTRIES = [
+    ConfigEntry(
+        entry_key=CONF_ENABLED,
+        entry_type=ConfigEntryType.BOOL,
+        default_value=True,
+        label=CONF_ENABLED,
+        description="desc_enable_provider",
+    )
+]
+
+DEFAULT_BASE_CONFIG_ENTRIES = {
+    CONF_KEY_BASE_WEBSERVER: [
+        ConfigEntry(
+            entry_key=CONF_HTTP_PORT,
+            entry_type=ConfigEntryType.INT,
+            default_value=8095,
+            label=CONF_HTTP_PORT,
+            description="desc_http_port",
+        ),
+        ConfigEntry(
+            entry_key=CONF_HTTPS_PORT,
+            entry_type=ConfigEntryType.INT,
+            default_value=8096,
+            label=CONF_HTTPS_PORT,
+            description="desc_https_port",
+        ),
+        ConfigEntry(
+            entry_key=CONF_SSL_CERTIFICATE,
+            entry_type=ConfigEntryType.STRING,
+            default_value="",
+            label=CONF_SSL_CERTIFICATE,
+            description="desc_ssl_certificate",
+        ),
+        ConfigEntry(
+            entry_key=CONF_SSL_KEY,
+            entry_type=ConfigEntryType.STRING,
+            default_value="",
+            label=CONF_SSL_KEY,
+            description="desc_ssl_key",
+        ),
+        ConfigEntry(
+            entry_key=CONF_EXTERNAL_URL,
+            entry_type=ConfigEntryType.STRING,
+            default_value=f"http://{get_external_ip()}:8095",
+            label="External url (fqdn)",
+            description="desc_external_url",
+        ),
+    ],
+    CONF_KEY_BASE_SECURITY: [
+        ConfigEntry(
+            entry_key=CONF_USERNAME,
+            entry_type=ConfigEntryType.STRING,
+            default_value="admin",
+            label=CONF_USERNAME,
+            description="desc_base_username",
+        ),
+        ConfigEntry(
+            entry_key=CONF_PASSWORD,
+            entry_type=ConfigEntryType.PASSWORD,
+            default_value="",
+            label=CONF_PASSWORD,
+            description="desc_base_password",
+            store_hashed=True,
+        ),
+    ],
+}
+
+
+class ConfigBaseType(Enum):
+    """Enum with config base types."""
+
+    BASE = CONF_KEY_BASE
+    PLAYER_SETTINGS = CONF_KEY_PLAYER_SETTINGS
+    MUSIC_PROVIDERS = CONF_KEY_MUSIC_PROVIDERS
+    PLAYER_PROVIDERS = CONF_KEY_PLAYER_PROVIDERS
+    METADATA_PROVIDERS = CONF_KEY_METADATA_PROVIDERS
+    PLUGINS = CONF_KEY_PLUGINS
+
+
+PROVIDER_TYPES = [
+    ConfigBaseType.MUSIC_PROVIDERS,
+    ConfigBaseType.PLAYER_PROVIDERS,
+    ConfigBaseType.METADATA_PROVIDERS,
+    ConfigBaseType.PLUGINS,
+]
+
+
+class ConfigItem:
+    """
+    Configuration Item connected to Config Entries.
+
+    Returns default value from config entry if no value present.
+    """
+
+    def __init__(self, mass, parent_item_key: str, base_type: ConfigBaseType):
+        """Initialize class."""
+        self._parent_item_key = parent_item_key
+        self._base_type = base_type
+        self.mass = mass
+        self.stored_config = OrderedDict()
+
+    def __repr__(self):
+        """Print class."""
+        return f"{OrderedDict}({self.to_dict()})"
+
+    def to_dict(self, lang="en") -> dict:
+        """Return entire config as dict."""
+        result = OrderedDict()
+        for entry in self.get_config_entries():
+            if entry.entry_key in self.stored_config:
+                # use saved value
+                entry.value = self.stored_config[entry.entry_key]
+            else:
+                # use default value for config entry
+                entry.value = entry.default_value
+            result[entry.entry_key] = entry
+            # get translated values
+            for entry_key in ["label", "description"]:
+                org_value = getattr(result[entry.entry_key], entry_key, None)
+                if not org_value:
+                    org_value = entry.entry_key
+                translated_value = self.mass.config.get_translation(org_value, lang)
+                if translated_value != org_value:
+                    setattr(result[entry.entry_key], entry_key, translated_value)
+        return result
+
+    def get(self, key, default=None):
+        """Return value if key exists, default if not."""
+        try:
+            return self[key]
+        except KeyError:
+            return default
+
+    def get_entry(self, key):
+        """Return complete ConfigEntry for specified key."""
+        for entry in self.get_config_entries():
+            if entry.entry_key == key:
+                if key in self.stored_config:
+                    # use saved value
+                    entry.value = self.stored_config[key]
+                else:
+                    # use default value for config entry
+                    entry.value = entry.default_value
+                return entry
+        raise KeyError(
+            "%s\\%s has no key %s!" % (self._base_type, self._parent_item_key, key)
+        )
+
+    def __getitem__(self, key) -> ConfigEntry:
+        """Return default value from ConfigEntry if needed."""
+        entry = self.get_entry(key)
+        if entry.entry_type == ConfigEntryType.PASSWORD:
+            # decrypted password is only returned if explicitly asked for this key
+            decrypted_value = decrypt_string(entry.value)
+            if decrypted_value:
+                return decrypted_value
+        return entry.value
+
+    def __setitem__(self, key, value):
+        """Store value and validate."""
+        for entry in self.get_config_entries():
+            if entry.entry_key != key:
+                continue
+            # do some simple type checking
+            if entry.multi_value:
+                # multi value item
+                if not isinstance(value, list):
+                    raise ValueError
+            else:
+                # single value item
+                if entry.entry_type == ConfigEntryType.STRING and not isinstance(
+                    value, str
+                ):
+                    if not value:
+                        value = ""
+                    else:
+                        raise ValueError
+                if entry.entry_type == ConfigEntryType.BOOL and not isinstance(
+                    value, bool
+                ):
+                    raise ValueError
+                if entry.entry_type == ConfigEntryType.FLOAT and not isinstance(
+                    value, (float, int)
+                ):
+                    raise ValueError
+            if value != self[key]:
+                if entry.store_hashed:
+                    value = pbkdf2_sha256.hash(value)
+                if entry.entry_type == ConfigEntryType.PASSWORD:
+                    value = encrypt_string(value)
+                self.stored_config[key] = value
+
+                self.mass.add_job(self.mass.config.save)
+                # reload provider/plugin if value changed
+                if self._base_type in PROVIDER_TYPES:
+                    self.mass.add_job(
+                        self.mass.async_reload_provider(self._parent_item_key)
+                    )
+                if self._base_type == ConfigBaseType.PLAYER_SETTINGS:
+                    # force update of player if it's config changed
+                    self.mass.add_job(
+                        self.mass.players.async_trigger_player_update(
+                            self._parent_item_key
+                        )
+                    )
+                # signal config changed event
+                self.mass.signal_event(
+                    EVENT_CONFIG_CHANGED, (self._base_type, self._parent_item_key)
+                )
+            return
+        # raise KeyError if we're trying to set a value not defined as ConfigEntry
+        raise KeyError
+
+    def get_config_entries(self) -> List[ConfigEntry]:
+        """Return config entries for this item."""
+        if self._base_type == ConfigBaseType.PLAYER_SETTINGS:
+            return self.mass.config.get_player_config_entries(self._parent_item_key)
+        if self._base_type in PROVIDER_TYPES:
+            return self.mass.config.get_provider_config_entries(self._parent_item_key)
+        return self.mass.config.get_base_config_entries(self._parent_item_key)
+
+
+class ConfigBase(OrderedDict):
+    """Configuration class with ConfigItem items."""
+
+    def __init__(self, mass, base_type=ConfigBaseType):
+        """Initialize class."""
+        self.mass = mass
+        self._base_type = base_type
+        super().__init__()
+
+    def __getitem__(self, item_key):
+        """Return convenience method for get."""
+        if item_key not in self:
+            # create new ConfigDictItem on the fly
+            super().__setitem__(
+                item_key, ConfigItem(self.mass, item_key, self._base_type)
+            )
+        return super().__getitem__(item_key)
+
+    def to_dict(self, lang="en") -> dict:
+        """Return entire config as dict."""
+        return {key: value.to_dict(lang) for key, value in self.items()}
+
+
+class ConfigManager:
+    """Class which holds our configuration."""
+
+    def __init__(self, mass, data_path: str):
+        """Initialize class."""
+        self._data_path = data_path
+        self.loading = False
+        self.mass = mass
+        self._conf_base = ConfigBase(mass, ConfigBaseType.BASE)
+        self._conf_player_settings = ConfigBase(mass, ConfigBaseType.PLAYER_SETTINGS)
+        self._conf_player_providers = ConfigBase(mass, ConfigBaseType.PLAYER_PROVIDERS)
+        self._conf_music_providers = ConfigBase(mass, ConfigBaseType.MUSIC_PROVIDERS)
+        self._conf_metadata_providers = ConfigBase(
+            mass, ConfigBaseType.METADATA_PROVIDERS
+        )
+        self._conf_plugins = ConfigBase(mass, ConfigBaseType.PLUGINS)
+        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()
+
+    @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
+
+    @property
+    def base(self):
+        """Return base config."""
+        return self._conf_base
+
+    @property
+    def player_settings(self):
+        """Return all player configs."""
+        return self._conf_player_settings
+
+    @property
+    def music_providers(self):
+        """Return all music provider configs."""
+        return self._conf_music_providers
+
+    @property
+    def player_providers(self):
+        """Return all player provider configs."""
+        return self._conf_player_providers
+
+    @property
+    def metadata_providers(self):
+        """Return all metadata provider configs."""
+        return self._conf_metadata_providers
+
+    @property
+    def plugins(self):
+        """Return all plugin configs."""
+        return self._conf_plugins
+
+    def get_provider_config(self, provider_id: str, provider_type: ProviderType = None):
+        """Return config for given provider."""
+        if not provider_type:
+            provider = self.mass.get_provider(provider_id)
+            if provider:
+                provider_type = provider.type
+        if provider_type == ProviderType.METADATA_PROVIDER:
+            return self._conf_metadata_providers[provider_id]
+        if provider_type == ProviderType.MUSIC_PROVIDER:
+            return self._conf_music_providers[provider_id]
+        if provider_type == ProviderType.PLAYER_PROVIDER:
+            return self._conf_player_providers[provider_id]
+        if provider_type == ProviderType.PLUGIN:
+            return self._conf_plugins[provider_id]
+        raise RuntimeError("Invalid provider type")
+
+    def get_player_config(self, player_id):
+        """Return config for given player."""
+        return self._conf_player_settings[player_id]
+
+    def get_provider_config_entries(self, provider_id: str) -> List[ConfigEntry]:
+        """Return all config entries for the given provider."""
+        provider = self.mass.get_provider(provider_id)
+        if provider:
+            specials = [
+                ConfigEntry(
+                    "__name__", ConfigEntryType.LABEL, label=provider.name, hidden=True
+                )
+            ]
+            return specials + DEFAULT_PROVIDER_CONFIG_ENTRIES + provider.config_entries
+        return DEFAULT_PROVIDER_CONFIG_ENTRIES
+
+    def get_player_config_entries(self, player_id: str) -> List[ConfigEntry]:
+        """Return all config entries for the given player."""
+        player_state = self.mass.players.get_player_state(player_id)
+        if player_state:
+            return DEFAULT_PLAYER_CONFIG_ENTRIES + player_state.config_entries
+        return DEFAULT_PLAYER_CONFIG_ENTRIES
+
+    @staticmethod
+    def get_base_config_entries(base_key) -> List[ConfigEntry]:
+        """Return all base config entries."""
+        return DEFAULT_BASE_CONFIG_ENTRIES[base_key]
+
+    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)
+
+    async def async_close(self):
+        """Save config on exit."""
+        self.save()
+
+    def get_translation(self, org_string: str, lang: str):
+        """Get translated value for a string, fallback to english."""
+        for lang in [lang, "en"]:
+            translated_value = self.mass.config.translations.get(lang, {}).get(
+                org_string
+            )
+            if translated_value:
+                return translated_value
+        return org_string
+
+    def __get_all_translations(self) -> dict:
+        """Build a list of all translations."""
+        base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+        # get base translations
+        translations_file = os.path.join(base_dir, "translations.json")
+        res = try_load_json_file(translations_file)
+        if res is not None:
+            translations = res
+        else:
+            translations = {}
+        # append provider translations but do not overwrite keys
+        modules_path = os.path.join(base_dir, "providers")
+        # load modules
+        for dir_str in os.listdir(modules_path):
+            dir_path = os.path.join(modules_path, dir_str)
+            translations_file = os.path.join(dir_path, "translations.json")
+            if not os.path.isfile(translations_file):
+                continue
+            res = try_load_json_file(translations_file)
+            if res is not None:
+                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)
+        # create dict for stored config
+        stored_conf = {
+            CONF_KEY_BASE: {},
+            CONF_KEY_PLAYER_SETTINGS: {},
+            CONF_KEY_MUSIC_PROVIDERS: {},
+            CONF_KEY_METADATA_PROVIDERS: {},
+            CONF_KEY_PLAYER_PROVIDERS: {},
+            CONF_KEY_PLUGINS: {},
+        }
+        for conf_key in stored_conf:
+            for key, value in self[conf_key].items():
+                stored_conf[conf_key][key] = value.stored_config
+
+        # write current config to file
+        with open(conf_file, "wb") as _file:
+            _file.write(orjson.dumps(stored_conf, option=orjson.OPT_INDENT_2))
+        LOGGER.info("Config saved!")
+        self.loading = False
+
+    def __load(self):
+        """Load stored config from file."""
+        self.loading = True
+        conf_file = os.path.join(self.data_path, "config.json")
+        data = try_load_json_file(conf_file)
+        if not data:
+            # might be a corrupt config file, retry with backup file
+            conf_file_backup = os.path.join(self.data_path, "config.json.backup")
+            data = try_load_json_file(conf_file_backup)
+        if data:
+
+            for conf_key in [
+                CONF_KEY_BASE,
+                CONF_KEY_PLAYER_SETTINGS,
+                CONF_KEY_MUSIC_PROVIDERS,
+                CONF_KEY_METADATA_PROVIDERS,
+                CONF_KEY_PLAYER_PROVIDERS,
+                CONF_KEY_PLUGINS,
+            ]:
+                if not data.get(conf_key):
+                    continue
+                for key, value in data[conf_key].items():
+                    for subkey, subvalue in value.items():
+                        self[conf_key][key].stored_config[subkey] = subvalue
+
+        self.loading = False
diff --git a/music_assistant/managers/database.py b/music_assistant/managers/database.py
new file mode 100755 (executable)
index 0000000..b2ffbe2
--- /dev/null
@@ -0,0 +1,1139 @@
+"""Database logic."""
+# pylint: disable=too-many-lines
+import logging
+import os
+import sqlite3
+from functools import partial
+from typing import List
+
+import aiosqlite
+from music_assistant.helpers.util import compare_strings, get_sort_name, try_parse_int
+from music_assistant.models.media_types import (
+    Album,
+    AlbumType,
+    Artist,
+    ExternalId,
+    MediaItem,
+    MediaItemProviderId,
+    MediaType,
+    Playlist,
+    Radio,
+    SearchResult,
+    Track,
+    TrackQuality,
+)
+
+LOGGER = logging.getLogger("mass")
+
+
+class DbConnect:
+    """Helper to initialize the db connection or utilize an existing one."""
+
+    def __init__(self, dbfile: str, db_conn: sqlite3.Connection = None):
+        """Initialize class."""
+        self._db_conn_provided = db_conn is not None
+        self._db_conn = db_conn
+        self._dbfile = dbfile
+
+    async def __aenter__(self):
+        """Enter."""
+        if not self._db_conn_provided:
+            self._db_conn = await aiosqlite.connect(self._dbfile, timeout=120)
+        return self._db_conn
+
+    async def __aexit__(self, exc_type, exc_value, traceback):
+        """Exit."""
+        if not self._db_conn_provided:
+            await self._db_conn.close()
+        return False
+
+
+class DatabaseManager:
+    """Class that holds the (logic to the) database."""
+
+    def __init__(self, mass):
+        """Initialize class."""
+        self.mass = mass
+        self._dbfile = os.path.join(mass.config.data_path, "database.db")
+        self.db_conn = partial(DbConnect, self._dbfile)
+
+    async def async_setup(self):
+        """Async initialization."""
+        async with DbConnect(self._dbfile) as db_conn:
+
+            await db_conn.execute(
+                """CREATE TABLE IF NOT EXISTS library_items(
+                    item_id INTEGER NOT NULL, provider TEXT NOT NULL,
+                    media_type INTEGER NOT NULL, UNIQUE(item_id, provider, media_type)
+                );"""
+            )
+
+            await db_conn.execute(
+                """CREATE TABLE IF NOT EXISTS artists(
+                    artist_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL,
+                    sort_name TEXT, musicbrainz_id TEXT NOT NULL UNIQUE);"""
+            )
+
+            await db_conn.execute(
+                """CREATE TABLE IF NOT EXISTS albums(
+                    album_id INTEGER PRIMARY KEY AUTOINCREMENT, artist_id INTEGER NOT NULL,
+                    name TEXT NOT NULL, albumtype TEXT, year INTEGER, version TEXT,
+                    UNIQUE(artist_id, name, version, year)
+                );"""
+            )
+
+            await db_conn.execute(
+                """CREATE TABLE IF NOT EXISTS labels(
+                    label_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE);"""
+            )
+            await db_conn.execute(
+                """CREATE TABLE IF NOT EXISTS album_labels(
+                    album_id INTEGER, label_id INTEGER, UNIQUE(album_id, label_id));"""
+            )
+
+            await db_conn.execute(
+                """CREATE TABLE IF NOT EXISTS tracks(
+                    track_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL,
+                    album_id INTEGER, version TEXT, duration INTEGER,
+                    UNIQUE(name, version, album_id, duration)
+                );"""
+            )
+            await db_conn.execute(
+                """CREATE TABLE IF NOT EXISTS track_artists(
+                    track_id INTEGER, artist_id INTEGER, UNIQUE(track_id, artist_id));"""
+            )
+
+            await db_conn.execute(
+                """CREATE TABLE IF NOT EXISTS tags(
+                    tag_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE);"""
+            )
+            await db_conn.execute(
+                """CREATE TABLE IF NOT EXISTS media_tags(
+                    item_id INTEGER, media_type INTEGER, tag_id,
+                    UNIQUE(item_id, media_type, tag_id)
+                );"""
+            )
+
+            await db_conn.execute(
+                """CREATE TABLE IF NOT EXISTS provider_mappings(
+                    item_id INTEGER NOT NULL, media_type INTEGER NOT NULL,
+                    prov_item_id TEXT NOT NULL,
+                    provider TEXT NOT NULL, quality INTEGER NOT NULL, details TEXT NULL,
+                    UNIQUE(item_id, media_type, prov_item_id, provider, quality)
+                    );"""
+            )
+
+            await db_conn.execute(
+                """CREATE TABLE IF NOT EXISTS metadata(
+                    item_id INTEGER NOT NULL, media_type INTEGER NOT NULL, key TEXT NOT NULL,
+                    value TEXT, UNIQUE(item_id, media_type, key));"""
+            )
+
+            await db_conn.execute(
+                """CREATE TABLE IF NOT EXISTS external_ids(
+                    item_id INTEGER NOT NULL, media_type INTEGER NOT NULL, key TEXT NOT NULL,
+                    value TEXT, UNIQUE(item_id, media_type, key, value));"""
+            )
+
+            await db_conn.execute(
+                """CREATE TABLE IF NOT EXISTS playlists(
+                    playlist_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL,
+                    owner TEXT NOT NULL, is_editable BOOLEAN NOT NULL, checksum TEXT NOT NULL,
+                    UNIQUE(name, owner)
+                    );"""
+            )
+
+            await db_conn.execute(
+                """CREATE TABLE IF NOT EXISTS radios(
+                    radio_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE);"""
+            )
+
+            await db_conn.execute(
+                """CREATE TABLE IF NOT EXISTS track_loudness(
+                    provider_track_id INTEGER NOT NULL, provider TEXT NOT NULL, loudness REAL,
+                    UNIQUE(provider_track_id, provider));"""
+            )
+
+            await db_conn.commit()
+            await db_conn.execute("VACUUM;")
+            await db_conn.commit()
+
+    async def async_get_database_id(
+        self,
+        provider_id: str,
+        prov_item_id: str,
+        media_type: MediaType,
+        db_conn: sqlite3.Connection = None,
+    ) -> int:
+        """Get the database id for the given prov_id."""
+        async with DbConnect(self._dbfile, db_conn) as db_conn:
+            if provider_id == "database":
+                return prov_item_id
+            sql_query = """SELECT item_id FROM provider_mappings
+                WHERE prov_item_id = ? AND provider = ? AND media_type = ?;"""
+            async with db_conn.execute(
+                sql_query, (prov_item_id, provider_id, int(media_type))
+            ) as cursor:
+                item_id = await cursor.fetchone()
+            if item_id:
+                return item_id[0]
+        return None
+
+    async def async_search(
+        self, searchquery: str, media_types: List[MediaType]
+    ) -> SearchResult:
+        """Search library for the given searchphrase."""
+        async with DbConnect(self._dbfile) as db_conn:
+            result = SearchResult([], [], [], [], [])
+            searchquery = "%" + searchquery + "%"
+            if media_types is None or MediaType.Artist in media_types:
+                sql_query = ' WHERE name LIKE "%s"' % searchquery
+                result.artists = [
+                    item
+                    async for item in self.async_get_artists(sql_query, db_conn=db_conn)
+                ]
+            if media_types is None or MediaType.Album in media_types:
+                sql_query = ' WHERE name LIKE "%s"' % searchquery
+                result.albums = [
+                    item
+                    async for item in self.async_get_albums(sql_query, db_conn=db_conn)
+                ]
+            if media_types is None or MediaType.Track in media_types:
+                sql_query = ' WHERE name LIKE "%s"' % searchquery
+                result.tracks = [
+                    item
+                    async for item in self.async_get_tracks(sql_query, db_conn=db_conn)
+                ]
+            if media_types is None or MediaType.Playlist in media_types:
+                sql_query = ' WHERE name LIKE "%s"' % searchquery
+                result.playlists = [
+                    item
+                    async for item in self.async_get_playlists(
+                        sql_query, db_conn=db_conn
+                    )
+                ]
+            if media_types is None or MediaType.Radio in media_types:
+                sql_query = ' WHERE name LIKE "%s"' % searchquery
+                result.radios = [
+                    item
+                    async for item in self.async_get_radios(sql_query, db_conn=db_conn)
+                ]
+            return result
+
+    async def async_get_library_artists(
+        self, provider_id: str = None, orderby: str = "name"
+    ) -> List[Artist]:
+        """Get all library artists, optionally filtered by provider."""
+        if provider_id is not None:
+            sql_query = f"""WHERE artist_id in (SELECT item_id FROM library_items WHERE
+                provider = "{provider_id}" AND media_type = {int(MediaType.Artist)})"""
+        else:
+            sql_query = f"""WHERE artist_id in
+                    (SELECT item_id FROM library_items
+                    WHERE media_type = {int(MediaType.Artist)})"""
+        async for item in self.async_get_artists(
+            sql_query, orderby=orderby, fulldata=True
+        ):
+            yield item
+
+    async def async_get_library_albums(
+        self, provider_id: str = None, orderby: str = "name"
+    ) -> List[Album]:
+        """Get all library albums, optionally filtered by provider."""
+        if provider_id is not None:
+            sql_query = f"""WHERE album_id in (SELECT item_id FROM library_items
+                WHERE provider = "{provider_id}" AND media_type = {int(MediaType.Album)})"""
+        else:
+            sql_query = f"""WHERE album_id in
+                (SELECT item_id FROM library_items WHERE media_type = {int(MediaType.Album)})"""
+        async for item in self.async_get_albums(
+            sql_query, orderby=orderby, fulldata=True
+        ):
+            yield item
+
+    async def async_get_library_tracks(
+        self, provider_id: str = None, orderby: str = "name"
+    ) -> List[Track]:
+        """Get all library tracks, optionally filtered by provider."""
+        if provider_id is not None:
+            sql_query = f"""WHERE track_id in
+                (SELECT item_id FROM library_items WHERE provider = "{provider_id}"
+                AND media_type = {int(MediaType.Track)})"""
+        else:
+            sql_query = f"""WHERE track_id in
+                (SELECT item_id FROM library_items WHERE media_type = {int(MediaType.Track)})"""
+        async for item in self.async_get_tracks(sql_query, orderby=orderby):
+            yield item
+
+    async def async_get_library_playlists(
+        self, provider_id: str = None, orderby: str = "name"
+    ) -> List[Playlist]:
+        """Fetch all playlist records from table."""
+        if provider_id is not None:
+            sql_query = f"""WHERE playlist_id in
+                (SELECT item_id FROM library_items WHERE provider = "{provider_id}"
+                AND media_type = {int(MediaType.Playlist)})"""
+        else:
+            sql_query = f"""WHERE playlist_id in
+                (SELECT item_id FROM library_items WHERE media_type = {int(MediaType.Playlist)})"""
+        async for item in self.async_get_playlists(sql_query, orderby=orderby):
+            yield item
+
+    async def async_get_library_radios(
+        self, provider_id: str = None, orderby: str = "name"
+    ) -> List[Radio]:
+        """Fetch all radio records from table."""
+        if provider_id is not None:
+            sql_query = f"""WHERE radio_id in
+                (SELECT item_id FROM library_items WHERE provider = "{provider_id}"
+                AND media_type = { int(MediaType.Radio)})"""
+        else:
+            sql_query = f"""WHERE radio_id in
+                (SELECT item_id FROM library_items WHERE media_type = {int(MediaType.Radio)})"""
+        async for item in self.async_get_radios(sql_query, orderby=orderby):
+            yield item
+
+    async def async_get_playlists(
+        self,
+        filter_query: str = None,
+        orderby: str = "name",
+        db_conn: sqlite3.Connection = None,
+    ) -> List[Playlist]:
+        """Get all playlists from database."""
+        async with DbConnect(self._dbfile, db_conn) as db_conn:
+            db_conn.row_factory = aiosqlite.Row
+            sql_query = "SELECT * FROM playlists"
+            if filter_query:
+                sql_query += " " + filter_query
+            sql_query += " ORDER BY %s" % orderby
+            async with db_conn.execute(sql_query) as cursor:
+                db_rows = await cursor.fetchall()
+            for db_row in db_rows:
+                playlist = Playlist(
+                    item_id=db_row["playlist_id"],
+                    provider="database",
+                    name=db_row["name"],
+                    metadata=await self.__async_get_metadata(
+                        db_row["playlist_id"], MediaType.Playlist, db_conn
+                    ),
+                    tags=await self.__async_get_tags(
+                        db_row["playlist_id"], int(MediaType.Playlist), db_conn
+                    ),
+                    external_ids=await self.__async_get_external_ids(
+                        db_row["playlist_id"], MediaType.Playlist, db_conn
+                    ),
+                    provider_ids=await self.__async_get_prov_ids(
+                        db_row["playlist_id"], MediaType.Playlist, db_conn
+                    ),
+                    in_library=await self.__async_get_library_providers(
+                        db_row["playlist_id"], MediaType.Playlist, db_conn
+                    ),
+                    is_lazy=False,
+                    available=True,
+                    owner=db_row["owner"],
+                    checksum=db_row["checksum"],
+                    is_editable=db_row["is_editable"],
+                )
+                yield playlist
+
+    async def async_get_playlist(self, playlist_id: int) -> Playlist:
+        """Get playlist record by id."""
+        playlist_id = try_parse_int(playlist_id)
+        async for item in self.async_get_playlists(
+            f"WHERE playlist_id = {playlist_id}"
+        ):
+            return item
+        return None
+
+    async def async_get_radios(
+        self,
+        filter_query: str = None,
+        orderby: str = "name",
+        db_conn: sqlite3.Connection = None,
+    ) -> List[Radio]:
+        """Fetch radio records from database."""
+        sql_query = "SELECT * FROM radios"
+        if filter_query:
+            sql_query += " " + filter_query
+        sql_query += " ORDER BY %s" % orderby
+        async with DbConnect(self._dbfile, db_conn) as db_conn:
+            db_conn.row_factory = aiosqlite.Row
+            async with db_conn.execute(sql_query) as cursor:
+                db_rows = await cursor.fetchall()
+            for db_row in db_rows:
+                radio = Radio(
+                    item_id=db_row["radio_id"],
+                    provider="database",
+                    name=db_row["name"],
+                    metadata=await self.__async_get_metadata(
+                        db_row["radio_id"], MediaType.Radio, db_conn
+                    ),
+                    tags=await self.__async_get_tags(
+                        db_row["radio_id"], MediaType.Radio, db_conn
+                    ),
+                    external_ids=await self.__async_get_external_ids(
+                        db_row["radio_id"], MediaType.Radio, db_conn
+                    ),
+                    provider_ids=await self.__async_get_prov_ids(
+                        db_row["radio_id"], MediaType.Radio, db_conn
+                    ),
+                    in_library=await self.__async_get_library_providers(
+                        db_row["radio_id"], MediaType.Radio, db_conn
+                    ),
+                    is_lazy=False,
+                    available=True,
+                )
+                yield radio
+
+    async def async_get_radio(self, radio_id: int) -> Playlist:
+        """Get radio record by id."""
+        radio_id = try_parse_int(radio_id)
+        async for item in self.async_get_radios(f"WHERE radio_id = {radio_id}"):
+            return item
+        return None
+
+    async def async_add_playlist(self, playlist: Playlist):
+        """Add a new playlist record to the database."""
+        assert playlist.name
+        async with DbConnect(self._dbfile) as db_conn:
+            async with db_conn.execute(
+                "SELECT (playlist_id) FROM playlists WHERE name=? AND owner=?;",
+                (playlist.name, playlist.owner),
+            ) as cursor:
+                result = await cursor.fetchone()
+            if result:
+                playlist_id = result[0]
+                # update existing
+                sql_query = "UPDATE playlists SET is_editable=?, checksum=? WHERE playlist_id=?;"
+                await db_conn.execute(
+                    sql_query, (playlist.is_editable, playlist.checksum, playlist_id)
+                )
+            else:
+                # insert playlist
+                sql_query = """INSERT INTO playlists (name, owner, is_editable, checksum)
+                VALUES(?,?,?,?);"""
+                async with db_conn.execute(
+                    sql_query,
+                    (
+                        playlist.name,
+                        playlist.owner,
+                        playlist.is_editable,
+                        playlist.checksum,
+                    ),
+                ) as cursor:
+                    last_row_id = cursor.lastrowid
+                # get id from newly created item
+                sql_query = "SELECT (playlist_id) FROM playlists WHERE ROWID=?"
+                async with db_conn.execute(sql_query, (last_row_id,)) as cursor:
+                    playlist_id = await cursor.fetchone()
+                    playlist_id = playlist_id[0]
+                LOGGER.debug(
+                    "added playlist %s to database: %s", playlist.name, playlist_id
+                )
+            # add/update metadata
+            await self.__async_add_prov_ids(
+                playlist_id, MediaType.Playlist, playlist.provider_ids, db_conn
+            )
+            await self.__async_add_metadata(
+                playlist_id, MediaType.Playlist, playlist.metadata, db_conn
+            )
+            # save
+            await db_conn.commit()
+        return playlist_id
+
+    async def async_add_radio(self, radio: Radio):
+        """Add a new radio record to the database."""
+        assert radio.name
+        async with DbConnect(self._dbfile) as db_conn:
+            async with db_conn.execute(
+                "SELECT (radio_id) FROM radios WHERE name=?;", (radio.name,)
+            ) as cursor:
+                result = await cursor.fetchone()
+            if result:
+                radio_id = result[0]
+            else:
+                # insert radio
+                sql_query = "INSERT INTO radios (name) VALUES(?);"
+                async with db_conn.execute(sql_query, (radio.name,)) as cursor:
+                    last_row_id = cursor.lastrowid
+                    # await db_conn.commit()
+                # get id from newly created item
+                sql_query = "SELECT (radio_id) FROM radios WHERE ROWID=?"
+                async with db_conn.execute(sql_query, (last_row_id,)) as cursor:
+                    radio_id = await cursor.fetchone()
+                    radio_id = radio_id[0]
+                LOGGER.debug(
+                    "added radio station %s to database: %s", radio.name, radio_id
+                )
+            # add/update metadata
+            await self.__async_add_prov_ids(
+                radio_id, MediaType.Radio, radio.provider_ids, db_conn
+            )
+            await self.__async_add_metadata(
+                radio_id, MediaType.Radio, radio.metadata, db_conn
+            )
+            # save
+            await db_conn.commit()
+        return radio_id
+
+    async def async_add_to_library(
+        self, item_id: int, media_type: MediaType, provider: str
+    ):
+        """Add an item to the library (item must already be present in the db!)."""
+        async with DbConnect(self._dbfile) as db_conn:
+            item_id = try_parse_int(item_id)
+            sql_query = """INSERT or REPLACE INTO library_items
+                (item_id, provider, media_type) VALUES(?,?,?);"""
+            await db_conn.execute(sql_query, (item_id, provider, int(media_type)))
+            await db_conn.commit()
+
+    async def async_remove_from_library(
+        self, item_id: int, media_type: MediaType, provider: str
+    ):
+        """Remove item from the library."""
+        async with DbConnect(self._dbfile) as db_conn:
+            item_id = try_parse_int(item_id)
+            sql_query = "DELETE FROM library_items WHERE item_id=? AND provider=? AND media_type=?;"
+            await db_conn.execute(sql_query, (item_id, provider, int(media_type)))
+            if media_type == MediaType.Playlist:
+                sql_query = "DELETE FROM playlists WHERE playlist_id=?;"
+                await db_conn.execute(sql_query, (item_id,))
+                sql_query = """DELETE FROM provider_mappings WHERE
+                    item_id=? AND media_type=? AND provider=?;"""
+                await db_conn.execute(sql_query, (item_id, int(media_type), provider))
+                await db_conn.commit()
+
+    async def async_get_artists(
+        self,
+        filter_query: str = None,
+        orderby: str = "name",
+        fulldata=False,
+        db_conn: sqlite3.Connection = None,
+    ) -> List[Artist]:
+        """Fetch artist records from database."""
+        async with DbConnect(self._dbfile, db_conn) as db_conn:
+            db_conn.row_factory = aiosqlite.Row
+            sql_query = "SELECT * FROM artists"
+            if filter_query:
+                sql_query += " " + filter_query
+            sql_query += " ORDER BY %s" % orderby
+            for db_row in await db_conn.execute_fetchall(sql_query):
+                artist = Artist(
+                    item_id=db_row["artist_id"],
+                    provider="database",
+                    name=db_row["name"],
+                    sort_name=db_row["sort_name"],
+                )
+                if fulldata:
+                    artist.provider_ids = await self.__async_get_prov_ids(
+                        db_row["artist_id"], MediaType.Artist, db_conn
+                    )
+                    artist.in_library = await self.__async_get_library_providers(
+                        db_row["artist_id"], MediaType.Artist, db_conn
+                    )
+                    artist.external_ids = await self.__async_get_external_ids(
+                        artist.item_id, MediaType.Artist, db_conn
+                    )
+                    artist.metadata = await self.__async_get_metadata(
+                        artist.item_id, MediaType.Artist, db_conn
+                    )
+                    artist.tags = await self.__async_get_tags(
+                        artist.item_id, MediaType.Artist, db_conn
+                    )
+                yield artist
+
+    async def async_get_artist(
+        self, artist_id: int, fulldata=True, db_conn: sqlite3.Connection = None
+    ) -> Artist:
+        """Get artist record by id."""
+        artist_id = try_parse_int(artist_id)
+        async for item in self.async_get_artists(
+            "WHERE artist_id = %d" % artist_id, fulldata=fulldata, db_conn=db_conn
+        ):
+            return item
+        return None
+
+    async def async_add_artist(self, artist: Artist) -> int:
+        """Add a new artist record to the database."""
+        artist_id = None
+        async with DbConnect(self._dbfile) as db_conn:
+            # always prefer to grab existing artist with external_id (=musicbrainz_id)
+            artist_id = await self.__async_get_item_by_external_id(artist, db_conn)
+            if not artist_id:
+                # insert artist
+                musicbrainz_id = artist.external_ids.get(ExternalId.MUSICBRAINZ)
+                assert musicbrainz_id  # musicbrainz id is required
+                if not artist.sort_name:
+                    artist.sort_name = get_sort_name(artist.name)
+                sql_query = "INSERT INTO artists (name, sort_name, musicbrainz_id) VALUES(?,?,?);"
+                async with db_conn.execute(
+                    sql_query, (artist.name, artist.sort_name, musicbrainz_id)
+                ) as cursor:
+                    last_row_id = cursor.lastrowid
+                await db_conn.commit()
+                # get id from (newly created) item
+                async with db_conn.execute(
+                    "SELECT artist_id FROM artists WHERE ROWID=?;", (last_row_id,)
+                ) as cursor:
+                    artist_id = await cursor.fetchone()
+                    artist_id = artist_id[0]
+            # always add metadata and tags etc. because we might have received
+            # additional info or a match from other provider
+            await self.__async_add_prov_ids(
+                artist_id, MediaType.Artist, artist.provider_ids, db_conn
+            )
+            await self.__async_add_metadata(
+                artist_id, MediaType.Artist, artist.metadata, db_conn
+            )
+            await self.__async_add_tags(
+                artist_id, MediaType.Artist, artist.tags, db_conn
+            )
+            await self.__async_add_external_ids(
+                artist_id, MediaType.Artist, artist.external_ids, db_conn
+            )
+            # save
+            await db_conn.commit()
+            LOGGER.debug(
+                "added artist %s (%s) to database: %s",
+                artist.name,
+                artist.provider_ids,
+                artist_id,
+            )
+        return artist_id
+
+    async def async_get_albums(
+        self,
+        filter_query: str = None,
+        orderby: str = "name",
+        fulldata=False,
+        db_conn: sqlite3.Connection = None,
+    ) -> List[Album]:
+        """Fetch all album records from the database."""
+        sql_query = "SELECT * FROM albums"
+        if filter_query:
+            sql_query += " " + filter_query
+        sql_query += " ORDER BY %s" % orderby
+        async with DbConnect(self._dbfile, db_conn) as db_conn:
+            db_conn.row_factory = aiosqlite.Row
+            for db_row in await db_conn.execute_fetchall(sql_query):
+                album = Album(
+                    item_id=db_row["album_id"],
+                    provider="database",
+                    name=db_row["name"],
+                    album_type=AlbumType(int(db_row["albumtype"])),
+                    year=db_row["year"],
+                    version=db_row["version"],
+                    artist=await self.async_get_artist(
+                        db_row["artist_id"], fulldata=fulldata, db_conn=db_conn
+                    ),
+                )
+                if fulldata:
+                    album.provider_ids = await self.__async_get_prov_ids(
+                        db_row["album_id"], MediaType.Album, db_conn
+                    )
+                    album.in_library = await self.__async_get_library_providers(
+                        db_row["album_id"], MediaType.Album, db_conn
+                    )
+                    album.external_ids = await self.__async_get_external_ids(
+                        album.item_id, MediaType.Album, db_conn
+                    )
+                    album.metadata = await self.__async_get_metadata(
+                        album.item_id, MediaType.Album, db_conn
+                    )
+                    album.tags = await self.__async_get_tags(
+                        album.item_id, MediaType.Album, db_conn
+                    )
+                    album.labels = await self.__async_get_album_labels(
+                        album.item_id, db_conn
+                    )
+                yield album
+
+    async def async_get_album(
+        self, album_id: int, fulldata=True, db_conn: sqlite3.Connection = None
+    ) -> Album:
+        """Get album record by id."""
+        album_id = try_parse_int(album_id)
+        async for item in self.async_get_albums(
+            "WHERE album_id = %d" % album_id, fulldata=fulldata, db_conn=db_conn
+        ):
+            return item
+        return None
+
+    async def async_add_album(self, album: Album) -> int:
+        """Add a new album record to the database."""
+        assert album.name and album.artist
+        assert album.artist.provider == "database"
+        album_id = None
+        async with DbConnect(self._dbfile) as db_conn:
+            db_conn.row_factory = aiosqlite.Row
+            # always try to grab existing album with external_id
+            album_id = await self.__async_get_item_by_external_id(album, db_conn)
+            # fallback to matching on artist_id, name and version
+            if not album_id:
+                sql_query = """SELECT album_id FROM albums WHERE
+                    artist_id=? AND name=? AND version=? AND year=? AND albumtype=?"""
+                async with db_conn.execute(
+                    sql_query,
+                    (
+                        album.artist.item_id,
+                        album.name,
+                        album.version,
+                        int(album.year),
+                        int(album.album_type),
+                    ),
+                ) as cursor:
+                    res = await cursor.fetchone()
+                    if res:
+                        album_id = res["album_id"]
+            # fallback to almost exact match
+            if not album_id:
+                sql_query = """SELECT album_id, year, version, albumtype FROM
+                    albums WHERE artist_id=? AND name=?"""
+                async with db_conn.execute(
+                    sql_query, (album.artist.item_id, album.name)
+                ) as cursor:
+                    albums = await cursor.fetchall()
+                for result in albums:
+                    if (not album.version and result["year"] == album.year) or (
+                        album.version and result["version"] == album.version
+                    ):
+                        album_id = result["album_id"]
+                        break
+            # no match: insert album
+            if not album_id:
+                sql_query = """INSERT INTO albums (artist_id, name, albumtype, year, version)
+                    VALUES(?,?,?,?,?);"""
+                query_params = (
+                    album.artist.item_id,
+                    album.name,
+                    int(album.album_type),
+                    album.year,
+                    album.version,
+                )
+                async with db_conn.execute(sql_query, query_params) as cursor:
+                    last_row_id = cursor.lastrowid
+                # get id from newly created item
+                sql_query = "SELECT (album_id) FROM albums WHERE ROWID=?"
+                async with db_conn.execute(sql_query, (last_row_id,)) as cursor:
+                    album_id = await cursor.fetchone()
+                    album_id = album_id[0]
+                await db_conn.commit()
+            # always add metadata and tags etc. because we might have received
+            # additional info or a match from other provider
+            await self.__async_add_prov_ids(
+                album_id, MediaType.Album, album.provider_ids, db_conn
+            )
+            await self.__async_add_metadata(
+                album_id, MediaType.Album, album.metadata, db_conn
+            )
+            await self.__async_add_tags(album_id, MediaType.Album, album.tags, db_conn)
+            await self.__async_add_album_labels(album_id, album.labels, db_conn)
+            await self.__async_add_external_ids(
+                album_id, MediaType.Album, album.external_ids, db_conn
+            )
+            # save
+            await db_conn.commit()
+            LOGGER.debug(
+                "added album %s (%s) to database: %s",
+                album.name,
+                album.provider_ids,
+                album_id,
+            )
+        return album_id
+
+    async def async_get_tracks(
+        self,
+        filter_query: str = None,
+        orderby: str = "name",
+        fulldata=False,
+        db_conn: sqlite3.Connection = None,
+    ) -> List[Track]:
+        """Return all track records from the database."""
+        async with DbConnect(self._dbfile, db_conn) as db_conn:
+            db_conn.row_factory = aiosqlite.Row
+            sql_query = "SELECT * FROM tracks"
+            if filter_query:
+                sql_query += " " + filter_query
+            sql_query += " ORDER BY %s" % orderby
+            for db_row in await db_conn.execute_fetchall(sql_query, ()):
+                track = Track(
+                    item_id=db_row["track_id"],
+                    provider="database",
+                    name=db_row["name"],
+                    external_ids=await self.__async_get_external_ids(
+                        db_row["track_id"], MediaType.Track, db_conn
+                    ),
+                    provider_ids=await self.__async_get_prov_ids(
+                        db_row["track_id"], MediaType.Track, db_conn
+                    ),
+                    in_library=await self.__async_get_library_providers(
+                        db_row["track_id"], MediaType.Track, db_conn
+                    ),
+                    duration=db_row["duration"],
+                    version=db_row["version"],
+                    album=await self.async_get_album(
+                        db_row["album_id"], fulldata=fulldata, db_conn=db_conn
+                    ),
+                    artists=await self.__async_get_track_artists(
+                        db_row["track_id"], db_conn=db_conn, fulldata=fulldata
+                    ),
+                )
+                if fulldata:
+                    track.metadata = await self.__async_get_metadata(
+                        db_row["track_id"], MediaType.Track, db_conn
+                    )
+                    track.tags = await self.__async_get_tags(
+                        db_row["track_id"], MediaType.Track, db_conn
+                    )
+                yield track
+
+    async def async_get_track(
+        self, track_id: int, fulldata=True, db_conn: sqlite3.Connection = None
+    ) -> Track:
+        """Get track record by id."""
+        track_id = try_parse_int(track_id)
+        async for item in self.async_get_tracks(
+            "WHERE track_id = %d" % track_id, fulldata=fulldata, db_conn=db_conn
+        ):
+            return item
+        return None
+
+    async def async_add_track(self, track: Track) -> int:
+        """Add a new track record to the database."""
+        assert track.name and track.album
+        assert track.album.provider == "database"
+        assert track.artists
+        for artist in track.artists:
+            assert artist.provider == "database"
+        async with DbConnect(self._dbfile) as db_conn:
+            db_conn.row_factory = aiosqlite.Row
+            # always try to grab existing track with external_id
+            track_id = await self.__async_get_item_by_external_id(track, db_conn)
+            # fallback to matching on album_id, name and version
+            if not track_id:
+                sql_query = "SELECT track_id, duration, version \
+                    FROM tracks WHERE album_id=? AND name=?"
+                async with db_conn.execute(
+                    sql_query, (track.album.item_id, track.name)
+                ) as cursor:
+                    results = await cursor.fetchall()
+                for result in results:
+                    # we perform an additional safety check on the duration or version
+                    if (
+                        track.version
+                        and compare_strings(result["version"], track.version)
+                    ) or (
+                        (
+                            not track.version
+                            and not result["version"]
+                            and abs(result["duration"] - track.duration) < 10
+                        )
+                    ):
+                        track_id = result["track_id"]
+                        break
+            # no match found: insert track
+            if not track_id:
+                assert track.name and track.album.item_id
+                sql_query = "INSERT INTO tracks (name, album_id, duration, version) \
+                        VALUES(?,?,?,?);"
+                query_params = (
+                    track.name,
+                    track.album.item_id,
+                    track.duration,
+                    track.version,
+                )
+                async with db_conn.execute(sql_query, query_params) as cursor:
+                    last_row_id = cursor.lastrowid
+                await db_conn.commit()
+                # get id from newly created item (the safe way)
+                async with db_conn.execute(
+                    "SELECT track_id FROM tracks WHERE ROWID=?", (last_row_id,)
+                ) as cursor:
+                    track_id = await cursor.fetchone()
+                    track_id = track_id[0]
+            # always add metadata and tags etc. because we might have received
+            # additional info or a match from other provider
+            for artist in track.artists:
+                sql_query = "INSERT or IGNORE INTO track_artists (track_id, artist_id) VALUES(?,?);"
+                await db_conn.execute(sql_query, (track_id, artist.item_id))
+            await self.__async_add_prov_ids(
+                track_id, MediaType.Track, track.provider_ids, db_conn
+            )
+            await self.__async_add_metadata(
+                track_id, MediaType.Track, track.metadata, db_conn
+            )
+            await self.__async_add_tags(track_id, MediaType.Track, track.tags, db_conn)
+            await self.__async_add_external_ids(
+                track_id, MediaType.Track, track.external_ids, db_conn
+            )
+            # save to db
+            await db_conn.commit()
+            LOGGER.debug(
+                "added track %s (%s) to database: %s",
+                track.name,
+                track.provider_ids,
+                track_id,
+            )
+        return track_id
+
+    async def async_update_playlist(
+        self, playlist_id: int, column_key: str, column_value: str
+    ):
+        """Update column of existing playlist."""
+        async with DbConnect(self._dbfile) as db_conn:
+            sql_query = f"UPDATE playlists SET {column_key}=? WHERE playlist_id=?;"
+            await db_conn.execute(sql_query, (column_value, playlist_id))
+            await db_conn.commit()
+
+    async def async_get_artist_tracks(
+        self, artist_id: int, orderby: str = "name"
+    ) -> List[Track]:
+        """Get all library tracks for the given artist."""
+        artist_id = try_parse_int(artist_id)
+        sql_query = f"""WHERE track_id in
+            (SELECT track_id FROM track_artists WHERE artist_id = {artist_id})"""
+        async for item in self.async_get_tracks(
+            sql_query, orderby=orderby, fulldata=False
+        ):
+            yield item
+
+    async def async_get_artist_albums(
+        self, artist_id: int, orderby: str = "name"
+    ) -> List[Album]:
+        """Get all library albums for the given artist."""
+        sql_query = " WHERE artist_id = %s" % artist_id
+        async for item in self.async_get_albums(
+            sql_query, orderby=orderby, fulldata=False
+        ):
+            yield item
+
+    async def async_set_track_loudness(
+        self, provider_track_id: str, provider: str, loudness: int
+    ):
+        """Set integrated loudness for a track in db."""
+        async with DbConnect(self._dbfile) as db_conn:
+            sql_query = """INSERT or REPLACE INTO track_loudness
+                (provider_track_id, provider, loudness) VALUES(?,?,?);"""
+            await db_conn.execute(sql_query, (provider_track_id, provider, loudness))
+            await db_conn.commit()
+
+    async def async_get_track_loudness(self, provider_track_id, provider):
+        """Get integrated loudness for a track in db."""
+        async with DbConnect(self._dbfile) as db_conn:
+            sql_query = """SELECT loudness FROM track_loudness WHERE
+                provider_track_id = ? AND provider = ?"""
+            async with db_conn.execute(
+                sql_query, (provider_track_id, provider)
+            ) as cursor:
+                result = await cursor.fetchone()
+            if result:
+                return result[0]
+        return None
+
+    async def __async_add_metadata(
+        self,
+        item_id: int,
+        media_type: MediaType,
+        metadata: dict,
+        db_conn: sqlite3.Connection,
+    ):
+        """Add or update metadata."""
+        for key, value in metadata.items():
+            if value:
+                sql_query = """INSERT or REPLACE INTO metadata
+                    (item_id, media_type, key, value) VALUES(?,?,?,?);"""
+                await db_conn.execute(sql_query, (item_id, int(media_type), key, value))
+
+    async def __async_get_metadata(
+        self,
+        item_id: int,
+        media_type: MediaType,
+        db_conn: sqlite3.Connection,
+        filter_key: str = None,
+    ) -> dict:
+        """Get metadata for media item."""
+        metadata = {}
+        sql_query = (
+            "SELECT key, value FROM metadata WHERE item_id = ? AND media_type = ?"
+        )
+        if filter_key:
+            sql_query += ' AND key = "%s"' % filter_key
+        async with db_conn.execute(sql_query, (item_id, int(media_type))) as cursor:
+            db_rows = await cursor.fetchall()
+        for db_row in db_rows:
+            key = db_row[0]
+            value = db_row[1]
+            metadata[key] = value
+        return metadata
+
+    async def __async_add_tags(
+        self,
+        item_id: int,
+        media_type: MediaType,
+        tags: List[str],
+        db_conn: sqlite3.Connection,
+    ):
+        """Add tags to db."""
+        for tag in tags:
+            sql_query = "INSERT or IGNORE INTO tags (name) VALUES(?);"
+            async with db_conn.execute(sql_query, (tag,)) as cursor:
+                tag_id = cursor.lastrowid
+            sql_query = """INSERT or IGNORE INTO media_tags
+                (item_id, media_type, tag_id) VALUES(?,?,?);"""
+            await db_conn.execute(sql_query, (item_id, int(media_type), tag_id))
+
+    async def __async_get_tags(
+        self, item_id: int, media_type: MediaType, db_conn: sqlite3.Connection
+    ) -> List[str]:
+        """Get tags for media item."""
+        tags = []
+        sql_query = """SELECT name FROM tags INNER JOIN media_tags ON
+            tags.tag_id = media_tags.tag_id WHERE item_id = ? AND media_type = ?"""
+        async with db_conn.execute(sql_query, (item_id, int(media_type))) as cursor:
+            db_rows = await cursor.fetchall()
+        for db_row in db_rows:
+            tags.append(db_row[0])
+        return tags
+
+    async def __async_add_album_labels(
+        self, album_id: int, labels: List[str], db_conn: sqlite3.Connection
+    ):
+        """Add labels to album in db."""
+        for label in labels:
+            sql_query = "INSERT or IGNORE INTO labels (name) VALUES(?);"
+            async with db_conn.execute(sql_query, (label,)) as cursor:
+                label_id = cursor.lastrowid
+            sql_query = (
+                "INSERT or IGNORE INTO album_labels (album_id, label_id) VALUES(?,?);"
+            )
+            await db_conn.execute(sql_query, (album_id, label_id))
+
+    async def __async_get_album_labels(
+        self, album_id: int, db_conn: sqlite3.Connection
+    ) -> List[str]:
+        """Get labels for album item."""
+        labels = []
+        sql_query = """SELECT name FROM labels INNER JOIN album_labels
+            ON labels.label_id = album_labels.label_id WHERE album_id = ?"""
+        async with db_conn.execute(sql_query, (album_id,)) as cursor:
+            db_rows = await cursor.fetchall()
+        for db_row in db_rows:
+            labels.append(db_row[0])
+        return labels
+
+    async def __async_get_track_artists(
+        self, track_id: int, db_conn: sqlite3.Connection, fulldata: bool = False
+    ) -> List[Artist]:
+        """Get artists for track."""
+        sql_query = (
+            "WHERE artist_id in (SELECT artist_id FROM track_artists WHERE track_id = %s)"
+            % track_id
+        )
+        return [
+            item
+            async for item in self.async_get_artists(
+                sql_query, fulldata=fulldata, db_conn=db_conn
+            )
+        ]
+
+    async def __async_add_external_ids(
+        self,
+        item_id: int,
+        media_type: MediaType,
+        external_ids: dict,
+        db_conn: sqlite3.Connection,
+    ):
+        """Add or update external_ids."""
+        for key, value in external_ids.items():
+            sql_query = """INSERT or REPLACE INTO external_ids
+                (item_id, media_type, key, value) VALUES(?,?,?,?);"""
+            await db_conn.execute(
+                sql_query, (item_id, int(media_type), str(key), value)
+            )
+
+    async def __async_get_external_ids(
+        self, item_id: int, media_type: MediaType, db_conn: sqlite3.Connection
+    ) -> dict:
+        """Get external_ids for media item."""
+        external_ids = {}
+        sql_query = (
+            "SELECT key, value FROM external_ids WHERE item_id = ? AND media_type = ?"
+        )
+        for db_row in await db_conn.execute_fetchall(
+            sql_query, (item_id, int(media_type))
+        ):
+            external_ids[db_row[0]] = db_row[1]
+        return external_ids
+
+    async def __async_add_prov_ids(
+        self,
+        item_id: int,
+        media_type: MediaType,
+        provider_ids: List[MediaItemProviderId],
+        db_conn: sqlite3.Connection,
+    ):
+        """Add provider ids for media item to db_conn."""
+
+        for prov in provider_ids:
+            sql_query = """INSERT OR REPLACE INTO provider_mappings
+                (item_id, media_type, prov_item_id, provider, quality, details)
+                VALUES(?,?,?,?,?,?);"""
+            await db_conn.execute(
+                sql_query,
+                (
+                    item_id,
+                    int(media_type),
+                    prov.item_id,
+                    prov.provider,
+                    int(prov.quality),
+                    prov.details,
+                ),
+            )
+
+    async def __async_get_prov_ids(
+        self, item_id: int, media_type: MediaType, db_conn: sqlite3.Connection
+    ) -> List[MediaItemProviderId]:
+        """Get all provider id's for media item."""
+        provider_ids = []
+        sql_query = "SELECT prov_item_id, provider, quality, details \
+            FROM provider_mappings \
+            WHERE item_id = ? AND media_type = ?"
+        for db_row in await db_conn.execute_fetchall(
+            sql_query, (item_id, int(media_type))
+        ):
+            prov_mapping = MediaItemProviderId(
+                provider=db_row["provider"],
+                item_id=db_row["prov_item_id"],
+                quality=TrackQuality(db_row["quality"]),
+                details=db_row["details"],
+            )
+            provider_ids.append(prov_mapping)
+        return provider_ids
+
+    async def __async_get_library_providers(
+        self, db_item_id: int, media_type: MediaType, db_conn: sqlite3.Connection
+    ) -> List[str]:
+        """Get the providers that have this media_item added to the library."""
+        providers = []
+        sql_query = (
+            "SELECT provider FROM library_items WHERE item_id = ? AND media_type = ?"
+        )
+        for db_row in await db_conn.execute_fetchall(
+            sql_query, (db_item_id, int(media_type))
+        ):
+            providers.append(db_row[0])
+        return providers
+
+    async def __async_get_item_by_external_id(
+        self, media_item: MediaItem, db_conn: sqlite3.Connection
+    ) -> int:
+        """Try to get existing item in db by matching the new item's external id's."""
+        for key, value in media_item.external_ids.items():
+            sql_query = "SELECT (item_id) FROM external_ids \
+                    WHERE media_type=? AND key=? AND value=?;"
+            for db_row in await db_conn.execute_fetchall(
+                sql_query, (int(media_item.media_type), str(key), value)
+            ):
+                if db_row:
+                    return db_row[0]
+        return None
diff --git a/music_assistant/managers/metadata.py b/music_assistant/managers/metadata.py
new file mode 100755 (executable)
index 0000000..7df6364
--- /dev/null
@@ -0,0 +1,43 @@
+"""All logic for metadata retrieval."""
+
+import logging
+from typing import Dict, List
+
+from music_assistant.helpers.cache import async_cached
+from music_assistant.helpers.typing import MusicAssistantType
+from music_assistant.helpers.util import merge_dict
+from music_assistant.models.provider import MetadataProvider, ProviderType
+
+LOGGER = logging.getLogger("mass")
+
+
+class MetaDataManager:
+    """Several helpers to search and store metadata for mediaitems using metadata providers."""
+
+    # TODO: create periodic task to search for missing metadata
+    def __init__(self, mass: MusicAssistantType) -> None:
+        """Initialize class."""
+        self.mass = mass
+        self.cache = mass.cache
+
+    @property
+    def providers(self) -> List[MetadataProvider]:
+        """Return all providers of type MetadataProvider."""
+        return self.mass.get_providers(ProviderType.METADATA_PROVIDER)
+
+    async def async_get_artist_metadata(
+        self, mb_artist_id: str, cur_metadata: Dict
+    ) -> Dict:
+        """Get/update rich metadata for an artist by providing the musicbrainz artist id."""
+        metadata = cur_metadata
+        for provider in self.providers:
+            if "fanart" in metadata:
+                # no need to query (other) metadata providers if we already have a result
+                break
+            cache_key = f"{provider.id}.artist_metadata.{mb_artist_id}"
+            res = await async_cached(
+                self.cache, cache_key, provider.async_get_artist_images(mb_artist_id)
+            )
+            if res:
+                merge_dict(metadata, res)
+        return metadata
diff --git a/music_assistant/managers/music.py b/music_assistant/managers/music.py
new file mode 100755 (executable)
index 0000000..1621e73
--- /dev/null
@@ -0,0 +1,1305 @@
+"""MusicManager: Orchestrates all data from music providers and sync to internal database."""
+# pylint: disable=too-many-lines
+import asyncio
+import base64
+import functools
+import logging
+import os
+import time
+from typing import Any, List, Optional
+
+import aiohttp
+from music_assistant.constants import EVENT_MUSIC_SYNC_STATUS, EVENT_PROVIDER_REGISTERED
+from music_assistant.helpers.cache import async_cached, async_cached_generator
+from music_assistant.helpers.musicbrainz import MusicBrainz
+from music_assistant.helpers.util import (
+    callback,
+    compare_strings,
+    encrypt_string,
+    run_periodic,
+)
+from music_assistant.models.media_types import (
+    Album,
+    Artist,
+    ExternalId,
+    MediaItem,
+    MediaType,
+    Playlist,
+    Radio,
+    SearchResult,
+    Track,
+)
+from music_assistant.models.provider import MusicProvider, ProviderType
+from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
+from PIL import Image
+
+LOGGER = logging.getLogger("mass")
+
+
+def sync_task(desc):
+    """Return decorator to report a sync task."""
+
+    def wrapper(func):
+        @functools.wraps(func)
+        async def async_wrapped(*args):
+            method_class = args[0]
+            prov_id = args[1]
+            # check if this sync task is not already running
+            for sync_prov_id, sync_desc in method_class.running_sync_jobs:
+                if sync_prov_id == prov_id and sync_desc == desc:
+                    LOGGER.debug(
+                        "Syncjob %s for provider %s is already running!", desc, prov_id
+                    )
+                    return
+            LOGGER.debug("Start syncjob %s for provider %s.", desc, prov_id)
+            sync_job = (prov_id, desc)
+            method_class.running_sync_jobs.append(sync_job)
+            method_class.mass.signal_event(
+                EVENT_MUSIC_SYNC_STATUS, method_class.running_sync_jobs
+            )
+            await func(*args)
+            LOGGER.info("Finished syncing %s for provider %s", desc, prov_id)
+            method_class.running_sync_jobs.remove(sync_job)
+            method_class.mass.signal_event(
+                EVENT_MUSIC_SYNC_STATUS, method_class.running_sync_jobs
+            )
+
+        return async_wrapped
+
+    return wrapper
+
+
+class MusicManager:
+    """Several helpers around the musicproviders."""
+
+    def __init__(self, mass):
+        """Initialize class."""
+        self.running_sync_jobs = []
+        self.mass = mass
+        self.cache = mass.cache
+        self.musicbrainz = MusicBrainz(mass)
+        self._match_jobs = []
+        self.mass.add_event_listener(self.mass_event, [EVENT_PROVIDER_REGISTERED])
+
+    async def async_setup(self):
+        """Async initialize of module."""
+        # schedule sync task
+        self.mass.add_job(self.__async_music_providers_sync())
+
+    @property
+    def providers(self) -> List[MusicProvider]:
+        """Return all providers of type musicprovider."""
+        return self.mass.get_providers(ProviderType.MUSIC_PROVIDER)
+
+    @callback
+    def mass_event(self, msg: str, msg_details: Any):
+        """Handle message on eventbus."""
+        if msg == EVENT_PROVIDER_REGISTERED:
+            # schedule a sync task when a new provider registers
+            provider = self.mass.get_provider(msg_details)
+            if provider.type == ProviderType.MUSIC_PROVIDER:
+                self.mass.add_job(self.async_music_provider_sync(msg_details))
+
+    ################ GET MediaItem(s) by id and provider #################
+
+    async def async_get_item(
+        self, item_id: str, provider_id: str, media_type: MediaType, lazy: bool = True
+    ):
+        """Get single music item by id and media type."""
+        if media_type == MediaType.Artist:
+            return await self.async_get_artist(item_id, provider_id, lazy)
+        if media_type == MediaType.Album:
+            return await self.async_get_album(item_id, provider_id, lazy)
+        if media_type == MediaType.Track:
+            return await self.async_get_track(item_id, provider_id, lazy)
+        if media_type == MediaType.Playlist:
+            return await self.async_get_playlist(item_id, provider_id)
+        if media_type == MediaType.Radio:
+            return await self.async_get_radio(item_id, provider_id)
+        return None
+
+    async def async_get_artist(
+        self, item_id: str, provider_id: str, lazy: bool = True
+    ) -> Artist:
+        """Return artist details for the given provider artist id."""
+        assert item_id and provider_id
+        db_id = await self.mass.database.async_get_database_id(
+            provider_id, item_id, MediaType.Artist
+        )
+        if db_id is None:
+            # artist not yet in local database so fetch details
+            provider = self.mass.get_provider(provider_id)
+            if not provider.available:
+                return None
+            cache_key = f"{provider_id}.get_artist.{item_id}"
+            artist = await async_cached(
+                self.cache, cache_key, provider.async_get_artist(item_id)
+            )
+            if not artist:
+                raise Exception(
+                    "Artist %s not found on provider %s" % (item_id, provider_id)
+                )
+            if lazy:
+                self.mass.add_job(self.__async_add_artist(artist))
+                artist.is_lazy = True
+                return artist
+            db_id = await self.__async_add_artist(artist)
+        return await self.mass.database.async_get_artist(db_id)
+
+    async def async_get_album(
+        self,
+        item_id: str,
+        provider_id: str,
+        lazy=True,
+        album_details: Optional[Album] = None,
+    ) -> Album:
+        """Return album details for the given provider album id."""
+        assert item_id and provider_id
+        db_id = await self.mass.database.async_get_database_id(
+            provider_id, item_id, MediaType.Album
+        )
+        if db_id is None:
+            # album not yet in local database so fetch details
+            if not album_details:
+                provider = self.mass.get_provider(provider_id)
+                if not provider.available:
+                    return None
+                cache_key = f"{provider_id}.get_album.{item_id}"
+                album_details = await async_cached(
+                    self.cache, cache_key, provider.async_get_album(item_id)
+                )
+            if not album_details:
+                raise Exception(
+                    "Album %s not found on provider %s" % (item_id, provider_id)
+                )
+            if lazy:
+                self.mass.add_job(self.__async_add_album(album_details))
+                album_details.is_lazy = True
+                return album_details
+            db_id = await self.__async_add_album(album_details)
+        return await self.mass.database.async_get_album(db_id)
+
+    async def async_get_track(
+        self,
+        item_id: str,
+        provider_id: str,
+        lazy: bool = True,
+        track_details: Track = None,
+        refresh: bool = False,
+    ) -> Track:
+        """Return track details for the given provider track id."""
+        assert item_id and provider_id
+        db_id = await self.mass.database.async_get_database_id(
+            provider_id, item_id, MediaType.Track
+        )
+        if db_id and refresh:
+            # in some cases (e.g. at playback time or requesting full track info)
+            # it's useful to have the track refreshed from the provider instead of
+            # the database cache to make sure that the track is available and perhaps
+            # another or a higher quality version is available.
+            if lazy:
+                self.mass.add_job(self.__async_match_track(db_id))
+            else:
+                await self.__async_match_track(db_id)
+        if not db_id:
+            # track not yet in local database so fetch details
+            if not track_details:
+                provider = self.mass.get_provider(provider_id)
+                if not provider.available:
+                    return None
+                cache_key = f"{provider_id}.get_track.{item_id}"
+                track_details = await async_cached(
+                    self.cache, cache_key, provider.async_get_track(item_id)
+                )
+            if not track_details:
+                raise Exception(
+                    "Track %s not found on provider %s" % (item_id, provider_id)
+                )
+            if lazy:
+                self.mass.add_job(self.__async_add_track(track_details))
+                track_details.is_lazy = True
+                return track_details
+            db_id = await self.__async_add_track(track_details)
+        return await self.mass.database.async_get_track(db_id, fulldata=True)
+
+    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_id = await self.mass.database.async_get_database_id(
+            provider_id, item_id, MediaType.Playlist
+        )
+        if db_id is None:
+            # item not yet in local database so fetch and store details
+            provider = self.mass.get_provider(provider_id)
+            if not provider.available:
+                return None
+            item_details = await provider.async_get_playlist(item_id)
+            db_id = await self.mass.database.async_add_playlist(item_details)
+        return await self.mass.database.async_get_playlist(db_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_id = await self.mass.database.async_get_database_id(
+            provider_id, item_id, MediaType.Radio
+        )
+        if db_id is None:
+            # item not yet in local database so fetch and store details
+            provider = self.mass.get_provider(provider_id)
+            if not provider.available:
+                return None
+            item_details = await provider.async_get_radio(item_id)
+            db_id = await self.mass.database.async_add_radio(item_details)
+        return await self.mass.database.async_get_radio(db_id)
+
+    async def async_get_album_tracks(
+        self, item_id: str, provider_id: str
+    ) -> List[Track]:
+        """Return album tracks for the given provider album id. Generator."""
+        assert item_id and provider_id
+        album = await self.async_get_album(item_id, provider_id)
+        if album.provider == "database":
+            # album tracks are not stored in db, we always fetch them (cached) from the provider.
+            provider_id = album.provider_ids[0].provider
+            item_id = album.provider_ids[0].item_id
+        provider = self.mass.get_provider(provider_id)
+        cache_key = f"{provider_id}.album_tracks.{item_id}"
+        async with self.mass.database.db_conn() as db_conn:
+            async for item in async_cached_generator(
+                self.cache, cache_key, provider.async_get_album_tracks(item_id)
+            ):
+                if not item:
+                    continue
+                db_id = await self.mass.database.async_get_database_id(
+                    item.provider, item.item_id, MediaType.Track, db_conn
+                )
+                if db_id:
+                    # return database track instead if we have a match
+                    track = await self.mass.database.async_get_track(
+                        db_id, fulldata=False, db_conn=db_conn
+                    )
+                    track.disc_number = item.disc_number
+                    track.track_number = item.track_number
+                else:
+                    track = item
+                if not track.album:
+                    track.album = album
+                yield track
+
+    async def async_get_album_versions(
+        self, item_id: str, provider_id: str
+    ) -> List[Album]:
+        """Return all versions of an album we can find on all providers. Generator."""
+        album = await self.async_get_album(item_id, provider_id)
+        provider_ids = [
+            item.id for item in self.mass.get_providers(ProviderType.MUSIC_PROVIDER)
+        ]
+        search_query = f"{album.artist.name} - {album.name}"
+        for prov_id in provider_ids:
+            provider_result = await self.async_search_provider(
+                search_query, prov_id, [MediaType.Album], 25
+            )
+            for item in provider_result.albums:
+                if compare_strings(item.artist.name, album.artist.name):
+                    yield item
+
+    async def async_get_track_versions(
+        self, item_id: str, provider_id: str
+    ) -> List[Track]:
+        """Return all versions of a track we can find on all providers. Generator."""
+        track = await self.async_get_track(item_id, provider_id)
+        provider_ids = [
+            item.id for item in self.mass.get_providers(ProviderType.MUSIC_PROVIDER)
+        ]
+        search_query = f"{track.artists[0].name} - {track.name}"
+        for prov_id in provider_ids:
+            provider_result = await self.async_search_provider(
+                search_query, prov_id, [MediaType.Track], 25
+            )
+            for item in provider_result.tracks:
+                if not compare_strings(item.name, track.name):
+                    continue
+                for artist in item.artists:
+                    # artist must match
+                    if compare_strings(artist.name, track.artists[0].name):
+                        yield item
+                        break
+
+    async def async_get_playlist_tracks(
+        self, item_id: str, provider_id: str
+    ) -> List[Track]:
+        """Return playlist tracks for the given provider playlist id. Generator."""
+        assert item_id and provider_id
+        if provider_id == "database":
+            # playlist tracks are not stored in db, we always fetch them (cached) from the provider.
+            db_item = await self.mass.database.async_get_playlist(item_id)
+            provider_id = db_item.provider_ids[0].provider
+            item_id = db_item.provider_ids[0].item_id
+        provider = self.mass.get_provider(provider_id)
+        playlist = await provider.async_get_playlist(item_id)
+        cache_checksum = playlist.checksum
+        cache_key = f"{provider_id}.playlist_tracks.{item_id}"
+        pos = 0
+        async with self.mass.database.db_conn() as db_conn:
+            async for item in async_cached_generator(
+                self.cache,
+                cache_key,
+                provider.async_get_playlist_tracks(item_id),
+                checksum=cache_checksum,
+            ):
+                if not item:
+                    continue
+                assert item.item_id and item.provider
+                db_id = await self.mass.database.async_get_database_id(
+                    item.provider, item.item_id, MediaType.Track, db_conn=db_conn
+                )
+                if db_id:
+                    # return database track instead if we have a match
+                    item = await self.mass.database.async_get_track(
+                        db_id, fulldata=False, db_conn=db_conn
+                    )
+                item.position = pos
+                pos += 1
+                yield item
+
+    async def async_get_artist_toptracks(
+        self, artist_id: str, provider_id: str
+    ) -> List[Track]:
+        """Return top tracks for an artist. Generator."""
+        async with self.mass.database.db_conn() as db_conn:
+            if provider_id == "database":
+                # tracks from all providers
+                item_ids = []
+                artist = await self.mass.database.async_get_artist(
+                    artist_id, True, db_conn=db_conn
+                )
+                for prov_id in artist.provider_ids:
+                    provider = self.mass.get_provider(prov_id.provider)
+                    if (
+                        not provider
+                        or MediaType.Track not in provider.supported_mediatypes
+                    ):
+                        continue
+                    async for item in self.async_get_artist_toptracks(
+                        prov_id.item_id, prov_id.provider
+                    ):
+                        if item.item_id not in item_ids:
+                            yield item
+                            item_ids.append(item.item_id)
+            else:
+                # items from provider
+                provider = self.mass.get_provider(provider_id)
+                cache_key = f"{provider_id}.artist_toptracks.{artist_id}"
+                async for item in async_cached_generator(
+                    self.cache,
+                    cache_key,
+                    provider.async_get_artist_toptracks(artist_id),
+                ):
+                    if item:
+                        assert item.item_id and item.provider
+                        db_id = await self.mass.database.async_get_database_id(
+                            item.provider,
+                            item.item_id,
+                            MediaType.Track,
+                            db_conn=db_conn,
+                        )
+                        if db_id:
+                            # return database track instead if we have a match
+                            yield await self.mass.database.async_get_track(
+                                db_id, fulldata=False, db_conn=db_conn
+                            )
+                        else:
+                            yield item
+
+    async def async_get_artist_albums(
+        self, artist_id: str, provider_id: str
+    ) -> List[Album]:
+        """Return (all) albums for an artist. Generator."""
+        async with self.mass.database.db_conn() as db_conn:
+            if provider_id == "database":
+                # albums from all providers
+                item_ids = []
+                artist = await self.mass.database.async_get_artist(
+                    artist_id, True, db_conn=db_conn
+                )
+                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
+                    async for item in self.async_get_artist_albums(
+                        prov_id.item_id, prov_id.provider
+                    ):
+                        if item.item_id not in item_ids:
+                            yield item
+                            item_ids.append(item.item_id)
+            else:
+                # items from provider
+                provider = self.mass.get_provider(provider_id)
+                cache_key = f"{provider_id}.artist_albums.{artist_id}"
+                async for item in async_cached_generator(
+                    self.cache, cache_key, provider.async_get_artist_albums(artist_id)
+                ):
+                    assert item.item_id and item.provider
+                    db_id = await self.mass.database.async_get_database_id(
+                        item.provider, item.item_id, MediaType.Album, db_conn=db_conn
+                    )
+                    if db_id:
+                        # return database album instead if we have a match
+                        yield await self.mass.database.async_get_album(
+                            db_id, db_conn=db_conn
+                        )
+                    else:
+                        yield item
+
+    ################ GET MediaItems that are added in the library ################
+
+    async def async_get_library_artists(
+        self, orderby: str = "name", provider_filter: str = None
+    ) -> List[Artist]:
+        """Return all library artists, optionally filtered by provider. Generator."""
+        async for item in self.mass.database.async_get_library_artists(
+            provider_id=provider_filter, orderby=orderby
+        ):
+            yield item
+
+    async def async_get_library_albums(
+        self, orderby: str = "name", provider_filter: str = None
+    ) -> List[Album]:
+        """Return all library albums, optionally filtered by provider. Generator."""
+        async for item in self.mass.database.async_get_library_albums(
+            provider_id=provider_filter, orderby=orderby
+        ):
+            yield item
+
+    async def async_get_library_tracks(
+        self, orderby: str = "name", provider_filter: str = None
+    ) -> List[Track]:
+        """Return all library tracks, optionally filtered by provider. Generator."""
+        async for item in self.mass.database.async_get_library_tracks(
+            provider_id=provider_filter, orderby=orderby
+        ):
+            yield item
+
+    async def async_get_library_playlists(
+        self, orderby: str = "name", provider_filter: str = None
+    ) -> List[Playlist]:
+        """Return all library playlists, optionally filtered by provider. Generator."""
+        async for item in self.mass.database.async_get_library_playlists(
+            provider_id=provider_filter, orderby=orderby
+        ):
+            yield item
+
+    async def async_get_library_radios(
+        self, orderby: str = "name", provider_filter: str = None
+    ) -> List[Playlist]:
+        """Return all library radios, optionally filtered by provider. Generator."""
+        async for item in self.mass.database.async_get_library_radios(
+            provider_id=provider_filter, orderby=orderby
+        ):
+            yield item
+
+    ################ ADD MediaItem(s) to database helpers ################
+
+    async def __async_add_artist(self, artist: Artist) -> int:
+        """Add artist to local db and return the new database id."""
+        musicbrainz_id = artist.external_ids.get(ExternalId.MUSICBRAINZ)
+        if not musicbrainz_id:
+            musicbrainz_id = await self.__async_get_artist_musicbrainz_id(artist)
+        # grab additional metadata
+        artist.external_ids[ExternalId.MUSICBRAINZ] = musicbrainz_id
+        artist.metadata = await self.mass.metadata.async_get_artist_metadata(
+            musicbrainz_id, artist.metadata
+        )
+        db_id = await self.mass.database.async_add_artist(artist)
+        # also fetch same artist on all providers
+        await self.__async_match_artist(db_id)
+        return db_id
+
+    async def __async_add_album(self, album: Album) -> int:
+        """Add album to local db and return the new database id."""
+        # we need to fetch album artist too
+        album.artist = await self.async_get_artist(
+            album.artist.item_id, album.artist.provider, lazy=False
+        )
+        db_id = await self.mass.database.async_add_album(album)
+        # also fetch same album on all providers
+        await self.__async_match_album(db_id)
+        return db_id
+
+    async def __async_add_track(
+        self, track: Track, album_id: Optional[str] = None
+    ) -> int:
+        """Add track to local db and return the new database id."""
+        track_artists = []
+        # we need to fetch track artists too
+        for track_artist in track.artists:
+            db_track_artist = await self.async_get_artist(
+                track_artist.item_id, track_artist.provider, lazy=False
+            )
+            if db_track_artist:
+                track_artists.append(db_track_artist)
+        track.artists = track_artists
+        # fetch album details - prefer optional provided album_id
+        if album_id:
+            album_details = await self.async_get_album(
+                album_id, track.provider, lazy=False
+            )
+            if album_details:
+                track.album = album_details
+        # make sure we have a database album
+        assert track.album
+        if track.album.provider != "database":
+            track.album = await self.async_get_album(
+                track.album.item_id, track.provider, lazy=False
+            )
+        db_id = await self.mass.database.async_add_track(track)
+        # also fetch same track on all providers (will also get other quality versions)
+        await self.__async_match_track(db_id)
+        return db_id
+
+    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
+        async for lookup_album in self.async_get_artist_albums(
+            artist.item_id, artist.provider
+        ):
+            if not lookup_album:
+                continue
+            musicbrainz_id = await self.musicbrainz.async_get_mb_artist_id(
+                artist.name,
+                albumname=lookup_album.name,
+                album_upc=lookup_album.external_ids.get(ExternalId.UPC),
+            )
+            if musicbrainz_id:
+                return musicbrainz_id
+        # fallback to track
+        async for lookup_track in self.async_get_artist_toptracks(
+            artist.item_id, artist.provider
+        ):
+            if not lookup_track:
+                continue
+            musicbrainz_id = await self.musicbrainz.async_get_mb_artist_id(
+                artist.name,
+                trackname=lookup_track.name,
+                track_isrc=lookup_track.external_ids.get(ExternalId.ISRC),
+            )
+            if musicbrainz_id:
+                return musicbrainz_id
+        # lookup failed, use the shitty workaround to use the name as id.
+        LOGGER.warning("Unable to get musicbrainz ID for artist %s !", artist.name)
+        return artist.name
+
+    async def __async_match_artist(self, db_artist_id: int):
+        """
+        Try to find matching artists on all providers for the provided (database) artist_id.
+
+        This is used to link objects of different providers together.
+            :attrib db_artist_id: Database artist_id.
+        """
+        match_job_id = f"artist.{db_artist_id}"
+        if match_job_id in self._match_jobs:
+            return
+        self._match_jobs.append(match_job_id)
+        artist = await self.mass.database.async_get_artist(db_artist_id)
+        cur_providers = [item.provider for item in artist.provider_ids]
+        for provider in self.mass.get_providers(ProviderType.MUSIC_PROVIDER):
+            if provider.id in cur_providers:
+                continue
+            LOGGER.debug(
+                "Trying to match artist %s on provider %s", artist.name, provider.name
+            )
+            match_found = False
+            # try to get a match with some reference albums of this artist
+            async for ref_album in self.async_get_artist_albums(
+                artist.item_id, artist.provider
+            ):
+                if match_found:
+                    break
+                searchstr = "%s - %s" % (artist.name, ref_album.name)
+                search_result = await self.async_search_provider(
+                    searchstr, provider.id, [MediaType.Album], limit=5
+                )
+                for strictness in [True, False]:
+                    if match_found:
+                        break
+                    for search_result_item in search_result.albums:
+                        if not search_result_item:
+                            continue
+                        if not compare_strings(
+                            search_result_item.name, ref_album.name, strict=strictness
+                        ):
+                            continue
+                        # double safety check - artist must match exactly !
+                        if not compare_strings(
+                            search_result_item.artist.name,
+                            artist.name,
+                            strict=strictness,
+                        ):
+                            continue
+                        # just load this item in the database where it will be strictly matched
+                        await self.async_get_artist(
+                            search_result_item.artist.item_id,
+                            search_result_item.artist.provider,
+                            lazy=False,
+                        )
+                        match_found = True
+                        break
+            # try to get a match with some reference tracks of this artist
+            if not match_found:
+                async for search_track in self.async_get_artist_toptracks(
+                    artist.item_id, artist.provider
+                ):
+                    if match_found:
+                        break
+                    searchstr = "%s - %s" % (artist.name, search_track.name)
+                    search_results = await self.async_search_provider(
+                        searchstr, provider.id, [MediaType.Track], limit=5
+                    )
+                    for strictness in [True, False]:
+                        if match_found:
+                            break
+                        for search_result_item in search_results.tracks:
+                            if match_found:
+                                break
+                            if not search_result_item:
+                                continue
+                            if not compare_strings(
+                                search_result_item.name,
+                                search_track.name,
+                                strict=strictness,
+                            ):
+                                continue
+                            # double safety check - artist must match exactly !
+                            for match_artist in search_result_item.artists:
+                                if not compare_strings(
+                                    match_artist.name, artist.name, strict=strictness
+                                ):
+                                    continue
+                                # load this item in the database where it will be strictly matched
+                                await self.async_get_artist(
+                                    match_artist.item_id,
+                                    match_artist.provider,
+                                    lazy=False,
+                                )
+                                match_found = True
+                                break
+            if match_found:
+                LOGGER.debug(
+                    "Found match for Artist %s on provider %s",
+                    artist.name,
+                    provider.name,
+                )
+            else:
+                LOGGER.warning(
+                    "Could not find match for Artist %s on provider %s",
+                    artist.name,
+                    provider.name,
+                )
+
+    async def __async_match_album(self, db_album_id: int):
+        """
+        Try to find matching album on all providers for the provided (database) album_id.
+
+        This is used to link objects of different providers/qualities together.
+            :attrib db_album_id: Database album_id.
+        """
+        match_job_id = f"album.{db_album_id}"
+        if match_job_id in self._match_jobs:
+            return
+        self._match_jobs.append(match_job_id)
+        album = await self.mass.database.async_get_album(db_album_id)
+        cur_providers = [item.provider for item in album.provider_ids]
+        providers = self.mass.get_providers(ProviderType.MUSIC_PROVIDER)
+        for provider in providers:
+            if provider.id in cur_providers:
+                continue
+            LOGGER.debug(
+                "Trying to match album %s on provider %s", album.name, provider.name
+            )
+            match_found = False
+            searchstr = "%s - %s" % (album.artist.name, album.name)
+            if album.version:
+                searchstr += " " + album.version
+            search_result = await self.async_search_provider(
+                searchstr, provider.id, [MediaType.Album], limit=5
+            )
+            for search_result_item in search_result.albums:
+                if not search_result_item:
+                    continue
+                if search_result_item.album_type != album.album_type:
+                    continue
+                if not (
+                    compare_strings(search_result_item.name, album.name)
+                    and compare_strings(search_result_item.version, album.version)
+                ):
+                    continue
+                if not compare_strings(
+                    search_result_item.artist.name, album.artist.name, strict=False
+                ):
+                    continue
+                # just load this item in the database where it will be strictly matched
+                await self.async_get_album(
+                    search_result_item.item_id,
+                    provider.id,
+                    lazy=False,
+                    album_details=search_result_item,
+                )
+                match_found = True
+            if match_found:
+                LOGGER.debug(
+                    "Found match for Album %s on provider %s", album.name, provider.name
+                )
+            else:
+                LOGGER.warning(
+                    "Could not find match for Album %s on provider %s",
+                    album.name,
+                    provider.name,
+                )
+
+    async def __async_match_track(self, db_track_id: int):
+        """
+        Try to find matching track on all providers for the provided (database) track_id.
+
+        This is used to link objects of different providers/qualities together.
+            :attrib db_track_id: Database track_id.
+        """
+        match_job_id = f"track.{db_track_id}"
+        if match_job_id in self._match_jobs:
+            return
+        self._match_jobs.append(match_job_id)
+        track = await self.mass.database.async_get_track(db_track_id, fulldata=False)
+        for provider in self.mass.get_providers(ProviderType.MUSIC_PROVIDER):
+            LOGGER.debug(
+                "Trying to match track %s on provider %s", track.name, provider.name
+            )
+            match_found = False
+            searchstr = "%s - %s" % (track.artists[0].name, track.name)
+            if track.version:
+                searchstr += " " + track.version
+            search_result = await self.async_search_provider(
+                searchstr, provider.id, [MediaType.Track], limit=10
+            )
+            for search_result_item in search_result.tracks:
+                if (
+                    not search_result_item
+                    or not search_result_item.name
+                    or not search_result_item.album
+                ):
+                    continue
+                if not (
+                    compare_strings(search_result_item.name, track.name)
+                    and compare_strings(search_result_item.version, track.version)
+                ):
+                    continue
+                # double safety check - artist must match exactly !
+                artist_match_found = False
+                for artist in track.artists:
+                    if artist_match_found:
+                        break
+                    for search_item_artist in search_result_item.artists:
+                        if not compare_strings(
+                            artist.name, search_item_artist.name, strict=False
+                        ):
+                            continue
+                        # just load this item in the database where it will be strictly matched
+                        await self.async_get_track(
+                            search_item_artist.item_id,
+                            provider.id,
+                            lazy=False,
+                            track_details=search_result_item,
+                        )
+                        match_found = True
+                        artist_match_found = True
+                        break
+            if match_found:
+                LOGGER.debug(
+                    "Found match for Track %s on provider %s", track.name, provider.name
+                )
+            else:
+                LOGGER.warning(
+                    "Could not find match for Track %s on provider %s",
+                    track.name,
+                    provider.name,
+                )
+
+    ################ Various convenience/helper methods ################
+
+    async def async_get_library_playlist_by_name(self, name: str) -> Playlist:
+        """Get in-library playlist by name."""
+        async for playlist in self.async_get_library_playlists():
+            if playlist.name == name:
+                return playlist
+        return None
+
+    async def async_get_radio_by_name(self, name: str) -> Radio:
+        """Get in-library radio by name."""
+        async for radio in self.async_get_library_radios():
+            if radio.name == name:
+                return radio
+        return None
+
+    async def async_search_provider(
+        self,
+        search_query: str,
+        provider_id: str,
+        media_types: List[MediaType],
+        limit: int = 10,
+    ) -> SearchResult:
+        """
+        Perform search on given provider.
+
+            :param search_query: Search query
+            :param provider_id: provider_id of the provider to perform the search on.
+            :param media_types: A list of media_types to include. All types if None.
+            :param limit: number of items to return in the search (per type).
+        """
+        if provider_id == "database":
+            # get results from database
+            return await self.mass.database.async_search(search_query, media_types)
+        provider = self.mass.get_provider(provider_id)
+        cache_key = f"{provider_id}.search.{search_query}.{media_types}.{limit}"
+        return await async_cached(
+            self.cache,
+            cache_key,
+            provider.async_search(search_query, media_types, limit),
+        )
+
+    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 limit: number of items to return in the search (per type).
+        """
+        result = SearchResult([], [], [], [], [])
+        # include results from all music providers
+        provider_ids = ["database"] + [
+            item.id for item in self.mass.get_providers(ProviderType.MUSIC_PROVIDER)
+        ]
+        for provider_id in provider_ids:
+            provider_result = await self.async_search_provider(
+                search_query, provider_id, media_types, limit
+            )
+            result.artists += provider_result.artists
+            result.albums += provider_result.albums
+            result.tracks += provider_result.tracks
+            result.playlists += provider_result.playlists
+            result.radios += provider_result.radios
+            # TODO: sort by name and filter out duplicates ?
+        return result
+
+    async def async_library_add(self, media_items: List[MediaItem]):
+        """Add media item(s) to the library."""
+        result = False
+        for media_item in media_items:
+            # make sure we have a database item
+            db_item = await self.async_get_item(
+                media_item.item_id,
+                media_item.provider,
+                media_item.media_type,
+                lazy=False,
+            )
+            if not db_item:
+                continue
+            # add to provider's libraries
+            for prov in db_item.provider_ids:
+                provider = self.mass.get_provider(prov.provider)
+                if provider:
+                    result = await provider.async_library_add(
+                        prov.item_id, media_item.media_type
+                    )
+                # mark as library item in internal db
+                await self.mass.database.async_add_to_library(
+                    db_item.item_id, db_item.media_type, prov.provider
+                )
+        return result
+
+    async def async_library_remove(self, media_items: List[MediaItem]):
+        """Remove media item(s) from the library."""
+        result = False
+        for media_item in media_items:
+            # make sure we have a database item
+            db_item = await self.async_get_item(
+                media_item.item_id,
+                media_item.provider,
+                media_item.media_type,
+                lazy=False,
+            )
+            if not db_item:
+                continue
+            # remove from provider's libraries
+            for prov in db_item.provider_ids:
+                provider = self.mass.get_provider(prov.provider)
+                if provider:
+                    result = await provider.async_library_remove(
+                        prov.item_id, media_item.media_type
+                    )
+                # mark as library item in internal db
+                await self.mass.database.async_remove_from_library(
+                    db_item.item_id, db_item.media_type, prov.provider
+                )
+        return result
+
+    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)
+        playlist = await self.async_get_playlist(db_playlist_id, "database")
+        if not playlist or not playlist.is_editable:
+            return False
+        # playlist can only have one provider (for now)
+        playlist_prov = playlist.provider_ids[0]
+        # grab all existing track ids in the playlist so we can check for duplicates
+        cur_playlist_track_ids = []
+        async for item in self.async_get_playlist_tracks(
+            playlist_prov.item_id, playlist_prov.provider
+        ):
+            cur_playlist_track_ids.append(item.item_id)
+            cur_playlist_track_ids += [i.item_id for i in item.provider_ids]
+        track_ids_to_add = []
+        for track in tracks:
+            # check for duplicates
+            already_exists = track.item_id in cur_playlist_track_ids
+            for track_prov in track.provider_ids:
+                if track_prov.item_id in cur_playlist_track_ids:
+                    already_exists = True
+            if already_exists:
+                continue
+            # we can only add a track to a provider playlist if track is available on that provider
+            # this should all be handled in the frontend but these checks are here just to be safe
+            # a track can contain multiple versions on the same provider
+            # simply sort by quality and just add the first one (assuming track is still available)
+            for track_version in sorted(
+                track.provider_ids, key=lambda x: x.quality, reverse=True
+            ):
+                if track_version.provider == playlist_prov.provider:
+                    track_ids_to_add.append(track_version.item_id)
+                    break
+                if playlist_prov.provider == "file":
+                    # the file provider can handle uri's from all providers so simply add the uri
+                    uri = f"{track_version.provider}://{track_version.item_id}"
+                    track_ids_to_add.append(uri)
+                    break
+        # 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())
+            )
+            # return result of the action on the provider
+            provider = self.mass.get_provider(playlist_prov.provider)
+            return await provider.async_add_playlist_tracks(
+                playlist_prov.item_id, track_ids_to_add
+            )
+        return False
+
+    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)
+        playlist = await self.async_get_playlist(db_playlist_id, "database")
+        if not playlist or not playlist.is_editable:
+            return False
+        # playlist can only have one provider (for now)
+        prov_playlist = playlist.provider_ids[0]
+        track_ids_to_remove = []
+        for track in tracks:
+            # a track can contain multiple versions on the same provider, remove all
+            for track_provider in track.provider_ids:
+                if track_provider.provider == prov_playlist.provider:
+                    track_ids_to_remove.append(track_provider.item_id)
+        # 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())
+            )
+            provider = self.mass.get_provider(prov_playlist.provider)
+            return await provider.async_remove_playlist_tracks(
+                prov_playlist.item_id, track_ids_to_remove
+            )
+
+    async def async_get_image_thumb(
+        self, item_id: str, provider_id: str, media_type: MediaType, size: int = 50
+    ):
+        """Get path to (resized) thumb image for given media item."""
+        assert item_id and provider_id and media_type
+        cache_folder = os.path.join(self.mass.config.data_path, ".thumbs")
+        cache_id = f"{item_id}{media_type}{provider_id}"
+        cache_id = base64.b64encode(cache_id.encode("utf-8")).decode("utf-8")
+        cache_file_org = os.path.join(cache_folder, f"{cache_id}0.png")
+        cache_file_sized = os.path.join(cache_folder, f"{cache_id}{size}.png")
+        if os.path.isfile(cache_file_sized):
+            # return file from cache
+            return cache_file_sized
+        # no file in cache so we should get it
+        img_url = ""
+        # we only retrieve items that we already have in cache
+        item = None
+        if await self.mass.database.async_get_database_id(
+            provider_id, item_id, media_type
+        ):
+            item = await self.async_get_item(item_id, provider_id, media_type)
+        if not item:
+            return ""
+        if item and item.metadata.get("image"):
+            img_url = item.metadata["image"]
+        elif media_type == MediaType.Track and item.album:
+            # try album image instead for tracks
+            return await self.async_get_image_thumb(
+                item.album.item_id, item.album.provider, MediaType.Album, size
+            )
+        elif media_type == MediaType.Album and item.artist:
+            # try artist image instead for albums
+            return await self.async_get_image_thumb(
+                item.artist.item_id, item.artist.provider, MediaType.Artist, size
+            )
+        if not img_url:
+            return None
+        # fetch image and store in cache
+        os.makedirs(cache_folder, exist_ok=True)
+        # download base image
+        async with aiohttp.ClientSession() as session:
+            async with session.get(img_url, verify_ssl=False) as response:
+                assert response.status == 200
+                img_data = await response.read()
+                with open(cache_file_org, "wb") as img_file:
+                    img_file.write(img_data)
+        if not size:
+            # return base image
+            return cache_file_org
+        # save resized image
+        basewidth = size
+        img = Image.open(cache_file_org)
+        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_sized)
+        # return file from cache
+        return cache_file_sized
+
+    async def async_get_stream_details(
+        self, media_item: MediaItem, player_id: str = ""
+    ) -> StreamDetails:
+        """
+        Get streamdetails for the given media_item.
+
+        This is called just-in-time when a player/queue wants a MediaItem to be played.
+        Do not try to request streamdetails in advance as this is expiring data.
+            param media_item: The MediaItem (track/radio) for which to request the streamdetails for.
+            param player_id: Optionally provide the player_id which will play this stream.
+        """
+        if media_item.provider == "uri":
+            # special type: a plain uri was added to the queue
+            streamdetails = StreamDetails(
+                type=StreamType.URL,
+                provider="uri",
+                item_id=media_item.item_id,
+                path=media_item.item_id,
+                content_type=ContentType(media_item.item_id.split(".")[-1]),
+                sample_rate=44100,
+                bit_depth=16,
+            )
+        else:
+            # always request the full db track as there might be other qualities available
+            # except for radio
+            if media_item.media_type == MediaType.Radio:
+                full_track = media_item
+            else:
+                full_track = await self.async_get_track(
+                    media_item.item_id, media_item.provider, lazy=True, refresh=False
+                )
+            # sort by quality and check track availability
+            for prov_media in sorted(
+                full_track.provider_ids, key=lambda x: x.quality, reverse=True
+            ):
+                # get streamdetails from provider
+                music_prov = self.mass.get_provider(prov_media.provider)
+                if not music_prov:
+                    continue  # provider temporary unavailable ?
+
+                streamdetails = await music_prov.async_get_stream_details(
+                    prov_media.item_id
+                )
+                if streamdetails:
+                    break
+
+        if streamdetails:
+            # set player_id on the streamdetails so we know what players stream
+            streamdetails.player_id = player_id
+            # store the path encrypted as we do not want it to be visible in the api
+            streamdetails.path = encrypt_string(streamdetails.path)
+            # set streamdetails as attribute on the media_item
+            # this way the app knows what content is playing
+            media_item.streamdetails = streamdetails
+            return streamdetails
+        return None
+
+    ################ Library synchronization logic ################
+
+    @run_periodic(3600 * 3)
+    async def __async_music_providers_sync(self):
+        """Periodic sync of all music providers."""
+        await asyncio.sleep(10)
+        for prov in self.mass.get_providers(ProviderType.MUSIC_PROVIDER):
+            await self.async_music_provider_sync(prov.id)
+
+    async def async_music_provider_sync(self, prov_id: str):
+        """
+        Sync a music provider.
+
+        param prov_id: {string} -- provider id to sync
+        """
+        provider = self.mass.get_provider(prov_id)
+        if not provider:
+            return
+        if MediaType.Album in provider.supported_mediatypes:
+            await self.async_library_albums_sync(prov_id)
+        if MediaType.Track in provider.supported_mediatypes:
+            await self.async_library_tracks_sync(prov_id)
+        if MediaType.Artist in provider.supported_mediatypes:
+            await self.async_library_artists_sync(prov_id)
+        if MediaType.Playlist in provider.supported_mediatypes:
+            await self.async_library_playlists_sync(prov_id)
+        if MediaType.Radio in provider.supported_mediatypes:
+            await self.async_library_radios_sync(prov_id)
+
+    @sync_task("artists")
+    async def async_library_artists_sync(self, provider_id: str):
+        """Sync library artists for given provider."""
+        music_provider = self.mass.get_provider(provider_id)
+        prev_db_ids = [
+            item.item_id
+            async for item in self.async_get_library_artists(
+                provider_filter=provider_id
+            )
+        ]
+        cur_db_ids = []
+        async for item in music_provider.async_get_library_artists():
+            db_item = await self.async_get_artist(item.item_id, provider_id, lazy=False)
+            cur_db_ids.append(db_item.item_id)
+            if db_item.item_id not in prev_db_ids:
+                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:
+                await self.mass.database.async_remove_from_library(
+                    db_id, MediaType.Artist, provider_id
+                )
+
+    @sync_task("albums")
+    async def async_library_albums_sync(self, provider_id: str):
+        """Sync library albums for given provider."""
+        music_provider = self.mass.get_provider(provider_id)
+        prev_db_ids = [
+            item.item_id
+            async for item in self.async_get_library_albums(provider_filter=provider_id)
+        ]
+        cur_db_ids = []
+        async for item in music_provider.async_get_library_albums():
+
+            db_album = await self.async_get_album(
+                item.item_id, provider_id, album_details=item, lazy=False
+            )
+            if not db_album:
+                LOGGER.error("provider %s album: %s", provider_id, str(item))
+            cur_db_ids.append(db_album.item_id)
+            if db_album.item_id not in prev_db_ids:
+                await self.mass.database.async_add_to_library(
+                    db_album.item_id, MediaType.Album, provider_id
+                )
+        # process deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.database.async_remove_from_library(
+                    db_id, MediaType.Album, provider_id
+                )
+
+    @sync_task("tracks")
+    async def async_library_tracks_sync(self, provider_id: str):
+        """Sync library tracks for given provider."""
+        music_provider = self.mass.get_provider(provider_id)
+        prev_db_ids = [
+            item.item_id
+            async for item in self.async_get_library_tracks(provider_filter=provider_id)
+        ]
+        cur_db_ids = []
+        async for item in music_provider.async_get_library_tracks():
+            db_item = await self.async_get_track(
+                item.item_id, provider_id=provider_id, lazy=False
+            )
+            cur_db_ids.append(db_item.item_id)
+            if db_item.item_id not in prev_db_ids:
+                await self.mass.database.async_add_to_library(
+                    db_item.item_id, MediaType.Track, provider_id
+                )
+        # process deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.database.async_remove_from_library(
+                    db_id, MediaType.Track, provider_id
+                )
+
+    @sync_task("playlists")
+    async def async_library_playlists_sync(self, provider_id: str):
+        """Sync library playlists for given provider."""
+        music_provider = self.mass.get_provider(provider_id)
+        prev_db_ids = [
+            item.item_id
+            async for item in self.async_get_library_playlists(
+                provider_filter=provider_id
+            )
+        ]
+        cur_db_ids = []
+        async for playlist in music_provider.async_get_library_playlists():
+            if playlist is None:
+                continue
+            # always add to db because playlist attributes could have changed
+            db_id = await self.mass.database.async_add_playlist(playlist)
+            cur_db_ids.append(db_id)
+            if db_id not in prev_db_ids:
+                await self.mass.database.async_add_to_library(
+                    db_id, MediaType.Playlist, playlist.provider
+                )
+            # We do not precache/store playlist tracks, these will be retrieved on request only
+        # process playlist deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.database.async_remove_from_library(
+                    db_id, MediaType.Playlist, provider_id
+                )
+
+    @sync_task("radios")
+    async def async_library_radios_sync(self, provider_id: str):
+        """Sync library radios for given provider."""
+        music_provider = self.mass.get_provider(provider_id)
+        prev_db_ids = [
+            item.item_id
+            async for item in self.async_get_library_radios(provider_filter=provider_id)
+        ]
+        cur_db_ids = []
+        async for item in music_provider.async_get_radios():
+            if not item:
+                continue
+            db_id = await self.mass.database.async_get_database_id(
+                item.provider, item.item_id, MediaType.Radio
+            )
+            if not db_id:
+                db_id = await self.mass.database.async_add_radio(item)
+            cur_db_ids.append(db_id)
+            if db_id not in prev_db_ids:
+                await self.mass.database.async_add_to_library(
+                    db_id, MediaType.Radio, provider_id
+                )
+        # process deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.database.async_remove_from_library(
+                    db_id, MediaType.Radio, provider_id
+                )
diff --git a/music_assistant/managers/players.py b/music_assistant/managers/players.py
new file mode 100755 (executable)
index 0000000..5d2381a
--- /dev/null
@@ -0,0 +1,611 @@
+"""PlayerManager: Orchestrates all players from player providers."""
+
+import logging
+from typing import List, Optional
+
+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 (
+    async_iter_items,
+    callback,
+    run_periodic,
+    try_parse_int,
+)
+from music_assistant.models.media_types import MediaItem, MediaType, Track
+from music_assistant.models.player import (
+    PlaybackState,
+    Player,
+    PlayerControl,
+    PlayerControlType,
+)
+from music_assistant.models.player_queue import PlayerQueue, QueueItem, QueueOption
+from music_assistant.models.player_state import PlayerState
+from music_assistant.models.provider import PlayerProvider, ProviderType
+
+POLL_INTERVAL = 30
+
+LOGGER = logging.getLogger("mass")
+
+
+class PlayerManager:
+    """Several helpers to handle playback through player providers."""
+
+    def __init__(self, mass: MusicAssistantType):
+        """Initialize class."""
+        self.mass = mass
+        self._player_states = {}
+        self._providers = {}
+        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,
+            ],
+        )
+
+    async def async_setup(self):
+        """Async initialize of module."""
+        self.mass.add_job(self.poll_task())
+
+    async def async_close(self):
+        """Handle stop/shutdown."""
+        for player_queue in list(self._player_queues.values()):
+            await player_queue.async_close()
+        for player in self.players:
+            await player.async_on_remove()
+
+    @run_periodic(1)
+    async def poll_task(self):
+        """Check for updates on players that need to be polled."""
+        for player in self.players:
+            if player.should_poll and (
+                self._poll_ticks >= POLL_INTERVAL
+                or player.state == PlaybackState.Playing
+            ):
+                await player.async_on_update()
+        if self._poll_ticks >= POLL_INTERVAL:
+            self._poll_ticks = 0
+        else:
+            self._poll_ticks += 1
+
+    @property
+    def player_states(self) -> List[PlayerState]:
+        """Return PlayerState of all registered players."""
+        return list(self._player_states.values())
+
+    @property
+    def players(self) -> List[Player]:
+        """Return all registered players."""
+        return [player_state.player for player_state in self._player_states.values()]
+
+    @property
+    def player_queues(self) -> List[PlayerQueue]:
+        """Return all player queues."""
+        return list(self._player_queues.values())
+
+    @property
+    def providers(self) -> List[PlayerProvider]:
+        """Return all loaded player providers."""
+        return self.mass.get_providers(ProviderType.PLAYER_PROVIDER)
+
+    @callback
+    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)
+
+    @callback
+    def get_player(
+        self, player_id: str, return_player_state: bool = True
+    ) -> PlayerState:
+        """Return Player by player_id or None if player does not exist."""
+        player_state = self._player_states.get(player_id)
+        if player_state:
+            return player_state.player
+        return None
+
+    @callback
+    def get_player_provider(self, player_id: str) -> PlayerProvider:
+        """Return provider by player_id or None if player does not exist."""
+        player = self.get_player(player_id)
+        return self.mass.get_provider(player.provider_id) if player else None
+
+    @callback
+    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)
+        if not player_state:
+            LOGGER.warning("Player(queue) %s is not available!", player_id)
+            return None
+        return self._player_queues.get(player_state.active_queue)
+
+    @callback
+    def get_player_control(self, control_id: str) -> PlayerControl:
+        """Return PlayerControl by id."""
+        if control_id not in self._controls:
+            LOGGER.warning("PlayerControl %s is not available", control_id)
+            return None
+        return self._controls[control_id]
+
+    @callback
+    def get_player_controls(
+        self, filter_type: Optional[PlayerControlType] = None
+    ) -> List[PlayerControl]:
+        """Return all PlayerControls, optionally filtered by type."""
+        return [
+            item
+            for item in self._controls.values()
+            if (filter_type is None or item.type == filter_type)
+        ]
+
+    # ADD/REMOVE/UPDATE HELPERS
+
+    async def async_add_player(self, player: Player) -> None:
+        """Register a new player or update an existing one."""
+        if not player or not player.available:
+            return
+        if player.player_id in self._player_states:
+            return await self.async_update_player(player)
+        # set the mass object on the player
+        player.mass = self.mass
+        # create playerstate and queue object
+        self._player_states[player.player_id] = PlayerState(self.mass, player)
+        self._player_queues[player.player_id] = PlayerQueue(self.mass, player.player_id)
+        # TODO: turn on player if it was previously turned on ?
+        LOGGER.info(
+            "New player added: %s/%s",
+            player.provider_id,
+            self._player_states[player.player_id].name,
+        )
+        self.mass.signal_event(
+            EVENT_PLAYER_ADDED, self._player_states[player.player_id]
+        )
+
+    async def async_remove_player(self, player_id: str):
+        """Remove a player from the registry."""
+        player_state = self._player_states.pop(player_id, None)
+        if player_state:
+            await player_state.player.async_on_remove()
+        self._player_queues.pop(player_id, None)
+        LOGGER.info("Player removed: %s", player_id)
+        self.mass.signal_event(EVENT_PLAYER_REMOVED, {"player_id": player_id})
+
+    async def async_update_player(self, player: Player):
+        """Update an existing player (or register as new if non existing)."""
+        if player.player_id not in self._player_states:
+            return await self.async_add_player(player)
+        await self._player_states[player.player_id].async_update(player)
+
+    async def async_trigger_player_update(self, player_id: str):
+        """Trigger update of an existing player.."""
+        player = self.get_player(player_id)
+        if player:
+            await self._player_states[player.player_id].async_update(player)
+
+    async def async_register_player_control(self, 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
+        LOGGER.info(
+            "New PlayerControl (%s) registered: %s\\%s",
+            control.type,
+            control.provider,
+            control.name,
+        )
+        # 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 [
+                conf.get(CONF_POWER_CONTROL),
+                conf.get(CONF_VOLUME_CONTROL),
+            ]:
+                self.mass.add_job(
+                    self.async_trigger_player_update(player_state.player_id)
+                )
+
+    async def async_update_player_control(self, 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)
+        new_state = control.state
+        if self._controls[control.control_id].state == new_state:
+            return
+        self._controls[control.control_id].state = new_state
+        LOGGER.debug(
+            "PlayerControl %s\\%s updated - new state: %s",
+            control.provider,
+            control.name,
+            new_state,
+        )
+        # 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 [
+                conf.get(CONF_POWER_CONTROL),
+                conf.get(CONF_VOLUME_CONTROL),
+            ]:
+                self.mass.add_job(
+                    self.async_trigger_player_update(player_state.player_id)
+                )
+
+    # SERVICE CALLS / PLAYER COMMANDS
+
+    async def async_play_media(
+        self,
+        player_id: str,
+        media_items: 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 queue_opt:
+                QueueOption.Play -> Insert new items in queue and start playing at inserted position
+                QueueOption.Replace -> Replace queue contents with these items
+                QueueOption.Next -> Play item(s) after current playing item
+                QueueOption.Add -> Append new items at end of the queue
+        """
+        # a single item or list of items may be provided
+        queue_items = []
+        for media_item in media_items:
+            # collect tracks to play
+            if media_item.media_type == MediaType.Artist:
+                tracks = self.mass.music.async_get_artist_toptracks(
+                    media_item.item_id, provider_id=media_item.provider
+                )
+            elif media_item.media_type == MediaType.Album:
+                tracks = self.mass.music.async_get_album_tracks(
+                    media_item.item_id, provider_id=media_item.provider
+                )
+            elif media_item.media_type == MediaType.Playlist:
+                tracks = self.mass.music.async_get_playlist_tracks(
+                    media_item.item_id, provider_id=media_item.provider
+                )
+            else:
+                tracks = async_iter_items(media_item)  # single track
+            async for track in tracks:
+                queue_item = QueueItem(track)
+                # generate uri for this queue item
+                queue_item.uri = "%s/stream/queue/%s/%s" % (
+                    self.mass.web.internal_url,
+                    player_id,
+                    queue_item.queue_item_id,
+                )
+                queue_items.append(queue_item)
+        # turn on player
+        await self.async_cmd_power_on(player_id)
+        # load items into the queue
+        player_queue = self.get_player_queue(player_id)
+        if queue_opt == QueueOption.Replace or (
+            len(queue_items) > 10 and queue_opt in [QueueOption.Play, QueueOption.Next]
+        ):
+            return await player_queue.async_load(queue_items)
+        if queue_opt == QueueOption.Next:
+            return await player_queue.async_insert(queue_items, 1)
+        if queue_opt == QueueOption.Play:
+            return await player_queue.async_insert(queue_items, 0)
+        if queue_opt == QueueOption.Add:
+            return await player_queue.async_append(queue_items)
+
+    async def async_cmd_play_uri(self, player_id: str, uri: str):
+        """
+        Play the specified uri/url on the given player.
+
+        Will create a fake track on the queue.
+
+            :param player_id: player_id of the player to handle the command.
+            :param uri: Url/Uri that can be played by a player.
+            :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.Next -> Play item(s) after current playing item
+                QueueOption.Add -> Append new items at end of the queue
+        """
+        queue_item = QueueItem(
+            Track(
+                item_id=uri,
+                provider="uri",
+                name=uri,
+            )
+        )
+        # generate uri for this queue item
+        queue_item.uri = "%s/stream/%s/%s" % (
+            self.mass.web.internal_url,
+            player_id,
+            queue_item.queue_item_id,
+        )
+        # turn on player
+        await self.async_cmd_power_on(player_id)
+        # load item into the queue
+        player_queue = self.get_player_queue(player_id)
+        return await player_queue.async_insert([queue_item], 0)
+
+    async def async_cmd_stop(self, player_id: str) -> None:
+        """
+        Send STOP command to given player.
+
+            :param player_id: player_id of the player to handle the command.
+        """
+        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)
+        return await queue_player.async_cmd_stop()
+
+    async def async_cmd_play(self, player_id: str) -> None:
+        """
+        Send PLAY command to given player.
+
+            :param player_id: player_id of the player to handle the command.
+        """
+        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)
+        # 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()
+
+    async def async_cmd_pause(self, player_id: str):
+        """
+        Send PAUSE command to given player.
+
+            :param player_id: player_id of the player to handle the command.
+        """
+        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)
+        return await queue_player.async_cmd_pause()
+
+    async def async_cmd_play_pause(self, player_id: str):
+        """
+        Toggle play/pause on given player.
+
+            :param player_id: player_id of the player to handle the command.
+        """
+        player_state = self.get_player_state(player_id)
+        if not player_state:
+            return
+        if player_state.state == PlaybackState.Playing:
+            return await self.async_cmd_pause(player_id)
+        return await self.async_cmd_play(player_id)
+
+    async def async_cmd_next(self, player_id: str):
+        """
+        Send NEXT TRACK command to given player.
+
+            :param player_id: player_id of the player to handle the command.
+        """
+        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()
+
+    async def async_cmd_previous(self, player_id: str):
+        """
+        Send PREVIOUS TRACK command to given player.
+
+            :param player_id: player_id of the player to handle the command.
+        """
+        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()
+
+    async def async_cmd_power_on(self, player_id: str) -> None:
+        """
+        Send POWER ON command to given player.
+
+            :param player_id: player_id of the player to handle the command.
+        """
+        player_state = self.get_player_state(player_id)
+        if not player_state:
+            return
+        player_config = self.mass.config.player_settings[player_state.player_id]
+        # turn on player
+        await player_state.player.async_cmd_power_on()
+        # player control support
+        if player_config.get(CONF_POWER_CONTROL):
+            control = self.get_player_control(player_config[CONF_POWER_CONTROL])
+            if control:
+                await control.async_set_state(True)
+
+    async def async_cmd_power_off(self, player_id: str) -> None:
+        """
+        Send POWER OFF command to given player.
+
+            :param player_id: player_id of the player to handle the command.
+        """
+        player_state = self.get_player_state(player_id)
+        if not player_state:
+            return
+        # send stop if player is playing
+        if player_state.active_queue == player_id and player_state.state in [
+            PlaybackState.Playing,
+            PlaybackState.Paused,
+        ]:
+            await self.async_cmd_stop(player_id)
+        player_config = self.mass.config.player_settings[player_state.player_id]
+        # turn off player
+        await player_state.player.async_cmd_power_off()
+        # player control support
+        if player_config.get(CONF_POWER_CONTROL):
+            control = self.get_player_control(player_config[CONF_POWER_CONTROL])
+            if control:
+                await control.async_set_state(False)
+        # handle group power
+        if player_state.is_group_player:
+            # player is group, turn off all childs
+            for child_player_id in player_state.group_childs:
+                child_player = self.get_player(child_player_id)
+                if child_player and child_player.powered:
+                    self.mass.add_job(self.async_cmd_power_off(child_player_id))
+        else:
+            # if this was the last powered player in the group, turn off group
+            for parent_player_id in player_state.group_parents:
+                parent_player = self.get_player(parent_player_id)
+                if not parent_player or not parent_player.powered:
+                    continue
+                has_powered_players = False
+                for child_player_id in parent_player.group_childs:
+                    if child_player_id == player_id:
+                        continue
+                    child_player = self.get_player(child_player_id)
+                    if child_player and child_player.powered:
+                        has_powered_players = True
+                if not has_powered_players:
+                    self.mass.add_job(self.async_cmd_power_off(parent_player_id))
+
+    async def async_cmd_power_toggle(self, player_id: str):
+        """
+        Send POWER TOGGLE command to given player.
+
+            :param player_id: player_id of the player to handle the command.
+        """
+        player_state = self.get_player_state(player_id)
+        if not player_state:
+            return
+        if player_state.powered:
+            return await self.async_cmd_power_off(player_id)
+        return await self.async_cmd_power_on(player_id)
+
+    async def async_cmd_volume_set(self, player_id: str, volume_level: int) -> None:
+        """
+        Send volume level command to given player.
+
+            :param player_id: player_id of the player to handle the command.
+            :param volume_level: volume level to set (0..100).
+        """
+        player_state = self.get_player_state(player_id)
+        if not player_state or not player_state.powered:
+            return
+        player_config = self.mass.config.player_settings[player_state.player_id]
+        volume_level = try_parse_int(volume_level)
+        if volume_level < 0:
+            volume_level = 0
+        elif volume_level > 100:
+            volume_level = 100
+        # player control support
+        if player_config.get(CONF_VOLUME_CONTROL):
+            control = self.get_player_control(player_config[CONF_VOLUME_CONTROL])
+            if control:
+                await control.async_set_state(volume_level)
+                # just force full volume on actual player if volume is outsourced to volumecontrol
+                await player_state.player.async_cmd_volume_set(player_id, 100)
+        # handle group volume
+        elif player_state.is_group_player:
+            cur_volume = player_state.volume_level
+            new_volume = volume_level
+            volume_dif = new_volume - cur_volume
+            if cur_volume == 0:
+                volume_dif_percent = 1 + (new_volume / 100)
+            else:
+                volume_dif_percent = volume_dif / cur_volume
+            for child_player_id in player_state.group_childs:
+                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
+                    new_child_volume = cur_child_volume + (
+                        cur_child_volume * volume_dif_percent
+                    )
+                    await self.async_cmd_volume_set(child_player_id, new_child_volume)
+        # regular volume command
+        else:
+            await player_state.player.async_cmd_volume_set(volume_level)
+
+    async def async_cmd_volume_up(self, player_id: str):
+        """
+        Send volume UP command to given player.
+
+            :param player_id: player_id of the player to handle the command.
+        """
+        player_state = self.get_player_state(player_id)
+        if not player_state:
+            return
+        new_level = player_state.volume_level + 1
+        if new_level > 100:
+            new_level = 100
+        return await self.async_cmd_volume_set(player_id, new_level)
+
+    async def async_cmd_volume_down(self, player_id: str):
+        """
+        Send volume DOWN command to given player.
+
+            :param player_id: player_id of the player to handle the command.
+        """
+        player_state = self.get_player_state(player_id)
+        if not player_state:
+            return
+        new_level = player_state.volume_level - 1
+        if new_level < 0:
+            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):
+        """
+        Send MUTE command to given player.
+
+            :param player_id: player_id of the player to handle the command.
+            :param is_muted: bool with the new mute state.
+        """
+        player_state = self.get_player_state(player_id)
+        if not player_state:
+            return
+        # TODO: handle mute on volumecontrol?
+        return await player_state.player.async_cmd_volume_mute(is_muted)
+
+    # OTHER/HELPER FUNCTIONS
+
+    async def async_get_gain_correct(
+        self, player_id: str, item_id: str, provider_id: str
+    ):
+        """Get gain correction for given player / track combination."""
+        player_conf = self.mass.config.get_player_config(player_id)
+        if not player_conf["volume_normalisation"]:
+            return 0
+        target_gain = int(player_conf["target_volume"])
+        fallback_gain = int(player_conf["fallback_gain_correct"])
+        track_loudness = await self.mass.database.async_get_track_loudness(
+            item_id, provider_id
+        )
+        if track_loudness is None:
+            gain_correct = fallback_gain
+        else:
+            gain_correct = target_gain - track_loudness
+        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)
diff --git a/music_assistant/managers/streams.py b/music_assistant/managers/streams.py
new file mode 100755 (executable)
index 0000000..50f8398
--- /dev/null
@@ -0,0 +1,595 @@
+"""
+StreamManager: handles all audio streaming to players.
+
+Either by sending tracks one by one or send one continuous stream
+of music with crossfade/gapless support (queue stream).
+"""
+import asyncio
+import gc
+import gzip
+import io
+import logging
+import os
+import shlex
+from enum import Enum
+from typing import AsyncGenerator, List, Optional, Tuple
+
+import pyloudnorm
+import soundfile
+from aiofile import AIOFile, Reader
+from music_assistant.constants import EVENT_STREAM_ENDED, EVENT_STREAM_STARTED
+from music_assistant.helpers.typing import MusicAssistantType
+from music_assistant.helpers.util import (
+    create_tempfile,
+    decrypt_bytes,
+    decrypt_string,
+    encrypt_bytes,
+    get_ip,
+    try_parse_int,
+    yield_chunks,
+)
+from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
+
+LOGGER = logging.getLogger("mass")
+
+
+class SoxOutputFormat(Enum):
+    """Enum representing the various output formats."""
+
+    MP3 = "mp3"  # Lossy mp3
+    OGG = "ogg"  # Lossy Ogg Vorbis
+    FLAC = "flac"  # Flac (with default compression)
+    S24 = "s24"  # Raw PCM 24bits signed
+    S32 = "s32"  # Raw PCM 32bits signed
+    S64 = "s64"  # Raw PCM 64bits signed
+
+
+class StreamManager:
+    """Built-in streamer utilizing SoX."""
+
+    def __init__(self, mass: MusicAssistantType) -> None:
+        """Initialize class."""
+        self.mass = mass
+        self.local_ip = get_ip()
+        self.analyze_jobs = {}
+
+    async def async_get_sox_stream(
+        self,
+        streamdetails: StreamDetails,
+        output_format: SoxOutputFormat = SoxOutputFormat.FLAC,
+        resample: Optional[int] = None,
+        gain_db_adjust: Optional[float] = None,
+        chunk_size: int = 128000,
+    ) -> AsyncGenerator[Tuple[bool, bytes], None]:
+        """Get the sox manipulated audio data for the given streamdetails."""
+        # collect all args for sox
+        if output_format in [
+            SoxOutputFormat.S24,
+            SoxOutputFormat.S32,
+            SoxOutputFormat.S64,
+        ]:
+            output_format = [output_format.value, "-c", "2"]
+        else:
+            output_format = [output_format.value]
+        args = (
+            ["sox", "-t", streamdetails.content_type.value, "-", "-t"]
+            + output_format
+            + ["-"]
+        )
+        if gain_db_adjust:
+            args += ["vol", str(gain_db_adjust), "dB"]
+        if resample:
+            args += ["rate", "-v", str(resample)]
+        LOGGER.debug(
+            "[async_get_sox_stream] [%s/%s] started using args: %s",
+            streamdetails.provider,
+            streamdetails.item_id,
+            " ".join(args),
+        )
+        # init the process with stdin/out pipes
+        sox_proc = await asyncio.create_subprocess_exec(
+            *args,
+            stdout=asyncio.subprocess.PIPE,
+            stdin=asyncio.subprocess.PIPE,
+            bufsize=0,
+        )
+
+        async def fill_buffer():
+            """Forward audio chunks to sox stdin."""
+            LOGGER.debug(
+                "[async_get_sox_stream] [%s/%s] fill_buffer started",
+                streamdetails.provider,
+                streamdetails.item_id,
+            )
+            # feed audio data into sox stdin for processing
+            async for chunk in self.async_get_media_stream(streamdetails):
+                sox_proc.stdin.write(chunk)
+                await sox_proc.stdin.drain()
+            sox_proc.stdin.write_eof()
+            await sox_proc.stdin.drain()
+            LOGGER.debug(
+                "[async_get_sox_stream] [%s/%s] fill_buffer finished",
+                streamdetails.provider,
+                streamdetails.item_id,
+            )
+
+        fill_buffer_task = self.mass.loop.create_task(fill_buffer())
+        try:
+            # yield chunks from stdout
+            # we keep 1 chunk behind to detect end of stream properly
+            prev_chunk = b""
+            while True:
+                # read exactly chunksize of data
+                try:
+                    chunk = await sox_proc.stdout.readexactly(chunk_size)
+                except asyncio.IncompleteReadError as exc:
+                    chunk = exc.partial
+                if len(chunk) < chunk_size:
+                    # last chunk
+                    yield (True, prev_chunk + chunk)
+                    break
+                if prev_chunk:
+                    yield (False, prev_chunk)
+                prev_chunk = chunk
+
+            await asyncio.wait([fill_buffer_task])
+            LOGGER.debug(
+                "[async_get_sox_stream] [%s/%s] finished",
+                streamdetails.provider,
+                streamdetails.item_id,
+            )
+        except (GeneratorExit, Exception):  # pylint: disable=broad-except
+            LOGGER.warning(
+                "[async_get_sox_stream] [%s/%s] aborted",
+                streamdetails.provider,
+                streamdetails.item_id,
+            )
+            if fill_buffer_task and not fill_buffer_task.cancelled():
+                fill_buffer_task.cancel()
+            await sox_proc.communicate()
+            if sox_proc and sox_proc.returncode is None:
+                sox_proc.terminate()
+                await sox_proc.wait()
+        else:
+            LOGGER.debug(
+                "[async_get_sox_stream] [%s/%s] finished",
+                streamdetails.provider,
+                streamdetails.item_id,
+            )
+
+    async def async_queue_stream_flac(self, player_id) -> AsyncGenerator[bytes, None]:
+        """Stream the PlayerQueue's tracks as constant feed in flac format."""
+
+        args = ["sox", "-t", "s32", "-c", "2", "-r", "96000", "-", "-t", "flac", "-"]
+        sox_proc = await asyncio.create_subprocess_exec(
+            *args,
+            stdout=asyncio.subprocess.PIPE,
+            stdin=asyncio.subprocess.PIPE,
+        )
+        LOGGER.debug(
+            "[async_queue_stream_flac] [%s] started using args: %s",
+            player_id,
+            " ".join(args),
+        )
+        chunk_size = 571392  # 74,7% of pcm
+
+        # feed stdin with pcm samples
+        async def fill_buffer():
+            """Feed audio data into sox stdin for processing."""
+            LOGGER.debug(
+                "[async_queue_stream_flac] [%s] fill buffer started", player_id
+            )
+            async for chunk in self.async_queue_stream_pcm(player_id, 96000, 32):
+                sox_proc.stdin.write(chunk)
+                await sox_proc.stdin.drain()
+            sox_proc.stdin.write_eof()
+            await sox_proc.stdin.drain()
+            LOGGER.debug(
+                "[async_queue_stream_flac] [%s] fill buffer finished", player_id
+            )
+
+        fill_buffer_task = self.mass.loop.create_task(fill_buffer())
+        try:
+            # yield flac chunks from stdout
+            while True:
+                try:
+                    chunk = await sox_proc.stdout.readexactly(chunk_size)
+                    yield chunk
+                except asyncio.IncompleteReadError as exc:
+                    chunk = exc.partial
+                    yield chunk
+                    break
+        except (GeneratorExit, Exception):  # pylint: disable=broad-except
+            LOGGER.debug("[async_queue_stream_flac] [%s] aborted", player_id)
+            if fill_buffer_task and not fill_buffer_task.cancelled():
+                fill_buffer_task.cancel()
+            await sox_proc.communicate()
+            if sox_proc and sox_proc.returncode is None:
+                sox_proc.terminate()
+                await sox_proc.wait()
+        else:
+            LOGGER.debug(
+                "[async_queue_stream_flac] [%s] finished",
+                player_id,
+            )
+
+    async def async_queue_stream_pcm(
+        self, player_id, sample_rate=96000, bit_depth=32
+    ) -> AsyncGenerator[bytes, None]:
+        """Stream the PlayerQueue's tracks as constant feed in PCM raw audio."""
+        player_queue = self.mass.players.get_player_queue(player_id)
+        queue_conf = self.mass.config.get_player_config(player_id)
+        fade_length = try_parse_int(queue_conf["crossfade_duration"])
+        pcm_args = ["s32", "-c", "2", "-r", str(sample_rate)]
+        chunk_size = int(sample_rate * (bit_depth / 8) * 2)  # 1 second
+        if fade_length:
+            buffer_size = chunk_size * fade_length
+        else:
+            buffer_size = chunk_size * 10
+
+        LOGGER.info("Start Queue Stream for player %s ", player_id)
+
+        is_start = True
+        last_fadeout_data = b""
+        while True:
+
+            # get the (next) track in queue
+            if is_start:
+                # report start of queue playback so we can calculate current track/duration etc.
+                queue_track = await player_queue.async_start_queue_stream()
+                is_start = False
+            else:
+                queue_track = player_queue.next_item
+            if not queue_track:
+                LOGGER.debug("no (more) tracks left in queue")
+                break
+            # get streamdetails
+            streamdetails = await self.mass.music.async_get_stream_details(
+                queue_track, player_id
+            )
+            # get gain correct / replaygain
+            gain_correct = await self.mass.players.async_get_gain_correct(
+                player_id, streamdetails.item_id, streamdetails.provider
+            )
+            LOGGER.debug(
+                "Start Streaming queue track: %s (%s) for player %s",
+                queue_track.item_id,
+                queue_track.name,
+                player_id,
+            )
+            fade_in_part = b""
+            cur_chunk = 0
+            prev_chunk = None
+            bytes_written = 0
+            # handle incoming audio chunks
+            async for is_last_chunk, chunk in self.mass.streams.async_get_sox_stream(
+                streamdetails,
+                SoxOutputFormat.S32,
+                resample=sample_rate,
+                gain_db_adjust=gain_correct,
+                chunk_size=buffer_size,
+            ):
+                cur_chunk += 1
+
+                # HANDLE FIRST PART OF TRACK
+                if not chunk and cur_chunk == 1 and is_last_chunk:
+                    LOGGER.warning("Stream error, skip track %s", queue_track.item_id)
+                    break
+                if cur_chunk <= 2 and not last_fadeout_data:
+                    # no fadeout_part available so just pass it to the output directly
+                    for small_chunk in yield_chunks(chunk, chunk_size):
+                        yield small_chunk
+                    bytes_written += len(chunk)
+                    del chunk
+                elif cur_chunk == 1 and last_fadeout_data:
+                    prev_chunk = chunk
+                    del chunk
+                # HANDLE CROSSFADE OF PREVIOUS TRACK FADE_OUT AND THIS TRACK FADE_IN
+                elif cur_chunk == 2 and last_fadeout_data:
+                    # combine the first 2 chunks and strip off silence
+                    first_part = await async_strip_silence(prev_chunk + chunk, pcm_args)
+                    if len(first_part) < buffer_size:
+                        # part is too short after the strip action?!
+                        # so we just use the full first part
+                        first_part = prev_chunk + chunk
+                    fade_in_part = first_part[:buffer_size]
+                    remaining_bytes = first_part[buffer_size:]
+                    del first_part
+                    # do crossfade
+                    crossfade_part = await async_crossfade_pcm_parts(
+                        fade_in_part, last_fadeout_data, pcm_args, fade_length
+                    )
+                    # send crossfade_part
+                    for small_chunk in yield_chunks(crossfade_part, chunk_size):
+                        yield small_chunk
+                    bytes_written += len(crossfade_part)
+                    del crossfade_part
+                    del fade_in_part
+                    last_fadeout_data = b""
+                    # also write the leftover bytes from the strip action
+                    for small_chunk in yield_chunks(remaining_bytes, chunk_size):
+                        yield small_chunk
+                    bytes_written += len(remaining_bytes)
+                    del remaining_bytes
+                    del chunk
+                    prev_chunk = None  # needed to prevent this chunk being sent again
+                # HANDLE LAST PART OF TRACK
+                elif prev_chunk and is_last_chunk:
+                    # last chunk received so create the last_part
+                    # with the previous chunk and this chunk
+                    # and strip off silence
+                    last_part = await async_strip_silence(
+                        prev_chunk + chunk, pcm_args, True
+                    )
+                    if len(last_part) < buffer_size:
+                        # part is too short after the strip action
+                        # so we just use the entire original data
+                        last_part = prev_chunk + chunk
+                        if len(last_part) < buffer_size:
+                            LOGGER.warning(
+                                "Not enough data for crossfade: %s", len(last_part)
+                            )
+                    if (
+                        not player_queue.crossfade_enabled
+                        or len(last_part) < buffer_size
+                    ):
+                        # crossfading is not enabled so just pass the (stripped) audio data
+                        for small_chunk in yield_chunks(last_part, chunk_size):
+                            yield small_chunk
+                        bytes_written += len(last_part)
+                        del last_part
+                        del chunk
+                    else:
+                        # handle crossfading support
+                        # store fade section to be picked up for next track
+                        last_fadeout_data = last_part[-buffer_size:]
+                        remaining_bytes = last_part[:-buffer_size]
+                        # write remaining bytes
+                        for small_chunk in yield_chunks(remaining_bytes, chunk_size):
+                            yield small_chunk
+                        bytes_written += len(remaining_bytes)
+                        del last_part
+                        del remaining_bytes
+                        del chunk
+                # MIDDLE PARTS OF TRACK
+                else:
+                    # middle part of the track
+                    # keep previous chunk in memory so we have enough
+                    # samples to perform the crossfade
+                    if prev_chunk:
+                        for small_chunk in yield_chunks(prev_chunk, chunk_size):
+                            yield small_chunk
+                        bytes_written += len(prev_chunk)
+                        prev_chunk = chunk
+                    else:
+                        prev_chunk = chunk
+                    del chunk
+            # end of the track reached
+            # update actual duration to the queue for more accurate now playing info
+            accurate_duration = bytes_written / chunk_size
+            queue_track.duration = accurate_duration
+            LOGGER.debug(
+                "Finished Streaming queue track: %s (%s) on queue %s",
+                queue_track.item_id,
+                queue_track.name,
+                player_id,
+            )
+            # run garbage collect manually to avoid too much memory fragmentation
+            gc.collect()
+        # end of queue reached, pass last fadeout bits to final output
+        for small_chunk in yield_chunks(last_fadeout_data, chunk_size):
+            yield small_chunk
+        del last_fadeout_data
+        # END OF QUEUE STREAM
+        # run garbage collect manually to avoid too much memory fragmentation
+        gc.collect()
+        LOGGER.info("streaming of queue for player %s completed", player_id)
+
+    async def async_stream_queue_item(
+        self, player_id: str, queue_item_id: str
+    ) -> AsyncGenerator[bytes, None]:
+        """Stream a single Queue item."""
+        # collect streamdetails
+        player_queue = self.mass.players.get_player_queue(player_id)
+        if not player_queue:
+            raise FileNotFoundError("invalid player_id")
+        queue_item = player_queue.by_item_id(queue_item_id)
+        if not queue_item:
+            raise FileNotFoundError("invalid queue_item_id")
+        streamdetails = await self.mass.music.async_get_stream_details(
+            queue_item, player_id
+        )
+
+        # get gain correct / replaygain
+        gain_correct = await self.mass.players.async_get_gain_correct(
+            player_id, streamdetails.item_id, streamdetails.provider
+        )
+        # start streaming
+        async for _, audio_chunk in self.async_get_sox_stream(
+            streamdetails, gain_db_adjust=gain_correct
+        ):
+            yield audio_chunk
+
+    async def async_get_media_stream(
+        self, streamdetails: StreamDetails
+    ) -> AsyncGenerator[bytes, None]:
+        """Get the (original/untouched) audio data for the given streamdetails. Generator."""
+        stream_path = decrypt_string(streamdetails.path)
+        stream_type = StreamType(streamdetails.type)
+        audio_data = b""
+
+        # Handle (optional) caching of audio data
+        cache_file = "/tmp/" + f"{streamdetails.item_id}{streamdetails.provider}"[::-1]
+        if os.path.isfile(cache_file):
+            with gzip.open(cache_file, "rb") as _file:
+                audio_data = decrypt_bytes(_file.read())
+            if audio_data:
+                stream_type = StreamType.CACHE
+
+        # support for AAC/MPEG created with ffmpeg in between
+        if streamdetails.content_type in [ContentType.AAC, ContentType.MPEG]:
+            stream_type = StreamType.EXECUTABLE
+            streamdetails.content_type = ContentType.FLAC
+            stream_path = f'ffmpeg -v quiet -i "{stream_path}" -f flac -'
+
+        # signal start of stream event
+        self.mass.signal_event(EVENT_STREAM_STARTED, streamdetails)
+        LOGGER.debug(
+            "[async_get_media_stream] [%s/%s] started, using %s",
+            streamdetails.provider,
+            streamdetails.item_id,
+            stream_type,
+        )
+
+        if stream_type == StreamType.CACHE:
+            yield audio_data
+        elif stream_type == StreamType.URL:
+            async with self.mass.http_session.get(stream_path) as response:
+                async for chunk in response.content.iter_any():
+                    audio_data += chunk
+                    yield chunk
+        elif stream_type == StreamType.FILE:
+            async with AIOFile(stream_path) as afp:
+                async for chunk in Reader(afp):
+                    audio_data += chunk
+                    yield chunk
+        elif stream_type == StreamType.EXECUTABLE:
+            args = shlex.split(stream_path)
+            process = await asyncio.create_subprocess_exec(
+                *args, stdout=asyncio.subprocess.PIPE
+            )
+            try:
+                async for chunk in process.stdout:
+                    audio_data += chunk
+                    yield chunk
+            except (GeneratorExit, Exception) as exc:  # pylint: disable=broad-except
+                LOGGER.warning(
+                    "[async_get_media_stream] [%s/%s] Aborted: %s",
+                    streamdetails.provider,
+                    streamdetails.item_id,
+                    str(exc),
+                )
+                # read remaining bytes
+                await process.communicate()
+                if process and process.returncode is None:
+                    process.terminate()
+                    await process.wait()
+
+        # signal end of stream event
+        self.mass.signal_event(EVENT_STREAM_ENDED, streamdetails)
+
+        # send analyze job to background worker
+        self.mass.add_job(self.__analyze_audio, streamdetails, audio_data)
+        LOGGER.debug(
+            "[async_get_media_stream] [%s/%s] Finished",
+            streamdetails.provider,
+            streamdetails.item_id,
+        )
+
+    def __get_player_sox_options(
+        self, player_id: str, streamdetails: StreamDetails
+    ) -> str:
+        """Get player specific sox effect options."""
+        sox_options = []
+        player_conf = self.mass.config.get_player_config(player_id)
+        # volume normalisation
+        gain_correct = self.mass.add_job(
+            self.mass.players.async_get_gain_correct(
+                player_id, streamdetails.item_id, streamdetails.provider
+            )
+        ).result()
+        if gain_correct != 0:
+            sox_options.append("vol %s dB " % gain_correct)
+        # downsample if needed
+        if player_conf["max_sample_rate"]:
+            max_sample_rate = try_parse_int(player_conf["max_sample_rate"])
+            if max_sample_rate < streamdetails.sample_rate:
+                sox_options.append(f"rate -v {max_sample_rate}")
+        if player_conf.get("sox_options"):
+            sox_options.append(player_conf["sox_options"])
+        return " ".join(sox_options)
+
+    def __analyze_audio(self, streamdetails, audio_data) -> None:
+        """Analyze track audio, for now we only calculate EBU R128 loudness."""
+        item_key = "%s%s" % (streamdetails.item_id, streamdetails.provider)
+        if item_key in self.analyze_jobs:
+            return  # prevent multiple analyze jobs for same track
+        self.analyze_jobs[item_key] = True
+        # do we need saving to disk ?
+        cache_file = "/tmp/" + f"{streamdetails.item_id}{streamdetails.provider}"[::-1]
+        if not os.path.isfile(cache_file):
+            with gzip.open(cache_file, "wb") as _file:
+                _file.write(encrypt_bytes(audio_data))
+        # get track loudness
+        track_loudness = self.mass.add_job(
+            self.mass.database.async_get_track_loudness(
+                streamdetails.item_id, streamdetails.provider
+            )
+        ).result()
+        if track_loudness is None:
+            # only when needed we do the analyze stuff
+            LOGGER.debug("Start analyzing track %s", item_key)
+            # calculate BS.1770 R128 integrated loudness
+            with io.BytesIO(audio_data) as tmpfile:
+                data, rate = soundfile.read(tmpfile)
+            meter = pyloudnorm.Meter(rate)  # create BS.1770 meter
+            loudness = meter.integrated_loudness(data)  # measure loudness
+            del data
+            self.mass.add_job(
+                self.mass.database.async_set_track_loudness(
+                    streamdetails.item_id, streamdetails.provider, loudness
+                )
+            )
+            LOGGER.debug("Integrated loudness of track %s is: %s", item_key, loudness)
+        del audio_data
+        self.analyze_jobs.pop(item_key, None)
+
+
+async def async_crossfade_pcm_parts(
+    fade_in_part: bytes, fade_out_part: bytes, pcm_args: List[str], fade_length: int
+) -> bytes:
+    """Crossfade two chunks of pcm/raw audio using sox."""
+    # create fade-in part
+    fadeinfile = create_tempfile()
+    args = ["sox", "--ignore-length", "-t"] + pcm_args
+    args += ["-", "-t"] + pcm_args + [fadeinfile.name, "fade", "t", str(fade_length)]
+    process = await asyncio.create_subprocess_exec(*args, stdin=asyncio.subprocess.PIPE)
+    await process.communicate(fade_in_part)
+    # create fade-out part
+    fadeoutfile = create_tempfile()
+    args = ["sox", "--ignore-length", "-t"] + pcm_args + ["-", "-t"] + pcm_args
+    args += [fadeoutfile.name, "reverse", "fade", "t", str(fade_length), "reverse"]
+    process = await asyncio.create_subprocess_exec(
+        *args, stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE
+    )
+    await process.communicate(fade_out_part)
+    # create crossfade using sox and some temp files
+    # TODO: figure out how to make this less complex and without the tempfiles
+    args = ["sox", "-m", "-v", "1.0", "-t"] + pcm_args + [fadeoutfile.name, "-v", "1.0"]
+    args += ["-t"] + pcm_args + [fadeinfile.name, "-t"] + pcm_args + ["-"]
+    process = await asyncio.create_subprocess_exec(
+        *args, stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE
+    )
+    crossfade_part, _ = await process.communicate()
+    fadeinfile.close()
+    fadeoutfile.close()
+    del fadeinfile
+    del fadeoutfile
+    return crossfade_part
+
+
+async def async_strip_silence(
+    audio_data: bytes, pcm_args: List[str], reverse=False
+) -> bytes:
+    """Strip silence from (a chunk of) pcm audio."""
+    args = ["sox", "--ignore-length", "-t"] + pcm_args + ["-", "-t"] + pcm_args + ["-"]
+    if reverse:
+        args.append("reverse")
+    args += ["silence", "1", "0.1", "1%"]
+    if reverse:
+        args.append("reverse")
+    process = await asyncio.create_subprocess_exec(
+        *args, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE
+    )
+    stripped_data, _ = await process.communicate(audio_data)
+    return stripped_data
index 8824502b05fe226e8999e4ada7609ca6aadda835..da1d4118890a43f35b43ec9e3f08dbe0653cda94 100644 (file)
@@ -6,30 +6,30 @@ import importlib
 import logging
 import os
 import threading
-from typing import Any, Awaitable, Callable, List, Optional, Union
+from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
 
 import aiohttp
-from music_assistant.cache import Cache
-from music_assistant.config import MassConfig
 from music_assistant.constants import (
     CONF_ENABLED,
     EVENT_PROVIDER_REGISTERED,
+    EVENT_PROVIDER_UNREGISTERED,
     EVENT_SHUTDOWN,
 )
-from music_assistant.database import Database
-from music_assistant.metadata import MetaData
+from music_assistant.helpers.cache import Cache
+from music_assistant.helpers.util import callback, get_ip_pton, is_callback
+from music_assistant.managers.config import ConfigManager
+from music_assistant.managers.database import DatabaseManager
+from music_assistant.managers.metadata import MetaDataManager
+from music_assistant.managers.music import MusicManager
+from music_assistant.managers.players import PlayerManager
+from music_assistant.managers.streams import StreamManager
 from music_assistant.models.provider import Provider, ProviderType
-from music_assistant.music_manager import MusicManager
-from music_assistant.player_manager import PlayerManager
-from music_assistant.stream_manager import StreamManager
-from music_assistant.utils import callback, get_ip_pton, is_callback
-from music_assistant.web import Web
+from music_assistant.web import WebServer
 from zeroconf import NonUniqueNameException, ServiceInfo, Zeroconf
 
 LOGGER = logging.getLogger("mass")
 
 
-# pylint: disable=too-many-instance-attributes
 class MusicAssistant:
     """Main MusicAssistant object."""
 
@@ -40,19 +40,20 @@ class MusicAssistant:
             :param datapath: file location to store the data
         """
 
-        self.loop = None
+        self._loop = None
         self._http_session = None
         self._event_listeners = []
         self._providers = {}
-        self.config = MassConfig(self, datapath)
-        # init modules
-        self.database = Database(self)
-        self.cache = Cache(self)
-        self.metadata = MetaData(self)
-        self.web = Web(self)
-        self.music_manager = MusicManager(self)
-        self.player_manager = PlayerManager(self)
-        self.stream_manager = StreamManager(self)
+
+        # init core managers/controllers
+        self._config = ConfigManager(self, datapath)
+        self._database = DatabaseManager(self)
+        self._cache = Cache(self)
+        self._metadata = MetaDataManager(self)
+        self._web = WebServer(self)
+        self._music = MusicManager(self)
+        self._players = PlayerManager(self)
+        self._streams = StreamManager(self)
         # shared zeroconf instance
         self.zeroconf = Zeroconf()
         self._exit = False
@@ -60,22 +61,22 @@ class MusicAssistant:
     async def async_start(self):
         """Start running the music assistant server."""
         # initialize loop
-        self.loop = asyncio.get_event_loop()
-        self.loop.set_exception_handler(self.__handle_exception)
+        self._loop = asyncio.get_event_loop()
+        self._loop.set_exception_handler(self.__handle_exception)
         if LOGGER.level == logging.DEBUG:
-            self.loop.set_debug(True)
+            self._loop.set_debug(True)
         # create shared aiohttp ClientSession
         self._http_session = aiohttp.ClientSession(
             loop=self.loop,
             connector=aiohttp.TCPConnector(enable_cleanup_closed=True, ssl=False),
         )
-        await self.database.async_setup()
-        await self.cache.async_setup()
-        await self.music_manager.async_setup()
-        await self.player_manager.async_setup()
-        await self.async_preload_providers()
+        await self._database.async_setup()
+        await self._cache.async_setup()
+        await self._music.async_setup()
+        await self._players.async_setup()
+        await self.__async_preload_providers()
         await self.__async_setup_discovery()
-        await self.web.async_setup()
+        await self._web.async_setup()
 
     async def async_stop(self):
         """Stop running the music assistant server."""
@@ -83,35 +84,97 @@ class MusicAssistant:
         self.signal_event(EVENT_SHUTDOWN)
         self._exit = True
         await self.config.async_close()
+        await self._web.async_stop()
         for prov in self._providers.values():
             await prov.async_on_stop()
-        await self.player_manager.async_close()
+        await self._players.async_close()
         await self._http_session.connector.close()
         self._http_session.detach()
 
     @property
-    def http_session(self):
+    def loop(self) -> asyncio.AbstractEventLoop:
+        """Return the running event loop."""
+        return self._loop
+
+    @property
+    def players(self) -> PlayerManager:
+        """Return the Players controller/manager."""
+        return self._players
+
+    @property
+    def music(self) -> MusicManager:
+        """Return the Music controller/manager."""
+        return self._music
+
+    @property
+    def config(self) -> ConfigManager:
+        """Return the Configuration controller/manager."""
+        return self._config
+
+    @property
+    def cache(self) -> Cache:
+        """Return the Cache instance."""
+        return self._cache
+
+    @property
+    def streams(self) -> StreamManager:
+        """Return the Streams controller/manager."""
+        return self._streams
+
+    @property
+    def database(self) -> DatabaseManager:
+        """Return the Database controller/manager."""
+        return self._database
+
+    @property
+    def web(self) -> WebServer:
+        """Return the webserver instance."""
+        return self._web
+
+    @property
+    def http_session(self) -> aiohttp.ClientSession:
         """Return the default http session."""
         return self._http_session
 
-    async def async_register_provider(self, provider: Provider):
+    async def async_register_provider(self, provider: Provider) -> None:
         """Register a new Provider/Plugin."""
         assert provider.id and provider.name
-        assert provider.id not in self._providers  # provider id's must be unique!
+        if provider.id in self._providers:
+            LOGGER.debug("Provider %s is already registered.", provider.id)
+            return
         provider.mass = self  # make sure we have the mass object
         provider.available = False
         self._providers[provider.id] = provider
-        if self.config.providers[provider.id][CONF_ENABLED]:
-            if await provider.async_on_start():
+        if self.config.get_provider_config(provider.id, provider.type)[CONF_ENABLED]:
+            if await provider.async_on_start() is not False:
                 provider.available = True
                 LOGGER.debug("Provider registered: %s", provider.name)
                 self.signal_event(EVENT_PROVIDER_REGISTERED, provider.id)
+            else:
+                LOGGER.debug(
+                    "Provider registered but loading failed: %s", provider.name
+                )
         else:
             LOGGER.debug("Not loading provider %s as it is disabled", provider.name)
 
-    async def register_provider(self, provider: Provider):
-        """Register a new Provider/Plugin."""
-        self.add_job(self.async_register_provider(provider))
+    async def async_unregister_provider(self, provider_id: str) -> None:
+        """Unregister an existing Provider/Plugin."""
+        if provider_id in self._providers:
+            # unload it if it's loaded
+            await self._providers[provider_id].async_on_stop()
+            LOGGER.debug("Provider unregistered: %s", provider_id)
+            self.signal_event(EVENT_PROVIDER_UNREGISTERED, provider_id)
+        return self._providers.pop(provider_id, None)
+
+    async def async_reload_provider(self, provider_id: str) -> None:
+        """Reload an existing Provider/Plugin."""
+        provider = await self.async_unregister_provider(provider_id)
+        if provider is not None:
+            # simply re-register the same provider again
+            await self.async_register_provider(provider)
+        else:
+            # try preloading all providers
+            self.add_job(self.__async_preload_providers())
 
     @callback
     def get_provider(self, provider_id: str) -> Provider:
@@ -132,47 +195,14 @@ class MusicAssistant:
             if (filter_type is None or item.type == filter_type) and item.available
         ]
 
-    async def async_preload_providers(self):
-        """Dynamically load all providermodules."""
-        base_dir = os.path.dirname(os.path.abspath(__file__))
-        modules_path = os.path.join(base_dir, "providers")
-        # load modules
-        for dir_str in os.listdir(modules_path):
-            dir_path = os.path.join(modules_path, dir_str)
-            if not os.path.isdir(dir_path):
-                continue
-            # get files in directory
-            for file_str in os.listdir(dir_path):
-                file_path = os.path.join(dir_path, file_str)
-                if not os.path.isfile(file_path):
-                    continue
-                if not file_str == "__init__.py":
-                    continue
-                module_name = dir_str
-                if module_name in [i.id for i in self._providers.values()]:
-                    continue
-                # try to load the module
-                try:
-                    prov_mod = importlib.import_module(
-                        f".{module_name}", "music_assistant.providers"
-                    )
-                    await prov_mod.async_setup(self)
-                # pylint: disable=broad-except
-                except Exception as exc:
-                    LOGGER.exception("Error preloading module %s: %s", module_name, exc)
-                else:
-                    LOGGER.debug("Successfully preloaded module %s", module_name)
-
     @callback
-    def signal_event(self, event_msg: str, event_details: Any = None):
+    def signal_event(self, event_msg: str, event_details: Any = None) -> None:
         """
         Signal (systemwide) event.
 
             :param event_msg: the eventmessage to signal
             :param event_details: optional details to send with the event.
         """
-        if self._exit:
-            return
         for cb_func, event_filter in self._event_listeners:
             if not event_filter or event_msg in event_filter:
                 self.add_job(cb_func, event_msg, event_details)
@@ -201,7 +231,7 @@ class MusicAssistant:
     @callback
     def add_job(
         self, target: Callable[..., Any], *args: Any, **kwargs: Any
-    ) -> Optional[asyncio.Future]:
+    ) -> Optional[asyncio.Task]:
         """Add a job/task to the event loop.
 
         target: target to call.
@@ -215,7 +245,7 @@ class MusicAssistant:
             check_target = check_target.func
 
         if self._exit:
-            LOGGER.warning("scheduling job %s while exiting", check_target.__name__)
+            LOGGER.debug("scheduling job %s while exiting!", check_target.__name__)
 
         if threading.current_thread() is not threading.main_thread():
             # called from other thread
@@ -242,12 +272,13 @@ class MusicAssistant:
         return task
 
     @staticmethod
-    def __handle_exception(loop, context):
+    def __handle_exception(loop: asyncio.AbstractEventLoop, context: Dict) -> None:
         """Global exception handler."""
         LOGGER.error("Caught exception: %s", context)
-        loop.default_exception_handler(context)
+        if loop.get_debug():
+            loop.default_exception_handler(context)
 
-    async def __async_setup_discovery(self):
+    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
@@ -266,3 +297,34 @@ class MusicAssistant:
             LOGGER.error(
                 "Music Assistant instance with identical name present in the local network"
             )
+
+    async def __async_preload_providers(self):
+        """Dynamically load all providermodules."""
+        base_dir = os.path.dirname(os.path.abspath(__file__))
+        modules_path = os.path.join(base_dir, "providers")
+        # load modules
+        for dir_str in os.listdir(modules_path):
+            dir_path = os.path.join(modules_path, dir_str)
+            if not os.path.isdir(dir_path):
+                continue
+            # get files in directory
+            for file_str in os.listdir(dir_path):
+                file_path = os.path.join(dir_path, file_str)
+                if not os.path.isfile(file_path):
+                    continue
+                if not file_str == "__init__.py":
+                    continue
+                module_name = dir_str
+                if module_name in [i.id for i in self._providers.values()]:
+                    continue
+                # try to load the module
+                try:
+                    prov_mod = importlib.import_module(
+                        f".{module_name}", "music_assistant.providers"
+                    )
+                    await prov_mod.async_setup(self)
+                # pylint: disable=broad-except
+                except Exception as exc:
+                    LOGGER.exception("Error preloading module %s: %s", module_name, exc)
+                else:
+                    LOGGER.debug("Successfully preloaded module %s", module_name)
diff --git a/music_assistant/metadata.py b/music_assistant/metadata.py
deleted file mode 100755 (executable)
index 04d0bae..0000000
+++ /dev/null
@@ -1,272 +0,0 @@
-"""All logic for metadata retrieval."""
-# TODO: split up into (optional) providers
-import json
-import logging
-import re
-from typing import Optional
-
-import aiohttp
-from asyncio_throttle import Throttler
-from music_assistant.cache import async_use_cache
-from music_assistant.utils import compare_strings, get_compare_string
-
-LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])'
-
-LOGGER = logging.getLogger("mass")
-
-
-class MetaData:
-    """Several helpers to search and store metadata for mediaitems."""
-
-    # TODO: create periodic task to search for missing metadata
-    def __init__(self, mass):
-        """Initialize class."""
-        self.mass = mass
-        self.musicbrainz = MusicBrainz(mass)
-        self.fanarttv = FanartTv(mass)
-
-    async def async_get_artist_metadata(self, mb_artist_id, cur_metadata):
-        """Get/update rich metadata for an artist by providing the musicbrainz artist id."""
-        metadata = cur_metadata
-        if "fanart" not in metadata:
-            res = await self.fanarttv.async_get_artist_images(mb_artist_id)
-            if res:
-                self.merge_metadata(cur_metadata, res)
-        return metadata
-
-    async def async_get_mb_artist_id(
-        self,
-        artistname,
-        albumname=None,
-        album_upc=None,
-        trackname=None,
-        track_isrc=None,
-    ):
-        """Retrieve musicbrainz artist id for the given details."""
-        LOGGER.debug(
-            "searching musicbrainz for %s \
-                (albumname: %s - album_upc: %s - trackname: %s - track_isrc: %s)",
-            artistname,
-            albumname,
-            album_upc,
-            trackname,
-            track_isrc,
-        )
-        mb_artist_id = None
-        if album_upc:
-            mb_artist_id = await self.musicbrainz.async_search_artist_by_album(
-                artistname, None, album_upc
-            )
-            if mb_artist_id:
-                LOGGER.debug(
-                    "Got MusicbrainzArtistId for %s after search on upc %s --> %s",
-                    artistname,
-                    album_upc,
-                    mb_artist_id,
-                )
-        if not mb_artist_id and track_isrc:
-            mb_artist_id = await self.musicbrainz.async_search_artist_by_track(
-                artistname, None, track_isrc
-            )
-            if mb_artist_id:
-                LOGGER.debug(
-                    "Got MusicbrainzArtistId for %s after search on isrc %s --> %s",
-                    artistname,
-                    track_isrc,
-                    mb_artist_id,
-                )
-        if not mb_artist_id and albumname:
-            mb_artist_id = await self.musicbrainz.async_search_artist_by_album(
-                artistname, albumname
-            )
-            if mb_artist_id:
-                LOGGER.debug(
-                    "Got MusicbrainzArtistId for %s after search on albumname %s --> %s",
-                    artistname,
-                    albumname,
-                    mb_artist_id,
-                )
-        if not mb_artist_id and trackname:
-            mb_artist_id = await self.musicbrainz.async_search_artist_by_track(
-                artistname, trackname
-            )
-            if mb_artist_id:
-                LOGGER.debug(
-                    "Got MusicbrainzArtistId for %s after search on trackname %s --> %s",
-                    artistname,
-                    trackname,
-                    mb_artist_id,
-                )
-        return mb_artist_id
-
-    @staticmethod
-    def merge_metadata(cur_metadata, new_values):
-        """Merge new info into the metadata dict without overwriting existing values."""
-        for key, value in new_values.items():
-            if not cur_metadata.get(key):
-                cur_metadata[key] = value
-        return cur_metadata
-
-
-class MusicBrainz:
-    """Handle getting Id's from MusicBrainz."""
-
-    def __init__(self, mass):
-        """Initialize class."""
-        self.mass = mass
-        self.cache = mass.cache
-        self.throttler = Throttler(rate_limit=1, period=1)
-
-    async def async_search_artist_by_album(
-        self, artistname, albumname=None, album_upc=None
-    ):
-        """Retrieve musicbrainz artist id by providing the artist name and albumname or upc."""
-        for searchartist in [
-            re.sub(LUCENE_SPECIAL, r"\\\1", artistname),
-            get_compare_string(artistname),
-        ]:
-            if album_upc:
-                endpoint = "release"
-                params = {"query": "barcode:%s" % album_upc}
-            else:
-                searchalbum = re.sub(LUCENE_SPECIAL, r"\\\1", albumname)
-                endpoint = "release"
-                params = {
-                    "query": 'artist:"%s" AND release:"%s"'
-                    % (searchartist, searchalbum)
-                }
-            result = await self.async_get_data(endpoint, params)
-            if result and "releases" in result:
-                for strictness in [True, False]:
-                    for item in result["releases"]:
-                        if album_upc or compare_strings(
-                            item["title"], albumname, strictness
-                        ):
-                            for artist in item["artist-credit"]:
-                                if compare_strings(
-                                    artist["artist"]["name"], artistname, strictness
-                                ):
-                                    return artist["artist"]["id"]
-                                for alias in artist.get("aliases", []):
-                                    if compare_strings(
-                                        alias["name"], artistname, strictness
-                                    ):
-                                        return artist["id"]
-        return ""
-
-    async def async_search_artist_by_track(
-        self, artistname, trackname=None, track_isrc=None
-    ):
-        """Retrieve artist id by providing the artist name and trackname or track isrc."""
-        endpoint = "recording"
-        searchartist = re.sub(LUCENE_SPECIAL, r"\\\1", artistname)
-        # searchartist = searchartist.replace('/','').replace('\\','').replace('-', '')
-        if track_isrc:
-            endpoint = "isrc/%s" % track_isrc
-            params = {"inc": "artist-credits"}
-        else:
-            searchtrack = re.sub(LUCENE_SPECIAL, r"\\\1", trackname)
-            endpoint = "recording"
-            params = {"query": '"%s" AND artist:"%s"' % (searchtrack, searchartist)}
-        result = await self.async_get_data(endpoint, params)
-        if result and "recordings" in result:
-            for strictness in [True, False]:
-                for item in result["recordings"]:
-                    if track_isrc or compare_strings(
-                        item["title"], trackname, strictness
-                    ):
-                        for artist in item["artist-credit"]:
-                            if compare_strings(
-                                artist["artist"]["name"], artistname, strictness
-                            ):
-                                return artist["artist"]["id"]
-                            for alias in artist.get("aliases", []):
-                                if compare_strings(
-                                    alias["name"], artistname, strictness
-                                ):
-                                    return artist["id"]
-        return ""
-
-    @async_use_cache(2)
-    async def async_get_data(self, endpoint: str, params: Optional[dict] = None):
-        """Get data from api."""
-        if params is None:
-            params = {}
-        url = "http://musicbrainz.org/ws/2/%s" % endpoint
-        headers = {"User-Agent": "Music Assistant/1.0.0 https://github.com/marcelveldt"}
-        params["fmt"] = "json"
-        async with self.throttler:
-            async with self.mass.http_session.get(
-                url, headers=headers, params=params, verify_ssl=False
-            ) as response:
-                try:
-                    result = await response.json()
-                except (
-                    aiohttp.client_exceptions.ContentTypeError,
-                    json.decoder.JSONDecodeError,
-                ) as exc:
-                    msg = await response.text()
-                    LOGGER.exception("%s - %s", str(exc), msg)
-                    result = None
-                return result
-
-
-class FanartTv:
-    """FanartTv support for metadata retrieval."""
-
-    def __init__(self, mass):
-        """Initialize class."""
-        self.mass = mass
-        self.cache = mass.cache
-        self.throttler = Throttler(rate_limit=1, period=2)
-
-    async def async_get_artist_images(self, mb_artist_id):
-        """Retrieve images by musicbrainz artist id."""
-        metadata = {}
-        data = await self.async_get_data("music/%s" % mb_artist_id)
-        if data:
-            if data.get("hdmusiclogo"):
-                metadata["logo"] = data["hdmusiclogo"][0]["url"]
-            elif data.get("musiclogo"):
-                metadata["logo"] = data["musiclogo"][0]["url"]
-            if data.get("artistbackground"):
-                count = 0
-                for item in data["artistbackground"]:
-                    key = "fanart" if count == 0 else "fanart.%s" % count
-                    metadata[key] = item["url"]
-            if data.get("artistthumb"):
-                url = data["artistthumb"][0]["url"]
-                if "2a96cbd8b46e442fc41c2b86b821562f" not in url:
-                    metadata["image"] = url
-            if data.get("musicbanner"):
-                metadata["banner"] = data["musicbanner"][0]["url"]
-        return metadata
-
-    @async_use_cache(30)
-    async def async_get_data(self, endpoint, params=None):
-        """Get data from api."""
-        if params is None:
-            params = {}
-        url = "http://webservice.fanart.tv/v3/%s" % endpoint
-        params["api_key"] = "639191cb0774661597f28a47e7e2bad5"
-        async with self.throttler:
-            async with self.mass.http_session.get(
-                url, params=params, verify_ssl=False
-            ) as response:
-                try:
-                    result = await response.json()
-                except (
-                    aiohttp.client_exceptions.ContentTypeError,
-                    json.decoder.JSONDecodeError,
-                ):
-                    LOGGER.error("Failed to retrieve %s", endpoint)
-                    text_result = await response.text()
-                    LOGGER.debug(text_result)
-                    return None
-                except aiohttp.client_exceptions.ClientConnectorError:
-                    LOGGER.error("Failed to retrieve %s", endpoint)
-                    return None
-                if "error" in result and "limit" in result["error"]:
-                    LOGGER.error(result["error"])
-                    return None
-                return result
index d4dd4442506030db5383f3d0c3f0be3704e6b074..a094c99ebf93733fa08a62b9c81962a16dfad462 100644 (file)
@@ -4,8 +4,6 @@ from dataclasses import dataclass, field
 from enum import Enum
 from typing import Any, List, Tuple
 
-from mashumaro import DataClassDictMixin
-
 
 class ConfigEntryType(Enum):
     """Enum for the type of a config entry."""
@@ -16,11 +14,10 @@ class ConfigEntryType(Enum):
     INT = "integer"
     FLOAT = "float"
     LABEL = "label"
-    HEADER = "header"
 
 
 @dataclass
-class ConfigEntry(DataClassDictMixin):
+class ConfigEntry:
     """Model for a Config Entry."""
 
     entry_key: str
@@ -28,8 +25,9 @@ class ConfigEntry(DataClassDictMixin):
     default_value: Any = None
     values: List[Any] = field(default_factory=list)  # select from list of values
     range: Tuple[Any] = ()  # select values within range
-    description_key: str = None  # key in the translations file
-    help_key: str = None  # key in the translations file
+    label: str = ""  # a friendly name for the setting
+    description: str = ""  # extended description of the setting.
+    help_key: str = ""  # key in the translations file
     multi_value: bool = False  # allow multiple values from the list
     depends_on: str = ""  # needs to be set before this setting shows up in frontend
     hidden: bool = False  # hide from UI
index 89754db90a8a78ecea7bee0df33c892785681aee..ca557bbf81754bdd1d97c46f85a088f5540aa5a3 100755 (executable)
@@ -4,8 +4,7 @@ from dataclasses import dataclass, field
 from enum import Enum
 from typing import Any, List
 
-from mashumaro import DataClassDictMixin
-from music_assistant.utils import CustomIntEnum
+from music_assistant.helpers.util import CustomIntEnum
 
 
 class MediaType(CustomIntEnum):
@@ -49,7 +48,7 @@ class TrackQuality(CustomIntEnum):
 
 
 @dataclass
-class MediaItemProviderId(DataClassDictMixin):
+class MediaItemProviderId:
     """Model for a MediaItem's provider id."""
 
     provider: str
@@ -67,7 +66,7 @@ class ExternalId(Enum):
 
 
 @dataclass
-class MediaItem(DataClassDictMixin):
+class MediaItem:
     """Representation of a media item."""
 
     item_id: str = ""
@@ -134,7 +133,7 @@ class Radio(MediaItem):
 
 
 @dataclass
-class SearchResult(DataClassDictMixin):
+class SearchResult:
     """Model for Media Item Search result."""
 
     artists: List[Artist] = field(default_factory=list)
diff --git a/music_assistant/models/musicprovider.py b/music_assistant/models/musicprovider.py
deleted file mode 100755 (executable)
index fd3f059..0000000
+++ /dev/null
@@ -1,149 +0,0 @@
-"""Model and helpers for Music Providers."""
-
-from typing import List, Optional
-
-from music_assistant.models.media_types import (
-    Album,
-    Artist,
-    MediaType,
-    Playlist,
-    Radio,
-    SearchResult,
-    Track,
-)
-from music_assistant.models.provider import Provider, ProviderType
-from music_assistant.models.streamdetails import StreamDetails
-
-
-class MusicProvider(Provider):
-    """
-    Base class for a Musicprovider.
-
-    Should be overriden in the provider specific implementation.
-    """
-
-    @property
-    def type(self) -> ProviderType:
-        """Return ProviderType."""
-        return ProviderType.MUSIC_PROVIDER
-
-    @property
-    def supported_mediatypes(self) -> List[MediaType]:
-        """Return MediaTypes the provider supports."""
-        return [
-            MediaType.Album,
-            MediaType.Artist,
-            MediaType.Playlist,
-            MediaType.Radio,
-            MediaType.Track,
-        ]
-
-    async def async_search(
-        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
-    ) -> SearchResult:
-        """
-        Perform search on musicprovider.
-
-            :param search_query: Search query.
-            :param media_types: A list of media_types to include. All types if None.
-            :param limit: Number of items to return in the search (per type).
-        """
-        raise NotImplementedError
-
-    async def async_get_library_artists(self) -> List[Artist]:
-        """Retrieve library artists from the provider."""
-        if MediaType.Artist in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def async_get_library_albums(self) -> List[Album]:
-        """Retrieve library albums from the provider."""
-        if MediaType.Album in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def async_get_library_tracks(self) -> List[Track]:
-        """Retrieve library tracks from the provider."""
-        if MediaType.Track in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def async_get_library_playlists(self) -> List[Playlist]:
-        """Retrieve library/subscribed playlists from the provider."""
-        if MediaType.Playlist in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def async_get_radios(self) -> List[Radio]:
-        """Retrieve library/subscribed radio stations from the provider."""
-        if MediaType.Radio in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def async_get_artist(self, prov_artist_id: str) -> Artist:
-        """Get full artist details by id."""
-        if MediaType.Artist in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def async_get_artist_albums(self, prov_artist_id: str) -> List[Album]:
-        """Get a list of all albums for the given artist."""
-        if MediaType.Album in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def async_get_artist_toptracks(self, prov_artist_id: str) -> List[Track]:
-        """Get a list of most popular tracks for the given artist."""
-        if MediaType.Track in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def async_get_album(self, prov_album_id: str) -> Album:
-        """Get full album details by id."""
-        if MediaType.Album in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def async_get_track(self, prov_track_id: str) -> Track:
-        """Get full track details by id."""
-        if MediaType.Track in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def async_get_playlist(self, prov_playlist_id: str) -> Playlist:
-        """Get full playlist details by id."""
-        if MediaType.Playlist in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def async_get_radio(self, prov_radio_id: str) -> Radio:
-        """Get full radio details by id."""
-        if MediaType.Radio in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def async_get_album_tracks(self, prov_album_id: str) -> List[Track]:
-        """Get album tracks for given album id."""
-        if MediaType.Album in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def async_get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]:
-        """Get all playlist tracks for given playlist id."""
-        if MediaType.Playlist in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def async_library_add(self, prov_item_id: str, media_type: MediaType) -> bool:
-        """Add item to provider's library. Return true on succes."""
-        raise NotImplementedError
-
-    async def async_library_remove(
-        self, prov_item_id: str, media_type: MediaType
-    ) -> bool:
-        """Remove item from provider's library. Return true on succes."""
-        raise NotImplementedError
-
-    async def async_add_playlist_tracks(
-        self, prov_playlist_id: str, prov_track_ids: List[str]
-    ) -> bool:
-        """Add track(s) to playlist. Return true on succes."""
-        if MediaType.Playlist in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def async_remove_playlist_tracks(
-        self, prov_playlist_id: str, prov_track_ids: List[str]
-    ) -> bool:
-        """Remove track(s) from playlist. Return true on succes."""
-        if MediaType.Playlist in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def async_get_stream_details(self, item_id: str) -> StreamDetails:
-        """Get streamdetails for a track/radio."""
-        raise NotImplementedError
index 607ac65a94118b7125adb26c9e126c3c1f701c71..8de28b83c005403e8495e52886871cfde3ba00cf 100755 (executable)
@@ -5,11 +5,10 @@ from dataclasses import dataclass
 from enum import Enum
 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 CustomIntEnum, callback
 from music_assistant.models.config_entry import ConfigEntry
-from music_assistant.utils import CustomIntEnum, callback
 
 
 class PlaybackState(Enum):
@@ -22,7 +21,7 @@ class PlaybackState(Enum):
 
 
 @dataclass
-class DeviceInfo(DataClassDictMixin):
+class DeviceInfo:
     """Model for a player's deviceinfo."""
 
     model: str = ""
@@ -267,7 +266,7 @@ class Player:
     @callback
     def update_state(self) -> None:
         """Call to store current player state in the player manager."""
-        self.mass.add_job(self.mass.player_manager.async_update_player(self))
+        self.mass.add_job(self.mass.players.async_update_player(self))
 
 
 class PlayerControlType(CustomIntEnum):
index 0372da586b9b4e5cbcbeca526a8476de70bea6c3..4c277258f84a78f37ae6e8509352d233df136028 100755 (executable)
@@ -19,10 +19,10 @@ from music_assistant.helpers.typing import (
     OptionalStr,
     PlayerType,
 )
+from music_assistant.helpers.util import callback
 from music_assistant.models.media_types import Track
 from music_assistant.models.player import PlaybackState, PlayerFeature
 from music_assistant.models.streamdetails import StreamDetails
-from music_assistant.utils import callback
 
 # pylint: disable=too-many-instance-attributes
 # pylint: disable=too-many-public-methods
@@ -85,7 +85,12 @@ class PlayerQueue:
     @property
     def player(self) -> PlayerType:
         """Return handle to player."""
-        return self.mass.player_manager.get_player(self._player_id)
+        return self.mass.players.get_player(self._player_id)
+
+    @property
+    def player_state(self) -> PlayerType:
+        """Return handle to player state."""
+        return self.mass.players.get_player_state(self._player_id)
 
     @property
     def player_id(self) -> str:
@@ -274,7 +279,7 @@ class PlayerQueue:
             return
         if self.use_queue_stream:
             return await self.async_play_index(self.cur_index - 1)
-        return await self.mass.player_manager.async_cmd_previous(self.player_id)
+        return await self.mass.players.async_cmd_previous(self.player_id)
 
     async def async_resume(self) -> None:
         """Resume previous queue."""
@@ -449,7 +454,7 @@ class PlayerQueue:
 
     async def async_clear(self) -> None:
         """Clear all items in the queue."""
-        await self.mass.player_manager.async_cmd_stop(self.player_id)
+        await self.mass.players.async_cmd_stop(self.player_id)
         self._items = []
         if self.supports_queue:
             # send queue cmd to player's own implementation
index 46517d0a61e9dc3831b7ffc7241b84988b9c4cfd..3b0fe8d076beb4f7d95f0f9eb3a4916c91ef165e 100755 (executable)
@@ -12,6 +12,24 @@ from datetime import datetime
 from typing import List, Optional
 
 from music_assistant.constants import (
+    ATTR_ACTIVE_QUEUE,
+    ATTR_AVAILABLE,
+    ATTR_CURRENT_URI,
+    ATTR_DEVICE_INFO,
+    ATTR_ELAPSED_TIME,
+    ATTR_FEATURES,
+    ATTR_GROUP_CHILDS,
+    ATTR_GROUP_PARENTS,
+    ATTR_IS_GROUP_PLAYER,
+    ATTR_MUTED,
+    ATTR_NAME,
+    ATTR_PLAYER_ID,
+    ATTR_POWERED,
+    ATTR_PROVIDER_ID,
+    ATTR_SHOULD_POLL,
+    ATTR_STATE,
+    ATTR_UPDATED_AT,
+    ATTR_VOLUME_LEVEL,
     CONF_ENABLED,
     CONF_GROUP_DELAY,
     CONF_NAME,
@@ -20,6 +38,7 @@ from music_assistant.constants import (
     EVENT_PLAYER_CHANGED,
 )
 from music_assistant.helpers.typing import MusicAssistantType
+from music_assistant.helpers.util import callback
 from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType
 from music_assistant.models.player import (
     DeviceInfo,
@@ -28,30 +47,9 @@ from music_assistant.models.player import (
     PlayerControlType,
     PlayerFeature,
 )
-from music_assistant.utils import callback
 
 LOGGER = logging.getLogger("mass")
 
-ATTR_PLAYER_ID = "player_id"
-ATTR_PROVIDER_ID = "provider_id"
-ATTR_NAME = "name"
-ATTR_POWERED = "powered"
-ATTR_ELAPSED_TIME = "elapsed_time"
-ATTR_STATE = "state"
-ATTR_AVAILABLE = "available"
-ATTR_CURRENT_URI = "current_uri"
-ATTR_VOLUME_LEVEL = "volume_level"
-ATTR_MUTED = "muted"
-ATTR_IS_GROUP_PLAYER = "is_group_player"
-ATTR_GROUP_CHILDS = "group_childs"
-ATTR_DEVICE_INFO = "device_info"
-ATTR_SHOULD_POLL = "should_poll"
-ATTR_FEATURES = "features"
-ATTR_CONFIG_ENTRIES = "config_entries"
-ATTR_UPDATED_AT = "updated_at"
-ATTR_ACTIVE_QUEUE = "active_queue"
-ATTR_GROUP_PARENTS = "group_parents"
-
 
 # list of Player attributes that can/will cause a player changed event
 UPDATE_ATTRIBUTES = [
@@ -66,6 +64,7 @@ UPDATE_ATTRIBUTES = [
     ATTR_GROUP_CHILDS,
     ATTR_DEVICE_INFO,
     ATTR_FEATURES,
+    ATTR_SHOULD_POLL,
 ]
 
 
@@ -242,13 +241,11 @@ class PlayerState:
             if ATTR_GROUP_CHILDS in changed_keys:
                 for child_player_id in self.group_childs:
                     self.mass.add_job(
-                        self.mass.player_manager.async_trigger_player_update(
-                            child_player_id
-                        )
+                        self.mass.players.async_trigger_player_update(child_player_id)
                     )
 
         # always update the player queue
-        player_queue = self.mass.player_manager.get_player_queue(self.active_queue)
+        player_queue = self.mass.players.get_player_queue(self.active_queue)
         if player_queue:
             self.mass.add_job(player_queue.async_update_state())
 
@@ -265,7 +262,7 @@ class PlayerState:
             return False
         player_config = self.mass.config.player_settings[self.player_id]
         if player_config.get(CONF_POWER_CONTROL):
-            control = self.mass.player_manager.get_player_control(
+            control = self.mass.players.get_player_control(
                 player_config[CONF_POWER_CONTROL]
             )
             if control:
@@ -277,7 +274,7 @@ class PlayerState:
         """Return final/calculated player's playback state."""
         if self.powered and self.active_queue != self.player_id:
             # use group state
-            return self.mass.player_manager.get_player(self.active_queue).state
+            return self.mass.players.get_player_state(self.active_queue).state
         if state == PlaybackState.Stopped and not self.powered:
             return PlaybackState.Off
         return state
@@ -297,7 +294,7 @@ class PlayerState:
             return 0
         player_config = self.mass.config.player_settings[self.player_id]
         if player_config.get(CONF_VOLUME_CONTROL):
-            control = self.mass.player_manager.get_player_control(
+            control = self.mass.players.get_player_control(
                 player_config[CONF_VOLUME_CONTROL]
             )
             if control:
@@ -307,7 +304,7 @@ class PlayerState:
             group_volume = 0
             active_players = 0
             for child_player_id in self.group_childs:
-                child_player = self.mass.player_manager.get_player(child_player_id)
+                child_player = self.mass.players.get_player_state(child_player_id)
                 if child_player and child_player.available and child_player.powered:
                     group_volume += child_player.volume_level
                     active_players += 1
@@ -327,7 +324,7 @@ class PlayerState:
         if self.is_group_player:
             return []
         result = []
-        for player in self.mass.player_manager.players:
+        for player in self.mass.players.player_states:
             if not player.is_group_player:
                 continue
             if self.player_id not in player.group_childs:
@@ -346,7 +343,7 @@ class PlayerState:
         # if a group is powered on, all of it's childs will have/use
         # the parent's player's queue.
         for group_player_id in self.group_parents:
-            group_player = self.mass.player_manager.get_player(group_player_id)
+            group_player = self.mass.players.get_player_state(group_player_id)
             if group_player and group_player.powered:
                 return group_player_id
         return self.player_id
@@ -362,9 +359,7 @@ class PlayerState:
         entries = []
         entries += self.player.config_entries
         # append power control config entries
-        power_controls = self.mass.player_manager.get_player_controls(
-            PlayerControlType.POWER
-        )
+        power_controls = self.mass.players.get_player_controls(PlayerControlType.POWER)
         if power_controls:
             controls = [
                 {"text": f"{item.provider}: {item.name}", "value": item.control_id}
@@ -374,12 +369,12 @@ class PlayerState:
                 ConfigEntry(
                     entry_key=CONF_POWER_CONTROL,
                     entry_type=ConfigEntryType.STRING,
-                    description_key=CONF_POWER_CONTROL,
+                    description=CONF_POWER_CONTROL,
                     values=controls,
                 )
             )
         # append volume control config entries
-        volume_controls = self.mass.player_manager.get_player_controls(
+        volume_controls = self.mass.players.get_player_controls(
             PlayerControlType.VOLUME
         )
         if volume_controls:
@@ -391,13 +386,13 @@ class PlayerState:
                 ConfigEntry(
                     entry_key=CONF_VOLUME_CONTROL,
                     entry_type=ConfigEntryType.STRING,
-                    description_key=CONF_VOLUME_CONTROL,
+                    description=CONF_VOLUME_CONTROL,
                     values=controls,
                 )
             )
         # append group player entries
         for parent_id in self.group_parents:
-            parent_player = self.mass.player_manager.get_player(parent_id)
+            parent_player = self.mass.players.get_player_state(parent_id)
             if parent_player and parent_player.provider_id == "group_player":
                 entries.append(
                     ConfigEntry(
@@ -405,13 +400,13 @@ class PlayerState:
                         entry_type=ConfigEntryType.INT,
                         default_value=0,
                         range=(0, 500),
-                        description_key=CONF_GROUP_DELAY,
+                        description=CONF_GROUP_DELAY,
                     )
                 )
                 break
         return entries
 
-    @callback
+    @callback
     def to_dict(self):
         """Instance attributes as dict so it can be serialized to json."""
         return {
@@ -427,109 +422,9 @@ class PlayerState:
             ATTR_MUTED: self.muted,
             ATTR_IS_GROUP_PLAYER: self.is_group_player,
             ATTR_GROUP_CHILDS: self.group_childs,
-            ATTR_DEVICE_INFO: self.device_info.to_dict(),
-            ATTR_UPDATED_AT: self.updated_at.isoformat(),
+            ATTR_DEVICE_INFO: self.device_info,
+            ATTR_UPDATED_AT: self.updated_at,
             ATTR_GROUP_PARENTS: self.group_parents,
             ATTR_FEATURES: self.features,
             ATTR_ACTIVE_QUEUE: self.active_queue,
         }
-
-    async def async_cmd_play_uri(self, uri: str) -> None:
-        """
-        Play the specified uri/url on the player.
-
-            :param uri: uri/url to send to the player.
-        """
-        return await self.player.async_cmd_play_uri(uri)
-
-    async def async_cmd_stop(self) -> None:
-        """Send STOP command to player."""
-        return await self.player.async_cmd_stop()
-
-    async def async_cmd_play(self) -> None:
-        """Send PLAY command to player."""
-        return await self.player.async_cmd_play()
-
-    async def async_cmd_pause(self) -> None:
-        """Send PAUSE command to player."""
-        return await self.player.async_cmd_pause()
-
-    async def async_cmd_next(self) -> None:
-        """Send NEXT TRACK command to player."""
-        return await self.player.async_cmd_next()
-
-    async def async_cmd_previous(self) -> None:
-        """Send PREVIOUS TRACK command to player."""
-        return await self.player.async_cmd_previous()
-
-    async def async_cmd_power_on(self) -> None:
-        """Send POWER ON command to player."""
-        return await self.player.async_cmd_power_on()
-
-    async def async_cmd_power_off(self) -> None:
-        """Send POWER OFF command to player."""
-        return await self.player.async_cmd_power_off()
-
-    async def async_cmd_volume_set(self, volume_level: int) -> None:
-        """
-        Send volume level command to player.
-
-            :param volume_level: volume level to set (0..100).
-        """
-        return await self.player.async_cmd_volume_set(volume_level)
-
-    async def async_cmd_volume_mute(self, is_muted: bool = False) -> None:
-        """
-        Send volume MUTE command to given player.
-
-            :param is_muted: bool with new mute state.
-        """
-        return await self.player.async_cmd_volume_mute(is_muted)
-
-    # OPTIONAL: QUEUE SERVICE CALLS/COMMANDS - OVERRIDE ONLY IF SUPPORTED BY PROVIDER
-
-    async def async_cmd_queue_play_index(self, index: int) -> None:
-        """
-        Play item at index X on player's queue.
-
-            :param index: (int) index of the queue item that should start playing
-        """
-        return await self.player.async_cmd_queue_play_index(index)
-
-    async def async_cmd_queue_load(self, queue_items) -> None:
-        """
-        Load/overwrite given items in the player's queue implementation.
-
-            :param queue_items: a list of QueueItems
-        """
-        return await self.player.async_cmd_queue_load(queue_items)
-
-    async def async_cmd_queue_insert(self, queue_items, insert_at_index: int) -> None:
-        """
-        Insert new items at position X into existing queue.
-
-        If insert_at_index 0 or None, will start playing newly added item(s)
-            :param queue_items: a list of QueueItems
-            :param insert_at_index: queue position to insert new items
-        """
-        return await self.player.async_cmd_queue_insert(queue_items, insert_at_index)
-
-    async def async_cmd_queue_append(self, queue_items) -> None:
-        """
-        Append new items at the end of the queue.
-
-            :param queue_items: a list of QueueItems
-        """
-        return await self.player.async_cmd_queue_append(queue_items)
-
-    async def async_cmd_queue_update(self, queue_items) -> None:
-        """
-        Overwrite the existing items in the queue, used for reordering.
-
-            :param queue_items: a list of QueueItems
-        """
-        return await self.player.async_cmd_queue_update(queue_items)
-
-    async def async_cmd_queue_clear(self) -> None:
-        """Clear the player's queue."""
-        return await self.player.async_cmd_queue_clear()
diff --git a/music_assistant/models/playerprovider.py b/music_assistant/models/playerprovider.py
deleted file mode 100755 (executable)
index 7ed6c1f..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-"""Models and helpers for a player provider."""
-
-from music_assistant.helpers.typing import Players
-from music_assistant.models.provider import Provider, ProviderType
-
-
-class PlayerProvider(Provider):
-    """
-    Base class for a Playerprovider.
-
-    Should be overridden/subclassed by provider specific implementation.
-    """
-
-    @property
-    def type(self) -> ProviderType:
-        """Return ProviderType."""
-        return ProviderType.PLAYER_PROVIDER
-
-    @property
-    def players(self) -> Players:
-        """Return all players belonging to this provider."""
-        # pylint: disable=no-member
-        return [
-            player
-            for player in self.mass.player_manager.players
-            if player.provider_id == self.id
-        ]
index 4751074218045b3d3bc9e7f5c88e858db322e401..95e29e3b58043a701bc15b63c76295be340def9c 100644 (file)
@@ -1,11 +1,21 @@
-"""Generic Models and helpers for providers/plugins."""
+"""Models for providers/plugins."""
 
 from abc import abstractmethod
 from enum import Enum
-from typing import List
+from typing import Dict, List, Optional
 
-from music_assistant.helpers.typing import MusicAssistantType
+from music_assistant.helpers.typing import MusicAssistantType, Players
 from music_assistant.models.config_entry import ConfigEntry
+from music_assistant.models.media_types import (
+    Album,
+    Artist,
+    MediaType,
+    Playlist,
+    Radio,
+    SearchResult,
+    Track,
+)
+from music_assistant.models.streamdetails import StreamDetails
 
 
 class ProviderType(Enum):
@@ -13,7 +23,8 @@ class ProviderType(Enum):
 
     MUSIC_PROVIDER = "music_provider"
     PLAYER_PROVIDER = "player_provider"
-    GENERIC = "generic"
+    METADATA_PROVIDER = "metadata_provider"
+    PLUGIN = "plugin"
 
 
 class Provider:
@@ -28,7 +39,6 @@ class Provider:
     @abstractmethod
     def type(self) -> ProviderType:
         """Return ProviderType."""
-        return ProviderType.GENERIC
 
     @property
     @abstractmethod
@@ -56,9 +66,195 @@ class Provider:
 
     @abstractmethod
     async def async_on_stop(self) -> None:
-        """Handle correct close/cleanup of the provider on exit. Called on shutdown."""
+        """Handle correct close/cleanup of the provider on exit. Called on shutdown/reload."""
 
-    async def async_on_reload(self) -> None:
-        """Handle configuration changes for this provider. Called on reload."""
-        await self.async_on_stop()
-        await self.async_on_start()
+
+class Plugin(Provider):
+    """
+    Base class for a Plugin.
+
+    Should be overridden/subclassed by provider specific implementation.
+    """
+
+    @property
+    def type(self) -> ProviderType:
+        """Return ProviderType."""
+        return ProviderType.PLUGIN
+
+
+class PlayerProvider(Provider):
+    """
+    Base class for a Playerprovider.
+
+    Should be overridden/subclassed by provider specific implementation.
+    """
+
+    @property
+    def type(self) -> ProviderType:
+        """Return ProviderType."""
+        return ProviderType.PLAYER_PROVIDER
+
+    @property
+    def players(self) -> Players:
+        """Return all players belonging to this provider."""
+        # pylint: disable=no-member
+        return [
+            player
+            for player in self.mass.players.players
+            if player.provider_id == self.id
+        ]
+
+
+class MetadataProvider(Provider):
+    """
+    Base class for a MetadataProvider.
+
+    Should be overridden/subclassed by provider specific implementation.
+    """
+
+    @property
+    def type(self) -> ProviderType:
+        """Return ProviderType."""
+        return ProviderType.METADATA_PROVIDER
+
+    async def async_get_artist_images(self, mb_artist_id: str) -> Dict:
+        """Retrieve artist metadata as dict by musicbrainz artist id."""
+        raise NotImplementedError
+
+    async def async_get_album_images(self, mb_album_id: str) -> Dict:
+        """Retrieve album metadata as dict by musicbrainz album id."""
+        raise NotImplementedError
+
+
+class MusicProvider(Provider):
+    """
+    Base class for a Musicprovider.
+
+    Should be overriden in the provider specific implementation.
+    """
+
+    @property
+    def type(self) -> ProviderType:
+        """Return ProviderType."""
+        return ProviderType.MUSIC_PROVIDER
+
+    @property
+    def supported_mediatypes(self) -> List[MediaType]:
+        """Return MediaTypes the provider supports."""
+        return [
+            MediaType.Album,
+            MediaType.Artist,
+            MediaType.Playlist,
+            MediaType.Radio,
+            MediaType.Track,
+        ]
+
+    async def async_search(
+        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
+    ) -> SearchResult:
+        """
+        Perform search on musicprovider.
+
+            :param search_query: Search query.
+            :param media_types: A list of media_types to include. All types if None.
+            :param limit: Number of items to return in the search (per type).
+        """
+        raise NotImplementedError
+
+    async def async_get_library_artists(self) -> List[Artist]:
+        """Retrieve library artists from the provider."""
+        if MediaType.Artist in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def async_get_library_albums(self) -> List[Album]:
+        """Retrieve library albums from the provider."""
+        if MediaType.Album in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def async_get_library_tracks(self) -> List[Track]:
+        """Retrieve library tracks from the provider."""
+        if MediaType.Track in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def async_get_library_playlists(self) -> List[Playlist]:
+        """Retrieve library/subscribed playlists from the provider."""
+        if MediaType.Playlist in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def async_get_radios(self) -> List[Radio]:
+        """Retrieve library/subscribed radio stations from the provider."""
+        if MediaType.Radio in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def async_get_artist(self, prov_artist_id: str) -> Artist:
+        """Get full artist details by id."""
+        if MediaType.Artist in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def async_get_artist_albums(self, prov_artist_id: str) -> List[Album]:
+        """Get a list of all albums for the given artist."""
+        if MediaType.Album in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def async_get_artist_toptracks(self, prov_artist_id: str) -> List[Track]:
+        """Get a list of most popular tracks for the given artist."""
+        if MediaType.Track in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def async_get_album(self, prov_album_id: str) -> Album:
+        """Get full album details by id."""
+        if MediaType.Album in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def async_get_track(self, prov_track_id: str) -> Track:
+        """Get full track details by id."""
+        if MediaType.Track in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def async_get_playlist(self, prov_playlist_id: str) -> Playlist:
+        """Get full playlist details by id."""
+        if MediaType.Playlist in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def async_get_radio(self, prov_radio_id: str) -> Radio:
+        """Get full radio details by id."""
+        if MediaType.Radio in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def async_get_album_tracks(self, prov_album_id: str) -> List[Track]:
+        """Get album tracks for given album id."""
+        if MediaType.Album in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def async_get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]:
+        """Get all playlist tracks for given playlist id."""
+        if MediaType.Playlist in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def async_library_add(self, prov_item_id: str, media_type: MediaType) -> bool:
+        """Add item to provider's library. Return true on succes."""
+        raise NotImplementedError
+
+    async def async_library_remove(
+        self, prov_item_id: str, media_type: MediaType
+    ) -> bool:
+        """Remove item from provider's library. Return true on succes."""
+        raise NotImplementedError
+
+    async def async_add_playlist_tracks(
+        self, prov_playlist_id: str, prov_track_ids: List[str]
+    ) -> bool:
+        """Add track(s) to playlist. Return true on succes."""
+        if MediaType.Playlist in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def async_remove_playlist_tracks(
+        self, prov_playlist_id: str, prov_track_ids: List[str]
+    ) -> bool:
+        """Remove track(s) from playlist. Return true on succes."""
+        if MediaType.Playlist in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def async_get_stream_details(self, item_id: str) -> StreamDetails:
+        """Get streamdetails for a track/radio."""
+        raise NotImplementedError
index 5a5e3cbf5d045478b7eaa4655451e54daefbed22..e3313f9fe3d49b50740cd5979978841e20c5ae8f 100644 (file)
@@ -4,8 +4,6 @@ from dataclasses import dataclass
 from enum import Enum
 from typing import Any
 
-from mashumaro import DataClassDictMixin
-
 
 class StreamType(Enum):
     """Enum with stream types."""
@@ -28,7 +26,7 @@ class ContentType(Enum):
 
 
 @dataclass
-class StreamDetails(DataClassDictMixin):
+class StreamDetails:
     """Model for streamdetails."""
 
     type: StreamType
diff --git a/music_assistant/music_manager.py b/music_assistant/music_manager.py
deleted file mode 100755 (executable)
index 6f773db..0000000
+++ /dev/null
@@ -1,1289 +0,0 @@
-"""MusicManager: Orchestrates all data from music providers and sync to internal database."""
-# pylint: disable=too-many-lines
-import asyncio
-import base64
-import functools
-import logging
-import os
-import time
-from typing import List, Optional
-
-import aiohttp
-from music_assistant.cache import async_cached, async_cached_generator
-from music_assistant.constants import EVENT_MUSIC_SYNC_STATUS
-from music_assistant.models.media_types import (
-    Album,
-    Artist,
-    ExternalId,
-    MediaItem,
-    MediaType,
-    Playlist,
-    Radio,
-    SearchResult,
-    Track,
-)
-from music_assistant.models.musicprovider import MusicProvider
-from music_assistant.models.provider import ProviderType
-from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
-from music_assistant.utils import compare_strings, encrypt_string, run_periodic
-from PIL import Image
-
-LOGGER = logging.getLogger("mass")
-
-
-def sync_task(desc):
-    """Return decorator to report a sync task."""
-
-    def wrapper(func):
-        @functools.wraps(func)
-        async def async_wrapped(*args):
-            method_class = args[0]
-            prov_id = args[1]
-            # check if this sync task is not already running
-            for sync_prov_id, sync_desc in method_class.running_sync_jobs:
-                if sync_prov_id == prov_id and sync_desc == desc:
-                    LOGGER.debug(
-                        "Syncjob %s for provider %s is already running!", desc, prov_id
-                    )
-                    return
-            LOGGER.debug("Start syncjob %s for provider %s.", desc, prov_id)
-            sync_job = (prov_id, desc)
-            method_class.running_sync_jobs.append(sync_job)
-            method_class.mass.signal_event(
-                EVENT_MUSIC_SYNC_STATUS, method_class.running_sync_jobs
-            )
-            await func(*args)
-            LOGGER.info("Finished syncing %s for provider %s", desc, prov_id)
-            method_class.running_sync_jobs.remove(sync_job)
-            method_class.mass.signal_event(
-                EVENT_MUSIC_SYNC_STATUS, method_class.running_sync_jobs
-            )
-
-        return async_wrapped
-
-    return wrapper
-
-
-class MusicManager:
-    """Several helpers around the musicproviders."""
-
-    def __init__(self, mass):
-        """Initialize class."""
-        self.running_sync_jobs = []
-        self.mass = mass
-        self.cache = mass.cache
-        self._match_jobs = []
-
-    async def async_setup(self):
-        """Async initialize of module."""
-        # schedule sync task
-        self.mass.add_job(self.__async_music_providers_sync())
-
-    @property
-    def providers(self) -> List[MusicProvider]:
-        """Return all providers of type musicprovider."""
-        return self.mass.get_providers(ProviderType.MUSIC_PROVIDER)
-
-    ################ GET MediaItem(s) by id and provider #################
-
-    async def async_get_item(
-        self, item_id: str, provider_id: str, media_type: MediaType, lazy: bool = True
-    ):
-        """Get single music item by id and media type."""
-        if media_type == MediaType.Artist:
-            return await self.async_get_artist(item_id, provider_id, lazy)
-        if media_type == MediaType.Album:
-            return await self.async_get_album(item_id, provider_id, lazy)
-        if media_type == MediaType.Track:
-            return await self.async_get_track(item_id, provider_id, lazy)
-        if media_type == MediaType.Playlist:
-            return await self.async_get_playlist(item_id, provider_id)
-        if media_type == MediaType.Radio:
-            return await self.async_get_radio(item_id, provider_id)
-        return None
-
-    async def async_get_artist(
-        self, item_id: str, provider_id: str, lazy: bool = True
-    ) -> Artist:
-        """Return artist details for the given provider artist id."""
-        assert item_id and provider_id
-        db_id = await self.mass.database.async_get_database_id(
-            provider_id, item_id, MediaType.Artist
-        )
-        if db_id is None:
-            # artist not yet in local database so fetch details
-            provider = self.mass.get_provider(provider_id)
-            if not provider.available:
-                return None
-            cache_key = f"{provider_id}.get_artist.{item_id}"
-            artist = await async_cached(
-                self.cache, cache_key, provider.async_get_artist(item_id)
-            )
-            if not artist:
-                raise Exception(
-                    "Artist %s not found on provider %s" % (item_id, provider_id)
-                )
-            if lazy:
-                self.mass.add_job(self.__async_add_artist(artist))
-                artist.is_lazy = True
-                return artist
-            db_id = await self.__async_add_artist(artist)
-        return await self.mass.database.async_get_artist(db_id)
-
-    async def async_get_album(
-        self,
-        item_id: str,
-        provider_id: str,
-        lazy=True,
-        album_details: Optional[Album] = None,
-    ) -> Album:
-        """Return album details for the given provider album id."""
-        assert item_id and provider_id
-        db_id = await self.mass.database.async_get_database_id(
-            provider_id, item_id, MediaType.Album
-        )
-        if db_id is None:
-            # album not yet in local database so fetch details
-            if not album_details:
-                provider = self.mass.get_provider(provider_id)
-                if not provider.available:
-                    return None
-                cache_key = f"{provider_id}.get_album.{item_id}"
-                album_details = await async_cached(
-                    self.cache, cache_key, provider.async_get_album(item_id)
-                )
-            if not album_details:
-                raise Exception(
-                    "Album %s not found on provider %s" % (item_id, provider_id)
-                )
-            if lazy:
-                self.mass.add_job(self.__async_add_album(album_details))
-                album_details.is_lazy = True
-                return album_details
-            db_id = await self.__async_add_album(album_details)
-        return await self.mass.database.async_get_album(db_id)
-
-    async def async_get_track(
-        self,
-        item_id: str,
-        provider_id: str,
-        lazy: bool = True,
-        track_details: Track = None,
-        refresh: bool = False,
-    ) -> Track:
-        """Return track details for the given provider track id."""
-        assert item_id and provider_id
-        db_id = await self.mass.database.async_get_database_id(
-            provider_id, item_id, MediaType.Track
-        )
-        if db_id and refresh:
-            # in some cases (e.g. at playback time or requesting full track info)
-            # it's useful to have the track refreshed from the provider instead of
-            # the database cache to make sure that the track is available and perhaps
-            # another or a higher quality version is available.
-            if lazy:
-                self.mass.add_job(self.__async_match_track(db_id))
-            else:
-                await self.__async_match_track(db_id)
-        if not db_id:
-            # track not yet in local database so fetch details
-            if not track_details:
-                provider = self.mass.get_provider(provider_id)
-                if not provider.available:
-                    return None
-                cache_key = f"{provider_id}.get_track.{item_id}"
-                track_details = await async_cached(
-                    self.cache, cache_key, provider.async_get_track(item_id)
-                )
-            if not track_details:
-                raise Exception(
-                    "Track %s not found on provider %s" % (item_id, provider_id)
-                )
-            if lazy:
-                self.mass.add_job(self.__async_add_track(track_details))
-                track_details.is_lazy = True
-                return track_details
-            db_id = await self.__async_add_track(track_details)
-        return await self.mass.database.async_get_track(db_id, fulldata=True)
-
-    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_id = await self.mass.database.async_get_database_id(
-            provider_id, item_id, MediaType.Playlist
-        )
-        if db_id is None:
-            # item not yet in local database so fetch and store details
-            provider = self.mass.get_provider(provider_id)
-            if not provider.available:
-                return None
-            item_details = await provider.async_get_playlist(item_id)
-            db_id = await self.mass.database.async_add_playlist(item_details)
-        return await self.mass.database.async_get_playlist(db_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_id = await self.mass.database.async_get_database_id(
-            provider_id, item_id, MediaType.Radio
-        )
-        if db_id is None:
-            # item not yet in local database so fetch and store details
-            provider = self.mass.get_provider(provider_id)
-            if not provider.available:
-                return None
-            item_details = await provider.async_get_radio(item_id)
-            db_id = await self.mass.database.async_add_radio(item_details)
-        return await self.mass.database.async_get_radio(db_id)
-
-    async def async_get_album_tracks(
-        self, item_id: str, provider_id: str
-    ) -> List[Track]:
-        """Return album tracks for the given provider album id. Generator."""
-        assert item_id and provider_id
-        album = await self.async_get_album(item_id, provider_id)
-        if album.provider == "database":
-            # album tracks are not stored in db, we always fetch them (cached) from the provider.
-            provider_id = album.provider_ids[0].provider
-            item_id = album.provider_ids[0].item_id
-        provider = self.mass.get_provider(provider_id)
-        cache_key = f"{provider_id}.album_tracks.{item_id}"
-        async with self.mass.database.db_conn() as db_conn:
-            async for item in async_cached_generator(
-                self.cache, cache_key, provider.async_get_album_tracks(item_id)
-            ):
-                if not item:
-                    continue
-                db_id = await self.mass.database.async_get_database_id(
-                    item.provider, item.item_id, MediaType.Track, db_conn
-                )
-                if db_id:
-                    # return database track instead if we have a match
-                    track = await self.mass.database.async_get_track(
-                        db_id, fulldata=False, db_conn=db_conn
-                    )
-                    track.disc_number = item.disc_number
-                    track.track_number = item.track_number
-                else:
-                    track = item
-                if not track.album:
-                    track.album = album
-                yield track
-
-    async def async_get_album_versions(
-        self, item_id: str, provider_id: str
-    ) -> List[Album]:
-        """Return all versions of an album we can find on all providers. Generator."""
-        album = await self.async_get_album(item_id, provider_id)
-        provider_ids = [
-            item.id for item in self.mass.get_providers(ProviderType.MUSIC_PROVIDER)
-        ]
-        search_query = f"{album.artist.name} - {album.name}"
-        for prov_id in provider_ids:
-            provider_result = await self.async_search_provider(
-                search_query, prov_id, [MediaType.Album], 25
-            )
-            for item in provider_result.albums:
-                if compare_strings(item.artist.name, album.artist.name):
-                    yield item
-
-    async def async_get_track_versions(
-        self, item_id: str, provider_id: str
-    ) -> List[Track]:
-        """Return all versions of a track we can find on all providers. Generator."""
-        track = await self.async_get_track(item_id, provider_id)
-        provider_ids = [
-            item.id for item in self.mass.get_providers(ProviderType.MUSIC_PROVIDER)
-        ]
-        search_query = f"{track.artists[0].name} - {track.name}"
-        for prov_id in provider_ids:
-            provider_result = await self.async_search_provider(
-                search_query, prov_id, [MediaType.Track], 25
-            )
-            for item in provider_result.tracks:
-                if not compare_strings(item.name, track.name):
-                    continue
-                for artist in item.artists:
-                    # artist must match
-                    if compare_strings(artist.name, track.artists[0].name):
-                        yield item
-                        break
-
-    async def async_get_playlist_tracks(
-        self, item_id: str, provider_id: str
-    ) -> List[Track]:
-        """Return playlist tracks for the given provider playlist id. Generator."""
-        assert item_id and provider_id
-        if provider_id == "database":
-            # playlist tracks are not stored in db, we always fetch them (cached) from the provider.
-            db_item = await self.mass.database.async_get_playlist(item_id)
-            provider_id = db_item.provider_ids[0].provider
-            item_id = db_item.provider_ids[0].item_id
-        provider = self.mass.get_provider(provider_id)
-        playlist = await provider.async_get_playlist(item_id)
-        cache_checksum = playlist.checksum
-        cache_key = f"{provider_id}.playlist_tracks.{item_id}"
-        pos = 0
-        async with self.mass.database.db_conn() as db_conn:
-            async for item in async_cached_generator(
-                self.cache,
-                cache_key,
-                provider.async_get_playlist_tracks(item_id),
-                checksum=cache_checksum,
-            ):
-                if not item:
-                    continue
-                assert item.item_id and item.provider
-                db_id = await self.mass.database.async_get_database_id(
-                    item.provider, item.item_id, MediaType.Track, db_conn=db_conn
-                )
-                if db_id:
-                    # return database track instead if we have a match
-                    item = await self.mass.database.async_get_track(
-                        db_id, fulldata=False, db_conn=db_conn
-                    )
-                item.position = pos
-                pos += 1
-                yield item
-
-    async def async_get_artist_toptracks(
-        self, artist_id: str, provider_id: str
-    ) -> List[Track]:
-        """Return top tracks for an artist. Generator."""
-        async with self.mass.database.db_conn() as db_conn:
-            if provider_id == "database":
-                # tracks from all providers
-                item_ids = []
-                artist = await self.mass.database.async_get_artist(
-                    artist_id, True, db_conn=db_conn
-                )
-                for prov_id in artist.provider_ids:
-                    provider = self.mass.get_provider(prov_id.provider)
-                    if (
-                        not provider
-                        or MediaType.Track not in provider.supported_mediatypes
-                    ):
-                        continue
-                    async for item in self.async_get_artist_toptracks(
-                        prov_id.item_id, prov_id.provider
-                    ):
-                        if item.item_id not in item_ids:
-                            yield item
-                            item_ids.append(item.item_id)
-            else:
-                # items from provider
-                provider = self.mass.get_provider(provider_id)
-                cache_key = f"{provider_id}.artist_toptracks.{artist_id}"
-                async for item in async_cached_generator(
-                    self.cache,
-                    cache_key,
-                    provider.async_get_artist_toptracks(artist_id),
-                ):
-                    if item:
-                        assert item.item_id and item.provider
-                        db_id = await self.mass.database.async_get_database_id(
-                            item.provider,
-                            item.item_id,
-                            MediaType.Track,
-                            db_conn=db_conn,
-                        )
-                        if db_id:
-                            # return database track instead if we have a match
-                            yield await self.mass.database.async_get_track(
-                                db_id, fulldata=False, db_conn=db_conn
-                            )
-                        else:
-                            yield item
-
-    async def async_get_artist_albums(
-        self, artist_id: str, provider_id: str
-    ) -> List[Album]:
-        """Return (all) albums for an artist. Generator."""
-        async with self.mass.database.db_conn() as db_conn:
-            if provider_id == "database":
-                # albums from all providers
-                item_ids = []
-                artist = await self.mass.database.async_get_artist(
-                    artist_id, True, db_conn=db_conn
-                )
-                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
-                    async for item in self.async_get_artist_albums(
-                        prov_id.item_id, prov_id.provider
-                    ):
-                        if item.item_id not in item_ids:
-                            yield item
-                            item_ids.append(item.item_id)
-            else:
-                # items from provider
-                provider = self.mass.get_provider(provider_id)
-                cache_key = f"{provider_id}.artist_albums.{artist_id}"
-                async for item in async_cached_generator(
-                    self.cache, cache_key, provider.async_get_artist_albums(artist_id)
-                ):
-                    assert item.item_id and item.provider
-                    db_id = await self.mass.database.async_get_database_id(
-                        item.provider, item.item_id, MediaType.Album, db_conn=db_conn
-                    )
-                    if db_id:
-                        # return database album instead if we have a match
-                        yield await self.mass.database.async_get_album(
-                            db_id, db_conn=db_conn
-                        )
-                    else:
-                        yield item
-
-    ################ GET MediaItems that are added in the library ################
-
-    async def async_get_library_artists(
-        self, orderby: str = "name", provider_filter: str = None
-    ) -> List[Artist]:
-        """Return all library artists, optionally filtered by provider. Generator."""
-        async for item in self.mass.database.async_get_library_artists(
-            provider_id=provider_filter, orderby=orderby
-        ):
-            yield item
-
-    async def async_get_library_albums(
-        self, orderby: str = "name", provider_filter: str = None
-    ) -> List[Album]:
-        """Return all library albums, optionally filtered by provider. Generator."""
-        async for item in self.mass.database.async_get_library_albums(
-            provider_id=provider_filter, orderby=orderby
-        ):
-            yield item
-
-    async def async_get_library_tracks(
-        self, orderby: str = "name", provider_filter: str = None
-    ) -> List[Track]:
-        """Return all library tracks, optionally filtered by provider. Generator."""
-        async for item in self.mass.database.async_get_library_tracks(
-            provider_id=provider_filter, orderby=orderby
-        ):
-            yield item
-
-    async def async_get_library_playlists(
-        self, orderby: str = "name", provider_filter: str = None
-    ) -> List[Playlist]:
-        """Return all library playlists, optionally filtered by provider. Generator."""
-        async for item in self.mass.database.async_get_library_playlists(
-            provider_id=provider_filter, orderby=orderby
-        ):
-            yield item
-
-    async def async_get_library_radios(
-        self, orderby: str = "name", provider_filter: str = None
-    ) -> List[Playlist]:
-        """Return all library radios, optionally filtered by provider. Generator."""
-        async for item in self.mass.database.async_get_library_radios(
-            provider_id=provider_filter, orderby=orderby
-        ):
-            yield item
-
-    ################ ADD MediaItem(s) to database helpers ################
-
-    async def __async_add_artist(self, artist: Artist) -> int:
-        """Add artist to local db and return the new database id."""
-        musicbrainz_id = artist.external_ids.get(ExternalId.MUSICBRAINZ)
-        if not musicbrainz_id:
-            musicbrainz_id = await self.__async_get_artist_musicbrainz_id(artist)
-        # grab additional metadata
-        artist.external_ids[ExternalId.MUSICBRAINZ] = musicbrainz_id
-        artist.metadata = await self.mass.metadata.async_get_artist_metadata(
-            musicbrainz_id, artist.metadata
-        )
-        db_id = await self.mass.database.async_add_artist(artist)
-        # also fetch same artist on all providers
-        await self.__async_match_artist(db_id)
-        return db_id
-
-    async def __async_add_album(self, album: Album) -> int:
-        """Add album to local db and return the new database id."""
-        # we need to fetch album artist too
-        album.artist = await self.async_get_artist(
-            album.artist.item_id, album.artist.provider, lazy=False
-        )
-        db_id = await self.mass.database.async_add_album(album)
-        # also fetch same album on all providers
-        await self.__async_match_album(db_id)
-        return db_id
-
-    async def __async_add_track(
-        self, track: Track, album_id: Optional[str] = None
-    ) -> int:
-        """Add track to local db and return the new database id."""
-        track_artists = []
-        # we need to fetch track artists too
-        for track_artist in track.artists:
-            db_track_artist = await self.async_get_artist(
-                track_artist.item_id, track_artist.provider, lazy=False
-            )
-            if db_track_artist:
-                track_artists.append(db_track_artist)
-        track.artists = track_artists
-        # fetch album details - prefer optional provided album_id
-        if album_id:
-            album_details = await self.async_get_album(
-                album_id, track.provider, lazy=False
-            )
-            if album_details:
-                track.album = album_details
-        # make sure we have a database album
-        assert track.album
-        if track.album.provider != "database":
-            track.album = await self.async_get_album(
-                track.album.item_id, track.provider, lazy=False
-            )
-        db_id = await self.mass.database.async_add_track(track)
-        # also fetch same track on all providers (will also get other quality versions)
-        await self.__async_match_track(db_id)
-        return db_id
-
-    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
-        async for lookup_album in self.async_get_artist_albums(
-            artist.item_id, artist.provider
-        ):
-            if not lookup_album:
-                continue
-            musicbrainz_id = await self.mass.metadata.async_get_mb_artist_id(
-                artist.name,
-                albumname=lookup_album.name,
-                album_upc=lookup_album.external_ids.get(ExternalId.UPC),
-            )
-            if musicbrainz_id:
-                return musicbrainz_id
-        # fallback to track
-        async for lookup_track in self.async_get_artist_toptracks(
-            artist.item_id, artist.provider
-        ):
-            if not lookup_track:
-                continue
-            musicbrainz_id = await self.mass.metadata.async_get_mb_artist_id(
-                artist.name,
-                trackname=lookup_track.name,
-                track_isrc=lookup_track.external_ids.get(ExternalId.ISRC),
-            )
-            if musicbrainz_id:
-                return musicbrainz_id
-        # lookup failed, use the shitty workaround to use the name as id.
-        LOGGER.warning("Unable to get musicbrainz ID for artist %s !", artist.name)
-        return artist.name
-
-    async def __async_match_artist(self, db_artist_id: int):
-        """
-        Try to find matching artists on all providers for the provided (database) artist_id.
-
-        This is used to link objects of different providers together.
-            :attrib db_artist_id: Database artist_id.
-        """
-        match_job_id = f"artist.{db_artist_id}"
-        if match_job_id in self._match_jobs:
-            return
-        self._match_jobs.append(match_job_id)
-        artist = await self.mass.database.async_get_artist(db_artist_id)
-        cur_providers = [item.provider for item in artist.provider_ids]
-        for provider in self.mass.get_providers(ProviderType.MUSIC_PROVIDER):
-            if provider.id in cur_providers:
-                continue
-            LOGGER.debug(
-                "Trying to match artist %s on provider %s", artist.name, provider.name
-            )
-            match_found = False
-            # try to get a match with some reference albums of this artist
-            async for ref_album in self.async_get_artist_albums(
-                artist.item_id, artist.provider
-            ):
-                if match_found:
-                    break
-                searchstr = "%s - %s" % (artist.name, ref_album.name)
-                search_result = await self.async_search_provider(
-                    searchstr, provider.id, [MediaType.Album], limit=5
-                )
-                for strictness in [True, False]:
-                    if match_found:
-                        break
-                    for search_result_item in search_result.albums:
-                        if not search_result_item:
-                            continue
-                        if not compare_strings(
-                            search_result_item.name, ref_album.name, strict=strictness
-                        ):
-                            continue
-                        # double safety check - artist must match exactly !
-                        if not compare_strings(
-                            search_result_item.artist.name,
-                            artist.name,
-                            strict=strictness,
-                        ):
-                            continue
-                        # just load this item in the database where it will be strictly matched
-                        await self.async_get_artist(
-                            search_result_item.artist.item_id,
-                            search_result_item.artist.provider,
-                            lazy=False,
-                        )
-                        match_found = True
-                        break
-            # try to get a match with some reference tracks of this artist
-            if not match_found:
-                async for search_track in self.async_get_artist_toptracks(
-                    artist.item_id, artist.provider
-                ):
-                    if match_found:
-                        break
-                    searchstr = "%s - %s" % (artist.name, search_track.name)
-                    search_results = await self.async_search_provider(
-                        searchstr, provider.id, [MediaType.Track], limit=5
-                    )
-                    for strictness in [True, False]:
-                        if match_found:
-                            break
-                        for search_result_item in search_results.tracks:
-                            if match_found:
-                                break
-                            if not search_result_item:
-                                continue
-                            if not compare_strings(
-                                search_result_item.name,
-                                search_track.name,
-                                strict=strictness,
-                            ):
-                                continue
-                            # double safety check - artist must match exactly !
-                            for match_artist in search_result_item.artists:
-                                if not compare_strings(
-                                    match_artist.name, artist.name, strict=strictness
-                                ):
-                                    continue
-                                # load this item in the database where it will be strictly matched
-                                await self.async_get_artist(
-                                    match_artist.item_id,
-                                    match_artist.provider,
-                                    lazy=False,
-                                )
-                                match_found = True
-                                break
-            if match_found:
-                LOGGER.debug(
-                    "Found match for Artist %s on provider %s",
-                    artist.name,
-                    provider.name,
-                )
-            else:
-                LOGGER.warning(
-                    "Could not find match for Artist %s on provider %s",
-                    artist.name,
-                    provider.name,
-                )
-
-    async def __async_match_album(self, db_album_id: int):
-        """
-        Try to find matching album on all providers for the provided (database) album_id.
-
-        This is used to link objects of different providers/qualities together.
-            :attrib db_album_id: Database album_id.
-        """
-        match_job_id = f"album.{db_album_id}"
-        if match_job_id in self._match_jobs:
-            return
-        self._match_jobs.append(match_job_id)
-        album = await self.mass.database.async_get_album(db_album_id)
-        cur_providers = [item.provider for item in album.provider_ids]
-        providers = self.mass.get_providers(ProviderType.MUSIC_PROVIDER)
-        for provider in providers:
-            if provider.id in cur_providers:
-                continue
-            LOGGER.debug(
-                "Trying to match album %s on provider %s", album.name, provider.name
-            )
-            match_found = False
-            searchstr = "%s - %s" % (album.artist.name, album.name)
-            if album.version:
-                searchstr += " " + album.version
-            search_result = await self.async_search_provider(
-                searchstr, provider.id, [MediaType.Album], limit=5
-            )
-            for search_result_item in search_result.albums:
-                if not search_result_item:
-                    continue
-                if search_result_item.album_type != album.album_type:
-                    continue
-                if not (
-                    compare_strings(search_result_item.name, album.name)
-                    and compare_strings(search_result_item.version, album.version)
-                ):
-                    continue
-                if not compare_strings(
-                    search_result_item.artist.name, album.artist.name, strict=False
-                ):
-                    continue
-                # just load this item in the database where it will be strictly matched
-                await self.async_get_album(
-                    search_result_item.item_id,
-                    provider.id,
-                    lazy=False,
-                    album_details=search_result_item,
-                )
-                match_found = True
-            if match_found:
-                LOGGER.debug(
-                    "Found match for Album %s on provider %s", album.name, provider.name
-                )
-            else:
-                LOGGER.warning(
-                    "Could not find match for Album %s on provider %s",
-                    album.name,
-                    provider.name,
-                )
-
-    async def __async_match_track(self, db_track_id: int):
-        """
-        Try to find matching track on all providers for the provided (database) track_id.
-
-        This is used to link objects of different providers/qualities together.
-            :attrib db_track_id: Database track_id.
-        """
-        match_job_id = f"track.{db_track_id}"
-        if match_job_id in self._match_jobs:
-            return
-        self._match_jobs.append(match_job_id)
-        track = await self.mass.database.async_get_track(db_track_id, fulldata=False)
-        for provider in self.mass.get_providers(ProviderType.MUSIC_PROVIDER):
-            LOGGER.debug(
-                "Trying to match track %s on provider %s", track.name, provider.name
-            )
-            match_found = False
-            searchstr = "%s - %s" % (track.artists[0].name, track.name)
-            if track.version:
-                searchstr += " " + track.version
-            search_result = await self.async_search_provider(
-                searchstr, provider.id, [MediaType.Track], limit=10
-            )
-            for search_result_item in search_result.tracks:
-                if (
-                    not search_result_item
-                    or not search_result_item.name
-                    or not search_result_item.album
-                ):
-                    continue
-                if not (
-                    compare_strings(search_result_item.name, track.name)
-                    and compare_strings(search_result_item.version, track.version)
-                ):
-                    continue
-                # double safety check - artist must match exactly !
-                artist_match_found = False
-                for artist in track.artists:
-                    if artist_match_found:
-                        break
-                    for search_item_artist in search_result_item.artists:
-                        if not compare_strings(
-                            artist.name, search_item_artist.name, strict=False
-                        ):
-                            continue
-                        # just load this item in the database where it will be strictly matched
-                        await self.async_get_track(
-                            search_item_artist.item_id,
-                            provider.id,
-                            lazy=False,
-                            track_details=search_result_item,
-                        )
-                        match_found = True
-                        artist_match_found = True
-                        break
-            if match_found:
-                LOGGER.debug(
-                    "Found match for Track %s on provider %s", track.name, provider.name
-                )
-            else:
-                LOGGER.warning(
-                    "Could not find match for Track %s on provider %s",
-                    track.name,
-                    provider.name,
-                )
-
-    ################ Various convenience/helper methods ################
-
-    async def async_get_library_playlist_by_name(self, name: str) -> Playlist:
-        """Get in-library playlist by name."""
-        async for playlist in self.async_get_library_playlists():
-            if playlist.name == name:
-                return playlist
-        return None
-
-    async def async_get_radio_by_name(self, name: str) -> Radio:
-        """Get in-library radio by name."""
-        async for radio in self.async_get_library_radios():
-            if radio.name == name:
-                return radio
-        return None
-
-    async def async_search_provider(
-        self,
-        search_query: str,
-        provider_id: str,
-        media_types: List[MediaType],
-        limit: int = 10,
-    ) -> SearchResult:
-        """
-        Perform search on given provider.
-
-            :param search_query: Search query
-            :param provider_id: provider_id of the provider to perform the search on.
-            :param media_types: A list of media_types to include. All types if None.
-            :param limit: number of items to return in the search (per type).
-        """
-        if provider_id == "database":
-            # get results from database
-            return await self.mass.database.async_search(search_query, media_types)
-        provider = self.mass.get_provider(provider_id)
-        cache_key = f"{provider_id}.search.{search_query}.{media_types}.{limit}"
-        return await async_cached(
-            self.cache,
-            cache_key,
-            provider.async_search(search_query, media_types, limit),
-        )
-
-    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 limit: number of items to return in the search (per type).
-        """
-        result = SearchResult([], [], [], [], [])
-        # include results from all music providers
-        provider_ids = ["database"] + [
-            item.id for item in self.mass.get_providers(ProviderType.MUSIC_PROVIDER)
-        ]
-        for provider_id in provider_ids:
-            provider_result = await self.async_search_provider(
-                search_query, provider_id, media_types, limit
-            )
-            result.artists += provider_result.artists
-            result.albums += provider_result.albums
-            result.tracks += provider_result.tracks
-            result.playlists += provider_result.playlists
-            result.radios += provider_result.radios
-            # TODO: sort by name and filter out duplicates ?
-        return result
-
-    async def async_library_add(self, media_items: List[MediaItem]):
-        """Add media item(s) to the library."""
-        result = False
-        for media_item in media_items:
-            # make sure we have a database item
-            db_item = await self.async_get_item(
-                media_item.item_id,
-                media_item.provider,
-                media_item.media_type,
-                lazy=False,
-            )
-            if not db_item:
-                continue
-            # add to provider's libraries
-            for prov in db_item.provider_ids:
-                provider = self.mass.get_provider(prov.provider)
-                if provider:
-                    result = await provider.async_library_add(
-                        prov.item_id, media_item.media_type
-                    )
-                # mark as library item in internal db
-                await self.mass.database.async_add_to_library(
-                    db_item.item_id, db_item.media_type, prov.provider
-                )
-        return result
-
-    async def async_library_remove(self, media_items: List[MediaItem]):
-        """Remove media item(s) from the library."""
-        result = False
-        for media_item in media_items:
-            # make sure we have a database item
-            db_item = await self.async_get_item(
-                media_item.item_id,
-                media_item.provider,
-                media_item.media_type,
-                lazy=False,
-            )
-            if not db_item:
-                continue
-            # remove from provider's libraries
-            for prov in db_item.provider_ids:
-                provider = self.mass.get_provider(prov.provider)
-                if provider:
-                    result = await provider.async_library_remove(
-                        prov.item_id, media_item.media_type
-                    )
-                # mark as library item in internal db
-                await self.mass.database.async_remove_from_library(
-                    db_item.item_id, db_item.media_type, prov.provider
-                )
-        return result
-
-    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)
-        playlist = await self.async_get_playlist(db_playlist_id, "database")
-        if not playlist or not playlist.is_editable:
-            return False
-        # playlist can only have one provider (for now)
-        playlist_prov = playlist.provider_ids[0]
-        # grab all existing track ids in the playlist so we can check for duplicates
-        cur_playlist_track_ids = []
-        async for item in self.async_get_playlist_tracks(
-            playlist_prov.item_id, playlist_prov.provider
-        ):
-            cur_playlist_track_ids.append(item.item_id)
-            cur_playlist_track_ids += [i.item_id for i in item.provider_ids]
-        track_ids_to_add = []
-        for track in tracks:
-            # check for duplicates
-            already_exists = track.item_id in cur_playlist_track_ids
-            for track_prov in track.provider_ids:
-                if track_prov.item_id in cur_playlist_track_ids:
-                    already_exists = True
-            if already_exists:
-                continue
-            # we can only add a track to a provider playlist if track is available on that provider
-            # this should all be handled in the frontend but these checks are here just to be safe
-            # a track can contain multiple versions on the same provider
-            # simply sort by quality and just add the first one (assuming track is still available)
-            for track_version in sorted(
-                track.provider_ids, key=lambda x: x.quality, reverse=True
-            ):
-                if track_version.provider == playlist_prov.provider:
-                    track_ids_to_add.append(track_version.item_id)
-                    break
-                if playlist_prov.provider == "file":
-                    # the file provider can handle uri's from all providers so simply add the uri
-                    uri = f"{track_version.provider}://{track_version.item_id}"
-                    track_ids_to_add.append(uri)
-                    break
-        # 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())
-            )
-            # return result of the action on the provider
-            provider = self.mass.get_provider(playlist_prov.provider)
-            return await provider.async_add_playlist_tracks(
-                playlist_prov.item_id, track_ids_to_add
-            )
-        return False
-
-    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)
-        playlist = await self.async_get_playlist(db_playlist_id, "database")
-        if not playlist or not playlist.is_editable:
-            return False
-        # playlist can only have one provider (for now)
-        prov_playlist = playlist.provider_ids[0]
-        track_ids_to_remove = []
-        for track in tracks:
-            # a track can contain multiple versions on the same provider, remove all
-            for track_provider in track.provider_ids:
-                if track_provider.provider == prov_playlist.provider:
-                    track_ids_to_remove.append(track_provider.item_id)
-        # 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())
-            )
-            provider = self.mass.get_provider(prov_playlist.provider)
-            return await provider.async_remove_playlist_tracks(
-                prov_playlist.item_id, track_ids_to_remove
-            )
-
-    async def async_get_image_thumb(
-        self, item_id: str, provider_id: str, media_type: MediaType, size: int = 50
-    ):
-        """Get path to (resized) thumb image for given media item."""
-        assert item_id and provider_id and media_type
-        cache_folder = os.path.join(self.mass.config.data_path, ".thumbs")
-        cache_id = f"{item_id}{media_type}{provider_id}"
-        cache_id = base64.b64encode(cache_id.encode("utf-8")).decode("utf-8")
-        cache_file_org = os.path.join(cache_folder, f"{cache_id}0.png")
-        cache_file_sized = os.path.join(cache_folder, f"{cache_id}{size}.png")
-        if os.path.isfile(cache_file_sized):
-            # return file from cache
-            return cache_file_sized
-        # no file in cache so we should get it
-        img_url = ""
-        # we only retrieve items that we already have in cache
-        item = None
-        if await self.mass.database.async_get_database_id(
-            provider_id, item_id, media_type
-        ):
-            item = await self.async_get_item(item_id, provider_id, media_type)
-        if not item:
-            return ""
-        if item and item.metadata.get("image"):
-            img_url = item.metadata["image"]
-        elif media_type == MediaType.Track and item.album:
-            # try album image instead for tracks
-            return await self.async_get_image_thumb(
-                item.album.item_id, item.album.provider, MediaType.Album, size
-            )
-        elif media_type == MediaType.Album and item.artist:
-            # try artist image instead for albums
-            return await self.async_get_image_thumb(
-                item.artist.item_id, item.artist.provider, MediaType.Artist, size
-            )
-        if not img_url:
-            return None
-        # fetch image and store in cache
-        os.makedirs(cache_folder, exist_ok=True)
-        # download base image
-        async with aiohttp.ClientSession() as session:
-            async with session.get(img_url, verify_ssl=False) as response:
-                assert response.status == 200
-                img_data = await response.read()
-                with open(cache_file_org, "wb") as img_file:
-                    img_file.write(img_data)
-        if not size:
-            # return base image
-            return cache_file_org
-        # save resized image
-        basewidth = size
-        img = Image.open(cache_file_org)
-        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_sized)
-        # return file from cache
-        return cache_file_sized
-
-    async def async_get_stream_details(
-        self, media_item: MediaItem, player_id: str = ""
-    ) -> StreamDetails:
-        """
-        Get streamdetails for the given media_item.
-
-        This is called just-in-time when a player/queue wants a MediaItem to be played.
-        Do not try to request streamdetails in advance as this is expiring data.
-            param media_item: The MediaItem (track/radio) for which to request the streamdetails for.
-            param player_id: Optionally provide the player_id which will play this stream.
-        """
-        if media_item.provider == "uri":
-            # special type: a plain uri was added to the queue
-            streamdetails = StreamDetails(
-                type=StreamType.URL,
-                provider="uri",
-                item_id=media_item.item_id,
-                path=media_item.item_id,
-                content_type=ContentType(media_item.item_id.split(".")[-1]),
-                sample_rate=44100,
-                bit_depth=16,
-            )
-        else:
-            # always request the full db track as there might be other qualities available
-            # except for radio
-            if media_item.media_type == MediaType.Radio:
-                full_track = media_item
-            else:
-                full_track = await self.async_get_track(
-                    media_item.item_id, media_item.provider, lazy=True, refresh=False
-                )
-            # sort by quality and check track availability
-            for prov_media in sorted(
-                full_track.provider_ids, key=lambda x: x.quality, reverse=True
-            ):
-                # get streamdetails from provider
-                music_prov = self.mass.get_provider(prov_media.provider)
-                if not music_prov:
-                    continue  # provider temporary unavailable ?
-
-                streamdetails = await music_prov.async_get_stream_details(
-                    prov_media.item_id
-                )
-                if streamdetails:
-                    break
-
-        if streamdetails:
-            # set player_id on the streamdetails so we know what players stream
-            streamdetails.player_id = player_id
-            # store the path encrypted as we do not want it to be visible in the api
-            streamdetails.path = encrypt_string(streamdetails.path)
-            # set streamdetails as attribute on the media_item
-            # this way the app knows what content is playing
-            media_item.streamdetails = streamdetails
-            return streamdetails
-        return None
-
-    ################ Library synchronization logic ################
-
-    @run_periodic(3600 * 3)
-    async def __async_music_providers_sync(self):
-        """Periodic sync of all music providers."""
-        await asyncio.sleep(10)
-        for prov in self.mass.get_providers(ProviderType.MUSIC_PROVIDER):
-            await self.async_music_provider_sync(prov.id)
-
-    async def async_music_provider_sync(self, prov_id: str):
-        """
-        Sync a music provider.
-
-        param prov_id: {string} -- provider id to sync
-        """
-        provider = self.mass.get_provider(prov_id)
-        if not provider:
-            return
-        if MediaType.Album in provider.supported_mediatypes:
-            await self.async_library_albums_sync(prov_id)
-        if MediaType.Track in provider.supported_mediatypes:
-            await self.async_library_tracks_sync(prov_id)
-        if MediaType.Artist in provider.supported_mediatypes:
-            await self.async_library_artists_sync(prov_id)
-        if MediaType.Playlist in provider.supported_mediatypes:
-            await self.async_library_playlists_sync(prov_id)
-        if MediaType.Radio in provider.supported_mediatypes:
-            await self.async_library_radios_sync(prov_id)
-
-    @sync_task("artists")
-    async def async_library_artists_sync(self, provider_id: str):
-        """Sync library artists for given provider."""
-        music_provider = self.mass.get_provider(provider_id)
-        prev_db_ids = [
-            item.item_id
-            async for item in self.async_get_library_artists(
-                provider_filter=provider_id
-            )
-        ]
-        cur_db_ids = []
-        async for item in music_provider.async_get_library_artists():
-            db_item = await self.async_get_artist(item.item_id, provider_id, lazy=False)
-            cur_db_ids.append(db_item.item_id)
-            if db_item.item_id not in prev_db_ids:
-                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:
-                await self.mass.database.async_remove_from_library(
-                    db_id, MediaType.Artist, provider_id
-                )
-
-    @sync_task("albums")
-    async def async_library_albums_sync(self, provider_id: str):
-        """Sync library albums for given provider."""
-        music_provider = self.mass.get_provider(provider_id)
-        prev_db_ids = [
-            item.item_id
-            async for item in self.async_get_library_albums(provider_filter=provider_id)
-        ]
-        cur_db_ids = []
-        async for item in music_provider.async_get_library_albums():
-
-            db_album = await self.async_get_album(
-                item.item_id, provider_id, album_details=item, lazy=False
-            )
-            if not db_album:
-                LOGGER.error("provider %s album: %s", provider_id, str(item))
-            cur_db_ids.append(db_album.item_id)
-            if db_album.item_id not in prev_db_ids:
-                await self.mass.database.async_add_to_library(
-                    db_album.item_id, MediaType.Album, provider_id
-                )
-        # process deletions
-        for db_id in prev_db_ids:
-            if db_id not in cur_db_ids:
-                await self.mass.database.async_remove_from_library(
-                    db_id, MediaType.Album, provider_id
-                )
-
-    @sync_task("tracks")
-    async def async_library_tracks_sync(self, provider_id: str):
-        """Sync library tracks for given provider."""
-        music_provider = self.mass.get_provider(provider_id)
-        prev_db_ids = [
-            item.item_id
-            async for item in self.async_get_library_tracks(provider_filter=provider_id)
-        ]
-        cur_db_ids = []
-        async for item in music_provider.async_get_library_tracks():
-            db_item = await self.async_get_track(
-                item.item_id, provider_id=provider_id, lazy=False
-            )
-            cur_db_ids.append(db_item.item_id)
-            if db_item.item_id not in prev_db_ids:
-                await self.mass.database.async_add_to_library(
-                    db_item.item_id, MediaType.Track, provider_id
-                )
-        # process deletions
-        for db_id in prev_db_ids:
-            if db_id not in cur_db_ids:
-                await self.mass.database.async_remove_from_library(
-                    db_id, MediaType.Track, provider_id
-                )
-
-    @sync_task("playlists")
-    async def async_library_playlists_sync(self, provider_id: str):
-        """Sync library playlists for given provider."""
-        music_provider = self.mass.get_provider(provider_id)
-        prev_db_ids = [
-            item.item_id
-            async for item in self.async_get_library_playlists(
-                provider_filter=provider_id
-            )
-        ]
-        cur_db_ids = []
-        async for playlist in music_provider.async_get_library_playlists():
-            if playlist is None:
-                continue
-            # always add to db because playlist attributes could have changed
-            db_id = await self.mass.database.async_add_playlist(playlist)
-            cur_db_ids.append(db_id)
-            if db_id not in prev_db_ids:
-                await self.mass.database.async_add_to_library(
-                    db_id, MediaType.Playlist, playlist.provider
-                )
-            # We do not precache/store playlist tracks, these will be retrieved on request only
-        # process playlist deletions
-        for db_id in prev_db_ids:
-            if db_id not in cur_db_ids:
-                await self.mass.database.async_remove_from_library(
-                    db_id, MediaType.Playlist, provider_id
-                )
-
-    @sync_task("radios")
-    async def async_library_radios_sync(self, provider_id: str):
-        """Sync library radios for given provider."""
-        music_provider = self.mass.get_provider(provider_id)
-        prev_db_ids = [
-            item.item_id
-            async for item in self.async_get_library_radios(provider_filter=provider_id)
-        ]
-        cur_db_ids = []
-        async for item in music_provider.async_get_radios():
-            if not item:
-                continue
-            db_id = await self.mass.database.async_get_database_id(
-                item.provider, item.item_id, MediaType.Radio
-            )
-            if not db_id:
-                db_id = await self.mass.database.async_add_radio(item)
-            cur_db_ids.append(db_id)
-            if db_id not in prev_db_ids:
-                await self.mass.database.async_add_to_library(
-                    db_id, MediaType.Radio, provider_id
-                )
-        # process deletions
-        for db_id in prev_db_ids:
-            if db_id not in cur_db_ids:
-                await self.mass.database.async_remove_from_library(
-                    db_id, MediaType.Radio, provider_id
-                )
diff --git a/music_assistant/player_manager.py b/music_assistant/player_manager.py
deleted file mode 100755 (executable)
index 3fdea44..0000000
+++ /dev/null
@@ -1,602 +0,0 @@
-"""PlayerManager: Orchestrates all players from player providers."""
-
-import logging
-from typing import List, Optional
-
-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.models.media_types import MediaItem, MediaType, Track
-from music_assistant.models.player import (
-    PlaybackState,
-    Player,
-    PlayerControl,
-    PlayerControlType,
-)
-from music_assistant.models.player_queue import PlayerQueue, QueueItem, QueueOption
-from music_assistant.models.player_state import PlayerState
-from music_assistant.models.playerprovider import PlayerProvider
-from music_assistant.models.provider import ProviderType
-from music_assistant.utils import (
-    async_iter_items,
-    callback,
-    run_periodic,
-    try_parse_int,
-)
-
-POLL_INTERVAL = 30
-
-LOGGER = logging.getLogger("mass")
-
-
-class PlayerManager:
-    """Several helpers to handle playback through player providers."""
-
-    def __init__(self, mass: MusicAssistantType):
-        """Initialize class."""
-        self.mass = mass
-        self._player_states = {}
-        self._providers = {}
-        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,
-            ],
-        )
-
-    async def async_setup(self):
-        """Async initialize of module."""
-        self.mass.add_job(self.poll_task())
-
-    async def async_close(self):
-        """Handle stop/shutdown."""
-        for player_queue in list(self._player_queues.values()):
-            await player_queue.async_close()
-        for player_state in self.players:
-            await player_state.player.async_on_remove()
-
-    @run_periodic(1)
-    async def poll_task(self):
-        """Check for updates on players that need to be polled."""
-        for player_state in self.players:
-            if player_state.player.should_poll and (
-                self._poll_ticks >= POLL_INTERVAL
-                or player_state.state == PlaybackState.Playing
-            ):
-                await player_state.player.async_on_update()
-        if self._poll_ticks >= POLL_INTERVAL:
-            self._poll_ticks = 0
-        else:
-            self._poll_ticks += 1
-
-    @property
-    def players(self) -> List[PlayerState]:
-        """Return all registered players."""
-        return list(self._player_states.values())
-
-    @property
-    def player_queues(self) -> List[PlayerQueue]:
-        """Return all player queues."""
-        return list(self._player_queues.values())
-
-    @property
-    def providers(self) -> List[PlayerProvider]:
-        """Return all loaded player providers."""
-        return self.mass.get_providers(ProviderType.PLAYER_PROVIDER)
-
-    @callback
-    def get_player(
-        self, player_id: str, return_player_state: bool = True
-    ) -> PlayerState:
-        """Return player by player_id or None if player does not exist."""
-        player_state = self._player_states.get(player_id)
-        if return_player_state and player_state:
-            # return underlying player object
-            return player_state.player
-        return player_state
-
-    @callback
-    def get_player_provider(self, player_id: str) -> PlayerProvider:
-        """Return provider by player_id or None if player does not exist."""
-        player = self.get_player(player_id)
-        return self.mass.get_provider(player.provider_id) if player else None
-
-    @callback
-    def get_player_queue(self, player_id: str) -> PlayerQueue:
-        """Return player's queue by player_id or None if player does not exist."""
-        player = self.get_player(player_id)
-        if not player:
-            LOGGER.warning("Player(queue) %s is not available!", player_id)
-            return None
-        return self._player_queues.get(player.active_queue)
-
-    @callback
-    def get_player_control(self, control_id: str) -> PlayerControl:
-        """Return PlayerControl by id."""
-        if control_id not in self._controls:
-            LOGGER.warning("PlayerControl %s is not available", control_id)
-            return None
-        return self._controls[control_id]
-
-    @callback
-    def get_player_controls(
-        self, filter_type: Optional[PlayerControlType] = None
-    ) -> List[PlayerControl]:
-        """Return all PlayerControls, optionally filtered by type."""
-        return [
-            item
-            for item in self._controls.values()
-            if (filter_type is None or item.type == filter_type)
-        ]
-
-    # ADD/REMOVE/UPDATE HELPERS
-
-    async def async_add_player(self, player: Player) -> None:
-        """Register a new player or update an existing one."""
-        if not player or not player.available:
-            return
-        if player.player_id in self._player_states:
-            return await self.async_update_player(player)
-        # set the mass object on the player
-        player.mass = self.mass
-        # create playerstate and queue object
-        self._player_states[player.player_id] = PlayerState(self.mass, player)
-        self._player_queues[player.player_id] = PlayerQueue(self.mass, player.player_id)
-        # TODO: turn on player if it was previously turned on ?
-        LOGGER.info(
-            "New player added: %s/%s",
-            player.provider_id,
-            self._player_states[player.player_id].name,
-        )
-        self.mass.signal_event(
-            EVENT_PLAYER_ADDED, self._player_states[player.player_id]
-        )
-
-    async def async_remove_player(self, player_id: str):
-        """Remove a player from the registry."""
-        player_state = self._player_states.pop(player_id, None)
-        if player_state:
-            await player_state.player.async_on_remove()
-        self._player_queues.pop(player_id, None)
-        LOGGER.info("Player removed: %s", player_id)
-        self.mass.signal_event(EVENT_PLAYER_REMOVED, {"player_id": player_id})
-
-    async def async_update_player(self, player: Player):
-        """Update an existing player (or register as new if non existing)."""
-        if player.player_id not in self._player_states:
-            return await self.async_add_player(player)
-        await self._player_states[player.player_id].async_update(player)
-
-    async def async_trigger_player_update(self, player_id: str):
-        """Trigger update of an existing player.."""
-        player = self.get_player(player_id)
-        if player:
-            await self._player_states[player.player_id].async_update(player.player)
-
-    async def async_register_player_control(self, 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
-        LOGGER.info(
-            "New PlayerControl (%s) registered: %s\\%s",
-            control.type,
-            control.provider,
-            control.name,
-        )
-        # update all players using this playercontrol
-        for player in self.players:
-            conf = self.mass.config.player_settings[player.player_id]
-            if control.control_id in [
-                conf.get(CONF_POWER_CONTROL),
-                conf.get(CONF_VOLUME_CONTROL),
-            ]:
-                self.mass.add_job(self.async_update_player(player))
-
-    async def async_update_player_control(self, 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)
-        new_state = control.state
-        if self._controls[control.control_id].state == new_state:
-            return
-        self._controls[control.control_id].state = new_state
-        LOGGER.debug(
-            "PlayerControl %s\\%s updated - new state: %s",
-            control.provider,
-            control.name,
-            new_state,
-        )
-        # update all players using this playercontrol
-        for player in self.players:
-            conf = self.mass.config.player_settings[player.player_id]
-            if control.control_id in [
-                conf.get(CONF_POWER_CONTROL),
-                conf.get(CONF_VOLUME_CONTROL),
-            ]:
-                await self.async_trigger_player_update(player.player_id)
-
-    # SERVICE CALLS / PLAYER COMMANDS
-
-    async def async_play_media(
-        self,
-        player_id: str,
-        media_items: 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 queue_opt:
-                QueueOption.Play -> Insert new items in queue and start playing at inserted position
-                QueueOption.Replace -> Replace queue contents with these items
-                QueueOption.Next -> Play item(s) after current playing item
-                QueueOption.Add -> Append new items at end of the queue
-        """
-        player = self.get_player(player_id)
-        if not player:
-            return
-        # a single item or list of items may be provided
-        queue_items = []
-        for media_item in media_items:
-            # collect tracks to play
-            if media_item.media_type == MediaType.Artist:
-                tracks = self.mass.music_manager.async_get_artist_toptracks(
-                    media_item.item_id, provider_id=media_item.provider
-                )
-            elif media_item.media_type == MediaType.Album:
-                tracks = self.mass.music_manager.async_get_album_tracks(
-                    media_item.item_id, provider_id=media_item.provider
-                )
-            elif media_item.media_type == MediaType.Playlist:
-                tracks = self.mass.music_manager.async_get_playlist_tracks(
-                    media_item.item_id, provider_id=media_item.provider
-                )
-            else:
-                tracks = async_iter_items(media_item)  # single track
-            async for track in tracks:
-                queue_item = QueueItem(track)
-                # generate uri for this queue item
-                queue_item.uri = "%s/stream/queue/%s/%s" % (
-                    self.mass.web.internal_url,
-                    player_id,
-                    queue_item.queue_item_id,
-                )
-                queue_items.append(queue_item)
-        # turn on player
-        await self.async_cmd_power_on(player_id)
-        # load items into the queue
-        player_queue = self.get_player_queue(player_id)
-        if queue_opt == QueueOption.Replace or (
-            len(queue_items) > 10 and queue_opt in [QueueOption.Play, QueueOption.Next]
-        ):
-            return await player_queue.async_load(queue_items)
-        if queue_opt == QueueOption.Next:
-            return await player_queue.async_insert(queue_items, 1)
-        if queue_opt == QueueOption.Play:
-            return await player_queue.async_insert(queue_items, 0)
-        if queue_opt == QueueOption.Add:
-            return await player_queue.async_append(queue_items)
-
-    async def async_cmd_play_uri(self, player_id: str, uri: str):
-        """
-        Play the specified uri/url on the given player.
-
-        Will create a fake track on the queue.
-
-            :param player_id: player_id of the player to handle the command.
-            :param uri: Url/Uri that can be played by a player.
-            :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.Next -> Play item(s) after current playing item
-                QueueOption.Add -> Append new items at end of the queue
-        """
-        player = self.get_player(player_id)
-        if not player:
-            return
-        queue_item = QueueItem(
-            Track(
-                item_id=uri,
-                provider="uri",
-                name=uri,
-            )
-        )
-        # generate uri for this queue item
-        queue_item.uri = "%s/stream/%s/%s" % (
-            self.mass.web.internal_url,
-            player_id,
-            queue_item.queue_item_id,
-        )
-        # turn on player
-        await self.async_cmd_power_on(player_id)
-        # load item into the queue
-        player_queue = self.get_player_queue(player_id)
-        return await player_queue.async_insert([queue_item], 0)
-
-    async def async_cmd_stop(self, player_id: str) -> None:
-        """
-        Send STOP command to given player.
-
-            :param player_id: player_id of the player to handle the command.
-        """
-        player = self.get_player(player_id)
-        if not player:
-            return
-        queue_player_id = player.active_queue
-        queue_player = self.get_player(queue_player_id)
-        return await queue_player.async_cmd_stop()
-
-    async def async_cmd_play(self, player_id: str) -> None:
-        """
-        Send PLAY command to given player.
-
-            :param player_id: player_id of the player to handle the command.
-        """
-        player = self.get_player(player_id)
-        if not player:
-            return
-        queue_player_id = player.active_queue
-        queue_player = self.get_player(queue_player_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()
-
-    async def async_cmd_pause(self, player_id: str):
-        """
-        Send PAUSE command to given player.
-
-            :param player_id: player_id of the player to handle the command.
-        """
-        player = self.get_player(player_id)
-        if not player:
-            return
-        queue_player_id = player.active_queue
-        queue_player = self.get_player(queue_player_id)
-        return await queue_player.async_cmd_pause()
-
-    async def async_cmd_play_pause(self, player_id: str):
-        """
-        Toggle play/pause on given player.
-
-            :param player_id: player_id of the player to handle the command.
-        """
-        player = self.get_player(player_id)
-        if not player:
-            return
-        if player.state == PlaybackState.Playing:
-            return await self.async_cmd_pause(player_id)
-        return await self.async_cmd_play(player_id)
-
-    async def async_cmd_next(self, player_id: str):
-        """
-        Send NEXT TRACK command to given player.
-
-            :param player_id: player_id of the player to handle the command.
-        """
-        player = self.get_player(player_id)
-        if not player:
-            return
-        queue_player_id = player.active_queue
-        return await self.get_player_queue(queue_player_id).async_next()
-
-    async def async_cmd_previous(self, player_id: str):
-        """
-        Send PREVIOUS TRACK command to given player.
-
-            :param player_id: player_id of the player to handle the command.
-        """
-        player = self.get_player(player_id)
-        if not player:
-            return
-        queue_player_id = player.active_queue
-        return await self.get_player_queue(queue_player_id).async_previous()
-
-    async def async_cmd_power_on(self, player_id: str) -> None:
-        """
-        Send POWER ON command to given player.
-
-            :param player_id: player_id of the player to handle the command.
-        """
-        player = self.get_player(player_id)
-        if not player:
-            return
-        player_config = self.mass.config.player_settings[player.player_id]
-        # turn on player
-        await player.async_cmd_power_on()
-        # player control support
-        if player_config.get(CONF_POWER_CONTROL):
-            control = self.get_player_control(player_config[CONF_POWER_CONTROL])
-            if control:
-                await control.async_set_state(True)
-
-    async def async_cmd_power_off(self, player_id: str) -> None:
-        """
-        Send POWER OFF command to given player.
-
-            :param player_id: player_id of the player to handle the command.
-        """
-        player = self.get_player(player_id)
-        if not player:
-            return
-        # send stop if player is playing
-        if player.active_queue == player_id:
-            await self.async_cmd_stop(player_id)
-        player_config = self.mass.config.player_settings[player.player_id]
-        # turn off player
-        await player.async_cmd_power_off()
-        # player control support
-        if player_config.get(CONF_POWER_CONTROL):
-            control = self.get_player_control(player_config[CONF_POWER_CONTROL])
-            if control:
-                await control.async_set_state(False)
-        # handle group power
-        if player.is_group_player:
-            # player is group, turn off all childs
-            for child_player_id in player.group_childs:
-                child_player = self.get_player(child_player_id)
-                if child_player and child_player.powered:
-                    self.mass.add_job(self.async_cmd_power_off(child_player_id))
-        else:
-            # if this was the last powered player in the group, turn off group
-            for parent_player_id in player.group_parents:
-                parent_player = self.get_player(parent_player_id)
-                if not parent_player or not parent_player.powered:
-                    continue
-                has_powered_players = False
-                for child_player_id in parent_player.group_childs:
-                    if child_player_id == player_id:
-                        continue
-                    child_player = self.get_player(child_player_id)
-                    if child_player and child_player.powered:
-                        has_powered_players = True
-                if not has_powered_players:
-                    self.mass.add_job(self.async_cmd_power_off(parent_player_id))
-
-    async def async_cmd_power_toggle(self, player_id: str):
-        """
-        Send POWER TOGGLE command to given player.
-
-            :param player_id: player_id of the player to handle the command.
-        """
-        player = self.get_player(player_id)
-        if not player:
-            return
-        if player.powered:
-            return await self.async_cmd_power_off(player_id)
-        return await self.async_cmd_power_on(player_id)
-
-    async def async_cmd_volume_set(self, player_id: str, volume_level: int) -> None:
-        """
-        Send volume level command to given player.
-
-            :param player_id: player_id of the player to handle the command.
-            :param volume_level: volume level to set (0..100).
-        """
-        player = self.get_player(player_id)
-        if not player or not player.powered:
-            return
-        player_config = self.mass.config.player_settings[player.player_id]
-        volume_level = try_parse_int(volume_level)
-        if volume_level < 0:
-            volume_level = 0
-        elif volume_level > 100:
-            volume_level = 100
-        # player control support
-        if player_config.get(CONF_VOLUME_CONTROL):
-            control = self.get_player_control(player_config[CONF_VOLUME_CONTROL])
-            if control:
-                await control.async_set_state(volume_level)
-                # just force full volume on actual player if volume is outsourced to volumecontrol
-                await player.async_cmd_volume_set(player_id, 100)
-        # handle group volume
-        elif player.is_group_player:
-            cur_volume = player.volume_level
-            new_volume = volume_level
-            volume_dif = new_volume - cur_volume
-            if cur_volume == 0:
-                volume_dif_percent = 1 + (new_volume / 100)
-            else:
-                volume_dif_percent = volume_dif / cur_volume
-            for child_player_id in player.group_childs:
-                child_player = self.get_player(child_player_id)
-                if child_player and child_player.available and child_player.powered:
-                    cur_child_volume = child_player.volume_level
-                    new_child_volume = cur_child_volume + (
-                        cur_child_volume * volume_dif_percent
-                    )
-                    await self.async_cmd_volume_set(child_player_id, new_child_volume)
-        # regular volume command
-        else:
-            await player.async_cmd_volume_set(volume_level)
-
-    async def async_cmd_volume_up(self, player_id: str):
-        """
-        Send volume UP command to given player.
-
-            :param player_id: player_id of the player to handle the command.
-        """
-        player = self.get_player(player_id)
-        if not player:
-            return
-        new_level = player.volume_level + 1
-        if new_level > 100:
-            new_level = 100
-        return await self.async_cmd_volume_set(player_id, new_level)
-
-    async def async_cmd_volume_down(self, player_id: str):
-        """
-        Send volume DOWN command to given player.
-
-            :param player_id: player_id of the player to handle the command.
-        """
-        player = self.get_player(player_id)
-        if not player:
-            return
-        new_level = player.volume_level - 1
-        if new_level < 0:
-            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):
-        """
-        Send MUTE command to given player.
-
-            :param player_id: player_id of the player to handle the command.
-            :param is_muted: bool with the new mute state.
-        """
-        player = self.get_player(player_id)
-        if not player:
-            return
-        # TODO: handle mute on volumecontrol?
-        return await player.async_cmd_volume_mute(is_muted)
-
-    # OTHER/HELPER FUNCTIONS
-
-    async def async_get_gain_correct(
-        self, player_id: str, item_id: str, provider_id: str
-    ):
-        """Get gain correction for given player / track combination."""
-        player_conf = self.mass.config.get_player_config(player_id)
-        if not player_conf["volume_normalisation"]:
-            return 0
-        target_gain = int(player_conf["target_volume"])
-        fallback_gain = int(player_conf["fallback_gain_correct"])
-        track_loudness = await self.mass.database.async_get_track_loudness(
-            item_id, provider_id
-        )
-        if track_loudness is None:
-            gain_correct = fallback_gain
-        else:
-            gain_correct = target_gain - track_loudness
-        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)
diff --git a/music_assistant/providers/builtin/__init__.py b/music_assistant/providers/builtin/__init__.py
new file mode 100644 (file)
index 0000000..98f2fd9
--- /dev/null
@@ -0,0 +1,209 @@
+"""Local player provider."""
+import asyncio
+import logging
+import signal
+import subprocess
+from typing import List
+
+from music_assistant.models.config_entry import ConfigEntry
+from music_assistant.models.player import DeviceInfo, PlaybackState, Player
+from music_assistant.models.provider import PlayerProvider
+
+PROV_ID = "builtin"
+PROV_NAME = "Built-in (local) player"
+LOGGER = logging.getLogger(PROV_ID)
+
+
+async def async_setup(mass):
+    """Perform async setup of this Plugin/Provider."""
+    prov = BuiltinPlayerProvider()
+    await mass.async_register_provider(prov)
+
+
+class BuiltinPlayerProvider(PlayerProvider):
+    """Demo PlayerProvider which provides a single local player."""
+
+    @property
+    def id(self) -> str:
+        """Return provider ID for this provider."""
+        return PROV_ID
+
+    @property
+    def name(self) -> str:
+        """Return provider Name for this provider."""
+        return PROV_NAME
+
+    @property
+    def config_entries(self) -> List[ConfigEntry]:
+        """Return Config Entries for this provider."""
+        return []
+
+    async def async_on_start(self) -> bool:
+        """Handle initialization of the provider based on config."""
+        player = BuiltinPlayer("local_player", "Built-in player on the server")
+        self.mass.add_job(self.mass.players.async_add_player(player))
+        return True
+
+    async def async_on_stop(self):
+        """Handle correct close/cleanup of the provider on exit."""
+        for player in self.players:
+            await player.async_cmd_stop()
+
+
+class BuiltinPlayer(Player):
+    """Representation of a BuiltinPlayer."""
+
+    def __init__(self, player_id: str, name: str) -> None:
+        """Initialize the built-in player."""
+        self._player_id = player_id
+        self._name = name
+        self._powered = False
+        self._elapsed_time = 0
+        self._state = PlaybackState.Stopped
+        self._current_uri = ""
+        self._volume_level = 100
+        self._muted = False
+        self._sox = None
+        self._progress_task = None
+
+    @property
+    def player_id(self) -> str:
+        """Return player id of this player."""
+        return self._player_id
+
+    @property
+    def provider_id(self) -> str:
+        """Return provider id of this player."""
+        return PROV_ID
+
+    @property
+    def name(self) -> str:
+        """Return name of the player."""
+        return self._name
+
+    @property
+    def powered(self) -> bool:
+        """Return current power state of player."""
+        return self._powered
+
+    @property
+    def elapsed_time(self) -> float:
+        """Return elapsed_time of current playing uri in seconds."""
+        return self._elapsed_time
+
+    @property
+    def state(self) -> PlaybackState:
+        """Return current PlaybackState of player."""
+        return self._state
+
+    @property
+    def available(self) -> bool:
+        """Return current availablity of player."""
+        return True
+
+    @property
+    def current_uri(self) -> str:
+        """Return currently loaded uri of player (if any)."""
+        return self._current_uri
+
+    @property
+    def volume_level(self) -> int:
+        """Return current volume level of player (scale 0..100)."""
+        return self._volume_level
+
+    @property
+    def muted(self) -> bool:
+        """Return current mute state of player."""
+        return self._muted
+
+    @property
+    def is_group_player(self) -> bool:
+        """Return True if this player is a group player."""
+        return False
+
+    @property
+    def device_info(self) -> DeviceInfo:
+        """Return the device info for this player."""
+        return DeviceInfo(
+            model="Demo", address="http://demo:12345", manufacturer=PROV_NAME
+        )
+
+    # SERVICE CALLS / PLAYER COMMANDS
+
+    async def async_cmd_play_uri(self, uri: str):
+        """Play the specified uri/url on the player."""
+        if self._sox:
+            await self.async_cmd_stop()
+        self._current_uri = uri
+        self._sox = subprocess.Popen(["play", "-t", "flac", "-q", uri])
+        self._state = PlaybackState.Playing
+        self._powered = True
+        self.update_state()
+
+        async def report_progress():
+            """Report fake progress while sox is playing."""
+            LOGGER.info("Playback started on player %s", self.name)
+            self._elapsed_time = 0
+            while self._sox and not self._sox.poll():
+                await asyncio.sleep(1)
+                self._elapsed_time += 1
+                self.update_state()
+            LOGGER.info("Playback stopped on player %s", self.name)
+            self._elapsed_time = 0
+            self._state = PlaybackState.Stopped
+            self.update_state()
+
+        if self._progress_task:
+            self._progress_task.cancel()
+        self._progress_task = self.mass.add_job(report_progress)
+
+    async def async_cmd_stop(self) -> None:
+        """Send STOP command to player."""
+        if self._sox:
+            self._sox.terminate()
+            self._sox = None
+        self._state = PlaybackState.Stopped
+        self.update_state()
+
+    async def async_cmd_play(self) -> None:
+        """Send PLAY command to player."""
+        if self._sox:
+            self._sox.send_signal(signal.SIGCONT)
+            self._state = PlaybackState.Playing
+            self.update_state()
+
+    async def async_cmd_pause(self):
+        """Send PAUSE command to given player."""
+        if self._sox:
+            self._sox.send_signal(signal.SIGSTOP)
+        self._state = PlaybackState.Paused
+        self.update_state()
+
+    async def async_cmd_power_on(self) -> None:
+        """Send POWER ON command to player."""
+        self._powered = True
+        self.update_state()
+
+    async def async_cmd_power_off(self) -> None:
+        """Send POWER OFF command to player."""
+        await self.async_cmd_stop()
+        self._powered = False
+        self.update_state()
+
+    async def async_cmd_volume_set(self, volume_level: int) -> None:
+        """
+        Send volume level command to given player.
+
+            :param volume_level: volume level to set (0..100).
+        """
+        self._volume_level = volume_level
+        self.update_state()
+
+    async def async_cmd_volume_mute(self, is_muted=False):
+        """
+        Send volume MUTE command to given player.
+
+            :param is_muted: bool with new mute state.
+        """
+        self._muted = is_muted
+        self.update_state()
diff --git a/music_assistant/providers/builtin/icon.png b/music_assistant/providers/builtin/icon.png
new file mode 100644 (file)
index 0000000..092121e
Binary files /dev/null and b/music_assistant/providers/builtin/icon.png differ
diff --git a/music_assistant/providers/builtin/translations.json b/music_assistant/providers/builtin/translations.json
new file mode 100644 (file)
index 0000000..12c5f9c
--- /dev/null
@@ -0,0 +1,5 @@
+{
+    "nl": {
+        "Built-in (local) player": "Ingebouwde speler van de server"
+    }
+}
\ No newline at end of file
index ffc33dcd0fac2429a4e7369015456601fe1c4e3f..d406e03462f3e602e379920d7dbc0b0a4f57f2ae 100644 (file)
@@ -6,7 +6,7 @@ from typing import List
 
 import pychromecast
 from music_assistant.models.config_entry import ConfigEntry
-from music_assistant.models.playerprovider import PlayerProvider
+from music_assistant.models.provider import PlayerProvider
 from pychromecast.controllers.multizone import MultizoneManager
 
 from .const import PROV_ID, PROV_NAME, PROVIDER_CONFIG_ENTRIES
@@ -86,17 +86,17 @@ class ChromecastProvider(PlayerProvider):
             port=service[5],
         )
         player_id = cast_info.uuid
-        player_state = self.mass.player_manager.get_player(player_id)
-        if player_state:
+        player = self.mass.players.get_player(player_id)
+        if player:
             # player already added, the player will take care of reconnects itself.
-            self.mass.add_job(player_state.player.set_cast_info, cast_info)
+            self.mass.add_job(player.set_cast_info, cast_info)
             return
         LOGGER.debug(
             "Chromecast discovered: %s (%s)", cast_info.friendly_name, player_id
         )
         player = ChromecastPlayer(self.mass, cast_info)
         self.mass.add_job(player.set_cast_info, cast_info)
-        self.mass.add_job(self.mass.player_manager.async_add_player(player))
+        self.mass.add_job(self.mass.players.async_add_player(player))
 
     def __chromecast_remove_callback(self, cast_uuid, cast_service_name, cast_service):
         """Handle a Chromecast removed event."""
@@ -104,4 +104,4 @@ class ChromecastProvider(PlayerProvider):
         player_id = str(cast_service[1])
         friendly_name = cast_service[3]
         LOGGER.debug("Chromecast removed: %s - %s", friendly_name, player_id)
-        self.mass.add_job(self.mass.player_manager.async_remove_player(player_id))
+        self.mass.add_job(self.mass.players.async_remove_player(player_id))
diff --git a/music_assistant/providers/chromecast/icon.png b/music_assistant/providers/chromecast/icon.png
new file mode 100644 (file)
index 0000000..e7372ee
Binary files /dev/null and b/music_assistant/providers/chromecast/icon.png differ
index 0b2a3795f4b6c276a8c2033b01e60346deeb9486..a0854d76cebfafea20385788252bd9debced2ae5 100644 (file)
@@ -6,6 +6,7 @@ from typing import List, Optional
 
 import pychromecast
 from music_assistant.helpers.typing import MusicAssistantType
+from music_assistant.helpers.util import compare_strings, yield_chunks
 from music_assistant.models.config_entry import ConfigEntry
 from music_assistant.models.player import (
     DeviceInfo,
@@ -14,7 +15,6 @@ from music_assistant.models.player import (
     PlayerFeature,
 )
 from music_assistant.models.player_queue import QueueItem
-from music_assistant.utils import compare_strings, yield_chunks
 from pychromecast.controllers.multizone import MultizoneController
 from pychromecast.socket_client import (
     CONNECTION_STATUS_CONNECTED,
@@ -300,7 +300,7 @@ class ChromecastPlayer(Player):
 
     async def async_on_update(self) -> None:
         """Call when player is periodically polled by the player manager (should_poll=True)."""
-        if self.mass.player_manager.get_player(self.player_id).active_queue.startswith(
+        if self.mass.players.get_player_state(self.player_id).active_queue.startswith(
             "group_player"
         ):
             self.mass.add_job(self._chromecast.media_controller.update_status)
@@ -360,7 +360,7 @@ class ChromecastPlayer(Player):
 
     async def async_cmd_play_uri(self, uri: str) -> None:
         """Play single uri on player."""
-        player_queue = self.mass.player_manager.get_player_queue(self.player_id)
+        player_queue = self.mass.players.get_player_queue(self.player_id)
         if player_queue.use_queue_stream:
             # create CC queue so that skip and previous will work
             queue_item = QueueItem()
@@ -371,7 +371,7 @@ class ChromecastPlayer(Player):
 
     async def async_cmd_queue_load(self, queue_items: List[QueueItem]) -> None:
         """Load (overwrite) queue with new items."""
-        player_queue = self.mass.player_manager.get_player_queue(self.player_id)
+        player_queue = self.mass.players.get_player_queue(self.player_id)
         cc_queue_items = self.__create_queue_items(queue_items[:50])
         repeat_enabled = player_queue.use_queue_stream or player_queue.repeat_enabled
         queuedata = {
@@ -407,7 +407,7 @@ class ChromecastPlayer(Player):
 
     def __create_queue_item(self, track):
         """Create CC queue item from track info."""
-        player_queue = self.mass.player_manager.get_player_queue(self.player_id)
+        player_queue = self.mass.players.get_player_queue(self.player_id)
         return {
             "opt_itemId": track.queue_item_id,
             "autoplay": True,
diff --git a/music_assistant/providers/demo/__init__.py b/music_assistant/providers/demo/__init__.py
deleted file mode 100644 (file)
index 09c851c..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-"""Demo/test providers."""
-
-from .demo_playerprovider import DemoPlayerProvider
-
-
-async def async_setup(mass):
-    """Perform async setup of this Plugin/Provider."""
-    prov = DemoPlayerProvider()
-    await mass.async_register_provider(prov)
diff --git a/music_assistant/providers/demo/demo_musicprovider.py b/music_assistant/providers/demo/demo_musicprovider.py
deleted file mode 100644 (file)
index dfee494..0000000
+++ /dev/null
@@ -1 +0,0 @@
-"""Demo music provider."""
diff --git a/music_assistant/providers/demo/demo_playerprovider.py b/music_assistant/providers/demo/demo_playerprovider.py
deleted file mode 100644 (file)
index 6ed6b72..0000000
+++ /dev/null
@@ -1,206 +0,0 @@
-"""Demo/test providers."""
-import asyncio
-import logging
-import signal
-import subprocess
-from typing import List
-
-from music_assistant.models.config_entry import ConfigEntry
-from music_assistant.models.player import DeviceInfo, PlaybackState, Player
-from music_assistant.models.playerprovider import PlayerProvider
-
-PROV_ID = "demo_player"
-PROV_NAME = "Demo/Test players"
-LOGGER = logging.getLogger(PROV_ID)
-
-
-class DemoPlayerProvider(PlayerProvider):
-    """Demo PlayerProvider which provides fake players."""
-
-    @property
-    def id(self) -> str:
-        """Return provider ID for this provider."""
-        return PROV_ID
-
-    @property
-    def name(self) -> str:
-        """Return provider Name for this provider."""
-        return PROV_NAME
-
-    @property
-    def config_entries(self) -> List[ConfigEntry]:
-        """Return Config Entries for this provider."""
-        return []
-
-    async def async_on_start(self) -> bool:
-        """Handle initialization of the provider based on config."""
-        # create fake/test regular player 1
-        player = DemoPlayer("demo_player_1", "Demo player 1")
-        self.mass.add_job(self.mass.player_manager.async_add_player(player))
-        player = DemoPlayer("demo_player_2", "Demo player 2")
-        self.mass.add_job(self.mass.player_manager.async_add_player(player))
-        return True
-
-    async def async_on_stop(self):
-        """Handle correct close/cleanup of the provider on exit."""
-        for player in self.players:
-            await player.async_cmd_stop()
-
-
-class DemoPlayer(Player):
-    """Representation of a player for the demo provider."""
-
-    def __init__(self, player_id: str, name: str) -> None:
-        """Initialize a demo player."""
-        self._player_id = player_id
-        self._name = name
-        self._powered = False
-        self._elapsed_time = 0
-        self._state = PlaybackState.Stopped
-        self._current_uri = ""
-        self._volume_level = 100
-        self._muted = False
-        self._sox = None
-        self._progress_task = None
-
-    @property
-    def player_id(self) -> str:
-        """Return player id of this player."""
-        return self._player_id
-
-    @property
-    def provider_id(self) -> str:
-        """Return provider id of this player."""
-        return PROV_ID
-
-    @property
-    def name(self) -> str:
-        """Return name of the player."""
-        return self._name
-
-    @property
-    def powered(self) -> bool:
-        """Return current power state of player."""
-        return self._powered
-
-    @property
-    def elapsed_time(self) -> float:
-        """Return elapsed_time of current playing uri in (fractions of) seconds."""
-        return self._elapsed_time
-
-    @property
-    def state(self) -> PlaybackState:
-        """Return current PlaybackState of player."""
-        return self._state
-
-    @property
-    def available(self) -> bool:
-        """Return current availablity of player."""
-        return True
-
-    @property
-    def current_uri(self) -> str:
-        """Return currently loaded uri of player (if any)."""
-        return self._current_uri
-
-    @property
-    def volume_level(self) -> int:
-        """Return current volume level of player (scale 0..100)."""
-        return self._volume_level
-
-    @property
-    def muted(self) -> bool:
-        """Return current mute state of player."""
-        return self._muted
-
-    @property
-    def is_group_player(self) -> bool:
-        """Return True if this player is a group player."""
-        return False
-
-    @property
-    def device_info(self) -> DeviceInfo:
-        """Return the device info for this player."""
-        return DeviceInfo(
-            model="Demo", address="http://demo:12345", manufacturer=PROV_NAME
-        )
-
-    # SERVICE CALLS / PLAYER COMMANDS
-
-    async def async_cmd_play_uri(self, uri: str):
-        """Play the specified uri/url on the player."""
-        if self._sox:
-            await self.async_cmd_stop()
-        self._current_uri = uri
-        self._sox = subprocess.Popen(["play", "-t", "flac", "-q", uri])
-        self._state = PlaybackState.Playing
-        self._powered = True
-        self.update_state()
-
-        async def report_progress():
-            """Report fake progress while sox is playing."""
-            LOGGER.info("Playback started on player %s", self.name)
-            self._elapsed_time = 0
-            while self._sox and not self._sox.poll():
-                await asyncio.sleep(1)
-                self._elapsed_time += 1
-                self.update_state()
-            LOGGER.info("Playback stopped on player %s", self.name)
-            self._elapsed_time = 0
-            self._state = PlaybackState.Stopped
-            self.update_state()
-
-        if self._progress_task:
-            self._progress_task.cancel()
-        self._progress_task = self.mass.add_job(report_progress)
-
-    async def async_cmd_stop(self) -> None:
-        """Send STOP command to player."""
-        if self._sox:
-            self._sox.terminate()
-            self._sox = None
-        self._state = PlaybackState.Stopped
-        self.update_state()
-
-    async def async_cmd_play(self) -> None:
-        """Send PLAY command to player."""
-        if self._sox:
-            self._sox.send_signal(signal.SIGCONT)
-            self._state = PlaybackState.Playing
-            self.update_state()
-
-    async def async_cmd_pause(self):
-        """Send PAUSE command to given player."""
-        if self._sox:
-            self._sox.send_signal(signal.SIGSTOP)
-        self._state = PlaybackState.Paused
-        self.update_state()
-
-    async def async_cmd_power_on(self) -> None:
-        """Send POWER ON command to player."""
-        self._powered = True
-        self.update_state()
-
-    async def async_cmd_power_off(self) -> None:
-        """Send POWER OFF command to player."""
-        await self.async_cmd_stop()
-        self._powered = False
-        self.update_state()
-
-    async def async_cmd_volume_set(self, volume_level: int) -> None:
-        """
-        Send volume level command to given player.
-
-            :param volume_level: volume level to set (0..100).
-        """
-        self._volume_level = volume_level
-        self.update_state()
-
-    async def async_cmd_volume_mute(self, is_muted=False):
-        """
-        Send volume MUTE command to given player.
-
-            :param is_muted: bool with new mute state.
-        """
-        self._muted = is_muted
-        self.update_state()
diff --git a/music_assistant/providers/fanarttv/__init__.py b/music_assistant/providers/fanarttv/__init__.py
new file mode 100755 (executable)
index 0000000..fb43e7a
--- /dev/null
@@ -0,0 +1,108 @@
+"""FanartTv Metadata provider."""
+
+import logging
+from typing import Dict, List
+
+import aiohttp
+import orjson
+from asyncio_throttle import Throttler
+from music_assistant.models.config_entry import ConfigEntry
+from music_assistant.models.provider import MetadataProvider
+
+# TODO: add support for personal api keys ?
+# TODO: Add support for album artwork ?
+
+PROV_ID = "fanarttv"
+PROV_NAME = "Fanart.tv"
+
+LOGGER = logging.getLogger(PROV_ID)
+
+CONFIG_ENTRIES = []
+
+
+async def async_setup(mass) -> None:
+    """Perform async setup of this Plugin/Provider."""
+    prov = FanartTvProvider(mass)
+    await mass.async_register_provider(prov)
+
+
+class FanartTvProvider(MetadataProvider):
+    """Fanart.tv metadata provider."""
+
+    def __init__(self, mass):
+        """Initialize class."""
+        self.mass = mass
+        self.throttler = Throttler(rate_limit=1, period=2)
+
+    async def async_on_start(self) -> bool:
+        """
+        Handle initialization of the provider based on config.
+
+        Return bool if start was succesfull. Called on startup.
+        """
+        return True  # we have nothing to initialize
+
+    @property
+    def id(self) -> str:
+        """Return provider ID for this provider."""
+        return PROV_ID
+
+    @property
+    def name(self) -> str:
+        """Return provider Name for this provider."""
+        return PROV_NAME
+
+    @property
+    def config_entries(self) -> List[ConfigEntry]:
+        """Return Config Entries for this provider."""
+        return CONFIG_ENTRIES
+
+    async def async_get_artist_images(self, mb_artist_id: str) -> Dict:
+        """Retrieve images by musicbrainz artist id."""
+        metadata = {}
+        data = await self.__async_get_data("music/%s" % mb_artist_id)
+        if data:
+            if data.get("hdmusiclogo"):
+                metadata["logo"] = data["hdmusiclogo"][0]["url"]
+            elif data.get("musiclogo"):
+                metadata["logo"] = data["musiclogo"][0]["url"]
+            if data.get("artistbackground"):
+                count = 0
+                for item in data["artistbackground"]:
+                    key = "fanart" if count == 0 else "fanart.%s" % count
+                    metadata[key] = item["url"]
+            if data.get("artistthumb"):
+                url = data["artistthumb"][0]["url"]
+                if "2a96cbd8b46e442fc41c2b86b821562f" not in url:
+                    metadata["image"] = url
+            if data.get("musicbanner"):
+                metadata["banner"] = data["musicbanner"][0]["url"]
+        return metadata
+
+    async def __async_get_data(self, endpoint, params=None):
+        """Get data from api."""
+        if params is None:
+            params = {}
+        url = "http://webservice.fanart.tv/v3/%s" % endpoint
+        params["api_key"] = "639191cb0774661597f28a47e7e2bad5"
+        async with self.throttler:
+            async with self.mass.http_session.get(
+                url, params=params, verify_ssl=False
+            ) as response:
+                try:
+                    result = await response.json(loads=orjson.loads)
+                except (
+                    aiohttp.client_exceptions.ContentTypeError,
+                    orjson.decoder.JSONDecodeError,
+                ):
+                    LOGGER.error("Failed to retrieve %s", endpoint)
+                    text_result = await response.text()
+                    LOGGER.debug(text_result)
+                    return None
+                except aiohttp.client_exceptions.ClientConnectorError:
+                    LOGGER.error("Failed to retrieve %s", endpoint)
+                    return None
+                if "error" in result and "limit" in result["error"]:
+                    LOGGER.error(result["error"])
+                    return None
+                return result
diff --git a/music_assistant/providers/fanarttv/icon.png b/music_assistant/providers/fanarttv/icon.png
new file mode 100644 (file)
index 0000000..17b39a4
Binary files /dev/null and b/music_assistant/providers/fanarttv/icon.png differ
index fbbc47e79912574c87b22961557e47244fb1a816..5e4c17984608c2593b327d872b9ee2ed74224c4b 100644 (file)
@@ -5,6 +5,7 @@ import os
 from typing import List, Optional
 
 import taglib
+from music_assistant.helpers.util import parse_title_and_version
 from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType
 from music_assistant.models.media_types import (
     Album,
@@ -16,9 +17,8 @@ from music_assistant.models.media_types import (
     Track,
     TrackQuality,
 )
-from music_assistant.models.musicprovider import MusicProvider
+from music_assistant.models.provider import MusicProvider
 from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
-from music_assistant.utils import parse_title_and_version
 
 PROV_ID = "file"
 PROV_NAME = "Local files and playlists"
@@ -32,12 +32,12 @@ CONFIG_ENTRIES = [
     ConfigEntry(
         entry_key=CONF_MUSIC_DIR,
         entry_type=ConfigEntryType.STRING,
-        description_key="file_prov_music_path",
+        description="file_prov_music_path",
     ),
     ConfigEntry(
         entry_key=CONF_PLAYLISTS_DIR,
         entry_type=ConfigEntryType.STRING,
-        description_key="file_prov_playlists_path",
+        description="file_prov_playlists_path",
     ),
 ]
 
@@ -395,7 +395,7 @@ class FileProvider(MusicProvider):
             prov_id = uri.split("://")[0]
             prov_item_id = uri.split("/")[-1].split(".")[0].split(":")[-1]
             try:
-                return await self.mass.music_manager.async_get_track(
+                return await self.mass.music.async_get_track(
                     prov_item_id, prov_id, lazy=False
                 )
             except Exception as exc:
diff --git a/music_assistant/providers/file/icon.png b/music_assistant/providers/file/icon.png
new file mode 100644 (file)
index 0000000..bd2df04
Binary files /dev/null and b/music_assistant/providers/file/icon.png differ
index 78848994ad4a3f275af93f87a4c7c04b7c239e27..f9527837958bf5bf6745928313b327f843a87afd 100644 (file)
@@ -8,7 +8,7 @@ from music_assistant.constants import CONF_GROUP_DELAY
 from music_assistant.helpers.typing import MusicAssistantType
 from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType
 from music_assistant.models.player import DeviceInfo, PlaybackState, Player
-from music_assistant.models.playerprovider import PlayerProvider
+from music_assistant.models.provider import PlayerProvider
 
 PROV_ID = "group_player"
 PROV_NAME = "Group player creator"
@@ -22,7 +22,7 @@ CONFIG_ENTRIES = [
     ConfigEntry(
         entry_key=CONF_PLAYER_COUNT,
         entry_type=ConfigEntryType.INT,
-        description_key=CONF_PLAYER_COUNT,
+        description=CONF_PLAYER_COUNT,
         default_value=1,
         range=(0, 10),
     )
@@ -55,10 +55,10 @@ class GroupPlayerProvider(PlayerProvider):
 
     async def async_on_start(self) -> bool:
         """Handle initialization of the provider based on config."""
-        conf = self.mass.config.providers[PROV_ID]
+        conf = self.mass.config.player_providers[PROV_ID]
         for index in range(conf[CONF_PLAYER_COUNT]):
             player = GroupPlayer(self.mass, index)
-            self.mass.add_job(self.mass.player_manager.async_add_player(player))
+            self.mass.add_job(self.mass.players.async_add_player(player))
         return True
 
     async def async_on_stop(self):
@@ -137,7 +137,7 @@ class GroupPlayer(Player):
         """Return elapsed timefor first child player."""
         if self.state in [PlaybackState.Playing, PlaybackState.Paused]:
             for player_id in self.group_childs:
-                player = self.mass.player_manager.get_player(player_id)
+                player = self.mass.players.get_player(player_id)
                 if player:
                     return player.elapsed_time
         return 0
@@ -173,18 +173,19 @@ class GroupPlayer(Player):
         """Return config entries for this group player."""
         all_players = [
             {"text": item.name, "value": item.player_id}
-            for item in self.mass.player_manager.players
+            for item in self.mass.players.player_states
             if item.player_id is not self._player_id
         ]
         selected_players_ids = self.mass.config.get_player_config(self.player_id).get(
             CONF_PLAYERS, []
         )
+        # selected_players_ids = []
         selected_players = []
         for player_id in selected_players_ids:
-            player = self.mass.player_manager.get_player(player_id)
-            if player:
+            player_state = self.mass.players.get_player_state(player_id)
+            if player_state:
                 selected_players.append(
-                    {"text": player.name, "value": player.player_id}
+                    {"text": player_state.name, "value": player_state.player_id}
                 )
         default_master = ""
         if selected_players:
@@ -195,7 +196,7 @@ class GroupPlayer(Player):
                 entry_type=ConfigEntryType.STRING,
                 default_value=[],
                 values=all_players,
-                description_key=CONF_PLAYERS,
+                description=CONF_PLAYERS,
                 multi_value=True,
             ),
             ConfigEntry(
@@ -203,7 +204,7 @@ class GroupPlayer(Player):
                 entry_type=ConfigEntryType.STRING,
                 default_value=default_master,
                 values=selected_players,
-                description_key=CONF_MASTER,
+                description=CONF_MASTER,
                 multi_value=False,
                 depends_on=CONF_MASTER,
             ),
@@ -221,7 +222,7 @@ class GroupPlayer(Player):
         # TODO: Only start playing on powered players ?
         # Monitor if a child turns on and join it to the sync ?
         for child_player_id in self.group_childs:
-            child_player = self.mass.player_manager.get_player(child_player_id)
+            child_player = self.mass.players.get_player(child_player_id)
             if child_player:
                 queue_stream_uri = f"{self.mass.web.internal_url}/stream/group/{self.player_id}?player_id={child_player_id}"
                 await child_player.async_cmd_play_uri(queue_stream_uri)
@@ -241,7 +242,7 @@ class GroupPlayer(Player):
         # forward this command to each child player
         # TODO: Only forward to powered child players
         for child_player_id in self.group_childs:
-            child_player = self.mass.player_manager.get_player(child_player_id)
+            child_player = self.mass.players.get_player(child_player_id)
             if child_player:
                 await child_player.async_cmd_stop()
         self.update_state()
@@ -252,7 +253,7 @@ class GroupPlayer(Player):
             return
         # forward this command to each child player
         for child_player_id in self.group_childs:
-            child_player = self.mass.player_manager.get_player(child_player_id)
+            child_player = self.mass.players.get_player(child_player_id)
             if child_player:
                 await child_player.async_cmd_play()
         self._state = PlaybackState.Playing
@@ -262,7 +263,7 @@ class GroupPlayer(Player):
         """Send PAUSE command to player."""
         # forward this command to each child player
         for child_player_id in self.group_childs:
-            child_player = self.mass.player_manager.get_player(child_player_id)
+            child_player = self.mass.players.get_player(child_player_id)
             if child_player:
                 await child_player.async_cmd_pause()
         self._state = PlaybackState.Paused
@@ -285,10 +286,7 @@ class GroupPlayer(Player):
 
             :param volume_level: volume level to set (0..100).
         """
-        for child_player_id in self.group_childs:
-            child_player = self.mass.player_manager.get_player(child_player_id)
-            if child_player and child_player.powered:
-                await child_player.async_cmd_volume_set(volume_level)
+        # this is already handled by the player manager
 
     async def async_cmd_volume_mute(self, is_muted=False):
         """
@@ -297,9 +295,7 @@ class GroupPlayer(Player):
             :param is_muted: bool with new mute state.
         """
         for child_player_id in self.group_childs:
-            child_player = self.mass.player_manager.get_player(child_player_id)
-            if child_player and child_player.powered:
-                await child_player.async_cmd_volume_mute(is_muted)
+            self.mass.players.async_cmd_volume_mute(child_player_id)
         self.muted = is_muted
 
     async def subscribe_stream_client(self, child_player_id):
@@ -379,7 +375,7 @@ class GroupPlayer(Player):
 
         received_milliseconds = 0
         received_seconds = 0
-        async for audio_chunk in self.mass.stream_manager.async_queue_stream_pcm(
+        async for audio_chunk in self.mass.streams.async_queue_stream_pcm(
             self.player_id, sample_rate=96000, bit_depth=32
         ):
             received_seconds += 1
@@ -424,13 +420,13 @@ class GroupPlayer(Player):
         master_player_id = self.mass.config.player_settings[self.player_id].get(
             CONF_MASTER
         )
-        if not master_player_id:
+        master_player = self.mass.players.get_player(master_player_id)
+        if not master_player:
             LOGGER.warning("Synchronization of playback aborted: no master player.")
             return
         LOGGER.debug(
-            "Synchronize playback of group using master player %s", master_player_id
+            "Synchronize playback of group using master player %s", master_player.name
         )
-        master_player = self.mass.player_manager.get_player(master_player_id)
 
         # wait until master is playing
         while master_player.state != PlaybackState.Playing:
@@ -449,7 +445,7 @@ class GroupPlayer(Player):
 
                 if child_player_id == master_player_id:
                     continue
-                child_player = self.mass.player_manager.get_player(child_player_id)
+                child_player = self.mass.players.get_player(child_player_id)
 
                 if (
                     not child_player
diff --git a/music_assistant/providers/group_player/icon.png b/music_assistant/providers/group_player/icon.png
new file mode 100644 (file)
index 0000000..092121e
Binary files /dev/null and b/music_assistant/providers/group_player/icon.png differ
index 9e803225e5eb1eaf2d075b46ffe4f2fa3472c698..d49fb5cbc690c881b2222eb14cdd6cc57edabf93 100644 (file)
@@ -6,13 +6,14 @@ import time
 from typing import List, Optional
 
 from asyncio_throttle import Throttler
-from music_assistant.app_vars import get_app_var  # noqa # pylint: disable=all
 from music_assistant.constants import (
     CONF_PASSWORD,
     CONF_USERNAME,
     EVENT_STREAM_ENDED,
     EVENT_STREAM_STARTED,
 )
+from music_assistant.helpers.app_vars import get_app_var  # noqa # pylint: disable=all
+from music_assistant.helpers.util import parse_title_and_version, try_parse_int
 from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType
 from music_assistant.models.media_types import (
     Album,
@@ -26,9 +27,8 @@ from music_assistant.models.media_types import (
     Track,
     TrackQuality,
 )
-from music_assistant.models.musicprovider import MusicProvider
+from music_assistant.models.provider import MusicProvider
 from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
-from music_assistant.utils import parse_title_and_version, try_parse_int
 
 PROV_ID = "qobuz"
 PROV_NAME = "Qobuz"
@@ -38,12 +38,12 @@ CONFIG_ENTRIES = [
     ConfigEntry(
         entry_key=CONF_USERNAME,
         entry_type=ConfigEntryType.STRING,
-        description_key=CONF_USERNAME,
+        description=CONF_USERNAME,
     ),
     ConfigEntry(
         entry_key=CONF_PASSWORD,
         entry_type=ConfigEntryType.PASSWORD,
-        description_key=CONF_PASSWORD,
+        description=CONF_PASSWORD,
     ),
 ]
 
diff --git a/music_assistant/providers/qobuz/icon.png b/music_assistant/providers/qobuz/icon.png
new file mode 100644 (file)
index 0000000..9d7b726
Binary files /dev/null and b/music_assistant/providers/qobuz/icon.png differ
diff --git a/music_assistant/providers/sonos/icon.png b/music_assistant/providers/sonos/icon.png
new file mode 100644 (file)
index 0000000..d00f12a
Binary files /dev/null and b/music_assistant/providers/sonos/icon.png differ
index f48b7aba96d07eb0819863732cb2451158ac2230..e2b5bdd9469007a62e38a2eeaaeaf397a3cd6267 100644 (file)
@@ -6,6 +6,7 @@ import time
 from typing import List
 
 import soco
+from music_assistant.helpers.util import run_periodic
 from music_assistant.models.config_entry import ConfigEntry
 from music_assistant.models.player import (
     DeviceInfo,
@@ -14,8 +15,7 @@ from music_assistant.models.player import (
     PlayerFeature,
 )
 from music_assistant.models.player_queue import QueueItem
-from music_assistant.models.playerprovider import PlayerProvider
-from music_assistant.utils import run_periodic
+from music_assistant.models.provider import PlayerProvider
 
 PROV_ID = "sonos"
 PROV_NAME = "Sonos"
@@ -244,7 +244,7 @@ class SonosProvider(PlayerProvider):
             :param player_id: player_id of the player to handle the command.
             :param queue_items: a list of QueueItems
         """
-        player_queue = self.mass.player_manager.get_player_queue(player_id)
+        player_queue = self.mass.players.get_player_queue(player_id)
         if player_queue:
             return await self.async_cmd_queue_insert(
                 player_id, queue_items, len(player_queue.items)
@@ -283,7 +283,7 @@ class SonosProvider(PlayerProvider):
         for player in list(self._players.values()):
             if not player.is_group and player.soco.uid not in new_device_ids:
                 self.mass.add_job(
-                    self.mass.player_manager.async_remove_player(player.player_id)
+                    self.mass.players.async_remove_player(player.player_id)
                 )
                 for sub in player.subscriptions:
                     sub.unsubscribe()
@@ -327,7 +327,7 @@ class SonosProvider(PlayerProvider):
         subscribe(soco_device.avTransport, self.__player_event)
         subscribe(soco_device.renderingControl, self.__player_event)
         subscribe(soco_device.zoneGroupTopology, self.__topology_changed)
-        self.mass.run_task(self.mass.player_manager.async_add_player(player))
+        self.mass.run_task(self.mass.players.async_add_player(player))
         return player
 
     def __player_event(self, player_id: str, event):
@@ -360,7 +360,7 @@ class SonosProvider(PlayerProvider):
             player.elapsed_time = rel_time
             if player.state == PlaybackState.Playing:
                 self.mass.add_job(self.__async_report_progress(player_id))
-        self.mass.add_job(self.mass.player_manager.async_update_player(player))
+        self.mass.add_job(self.mass.players.async_update_player(player))
 
     def __process_groups(self, sonos_groups):
         """Process all sonos groups."""
@@ -376,9 +376,7 @@ class SonosProvider(PlayerProvider):
             group_player.is_group_player = True
             group_player.name = group.label
             group_player.group_childs = [item.uid for item in group.members]
-            self.mass.run_task(
-                self.mass.player_manager.async_update_player(group_player)
-            )
+            self.mass.run_task(self.mass.players.async_update_player(group_player))
 
     async def __topology_changed(self, player_id, event=None):
         """Received topology changed event from one of the sonos players."""
index f67fc8e31f8d4230c257bf58023f95485475215b..ad5a4b90d531d1994a8033c586d2083eddce040f 100644 (file)
@@ -6,9 +6,11 @@ import platform
 import time
 from typing import List, Optional
 
+import orjson
 from asyncio_throttle import Throttler
-from music_assistant.app_vars import get_app_var  # noqa # pylint: disable=all
 from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME
+from music_assistant.helpers.app_vars import get_app_var  # noqa # pylint: disable=all
+from music_assistant.helpers.util import parse_title_and_version
 from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType
 from music_assistant.models.media_types import (
     Album,
@@ -22,9 +24,8 @@ from music_assistant.models.media_types import (
     Track,
     TrackQuality,
 )
-from music_assistant.models.musicprovider import MusicProvider
+from music_assistant.models.provider import MusicProvider
 from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
-from music_assistant.utils import json, parse_title_and_version
 
 PROV_ID = "spotify"
 PROV_NAME = "Spotify"
@@ -35,12 +36,14 @@ CONFIG_ENTRIES = [
     ConfigEntry(
         entry_key=CONF_USERNAME,
         entry_type=ConfigEntryType.STRING,
-        description_key=CONF_USERNAME,
+        label=CONF_USERNAME,
+        description="desc_spotify_username",
     ),
     ConfigEntry(
         entry_key=CONF_PASSWORD,
         entry_type=ConfigEntryType.PASSWORD,
-        description_key=CONF_PASSWORD,
+        label=CONF_PASSWORD,
+        description="desc_spotify_password",
     ),
 ]
 
@@ -522,8 +525,8 @@ class SpotifyProvider(MusicProvider):
         )
         stdout, _ = await spotty.communicate()
         try:
-            result = json.loads(stdout)
-        except json.decoder.JSONDecodeError:
+            result = orjson.loads(stdout)
+        except orjson.decoder.JSONDecodeError:
             LOGGER.warning("Error while retrieving Spotify token!")
             result = None
         # transform token info to spotipy compatible format
@@ -563,7 +566,7 @@ class SpotifyProvider(MusicProvider):
             async with self.mass.http_session.get(
                 url, headers=headers, params=params, verify_ssl=False
             ) as response:
-                result = await response.json()
+                result = await response.json(loads=orjson.loads)
                 if not result or "error" in result:
                     LOGGER.error("%s - %s", endpoint, result)
                     result = None
diff --git a/music_assistant/providers/spotify/icon.png b/music_assistant/providers/spotify/icon.png
new file mode 100644 (file)
index 0000000..1ed4049
Binary files /dev/null and b/music_assistant/providers/spotify/icon.png differ
diff --git a/music_assistant/providers/spotify/translations.json b/music_assistant/providers/spotify/translations.json
new file mode 100644 (file)
index 0000000..2b40b74
--- /dev/null
@@ -0,0 +1,10 @@
+{
+    "en": {
+        "desc_spotify_username": "Username for your Spotify account",
+        "desc_spotify_password": "Password for your Spotify account"
+    },
+    "nl": {
+        "desc_spotify_username": "Gebruikersnaam van jouw Spotify account",
+        "desc_spotify_password": "Wachtwoord van jouw Spotify account"
+    }
+}
\ No newline at end of file
index a4b29b304ac2535bb65df6e3a30332a43c13f91f..3bbb88889545c70feb9dde33ff9421e29c0b1c7a 100644 (file)
@@ -6,6 +6,7 @@ from typing import List
 
 from music_assistant.constants import CONF_CROSSFADE_DURATION
 from music_assistant.helpers.typing import MusicAssistantType
+from music_assistant.helpers.util import callback
 from music_assistant.models.config_entry import ConfigEntry
 from music_assistant.models.player import (
     DeviceInfo,
@@ -14,8 +15,7 @@ from music_assistant.models.player import (
     PlayerFeature,
 )
 from music_assistant.models.player_queue import QueueItem
-from music_assistant.models.playerprovider import PlayerProvider
-from music_assistant.utils import callback
+from music_assistant.models.provider import PlayerProvider
 
 from .constants import PROV_ID, PROV_NAME
 from .discovery import DiscoveryProtocol
@@ -96,10 +96,8 @@ class PySqueezeProvider(PlayerProvider):
             if not player_id:
                 return
             # always check if we already have this player as it might be reconnected
-            player_state = self.mass.player_manager.get_player(player_id)
-            if player_state:
-                player = player_state.player
-            else:
+            player = self.mass.players.get_player(player_id)
+            if not player:
                 player = SqueezePlayer(self.mass, socket_client)
             player.set_socket_client(socket_client)
             # just update, the playermanager will take care of adding it if it's a new player
@@ -252,7 +250,7 @@ class SqueezePlayer(Player):
 
     async def async_cmd_next(self):
         """Send NEXT TRACK command to player."""
-        queue = self.mass.player_manager.get_player_queue(self.player_id)
+        queue = self.mass.players.get_player_queue(self.player_id)
         if queue:
             new_track = queue.get_item(queue.cur_index + 1)
             if new_track:
@@ -260,7 +258,7 @@ class SqueezePlayer(Player):
 
     async def async_cmd_previous(self):
         """Send PREVIOUS TRACK command to player."""
-        queue = self.mass.player_manager.get_player_queue(self.player_id)
+        queue = self.mass.players.get_player_queue(self.player_id)
         if queue:
             new_track = queue.get_item(queue.cur_index - 1)
             if new_track:
@@ -272,7 +270,7 @@ class SqueezePlayer(Player):
 
             :param index: (int) index of the queue item that should start playing
         """
-        queue = self.mass.player_manager.get_player_queue(self.player_id)
+        queue = self.mass.players.get_player_queue(self.player_id)
         if queue:
             new_track = queue.get_item(index)
             if new_track:
@@ -300,7 +298,7 @@ class SqueezePlayer(Player):
         """
         # queue handled by built-in queue controller
         # we only check the start index
-        queue = self.mass.player_manager.get_player_queue(self.player_id)
+        queue = self.mass.players.get_player_queue(self.player_id)
         if queue and insert_at_index == queue.cur_index:
             return await self.async_cmd_queue_play_index(insert_at_index)
 
@@ -341,7 +339,7 @@ class SqueezePlayer(Player):
             self.mass.add_job(self.async_restore_states())
         elif event == SqueezeEvent.DECODER_READY:
             # tell player to load next queue track
-            queue = self.mass.player_manager.get_player_queue(self.player_id)
+            queue = self.mass.players.get_player_queue(self.player_id)
             if queue:
                 next_item = queue.next_item
                 if next_item:
index 39bcc44d7b506e57bed3042cd173008288b251a0..10c840a48bc49035c17da7cf87f12affb1891ee1 100644 (file)
@@ -5,7 +5,7 @@ import socket
 import struct
 from collections import OrderedDict
 
-from music_assistant.utils import get_hostname, get_ip
+from music_assistant.helpers.util import get_hostname, get_ip
 
 LOGGER = logging.getLogger("squeezebox")
 
diff --git a/music_assistant/providers/squeezebox/icon.png b/music_assistant/providers/squeezebox/icon.png
new file mode 100644 (file)
index 0000000..18531d7
Binary files /dev/null and b/music_assistant/providers/squeezebox/icon.png differ
index db07991ef44e776046904f6c4f73721b1a376bb8..4c69efa3308d0c6aa4f5215e519918fa8b3d47a4 100644 (file)
@@ -8,7 +8,7 @@ import time
 from enum import Enum
 from typing import Callable
 
-from music_assistant.utils import callback, run_periodic
+from music_assistant.helpers.util import callback, run_periodic
 
 from .constants import PROV_ID
 
index 4bad3115d4440c930196dd44be005590f8e6304e..3d16f2e30e7269c4ac9af20d9d7fad8f72db7d34 100644 (file)
@@ -12,7 +12,7 @@ from music_assistant.models.media_types import (
     SearchResult,
     TrackQuality,
 )
-from music_assistant.models.musicprovider import MusicProvider
+from music_assistant.models.provider import MusicProvider
 from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
 
 PROV_ID = "tunein"
@@ -23,12 +23,12 @@ CONFIG_ENTRIES = [
     ConfigEntry(
         entry_key=CONF_USERNAME,
         entry_type=ConfigEntryType.STRING,
-        description_key=CONF_USERNAME,
+        description=CONF_USERNAME,
     ),
     ConfigEntry(
         entry_key=CONF_PASSWORD,
         entry_type=ConfigEntryType.PASSWORD,
-        description_key=CONF_PASSWORD,
+        description=CONF_PASSWORD,
     ),
 ]
 
@@ -78,6 +78,7 @@ class TuneInProvider(MusicProvider):
         self._username = config[CONF_USERNAME]
         self._password = config[CONF_PASSWORD]
         self._throttler = Throttler(rate_limit=1, period=1)
+        return True
 
     async def async_search(
         self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
diff --git a/music_assistant/providers/tunein/icon.png b/music_assistant/providers/tunein/icon.png
new file mode 100644 (file)
index 0000000..18c537c
Binary files /dev/null and b/music_assistant/providers/tunein/icon.png differ
diff --git a/music_assistant/providers/webplayer/icon.png b/music_assistant/providers/webplayer/icon.png
new file mode 100644 (file)
index 0000000..ffcf4fa
Binary files /dev/null and b/music_assistant/providers/webplayer/icon.png differ
diff --git a/music_assistant/stream_manager.py b/music_assistant/stream_manager.py
deleted file mode 100755 (executable)
index a10fb73..0000000
+++ /dev/null
@@ -1,595 +0,0 @@
-"""
-StreamManager: handles all audio streaming to players.
-
-Either by sending tracks one by one or send one continuous stream
-of music with crossfade/gapless support (queue stream).
-"""
-import asyncio
-import gc
-import gzip
-import io
-import logging
-import os
-import shlex
-from enum import Enum
-from typing import AsyncGenerator, List, Optional, Tuple
-
-import pyloudnorm
-import soundfile
-from aiofile import AIOFile, Reader
-from music_assistant.constants import EVENT_STREAM_ENDED, EVENT_STREAM_STARTED
-from music_assistant.helpers.typing import MusicAssistantType
-from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
-from music_assistant.utils import (
-    create_tempfile,
-    decrypt_bytes,
-    decrypt_string,
-    encrypt_bytes,
-    get_ip,
-    try_parse_int,
-    yield_chunks,
-)
-
-LOGGER = logging.getLogger("mass")
-
-
-class SoxOutputFormat(Enum):
-    """Enum representing the various output formats."""
-
-    MP3 = "mp3"  # Lossy mp3
-    OGG = "ogg"  # Lossy Ogg Vorbis
-    FLAC = "flac"  # Flac (with default compression)
-    S24 = "s24"  # Raw PCM 24bits signed
-    S32 = "s32"  # Raw PCM 32bits signed
-    S64 = "s64"  # Raw PCM 64bits signed
-
-
-class StreamManager:
-    """Built-in streamer utilizing SoX."""
-
-    def __init__(self, mass: MusicAssistantType) -> None:
-        """Initialize class."""
-        self.mass = mass
-        self.local_ip = get_ip()
-        self.analyze_jobs = {}
-
-    async def async_get_sox_stream(
-        self,
-        streamdetails: StreamDetails,
-        output_format: SoxOutputFormat = SoxOutputFormat.FLAC,
-        resample: Optional[int] = None,
-        gain_db_adjust: Optional[float] = None,
-        chunk_size: int = 128000,
-    ) -> AsyncGenerator[Tuple[bool, bytes], None]:
-        """Get the sox manipulated audio data for the given streamdetails."""
-        # collect all args for sox
-        if output_format in [
-            SoxOutputFormat.S24,
-            SoxOutputFormat.S32,
-            SoxOutputFormat.S64,
-        ]:
-            output_format = [output_format.value, "-c", "2"]
-        else:
-            output_format = [output_format.value]
-        args = (
-            ["sox", "-t", streamdetails.content_type.value, "-", "-t"]
-            + output_format
-            + ["-"]
-        )
-        if gain_db_adjust:
-            args += ["vol", str(gain_db_adjust), "dB"]
-        if resample:
-            args += ["rate", "-v", str(resample)]
-        LOGGER.debug(
-            "[async_get_sox_stream] [%s/%s] started using args: %s",
-            streamdetails.provider,
-            streamdetails.item_id,
-            " ".join(args),
-        )
-        # init the process with stdin/out pipes
-        sox_proc = await asyncio.create_subprocess_exec(
-            *args,
-            stdout=asyncio.subprocess.PIPE,
-            stdin=asyncio.subprocess.PIPE,
-            bufsize=0,
-        )
-
-        async def fill_buffer():
-            """Forward audio chunks to sox stdin."""
-            LOGGER.debug(
-                "[async_get_sox_stream] [%s/%s] fill_buffer started",
-                streamdetails.provider,
-                streamdetails.item_id,
-            )
-            # feed audio data into sox stdin for processing
-            async for chunk in self.async_get_media_stream(streamdetails):
-                sox_proc.stdin.write(chunk)
-                await sox_proc.stdin.drain()
-            sox_proc.stdin.write_eof()
-            await sox_proc.stdin.drain()
-            LOGGER.debug(
-                "[async_get_sox_stream] [%s/%s] fill_buffer finished",
-                streamdetails.provider,
-                streamdetails.item_id,
-            )
-
-        fill_buffer_task = self.mass.loop.create_task(fill_buffer())
-        try:
-            # yield chunks from stdout
-            # we keep 1 chunk behind to detect end of stream properly
-            prev_chunk = b""
-            while True:
-                # read exactly chunksize of data
-                try:
-                    chunk = await sox_proc.stdout.readexactly(chunk_size)
-                except asyncio.IncompleteReadError as exc:
-                    chunk = exc.partial
-                if len(chunk) < chunk_size:
-                    # last chunk
-                    yield (True, prev_chunk + chunk)
-                    break
-                if prev_chunk:
-                    yield (False, prev_chunk)
-                prev_chunk = chunk
-
-            await asyncio.wait([fill_buffer_task])
-            LOGGER.debug(
-                "[async_get_sox_stream] [%s/%s] finished",
-                streamdetails.provider,
-                streamdetails.item_id,
-            )
-        except (GeneratorExit, Exception):  # pylint: disable=broad-except
-            LOGGER.warning(
-                "[async_get_sox_stream] [%s/%s] aborted",
-                streamdetails.provider,
-                streamdetails.item_id,
-            )
-            if fill_buffer_task and not fill_buffer_task.cancelled():
-                fill_buffer_task.cancel()
-            await sox_proc.communicate()
-            if sox_proc and sox_proc.returncode is None:
-                sox_proc.terminate()
-                await sox_proc.wait()
-        else:
-            LOGGER.debug(
-                "[async_get_sox_stream] [%s/%s] finished",
-                streamdetails.provider,
-                streamdetails.item_id,
-            )
-
-    async def async_queue_stream_flac(self, player_id) -> AsyncGenerator[bytes, None]:
-        """Stream the PlayerQueue's tracks as constant feed in flac format."""
-
-        args = ["sox", "-t", "s32", "-c", "2", "-r", "96000", "-", "-t", "flac", "-"]
-        sox_proc = await asyncio.create_subprocess_exec(
-            *args,
-            stdout=asyncio.subprocess.PIPE,
-            stdin=asyncio.subprocess.PIPE,
-        )
-        LOGGER.debug(
-            "[async_queue_stream_flac] [%s] started using args: %s",
-            player_id,
-            " ".join(args),
-        )
-        chunk_size = 571392  # 74,7% of pcm
-
-        # feed stdin with pcm samples
-        async def fill_buffer():
-            """Feed audio data into sox stdin for processing."""
-            LOGGER.debug(
-                "[async_queue_stream_flac] [%s] fill buffer started", player_id
-            )
-            async for chunk in self.async_queue_stream_pcm(player_id, 96000, 32):
-                sox_proc.stdin.write(chunk)
-                await sox_proc.stdin.drain()
-            sox_proc.stdin.write_eof()
-            await sox_proc.stdin.drain()
-            LOGGER.debug(
-                "[async_queue_stream_flac] [%s] fill buffer finished", player_id
-            )
-
-        fill_buffer_task = self.mass.loop.create_task(fill_buffer())
-        try:
-            # yield flac chunks from stdout
-            while True:
-                try:
-                    chunk = await sox_proc.stdout.readexactly(chunk_size)
-                    yield chunk
-                except asyncio.IncompleteReadError as exc:
-                    chunk = exc.partial
-                    yield chunk
-                    break
-        except (GeneratorExit, Exception):  # pylint: disable=broad-except
-            LOGGER.debug("[async_queue_stream_flac] [%s] aborted", player_id)
-            if fill_buffer_task and not fill_buffer_task.cancelled():
-                fill_buffer_task.cancel()
-            await sox_proc.communicate()
-            if sox_proc and sox_proc.returncode is None:
-                sox_proc.terminate()
-                await sox_proc.wait()
-        else:
-            LOGGER.debug(
-                "[async_queue_stream_flac] [%s] finished",
-                player_id,
-            )
-
-    async def async_queue_stream_pcm(
-        self, player_id, sample_rate=96000, bit_depth=32
-    ) -> AsyncGenerator[bytes, None]:
-        """Stream the PlayerQueue's tracks as constant feed in PCM raw audio."""
-        player_queue = self.mass.player_manager.get_player_queue(player_id)
-        queue_conf = self.mass.config.get_player_config(player_id)
-        fade_length = try_parse_int(queue_conf["crossfade_duration"])
-        pcm_args = ["s32", "-c", "2", "-r", str(sample_rate)]
-        chunk_size = int(sample_rate * (bit_depth / 8) * 2)  # 1 second
-        if fade_length:
-            buffer_size = chunk_size * fade_length
-        else:
-            buffer_size = chunk_size * 10
-
-        LOGGER.info("Start Queue Stream for player %s ", player_id)
-
-        is_start = True
-        last_fadeout_data = b""
-        while True:
-
-            # get the (next) track in queue
-            if is_start:
-                # report start of queue playback so we can calculate current track/duration etc.
-                queue_track = await player_queue.async_start_queue_stream()
-                is_start = False
-            else:
-                queue_track = player_queue.next_item
-            if not queue_track:
-                LOGGER.debug("no (more) tracks left in queue")
-                break
-            # get streamdetails
-            streamdetails = await self.mass.music_manager.async_get_stream_details(
-                queue_track, player_id
-            )
-            # get gain correct / replaygain
-            gain_correct = await self.mass.player_manager.async_get_gain_correct(
-                player_id, streamdetails.item_id, streamdetails.provider
-            )
-            LOGGER.debug(
-                "Start Streaming queue track: %s (%s) for player %s",
-                queue_track.item_id,
-                queue_track.name,
-                player_id,
-            )
-            fade_in_part = b""
-            cur_chunk = 0
-            prev_chunk = None
-            bytes_written = 0
-            # handle incoming audio chunks
-            async for is_last_chunk, chunk in self.mass.stream_manager.async_get_sox_stream(
-                streamdetails,
-                SoxOutputFormat.S32,
-                resample=sample_rate,
-                gain_db_adjust=gain_correct,
-                chunk_size=buffer_size,
-            ):
-                cur_chunk += 1
-
-                # HANDLE FIRST PART OF TRACK
-                if not chunk and cur_chunk == 1 and is_last_chunk:
-                    LOGGER.warning("Stream error, skip track %s", queue_track.item_id)
-                    break
-                if cur_chunk <= 2 and not last_fadeout_data:
-                    # no fadeout_part available so just pass it to the output directly
-                    for small_chunk in yield_chunks(chunk, chunk_size):
-                        yield small_chunk
-                    bytes_written += len(chunk)
-                    del chunk
-                elif cur_chunk == 1 and last_fadeout_data:
-                    prev_chunk = chunk
-                    del chunk
-                # HANDLE CROSSFADE OF PREVIOUS TRACK FADE_OUT AND THIS TRACK FADE_IN
-                elif cur_chunk == 2 and last_fadeout_data:
-                    # combine the first 2 chunks and strip off silence
-                    first_part = await async_strip_silence(prev_chunk + chunk, pcm_args)
-                    if len(first_part) < buffer_size:
-                        # part is too short after the strip action?!
-                        # so we just use the full first part
-                        first_part = prev_chunk + chunk
-                    fade_in_part = first_part[:buffer_size]
-                    remaining_bytes = first_part[buffer_size:]
-                    del first_part
-                    # do crossfade
-                    crossfade_part = await async_crossfade_pcm_parts(
-                        fade_in_part, last_fadeout_data, pcm_args, fade_length
-                    )
-                    # send crossfade_part
-                    for small_chunk in yield_chunks(crossfade_part, chunk_size):
-                        yield small_chunk
-                    bytes_written += len(crossfade_part)
-                    del crossfade_part
-                    del fade_in_part
-                    last_fadeout_data = b""
-                    # also write the leftover bytes from the strip action
-                    for small_chunk in yield_chunks(remaining_bytes, chunk_size):
-                        yield small_chunk
-                    bytes_written += len(remaining_bytes)
-                    del remaining_bytes
-                    del chunk
-                    prev_chunk = None  # needed to prevent this chunk being sent again
-                # HANDLE LAST PART OF TRACK
-                elif prev_chunk and is_last_chunk:
-                    # last chunk received so create the last_part
-                    # with the previous chunk and this chunk
-                    # and strip off silence
-                    last_part = await async_strip_silence(
-                        prev_chunk + chunk, pcm_args, True
-                    )
-                    if len(last_part) < buffer_size:
-                        # part is too short after the strip action
-                        # so we just use the entire original data
-                        last_part = prev_chunk + chunk
-                        if len(last_part) < buffer_size:
-                            LOGGER.warning(
-                                "Not enough data for crossfade: %s", len(last_part)
-                            )
-                    if (
-                        not player_queue.crossfade_enabled
-                        or len(last_part) < buffer_size
-                    ):
-                        # crossfading is not enabled so just pass the (stripped) audio data
-                        for small_chunk in yield_chunks(last_part, chunk_size):
-                            yield small_chunk
-                        bytes_written += len(last_part)
-                        del last_part
-                        del chunk
-                    else:
-                        # handle crossfading support
-                        # store fade section to be picked up for next track
-                        last_fadeout_data = last_part[-buffer_size:]
-                        remaining_bytes = last_part[:-buffer_size]
-                        # write remaining bytes
-                        for small_chunk in yield_chunks(remaining_bytes, chunk_size):
-                            yield small_chunk
-                        bytes_written += len(remaining_bytes)
-                        del last_part
-                        del remaining_bytes
-                        del chunk
-                # MIDDLE PARTS OF TRACK
-                else:
-                    # middle part of the track
-                    # keep previous chunk in memory so we have enough
-                    # samples to perform the crossfade
-                    if prev_chunk:
-                        for small_chunk in yield_chunks(prev_chunk, chunk_size):
-                            yield small_chunk
-                        bytes_written += len(prev_chunk)
-                        prev_chunk = chunk
-                    else:
-                        prev_chunk = chunk
-                    del chunk
-            # end of the track reached
-            # update actual duration to the queue for more accurate now playing info
-            accurate_duration = bytes_written / chunk_size
-            queue_track.duration = accurate_duration
-            LOGGER.debug(
-                "Finished Streaming queue track: %s (%s) on queue %s",
-                queue_track.item_id,
-                queue_track.name,
-                player_id,
-            )
-            # run garbage collect manually to avoid too much memory fragmentation
-            gc.collect()
-        # end of queue reached, pass last fadeout bits to final output
-        for small_chunk in yield_chunks(last_fadeout_data, chunk_size):
-            yield small_chunk
-        del last_fadeout_data
-        # END OF QUEUE STREAM
-        # run garbage collect manually to avoid too much memory fragmentation
-        gc.collect()
-        LOGGER.info("streaming of queue for player %s completed", player_id)
-
-    async def async_stream_queue_item(
-        self, player_id: str, queue_item_id: str
-    ) -> AsyncGenerator[bytes, None]:
-        """Stream a single Queue item."""
-        # collect streamdetails
-        player_queue = self.mass.player_manager.get_player_queue(player_id)
-        if not player_queue:
-            raise FileNotFoundError("invalid player_id")
-        queue_item = player_queue.by_item_id(queue_item_id)
-        if not queue_item:
-            raise FileNotFoundError("invalid queue_item_id")
-        streamdetails = await self.mass.music_manager.async_get_stream_details(
-            queue_item, player_id
-        )
-
-        # get gain correct / replaygain
-        gain_correct = await self.mass.player_manager.async_get_gain_correct(
-            player_id, streamdetails.item_id, streamdetails.provider
-        )
-        # start streaming
-        async for _, audio_chunk in self.async_get_sox_stream(
-            streamdetails, gain_db_adjust=gain_correct
-        ):
-            yield audio_chunk
-
-    async def async_get_media_stream(
-        self, streamdetails: StreamDetails
-    ) -> AsyncGenerator[bytes, None]:
-        """Get the (original/untouched) audio data for the given streamdetails. Generator."""
-        stream_path = decrypt_string(streamdetails.path)
-        stream_type = StreamType(streamdetails.type)
-        audio_data = b""
-
-        # Handle (optional) caching of audio data
-        cache_file = "/tmp/" + f"{streamdetails.item_id}{streamdetails.provider}"[::-1]
-        if os.path.isfile(cache_file):
-            with gzip.open(cache_file, "rb") as _file:
-                audio_data = decrypt_bytes(_file.read())
-            if audio_data:
-                stream_type = StreamType.CACHE
-
-        # support for AAC/MPEG created with ffmpeg in between
-        if streamdetails.content_type in [ContentType.AAC, ContentType.MPEG]:
-            stream_type = StreamType.EXECUTABLE
-            streamdetails.content_type = ContentType.FLAC
-            stream_path = f'ffmpeg -v quiet -i "{stream_path}" -f flac -'
-
-        # signal start of stream event
-        self.mass.signal_event(EVENT_STREAM_STARTED, streamdetails)
-        LOGGER.debug(
-            "[async_get_media_stream] [%s/%s] started, using %s",
-            streamdetails.provider,
-            streamdetails.item_id,
-            stream_type,
-        )
-
-        if stream_type == StreamType.CACHE:
-            yield audio_data
-        elif stream_type == StreamType.URL:
-            async with self.mass.http_session.get(stream_path) as response:
-                async for chunk in response.content.iter_any():
-                    audio_data += chunk
-                    yield chunk
-        elif stream_type == StreamType.FILE:
-            async with AIOFile(stream_path) as afp:
-                async for chunk in Reader(afp):
-                    audio_data += chunk
-                    yield chunk
-        elif stream_type == StreamType.EXECUTABLE:
-            args = shlex.split(stream_path)
-            process = await asyncio.create_subprocess_exec(
-                *args, stdout=asyncio.subprocess.PIPE
-            )
-            try:
-                async for chunk in process.stdout:
-                    audio_data += chunk
-                    yield chunk
-            except (GeneratorExit, Exception) as exc:  # pylint: disable=broad-except
-                LOGGER.warning(
-                    "[async_get_media_stream] [%s/%s] Aborted: %s",
-                    streamdetails.provider,
-                    streamdetails.item_id,
-                    str(exc),
-                )
-                # read remaining bytes
-                await process.communicate()
-                if process and process.returncode is None:
-                    process.terminate()
-                    await process.wait()
-
-        # signal end of stream event
-        self.mass.signal_event(EVENT_STREAM_ENDED, streamdetails)
-
-        # send analyze job to background worker
-        self.mass.add_job(self.__analyze_audio, streamdetails, audio_data)
-        LOGGER.debug(
-            "[async_get_media_stream] [%s/%s] Finished",
-            streamdetails.provider,
-            streamdetails.item_id,
-        )
-
-    def __get_player_sox_options(
-        self, player_id: str, streamdetails: StreamDetails
-    ) -> str:
-        """Get player specific sox effect options."""
-        sox_options = []
-        player_conf = self.mass.config.get_player_config(player_id)
-        # volume normalisation
-        gain_correct = self.mass.add_job(
-            self.mass.player_manager.async_get_gain_correct(
-                player_id, streamdetails.item_id, streamdetails.provider
-            )
-        ).result()
-        if gain_correct != 0:
-            sox_options.append("vol %s dB " % gain_correct)
-        # downsample if needed
-        if player_conf["max_sample_rate"]:
-            max_sample_rate = try_parse_int(player_conf["max_sample_rate"])
-            if max_sample_rate < streamdetails.sample_rate:
-                sox_options.append(f"rate -v {max_sample_rate}")
-        if player_conf.get("sox_options"):
-            sox_options.append(player_conf["sox_options"])
-        return " ".join(sox_options)
-
-    def __analyze_audio(self, streamdetails, audio_data) -> None:
-        """Analyze track audio, for now we only calculate EBU R128 loudness."""
-        item_key = "%s%s" % (streamdetails.item_id, streamdetails.provider)
-        if item_key in self.analyze_jobs:
-            return  # prevent multiple analyze jobs for same track
-        self.analyze_jobs[item_key] = True
-        # do we need saving to disk ?
-        cache_file = "/tmp/" + f"{streamdetails.item_id}{streamdetails.provider}"[::-1]
-        if not os.path.isfile(cache_file):
-            with gzip.open(cache_file, "wb") as _file:
-                _file.write(encrypt_bytes(audio_data))
-        # get track loudness
-        track_loudness = self.mass.add_job(
-            self.mass.database.async_get_track_loudness(
-                streamdetails.item_id, streamdetails.provider
-            )
-        ).result()
-        if track_loudness is None:
-            # only when needed we do the analyze stuff
-            LOGGER.debug("Start analyzing track %s", item_key)
-            # calculate BS.1770 R128 integrated loudness
-            with io.BytesIO(audio_data) as tmpfile:
-                data, rate = soundfile.read(tmpfile)
-            meter = pyloudnorm.Meter(rate)  # create BS.1770 meter
-            loudness = meter.integrated_loudness(data)  # measure loudness
-            del data
-            self.mass.add_job(
-                self.mass.database.async_set_track_loudness(
-                    streamdetails.item_id, streamdetails.provider, loudness
-                )
-            )
-            LOGGER.debug("Integrated loudness of track %s is: %s", item_key, loudness)
-        del audio_data
-        self.analyze_jobs.pop(item_key, None)
-
-
-async def async_crossfade_pcm_parts(
-    fade_in_part: bytes, fade_out_part: bytes, pcm_args: List[str], fade_length: int
-) -> bytes:
-    """Crossfade two chunks of pcm/raw audio using sox."""
-    # create fade-in part
-    fadeinfile = create_tempfile()
-    args = ["sox", "--ignore-length", "-t"] + pcm_args
-    args += ["-", "-t"] + pcm_args + [fadeinfile.name, "fade", "t", str(fade_length)]
-    process = await asyncio.create_subprocess_exec(*args, stdin=asyncio.subprocess.PIPE)
-    await process.communicate(fade_in_part)
-    # create fade-out part
-    fadeoutfile = create_tempfile()
-    args = ["sox", "--ignore-length", "-t"] + pcm_args + ["-", "-t"] + pcm_args
-    args += [fadeoutfile.name, "reverse", "fade", "t", str(fade_length), "reverse"]
-    process = await asyncio.create_subprocess_exec(
-        *args, stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE
-    )
-    await process.communicate(fade_out_part)
-    # create crossfade using sox and some temp files
-    # TODO: figure out how to make this less complex and without the tempfiles
-    args = ["sox", "-m", "-v", "1.0", "-t"] + pcm_args + [fadeoutfile.name, "-v", "1.0"]
-    args += ["-t"] + pcm_args + [fadeinfile.name, "-t"] + pcm_args + ["-"]
-    process = await asyncio.create_subprocess_exec(
-        *args, stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE
-    )
-    crossfade_part, _ = await process.communicate()
-    fadeinfile.close()
-    fadeoutfile.close()
-    del fadeinfile
-    del fadeoutfile
-    return crossfade_part
-
-
-async def async_strip_silence(
-    audio_data: bytes, pcm_args: List[str], reverse=False
-) -> bytes:
-    """Strip silence from (a chunk of) pcm audio."""
-    args = ["sox", "--ignore-length", "-t"] + pcm_args + ["-", "-t"] + pcm_args + ["-"]
-    if reverse:
-        args.append("reverse")
-    args += ["silence", "1", "0.1", "1%"]
-    if reverse:
-        args.append("reverse")
-    process = await asyncio.create_subprocess_exec(
-        *args, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE
-    )
-    stripped_data, _ = await process.communicate(audio_data)
-    return stripped_data
diff --git a/music_assistant/translations.json b/music_assistant/translations.json
new file mode 100644 (file)
index 0000000..576778f
--- /dev/null
@@ -0,0 +1,59 @@
+{
+  "en": {
+    "enabled": "Enabled",
+    "name": "Name",
+    "username": "Username",
+    "password": "Password",
+    "enable_player": "Enable this player",
+    "custom_name": "Custom name",
+    "max_sample_rate": "Maximum sample rate",
+    "volume_normalisation": "Enable Volume normalisation",
+    "target_volume": "Target Volume level",
+    "fallback_gain_correct": "Fallback gain correction level",
+    "desc_player_name": "Set a custom name for this player.",
+    "crossfade_duration": "Enable crossfade",
+    "http_port": "HTTP Port",
+    "https_port": "HTTPS Port",
+    "ssl_certificate": "SSL Certificate file location",
+    "ssl_key": "Path to certificate key file",
+
+    "desc_sample_rate": "Set the maximum sample rate this player can handle.",
+    "desc_volume_normalisation": "Enable R128 volume normalisation to play music at an equally loud volume.",
+    "desc_target_volume": "Set the preferred target volume level in LUFS. The R128 default is -22 LUFS.",
+    "desc_gain_correct": "Set a fallback gain correction when there is no R128 measurement available.",
+    "desc_crossfade": "Enable crossfading of Queue tracks by setting a crossfade duration in seconds.",
+    "desc_enable_provider": "Enable this provider.",
+    "desc_http_port": "The port on which to run the HTTP (internal) server.",
+    "desc_https_port": "The port on which to run the HTTPS (external) server. The HTTPS Server will only be enabled if correct certificate details are also set",
+    "desc_ssl_certificate": "Supply the full path to a certificate file (PEM).",
+    "desc_ssl_key": "Supply the full path to the file containing the private key.",
+    "desc_external_url": "Supply the full URL how this Music Assistant instance can be accessed from outside. Make sure this matches the common name of the certificate.",
+    "desc_base_username": "Username to access this Music Assistant server.",
+    "desc_base_password": "A password to protect this Music Assistant server. Can be left blank but this is extremely dangerous if this server is reachable from outside."
+  },
+  "nl": {
+    "enabled": "Ingeschakeld",
+    "name": "Naam",
+    "username": "Gebruikersnaam",
+    "password": "Wachtwoord",
+    "enable_player": "Deze speler inschakelen",
+    "custom_name": "Aangepaste name",
+    "max_sample_rate": "Maximale sample rate",
+    "volume_normalisation": "Volume normalisering inschakelen",
+    "target_volume": "Doel volume",
+    "fallback_gain_correct": "Fallback gain correctie niveau",
+    "desc_player_name": "Stel een aangepaste naam in voor deze speler.",
+    "crossfade_duration": "Crossfade inschakelen",
+    "http_port": "HTTP Port",
+    "https_port": "HTTPS Port",
+    "ssl_certificate": "SSL Certificaat bestandslocatie",
+    "ssl_key": "Pad naar het certificaat key bestand",
+
+    "desc_sample_rate": "Stel de maximale sample rate in die deze speler aankan.",
+    "desc_volume_normalisation": "R128 volume normalisatie inschakelen om muziek altijd op een gelijk volume af te spelen.",
+    "desc_target_volume": "Selecteer het gewenste doelvolume in LUFS. De R128 standaard is -22 LUFS.",
+    "desc_gain_correct": "Stel een fallback gain correctie in als er geen R128 meting beschikbaar is.",
+    "desc_crossfade": "Crossfade inschakelen door het instellen van een crossfade duur in seconden.",
+    "desc_enable_provider": "Deze provider inschakelen.",
+  }
+}
diff --git a/music_assistant/utils.py b/music_assistant/utils.py
deleted file mode 100755 (executable)
index ab041bf..0000000
+++ /dev/null
@@ -1,411 +0,0 @@
-"""Helper and utility functions."""
-import asyncio
-import functools
-import logging
-import os
-import platform
-import re
-import socket
-import struct
-import tempfile
-import urllib.request
-from datetime import datetime
-from enum import Enum
-from io import BytesIO
-from typing import Any, Callable, TypeVar
-
-import memory_tempfile
-import unidecode
-from cryptography.fernet import Fernet, InvalidToken
-from music_assistant.app_vars import get_app_var  # noqa # pylint: disable=all
-
-try:
-    import simplejson as json
-except ImportError:
-    import json
-
-
-# pylint: disable=invalid-name
-T = TypeVar("T")
-_UNDEF: dict = {}
-CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable)
-CALLBACK_TYPE = Callable[[], None]
-# pylint: enable=invalid-name
-
-
-def callback(func: CALLABLE_T) -> CALLABLE_T:
-    """Annotation to mark method as safe to call from within the event loop."""
-    setattr(func, "_mass_callback", True)
-    return func
-
-
-def is_callback(func: Callable[..., Any]) -> bool:
-    """Check if function is safe to be called in the event loop."""
-    return getattr(func, "_mass_callback", False) is True
-
-
-def run_periodic(period):
-    """Run a coroutine at interval."""
-
-    def scheduler(fcn):
-        async def async_wrapper(*args, **kwargs):
-            while True:
-                asyncio.create_task(fcn(*args, **kwargs))
-                await asyncio.sleep(period)
-
-        return async_wrapper
-
-    return scheduler
-
-
-def get_external_ip():
-    """Try to get the external (WAN) IP address."""
-    # pylint: disable=broad-except
-    try:
-        return urllib.request.urlopen("https://ident.me").read().decode("utf8")
-    except Exception:
-        return None
-
-
-def filename_from_string(string):
-    """Create filename from unsafe string."""
-    keepcharacters = (" ", ".", "_")
-    return "".join(c for c in string if c.isalnum() or c in keepcharacters).rstrip()
-
-
-def run_background_task(corofn, *args, executor=None):
-    """Run non-async task in background."""
-    return asyncio.get_event_loop().run_in_executor(executor, corofn, *args)
-
-
-def run_async_background_task(executor, corofn, *args):
-    """Run async task in background."""
-
-    def run_task(corofn, *args):
-        new_loop = asyncio.new_event_loop()
-        asyncio.set_event_loop(new_loop)
-        coro = corofn(*args)
-        res = new_loop.run_until_complete(coro)
-        new_loop.close()
-        return res
-
-    return asyncio.get_event_loop().run_in_executor(executor, run_task, corofn, *args)
-
-
-def get_sort_name(name):
-    """Create a sort name for an artist/title."""
-    sort_name = name
-    for item in ["The ", "De ", "de ", "Les "]:
-        if name.startswith(item):
-            sort_name = "".join(name.split(item)[1:])
-    return sort_name
-
-
-def try_parse_int(possible_int):
-    """Try to parse an int."""
-    try:
-        return int(possible_int)
-    except (TypeError, ValueError):
-        return 0
-
-
-async def async_iter_items(items):
-    """Fake async iterator for compatability reasons."""
-    if not isinstance(items, list):
-        yield items
-    else:
-        for item in items:
-            yield item
-
-
-def try_parse_float(possible_float):
-    """Try to parse a float."""
-    try:
-        return float(possible_float)
-    except (TypeError, ValueError):
-        return 0.0
-
-
-def try_parse_bool(possible_bool):
-    """Try to parse a bool."""
-    if isinstance(possible_bool, bool):
-        return possible_bool
-    return possible_bool in ["true", "True", "1", "on", "ON", 1]
-
-
-def parse_title_and_version(track_title, track_version=None):
-    """Try to parse clean track title and version from the title."""
-    title = track_title.lower()
-    version = ""
-    for splitter in [" (", " [", " - ", " (", " [", "-"]:
-        if splitter in title:
-            title_parts = title.split(splitter)
-            for title_part in title_parts:
-                # look for the end splitter
-                for end_splitter in [")", "]"]:
-                    if end_splitter in title_part:
-                        title_part = title_part.split(end_splitter)[0]
-                for ignore_str in [
-                    "feat.",
-                    "featuring",
-                    "ft.",
-                    "with ",
-                    " & ",
-                    "explicit",
-                ]:
-                    if ignore_str in title_part:
-                        title = title.split(splitter + title_part)[0]
-                for version_str in [
-                    "version",
-                    "live",
-                    "edit",
-                    "remix",
-                    "mix",
-                    "acoustic",
-                    " instrumental",
-                    "karaoke",
-                    "remaster",
-                    "versie",
-                    "radio",
-                    "unplugged",
-                    "disco",
-                ]:
-                    if version_str in title_part:
-                        version = title_part
-                        title = title.split(splitter + version)[0]
-    title = title.strip().title()
-    if not version and track_version:
-        version = track_version
-    version = get_version_substitute(version).title()
-    return title, version
-
-
-def get_version_substitute(version_str):
-    """Transform provider version str to universal version type."""
-    version_str = version_str.lower()
-    # substitute edit and edition with version
-    if "edition" in version_str or "edit" in version_str:
-        version_str = version_str.replace(" edition", " version")
-        version_str = version_str.replace(" edit ", " version")
-    if version_str.startswith("the "):
-        version_str = version_str.split("the ")[1]
-    if "radio mix" in version_str:
-        version_str = "radio version"
-    elif "video mix" in version_str:
-        version_str = "video version"
-    elif "spanglish" in version_str or "spanish" in version_str:
-        version_str = "spanish version"
-    elif version_str.endswith("remaster"):
-        version_str = "remaster"
-    return version_str.strip()
-
-
-def get_ip():
-    """Get primary IP-address for this host."""
-    # pylint: disable=broad-except
-    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
-    try:
-        # doesn't even have to be reachable
-        sock.connect(("10.255.255.255", 1))
-        _ip = sock.getsockname()[0]
-    except Exception:
-        _ip = "127.0.0.1"
-    finally:
-        sock.close()
-    return _ip
-
-
-def get_ip_pton():
-    """Return socket pton for local ip."""
-    try:
-        return socket.inet_pton(socket.AF_INET, get_ip())
-    except OSError:
-        return socket.inet_pton(socket.AF_INET6, get_ip())
-
-
-# pylint: enable=broad-except
-
-
-def get_hostname():
-    """Get hostname for this machine."""
-    return socket.gethostname()
-
-
-def get_folder_size(folderpath):
-    """Return folder size in gb."""
-    total_size = 0
-    # pylint: disable=unused-variable
-    for dirpath, dirnames, filenames in os.walk(folderpath):
-        for _file in filenames:
-            _fp = os.path.join(dirpath, _file)
-            total_size += os.path.getsize(_fp)
-    # pylint: enable=unused-variable
-    total_size_gb = total_size / float(1 << 30)
-    return total_size_gb
-
-
-class EnhancedJSONEncoder(json.JSONEncoder):
-    """Custom JSON decoder."""
-
-    def default(self, obj):
-        """Return default handler."""
-        # pylint: disable=method-hidden
-        try:
-            # as most of our objects are dataclass, we just try this first
-            return obj.to_dict()
-        except AttributeError:
-            pass
-        if isinstance(obj, datetime):
-            return obj.isoformat()
-        if isinstance(obj, Enum):
-            return str(obj)
-        return super().default(obj)
-
-
-# pylint: disable=invalid-name
-json_serializer = functools.partial(json.dumps, cls=EnhancedJSONEncoder)
-# pylint: enable=invalid-name
-
-
-def get_compare_string(input_str):
-    """Return clean lowered string for compare actions."""
-    unaccented_string = unidecode.unidecode(input_str)
-    return re.sub(r"[^a-zA-Z0-9]", "", unaccented_string).lower()
-
-
-def compare_strings(str1, str2, strict=False):
-    """Compare strings and return True if we have an (almost) perfect match."""
-    match = str1.lower() == str2.lower()
-    if not match and not strict:
-        match = get_compare_string(str1) == get_compare_string(str2)
-    return match
-
-
-def try_load_json_file(jsonfile):
-    """Try to load json from file."""
-    try:
-        with open(jsonfile) as _file:
-            return json.loads(_file.read())
-    except (FileNotFoundError, json.JSONDecodeError) as exc:
-        logging.getLogger().debug(
-            "Could not load json from file %s", jsonfile, exc_info=exc
-        )
-        return None
-
-
-def create_tempfile():
-    """Return a (named) temporary file."""
-    if platform.system() == "Linux":
-        return memory_tempfile.MemoryTempfile(fallback=True).NamedTemporaryFile(
-            buffering=0
-        )
-    return tempfile.NamedTemporaryFile(buffering=0)
-
-
-def encrypt_string(str_value):
-    """Encrypt a string with Fernet."""
-    return Fernet(get_app_var(3)).encrypt(str_value.encode()).decode()
-
-
-def encrypt_bytes(bytes_value):
-    """Encrypt bytes with Fernet."""
-    return Fernet(get_app_var(3)).encrypt(bytes_value)
-
-
-def yield_chunks(_obj, chunk_size):
-    """Yield successive n-sized chunks from list/str/bytes."""
-    chunk_size = int(chunk_size)
-    for i in range(0, len(_obj), chunk_size):
-        yield _obj[i : i + chunk_size]
-
-
-def decrypt_string(str_value):
-    """Decrypt a string with Fernet."""
-    try:
-        return Fernet(get_app_var(3)).decrypt(str_value.encode()).decode()
-    except InvalidToken:
-        return None
-
-
-def decrypt_bytes(bytes_value):
-    """Decrypt bytes with Fernet."""
-    try:
-        return Fernet(get_app_var(3)).decrypt(bytes_value)
-    except InvalidToken:
-        return None
-
-
-class CustomIntEnum(int, Enum):
-    """Base for IntEnum with some helpers."""
-
-    # when serializing we prefer the string (name) representation
-    # internally (database) we use the int value
-
-    def __int__(self):
-        """Return integer value."""
-        return super().value
-
-    def __str__(self):
-        """Return string value."""
-        # pylint: disable=no-member
-        return self._name_.lower()
-
-    @property
-    def value(self):
-        """Return the (json friendly) string name."""
-        return self.__str__()
-
-    @classmethod
-    def from_string(cls, string):
-        """Create IntEnum from it's string equivalent."""
-        for key, value in cls.__dict__.items():
-            if key.lower() == string or value == try_parse_int(string):
-                return value
-        return KeyError
-
-
-def create_wave_header(samplerate=44100, channels=2, bitspersample=16, duration=3600):
-    """Generate a wave header from given params."""
-    file = BytesIO()
-    numsamples = samplerate * duration
-
-    # Generate format chunk
-    format_chunk_spec = b"<4sLHHLLHH"
-    format_chunk = struct.pack(
-        format_chunk_spec,
-        b"fmt ",  # Chunk id
-        16,  # Size of this chunk (excluding chunk id and this field)
-        1,  # Audio format, 1 for PCM
-        channels,  # Number of channels
-        int(samplerate),  # Samplerate, 44100, 48000, etc.
-        int(samplerate * channels * (bitspersample / 8)),  # Byterate
-        int(channels * (bitspersample / 8)),  # Blockalign
-        bitspersample,  # 16 bits for two byte samples, etc.
-    )
-    # Generate data chunk
-    data_chunk_spec = b"<4sL"
-    datasize = int(numsamples * channels * (bitspersample / 8))
-    data_chunk = struct.pack(
-        data_chunk_spec,
-        b"data",  # Chunk id
-        int(datasize),  # Chunk size (excluding chunk id and this field)
-    )
-    sum_items = [
-        # "WAVE" string following size field
-        4,
-        # "fmt " + chunk size field + chunk size
-        struct.calcsize(format_chunk_spec),
-        # Size of data chunk spec + data size
-        struct.calcsize(data_chunk_spec) + datasize,
-    ]
-    # Generate main header
-    all_chunks_size = int(sum(sum_items))
-    main_header_spec = b"<4sL4s"
-    main_header = struct.pack(main_header_spec, b"RIFF", all_chunks_size, b"WAVE")
-    # Write all the contents in
-    file.write(main_header)
-    file.write(format_chunk)
-    file.write(data_chunk)
-
-    # return file.getvalue(), all_chunks_size + 8
-    return file.getvalue()
diff --git a/music_assistant/web.py b/music_assistant/web.py
deleted file mode 100755 (executable)
index f3c97e9..0000000
+++ /dev/null
@@ -1,947 +0,0 @@
-"""The web module handles serving the frontend and the rest/websocket api's."""
-import asyncio
-import datetime
-import functools
-import inspect
-import ipaddress
-import json
-import logging
-import os
-import ssl
-
-import aiohttp
-import aiohttp_cors
-import jwt
-from aiohttp import web
-from aiohttp_jwt import JWTMiddleware, login_required
-from music_assistant.constants import (
-    CONF_KEY_BASE,
-    CONF_KEY_PLAYERSETTINGS,
-    CONF_KEY_PROVIDERS,
-)
-from music_assistant.constants import __version__ as MASS_VERSION
-from music_assistant.models.media_types import MediaType
-from music_assistant.models.player_queue import QueueOption
-from music_assistant.utils import get_external_ip, get_hostname, get_ip, json_serializer
-
-LOGGER = logging.getLogger("mass")
-
-
-class ClassRouteTableDef(web.RouteTableDef):
-    """Helper class to add class based routing tables."""
-
-    def __repr__(self) -> str:
-        """Print the class contents."""
-        return "<ClassRouteTableDef count={}>".format(len(self._items))
-
-    def route(self, method: str, path: str, **kwargs):
-        """Return the route."""
-        # pylint: disable=missing-function-docstring
-        def inner(handler):
-            handler.route_info = (method, path, kwargs)
-            return handler
-
-        return inner
-
-    def add_class_routes(self, instance) -> None:
-        """Add class routes."""
-        # pylint: disable=missing-function-docstring
-        def predicate(member) -> bool:
-            return all(
-                (inspect.iscoroutinefunction(member), hasattr(member, "route_info"))
-            )
-
-        for _, handler in inspect.getmembers(instance, predicate):
-            method, path, kwargs = handler.route_info
-            super().route(method, path, **kwargs)(handler)
-
-
-# pylint: disable=invalid-name
-routes = ClassRouteTableDef()
-# pylint: enable=invalid-name
-
-
-def require_local_subnet(func):
-    """Return decorator to specify web method as available locally only."""
-
-    @functools.wraps(func)
-    async def wrapped(*args, **kwargs):
-        request = args[-1]
-
-        if isinstance(request, web.View):
-            request = request.request
-
-        if not isinstance(request, web.BaseRequest):  # pragma: no cover
-            raise RuntimeError(
-                "Incorrect usage of decorator." "Expect web.BaseRequest as an argument"
-            )
-
-        if not ipaddress.ip_address(request.remote).is_private:
-            raise web.HTTPUnauthorized(reason="Not remote available")
-
-        return await func(*args, **kwargs)
-
-    return wrapped
-
-
-class Web:
-    """Webserver and json/websocket api."""
-
-    def __init__(self, mass):
-        """Initialize class."""
-        self.mass = mass
-        # load/create/update config
-        self._local_ip = get_ip()
-        self.config = mass.config.base["web"]
-        self.runner = None
-
-        enable_ssl = self.config["ssl_certificate"] and self.config["ssl_key"]
-        if self.config["ssl_certificate"] and not os.path.isfile(
-            self.config["ssl_certificate"]
-        ):
-            enable_ssl = False
-            LOGGER.warning(
-                "SSL certificate file not found: %s", self.config["ssl_certificate"]
-            )
-        if self.config["ssl_key"] and not os.path.isfile(self.config["ssl_key"]):
-            enable_ssl = False
-            LOGGER.warning(
-                "SSL certificate key file not found: %s", self.config["ssl_key"]
-            )
-        if not self.config.get("external_url"):
-            enable_ssl = False
-        self._enable_ssl = enable_ssl
-        self._jwt_shared_secret = f"mass_{self._local_ip}_{self.http_port}"
-
-    async def async_setup(self):
-        """Perform async setup."""
-        routes.add_class_routes(self)
-        jwt_middleware = JWTMiddleware(
-            self._jwt_shared_secret, request_property="user", credentials_required=False
-        )
-        app = web.Application(middlewares=[jwt_middleware])
-        # add routes
-        app.add_routes(routes)
-        app.add_routes(
-            [
-                web.get("/", self.async_index),
-                web.post("/login", self.async_login),
-                web.get("/jsonrpc.js", self.async_json_rpc),
-                web.post("/jsonrpc.js", self.async_json_rpc),
-                web.get("/ws", self.async_websocket_handler),
-                web.get("/info", self.async_info),
-            ]
-        )
-        webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web/")
-        if os.path.isdir(webdir):
-            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,
-            defaults={
-                "*": aiohttp_cors.ResourceOptions(
-                    allow_credentials=True,
-                    expose_headers="*",
-                    allow_headers="*",
-                    allow_methods=["POST", "PUT", "DELETE", "GET"],
-                )
-            },
-        )
-        for route in list(app.router.routes()):
-            cors.add(route)
-        self.runner = web.AppRunner(app, access_log=None)
-        await self.runner.setup()
-        http_site = web.TCPSite(self.runner, "0.0.0.0", self.http_port)
-        await http_site.start()
-        LOGGER.info("Started HTTP webserver on port %s", self.http_port)
-        if self._enable_ssl:
-            ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
-            ssl_context.load_cert_chain(
-                self.config["ssl_certificate"], self.config["ssl_key"]
-            )
-            https_site = web.TCPSite(
-                self.runner, "0.0.0.0", self.https_port, ssl_context=ssl_context
-            )
-            await https_site.start()
-            LOGGER.info(
-                "Started HTTPS webserver on port %s - serving at FQDN %s",
-                self.https_port,
-                self.external_url,
-            )
-
-    @property
-    def internal_ip(self):
-        """Return the local IP address for this Music Assistant instance."""
-        return self._local_ip
-
-    @property
-    def http_port(self):
-        """Return the HTTP port for this Music Assistant instance."""
-        return self.config.get("http_port", 8095)
-
-    @property
-    def https_port(self):
-        """Return the HTTPS port for this Music Assistant instance."""
-        return self.config.get("https_port", 8096)
-
-    @property
-    def internal_url(self):
-        """Return the internal URL for this Music Assistant instance."""
-        return f"http://{self._local_ip}:{self.http_port}"
-
-    @property
-    def external_url(self):
-        """Return the internal URL for this Music Assistant instance."""
-        if self._enable_ssl and self.config.get("external_url"):
-            return self.config["external_url"]
-        return f"http://{get_external_ip()}:{self.http_port}"
-
-    @property
-    def discovery_info(self):
-        """Return (discovery) info about this instance."""
-        return {
-            "id": f"{get_hostname()}",
-            "external_url": self.external_url,
-            "internal_url": self.internal_url,
-            "host": self.internal_ip,
-            "http_port": self.http_port,
-            "https_port": self.https_port,
-            "ssl_enabled": self._enable_ssl,
-            "version": MASS_VERSION,
-        }
-
-    @routes.post("/api/login")
-    async def async_login(self, request):
-        """Handle the retrieval of a JWT token."""
-        form = await request.json()
-        username = form.get("username")
-        password = form.get("password")
-        token_info = await self.__async_get_token(username, password)
-        if token_info:
-            return web.json_response(token_info, dumps=json_serializer)
-        return web.HTTPUnauthorized(body="Invalid username and/or password provided!")
-
-    @routes.get("/api/info")
-    async def async_info(self, request):
-        # pylint: disable=unused-argument
-        """Return (discovery) info about this instance."""
-        return web.json_response(self.discovery_info, dumps=json_serializer)
-
-    async def async_index(self, request):
-        """Get the index page, redirect if we do not have a web directory."""
-        # pylint: disable=unused-argument
-        webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web/")
-        if not os.path.isdir(webdir):
-            raise web.HTTPFound("https://music-assistant.github.io/app")
-        return web.FileResponse(os.path.join(webdir, "index.html"))
-
-    @routes.get("/stream/media/{media_type}/{item_id}")
-    async def stream_media(self, request):
-        """Stream a single audio track."""
-        media_type = MediaType.from_string(request.match_info["media_type"])
-        if media_type not in [MediaType.Track, MediaType.Radio]:
-            return web.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 self.mass.music_manager.async_get_item(
-            item_id, provider, media_type
-        )
-        streamdetails = await self.mass.music_manager.async_get_stream_details(
-            media_item
-        )
-
-        # prepare request
-        content_type = streamdetails.content_type.value
-        resp = web.StreamResponse(
-            status=200, reason="OK", headers={"Content-Type": f"audio/{content_type}"}
-        )
-        resp.enable_chunked_encoding()
-        await resp.prepare(request)
-
-        # stream track
-        async for audio_chunk in self.mass.stream_manager.async_get_stream(
-            streamdetails
-        ):
-            await resp.write(audio_chunk)
-        return resp
-
-    @routes.get("/stream/queue/{player_id}")
-    async def stream_queue(self, request):
-        """Stream a player's queue."""
-        player_id = request.match_info["player_id"]
-        if not self.mass.player_manager.get_player_queue(player_id):
-            return web.Response(text="invalid queue", status=404)
-
-        # prepare request
-        resp = web.StreamResponse(
-            status=200, reason="OK", headers={"Content-Type": "audio/flac"}
-        )
-        resp.enable_chunked_encoding()
-        await resp.prepare(request)
-
-        # stream queue
-        async for audio_chunk in self.mass.stream_manager.async_queue_stream_flac(
-            player_id
-        ):
-            await resp.write(audio_chunk)
-        return resp
-
-    @routes.get("/stream/queue/{player_id}/{queue_item_id}")
-    async def stream_queue_item(self, 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 = web.StreamResponse(
-            status=200, reason="OK", headers={"Content-Type": "audio/flac"}
-        )
-        resp.enable_chunked_encoding()
-        await resp.prepare(request)
-
-        async for audio_chunk in self.mass.stream_manager.async_stream_queue_item(
-            player_id, queue_item_id
-        ):
-            await resp.write(audio_chunk)
-        return resp
-
-    @routes.get("/stream/group/{group_player_id}")
-    async def stream_group(self, request):
-        """Handle streaming to all players of a group. Highly experimental."""
-        group_player_id = request.match_info["group_player_id"]
-        if not self.mass.player_manager.get_player_queue(group_player_id):
-            return web.Response(text="invalid player id", status=404)
-        child_player_id = request.rel_url.query.get("player_id", request.remote)
-
-        # prepare request
-        resp = web.StreamResponse(
-            status=200, reason="OK", headers={"Content-Type": "audio/flac"}
-        )
-        resp.enable_chunked_encoding()
-        await resp.prepare(request)
-
-        # stream queue
-        player = self.mass.player_manager.get_player(group_player_id)
-        async for audio_chunk in player.player.subscribe_stream_client(child_player_id):
-            await resp.write(audio_chunk)
-        return resp
-
-    @login_required
-    @routes.get("/api/library/artists")
-    async def async_library_artists(self, request):
-        """Get all library artists."""
-        orderby = request.query.get("orderby", "name")
-        provider_filter = request.rel_url.query.get("provider")
-        iterator = self.mass.music_manager.async_get_library_artists(
-            orderby=orderby, provider_filter=provider_filter
-        )
-        return await self.__async_stream_json(request, iterator)
-
-    @login_required
-    @routes.get("/api/library/albums")
-    async def async_library_albums(self, request):
-        """Get all library albums."""
-        orderby = request.query.get("orderby", "name")
-        provider_filter = request.rel_url.query.get("provider")
-        iterator = self.mass.music_manager.async_get_library_albums(
-            orderby=orderby, provider_filter=provider_filter
-        )
-        return await self.__async_stream_json(request, iterator)
-
-    @login_required
-    @routes.get("/api/library/tracks")
-    async def async_library_tracks(self, request):
-        """Get all library tracks."""
-        orderby = request.query.get("orderby", "name")
-        provider_filter = request.rel_url.query.get("provider")
-        iterator = self.mass.music_manager.async_get_library_tracks(
-            orderby=orderby, provider_filter=provider_filter
-        )
-        return await self.__async_stream_json(request, iterator)
-
-    @login_required
-    @routes.get("/api/library/radios")
-    async def async_library_radios(self, request):
-        """Get all library radios."""
-        orderby = request.query.get("orderby", "name")
-        provider_filter = request.rel_url.query.get("provider")
-        iterator = self.mass.music_manager.async_get_library_radios(
-            orderby=orderby, provider_filter=provider_filter
-        )
-        return await self.__async_stream_json(request, iterator)
-
-    @login_required
-    @routes.get("/api/library/playlists")
-    async def async_library_playlists(self, request):
-        """Get all library playlists."""
-        orderby = request.query.get("orderby", "name")
-        provider_filter = request.rel_url.query.get("provider")
-        iterator = self.mass.music_manager.async_get_library_playlists(
-            orderby=orderby, provider_filter=provider_filter
-        )
-        return await self.__async_stream_json(request, iterator)
-
-    @login_required
-    @routes.put("/api/library")
-    async def async_library_add(self, request):
-        """Add item(s) to the library."""
-        body = await request.json()
-        media_items = await self.__async_media_items_from_body(body)
-        result = await self.mass.music_manager.async_library_add(media_items)
-        return web.json_response(result, dumps=json_serializer)
-
-    @login_required
-    @routes.delete("/api/library")
-    async def async_library_remove(self, request):
-        """Remove item(s) from the library."""
-        body = await request.json()
-        media_items = await self.__async_media_items_from_body(body)
-        result = await self.mass.music_manager.async_library_remove(media_items)
-        return web.json_response(result, dumps=json_serializer)
-
-    @login_required
-    @routes.get("/api/artists/{item_id}")
-    async def async_artist(self, request):
-        """Get full artist details."""
-        item_id = request.match_info.get("item_id")
-        provider = request.rel_url.query.get("provider")
-        lazy = request.rel_url.query.get("lazy", "true") != "false"
-        if item_id is None or provider is None:
-            return web.Response(text="invalid item or provider", status=501)
-        result = await self.mass.music_manager.async_get_artist(
-            item_id, provider, lazy=lazy
-        )
-        return web.json_response(result, dumps=json_serializer)
-
-    @login_required
-    @routes.get("/api/albums/{item_id}")
-    async def async_album(self, request):
-        """Get full album details."""
-        item_id = request.match_info.get("item_id")
-        provider = request.rel_url.query.get("provider")
-        lazy = request.rel_url.query.get("lazy", "true") != "false"
-        if item_id is None or provider is None:
-            return web.Response(text="invalid item or provider", status=501)
-        result = await self.mass.music_manager.async_get_album(
-            item_id, provider, lazy=lazy
-        )
-        return web.json_response(result, dumps=json_serializer)
-
-    @login_required
-    @routes.get("/api/tracks/{item_id}")
-    async def async_track(self, request):
-        """Get full track details."""
-        item_id = request.match_info.get("item_id")
-        provider = request.rel_url.query.get("provider")
-        lazy = request.rel_url.query.get("lazy", "true") != "false"
-        if item_id is None or provider is None:
-            return web.Response(text="invalid item or provider", status=501)
-        result = await self.mass.music_manager.async_get_track(
-            item_id, provider, lazy=lazy
-        )
-        return web.json_response(result, dumps=json_serializer)
-
-    @login_required
-    @routes.get("/api/playlists/{item_id}")
-    async def async_playlist(self, 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 web.Response(text="invalid item or provider", status=501)
-        result = await self.mass.music_manager.async_get_playlist(item_id, provider)
-        return web.json_response(result, dumps=json_serializer)
-
-    @login_required
-    @routes.get("/api/radios/{item_id}")
-    async def async_radio(self, 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 web.Response(text="invalid item_id or provider", status=501)
-        result = await self.mass.music_manager.async_get_radio(item_id, provider)
-        return web.json_response(result, dumps=json_serializer)
-
-    @routes.get("/api/{media_type}/{media_id}/thumb")
-    async def async_get_image(self, request):
-        """Get (resized) thumb image."""
-        media_type_str = request.match_info.get("media_type")
-        media_type = MediaType.from_string(media_type_str)
-        media_id = request.match_info.get("media_id")
-        provider = request.rel_url.query.get("provider")
-        if media_id is None or provider is None:
-            return web.Response(text="invalid media_id or provider", status=501)
-        size = int(request.rel_url.query.get("size", 0))
-        img_file = await self.mass.music_manager.async_get_image_thumb(
-            media_id, provider, media_type, size
-        )
-        if not img_file or not os.path.isfile(img_file):
-            return web.Response(status=404)
-        headers = {"Cache-Control": "max-age=86400, public", "Pragma": "public"}
-        return web.FileResponse(img_file, headers=headers)
-
-    @login_required
-    @routes.get("/api/artists/{item_id}/toptracks")
-    async def async_artist_toptracks(self, 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 web.Response(text="invalid item_id or provider", status=501)
-        iterator = self.mass.music_manager.async_get_artist_toptracks(item_id, provider)
-        return await self.__async_stream_json(request, iterator)
-
-    @login_required
-    @routes.get("/api/artists/{item_id}/albums")
-    async def async_artist_albums(self, 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 web.Response(text="invalid item_id or provider", status=501)
-        iterator = self.mass.music_manager.async_get_artist_albums(item_id, provider)
-        return await self.__async_stream_json(request, iterator)
-
-    @login_required
-    @routes.get("/api/playlists/{item_id}/tracks")
-    async def async_playlist_tracks(self, 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 web.Response(text="invalid item_id or provider", status=501)
-        iterator = self.mass.music_manager.async_get_playlist_tracks(item_id, provider)
-        return await self.__async_stream_json(request, iterator)
-
-    @login_required
-    @routes.put("/api/playlists/{item_id}/tracks")
-    async def async_add_playlist_tracks(self, request):
-        """Add tracks to (editable) playlist."""
-        item_id = request.match_info.get("item_id")
-        body = await request.json()
-        tracks = await self.__async_media_items_from_body(body)
-        result = await self.mass.music_manager.async_add_playlist_tracks(
-            item_id, tracks
-        )
-        return web.json_response(result, dumps=json_serializer)
-
-    @login_required
-    @routes.delete("/api/playlists/{item_id}/tracks")
-    async def async_remove_playlist_tracks(self, request):
-        """Remove tracks from (editable) playlist."""
-        item_id = request.match_info.get("item_id")
-        body = await request.json()
-        tracks = await self.__async_media_items_from_body(body)
-        result = await self.mass.music_manager.async_remove_playlist_tracks(
-            item_id, tracks
-        )
-        return web.json_response(result, dumps=json_serializer)
-
-    @login_required
-    @routes.get("/api/albums/{item_id}/tracks")
-    async def async_album_tracks(self, 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 web.Response(text="invalid item_id or provider", status=501)
-        iterator = self.mass.music_manager.async_get_album_tracks(item_id, provider)
-        return await self.__async_stream_json(request, iterator)
-
-    @login_required
-    @routes.get("/api/albums/{item_id}/versions")
-    async def async_album_versions(self, 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 web.Response(text="invalid item_id or provider", status=501)
-        iterator = self.mass.music_manager.async_get_album_versions(item_id, provider)
-        return await self.__async_stream_json(request, iterator)
-
-    @login_required
-    @routes.get("/api/tracks/{item_id}/versions")
-    async def async_track_versions(self, 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 web.Response(text="invalid item_id or provider", status=501)
-        iterator = self.mass.music_manager.async_get_track_versions(item_id, provider)
-        return await self.__async_stream_json(request, iterator)
-
-    @login_required
-    @routes.get("/api/search")
-    async def async_search(self, 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)
-        # get results from database
-        result = await self.mass.music_manager.async_global_search(
-            searchquery, media_types, limit=limit
-        )
-        return web.json_response(result, dumps=json_serializer)
-
-    @login_required
-    @routes.get("/api/players")
-    async def async_players(self, request):
-        # pylint: disable=unused-argument
-        """Get all players."""
-        players = self.mass.player_manager.players
-        players.sort(key=lambda x: str(x.name), reverse=False)
-        return web.json_response(players, dumps=json_serializer)
-
-    @login_required
-    @routes.post("/api/players/{player_id}/cmd/{cmd}")
-    async def async_player_command(self, 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()
-        except json.decoder.JSONDecodeError:
-            cmd_args = None
-        player_cmd = getattr(self.mass.player_manager, 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 web.Response(text="invalid command", status=501)
-        result = {"success": success in [True, None]}
-        return web.json_response(result, dumps=json_serializer)
-
-    @login_required
-    @routes.post("/api/players/{player_id}/play_media/{queue_opt}")
-    async def async_player_play_media(self, request):
-        """Issue player play media command."""
-        player_id = request.match_info.get("player_id")
-        player = self.mass.player_manager.get_player(player_id)
-        if not player:
-            return web.Response(status=404)
-        queue_opt = QueueOption(request.match_info.get("queue_opt", "play"))
-        body = await request.json()
-        media_items = await self.__async_media_items_from_body(body)
-        success = await self.mass.player_manager.async_play_media(
-            player_id, media_items, queue_opt
-        )
-        result = {"success": success in [True, None]}
-        return web.json_response(result, dumps=json_serializer)
-
-    @login_required
-    @routes.get("/api/players/{player_id}/queue/items/{queue_item}")
-    async def async_player_queue_item(self, 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 = self.mass.player_manager.get_player_queue(player_id)
-        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 web.json_response(queue_item, dumps=json_serializer)
-
-    @login_required
-    @routes.get("/api/players/{player_id}/queue/items")
-    async def async_player_queue_items(self, request):
-        """Return the items in the player's queue."""
-        player_id = request.match_info.get("player_id")
-        player_queue = self.mass.player_manager.get_player_queue(player_id)
-
-        async def async_queue_tracks_iter():
-            for item in player_queue.items:
-                yield item
-
-        return await self.__async_stream_json(request, async_queue_tracks_iter())
-
-    @login_required
-    @routes.get("/api/players/{player_id}/queue")
-    async def async_player_queue(self, request):
-        """Return the player queue details."""
-        player_id = request.match_info.get("player_id")
-        player_queue = self.mass.player_manager.get_player_queue(player_id)
-        return web.json_response(player_queue, dumps=json_serializer)
-
-    @login_required
-    @routes.put("/api/players/{player_id}/queue/{cmd}")
-    async def async_player_queue_cmd(self, request):
-        """Change the player queue details."""
-        player_id = request.match_info.get("player_id")
-        player_queue = self.mass.player_manager.get_player_queue(player_id)
-        cmd = request.match_info.get("cmd")
-        try:
-            cmd_args = await request.json()
-        except json.decoder.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 web.json_response(player_queue.to_dict(), dumps=json_serializer)
-
-    @login_required
-    @routes.get("/api/players/{player_id}")
-    async def async_player(self, request):
-        """Get single player."""
-        player_id = request.match_info.get("player_id")
-        player = self.mass.player_manager.get_player(player_id)
-        if not player:
-            return web.Response(text="invalid player", status=404)
-        return web.json_response(player, dumps=json_serializer)
-
-    @login_required
-    @routes.get("/api/config")
-    async def async_get_config(self, request):
-        # pylint: disable=unused-argument
-        """Get the full config."""
-        conf = {
-            CONF_KEY_BASE: self.mass.config.base,
-            CONF_KEY_PROVIDERS: self.mass.config.providers,
-            CONF_KEY_PLAYERSETTINGS: self.mass.config.player_settings,
-        }
-        return web.json_response(conf, dumps=json_serializer)
-
-    @login_required
-    @routes.get("/api/config/{base}")
-    async def async_get_config_item(self, request):
-        """Get the config by base type."""
-        conf_base = request.match_info.get("base")
-        conf = self.mass.config[conf_base]
-        return web.json_response(conf, dumps=json_serializer)
-
-    @login_required
-    @routes.put("/api/config/{base}/{key}/{entry_key}")
-    async def async_put_config(self, 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 json.decoder.JSONDecodeError:
-            new_value = (
-                self.mass.config[conf_base][conf_key].get_entry(entry_key).default_value
-            )
-        self.mass.config[conf_base][conf_key][entry_key] = new_value
-        return web.json_response(True)
-
-    async def async_websocket_handler(self, request):
-        """Handle websockets connection."""
-        ws_response = None
-        authenticated = False
-        remove_callbacks = []
-        try:
-            ws_response = web.WebSocketResponse()
-            await ws_response.prepare(request)
-
-            # callback for internal events
-            async def async_send_message(msg, msg_details=None):
-                ws_msg = {"message": msg, "message_details": msg_details}
-                try:
-                    await ws_response.send_json(ws_msg, dumps=json_serializer)
-                except AssertionError:
-                    LOGGER.debug("trying to send message to ws while disconnected")
-
-            # process incoming messages
-            async for msg in ws_response:
-                if msg.type != aiohttp.WSMsgType.TEXT:
-                    # not sure when/if this happens but log it anyway
-                    LOGGER.warning(msg.data)
-                    continue
-                try:
-                    data = msg.json()
-                except json.decoder.JSONDecodeError:
-                    await async_send_message(
-                        "error",
-                        'commands must be issued in json format \
-                            {"message": "command", "message_details":" optional details"}',
-                    )
-                    continue
-                msg = data.get("message")
-                msg_details = data.get("message_details")
-                if not authenticated and not msg == "login":
-                    # make sure client is authenticated
-                    await async_send_message("error", "authentication required")
-                elif msg == "login" and msg_details:
-                    # authenticate with token
-                    try:
-                        token_info = jwt.decode(msg_details, self._jwt_shared_secret)
-                    except jwt.InvalidTokenError as exc:
-                        LOGGER.exception(exc, exc_info=exc)
-                        error_msg = "Invalid authorization token, " + str(exc)
-                        await async_send_message("error", error_msg)
-                    else:
-                        authenticated = True
-                        await async_send_message("login", token_info)
-                elif msg == "add_event_listener":
-                    remove_callbacks.append(
-                        self.mass.add_event_listener(async_send_message, msg_details)
-                    )
-                    await async_send_message("event listener subscribed", msg_details)
-                elif msg == "player_command":
-                    player_id = msg_details.get("player_id")
-                    cmd = msg_details.get("cmd")
-                    cmd_args = msg_details.get("cmd_args")
-                    player_cmd = getattr(
-                        self.mass.player_manager, 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}
-                    await async_send_message("player_command_result", msg_details)
-                else:
-                    # simply echo the message on the eventbus
-                    self.mass.signal_event(msg, msg_details)
-        except (AssertionError, asyncio.CancelledError):
-            LOGGER.debug("Websocket disconnected")
-        finally:
-            for remove_callback in remove_callbacks:
-                remove_callback()
-        return ws_response
-
-    @require_local_subnet
-    async def async_json_rpc(self, 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()
-        LOGGER.debug("jsonrpc: %s", data)
-        params = data["params"]
-        player_id = params[0]
-        cmds = params[1]
-        cmd_str = " ".join(cmds)
-        if cmd_str == "play":
-            await self.mass.player_manager.async_cmd_play(player_id)
-        elif cmd_str == "pause":
-            await self.mass.player_manager.async_cmd_pause(player_id)
-        elif cmd_str == "stop":
-            await self.mass.player_manager.async_cmd_stop(player_id)
-        elif cmd_str == "next":
-            await self.mass.player_manager.async_cmd_next(player_id)
-        elif cmd_str == "previous":
-            await self.mass.player_manager.async_cmd_previous(player_id)
-        elif "power" in cmd_str:
-            powered = cmds[1] if len(cmds) > 1 else False
-            if powered:
-                await self.mass.player_manager.async_cmd_power_on(player_id)
-            else:
-                await self.mass.player_manager.async_cmd_power_off(player_id)
-        elif cmd_str == "playlist index +1":
-            await self.mass.player_manager.async_cmd_next(player_id)
-        elif cmd_str == "playlist index -1":
-            await self.mass.player_manager.async_cmd_previous(player_id)
-        elif "mixer volume" in cmd_str and "+" in cmds[2]:
-            player = self.mass.player_manager.get_player(player_id)
-            volume_level = player.volume_level + int(cmds[2].split("+")[1])
-            await self.mass.player_manager.async_cmd_volume_set(player_id, volume_level)
-        elif "mixer volume" in cmd_str and "-" in cmds[2]:
-            player = self.mass.player_manager.get_player(player_id)
-            volume_level = player.volume_level - int(cmds[2].split("-")[1])
-            await self.mass.player_manager.async_cmd_volume_set(player_id, volume_level)
-        elif "mixer volume" in cmd_str:
-            await self.mass.player_manager.async_cmd_volume_set(player_id, cmds[2])
-        elif cmd_str == "mixer muting 1":
-            await self.mass.player_manager.async_cmd_volume_mute(player_id, True)
-        elif cmd_str == "mixer muting 0":
-            await self.mass.player_manager.async_cmd_volume_mute(player_id, False)
-        elif cmd_str == "button volup":
-            await self.mass.player_manager.async_cmd_volume_up(player_id)
-        elif cmd_str == "button voldown":
-            await self.mass.player_manager.async_cmd_volume_down(player_id)
-        elif cmd_str == "button power":
-            await self.mass.player_manager.async_cmd_power_toggle(player_id)
-        else:
-            return web.Response(text="command not supported")
-        return web.Response(text="success")
-
-    async def __async_media_items_from_body(self, data):
-        """Convert posted body data into media items."""
-        if not isinstance(data, list):
-            data = [data]
-        media_items = []
-        for item in data:
-            media_item = await self.mass.music_manager.async_get_item(
-                item["item_id"],
-                item["provider"],
-                MediaType.from_string(item["media_type"]),
-                lazy=True,
-            )
-            media_items.append(media_item)
-        return media_items
-
-    async def __async_stream_json(self, request, iterator):
-        """Stream items from async iterator as json object."""
-        resp = web.StreamResponse(
-            status=200, reason="OK", headers={"Content-Type": "application/json"}
-        )
-        await resp.prepare(request)
-        # write json open tag
-        json_response = '{ "items": ['
-        await resp.write(json_response.encode("utf-8"))
-        count = 0
-        async for item in iterator:
-            # write each item into the items object of the json
-            if count:
-                json_response = "," + json_serializer(item)
-            else:
-                json_response = json_serializer(item)
-            await resp.write(json_response.encode("utf-8"))
-            count += 1
-        # write json close tag
-        json_response = '], "count": %s }' % count
-        await resp.write((json_response).encode("utf-8"))
-        await resp.write_eof()
-        return resp
-
-    async def __async_get_token(self, username, password):
-        """Validate given credentials and return JWT token."""
-        verified = self.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},
-                self._jwt_shared_secret,
-            )
-            return {
-                "user": username,
-                "token": token.decode(),
-                "expires": token_expires,
-                "scopes": scopes,
-            }
-        return None
diff --git a/music_assistant/web/__init__.py b/music_assistant/web/__init__.py
new file mode 100755 (executable)
index 0000000..be1d43f
--- /dev/null
@@ -0,0 +1,202 @@
+"""The web module handles serving the frontend and the rest/websocket api's."""
+import logging
+import os
+import ssl
+import uuid
+
+import aiohttp_cors
+from aiohttp import web
+from aiohttp_jwt import JWTMiddleware
+from music_assistant.constants import __version__ as MASS_VERSION
+from music_assistant.helpers.util import get_hostname, get_ip, json_serializer
+
+from .endpoints import (
+    albums,
+    artists,
+    config,
+    images,
+    json_rpc,
+    library,
+    login,
+    players,
+    playlists,
+    radios,
+    search,
+    streams,
+    tracks,
+    websocket,
+)
+
+LOGGER = logging.getLogger("mass")
+
+
+routes = web.RouteTableDef()
+
+
+class WebServer:
+    """Webserver and json/websocket api."""
+
+    def __init__(self, mass):
+        """Initialize class."""
+        self.mass = mass
+        # load/create/update config
+        self._local_ip = get_ip()
+        self._device_id = f"{uuid.getnode()}_{get_hostname()}"
+        self.config = mass.config.base["web"]
+        self._runner = None
+
+        enable_ssl = self.config["ssl_certificate"] and self.config["ssl_key"]
+        if self.config["ssl_certificate"] and not os.path.isfile(
+            self.config["ssl_certificate"]
+        ):
+            enable_ssl = False
+            LOGGER.warning(
+                "SSL certificate file not found: %s", self.config["ssl_certificate"]
+            )
+        if self.config["ssl_key"] and not os.path.isfile(self.config["ssl_key"]):
+            enable_ssl = False
+            LOGGER.warning(
+                "SSL certificate key file not found: %s", self.config["ssl_key"]
+            )
+        if not self.config.get("external_url"):
+            enable_ssl = False
+        self._enable_ssl = enable_ssl
+
+    async def async_setup(self):
+        """Perform async setup."""
+
+        jwt_middleware = JWTMiddleware(
+            self.device_id, request_property="user", credentials_required=False
+        )
+        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)
+
+        webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web/static/")
+        if os.path.isdir(webdir):
+            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,
+            defaults={
+                "*": aiohttp_cors.ResourceOptions(
+                    allow_credentials=True,
+                    expose_headers="*",
+                    allow_headers="*",
+                    allow_methods=["POST", "PUT", "DELETE", "GET"],
+                )
+            },
+        )
+        for route in list(app.router.routes()):
+            cors.add(route)
+        self._runner = web.AppRunner(app, access_log=None)
+        await self._runner.setup()
+        http_site = web.TCPSite(self._runner, "0.0.0.0", self.http_port)
+        await http_site.start()
+        LOGGER.info("Started HTTP webserver on port %s", self.http_port)
+        if self._enable_ssl:
+            ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+            ssl_context.load_cert_chain(
+                self.config["ssl_certificate"], self.config["ssl_key"]
+            )
+            https_site = web.TCPSite(
+                self._runner, "0.0.0.0", self.https_port, ssl_context=ssl_context
+            )
+            await https_site.start()
+            LOGGER.info(
+                "Started HTTPS webserver on port %s - serving at FQDN %s",
+                self.https_port,
+                self.external_url,
+            )
+
+    async def async_stop(self):
+        """Stop the webserver."""
+        # if self._runner:
+        #     await self._runner.cleanup()
+
+    @property
+    def internal_ip(self):
+        """Return the local IP address for this Music Assistant instance."""
+        return self._local_ip
+
+    @property
+    def http_port(self):
+        """Return the HTTP port for this Music Assistant instance."""
+        return self.config.get("http_port", 8095)
+
+    @property
+    def https_port(self):
+        """Return the HTTPS port for this Music Assistant instance."""
+        return self.config.get("https_port", 8096)
+
+    @property
+    def internal_url(self):
+        """Return the internal URL for this Music Assistant instance."""
+        return f"http://{self._local_ip}:{self.http_port}"
+
+    @property
+    def external_url(self):
+        """Return the internal URL for this Music Assistant instance."""
+        if self._enable_ssl and self.config.get("external_url"):
+            return self.config["external_url"]
+        return self.internal_url
+
+    @property
+    def device_id(self):
+        """Return the device ID for this Music Assistant Server."""
+        return self._device_id
+
+    @property
+    def discovery_info(self):
+        """Return (discovery) info about this instance."""
+        return {
+            "id": self._device_id,
+            "external_url": self.external_url,
+            "internal_url": self.internal_url,
+            "host": self.internal_ip,
+            "http_port": self.http_port,
+            "https_port": self.https_port,
+            "ssl_enabled": self._enable_ssl,
+            "version": MASS_VERSION,
+        }
+
+
+@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__)), "web/static/index.html"
+    )
+    if not os.path.isfile(html_app):
+        raise web.HTTPFound("https://music-assistant.github.io/app")
+    return web.FileResponse(html_app)
diff --git a/music_assistant/web/endpoints/__init__.py b/music_assistant/web/endpoints/__init__.py
new file mode 100644 (file)
index 0000000..fc1c4cc
--- /dev/null
@@ -0,0 +1 @@
+"""Web endpoints package."""
diff --git a/music_assistant/web/endpoints/albums.py b/music_assistant/web/endpoints/albums.py
new file mode 100644 (file)
index 0000000..f4d151e
--- /dev/null
@@ -0,0 +1,55 @@
+"""Albums API endpoints."""
+
+from aiohttp import web
+from aiohttp_jwt import login_required
+from music_assistant.helpers.util import json_serializer
+from music_assistant.helpers.web import async_stream_json
+
+routes = web.RouteTableDef()
+
+
+@routes.get("/api/albums")
+@login_required
+async def async_albums(request: web.Request):
+    """Get all albums known in the database."""
+    generator = request.app["mass"].database.async_get_albums()
+    return await async_stream_json(request, generator)
+
+
+@routes.get("/api/albums/{item_id}")
+@login_required
+async def async_album(request: web.Request):
+    """Get full album details."""
+    item_id = request.match_info.get("item_id")
+    provider = request.rel_url.query.get("provider")
+    lazy = request.rel_url.query.get("lazy", "true") != "false"
+    if item_id is None or provider is None:
+        return web.Response(text="invalid item or provider", status=501)
+    result = await request.app["mass"].music.async_get_album(
+        item_id, provider, lazy=lazy
+    )
+    return web.Response(body=json_serializer(result), content_type="application/json")
+
+
+@routes.get("/api/albums/{item_id}/tracks")
+@login_required
+async def async_album_tracks(request: web.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 web.Response(text="invalid item_id or provider", status=501)
+    generator = request.app["mass"].music.async_get_album_tracks(item_id, provider)
+    return await async_stream_json(request, generator)
+
+
+@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 web.Response(text="invalid item_id or provider", status=501)
+    generator = request.app["mass"].music.async_get_album_versions(item_id, provider)
+    return await async_stream_json(request, generator)
diff --git a/music_assistant/web/endpoints/artists.py b/music_assistant/web/endpoints/artists.py
new file mode 100644 (file)
index 0000000..b171248
--- /dev/null
@@ -0,0 +1,55 @@
+"""Artists API endpoints."""
+
+from aiohttp import web
+from aiohttp_jwt import login_required
+from music_assistant.helpers.util import json_serializer
+from music_assistant.helpers.web import async_stream_json
+
+routes = web.RouteTableDef()
+
+
+@routes.get("/api/artists")
+@login_required
+async def async_artists(request: web.Request):
+    """Get all artists known in the database."""
+    generator = request.app["mass"].database.async_get_artists()
+    return await async_stream_json(request, generator)
+
+
+@routes.get("/api/artists/{item_id}")
+@login_required
+async def async_artist(request: web.Request):
+    """Get full artist details."""
+    item_id = request.match_info.get("item_id")
+    provider = request.rel_url.query.get("provider")
+    lazy = request.rel_url.query.get("lazy", "true") != "false"
+    if item_id is None or provider is None:
+        return web.Response(text="invalid item or provider", status=501)
+    result = await request.app["mass"].music.async_get_artist(
+        item_id, provider, lazy=lazy
+    )
+    return web.Response(body=json_serializer(result), content_type="application/json")
+
+
+@routes.get("/api/artists/{item_id}/toptracks")
+@login_required
+async def async_artist_toptracks(request: web.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 web.Response(text="invalid item_id or provider", status=501)
+    generator = request.app["mass"].music.async_get_artist_toptracks(item_id, provider)
+    return await async_stream_json(request, generator)
+
+
+@routes.get("/api/artists/{item_id}/albums")
+@login_required
+async def async_artist_albums(request: web.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 web.Response(text="invalid item_id or provider", status=501)
+    generator = request.app["mass"].music.async_get_artist_albums(item_id, provider)
+    return await async_stream_json(request, generator)
diff --git a/music_assistant/web/endpoints/config.py b/music_assistant/web/endpoints/config.py
new file mode 100644 (file)
index 0000000..9e01422
--- /dev/null
@@ -0,0 +1,72 @@
+"""Config API endpoints."""
+
+import orjson
+from aiohttp import web
+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.util import json_serializer
+
+routes = web.RouteTableDef()
+
+
+@routes.get("/api/config")
+@login_required
+async def async_get_config(request: web.Request):
+    """Get the full config."""
+    language = request.rel_url.query.get("lang", "en")
+    conf = {
+        CONF_KEY_BASE: request.app["mass"].config.base.to_dict(language),
+        CONF_KEY_MUSIC_PROVIDERS: request.app["mass"].config.music_providers.to_dict(
+            language
+        ),
+        CONF_KEY_PLAYER_PROVIDERS: request.app["mass"].config.player_providers.to_dict(
+            language
+        ),
+        CONF_KEY_METADATA_PROVIDERS: request.app[
+            "mass"
+        ].config.metadata_providers.to_dict(language),
+        CONF_KEY_PLUGINS: request.app["mass"].config.plugins.to_dict(language),
+        CONF_KEY_PLAYER_SETTINGS: request.app["mass"].config.player_settings.to_dict(
+            language
+        ),
+    }
+    return web.Response(body=json_serializer(conf), content_type="application/json")
+
+
+@routes.get("/api/config/{base}")
+@login_required
+async def async_get_config_item(request: web.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]
+    return web.Response(
+        body=json_serializer(conf.to_dict(language)), content_type="application/json"
+    )
+
+
+@routes.put("/api/config/{base}/{key}/{entry_key}")
+@login_required
+async def async_put_config(request: web.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(loads=orjson.loads)
+    except orjson.decoder.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 web.json_response(True)
diff --git a/music_assistant/web/endpoints/images.py b/music_assistant/web/endpoints/images.py
new file mode 100644 (file)
index 0000000..0b849f8
--- /dev/null
@@ -0,0 +1,40 @@
+"""Images API endpoints."""
+
+
+import os
+
+from aiohttp import web
+from music_assistant.models.media_types import MediaType
+
+routes = web.RouteTableDef()
+
+
+@routes.get("/api/providers/{provider_id}/icon")
+async def async_get_provider_icon(request: web.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 web.FileResponse(icon_path, headers=headers)
+    return web.Response(status=404)
+
+
+@routes.get("/api/{media_type}/{media_id}/thumb")
+async def async_get_image(request: web.Request):
+    """Get (resized) thumb image."""
+    media_type_str = request.match_info.get("media_type")
+    media_type = MediaType.from_string(media_type_str)
+    media_id = request.match_info.get("media_id")
+    provider = request.rel_url.query.get("provider")
+    if media_id is None or provider is None:
+        return web.Response(text="invalid media_id or provider", status=501)
+    size = int(request.rel_url.query.get("size", 0))
+    img_file = await request.app["mass"].music.async_get_image_thumb(
+        media_id, provider, media_type, size
+    )
+    if not img_file or not os.path.isfile(img_file):
+        return web.Response(status=404)
+    headers = {"Cache-Control": "max-age=86400, public", "Pragma": "public"}
+    return web.FileResponse(img_file, headers=headers)
diff --git a/music_assistant/web/endpoints/json_rpc.py b/music_assistant/web/endpoints/json_rpc.py
new file mode 100644 (file)
index 0000000..9dbb752
--- /dev/null
@@ -0,0 +1,67 @@
+"""JSON RPC API endpoint."""
+
+from aiohttp import web
+from music_assistant.helpers.web import require_local_subnet
+
+routes = web.RouteTableDef()
+
+
+@routes.route("get", "/jsonrpc.js")
+@routes.route("post", "/jsonrpc.js")
+@require_local_subnet
+async def async_json_rpc(request: web.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 web.Response(text="command not supported")
+    return web.Response(text="success")
diff --git a/music_assistant/web/endpoints/library.py b/music_assistant/web/endpoints/library.py
new file mode 100644 (file)
index 0000000..46c2503
--- /dev/null
@@ -0,0 +1,88 @@
+"""Library API endpoints."""
+
+from aiohttp import web
+from aiohttp_jwt import login_required
+from music_assistant.helpers.util import json_serializer
+from music_assistant.helpers.web import async_media_items_from_body, async_stream_json
+
+routes = web.RouteTableDef()
+
+
+@routes.get("/api/library/artists")
+@login_required
+async def async_library_artists(request: web.Request):
+    """Get all library artists."""
+    orderby = request.query.get("orderby", "name")
+    provider_filter = request.rel_url.query.get("provider")
+    generator = request.app["mass"].music.async_get_library_artists(
+        orderby=orderby, provider_filter=provider_filter
+    )
+    return await async_stream_json(request, generator)
+
+
+@routes.get("/api/library/albums")
+@login_required
+async def async_library_albums(request: web.Request):
+    """Get all library albums."""
+    orderby = request.query.get("orderby", "name")
+    provider_filter = request.rel_url.query.get("provider")
+    generator = request.app["mass"].music.async_get_library_albums(
+        orderby=orderby, provider_filter=provider_filter
+    )
+    return await async_stream_json(request, generator)
+
+
+@routes.get("/api/library/tracks")
+@login_required
+async def async_library_tracks(request: web.Request):
+    """Get all library tracks."""
+    orderby = request.query.get("orderby", "name")
+    provider_filter = request.rel_url.query.get("provider")
+    generator = request.app["mass"].music.async_get_library_tracks(
+        orderby=orderby, provider_filter=provider_filter
+    )
+    return await async_stream_json(request, generator)
+
+
+@routes.get("/api/library/radios")
+@login_required
+async def async_library_radios(request: web.Request):
+    """Get all library radios."""
+    orderby = request.query.get("orderby", "name")
+    provider_filter = request.rel_url.query.get("provider")
+    generator = request.app["mass"].music.async_get_library_radios(
+        orderby=orderby, provider_filter=provider_filter
+    )
+    return await async_stream_json(request, generator)
+
+
+@routes.get("/api/library/playlists")
+@login_required
+async def async_library_playlists(request: web.Request):
+    """Get all library playlists."""
+    orderby = request.query.get("orderby", "name")
+    provider_filter = request.rel_url.query.get("provider")
+    generator = request.app["mass"].music.async_get_library_playlists(
+        orderby=orderby, provider_filter=provider_filter
+    )
+    return await async_stream_json(request, generator)
+
+
+@routes.put("/api/library")
+@login_required
+async def async_library_add(request: web.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"].music.async_library_add(media_items)
+    return web.Response(body=json_serializer(result), content_type="application/json")
+
+
+@routes.delete("/api/library")
+@login_required
+async def async_library_remove(request: web.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"].music.async_library_remove(media_items)
+    return web.Response(body=json_serializer(result), content_type="application/json")
diff --git a/music_assistant/web/endpoints/login.py b/music_assistant/web/endpoints/login.py
new file mode 100644 (file)
index 0000000..07e6081
--- /dev/null
@@ -0,0 +1,46 @@
+"""Login API endpoints."""
+
+import datetime
+
+import jwt
+from aiohttp import web
+from music_assistant.helpers.typing import MusicAssistantType
+from music_assistant.helpers.util import json_serializer
+
+routes = web.RouteTableDef()
+
+
+@routes.post("/login")
+@routes.post("/api/login")
+async def async_login(request: web.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 web.Response(
+            body=json_serializer(token_info), content_type="application/json"
+        )
+    return web.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,
+            "scopes": scopes,
+        }
+    return None
diff --git a/music_assistant/web/endpoints/players.py b/music_assistant/web/endpoints/players.py
new file mode 100644 (file)
index 0000000..ee40ba6
--- /dev/null
@@ -0,0 +1,146 @@
+"""Players API endpoints."""
+
+import orjson
+from aiohttp import web
+from aiohttp_jwt import login_required
+from music_assistant.helpers.util import json_serializer
+from music_assistant.helpers.web import async_media_items_from_body, async_stream_json
+from music_assistant.models.player_queue import QueueOption
+
+routes = web.RouteTableDef()
+
+
+@routes.get("/api/players")
+@login_required
+async def async_players(request: web.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)
+    players = [player_state.to_dict() for player_state in player_states]
+    return web.Response(body=json_serializer(players), content_type="application/json")
+
+
+@routes.post("/api/players/{player_id}/cmd/{cmd}")
+@login_required
+async def async_player_command(request: web.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(loads=orjson.loads)
+    except orjson.decoder.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 web.Response(text="invalid command", status=501)
+    result = {"success": success in [True, None]}
+    return web.Response(body=json_serializer(result), content_type="application/json")
+
+
+@routes.post("/api/players/{player_id}/play_media/{queue_opt}")
+@login_required
+async def async_player_play_media(request: web.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 web.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 web.Response(body=json_serializer(result), content_type="application/json")
+
+
+@routes.get("/api/players/{player_id}/queue/items/{queue_item}")
+@login_required
+async def async_player_queue_item(request: web.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)
+    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 web.Response(
+        body=json_serializer(queue_item), content_type="application/json"
+    )
+
+
+@routes.get("/api/players/{player_id}/queue/items")
+@login_required
+async def async_player_queue_items(request: web.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)
+
+    async def async_queue_tracks_iter():
+        for item in player_queue.items:
+            yield item
+
+    return await async_stream_json(request, async_queue_tracks_iter())
+
+
+@routes.get("/api/players/{player_id}/queue")
+@login_required
+async def async_player_queue(request: web.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)
+    return web.Response(
+        body=json_serializer(player_queue.to_dict()), content_type="application/json"
+    )
+
+
+@routes.put("/api/players/{player_id}/queue/{cmd}")
+@login_required
+async def async_player_queue_cmd(request: web.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(loads=orjson.loads)
+    except orjson.decoder.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 web.Response(
+        body=json_serializer(player_queue.to_dict()), content_type="application/json"
+    )
+
+
+@routes.get("/api/players/{player_id}")
+@login_required
+async def async_player(request: web.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 web.Response(text="invalid player", status=404)
+    return web.Response(
+        body=json_serializer(player_state.to_dict()), content_type="application/json"
+    )
diff --git a/music_assistant/web/endpoints/playlists.py b/music_assistant/web/endpoints/playlists.py
new file mode 100644 (file)
index 0000000..8b86794
--- /dev/null
@@ -0,0 +1,56 @@
+"""Playlists API endpoints."""
+
+from aiohttp import web
+from aiohttp_jwt import login_required
+from music_assistant.helpers.util import json_serializer
+from music_assistant.helpers.web import async_media_items_from_body, async_stream_json
+
+routes = web.RouteTableDef()
+
+
+@routes.get("/api/playlists/{item_id}")
+@login_required
+async def async_playlist(request: web.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 web.Response(text="invalid item or provider", status=501)
+    result = await request.app["mass"].music.async_get_playlist(item_id, provider)
+    return web.Response(body=json_serializer(result), content_type="application/json")
+
+
+@routes.get("/api/playlists/{item_id}/tracks")
+@login_required
+async def async_playlist_tracks(request: web.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 web.Response(text="invalid item_id or provider", status=501)
+    generator = request.app["mass"].music.async_get_playlist_tracks(item_id, provider)
+    return await async_stream_json(request, generator)
+
+
+@routes.put("/api/playlists/{item_id}/tracks")
+@login_required
+async def async_add_playlist_tracks(request: web.Request):
+    """Add tracks to (editable) playlist."""
+    item_id = request.match_info.get("item_id")
+    body = await request.json()
+    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 web.Response(body=json_serializer(result), content_type="application/json")
+
+
+@routes.delete("/api/playlists/{item_id}/tracks")
+@login_required
+async def async_remove_playlist_tracks(request: web.Request):
+    """Remove tracks from (editable) playlist."""
+    item_id = request.match_info.get("item_id")
+    body = await request.json()
+    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 web.Response(body=json_serializer(result), content_type="application/json")
diff --git a/music_assistant/web/endpoints/radios.py b/music_assistant/web/endpoints/radios.py
new file mode 100644 (file)
index 0000000..3407df7
--- /dev/null
@@ -0,0 +1,28 @@
+"""Tracks API endpoints."""
+
+from aiohttp import web
+from aiohttp_jwt import login_required
+from music_assistant.helpers.util import json_serializer
+from music_assistant.helpers.web import async_stream_json
+
+routes = web.RouteTableDef()
+
+
+@routes.get("/api/radios")
+@login_required
+async def async_radios(request: web.Request):
+    """Get all radios known in the database."""
+    generator = request.app["mass"].database.async_get_radios()
+    return await async_stream_json(request, generator)
+
+
+@routes.get("/api/radios/{item_id}")
+@login_required
+async def async_radio(request: web.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 web.Response(text="invalid item_id or provider", status=501)
+    result = await request.app["mass"].music.async_get_radio(item_id, provider)
+    return web.Response(body=json_serializer(result), content_type="application/json")
diff --git a/music_assistant/web/endpoints/search.py b/music_assistant/web/endpoints/search.py
new file mode 100644 (file)
index 0000000..a4175e7
--- /dev/null
@@ -0,0 +1,33 @@
+"""Search API endpoints."""
+
+from aiohttp import web
+from aiohttp_jwt import login_required
+from music_assistant.helpers.util import json_serializer
+from music_assistant.models.media_types import MediaType
+
+routes = web.RouteTableDef()
+
+
+@routes.get("/api/search")
+@login_required
+async def async_search(request: web.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)
+
+    result = await request.app["mass"].music.async_global_search(
+        searchquery, media_types, limit=limit
+    )
+    return web.Response(body=json_serializer(result), content_type="application/json")
diff --git a/music_assistant/web/endpoints/streams.py b/music_assistant/web/endpoints/streams.py
new file mode 100644 (file)
index 0000000..c8932fe
--- /dev/null
@@ -0,0 +1,103 @@
+"""Players API endpoints."""
+
+from aiohttp import web
+from music_assistant.helpers.web import require_local_subnet
+from music_assistant.models.media_types import MediaType
+
+routes = web.RouteTableDef()
+
+
+@routes.get("/stream/media/{media_type}/{item_id}")
+async def stream_media(request: web.Request):
+    """Stream a single audio track."""
+    media_type = MediaType.from_string(request.match_info["media_type"])
+    if media_type not in [MediaType.Track, MediaType.Radio]:
+        return web.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 = web.StreamResponse(
+        status=200, reason="OK", headers={"Content-Type": f"audio/{content_type}"}
+    )
+    resp.enable_chunked_encoding()
+    await resp.prepare(request)
+
+    # stream track
+    async for audio_chunk in request.app["mass"].streams.async_get_stream(
+        streamdetails
+    ):
+        await resp.write(audio_chunk)
+    return resp
+
+
+@routes.get("/stream/queue/{player_id}")
+@require_local_subnet
+async def stream_queue(request: web.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 web.Response(text="invalid queue", status=404)
+
+    # prepare request
+    resp = web.StreamResponse(
+        status=200, reason="OK", headers={"Content-Type": "audio/flac"}
+    )
+    resp.enable_chunked_encoding()
+    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: web.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 = web.StreamResponse(
+        status=200, reason="OK", headers={"Content-Type": "audio/flac"}
+    )
+    resp.enable_chunked_encoding()
+    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: web.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 web.Response(text="invalid player id", status=404)
+    child_player_id = request.rel_url.query.get("player_id", request.remote)
+
+    # prepare request
+    resp = web.StreamResponse(
+        status=200, reason="OK", headers={"Content-Type": "audio/flac"}
+    )
+    resp.enable_chunked_encoding()
+    await resp.prepare(request)
+
+    # stream queue
+    player_state = request.app["mass"].players.get_player(group_player_id)
+    async for audio_chunk in player_state.subscribe_stream_client(child_player_id):
+        await resp.write(audio_chunk)
+    return resp
diff --git a/music_assistant/web/endpoints/tracks.py b/music_assistant/web/endpoints/tracks.py
new file mode 100644 (file)
index 0000000..fd4f3d3
--- /dev/null
@@ -0,0 +1,43 @@
+"""Radio's API endpoints."""
+
+from aiohttp import web
+from aiohttp_jwt import login_required
+from music_assistant.helpers.util import json_serializer
+from music_assistant.helpers.web import async_stream_json
+
+routes = web.RouteTableDef()
+
+
+@routes.get("/api/tracks")
+@login_required
+async def async_tracks(request: web.Request):
+    """Get all tracks known in the database."""
+    generator = request.app["mass"].database.async_get_tracks()
+    return await async_stream_json(request, generator)
+
+
+@routes.get("/api/tracks/{item_id}/versions")
+@login_required
+async def async_track_versions(request: web.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 web.Response(text="invalid item_id or provider", status=501)
+    generator = request.app["mass"].music.async_get_track_versions(item_id, provider)
+    return await async_stream_json(request, generator)
+
+
+@routes.get("/api/tracks/{item_id}")
+@login_required
+async def async_track(request: web.Request):
+    """Get full track details."""
+    item_id = request.match_info.get("item_id")
+    provider = request.rel_url.query.get("provider")
+    lazy = request.rel_url.query.get("lazy", "true") != "false"
+    if item_id is None or provider is None:
+        return web.Response(text="invalid item or provider", status=501)
+    result = await request.app["mass"].music.async_get_track(
+        item_id, provider, lazy=lazy
+    )
+    return web.Response(body=json_serializer(result), content_type="application/json")
diff --git a/music_assistant/web/endpoints/websocket.py b/music_assistant/web/endpoints/websocket.py
new file mode 100644 (file)
index 0000000..43b110d
--- /dev/null
@@ -0,0 +1,97 @@
+"""Websocket API endpoint."""
+
+import logging
+from asyncio import CancelledError
+
+import aiohttp
+import jwt
+import orjson
+from music_assistant.helpers.util import json_serializer
+
+routes = aiohttp.web.RouteTableDef()
+
+LOGGER = logging.getLogger("websocket")
+
+
+@routes.get("/ws")
+async def async_websocket_handler(request: aiohttp.web.Request):
+    """Handle websockets connection."""
+    ws_response = None
+    authenticated = False
+    remove_callbacks = []
+    try:
+        ws_response = aiohttp.web.WebSocketResponse()
+        await ws_response.prepare(request)
+
+        # callback for internal events
+        async def async_send_message(msg, msg_details=None):
+            if hasattr(msg_details, "to_dict"):
+                msg_details = msg_details.to_dict()
+            ws_msg = {"message": msg, "message_details": msg_details}
+            try:
+                await ws_response.send_str(json_serializer(ws_msg).decode())
+            except AssertionError:
+                LOGGER.debug("trying to send message to ws while disconnected")
+
+        # process incoming messages
+        async for msg in ws_response:
+            if msg.type != aiohttp.WSMsgType.TEXT:
+                # not sure when/if this happens but log it anyway
+                LOGGER.warning(msg.data)
+                continue
+            try:
+                data = msg.json(loads=orjson.loads)
+            except orjson.decoder.JSONDecodeError:
+                await async_send_message(
+                    "error",
+                    'commands must be issued in json format \
+                        {"message": "command", "message_details":" optional details"}',
+                )
+                continue
+            msg = data.get("message")
+            msg_details = data.get("message_details")
+            if not authenticated and not msg == "login":
+                # make sure client is authenticated
+                await async_send_message("error", "authentication required")
+            elif msg == "login" and msg_details:
+                # authenticate with token
+                try:
+                    token_info = jwt.decode(
+                        msg_details, request.app["mass"].web.device_id
+                    )
+                except jwt.InvalidTokenError as exc:
+                    LOGGER.exception(exc, exc_info=exc)
+                    error_msg = "Invalid authorization token, " + str(exc)
+                    await async_send_message("error", error_msg)
+                else:
+                    authenticated = True
+                    await async_send_message("login", token_info)
+            elif msg == "add_event_listener":
+                remove_callbacks.append(
+                    request.app["mass"].add_event_listener(
+                        async_send_message, msg_details
+                    )
+                )
+                await async_send_message("event listener subscribed", msg_details)
+            elif msg == "player_command":
+                player_id = msg_details.get("player_id")
+                cmd = msg_details.get("cmd")
+                cmd_args = msg_details.get("cmd_args")
+                player_cmd = getattr(
+                    request.app["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}
+                await async_send_message("player_command_result", msg_details)
+            else:
+                # simply echo the message on the eventbus
+                request.app["mass"].signal_event(msg, msg_details)
+    except (AssertionError, CancelledError):
+        LOGGER.debug("Websocket disconnected")
+    finally:
+        for remove_callback in remove_callbacks:
+            remove_callback()
+    return ws_response
diff --git a/pylintrc b/pylintrc
deleted file mode 100644 (file)
index 0b60c45..0000000
--- a/pylintrc
+++ /dev/null
@@ -1,69 +0,0 @@
-[MASTER]
-ignore=tests
-ignore-patterns=app_vars
-# Use a conservative default here; 2 should speed up most setups and not hurt
-# any too bad. Override on command line as appropriate.
-jobs=2
-persistent=no
-suggestion-mode=yes
-extension-pkg-whitelist=taglib
-
-[BASIC]
-good-names=id,i,j,k,ex,Run,_,fp,T,ev
-
-[MESSAGES CONTROL]
-# Reasons disabled:
-# format - handled by black
-# locally-disabled - it spams too much
-# duplicate-code - unavoidable
-# cyclic-import - doesn't test if both import on load
-# abstract-class-little-used - prevents from setting right foundation
-# unused-argument - generic callbacks and setup methods create a lot of warnings
-# too-many-* - are not enforced for the sake of readability
-# too-few-* - same as too-many-*
-# abstract-method - with intro of async there are always methods missing
-# inconsistent-return-statements - doesn't handle raise
-# too-many-ancestors - it's too strict.
-# wrong-import-order - isort guards this
-# fixme - project is in development phase
-disable=
-  format,
-  abstract-class-little-used,
-  abstract-method,
-  cyclic-import,
-  duplicate-code,
-  inconsistent-return-statements,
-  locally-disabled,
-  not-context-manager,
-  too-few-public-methods,
-  too-many-ancestors,
-  too-many-arguments,
-  too-many-branches,
-  too-many-instance-attributes,
-  too-many-lines,
-  too-many-locals,
-  too-many-public-methods,
-  too-many-return-statements,
-  too-many-statements,
-  too-many-boolean-expressions,
-  unused-argument,
-  wrong-import-order,
-  fixme
-# enable useless-suppression temporarily every now and then to clean them up
-enable=
-  use-symbolic-message-instead
-
-[REPORTS]
-score=no
-
-[REFACTORING]
-
-# Maximum number of nested blocks for function / method body
-max-nested-blocks=15
-
-[TYPECHECK]
-# For attrs
-ignored-classes=_CountingAttr
-
-[FORMAT]
-expected-line-ending-format=LF
\ No newline at end of file
index a64260777788b18ca972b8c7feae5fb5887711df..d39b3418fd68cfd18aee7cccade31608b9a6423c 100644 (file)
@@ -22,4 +22,4 @@ aiohttp_jwt==0.6.1
 zeroconf==0.28.5
 passlib==1.7.2
 cryptography==3.1
-mashumaro==1.12
+orjson==3.4.0
index c2987645e45b1435995bce8f858e495105f87f30..81fb9d19c7a38552c850f119db20d49354080dd0 100644 (file)
--- a/setup.cfg
+++ b/setup.cfg
@@ -33,4 +33,76 @@ warn_redundant_casts = true
 warn_unused_configs = true
 
 [pydocstyle]
-add-ignore = D202
\ No newline at end of file
+add-ignore = D202
+
+[pylint.master]
+ignore=tests
+ignore-patterns=app_vars
+# Use a conservative default here; 2 should speed up most setups and not hurt
+# any too bad. Override on command line as appropriate.
+jobs=2
+persistent=no
+suggestion-mode=yes
+extension-pkg-whitelist=taglib,orjson
+
+[pylint.basic]
+good-names=id,i,j,k,ex,Run,_,fp,T,ev
+
+[pylint.messages_control]
+# Reasons disabled:
+# format - handled by black
+# locally-disabled - it spams too much
+# duplicate-code - unavoidable
+# cyclic-import - doesn't test if both import on load
+# abstract-class-little-used - prevents from setting right foundation
+# unused-argument - generic callbacks and setup methods create a lot of warnings
+# too-many-* - are not enforced for the sake of readability
+# too-few-* - same as too-many-*
+# abstract-method - with intro of async there are always methods missing
+# inconsistent-return-statements - doesn't handle raise
+# too-many-ancestors - it's too strict.
+# wrong-import-order - isort guards this
+# fixme - project is in development phase
+# c-extension-no-member - it was giving me headaches
+disable=
+  format,
+  abstract-class-little-used,
+  abstract-method,
+  cyclic-import,
+  duplicate-code,
+  inconsistent-return-statements,
+  locally-disabled,
+  not-context-manager,
+  too-few-public-methods,
+  too-many-ancestors,
+  too-many-arguments,
+  too-many-branches,
+  too-many-instance-attributes,
+  too-many-lines,
+  too-many-locals,
+  too-many-public-methods,
+  too-many-return-statements,
+  too-many-statements,
+  too-many-boolean-expressions,
+  unused-argument,
+  wrong-import-order,
+  fixme,
+  c-extension-no-member
+
+# enable useless-suppression temporarily every now and then to clean them up
+enable=
+  use-symbolic-message-instead
+
+[pylint.reports]
+score=no
+
+[pylint.refactoring]
+# Maximum number of nested blocks for function / method body
+max-nested-blocks=15
+
+[pylint.typecheck]
+# For attrs
+ignored-classes=_CountingAttr
+
+[pylint.format]
+expected-line-ending-format=LF
\ No newline at end of file