From 79b30546484380146c1ceb7922ed212878d1b2eb Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 2 Oct 2020 01:26:30 +0200 Subject: [PATCH] refactor part 2 finished (#24) --- .github/workflows/publish-to-pypi.yml | 2 +- .vscode/settings.json | 5 +- music_assistant/constants.py | 45 +- music_assistant/{ => helpers}/app_vars.py | 0 music_assistant/{ => helpers}/cache.py | 4 +- .../{metadata.py => helpers/musicbrainz.py} | 123 +-- music_assistant/{utils.py => helpers/util.py} | 50 +- music_assistant/helpers/web.py | 73 ++ music_assistant/managers/__init__.py | 1 + music_assistant/{ => managers}/config.py | 307 ++++-- music_assistant/{ => managers}/database.py | 5 +- music_assistant/managers/metadata.py | 43 + .../{music_manager.py => managers/music.py} | 32 +- .../players.py} | 189 ++-- .../streams.py} | 20 +- music_assistant/mass.py | 218 ++-- music_assistant/models/config_entry.py | 10 +- music_assistant/models/media_types.py | 9 +- music_assistant/models/musicprovider.py | 149 --- music_assistant/models/player.py | 7 +- music_assistant/models/player_queue.py | 13 +- music_assistant/models/player_state.py | 179 +--- music_assistant/models/playerprovider.py | 27 - music_assistant/models/provider.py | 216 +++- music_assistant/models/streamdetails.py | 4 +- .../__init__.py} | 33 +- music_assistant/providers/builtin/icon.png | Bin 0 -> 15778 bytes .../providers/builtin/translations.json | 5 + .../providers/chromecast/__init__.py | 12 +- music_assistant/providers/chromecast/icon.png | Bin 0 -> 2432 bytes .../providers/chromecast/player.py | 10 +- music_assistant/providers/demo/__init__.py | 9 - .../providers/demo/demo_musicprovider.py | 1 - .../providers/fanarttv/__init__.py | 108 ++ music_assistant/providers/fanarttv/icon.png | Bin 0 -> 5877 bytes music_assistant/providers/file/__init__.py | 10 +- music_assistant/providers/file/icon.png | Bin 0 -> 9623 bytes .../providers/group_player/__init__.py | 50 +- .../providers/group_player/icon.png | Bin 0 -> 15778 bytes music_assistant/providers/qobuz/__init__.py | 10 +- music_assistant/providers/qobuz/icon.png | Bin 0 -> 11363 bytes music_assistant/providers/sonos/icon.png | Bin 0 -> 40528 bytes music_assistant/providers/sonos/sonos.py | 16 +- music_assistant/providers/spotify/__init__.py | 19 +- music_assistant/providers/spotify/icon.png | Bin 0 -> 20017 bytes .../providers/spotify/translations.json | 10 + .../providers/squeezebox/__init__.py | 20 +- .../providers/squeezebox/discovery.py | 2 +- music_assistant/providers/squeezebox/icon.png | Bin 0 -> 20186 bytes .../providers/squeezebox/socket_client.py | 2 +- music_assistant/providers/tunein/__init__.py | 7 +- music_assistant/providers/tunein/icon.png | Bin 0 -> 23800 bytes music_assistant/providers/webplayer/icon.png | Bin 0 -> 8346 bytes music_assistant/translations.json | 59 ++ music_assistant/web.py | 947 ------------------ music_assistant/web/__init__.py | 202 ++++ music_assistant/web/endpoints/__init__.py | 1 + music_assistant/web/endpoints/albums.py | 55 + music_assistant/web/endpoints/artists.py | 55 + music_assistant/web/endpoints/config.py | 72 ++ music_assistant/web/endpoints/images.py | 40 + music_assistant/web/endpoints/json_rpc.py | 67 ++ music_assistant/web/endpoints/library.py | 88 ++ music_assistant/web/endpoints/login.py | 46 + music_assistant/web/endpoints/players.py | 146 +++ music_assistant/web/endpoints/playlists.py | 56 ++ music_assistant/web/endpoints/radios.py | 28 + music_assistant/web/endpoints/search.py | 33 + music_assistant/web/endpoints/streams.py | 103 ++ music_assistant/web/endpoints/tracks.py | 43 + music_assistant/web/endpoints/websocket.py | 97 ++ pylintrc | 69 -- requirements.txt | 2 +- setup.cfg | 74 +- 74 files changed, 2442 insertions(+), 1896 deletions(-) rename music_assistant/{ => helpers}/app_vars.py (100%) rename music_assistant/{ => helpers}/cache.py (98%) rename music_assistant/{metadata.py => helpers/musicbrainz.py} (60%) mode change 100755 => 100644 rename music_assistant/{utils.py => helpers/util.py} (92%) create mode 100644 music_assistant/helpers/web.py create mode 100644 music_assistant/managers/__init__.py rename music_assistant/{ => managers}/config.py (55%) rename music_assistant/{ => managers}/database.py (99%) create mode 100755 music_assistant/managers/metadata.py rename music_assistant/{music_manager.py => managers/music.py} (97%) rename music_assistant/{player_manager.py => managers/players.py} (81%) rename music_assistant/{stream_manager.py => managers/streams.py} (97%) delete mode 100755 music_assistant/models/musicprovider.py delete mode 100755 music_assistant/models/playerprovider.py rename music_assistant/providers/{demo/demo_playerprovider.py => builtin/__init__.py} (87%) create mode 100644 music_assistant/providers/builtin/icon.png create mode 100644 music_assistant/providers/builtin/translations.json create mode 100644 music_assistant/providers/chromecast/icon.png delete mode 100644 music_assistant/providers/demo/__init__.py delete mode 100644 music_assistant/providers/demo/demo_musicprovider.py create mode 100755 music_assistant/providers/fanarttv/__init__.py create mode 100644 music_assistant/providers/fanarttv/icon.png create mode 100644 music_assistant/providers/file/icon.png create mode 100644 music_assistant/providers/group_player/icon.png create mode 100644 music_assistant/providers/qobuz/icon.png create mode 100644 music_assistant/providers/sonos/icon.png create mode 100644 music_assistant/providers/spotify/icon.png create mode 100644 music_assistant/providers/spotify/translations.json create mode 100644 music_assistant/providers/squeezebox/icon.png create mode 100644 music_assistant/providers/tunein/icon.png create mode 100644 music_assistant/providers/webplayer/icon.png create mode 100644 music_assistant/translations.json delete mode 100755 music_assistant/web.py create mode 100755 music_assistant/web/__init__.py create mode 100644 music_assistant/web/endpoints/__init__.py create mode 100644 music_assistant/web/endpoints/albums.py create mode 100644 music_assistant/web/endpoints/artists.py create mode 100644 music_assistant/web/endpoints/config.py create mode 100644 music_assistant/web/endpoints/images.py create mode 100644 music_assistant/web/endpoints/json_rpc.py create mode 100644 music_assistant/web/endpoints/library.py create mode 100644 music_assistant/web/endpoints/login.py create mode 100644 music_assistant/web/endpoints/players.py create mode 100644 music_assistant/web/endpoints/playlists.py create mode 100644 music_assistant/web/endpoints/radios.py create mode 100644 music_assistant/web/endpoints/search.py create mode 100644 music_assistant/web/endpoints/streams.py create mode 100644 music_assistant/web/endpoints/tracks.py create mode 100644 music_assistant/web/endpoints/websocket.py delete mode 100644 pylintrc diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 5b4119ad..9a6e48d6 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -20,7 +20,7 @@ jobs: curl https://github.com/music-assistant/app/archive/master.zip -LOk unzip master.zip cd /tmp/app-master - mv docs /home/runner/work/server/server/music_assistant/web + mv docs /home/runner/work/server/server/music_assistant/web/static cd /home/runner/work/server/server/ - name: Install wheel run: >- diff --git a/.vscode/settings.json b/.vscode/settings.json index 9cbb6974..3b0a5ec1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,9 @@ { "python.linting.pylintEnabled": true, + "python.linting.pylintArgs": ["--rcfile=${workspaceFolder}/setup.cfg"], "python.linting.enabled": true, "python.pythonPath": "venv/bin/python3", - "python.linting.flake8Enabled": false + "python.linting.flake8Enabled": true, + "python.linting.flake8Args": ["--config=${workspaceFolder}/setup.cfg"], + "python.linting.mypyEnabled": false, } \ No newline at end of file diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 47f6aca2..f5fcb890 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -1,8 +1,9 @@ """All constants for Music Assistant.""" -__version__ = "0.0.47" +__version__ = "0.0.48" REQUIRED_PYTHON_VER = "3.7" +# configuration keys/attributes CONF_USERNAME = "username" CONF_PASSWORD = "password" CONF_ENABLED = "enabled" @@ -16,11 +17,27 @@ CONF_FALLBACK_GAIN_CORRECT = "fallback_gain_correct" CONF_GROUP_DELAY = "group_delay" CONF_VOLUME_CONTROL = "volume_control" CONF_POWER_CONTROL = "power_control" +CONF_HTTP_PORT = "http_port" +CONF_HTTPS_PORT = "https_port" +CONF_MAX_SAMPLE_RATE = "max_sample_rate" +CONF_VOLUME_NORMALISATION = "volume_normalisation" +CONF_TARGET_VOLUME = "target_volume" +CONF_SSL_CERTIFICATE = "ssl_certificate" +CONF_SSL_KEY = "ssl_key" +CONF_EXTERNAL_URL = "external_url" + +# configuration base keys/attributes CONF_KEY_BASE = "base" -CONF_KEY_PLAYERSETTINGS = "player_settings" -CONF_KEY_PROVIDERS = "providers" +CONF_KEY_PLAYER_SETTINGS = "player_settings" +CONF_KEY_MUSIC_PROVIDERS = "music_providers" +CONF_KEY_PLAYER_PROVIDERS = "player_providers" +CONF_KEY_METADATA_PROVIDERS = "metadata_providers" +CONF_KEY_PLUGINS = "plugins" +CONF_KEY_BASE_WEBSERVER = "web" +CONF_KEY_BASE_SECURITY = "security" +# events EVENT_PLAYER_ADDED = "player added" EVENT_PLAYER_REMOVED = "player removed" EVENT_PLAYER_CHANGED = "player changed" @@ -33,6 +50,7 @@ EVENT_QUEUE_ITEMS_UPDATED = "queue items updated" EVENT_QUEUE_TIME_UPDATED = "queue time updated" EVENT_SHUTDOWN = "application shutdown" EVENT_PROVIDER_REGISTERED = "provider registered" +EVENT_PROVIDER_UNREGISTERED = "provider unregistered" EVENT_PLAYER_CONTROL_REGISTERED = "player control registered" EVENT_PLAYER_CONTROL_UNREGISTERED = "player control unregistered" EVENT_PLAYER_CONTROL_UPDATED = "player control updated" @@ -42,3 +60,24 @@ EVENT_SET_PLAYER_CONTROL_STATE = "set player control state" EVENT_REGISTER_PLAYER_CONTROL = "register player control" EVENT_UNREGISTER_PLAYER_CONTROL = "unregister player control" EVENT_UPDATE_PLAYER_CONTROL = "update player control" + +# player attributes +ATTR_PLAYER_ID = "player_id" +ATTR_PROVIDER_ID = "provider_id" +ATTR_NAME = "name" +ATTR_POWERED = "powered" +ATTR_ELAPSED_TIME = "elapsed_time" +ATTR_STATE = "state" +ATTR_AVAILABLE = "available" +ATTR_CURRENT_URI = "current_uri" +ATTR_VOLUME_LEVEL = "volume_level" +ATTR_MUTED = "muted" +ATTR_IS_GROUP_PLAYER = "is_group_player" +ATTR_GROUP_CHILDS = "group_childs" +ATTR_DEVICE_INFO = "device_info" +ATTR_SHOULD_POLL = "should_poll" +ATTR_FEATURES = "features" +ATTR_CONFIG_ENTRIES = "config_entries" +ATTR_UPDATED_AT = "updated_at" +ATTR_ACTIVE_QUEUE = "active_queue" +ATTR_GROUP_PARENTS = "group_parents" diff --git a/music_assistant/app_vars.py b/music_assistant/helpers/app_vars.py similarity index 100% rename from music_assistant/app_vars.py rename to music_assistant/helpers/app_vars.py diff --git a/music_assistant/cache.py b/music_assistant/helpers/cache.py similarity index 98% rename from music_assistant/cache.py rename to music_assistant/helpers/cache.py index 7c36d047..f5c6366e 100644 --- a/music_assistant/cache.py +++ b/music_assistant/helpers/cache.py @@ -8,7 +8,7 @@ import time from functools import reduce import aiosqlite -from music_assistant.utils import run_periodic +from music_assistant.helpers.util import run_periodic LOGGER = logging.getLogger("mass") @@ -113,7 +113,7 @@ async def async_cached_generator( for item in cache_result: yield item else: - # nothing in cache, yield from iterator and store in cache when complete + # nothing in cache, yield from generator and store in cache when complete cache_result = [] async for item in coro_func: yield item diff --git a/music_assistant/metadata.py b/music_assistant/helpers/musicbrainz.py old mode 100755 new mode 100644 similarity index 60% rename from music_assistant/metadata.py rename to music_assistant/helpers/musicbrainz.py index 04d0bae3..b3fcc038 --- a/music_assistant/metadata.py +++ b/music_assistant/helpers/musicbrainz.py @@ -1,38 +1,28 @@ -"""All logic for metadata retrieval.""" -# TODO: split up into (optional) providers -import json +"""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.cache import async_use_cache -from music_assistant.utils import compare_strings, get_compare_string +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("mass") +LOGGER = logging.getLogger("musicbrainz") -class MetaData: - """Several helpers to search and store metadata for mediaitems.""" +class MusicBrainz: + """Handle getting Id's from MusicBrainz.""" - # 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 + self.cache = mass.cache + self.throttler = Throttler(rate_limit=1, period=1) async def async_get_mb_artist_id( self, @@ -54,7 +44,7 @@ class MetaData: ) mb_artist_id = None if album_upc: - mb_artist_id = await self.musicbrainz.async_search_artist_by_album( + mb_artist_id = await self.async_search_artist_by_album( artistname, None, album_upc ) if mb_artist_id: @@ -65,7 +55,7 @@ class MetaData: mb_artist_id, ) if not mb_artist_id and track_isrc: - mb_artist_id = await self.musicbrainz.async_search_artist_by_track( + mb_artist_id = await self.async_search_artist_by_track( artistname, None, track_isrc ) if mb_artist_id: @@ -76,7 +66,7 @@ class MetaData: mb_artist_id, ) if not mb_artist_id and albumname: - mb_artist_id = await self.musicbrainz.async_search_artist_by_album( + mb_artist_id = await self.async_search_artist_by_album( artistname, albumname ) if mb_artist_id: @@ -87,7 +77,7 @@ class MetaData: mb_artist_id, ) if not mb_artist_id and trackname: - mb_artist_id = await self.musicbrainz.async_search_artist_by_track( + mb_artist_id = await self.async_search_artist_by_track( artistname, trackname ) if mb_artist_id: @@ -99,24 +89,6 @@ class MetaData: ) 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 ): @@ -200,73 +172,12 @@ class MusicBrainz: url, headers=headers, params=params, verify_ssl=False ) as response: try: - result = await response.json() + result = await response.json(loads=orjson.loads) except ( aiohttp.client_exceptions.ContentTypeError, - json.decoder.JSONDecodeError, + orjson.decoder.JSONDecodeError, ) as exc: msg = await response.text() - LOGGER.exception("%s - %s", str(exc), msg) + LOGGER.error("%s - %s", str(exc), msg) result = None return result - - -class FanartTv: - """FanartTv support for metadata retrieval.""" - - def __init__(self, mass): - """Initialize class.""" - self.mass = mass - self.cache = mass.cache - self.throttler = Throttler(rate_limit=1, period=2) - - async def async_get_artist_images(self, mb_artist_id): - """Retrieve images by musicbrainz artist id.""" - metadata = {} - data = await self.async_get_data("music/%s" % mb_artist_id) - if data: - if data.get("hdmusiclogo"): - metadata["logo"] = data["hdmusiclogo"][0]["url"] - elif data.get("musiclogo"): - metadata["logo"] = data["musiclogo"][0]["url"] - if data.get("artistbackground"): - count = 0 - for item in data["artistbackground"]: - key = "fanart" if count == 0 else "fanart.%s" % count - metadata[key] = item["url"] - if data.get("artistthumb"): - url = data["artistthumb"][0]["url"] - if "2a96cbd8b46e442fc41c2b86b821562f" not in url: - metadata["image"] = url - if data.get("musicbanner"): - metadata["banner"] = data["musicbanner"][0]["url"] - return metadata - - @async_use_cache(30) - async def async_get_data(self, endpoint, params=None): - """Get data from api.""" - if params is None: - params = {} - url = "http://webservice.fanart.tv/v3/%s" % endpoint - params["api_key"] = "639191cb0774661597f28a47e7e2bad5" - async with self.throttler: - async with self.mass.http_session.get( - url, params=params, verify_ssl=False - ) as response: - try: - result = await response.json() - except ( - aiohttp.client_exceptions.ContentTypeError, - json.decoder.JSONDecodeError, - ): - LOGGER.error("Failed to retrieve %s", endpoint) - text_result = await response.text() - LOGGER.debug(text_result) - return None - except aiohttp.client_exceptions.ClientConnectorError: - LOGGER.error("Failed to retrieve %s", endpoint) - return None - if "error" in result and "limit" in result["error"]: - LOGGER.error(result["error"]) - return None - return result diff --git a/music_assistant/utils.py b/music_assistant/helpers/util.py similarity index 92% rename from music_assistant/utils.py rename to music_assistant/helpers/util.py index ab041bf7..4c6c92d4 100755 --- a/music_assistant/utils.py +++ b/music_assistant/helpers/util.py @@ -9,21 +9,15 @@ 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 orjson 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 - +from music_assistant.helpers.app_vars import get_app_var # noqa # pylint: disable=all # pylint: disable=invalid-name T = TypeVar("T") @@ -244,26 +238,8 @@ def get_folder_size(folderpath): 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) +json_serializer = functools.partial(orjson.dumps, option=orjson.OPT_NAIVE_UTC) # pylint: enable=invalid-name @@ -281,12 +257,22 @@ def compare_strings(str1, str2, strict=False): 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) as _file: - return json.loads(_file.read()) - except (FileNotFoundError, json.JSONDecodeError) as exc: + 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 ) @@ -323,7 +309,7 @@ def decrypt_string(str_value): """Decrypt a string with Fernet.""" try: return Fernet(get_app_var(3)).decrypt(str_value.encode()).decode() - except InvalidToken: + except (InvalidToken, AttributeError): return None @@ -331,7 +317,7 @@ def decrypt_bytes(bytes_value): """Decrypt bytes with Fernet.""" try: return Fernet(get_app_var(3)).decrypt(bytes_value) - except InvalidToken: + except (InvalidToken, AttributeError): return None diff --git a/music_assistant/helpers/web.py b/music_assistant/helpers/web.py new file mode 100644 index 00000000..207d9301 --- /dev/null +++ b/music_assistant/helpers/web.py @@ -0,0 +1,73 @@ +"""Various helpers for web requests.""" + +import ipaddress +from functools import wraps +from typing import AsyncGenerator + +from aiohttp import web +from music_assistant.helpers.typing import MusicAssistantType +from music_assistant.helpers.util import json_serializer +from music_assistant.models.media_types import MediaType + + +async def async_stream_json(request: web.Request, generator: AsyncGenerator): + """Stream items from async generator as json object.""" + resp = web.StreamResponse( + status=200, reason="OK", headers={"Content-Type": "application/json"} + ) + await resp.prepare(request) + # write json open tag + await resp.write(b'{ "items": [') + count = 0 + async for item in generator: + # write each item into the items object of the json + if count: + json_response = b"," + json_serializer(item) + else: + json_response = json_serializer(item) + await resp.write(json_response) + count += 1 + # write json close tag + msg = '], "count": %s }' % count + await resp.write(msg.encode()) + await resp.write_eof() + return resp + + +async def async_media_items_from_body(mass: MusicAssistantType, data: dict): + """Convert posted body data into media items.""" + if not isinstance(data, list): + data = [data] + media_items = [] + for item in data: + media_item = await mass.music.async_get_item( + item["item_id"], + item["provider"], + MediaType.from_string(item["media_type"]), + lazy=True, + ) + media_items.append(media_item) + return media_items + + +def require_local_subnet(func): + """Return decorator to specify web method as available locally only.""" + + @wraps(func) + async def wrapped(*args, **kwargs): + request = args[-1] + + if isinstance(request, web.View): + request = request.request + + if not isinstance(request, web.BaseRequest): # pragma: no cover + raise RuntimeError( + "Incorrect usage of decorator." "Expect web.BaseRequest as an argument" + ) + + if not ipaddress.ip_address(request.remote).is_private: + raise web.HTTPUnauthorized(reason="Not remote available") + + return await func(*args, **kwargs) + + return wrapped diff --git a/music_assistant/managers/__init__.py b/music_assistant/managers/__init__.py new file mode 100644 index 00000000..bc6f8f94 --- /dev/null +++ b/music_assistant/managers/__init__.py @@ -0,0 +1 @@ +"""Controllers/managers for Music Assistant entities.""" diff --git a/music_assistant/config.py b/music_assistant/managers/config.py similarity index 55% rename from music_assistant/config.py rename to music_assistant/managers/config.py index f711b823..a1487cd3 100755 --- a/music_assistant/config.py +++ b/music_assistant/managers/config.py @@ -7,24 +7,41 @@ 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_PLAYERSETTINGS, - CONF_KEY_PROVIDERS, + 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.models.config_entry import ConfigEntry, ConfigEntryType -from music_assistant.utils import ( +from music_assistant.helpers.util import ( decrypt_string, encrypt_string, get_external_ip, - json, + 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") @@ -34,49 +51,55 @@ DEFAULT_PLAYER_CONFIG_ENTRIES = [ entry_key=CONF_ENABLED, entry_type=ConfigEntryType.BOOL, default_value=True, - description_key="player_enabled", + label="enable_player", ), ConfigEntry( entry_key=CONF_NAME, entry_type=ConfigEntryType.STRING, default_value=None, - description_key="player_name", + label=CONF_NAME, + description="desc_player_name", ), ConfigEntry( - entry_key="max_sample_rate", + entry_key=CONF_MAX_SAMPLE_RATE, entry_type=ConfigEntryType.INT, values=[41000, 48000, 96000, 176000, 192000, 384000], default_value=96000, - description_key="max_sample_rate", + label=CONF_MAX_SAMPLE_RATE, + description="desc_sample_rate", ), ConfigEntry( - entry_key="volume_normalisation", + entry_key=CONF_VOLUME_NORMALISATION, entry_type=ConfigEntryType.BOOL, default_value=True, - description_key="enable_r128_volume_normalisation", + label=CONF_VOLUME_NORMALISATION, + description="desc_volume_normalisation", ), ConfigEntry( - entry_key="target_volume", + entry_key=CONF_TARGET_VOLUME, entry_type=ConfigEntryType.INT, range=(-30, 0), default_value=-23, - description_key="target_volume_lufs", - depends_on="volume_normalisation", + 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, - description_key=CONF_FALLBACK_GAIN_CORRECT, - depends_on="volume_normalisation", + 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, - description_key=CONF_CROSSFADE_DURATION, + label=CONF_CROSSFADE_DURATION, + description="desc_crossfade", ), ] @@ -85,55 +108,63 @@ DEFAULT_PROVIDER_CONFIG_ENTRIES = [ entry_key=CONF_ENABLED, entry_type=ConfigEntryType.BOOL, default_value=True, - description_key="enabled", + label=CONF_ENABLED, + description="desc_enable_provider", ) ] DEFAULT_BASE_CONFIG_ENTRIES = { - "web": [ + CONF_KEY_BASE_WEBSERVER: [ ConfigEntry( - entry_key="http_port", + entry_key=CONF_HTTP_PORT, entry_type=ConfigEntryType.INT, default_value=8095, - description_key="web_http_port", + label=CONF_HTTP_PORT, + description="desc_http_port", ), ConfigEntry( - entry_key="https_port", + entry_key=CONF_HTTPS_PORT, entry_type=ConfigEntryType.INT, default_value=8096, - description_key="web_https_port", + label=CONF_HTTPS_PORT, + description="desc_https_port", ), ConfigEntry( - entry_key="ssl_certificate", + entry_key=CONF_SSL_CERTIFICATE, entry_type=ConfigEntryType.STRING, default_value="", - description_key="web_ssl_cert", + label=CONF_SSL_CERTIFICATE, + description="desc_ssl_certificate", ), ConfigEntry( - entry_key="ssl_key", + entry_key=CONF_SSL_KEY, entry_type=ConfigEntryType.STRING, default_value="", - description_key="web_ssl_key", + label=CONF_SSL_KEY, + description="desc_ssl_key", ), ConfigEntry( - entry_key="external_url", + entry_key=CONF_EXTERNAL_URL, entry_type=ConfigEntryType.STRING, default_value=f"http://{get_external_ip()}:8095", - description_key="web_external_url", + label="External url (fqdn)", + description="desc_external_url", ), ], - "security": [ + CONF_KEY_BASE_SECURITY: [ ConfigEntry( - entry_key="username", + entry_key=CONF_USERNAME, entry_type=ConfigEntryType.STRING, default_value="admin", - description_key="security_username", + label=CONF_USERNAME, + description="desc_base_username", ), ConfigEntry( - entry_key="password", + entry_key=CONF_PASSWORD, entry_type=ConfigEntryType.PASSWORD, default_value="", - description_key="security_password", + label=CONF_PASSWORD, + description="desc_base_password", store_hashed=True, ), ], @@ -144,8 +175,19 @@ class ConfigBaseType(Enum): """Enum with config base types.""" BASE = CONF_KEY_BASE - PLAYER = CONF_KEY_PLAYERSETTINGS - PROVIDER = CONF_KEY_PROVIDERS + 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: @@ -166,7 +208,7 @@ class ConfigItem: """Print class.""" return f"{OrderedDict}({self.to_dict()})" - def to_dict(self) -> dict: + def to_dict(self, lang="en") -> dict: """Return entire config as dict.""" result = OrderedDict() for entry in self.get_config_entries(): @@ -177,6 +219,14 @@ class ConfigItem: # 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): @@ -244,31 +294,33 @@ class ConfigItem: 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: + # reload provider/plugin if value changed + if self._base_type in PROVIDER_TYPES: self.mass.add_job( - self.mass.get_provider(self._parent_item_key).async_on_reload() + self.mass.async_reload_provider(self._parent_item_key) ) - if self._base_type == ConfigBaseType.PLAYER: + if self._base_type == ConfigBaseType.PLAYER_SETTINGS: # force update of player if it's config changed self.mass.add_job( - self.mass.player_manager.async_trigger_player_update( + 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: + if self._base_type == ConfigBaseType.PLAYER_SETTINGS: return self.mass.config.get_player_config_entries(self._parent_item_key) - if self._base_type == ConfigBaseType.PROVIDER: + 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) @@ -291,8 +343,12 @@ class ConfigBase(OrderedDict): ) 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 MassConfig: + +class ConfigManager: """Class which holds our configuration.""" def __init__(self, mass, data_path: str): @@ -301,10 +357,16 @@ class MassConfig: 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) + 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 @@ -312,6 +374,11 @@ class MassConfig: """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.""" @@ -320,33 +387,65 @@ class MassConfig: @property def player_settings(self): """Return all player configs.""" - return self._conf_players + 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 providers(self): - """Return all provider configs.""" - return self._conf_providers + def plugins(self): + """Return all plugin configs.""" + return self._conf_plugins - def get_provider_config(self, provider_id): + def get_provider_config(self, provider_id: str, provider_type: ProviderType = None): """Return config for given provider.""" - return self._conf_providers[provider_id] + 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_players[player_id] + 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: - return DEFAULT_PROVIDER_CONFIG_ENTRIES + provider.config_entries + 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 = self.mass.player_manager.get_player(player_id) - if player: - return DEFAULT_PLAYER_CONFIG_ENTRIES + player.config_entries + 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 @@ -354,13 +453,16 @@ class MassConfig: """Return all base config entries.""" return DEFAULT_BASE_CONFIG_ENTRIES[base_key] - def validate_credentials(self, username, password): + 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 - return pbkdf2_sha256.verify(password, self.base["security"]["password"]) + try: + return pbkdf2_sha256.verify(password, self.base["security"]["password"]) + except ValueError: + return False def __getitem__(self, item_key): """Return item value by key.""" @@ -370,6 +472,39 @@ class MassConfig: """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: @@ -384,21 +519,24 @@ class MassConfig: # create dict for stored config stored_conf = { CONF_KEY_BASE: {}, - CONF_KEY_PLAYERSETTINGS: {}, - CONF_KEY_PROVIDERS: {}, + 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, "w") as _file: - _file.write(json.dumps(stored_conf, indent=4)) + 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 config from file.""" + """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) @@ -408,25 +546,18 @@ class MassConfig: 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 + for conf_key in [ + CONF_KEY_BASE, + CONF_KEY_PLAYER_SETTINGS, + CONF_KEY_MUSIC_PROVIDERS, + CONF_KEY_METADATA_PROVIDERS, + CONF_KEY_PLAYER_PROVIDERS, + CONF_KEY_PLUGINS, + ]: + if not data.get(conf_key): + continue + for key, value in data[conf_key].items(): + for subkey, subvalue in value.items(): + self[conf_key][key].stored_config[subkey] = subvalue self.loading = False diff --git a/music_assistant/database.py b/music_assistant/managers/database.py similarity index 99% rename from music_assistant/database.py rename to music_assistant/managers/database.py index 0ed0bd8d..b2ffbe29 100755 --- a/music_assistant/database.py +++ b/music_assistant/managers/database.py @@ -7,6 +7,7 @@ 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, @@ -21,7 +22,6 @@ from music_assistant.models.media_types import ( Track, TrackQuality, ) -from music_assistant.utils import compare_strings, get_sort_name, try_parse_int LOGGER = logging.getLogger("mass") @@ -48,7 +48,7 @@ class DbConnect: return False -class Database: +class DatabaseManager: """Class that holds the (logic to the) database.""" def __init__(self, mass): @@ -56,7 +56,6 @@ class Database: 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.""" diff --git a/music_assistant/managers/metadata.py b/music_assistant/managers/metadata.py new file mode 100755 index 00000000..7df6364d --- /dev/null +++ b/music_assistant/managers/metadata.py @@ -0,0 +1,43 @@ +"""All logic for metadata retrieval.""" + +import logging +from typing import Dict, List + +from music_assistant.helpers.cache import async_cached +from music_assistant.helpers.typing import MusicAssistantType +from music_assistant.helpers.util import merge_dict +from music_assistant.models.provider import MetadataProvider, ProviderType + +LOGGER = logging.getLogger("mass") + + +class MetaDataManager: + """Several helpers to search and store metadata for mediaitems using metadata providers.""" + + # TODO: create periodic task to search for missing metadata + def __init__(self, mass: MusicAssistantType) -> None: + """Initialize class.""" + self.mass = mass + self.cache = mass.cache + + @property + def providers(self) -> List[MetadataProvider]: + """Return all providers of type MetadataProvider.""" + return self.mass.get_providers(ProviderType.METADATA_PROVIDER) + + async def async_get_artist_metadata( + self, mb_artist_id: str, cur_metadata: Dict + ) -> Dict: + """Get/update rich metadata for an artist by providing the musicbrainz artist id.""" + metadata = cur_metadata + for provider in self.providers: + if "fanart" in metadata: + # no need to query (other) metadata providers if we already have a result + break + cache_key = f"{provider.id}.artist_metadata.{mb_artist_id}" + res = await async_cached( + self.cache, cache_key, provider.async_get_artist_images(mb_artist_id) + ) + if res: + merge_dict(metadata, res) + return metadata diff --git a/music_assistant/music_manager.py b/music_assistant/managers/music.py similarity index 97% rename from music_assistant/music_manager.py rename to music_assistant/managers/music.py index 6f773db5..1621e738 100755 --- a/music_assistant/music_manager.py +++ b/music_assistant/managers/music.py @@ -6,11 +6,18 @@ import functools import logging import os import time -from typing import List, Optional +from typing import Any, 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.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, @@ -22,10 +29,8 @@ from music_assistant.models.media_types import ( SearchResult, Track, ) -from music_assistant.models.musicprovider import MusicProvider -from music_assistant.models.provider import ProviderType +from music_assistant.models.provider import MusicProvider, 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") @@ -72,7 +77,9 @@ class MusicManager: 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.""" @@ -84,6 +91,15 @@ class MusicManager: """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( @@ -552,7 +568,7 @@ class MusicManager: ): if not lookup_album: continue - musicbrainz_id = await self.mass.metadata.async_get_mb_artist_id( + 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), @@ -565,7 +581,7 @@ class MusicManager: ): if not lookup_track: continue - musicbrainz_id = await self.mass.metadata.async_get_mb_artist_id( + 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), diff --git a/music_assistant/player_manager.py b/music_assistant/managers/players.py similarity index 81% rename from music_assistant/player_manager.py rename to music_assistant/managers/players.py index 3fdea445..5d2381a4 100755 --- a/music_assistant/player_manager.py +++ b/music_assistant/managers/players.py @@ -14,6 +14,12 @@ from music_assistant.constants import ( 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, @@ -23,14 +29,7 @@ from music_assistant.models.player import ( ) 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, -) +from music_assistant.models.provider import PlayerProvider, ProviderType POLL_INTERVAL = 30 @@ -65,28 +64,33 @@ class PlayerManager: """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() + 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_state in self.players: - if player_state.player.should_poll and ( + for player in self.players: + if player.should_poll and ( self._poll_ticks >= POLL_INTERVAL - or player_state.state == PlaybackState.Playing + or player.state == PlaybackState.Playing ): - await player_state.player.async_on_update() + await 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.""" + 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.""" @@ -97,16 +101,20 @@ class PlayerManager: """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.""" + """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 + if player_state: return player_state.player - return player_state + return None @callback def get_player_provider(self, player_id: str) -> PlayerProvider: @@ -117,11 +125,11 @@ class PlayerManager: @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: + 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.active_queue) + return self._player_queues.get(player_state.active_queue) @callback def get_player_control(self, control_id: str) -> PlayerControl: @@ -184,7 +192,7 @@ class PlayerManager: """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) + 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.""" @@ -199,13 +207,15 @@ class PlayerManager: control.name, ) # update all players using this playercontrol - for player in self.players: - conf = self.mass.config.player_settings[player.player_id] + 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_update_player(player)) + 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.""" @@ -222,13 +232,15 @@ class PlayerManager: new_state, ) # update all players using this playercontrol - for player in self.players: - conf = self.mass.config.player_settings[player.player_id] + 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), ]: - await self.async_trigger_player_update(player.player_id) + self.mass.add_job( + self.async_trigger_player_update(player_state.player_id) + ) # SERVICE CALLS / PLAYER COMMANDS @@ -249,23 +261,20 @@ class PlayerManager: 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( + 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_manager.async_get_album_tracks( + 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_manager.async_get_playlist_tracks( + tracks = self.mass.music.async_get_playlist_tracks( media_item.item_id, provider_id=media_item.provider ) else: @@ -308,9 +317,6 @@ class PlayerManager: 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, @@ -336,10 +342,10 @@ class PlayerManager: :param player_id: player_id of the player to handle the command. """ - player = self.get_player(player_id) - if not player: + player_state = self.get_player_state(player_id) + if not player_state: return - queue_player_id = player.active_queue + queue_player_id = player_state.active_queue queue_player = self.get_player(queue_player_id) return await queue_player.async_cmd_stop() @@ -349,10 +355,10 @@ class PlayerManager: :param player_id: player_id of the player to handle the command. """ - player = self.get_player(player_id) - if not player: + player_state = self.get_player_state(player_id) + if not player_state: return - queue_player_id = player.active_queue + 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: @@ -367,10 +373,10 @@ class PlayerManager: :param player_id: player_id of the player to handle the command. """ - player = self.get_player(player_id) - if not player: + player_state = self.get_player_state(player_id) + if not player_state: return - queue_player_id = player.active_queue + queue_player_id = player_state.active_queue queue_player = self.get_player(queue_player_id) return await queue_player.async_cmd_pause() @@ -380,10 +386,10 @@ class PlayerManager: :param player_id: player_id of the player to handle the command. """ - player = self.get_player(player_id) - if not player: + player_state = self.get_player_state(player_id) + if not player_state: return - if player.state == PlaybackState.Playing: + if player_state.state == PlaybackState.Playing: return await self.async_cmd_pause(player_id) return await self.async_cmd_play(player_id) @@ -393,10 +399,10 @@ class PlayerManager: :param player_id: player_id of the player to handle the command. """ - player = self.get_player(player_id) - if not player: + player_state = self.get_player_state(player_id) + if not player_state: return - queue_player_id = player.active_queue + 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): @@ -405,10 +411,10 @@ class PlayerManager: :param player_id: player_id of the player to handle the command. """ - player = self.get_player(player_id) - if not player: + player_state = self.get_player_state(player_id) + if not player_state: return - queue_player_id = player.active_queue + 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: @@ -417,12 +423,12 @@ class PlayerManager: :param player_id: player_id of the player to handle the command. """ - player = self.get_player(player_id) - if not player: + player_state = self.get_player_state(player_id) + if not player_state: return - player_config = self.mass.config.player_settings[player.player_id] + player_config = self.mass.config.player_settings[player_state.player_id] # turn on player - await player.async_cmd_power_on() + 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]) @@ -435,30 +441,33 @@ class PlayerManager: :param player_id: player_id of the player to handle the command. """ - player = self.get_player(player_id) - if not player: + player_state = self.get_player_state(player_id) + if not player_state: return # send stop if player is playing - if player.active_queue == player_id: + 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.player_id] + player_config = self.mass.config.player_settings[player_state.player_id] # turn off player - await player.async_cmd_power_off() + 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.is_group_player: + if player_state.is_group_player: # player is group, turn off all childs - for child_player_id in player.group_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.group_parents: + 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 @@ -478,10 +487,10 @@ class PlayerManager: :param player_id: player_id of the player to handle the command. """ - player = self.get_player(player_id) - if not player: + player_state = self.get_player_state(player_id) + if not player_state: return - if player.powered: + if player_state.powered: return await self.async_cmd_power_off(player_id) return await self.async_cmd_power_on(player_id) @@ -492,10 +501,10 @@ class PlayerManager: :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: + 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.player_id] + 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 @@ -507,18 +516,18 @@ class PlayerManager: 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) + await player_state.player.async_cmd_volume_set(player_id, 100) # handle group volume - elif player.is_group_player: - cur_volume = player.volume_level + 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.group_childs: - child_player = self.get_player(child_player_id) + 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 + ( @@ -527,7 +536,7 @@ class PlayerManager: await self.async_cmd_volume_set(child_player_id, new_child_volume) # regular volume command else: - await player.async_cmd_volume_set(volume_level) + await player_state.player.async_cmd_volume_set(volume_level) async def async_cmd_volume_up(self, player_id: str): """ @@ -535,10 +544,10 @@ class PlayerManager: :param player_id: player_id of the player to handle the command. """ - player = self.get_player(player_id) - if not player: + player_state = self.get_player_state(player_id) + if not player_state: return - new_level = player.volume_level + 1 + 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) @@ -549,10 +558,10 @@ class PlayerManager: :param player_id: player_id of the player to handle the command. """ - player = self.get_player(player_id) - if not player: + player_state = self.get_player_state(player_id) + if not player_state: return - new_level = player.volume_level - 1 + 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) @@ -564,11 +573,11 @@ class PlayerManager: :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: + player_state = self.get_player_state(player_id) + if not player_state: return # TODO: handle mute on volumecontrol? - return await player.async_cmd_volume_mute(is_muted) + return await player_state.player.async_cmd_volume_mute(is_muted) # OTHER/HELPER FUNCTIONS diff --git a/music_assistant/stream_manager.py b/music_assistant/managers/streams.py similarity index 97% rename from music_assistant/stream_manager.py rename to music_assistant/managers/streams.py index a10fb739..50f83987 100755 --- a/music_assistant/stream_manager.py +++ b/music_assistant/managers/streams.py @@ -19,8 +19,7 @@ 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 ( +from music_assistant.helpers.util import ( create_tempfile, decrypt_bytes, decrypt_string, @@ -29,6 +28,7 @@ from music_assistant.utils import ( try_parse_int, yield_chunks, ) +from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType LOGGER = logging.getLogger("mass") @@ -217,7 +217,7 @@ class StreamManager: 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) + 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)] @@ -244,11 +244,11 @@ class StreamManager: LOGGER.debug("no (more) tracks left in queue") break # get streamdetails - streamdetails = await self.mass.music_manager.async_get_stream_details( + streamdetails = await self.mass.music.async_get_stream_details( queue_track, player_id ) # get gain correct / replaygain - gain_correct = await self.mass.player_manager.async_get_gain_correct( + gain_correct = await self.mass.players.async_get_gain_correct( player_id, streamdetails.item_id, streamdetails.provider ) LOGGER.debug( @@ -262,7 +262,7 @@ class StreamManager: 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( + async for is_last_chunk, chunk in self.mass.streams.async_get_sox_stream( streamdetails, SoxOutputFormat.S32, resample=sample_rate, @@ -390,18 +390,18 @@ class StreamManager: ) -> AsyncGenerator[bytes, None]: """Stream a single Queue item.""" # collect streamdetails - player_queue = self.mass.player_manager.get_player_queue(player_id) + 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_manager.async_get_stream_details( + streamdetails = await self.mass.music.async_get_stream_details( queue_item, player_id ) # get gain correct / replaygain - gain_correct = await self.mass.player_manager.async_get_gain_correct( + gain_correct = await self.mass.players.async_get_gain_correct( player_id, streamdetails.item_id, streamdetails.provider ) # start streaming @@ -494,7 +494,7 @@ class StreamManager: 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( + self.mass.players.async_get_gain_correct( player_id, streamdetails.item_id, streamdetails.provider ) ).result() diff --git a/music_assistant/mass.py b/music_assistant/mass.py index 8824502b..da1d4118 100644 --- a/music_assistant/mass.py +++ b/music_assistant/mass.py @@ -6,30 +6,30 @@ import importlib import logging import os import threading -from typing import Any, Awaitable, Callable, List, Optional, Union +from typing import Any, Awaitable, Callable, Dict, List, Optional, Union import aiohttp -from music_assistant.cache import Cache -from music_assistant.config import MassConfig from music_assistant.constants import ( CONF_ENABLED, EVENT_PROVIDER_REGISTERED, + EVENT_PROVIDER_UNREGISTERED, EVENT_SHUTDOWN, ) -from music_assistant.database import Database -from music_assistant.metadata import MetaData +from music_assistant.helpers.cache import Cache +from music_assistant.helpers.util import callback, get_ip_pton, is_callback +from music_assistant.managers.config import ConfigManager +from music_assistant.managers.database import DatabaseManager +from music_assistant.managers.metadata import MetaDataManager +from music_assistant.managers.music import MusicManager +from music_assistant.managers.players import PlayerManager +from music_assistant.managers.streams import StreamManager from music_assistant.models.provider import Provider, ProviderType -from music_assistant.music_manager import MusicManager -from music_assistant.player_manager import PlayerManager -from music_assistant.stream_manager import StreamManager -from music_assistant.utils import callback, get_ip_pton, is_callback -from music_assistant.web import Web +from music_assistant.web import WebServer from zeroconf import NonUniqueNameException, ServiceInfo, Zeroconf LOGGER = logging.getLogger("mass") -# pylint: disable=too-many-instance-attributes class MusicAssistant: """Main MusicAssistant object.""" @@ -40,19 +40,20 @@ class MusicAssistant: :param datapath: file location to store the data """ - self.loop = None + self._loop = None self._http_session = None self._event_listeners = [] self._providers = {} - self.config = MassConfig(self, datapath) - # init modules - self.database = Database(self) - self.cache = Cache(self) - self.metadata = MetaData(self) - self.web = Web(self) - self.music_manager = MusicManager(self) - self.player_manager = PlayerManager(self) - self.stream_manager = StreamManager(self) + + # init core managers/controllers + self._config = ConfigManager(self, datapath) + self._database = DatabaseManager(self) + self._cache = Cache(self) + self._metadata = MetaDataManager(self) + self._web = WebServer(self) + self._music = MusicManager(self) + self._players = PlayerManager(self) + self._streams = StreamManager(self) # shared zeroconf instance self.zeroconf = Zeroconf() self._exit = False @@ -60,22 +61,22 @@ class MusicAssistant: async def async_start(self): """Start running the music assistant server.""" # initialize loop - self.loop = asyncio.get_event_loop() - self.loop.set_exception_handler(self.__handle_exception) + self._loop = asyncio.get_event_loop() + self._loop.set_exception_handler(self.__handle_exception) if LOGGER.level == logging.DEBUG: - self.loop.set_debug(True) + self._loop.set_debug(True) # create shared aiohttp ClientSession self._http_session = aiohttp.ClientSession( loop=self.loop, connector=aiohttp.TCPConnector(enable_cleanup_closed=True, ssl=False), ) - await self.database.async_setup() - await self.cache.async_setup() - await self.music_manager.async_setup() - await self.player_manager.async_setup() - await self.async_preload_providers() + await self._database.async_setup() + await self._cache.async_setup() + await self._music.async_setup() + await self._players.async_setup() + await self.__async_preload_providers() await self.__async_setup_discovery() - await self.web.async_setup() + await self._web.async_setup() async def async_stop(self): """Stop running the music assistant server.""" @@ -83,35 +84,97 @@ class MusicAssistant: self.signal_event(EVENT_SHUTDOWN) self._exit = True await self.config.async_close() + await self._web.async_stop() for prov in self._providers.values(): await prov.async_on_stop() - await self.player_manager.async_close() + await self._players.async_close() await self._http_session.connector.close() self._http_session.detach() @property - def http_session(self): + def loop(self) -> asyncio.AbstractEventLoop: + """Return the running event loop.""" + return self._loop + + @property + def players(self) -> PlayerManager: + """Return the Players controller/manager.""" + return self._players + + @property + def music(self) -> MusicManager: + """Return the Music controller/manager.""" + return self._music + + @property + def config(self) -> ConfigManager: + """Return the Configuration controller/manager.""" + return self._config + + @property + def cache(self) -> Cache: + """Return the Cache instance.""" + return self._cache + + @property + def streams(self) -> StreamManager: + """Return the Streams controller/manager.""" + return self._streams + + @property + def database(self) -> DatabaseManager: + """Return the Database controller/manager.""" + return self._database + + @property + def web(self) -> WebServer: + """Return the webserver instance.""" + return self._web + + @property + def http_session(self) -> aiohttp.ClientSession: """Return the default http session.""" return self._http_session - async def async_register_provider(self, provider: Provider): + async def async_register_provider(self, provider: Provider) -> None: """Register a new Provider/Plugin.""" assert provider.id and provider.name - assert provider.id not in self._providers # provider id's must be unique! + if provider.id in self._providers: + LOGGER.debug("Provider %s is already registered.", provider.id) + return provider.mass = self # make sure we have the mass object provider.available = False self._providers[provider.id] = provider - if self.config.providers[provider.id][CONF_ENABLED]: - if await provider.async_on_start(): + if self.config.get_provider_config(provider.id, provider.type)[CONF_ENABLED]: + if await provider.async_on_start() is not False: provider.available = True LOGGER.debug("Provider registered: %s", provider.name) self.signal_event(EVENT_PROVIDER_REGISTERED, provider.id) + else: + LOGGER.debug( + "Provider registered but loading failed: %s", provider.name + ) else: LOGGER.debug("Not loading provider %s as it is disabled", provider.name) - async def register_provider(self, provider: Provider): - """Register a new Provider/Plugin.""" - self.add_job(self.async_register_provider(provider)) + async def async_unregister_provider(self, provider_id: str) -> None: + """Unregister an existing Provider/Plugin.""" + if provider_id in self._providers: + # unload it if it's loaded + await self._providers[provider_id].async_on_stop() + LOGGER.debug("Provider unregistered: %s", provider_id) + self.signal_event(EVENT_PROVIDER_UNREGISTERED, provider_id) + return self._providers.pop(provider_id, None) + + async def async_reload_provider(self, provider_id: str) -> None: + """Reload an existing Provider/Plugin.""" + provider = await self.async_unregister_provider(provider_id) + if provider is not None: + # simply re-register the same provider again + await self.async_register_provider(provider) + else: + # try preloading all providers + self.add_job(self.__async_preload_providers()) @callback def get_provider(self, provider_id: str) -> Provider: @@ -132,47 +195,14 @@ class MusicAssistant: if (filter_type is None or item.type == filter_type) and item.available ] - async def async_preload_providers(self): - """Dynamically load all providermodules.""" - base_dir = os.path.dirname(os.path.abspath(__file__)) - modules_path = os.path.join(base_dir, "providers") - # load modules - for dir_str in os.listdir(modules_path): - dir_path = os.path.join(modules_path, dir_str) - if not os.path.isdir(dir_path): - continue - # get files in directory - for file_str in os.listdir(dir_path): - file_path = os.path.join(dir_path, file_str) - if not os.path.isfile(file_path): - continue - if not file_str == "__init__.py": - continue - module_name = dir_str - if module_name in [i.id for i in self._providers.values()]: - continue - # try to load the module - try: - prov_mod = importlib.import_module( - f".{module_name}", "music_assistant.providers" - ) - await prov_mod.async_setup(self) - # pylint: disable=broad-except - except Exception as exc: - LOGGER.exception("Error preloading module %s: %s", module_name, exc) - else: - LOGGER.debug("Successfully preloaded module %s", module_name) - @callback - def signal_event(self, event_msg: str, event_details: Any = None): + def signal_event(self, event_msg: str, event_details: Any = None) -> None: """ Signal (systemwide) event. :param event_msg: the eventmessage to signal :param event_details: optional details to send with the event. """ - if self._exit: - return for cb_func, event_filter in self._event_listeners: if not event_filter or event_msg in event_filter: self.add_job(cb_func, event_msg, event_details) @@ -201,7 +231,7 @@ class MusicAssistant: @callback def add_job( self, target: Callable[..., Any], *args: Any, **kwargs: Any - ) -> Optional[asyncio.Future]: + ) -> Optional[asyncio.Task]: """Add a job/task to the event loop. target: target to call. @@ -215,7 +245,7 @@ class MusicAssistant: check_target = check_target.func if self._exit: - LOGGER.warning("scheduling job %s while exiting", check_target.__name__) + LOGGER.debug("scheduling job %s while exiting!", check_target.__name__) if threading.current_thread() is not threading.main_thread(): # called from other thread @@ -242,12 +272,13 @@ class MusicAssistant: return task @staticmethod - def __handle_exception(loop, context): + def __handle_exception(loop: asyncio.AbstractEventLoop, context: Dict) -> None: """Global exception handler.""" LOGGER.error("Caught exception: %s", context) - loop.default_exception_handler(context) + if loop.get_debug(): + loop.default_exception_handler(context) - async def __async_setup_discovery(self): + async def __async_setup_discovery(self) -> None: """Make this Music Assistant instance discoverable on the network.""" zeroconf_type = "_music-assistant._tcp.local." discovery_info = self.web.discovery_info @@ -266,3 +297,34 @@ class MusicAssistant: LOGGER.error( "Music Assistant instance with identical name present in the local network" ) + + async def __async_preload_providers(self): + """Dynamically load all providermodules.""" + base_dir = os.path.dirname(os.path.abspath(__file__)) + modules_path = os.path.join(base_dir, "providers") + # load modules + for dir_str in os.listdir(modules_path): + dir_path = os.path.join(modules_path, dir_str) + if not os.path.isdir(dir_path): + continue + # get files in directory + for file_str in os.listdir(dir_path): + file_path = os.path.join(dir_path, file_str) + if not os.path.isfile(file_path): + continue + if not file_str == "__init__.py": + continue + module_name = dir_str + if module_name in [i.id for i in self._providers.values()]: + continue + # try to load the module + try: + prov_mod = importlib.import_module( + f".{module_name}", "music_assistant.providers" + ) + await prov_mod.async_setup(self) + # pylint: disable=broad-except + except Exception as exc: + LOGGER.exception("Error preloading module %s: %s", module_name, exc) + else: + LOGGER.debug("Successfully preloaded module %s", module_name) diff --git a/music_assistant/models/config_entry.py b/music_assistant/models/config_entry.py index d4dd4442..a094c99e 100644 --- a/music_assistant/models/config_entry.py +++ b/music_assistant/models/config_entry.py @@ -4,8 +4,6 @@ from dataclasses import dataclass, field from enum import Enum from typing import Any, List, Tuple -from mashumaro import DataClassDictMixin - class ConfigEntryType(Enum): """Enum for the type of a config entry.""" @@ -16,11 +14,10 @@ class ConfigEntryType(Enum): INT = "integer" FLOAT = "float" LABEL = "label" - HEADER = "header" @dataclass -class ConfigEntry(DataClassDictMixin): +class ConfigEntry: """Model for a Config Entry.""" entry_key: str @@ -28,8 +25,9 @@ class ConfigEntry(DataClassDictMixin): default_value: Any = None values: List[Any] = field(default_factory=list) # select from list of values range: Tuple[Any] = () # select values within range - description_key: str = None # key in the translations file - help_key: str = None # key in the translations file + label: str = "" # a friendly name for the setting + description: str = "" # extended description of the setting. + help_key: str = "" # key in the translations file multi_value: bool = False # allow multiple values from the list depends_on: str = "" # needs to be set before this setting shows up in frontend hidden: bool = False # hide from UI diff --git a/music_assistant/models/media_types.py b/music_assistant/models/media_types.py index 89754db9..ca557bbf 100755 --- a/music_assistant/models/media_types.py +++ b/music_assistant/models/media_types.py @@ -4,8 +4,7 @@ from dataclasses import dataclass, field from enum import Enum from typing import Any, List -from mashumaro import DataClassDictMixin -from music_assistant.utils import CustomIntEnum +from music_assistant.helpers.util import CustomIntEnum class MediaType(CustomIntEnum): @@ -49,7 +48,7 @@ class TrackQuality(CustomIntEnum): @dataclass -class MediaItemProviderId(DataClassDictMixin): +class MediaItemProviderId: """Model for a MediaItem's provider id.""" provider: str @@ -67,7 +66,7 @@ class ExternalId(Enum): @dataclass -class MediaItem(DataClassDictMixin): +class MediaItem: """Representation of a media item.""" item_id: str = "" @@ -134,7 +133,7 @@ class Radio(MediaItem): @dataclass -class SearchResult(DataClassDictMixin): +class SearchResult: """Model for Media Item Search result.""" artists: List[Artist] = field(default_factory=list) diff --git a/music_assistant/models/musicprovider.py b/music_assistant/models/musicprovider.py deleted file mode 100755 index fd3f059a..00000000 --- a/music_assistant/models/musicprovider.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Model and helpers for Music Providers.""" - -from typing import List, Optional - -from music_assistant.models.media_types import ( - Album, - Artist, - MediaType, - Playlist, - Radio, - SearchResult, - Track, -) -from music_assistant.models.provider import Provider, ProviderType -from music_assistant.models.streamdetails import StreamDetails - - -class MusicProvider(Provider): - """ - Base class for a Musicprovider. - - Should be overriden in the provider specific implementation. - """ - - @property - def type(self) -> ProviderType: - """Return ProviderType.""" - return ProviderType.MUSIC_PROVIDER - - @property - def supported_mediatypes(self) -> List[MediaType]: - """Return MediaTypes the provider supports.""" - return [ - MediaType.Album, - MediaType.Artist, - MediaType.Playlist, - MediaType.Radio, - MediaType.Track, - ] - - async def async_search( - self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 - ) -> SearchResult: - """ - Perform search on musicprovider. - - :param search_query: Search query. - :param media_types: A list of media_types to include. All types if None. - :param limit: Number of items to return in the search (per type). - """ - raise NotImplementedError - - async def async_get_library_artists(self) -> List[Artist]: - """Retrieve library artists from the provider.""" - if MediaType.Artist in self.supported_mediatypes: - raise NotImplementedError - - async def async_get_library_albums(self) -> List[Album]: - """Retrieve library albums from the provider.""" - if MediaType.Album in self.supported_mediatypes: - raise NotImplementedError - - async def async_get_library_tracks(self) -> List[Track]: - """Retrieve library tracks from the provider.""" - if MediaType.Track in self.supported_mediatypes: - raise NotImplementedError - - async def async_get_library_playlists(self) -> List[Playlist]: - """Retrieve library/subscribed playlists from the provider.""" - if MediaType.Playlist in self.supported_mediatypes: - raise NotImplementedError - - async def async_get_radios(self) -> List[Radio]: - """Retrieve library/subscribed radio stations from the provider.""" - if MediaType.Radio in self.supported_mediatypes: - raise NotImplementedError - - async def async_get_artist(self, prov_artist_id: str) -> Artist: - """Get full artist details by id.""" - if MediaType.Artist in self.supported_mediatypes: - raise NotImplementedError - - async def async_get_artist_albums(self, prov_artist_id: str) -> List[Album]: - """Get a list of all albums for the given artist.""" - if MediaType.Album in self.supported_mediatypes: - raise NotImplementedError - - async def async_get_artist_toptracks(self, prov_artist_id: str) -> List[Track]: - """Get a list of most popular tracks for the given artist.""" - if MediaType.Track in self.supported_mediatypes: - raise NotImplementedError - - async def async_get_album(self, prov_album_id: str) -> Album: - """Get full album details by id.""" - if MediaType.Album in self.supported_mediatypes: - raise NotImplementedError - - async def async_get_track(self, prov_track_id: str) -> Track: - """Get full track details by id.""" - if MediaType.Track in self.supported_mediatypes: - raise NotImplementedError - - async def async_get_playlist(self, prov_playlist_id: str) -> Playlist: - """Get full playlist details by id.""" - if MediaType.Playlist in self.supported_mediatypes: - raise NotImplementedError - - async def async_get_radio(self, prov_radio_id: str) -> Radio: - """Get full radio details by id.""" - if MediaType.Radio in self.supported_mediatypes: - raise NotImplementedError - - async def async_get_album_tracks(self, prov_album_id: str) -> List[Track]: - """Get album tracks for given album id.""" - if MediaType.Album in self.supported_mediatypes: - raise NotImplementedError - - async def async_get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]: - """Get all playlist tracks for given playlist id.""" - if MediaType.Playlist in self.supported_mediatypes: - raise NotImplementedError - - async def async_library_add(self, prov_item_id: str, media_type: MediaType) -> bool: - """Add item to provider's library. Return true on succes.""" - raise NotImplementedError - - async def async_library_remove( - self, prov_item_id: str, media_type: MediaType - ) -> bool: - """Remove item from provider's library. Return true on succes.""" - raise NotImplementedError - - async def async_add_playlist_tracks( - self, prov_playlist_id: str, prov_track_ids: List[str] - ) -> bool: - """Add track(s) to playlist. Return true on succes.""" - if MediaType.Playlist in self.supported_mediatypes: - raise NotImplementedError - - async def async_remove_playlist_tracks( - self, prov_playlist_id: str, prov_track_ids: List[str] - ) -> bool: - """Remove track(s) from playlist. Return true on succes.""" - if MediaType.Playlist in self.supported_mediatypes: - raise NotImplementedError - - async def async_get_stream_details(self, item_id: str) -> StreamDetails: - """Get streamdetails for a track/radio.""" - raise NotImplementedError diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index 607ac65a..8de28b83 100755 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -5,11 +5,10 @@ from dataclasses import dataclass from enum import Enum from typing import Any, List, Optional -from mashumaro import DataClassDictMixin from music_assistant.constants import EVENT_SET_PLAYER_CONTROL_STATE from music_assistant.helpers.typing import MusicAssistantType, QueueItems +from music_assistant.helpers.util import CustomIntEnum, callback from music_assistant.models.config_entry import ConfigEntry -from music_assistant.utils import CustomIntEnum, callback class PlaybackState(Enum): @@ -22,7 +21,7 @@ class PlaybackState(Enum): @dataclass -class DeviceInfo(DataClassDictMixin): +class DeviceInfo: """Model for a player's deviceinfo.""" model: str = "" @@ -267,7 +266,7 @@ class Player: @callback def update_state(self) -> None: """Call to store current player state in the player manager.""" - self.mass.add_job(self.mass.player_manager.async_update_player(self)) + self.mass.add_job(self.mass.players.async_update_player(self)) class PlayerControlType(CustomIntEnum): diff --git a/music_assistant/models/player_queue.py b/music_assistant/models/player_queue.py index 0372da58..4c277258 100755 --- a/music_assistant/models/player_queue.py +++ b/music_assistant/models/player_queue.py @@ -19,10 +19,10 @@ from music_assistant.helpers.typing import ( OptionalStr, PlayerType, ) +from music_assistant.helpers.util import callback from music_assistant.models.media_types import Track from music_assistant.models.player import PlaybackState, PlayerFeature from music_assistant.models.streamdetails import StreamDetails -from music_assistant.utils import callback # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-public-methods @@ -85,7 +85,12 @@ class PlayerQueue: @property def player(self) -> PlayerType: """Return handle to player.""" - return self.mass.player_manager.get_player(self._player_id) + return self.mass.players.get_player(self._player_id) + + @property + def player_state(self) -> PlayerType: + """Return handle to player state.""" + return self.mass.players.get_player_state(self._player_id) @property def player_id(self) -> str: @@ -274,7 +279,7 @@ class PlayerQueue: return if self.use_queue_stream: return await self.async_play_index(self.cur_index - 1) - return await self.mass.player_manager.async_cmd_previous(self.player_id) + return await self.mass.players.async_cmd_previous(self.player_id) async def async_resume(self) -> None: """Resume previous queue.""" @@ -449,7 +454,7 @@ class PlayerQueue: async def async_clear(self) -> None: """Clear all items in the queue.""" - await self.mass.player_manager.async_cmd_stop(self.player_id) + await self.mass.players.async_cmd_stop(self.player_id) self._items = [] if self.supports_queue: # send queue cmd to player's own implementation diff --git a/music_assistant/models/player_state.py b/music_assistant/models/player_state.py index 46517d0a..3b0fe8d0 100755 --- a/music_assistant/models/player_state.py +++ b/music_assistant/models/player_state.py @@ -12,6 +12,24 @@ from datetime import datetime from typing import List, Optional from music_assistant.constants import ( + ATTR_ACTIVE_QUEUE, + ATTR_AVAILABLE, + ATTR_CURRENT_URI, + ATTR_DEVICE_INFO, + ATTR_ELAPSED_TIME, + ATTR_FEATURES, + ATTR_GROUP_CHILDS, + ATTR_GROUP_PARENTS, + ATTR_IS_GROUP_PLAYER, + ATTR_MUTED, + ATTR_NAME, + ATTR_PLAYER_ID, + ATTR_POWERED, + ATTR_PROVIDER_ID, + ATTR_SHOULD_POLL, + ATTR_STATE, + ATTR_UPDATED_AT, + ATTR_VOLUME_LEVEL, CONF_ENABLED, CONF_GROUP_DELAY, CONF_NAME, @@ -20,6 +38,7 @@ from music_assistant.constants import ( EVENT_PLAYER_CHANGED, ) from music_assistant.helpers.typing import MusicAssistantType +from music_assistant.helpers.util import callback from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType from music_assistant.models.player import ( DeviceInfo, @@ -28,30 +47,9 @@ from music_assistant.models.player import ( PlayerControlType, PlayerFeature, ) -from music_assistant.utils import callback LOGGER = logging.getLogger("mass") -ATTR_PLAYER_ID = "player_id" -ATTR_PROVIDER_ID = "provider_id" -ATTR_NAME = "name" -ATTR_POWERED = "powered" -ATTR_ELAPSED_TIME = "elapsed_time" -ATTR_STATE = "state" -ATTR_AVAILABLE = "available" -ATTR_CURRENT_URI = "current_uri" -ATTR_VOLUME_LEVEL = "volume_level" -ATTR_MUTED = "muted" -ATTR_IS_GROUP_PLAYER = "is_group_player" -ATTR_GROUP_CHILDS = "group_childs" -ATTR_DEVICE_INFO = "device_info" -ATTR_SHOULD_POLL = "should_poll" -ATTR_FEATURES = "features" -ATTR_CONFIG_ENTRIES = "config_entries" -ATTR_UPDATED_AT = "updated_at" -ATTR_ACTIVE_QUEUE = "active_queue" -ATTR_GROUP_PARENTS = "group_parents" - # list of Player attributes that can/will cause a player changed event UPDATE_ATTRIBUTES = [ @@ -66,6 +64,7 @@ UPDATE_ATTRIBUTES = [ ATTR_GROUP_CHILDS, ATTR_DEVICE_INFO, ATTR_FEATURES, + ATTR_SHOULD_POLL, ] @@ -242,13 +241,11 @@ class PlayerState: if ATTR_GROUP_CHILDS in changed_keys: for child_player_id in self.group_childs: self.mass.add_job( - self.mass.player_manager.async_trigger_player_update( - child_player_id - ) + self.mass.players.async_trigger_player_update(child_player_id) ) # always update the player queue - player_queue = self.mass.player_manager.get_player_queue(self.active_queue) + player_queue = self.mass.players.get_player_queue(self.active_queue) if player_queue: self.mass.add_job(player_queue.async_update_state()) @@ -265,7 +262,7 @@ class PlayerState: return False player_config = self.mass.config.player_settings[self.player_id] if player_config.get(CONF_POWER_CONTROL): - control = self.mass.player_manager.get_player_control( + control = self.mass.players.get_player_control( player_config[CONF_POWER_CONTROL] ) if control: @@ -277,7 +274,7 @@ class PlayerState: """Return final/calculated player's playback state.""" if self.powered and self.active_queue != self.player_id: # use group state - return self.mass.player_manager.get_player(self.active_queue).state + return self.mass.players.get_player_state(self.active_queue).state if state == PlaybackState.Stopped and not self.powered: return PlaybackState.Off return state @@ -297,7 +294,7 @@ class PlayerState: return 0 player_config = self.mass.config.player_settings[self.player_id] if player_config.get(CONF_VOLUME_CONTROL): - control = self.mass.player_manager.get_player_control( + control = self.mass.players.get_player_control( player_config[CONF_VOLUME_CONTROL] ) if control: @@ -307,7 +304,7 @@ class PlayerState: group_volume = 0 active_players = 0 for child_player_id in self.group_childs: - child_player = self.mass.player_manager.get_player(child_player_id) + child_player = self.mass.players.get_player_state(child_player_id) if child_player and child_player.available and child_player.powered: group_volume += child_player.volume_level active_players += 1 @@ -327,7 +324,7 @@ class PlayerState: if self.is_group_player: return [] result = [] - for player in self.mass.player_manager.players: + for player in self.mass.players.player_states: if not player.is_group_player: continue if self.player_id not in player.group_childs: @@ -346,7 +343,7 @@ class PlayerState: # if a group is powered on, all of it's childs will have/use # the parent's player's queue. for group_player_id in self.group_parents: - group_player = self.mass.player_manager.get_player(group_player_id) + group_player = self.mass.players.get_player_state(group_player_id) if group_player and group_player.powered: return group_player_id return self.player_id @@ -362,9 +359,7 @@ class PlayerState: entries = [] entries += self.player.config_entries # append power control config entries - power_controls = self.mass.player_manager.get_player_controls( - PlayerControlType.POWER - ) + power_controls = self.mass.players.get_player_controls(PlayerControlType.POWER) if power_controls: controls = [ {"text": f"{item.provider}: {item.name}", "value": item.control_id} @@ -374,12 +369,12 @@ class PlayerState: ConfigEntry( entry_key=CONF_POWER_CONTROL, entry_type=ConfigEntryType.STRING, - description_key=CONF_POWER_CONTROL, + description=CONF_POWER_CONTROL, values=controls, ) ) # append volume control config entries - volume_controls = self.mass.player_manager.get_player_controls( + volume_controls = self.mass.players.get_player_controls( PlayerControlType.VOLUME ) if volume_controls: @@ -391,13 +386,13 @@ class PlayerState: ConfigEntry( entry_key=CONF_VOLUME_CONTROL, entry_type=ConfigEntryType.STRING, - description_key=CONF_VOLUME_CONTROL, + description=CONF_VOLUME_CONTROL, values=controls, ) ) # append group player entries for parent_id in self.group_parents: - parent_player = self.mass.player_manager.get_player(parent_id) + parent_player = self.mass.players.get_player_state(parent_id) if parent_player and parent_player.provider_id == "group_player": entries.append( ConfigEntry( @@ -405,13 +400,13 @@ class PlayerState: entry_type=ConfigEntryType.INT, default_value=0, range=(0, 500), - description_key=CONF_GROUP_DELAY, + description=CONF_GROUP_DELAY, ) ) break return entries - @callback + # @callback def to_dict(self): """Instance attributes as dict so it can be serialized to json.""" return { @@ -427,109 +422,9 @@ class PlayerState: ATTR_MUTED: self.muted, ATTR_IS_GROUP_PLAYER: self.is_group_player, ATTR_GROUP_CHILDS: self.group_childs, - ATTR_DEVICE_INFO: self.device_info.to_dict(), - ATTR_UPDATED_AT: self.updated_at.isoformat(), + ATTR_DEVICE_INFO: self.device_info, + ATTR_UPDATED_AT: self.updated_at, ATTR_GROUP_PARENTS: self.group_parents, ATTR_FEATURES: self.features, ATTR_ACTIVE_QUEUE: self.active_queue, } - - async def async_cmd_play_uri(self, uri: str) -> None: - """ - Play the specified uri/url on the player. - - :param uri: uri/url to send to the player. - """ - return await self.player.async_cmd_play_uri(uri) - - async def async_cmd_stop(self) -> None: - """Send STOP command to player.""" - return await self.player.async_cmd_stop() - - async def async_cmd_play(self) -> None: - """Send PLAY command to player.""" - return await self.player.async_cmd_play() - - async def async_cmd_pause(self) -> None: - """Send PAUSE command to player.""" - return await self.player.async_cmd_pause() - - async def async_cmd_next(self) -> None: - """Send NEXT TRACK command to player.""" - return await self.player.async_cmd_next() - - async def async_cmd_previous(self) -> None: - """Send PREVIOUS TRACK command to player.""" - return await self.player.async_cmd_previous() - - async def async_cmd_power_on(self) -> None: - """Send POWER ON command to player.""" - return await self.player.async_cmd_power_on() - - async def async_cmd_power_off(self) -> None: - """Send POWER OFF command to player.""" - return await self.player.async_cmd_power_off() - - async def async_cmd_volume_set(self, volume_level: int) -> None: - """ - Send volume level command to player. - - :param volume_level: volume level to set (0..100). - """ - return await self.player.async_cmd_volume_set(volume_level) - - async def async_cmd_volume_mute(self, is_muted: bool = False) -> None: - """ - Send volume MUTE command to given player. - - :param is_muted: bool with new mute state. - """ - return await self.player.async_cmd_volume_mute(is_muted) - - # OPTIONAL: QUEUE SERVICE CALLS/COMMANDS - OVERRIDE ONLY IF SUPPORTED BY PROVIDER - - async def async_cmd_queue_play_index(self, index: int) -> None: - """ - Play item at index X on player's queue. - - :param index: (int) index of the queue item that should start playing - """ - return await self.player.async_cmd_queue_play_index(index) - - async def async_cmd_queue_load(self, queue_items) -> None: - """ - Load/overwrite given items in the player's queue implementation. - - :param queue_items: a list of QueueItems - """ - return await self.player.async_cmd_queue_load(queue_items) - - async def async_cmd_queue_insert(self, queue_items, insert_at_index: int) -> None: - """ - Insert new items at position X into existing queue. - - If insert_at_index 0 or None, will start playing newly added item(s) - :param queue_items: a list of QueueItems - :param insert_at_index: queue position to insert new items - """ - return await self.player.async_cmd_queue_insert(queue_items, insert_at_index) - - async def async_cmd_queue_append(self, queue_items) -> None: - """ - Append new items at the end of the queue. - - :param queue_items: a list of QueueItems - """ - return await self.player.async_cmd_queue_append(queue_items) - - async def async_cmd_queue_update(self, queue_items) -> None: - """ - Overwrite the existing items in the queue, used for reordering. - - :param queue_items: a list of QueueItems - """ - return await self.player.async_cmd_queue_update(queue_items) - - async def async_cmd_queue_clear(self) -> None: - """Clear the player's queue.""" - return await self.player.async_cmd_queue_clear() diff --git a/music_assistant/models/playerprovider.py b/music_assistant/models/playerprovider.py deleted file mode 100755 index 7ed6c1f9..00000000 --- a/music_assistant/models/playerprovider.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Models and helpers for a player provider.""" - -from music_assistant.helpers.typing import Players -from music_assistant.models.provider import Provider, ProviderType - - -class PlayerProvider(Provider): - """ - Base class for a Playerprovider. - - Should be overridden/subclassed by provider specific implementation. - """ - - @property - def type(self) -> ProviderType: - """Return ProviderType.""" - return ProviderType.PLAYER_PROVIDER - - @property - def players(self) -> Players: - """Return all players belonging to this provider.""" - # pylint: disable=no-member - return [ - player - for player in self.mass.player_manager.players - if player.provider_id == self.id - ] diff --git a/music_assistant/models/provider.py b/music_assistant/models/provider.py index 47510742..95e29e3b 100644 --- a/music_assistant/models/provider.py +++ b/music_assistant/models/provider.py @@ -1,11 +1,21 @@ -"""Generic Models and helpers for providers/plugins.""" +"""Models for providers/plugins.""" from abc import abstractmethod from enum import Enum -from typing import List +from typing import Dict, List, Optional -from music_assistant.helpers.typing import MusicAssistantType +from music_assistant.helpers.typing import MusicAssistantType, Players from music_assistant.models.config_entry import ConfigEntry +from music_assistant.models.media_types import ( + Album, + Artist, + MediaType, + Playlist, + Radio, + SearchResult, + Track, +) +from music_assistant.models.streamdetails import StreamDetails class ProviderType(Enum): @@ -13,7 +23,8 @@ class ProviderType(Enum): MUSIC_PROVIDER = "music_provider" PLAYER_PROVIDER = "player_provider" - GENERIC = "generic" + METADATA_PROVIDER = "metadata_provider" + PLUGIN = "plugin" class Provider: @@ -28,7 +39,6 @@ class Provider: @abstractmethod def type(self) -> ProviderType: """Return ProviderType.""" - return ProviderType.GENERIC @property @abstractmethod @@ -56,9 +66,195 @@ class Provider: @abstractmethod async def async_on_stop(self) -> None: - """Handle correct close/cleanup of the provider on exit. Called on shutdown.""" + """Handle correct close/cleanup of the provider on exit. Called on shutdown/reload.""" - async def async_on_reload(self) -> None: - """Handle configuration changes for this provider. Called on reload.""" - await self.async_on_stop() - await self.async_on_start() + +class Plugin(Provider): + """ + Base class for a Plugin. + + Should be overridden/subclassed by provider specific implementation. + """ + + @property + def type(self) -> ProviderType: + """Return ProviderType.""" + return ProviderType.PLUGIN + + +class PlayerProvider(Provider): + """ + Base class for a Playerprovider. + + Should be overridden/subclassed by provider specific implementation. + """ + + @property + def type(self) -> ProviderType: + """Return ProviderType.""" + return ProviderType.PLAYER_PROVIDER + + @property + def players(self) -> Players: + """Return all players belonging to this provider.""" + # pylint: disable=no-member + return [ + player + for player in self.mass.players.players + if player.provider_id == self.id + ] + + +class MetadataProvider(Provider): + """ + Base class for a MetadataProvider. + + Should be overridden/subclassed by provider specific implementation. + """ + + @property + def type(self) -> ProviderType: + """Return ProviderType.""" + return ProviderType.METADATA_PROVIDER + + async def async_get_artist_images(self, mb_artist_id: str) -> Dict: + """Retrieve artist metadata as dict by musicbrainz artist id.""" + raise NotImplementedError + + async def async_get_album_images(self, mb_album_id: str) -> Dict: + """Retrieve album metadata as dict by musicbrainz album id.""" + raise NotImplementedError + + +class MusicProvider(Provider): + """ + Base class for a Musicprovider. + + Should be overriden in the provider specific implementation. + """ + + @property + def type(self) -> ProviderType: + """Return ProviderType.""" + return ProviderType.MUSIC_PROVIDER + + @property + def supported_mediatypes(self) -> List[MediaType]: + """Return MediaTypes the provider supports.""" + return [ + MediaType.Album, + MediaType.Artist, + MediaType.Playlist, + MediaType.Radio, + MediaType.Track, + ] + + async def async_search( + self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 + ) -> SearchResult: + """ + Perform search on musicprovider. + + :param search_query: Search query. + :param media_types: A list of media_types to include. All types if None. + :param limit: Number of items to return in the search (per type). + """ + raise NotImplementedError + + async def async_get_library_artists(self) -> List[Artist]: + """Retrieve library artists from the provider.""" + if MediaType.Artist in self.supported_mediatypes: + raise NotImplementedError + + async def async_get_library_albums(self) -> List[Album]: + """Retrieve library albums from the provider.""" + if MediaType.Album in self.supported_mediatypes: + raise NotImplementedError + + async def async_get_library_tracks(self) -> List[Track]: + """Retrieve library tracks from the provider.""" + if MediaType.Track in self.supported_mediatypes: + raise NotImplementedError + + async def async_get_library_playlists(self) -> List[Playlist]: + """Retrieve library/subscribed playlists from the provider.""" + if MediaType.Playlist in self.supported_mediatypes: + raise NotImplementedError + + async def async_get_radios(self) -> List[Radio]: + """Retrieve library/subscribed radio stations from the provider.""" + if MediaType.Radio in self.supported_mediatypes: + raise NotImplementedError + + async def async_get_artist(self, prov_artist_id: str) -> Artist: + """Get full artist details by id.""" + if MediaType.Artist in self.supported_mediatypes: + raise NotImplementedError + + async def async_get_artist_albums(self, prov_artist_id: str) -> List[Album]: + """Get a list of all albums for the given artist.""" + if MediaType.Album in self.supported_mediatypes: + raise NotImplementedError + + async def async_get_artist_toptracks(self, prov_artist_id: str) -> List[Track]: + """Get a list of most popular tracks for the given artist.""" + if MediaType.Track in self.supported_mediatypes: + raise NotImplementedError + + async def async_get_album(self, prov_album_id: str) -> Album: + """Get full album details by id.""" + if MediaType.Album in self.supported_mediatypes: + raise NotImplementedError + + async def async_get_track(self, prov_track_id: str) -> Track: + """Get full track details by id.""" + if MediaType.Track in self.supported_mediatypes: + raise NotImplementedError + + async def async_get_playlist(self, prov_playlist_id: str) -> Playlist: + """Get full playlist details by id.""" + if MediaType.Playlist in self.supported_mediatypes: + raise NotImplementedError + + async def async_get_radio(self, prov_radio_id: str) -> Radio: + """Get full radio details by id.""" + if MediaType.Radio in self.supported_mediatypes: + raise NotImplementedError + + async def async_get_album_tracks(self, prov_album_id: str) -> List[Track]: + """Get album tracks for given album id.""" + if MediaType.Album in self.supported_mediatypes: + raise NotImplementedError + + async def async_get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]: + """Get all playlist tracks for given playlist id.""" + if MediaType.Playlist in self.supported_mediatypes: + raise NotImplementedError + + async def async_library_add(self, prov_item_id: str, media_type: MediaType) -> bool: + """Add item to provider's library. Return true on succes.""" + raise NotImplementedError + + async def async_library_remove( + self, prov_item_id: str, media_type: MediaType + ) -> bool: + """Remove item from provider's library. Return true on succes.""" + raise NotImplementedError + + async def async_add_playlist_tracks( + self, prov_playlist_id: str, prov_track_ids: List[str] + ) -> bool: + """Add track(s) to playlist. Return true on succes.""" + if MediaType.Playlist in self.supported_mediatypes: + raise NotImplementedError + + async def async_remove_playlist_tracks( + self, prov_playlist_id: str, prov_track_ids: List[str] + ) -> bool: + """Remove track(s) from playlist. Return true on succes.""" + if MediaType.Playlist in self.supported_mediatypes: + raise NotImplementedError + + async def async_get_stream_details(self, item_id: str) -> StreamDetails: + """Get streamdetails for a track/radio.""" + raise NotImplementedError diff --git a/music_assistant/models/streamdetails.py b/music_assistant/models/streamdetails.py index 5a5e3cbf..e3313f9f 100644 --- a/music_assistant/models/streamdetails.py +++ b/music_assistant/models/streamdetails.py @@ -4,8 +4,6 @@ from dataclasses import dataclass from enum import Enum from typing import Any -from mashumaro import DataClassDictMixin - class StreamType(Enum): """Enum with stream types.""" @@ -28,7 +26,7 @@ class ContentType(Enum): @dataclass -class StreamDetails(DataClassDictMixin): +class StreamDetails: """Model for streamdetails.""" type: StreamType diff --git a/music_assistant/providers/demo/demo_playerprovider.py b/music_assistant/providers/builtin/__init__.py similarity index 87% rename from music_assistant/providers/demo/demo_playerprovider.py rename to music_assistant/providers/builtin/__init__.py index 6ed6b728..98f2fd99 100644 --- a/music_assistant/providers/demo/demo_playerprovider.py +++ b/music_assistant/providers/builtin/__init__.py @@ -1,4 +1,4 @@ -"""Demo/test providers.""" +"""Local player provider.""" import asyncio import logging import signal @@ -7,15 +7,21 @@ 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 +from music_assistant.models.provider import PlayerProvider -PROV_ID = "demo_player" -PROV_NAME = "Demo/Test players" +PROV_ID = "builtin" +PROV_NAME = "Built-in (local) player" LOGGER = logging.getLogger(PROV_ID) -class DemoPlayerProvider(PlayerProvider): - """Demo PlayerProvider which provides fake players.""" +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: @@ -34,11 +40,8 @@ class DemoPlayerProvider(PlayerProvider): 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)) + 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): @@ -47,11 +50,11 @@ class DemoPlayerProvider(PlayerProvider): await player.async_cmd_stop() -class DemoPlayer(Player): - """Representation of a player for the demo provider.""" +class BuiltinPlayer(Player): + """Representation of a BuiltinPlayer.""" def __init__(self, player_id: str, name: str) -> None: - """Initialize a demo player.""" + """Initialize the built-in player.""" self._player_id = player_id self._name = name self._powered = False @@ -85,7 +88,7 @@ class DemoPlayer(Player): @property def elapsed_time(self) -> float: - """Return elapsed_time of current playing uri in (fractions of) seconds.""" + """Return elapsed_time of current playing uri in seconds.""" return self._elapsed_time @property diff --git a/music_assistant/providers/builtin/icon.png b/music_assistant/providers/builtin/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..092121e1e21e1d799b1de9ef58ca6914c4e14096 GIT binary patch literal 15778 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 literal 0 HcmV?d00001 diff --git a/music_assistant/providers/chromecast/player.py b/music_assistant/providers/chromecast/player.py index 0b2a3795..a0854d76 100644 --- a/music_assistant/providers/chromecast/player.py +++ b/music_assistant/providers/chromecast/player.py @@ -6,6 +6,7 @@ from typing import List, Optional import pychromecast from music_assistant.helpers.typing import MusicAssistantType +from music_assistant.helpers.util import compare_strings, yield_chunks from music_assistant.models.config_entry import ConfigEntry from music_assistant.models.player import ( DeviceInfo, @@ -14,7 +15,6 @@ from music_assistant.models.player import ( PlayerFeature, ) from music_assistant.models.player_queue import QueueItem -from music_assistant.utils import compare_strings, yield_chunks from pychromecast.controllers.multizone import MultizoneController from pychromecast.socket_client import ( CONNECTION_STATUS_CONNECTED, @@ -300,7 +300,7 @@ class ChromecastPlayer(Player): async def async_on_update(self) -> None: """Call when player is periodically polled by the player manager (should_poll=True).""" - if self.mass.player_manager.get_player(self.player_id).active_queue.startswith( + if self.mass.players.get_player_state(self.player_id).active_queue.startswith( "group_player" ): self.mass.add_job(self._chromecast.media_controller.update_status) @@ -360,7 +360,7 @@ class ChromecastPlayer(Player): async def async_cmd_play_uri(self, uri: str) -> None: """Play single uri on player.""" - player_queue = self.mass.player_manager.get_player_queue(self.player_id) + player_queue = self.mass.players.get_player_queue(self.player_id) if player_queue.use_queue_stream: # create CC queue so that skip and previous will work queue_item = QueueItem() @@ -371,7 +371,7 @@ class ChromecastPlayer(Player): async def async_cmd_queue_load(self, queue_items: List[QueueItem]) -> None: """Load (overwrite) queue with new items.""" - player_queue = self.mass.player_manager.get_player_queue(self.player_id) + player_queue = self.mass.players.get_player_queue(self.player_id) cc_queue_items = self.__create_queue_items(queue_items[:50]) repeat_enabled = player_queue.use_queue_stream or player_queue.repeat_enabled queuedata = { @@ -407,7 +407,7 @@ class ChromecastPlayer(Player): def __create_queue_item(self, track): """Create CC queue item from track info.""" - player_queue = self.mass.player_manager.get_player_queue(self.player_id) + player_queue = self.mass.players.get_player_queue(self.player_id) return { "opt_itemId": track.queue_item_id, "autoplay": True, diff --git a/music_assistant/providers/demo/__init__.py b/music_assistant/providers/demo/__init__.py deleted file mode 100644 index 09c851c7..00000000 --- a/music_assistant/providers/demo/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Demo/test providers.""" - -from .demo_playerprovider import DemoPlayerProvider - - -async def async_setup(mass): - """Perform async setup of this Plugin/Provider.""" - prov = DemoPlayerProvider() - await mass.async_register_provider(prov) diff --git a/music_assistant/providers/demo/demo_musicprovider.py b/music_assistant/providers/demo/demo_musicprovider.py deleted file mode 100644 index dfee4949..00000000 --- a/music_assistant/providers/demo/demo_musicprovider.py +++ /dev/null @@ -1 +0,0 @@ -"""Demo music provider.""" diff --git a/music_assistant/providers/fanarttv/__init__.py b/music_assistant/providers/fanarttv/__init__.py new file mode 100755 index 00000000..fb43e7aa --- /dev/null +++ b/music_assistant/providers/fanarttv/__init__.py @@ -0,0 +1,108 @@ +"""FanartTv Metadata provider.""" + +import logging +from typing import Dict, List + +import aiohttp +import orjson +from asyncio_throttle import Throttler +from music_assistant.models.config_entry import ConfigEntry +from music_assistant.models.provider import MetadataProvider + +# TODO: add support for personal api keys ? +# TODO: Add support for album artwork ? + +PROV_ID = "fanarttv" +PROV_NAME = "Fanart.tv" + +LOGGER = logging.getLogger(PROV_ID) + +CONFIG_ENTRIES = [] + + +async def async_setup(mass) -> None: + """Perform async setup of this Plugin/Provider.""" + prov = FanartTvProvider(mass) + await mass.async_register_provider(prov) + + +class FanartTvProvider(MetadataProvider): + """Fanart.tv metadata provider.""" + + def __init__(self, mass): + """Initialize class.""" + self.mass = mass + self.throttler = Throttler(rate_limit=1, period=2) + + async def async_on_start(self) -> bool: + """ + Handle initialization of the provider based on config. + + Return bool if start was succesfull. Called on startup. + """ + return True # we have nothing to initialize + + @property + def id(self) -> str: + """Return provider ID for this provider.""" + return PROV_ID + + @property + def name(self) -> str: + """Return provider Name for this provider.""" + return PROV_NAME + + @property + def config_entries(self) -> List[ConfigEntry]: + """Return Config Entries for this provider.""" + return CONFIG_ENTRIES + + async def async_get_artist_images(self, mb_artist_id: str) -> Dict: + """Retrieve images by musicbrainz artist id.""" + metadata = {} + data = await self.__async_get_data("music/%s" % mb_artist_id) + if data: + if data.get("hdmusiclogo"): + metadata["logo"] = data["hdmusiclogo"][0]["url"] + elif data.get("musiclogo"): + metadata["logo"] = data["musiclogo"][0]["url"] + if data.get("artistbackground"): + count = 0 + for item in data["artistbackground"]: + key = "fanart" if count == 0 else "fanart.%s" % count + metadata[key] = item["url"] + if data.get("artistthumb"): + url = data["artistthumb"][0]["url"] + if "2a96cbd8b46e442fc41c2b86b821562f" not in url: + metadata["image"] = url + if data.get("musicbanner"): + metadata["banner"] = data["musicbanner"][0]["url"] + return metadata + + async def __async_get_data(self, endpoint, params=None): + """Get data from api.""" + if params is None: + params = {} + url = "http://webservice.fanart.tv/v3/%s" % endpoint + params["api_key"] = "639191cb0774661597f28a47e7e2bad5" + async with self.throttler: + async with self.mass.http_session.get( + url, params=params, verify_ssl=False + ) as response: + try: + result = await response.json(loads=orjson.loads) + except ( + aiohttp.client_exceptions.ContentTypeError, + orjson.decoder.JSONDecodeError, + ): + LOGGER.error("Failed to retrieve %s", endpoint) + text_result = await response.text() + LOGGER.debug(text_result) + return None + except aiohttp.client_exceptions.ClientConnectorError: + LOGGER.error("Failed to retrieve %s", endpoint) + return None + if "error" in result and "limit" in result["error"]: + LOGGER.error(result["error"]) + return None + return result diff --git a/music_assistant/providers/fanarttv/icon.png b/music_assistant/providers/fanarttv/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..17b39a4c7ea43a69790c4a2e1ea16e08c3064b9c GIT binary patch literal 5877 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 bool: """Handle initialization of the provider based on config.""" - conf = self.mass.config.providers[PROV_ID] + conf = self.mass.config.player_providers[PROV_ID] for index in range(conf[CONF_PLAYER_COUNT]): player = GroupPlayer(self.mass, index) - self.mass.add_job(self.mass.player_manager.async_add_player(player)) + self.mass.add_job(self.mass.players.async_add_player(player)) return True async def async_on_stop(self): @@ -137,7 +137,7 @@ class GroupPlayer(Player): """Return elapsed timefor first child player.""" if self.state in [PlaybackState.Playing, PlaybackState.Paused]: for player_id in self.group_childs: - player = self.mass.player_manager.get_player(player_id) + player = self.mass.players.get_player(player_id) if player: return player.elapsed_time return 0 @@ -173,18 +173,19 @@ class GroupPlayer(Player): """Return config entries for this group player.""" all_players = [ {"text": item.name, "value": item.player_id} - for item in self.mass.player_manager.players + for item in self.mass.players.player_states if item.player_id is not self._player_id ] selected_players_ids = self.mass.config.get_player_config(self.player_id).get( CONF_PLAYERS, [] ) + # selected_players_ids = [] selected_players = [] for player_id in selected_players_ids: - player = self.mass.player_manager.get_player(player_id) - if player: + player_state = self.mass.players.get_player_state(player_id) + if player_state: selected_players.append( - {"text": player.name, "value": player.player_id} + {"text": player_state.name, "value": player_state.player_id} ) default_master = "" if selected_players: @@ -195,7 +196,7 @@ class GroupPlayer(Player): entry_type=ConfigEntryType.STRING, default_value=[], values=all_players, - description_key=CONF_PLAYERS, + description=CONF_PLAYERS, multi_value=True, ), ConfigEntry( @@ -203,7 +204,7 @@ class GroupPlayer(Player): entry_type=ConfigEntryType.STRING, default_value=default_master, values=selected_players, - description_key=CONF_MASTER, + description=CONF_MASTER, multi_value=False, depends_on=CONF_MASTER, ), @@ -221,7 +222,7 @@ class GroupPlayer(Player): # TODO: Only start playing on powered players ? # Monitor if a child turns on and join it to the sync ? for child_player_id in self.group_childs: - child_player = self.mass.player_manager.get_player(child_player_id) + child_player = self.mass.players.get_player(child_player_id) if child_player: queue_stream_uri = f"{self.mass.web.internal_url}/stream/group/{self.player_id}?player_id={child_player_id}" await child_player.async_cmd_play_uri(queue_stream_uri) @@ -241,7 +242,7 @@ class GroupPlayer(Player): # forward this command to each child player # TODO: Only forward to powered child players for child_player_id in self.group_childs: - child_player = self.mass.player_manager.get_player(child_player_id) + child_player = self.mass.players.get_player(child_player_id) if child_player: await child_player.async_cmd_stop() self.update_state() @@ -252,7 +253,7 @@ class GroupPlayer(Player): return # forward this command to each child player for child_player_id in self.group_childs: - child_player = self.mass.player_manager.get_player(child_player_id) + child_player = self.mass.players.get_player(child_player_id) if child_player: await child_player.async_cmd_play() self._state = PlaybackState.Playing @@ -262,7 +263,7 @@ class GroupPlayer(Player): """Send PAUSE command to player.""" # forward this command to each child player for child_player_id in self.group_childs: - child_player = self.mass.player_manager.get_player(child_player_id) + child_player = self.mass.players.get_player(child_player_id) if child_player: await child_player.async_cmd_pause() self._state = PlaybackState.Paused @@ -285,10 +286,7 @@ class GroupPlayer(Player): :param volume_level: volume level to set (0..100). """ - for child_player_id in self.group_childs: - child_player = self.mass.player_manager.get_player(child_player_id) - if child_player and child_player.powered: - await child_player.async_cmd_volume_set(volume_level) + # this is already handled by the player manager async def async_cmd_volume_mute(self, is_muted=False): """ @@ -297,9 +295,7 @@ class GroupPlayer(Player): :param is_muted: bool with new mute state. """ for child_player_id in self.group_childs: - child_player = self.mass.player_manager.get_player(child_player_id) - if child_player and child_player.powered: - await child_player.async_cmd_volume_mute(is_muted) + self.mass.players.async_cmd_volume_mute(child_player_id) self.muted = is_muted async def subscribe_stream_client(self, child_player_id): @@ -379,7 +375,7 @@ class GroupPlayer(Player): received_milliseconds = 0 received_seconds = 0 - async for audio_chunk in self.mass.stream_manager.async_queue_stream_pcm( + async for audio_chunk in self.mass.streams.async_queue_stream_pcm( self.player_id, sample_rate=96000, bit_depth=32 ): received_seconds += 1 @@ -424,13 +420,13 @@ class GroupPlayer(Player): master_player_id = self.mass.config.player_settings[self.player_id].get( CONF_MASTER ) - if not master_player_id: + master_player = self.mass.players.get_player(master_player_id) + if not master_player: LOGGER.warning("Synchronization of playback aborted: no master player.") return LOGGER.debug( - "Synchronize playback of group using master player %s", master_player_id + "Synchronize playback of group using master player %s", master_player.name ) - master_player = self.mass.player_manager.get_player(master_player_id) # wait until master is playing while master_player.state != PlaybackState.Playing: @@ -449,7 +445,7 @@ class GroupPlayer(Player): if child_player_id == master_player_id: continue - child_player = self.mass.player_manager.get_player(child_player_id) + child_player = self.mass.players.get_player(child_player_id) if ( not child_player diff --git a/music_assistant/providers/group_player/icon.png b/music_assistant/providers/group_player/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..092121e1e21e1d799b1de9ef58ca6914c4e14096 GIT binary patch literal 15778 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 F{||p8c8~x7 literal 0 HcmV?d00001 diff --git a/music_assistant/providers/sonos/icon.png b/music_assistant/providers/sonos/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d00f12ac4780e2e70c33e978203e6933b87c9543 GIT binary patch literal 40528 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 literal 0 HcmV?d00001 diff --git a/music_assistant/providers/sonos/sonos.py b/music_assistant/providers/sonos/sonos.py index f48b7aba..e2b5bdd9 100644 --- a/music_assistant/providers/sonos/sonos.py +++ b/music_assistant/providers/sonos/sonos.py @@ -6,6 +6,7 @@ import time from typing import List import soco +from music_assistant.helpers.util import run_periodic from music_assistant.models.config_entry import ConfigEntry from music_assistant.models.player import ( DeviceInfo, @@ -14,8 +15,7 @@ from music_assistant.models.player import ( PlayerFeature, ) from music_assistant.models.player_queue import QueueItem -from music_assistant.models.playerprovider import PlayerProvider -from music_assistant.utils import run_periodic +from music_assistant.models.provider import PlayerProvider PROV_ID = "sonos" PROV_NAME = "Sonos" @@ -244,7 +244,7 @@ class SonosProvider(PlayerProvider): :param player_id: player_id of the player to handle the command. :param queue_items: a list of QueueItems """ - player_queue = self.mass.player_manager.get_player_queue(player_id) + player_queue = self.mass.players.get_player_queue(player_id) if player_queue: return await self.async_cmd_queue_insert( player_id, queue_items, len(player_queue.items) @@ -283,7 +283,7 @@ class SonosProvider(PlayerProvider): for player in list(self._players.values()): if not player.is_group and player.soco.uid not in new_device_ids: self.mass.add_job( - self.mass.player_manager.async_remove_player(player.player_id) + self.mass.players.async_remove_player(player.player_id) ) for sub in player.subscriptions: sub.unsubscribe() @@ -327,7 +327,7 @@ class SonosProvider(PlayerProvider): subscribe(soco_device.avTransport, self.__player_event) subscribe(soco_device.renderingControl, self.__player_event) subscribe(soco_device.zoneGroupTopology, self.__topology_changed) - self.mass.run_task(self.mass.player_manager.async_add_player(player)) + self.mass.run_task(self.mass.players.async_add_player(player)) return player def __player_event(self, player_id: str, event): @@ -360,7 +360,7 @@ class SonosProvider(PlayerProvider): player.elapsed_time = rel_time if player.state == PlaybackState.Playing: self.mass.add_job(self.__async_report_progress(player_id)) - self.mass.add_job(self.mass.player_manager.async_update_player(player)) + self.mass.add_job(self.mass.players.async_update_player(player)) def __process_groups(self, sonos_groups): """Process all sonos groups.""" @@ -376,9 +376,7 @@ class SonosProvider(PlayerProvider): group_player.is_group_player = True group_player.name = group.label group_player.group_childs = [item.uid for item in group.members] - self.mass.run_task( - self.mass.player_manager.async_update_player(group_player) - ) + self.mass.run_task(self.mass.players.async_update_player(group_player)) async def __topology_changed(self, player_id, event=None): """Received topology changed event from one of the sonos players.""" diff --git a/music_assistant/providers/spotify/__init__.py b/music_assistant/providers/spotify/__init__.py index f67fc8e3..ad5a4b90 100644 --- a/music_assistant/providers/spotify/__init__.py +++ b/music_assistant/providers/spotify/__init__.py @@ -6,9 +6,11 @@ import platform import time from typing import List, Optional +import orjson from asyncio_throttle import Throttler -from music_assistant.app_vars import get_app_var # noqa # pylint: disable=all from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME +from music_assistant.helpers.app_vars import get_app_var # noqa # pylint: disable=all +from music_assistant.helpers.util import parse_title_and_version from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType from music_assistant.models.media_types import ( Album, @@ -22,9 +24,8 @@ from music_assistant.models.media_types import ( Track, TrackQuality, ) -from music_assistant.models.musicprovider import MusicProvider +from music_assistant.models.provider import MusicProvider from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType -from music_assistant.utils import json, parse_title_and_version PROV_ID = "spotify" PROV_NAME = "Spotify" @@ -35,12 +36,14 @@ CONFIG_ENTRIES = [ ConfigEntry( entry_key=CONF_USERNAME, entry_type=ConfigEntryType.STRING, - description_key=CONF_USERNAME, + label=CONF_USERNAME, + description="desc_spotify_username", ), ConfigEntry( entry_key=CONF_PASSWORD, entry_type=ConfigEntryType.PASSWORD, - description_key=CONF_PASSWORD, + label=CONF_PASSWORD, + description="desc_spotify_password", ), ] @@ -522,8 +525,8 @@ class SpotifyProvider(MusicProvider): ) stdout, _ = await spotty.communicate() try: - result = json.loads(stdout) - except json.decoder.JSONDecodeError: + result = orjson.loads(stdout) + except orjson.decoder.JSONDecodeError: LOGGER.warning("Error while retrieving Spotify token!") result = None # transform token info to spotipy compatible format @@ -563,7 +566,7 @@ class SpotifyProvider(MusicProvider): async with self.mass.http_session.get( url, headers=headers, params=params, verify_ssl=False ) as response: - result = await response.json() + result = await response.json(loads=orjson.loads) if not result or "error" in result: LOGGER.error("%s - %s", endpoint, result) result = None diff --git a/music_assistant/providers/spotify/icon.png b/music_assistant/providers/spotify/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1ed404913820493f5bb22663415827a1a44d174f GIT binary patch literal 20017 zcmaHSbyQT}7w;XqOByMW5J>^)5(JTu?gr`Zo&h8j0i~N!1nKTB>5idGBu5$~hk3*I zx87Rsug6-fnYs7eea=4j+_OLX^V#>Kx|$*(9xWaK0E9|!=%vZ}HGP!*4VXMu_OjqCQtz!Lz7dLI5k{jP=90HE5ZBrmJ& zXL``)=w!0hbhPA(fFX+33=N04nW@YYsH-^eDUuTaA2PG;+DDZC3Y_!>Hy9R+r!?x~ zMUs6w{=T(?KeoS!zOYbobZlZbX^TklQ2Mqm*3Q~Rlv?X8_o}n$3no6tk1EeT@D=^X z1Bv>b_aprM;(bT#h!5IAIi7LDx2>r=!K>q_@%{h*WZp0PIP~ub(=T0HTfd>0FIgGMOc)u->AMZ(+ zO0ep0{kBHt&>c0JT#Kamt)aiDfkzdp96Brd9-BE#p)P>#g<6GVuZ$XNJ2Ij1_L|wDORFL4fG9cu@P8-O zr9Srzup_$3CsRw7dGq=e0Krp#kz4=QJ8RK<%)y#;y8Kk5R{oc)0syeOS=5e`OWVBR zCgpn$0F{8yr_cZRi4|(ll31`H#(BZB4g?F$QU2OSc-}8THE6&WI&GI^bYBu%LR(Cf zN1y01fG-AuqZM`PC8YAbN7;Iq%A7=KGHes)1^*OlB8aQJgyafd=^YB80msE1XmGa^{A_w`IzIPE zr0KcW@J!y0tN6c8OxOFf;80{^A^7rUWv4=XrlAzpkqs}%qmr(S_(yMf)QRe3~rm1CG@e$d~a9Yb3oHS`q zUI8@gbcx~&MYXYlmTO8IUnlM})v`o;SE{ zi*M{|IF5ZjtSRzgwM&s%VmEkMXuO40sRb~x+Qr5ZAWyY{i2-$%AWW?1=H~S}JfSu? zVR1FrpVxnD3IouwLaK6)92owm)Z$1}!gkt_=S=1yv!(GiU_tyJ3rz6qF6G&OmUE9m zGMaX|JW-ciWeV4Z{dzrPx)NN9tP~D(>^11aCHabE7$7o;d`G@cwI2C-StNaxab261 z^6N!ROG(c+VN5ck8s8b&FHMTW+6!EgFPw$equq=0(OAhi!My>LXXRuVqWU;YHZSQ{ z7Aj2ZFQ&F8`5ci~Jx-H7%|&AnJTiMdkS(3Db~t$)4Oa9=*e0>6A+O=8HcQD~u)FGCO2ctduenv}SStu57xhOhB zbCOv~7Cm9<8CWQXvt^he_}m~l7FMd0lZsQTlin>0uqMoPOHI~G7F_W=ZU=v4;(9E2 zT^4Bk2phoH`1g0aNKws*S3PCc&MdHe_KjgIyJd=YY^cN-o}GG+t}K8~F{Lg`2>&%$ zEVh}AxJlES-OzbI)VhV|6teU6+1y_c1a^C-ImWLu>R3@(ZrWHDmGrg zBffs3I z6e6YvjYc=ykTC@|4uzzToU{-J6s`ti^S&>O@ z3A;}9iyGjkoD$ZN5HC1YNy{j&616B*n21xOsR%Iyt+N*UXGg48kVgDt@g(Q{F1>o( zx3Xij7CayHGgzxh%L}cO&MPDLg6Z~$0!djvV}S?b_x|GQby!jVi0?A{9tMQUn)%8# zJs`>Rid5hSnNP5^qf9msM;v!hvhM#~S0GMIYe2H{NF4*0+!$PYVT$~4 zX75@Cu-#OEc`%>hP6I80{78x+Z2B~3mhy@z8+bz@g$*tv=Cl}a;2_~|S@pUyJ!lYK zil|j)2Q)(~y3D2#e|HpnV-#dyNa=kGQ2GG$S=Ws7H zD9;u3-O@WL1s~}rJjDqRA#uApMthxXpdROyE4U1sH}=K8Vz0x6^GgW&2PoT>S0!;?i2P@*TpRRV z44Kj^vb*Gs@j^yY$8YayK=eO-vRe*j;>KIp9z$QeMB$$w#9d~Xl3vGA7r&o8>C5M> z0UfaW2ESzoI2CYN7cDI%jqNq^LWZuMSSC2U?2Kv#W&ULFUlZB^^7DkAzE3tJ)-lxP z&FTpxsWNpK)WGf6y-lLjsre-Y2Zr{L$@Ag7H+n{nHy}OGCuX zA4HJ}nYz?m-=OQtW<~(y`oF*h?@|iiHz`SVZu5$cw%6E+{c(|IvE{uI^rt=3IFXny zrXZ>myI{3gxXiTf;yd$`f=M@}bw9CG3~i3+gHSgRJ$rgz+x^?pjdLmaa~(p{qz3Cc z*9&bSG~+X3f3{^fm@T*q^~6|NU$cC_e7NtbZJ7tgk}G#(ELuDSG7L&^pN^$Cjd^U+ znmjB4diLp5s?L9%k82(fB1}$$S4hVPAC8Dl)0nQ;QCIFTMDt+=oxWNeyO<+SFBmFK z#UkZ@?jlhwpyLl(r0LuX6}8+n63=;~HI-Y?r+=i_+geDr{YzkP52NrZ6QB6Y`zhVc zTm;IVVqMQORn`xp0!-LmDD(qszP?b`vVg^eMyFv5T}{TZmjstaiQTV6;0@5)KRYYA z;Qe&V^kgg}<&R7p@bs#E_GlP+G%bLq^=PkI#1=CH9|138J2kM0-ex1*x<>kWCzE@{vf zg|@qKuRe?gm0vPT`#uH_1XYrfy8G8T86Fved>;oMdsFFlQfV&?Hm55MhDt?8&z5XW z2l$|g@OC~R=5?qyOJc%$d^C0L#UTdA*Q~No1}Hc9qV%leq>O$W{d075ve6Msgm*wG zzQ7lS89DrbkKRkCKP5|H;V}=!J~$i{S1+zT)wXQH7yj~cziBHTWlcX2zJRN4*+e#C zG7wq|m3v%Dm+}DvEZ_U5)RrBR7p|qCe^8;Oct>_eN}AY^lMPvlJ6zuECZ5R*P(?gK z9faGqiGjEr@(Y5u%1Ab$MhNr9p_+9%rt$+u}WXk<_K43Tg7su%?2D%>ot}_q(xwUy{ zl|i^93}>0^GaX?aWGbam1f-6sfOBaR%Rb9B1-usipWie*>$?C6;73I&T zGm$}Nmf678k7vT85uDL<&P|m(LEPF;K32aii^3x%MW_oImA-7J)MRT^c-pjM=BNk; zUoo6nyposiXx+S$voSKm7e)p6qi)LHU(T-5Zk^5Z?j?KQGm&@k+j1YGqeGhC?`zbI z#v75LS+S|yi6^T3UPB?F(I{+`aiKwnP}<7oW6arv84GFh8(9=06>fF+eG(BS!o|Us zAokZ5P9BILC9NqxZAmC3m5J!2e)Ih|;}U{|^vsHE3B8W{3tp%W)afm}yRWDqTw81^ z;0*~Wegs-rk(CFxemmd#8o$yN`PvdUd-k*k? zeUp*z4Vrhat`|q2r_^T~%9*+O=*_>G@EI&GKQjg~%w-s;?|e~sQ7?Uf$+|~OO8UNx zm}shA#s>5j3d;juoX>35FrdlHkNl)tD?UgEji4pTlqn*Uu7AHp=djbdwqp8hkKs zlG>z*xj%4ovKn@%I4ple%(I*qQ$xV@-kh!V;?2P+$S_`7aL<%ITK(5kxTQ`>fJfFr zE?&>q!1c_H-Bb0WLypEZ5&yZJWh;P&xw&C3_VQk9M#JsWOaKpd7MjLcr?dR&*=%tr z4>soLw-oGqw^rBR4$Xc(0j9BF5{zYMtVW29*SV8Z+Abp1jt=!y$*$n z&N!T}g(P?*X+;kdJT@&h=>`AHG5%fIbzzs$!5E(=bopmK?)@m3Wn-jEer*ABoXK)j zr|8P?56bEF!r9$i#C?@WrW+N;wc8I$!6B77IZ2JF-;pP)M@_5|!X}Dd0bQOk)R~!a zQs#YW4(}s14 zakSljBdP*JqqY1oq^1L}pGet_;o5VHmzR!h!i`2~Uud>{@Rm~)e4d%Vj@-eVF@Gi# zwc6A#1fH>6>qvN6cqYBZF>mHuc&S)AE0;4#rki9&;v&p?LZYnwv)_>aC#|Ou6_ip3 znN<2sj=nTAl}hM>2IEdRIkZA@>CgkcldMn+T2{2tBKH%ZsybM?YQS3kFZ-k3jxx-; z1TR%#k7TB}{}f8lNMOhdgj_|7VRniCt6!}VJ$k&YO_B6G>pG4G%vtx4As&7m#?CmM zn2GY{=hRW6g%J*C7bwjGgV)@ihJ(&QN|{%tE%fz@+M!0K-Hlh z9!Z&-K6Dez18K!VR=<9{)lq$Xxob_uDxojkP9}6wp&1JsJ^AIPDouT0)^8dFQF-Kz z6Lx7D@hNH8svdVY-l%{<$V!XVrkziH`8_yz{6^1$?8PPMG%Dp^ZSPJ!{R{E+B8yO{ zzzGG;keB~DD$xwFL+rl&D}iX7!vt%mMO_2~B-~K}M>(Un-#%2it_D~~(d2RR*F=Q= z=DFK+>|#_!?D0MFH2{F^Zw6h*Pv!B*{H?NVG5^c+E;+2DL>lhx@guz-P4RRw0z(!E zzoUH)S96QNusw~XiR46^UnL#~s6@831+lu<{ULm7PPXCJ&Farj6+x{oILqy!y;elmUnIVG6?wM;Du*D;Zh|}QseG~PasD2u4(BH@y!%0{+VKY10s8_{;}^f0BB`4ev-C( z+vzk{$>nWWMzJ)cv9!BVb`s@qc^}w%!0Ywj28yz`PH*vz`7Mc_bDbiVBil+z2H^e| zap|+PR%(qzM=?!4uRamzxIA7$Ycs@}uvdV^bLg0GnFzuyZ=j}>4?VpIO~87H2h4Vx z1CKbg2<^)B^@>fo(X|5)k$nw5X;hO`-T|o(^O63lC3B`v!@SU|BpdVCh6cL02ab=q zH%{6I17FbJP;;#~*n(R1u?l@|)-ET(9P}ufkF

2{&B1&;a7_3EjVnU?SvqReTw* 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 literal 0 HcmV?d00001 diff --git a/music_assistant/providers/webplayer/icon.png b/music_assistant/providers/webplayer/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ffcf4fa0005ebf8753adbdc2ada10987b4d18550 GIT binary patch literal 8346 zcmeHs`9D;D^#3fDu?>@T$TA^g4Ot?~Fp}NaMMSni5k|^7V~H}P5Lri-86*_do9v-1 zH5e+4QmGlTR70A=cl!SP{R=+d&wV`ZT=Cu3(=j!AJ0D{0=+&sJxK7Ii~A*iqj?2xFKxP+t>Tw3O^tem_8;)tS>@=+C} zs+zinrq(fS9o^%4`UZw4jEqm3n3|cREG*Ghr>sxg*xK1UI666FTwLAUJv_a<&-nPB z_47X$5O_W)I3zR-8y*pP;bK(urI^?_T>RyPD_0Yfl2cOC@Pzb?%&cqKIoEIG67%wJ z7TmgBSX6wcr1WlCc|~PaHL2!aZC!l>nbO$Qe81&EYg_xnM;)DypFI7qtNU3`Z(skw z;L!8okWy4!V}vY{RA z%DLfcYv9{F@7bQk=vDJGU{(1!C*O?4 z8X8e?-^Ts5_wL3F{fRefw;p!?>ocO$Df9S3+A=lfb;>hcLx(s#&)X-Tx086|?-Q;R zN4{lF!`ylooxyn9mVl|kjMw+Grup>$u2-i0X(+H2a_~H5uX66amQcu@xz!(kB*k>k z5ME+8yZg*F!TcT!URT)+Ya;+9s_5omx#nd05gxdd_f=8p0m*rX`N@6|ID+mdzAUrS z>Nkl8fy0-TRkw`QbD`ah`NUE(vN1<8RSf@O+@<%4A7UinZee!%rdgy^pKTH~;w zgf^z(N&0L3RLuzmAUJp~9D<+uavas)?@fgRr^1B5V8NUUdy&J;p*XlmfPxsY8ILmh zG63QMNpWrJTYu2o^(_Sg*yk=E<8m$OWSLVpq4^rw_@*t4&K{KYpR#mS%*-Nh7v=v`E#2{rIg0k=rOn!yfAx^~o1~>)Y5Z?i2x5<2@KcDX)Wf zkflnkHmJ(4i(PVJi!$stJx<*=x(rR>W7XVSC`fkQP^}Rr>=(|!Y#YZV*uspH@}B71 zO~3h4cv*M1Oc6>g)GKTu#<6Uz)2=e!Hfg|=khO4|*szr;FO>Nie|g*1m%=Pj>glBx zE^u&)ojh>w)4m9?U`JyQ%*y>38Ax2PV@Q&m#ry>`&bp(j0egr(UgET7^*(?-SmmTW zapn(GfF%D7%loOKy(AUDMmfbvF=&1)AT=p6KjW6uyzM5T$~QqnFn-W85z3lx;L-Mc zRL#(*1}&5g1mwjsbf~^pTLL@oTxCfx8hgltmt>*SZln@u72l_Aje8|1Vc88H0k143 zf_ic}wQc`>3rmDic`|TsJc9*g-2EE3=gUw|6X$efa!Pm{n|xqXZ#X_Zw=WxB|tLlwa?m)6!)TnNvESS zRZS^Y;Ukz7EBnFm>=l`GwNxuOYq887o^F@j!i5 zY~}*|FDY;68#htRO-FGcE<{OqAQqx|PmUy4vRu6sz?CgC z5PPCN(+BS}G0;al*$-JtUtx)L0^*TTd1>7lw<`F?5s|#{fcVnP>iUCzh(vZk1&#GX z{P@jA&)S{#?VHhW>&}deE_9gIr)1~%8U3ke+j(fB5b(?*0v$y2z+`$ck*SVxUYT!_` zT+D*3;_1Ds6HobmneV=MK3aW+eOL8$A8YKre_G^x_h!oO_xE83K}&y+_5O```J?!K zN?+*uO_{X$hbdu8RVlo};&Oa5$52AoKBO(3=PFl2HTzWZ?1lfWlYD=-2kWuZC1h0o zWa9d2d)7Q{63IRK7z^Y`i9h8?DWW5UTduY{d! zIR_qYaT-2W>^li*K+Szu{PKAw7BuXab#83{V&{}zaWsv6!!wxFG23`zOwU?d#&R{! z-$GptGw*R?{`+s@2awH)KP`$KXYqrcn*gjd-I84Wz7UqTk{8Zt#a5RS7(!H zTe+tO>HchE4oOAv#bml=vGsbrxD0Yl%}TElW&#SCj-Gc?mm^X18*?ZF2xD=BYtl99 zb2B%Dk*f(g=k*jzd6dg0YQ&FWruQAJZnfWm{pATMNnAkq#DJAevna2^xXxu`JX`wi z6$3D?>Q;ssu(MeYZDri7)L|sj)k{-bB9*C4_=l8kyytn8MajS-;X>N%^9STHYI%+U^>+ot6;M?{QyeoUyujzmeN7~k3iSkvFIP)r~W~fCBcmtbdb=HBL5f8K9t0cY-t90S_>kCN!Qg4~z zDF(EAoAZ34Zbl#nv1dOLvL=qKq|Tm{Dv6;6@+#vG`J>L4Nlw?)$u8yvndCd%Pj?+p zyjL27WI~0O>bNk29yZiHTp8Hw;ti?n`iq#ODy_kXAHN|y3oHWIQF6IF=B>y}mu@{Q zjk%%=4sqXyk1V^rh_vM+;b(2$hTwb(^BLCKk5fu42;;wwKUPOX%W&gCRhA#WOfe;_ zW|7r8n$i5qWm;hl~>-UX)a%bKfbuZH*#F zS-j;?^ePMIBb`1YOI+rrNU~*uo(c_0yP0DC6Gyh3!F5SP&vY|4_S_*h)wT&I$I;%< zVsLR1yneQ+6zZ$?5&MZ5<-(mqc}Cp0hI%{h4t3w~ESfLGNoUjsf1aM*_=KoqrgpK- zxX!%67-9Z zf^C=w*rQuG1dR9x)Ckf9i`OmkWs_G$MKF z#0*NHyO4Ve_gLtW%6kF!|9v5S(L_a-@ z8cwYFRwgzbc$I(ueKu}Nw(P8(q&#X`-(ArCzL40J0;ddM=H=sE+f?O?ytV2SE{jTz zF>eoK!GD!dUv*#$^P?R0C(@6VMUv(~)aC8Y+LPP+^N3oDQu7rKl7$N4hO?mAv1^A^ z?K$iSx9WENrZQx#f5DYB8+=GUaiozmxJ0;&6n&e@xNQA|_x2KrBW^|AGGvPM=1gTp zTGWQ(rU2QuBBbm^@faI)k=0l!#q*)xeXC}wz42FI-?DbU#73vUBdce1stAA@ z-D^1f%_0;!h&q|-mXEFxH_To%p5|~Ojj~J_nTD2a2jJ4u|8H=b#jPgJ3+~mtyJVf9 z{JDe70g}ZEtp z)}1`*59&Bbu)=4TF145Go})Tl2?|&whe>G=V)oaJM>JMHXDTm?w7@~mL2Tg8UN ze1MAOs!bBV?f=i)al}_(*Ij|UJIA}!5#i8(od;l~51l^s6xd;O{a@#umT2Nu^Aw1c zt1F+Pet}s(5xcG1$VniT7mZg7v>w*juNqh<$SYOaaWav|k~)N&;Vxs<M zbC7ogTtt~il8@+}$k6J|q+ra%!5+;Z+j!{IurD=0!tboG%cz?^rqfMf71qUs#*|DC z4>;r)mvDwYKOPtTotfdXSP{?PDqUA#53l`GJc(G7>w*gf-lR^#3%0p917Q;Mp>7=W zBStYNA|+Gni{;x3O*~CIMwxUw!=J7Q%Hp9eBgsJn7Y-K9Eb!P@>4FPe6f(XgG_Iy5N<{n$~td2FAFcE@5SA>4d zapowLV~)V&c|qp})ZL;!-D^Wa3J-vsxmPRw89NC+v;fB_1xW{875TyA20(rY#A+9` zC;Lm<(R};84cxCi^HK$fZ7JhQUwL1y;^j_U(Ygd?P^pguVd?so-*~3VZhOv?1!O zH1SpFqJcbEvq{{Q3;D?(d)uHeM&PyVS+EXG%EWMj>nyZ5YLvHry=i2VP0u7iuaFfze_mW0e3PYRmfXXzsW@$ z7h`KQ9L8XT8JFW>+ohuE2AW8S7z@_1b{j1VHMnN>Nfja{76o0`trm1!(xj3HFY|+x zO@YFHL-p%59SLSbs(eK_62D17%zM}k{sg=kvmD7Mw#|Fx9$nY243zhv7Kx#;#jYH= z7z`Z#)&xv(ed8*IY&JBRPByC-%(C{vNFdkTt|;qWF+G)B2}-ei?;?S`uWwR#dD5rJ zD^^->)jp8aCb?Wgvxub32QyguS%kqGz617+k|}2`i+?nsYkwkzigth|b03y8HHv%G znK&?xT``S{sa)>t$X!|-GA^e!DA2N*iRi(W)sz}5hJT%+oT5pJzKiIr+z3f!CR|&bH&S}42et%Qt!P_@Y!9(Am z$%R84-L~VR1IP2Axt3I9M}aincrmx1X_)%BB?;UQBKn zOdHK?>yyAG-r@YXi&f)MFURVKY#20zgC@#!2~9Gw%g$S=aana;1xCc0f$BnFvTQVY znZ`v6{;31Wzscm}YEw^18NSOTyZ=PBlWfgQGmV?}#e~a>9bxnPr8TUBm zp(?A=4<>7en%uFpo5lp=tnU6zt$H_SEDa|g59;(e$G7j93YOi6=IP#XQOC57E=Sig zFpJXCrj9+Rp24tz*Q;`ML&vEUd3~_{cq5;DQWo@2q(ybZ9}1kZu&B4|nXN5rZCOnW zp$XA8bap+kFj<2;*=EjeCoz45twsV)8j!+vF~{_!yDi%nKc)t{K<4%6r}t38pJ|!$ zjwH9!*ur+A9H2+rP-uUKLq)V@HJQmx6}>r-)-bhv35}h(Sq13Pfuzo4ISE^qhyG#k zQDwNvgXdhmrUS#o8LnZboWfDCo8#^n##ksr%j3?BBFXDZ{lMvmvCK9#@v`8FdN0)L zXJYATCU#_hs#1uwDLC<~>aI0s#XOOzl~$jC5;g_&?Uq7^TWVfPB|=!!$lamexH3V< zsJarAGaY1ZbIvh)uah*Ytjs#ZXxxh5BEWE6qKOr8rB$FFk60rUuef9Xnk})uGv!Mf z%|8oiHARzw)O+6z5(QcF%TZgN3x|v~_${)1);LwQINp;3ZgET+pE1qH;x%DuAxYN+ zr&_s`3C(*mokSA;T*dRc=shZX(3(nGH(&!8yuT&;)oU576MBqi|LS*^-$ky}Zde$9 zYvsF|BES$JITJHf{A*@R3k>T@IYqx=fpBUox)gd+4ZVnEb2DVB-LFN}oWik9>*^iu zMdSC|I}G$>Y{=V85zh@FHZMcb+#uVN!w!=l(rl7z24dU#pJwP0oW{`w9zP@5d<^w$ zHmdp>H*S^hOx_&T3zen{2VQKL3fBtWrlZqJ3YlnwGx^Pk&Z}Q;XuWkld_4z^Cdp8`OoYOo#J-E-JS&2c?83B|yq;$mnf_ zhVO^ihZxSvqOOR>Q?N=oe5w7f>1tG;&!xj$DGp@12SVwSEnA%7+ntD>;FNHnkno2^ zz1-}#pTrduQ}inhyzs=-Ka6vv>b)yKD`EiG8?@El6J{8x&#_f_J7S||Mwn9v-x&q7 z^r>H=i+6Zts{*+s(WtMRj5yL}%Qvph`8(8b7_E6vEk&5sw^bB@3D;6&fM#8@Ii3?N z3X&WlV`m2;W`qdYJGzkmfYSH;H#mHeFZohD*57G^fr2^rc-7F?U5Ei{Mf`pxqj&&V zF(Ejl$d8_ZQ@g-b?BxHV9{^U0=7?t1i2;YiBFq&hJ^;YVo39*-Y_qj1p|o_}u*qN& z9$1**b=2-yM-GfgP%||P>cjbaprZbWCBMGe`x`0Bf4H|IvL+BAjWYqQP z7hTIgwF|+eCO0G(XCeBUK9d zOL6>R2Q^0dbGf%nGPw??O{$mQ#r4;9{4C@v(R+SS*BZ)|)O5tDlh@(QXdTzlR(iD= z?&>3v{ctYD-=ZHe?S2O;o;u+K+o<Z3jX9jQJF$m=u~H@9zeZ9VlTF7ccZYo}e5{LCy&KnPT}hVg$TY+uqsPc7j2 iAGKTc|JZz6KL9|k6)J(K8=3!hgl$hbSv{}}NdG^u#KY(S literal 0 HcmV?d00001 diff --git a/music_assistant/translations.json b/music_assistant/translations.json new file mode 100644 index 00000000..576778fe --- /dev/null +++ b/music_assistant/translations.json @@ -0,0 +1,59 @@ +{ + "en": { + "enabled": "Enabled", + "name": "Name", + "username": "Username", + "password": "Password", + "enable_player": "Enable this player", + "custom_name": "Custom name", + "max_sample_rate": "Maximum sample rate", + "volume_normalisation": "Enable Volume normalisation", + "target_volume": "Target Volume level", + "fallback_gain_correct": "Fallback gain correction level", + "desc_player_name": "Set a custom name for this player.", + "crossfade_duration": "Enable crossfade", + "http_port": "HTTP Port", + "https_port": "HTTPS Port", + "ssl_certificate": "SSL Certificate file location", + "ssl_key": "Path to certificate key file", + + "desc_sample_rate": "Set the maximum sample rate this player can handle.", + "desc_volume_normalisation": "Enable R128 volume normalisation to play music at an equally loud volume.", + "desc_target_volume": "Set the preferred target volume level in LUFS. The R128 default is -22 LUFS.", + "desc_gain_correct": "Set a fallback gain correction when there is no R128 measurement available.", + "desc_crossfade": "Enable crossfading of Queue tracks by setting a crossfade duration in seconds.", + "desc_enable_provider": "Enable this provider.", + "desc_http_port": "The port on which to run the HTTP (internal) server.", + "desc_https_port": "The port on which to run the HTTPS (external) server. The HTTPS Server will only be enabled if correct certificate details are also set", + "desc_ssl_certificate": "Supply the full path to a certificate file (PEM).", + "desc_ssl_key": "Supply the full path to the file containing the private key.", + "desc_external_url": "Supply the full URL how this Music Assistant instance can be accessed from outside. Make sure this matches the common name of the certificate.", + "desc_base_username": "Username to access this Music Assistant server.", + "desc_base_password": "A password to protect this Music Assistant server. Can be left blank but this is extremely dangerous if this server is reachable from outside." + }, + "nl": { + "enabled": "Ingeschakeld", + "name": "Naam", + "username": "Gebruikersnaam", + "password": "Wachtwoord", + "enable_player": "Deze speler inschakelen", + "custom_name": "Aangepaste name", + "max_sample_rate": "Maximale sample rate", + "volume_normalisation": "Volume normalisering inschakelen", + "target_volume": "Doel volume", + "fallback_gain_correct": "Fallback gain correctie niveau", + "desc_player_name": "Stel een aangepaste naam in voor deze speler.", + "crossfade_duration": "Crossfade inschakelen", + "http_port": "HTTP Port", + "https_port": "HTTPS Port", + "ssl_certificate": "SSL Certificaat bestandslocatie", + "ssl_key": "Pad naar het certificaat key bestand", + + "desc_sample_rate": "Stel de maximale sample rate in die deze speler aankan.", + "desc_volume_normalisation": "R128 volume normalisatie inschakelen om muziek altijd op een gelijk volume af te spelen.", + "desc_target_volume": "Selecteer het gewenste doelvolume in LUFS. De R128 standaard is -22 LUFS.", + "desc_gain_correct": "Stel een fallback gain correctie in als er geen R128 meting beschikbaar is.", + "desc_crossfade": "Crossfade inschakelen door het instellen van een crossfade duur in seconden.", + "desc_enable_provider": "Deze provider inschakelen.", + } +} diff --git a/music_assistant/web.py b/music_assistant/web.py deleted file mode 100755 index f3c97e9e..00000000 --- a/music_assistant/web.py +++ /dev/null @@ -1,947 +0,0 @@ -"""The web module handles serving the frontend and the rest/websocket api's.""" -import asyncio -import datetime -import functools -import inspect -import ipaddress -import json -import logging -import os -import ssl - -import aiohttp -import aiohttp_cors -import jwt -from aiohttp import web -from aiohttp_jwt import JWTMiddleware, login_required -from music_assistant.constants import ( - CONF_KEY_BASE, - CONF_KEY_PLAYERSETTINGS, - CONF_KEY_PROVIDERS, -) -from music_assistant.constants import __version__ as MASS_VERSION -from music_assistant.models.media_types import MediaType -from music_assistant.models.player_queue import QueueOption -from music_assistant.utils import get_external_ip, get_hostname, get_ip, json_serializer - -LOGGER = logging.getLogger("mass") - - -class ClassRouteTableDef(web.RouteTableDef): - """Helper class to add class based routing tables.""" - - def __repr__(self) -> str: - """Print the class contents.""" - return "".format(len(self._items)) - - def route(self, method: str, path: str, **kwargs): - """Return the route.""" - # pylint: disable=missing-function-docstring - def inner(handler): - handler.route_info = (method, path, kwargs) - return handler - - return inner - - def add_class_routes(self, instance) -> None: - """Add class routes.""" - # pylint: disable=missing-function-docstring - def predicate(member) -> bool: - return all( - (inspect.iscoroutinefunction(member), hasattr(member, "route_info")) - ) - - for _, handler in inspect.getmembers(instance, predicate): - method, path, kwargs = handler.route_info - super().route(method, path, **kwargs)(handler) - - -# pylint: disable=invalid-name -routes = ClassRouteTableDef() -# pylint: enable=invalid-name - - -def require_local_subnet(func): - """Return decorator to specify web method as available locally only.""" - - @functools.wraps(func) - async def wrapped(*args, **kwargs): - request = args[-1] - - if isinstance(request, web.View): - request = request.request - - if not isinstance(request, web.BaseRequest): # pragma: no cover - raise RuntimeError( - "Incorrect usage of decorator." "Expect web.BaseRequest as an argument" - ) - - if not ipaddress.ip_address(request.remote).is_private: - raise web.HTTPUnauthorized(reason="Not remote available") - - return await func(*args, **kwargs) - - return wrapped - - -class Web: - """Webserver and json/websocket api.""" - - def __init__(self, mass): - """Initialize class.""" - self.mass = mass - # load/create/update config - self._local_ip = get_ip() - self.config = mass.config.base["web"] - self.runner = None - - enable_ssl = self.config["ssl_certificate"] and self.config["ssl_key"] - if self.config["ssl_certificate"] and not os.path.isfile( - self.config["ssl_certificate"] - ): - enable_ssl = False - LOGGER.warning( - "SSL certificate file not found: %s", self.config["ssl_certificate"] - ) - if self.config["ssl_key"] and not os.path.isfile(self.config["ssl_key"]): - enable_ssl = False - LOGGER.warning( - "SSL certificate key file not found: %s", self.config["ssl_key"] - ) - if not self.config.get("external_url"): - enable_ssl = False - self._enable_ssl = enable_ssl - self._jwt_shared_secret = f"mass_{self._local_ip}_{self.http_port}" - - async def async_setup(self): - """Perform async setup.""" - routes.add_class_routes(self) - jwt_middleware = JWTMiddleware( - self._jwt_shared_secret, request_property="user", credentials_required=False - ) - app = web.Application(middlewares=[jwt_middleware]) - # add routes - app.add_routes(routes) - app.add_routes( - [ - web.get("/", self.async_index), - web.post("/login", self.async_login), - web.get("/jsonrpc.js", self.async_json_rpc), - web.post("/jsonrpc.js", self.async_json_rpc), - web.get("/ws", self.async_websocket_handler), - web.get("/info", self.async_info), - ] - ) - webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web/") - if os.path.isdir(webdir): - app.router.add_static("/", webdir, append_version=True) - else: - # The (minified) build of the frontend(app) is included in the pypi releases - LOGGER.warning("Loaded without frontend support.") - - # Add CORS support to all routes - cors = aiohttp_cors.setup( - app, - defaults={ - "*": aiohttp_cors.ResourceOptions( - allow_credentials=True, - expose_headers="*", - allow_headers="*", - allow_methods=["POST", "PUT", "DELETE", "GET"], - ) - }, - ) - for route in list(app.router.routes()): - cors.add(route) - self.runner = web.AppRunner(app, access_log=None) - await self.runner.setup() - http_site = web.TCPSite(self.runner, "0.0.0.0", self.http_port) - await http_site.start() - LOGGER.info("Started HTTP webserver on port %s", self.http_port) - if self._enable_ssl: - ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) - ssl_context.load_cert_chain( - self.config["ssl_certificate"], self.config["ssl_key"] - ) - https_site = web.TCPSite( - self.runner, "0.0.0.0", self.https_port, ssl_context=ssl_context - ) - await https_site.start() - LOGGER.info( - "Started HTTPS webserver on port %s - serving at FQDN %s", - self.https_port, - self.external_url, - ) - - @property - def internal_ip(self): - """Return the local IP address for this Music Assistant instance.""" - return self._local_ip - - @property - def http_port(self): - """Return the HTTP port for this Music Assistant instance.""" - return self.config.get("http_port", 8095) - - @property - def https_port(self): - """Return the HTTPS port for this Music Assistant instance.""" - return self.config.get("https_port", 8096) - - @property - def internal_url(self): - """Return the internal URL for this Music Assistant instance.""" - return f"http://{self._local_ip}:{self.http_port}" - - @property - def external_url(self): - """Return the internal URL for this Music Assistant instance.""" - if self._enable_ssl and self.config.get("external_url"): - return self.config["external_url"] - return f"http://{get_external_ip()}:{self.http_port}" - - @property - def discovery_info(self): - """Return (discovery) info about this instance.""" - return { - "id": f"{get_hostname()}", - "external_url": self.external_url, - "internal_url": self.internal_url, - "host": self.internal_ip, - "http_port": self.http_port, - "https_port": self.https_port, - "ssl_enabled": self._enable_ssl, - "version": MASS_VERSION, - } - - @routes.post("/api/login") - async def async_login(self, request): - """Handle the retrieval of a JWT token.""" - form = await request.json() - username = form.get("username") - password = form.get("password") - token_info = await self.__async_get_token(username, password) - if token_info: - return web.json_response(token_info, dumps=json_serializer) - return web.HTTPUnauthorized(body="Invalid username and/or password provided!") - - @routes.get("/api/info") - async def async_info(self, request): - # pylint: disable=unused-argument - """Return (discovery) info about this instance.""" - return web.json_response(self.discovery_info, dumps=json_serializer) - - async def async_index(self, request): - """Get the index page, redirect if we do not have a web directory.""" - # pylint: disable=unused-argument - webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web/") - if not os.path.isdir(webdir): - raise web.HTTPFound("https://music-assistant.github.io/app") - return web.FileResponse(os.path.join(webdir, "index.html")) - - @routes.get("/stream/media/{media_type}/{item_id}") - async def stream_media(self, request): - """Stream a single audio track.""" - media_type = MediaType.from_string(request.match_info["media_type"]) - if media_type not in [MediaType.Track, MediaType.Radio]: - return web.Response(status=404, reason="Media item is not playable!") - item_id = request.match_info["item_id"] - provider = request.rel_url.query.get("provider", "database") - media_item = await self.mass.music_manager.async_get_item( - item_id, provider, media_type - ) - streamdetails = await self.mass.music_manager.async_get_stream_details( - media_item - ) - - # prepare request - content_type = streamdetails.content_type.value - resp = web.StreamResponse( - status=200, reason="OK", headers={"Content-Type": f"audio/{content_type}"} - ) - resp.enable_chunked_encoding() - await resp.prepare(request) - - # stream track - async for audio_chunk in self.mass.stream_manager.async_get_stream( - streamdetails - ): - await resp.write(audio_chunk) - return resp - - @routes.get("/stream/queue/{player_id}") - async def stream_queue(self, request): - """Stream a player's queue.""" - player_id = request.match_info["player_id"] - if not self.mass.player_manager.get_player_queue(player_id): - return web.Response(text="invalid queue", status=404) - - # prepare request - resp = web.StreamResponse( - status=200, reason="OK", headers={"Content-Type": "audio/flac"} - ) - resp.enable_chunked_encoding() - await resp.prepare(request) - - # stream queue - async for audio_chunk in self.mass.stream_manager.async_queue_stream_flac( - player_id - ): - await resp.write(audio_chunk) - return resp - - @routes.get("/stream/queue/{player_id}/{queue_item_id}") - async def stream_queue_item(self, request): - """Stream a single queue item.""" - player_id = request.match_info["player_id"] - queue_item_id = request.match_info["queue_item_id"] - - # prepare request - resp = web.StreamResponse( - status=200, reason="OK", headers={"Content-Type": "audio/flac"} - ) - resp.enable_chunked_encoding() - await resp.prepare(request) - - async for audio_chunk in self.mass.stream_manager.async_stream_queue_item( - player_id, queue_item_id - ): - await resp.write(audio_chunk) - return resp - - @routes.get("/stream/group/{group_player_id}") - async def stream_group(self, request): - """Handle streaming to all players of a group. Highly experimental.""" - group_player_id = request.match_info["group_player_id"] - if not self.mass.player_manager.get_player_queue(group_player_id): - return web.Response(text="invalid player id", status=404) - child_player_id = request.rel_url.query.get("player_id", request.remote) - - # prepare request - resp = web.StreamResponse( - status=200, reason="OK", headers={"Content-Type": "audio/flac"} - ) - resp.enable_chunked_encoding() - await resp.prepare(request) - - # stream queue - player = self.mass.player_manager.get_player(group_player_id) - async for audio_chunk in player.player.subscribe_stream_client(child_player_id): - await resp.write(audio_chunk) - return resp - - @login_required - @routes.get("/api/library/artists") - async def async_library_artists(self, request): - """Get all library artists.""" - orderby = request.query.get("orderby", "name") - provider_filter = request.rel_url.query.get("provider") - iterator = self.mass.music_manager.async_get_library_artists( - orderby=orderby, provider_filter=provider_filter - ) - return await self.__async_stream_json(request, iterator) - - @login_required - @routes.get("/api/library/albums") - async def async_library_albums(self, request): - """Get all library albums.""" - orderby = request.query.get("orderby", "name") - provider_filter = request.rel_url.query.get("provider") - iterator = self.mass.music_manager.async_get_library_albums( - orderby=orderby, provider_filter=provider_filter - ) - return await self.__async_stream_json(request, iterator) - - @login_required - @routes.get("/api/library/tracks") - async def async_library_tracks(self, request): - """Get all library tracks.""" - orderby = request.query.get("orderby", "name") - provider_filter = request.rel_url.query.get("provider") - iterator = self.mass.music_manager.async_get_library_tracks( - orderby=orderby, provider_filter=provider_filter - ) - return await self.__async_stream_json(request, iterator) - - @login_required - @routes.get("/api/library/radios") - async def async_library_radios(self, request): - """Get all library radios.""" - orderby = request.query.get("orderby", "name") - provider_filter = request.rel_url.query.get("provider") - iterator = self.mass.music_manager.async_get_library_radios( - orderby=orderby, provider_filter=provider_filter - ) - return await self.__async_stream_json(request, iterator) - - @login_required - @routes.get("/api/library/playlists") - async def async_library_playlists(self, request): - """Get all library playlists.""" - orderby = request.query.get("orderby", "name") - provider_filter = request.rel_url.query.get("provider") - iterator = self.mass.music_manager.async_get_library_playlists( - orderby=orderby, provider_filter=provider_filter - ) - return await self.__async_stream_json(request, iterator) - - @login_required - @routes.put("/api/library") - async def async_library_add(self, request): - """Add item(s) to the library.""" - body = await request.json() - media_items = await self.__async_media_items_from_body(body) - result = await self.mass.music_manager.async_library_add(media_items) - return web.json_response(result, dumps=json_serializer) - - @login_required - @routes.delete("/api/library") - async def async_library_remove(self, request): - """Remove item(s) from the library.""" - body = await request.json() - media_items = await self.__async_media_items_from_body(body) - result = await self.mass.music_manager.async_library_remove(media_items) - return web.json_response(result, dumps=json_serializer) - - @login_required - @routes.get("/api/artists/{item_id}") - async def async_artist(self, request): - """Get full artist details.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - lazy = request.rel_url.query.get("lazy", "true") != "false" - if item_id is None or provider is None: - return web.Response(text="invalid item or provider", status=501) - result = await self.mass.music_manager.async_get_artist( - item_id, provider, lazy=lazy - ) - return web.json_response(result, dumps=json_serializer) - - @login_required - @routes.get("/api/albums/{item_id}") - async def async_album(self, request): - """Get full album details.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - lazy = request.rel_url.query.get("lazy", "true") != "false" - if item_id is None or provider is None: - return web.Response(text="invalid item or provider", status=501) - result = await self.mass.music_manager.async_get_album( - item_id, provider, lazy=lazy - ) - return web.json_response(result, dumps=json_serializer) - - @login_required - @routes.get("/api/tracks/{item_id}") - async def async_track(self, request): - """Get full track details.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - lazy = request.rel_url.query.get("lazy", "true") != "false" - if item_id is None or provider is None: - return web.Response(text="invalid item or provider", status=501) - result = await self.mass.music_manager.async_get_track( - item_id, provider, lazy=lazy - ) - return web.json_response(result, dumps=json_serializer) - - @login_required - @routes.get("/api/playlists/{item_id}") - async def async_playlist(self, request): - """Get full playlist details.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return web.Response(text="invalid item or provider", status=501) - result = await self.mass.music_manager.async_get_playlist(item_id, provider) - return web.json_response(result, dumps=json_serializer) - - @login_required - @routes.get("/api/radios/{item_id}") - async def async_radio(self, request): - """Get full radio details.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return web.Response(text="invalid item_id or provider", status=501) - result = await self.mass.music_manager.async_get_radio(item_id, provider) - return web.json_response(result, dumps=json_serializer) - - @routes.get("/api/{media_type}/{media_id}/thumb") - async def async_get_image(self, request): - """Get (resized) thumb image.""" - media_type_str = request.match_info.get("media_type") - media_type = MediaType.from_string(media_type_str) - media_id = request.match_info.get("media_id") - provider = request.rel_url.query.get("provider") - if media_id is None or provider is None: - return web.Response(text="invalid media_id or provider", status=501) - size = int(request.rel_url.query.get("size", 0)) - img_file = await self.mass.music_manager.async_get_image_thumb( - media_id, provider, media_type, size - ) - if not img_file or not os.path.isfile(img_file): - return web.Response(status=404) - headers = {"Cache-Control": "max-age=86400, public", "Pragma": "public"} - return web.FileResponse(img_file, headers=headers) - - @login_required - @routes.get("/api/artists/{item_id}/toptracks") - async def async_artist_toptracks(self, request): - """Get top tracks for given artist.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return web.Response(text="invalid item_id or provider", status=501) - iterator = self.mass.music_manager.async_get_artist_toptracks(item_id, provider) - return await self.__async_stream_json(request, iterator) - - @login_required - @routes.get("/api/artists/{item_id}/albums") - async def async_artist_albums(self, request): - """Get (all) albums for given artist.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return web.Response(text="invalid item_id or provider", status=501) - iterator = self.mass.music_manager.async_get_artist_albums(item_id, provider) - return await self.__async_stream_json(request, iterator) - - @login_required - @routes.get("/api/playlists/{item_id}/tracks") - async def async_playlist_tracks(self, request): - """Get playlist tracks from provider.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return web.Response(text="invalid item_id or provider", status=501) - iterator = self.mass.music_manager.async_get_playlist_tracks(item_id, provider) - return await self.__async_stream_json(request, iterator) - - @login_required - @routes.put("/api/playlists/{item_id}/tracks") - async def async_add_playlist_tracks(self, request): - """Add tracks to (editable) playlist.""" - item_id = request.match_info.get("item_id") - body = await request.json() - tracks = await self.__async_media_items_from_body(body) - result = await self.mass.music_manager.async_add_playlist_tracks( - item_id, tracks - ) - return web.json_response(result, dumps=json_serializer) - - @login_required - @routes.delete("/api/playlists/{item_id}/tracks") - async def async_remove_playlist_tracks(self, request): - """Remove tracks from (editable) playlist.""" - item_id = request.match_info.get("item_id") - body = await request.json() - tracks = await self.__async_media_items_from_body(body) - result = await self.mass.music_manager.async_remove_playlist_tracks( - item_id, tracks - ) - return web.json_response(result, dumps=json_serializer) - - @login_required - @routes.get("/api/albums/{item_id}/tracks") - async def async_album_tracks(self, request): - """Get album tracks from provider.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return web.Response(text="invalid item_id or provider", status=501) - iterator = self.mass.music_manager.async_get_album_tracks(item_id, provider) - return await self.__async_stream_json(request, iterator) - - @login_required - @routes.get("/api/albums/{item_id}/versions") - async def async_album_versions(self, request): - """Get all versions of an album.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return web.Response(text="invalid item_id or provider", status=501) - iterator = self.mass.music_manager.async_get_album_versions(item_id, provider) - return await self.__async_stream_json(request, iterator) - - @login_required - @routes.get("/api/tracks/{item_id}/versions") - async def async_track_versions(self, request): - """Get all versions of an track.""" - item_id = request.match_info.get("item_id") - provider = request.rel_url.query.get("provider") - if item_id is None or provider is None: - return web.Response(text="invalid item_id or provider", status=501) - iterator = self.mass.music_manager.async_get_track_versions(item_id, provider) - return await self.__async_stream_json(request, iterator) - - @login_required - @routes.get("/api/search") - async def async_search(self, request): - """Search database and/or providers.""" - searchquery = request.rel_url.query.get("query") - media_types_query = request.rel_url.query.get("media_types") - limit = request.rel_url.query.get("limit", 5) - media_types = [] - if not media_types_query or "artists" in media_types_query: - media_types.append(MediaType.Artist) - if not media_types_query or "albums" in media_types_query: - media_types.append(MediaType.Album) - if not media_types_query or "tracks" in media_types_query: - media_types.append(MediaType.Track) - if not media_types_query or "playlists" in media_types_query: - media_types.append(MediaType.Playlist) - if not media_types_query or "radios" in media_types_query: - media_types.append(MediaType.Radio) - # get results from database - result = await self.mass.music_manager.async_global_search( - searchquery, media_types, limit=limit - ) - return web.json_response(result, dumps=json_serializer) - - @login_required - @routes.get("/api/players") - async def async_players(self, request): - # pylint: disable=unused-argument - """Get all players.""" - players = self.mass.player_manager.players - players.sort(key=lambda x: str(x.name), reverse=False) - return web.json_response(players, dumps=json_serializer) - - @login_required - @routes.post("/api/players/{player_id}/cmd/{cmd}") - async def async_player_command(self, request): - """Issue player command.""" - success = False - player_id = request.match_info.get("player_id") - cmd = request.match_info.get("cmd") - try: - cmd_args = await request.json() - except json.decoder.JSONDecodeError: - cmd_args = None - player_cmd = getattr(self.mass.player_manager, f"async_cmd_{cmd}", None) - if player_cmd and cmd_args is not None: - success = await player_cmd(player_id, cmd_args) - elif player_cmd: - success = await player_cmd(player_id) - else: - return web.Response(text="invalid command", status=501) - result = {"success": success in [True, None]} - return web.json_response(result, dumps=json_serializer) - - @login_required - @routes.post("/api/players/{player_id}/play_media/{queue_opt}") - async def async_player_play_media(self, request): - """Issue player play media command.""" - player_id = request.match_info.get("player_id") - player = self.mass.player_manager.get_player(player_id) - if not player: - return web.Response(status=404) - queue_opt = QueueOption(request.match_info.get("queue_opt", "play")) - body = await request.json() - media_items = await self.__async_media_items_from_body(body) - success = await self.mass.player_manager.async_play_media( - player_id, media_items, queue_opt - ) - result = {"success": success in [True, None]} - return web.json_response(result, dumps=json_serializer) - - @login_required - @routes.get("/api/players/{player_id}/queue/items/{queue_item}") - async def async_player_queue_item(self, request): - """Return item (by index or queue item id) from the player's queue.""" - player_id = request.match_info.get("player_id") - item_id = request.match_info.get("queue_item") - player_queue = self.mass.player_manager.get_player_queue(player_id) - try: - item_id = int(item_id) - queue_item = player_queue.get_item(item_id) - except ValueError: - queue_item = player_queue.by_item_id(item_id) - return web.json_response(queue_item, dumps=json_serializer) - - @login_required - @routes.get("/api/players/{player_id}/queue/items") - async def async_player_queue_items(self, request): - """Return the items in the player's queue.""" - player_id = request.match_info.get("player_id") - player_queue = self.mass.player_manager.get_player_queue(player_id) - - async def async_queue_tracks_iter(): - for item in player_queue.items: - yield item - - return await self.__async_stream_json(request, async_queue_tracks_iter()) - - @login_required - @routes.get("/api/players/{player_id}/queue") - async def async_player_queue(self, request): - """Return the player queue details.""" - player_id = request.match_info.get("player_id") - player_queue = self.mass.player_manager.get_player_queue(player_id) - return web.json_response(player_queue, dumps=json_serializer) - - @login_required - @routes.put("/api/players/{player_id}/queue/{cmd}") - async def async_player_queue_cmd(self, request): - """Change the player queue details.""" - player_id = request.match_info.get("player_id") - player_queue = self.mass.player_manager.get_player_queue(player_id) - cmd = request.match_info.get("cmd") - try: - cmd_args = await request.json() - except json.decoder.JSONDecodeError: - cmd_args = None - if cmd == "repeat_enabled": - player_queue.repeat_enabled = cmd_args - elif cmd == "shuffle_enabled": - player_queue.shuffle_enabled = cmd_args - elif cmd == "clear": - await player_queue.async_clear() - elif cmd == "index": - await player_queue.async_play_index(cmd_args) - elif cmd == "move_up": - await player_queue.async_move_item(cmd_args, -1) - elif cmd == "move_down": - await player_queue.async_move_item(cmd_args, 1) - elif cmd == "next": - await player_queue.async_move_item(cmd_args, 0) - return web.json_response(player_queue.to_dict(), dumps=json_serializer) - - @login_required - @routes.get("/api/players/{player_id}") - async def async_player(self, request): - """Get single player.""" - player_id = request.match_info.get("player_id") - player = self.mass.player_manager.get_player(player_id) - if not player: - return web.Response(text="invalid player", status=404) - return web.json_response(player, dumps=json_serializer) - - @login_required - @routes.get("/api/config") - async def async_get_config(self, request): - # pylint: disable=unused-argument - """Get the full config.""" - conf = { - CONF_KEY_BASE: self.mass.config.base, - CONF_KEY_PROVIDERS: self.mass.config.providers, - CONF_KEY_PLAYERSETTINGS: self.mass.config.player_settings, - } - return web.json_response(conf, dumps=json_serializer) - - @login_required - @routes.get("/api/config/{base}") - async def async_get_config_item(self, request): - """Get the config by base type.""" - conf_base = request.match_info.get("base") - conf = self.mass.config[conf_base] - return web.json_response(conf, dumps=json_serializer) - - @login_required - @routes.put("/api/config/{base}/{key}/{entry_key}") - async def async_put_config(self, request): - """Save the given config item.""" - conf_key = request.match_info.get("key") - conf_base = request.match_info.get("base") - entry_key = request.match_info.get("entry_key") - try: - new_value = await request.json() - except json.decoder.JSONDecodeError: - new_value = ( - self.mass.config[conf_base][conf_key].get_entry(entry_key).default_value - ) - self.mass.config[conf_base][conf_key][entry_key] = new_value - return web.json_response(True) - - async def async_websocket_handler(self, request): - """Handle websockets connection.""" - ws_response = None - authenticated = False - remove_callbacks = [] - try: - ws_response = web.WebSocketResponse() - await ws_response.prepare(request) - - # callback for internal events - async def async_send_message(msg, msg_details=None): - ws_msg = {"message": msg, "message_details": msg_details} - try: - await ws_response.send_json(ws_msg, dumps=json_serializer) - except AssertionError: - LOGGER.debug("trying to send message to ws while disconnected") - - # process incoming messages - async for msg in ws_response: - if msg.type != aiohttp.WSMsgType.TEXT: - # not sure when/if this happens but log it anyway - LOGGER.warning(msg.data) - continue - try: - data = msg.json() - except json.decoder.JSONDecodeError: - await async_send_message( - "error", - 'commands must be issued in json format \ - {"message": "command", "message_details":" optional details"}', - ) - continue - msg = data.get("message") - msg_details = data.get("message_details") - if not authenticated and not msg == "login": - # make sure client is authenticated - await async_send_message("error", "authentication required") - elif msg == "login" and msg_details: - # authenticate with token - try: - token_info = jwt.decode(msg_details, self._jwt_shared_secret) - except jwt.InvalidTokenError as exc: - LOGGER.exception(exc, exc_info=exc) - error_msg = "Invalid authorization token, " + str(exc) - await async_send_message("error", error_msg) - else: - authenticated = True - await async_send_message("login", token_info) - elif msg == "add_event_listener": - remove_callbacks.append( - self.mass.add_event_listener(async_send_message, msg_details) - ) - await async_send_message("event listener subscribed", msg_details) - elif msg == "player_command": - player_id = msg_details.get("player_id") - cmd = msg_details.get("cmd") - cmd_args = msg_details.get("cmd_args") - player_cmd = getattr( - self.mass.player_manager, f"async_cmd_{cmd}", None - ) - if player_cmd and cmd_args is not None: - result = await player_cmd(player_id, cmd_args) - elif player_cmd: - result = await player_cmd(player_id) - msg_details = {"cmd": cmd, "result": result} - await async_send_message("player_command_result", msg_details) - else: - # simply echo the message on the eventbus - self.mass.signal_event(msg, msg_details) - except (AssertionError, asyncio.CancelledError): - LOGGER.debug("Websocket disconnected") - finally: - for remove_callback in remove_callbacks: - remove_callback() - return ws_response - - @require_local_subnet - async def async_json_rpc(self, request): - """ - Implement LMS jsonrpc interface. - - for some compatability with tools that talk to lms - only support for basic commands - """ - # pylint: disable=too-many-branches - data = await request.json() - LOGGER.debug("jsonrpc: %s", data) - params = data["params"] - player_id = params[0] - cmds = params[1] - cmd_str = " ".join(cmds) - if cmd_str == "play": - await self.mass.player_manager.async_cmd_play(player_id) - elif cmd_str == "pause": - await self.mass.player_manager.async_cmd_pause(player_id) - elif cmd_str == "stop": - await self.mass.player_manager.async_cmd_stop(player_id) - elif cmd_str == "next": - await self.mass.player_manager.async_cmd_next(player_id) - elif cmd_str == "previous": - await self.mass.player_manager.async_cmd_previous(player_id) - elif "power" in cmd_str: - powered = cmds[1] if len(cmds) > 1 else False - if powered: - await self.mass.player_manager.async_cmd_power_on(player_id) - else: - await self.mass.player_manager.async_cmd_power_off(player_id) - elif cmd_str == "playlist index +1": - await self.mass.player_manager.async_cmd_next(player_id) - elif cmd_str == "playlist index -1": - await self.mass.player_manager.async_cmd_previous(player_id) - elif "mixer volume" in cmd_str and "+" in cmds[2]: - player = self.mass.player_manager.get_player(player_id) - volume_level = player.volume_level + int(cmds[2].split("+")[1]) - await self.mass.player_manager.async_cmd_volume_set(player_id, volume_level) - elif "mixer volume" in cmd_str and "-" in cmds[2]: - player = self.mass.player_manager.get_player(player_id) - volume_level = player.volume_level - int(cmds[2].split("-")[1]) - await self.mass.player_manager.async_cmd_volume_set(player_id, volume_level) - elif "mixer volume" in cmd_str: - await self.mass.player_manager.async_cmd_volume_set(player_id, cmds[2]) - elif cmd_str == "mixer muting 1": - await self.mass.player_manager.async_cmd_volume_mute(player_id, True) - elif cmd_str == "mixer muting 0": - await self.mass.player_manager.async_cmd_volume_mute(player_id, False) - elif cmd_str == "button volup": - await self.mass.player_manager.async_cmd_volume_up(player_id) - elif cmd_str == "button voldown": - await self.mass.player_manager.async_cmd_volume_down(player_id) - elif cmd_str == "button power": - await self.mass.player_manager.async_cmd_power_toggle(player_id) - else: - return web.Response(text="command not supported") - return web.Response(text="success") - - async def __async_media_items_from_body(self, data): - """Convert posted body data into media items.""" - if not isinstance(data, list): - data = [data] - media_items = [] - for item in data: - media_item = await self.mass.music_manager.async_get_item( - item["item_id"], - item["provider"], - MediaType.from_string(item["media_type"]), - lazy=True, - ) - media_items.append(media_item) - return media_items - - async def __async_stream_json(self, request, iterator): - """Stream items from async iterator as json object.""" - resp = web.StreamResponse( - status=200, reason="OK", headers={"Content-Type": "application/json"} - ) - await resp.prepare(request) - # write json open tag - json_response = '{ "items": [' - await resp.write(json_response.encode("utf-8")) - count = 0 - async for item in iterator: - # write each item into the items object of the json - if count: - json_response = "," + json_serializer(item) - else: - json_response = json_serializer(item) - await resp.write(json_response.encode("utf-8")) - count += 1 - # write json close tag - json_response = '], "count": %s }' % count - await resp.write((json_response).encode("utf-8")) - await resp.write_eof() - return resp - - async def __async_get_token(self, username, password): - """Validate given credentials and return JWT token.""" - verified = self.mass.config.validate_credentials(username, password) - if verified: - token_expires = datetime.datetime.utcnow() + datetime.timedelta(hours=8) - scopes = ["user:admin"] # scopes not yet implemented - token = jwt.encode( - {"username": username, "scopes": scopes, "exp": token_expires}, - self._jwt_shared_secret, - ) - return { - "user": username, - "token": token.decode(), - "expires": token_expires, - "scopes": scopes, - } - return None diff --git a/music_assistant/web/__init__.py b/music_assistant/web/__init__.py new file mode 100755 index 00000000..be1d43f4 --- /dev/null +++ b/music_assistant/web/__init__.py @@ -0,0 +1,202 @@ +"""The web module handles serving the frontend and the rest/websocket api's.""" +import logging +import os +import ssl +import uuid + +import aiohttp_cors +from aiohttp import web +from aiohttp_jwt import JWTMiddleware +from music_assistant.constants import __version__ as MASS_VERSION +from music_assistant.helpers.util import get_hostname, get_ip, json_serializer + +from .endpoints import ( + albums, + artists, + config, + images, + json_rpc, + library, + login, + players, + playlists, + radios, + search, + streams, + tracks, + websocket, +) + +LOGGER = logging.getLogger("mass") + + +routes = web.RouteTableDef() + + +class WebServer: + """Webserver and json/websocket api.""" + + def __init__(self, mass): + """Initialize class.""" + self.mass = mass + # load/create/update config + self._local_ip = get_ip() + self._device_id = f"{uuid.getnode()}_{get_hostname()}" + self.config = mass.config.base["web"] + self._runner = None + + enable_ssl = self.config["ssl_certificate"] and self.config["ssl_key"] + if self.config["ssl_certificate"] and not os.path.isfile( + self.config["ssl_certificate"] + ): + enable_ssl = False + LOGGER.warning( + "SSL certificate file not found: %s", self.config["ssl_certificate"] + ) + if self.config["ssl_key"] and not os.path.isfile(self.config["ssl_key"]): + enable_ssl = False + LOGGER.warning( + "SSL certificate key file not found: %s", self.config["ssl_key"] + ) + if not self.config.get("external_url"): + enable_ssl = False + self._enable_ssl = enable_ssl + + async def async_setup(self): + """Perform async setup.""" + + jwt_middleware = JWTMiddleware( + self.device_id, request_property="user", credentials_required=False + ) + app = web.Application(middlewares=[jwt_middleware]) + app["mass"] = self.mass + # add routes + app.add_routes(albums.routes) + app.add_routes(artists.routes) + app.add_routes(config.routes) + app.add_routes(images.routes) + app.add_routes(json_rpc.routes) + app.add_routes(library.routes) + app.add_routes(login.routes) + app.add_routes(players.routes) + app.add_routes(playlists.routes) + app.add_routes(radios.routes) + app.add_routes(search.routes) + app.add_routes(streams.routes) + app.add_routes(tracks.routes) + app.add_routes(websocket.routes) + app.add_routes(routes) + + webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web/static/") + if os.path.isdir(webdir): + app.router.add_static("/", webdir, append_version=True) + else: + # The (minified) build of the frontend(app) is included in the pypi releases + LOGGER.warning("Loaded without frontend support.") + + # Add CORS support to all routes + cors = aiohttp_cors.setup( + app, + defaults={ + "*": aiohttp_cors.ResourceOptions( + allow_credentials=True, + expose_headers="*", + allow_headers="*", + allow_methods=["POST", "PUT", "DELETE", "GET"], + ) + }, + ) + for route in list(app.router.routes()): + cors.add(route) + self._runner = web.AppRunner(app, access_log=None) + await self._runner.setup() + http_site = web.TCPSite(self._runner, "0.0.0.0", self.http_port) + await http_site.start() + LOGGER.info("Started HTTP webserver on port %s", self.http_port) + if self._enable_ssl: + ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ssl_context.load_cert_chain( + self.config["ssl_certificate"], self.config["ssl_key"] + ) + https_site = web.TCPSite( + self._runner, "0.0.0.0", self.https_port, ssl_context=ssl_context + ) + await https_site.start() + LOGGER.info( + "Started HTTPS webserver on port %s - serving at FQDN %s", + self.https_port, + self.external_url, + ) + + async def async_stop(self): + """Stop the webserver.""" + # if self._runner: + # await self._runner.cleanup() + + @property + def internal_ip(self): + """Return the local IP address for this Music Assistant instance.""" + return self._local_ip + + @property + def http_port(self): + """Return the HTTP port for this Music Assistant instance.""" + return self.config.get("http_port", 8095) + + @property + def https_port(self): + """Return the HTTPS port for this Music Assistant instance.""" + return self.config.get("https_port", 8096) + + @property + def internal_url(self): + """Return the internal URL for this Music Assistant instance.""" + return f"http://{self._local_ip}:{self.http_port}" + + @property + def external_url(self): + """Return the internal URL for this Music Assistant instance.""" + if self._enable_ssl and self.config.get("external_url"): + return self.config["external_url"] + return self.internal_url + + @property + def device_id(self): + """Return the device ID for this Music Assistant Server.""" + return self._device_id + + @property + def discovery_info(self): + """Return (discovery) info about this instance.""" + return { + "id": self._device_id, + "external_url": self.external_url, + "internal_url": self.internal_url, + "host": self.internal_ip, + "http_port": self.http_port, + "https_port": self.https_port, + "ssl_enabled": self._enable_ssl, + "version": MASS_VERSION, + } + + +@routes.get("/api/info") +async def async_discovery_info(request: web.Request): + # pylint: disable=unused-argument + """Return (discovery) info about this instance.""" + return web.Response( + body=json_serializer(request.app["mass"].web.discovery_info), + content_type="application/json", + ) + + +@routes.get("/") +async def async_index(request: web.Request): + """Get the index page, redirect if we do not have a web directory.""" + # pylint: disable=unused-argument + html_app = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "web/static/index.html" + ) + if not os.path.isfile(html_app): + raise web.HTTPFound("https://music-assistant.github.io/app") + return web.FileResponse(html_app) diff --git a/music_assistant/web/endpoints/__init__.py b/music_assistant/web/endpoints/__init__.py new file mode 100644 index 00000000..fc1c4cc7 --- /dev/null +++ b/music_assistant/web/endpoints/__init__.py @@ -0,0 +1 @@ +"""Web endpoints package.""" diff --git a/music_assistant/web/endpoints/albums.py b/music_assistant/web/endpoints/albums.py new file mode 100644 index 00000000..f4d151e3 --- /dev/null +++ b/music_assistant/web/endpoints/albums.py @@ -0,0 +1,55 @@ +"""Albums API endpoints.""" + +from aiohttp import web +from aiohttp_jwt import login_required +from music_assistant.helpers.util import json_serializer +from music_assistant.helpers.web import async_stream_json + +routes = web.RouteTableDef() + + +@routes.get("/api/albums") +@login_required +async def async_albums(request: web.Request): + """Get all albums known in the database.""" + generator = request.app["mass"].database.async_get_albums() + return await async_stream_json(request, generator) + + +@routes.get("/api/albums/{item_id}") +@login_required +async def async_album(request: web.Request): + """Get full album details.""" + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + lazy = request.rel_url.query.get("lazy", "true") != "false" + if item_id is None or provider is None: + return web.Response(text="invalid item or provider", status=501) + result = await request.app["mass"].music.async_get_album( + item_id, provider, lazy=lazy + ) + return web.Response(body=json_serializer(result), content_type="application/json") + + +@routes.get("/api/albums/{item_id}/tracks") +@login_required +async def async_album_tracks(request: web.Request): + """Get album tracks from provider.""" + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + if item_id is None or provider is None: + return web.Response(text="invalid item_id or provider", status=501) + generator = request.app["mass"].music.async_get_album_tracks(item_id, provider) + return await async_stream_json(request, generator) + + +@routes.get("/api/albums/{item_id}/versions") +@login_required +async def async_album_versions(request): + """Get all versions of an album.""" + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + if item_id is None or provider is None: + return web.Response(text="invalid item_id or provider", status=501) + generator = request.app["mass"].music.async_get_album_versions(item_id, provider) + return await async_stream_json(request, generator) diff --git a/music_assistant/web/endpoints/artists.py b/music_assistant/web/endpoints/artists.py new file mode 100644 index 00000000..b1712483 --- /dev/null +++ b/music_assistant/web/endpoints/artists.py @@ -0,0 +1,55 @@ +"""Artists API endpoints.""" + +from aiohttp import web +from aiohttp_jwt import login_required +from music_assistant.helpers.util import json_serializer +from music_assistant.helpers.web import async_stream_json + +routes = web.RouteTableDef() + + +@routes.get("/api/artists") +@login_required +async def async_artists(request: web.Request): + """Get all artists known in the database.""" + generator = request.app["mass"].database.async_get_artists() + return await async_stream_json(request, generator) + + +@routes.get("/api/artists/{item_id}") +@login_required +async def async_artist(request: web.Request): + """Get full artist details.""" + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + lazy = request.rel_url.query.get("lazy", "true") != "false" + if item_id is None or provider is None: + return web.Response(text="invalid item or provider", status=501) + result = await request.app["mass"].music.async_get_artist( + item_id, provider, lazy=lazy + ) + return web.Response(body=json_serializer(result), content_type="application/json") + + +@routes.get("/api/artists/{item_id}/toptracks") +@login_required +async def async_artist_toptracks(request: web.Request): + """Get top tracks for given artist.""" + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + if item_id is None or provider is None: + return web.Response(text="invalid item_id or provider", status=501) + generator = request.app["mass"].music.async_get_artist_toptracks(item_id, provider) + return await async_stream_json(request, generator) + + +@routes.get("/api/artists/{item_id}/albums") +@login_required +async def async_artist_albums(request: web.Request): + """Get (all) albums for given artist.""" + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + if item_id is None or provider is None: + return web.Response(text="invalid item_id or provider", status=501) + generator = request.app["mass"].music.async_get_artist_albums(item_id, provider) + return await async_stream_json(request, generator) diff --git a/music_assistant/web/endpoints/config.py b/music_assistant/web/endpoints/config.py new file mode 100644 index 00000000..9e014225 --- /dev/null +++ b/music_assistant/web/endpoints/config.py @@ -0,0 +1,72 @@ +"""Config API endpoints.""" + +import orjson +from aiohttp import web +from aiohttp_jwt import login_required +from music_assistant.constants import ( + CONF_KEY_BASE, + CONF_KEY_METADATA_PROVIDERS, + CONF_KEY_MUSIC_PROVIDERS, + CONF_KEY_PLAYER_PROVIDERS, + CONF_KEY_PLAYER_SETTINGS, + CONF_KEY_PLUGINS, +) +from music_assistant.helpers.util import json_serializer + +routes = web.RouteTableDef() + + +@routes.get("/api/config") +@login_required +async def async_get_config(request: web.Request): + """Get the full config.""" + language = request.rel_url.query.get("lang", "en") + conf = { + CONF_KEY_BASE: request.app["mass"].config.base.to_dict(language), + CONF_KEY_MUSIC_PROVIDERS: request.app["mass"].config.music_providers.to_dict( + language + ), + CONF_KEY_PLAYER_PROVIDERS: request.app["mass"].config.player_providers.to_dict( + language + ), + CONF_KEY_METADATA_PROVIDERS: request.app[ + "mass" + ].config.metadata_providers.to_dict(language), + CONF_KEY_PLUGINS: request.app["mass"].config.plugins.to_dict(language), + CONF_KEY_PLAYER_SETTINGS: request.app["mass"].config.player_settings.to_dict( + language + ), + } + return web.Response(body=json_serializer(conf), content_type="application/json") + + +@routes.get("/api/config/{base}") +@login_required +async def async_get_config_item(request: web.Request): + """Get the config by base type.""" + language = request.rel_url.query.get("lang", "en") + conf_base = request.match_info.get("base") + conf = request.app["mass"].config[conf_base] + return web.Response( + body=json_serializer(conf.to_dict(language)), content_type="application/json" + ) + + +@routes.put("/api/config/{base}/{key}/{entry_key}") +@login_required +async def async_put_config(request: web.Request): + """Save the given config item.""" + conf_key = request.match_info.get("key") + conf_base = request.match_info.get("base") + entry_key = request.match_info.get("entry_key") + try: + new_value = await request.json(loads=orjson.loads) + except orjson.decoder.JSONDecodeError: + new_value = ( + request.app["mass"] + .config[conf_base][conf_key] + .get_entry(entry_key) + .default_value + ) + request.app["mass"].config[conf_base][conf_key][entry_key] = new_value + return web.json_response(True) diff --git a/music_assistant/web/endpoints/images.py b/music_assistant/web/endpoints/images.py new file mode 100644 index 00000000..0b849f86 --- /dev/null +++ b/music_assistant/web/endpoints/images.py @@ -0,0 +1,40 @@ +"""Images API endpoints.""" + + +import os + +from aiohttp import web +from music_assistant.models.media_types import MediaType + +routes = web.RouteTableDef() + + +@routes.get("/api/providers/{provider_id}/icon") +async def async_get_provider_icon(request: web.Request): + """Get Provider icon.""" + provider_id = request.match_info.get("provider_id") + base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + icon_path = os.path.join(base_dir, "..", "providers", provider_id, "icon.png") + if os.path.isfile(icon_path): + headers = {"Cache-Control": "max-age=86400, public", "Pragma": "public"} + return web.FileResponse(icon_path, headers=headers) + return web.Response(status=404) + + +@routes.get("/api/{media_type}/{media_id}/thumb") +async def async_get_image(request: web.Request): + """Get (resized) thumb image.""" + media_type_str = request.match_info.get("media_type") + media_type = MediaType.from_string(media_type_str) + media_id = request.match_info.get("media_id") + provider = request.rel_url.query.get("provider") + if media_id is None or provider is None: + return web.Response(text="invalid media_id or provider", status=501) + size = int(request.rel_url.query.get("size", 0)) + img_file = await request.app["mass"].music.async_get_image_thumb( + media_id, provider, media_type, size + ) + if not img_file or not os.path.isfile(img_file): + return web.Response(status=404) + headers = {"Cache-Control": "max-age=86400, public", "Pragma": "public"} + return web.FileResponse(img_file, headers=headers) diff --git a/music_assistant/web/endpoints/json_rpc.py b/music_assistant/web/endpoints/json_rpc.py new file mode 100644 index 00000000..9dbb7523 --- /dev/null +++ b/music_assistant/web/endpoints/json_rpc.py @@ -0,0 +1,67 @@ +"""JSON RPC API endpoint.""" + +from aiohttp import web +from music_assistant.helpers.web import require_local_subnet + +routes = web.RouteTableDef() + + +@routes.route("get", "/jsonrpc.js") +@routes.route("post", "/jsonrpc.js") +@require_local_subnet +async def async_json_rpc(request: web.Request): + """ + Implement LMS jsonrpc interface. + + for some compatability with tools that talk to lms + only support for basic commands + """ + # pylint: disable=too-many-branches + data = await request.json() + params = data["params"] + player_id = params[0] + cmds = params[1] + cmd_str = " ".join(cmds) + if cmd_str == "play": + await request.app["mass"].players.async_cmd_play(player_id) + elif cmd_str == "pause": + await request.app["mass"].players.async_cmd_pause(player_id) + elif cmd_str == "stop": + await request.app["mass"].players.async_cmd_stop(player_id) + elif cmd_str == "next": + await request.app["mass"].players.async_cmd_next(player_id) + elif cmd_str == "previous": + await request.app["mass"].players.async_cmd_previous(player_id) + elif "power" in cmd_str: + powered = cmds[1] if len(cmds) > 1 else False + if powered: + await request.app["mass"].players.async_cmd_power_on(player_id) + else: + await request.app["mass"].players.async_cmd_power_off(player_id) + elif cmd_str == "playlist index +1": + await request.app["mass"].players.async_cmd_next(player_id) + elif cmd_str == "playlist index -1": + await request.app["mass"].players.async_cmd_previous(player_id) + elif "mixer volume" in cmd_str and "+" in cmds[2]: + player_state = request.app["mass"].players.get_player_state(player_id) + volume_level = player_state.volume_level + int(cmds[2].split("+")[1]) + await request.app["mass"].players.async_cmd_volume_set(player_id, volume_level) + elif "mixer volume" in cmd_str and "-" in cmds[2]: + player_state = request.app["mass"].players.get_player_state(player_id) + volume_level = player_state.volume_level - int(cmds[2].split("-")[1]) + await request.app["mass"].players.async_cmd_volume_set(player_id, volume_level) + elif "mixer volume" in cmd_str: + await request.app["mass"].players.async_cmd_volume_set(player_id, cmds[2]) + elif cmd_str == "mixer muting 1": + await request.app["mass"].players.async_cmd_volume_mute(player_id, True) + elif cmd_str == "mixer muting 0": + await request.app["mass"].players.async_cmd_volume_mute(player_id, False) + elif cmd_str == "button volup": + await request.app["mass"].players.async_cmd_volume_up(player_id) + elif cmd_str == "button voldown": + await request.app["mass"].players.async_cmd_volume_down(player_id) + elif cmd_str == "button power": + await request.app["mass"].players.async_cmd_power_toggle(player_id) + else: + return web.Response(text="command not supported") + return web.Response(text="success") diff --git a/music_assistant/web/endpoints/library.py b/music_assistant/web/endpoints/library.py new file mode 100644 index 00000000..46c25039 --- /dev/null +++ b/music_assistant/web/endpoints/library.py @@ -0,0 +1,88 @@ +"""Library API endpoints.""" + +from aiohttp import web +from aiohttp_jwt import login_required +from music_assistant.helpers.util import json_serializer +from music_assistant.helpers.web import async_media_items_from_body, async_stream_json + +routes = web.RouteTableDef() + + +@routes.get("/api/library/artists") +@login_required +async def async_library_artists(request: web.Request): + """Get all library artists.""" + orderby = request.query.get("orderby", "name") + provider_filter = request.rel_url.query.get("provider") + generator = request.app["mass"].music.async_get_library_artists( + orderby=orderby, provider_filter=provider_filter + ) + return await async_stream_json(request, generator) + + +@routes.get("/api/library/albums") +@login_required +async def async_library_albums(request: web.Request): + """Get all library albums.""" + orderby = request.query.get("orderby", "name") + provider_filter = request.rel_url.query.get("provider") + generator = request.app["mass"].music.async_get_library_albums( + orderby=orderby, provider_filter=provider_filter + ) + return await async_stream_json(request, generator) + + +@routes.get("/api/library/tracks") +@login_required +async def async_library_tracks(request: web.Request): + """Get all library tracks.""" + orderby = request.query.get("orderby", "name") + provider_filter = request.rel_url.query.get("provider") + generator = request.app["mass"].music.async_get_library_tracks( + orderby=orderby, provider_filter=provider_filter + ) + return await async_stream_json(request, generator) + + +@routes.get("/api/library/radios") +@login_required +async def async_library_radios(request: web.Request): + """Get all library radios.""" + orderby = request.query.get("orderby", "name") + provider_filter = request.rel_url.query.get("provider") + generator = request.app["mass"].music.async_get_library_radios( + orderby=orderby, provider_filter=provider_filter + ) + return await async_stream_json(request, generator) + + +@routes.get("/api/library/playlists") +@login_required +async def async_library_playlists(request: web.Request): + """Get all library playlists.""" + orderby = request.query.get("orderby", "name") + provider_filter = request.rel_url.query.get("provider") + generator = request.app["mass"].music.async_get_library_playlists( + orderby=orderby, provider_filter=provider_filter + ) + return await async_stream_json(request, generator) + + +@routes.put("/api/library") +@login_required +async def async_library_add(request: web.Request): + """Add item(s) to the library.""" + body = await request.json() + media_items = await async_media_items_from_body(request.app["mass"], body) + result = await request.app["mass"].music.async_library_add(media_items) + return web.Response(body=json_serializer(result), content_type="application/json") + + +@routes.delete("/api/library") +@login_required +async def async_library_remove(request: web.Request): + """Remove item(s) from the library.""" + body = await request.json() + media_items = await async_media_items_from_body(request.app["mass"], body) + result = await request.app["mass"].music.async_library_remove(media_items) + return web.Response(body=json_serializer(result), content_type="application/json") diff --git a/music_assistant/web/endpoints/login.py b/music_assistant/web/endpoints/login.py new file mode 100644 index 00000000..07e6081c --- /dev/null +++ b/music_assistant/web/endpoints/login.py @@ -0,0 +1,46 @@ +"""Login API endpoints.""" + +import datetime + +import jwt +from aiohttp import web +from music_assistant.helpers.typing import MusicAssistantType +from music_assistant.helpers.util import json_serializer + +routes = web.RouteTableDef() + + +@routes.post("/login") +@routes.post("/api/login") +async def async_login(request: web.Request): + """Handle the retrieval of a JWT token.""" + form = await request.json() + username = form.get("username") + password = form.get("password") + token_info = await async_get_token(request.app["mass"], username, password) + if token_info: + return web.Response( + body=json_serializer(token_info), content_type="application/json" + ) + return web.HTTPUnauthorized(body="Invalid username and/or password provided!") + + +async def async_get_token( + mass: MusicAssistantType, username: str, password: str +) -> dict: + """Validate given credentials and return JWT token.""" + verified = mass.config.validate_credentials(username, password) + if verified: + token_expires = datetime.datetime.utcnow() + datetime.timedelta(hours=8) + scopes = ["user:admin"] # scopes not yet implemented + token = jwt.encode( + {"username": username, "scopes": scopes, "exp": token_expires}, + mass.web.device_id, + ) + return { + "user": username, + "token": token.decode(), + "expires": token_expires, + "scopes": scopes, + } + return None diff --git a/music_assistant/web/endpoints/players.py b/music_assistant/web/endpoints/players.py new file mode 100644 index 00000000..ee40ba66 --- /dev/null +++ b/music_assistant/web/endpoints/players.py @@ -0,0 +1,146 @@ +"""Players API endpoints.""" + +import orjson +from aiohttp import web +from aiohttp_jwt import login_required +from music_assistant.helpers.util import json_serializer +from music_assistant.helpers.web import async_media_items_from_body, async_stream_json +from music_assistant.models.player_queue import QueueOption + +routes = web.RouteTableDef() + + +@routes.get("/api/players") +@login_required +async def async_players(request: web.Request): + # pylint: disable=unused-argument + """Get all playerstates.""" + player_states = request.app["mass"].players.player_states + player_states.sort(key=lambda x: str(x.name), reverse=False) + players = [player_state.to_dict() for player_state in player_states] + return web.Response(body=json_serializer(players), content_type="application/json") + + +@routes.post("/api/players/{player_id}/cmd/{cmd}") +@login_required +async def async_player_command(request: web.Request): + """Issue player command.""" + success = False + player_id = request.match_info.get("player_id") + cmd = request.match_info.get("cmd") + try: + cmd_args = await request.json(loads=orjson.loads) + except orjson.decoder.JSONDecodeError: + cmd_args = None + player_cmd = getattr(request.app["mass"].players, f"async_cmd_{cmd}", None) + if player_cmd and cmd_args is not None: + success = await player_cmd(player_id, cmd_args) + elif player_cmd: + success = await player_cmd(player_id) + else: + return web.Response(text="invalid command", status=501) + result = {"success": success in [True, None]} + return web.Response(body=json_serializer(result), content_type="application/json") + + +@routes.post("/api/players/{player_id}/play_media/{queue_opt}") +@login_required +async def async_player_play_media(request: web.Request): + """Issue player play media command.""" + player_id = request.match_info.get("player_id") + player_state = request.app["mass"].players.get_player_state(player_id) + if not player_state: + return web.Response(status=404) + queue_opt = QueueOption(request.match_info.get("queue_opt", "play")) + body = await request.json() + media_items = await async_media_items_from_body(request.app["mass"], body) + success = await request.app["mass"].players.async_play_media( + player_id, media_items, queue_opt + ) + result = {"success": success in [True, None]} + return web.Response(body=json_serializer(result), content_type="application/json") + + +@routes.get("/api/players/{player_id}/queue/items/{queue_item}") +@login_required +async def async_player_queue_item(request: web.Request): + """Return item (by index or queue item id) from the player's queue.""" + player_id = request.match_info.get("player_id") + item_id = request.match_info.get("queue_item") + player_queue = request.app["mass"].players.get_player_queue(player_id) + try: + item_id = int(item_id) + queue_item = player_queue.get_item(item_id) + except ValueError: + queue_item = player_queue.by_item_id(item_id) + return web.Response( + body=json_serializer(queue_item), content_type="application/json" + ) + + +@routes.get("/api/players/{player_id}/queue/items") +@login_required +async def async_player_queue_items(request: web.Request): + """Return the items in the player's queue.""" + player_id = request.match_info.get("player_id") + player_queue = request.app["mass"].players.get_player_queue(player_id) + + async def async_queue_tracks_iter(): + for item in player_queue.items: + yield item + + return await async_stream_json(request, async_queue_tracks_iter()) + + +@routes.get("/api/players/{player_id}/queue") +@login_required +async def async_player_queue(request: web.Request): + """Return the player queue details.""" + player_id = request.match_info.get("player_id") + player_queue = request.app["mass"].players.get_player_queue(player_id) + return web.Response( + body=json_serializer(player_queue.to_dict()), content_type="application/json" + ) + + +@routes.put("/api/players/{player_id}/queue/{cmd}") +@login_required +async def async_player_queue_cmd(request: web.Request): + """Change the player queue details.""" + player_id = request.match_info.get("player_id") + player_queue = request.app["mass"].players.get_player_queue(player_id) + cmd = request.match_info.get("cmd") + try: + cmd_args = await request.json(loads=orjson.loads) + except orjson.decoder.JSONDecodeError: + cmd_args = None + if cmd == "repeat_enabled": + player_queue.repeat_enabled = cmd_args + elif cmd == "shuffle_enabled": + player_queue.shuffle_enabled = cmd_args + elif cmd == "clear": + await player_queue.async_clear() + elif cmd == "index": + await player_queue.async_play_index(cmd_args) + elif cmd == "move_up": + await player_queue.async_move_item(cmd_args, -1) + elif cmd == "move_down": + await player_queue.async_move_item(cmd_args, 1) + elif cmd == "next": + await player_queue.async_move_item(cmd_args, 0) + return web.Response( + body=json_serializer(player_queue.to_dict()), content_type="application/json" + ) + + +@routes.get("/api/players/{player_id}") +@login_required +async def async_player(request: web.Request): + """Get state of single player.""" + player_id = request.match_info.get("player_id") + player_state = request.app["mass"].players.get_player_state(player_id) + if not player_state: + return web.Response(text="invalid player", status=404) + return web.Response( + body=json_serializer(player_state.to_dict()), content_type="application/json" + ) diff --git a/music_assistant/web/endpoints/playlists.py b/music_assistant/web/endpoints/playlists.py new file mode 100644 index 00000000..8b867949 --- /dev/null +++ b/music_assistant/web/endpoints/playlists.py @@ -0,0 +1,56 @@ +"""Playlists API endpoints.""" + +from aiohttp import web +from aiohttp_jwt import login_required +from music_assistant.helpers.util import json_serializer +from music_assistant.helpers.web import async_media_items_from_body, async_stream_json + +routes = web.RouteTableDef() + + +@routes.get("/api/playlists/{item_id}") +@login_required +async def async_playlist(request: web.Request): + """Get full playlist details.""" + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + if item_id is None or provider is None: + return web.Response(text="invalid item or provider", status=501) + result = await request.app["mass"].music.async_get_playlist(item_id, provider) + return web.Response(body=json_serializer(result), content_type="application/json") + + +@routes.get("/api/playlists/{item_id}/tracks") +@login_required +async def async_playlist_tracks(request: web.Request): + """Get playlist tracks from provider.""" + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + if item_id is None or provider is None: + return web.Response(text="invalid item_id or provider", status=501) + generator = request.app["mass"].music.async_get_playlist_tracks(item_id, provider) + return await async_stream_json(request, generator) + + +@routes.put("/api/playlists/{item_id}/tracks") +@login_required +async def async_add_playlist_tracks(request: web.Request): + """Add tracks to (editable) playlist.""" + item_id = request.match_info.get("item_id") + body = await request.json() + tracks = await async_media_items_from_body(request.app["mass"], body) + result = await request.app["mass"].music.async_add_playlist_tracks(item_id, tracks) + return web.Response(body=json_serializer(result), content_type="application/json") + + +@routes.delete("/api/playlists/{item_id}/tracks") +@login_required +async def async_remove_playlist_tracks(request: web.Request): + """Remove tracks from (editable) playlist.""" + item_id = request.match_info.get("item_id") + body = await request.json() + tracks = await async_media_items_from_body(request.app["mass"], body) + result = await request.app["mass"].music.async_remove_playlist_tracks( + item_id, tracks + ) + return web.Response(body=json_serializer(result), content_type="application/json") diff --git a/music_assistant/web/endpoints/radios.py b/music_assistant/web/endpoints/radios.py new file mode 100644 index 00000000..3407df74 --- /dev/null +++ b/music_assistant/web/endpoints/radios.py @@ -0,0 +1,28 @@ +"""Tracks API endpoints.""" + +from aiohttp import web +from aiohttp_jwt import login_required +from music_assistant.helpers.util import json_serializer +from music_assistant.helpers.web import async_stream_json + +routes = web.RouteTableDef() + + +@routes.get("/api/radios") +@login_required +async def async_radios(request: web.Request): + """Get all radios known in the database.""" + generator = request.app["mass"].database.async_get_radios() + return await async_stream_json(request, generator) + + +@routes.get("/api/radios/{item_id}") +@login_required +async def async_radio(request: web.Request): + """Get full radio details.""" + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + if item_id is None or provider is None: + return web.Response(text="invalid item_id or provider", status=501) + result = await request.app["mass"].music.async_get_radio(item_id, provider) + return web.Response(body=json_serializer(result), content_type="application/json") diff --git a/music_assistant/web/endpoints/search.py b/music_assistant/web/endpoints/search.py new file mode 100644 index 00000000..a4175e76 --- /dev/null +++ b/music_assistant/web/endpoints/search.py @@ -0,0 +1,33 @@ +"""Search API endpoints.""" + +from aiohttp import web +from aiohttp_jwt import login_required +from music_assistant.helpers.util import json_serializer +from music_assistant.models.media_types import MediaType + +routes = web.RouteTableDef() + + +@routes.get("/api/search") +@login_required +async def async_search(request: web.Request): + """Search database and/or providers.""" + searchquery = request.rel_url.query.get("query") + media_types_query = request.rel_url.query.get("media_types") + limit = request.rel_url.query.get("limit", 5) + media_types = [] + if not media_types_query or "artists" in media_types_query: + media_types.append(MediaType.Artist) + if not media_types_query or "albums" in media_types_query: + media_types.append(MediaType.Album) + if not media_types_query or "tracks" in media_types_query: + media_types.append(MediaType.Track) + if not media_types_query or "playlists" in media_types_query: + media_types.append(MediaType.Playlist) + if not media_types_query or "radios" in media_types_query: + media_types.append(MediaType.Radio) + + result = await request.app["mass"].music.async_global_search( + searchquery, media_types, limit=limit + ) + return web.Response(body=json_serializer(result), content_type="application/json") diff --git a/music_assistant/web/endpoints/streams.py b/music_assistant/web/endpoints/streams.py new file mode 100644 index 00000000..c8932fe4 --- /dev/null +++ b/music_assistant/web/endpoints/streams.py @@ -0,0 +1,103 @@ +"""Players API endpoints.""" + +from aiohttp import web +from music_assistant.helpers.web import require_local_subnet +from music_assistant.models.media_types import MediaType + +routes = web.RouteTableDef() + + +@routes.get("/stream/media/{media_type}/{item_id}") +async def stream_media(request: web.Request): + """Stream a single audio track.""" + media_type = MediaType.from_string(request.match_info["media_type"]) + if media_type not in [MediaType.Track, MediaType.Radio]: + return web.Response(status=404, reason="Media item is not playable!") + item_id = request.match_info["item_id"] + provider = request.rel_url.query.get("provider", "database") + media_item = await request.app["mass"].music.async_get_item( + item_id, provider, media_type + ) + streamdetails = await request.app["mass"].music.async_get_stream_details(media_item) + + # prepare request + content_type = streamdetails.content_type.value + resp = web.StreamResponse( + status=200, reason="OK", headers={"Content-Type": f"audio/{content_type}"} + ) + resp.enable_chunked_encoding() + await resp.prepare(request) + + # stream track + async for audio_chunk in request.app["mass"].streams.async_get_stream( + streamdetails + ): + await resp.write(audio_chunk) + return resp + + +@routes.get("/stream/queue/{player_id}") +@require_local_subnet +async def stream_queue(request: web.Request): + """Stream a player's queue.""" + player_id = request.match_info["player_id"] + if not request.app["mass"].players.get_player_queue(player_id): + return web.Response(text="invalid queue", status=404) + + # prepare request + resp = web.StreamResponse( + status=200, reason="OK", headers={"Content-Type": "audio/flac"} + ) + resp.enable_chunked_encoding() + await resp.prepare(request) + + # stream queue + async for audio_chunk in request.app["mass"].streams.async_queue_stream_flac( + player_id + ): + await resp.write(audio_chunk) + return resp + + +@routes.get("/stream/queue/{player_id}/{queue_item_id}") +@require_local_subnet +async def stream_queue_item(request: web.Request): + """Stream a single queue item.""" + player_id = request.match_info["player_id"] + queue_item_id = request.match_info["queue_item_id"] + + # prepare request + resp = web.StreamResponse( + status=200, reason="OK", headers={"Content-Type": "audio/flac"} + ) + resp.enable_chunked_encoding() + await resp.prepare(request) + + async for audio_chunk in request.app["mass"].streams.async_stream_queue_item( + player_id, queue_item_id + ): + await resp.write(audio_chunk) + return resp + + +@routes.get("/stream/group/{group_player_id}") +@require_local_subnet +async def stream_group(request: web.Request): + """Handle streaming to all players of a group. Highly experimental.""" + group_player_id = request.match_info["group_player_id"] + if not request.app["mass"].players.get_player_queue(group_player_id): + return web.Response(text="invalid player id", status=404) + child_player_id = request.rel_url.query.get("player_id", request.remote) + + # prepare request + resp = web.StreamResponse( + status=200, reason="OK", headers={"Content-Type": "audio/flac"} + ) + resp.enable_chunked_encoding() + await resp.prepare(request) + + # stream queue + player_state = request.app["mass"].players.get_player(group_player_id) + async for audio_chunk in player_state.subscribe_stream_client(child_player_id): + await resp.write(audio_chunk) + return resp diff --git a/music_assistant/web/endpoints/tracks.py b/music_assistant/web/endpoints/tracks.py new file mode 100644 index 00000000..fd4f3d30 --- /dev/null +++ b/music_assistant/web/endpoints/tracks.py @@ -0,0 +1,43 @@ +"""Radio's API endpoints.""" + +from aiohttp import web +from aiohttp_jwt import login_required +from music_assistant.helpers.util import json_serializer +from music_assistant.helpers.web import async_stream_json + +routes = web.RouteTableDef() + + +@routes.get("/api/tracks") +@login_required +async def async_tracks(request: web.Request): + """Get all tracks known in the database.""" + generator = request.app["mass"].database.async_get_tracks() + return await async_stream_json(request, generator) + + +@routes.get("/api/tracks/{item_id}/versions") +@login_required +async def async_track_versions(request: web.Request): + """Get all versions of an track.""" + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + if item_id is None or provider is None: + return web.Response(text="invalid item_id or provider", status=501) + generator = request.app["mass"].music.async_get_track_versions(item_id, provider) + return await async_stream_json(request, generator) + + +@routes.get("/api/tracks/{item_id}") +@login_required +async def async_track(request: web.Request): + """Get full track details.""" + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + lazy = request.rel_url.query.get("lazy", "true") != "false" + if item_id is None or provider is None: + return web.Response(text="invalid item or provider", status=501) + result = await request.app["mass"].music.async_get_track( + item_id, provider, lazy=lazy + ) + return web.Response(body=json_serializer(result), content_type="application/json") diff --git a/music_assistant/web/endpoints/websocket.py b/music_assistant/web/endpoints/websocket.py new file mode 100644 index 00000000..43b110d7 --- /dev/null +++ b/music_assistant/web/endpoints/websocket.py @@ -0,0 +1,97 @@ +"""Websocket API endpoint.""" + +import logging +from asyncio import CancelledError + +import aiohttp +import jwt +import orjson +from music_assistant.helpers.util import json_serializer + +routes = aiohttp.web.RouteTableDef() + +LOGGER = logging.getLogger("websocket") + + +@routes.get("/ws") +async def async_websocket_handler(request: aiohttp.web.Request): + """Handle websockets connection.""" + ws_response = None + authenticated = False + remove_callbacks = [] + try: + ws_response = aiohttp.web.WebSocketResponse() + await ws_response.prepare(request) + + # callback for internal events + async def async_send_message(msg, msg_details=None): + if hasattr(msg_details, "to_dict"): + msg_details = msg_details.to_dict() + ws_msg = {"message": msg, "message_details": msg_details} + try: + await ws_response.send_str(json_serializer(ws_msg).decode()) + except AssertionError: + LOGGER.debug("trying to send message to ws while disconnected") + + # process incoming messages + async for msg in ws_response: + if msg.type != aiohttp.WSMsgType.TEXT: + # not sure when/if this happens but log it anyway + LOGGER.warning(msg.data) + continue + try: + data = msg.json(loads=orjson.loads) + except orjson.decoder.JSONDecodeError: + await async_send_message( + "error", + 'commands must be issued in json format \ + {"message": "command", "message_details":" optional details"}', + ) + continue + msg = data.get("message") + msg_details = data.get("message_details") + if not authenticated and not msg == "login": + # make sure client is authenticated + await async_send_message("error", "authentication required") + elif msg == "login" and msg_details: + # authenticate with token + try: + token_info = jwt.decode( + msg_details, request.app["mass"].web.device_id + ) + except jwt.InvalidTokenError as exc: + LOGGER.exception(exc, exc_info=exc) + error_msg = "Invalid authorization token, " + str(exc) + await async_send_message("error", error_msg) + else: + authenticated = True + await async_send_message("login", token_info) + elif msg == "add_event_listener": + remove_callbacks.append( + request.app["mass"].add_event_listener( + async_send_message, msg_details + ) + ) + await async_send_message("event listener subscribed", msg_details) + elif msg == "player_command": + player_id = msg_details.get("player_id") + cmd = msg_details.get("cmd") + cmd_args = msg_details.get("cmd_args") + player_cmd = getattr( + request.app["mass"].players, f"async_cmd_{cmd}", None + ) + if player_cmd and cmd_args is not None: + result = await player_cmd(player_id, cmd_args) + elif player_cmd: + result = await player_cmd(player_id) + msg_details = {"cmd": cmd, "result": result} + await async_send_message("player_command_result", msg_details) + else: + # simply echo the message on the eventbus + request.app["mass"].signal_event(msg, msg_details) + except (AssertionError, CancelledError): + LOGGER.debug("Websocket disconnected") + finally: + for remove_callback in remove_callbacks: + remove_callback() + return ws_response diff --git a/pylintrc b/pylintrc deleted file mode 100644 index 0b60c452..00000000 --- a/pylintrc +++ /dev/null @@ -1,69 +0,0 @@ -[MASTER] -ignore=tests -ignore-patterns=app_vars -# Use a conservative default here; 2 should speed up most setups and not hurt -# any too bad. Override on command line as appropriate. -jobs=2 -persistent=no -suggestion-mode=yes -extension-pkg-whitelist=taglib - -[BASIC] -good-names=id,i,j,k,ex,Run,_,fp,T,ev - -[MESSAGES CONTROL] -# Reasons disabled: -# format - handled by black -# locally-disabled - it spams too much -# duplicate-code - unavoidable -# cyclic-import - doesn't test if both import on load -# abstract-class-little-used - prevents from setting right foundation -# unused-argument - generic callbacks and setup methods create a lot of warnings -# too-many-* - are not enforced for the sake of readability -# too-few-* - same as too-many-* -# abstract-method - with intro of async there are always methods missing -# inconsistent-return-statements - doesn't handle raise -# too-many-ancestors - it's too strict. -# wrong-import-order - isort guards this -# fixme - project is in development phase -disable= - format, - abstract-class-little-used, - abstract-method, - cyclic-import, - duplicate-code, - inconsistent-return-statements, - locally-disabled, - not-context-manager, - too-few-public-methods, - too-many-ancestors, - too-many-arguments, - too-many-branches, - too-many-instance-attributes, - too-many-lines, - too-many-locals, - too-many-public-methods, - too-many-return-statements, - too-many-statements, - too-many-boolean-expressions, - unused-argument, - wrong-import-order, - fixme -# enable useless-suppression temporarily every now and then to clean them up -enable= - use-symbolic-message-instead - -[REPORTS] -score=no - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=15 - -[TYPECHECK] -# For attrs -ignored-classes=_CountingAttr - -[FORMAT] -expected-line-ending-format=LF \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index a6426077..d39b3418 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,4 +22,4 @@ aiohttp_jwt==0.6.1 zeroconf==0.28.5 passlib==1.7.2 cryptography==3.1 -mashumaro==1.12 +orjson==3.4.0 diff --git a/setup.cfg b/setup.cfg index c2987645..81fb9d19 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,4 +33,76 @@ warn_redundant_casts = true warn_unused_configs = true [pydocstyle] -add-ignore = D202 \ No newline at end of file +add-ignore = D202 + +[pylint.master] +ignore=tests +ignore-patterns=app_vars +# Use a conservative default here; 2 should speed up most setups and not hurt +# any too bad. Override on command line as appropriate. +jobs=2 +persistent=no +suggestion-mode=yes +extension-pkg-whitelist=taglib,orjson + +[pylint.basic] +good-names=id,i,j,k,ex,Run,_,fp,T,ev + +[pylint.messages_control] +# Reasons disabled: +# format - handled by black +# locally-disabled - it spams too much +# duplicate-code - unavoidable +# cyclic-import - doesn't test if both import on load +# abstract-class-little-used - prevents from setting right foundation +# unused-argument - generic callbacks and setup methods create a lot of warnings +# too-many-* - are not enforced for the sake of readability +# too-few-* - same as too-many-* +# abstract-method - with intro of async there are always methods missing +# inconsistent-return-statements - doesn't handle raise +# too-many-ancestors - it's too strict. +# wrong-import-order - isort guards this +# fixme - project is in development phase +# c-extension-no-member - it was giving me headaches +disable= + format, + abstract-class-little-used, + abstract-method, + cyclic-import, + duplicate-code, + inconsistent-return-statements, + locally-disabled, + not-context-manager, + too-few-public-methods, + too-many-ancestors, + too-many-arguments, + too-many-branches, + too-many-instance-attributes, + too-many-lines, + too-many-locals, + too-many-public-methods, + too-many-return-statements, + too-many-statements, + too-many-boolean-expressions, + unused-argument, + wrong-import-order, + fixme, + c-extension-no-member + +# enable useless-suppression temporarily every now and then to clean them up +enable= + use-symbolic-message-instead + +[pylint.reports] +score=no + +[pylint.refactoring] +# Maximum number of nested blocks for function / method body +max-nested-blocks=15 + +[pylint.typecheck] +# For attrs +ignored-classes=_CountingAttr + +[pylint.format] +expected-line-ending-format=LF \ No newline at end of file -- 2.34.1