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: >-
{
"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
+++ /dev/null
-"""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())
+++ /dev/null
-"""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
+++ /dev/null
-"""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
"""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"
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"
EVENT_QUEUE_TIME_UPDATED = "queue time updated"
EVENT_SHUTDOWN = "application shutdown"
EVENT_PROVIDER_REGISTERED = "provider registered"
+EVENT_PROVIDER_UNREGISTERED = "provider unregistered"
EVENT_PLAYER_CONTROL_REGISTERED = "player control registered"
EVENT_PLAYER_CONTROL_UNREGISTERED = "player control unregistered"
EVENT_PLAYER_CONTROL_UPDATED = "player control updated"
EVENT_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"
+++ /dev/null
-"""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
--- /dev/null
+"""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())
--- /dev/null
+"""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
--- /dev/null
+"""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
--- /dev/null
+"""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()
--- /dev/null
+"""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
--- /dev/null
+"""Controllers/managers for Music Assistant entities."""
--- /dev/null
+"""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
--- /dev/null
+"""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
--- /dev/null
+"""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
--- /dev/null
+"""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
+ )
--- /dev/null
+"""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)
--- /dev/null
+"""
+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
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."""
: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
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."""
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:
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)
@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.
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
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
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)
+++ /dev/null
-"""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
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."""
INT = "integer"
FLOAT = "float"
LABEL = "label"
- HEADER = "header"
@dataclass
-class ConfigEntry(DataClassDictMixin):
+class ConfigEntry:
"""Model for a Config Entry."""
entry_key: str
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
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):
@dataclass
-class MediaItemProviderId(DataClassDictMixin):
+class MediaItemProviderId:
"""Model for a MediaItem's provider id."""
provider: str
@dataclass
-class MediaItem(DataClassDictMixin):
+class MediaItem:
"""Representation of a media item."""
item_id: str = ""
@dataclass
-class SearchResult(DataClassDictMixin):
+class SearchResult:
"""Model for Media Item Search result."""
artists: List[Artist] = field(default_factory=list)
+++ /dev/null
-"""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
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):
@dataclass
-class DeviceInfo(DataClassDictMixin):
+class DeviceInfo:
"""Model for a player's deviceinfo."""
model: str = ""
@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):
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
@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:
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."""
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
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,
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,
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 = [
ATTR_GROUP_CHILDS,
ATTR_DEVICE_INFO,
ATTR_FEATURES,
+ ATTR_SHOULD_POLL,
]
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())
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:
"""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
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:
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
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:
# 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
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}
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:
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(
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 {
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()
+++ /dev/null
-"""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
- ]
-"""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):
MUSIC_PROVIDER = "music_provider"
PLAYER_PROVIDER = "player_provider"
- GENERIC = "generic"
+ METADATA_PROVIDER = "metadata_provider"
+ PLUGIN = "plugin"
class Provider:
@abstractmethod
def type(self) -> ProviderType:
"""Return ProviderType."""
- return ProviderType.GENERIC
@property
@abstractmethod
@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
from enum import Enum
from typing import Any
-from mashumaro import DataClassDictMixin
-
class StreamType(Enum):
"""Enum with stream types."""
@dataclass
-class StreamDetails(DataClassDictMixin):
+class StreamDetails:
"""Model for streamdetails."""
type: StreamType
+++ /dev/null
-"""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
- )
+++ /dev/null
-"""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)
--- /dev/null
+"""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()
--- /dev/null
+{
+ "nl": {
+ "Built-in (local) player": "Ingebouwde speler van de server"
+ }
+}
\ No newline at end of file
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
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."""
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))
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,
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,
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)
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()
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 = {
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,
+++ /dev/null
-"""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)
+++ /dev/null
-"""Demo music provider."""
+++ /dev/null
-"""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()
--- /dev/null
+"""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
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,
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"
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",
),
]
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:
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"
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),
)
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):
"""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
"""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:
entry_type=ConfigEntryType.STRING,
default_value=[],
values=all_players,
- description_key=CONF_PLAYERS,
+ description=CONF_PLAYERS,
multi_value=True,
),
ConfigEntry(
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,
),
# 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)
# 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()
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
"""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
: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):
"""
: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):
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
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:
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
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,
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"
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,
),
]
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,
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"
: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)
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()
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):
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."""
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."""
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,
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"
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",
),
]
)
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
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
--- /dev/null
+{
+ "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
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,
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
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
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:
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:
: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:
"""
# 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)
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:
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")
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
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"
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,
),
]
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
+++ /dev/null
-"""
-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
--- /dev/null
+{
+ "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.",
+ }
+}
+++ /dev/null
-"""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()
+++ /dev/null
-"""The web module handles serving the frontend and the rest/websocket api's."""
-import asyncio
-import datetime
-import functools
-import inspect
-import ipaddress
-import json
-import logging
-import os
-import ssl
-
-import aiohttp
-import aiohttp_cors
-import jwt
-from aiohttp import web
-from aiohttp_jwt import JWTMiddleware, login_required
-from music_assistant.constants import (
- CONF_KEY_BASE,
- CONF_KEY_PLAYERSETTINGS,
- CONF_KEY_PROVIDERS,
-)
-from music_assistant.constants import __version__ as MASS_VERSION
-from music_assistant.models.media_types import MediaType
-from music_assistant.models.player_queue import QueueOption
-from music_assistant.utils import get_external_ip, get_hostname, get_ip, json_serializer
-
-LOGGER = logging.getLogger("mass")
-
-
-class ClassRouteTableDef(web.RouteTableDef):
- """Helper class to add class based routing tables."""
-
- def __repr__(self) -> str:
- """Print the class contents."""
- return "<ClassRouteTableDef count={}>".format(len(self._items))
-
- def route(self, method: str, path: str, **kwargs):
- """Return the route."""
- # pylint: disable=missing-function-docstring
- def inner(handler):
- handler.route_info = (method, path, kwargs)
- return handler
-
- return inner
-
- def add_class_routes(self, instance) -> None:
- """Add class routes."""
- # pylint: disable=missing-function-docstring
- def predicate(member) -> bool:
- return all(
- (inspect.iscoroutinefunction(member), hasattr(member, "route_info"))
- )
-
- for _, handler in inspect.getmembers(instance, predicate):
- method, path, kwargs = handler.route_info
- super().route(method, path, **kwargs)(handler)
-
-
-# pylint: disable=invalid-name
-routes = ClassRouteTableDef()
-# pylint: enable=invalid-name
-
-
-def require_local_subnet(func):
- """Return decorator to specify web method as available locally only."""
-
- @functools.wraps(func)
- async def wrapped(*args, **kwargs):
- request = args[-1]
-
- if isinstance(request, web.View):
- request = request.request
-
- if not isinstance(request, web.BaseRequest): # pragma: no cover
- raise RuntimeError(
- "Incorrect usage of decorator." "Expect web.BaseRequest as an argument"
- )
-
- if not ipaddress.ip_address(request.remote).is_private:
- raise web.HTTPUnauthorized(reason="Not remote available")
-
- return await func(*args, **kwargs)
-
- return wrapped
-
-
-class Web:
- """Webserver and json/websocket api."""
-
- def __init__(self, mass):
- """Initialize class."""
- self.mass = mass
- # load/create/update config
- self._local_ip = get_ip()
- self.config = mass.config.base["web"]
- self.runner = None
-
- enable_ssl = self.config["ssl_certificate"] and self.config["ssl_key"]
- if self.config["ssl_certificate"] and not os.path.isfile(
- self.config["ssl_certificate"]
- ):
- enable_ssl = False
- LOGGER.warning(
- "SSL certificate file not found: %s", self.config["ssl_certificate"]
- )
- if self.config["ssl_key"] and not os.path.isfile(self.config["ssl_key"]):
- enable_ssl = False
- LOGGER.warning(
- "SSL certificate key file not found: %s", self.config["ssl_key"]
- )
- if not self.config.get("external_url"):
- enable_ssl = False
- self._enable_ssl = enable_ssl
- self._jwt_shared_secret = f"mass_{self._local_ip}_{self.http_port}"
-
- async def async_setup(self):
- """Perform async setup."""
- routes.add_class_routes(self)
- jwt_middleware = JWTMiddleware(
- self._jwt_shared_secret, request_property="user", credentials_required=False
- )
- app = web.Application(middlewares=[jwt_middleware])
- # add routes
- app.add_routes(routes)
- app.add_routes(
- [
- web.get("/", self.async_index),
- web.post("/login", self.async_login),
- web.get("/jsonrpc.js", self.async_json_rpc),
- web.post("/jsonrpc.js", self.async_json_rpc),
- web.get("/ws", self.async_websocket_handler),
- web.get("/info", self.async_info),
- ]
- )
- webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web/")
- if os.path.isdir(webdir):
- app.router.add_static("/", webdir, append_version=True)
- else:
- # The (minified) build of the frontend(app) is included in the pypi releases
- LOGGER.warning("Loaded without frontend support.")
-
- # Add CORS support to all routes
- cors = aiohttp_cors.setup(
- app,
- defaults={
- "*": aiohttp_cors.ResourceOptions(
- allow_credentials=True,
- expose_headers="*",
- allow_headers="*",
- allow_methods=["POST", "PUT", "DELETE", "GET"],
- )
- },
- )
- for route in list(app.router.routes()):
- cors.add(route)
- self.runner = web.AppRunner(app, access_log=None)
- await self.runner.setup()
- http_site = web.TCPSite(self.runner, "0.0.0.0", self.http_port)
- await http_site.start()
- LOGGER.info("Started HTTP webserver on port %s", self.http_port)
- if self._enable_ssl:
- ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
- ssl_context.load_cert_chain(
- self.config["ssl_certificate"], self.config["ssl_key"]
- )
- https_site = web.TCPSite(
- self.runner, "0.0.0.0", self.https_port, ssl_context=ssl_context
- )
- await https_site.start()
- LOGGER.info(
- "Started HTTPS webserver on port %s - serving at FQDN %s",
- self.https_port,
- self.external_url,
- )
-
- @property
- def internal_ip(self):
- """Return the local IP address for this Music Assistant instance."""
- return self._local_ip
-
- @property
- def http_port(self):
- """Return the HTTP port for this Music Assistant instance."""
- return self.config.get("http_port", 8095)
-
- @property
- def https_port(self):
- """Return the HTTPS port for this Music Assistant instance."""
- return self.config.get("https_port", 8096)
-
- @property
- def internal_url(self):
- """Return the internal URL for this Music Assistant instance."""
- return f"http://{self._local_ip}:{self.http_port}"
-
- @property
- def external_url(self):
- """Return the internal URL for this Music Assistant instance."""
- if self._enable_ssl and self.config.get("external_url"):
- return self.config["external_url"]
- return f"http://{get_external_ip()}:{self.http_port}"
-
- @property
- def discovery_info(self):
- """Return (discovery) info about this instance."""
- return {
- "id": f"{get_hostname()}",
- "external_url": self.external_url,
- "internal_url": self.internal_url,
- "host": self.internal_ip,
- "http_port": self.http_port,
- "https_port": self.https_port,
- "ssl_enabled": self._enable_ssl,
- "version": MASS_VERSION,
- }
-
- @routes.post("/api/login")
- async def async_login(self, request):
- """Handle the retrieval of a JWT token."""
- form = await request.json()
- username = form.get("username")
- password = form.get("password")
- token_info = await self.__async_get_token(username, password)
- if token_info:
- return web.json_response(token_info, dumps=json_serializer)
- return web.HTTPUnauthorized(body="Invalid username and/or password provided!")
-
- @routes.get("/api/info")
- async def async_info(self, request):
- # pylint: disable=unused-argument
- """Return (discovery) info about this instance."""
- return web.json_response(self.discovery_info, dumps=json_serializer)
-
- async def async_index(self, request):
- """Get the index page, redirect if we do not have a web directory."""
- # pylint: disable=unused-argument
- webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web/")
- if not os.path.isdir(webdir):
- raise web.HTTPFound("https://music-assistant.github.io/app")
- return web.FileResponse(os.path.join(webdir, "index.html"))
-
- @routes.get("/stream/media/{media_type}/{item_id}")
- async def stream_media(self, request):
- """Stream a single audio track."""
- media_type = MediaType.from_string(request.match_info["media_type"])
- if media_type not in [MediaType.Track, MediaType.Radio]:
- return web.Response(status=404, reason="Media item is not playable!")
- item_id = request.match_info["item_id"]
- provider = request.rel_url.query.get("provider", "database")
- media_item = await self.mass.music_manager.async_get_item(
- item_id, provider, media_type
- )
- streamdetails = await self.mass.music_manager.async_get_stream_details(
- media_item
- )
-
- # prepare request
- content_type = streamdetails.content_type.value
- resp = web.StreamResponse(
- status=200, reason="OK", headers={"Content-Type": f"audio/{content_type}"}
- )
- resp.enable_chunked_encoding()
- await resp.prepare(request)
-
- # stream track
- async for audio_chunk in self.mass.stream_manager.async_get_stream(
- streamdetails
- ):
- await resp.write(audio_chunk)
- return resp
-
- @routes.get("/stream/queue/{player_id}")
- async def stream_queue(self, request):
- """Stream a player's queue."""
- player_id = request.match_info["player_id"]
- if not self.mass.player_manager.get_player_queue(player_id):
- return web.Response(text="invalid queue", status=404)
-
- # prepare request
- resp = web.StreamResponse(
- status=200, reason="OK", headers={"Content-Type": "audio/flac"}
- )
- resp.enable_chunked_encoding()
- await resp.prepare(request)
-
- # stream queue
- async for audio_chunk in self.mass.stream_manager.async_queue_stream_flac(
- player_id
- ):
- await resp.write(audio_chunk)
- return resp
-
- @routes.get("/stream/queue/{player_id}/{queue_item_id}")
- async def stream_queue_item(self, request):
- """Stream a single queue item."""
- player_id = request.match_info["player_id"]
- queue_item_id = request.match_info["queue_item_id"]
-
- # prepare request
- resp = web.StreamResponse(
- status=200, reason="OK", headers={"Content-Type": "audio/flac"}
- )
- resp.enable_chunked_encoding()
- await resp.prepare(request)
-
- async for audio_chunk in self.mass.stream_manager.async_stream_queue_item(
- player_id, queue_item_id
- ):
- await resp.write(audio_chunk)
- return resp
-
- @routes.get("/stream/group/{group_player_id}")
- async def stream_group(self, request):
- """Handle streaming to all players of a group. Highly experimental."""
- group_player_id = request.match_info["group_player_id"]
- if not self.mass.player_manager.get_player_queue(group_player_id):
- return web.Response(text="invalid player id", status=404)
- child_player_id = request.rel_url.query.get("player_id", request.remote)
-
- # prepare request
- resp = web.StreamResponse(
- status=200, reason="OK", headers={"Content-Type": "audio/flac"}
- )
- resp.enable_chunked_encoding()
- await resp.prepare(request)
-
- # stream queue
- player = self.mass.player_manager.get_player(group_player_id)
- async for audio_chunk in player.player.subscribe_stream_client(child_player_id):
- await resp.write(audio_chunk)
- return resp
-
- @login_required
- @routes.get("/api/library/artists")
- async def async_library_artists(self, request):
- """Get all library artists."""
- orderby = request.query.get("orderby", "name")
- provider_filter = request.rel_url.query.get("provider")
- iterator = self.mass.music_manager.async_get_library_artists(
- orderby=orderby, provider_filter=provider_filter
- )
- return await self.__async_stream_json(request, iterator)
-
- @login_required
- @routes.get("/api/library/albums")
- async def async_library_albums(self, request):
- """Get all library albums."""
- orderby = request.query.get("orderby", "name")
- provider_filter = request.rel_url.query.get("provider")
- iterator = self.mass.music_manager.async_get_library_albums(
- orderby=orderby, provider_filter=provider_filter
- )
- return await self.__async_stream_json(request, iterator)
-
- @login_required
- @routes.get("/api/library/tracks")
- async def async_library_tracks(self, request):
- """Get all library tracks."""
- orderby = request.query.get("orderby", "name")
- provider_filter = request.rel_url.query.get("provider")
- iterator = self.mass.music_manager.async_get_library_tracks(
- orderby=orderby, provider_filter=provider_filter
- )
- return await self.__async_stream_json(request, iterator)
-
- @login_required
- @routes.get("/api/library/radios")
- async def async_library_radios(self, request):
- """Get all library radios."""
- orderby = request.query.get("orderby", "name")
- provider_filter = request.rel_url.query.get("provider")
- iterator = self.mass.music_manager.async_get_library_radios(
- orderby=orderby, provider_filter=provider_filter
- )
- return await self.__async_stream_json(request, iterator)
-
- @login_required
- @routes.get("/api/library/playlists")
- async def async_library_playlists(self, request):
- """Get all library playlists."""
- orderby = request.query.get("orderby", "name")
- provider_filter = request.rel_url.query.get("provider")
- iterator = self.mass.music_manager.async_get_library_playlists(
- orderby=orderby, provider_filter=provider_filter
- )
- return await self.__async_stream_json(request, iterator)
-
- @login_required
- @routes.put("/api/library")
- async def async_library_add(self, request):
- """Add item(s) to the library."""
- body = await request.json()
- media_items = await self.__async_media_items_from_body(body)
- result = await self.mass.music_manager.async_library_add(media_items)
- return web.json_response(result, dumps=json_serializer)
-
- @login_required
- @routes.delete("/api/library")
- async def async_library_remove(self, request):
- """Remove item(s) from the library."""
- body = await request.json()
- media_items = await self.__async_media_items_from_body(body)
- result = await self.mass.music_manager.async_library_remove(media_items)
- return web.json_response(result, dumps=json_serializer)
-
- @login_required
- @routes.get("/api/artists/{item_id}")
- async def async_artist(self, request):
- """Get full artist details."""
- item_id = request.match_info.get("item_id")
- provider = request.rel_url.query.get("provider")
- lazy = request.rel_url.query.get("lazy", "true") != "false"
- if item_id is None or provider is None:
- return web.Response(text="invalid item or provider", status=501)
- result = await self.mass.music_manager.async_get_artist(
- item_id, provider, lazy=lazy
- )
- return web.json_response(result, dumps=json_serializer)
-
- @login_required
- @routes.get("/api/albums/{item_id}")
- async def async_album(self, request):
- """Get full album details."""
- item_id = request.match_info.get("item_id")
- provider = request.rel_url.query.get("provider")
- lazy = request.rel_url.query.get("lazy", "true") != "false"
- if item_id is None or provider is None:
- return web.Response(text="invalid item or provider", status=501)
- result = await self.mass.music_manager.async_get_album(
- item_id, provider, lazy=lazy
- )
- return web.json_response(result, dumps=json_serializer)
-
- @login_required
- @routes.get("/api/tracks/{item_id}")
- async def async_track(self, request):
- """Get full track details."""
- item_id = request.match_info.get("item_id")
- provider = request.rel_url.query.get("provider")
- lazy = request.rel_url.query.get("lazy", "true") != "false"
- if item_id is None or provider is None:
- return web.Response(text="invalid item or provider", status=501)
- result = await self.mass.music_manager.async_get_track(
- item_id, provider, lazy=lazy
- )
- return web.json_response(result, dumps=json_serializer)
-
- @login_required
- @routes.get("/api/playlists/{item_id}")
- async def async_playlist(self, request):
- """Get full playlist details."""
- item_id = request.match_info.get("item_id")
- provider = request.rel_url.query.get("provider")
- if item_id is None or provider is None:
- return web.Response(text="invalid item or provider", status=501)
- result = await self.mass.music_manager.async_get_playlist(item_id, provider)
- return web.json_response(result, dumps=json_serializer)
-
- @login_required
- @routes.get("/api/radios/{item_id}")
- async def async_radio(self, request):
- """Get full radio details."""
- item_id = request.match_info.get("item_id")
- provider = request.rel_url.query.get("provider")
- if item_id is None or provider is None:
- return web.Response(text="invalid item_id or provider", status=501)
- result = await self.mass.music_manager.async_get_radio(item_id, provider)
- return web.json_response(result, dumps=json_serializer)
-
- @routes.get("/api/{media_type}/{media_id}/thumb")
- async def async_get_image(self, request):
- """Get (resized) thumb image."""
- media_type_str = request.match_info.get("media_type")
- media_type = MediaType.from_string(media_type_str)
- media_id = request.match_info.get("media_id")
- provider = request.rel_url.query.get("provider")
- if media_id is None or provider is None:
- return web.Response(text="invalid media_id or provider", status=501)
- size = int(request.rel_url.query.get("size", 0))
- img_file = await self.mass.music_manager.async_get_image_thumb(
- media_id, provider, media_type, size
- )
- if not img_file or not os.path.isfile(img_file):
- return web.Response(status=404)
- headers = {"Cache-Control": "max-age=86400, public", "Pragma": "public"}
- return web.FileResponse(img_file, headers=headers)
-
- @login_required
- @routes.get("/api/artists/{item_id}/toptracks")
- async def async_artist_toptracks(self, request):
- """Get top tracks for given artist."""
- item_id = request.match_info.get("item_id")
- provider = request.rel_url.query.get("provider")
- if item_id is None or provider is None:
- return web.Response(text="invalid item_id or provider", status=501)
- iterator = self.mass.music_manager.async_get_artist_toptracks(item_id, provider)
- return await self.__async_stream_json(request, iterator)
-
- @login_required
- @routes.get("/api/artists/{item_id}/albums")
- async def async_artist_albums(self, request):
- """Get (all) albums for given artist."""
- item_id = request.match_info.get("item_id")
- provider = request.rel_url.query.get("provider")
- if item_id is None or provider is None:
- return web.Response(text="invalid item_id or provider", status=501)
- iterator = self.mass.music_manager.async_get_artist_albums(item_id, provider)
- return await self.__async_stream_json(request, iterator)
-
- @login_required
- @routes.get("/api/playlists/{item_id}/tracks")
- async def async_playlist_tracks(self, request):
- """Get playlist tracks from provider."""
- item_id = request.match_info.get("item_id")
- provider = request.rel_url.query.get("provider")
- if item_id is None or provider is None:
- return web.Response(text="invalid item_id or provider", status=501)
- iterator = self.mass.music_manager.async_get_playlist_tracks(item_id, provider)
- return await self.__async_stream_json(request, iterator)
-
- @login_required
- @routes.put("/api/playlists/{item_id}/tracks")
- async def async_add_playlist_tracks(self, request):
- """Add tracks to (editable) playlist."""
- item_id = request.match_info.get("item_id")
- body = await request.json()
- tracks = await self.__async_media_items_from_body(body)
- result = await self.mass.music_manager.async_add_playlist_tracks(
- item_id, tracks
- )
- return web.json_response(result, dumps=json_serializer)
-
- @login_required
- @routes.delete("/api/playlists/{item_id}/tracks")
- async def async_remove_playlist_tracks(self, request):
- """Remove tracks from (editable) playlist."""
- item_id = request.match_info.get("item_id")
- body = await request.json()
- tracks = await self.__async_media_items_from_body(body)
- result = await self.mass.music_manager.async_remove_playlist_tracks(
- item_id, tracks
- )
- return web.json_response(result, dumps=json_serializer)
-
- @login_required
- @routes.get("/api/albums/{item_id}/tracks")
- async def async_album_tracks(self, request):
- """Get album tracks from provider."""
- item_id = request.match_info.get("item_id")
- provider = request.rel_url.query.get("provider")
- if item_id is None or provider is None:
- return web.Response(text="invalid item_id or provider", status=501)
- iterator = self.mass.music_manager.async_get_album_tracks(item_id, provider)
- return await self.__async_stream_json(request, iterator)
-
- @login_required
- @routes.get("/api/albums/{item_id}/versions")
- async def async_album_versions(self, request):
- """Get all versions of an album."""
- item_id = request.match_info.get("item_id")
- provider = request.rel_url.query.get("provider")
- if item_id is None or provider is None:
- return web.Response(text="invalid item_id or provider", status=501)
- iterator = self.mass.music_manager.async_get_album_versions(item_id, provider)
- return await self.__async_stream_json(request, iterator)
-
- @login_required
- @routes.get("/api/tracks/{item_id}/versions")
- async def async_track_versions(self, request):
- """Get all versions of an track."""
- item_id = request.match_info.get("item_id")
- provider = request.rel_url.query.get("provider")
- if item_id is None or provider is None:
- return web.Response(text="invalid item_id or provider", status=501)
- iterator = self.mass.music_manager.async_get_track_versions(item_id, provider)
- return await self.__async_stream_json(request, iterator)
-
- @login_required
- @routes.get("/api/search")
- async def async_search(self, request):
- """Search database and/or providers."""
- searchquery = request.rel_url.query.get("query")
- media_types_query = request.rel_url.query.get("media_types")
- limit = request.rel_url.query.get("limit", 5)
- media_types = []
- if not media_types_query or "artists" in media_types_query:
- media_types.append(MediaType.Artist)
- if not media_types_query or "albums" in media_types_query:
- media_types.append(MediaType.Album)
- if not media_types_query or "tracks" in media_types_query:
- media_types.append(MediaType.Track)
- if not media_types_query or "playlists" in media_types_query:
- media_types.append(MediaType.Playlist)
- if not media_types_query or "radios" in media_types_query:
- media_types.append(MediaType.Radio)
- # get results from database
- result = await self.mass.music_manager.async_global_search(
- searchquery, media_types, limit=limit
- )
- return web.json_response(result, dumps=json_serializer)
-
- @login_required
- @routes.get("/api/players")
- async def async_players(self, request):
- # pylint: disable=unused-argument
- """Get all players."""
- players = self.mass.player_manager.players
- players.sort(key=lambda x: str(x.name), reverse=False)
- return web.json_response(players, dumps=json_serializer)
-
- @login_required
- @routes.post("/api/players/{player_id}/cmd/{cmd}")
- async def async_player_command(self, request):
- """Issue player command."""
- success = False
- player_id = request.match_info.get("player_id")
- cmd = request.match_info.get("cmd")
- try:
- cmd_args = await request.json()
- except json.decoder.JSONDecodeError:
- cmd_args = None
- player_cmd = getattr(self.mass.player_manager, f"async_cmd_{cmd}", None)
- if player_cmd and cmd_args is not None:
- success = await player_cmd(player_id, cmd_args)
- elif player_cmd:
- success = await player_cmd(player_id)
- else:
- return web.Response(text="invalid command", status=501)
- result = {"success": success in [True, None]}
- return web.json_response(result, dumps=json_serializer)
-
- @login_required
- @routes.post("/api/players/{player_id}/play_media/{queue_opt}")
- async def async_player_play_media(self, request):
- """Issue player play media command."""
- player_id = request.match_info.get("player_id")
- player = self.mass.player_manager.get_player(player_id)
- if not player:
- return web.Response(status=404)
- queue_opt = QueueOption(request.match_info.get("queue_opt", "play"))
- body = await request.json()
- media_items = await self.__async_media_items_from_body(body)
- success = await self.mass.player_manager.async_play_media(
- player_id, media_items, queue_opt
- )
- result = {"success": success in [True, None]}
- return web.json_response(result, dumps=json_serializer)
-
- @login_required
- @routes.get("/api/players/{player_id}/queue/items/{queue_item}")
- async def async_player_queue_item(self, request):
- """Return item (by index or queue item id) from the player's queue."""
- player_id = request.match_info.get("player_id")
- item_id = request.match_info.get("queue_item")
- player_queue = self.mass.player_manager.get_player_queue(player_id)
- try:
- item_id = int(item_id)
- queue_item = player_queue.get_item(item_id)
- except ValueError:
- queue_item = player_queue.by_item_id(item_id)
- return web.json_response(queue_item, dumps=json_serializer)
-
- @login_required
- @routes.get("/api/players/{player_id}/queue/items")
- async def async_player_queue_items(self, request):
- """Return the items in the player's queue."""
- player_id = request.match_info.get("player_id")
- player_queue = self.mass.player_manager.get_player_queue(player_id)
-
- async def async_queue_tracks_iter():
- for item in player_queue.items:
- yield item
-
- return await self.__async_stream_json(request, async_queue_tracks_iter())
-
- @login_required
- @routes.get("/api/players/{player_id}/queue")
- async def async_player_queue(self, request):
- """Return the player queue details."""
- player_id = request.match_info.get("player_id")
- player_queue = self.mass.player_manager.get_player_queue(player_id)
- return web.json_response(player_queue, dumps=json_serializer)
-
- @login_required
- @routes.put("/api/players/{player_id}/queue/{cmd}")
- async def async_player_queue_cmd(self, request):
- """Change the player queue details."""
- player_id = request.match_info.get("player_id")
- player_queue = self.mass.player_manager.get_player_queue(player_id)
- cmd = request.match_info.get("cmd")
- try:
- cmd_args = await request.json()
- except json.decoder.JSONDecodeError:
- cmd_args = None
- if cmd == "repeat_enabled":
- player_queue.repeat_enabled = cmd_args
- elif cmd == "shuffle_enabled":
- player_queue.shuffle_enabled = cmd_args
- elif cmd == "clear":
- await player_queue.async_clear()
- elif cmd == "index":
- await player_queue.async_play_index(cmd_args)
- elif cmd == "move_up":
- await player_queue.async_move_item(cmd_args, -1)
- elif cmd == "move_down":
- await player_queue.async_move_item(cmd_args, 1)
- elif cmd == "next":
- await player_queue.async_move_item(cmd_args, 0)
- return web.json_response(player_queue.to_dict(), dumps=json_serializer)
-
- @login_required
- @routes.get("/api/players/{player_id}")
- async def async_player(self, request):
- """Get single player."""
- player_id = request.match_info.get("player_id")
- player = self.mass.player_manager.get_player(player_id)
- if not player:
- return web.Response(text="invalid player", status=404)
- return web.json_response(player, dumps=json_serializer)
-
- @login_required
- @routes.get("/api/config")
- async def async_get_config(self, request):
- # pylint: disable=unused-argument
- """Get the full config."""
- conf = {
- CONF_KEY_BASE: self.mass.config.base,
- CONF_KEY_PROVIDERS: self.mass.config.providers,
- CONF_KEY_PLAYERSETTINGS: self.mass.config.player_settings,
- }
- return web.json_response(conf, dumps=json_serializer)
-
- @login_required
- @routes.get("/api/config/{base}")
- async def async_get_config_item(self, request):
- """Get the config by base type."""
- conf_base = request.match_info.get("base")
- conf = self.mass.config[conf_base]
- return web.json_response(conf, dumps=json_serializer)
-
- @login_required
- @routes.put("/api/config/{base}/{key}/{entry_key}")
- async def async_put_config(self, request):
- """Save the given config item."""
- conf_key = request.match_info.get("key")
- conf_base = request.match_info.get("base")
- entry_key = request.match_info.get("entry_key")
- try:
- new_value = await request.json()
- except json.decoder.JSONDecodeError:
- new_value = (
- self.mass.config[conf_base][conf_key].get_entry(entry_key).default_value
- )
- self.mass.config[conf_base][conf_key][entry_key] = new_value
- return web.json_response(True)
-
- async def async_websocket_handler(self, request):
- """Handle websockets connection."""
- ws_response = None
- authenticated = False
- remove_callbacks = []
- try:
- ws_response = web.WebSocketResponse()
- await ws_response.prepare(request)
-
- # callback for internal events
- async def async_send_message(msg, msg_details=None):
- ws_msg = {"message": msg, "message_details": msg_details}
- try:
- await ws_response.send_json(ws_msg, dumps=json_serializer)
- except AssertionError:
- LOGGER.debug("trying to send message to ws while disconnected")
-
- # process incoming messages
- async for msg in ws_response:
- if msg.type != aiohttp.WSMsgType.TEXT:
- # not sure when/if this happens but log it anyway
- LOGGER.warning(msg.data)
- continue
- try:
- data = msg.json()
- except json.decoder.JSONDecodeError:
- await async_send_message(
- "error",
- 'commands must be issued in json format \
- {"message": "command", "message_details":" optional details"}',
- )
- continue
- msg = data.get("message")
- msg_details = data.get("message_details")
- if not authenticated and not msg == "login":
- # make sure client is authenticated
- await async_send_message("error", "authentication required")
- elif msg == "login" and msg_details:
- # authenticate with token
- try:
- token_info = jwt.decode(msg_details, self._jwt_shared_secret)
- except jwt.InvalidTokenError as exc:
- LOGGER.exception(exc, exc_info=exc)
- error_msg = "Invalid authorization token, " + str(exc)
- await async_send_message("error", error_msg)
- else:
- authenticated = True
- await async_send_message("login", token_info)
- elif msg == "add_event_listener":
- remove_callbacks.append(
- self.mass.add_event_listener(async_send_message, msg_details)
- )
- await async_send_message("event listener subscribed", msg_details)
- elif msg == "player_command":
- player_id = msg_details.get("player_id")
- cmd = msg_details.get("cmd")
- cmd_args = msg_details.get("cmd_args")
- player_cmd = getattr(
- self.mass.player_manager, f"async_cmd_{cmd}", None
- )
- if player_cmd and cmd_args is not None:
- result = await player_cmd(player_id, cmd_args)
- elif player_cmd:
- result = await player_cmd(player_id)
- msg_details = {"cmd": cmd, "result": result}
- await async_send_message("player_command_result", msg_details)
- else:
- # simply echo the message on the eventbus
- self.mass.signal_event(msg, msg_details)
- except (AssertionError, asyncio.CancelledError):
- LOGGER.debug("Websocket disconnected")
- finally:
- for remove_callback in remove_callbacks:
- remove_callback()
- return ws_response
-
- @require_local_subnet
- async def async_json_rpc(self, request):
- """
- Implement LMS jsonrpc interface.
-
- for some compatability with tools that talk to lms
- only support for basic commands
- """
- # pylint: disable=too-many-branches
- data = await request.json()
- LOGGER.debug("jsonrpc: %s", data)
- params = data["params"]
- player_id = params[0]
- cmds = params[1]
- cmd_str = " ".join(cmds)
- if cmd_str == "play":
- await self.mass.player_manager.async_cmd_play(player_id)
- elif cmd_str == "pause":
- await self.mass.player_manager.async_cmd_pause(player_id)
- elif cmd_str == "stop":
- await self.mass.player_manager.async_cmd_stop(player_id)
- elif cmd_str == "next":
- await self.mass.player_manager.async_cmd_next(player_id)
- elif cmd_str == "previous":
- await self.mass.player_manager.async_cmd_previous(player_id)
- elif "power" in cmd_str:
- powered = cmds[1] if len(cmds) > 1 else False
- if powered:
- await self.mass.player_manager.async_cmd_power_on(player_id)
- else:
- await self.mass.player_manager.async_cmd_power_off(player_id)
- elif cmd_str == "playlist index +1":
- await self.mass.player_manager.async_cmd_next(player_id)
- elif cmd_str == "playlist index -1":
- await self.mass.player_manager.async_cmd_previous(player_id)
- elif "mixer volume" in cmd_str and "+" in cmds[2]:
- player = self.mass.player_manager.get_player(player_id)
- volume_level = player.volume_level + int(cmds[2].split("+")[1])
- await self.mass.player_manager.async_cmd_volume_set(player_id, volume_level)
- elif "mixer volume" in cmd_str and "-" in cmds[2]:
- player = self.mass.player_manager.get_player(player_id)
- volume_level = player.volume_level - int(cmds[2].split("-")[1])
- await self.mass.player_manager.async_cmd_volume_set(player_id, volume_level)
- elif "mixer volume" in cmd_str:
- await self.mass.player_manager.async_cmd_volume_set(player_id, cmds[2])
- elif cmd_str == "mixer muting 1":
- await self.mass.player_manager.async_cmd_volume_mute(player_id, True)
- elif cmd_str == "mixer muting 0":
- await self.mass.player_manager.async_cmd_volume_mute(player_id, False)
- elif cmd_str == "button volup":
- await self.mass.player_manager.async_cmd_volume_up(player_id)
- elif cmd_str == "button voldown":
- await self.mass.player_manager.async_cmd_volume_down(player_id)
- elif cmd_str == "button power":
- await self.mass.player_manager.async_cmd_power_toggle(player_id)
- else:
- return web.Response(text="command not supported")
- return web.Response(text="success")
-
- async def __async_media_items_from_body(self, data):
- """Convert posted body data into media items."""
- if not isinstance(data, list):
- data = [data]
- media_items = []
- for item in data:
- media_item = await self.mass.music_manager.async_get_item(
- item["item_id"],
- item["provider"],
- MediaType.from_string(item["media_type"]),
- lazy=True,
- )
- media_items.append(media_item)
- return media_items
-
- async def __async_stream_json(self, request, iterator):
- """Stream items from async iterator as json object."""
- resp = web.StreamResponse(
- status=200, reason="OK", headers={"Content-Type": "application/json"}
- )
- await resp.prepare(request)
- # write json open tag
- json_response = '{ "items": ['
- await resp.write(json_response.encode("utf-8"))
- count = 0
- async for item in iterator:
- # write each item into the items object of the json
- if count:
- json_response = "," + json_serializer(item)
- else:
- json_response = json_serializer(item)
- await resp.write(json_response.encode("utf-8"))
- count += 1
- # write json close tag
- json_response = '], "count": %s }' % count
- await resp.write((json_response).encode("utf-8"))
- await resp.write_eof()
- return resp
-
- async def __async_get_token(self, username, password):
- """Validate given credentials and return JWT token."""
- verified = self.mass.config.validate_credentials(username, password)
- if verified:
- token_expires = datetime.datetime.utcnow() + datetime.timedelta(hours=8)
- scopes = ["user:admin"] # scopes not yet implemented
- token = jwt.encode(
- {"username": username, "scopes": scopes, "exp": token_expires},
- self._jwt_shared_secret,
- )
- return {
- "user": username,
- "token": token.decode(),
- "expires": token_expires,
- "scopes": scopes,
- }
- return None
--- /dev/null
+"""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)
--- /dev/null
+"""Web endpoints package."""
--- /dev/null
+"""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)
--- /dev/null
+"""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)
--- /dev/null
+"""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)
--- /dev/null
+"""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)
--- /dev/null
+"""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")
--- /dev/null
+"""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")
--- /dev/null
+"""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
--- /dev/null
+"""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"
+ )
--- /dev/null
+"""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")
--- /dev/null
+"""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")
--- /dev/null
+"""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")
--- /dev/null
+"""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
--- /dev/null
+"""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")
--- /dev/null
+"""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
+++ /dev/null
-[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
zeroconf==0.28.5
passlib==1.7.2
cryptography==3.1
-mashumaro==1.12
+orjson==3.4.0
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