From: Marcel van der Veldt Date: Thu, 1 Oct 2020 23:26:30 +0000 (+0200) Subject: refactor part 2 finished (#24) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=79b30546484380146c1ceb7922ed212878d1b2eb;p=music-assistant-server.git refactor part 2 finished (#24) --- diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 5b4119ad..9a6e48d6 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -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: >- diff --git a/.vscode/settings.json b/.vscode/settings.json index 9cbb6974..3b0a5ec1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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 index b0103969..00000000 --- a/music_assistant/app_vars.py +++ /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 index 7c36d047..00000000 --- a/music_assistant/cache.py +++ /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 index f711b823..00000000 --- a/music_assistant/config.py +++ /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 diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 47f6aca2..f5fcb890 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -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 index 0ed0bd8d..00000000 --- a/music_assistant/database.py +++ /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 index 00000000..b0103969 --- /dev/null +++ b/music_assistant/helpers/app_vars.py @@ -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 index 00000000..f5c6366e --- /dev/null +++ b/music_assistant/helpers/cache.py @@ -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 index 00000000..b3fcc038 --- /dev/null +++ b/music_assistant/helpers/musicbrainz.py @@ -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 index 00000000..4c6c92d4 --- /dev/null +++ b/music_assistant/helpers/util.py @@ -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 index 00000000..207d9301 --- /dev/null +++ b/music_assistant/helpers/web.py @@ -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 index 00000000..bc6f8f94 --- /dev/null +++ b/music_assistant/managers/__init__.py @@ -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 index 00000000..a1487cd3 --- /dev/null +++ b/music_assistant/managers/config.py @@ -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 index 00000000..b2ffbe29 --- /dev/null +++ b/music_assistant/managers/database.py @@ -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 index 00000000..7df6364d --- /dev/null +++ b/music_assistant/managers/metadata.py @@ -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 index 00000000..1621e738 --- /dev/null +++ b/music_assistant/managers/music.py @@ -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 index 00000000..5d2381a4 --- /dev/null +++ b/music_assistant/managers/players.py @@ -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 index 00000000..50f83987 --- /dev/null +++ b/music_assistant/managers/streams.py @@ -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 diff --git a/music_assistant/mass.py b/music_assistant/mass.py index 8824502b..da1d4118 100644 --- a/music_assistant/mass.py +++ b/music_assistant/mass.py @@ -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 index 04d0bae3..00000000 --- a/music_assistant/metadata.py +++ /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 diff --git a/music_assistant/models/config_entry.py b/music_assistant/models/config_entry.py index d4dd4442..a094c99e 100644 --- a/music_assistant/models/config_entry.py +++ b/music_assistant/models/config_entry.py @@ -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 diff --git a/music_assistant/models/media_types.py b/music_assistant/models/media_types.py index 89754db9..ca557bbf 100755 --- a/music_assistant/models/media_types.py +++ b/music_assistant/models/media_types.py @@ -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 index fd3f059a..00000000 --- a/music_assistant/models/musicprovider.py +++ /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 diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index 607ac65a..8de28b83 100755 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -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): diff --git a/music_assistant/models/player_queue.py b/music_assistant/models/player_queue.py index 0372da58..4c277258 100755 --- a/music_assistant/models/player_queue.py +++ b/music_assistant/models/player_queue.py @@ -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 diff --git a/music_assistant/models/player_state.py b/music_assistant/models/player_state.py index 46517d0a..3b0fe8d0 100755 --- a/music_assistant/models/player_state.py +++ b/music_assistant/models/player_state.py @@ -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 index 7ed6c1f9..00000000 --- a/music_assistant/models/playerprovider.py +++ /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 - ] diff --git a/music_assistant/models/provider.py b/music_assistant/models/provider.py index 47510742..95e29e3b 100644 --- a/music_assistant/models/provider.py +++ b/music_assistant/models/provider.py @@ -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 diff --git a/music_assistant/models/streamdetails.py b/music_assistant/models/streamdetails.py index 5a5e3cbf..e3313f9f 100644 --- a/music_assistant/models/streamdetails.py +++ b/music_assistant/models/streamdetails.py @@ -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 index 6f773db5..00000000 --- a/music_assistant/music_manager.py +++ /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 index 3fdea445..00000000 --- a/music_assistant/player_manager.py +++ /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 index 00000000..98f2fd99 --- /dev/null +++ b/music_assistant/providers/builtin/__init__.py @@ -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 index 00000000..092121e1 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 index 00000000..12c5f9c5 --- /dev/null +++ b/music_assistant/providers/builtin/translations.json @@ -0,0 +1,5 @@ +{ + "nl": { + "Built-in (local) player": "Ingebouwde speler van de server" + } +} \ No newline at end of file diff --git a/music_assistant/providers/chromecast/__init__.py b/music_assistant/providers/chromecast/__init__.py index ffc33dcd..d406e034 100644 --- a/music_assistant/providers/chromecast/__init__.py +++ b/music_assistant/providers/chromecast/__init__.py @@ -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 index 00000000..e7372ee1 Binary files /dev/null and b/music_assistant/providers/chromecast/icon.png differ diff --git a/music_assistant/providers/chromecast/player.py b/music_assistant/providers/chromecast/player.py index 0b2a3795..a0854d76 100644 --- a/music_assistant/providers/chromecast/player.py +++ b/music_assistant/providers/chromecast/player.py @@ -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 index 09c851c7..00000000 --- a/music_assistant/providers/demo/__init__.py +++ /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 index dfee4949..00000000 --- a/music_assistant/providers/demo/demo_musicprovider.py +++ /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 index 6ed6b728..00000000 --- a/music_assistant/providers/demo/demo_playerprovider.py +++ /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 index 00000000..fb43e7aa --- /dev/null +++ b/music_assistant/providers/fanarttv/__init__.py @@ -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 index 00000000..17b39a4c Binary files /dev/null and b/music_assistant/providers/fanarttv/icon.png differ diff --git a/music_assistant/providers/file/__init__.py b/music_assistant/providers/file/__init__.py index fbbc47e7..5e4c1798 100644 --- a/music_assistant/providers/file/__init__.py +++ b/music_assistant/providers/file/__init__.py @@ -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 index 00000000..bd2df042 Binary files /dev/null and b/music_assistant/providers/file/icon.png differ diff --git a/music_assistant/providers/group_player/__init__.py b/music_assistant/providers/group_player/__init__.py index 78848994..f9527837 100644 --- a/music_assistant/providers/group_player/__init__.py +++ b/music_assistant/providers/group_player/__init__.py @@ -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 index 00000000..092121e1 Binary files /dev/null and b/music_assistant/providers/group_player/icon.png differ diff --git a/music_assistant/providers/qobuz/__init__.py b/music_assistant/providers/qobuz/__init__.py index 9e803225..d49fb5cb 100644 --- a/music_assistant/providers/qobuz/__init__.py +++ b/music_assistant/providers/qobuz/__init__.py @@ -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 index 00000000..9d7b726c 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 index 00000000..d00f12ac Binary files /dev/null and b/music_assistant/providers/sonos/icon.png differ diff --git a/music_assistant/providers/sonos/sonos.py b/music_assistant/providers/sonos/sonos.py index f48b7aba..e2b5bdd9 100644 --- a/music_assistant/providers/sonos/sonos.py +++ b/music_assistant/providers/sonos/sonos.py @@ -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.""" diff --git a/music_assistant/providers/spotify/__init__.py b/music_assistant/providers/spotify/__init__.py index f67fc8e3..ad5a4b90 100644 --- a/music_assistant/providers/spotify/__init__.py +++ b/music_assistant/providers/spotify/__init__.py @@ -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 index 00000000..1ed40491 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 index 00000000..2b40b740 --- /dev/null +++ b/music_assistant/providers/spotify/translations.json @@ -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 diff --git a/music_assistant/providers/squeezebox/__init__.py b/music_assistant/providers/squeezebox/__init__.py index a4b29b30..3bbb8888 100644 --- a/music_assistant/providers/squeezebox/__init__.py +++ b/music_assistant/providers/squeezebox/__init__.py @@ -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: diff --git a/music_assistant/providers/squeezebox/discovery.py b/music_assistant/providers/squeezebox/discovery.py index 39bcc44d..10c840a4 100644 --- a/music_assistant/providers/squeezebox/discovery.py +++ b/music_assistant/providers/squeezebox/discovery.py @@ -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 index 00000000..18531d79 Binary files /dev/null and b/music_assistant/providers/squeezebox/icon.png differ diff --git a/music_assistant/providers/squeezebox/socket_client.py b/music_assistant/providers/squeezebox/socket_client.py index db07991e..4c69efa3 100644 --- a/music_assistant/providers/squeezebox/socket_client.py +++ b/music_assistant/providers/squeezebox/socket_client.py @@ -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 diff --git a/music_assistant/providers/tunein/__init__.py b/music_assistant/providers/tunein/__init__.py index 4bad3115..3d16f2e3 100644 --- a/music_assistant/providers/tunein/__init__.py +++ b/music_assistant/providers/tunein/__init__.py @@ -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 index 00000000..18c537c3 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 index 00000000..ffcf4fa0 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 index a10fb739..00000000 --- a/music_assistant/stream_manager.py +++ /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 index 00000000..576778fe --- /dev/null +++ b/music_assistant/translations.json @@ -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 index ab041bf7..00000000 --- a/music_assistant/utils.py +++ /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 index f3c97e9e..00000000 --- a/music_assistant/web.py +++ /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 "".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 index 00000000..be1d43f4 --- /dev/null +++ b/music_assistant/web/__init__.py @@ -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 index 00000000..fc1c4cc7 --- /dev/null +++ b/music_assistant/web/endpoints/__init__.py @@ -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 index 00000000..f4d151e3 --- /dev/null +++ b/music_assistant/web/endpoints/albums.py @@ -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 index 00000000..b1712483 --- /dev/null +++ b/music_assistant/web/endpoints/artists.py @@ -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 index 00000000..9e014225 --- /dev/null +++ b/music_assistant/web/endpoints/config.py @@ -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 index 00000000..0b849f86 --- /dev/null +++ b/music_assistant/web/endpoints/images.py @@ -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 index 00000000..9dbb7523 --- /dev/null +++ b/music_assistant/web/endpoints/json_rpc.py @@ -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 index 00000000..46c25039 --- /dev/null +++ b/music_assistant/web/endpoints/library.py @@ -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 index 00000000..07e6081c --- /dev/null +++ b/music_assistant/web/endpoints/login.py @@ -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 index 00000000..ee40ba66 --- /dev/null +++ b/music_assistant/web/endpoints/players.py @@ -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 index 00000000..8b867949 --- /dev/null +++ b/music_assistant/web/endpoints/playlists.py @@ -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 index 00000000..3407df74 --- /dev/null +++ b/music_assistant/web/endpoints/radios.py @@ -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 index 00000000..a4175e76 --- /dev/null +++ b/music_assistant/web/endpoints/search.py @@ -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 index 00000000..c8932fe4 --- /dev/null +++ b/music_assistant/web/endpoints/streams.py @@ -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 index 00000000..fd4f3d30 --- /dev/null +++ b/music_assistant/web/endpoints/tracks.py @@ -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 index 00000000..43b110d7 --- /dev/null +++ b/music_assistant/web/endpoints/websocket.py @@ -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 index 0b60c452..00000000 --- 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 diff --git a/requirements.txt b/requirements.txt index a6426077..d39b3418 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/setup.cfg b/setup.cfg index c2987645..81fb9d19 100644 --- 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