From: Marcel van der Veldt Date: Mon, 4 Apr 2022 22:38:11 +0000 (+0200) Subject: Refactor into standalone library (#238) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=b54bd9c513d1f8920ffd07fce854f3871997a9b4;p=music-assistant-server.git Refactor into standalone library (#238) * drop authentication * simplify logging * refactor database * improve streaming stability * drop player providers from main library --- diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 1c4a7a3c..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - - { - "name": "Python: Module", - "type": "python", - "request": "launch", - "module": "music_assistant", - "args": ["--debug"] - }, - { - "name": "Python: Huidige bestand", - "type": "python", - "request": "launch", - "program": "${file}", - "console": "integratedTerminal", - "args": ["--debug"] - }, - { - "name": "Python: Attach using Process Id", - "type": "python", - "request": "attach", - "processId": "${command:pickProcess}" - } - ] -} \ No newline at end of file diff --git a/examples/full.py b/examples/full.py new file mode 100644 index 00000000..f4165887 --- /dev/null +++ b/examples/full.py @@ -0,0 +1,193 @@ +"""Extended example/script to run Music Assistant with all bells and whistles.""" +import argparse +import asyncio +import logging +import os +from sys import path + +from aiorun import run + +# pylint: disable=wrong-import-position +from music_assistant.models.player import Player, PlayerState + +path.insert(1, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from music_assistant.mass import MusicAssistant +from music_assistant.providers.spotify import SpotifyProvider +from music_assistant.providers.qobuz import QobuzProvider +from music_assistant.providers.tunein import TuneInProvider +from music_assistant.providers.filesystem import FileSystemProvider + +parser = argparse.ArgumentParser(description="MusicAssistant") +parser.add_argument( + "--spotify-username", + required=False, + help="Spotify username", +) +parser.add_argument( + "--spotify-password", + required=False, + help="Spotify password.", +) +parser.add_argument( + "--qobuz-username", + required=False, + help="Qobuz username", +) +parser.add_argument( + "--qobuz-password", + required=False, + help="Qobuz password.", +) +parser.add_argument( + "--tunein-username", + required=False, + help="Tunein username", +) +parser.add_argument( + "--musicdir", + required=False, + help="Directory on disk for local music library", +) +parser.add_argument( + "--playlistdir", + required=False, + help="Directory on disk for local (m3u) playlists", +) +parser.add_argument( + "--debug", + action="store_true", + help="Enable verbose debug logging", +) +args = parser.parse_args() + + +# setup logger +logging.basicConfig( + level=logging.DEBUG if args.debug else logging.INFO, + format="%(asctime)-15s %(levelname)-5s %(name)s -- %(message)s", +) +# silence some loggers +logging.getLogger("aiorun").setLevel(logging.WARNING) +logging.getLogger("asyncio").setLevel(logging.INFO) +logging.getLogger("aiosqlite").setLevel(logging.WARNING) +logging.getLogger("databases").setLevel(logging.WARNING) + + +# default database based on sqlite +data_dir = os.getenv("APPDATA") if os.name == "nt" else os.path.expanduser("~") +data_dir = os.path.join(data_dir, ".musicassistant") +if not os.path.isdir(data_dir): + os.makedirs(data_dir) +db_file = os.path.join(data_dir, "music_assistant.db") + +mass = MusicAssistant(f"sqlite:///{db_file}") + + +providers = [] +if args.spotify_username and args.spotify_password: + providers.append(SpotifyProvider(args.spotify_username, args.spotify_password)) +if args.qobuz_username and args.qobuz_password: + providers.append(QobuzProvider(args.qobuz_username, args.qobuz_password)) +if args.tunein_username: + providers.append(TuneInProvider(args.tunein_username)) +if args.musicdir: + providers.append(FileSystemProvider(args.musicdir, args.playlistdir)) + +class TestPlayer(Player): + def __init__(self): + self.player_id = "test" + self.is_group = False + self._attr_name = "Test player" + self._attr_powered = False + self._attr_elapsed_time = 0 + self._attr_current_url = None + self._attr_state = PlayerState.IDLE + self._attr_available = True + self._attr_volume_level = 100 + + async def play_url(self, url: str) -> None: + """Play the specified url on the player.""" + print("play uri: %s" % url) + self._attr_current_url = url + self.update_state() + + async def stop(self) -> None: + """Send STOP command to player.""" + print("STOP CALLED") + self._attr_state = PlayerState.IDLE + self._attr_current_url = None + self._attr_elapsed_time = 0 + self.update_state() + + async def play(self) -> None: + """Send PLAY/UNPAUSE command to player.""" + print("PLAY CALLED") + self._attr_state = PlayerState.PLAYING + self._attr_elapsed_time = 1 + self.update_state() + + async def pause(self) -> None: + """Send PAUSE command to player.""" + print("PAUSE CALLED") + self._attr_state = PlayerState.PAUSED + self.update_state() + + async def power(self, powered: bool) -> None: + """Send POWER command to player.""" + print("POWER CALLED - %s" % powered) + self._attr_powered = powered + self._attr_current_url = None + self.update_state() + + async def volume_set(self, volume_level: int) -> None: + """Send volume level (0..100) command to player.""" + print("VOLUME SET CALLED - %s" % volume_level) + self._attr_volume_level = volume_level + self.update_state() + + +def main(): + """Handle main execution.""" + + async def async_main(): + """Async main routine.""" + asyncio.get_event_loop().set_debug(args.debug) + + await mass.setup() + # register music provider(s) + for prov in providers: + await mass.music.register_provider(prov) + # get some data + artists = await mass.music.artists.library() + print(f"Got {len(artists)} artists in library") + albums = await mass.music.albums.library() + print(f"Got {len(albums)} albums in library") + tracks = await mass.music.tracks.library() + print(f"Got {len(tracks)} tracks in library") + radios = await mass.music.radio.library() + print(f"Got {len(radios)} radio stations in library") + playlists = await mass.music.playlists.library() + print(f"Got {len(playlists)} playlists in library") + # register a player + test_player = TestPlayer() + await mass.players.register_player(test_player) + # try to play some playlist + await test_player.queue.set_crossfade_duration(10) + await test_player.queue.set_shuffle_enabled(True) + if len(playlists) > 0: + await test_player.queue.play_media(playlists[0].uri) + + def on_shutdown(loop): + loop.run_until_complete(mass.stop()) + + run( + async_main(), + use_uvloop=True, + shutdown_callback=on_shutdown, + executor_workers=64, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/simple.py b/examples/simple.py new file mode 100644 index 00000000..8f235b2d --- /dev/null +++ b/examples/simple.py @@ -0,0 +1,84 @@ +"""Simple example/script to run Music Assistant with Spotify provider.""" +import argparse +import asyncio +import logging +import os +from sys import path + +from aiorun import run + +path.insert(1, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from music_assistant.mass import MusicAssistant +from music_assistant.providers.spotify import SpotifyProvider + +parser = argparse.ArgumentParser(description="MusicAssistant") +parser.add_argument( + "--username", + required=True, + help="Spotify username", +) +parser.add_argument( + "--password", + required=True, + help="Spotify password.", +) +parser.add_argument( + "--debug", + action="store_true", + help="Enable verbose debug logging", +) +args = parser.parse_args() + + +# setup logger +if args.debug: + logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)-15s %(levelname)-5s %(name)s -- %(message)s", + ) + # silence some loggers + logging.getLogger("aiorun").setLevel(logging.WARNING) + logging.getLogger("asyncio").setLevel(logging.INFO) + logging.getLogger("aiosqlite").setLevel(logging.WARNING) + logging.getLogger("databases").setLevel(logging.WARNING) + + +# default database based on sqlite +data_dir = os.getenv("APPDATA") if os.name == "nt" else os.path.expanduser("~") +data_dir = os.path.join(data_dir, ".musicassistant") +if not os.path.isdir(data_dir): + os.makedirs(data_dir) +db_file = os.path.join(data_dir, "music_assistant.db") + +mass = MusicAssistant(f"sqlite:///{db_file}") +spotify = SpotifyProvider(args.username, args.password) + + +def main(): + """Handle main execution.""" + + async def async_main(): + """Async main routine.""" + asyncio.get_event_loop().set_debug(args.debug) + await mass.setup() + # register music provider(s) + await mass.music.register_provider(spotify) + # get some data + await mass.music.artists.library() + await mass.music.tracks.library() + await mass.music.radio.library() + + def on_shutdown(loop): + loop.run_until_complete(mass.stop()) + + run( + async_main(), + use_uvloop=True, + shutdown_callback=on_shutdown, + executor_workers=64, + ) + + +if __name__ == "__main__": + main() diff --git a/music_assistant.code-workspace b/music_assistant.code-workspace deleted file mode 100644 index c6efe05f..00000000 --- a/music_assistant.code-workspace +++ /dev/null @@ -1,10 +0,0 @@ -{ - "folders": [ - { - "path": "." - } - ], - "settings": { - "python.pythonPath": "venv/bin/python" - } -} \ No newline at end of file diff --git a/music_assistant/__init__.py b/music_assistant/__init__.py index a6634771..e7243716 100644 --- a/music_assistant/__init__.py +++ b/music_assistant/__init__.py @@ -1 +1,3 @@ -"""Init file for Music Assistant.""" +"""Music Assistant: The music library manager in python.""" + +from .mass import MusicAssistant # noqa diff --git a/music_assistant/__main__.py b/music_assistant/__main__.py deleted file mode 100755 index 65befb74..00000000 --- a/music_assistant/__main__.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Start Music Assistant.""" -import argparse -import os - -from aiorun import run -from music_assistant.helpers.logger import setup_logger -from music_assistant.mass import MusicAssistant - - -def get_arguments(): - """Arguments handling.""" - parser = argparse.ArgumentParser(description="MusicAssistant") - - default_data_dir = ( - os.getenv("APPDATA") if os.name == "nt" else os.path.expanduser("~") - ) - default_data_dir = os.path.join(default_data_dir, ".musicassistant") - - parser.add_argument( - "-c", - "--config", - metavar="path_to_config_dir", - default=default_data_dir, - help="Directory that contains the MusicAssistant configuration", - ) - parser.add_argument( - "-p", - "--port", - metavar="port", - default=8095, - help="TCP port on which the server should be run.", - ) - parser.add_argument( - "--debug", - action="store_true", - help="Start MusicAssistant with verbose debug logging", - ) - arguments = parser.parse_args() - return arguments - - -def main(): - """Start MusicAssistant.""" - # parse arguments - args = get_arguments() - data_dir = args.config - if not os.path.isdir(data_dir): - os.makedirs(data_dir) - # setup logger - logger = setup_logger(data_dir) - # config debug settings if needed - if args.debug or bool(os.environ.get("DEBUG")): - debug = True - else: - debug = False - mass = MusicAssistant(data_dir, debug, int(args.port)) - - def on_shutdown(loop): - logger.info("shutdown requested!") - loop.run_until_complete(mass.stop()) - - run( - mass.start(), - use_uvloop=True, - shutdown_callback=on_shutdown, - executor_workers=64, - ) - - -if __name__ == "__main__": - main() diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 3a82775f..8a054cf6 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -1,60 +1,30 @@ """All constants for Music Assistant.""" -__version__ = "0.2.13" -REQUIRED_PYTHON_VER = "3.9" +from enum import Enum -# configuration keys/attributes -CONF_USERNAME = "username" -CONF_PASSWORD = "password" -CONF_ENABLED = "enabled" -CONF_HOSTNAME = "hostname" -CONF_PORT = "port" -CONF_TOKEN = "token" -CONF_URL = "url" -CONF_NAME = "name" -CONF_CROSSFADE_DURATION = "crossfade_duration" -CONF_GROUP_DELAY = "group_delay" -CONF_VOLUME_CONTROL = "volume_control" -CONF_POWER_CONTROL = "power_control" -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" +class EventType(Enum): + """Enum with possible Events.""" -# configuration base keys/attributes -CONF_KEY_BASE = "base" -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_SECURITY = "security" -CONF_KEY_SECURITY_LOGIN = "login" -CONF_KEY_SECURITY_APP_TOKENS = "app_tokens" -CONF_KEY_BASE_INFO = "info" + PLAYER_ADDED = "player added" + PLAYER_REMOVED = "player removed" + PLAYER_CHANGED = "player changed" + STREAM_STARTED = "streaming started" + STREAM_ENDED = "streaming ended" + CONFIG_CHANGED = "config changed" + MUSIC_SYNC_STATUS = "music sync status" + QUEUE_ADDED = "queue_added" + QUEUE_UPDATED = "queue updated" + QUEUE_ITEMS_UPDATED = "queue items updated" + SHUTDOWN = "application shutdown" + ARTIST_ADDED = "artist added" + ALBUM_ADDED = "album added" + TRACK_ADDED = "track added" + PLAYLIST_ADDED = "playlist added" + RADIO_ADDED = "radio added" + TASK_UPDATED = "task updated" + PROVIDER_REGISTERED = "PROVIDER_REGISTERED" -# events -EVENT_PLAYER_ADDED = "player added" -EVENT_PLAYER_REMOVED = "player removed" -EVENT_PLAYER_CHANGED = "player changed" -EVENT_STREAM_STARTED = "streaming started" -EVENT_STREAM_ENDED = "streaming ended" -EVENT_CONFIG_CHANGED = "config changed" -EVENT_MUSIC_SYNC_STATUS = "music sync status" -EVENT_QUEUE_UPDATED = "queue updated" -EVENT_QUEUE_ITEMS_UPDATED = "queue items updated" -EVENT_SHUTDOWN = "application shutdown" -EVENT_PROVIDER_REGISTERED = "provider registered" -EVENT_PROVIDER_UNREGISTERED = "provider unregistered" -EVENT_ARTIST_ADDED = "artist added" -EVENT_ALBUM_ADDED = "album added" -EVENT_TRACK_ADDED = "track added" -EVENT_PLAYLIST_ADDED = "playlist added" -EVENT_RADIO_ADDED = "radio added" -EVENT_TASK_UPDATED = "task updated" # player attributes ATTR_PLAYER_ID = "player_id" diff --git a/music_assistant/controllers/metadata/__init__.py b/music_assistant/controllers/metadata/__init__.py new file mode 100755 index 00000000..5ad7100a --- /dev/null +++ b/music_assistant/controllers/metadata/__init__.py @@ -0,0 +1,73 @@ +"""All logic for metadata retrieval.""" + +from music_assistant.helpers.cache import cached +from music_assistant.helpers.images import create_thumbnail +from music_assistant.helpers.typing import MusicAssistant +from music_assistant.helpers.util import merge_dict + +from .fanarttv import FanartTv +from .musicbrainz import MusicBrainz + +# TODO: add more metadata providers such as theaudiodb +# TODO: add metadata support for albums and other media types + +TABLE_THUMBS = "thumbnails" + + +class MetaDataController: + """Several helpers to search and store metadata for mediaitems.""" + + # TODO: create periodic task to search for missing metadata + def __init__(self, mass: MusicAssistant) -> None: + """Initialize class.""" + self.mass = mass + self.cache = mass.cache + self.logger = mass.logger.getChild("metadata") + self.fanarttv = FanartTv(mass) + self.musicbrainz = MusicBrainz(mass) + + async def setup(self): + """Async initialize of module.""" + async with self.mass.database.get_db() as _db: + await _db.execute( + f"""CREATE TABLE IF NOT EXISTS {TABLE_THUMBS}( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + size INTEGER, + img BLOB, + UNIQUE(url, size));""" + ) + + async def 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 + if "fanart" in metadata: + # no need to query (other) metadata providers if we already have a result + return metadata + self.logger.info( + "Fetching metadata for MusicBrainz Artist %s on Fanrt.tv", mb_artist_id + ) + cache_key = f"fanarttv.artist_metadata.{mb_artist_id}" + res = await cached( + self.cache, cache_key, self.fanarttv.get_artist_images, mb_artist_id + ) + if res: + metadata = merge_dict(metadata, res) + self.logger.debug( + "Found metadata for MusicBrainz Artist %s on Fanart.tv: %s", + mb_artist_id, + ", ".join(res.keys()), + ) + return metadata + + async def get_thumbnail(self, url, size) -> bytes: + """Get/create thumbnail image for url.""" + match = {"url": url, "size": size} + if result := await self.mass.database.get_row(TABLE_THUMBS, match): + return result["img"] + # create thumbnail if it doesn't exist + thumbnail = await create_thumbnail(self.mass, url, size) + await self.mass.database.insert_or_replace( + TABLE_THUMBS, {**match, "img": thumbnail} + ) + return thumbnail diff --git a/music_assistant/controllers/metadata/fanarttv.py b/music_assistant/controllers/metadata/fanarttv.py new file mode 100755 index 00000000..a5c9c5fc --- /dev/null +++ b/music_assistant/controllers/metadata/fanarttv.py @@ -0,0 +1,73 @@ +"""FanartTv Metadata provider.""" + +from json.decoder import JSONDecodeError +from typing import Dict + +import aiohttp +from asyncio_throttle import Throttler + +from music_assistant.helpers.typing import MusicAssistant + + +# TODO: add support for personal api keys ? +# TODO: Add support for album artwork ? + + +class FanartTv: + """Fanart.tv metadata provider.""" + + def __init__(self, mass: MusicAssistant): + """Initialize class.""" + self.mass = mass + self.logger = mass.logger.getChild("fanarttv") + self.throttler = Throttler(rate_limit=1, period=2) + + async def get_artist_images(self, mb_artist_id: str) -> Dict: + """Retrieve images by musicbrainz artist id.""" + metadata = {} + data = await self._get_data(f"music/{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 f"fanart.{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 _get_data(self, endpoint, params=None): + """Get data from api.""" + if params is None: + params = {} + url = f"http://webservice.fanart.tv/v3/{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.ContentTypeError, + JSONDecodeError, + ): + self.logger.error("Failed to retrieve %s", endpoint) + text_result = await response.text() + self.logger.debug(text_result) + return None + except aiohttp.ClientConnectorError: + self.logger.error("Failed to retrieve %s", endpoint) + return None + if "error" in result and "limit" in result["error"]: + self.logger.error(result["error"]) + return None + return result diff --git a/music_assistant/controllers/metadata/musicbrainz.py b/music_assistant/controllers/metadata/musicbrainz.py new file mode 100644 index 00000000..57b32e37 --- /dev/null +++ b/music_assistant/controllers/metadata/musicbrainz.py @@ -0,0 +1,180 @@ +"""Handle getting Id's from MusicBrainz.""" + +import re +from json.decoder import JSONDecodeError +from typing import Optional + +import aiohttp +from asyncio_throttle import Throttler + +from music_assistant.helpers.cache import cached +from music_assistant.helpers.compare import compare_strings, get_compare_string +from music_assistant.helpers.typing import MusicAssistant + +LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])' + + +class MusicBrainz: + """Handle getting Id's from MusicBrainz.""" + + def __init__(self, mass: MusicAssistant): + """Initialize class.""" + self.mass = mass + self.cache = mass.cache + self.logger = mass.logger.getChild("musicbrainz") + self.throttler = Throttler(rate_limit=1, period=1) + + async def get_mb_artist_id( + self, + artistname, + albumname=None, + album_upc=None, + trackname=None, + track_isrc=None, + ): + """Retrieve musicbrainz artist id for the given details.""" + self.logger.debug( + "searching musicbrainz for %s \ + (albumname: %s - album_upc: %s - trackname: %s - track_isrc: %s)", + artistname, + albumname, + album_upc, + trackname, + track_isrc, + ) + mb_artist_id = None + if album_upc: + mb_artist_id = await self.search_artist_by_album( + artistname, None, album_upc + ) + if mb_artist_id: + self.logger.debug( + "Got MusicbrainzArtistId for %s after search on upc %s --> %s", + artistname, + album_upc, + mb_artist_id, + ) + if not mb_artist_id and track_isrc: + mb_artist_id = await self.search_artist_by_track( + artistname, None, track_isrc + ) + if mb_artist_id: + self.logger.debug( + "Got MusicbrainzArtistId for %s after search on isrc %s --> %s", + artistname, + track_isrc, + mb_artist_id, + ) + if not mb_artist_id and albumname: + mb_artist_id = await self.search_artist_by_album(artistname, albumname) + if mb_artist_id: + self.logger.debug( + "Got MusicbrainzArtistId for %s after search on albumname %s --> %s", + artistname, + albumname, + mb_artist_id, + ) + if not mb_artist_id and trackname: + mb_artist_id = await self.search_artist_by_track(artistname, trackname) + if mb_artist_id: + self.logger.debug( + "Got MusicbrainzArtistId for %s after search on trackname %s --> %s", + artistname, + trackname, + mb_artist_id, + ) + return mb_artist_id + + async def search_artist_by_album(self, artistname, albumname=None, album_upc=None): + """Retrieve musicbrainz artist id by providing the artist name and albumname or upc.""" + for searchartist in [ + re.sub(LUCENE_SPECIAL, r"\\\1", artistname), + get_compare_string(artistname), + ]: + if album_upc: + endpoint = "release" + params = {"query": f"barcode:{album_upc}"} + cache_key = f"{endpoint}.barcode.{album_upc}" + else: + searchalbum = re.sub(LUCENE_SPECIAL, r"\\\1", albumname) + endpoint = "release" + params = { + "query": f'artist:"{searchartist}" AND release:"{searchalbum}"' + } + cache_key = f"{endpoint}.{searchartist}.{searchalbum}" + result = await cached( + self.mass.cache, cache_key, self.get_data, endpoint, params + ) + if result and "releases" in result: + for strictness in [True, False]: + for item in result["releases"]: + if album_upc or compare_strings( + item["title"], albumname, strictness + ): + for artist in item["artist-credit"]: + if compare_strings( + artist["artist"]["name"], artistname, strictness + ): + return artist["artist"]["id"] + for alias in artist.get("aliases", []): + if compare_strings( + alias["name"], artistname, strictness + ): + return artist["id"] + return "" + + async def search_artist_by_track(self, artistname, trackname=None, track_isrc=None): + """Retrieve artist id by providing the artist name and trackname or track isrc.""" + endpoint = "recording" + searchartist = re.sub(LUCENE_SPECIAL, r"\\\1", artistname) + if track_isrc: + endpoint = f"isrc/{track_isrc}" + params = {"inc": "artist-credits"} + cache_key = endpoint + else: + searchtrack = re.sub(LUCENE_SPECIAL, r"\\\1", trackname) + endpoint = "recording" + params = {"query": '"{searchtrack}" AND artist:"{searchartist}"'} + cache_key = f"{endpoint}.{searchtrack}.{searchartist}" + result = await cached( + self.mass.cache, cache_key, self.get_data(endpoint, params) + ) + if result and "recordings" in result: + for strictness in [True, False]: + for item in result["recordings"]: + if track_isrc or compare_strings( + item["title"], trackname, strictness + ): + for artist in item["artist-credit"]: + if compare_strings( + artist["artist"]["name"], artistname, strictness + ): + return artist["artist"]["id"] + for alias in artist.get("aliases", []): + if compare_strings( + alias["name"], artistname, strictness + ): + return artist["id"] + return "" + + async def get_data(self, endpoint: str, params: Optional[dict] = None): + """Get data from api.""" + if params is None: + params = {} + url = f"http://musicbrainz.org/ws/2/{endpoint}" + headers = {"User-Agent": "Music Assistant/1.0.0 https://github.com/marcelveldt"} + params["fmt"] = "json" + async with self.throttler: + async with self.mass.http_session.get( + url, headers=headers, params=params, verify_ssl=False + ) as response: + try: + result = await response.json() + except ( + aiohttp.client_exceptions.ContentTypeError, + JSONDecodeError, + ) as exc: + msg = await response.text() + self.logger.error("%s - %s", str(exc), msg) + result = None + return result diff --git a/music_assistant/controllers/music/__init__.py b/music_assistant/controllers/music/__init__.py new file mode 100755 index 00000000..ed8ee758 --- /dev/null +++ b/music_assistant/controllers/music/__init__.py @@ -0,0 +1,498 @@ +"""MusicController: Orchestrates all data from music providers and sync to internal database.""" +from __future__ import annotations + +import asyncio +import statistics +from typing import Dict, List, Tuple + +from music_assistant.constants import EventType +from music_assistant.controllers.music.albums import AlbumsController +from music_assistant.controllers.music.artists import ArtistsController +from music_assistant.controllers.music.playlists import PlaylistController +from music_assistant.controllers.music.radio import RadioController +from music_assistant.controllers.music.tracks import TracksController +from music_assistant.helpers.cache import cached +from music_assistant.helpers.datetime import utc_timestamp +from music_assistant.models.errors import ( + AlreadyRegisteredError, + MusicAssistantError, + SetupFailedError, +) +from music_assistant.helpers.typing import MusicAssistant +from music_assistant.helpers.util import create_task +from music_assistant.models.media_items import ( + Album, + MediaItem, + MediaItemProviderId, + MediaItemType, + MediaType, + Playlist, +) +from music_assistant.models.provider import MusicProvider + +DB_PROV_MAPPINGS = "provider_mappings" +DB_TRACK_LOUDNESS = "track_loudness" +DB_PLAYLOG = "playlog" + + +class MusicController: + """Several helpers around the musicproviders.""" + + def __init__(self, mass: MusicAssistant): + """Initialize class.""" + self.logger = mass.logger.getChild("music") + self.mass = mass + self.artists = ArtistsController(mass) + self.albums = AlbumsController(mass) + self.tracks = TracksController(mass) + self.radio = RadioController(mass) + self.playlists = PlaylistController(mass) + self._providers: Dict[str, MusicProvider] = {} + + async def setup(self): + """Async initialize of module.""" + await self.__setup_database_tables() + # setup generic controllers + await self.artists.setup() + await self.albums.setup() + await self.tracks.setup() + await self.radio.setup() + await self.playlists.setup() + self.__schedule_sync_tasks() + + @property + def provider_count(self) -> int: + """Return count of all registered music providers.""" + return len(self._providers) + + @property + def providers(self) -> Tuple[MusicProvider]: + """Return all (available) music providers.""" + return tuple(x for x in self._providers.values() if x.available) + + def get_provider(self, provider_id: str) -> MusicProvider | None: + """Return provider/plugin by id.""" + prov = self._providers.get(provider_id, None) + if prov is None or not prov.available: + self.logger.warning("Provider %s is not available", provider_id) + return prov + + async def register_provider(self, provider: MusicProvider) -> None: + """Register a music provider.""" + if provider.id in self._providers: + raise AlreadyRegisteredError( + f"Provider {provider.id} is already registered" + ) + try: + provider.mass = self.mass + provider.logger = self.logger.getChild(provider.id) + await provider.setup() + except Exception as err: # pylint: disable=broad-except + raise SetupFailedError(f"Setup failed of provider {provider.id}") from err + else: + self._providers[provider.id] = provider + self.mass.signal_event(EventType.PROVIDER_REGISTERED, provider) + await self.schedule_provider_sync(provider.id) + + async def search( + self, search_query, media_types: List[MediaType], limit: int = 10 + ) -> List[MediaItemType]: + """ + Perform global search for media items on all providers. + + :param search_query: Search query. + :param media_types: A list of media_types to include. + :param limit: number of items to return in the search (per type). + """ + # include results from all music providers + provider_ids = ["database"] + [item.id for item in self.providers] + # TODO: sort by name and filter out duplicates ? + return await asyncio.gather( + *[ + self.search_provider(search_query, prov_id, media_types, limit) + for prov_id in provider_ids + ] + ) + + async def search_provider( + self, + search_query: str, + provider_id: str, + media_types: List[MediaType], + limit: int = 10, + ) -> List[MediaItemType]: + """ + Perform search on given provider. + + :param search_query: Search query + :param provider_id: provider_id of the provider to perform the search on. + :param media_types: A list of media_types to include. All types if None. + :param limit: number of items to return in the search (per type). + """ + if provider_id == "database": + # get results from database + return ( + await self.artists.search(search_query, "database", limit) + + await self.albums.search(search_query, "database", limit) + + await self.tracks.search(search_query, "database", limit) + + await self.playlists.search(search_query, "database", limit) + + await self.radio.search(search_query, "database", limit) + ) + provider = self.get_provider(provider_id) + media_types_str = ".".join(sorted([x.value for x in media_types])) + cache_key = f"{provider_id}.search.{search_query}.{media_types_str}.{limit}" + return await cached( + self.mass.cache, + cache_key, + provider.search, + search_query, + media_types, + limit, + ) + + async def get_item_by_uri( + self, uri: str, force_refresh: bool = False, lazy: bool = True + ) -> MediaItemType: + """Fetch MediaItem by uri.""" + if "://" in uri: + provider = uri.split("://")[0] + item_id = uri.split("/")[-1] + media_type = MediaType(uri.split("/")[-2]) + else: + # spotify new-style uri + provider, media_type, item_id = uri.split(":") + media_type = MediaType(media_type) + return await self.get_item( + item_id, provider, media_type, force_refresh=force_refresh, lazy=lazy + ) + + async def get_item( + self, + item_id: str, + provider_id: str, + media_type: MediaType, + force_refresh: bool = False, + lazy: bool = True, + ) -> MediaItemType: + """Get single music item by id and media type.""" + ctrl = self._get_controller(media_type) + return await ctrl.get( + item_id, provider_id, force_refresh=force_refresh, lazy=lazy + ) + + async def refresh_items(self, items: List[MediaItem]) -> None: + """ + Refresh MediaItems to force retrieval of full info and matches. + + Creates background tasks to process the action. + """ + for media_item in items: + job_desc = f"Refresh metadata of {media_item.uri}" + self.mass.add_job(self.refresh_item(media_item), job_desc) + + async def refresh_item( + self, + media_item: MediaItem, + ): + """Try to refresh a mediaitem by requesting it's full object or search for substitutes.""" + try: + return await self.get_item( + media_item.item_id, + media_item.provider, + media_item.media_type, + force_refresh=True, + lazy=False, + ) + except MusicAssistantError: + pass + + for item in await self.search(media_item.name, [media_item.media_type], 20): + if item.available: + await self.get_item( + item.item_id, item.provider, item.media_type, lazy=False + ) + + async def get_provider_mapping( + self, media_type: MediaType, provider_id: str, provider_item_id: str + ) -> int | None: + """Lookup database id for media item from provider id.""" + if result := await self.mass.database.get_row( + DB_PROV_MAPPINGS, + { + "media_type": media_type.value, + "provider": provider_id, + "prov_item_id": provider_item_id, + }, + ): + return result["item_id"] + return None + + async def add_provider_mappings( + self, + item_id: int, + media_type: MediaType, + prov_ids: List[MediaItemProviderId], + ): + """Add provider ids for media item to database.""" + for prov in prov_ids: + await self.add_provider_mapping(item_id, media_type, prov) + + async def add_provider_mapping( + self, + item_id: int, + media_type: MediaType, + prov_id: MediaItemProviderId, + ): + """Add provider id for media item to database.""" + await self.mass.database.insert_or_replace( + DB_PROV_MAPPINGS, + { + "item_id": item_id, + "media_type": media_type.value, + "prov_item_id": prov_id.item_id, + "provider": prov_id.provider, + "quality": prov_id.quality.value, + "details": prov_id.details, + }, + ) + + async def add_to_library( + self, media_type: MediaType, provider_item_id: str, provider_id: str + ) -> None: + """Add an item to the library.""" + ctrl = self._get_controller(media_type) + await ctrl.add_to_library(provider_item_id, provider_id) + + async def remove_from_library( + self, media_type: MediaType, provider_item_id: str, provider_id: str + ) -> None: + """Remove item from the library.""" + ctrl = self._get_controller(media_type) + await ctrl.remove_from_library(provider_item_id, provider_id) + + async def set_track_loudness(self, item_id: str, provider_id: str, loudness: int): + """List integrated loudness for a track in db.""" + await self.mass.database.insert_or_replace( + DB_TRACK_LOUDNESS, + {"item_id": item_id, "provider": provider_id, "loudness": loudness}, + ) + + async def get_track_loudness( + self, provider_item_id: str, provider_id: str + ) -> float | None: + """Get integrated loudness for a track in db.""" + if result := await self.mass.database.get_row( + DB_TRACK_LOUDNESS, + { + "item_id": provider_item_id, + "provider": provider_id, + }, + ): + return result["loudness"] + return None + + async def get_provider_loudness(self, provider_id: str) -> float | None: + """Get average integrated loudness for tracks of given provider.""" + all_items = [] + for db_row in await self.mass.database.get_rows( + DB_TRACK_LOUDNESS, + { + "provider": provider_id, + }, + ): + all_items.append(db_row["loudness"]) + if all_items: + return statistics.fmean(all_items) + return None + + async def mark_item_played(self, item_id: str, provider_id: str): + """Mark item as played in playlog.""" + timestamp = utc_timestamp() + await self.mass.database.insert_or_replace( + DB_PLAYLOG, + {"item_id": item_id, "provider": provider_id, "timestamp": timestamp}, + ) + + async def library_add_items(self, items: List[MediaItem]) -> None: + """ + Add media item(s) to the library. + + Creates background tasks to process the action. + """ + for media_item in items: + job_desc = f"Add {media_item.uri} to library" + self.mass.add_job( + self.add_to_library( + media_item.media_type, media_item.item_id, media_item.provider + ), + job_desc, + ) + + async def library_remove_items(self, items: List[MediaItem]) -> None: + """ + Remove media item(s) from the library. + + Creates background tasks to process the action. + """ + for media_item in items: + job_desc = f"Remove {media_item.uri} from library" + self.mass.add_job( + self.remove_from_library( + media_item.media_type, media_item.item_id, media_item.provider + ), + job_desc, + ) + + async def schedule_provider_sync(self, provider_id: str): + """Schedule library sync for a provider.""" + provider = self.get_provider(provider_id) + if not provider: + return + for media_type in provider.supported_mediatypes: + self.mass.add_job( + self._library_items_sync( + media_type, + provider_id, + ), + f"Library sync of {media_type.value}s for provider {provider.name}", + ) + + async def _library_items_sync( + self, media_type: MediaType, provider_id: str + ) -> None: + """Sync library items for given provider.""" + music_provider = self.get_provider(provider_id) + if not music_provider or not music_provider.available: + return + controller = self._get_controller(media_type) + # create a set of all previous and current db id's + prev_ids = set() + for db_item in await controller.library(): + for prov_id in db_item.provider_ids: + if prov_id.provider == provider_id: + prev_ids.add(db_item.item_id) + cur_ids = set() + for prov_item in await music_provider.get_library_items(media_type): + prov_item: MediaItemType = prov_item + db_item: MediaItemType = await controller.get_db_item_by_prov_id( + prov_item.provider, prov_item.item_id + ) + if not db_item and media_type == MediaType.ARTIST: + # for artists we need a fully matched item (with musicbrainz id) + db_item = await controller.get( + prov_item.item_id, prov_item.provider, details=prov_item + ) + elif not db_item: + # for other mediatypes its enough to simply dump the item in the db + db_item = await controller.add_db_item(prov_item) + cur_ids.add(db_item.item_id) + if not db_item.in_library: + await controller.set_db_library(db_item.item_id, True) + # sync album tracks + if media_type == MediaType.ALBUM: + self.mass.add_job( + self._sync_album_tracks(db_item), + f"Sync album tracks for album {db_item.name}", + ) + # sync playlist tracks + if media_type == MediaType.PLAYLIST: + self.mass.add_job( + self._sync_playlist_tracks(db_item), + f"Sync playlist tracks for playlist {db_item.name}", + ) + + # process deletions + for item_id in prev_ids: + if item_id not in cur_ids: + await controller.set_db_library(item_id, False) + + async def _sync_album_tracks(self, db_album: Album) -> None: + """Store album tracks of in-library album in database.""" + for prov_id in db_album.provider_ids: + for album_track in await self.albums.get_provider_album_tracks( + prov_id.item_id, prov_id.provider + ): + db_track = await self.tracks.get_db_item_by_prov_id( + album_track.provider, album_track.item_id + ) + if not db_track: + db_track = await self.tracks.add_db_item(album_track) + # add track to album_tracks + await self.mass.music.albums.add_db_album_track( + db_album.item_id, + db_track.item_id, + album_track.disc_number, + album_track.track_number, + ) + + async def _sync_playlist_tracks(self, db_playlist: Playlist) -> None: + """Store playlist tracks of in-library playlist in database.""" + for prov_id in db_playlist.provider_ids: + provider = self.get_provider(prov_id.provider) + if not provider: + continue + for playlist_track in await self.playlists.get_provider_playlist_tracks( + prov_id.item_id, prov_id.provider + ): + db_track = await self.tracks.get_db_item_by_prov_id( + playlist_track.provider, playlist_track.item_id + ) + if not db_track: + db_track = await self.tracks.add_db_item(playlist_track) + assert playlist_track.position is not None + await self.playlists.add_db_playlist_track( + db_playlist.item_id, + db_track.item_id, + playlist_track.position, + ) + + def _get_controller( + self, media_type: MediaType + ) -> ArtistsController | AlbumsController | TracksController | RadioController | PlaylistController: + """Return controller for MediaType.""" + if media_type == MediaType.ARTIST: + return self.artists + if media_type == MediaType.ALBUM: + return self.albums + if media_type == MediaType.TRACK: + return self.tracks + if media_type == MediaType.RADIO: + return self.radio + if media_type == MediaType.PLAYLIST: + return self.playlists + + async def __setup_database_tables(self) -> None: + """Init generic database tables.""" + async with self.mass.database.get_db() as _db: + await _db.execute( + f"""CREATE TABLE IF NOT EXISTS {DB_PROV_MAPPINGS}( + item_id INTEGER NOT NULL, + media_type TEXT NOT NULL, + prov_item_id TEXT NOT NULL, + provider TEXT NOT NULL, + quality INTEGER NOT NULL, + details TEXT NULL, + UNIQUE(item_id, media_type, prov_item_id, provider) + );""" + ) + await _db.execute( + f"""CREATE TABLE IF NOT EXISTS {DB_TRACK_LOUDNESS}( + item_id INTEGER NOT NULL, + provider TEXT NOT NULL, + loudness REAL, + UNIQUE(item_id, provider));""" + ) + await _db.execute( + f"""CREATE TABLE IF NOT EXISTS {DB_PLAYLOG}( + item_id INTEGER NOT NULL, + provider TEXT NOT NULL, + timestamp REAL, + UNIQUE(item_id, provider));""" + ) + + def __schedule_sync_tasks(self): + """Schedule the sync tasks.""" + for prov in self.providers: + create_task(self.schedule_provider_sync(prov.id)) + # reschedule self + self.mass.loop.call_later(3 * 3600, self.__schedule_sync_tasks) diff --git a/music_assistant/controllers/music/albums.py b/music_assistant/controllers/music/albums.py new file mode 100644 index 00000000..6e0abe29 --- /dev/null +++ b/music_assistant/controllers/music/albums.py @@ -0,0 +1,263 @@ +"""Manage MediaItems of type Album.""" +from __future__ import annotations + +import asyncio +from typing import List + +from music_assistant.constants import EventType +from music_assistant.helpers.cache import cached +from music_assistant.helpers.compare import compare_album, compare_strings +from music_assistant.helpers.json import json_serializer +from music_assistant.helpers.util import create_sort_name, merge_dict, merge_list +from music_assistant.models.media_controller import MediaControllerBase +from music_assistant.models.media_items import ( + Album, + AlbumType, + ItemMapping, + MediaType, + Track, +) +from music_assistant.models.provider import MusicProvider + + +class AlbumsController(MediaControllerBase[Album]): + """Controller managing MediaItems of type Album.""" + + db_table = "albums" + media_type = MediaType.ALBUM + item_cls = Album + + async def setup(self): + """Async initialize of module.""" + # prepare database + async with self.mass.database.get_db() as _db: + await _db.execute( + f"""CREATE TABLE IF NOT EXISTS {self.db_table}( + item_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + sort_name TEXT NOT NULL, + album_type TEXT, + year INTEGER, + version TEXT, + in_library BOOLEAN DEFAULT 0, + upc TEXT, + artist json, + metadata json, + provider_ids json + );""" + ) + await _db.execute( + """CREATE TABLE IF NOT EXISTS album_tracks( + album_id INTEGER NOT NULL, + track_id INTEGER NOT NULL, + disc_number INTEGER NOT NULL, + track_number INTEGER NOT NULL, + UNIQUE(album_id, disc_number, track_number) + );""" + ) + + async def tracks(self, item_id: str, provider_id: str) -> List[Track]: + """Return album tracks for the given provider album id.""" + album = await self.get(item_id, provider_id) + # for in-library albums we have the tracks in db + if album.in_library and album.provider == "database": + return await self.get_db_album_tracks(album.item_id) + # else: simply return the tracks from the first provider + for prov in album.provider_ids: + if tracks := await self.get_provider_album_tracks( + prov.item_id, prov.provider + ): + return tracks + return [] + + async def versions(self, item_id: str, provider_id: str) -> List[Album]: + """Return all versions of an album we can find on all providers.""" + album = await self.get(item_id, provider_id) + provider_ids = {item.id for item in self.mass.music.providers} + search_query = f"{album.artist.name} {album.name}" + return [ + prov_item + for prov_items in await asyncio.gather( + *[self.search(search_query, prov_id) for prov_id in provider_ids] + ) + for prov_item in prov_items + if compare_strings(prov_item.artist.name, album.artist.name) + ] + + async def add(self, item: Album) -> Album: + """Add album to local db and return the database item.""" + # make sure we have an artist + assert item.artist + db_item = await self.add_db_item(item) + # also fetch same album on all providers + await self._match(db_item) + db_item = await self.get_db_item(db_item.item_id) + self.mass.signal_event(EventType.ALBUM_ADDED, db_item) + return db_item + + async def get_provider_album_tracks( + self, item_id: str, provider_id: str + ) -> List[Track]: + """Return album tracks for the given provider album id.""" + provider = self.mass.music.get_provider(provider_id) + if not provider: + return [] + cache_key = f"{provider_id}.albumtracks.{item_id}" + return await cached( + self.mass.cache, + cache_key, + provider.get_album_tracks, + item_id, + ) + + async def add_db_item(self, album: Album) -> Album: + """Add a new album record to the database.""" + cur_item = None + if not album.sort_name: + album.sort_name = create_sort_name(album.name) + # always try to grab existing item by external_id + if album.upc: + match = {"upc": album.upc} + cur_item = await self.mass.database.get_row(self.db_table, match) + if not cur_item: + # fallback to matching + match = {"sort_name": album.sort_name} + for row in await self.mass.database.get_rows(self.db_table, match): + row_album = Album.from_db_row(row) + if compare_album(row_album, album): + cur_item = row_album + break + if cur_item: + # update existing + return await self.update_db_album(cur_item.item_id, album) + + # insert new album + album_artist = ItemMapping.from_item( + await self.mass.music.artists.get_db_item_by_prov_id( + album.artist.provider, album.artist.item_id + ) + or album.artist + ) + new_item = await self.mass.database.insert_or_replace( + self.db_table, + {**album.to_db_row(), "artist": json_serializer(album_artist)}, + ) + item_id = new_item["item_id"] + # store provider mappings + await self.mass.music.add_provider_mappings( + item_id, MediaType.ALBUM, album.provider_ids + ) + self.logger.debug("added %s to database", album.name) + # return created object + return await self.get_db_item(item_id) + + async def update_db_album(self, item_id: int, album: Album) -> Album: + """Update Album record in the database.""" + cur_item = await self.get_db_item(item_id) + metadata = merge_dict(cur_item.metadata, album.metadata) + provider_ids = merge_list(cur_item.provider_ids, album.provider_ids) + album_artist = ItemMapping.from_item( + await self.mass.music.artists.get_db_item_by_prov_id( + cur_item.artist.provider, cur_item.artist.item_id + ) + or cur_item.artist + ) + + if cur_item.album_type == AlbumType.UNKNOWN: + album_type = album.album_type + else: + album_type = cur_item.album_type + + match = {"item_id": item_id} + await self.mass.database.update( + self.db_table, + match, + { + "artist": json_serializer(album_artist), + "album_type": album_type.value, + "metadata": json_serializer(metadata), + "provider_ids": json_serializer(provider_ids), + }, + ) + await self.mass.music.add_provider_mappings( + item_id, MediaType.ALBUM, album.provider_ids + ) + self.logger.debug("updated %s in database: %s", album.name, item_id) + return await self.get_db_item(item_id) + + async def get_db_album_tracks(self, item_id) -> List[Track]: + """Get album tracks for an in-library album.""" + query = ( + "SELECT TRACKS.*, ALBUMTRACKS.disc_number, ALBUMTRACKS.track_number " + "FROM [tracks] TRACKS " + "JOIN album_tracks ALBUMTRACKS ON TRACKS.item_id = ALBUMTRACKS.track_id " + f"WHERE ALBUMTRACKS.album_id = {item_id}" + ) + return await self.mass.music.tracks.get_db_items(query) + + async def add_db_album_track( + self, album_id: int, track_id: int, disc_number: int, track_number: int + ) -> None: + """Add album track for an in-library album.""" + return await self.mass.database.insert_or_replace( + "album_tracks", + { + "album_id": album_id, + "track_id": track_id, + "disc_number": disc_number, + "track_number": track_number, + }, + ) + + async def _match(self, db_album: Album) -> None: + """ + Try to find matching album on all providers for the provided (database) album. + + This is used to link objects of different providers/qualities together. + """ + if db_album.provider != "database": + return # Matching only supported for database items + + async def find_prov_match(provider: MusicProvider): + self.logger.debug( + "Trying to match album %s on provider %s", db_album.name, provider.name + ) + match_found = False + searchstr = f"{db_album.artist.name} {db_album.name}" + if db_album.version: + searchstr += " " + db_album.version + search_result = await self.search(searchstr, provider.id) + for search_result_item in search_result: + if not search_result_item.available: + continue + if not compare_album(search_result_item, db_album): + continue + # we must fetch the full album version, search results are simplified objects + prov_album = await self.get_provider_item( + search_result_item.item_id, search_result_item.provider + ) + if compare_album(prov_album, db_album): + # 100% match, we can simply update the db with additional provider ids + await self.update_db_album(db_album.item_id, prov_album) + match_found = True + # while we're here, also match the artist + if db_album.artist.provider == "database": + prov_artist = await self.mass.music.artists.get_provider_item( + prov_album.artist.item_id, prov_album.artist.provider + ) + await self.mass.music.artists.update_db_artist( + db_album.artist.item_id, prov_artist + ) + + # no match found + if not match_found: + self.logger.debug( + "Could not find match for Album %s on provider %s", + db_album.name, + provider.name, + ) + + # try to find match on all providers + for provider in self.mass.music.providers: + if MediaType.ALBUM in provider.supported_mediatypes: + await find_prov_match(provider) diff --git a/music_assistant/controllers/music/artists.py b/music_assistant/controllers/music/artists.py new file mode 100644 index 00000000..f03cf957 --- /dev/null +++ b/music_assistant/controllers/music/artists.py @@ -0,0 +1,269 @@ +"""Manage MediaItems of type Artist.""" + +import asyncio +import itertools +from typing import List + +from music_assistant.constants import EventType +from music_assistant.helpers.cache import cached +from music_assistant.helpers.compare import ( + compare_album, + compare_strings, + compare_track, +) +from music_assistant.helpers.json import json_serializer +from music_assistant.helpers.util import create_sort_name, merge_dict, merge_list +from music_assistant.models.media_controller import MediaControllerBase +from music_assistant.models.media_items import ( + Album, + AlbumType, + Artist, + ItemMapping, + MediaType, + Track, +) +from music_assistant.models.provider import MusicProvider + + +class ArtistsController(MediaControllerBase[Artist]): + """Controller managing MediaItems of type Artist.""" + + db_table = "artists" + media_type = MediaType.ARTIST + item_cls = Artist + + async def setup(self): + """Async initialize of module.""" + # prepare database + async with self.mass.database.get_db() as _db: + await _db.execute( + f"""CREATE TABLE IF NOT EXISTS {self.db_table}( + item_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + sort_name TEXT NOT NULL, + musicbrainz_id TEXT NOT NULL UNIQUE, + in_library BOOLEAN DEFAULT 0, + metadata json, + provider_ids json + );""" + ) + + async def toptracks(self, item_id: str, provider_id: str) -> List[Track]: + """Return top tracks for an artist.""" + artist = await self.get(item_id, provider_id) + # get results from all providers + # TODO: add db results + return itertools.chain.from_iterable( + await asyncio.gather( + *[ + self.get_provider_artist_toptracks(item.item_id, item.provider) + for item in artist.provider_ids + ] + ) + ) + + async def albums(self, item_id: str, provider_id: str) -> List[Album]: + """Return (all/most popular) albums for an artist.""" + artist = await self.get(item_id, provider_id) + # get results from all providers + # TODO: add db results + return itertools.chain.from_iterable( + await asyncio.gather( + *[ + self.get_provider_artist_albums(item.item_id, item.provider) + for item in artist.provider_ids + ] + ) + ) + + async def add(self, item: Artist) -> Artist: + """Add artist to local db and return the database item.""" + if not item.musicbrainz_id: + item.musicbrainz_id = await self.get_artist_musicbrainz_id(item) + # grab additional metadata + item.metadata = await self.mass.metadata.get_artist_metadata( + item.musicbrainz_id, item.metadata + ) + db_item = await self.add_db_item(item) + # also fetch same artist on all providers + await self.match_artist(db_item) + db_item = await self.get_db_item(db_item.item_id) + self.mass.signal_event(EventType.ARTIST_ADDED, db_item) + return db_item + + async def match_artist(self, db_artist: Artist): + """ + Try to find matching artists on all providers for the provided (database) item_id. + + This is used to link objects of different providers together. + """ + assert ( + db_artist.provider == "database" + ), "Matching only supported for database items!" + cur_providers = {item.provider for item in db_artist.provider_ids} + for provider in self.mass.music.providers: + if provider.id in cur_providers: + continue + if MediaType.ARTIST not in provider.supported_mediatypes: + continue + if not await self._match(db_artist, provider): + self.logger.debug( + "Could not find match for Artist %s on provider %s", + db_artist.name, + provider.name, + ) + + async def get_provider_artist_toptracks( + self, item_id: str, provider_id: str + ) -> List[Track]: + """Return top tracks for an artist on given provider.""" + provider = self.mass.music.get_provider(provider_id) + if not provider: + return [] + cache_key = f"{provider_id}.artist_toptracks.{item_id}" + return await cached( + self.mass.cache, + cache_key, + provider.get_artist_toptracks, + item_id, + ) + + async def get_provider_artist_albums( + self, item_id: str, provider_id: str + ) -> List[Album]: + """Return albums for an artist on given provider.""" + provider = self.mass.music.get_provider(provider_id) + if not provider: + return [] + cache_key = f"{provider_id}.artistalbums.{item_id}" + return await cached( + self.mass.cache, + cache_key, + provider.get_artist_albums, + item_id, + ) + + async def add_db_item(self, artist: Artist) -> Artist: + """Add a new artist record to the database.""" + assert artist.musicbrainz_id + assert artist.name + match = {"musicbrainz_id": artist.musicbrainz_id} + if cur_item := await self.mass.database.get_row(self.db_table, match): + # update existing + return await self.update_db_artist(cur_item["item_id"], artist) + # insert artist + if not artist.sort_name: + artist.sort_name = create_sort_name(artist.name) + new_item = await self.mass.database.insert_or_replace( + self.db_table, artist.to_db_row() + ) + item_id = new_item["item_id"] + # store provider mappings + await self.mass.music.add_provider_mappings( + item_id, MediaType.ARTIST, artist.provider_ids + ) + self.logger.debug("added %s to database", artist.name) + # return created object + return await self.get_db_item(item_id) + + async def update_db_artist(self, item_id: int, artist: Artist) -> Artist: + """Update Artist record in the database.""" + cur_item = await self.get_db_item(item_id) + metadata = merge_dict(cur_item.metadata, artist.metadata) + provider_ids = merge_list(cur_item.provider_ids, artist.provider_ids) + match = {"item_id": item_id} + await self.mass.database.update( + self.db_table, + match, + { + "metadata": json_serializer(metadata), + "provider_ids": json_serializer(provider_ids), + }, + ) + await self.mass.music.add_provider_mappings( + item_id, MediaType.ARTIST, artist.provider_ids + ) + self.logger.debug("updated %s in database: %s", artist.name, item_id) + return await self.get_db_item(item_id) + + async def get_artist_musicbrainz_id(self, artist: Artist) -> str: + """Fetch musicbrainz id by performing search using the artist name, albums and tracks.""" + # try with album first + for lookup_album in await self.get_provider_artist_albums( + artist.item_id, artist.provider + ): + if not lookup_album: + continue + if artist.name != lookup_album.artist.name: + continue + musicbrainz_id = await self.mass.metadata.musicbrainz.get_mb_artist_id( + artist.name, + albumname=lookup_album.name, + album_upc=lookup_album.upc, + ) + if musicbrainz_id: + return musicbrainz_id + # fallback to track + for lookup_track in await self.get_provider_artist_toptracks( + artist.item_id, artist.provider + ): + if not lookup_track: + continue + musicbrainz_id = await self.mass.metadata.musicbrainz.get_mb_artist_id( + artist.name, + trackname=lookup_track.name, + track_isrc=lookup_track.isrc, + ) + if musicbrainz_id: + return musicbrainz_id + # lookup failed, use the shitty workaround to use the name as id. + self.logger.warning("Unable to get musicbrainz ID for artist %s !", artist.name) + return artist.name + + async def _match(self, db_artist: Artist, provider: MusicProvider): + """Try to find matching artists on given provider for the provided (database) artist.""" + self.logger.debug( + "Trying to match artist %s on provider %s", db_artist.name, provider.name + ) + # try to get a match with some reference tracks of this artist + for ref_track in await self.toptracks(db_artist.item_id, db_artist.provider): + # make sure we have a full track + if isinstance(ref_track.album, ItemMapping): + ref_track = await self.mass.music.tracks.get( + ref_track.item_id, ref_track.provider + ) + searchstr = f"{db_artist.name} {ref_track.name}" + search_results = await self.mass.music.tracks.search(searchstr, provider.id) + for search_result_item in search_results: + if compare_track(search_result_item, ref_track): + # get matching artist from track + for search_item_artist in search_result_item.artists: + if compare_strings(db_artist.name, search_item_artist.name): + # 100% album match + # get full artist details so we have all metadata + prov_artist = await self.get_provider_item( + search_item_artist.item_id, search_item_artist.provider + ) + await self.update_db_artist(db_artist.item_id, prov_artist) + return + # try to get a match with some reference albums of this artist + artist_albums = await self.albums(db_artist.item_id, db_artist.provider) + for ref_album in artist_albums: + if ref_album.album_type == AlbumType.COMPILATION: + continue + searchstr = f"{db_artist.name} {ref_album.name}" + search_result = await self.mass.music.albums.search(searchstr, provider.id) + for search_result_item in search_result: + # artist must match 100% + if not compare_strings(db_artist.name, search_result_item.artist.name): + continue + if compare_album(search_result_item, ref_album): + # 100% album match + # get full artist details so we have all metadata + prov_artist = await self.get_provider_item( + search_result_item.artist.item_id, + search_result_item.artist.provider, + ) + await self.update_db_artist(db_artist.item_id, prov_artist) + return + return diff --git a/music_assistant/controllers/music/playlists.py b/music_assistant/controllers/music/playlists.py new file mode 100644 index 00000000..73744433 --- /dev/null +++ b/music_assistant/controllers/music/playlists.py @@ -0,0 +1,292 @@ +"""Manage MediaItems of type Playlist.""" +from __future__ import annotations +import time + +from typing import List + +from music_assistant.constants import EventType +from music_assistant.helpers.cache import cached +from music_assistant.models.errors import InvalidDataError, MediaNotFoundError +from music_assistant.helpers.util import create_sort_name, merge_dict, merge_list +from music_assistant.helpers.json import json_serializer +from music_assistant.models.media_controller import MediaControllerBase +from music_assistant.models.media_items import MediaType, Playlist, Track + + +class PlaylistController(MediaControllerBase[Playlist]): + """Controller managing MediaItems of type Playlist.""" + + db_table = "playlists" + media_type = MediaType.PLAYLIST + item_cls = Playlist + + async def setup(self): + """Async initialize of module.""" + # prepare database + async with self.mass.database.get_db() as _db: + await _db.execute( + f"""CREATE TABLE IF NOT EXISTS {self.db_table}( + item_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + sort_name TEXT NOT NULL, + owner TEXT NOT NULL, + is_editable BOOLEAN NOT NULL, + checksum TEXT NOT NULL, + in_library BOOLEAN DEFAULT 0, + metadata json, + provider_ids json, + UNIQUE(name, owner) + );""" + ) + await _db.execute( + """CREATE TABLE IF NOT EXISTS playlist_tracks( + playlist_id INTEGER NOT NULL, + track_id INTEGER NOT NULL, + position INTEGER NOT NULL, + UNIQUE(playlist_id, position) + );""" + ) + + async def get_playlist_by_name(self, name: str) -> Playlist | None: + """Get in-library playlist by name.""" + return await self.mass.database.get_row(self.db_table, {"name": name}) + + async def tracks(self, item_id: str, provider_id: str) -> List[Track]: + """Return playlist tracks for the given provider playlist id.""" + playlist = await self.get(item_id, provider_id) + if playlist.in_library and playlist.provider == "database": + # for in-library playlists we have the tracks in db + return await self.get_db_playlist_tracks(playlist.item_id) + # else: simply return the tracks from the first provider + for prov in playlist.provider_ids: + if tracks := await self.get_provider_playlist_tracks( + prov.item_id, prov.provider + ): + return tracks + return [] + + async def add(self, item: Playlist) -> Playlist: + """Add playlist to local db and return the new database item.""" + db_item = await self.add_db_item(item) + self.mass.signal_event(EventType.PLAYLIST_ADDED, db_item) + return db_item + + async def add_playlist_tracks( + self, item_id: str, provider_id: str, tracks: List[Track] + ) -> None: + """Add multiple tracks to playlist. Creates background tasks to process the action.""" + playlist = await self.get(item_id, provider_id) + if not playlist: + raise MediaNotFoundError(f"Playlist {item_id} not found") + if not playlist.is_editable: + raise InvalidDataError(f"Playlist {playlist.name} is not editable") + for track in tracks: + job_desc = f"Add track {track.uri} to playlist {playlist.uri}" + self.mass.add_job( + self.add_playlist_track(item_id, provider_id, track), job_desc + ) + + async def add_playlist_track( + self, item_id: str, provider_id: str, track: Track + ) -> None: + """Add track to playlist - make sure we dont add duplicates.""" + # we can only edit playlists that are in the database (marked as editable) + playlist = await self.get(item_id, provider_id) + if not playlist: + raise MediaNotFoundError(f"Playlist {item_id} not found") + if not playlist.is_editable: + raise InvalidDataError(f"Playlist {playlist.name} is not editable") + # make sure we have recent full track details + track = await self.mass.music.tracks.get( + track.item_id, track.provider, force_refresh=True, lazy=False + ) + # a playlist can only have one provider (for now) + playlist_prov = next(iter(playlist.provider_ids)) + # grab all existing track ids in the playlist so we can check for duplicates + cur_playlist_track_ids = set() + for item in await self.tracks(playlist_prov.item_id, playlist_prov.provider): + cur_playlist_track_ids.update( + { + i.item_id + for i in item.provider_ids + if i.provider == playlist_prov.provider + } + ) + # check for duplicates + for track_prov in track.provider_ids: + if ( + track_prov.provider == playlist_prov.provider + and track_prov.item_id in cur_playlist_track_ids + ): + raise InvalidDataError( + "Track already exists in playlist {playlist.name}" + ) + # add track to playlist + # we can only add a track to a provider playlist if track is available on that provider + # a track can contain multiple versions on the same provider + # simply sort by quality and just add the first one (assuming track is still available) + track_id_to_add = None + for track_version in sorted( + track.provider_ids, key=lambda x: x.quality, reverse=True + ): + if not track.available: + continue + if track_version.provider == playlist_prov.provider: + track_id_to_add = track_version.item_id + break + if playlist_prov.provider == "file": + # the file provider can handle uri's from all providers so simply add the uri + track_id_to_add = track.uri + break + if not track_id_to_add: + raise MediaNotFoundError( + "Track is not available on provider {playlist_prov.provider}" + ) + # actually add the tracks to the playlist on the provider + # invalidate cache + playlist.checksum = str(time.time()) + await self.update_db_playlist(playlist.item_id, playlist) + # return result of the action on the provider + provider = self.mass.music.get_provider(playlist_prov.provider) + return await provider.add_playlist_tracks( + playlist_prov.item_id, [track_id_to_add] + ) + + async def remove_playlist_tracks( + self, item_id: str, provider_id: str, tracks: List[Track] + ) -> None: + """Remove multiple tracks from playlist. Creates background tasks to process the action.""" + playlist = await self.get(item_id, provider_id) + if not playlist: + raise MediaNotFoundError(f"Playlist {item_id} not found") + if not playlist.is_editable: + raise InvalidDataError(f"Playlist {playlist.name} is not editable") + for track in tracks: + job_desc = f"Remove track {track.uri} from playlist {playlist.uri}" + self.mass.add_job( + self.remove_playlist_track(item_id, provider_id, track), job_desc + ) + + async def remove_playlist_track( + self, item_id: str, provider_id: str, track: Track + ) -> None: + """Remove track from playlist.""" + # we can only edit playlists that are in the database (marked as editable) + playlist = await self.get(item_id, provider_id) + if not playlist: + raise MediaNotFoundError(f"Playlist {item_id} not found") + if not playlist.is_editable: + raise InvalidDataError(f"Playlist {playlist.name} is not editable") + # playlist can only have one provider (for now) + prov_playlist = next(iter(playlist.provider_ids)) + track_ids_to_remove = set() + # a track can contain multiple versions on the same provider, remove all + for track_provider in track.provider_ids: + if track_provider.provider == prov_playlist.provider: + track_ids_to_remove.add(track_provider.item_id) + # actually remove the tracks from the playlist on the provider + if track_ids_to_remove: + # invalidate cache + playlist.checksum = str(time.time()) + await self.update_db_playlist(playlist.item_id, playlist) + provider = self.mass.music.get_provider(prov_playlist.provider) + return await provider.remove_playlist_tracks( + prov_playlist.item_id, track_ids_to_remove + ) + + async def get_provider_playlist_tracks( + self, item_id: str, provider_id: str + ) -> List[Track]: + """Return playlist tracks for the given provider playlist id.""" + provider = self.mass.music.get_provider(provider_id) + if not provider: + return [] + playlist = await provider.get_playlist(item_id) + cache_key = f"{provider_id}.playlisttracks.{item_id}" + + # we need to make sure that position is set on the track + def playlist_track_with_position(track: Track, index: int): + if track.position is None: + track.position = index + return track + + tracks = await cached( + self.mass.cache, + cache_key, + provider.get_playlist_tracks, + item_id, + checksum=playlist.checksum, + ) + + return [ + playlist_track_with_position(track, index) + for index, track in enumerate(tracks) + ] + + async def add_db_item(self, playlist: Playlist) -> Playlist: + """Add a new playlist record to the database.""" + match = {"name": playlist.name, "owner": playlist.owner} + if cur_item := await self.mass.database.get_row(self.db_table, match): + # update existing + return await self.update_db_playlist(cur_item["item_id"], playlist) + + # insert new playlist + new_item = await self.mass.database.insert_or_replace( + self.db_table, + playlist.to_db_row(), + ) + item_id = new_item["item_id"] + # store provider mappings + await self.mass.music.add_provider_mappings( + item_id, MediaType.PLAYLIST, playlist.provider_ids + ) + self.logger.debug("added %s to database", playlist.name) + # return created object + return await self.get_db_item(item_id) + + async def update_db_playlist(self, item_id: int, playlist: Playlist) -> Playlist: + """Update Playlist record in the database.""" + cur_item = await self.get_db_item(item_id) + metadata = merge_dict(cur_item.metadata, playlist.metadata) + provider_ids = merge_list(cur_item.provider_ids, playlist.provider_ids) + if not playlist.sort_name: + playlist.sort_name = create_sort_name(playlist.name) + + match = {"item_id": item_id} + await self.mass.database.update( + self.db_table, + match, + { + "name": playlist.name, + "sort_name": playlist.sort_name, + "owner": playlist.owner, + "is_editable": playlist.is_editable, + "checksum": playlist.checksum, + "metadata": json_serializer(metadata), + "provider_ids": json_serializer(provider_ids), + }, + ) + await self.mass.music.add_provider_mappings( + item_id, MediaType.PLAYLIST, playlist.provider_ids + ) + self.logger.debug("updated %s in database: %s", playlist.name, item_id) + return await self.get_db_item(item_id) + + async def get_db_playlist_tracks(self, item_id) -> List[Track]: + """Get playlist tracks for an in-library playlist.""" + query = ( + "SELECT TRACKS.*, PLAYLISTTRACKS.position " + "FROM [tracks] TRACKS " + "JOIN playlist_tracks PLAYLISTTRACKS ON TRACKS.item_id = PLAYLISTTRACKS.track_id " + f"WHERE PLAYLISTTRACKS.playlist_id = {item_id}" + ) + return await self.mass.music.tracks.get_db_items(query) + + async def add_db_playlist_track( + self, playlist_id: int, track_id: int, position: int + ) -> None: + """Add playlist track for an in-library playlist.""" + return await self.mass.database.insert_or_replace( + "playlist_tracks", + {"playlist_id": playlist_id, "track_id": track_id, "position": position}, + ) diff --git a/music_assistant/controllers/music/radio.py b/music_assistant/controllers/music/radio.py new file mode 100644 index 00000000..cafda653 --- /dev/null +++ b/music_assistant/controllers/music/radio.py @@ -0,0 +1,88 @@ +"""Manage MediaItems of type Radio.""" +from __future__ import annotations + +from music_assistant.constants import EventType +from music_assistant.helpers.util import create_sort_name, merge_dict, merge_list +from music_assistant.helpers.json import json_serializer +from music_assistant.models.media_controller import MediaControllerBase +from music_assistant.models.media_items import MediaType, Radio + + +class RadioController(MediaControllerBase[Radio]): + """Controller managing MediaItems of type Radio.""" + + db_table = "radios" + media_type = MediaType.RADIO + item_cls = Radio + + async def setup(self): + """Async initialize of module.""" + # prepare database + async with self.mass.database.get_db() as _db: + await _db.execute( + f"""CREATE TABLE IF NOT EXISTS {self.db_table}( + item_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + sort_name TEXT NOT NULL, + in_library BOOLEAN DEFAULT 0, + metadata json, + provider_ids json + );""" + ) + + async def get_radio_by_name(self, name: str) -> Radio | None: + """Get in-library radio by name.""" + return await self.mass.database.get_row(self.db_table, {"name": name}) + + async def add(self, item: Radio) -> Radio: + """Add radio to local db and return the new database item.""" + db_item = await self.add_db_item(item) + self.mass.signal_event(EventType.RADIO_ADDED, db_item) + return db_item + + async def add_db_item(self, radio: Radio) -> Radio: + """Add a new radio record to the database.""" + if not radio.sort_name: + radio.sort_name = create_sort_name(radio.name) + match = {"sort_name": radio.sort_name} + if cur_item := await self.mass.database.get_row(self.db_table, match): + # update existing + return await self.update_db_radio(cur_item["item_id"], radio) + + # insert new radio + new_item = await self.mass.database.insert_or_replace( + self.db_table, radio.to_db_row() + ) + item_id = new_item["item_id"] + # store provider mappings + await self.mass.music.add_provider_mappings( + item_id, MediaType.RADIO, radio.provider_ids + ) + self.logger.debug("added %s to database", radio.name) + # return created object + return await self.get_db_item(item_id) + + async def update_db_radio(self, item_id: int, radio: Radio) -> Radio: + """Update Radio record in the database.""" + cur_item = await self.get_db_item(item_id) + metadata = merge_dict(cur_item.metadata, radio.metadata) + provider_ids = merge_list(cur_item.provider_ids, radio.provider_ids) + if not radio.sort_name: + radio.sort_name = create_sort_name(radio.name) + + match = {"item_id": item_id} + await self.mass.database.update( + self.db_table, + match, + { + "name": radio.name, + "sort_name": radio.sort_name, + "metadata": json_serializer(metadata), + "provider_ids": json_serializer(provider_ids), + }, + ) + await self.mass.music.add_provider_mappings( + item_id, MediaType.RADIO, radio.provider_ids + ) + self.logger.debug("updated %s in database: %s", radio.name, item_id) + return await self.get_db_item(item_id) diff --git a/music_assistant/controllers/music/tracks.py b/music_assistant/controllers/music/tracks.py new file mode 100644 index 00000000..867bf8db --- /dev/null +++ b/music_assistant/controllers/music/tracks.py @@ -0,0 +1,243 @@ +"""Manage MediaItems of type Track.""" +from __future__ import annotations + +import asyncio +from typing import List + +from music_assistant.constants import EventType +from music_assistant.helpers.compare import ( + compare_artists, + compare_strings, + compare_track, +) +from music_assistant.helpers.util import create_sort_name, merge_dict, merge_list +from music_assistant.helpers.json import json_serializer +from music_assistant.models.media_controller import MediaControllerBase +from music_assistant.models.media_items import ( + ItemMapping, + MediaType, + Track, +) + + +class TracksController(MediaControllerBase[Track]): + """Controller managing MediaItems of type Track.""" + + db_table = "tracks" + media_type = MediaType.TRACK + item_cls = Track + + async def setup(self) -> None: + """Async initialize of module.""" + # prepare database + async with self.mass.database.get_db() as _db: + await _db.execute( + f"""CREATE TABLE IF NOT EXISTS {self.db_table}( + item_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + sort_name TEXT NOT NULL, + version TEXT, + duration INTEGER, + in_library BOOLEAN DEFAULT 0, + isrc TEXT, + artists json, + metadata json, + provider_ids json + );""" + ) + + async def add(self, item: Track) -> Track: + """Add track to local db and return the new database item.""" + # make sure we have artists + assert item.artists + db_item = await self.add_db_item(item) + # also fetch same track on all providers (will also get other quality versions) + await self._match(db_item) + db_item = await self.get_db_item(db_item.item_id) + self.mass.signal_event(EventType.TRACK_ADDED, db_item) + return db_item + + async def versions(self, item_id: str, provider_id: str) -> List[Track]: + """Return all versions of a track we can find on all providers.""" + track = await self.get(item_id, provider_id) + provider_ids = {item.id for item in self.mass.music.providers} + first_artist = next(iter(track.artists)) + search_query = f"{first_artist.name} {track.name}" + return [ + prov_item + for prov_items in await asyncio.gather( + *[self.search(search_query, prov_id) for prov_id in provider_ids] + ) + for prov_item in prov_items + if compare_artists(prov_item.artists, track.artists) + ] + + async def _match(self, db_track: Track) -> None: + """ + Try to find matching track on all providers for the provided (database) track_id. + + This is used to link objects of different providers/qualities together. + """ + if db_track.provider != "database": + return # Matching only supported for database items + if isinstance(db_track.album, ItemMapping): + # matching only works if we have a full track object + db_track = await self.get_db_item(db_track.item_id) + for provider in self.mass.music.providers: + if MediaType.TRACK not in provider.supported_mediatypes: + continue + self.logger.debug( + "Trying to match track %s on provider %s", db_track.name, provider.name + ) + match_found = False + for db_track_artist in db_track.artists: + if match_found: + break + searchstr = f"{db_track_artist.name} {db_track.name}" + if db_track.version: + searchstr += " " + db_track.version + search_result = await self.search(searchstr, provider.id) + for search_result_item in search_result: + if not search_result_item.available: + continue + if compare_track(search_result_item, db_track): + # 100% match, we can simply update the db with additional provider ids + match_found = True + await self.update_db_track(db_track.item_id, search_result_item) + # while we're here, also match the artist + if db_track_artist.provider == "database": + for artist in search_result_item.artists: + if not compare_strings( + db_track_artist.name, artist.name + ): + continue + prov_artist = ( + await self.mass.music.artists.get_provider_item( + artist.item_id, artist.provider + ) + ) + await self.mass.music.artists.update_db_artist( + db_track_artist.item_id, prov_artist + ) + + if not match_found: + self.logger.debug( + "Could not find match for Track %s on provider %s", + db_track.name, + provider.name, + ) + + async def add_db_item(self, track: Track) -> Track: + """Add a new track record to the database.""" + assert track.artists, "Track is missing artist(s)" + if not track.sort_name: + track.sort_name = create_sort_name(track.name) + cur_item = None + # always try to grab existing item by external_id + if track.isrc: + match = {"isrc": track.isrc} + cur_item = await self.mass.database.get_row(self.db_table, match) + if not cur_item: + # fallback to matching + match = {"sort_name": track.sort_name} + for row in await self.mass.database.get_rows(self.db_table, match): + row_track = Track.from_db_row(row) + if compare_track(row_track, track): + cur_item = row_track + break + if cur_item: + # update existing + return await self.update_db_track(cur_item.item_id, track) + + # no existing match found: insert new track + track_artists = await self._get_track_artists(track) + new_item = await self.mass.database.insert_or_replace( + self.db_table, + { + **track.to_db_row(), + "artists": json_serializer(track_artists), + }, + ) + item_id = new_item["item_id"] + # store provider mappings + await self.mass.music.add_provider_mappings( + item_id, MediaType.TRACK, track.provider_ids + ) + + # add track to album_tracks + if track.album is not None: + album = await self.get_db_item_by_prov_id( + track.album.provider, track.album.item_id + ) or await self.mass.music.albums.add_db_item(track.album) + if ( + album + and track.disc_number is not None + and track.track_number is not None + ): + await self.mass.music.albums.add_db_album_track( + album.item_id, item_id, track.disc_number, track.track_number + ) + # return created object + return await self.get_db_item(item_id) + + async def update_db_track(self, item_id: int, track: Track) -> Track: + """Update Track record in the database.""" + cur_item = await self.get_db_item(item_id) + metadata = merge_dict(cur_item.metadata, track.metadata) + provider_ids = merge_list(cur_item.provider_ids, track.provider_ids) + # we store a mapping to artists on the track for easier access/listings + track_artists = await self._get_track_artists(track, cur_item.artists) + match = {"item_id": item_id} + await self.mass.database.update( + self.db_table, + match, + { + "artists": json_serializer(track_artists), + "metadata": json_serializer(metadata), + "provider_ids": json_serializer(provider_ids), + "isrc": cur_item.isrc or track.isrc, + "duration": cur_item.duration or track.duration, + }, + ) + await self.mass.music.add_provider_mappings( + item_id, MediaType.TRACK, track.provider_ids + ) + # add track to album_tracks + if ( + track.album is not None + and track.disc_number is not None + and track.track_number is not None + ): + album = await self.get_db_item_by_prov_id( + track.album.provider, track.album.item_id + ) or await self.mass.music.albums.add_db_item(track.album) + if album: + await self.mass.music.albums.add_db_album_track( + album.item_id, item_id, track.disc_number, track.track_number + ) + self.logger.debug("updated %s in database: %s", track.name, item_id) + return await self.get_db_item(item_id) + + async def _get_track_artists( + self, track: Track, cur_artists: List[ItemMapping] | None = None + ) -> List[ItemMapping]: + """Extract all (unique) artists of track as ItemMapping.""" + if cur_artists is None: + cur_artists = [] + cur_artists.extend(track.artists) + track_artists: List[ItemMapping] = [] + for item in cur_artists: + cur_names = {x.name for x in track_artists} + cur_ids = {x.item_id for x in track_artists} + track_artist = ( + await self.mass.music.artists.get_db_item_by_prov_id( + item.provider, item.item_id + ) + or item + ) + if ( + track_artist.name not in cur_names + and track_artist.item_id not in cur_ids + ): + track_artists.append(ItemMapping.from_item(track_artist)) + return track_artists diff --git a/music_assistant/controllers/players.py b/music_assistant/controllers/players.py new file mode 100755 index 00000000..0c77756e --- /dev/null +++ b/music_assistant/controllers/players.py @@ -0,0 +1,100 @@ +"""Logic to play music from MusicProviders to supported players.""" +from __future__ import annotations + +from typing import Dict, Tuple + +from music_assistant.constants import EventType +from music_assistant.controllers.stream import StreamController +from music_assistant.models.errors import AlreadyRegisteredError +from music_assistant.helpers.typing import MusicAssistant +from music_assistant.models.player import Player, PlayerGroup +from music_assistant.models.player_queue import PlayerQueue + +PlayerType = Player | PlayerGroup + +DB_TABLE = "queue_settings" + + +class PlayerController: + """Controller holding all logic to play music from MusicProviders to supported players.""" + + def __init__(self, mass: MusicAssistant, stream_port: int) -> None: + """Initialize class.""" + self.mass = mass + self.logger = mass.logger.getChild("players") + self._players: Dict[str, PlayerType] = {} + self._player_queues: Dict[str, PlayerQueue] = {} + self.streams = StreamController(mass, stream_port) + + async def setup(self) -> None: + """Async initialize of module.""" + async with self.mass.database.get_db() as _db: + await _db.execute( + """CREATE TABLE IF NOT EXISTS queue_settings( + queue_id TEXT UNIQUE, + crossfade_duration INTEGER, + shuffle_enabled BOOLEAN, + repeat_enabled BOOLEAN, + volume_normalization_enabled BOOLEAN, + volume_normalization_target INTEGER)""" + ) + await self.streams.setup() + + @property + def players(self) -> Tuple[PlayerType]: + """Return all available players.""" + return tuple(x for x in self._players.values() if x.available) + + @property + def player_queues(self) -> Tuple[PlayerQueue]: + """Return all available PlayerQueue's.""" + return tuple(x for x in self._player_queues.values() if x.available) + + def __iter__(self): + """Iterate over (available) players.""" + return iter(x for x in self._players.values() if x.available) + + def get_player( + self, player_id: str, include_unavailable: bool = False + ) -> PlayerType | None: + """Return Player by player_id or None if not found/unavailable.""" + if player := self._players.get(player_id): + if player.available or include_unavailable: + return player + return None + + def get_player_queue( + self, queue_id: str, include_unavailable: bool = False + ) -> PlayerQueue | None: + """Return PlayerQueue by id or None if not found/unavailable.""" + if player_queue := self._player_queues.get(queue_id): + if player_queue.available or include_unavailable: + return player_queue + return None + + def get_player_by_name(self, name: str) -> PlayerType | None: + """Return Player by name or None if no match is found.""" + return next((x for x in self._players.values() if x.name == name), None) + + async def register_player(self, player: PlayerType) -> None: + """Register a new player on the controller.""" + player_id = player.player_id + + if player_id in self._players: + raise AlreadyRegisteredError(f"Player {player_id} is already registered") + + # make sure that the mass instance is set on the player + player.mass = self.mass + self._players[player_id] = player + + # create playerqueue for this player + self._player_queues[player.player_id] = player_queue = PlayerQueue( + self.mass, player_id + ) + await player_queue.setup() + self.logger.info( + "Player registered: %s/%s", + player_id, + player.name, + ) + self.mass.signal_event(EventType.PLAYER_ADDED, player) diff --git a/music_assistant/controllers/stream.py b/music_assistant/controllers/stream.py new file mode 100644 index 00000000..b4993117 --- /dev/null +++ b/music_assistant/controllers/stream.py @@ -0,0 +1,465 @@ +"""Controller to stream audio to players.""" +from __future__ import annotations + +import asyncio +from asyncio import Task +from dataclasses import dataclass + +from typing import AsyncGenerator, Awaitable, Callable, Dict, List +from time import time +from uuid import uuid4 +from aiohttp import web + +from music_assistant.constants import EventType +from music_assistant.helpers.audio import ( + check_audio_support, + create_wave_header, + crossfade_pcm_parts, + get_media_stream, + get_sox_args_for_pcm_stream, + get_stream_details, + strip_silence, +) +from music_assistant.helpers.process import AsyncProcess +from music_assistant.helpers.typing import MusicAssistant +from music_assistant.helpers.util import get_ip +from music_assistant.models.media_items import ContentType +from music_assistant.models.player_queue import PlayerQueue + + +@dataclass(frozen=True) +class PCMArgs: + """Specify raw pcm audio.""" + + sample_rate: int + bit_depth: int + channels: int + + +class StreamController: + """Controller to stream audio to players.""" + + def __init__(self, mass: MusicAssistant, port: int = 8095): + """Initialize instance.""" + self.mass = mass + self.logger = mass.logger.getChild("stream") + self._port = port + self._ip: str = get_ip() + self._subscribers: Dict[str, Dict[str, List[Callable]]] = {} + self._stream_tasks: Dict[str, Task] = {} + self._pcmargs: Dict[str, PCMArgs] = {} + + def get_stream_url(self, queue_id: str) -> str: + """Return the full stream url for the PlayerQueue Stream.""" + return f"http://{self._ip}:{self._port}/{queue_id}.flac" + + async def setup(self) -> None: + """Async initialize of module.""" + app = web.Application() + + app.router.add_get("/{queue_id}.wav", self.serve_stream_client_pcm) + app.router.add_get("/{queue_id}.{format}", self.serve_stream_client) + app.router.add_get("/{queue_id}", self.serve_stream_client) + + runner = web.AppRunner(app, access_log=None) + await runner.setup() + # set host to None to bind to all addresses on both IPv4 and IPv6 + http_site = web.TCPSite( + runner, host=None, port=self._port, reuse_address=True, reuse_port=True + ) + await http_site.start() + + async def on_shutdown_event(*args, **kwargs): + """Handle shutdown event.""" + for subscribers in self._subscribers.values(): + for callback in subscribers.values(): + await callback(b"") + for task in self._stream_tasks.values(): + task.cancel() + await http_site.stop() + await runner.shutdown() + await app.shutdown() + await app.cleanup() + self.logger.info("Streamserver exited.") + + self.mass.subscribe(on_shutdown_event, EventType.SHUTDOWN) + + sox_present, ffmpeg_present = await check_audio_support(True) + if not ffmpeg_present: + self.logger.error( + "The FFmpeg binary was not found on your system, " + "you might have issues with playback. " + "Please install FFmpeg with your OS package manager.", + ) + elif not sox_present: + self.logger.warning( + "The SoX binary was not found on your system so FFmpeg is used as fallback. " + "For best audio quality, please install SoX with your OS package manager.", + ) + + self.logger.info("Started stream server on port %s", self._port) + + async def serve_stream_client(self, request: web.Request): + """Serve queue audio stream to client (encoded to FLAC or MP3).""" + queue_id = request.match_info["queue_id"] + clientid = f'{request.remote}_{request.query.get("playerid", str(uuid4()))}' + fmt = request.match_info.get("format", "flac") + + if self.mass.players.get_player_queue(queue_id) is None: + return web.Response(status=404) + + # prepare request + resp = web.StreamResponse( + status=200, reason="OK", headers={"Content-Type": f"audio/{fmt}"} + ) + await resp.prepare(request) + + pcmargs = await self._get_queue_stream_pcm_args(queue_id) + output_fmt = ContentType(fmt) + sox_args = await get_sox_args_for_pcm_stream( + pcmargs.sample_rate, + pcmargs.bit_depth, + pcmargs.channels, + output_format=output_fmt, + ) + try: + # get the raw pcm bytes from the queue stream and on the fly encode as flac + # send the flac endoded stream to the subscribers. + async with AsyncProcess(sox_args, True) as sox_proc: + + async def reader(): + # task that reads flac endoded chunks from the subprocess + self.logger.debug("start reader task") + chunksize = 32000 if output_fmt == ContentType.MP3 else 256000 + async for audio_chunk in sox_proc.iterate_chunks(chunksize): + await resp.write(audio_chunk) + self.logger.debug("reader task finished") + + # feed raw pcm chunks into sox/ffmpeg to encode to flac + async def audio_callback(audio_chunk): + if audio_chunk == b"": + self.logger.debug("last chunk received from stream") + sox_proc.write_eof() + return + await sox_proc.write(audio_chunk) + + # wait for the output task to complete + await self.subscribe(queue_id, clientid, audio_callback) + await reader() + + finally: + await self.unsubscribe(queue_id, clientid) + return resp + + async def serve_stream_client_pcm(self, request: web.Request): + """Serve queue audio stream to client in the raw PCM format.""" + queue_id = request.match_info["queue_id"] + queue = self.mass.players.get_player_queue(queue_id) + clientid = f'{request.remote}_{request.query.get("playerid", str(uuid4()))}' + + if queue is None: + return web.Response(status=404) + + # prepare request + pcmargs = await self._get_queue_stream_pcm_args(queue_id, 32) + fmt = f"x-wav;codec=pcm;rate={pcmargs.sample_rate};bitrate={pcmargs.bit_depth};channels={pcmargs.channels}" + resp = web.StreamResponse( + status=200, + reason="OK", + headers={"Content-Type": f"audio/{fmt}"}, + ) + await resp.prepare(request) + + # write wave header + wav_header = create_wave_header( + pcmargs.sample_rate, + pcmargs.channels, + pcmargs.bit_depth, + ) + await resp.write(wav_header) + + # start delivering audio chunks + last_chunk_received = asyncio.Event() + try: + + async def audio_callback(audio_chunk): + if audio_chunk == b"": + last_chunk_received.set() + return + try: + await resp.write(audio_chunk) + except BrokenPipeError: + pass # race condition + + await self.subscribe(queue_id, clientid, audio_callback) + await last_chunk_received.wait() + finally: + await self.unsubscribe(queue_id, clientid) + return resp + + async def subscribe( + self, queue_id: str, clientid: str, callback: Awaitable + ) -> None: + """Subscribe client to queue stream.""" + self._subscribers.setdefault(queue_id, {}) + if queue_id in self._subscribers[queue_id]: + # client is already subscribed ? + await self.unsubscribe(queue_id, clientid) + self._subscribers[queue_id][clientid] = callback + stream_task = self._stream_tasks.get(queue_id) + if not stream_task or stream_task.cancelled(): + # first connect, start the stream task + task = asyncio.create_task(self.start_queue_stream(queue_id)) + + def task_done_callback(*args, **kwargs): + self._stream_tasks.pop(queue_id, None) + + task.add_done_callback(task_done_callback) + self._stream_tasks[queue_id] = task + + self.logger.debug("Subscribed client %s to queue stream %s", clientid, queue_id) + + async def unsubscribe(self, queue_id: str, clientid: str): + """Unsubscribe client from queue stream.""" + self._subscribers[queue_id].pop(clientid, None) + self.logger.debug( + "Unsubscribed client %s from queue stream %s", clientid, queue_id + ) + if len(self._subscribers[queue_id]) == 0: + # no more clients, cancel stream task + self.logger.debug( + "Aborted queue stream %s due to no more clients", queue_id + ) + if task := self._stream_tasks.pop(queue_id, None): + task.cancel() + self._pcmargs.pop(queue_id, None) + + async def start_queue_stream(self, queue_id: str) -> None: + """Start the Queue stream feeding callbacks of listeners..""" + queue = self.mass.players.get_player_queue(queue_id) + pcmargs = await self._get_queue_stream_pcm_args(queue_id) + + self.logger.info( + "Starting Queue stream for Queue %s with args: %s", queue_id, pcmargs + ) + async for chunk in self._get_queue_stream( + queue, + pcmargs.sample_rate, + pcmargs.bit_depth, + pcmargs.channels, + ): + if len(self._subscribers[queue_id].values()) == 0: + self.logger.info("Queue stream for Queue %s aborted", queue_id) + return + await asyncio.gather( + *[cb(chunk) for cb in list(self._subscribers[queue_id].values())] + ) + self.logger.info("Queue stream for Queue %s finished.", queue_id) + # send empty chunk to inform EOF + await asyncio.gather( + *[cb(b"") for cb in list(self._subscribers[queue_id].values())] + ) + + async def _get_queue_stream_pcm_args( + self, queue_id: str, forced_bit_depth: int = None + ) -> PCMArgs: + """Return the current/ext PCM args for the queue stream.""" + if queue_id in self._pcmargs: + return self._pcmargs[queue_id] + queue = self.mass.players.get_player_queue(queue_id) + next_streamdetails = await queue.queue_stream_prepare() + pcmargs = PCMArgs( + sample_rate=min(next_streamdetails.sample_rate, queue.max_sample_rate), + bit_depth=forced_bit_depth or next_streamdetails.bit_depth, + channels=2, + ) + self._pcmargs[queue_id] = pcmargs + return pcmargs + + async def _get_queue_stream( + self, queue: PlayerQueue, sample_rate: int, bit_depth: int, channels: int = 2 + ) -> AsyncGenerator[None, bytes]: + """Stream the PlayerQueue's tracks as constant feed of PCM raw audio.""" + last_fadeout_data = b"" + queue_index = None + track_count = 0 + start_timestamp = time() + + # stream queue tracks one by one + while True: + # get the (next) track in queue + track_count += 1 + if track_count == 1: + # report start of queue playback so we can calculate current track/duration etc. + queue_index = await queue.queue_stream_start() + else: + queue_index = await queue.queue_stream_next(queue_index) + queue_track = queue.get_item(queue_index) + if not queue_track: + self.logger.debug("no (more) tracks in queue %s", queue.queue_id) + break + # get streamdetails + streamdetails = await get_stream_details( + self.mass, queue_track, queue.queue_id, lazy=track_count == 1 + ) + if not streamdetails: + self.logger.warning("Skip track due to missing streamdetails") + continue + + # get the PCM samplerate/bitrate + if streamdetails.bit_depth > bit_depth: + await queue.queue_stream_signal_next() + self.logger.debug("Abort queue stream due to bit depth mismatch") + await queue.queue_stream_signal_next() + break + if ( + streamdetails.sample_rate > sample_rate + and streamdetails.sample_rate <= queue.max_sample_rate + ): + self.logger.debug("Abort queue stream due to sample rate mismatch") + await queue.queue_stream_signal_next() + break + + pcm_fmt = ContentType.from_bit_depth(bit_depth) + sample_size = int(sample_rate * (bit_depth / 8) * channels) # 1 second + buffer_size = sample_size * ( + queue.crossfade_duration or 1 + ) # 1...10 seconds + + self.logger.debug( + "Start Streaming queue track: %s (%s) for player %s - PCM format: %s - rate: %s", + queue_track.item_id, + queue_track.name, + queue.player.name, + pcm_fmt.value, + sample_rate, + ) + fade_in_part = b"" + cur_chunk = 0 + prev_chunk = None + bytes_written = 0 + # handle incoming audio chunks + async for is_last_chunk, chunk in get_media_stream( + self.mass, + streamdetails, + pcm_fmt, + resample=sample_rate, + chunk_size=buffer_size, + ): + cur_chunk += 1 + + # HANDLE FIRST PART OF TRACK + if not chunk and bytes_written == 0: + # stream error: got empy first chunk + self.logger.error("Stream error on track %s", queue_track.item_id) + # prevent player queue get stuck by just skipping to the next track + queue_track.duration = 0 + continue + if cur_chunk <= 2 and not last_fadeout_data: + # no fadeout_part available so just pass it to the output directly + yield chunk + bytes_written += len(chunk) + del chunk + elif cur_chunk == 1 and last_fadeout_data: + prev_chunk = chunk + del chunk + # HANDLE CROSSFADE OF PREVIOUS TRACK FADE_OUT AND THIS TRACK FADE_IN + elif cur_chunk == 2 and last_fadeout_data: + # combine the first 2 chunks and strip off silence + first_part = await strip_silence( + prev_chunk + chunk, pcm_fmt, sample_rate + ) + if len(first_part) < buffer_size: + # part is too short after the strip action?! + # so we just use the full first part + first_part = prev_chunk + chunk + fade_in_part = first_part[:buffer_size] + remaining_bytes = first_part[buffer_size:] + del first_part + # do crossfade + crossfade_part = await crossfade_pcm_parts( + fade_in_part, + last_fadeout_data, + queue.crossfade_duration, + pcm_fmt, + sample_rate, + ) + # send crossfade_part + yield crossfade_part + bytes_written += len(crossfade_part) + del crossfade_part + del fade_in_part + last_fadeout_data = b"" + # also write the leftover bytes from the strip action + yield remaining_bytes + bytes_written += len(remaining_bytes) + del remaining_bytes + del chunk + prev_chunk = None # needed to prevent this chunk being sent again + # HANDLE LAST PART OF TRACK + elif prev_chunk and is_last_chunk: + # last chunk received so create the last_part + # with the previous chunk and this chunk + # and strip off silence + last_part = await strip_silence( + prev_chunk + chunk, pcm_fmt, sample_rate, True + ) + if len(last_part) < buffer_size: + # part is too short after the strip action + # so we just use the entire original data + last_part = prev_chunk + chunk + if not queue.crossfade_duration or len(last_part) < buffer_size: + # crossfading is not enabled or not enough data, + # so just pass the (stripped) audio data + if queue.crossfade_duration: + self.logger.warning( + "Not enough data for crossfade: %s", len(last_part) + ) + yield last_part + bytes_written += len(last_part) + del last_part + del chunk + else: + # handle crossfading support + # store fade section to be picked up for next track + last_fadeout_data = last_part[-buffer_size:] + remaining_bytes = last_part[:-buffer_size] + # write remaining bytes + if remaining_bytes: + yield remaining_bytes + bytes_written += len(remaining_bytes) + del last_part + del remaining_bytes + del chunk + # MIDDLE PARTS OF TRACK + else: + # middle part of the track + # keep previous chunk in memory so we have enough + # samples to perform the crossfade + if prev_chunk: + yield prev_chunk + bytes_written += len(prev_chunk) + prev_chunk = chunk + else: + prev_chunk = chunk + del chunk + # guard for clients buffering too much + seconds_streamed = bytes_written / sample_size + seconds_per_chunk = buffer_size / sample_size + seconds_needed = int(time() - start_timestamp + seconds_per_chunk) + if (seconds_streamed) > seconds_needed: + self.logger.debug("cooldown %s seconds", seconds_per_chunk / 2) + await asyncio.sleep(seconds_per_chunk / 2) + # end of the track reached + # update actual duration to the queue for more accurate now playing info + accurate_duration = bytes_written / sample_size + queue_track.duration = accurate_duration + self.logger.debug( + "Finished Streaming queue track: %s (%s) on queue %s", + queue_track.item_id, + queue_track.name, + queue.player.name, + ) + # end of queue reached, pass last fadeout bits to final output + yield last_fadeout_data + # END OF QUEUE STREAM diff --git a/music_assistant/helpers/audio.py b/music_assistant/helpers/audio.py index b57fddb3..394f202f 100644 --- a/music_assistant/helpers/audio.py +++ b/music_assistant/helpers/audio.py @@ -4,37 +4,81 @@ import asyncio import logging import struct from io import BytesIO -from typing import List, Optional, Tuple +from typing import AsyncGenerator, List, Optional, Tuple -from music_assistant.helpers.process import AsyncProcess +import aiofiles + +from music_assistant.constants import EventType +from music_assistant.helpers.process import AsyncProcess, check_output from music_assistant.helpers.typing import MusicAssistant, QueueItem from music_assistant.helpers.util import create_tempfile -from music_assistant.models.media_types import MediaType -from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType +from music_assistant.models.errors import AudioError +from music_assistant.models.media_items import ( + ContentType, + MediaType, + StreamDetails, + StreamType, +) LOGGER = logging.getLogger("audio") +# pylint:disable=consider-using-f-string + async def crossfade_pcm_parts( - fade_in_part: bytes, fade_out_part: bytes, pcm_args: List[str], fade_length: int + fade_in_part: bytes, + fade_out_part: bytes, + fade_length: int, + fmt: ContentType, + sample_rate: int, ) -> bytes: """Crossfade two chunks of pcm/raw audio using sox.""" + _, ffmpeg_present = await check_audio_support() + + # prefer ffmpeg implementation (due to simplicity) + if ffmpeg_present: + fadeoutfile = create_tempfile() + async with aiofiles.open(fadeoutfile.name, "wb") as outfile: + await outfile.write(fade_out_part) + # input args + args = ["ffmpeg", "-hide_banner", "-loglevel", "error"] + args += [ + "-f", + fmt.value, + "-ac", + "2", + "-ar", + str(sample_rate), + "-i", + fadeoutfile.name, + ] + args += ["-f", fmt.value, "-ac", "2", "-ar", str(sample_rate), "-i", "-"] + # filter args + args += ["-filter_complex", f"[0][1]acrossfade=d={fade_length}"] + # output args + args += ["-f", fmt.value, "-"] + async with AsyncProcess(args, True) as proc: + crossfade_data, _ = await proc.communicate(fade_in_part) + return crossfade_data + + # sox based implementation + sox_args = [fmt.sox_format(), "-c", "2", "-r", str(sample_rate)] # create fade-in part fadeinfile = create_tempfile() - args = ["sox", "--ignore-length", "-t"] + pcm_args - args += ["-", "-t"] + pcm_args + [fadeinfile.name, "fade", "t", str(fade_length)] + args = ["sox", "--ignore-length", "-t"] + sox_args + args += ["-", "-t"] + sox_args + [fadeinfile.name, "fade", "t", str(fade_length)] async with AsyncProcess(args, enable_write=True) as sox_proc: await sox_proc.communicate(fade_in_part) # create fade-out part fadeoutfile = create_tempfile() - args = ["sox", "--ignore-length", "-t"] + pcm_args + ["-", "-t"] + pcm_args + args = ["sox", "--ignore-length", "-t"] + sox_args + ["-", "-t"] + sox_args args += [fadeoutfile.name, "reverse", "fade", "t", str(fade_length), "reverse"] async with AsyncProcess(args, enable_write=True) as sox_proc: await sox_proc.communicate(fade_out_part) # create crossfade using sox and some temp files # TODO: figure out how to make this less complex and without the tempfiles - args = ["sox", "-m", "-v", "1.0", "-t"] + pcm_args + [fadeoutfile.name, "-v", "1.0"] - args += ["-t"] + pcm_args + [fadeinfile.name, "-t"] + pcm_args + ["-"] + args = ["sox", "-m", "-v", "1.0", "-t"] + sox_args + [fadeoutfile.name, "-v", "1.0"] + args += ["-t"] + sox_args + [fadeinfile.name, "-t"] + sox_args + ["-"] async with AsyncProcess(args, enable_write=False) as sox_proc: crossfade_part, _ = await sox_proc.communicate() fadeinfile.close() @@ -44,9 +88,30 @@ async def crossfade_pcm_parts( return crossfade_part -async def strip_silence(audio_data: bytes, pcm_args: List[str], reverse=False) -> bytes: +async def strip_silence( + audio_data: bytes, fmt: ContentType, sample_rate: int, reverse=False +) -> bytes: """Strip silence from (a chunk of) pcm audio.""" - args = ["sox", "--ignore-length", "-t"] + pcm_args + ["-", "-t"] + pcm_args + ["-"] + _, ffmpeg_present = await check_audio_support() + # prefer ffmpeg implementation + if ffmpeg_present: + # input args + args = ["ffmpeg", "-hide_banner", "-loglevel", "error"] + args += ["-f", fmt.value, "-ac", "2", "-ar", str(sample_rate), "-i", "-"] + # filter args + if reverse: + args += ["-af", "areverse,silenceremove=1:0:-50dB:detection=peak,areverse"] + else: + args += ["-af", "silenceremove=1:0:-50dB:detection=peak"] + # output args + args += ["-f", fmt.value, "-"] + async with AsyncProcess(args, True) as proc: + stripped_data, _ = await proc.communicate(audio_data) + return stripped_data + + # sox implementation + sox_args = [fmt.sox_format(), "-c", "2", "-r", str(sample_rate)] + args = ["sox", "--ignore-length", "-t"] + sox_args + ["-", "-t"] + sox_args + ["-"] if reverse: args.append("reverse") args += ["silence", "1", "0.1", "1%"] @@ -97,7 +162,7 @@ async def analyze_audio(mass: MusicAssistant, streamdetails: StreamDetails) -> N ) value, _ = await proc.communicate(audio_data or None) loudness = float(value.decode().strip()) - await mass.database.set_track_loudness( + await mass.music.set_track_loudness( streamdetails.item_id, streamdetails.provider, loudness ) LOGGER.debug( @@ -109,46 +174,40 @@ async def analyze_audio(mass: MusicAssistant, streamdetails: StreamDetails) -> N async def get_stream_details( - mass: MusicAssistant, queue_item: QueueItem, player_id: str = "" -) -> StreamDetails: + mass: MusicAssistant, queue_item: QueueItem, queue_id: str = "", lazy: bool = True +) -> StreamDetails | None: """ - Get streamdetails for the given media_item. + Get streamdetails for the given QueueItem. - This is called just-in-time when a player/queue wants a MediaItem to be played. + This is called just-in-time when a PlayerQueue wants a MediaItem to be played. Do not try to request streamdetails in advance as this is expiring data. param media_item: The MediaItem (track/radio) for which to request the streamdetails for. - param player_id: Optionally provide the player_id which will play this stream. + param queue_id: Optionally provide the queue_id which will play this stream. """ - if queue_item.provider == "url": + if not queue_item.is_media_item: # special case: a plain url was added to the queue streamdetails = StreamDetails( type=StreamType.URL, provider="url", item_id=queue_item.item_id, - path=queue_item.uri if queue_item.uri else queue_item.item_id, + path=queue_item.uri, content_type=ContentType(queue_item.uri.split(".")[-1]), ) else: # always request the full db track as there might be other qualities available - # except for radio - if queue_item.media_type == MediaType.RADIO: - full_track = await mass.music.get_radio( - queue_item.item_id, queue_item.provider - ) - else: - full_track = await mass.music.get_track( - queue_item.item_id, queue_item.provider - ) - if not full_track: + full_item = await mass.music.get_item_by_uri( + queue_item.uri, force_refresh=not lazy, lazy=lazy + ) + if not full_item: return None # sort by quality and check track availability for prov_media in sorted( - full_track.provider_ids, key=lambda x: x.quality, reverse=True + full_item.provider_ids, key=lambda x: x.quality, reverse=True ): if not prov_media.available: continue # get streamdetails from provider - music_prov = mass.get_provider(prov_media.provider) + music_prov = mass.music.get_provider(prov_media.provider) if not music_prov or not music_prov.available: continue # provider temporary unavailable ? @@ -165,15 +224,11 @@ async def get_stream_details( if streamdetails: # set player_id on the streamdetails so we know what players stream - streamdetails.player_id = player_id + streamdetails.queue_id = queue_id # get gain correct / replaygain - if queue_item.name == "alert": - loudness = 5 - gain_correct = 0 - else: - loudness, gain_correct = await get_gain_correct( - mass, player_id, streamdetails.item_id, streamdetails.provider - ) + loudness, gain_correct = await get_gain_correct( + mass, queue_id, streamdetails.item_id, streamdetails.provider + ) streamdetails.gain_correct = gain_correct streamdetails.loudness = loudness # set streamdetails as attribute on the media_item @@ -184,17 +239,17 @@ async def get_stream_details( async def get_gain_correct( - mass: MusicAssistant, player_id: str, item_id: str, provider_id: str + mass: MusicAssistant, queue_id: str, item_id: str, provider_id: str ) -> Tuple[float, float]: - """Get gain correction for given player / track combination.""" - player_conf = mass.config.get_player_config(player_id) - if not player_conf["volume_normalisation"]: + """Get gain correction for given queue / track combination.""" + queue = mass.players.get_player_queue(queue_id, True) + if not queue or not queue.volume_normalization_enabled: return 0 - target_gain = int(player_conf["target_volume"]) - track_loudness = await mass.database.get_track_loudness(item_id, provider_id) + target_gain = queue.volume_normalization_target + track_loudness = await mass.music.get_track_loudness(item_id, provider_id) if track_loudness is None: # fallback to provider average - fallback_track_loudness = await mass.database.get_provider_loudness(provider_id) + fallback_track_loudness = await mass.music.get_provider_loudness(provider_id) if fallback_track_loudness is None: # fallback to some (hopefully sane) average value for now fallback_track_loudness = -8.5 @@ -205,7 +260,7 @@ async def get_gain_correct( return (track_loudness, gain_correct) -def create_wave_header(samplerate=44100, channels=2, bitspersample=16, duration=3600): +def create_wave_header(samplerate=44100, channels=2, bitspersample=16, duration=1800): """Generate a wave header from given params.""" # pylint: disable=no-member file = BytesIO() @@ -253,11 +308,11 @@ def create_wave_header(samplerate=44100, channels=2, bitspersample=16, duration= return file.getvalue() -def get_sox_args( +async def get_sox_args( streamdetails: StreamDetails, output_format: Optional[ContentType] = None, resample: Optional[int] = None, -): +) -> List[str]: """Collect all args to send to the sox (or ffmpeg) process.""" stream_path = streamdetails.path stream_type = StreamType(streamdetails.type) @@ -265,10 +320,39 @@ def get_sox_args( if output_format is None: output_format = streamdetails.content_type + sox_present, ffmpeg_present = await check_audio_support() + # use ffmpeg if content not supported by SoX (e.g. AAC radio streams) - if not streamdetails.content_type.sox_supported(): + if not sox_present or not streamdetails.content_type.sox_supported(): + if not ffmpeg_present: + raise AudioError( + "FFmpeg binary is missing from system." + "Please install ffmpeg on your OS to enable playback.", + ) # collect input args - input_args = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", stream_path] + if stream_type == StreamType.EXECUTABLE: + # stream from executable + input_args = [ + stream_path, + "|", + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-f", + content_type.value, + "-i", + "-", + ] + else: + input_args = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-i", + stream_path, + ] # collect output args if output_format.is_pcm(): output_args = [ @@ -283,7 +367,7 @@ def get_sox_args( # collect filter args filter_args = [] if streamdetails.gain_correct: - filter_args += ["-filter:a", "volume=%sdB" % streamdetails.gain_correct] + filter_args += ["-filter:a", f"volume={streamdetails.gain_correct}dB"] if resample: filter_args += ["-ar", str(resample)] return input_args + filter_args + output_args @@ -312,9 +396,159 @@ def get_sox_args( filter_args = [] if streamdetails.gain_correct: filter_args += ["vol", str(streamdetails.gain_correct), "dB"] - if resample and streamdetails.content_type == ContentType.FLAC: + if resample and streamdetails.media_type != MediaType.RADIO: # use extra high quality resampler only if it makes sense filter_args += ["rate", "-v", str(resample)] elif resample: filter_args += ["rate", str(resample)] return input_args + output_args + filter_args + + +async def get_media_stream( + mass: MusicAssistant, + streamdetails: StreamDetails, + output_format: Optional[ContentType] = None, + resample: Optional[int] = None, + chunk_size: Optional[int] = None, +) -> AsyncGenerator[Tuple[bool, bytes], None]: + """Get the audio stream for the given streamdetails.""" + + mass.signal_event(EventType.STREAM_STARTED, streamdetails) + args = await get_sox_args(streamdetails, output_format, resample) + async with AsyncProcess(args) as sox_proc: + + LOGGER.debug( + "start media stream for: %s/%s (%s)", + streamdetails.provider, + streamdetails.item_id, + streamdetails.type, + ) + + # yield chunks from stdout + # we keep 1 chunk behind to detect end of stream properly + try: + prev_chunk = b"" + async for chunk in sox_proc.iterate_chunks(chunk_size): + if prev_chunk: + yield (False, prev_chunk) + prev_chunk = chunk + # send last chunk + yield (True, prev_chunk) + except (asyncio.CancelledError, GeneratorExit) as err: + LOGGER.debug( + "media stream aborted for: %s/%s", + streamdetails.provider, + streamdetails.item_id, + ) + raise err + else: + LOGGER.debug( + "finished media stream for: %s/%s", + streamdetails.provider, + streamdetails.item_id, + ) + await mass.music.mark_item_played( + streamdetails.item_id, streamdetails.provider + ) + finally: + mass.signal_event(EventType.STREAM_ENDED, streamdetails) + # send analyze job to background worker + if streamdetails.loudness is None: + uri = f"{streamdetails.provider}://{streamdetails.media_type.value}/{streamdetails.item_id}" + mass.add_job( + analyze_audio(mass, streamdetails), f"Analyze audio for {uri}" + ) + + +async def check_audio_support(try_install: bool = False) -> Tuple[bool, bool, bool]: + """Check if sox and/or ffmpeg are present.""" + cache_key = "audio_support_cache" + if cache := globals().get(cache_key): + return cache + # check for SoX presence + returncode, output = await check_output("sox --version") + sox_present = returncode == 0 and "SoX" in output.decode() + if not sox_present and try_install: + # try a few common ways to install SoX + # this all assumes we have enough rights and running on a linux based platform (or docker) + await check_output("apt-get update && apt-get install sox libsox-fmt-all") + await check_output("apk add sox") + # test again + returncode, output = await check_output("sox --version") + sox_present = returncode == 0 and "SoX" in output.decode() + + # check for FFmpeg presence + returncode, output = await check_output("ffmpeg -version") + ffmpeg_present = returncode == 0 and "FFmpeg" in output.decode() + if not ffmpeg_present and try_install: + # try a few common ways to install SoX + # this all assumes we have enough rights and running on a linux based platform (or docker) + await check_output("apt-get update && apt-get install ffmpeg") + await check_output("apk add ffmpeg") + # test again + returncode, output = await check_output("ffmpeg -version") + ffmpeg_present = returncode == 0 and "FFmpeg" in output.decode() + + # use globals as in-memory cache + result = (sox_present, ffmpeg_present) + globals()[cache_key] = result + return result + + +async def get_sox_args_for_pcm_stream( + sample_rate: int, + bit_depth: int, + channels: int, + floating_point: bool = False, + output_format: ContentType = ContentType.FLAC, +) -> List[str]: + """Collect args for aox (or ffmpeg) when converting from raw pcm to another contenttype.""" + + sox_present, ffmpeg_present = await check_audio_support() + input_format = ContentType.from_bit_depth(bit_depth, floating_point) + sox_present = True + + # use ffmpeg if sox is not present + if not sox_present: + if not ffmpeg_present: + raise AudioError( + "FFmpeg binary is missing from system." + "Please install ffmpeg on your OS to enable playback.", + ) + # collect input args + input_args = ["ffmpeg", "-hide_banner", "-loglevel", "error"] + input_args += [ + "-f", + input_format.value, + "-ac", + str(channels), + "-ar", + str(sample_rate), + "-i", + "-", + ] + # collect output args + output_args = ["-f", output_format.value, "-"] + return input_args + output_args + + # Prefer SoX for all other (=highest quality) + + # collect input args + input_args = [ + "sox", + "-t", + input_format.sox_format(), + "-r", + str(sample_rate), + "-b", + str(bit_depth), + "-c", + str(channels), + "-", + ] + # collect output args + if output_format == ContentType.FLAC: + output_args = ["-t", "flac", "-C", "0", "-"] + else: + output_args = ["-t", output_format.sox_format(), "-"] + return input_args + output_args diff --git a/music_assistant/helpers/cache.py b/music_assistant/helpers/cache.py index fc7ea370..404b016d 100644 --- a/music_assistant/helpers/cache.py +++ b/music_assistant/helpers/cache.py @@ -2,41 +2,34 @@ import asyncio import functools -import logging -import os import pickle import time from typing import Awaitable -import aiosqlite from music_assistant.helpers.typing import MusicAssistant from music_assistant.helpers.util import create_task -LOGGER = logging.getLogger("cache") +DB_TABLE = "cache" class Cache: - """Basic stateless caching system.""" - - _db = None + """Basic cache using both memory and database.""" def __init__(self, mass: MusicAssistant) -> None: """Initialize our caching class.""" self.mass = mass - self._dbfile = os.path.join(mass.config.data_path, ".cache.db") + self.logger = mass.logger.getChild("cache") self._mem_cache = {} async def setup(self) -> None: """Async initialize of cache module.""" - async with aiosqlite.connect(self._dbfile, timeout=180) as db_conn: - await db_conn.execute( - """CREATE TABLE IF NOT EXISTS simplecache( - id TEXT UNIQUE, expires INTEGER, data TEXT, checksum INTEGER)""" + # prepare database + async with self.mass.database.get_db() as _db: + await _db.execute( + f"""CREATE TABLE IF NOT EXISTS {DB_TABLE}( + key TEXT UNIQUE, expires INTEGER, data TEXT, checksum INTEGER)""" ) - await db_conn.commit() - await db_conn.execute("VACUUM;") - await db_conn.commit() - self.mass.tasks.add("Cleanup cache", self.auto_cleanup, periodic=3600) + self.__schedule_cleanup_task() async def get(self, cache_key, checksum="", default=None): """ @@ -58,27 +51,28 @@ class Cache: ): return cache_data[0] # fall back to db cache - sql_query = "SELECT data, checksum, expires FROM simplecache WHERE id = ?" - async with aiosqlite.connect(self._dbfile, timeout=180) as db_conn: - async with db_conn.execute(sql_query, (cache_key,)) as cursor: - cache_data = await cursor.fetchone() - if ( - cache_data - and (not checksum or cache_data[1] == checksum) - and cache_data[2] >= cur_time - ): + if db_row := await self.mass.database.get_row(DB_TABLE, {"key": cache_key}): + if ( + not checksum + or db_row["checksum"] == checksum + and db_row["expires"] >= cur_time + ): + try: data = await asyncio.get_running_loop().run_in_executor( - None, pickle.loads, cache_data[0] + None, pickle.loads, db_row["data"] ) + except Exception: # pylint: disable=broad-except + self.logger.warning("Error parsing cache data for %s", cache_key) + else: # also store in memory cache for faster access if cache_key not in self._mem_cache: self._mem_cache[cache_key] = ( data, - cache_data[1], - cache_data[2], + db_row["checksum"], + db_row["expires"], ) return data - LOGGER.debug("no cache data for %s", cache_key) + self.logger.debug("no cache data for %s", cache_key) return default async def set(self, cache_key, data, checksum="", expiration=(86400 * 30)): @@ -89,38 +83,34 @@ class Cache: data = await asyncio.get_running_loop().run_in_executor( None, pickle.dumps, data ) - sql_query = """INSERT OR REPLACE INTO simplecache - (id, expires, data, checksum) VALUES (?, ?, ?, ?)""" - async with aiosqlite.connect(self._dbfile, timeout=180) as db_conn: - await db_conn.execute(sql_query, (cache_key, expires, data, checksum)) - await db_conn.commit() + await self.mass.database.insert_or_replace( + DB_TABLE, + {"key": cache_key, "expires": expires, "checksum": checksum, "data": data}, + ) async def delete(self, cache_key): """Delete data from cache.""" self._mem_cache.pop(cache_key, None) - sql_query = "DELETE FROM simplecache WHERE id = ?" - async with aiosqlite.connect(self._dbfile, timeout=180) as db_conn: - await db_conn.execute(sql_query, (cache_key,)) - await db_conn.commit() + await self.mass.database.delete(DB_TABLE, {"key": cache_key}) async def auto_cleanup(self): """Sceduled auto cleanup task.""" - # for now we simply rest the memory cache + # for now we simply reset the memory cache self._mem_cache = {} cur_timestamp = int(time.time()) - sql_query = "SELECT id, expires FROM simplecache" - async with aiosqlite.connect(self._dbfile, timeout=600) as db_conn: - db_conn.row_factory = aiosqlite.Row - async with db_conn.execute(sql_query) as cursor: - cache_objects = await cursor.fetchall() - for cache_data in cache_objects: - cache_id = cache_data["id"] - # clean up db cache object only if expired - if cache_data["expires"] < cur_timestamp: - sql_query = "DELETE FROM simplecache WHERE id = ?" - await db_conn.execute(sql_query, (cache_id,)) - # compact db - await db_conn.commit() + for db_row in await self.mass.database.get_rows(DB_TABLE): + # clean up db cache object only if expired + if db_row["expires"] < cur_timestamp: + await self.delete(db_row["key"]) + # compact db + async with self.mass.database.get_db() as _db: + await _db.execute("VACUUM") + + def __schedule_cleanup_task(self): + """Schedule the cleanup task.""" + self.mass.add_job(self.auto_cleanup(), "Cleanup cache") + # reschedule self + self.mass.loop.call_later(3600, self.__schedule_cleanup_task) @staticmethod def _get_checksum(stringinput): @@ -137,65 +127,15 @@ async def cached( coro_func: Awaitable, *args, expires: int = (86400 * 30), - checksum=None + checksum=None, ): """Return helper method to store results of a coroutine in the cache.""" cache_result = await cache.get(cache_key, checksum) if cache_result is not None: return cache_result - result = await coro_func(*args) + if asyncio.iscoroutine(coro_func): + result = await coro_func + else: + result = await coro_func(*args) create_task(cache.set(cache_key, result, checksum, expires)) return result - - -def use_cache(cache_days=14, cache_checksum=None): - """Return decorator that can be used to cache a method's result.""" - - def wrapper(func): - @functools.wraps(func) - async def wrapped(*args, **kwargs): - method_class = args[0] - method_class_name = method_class.__class__.__name__ - cache_str = "%s.%s" % (method_class_name, func.__name__) - cache_str += __cache_id_from_args(*args, **kwargs) - cache_str = cache_str.lower() - cachedata = await method_class.cache.get(cache_str) - if cachedata is not None: - return cachedata - result = await func(*args, **kwargs) - create_task( - method_class.cache.set( - cache_str, - result, - checksum=cache_checksum, - expiration=(86400 * cache_days), - ) - ) - return result - - return wrapped - - return wrapper - - -def __cache_id_from_args(*args, **kwargs): - """Parse arguments to build cache id.""" - cache_str = "" - # append args to cache identifier - for item in args[1:]: - if isinstance(item, dict): - for subkey in sorted(list(item.keys())): - subvalue = item[subkey] - cache_str += ".%s%s" % (subkey, subvalue) - else: - cache_str += ".%s" % item - # append kwargs to cache identifier - for key in sorted(list(kwargs.keys())): - value = kwargs[key] - if isinstance(value, dict): - for subkey in sorted(list(value.keys())): - subvalue = value[subkey] - cache_str += ".%s%s" % (subkey, subvalue) - else: - cache_str += ".%s%s" % (key, value) - return cache_str diff --git a/music_assistant/helpers/compare.py b/music_assistant/helpers/compare.py index 098fe704..c86f670e 100644 --- a/music_assistant/helpers/compare.py +++ b/music_assistant/helpers/compare.py @@ -1,9 +1,11 @@ """Several helper/utils to compare objects.""" import re -from typing import List +from typing import TYPE_CHECKING, List import unidecode -from music_assistant.models.media_types import Album, Artist, Track + +if TYPE_CHECKING: + from music_assistant.models.media_items import Album, Artist, Track def get_compare_string(input_str): @@ -36,7 +38,7 @@ def compare_version(left_version: str, right_version: str): return left_versions == right_versions -def compare_artists(left_artists: List[Artist], right_artists: List[Artist]): +def compare_artists(left_artists: List["Artist"], right_artists: List["Artist"]): """Compare two lists of artist and return True if both lists match.""" matches = 0 for left_artist in left_artists: @@ -46,7 +48,7 @@ def compare_artists(left_artists: List[Artist], right_artists: List[Artist]): return len(left_artists) == matches -def compare_albums(left_albums: List[Album], right_albums: List[Album]): +def compare_albums(left_albums: List["Album"], right_albums: List["Album"]): """Compare two lists of albums and return True if a match was found.""" for left_album in left_albums: for right_album in right_albums: @@ -55,8 +57,10 @@ def compare_albums(left_albums: List[Album], right_albums: List[Album]): return False -def compare_album(left_album: Album, right_album: Album): +def compare_album(left_album: "Album", right_album: "Album"): """Compare two album items and return True if they match.""" + if left_album is None or right_album is None: + return False # do not match on year and albumtype as this info is often inaccurate on providers if ( left_album.provider == right_album.provider @@ -77,7 +81,7 @@ def compare_album(left_album: Album, right_album: Album): return True -def compare_track(left_track: Track, right_track: Track): +def compare_track(left_track: "Track", right_track: "Track"): """Compare two track items and return True if they match.""" if ( left_track.provider == right_track.provider @@ -96,11 +100,9 @@ def compare_track(left_track: Track, right_track: Track): if not compare_artists(left_track.artists, right_track.artists): return False # album match OR near exact duration match - left_albums = left_track.albums or [left_track.album] - right_albums = right_track.albums or [right_track.album] if ( - compare_albums(left_albums, right_albums) - and abs(left_track.duration - right_track.duration) < 3 + compare_album(left_track.album, right_track.album) + and abs(left_track.duration - right_track.duration) < 5 ) or abs(left_track.duration - right_track.duration) < 1: # 100% match, all criteria passed return True diff --git a/music_assistant/helpers/database.py b/music_assistant/helpers/database.py new file mode 100755 index 00000000..cc496cd4 --- /dev/null +++ b/music_assistant/helpers/database.py @@ -0,0 +1,125 @@ +"""Database logic.""" +from __future__ import annotations +import asyncio +from contextlib import asynccontextmanager +from typing import Any, Dict, List, Mapping + +from databases import Database as Db +from databases import DatabaseURL + +from music_assistant.constants import EventType +from music_assistant.helpers.typing import EventDetails, MusicAssistant + +# pylint: disable=invalid-name + + +class Database: + """Class that holds the (logic to the) database.""" + + def __init__(self, mass: MusicAssistant, url: DatabaseURL): + """Initialize class.""" + self.url = url + self.mass = mass + self.logger = mass.logger.getChild("db") + self._lock = asyncio.Lock() + mass.subscribe(self.__on_shutdown_event, EventType.SHUTDOWN) + + async def setup(self): + """Async initialize of module.""" + # await self.connect() + + @asynccontextmanager + async def get_db(self, db: Db | None = None) -> Db: + """Context manager helper to get the active db connection.""" + if db is not None: + yield db + else: + async with Db(self.url) as _db: + yield _db + + async def get_rows( + self, table: str, match: dict = None, order_by: str = None, db: Db | None = None + ) -> List[Mapping]: + """Get all rows for given table.""" + async with self.get_db(db) as _db: + sql_query = f"SELECT * FROM {table}" + if match is not None: + sql_query += " WHERE " + " AND ".join((f"{x} = :{x}" for x in match)) + if order_by is not None: + sql_query += f"ORDER BY {order_by}" + return await _db.fetch_all(sql_query, match) + + async def get_rows_from_query( + self, query: str, db: Db | None = None + ) -> List[Mapping]: + """Get all rows for given custom query.""" + async with self.get_db(db) as _db: + return await _db.fetch_all(query) + + async def search( + self, table: str, search: str, column: str = "name", db: Db | None = None + ) -> List[Mapping]: + """Search table by column.""" + async with self.get_db(db) as _db: + sql_query = f'SELECT * FROM {table} WHERE {column} LIKE "{search}"' + return await _db.fetch_all(sql_query) + + async def get_row( + self, table: str, match: Dict[str, Any] = None, db: Db | None = None + ) -> Mapping | None: + """Get single row for given table where column matches keys/values.""" + async with self.get_db(db) as _db: + sql_query = f"SELECT * FROM {table} WHERE " + sql_query += " AND ".join((f"{x} = :{x}" for x in match)) + return await _db.fetch_one(sql_query, match) + + async def insert_or_replace( + self, table: str, values: Dict[str, Any], db: Db | None = None + ) -> Mapping: + """Insert or replace data in given table.""" + async with self.get_db(db) as _db: + keys = tuple(values.keys()) + sql_query = f'INSERT OR REPLACE INTO {table}({",".join(keys)})' + sql_query += f' VALUES ({",".join((f":{x}" for x in keys))})' + await _db.execute(sql_query, values) + # return inserted/replaced item + lookup_vals = { + key: value + for key, value in values.items() + if value is not None and value != "" + } + return await self.get_row(table, lookup_vals, db=_db) + + async def update( + self, + table: str, + match: Dict[str, Any], + values: Dict[str, Any], + db: Db | None = None, + ) -> Mapping: + """Update record.""" + async with self.get_db(db) as _db: + keys = tuple(values.keys()) + sql_query = ( + f'UPDATE {table} SET {",".join((f"{x}=:{x}" for x in keys))} WHERE ' + ) + sql_query += " AND ".join((f"{x} = :{x}" for x in match)) + await _db.execute(sql_query, {**match, **values}) + # return updated item + return await self.get_row(table, match, db=_db) + + async def delete( + self, table: str, match: Dict[str, Any], db: Db | None = None + ) -> None: + """Delete data in given table.""" + async with self.get_db(db) as _db: + sql_query = f"DELETE FROM {table}" + sql_query += " WHERE " + " AND ".join((f"{x} = :{x}" for x in match)) + await _db.execute(sql_query) + + async def __on_shutdown_event( + self, event: EventType, details: EventDetails + ) -> None: + """Handle shutdown event.""" + # await self.disconnect() + self.logger.info("database closed") diff --git a/music_assistant/helpers/encryption.py b/music_assistant/helpers/encryption.py deleted file mode 100644 index eca69ba9..00000000 --- a/music_assistant/helpers/encryption.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Various helpers for data encryption/decryption.""" - -import asyncio - -from cryptography.fernet import Fernet, InvalidToken -from music_assistant.helpers.app_vars import get_app_var # noqa # pylint: disable=all - - -async def encrypt_string(str_value: str) -> str: - """Encrypt a string with Fernet.""" - return await asyncio.get_running_loop().run_in_executor( - None, _encrypt_string, str_value - ) - - -def _encrypt_string(str_value: str) -> str: - """Encrypt a string with Fernet.""" - return Fernet(get_app_var(3)).encrypt(str_value.encode()).decode() - - -async def encrypt_bytes(bytes_value: bytes) -> bytes: - """Encrypt bytes with Fernet.""" - return await asyncio.get_running_loop().run_in_executor( - None, _encrypt_bytes, bytes_value - ) - - -def _encrypt_bytes(bytes_value: bytes) -> bytes: - """Encrypt bytes with Fernet.""" - return Fernet(get_app_var(3)).encrypt(bytes_value) - - -async def decrypt_string(encrypted_str: str) -> str: - """Decrypt a string with Fernet.""" - return await asyncio.get_running_loop().run_in_executor( - None, _decrypt_string, encrypted_str - ) - - -def _decrypt_string(encrypted_str: str) -> str: - """Decrypt a string with Fernet.""" - try: - return Fernet(get_app_var(3)).decrypt(encrypted_str.encode()).decode() - except (InvalidToken, AttributeError): - return None - - -async def decrypt_bytes(bytes_value: bytes) -> bytes: - """Decrypt bytes with Fernet.""" - return await asyncio.get_running_loop().run_in_executor( - None, _decrypt_bytes, bytes_value - ) - - -def _decrypt_bytes(bytes_value): - """Decrypt bytes with Fernet.""" - try: - return Fernet(get_app_var(3)).decrypt(bytes_value) - except (InvalidToken, AttributeError): - return None diff --git a/music_assistant/helpers/errors.py b/music_assistant/helpers/errors.py deleted file mode 100644 index 076fd86c..00000000 --- a/music_assistant/helpers/errors.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Custom errors and exceptions.""" - - -class AuthenticationError(Exception): - """Custom Exception for all authentication errors.""" diff --git a/music_assistant/helpers/images.py b/music_assistant/helpers/images.py index 4f7267c7..86b2d866 100644 --- a/music_assistant/helpers/images.py +++ b/music_assistant/helpers/images.py @@ -1,77 +1,51 @@ """Utilities for image manipulation and retrieval.""" -import os from io import BytesIO from music_assistant.helpers.typing import MusicAssistant -from music_assistant.models.media_types import MediaType +from music_assistant.models.media_items import ItemMapping, MediaType, MediaItemType from PIL import Image -async def get_thumb_file(mass: MusicAssistant, url, size: int = 150): - """Get path to (resized) thumbnail image for given image url.""" - assert url - cache_folder = os.path.join(mass.config.data_path, ".thumbs") - cache_id = await mass.database.get_thumbnail_id(url, size) - cache_file = os.path.join(cache_folder, f"{cache_id}.png") - if os.path.isfile(cache_file): - # return file from cache - return cache_file - # no file in cache so we should get it - os.makedirs(cache_folder, exist_ok=True) - # download base image +async def create_thumbnail(mass: MusicAssistant, url, size: int = 150) -> bytes: + """Create thumbnail from image url.""" async with mass.http_session.get(url, verify_ssl=False) as response: assert response.status == 200 img_data = BytesIO(await response.read()) - - # save resized image - if size: - basewidth = size img = Image.open(img_data) - wpercent = basewidth / float(img.size[0]) - hsize = int((float(img.size[1]) * float(wpercent))) - img = img.resize((basewidth, hsize), Image.ANTIALIAS) - img.save(cache_file, format="png") - else: - with open(cache_file, "wb") as _file: - _file.write(img_data.getvalue()) - # return file from cache - return cache_file + img.thumbnail((size, size), Image.ANTIALIAS) + img.save(format="png") + return img_data.getvalue() -async def get_image_url( - mass: MusicAssistant, item_id: str, provider_id: str, media_type: MediaType -): - """Get url to image for given media item.""" - item = await mass.music.get_item(item_id, provider_id, media_type) - if not item: +async def get_image_url(mass: MusicAssistant, media_item: MediaItemType): + """Get url to image for given media media_item.""" + if not media_item: return None - if item and item.metadata.get("image"): - return item.metadata["image"] + if isinstance(media_item, ItemMapping): + media_item = await mass.music.get_item_by_uri(media_item.uri) + if media_item and media_item.metadata.get("image"): + return media_item.metadata["image"] if ( - hasattr(item, "album") - and hasattr(item.album, "metadata") - and item.album.metadata.get("image") + hasattr(media_item, "album") + and hasattr(media_item.album, "metadata") + and media_item.album.metadata.get("image") ): - return item.album.metadata["image"] - if hasattr(item, "albums"): - for album in item.albums: + return media_item.album.metadata["image"] + if hasattr(media_item, "albums"): + for album in media_item.albums: if hasattr(album, "metadata") and album.metadata.get("image"): return album.metadata["image"] if ( - hasattr(item, "artist") - and hasattr(item.artist, "metadata") - and item.artist.metadata.get("image") + hasattr(media_item, "artist") + and hasattr(media_item.artist, "metadata") + and media_item.artist.metadata.get("image") ): - return item.artist.metadata["image"] - if media_type == MediaType.TRACK and item.album: + return media_item.artist.metadata["image"] + if media_item.media_type == MediaType.TRACK and media_item.album: # try album instead for tracks - return await get_image_url( - mass, item.album.item_id, item.album.provider, MediaType.ALBUM - ) - if media_type == MediaType.ALBUM and item.artist: + return await get_image_url(mass, media_item.album) + if media_item.media_type == MediaType.ALBUM and media_item.artist: # try artist instead for albums - return await get_image_url( - mass, item.artist.item_id, item.artist.provider, MediaType.ARTIST - ) + return await get_image_url(mass, media_item.artist) return None diff --git a/music_assistant/helpers/json.py b/music_assistant/helpers/json.py new file mode 100644 index 00000000..4a3b1a37 --- /dev/null +++ b/music_assistant/helpers/json.py @@ -0,0 +1,43 @@ +"""Various helpers for web requests.""" + +import asyncio + +try: + import ujson as json +except ImportError: + import json + + +def serialize_values(obj): + """Recursively create serializable values for (custom) data types.""" + + def get_val(val): + if ( + isinstance(val, (list, set, filter, tuple)) + or val.__class__ == "dict_valueiterator" + ): + return [get_val(x) for x in val] if val else [] + if isinstance(val, dict): + return {key: get_val(value) for key, value in val.items()} + try: + return val.to_dict() + except AttributeError: + return val + except Exception: # pylint: disable=broad-except + return val + + return get_val(obj) + + +def json_serializer(data): + """Json serializer to recursively create serializable values for custom data types.""" + return json.dumps(serialize_values(data)) + + +async def async_json_serializer(data): + """Run json serializer in executor for large data.""" + if isinstance(data, list) and len(data) > 100: + return await asyncio.get_running_loop().run_in_executor( + None, json_serializer, data + ) + return json_serializer(data) diff --git a/music_assistant/helpers/logger.py b/music_assistant/helpers/logger.py deleted file mode 100644 index c695666f..00000000 --- a/music_assistant/helpers/logger.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Initialize logger.""" -import logging -import os -import random -from logging.handlers import TimedRotatingFileHandler - -from .util import LimitedList - - -def setup_logger(data_path): - """Initialize logger.""" - logs_dir = os.path.join(data_path, "logs") - if not os.path.isdir(logs_dir): - os.mkdir(logs_dir) - logger = logging.getLogger() - log_formatter = logging.Formatter( - "%(asctime)-15s %(levelname)-5s %(name)s -- %(message)s" - ) - consolehandler = logging.StreamHandler() - consolehandler.setFormatter(log_formatter) - consolehandler.setLevel(logging.DEBUG) - logger.addHandler(consolehandler) - log_filename = os.path.join(logs_dir, "musicassistant.log") - file_handler = TimedRotatingFileHandler( - log_filename, when="midnight", interval=1, backupCount=10 - ) - file_handler.setLevel(logging.INFO) - file_handler.setFormatter(log_formatter) - logger.addHandler(file_handler) - - html_handler = HistoryLogHandler() - html_handler.setLevel(logging.DEBUG) - html_handler.setFormatter(log_formatter) - logger.addHandler(html_handler) - - # global level is debug - logger.setLevel(logging.DEBUG) - - # silence some loggers - logging.getLogger("asyncio").setLevel(logging.WARNING) - logging.getLogger("aiosqlite").setLevel(logging.WARNING) - logging.getLogger("databases").setLevel(logging.WARNING) - logging.getLogger("multipart.multipart").setLevel(logging.WARNING) - logging.getLogger("passlib.handlers.bcrypt").setLevel(logging.WARNING) - - return logger - - -class HistoryLogHandler(logging.Handler): - """A logging handler that keeps the last X records in memory.""" - - def __init__(self, max_len: int = 200): - """Initialize instance.""" - logging.Handler.__init__(self) - # Our custom argument - self._history = LimitedList(max_len=max_len) - self._max_len = max_len - - @property - def max_len(self) -> int: - """Return the max size of the log list.""" - return self._max_len - - def emit(self, record): - """Emit log record.""" - self._history.append( - { - "id": f"{record.asctime}.{random.randint(0, 9)}", - "time": record.asctime, - "name": record.name, - "level": record.levelname, - "message": record.message, - } - ) - - def get_history(self): - """Get all log lines in history.""" - return self._history diff --git a/music_assistant/helpers/migration.py b/music_assistant/helpers/migration.py deleted file mode 100644 index fa813289..00000000 --- a/music_assistant/helpers/migration.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Logic to handle database/configuration changes and creation.""" - -import os -import shutil -import uuid - -from pkg_resources import packaging - -import aiosqlite -from music_assistant.constants import __version__ as app_version -from music_assistant.helpers.encryption import encrypt_string -from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.util import get_hostname - - -async def check_migrations(mass: MusicAssistant): - """Check for any migrations that need to be done.""" - - is_fresh_setup = len(mass.config.stored_config.keys()) == 0 - prev_version = packaging.version.parse(mass.config.stored_config.get("version", "")) - - # perform version specific migrations - if not is_fresh_setup and prev_version < packaging.version.parse("0.1.1"): - await run_migration_1(mass) - - # store version in config - mass.config.stored_config["version"] = app_version - # create unique server id from machine id - if "server_id" not in mass.config.stored_config: - mass.config.stored_config["server_id"] = str(uuid.getnode()) - if "jwt_key" not in mass.config.stored_config: - mass.config.stored_config["jwt_key"] = await encrypt_string(str(uuid.uuid4())) - if "initialized" not in mass.config.stored_config: - mass.config.stored_config["initialized"] = False - if "friendly_name" not in mass.config.stored_config: - mass.config.stored_config["friendly_name"] = get_hostname() - mass.config.save() - - # create default db tables (if needed) - await create_db_tables(mass.database.db_file) - - -async def run_migration_1(mass: MusicAssistant): - """Run migration for version 0.1.1.""" - # 0.1.0 introduced major changes to all data models and db structure - # a full refresh of data is unavoidable - data_path = mass.config.data_path - tracks_loudness = [] - - for dbname in ["mass.db", "database.db", "music_assistant.db"]: - filename = os.path.join(data_path, dbname) - if os.path.isfile(filename): - # we try to backup the loudness measurements - async with aiosqlite.connect(filename, timeout=120) as db_conn: - db_conn.row_factory = aiosqlite.Row - sql_query = "SELECT * FROM track_loudness" - for db_row in await db_conn.execute_fetchall(sql_query, ()): - if "provider_track_id" in db_row.keys(): - track_id = db_row["provider_track_id"] - else: - track_id = db_row["item_id"] - tracks_loudness.append( - ( - track_id, - db_row["provider"], - db_row["loudness"], - ) - ) - # remove old db file - os.remove(filename) - - # remove old cache db - for dbname in ["cache.db", ".cache.db"]: - filename = os.path.join(data_path, dbname) - if os.path.isfile(filename): - os.remove(filename) - - # remove old thumbs db - for dirname in ["thumbs", ".thumbs", ".thumbnails"]: - dirname = os.path.join(data_path, dirname) - if os.path.isdir(dirname): - shutil.rmtree(dirname, True) - - # create default db tables (if needed) - await create_db_tables(mass.database.db_file) - - # restore loudness measurements - if tracks_loudness: - async with aiosqlite.connect(mass.database.db_file, timeout=120) as db_conn: - sql_query = """INSERT or REPLACE INTO track_loudness - (item_id, provider, loudness) VALUES(?,?,?);""" - for item in tracks_loudness: - await db_conn.execute(sql_query, item) - await db_conn.commit() - - -async def create_db_tables(db_file): - """Async initialization.""" - async with aiosqlite.connect(db_file, timeout=120) as db_conn: - - await db_conn.execute( - """CREATE TABLE IF NOT EXISTS provider_mappings( - item_id INTEGER NOT NULL, - media_type TEXT NOT NULL, - prov_item_id TEXT NOT NULL, - provider TEXT NOT NULL, - quality INTEGER NOT NULL, - details TEXT NULL, - UNIQUE(item_id, media_type, prov_item_id, provider, quality) - );""" - ) - - await db_conn.execute( - """CREATE TABLE IF NOT EXISTS artists( - item_id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - sort_name TEXT, - musicbrainz_id TEXT NOT NULL UNIQUE, - in_library BOOLEAN DEFAULT 0, - metadata json, - provider_ids json - );""" - ) - - await db_conn.execute( - """CREATE TABLE IF NOT EXISTS albums( - item_id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - sort_name TEXT, - album_type TEXT, - year INTEGER, - version TEXT, - in_library BOOLEAN DEFAULT 0, - upc TEXT, - artist json, - metadata json, - provider_ids json - );""" - ) - - await db_conn.execute( - """CREATE TABLE IF NOT EXISTS tracks( - item_id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - sort_name TEXT, - version TEXT, - duration INTEGER, - in_library BOOLEAN DEFAULT 0, - isrc TEXT, - albums json, - artists json, - metadata json, - provider_ids json - );""" - ) - - await db_conn.execute( - """CREATE TABLE IF NOT EXISTS playlists( - item_id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - sort_name TEXT, - owner TEXT NOT NULL, - is_editable BOOLEAN NOT NULL, - checksum TEXT NOT NULL, - in_library BOOLEAN DEFAULT 0, - metadata json, - provider_ids json, - UNIQUE(name, owner) - );""" - ) - - await db_conn.execute( - """CREATE TABLE IF NOT EXISTS radios( - item_id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - sort_name TEXT, - in_library BOOLEAN DEFAULT 0, - metadata json, - provider_ids json - );""" - ) - - await db_conn.execute( - """CREATE TABLE IF NOT EXISTS track_loudness( - item_id INTEGER NOT NULL, - provider TEXT NOT NULL, - loudness REAL, - UNIQUE(item_id, provider));""" - ) - - await db_conn.execute( - """CREATE TABLE IF NOT EXISTS playlog( - item_id INTEGER NOT NULL, - provider TEXT NOT NULL, - timestamp REAL, - UNIQUE(item_id, provider));""" - ) - - await db_conn.execute( - """CREATE TABLE IF NOT EXISTS thumbs( - id INTEGER PRIMARY KEY AUTOINCREMENT, - url TEXT NOT NULL, - size INTEGER, - UNIQUE(url, size));""" - ) - - await db_conn.commit() - await db_conn.execute("VACUUM;") - await db_conn.commit() diff --git a/music_assistant/helpers/muli_state_queue.py b/music_assistant/helpers/muli_state_queue.py deleted file mode 100644 index 7655704b..00000000 --- a/music_assistant/helpers/muli_state_queue.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Special queue-like to process items in different states.""" -import asyncio -from collections import deque -from typing import Any, List, Type - - -class MultiStateQueue: - """Special queue-like to process items in different states.""" - - QUEUE_ITEM_TYPE: Type = Any - - def __init__(self, max_finished_items: int = 50) -> None: - """Initialize class.""" - self._pending_items = asyncio.Queue() - self._progress_items = deque() - self._finished_items = deque(maxlen=max_finished_items) - - @property - def pending_items(self) -> List[QUEUE_ITEM_TYPE]: - """Return all pending items.""" - # pylint: disable=protected-access - return list(self._pending_items._queue) - - @property - def progress_items(self) -> List[QUEUE_ITEM_TYPE]: - """Return all in-progress items.""" - return list(self._progress_items) - - @property - def finished_items(self) -> List[QUEUE_ITEM_TYPE]: - """Return all finished items.""" - return list(self._finished_items) - - @property - def all_items(self) -> List[QUEUE_ITEM_TYPE]: - """Return all items.""" - return list(self.pending_items + self.progress_items + self.finished_items) - - def put_nowait(self, item: QUEUE_ITEM_TYPE) -> None: - """Put item in the queue to progress.""" - if item in self._finished_items: - self._finished_items.remove(item) - return self._pending_items.put_nowait(item) - - async def put(self, item: QUEUE_ITEM_TYPE) -> None: - """Put item on the queue to progress.""" - if item in self._finished_items: - self._finished_items.remove(item) - return await self._pending_items.put(item) - - async def get_nowait(self) -> QUEUE_ITEM_TYPE: - """Get next item in Queue, raises QueueEmpty if no items in Queue.""" - next_item = self._pending_items.get_nowait() - self._progress_items.append(next_item) - return next_item - - async def get(self) -> QUEUE_ITEM_TYPE: - """Get next item in Queue, waits until item is available.""" - next_item = await self._pending_items.get() - self._progress_items.append(next_item) - return next_item - - def mark_finished(self, item: QUEUE_ITEM_TYPE) -> None: - """Mark item as finished.""" - self._progress_items.remove(item) - self._finished_items.append(item) diff --git a/music_assistant/helpers/musicbrainz.py b/music_assistant/helpers/musicbrainz.py deleted file mode 100644 index 5660d414..00000000 --- a/music_assistant/helpers/musicbrainz.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Handle getting Id's from MusicBrainz.""" - -import logging -import re -from json.decoder import JSONDecodeError -from typing import Optional - -import aiohttp -from asyncio_throttle import Throttler -from music_assistant.helpers.cache import use_cache -from music_assistant.helpers.compare import compare_strings, get_compare_string - -LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])' - -LOGGER = logging.getLogger("musicbrainz") - - -class MusicBrainz: - """Handle getting Id's from MusicBrainz.""" - - def __init__(self, mass): - """Initialize class.""" - self.mass = mass - self.cache = mass.cache - self.throttler = Throttler(rate_limit=1, period=1) - - async def get_mb_artist_id( - self, - artistname, - albumname=None, - album_upc=None, - trackname=None, - track_isrc=None, - ): - """Retrieve musicbrainz artist id for the given details.""" - LOGGER.debug( - "searching musicbrainz for %s \ - (albumname: %s - album_upc: %s - trackname: %s - track_isrc: %s)", - artistname, - albumname, - album_upc, - trackname, - track_isrc, - ) - mb_artist_id = None - if album_upc: - mb_artist_id = await self.search_artist_by_album( - artistname, None, album_upc - ) - if mb_artist_id: - LOGGER.debug( - "Got MusicbrainzArtistId for %s after search on upc %s --> %s", - artistname, - album_upc, - mb_artist_id, - ) - if not mb_artist_id and track_isrc: - mb_artist_id = await self.search_artist_by_track( - artistname, None, track_isrc - ) - if mb_artist_id: - LOGGER.debug( - "Got MusicbrainzArtistId for %s after search on isrc %s --> %s", - artistname, - track_isrc, - mb_artist_id, - ) - if not mb_artist_id and albumname: - mb_artist_id = await self.search_artist_by_album(artistname, albumname) - if mb_artist_id: - LOGGER.debug( - "Got MusicbrainzArtistId for %s after search on albumname %s --> %s", - artistname, - albumname, - mb_artist_id, - ) - if not mb_artist_id and trackname: - mb_artist_id = await self.search_artist_by_track(artistname, trackname) - if mb_artist_id: - LOGGER.debug( - "Got MusicbrainzArtistId for %s after search on trackname %s --> %s", - artistname, - trackname, - mb_artist_id, - ) - return mb_artist_id - - async def search_artist_by_album(self, artistname, albumname=None, album_upc=None): - """Retrieve musicbrainz artist id by providing the artist name and albumname or upc.""" - for searchartist in [ - re.sub(LUCENE_SPECIAL, r"\\\1", artistname), - get_compare_string(artistname), - ]: - if album_upc: - endpoint = "release" - params = {"query": "barcode:%s" % album_upc} - else: - searchalbum = re.sub(LUCENE_SPECIAL, r"\\\1", albumname) - endpoint = "release" - params = { - "query": 'artist:"%s" AND release:"%s"' - % (searchartist, searchalbum) - } - result = await self.get_data(endpoint, params) - if result and "releases" in result: - for strictness in [True, False]: - for item in result["releases"]: - if album_upc or compare_strings( - item["title"], albumname, strictness - ): - for artist in item["artist-credit"]: - if compare_strings( - artist["artist"]["name"], artistname, strictness - ): - return artist["artist"]["id"] - for alias in artist.get("aliases", []): - if compare_strings( - alias["name"], artistname, strictness - ): - return artist["id"] - return "" - - async def search_artist_by_track(self, artistname, trackname=None, track_isrc=None): - """Retrieve artist id by providing the artist name and trackname or track isrc.""" - endpoint = "recording" - searchartist = re.sub(LUCENE_SPECIAL, r"\\\1", artistname) - # searchartist = searchartist.replace('/','').replace('\\','').replace('-', '') - if track_isrc: - endpoint = "isrc/%s" % track_isrc - params = {"inc": "artist-credits"} - else: - searchtrack = re.sub(LUCENE_SPECIAL, r"\\\1", trackname) - endpoint = "recording" - params = {"query": '"%s" AND artist:"%s"' % (searchtrack, searchartist)} - result = await self.get_data(endpoint, params) - if result and "recordings" in result: - for strictness in [True, False]: - for item in result["recordings"]: - if track_isrc or compare_strings( - item["title"], trackname, strictness - ): - for artist in item["artist-credit"]: - if compare_strings( - artist["artist"]["name"], artistname, strictness - ): - return artist["artist"]["id"] - for alias in artist.get("aliases", []): - if compare_strings( - alias["name"], artistname, strictness - ): - return artist["id"] - return "" - - @use_cache(2) - async def get_data(self, endpoint: str, params: Optional[dict] = None): - """Get data from api.""" - if params is None: - params = {} - url = "http://musicbrainz.org/ws/2/%s" % endpoint - headers = {"User-Agent": "Music Assistant/1.0.0 https://github.com/marcelveldt"} - params["fmt"] = "json" - async with self.throttler: - async with self.mass.http_session.get( - url, headers=headers, params=params, verify_ssl=False - ) as response: - try: - result = await response.json() - except ( - aiohttp.client_exceptions.ContentTypeError, - JSONDecodeError, - ) as exc: - msg = await response.text() - LOGGER.error("%s - %s", str(exc), msg) - result = None - return result diff --git a/music_assistant/helpers/process.py b/music_assistant/helpers/process.py index 13ce82b2..eed32d62 100644 --- a/music_assistant/helpers/process.py +++ b/music_assistant/helpers/process.py @@ -7,9 +7,9 @@ even when properly handling reading/writes from different tasks. import asyncio import logging -from typing import AsyncGenerator, List, Optional, Union +from typing import AsyncGenerator, List, Optional, Tuple, Union -from async_timeout import timeout +from async_timeout import timeout as _timeout LOGGER = logging.getLogger("AsyncProcess") @@ -61,26 +61,28 @@ class AsyncProcess: self._proc.terminate() await self._proc.stdout.read() self._proc.kill() - except (ProcessLookupError, BrokenPipeError): + except (ProcessLookupError, BrokenPipeError, RuntimeError): pass del self._proc async def iterate_chunks( - self, chunk_size: int = DEFAULT_CHUNKSIZE + self, chunk_size: int = DEFAULT_CHUNKSIZE, timeout: int = DEFAULT_TIMEOUT ) -> AsyncGenerator[bytes, None]: """Yield chunks from the process stdout. Generator.""" while True: - chunk = await self.read(chunk_size) + chunk = await self.read(chunk_size, timeout) if not chunk: break yield chunk if chunk_size is not None and len(chunk) < chunk_size: break - async def read(self, chunk_size: int = DEFAULT_CHUNKSIZE) -> bytes: + async def read( + self, chunk_size: int = DEFAULT_CHUNKSIZE, timeout: int = DEFAULT_TIMEOUT + ) -> bytes: """Read x bytes from the process stdout.""" try: - async with timeout(DEFAULT_TIMEOUT): + async with _timeout(timeout): if chunk_size is None: return await self._proc.stdout.read(DEFAULT_CHUNKSIZE) return await self._proc.stdout.readexactly(chunk_size) @@ -88,6 +90,8 @@ class AsyncProcess: return err.partial except AttributeError as exc: raise asyncio.CancelledError() from exc + except asyncio.TimeoutError: + return b"" async def write(self, data: bytes) -> None: """Write data to process stdin.""" @@ -96,9 +100,25 @@ class AsyncProcess: await self._proc.stdin.drain() except BrokenPipeError: pass - except AttributeError: - raise asyncio.CancelledError() + except (AttributeError, AssertionError) as err: + raise asyncio.CancelledError() from err + + def write_eof(self) -> None: + """Write end of file to to process stdin.""" + if self._proc.stdin.can_write_eof(): + self._proc.stdin.write_eof() async def communicate(self, input_data: Optional[bytes] = None) -> bytes: """Write bytes to process and read back results.""" return await self._proc.communicate(input_data) + + +async def check_output(shell_cmd: str) -> Tuple[int, bytes]: + """Run shell subprocess and return output.""" + proc = await asyncio.create_subprocess_shell( + shell_cmd, + stderr=asyncio.subprocess.STDOUT, + stdout=asyncio.subprocess.PIPE, + ) + stdout, _ = await proc.communicate() + return (proc.returncode, stdout) diff --git a/music_assistant/helpers/typing.py b/music_assistant/helpers/typing.py index bb5b3b84..56ece308 100644 --- a/music_assistant/helpers/typing.py +++ b/music_assistant/helpers/typing.py @@ -1,18 +1,22 @@ """Typing helper.""" -from typing import TYPE_CHECKING, Optional, Set +from typing import TYPE_CHECKING, Any, Optional, List + # pylint: disable=invalid-name if TYPE_CHECKING: - from music_assistant.mass import MusicAssistant - from music_assistant.models.player_queue import ( - QueueItem, + from music_assistant.mass import ( + MusicAssistant, + EventDetails, + EventCallBackType, + EventSubscriptionType, + ) + from music_assistant.models.media_items import MediaType + from music_assistant.models.player import ( PlayerQueue, + QueueItem, ) - from music_assistant.models.streamdetails import StreamDetails, StreamType from music_assistant.models.player import Player - from music_assistant.managers.config import ConfigSubItem - from music_assistant.models.media_types import MediaType else: MusicAssistant = "MusicAssistant" @@ -20,13 +24,14 @@ else: PlayerQueue = "PlayerQueue" StreamDetails = "StreamDetails" Player = "Player" - ConfigSubItem = "ConfigSubItem" MediaType = "MediaType" - StreamType = "StreamType" + EventDetails = Any | None + EventCallBackType = "EventCallBackType" + EventSubscriptionType = "EventSubscriptionType" -QueueItems = Set[QueueItem] -Players = Set[Player] +QueueItems = List[QueueItem] +Players = List[Player] OptionalInt = Optional[int] OptionalStr = Optional[str] diff --git a/music_assistant/helpers/util.py b/music_assistant/helpers/util.py index 38a3b763..6743b733 100755 --- a/music_assistant/helpers/util.py +++ b/music_assistant/helpers/util.py @@ -1,20 +1,15 @@ """Helper and utility functions.""" import asyncio import functools -import logging import os import platform import socket import tempfile import threading -import urllib.request from asyncio.events import AbstractEventLoop from typing import Any, Callable, Dict, List, Optional, Set, TypeVar, Union import memory_tempfile -import ujson - -from .typing import MediaType # pylint: disable=invalid-name T = TypeVar("T") @@ -26,17 +21,6 @@ CALLBACK_TYPE = Callable[[], None] DEFAULT_LOOP = None -def callback(func: CALLABLE_T) -> CALLABLE_T: - """Annotation to mark method as safe to call from within the event loop.""" - setattr(func, "_mass_callback", True) - return func - - -def is_callback(func: Callable[..., Any]) -> bool: - """Check if function is safe to be called in the event loop.""" - return getattr(func, "_mass_callback", False) is True - - def create_task( target: Callable[..., Any], *args: Any, @@ -60,9 +44,6 @@ def create_task( while isinstance(check_target, functools.partial): check_target = check_target.func - async def cb_wrapper(_target: Callable, *_args, **_kwargs): - return _target(*_args, **_kwargs) - async def executor_wrapper(_target: Callable, *_args, **_kwargs): return await loop.run_in_executor(None, _target, *_args, **_kwargs) @@ -72,10 +53,6 @@ def create_task( return asyncio.run_coroutine_threadsafe(target, loop) if asyncio.iscoroutinefunction(check_target): return asyncio.run_coroutine_threadsafe(target(*args), loop) - if is_callback(check_target): - return asyncio.run_coroutine_threadsafe( - cb_wrapper(target, *args, **kwargs), loop - ) return asyncio.run_coroutine_threadsafe( executor_wrapper(target, *args, **kwargs), loop ) @@ -84,8 +61,6 @@ def create_task( return loop.create_task(target) if asyncio.iscoroutinefunction(check_target): return loop.create_task(target(*args)) - if is_callback(check_target): - return loop.create_task(cb_wrapper(target, *args, **kwargs)) return loop.create_task(executor_wrapper(target, *args, **kwargs)) @@ -103,15 +78,6 @@ def run_periodic(period): return scheduler -def get_external_ip(): - """Try to get the external (WAN) IP address.""" - # pylint: disable=broad-except - try: - return urllib.request.urlopen("https://ident.me").read().decode("utf8") - except Exception: - return None - - def filename_from_string(string): """Create filename from unsafe string.""" keepcharacters = (" ", ".", "_") @@ -150,6 +116,15 @@ def try_parse_bool(possible_bool): return possible_bool in ["true", "True", "1", "on", "ON", 1] +def create_sort_name(name): + """Return sort name.""" + sort_name = name + for item in ["The ", "De ", "de ", "Les "]: + if name.startswith(item): + sort_name = "".join(name.split(item)[1:]) + return sort_name.lower() + + def parse_title_and_version(track_title, track_version=None): """Try to parse clean track title and version from the title.""" title = track_title.lower() @@ -236,24 +211,6 @@ def get_ip(): return _ip -def get_ip_pton(): - """Return socket pton for local ip.""" - # pylint:disable=no-member - try: - return socket.inet_pton(socket.AF_INET, get_ip()) - except OSError: - return socket.inet_pton(socket.AF_INET6, get_ip()) - - -# pylint: enable=broad-except - - -def get_hostname(): - """Get hostname for this machine.""" - # pylint:disable=no-member - return socket.gethostname() - - def get_folder_size(folderpath): """Return folder size in gb.""" total_size = 0 @@ -280,7 +237,7 @@ def merge_dict(base_dict: dict, new_dict: dict, allow_overwite=False): return final_dict -def merge_list(base_list: list, new_list: list) -> Set: +def merge_list(base_list: list, new_list: list) -> List: """Merge 2 lists.""" final_list = set(base_list) for item in new_list: @@ -290,19 +247,7 @@ def merge_list(base_list: list, new_list: list) -> Set: prov_item = item if item not in final_list: final_list.add(item) - return final_list - - -def try_load_json_file(jsonfile): - """Try to load json from file.""" - try: - with open(jsonfile, "r") as _file: - return ujson.loads(_file.read()) - except (FileNotFoundError, ValueError) as exc: - logging.getLogger().debug( - "Could not load json from file %s", jsonfile, exc_info=exc - ) - return None + return list(final_list) def create_tempfile(): @@ -314,15 +259,10 @@ def create_tempfile(): return tempfile.NamedTemporaryFile(buffering=0) -async def yield_chunks(_obj, chunk_size): - """Yield successive n-sized chunks from list/str/bytes.""" - chunk_size = int(chunk_size) - for i in range(0, len(_obj), chunk_size): - yield _obj[i : i + chunk_size] - - def get_changed_keys( - dict1: Dict[str, Any], dict2: Dict[str, Any], ignore_keys: Optional[Set[str]] = None + dict1: Dict[str, Any], + dict2: Dict[str, Any], + ignore_keys: Optional[List[str]] = None, ) -> Set[str]: """Compare 2 dicts and return set of changed keys.""" if not dict2: @@ -336,56 +276,3 @@ def get_changed_keys( elif dict1[key] != value: changed_keys.add(key) return changed_keys - - -def create_uri(media_type: MediaType, provider: str, item_id: str): - """Create uri for mediaitem.""" - return f"{provider}://{media_type.value}/{item_id}" - - -class LimitedList(list): - """Implementation of a size limited list.""" - - @property - def max_len(self): - """Return list's max length.""" - return self._max_len - - def __init__(self, lst: Optional[List] = None, max_len=500): - """Initialize instance.""" - self._max_len = max_len - if lst is not None: - super().__init__(lst) - else: - super().__init__() - - def _truncate(self): - """Call by various methods to reinforce the maximum length.""" - dif = len(self) - self._max_len - if dif > 0: - self[:dif] = [] - - def append(self, x): - """Append item x to the list.""" - super().append(x) - self._truncate() - - def insert(self, *args): - """Insert items at position x to the list.""" - super().insert(*args) - self._truncate() - - def extend(self, x): - """Extend the list.""" - super().extend(x) - self._truncate() - - def __setitem__(self, *args): - """Internally set.""" - super().__setitem__(*args) - self._truncate() - - # def __setslice__(self, *args): - # """Internally set slice.""" - # super().__setslice__(*args) - # self._truncate() diff --git a/music_assistant/helpers/web.py b/music_assistant/helpers/web.py deleted file mode 100644 index 0a34e7c3..00000000 --- a/music_assistant/helpers/web.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Various helpers for web requests.""" - -import asyncio -import inspect -import ipaddress -import re -from dataclasses import dataclass -from functools import wraps -from typing import Any, Callable, Dict, Optional, Tuple, Union, get_args, get_origin - -import ujson -from aiohttp import web -from music_assistant.helpers.typing import MusicAssistant - - -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 - - -def serialize_values(obj): - """Recursively create serializable values for (custom) data types.""" - - def get_val(val): - if ( - isinstance(val, (list, set, filter, tuple)) - or val.__class__ == "dict_valueiterator" - ): - return [get_val(x) for x in val] if val else [] - if isinstance(val, dict): - return {key: get_val(value) for key, value in val.items()} - try: - return val.to_dict() - except AttributeError: - return val - except Exception: - return val - - return get_val(obj) - - -def json_serializer(data): - """Json serializer to recursively create serializable values for custom data types.""" - return ujson.dumps(serialize_values(data)) - - -async def async_json_serializer(data): - """Run json serializer in executor for large data.""" - if isinstance(data, list) and len(data) > 100: - return await asyncio.get_running_loop().run_in_executor( - None, json_serializer, data - ) - return json_serializer(data) - - -def json_response(data: Any, status: int = 200): - """Return json in web request.""" - return web.Response( - body=json_serializer(data), status=200, content_type="application/json" - ) - - -async def async_json_response(data: Any, status: int = 200): - """Return json in web request.""" - return web.Response( - body=await async_json_serializer(data), - status=200, - content_type="application/json", - ) - - -def api_route(api_path, method="GET"): - """Decorate a function as API route/command.""" - - def decorate(func): - func.api_path = api_path - func.api_method = method - return func - - return decorate - - -def get_match_pattern(api_path: str) -> Optional[re.Pattern]: - """Return match pattern for given path.""" - if "{" in api_path and "}" in api_path: - regex_parts = [] - for part in api_path.split("/"): - if part.startswith("{") and part.endswith("}"): - # path variable, create named capture group - regex_parts.append(part.replace("{", "(?P<").replace("}", ">[^{}/]+)")) - else: - # literal string - regex_parts.append(r"\b" + part + r"\b") - path_regex = "/" if api_path.startswith("/") else "" - path_regex += "/".join(regex_parts) - if api_path.endswith("/"): - path_regex += "/" - return re.compile(path_regex) - return None - - -def create_api_route( - api_path: str, - handler: Callable, - method: str = "GET", -): - """Create APIRoute instance from given params.""" - return APIRoute( - path=api_path, - method=method, - pattern=get_match_pattern(api_path), - part_count=api_path.count("/"), - signature=get_typed_signature(handler), - target=handler, - ) - - -def get_typed_signature(call: Callable) -> inspect.Signature: - """Parse signature of function to do type validation and/or api spec generation.""" - signature = inspect.signature(call) - return signature - - -def parse_arguments(mass: MusicAssistant, func_sig: inspect.Signature, args: dict): - """Parse (and convert) incoming arguments to correct types.""" - final_args = {} - for key, value in args.items(): - if key not in func_sig.parameters: - raise KeyError("Invalid parameter: '%s'" % key) - arg_type = func_sig.parameters[key].annotation - final_args[key] = convert_value(key, value, arg_type) - # check for missing args - for key, value in func_sig.parameters.items(): - if key == "mass": - final_args[key] = mass - elif value.default is inspect.Parameter.empty: - if key not in final_args: - raise KeyError("Missing parameter: '%s'" % key) - return final_args - - -def convert_value(arg_key, value, arg_type): - """Convert dict value to one of our models.""" - if arg_type == inspect.Parameter.empty: - return value - if get_origin(arg_type) is list: - return [ - convert_value(arg_key, subval, get_args(arg_type)[0]) for subval in value - ] - if get_origin(arg_type) is Union: - # try all possible types - for sub_arg_type in get_args(arg_type): - try: - return convert_value(arg_key, value, sub_arg_type) - except Exception: # pylint: disable=broad-except - pass - raise ValueError("Error parsing '%s', possibly wrong type?" % arg_key) - if hasattr(arg_type, "from_dict"): - return arg_type.from_dict(value) - if value is None: - return value - if arg_type is Any: - return value - return arg_type(value) - - -@dataclass -class APIRoute: - """Model for an API route.""" - - path: str - method: str - pattern: Optional[re.Pattern] - part_count: int - signature: inspect.Signature - target: Callable - - def match( - self, matchpath: str, method: str - ) -> Optional[Tuple["APIRoute", Dict[str, str]]]: - """Match this route with given path and return the route and resolved params.""" - if matchpath.endswith("/"): - matchpath = matchpath[0:-1] - if self.method.upper() != method.upper(): - return None - if self.part_count != matchpath.count("/"): - return None - if self.pattern is not None: - match = re.match(self.pattern, matchpath) - if match: - return self, match.groupdict() - match = self.path.lower() == matchpath.lower() - if match: - return self, {} - return None diff --git a/music_assistant/managers/__init__.py b/music_assistant/managers/__init__.py deleted file mode 100644 index bc6f8f94..00000000 --- a/music_assistant/managers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Controllers/managers for Music Assistant entities.""" diff --git a/music_assistant/managers/config.py b/music_assistant/managers/config.py deleted file mode 100755 index 06c46451..00000000 --- a/music_assistant/managers/config.py +++ /dev/null @@ -1,709 +0,0 @@ -"""All classes and helpers for the Configuration.""" - -import copy -import json -import logging -import os -import pathlib -import shutil -from typing import Any, List - -from music_assistant.constants import ( - CONF_CROSSFADE_DURATION, - CONF_ENABLED, - CONF_GROUP_DELAY, - CONF_KEY_BASE, - CONF_KEY_METADATA_PROVIDERS, - CONF_KEY_MUSIC_PROVIDERS, - CONF_KEY_PLAYER_PROVIDERS, - CONF_KEY_PLAYER_SETTINGS, - CONF_KEY_PLUGINS, - CONF_KEY_SECURITY, - CONF_KEY_SECURITY_APP_TOKENS, - CONF_KEY_SECURITY_LOGIN, - CONF_MAX_SAMPLE_RATE, - CONF_NAME, - CONF_PASSWORD, - CONF_POWER_CONTROL, - CONF_TARGET_VOLUME, - CONF_USERNAME, - CONF_VOLUME_CONTROL, - CONF_VOLUME_NORMALISATION, - EVENT_CONFIG_CHANGED, -) -from music_assistant.helpers.datetime import utc_timestamp -from music_assistant.helpers.encryption import _decrypt_string, _encrypt_string -from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.util import create_task, try_load_json_file -from music_assistant.helpers.web import api_route -from music_assistant.models.config_entry import ( - ConfigEntry, - ConfigEntryType, - ConfigValueOption, -) -from music_assistant.models.player import PlayerControlType -from music_assistant.models.provider import ProviderType -from passlib.hash import pbkdf2_sha256 - -RESOURCES_DIR = ( - pathlib.Path(__file__).parent.resolve().parent.resolve().joinpath("resources") -) - -LOGGER = logging.getLogger("config_manager") - -SAMPLERATE_OPTIONS = [ - ConfigValueOption(text=str(val), value=val) - for val in (41000, 48000, 96000, 176000, 192000, 384000) -] - -DEFAULT_PLAYER_CONFIG_ENTRIES = [ - ConfigEntry( - entry_key=CONF_ENABLED, - entry_type=ConfigEntryType.BOOL, - default_value=True, - label="enable_player", - ), - ConfigEntry( - entry_key=CONF_NAME, - entry_type=ConfigEntryType.STRING, - default_value=None, - label=CONF_NAME, - description="desc_player_name", - ), - ConfigEntry( - entry_key=CONF_MAX_SAMPLE_RATE, - entry_type=ConfigEntryType.INT, - options=SAMPLERATE_OPTIONS, - default_value=96000, - label=CONF_MAX_SAMPLE_RATE, - description="desc_sample_rate", - ), - ConfigEntry( - entry_key=CONF_VOLUME_NORMALISATION, - entry_type=ConfigEntryType.BOOL, - default_value=True, - label=CONF_VOLUME_NORMALISATION, - description="desc_volume_normalisation", - ), - ConfigEntry( - entry_key=CONF_TARGET_VOLUME, - entry_type=ConfigEntryType.INT, - range=(-40, 0), - default_value=-23, - label=CONF_TARGET_VOLUME, - description="desc_target_volume", - depends_on=CONF_VOLUME_NORMALISATION, - ), - ConfigEntry( - entry_key=CONF_CROSSFADE_DURATION, - entry_type=ConfigEntryType.INT, - range=(0, 10), - default_value=0, - label=CONF_CROSSFADE_DURATION, - description="desc_crossfade", - ), -] - -DEFAULT_PROVIDER_CONFIG_ENTRIES = [ - ConfigEntry( - entry_key=CONF_ENABLED, - entry_type=ConfigEntryType.BOOL, - default_value=True, - label=CONF_ENABLED, - description="desc_enable_provider", - ) -] - -DEFAULT_BASE_CONFIG_ENTRIES = {} - -DEFAULT_SECURITY_CONFIG_ENTRIES = { - CONF_KEY_SECURITY_LOGIN: [ - ConfigEntry( - entry_key=CONF_USERNAME, - entry_type=ConfigEntryType.STRING, - default_value="admin", - label=CONF_USERNAME, - description="desc_base_username", - ), - ConfigEntry( - entry_key=CONF_PASSWORD, - entry_type=ConfigEntryType.PASSWORD, - default_value="", - label=CONF_PASSWORD, - description="desc_base_password", - store_hashed=True, - ), - ], - CONF_KEY_SECURITY_APP_TOKENS: [], -} - - -PROVIDER_TYPE_MAPPINGS = { - CONF_KEY_MUSIC_PROVIDERS: ProviderType.MUSIC_PROVIDER, - CONF_KEY_PLAYER_PROVIDERS: ProviderType.PLAYER_PROVIDER, - CONF_KEY_METADATA_PROVIDERS: ProviderType.METADATA_PROVIDER, - CONF_KEY_PLUGINS: ProviderType.PLUGIN, -} - - -class ConfigManager: - """Class which holds our configuration.""" - - def __init__(self, mass: MusicAssistant, data_path: str): - """Initialize class.""" - self._data_path = data_path - self._stored_config = {} - self._strings = {} - self.loading = False - self.mass = mass - if not os.path.isdir(data_path): - raise FileNotFoundError(f"data directory {data_path} does not exist!") - self.__load() - - async def setup(self): - """Async initialize of module.""" - self._strings = await self._fetch_strings() - - @api_route("config/{conf_base}") - def base_items(self, conf_base: str) -> dict: - """Return config items.""" - obj = getattr(self, conf_base) - if isinstance(obj, dict): - return obj - return obj.all_items() - - @api_route("config/{conf_base}/{conf_key}") - def sub_items(self, conf_base: str, conf_key: str) -> dict: - """Return specific config entries.""" - obj = getattr(self, conf_base)[conf_key] - if isinstance(obj, dict): - return obj - return obj.all_items() - - @api_route("config") - def all_items(self) -> dict: - """Return entire config as dict.""" - return { - key: getattr(self, key).all_items() - for key in [ - CONF_KEY_BASE, - CONF_KEY_SECURITY, - CONF_KEY_MUSIC_PROVIDERS, - CONF_KEY_PLAYER_PROVIDERS, - CONF_KEY_METADATA_PROVIDERS, - CONF_KEY_PLUGINS, - CONF_KEY_PLAYER_SETTINGS, - ] - } - - @api_route("config/{conf_base}/{conf_key}/{conf_val}", method="PUT") - def set_config( - self, conf_base: str, conf_key: str, conf_val: str, new_value: Any - ) -> dict: - """Set value of the given config item.""" - if new_value is None: - return self[conf_base][conf_key].pop(conf_val) - self[conf_base][conf_key][conf_val] = new_value - return self[conf_base][conf_key].all_items() - - @api_route("config/{conf_base}/{conf_key}", method="DELETE") - def delete_config(self, conf_base: str, conf_key: str) -> dict: - """Delete value from stored configuration.""" - return self[conf_base].pop(conf_key) - - @property - def data_path(self): - """Return the path where all (configuration) data is stored.""" - return self._data_path - - @property - def server_id(self): - """Return the unique identifier for this server.""" - return self.stored_config["server_id"] - - @property - def base(self): - """Return base config.""" - return BaseSettings(self) - - @property - def security(self): - """Return security config.""" - return SecuritySettings(self) - - @property - def player_settings(self): - """Return all player configs.""" - return PlayerSettings(self) - - @property - def music_providers(self): - """Return all music provider configs.""" - return ProviderSettings(self, CONF_KEY_MUSIC_PROVIDERS) - - @property - def player_providers(self): - """Return all player provider configs.""" - return ProviderSettings(self, CONF_KEY_PLAYER_PROVIDERS) - - @property - def metadata_providers(self): - """Return all metadata provider configs.""" - return ProviderSettings(self, CONF_KEY_METADATA_PROVIDERS) - - @property - def plugins(self): - """Return all plugin configs.""" - return ProviderSettings(self, CONF_KEY_PLUGINS) - - @property - def stored_config(self): - """Return the config that is actually stored on disk.""" - return self._stored_config - - @api_route("strings") - def all_strings(self): - """Return all strings for all languages.""" - return self._strings - - @api_route("strings/{language}") - def language_strings(self, language: str): - """Return all strings for given language.""" - return self._strings[language] - - def get_provider_config(self, provider_id: str, provider_type: ProviderType = None): - """Return config for given provider.""" - if not provider_type: - provider = self.mass.get_provider(provider_id) - if provider: - provider_type = provider.type - if provider_type == ProviderType.METADATA_PROVIDER: - return self.metadata_providers[provider_id] - if provider_type == ProviderType.MUSIC_PROVIDER: - return self.music_providers[provider_id] - if provider_type == ProviderType.PLAYER_PROVIDER: - return self.player_providers[provider_id] - if provider_type == ProviderType.PLUGIN: - return self.plugins[provider_id] - raise RuntimeError("Invalid provider type") - - def get_player_config(self, player_id: str): - """Return config for given player.""" - return self.player_settings[player_id] - - def __getitem__(self, item_key): - """Return item value by key.""" - return getattr(self, item_key) - - async def close(self): - """Save config on exit.""" - self.save() - - def save(self): - """Save config to file.""" - if self.loading: - LOGGER.warning("save already running") - return - self.loading = True - # backup existing file - conf_file = os.path.join(self.data_path, "config.json") - conf_file_backup = os.path.join(self.data_path, "config.json.backup") - if os.path.isfile(conf_file): - shutil.move(conf_file, conf_file_backup) - # write current config to file - with open(conf_file, "w") as _file: - _file.write(json.dumps(self._stored_config, indent=4)) - LOGGER.info("Config saved!") - self.loading = False - - @staticmethod - async def _fetch_strings() -> dict: - """Build a list of all strings/translations.""" - strings = {} - for _file in os.listdir(RESOURCES_DIR.joinpath("strings")): - if not _file.endswith(".json"): - continue - language = _file.replace(".json", "") - lang_file = RESOURCES_DIR.joinpath("strings", _file) - strings[language] = try_load_json_file(lang_file) - return strings - - def __load(self): - """Load stored config from file.""" - self.loading = True - conf_file = os.path.join(self.data_path, "config.json") - data = try_load_json_file(conf_file) - if data is None: - # might be a corrupt config file, retry with backup file - conf_file_backup = os.path.join(self.data_path, "config.json.backup") - data = try_load_json_file(conf_file_backup) - if data: - self._stored_config = data - self.loading = False - - -class ConfigBaseItem: - """Configuration class that holds the ConfigSubItem items.""" - - def __init__(self, conf_mgr: ConfigManager, conf_key: str): - """Initialize class.""" - self.conf_mgr = conf_mgr - self.mass = conf_mgr.mass - self.conf_key = conf_key - - def all_keys(self): - """Return all possible keys of this Config object.""" - return self.conf_mgr.stored_config.get(self.conf_key, {}).keys() - - def __getitem__(self, item_key: str): - """Return ConfigSubItem for given key.""" - return ConfigSubItem(self, item_key) - - def all_items(self) -> dict: - """Return entire config as dict.""" - return { - key: copy.deepcopy(ConfigSubItem(self, key).all_items()) - for key in self.all_keys() - } - - -class BaseSettings(ConfigBaseItem): - """Configuration class that holds the base settings.""" - - def __init__(self, conf_mgr: ConfigManager): - """Initialize class.""" - super().__init__(conf_mgr, CONF_KEY_BASE) - - def all_keys(self): - """Return all possible keys of this Config object.""" - return list(DEFAULT_BASE_CONFIG_ENTRIES.keys()) - - @staticmethod - def get_config_entries(child_key) -> List[ConfigEntry]: - """Return all base config entries.""" - return list(DEFAULT_BASE_CONFIG_ENTRIES[child_key]) - - -class SecuritySettings(ConfigBaseItem): - """Configuration class that holds the security settings.""" - - def __init__(self, conf_mgr: ConfigManager): - """Initialize class.""" - super().__init__(conf_mgr, CONF_KEY_SECURITY) - # make sure the keys exist in config dict - if CONF_KEY_SECURITY not in conf_mgr.stored_config: - conf_mgr.stored_config[CONF_KEY_SECURITY] = {} - if ( - CONF_KEY_SECURITY_APP_TOKENS - not in conf_mgr.stored_config[CONF_KEY_SECURITY] - ): - conf_mgr.stored_config[CONF_KEY_SECURITY][CONF_KEY_SECURITY_APP_TOKENS] = {} - - def all_keys(self): - """Return all possible keys of this Config object.""" - return DEFAULT_SECURITY_CONFIG_ENTRIES.keys() - - def add_app_token(self, token_info: dict): - """Add token to config.""" - client_id = token_info["client_id"] - self.conf_mgr.stored_config[CONF_KEY_SECURITY][CONF_KEY_SECURITY_APP_TOKENS][ - client_id - ] = token_info - self.conf_mgr.save() - - def set_last_login(self, client_id: str): - """Set last login to client.""" - if ( - client_id - not in self.conf_mgr.stored_config[CONF_KEY_SECURITY][ - CONF_KEY_SECURITY_APP_TOKENS - ] - ): - return - self.conf_mgr.stored_config[CONF_KEY_SECURITY][CONF_KEY_SECURITY_APP_TOKENS][ - client_id - ]["last_login"] = utc_timestamp() - self.conf_mgr.save() - - def revoke_app_token(self, client_id): - """Revoke a token registered for an app.""" - return_info = self.conf_mgr.stored_config[CONF_KEY_SECURITY][ - CONF_KEY_SECURITY_APP_TOKENS - ].pop(client_id) - self.conf_mgr.save() - self.conf_mgr.mass.eventbus.signal( - EVENT_CONFIG_CHANGED, (CONF_KEY_SECURITY, CONF_KEY_SECURITY_APP_TOKENS) - ) - return return_info - - def is_token_revoked(self, token_info: dict): - """Return bool if token is revoked.""" - if not token_info.get("app_id"): - # short lived token does not have app_id and is not stored so can't be revoked - return False - return self[CONF_KEY_SECURITY_APP_TOKENS].get(token_info["client_id"]) is None - - def validate_credentials(self, username: str, password: str) -> bool: - """Check if credentials matches.""" - if username != self[CONF_KEY_SECURITY_LOGIN][CONF_USERNAME]: - return False - try: - return pbkdf2_sha256.verify( - password, self[CONF_KEY_SECURITY_LOGIN][CONF_PASSWORD] - ) - except ValueError: - return False - - def get_config_entries(self, child_key) -> List[ConfigEntry]: - """Return all base config entries.""" - if child_key == CONF_KEY_SECURITY_LOGIN: - return list(DEFAULT_SECURITY_CONFIG_ENTRIES[CONF_KEY_SECURITY_LOGIN]) - if child_key == CONF_KEY_SECURITY_APP_TOKENS: - return [ - ConfigEntry( - entry_key=client_id, - entry_type=ConfigEntryType.DICT, - default_value={}, - label=token_info["app_id"], - description="App connected to MusicAssistant API", - store_hashed=False, - value={ - "expires": token_info.get("exp"), - "last_login": token_info.get("last_login"), - }, - ) - for client_id, token_info in self.conf_mgr.stored_config[ - CONF_KEY_SECURITY - ][CONF_KEY_SECURITY_APP_TOKENS].items() - ] - return [] - - -class PlayerSettings(ConfigBaseItem): - """Configuration class that holds the player settings.""" - - def __init__(self, conf_mgr: ConfigManager): - """Initialize class.""" - super().__init__(conf_mgr, CONF_KEY_PLAYER_SETTINGS) - - def all_keys(self): - """Return all possible keys of this Config object.""" - return {player.player_id for player in self.mass.players} - - def get_config_entries(self, child_key: str) -> List[ConfigEntry]: - """Return all config entries for the given child entry.""" - entries = [] - entries += DEFAULT_PLAYER_CONFIG_ENTRIES - player = self.mass.players.get_player(child_key) - if player: - entries += player.config_entries - # append power control config entries - power_controls = self.mass.players.get_player_controls( - PlayerControlType.POWER - ) - if power_controls: - controls = [ - ConfigValueOption( - text=f"{item.provider}: {item.name}", value=item.control_id - ) - for item in power_controls - ] - entries.append( - ConfigEntry( - entry_key=CONF_POWER_CONTROL, - entry_type=ConfigEntryType.STRING, - label=CONF_POWER_CONTROL, - description="desc_power_control", - options=controls, - ) - ) - # append volume control config entries - volume_controls = self.mass.players.get_player_controls( - PlayerControlType.VOLUME - ) - if volume_controls: - controls = [ - ConfigValueOption( - text=f"{item.provider}: {item.name}", value=item.control_id - ) - for item in volume_controls - ] - entries.append( - ConfigEntry( - entry_key=CONF_VOLUME_CONTROL, - entry_type=ConfigEntryType.STRING, - label=CONF_VOLUME_CONTROL, - description="desc_volume_control", - options=controls, - ) - ) - # append special group player entries - for parent_id in player.group_parents: - parent_player = self.mass.players.get_player(parent_id) - if parent_player and parent_player.provider_id == "group_player": - entries.append( - ConfigEntry( - entry_key=CONF_GROUP_DELAY, - entry_type=ConfigEntryType.INT, - default_value=0, - range=(0, 500), - label=CONF_GROUP_DELAY, - description="desc_group_delay", - ) - ) - break - return entries - - -class ProviderSettings(ConfigBaseItem): - """Configuration class that holds the provider settings.""" - - def all_keys(self): - """Return all possible keys of this Config object.""" - prov_type = PROVIDER_TYPE_MAPPINGS[self.conf_key] - return ( - item.id - for item in self.mass.get_providers(prov_type, include_unavailable=True) - ) - - def get_config_entries(self, child_key: str) -> List[ConfigEntry]: - """Return all config entries for the given provider.""" - provider = self.mass.get_provider(child_key) - if provider: - return DEFAULT_PROVIDER_CONFIG_ENTRIES + provider.config_entries - return DEFAULT_PROVIDER_CONFIG_ENTRIES - - -class ConfigSubItem: - """ - Configuration Item connected to Config Entries. - - Returns default value from config entry if no value present. - """ - - def __init__(self, conf_parent: ConfigBaseItem, conf_key: str): - """Initialize class.""" - self.conf_parent = conf_parent - self.conf_key = conf_key - self.conf_mgr = conf_parent.conf_mgr - self.parent_conf_key = conf_parent.conf_key - - def all_items(self) -> dict: - """Return entire config as dict.""" - return { - item.entry_key: self.get_entry(item.entry_key) - for item in self.conf_parent.get_config_entries(self.conf_key) - } - - def get(self, key, default=None): - """Return value if key exists, default if not.""" - try: - return self[key] - except KeyError: - return default - - def __getitem__(self, key) -> ConfigEntry: - """Get value for ConfigEntry.""" - # always lookup the config entry because config entries are dynamic - # and values may be transformed (e.g. encrypted) - entry = self.get_entry(key) - if entry.entry_type == ConfigEntryType.PASSWORD: - # decrypted password is only returned if explicitly asked for this key - decrypted_value = _decrypt_string(entry.value) - if decrypted_value: - return decrypted_value - return entry.value - - def get_entry(self, key): - """Return complete ConfigEntry for specified key.""" - stored_config = self.conf_mgr.stored_config.get(self.conf_parent.conf_key, {}) - stored_config = stored_config.get(self.conf_key, {}) - for conf_entry in self.conf_parent.get_config_entries(self.conf_key): - if conf_entry.entry_key == key: - if key in stored_config: - # use stored value - conf_entry.value = stored_config[key] - else: - # use default value for config entry - conf_entry.value = conf_entry.default_value - return conf_entry - raise KeyError( - "%s\\%s has no key %s!" % (self.conf_parent.conf_key, self.conf_key, key) - ) - - def __setitem__(self, key, value): - """Store value and validate.""" - assert isinstance(key, str) - for entry in self.conf_parent.get_config_entries(self.conf_key): - if entry.entry_key != key: - continue - # do some simple type checking - if entry.multi_value: - # multi value item - if value is None: - value = [] - if not isinstance(value, list): - raise ValueError - else: - # single value item - if entry.entry_type == ConfigEntryType.STRING and not isinstance( - value, str - ): - if value is None: - value = "" - else: - raise ValueError - if entry.entry_type == ConfigEntryType.BOOL and not isinstance( - value, bool - ): - if value is None: - value = False - else: - raise ValueError - if entry.entry_type == ConfigEntryType.FLOAT and not isinstance( - value, (float, int) - ): - if value is None: - value = 0 - else: - raise ValueError - if value != self[key]: - if entry.store_hashed: - value = pbkdf2_sha256.hash(value) - if entry.entry_type == ConfigEntryType.PASSWORD: - value = _encrypt_string(value) - - # write value to stored config - stored_conf = self.conf_mgr.stored_config - if self.parent_conf_key not in stored_conf: - stored_conf[self.parent_conf_key] = {} - if self.conf_key not in stored_conf[self.parent_conf_key]: - stored_conf[self.parent_conf_key][self.conf_key] = {} - stored_conf[self.parent_conf_key][self.conf_key][key] = value - - self.conf_mgr.mass.tasks.add("Save configuration", self.conf_mgr.save) - # reload provider/plugin if value changed - if self.parent_conf_key in PROVIDER_TYPE_MAPPINGS: - create_task(self.conf_mgr.mass.reload_provider(self.conf_key)) - if self.parent_conf_key == CONF_KEY_PLAYER_SETTINGS: - # force update of player if it's config changed - create_task( - self.conf_mgr.mass.players.trigger_player_update(self.conf_key) - ) - # signal config changed event - self.conf_mgr.mass.eventbus.signal( - EVENT_CONFIG_CHANGED, (self.parent_conf_key, self.conf_key) - ) - return - # raise KeyError if we're trying to set a value not defined as ConfigEntry - raise KeyError - - def pop(self, key): - """Delete ConfigEntry for specified key if exists.""" - stored_config = self.conf_mgr.stored_config.get(self.conf_parent.conf_key, {}) - stored_config = stored_config.get(self.conf_key, {}) - cur_val = stored_config.get(key, None) - if cur_val: - del stored_config[key] - self.conf_mgr.save() - return cur_val diff --git a/music_assistant/managers/database.py b/music_assistant/managers/database.py deleted file mode 100755 index 0b1f58bf..00000000 --- a/music_assistant/managers/database.py +++ /dev/null @@ -1,969 +0,0 @@ -"""Database logic.""" -# pylint: disable=too-many-lines -import logging -import os -import statistics -from typing import List, Optional, Set, Union - -import aiosqlite -from music_assistant.helpers.compare import compare_album, compare_track -from music_assistant.helpers.datetime import utc_timestamp -from music_assistant.helpers.util import merge_dict, merge_list, try_parse_int -from music_assistant.helpers.web import json_serializer -from music_assistant.models.media_types import ( - Album, - AlbumType, - Artist, - FullAlbum, - FullTrack, - ItemMapping, - MediaItem, - MediaItemProviderId, - MediaType, - Playlist, - Radio, - SearchResult, - Track, -) - -LOGGER = logging.getLogger("database") - - -class DatabaseManager: - """Class that holds the (logic to the) database.""" - - def __init__(self, mass): - """Initialize class.""" - self.mass = mass - self._dbfile = os.path.join(mass.config.data_path, "music_assistant.db") - - @property - def db_file(self): - """Return location of database on disk.""" - return self._dbfile - - async def get_item_by_prov_id( - self, - provider_id: str, - prov_item_id: str, - media_type: MediaType, - ) -> Optional[MediaItem]: - """Get the database item for the given prov_id.""" - if media_type == MediaType.ARTIST: - return await self.get_artist_by_prov_id(provider_id, prov_item_id) - if media_type == MediaType.ALBUM: - return await self.get_album_by_prov_id(provider_id, prov_item_id) - if media_type == MediaType.TRACK: - return await self.get_track_by_prov_id(provider_id, prov_item_id) - if media_type == MediaType.PLAYLIST: - return await self.get_playlist_by_prov_id(provider_id, prov_item_id) - if media_type == MediaType.RADIO: - return await self.get_radio_by_prov_id(provider_id, prov_item_id) - return None - - async def get_track_by_prov_id( - self, - provider_id: str, - prov_item_id: str, - ) -> Optional[FullTrack]: - """Get the database track for the given prov_id.""" - if provider_id == "database": - return await self.get_track(prov_item_id) - sql_query = f"""WHERE item_id in - (SELECT item_id FROM provider_mappings - WHERE prov_item_id = '{prov_item_id}' - AND provider = '{provider_id}' AND media_type = 'track')""" - for item in await self.get_tracks(sql_query): - return item - return None - - async def get_album_by_prov_id( - self, - provider_id: str, - prov_item_id: str, - ) -> Optional[FullAlbum]: - """Get the database album for the given prov_id.""" - if provider_id == "database": - return await self.get_album(prov_item_id) - sql_query = f"""WHERE item_id in - (SELECT item_id FROM provider_mappings - WHERE prov_item_id = '{prov_item_id}' - AND provider = '{provider_id}' AND media_type = 'album')""" - for item in await self.get_albums(sql_query): - return item - return None - - async def get_artist_by_prov_id( - self, - provider_id: str, - prov_item_id: str, - ) -> Optional[Artist]: - """Get the database artist for the given prov_id.""" - if provider_id == "database": - return await self.get_artist(prov_item_id) - sql_query = f"""WHERE item_id in - (SELECT item_id FROM provider_mappings - WHERE prov_item_id = '{prov_item_id}' - AND provider = '{provider_id}' AND media_type = 'artist')""" - for item in await self.get_artists(sql_query): - return item - return None - - async def get_playlist_by_prov_id( - self, provider_id: str, prov_item_id: str - ) -> Optional[Playlist]: - """Get the database playlist for the given prov_id.""" - if provider_id == "database": - return await self.get_playlist(prov_item_id) - sql_query = f"""WHERE item_id in - (SELECT item_id FROM provider_mappings - WHERE prov_item_id = '{prov_item_id}' - AND provider = '{provider_id}' AND media_type = 'playlist')""" - for item in await self.get_playlists(sql_query): - return item - return None - - async def get_radio_by_prov_id( - self, - provider_id: str, - prov_item_id: str, - ) -> Optional[Radio]: - """Get the database radio for the given prov_id.""" - if provider_id == "database": - return await self.get_radio(prov_item_id) - sql_query = f"""WHERE item_id in - (SELECT item_id FROM provider_mappings - WHERE prov_item_id = '{prov_item_id}' - AND provider = '{provider_id}' AND media_type = 'radio')""" - for item in await self.get_radios(sql_query): - return item - return None - - async def search( - self, searchquery: str, media_types: List[MediaType] - ) -> SearchResult: - """Search library for the given searchphrase.""" - result = SearchResult([], [], [], [], []) - searchquery = "%" + searchquery + "%" - if media_types is None or MediaType.ARTIST in media_types: - sql_query = ' WHERE name LIKE "%s"' % searchquery - result.artists = await self.get_artists(sql_query) - if media_types is None or MediaType.ALBUM in media_types: - sql_query = ' WHERE name LIKE "%s"' % searchquery - result.albums = await self.get_albums(sql_query) - if media_types is None or MediaType.TRACK in media_types: - sql_query = ' WHERE name LIKE "%s"' % searchquery - result.tracks = await self.get_tracks(sql_query) - if media_types is None or MediaType.PLAYLIST in media_types: - sql_query = ' WHERE name LIKE "%s"' % searchquery - result.playlists = await self.get_playlists(sql_query) - if media_types is None or MediaType.RADIO in media_types: - sql_query = ' WHERE name LIKE "%s"' % searchquery - result.radios = await self.get_radios(sql_query) - return result - - async def get_library_artists(self, orderby: str = "name") -> List[Artist]: - """Get all library artists.""" - sql_query = "WHERE in_library = 1" - return await self.get_artists(sql_query, orderby=orderby) - - async def get_library_albums(self, orderby: str = "name") -> List[Album]: - """Get all library albums.""" - sql_query = "WHERE in_library = 1" - return await self.get_albums(sql_query, orderby=orderby) - - async def get_library_tracks(self, orderby: str = "name") -> List[Track]: - """Get all library tracks.""" - sql_query = "WHERE in_library = 1" - return await self.get_tracks(sql_query, orderby=orderby) - - async def get_library_playlists(self, orderby: str = "name") -> List[Playlist]: - """Fetch all playlist records from table.""" - sql_query = "WHERE in_library = 1" - return await self.get_playlists(sql_query, orderby=orderby) - - async def get_library_radios( - self, provider_id: str = None, orderby: str = "name" - ) -> List[Radio]: - """Fetch all radio records from table.""" - sql_query = "WHERE in_library = 1" - return await self.get_radios(sql_query, orderby=orderby) - - async def get_playlists( - self, - filter_query: str = None, - orderby: str = "name", - ) -> List[Playlist]: - """Get all playlists from database.""" - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - db_conn.row_factory = aiosqlite.Row - sql_query = "SELECT * FROM playlists" - if filter_query: - sql_query += " " + filter_query - sql_query += " ORDER BY %s" % orderby - return [ - Playlist.from_db_row(db_row) - for db_row in await db_conn.execute_fetchall(sql_query, ()) - ] - - async def get_playlist(self, item_id: int) -> Playlist: - """Get playlist record by id.""" - item_id = try_parse_int(item_id) - for item in await self.get_playlists(f"WHERE item_id = {item_id}"): - return item - return None - - async def get_radios( - self, - filter_query: str = None, - orderby: str = "name", - db_conn: aiosqlite.Connection = None, - ) -> List[Radio]: - """Fetch radio records from database.""" - sql_query = "SELECT * FROM radios" - if filter_query: - sql_query += " " + filter_query - sql_query += " ORDER BY %s" % orderby - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - db_conn.row_factory = aiosqlite.Row - return [ - Radio.from_db_row(db_row) - for db_row in await db_conn.execute_fetchall(sql_query, ()) - ] - - async def get_radio(self, item_id: int) -> Playlist: - """Get radio record by id.""" - item_id = try_parse_int(item_id) - for item in await self.get_radios(f"WHERE item_id = {item_id}"): - return item - return None - - async def add_playlist(self, playlist: Playlist): - """Add a new playlist record to the database.""" - assert playlist.name - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - db_conn.row_factory = aiosqlite.Row - cur_item = await self.__execute_fetchone( - "SELECT (item_id) FROM playlists WHERE name=? AND owner=?;", - (playlist.name, playlist.owner), - db_conn, - ) - - if cur_item: - # update existing - return await self.update_playlist(cur_item[0], playlist) - # insert playlist - sql_query = """INSERT INTO playlists - (name, sort_name, owner, is_editable, checksum, metadata, provider_ids) - VALUES(?,?,?,?,?,?,?);""" - async with db_conn.execute( - sql_query, - ( - playlist.name, - playlist.sort_name, - playlist.owner, - playlist.is_editable, - playlist.checksum, - json_serializer(playlist.metadata), - json_serializer(playlist.provider_ids), - ), - ) as cursor: - last_row_id = cursor.lastrowid - new_item = await self.__execute_fetchone( - "SELECT (item_id) FROM playlists WHERE ROWID=?;", - (last_row_id,), - db_conn, - ) - await self._add_prov_ids( - new_item[0], MediaType.PLAYLIST, playlist.provider_ids, db_conn=db_conn - ) - await db_conn.commit() - LOGGER.debug("added playlist %s to database", playlist.name) - # return created object - return await self.get_playlist(new_item[0]) - - async def update_playlist(self, item_id: int, playlist: Playlist): - """Update a playlist record in the database.""" - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - db_conn.row_factory = aiosqlite.Row - cur_item = Playlist.from_db_row( - await self.__execute_fetchone( - "SELECT * FROM playlists WHERE item_id=?;", (item_id,), db_conn - ) - ) - metadata = merge_dict(cur_item.metadata, playlist.metadata) - provider_ids = merge_list(cur_item.provider_ids, playlist.provider_ids) - sql_query = """UPDATE playlists - SET name=?, - sort_name=?, - owner=?, - is_editable=?, - checksum=?, - metadata=?, - provider_ids=? - WHERE item_id=?;""" - await db_conn.execute( - sql_query, - ( - playlist.name, - playlist.sort_name, - playlist.owner, - playlist.is_editable, - playlist.checksum, - json_serializer(metadata), - json_serializer(provider_ids), - item_id, - ), - ) - await self._add_prov_ids( - item_id, MediaType.PLAYLIST, playlist.provider_ids, db_conn=db_conn - ) - LOGGER.debug("updated playlist %s in database: %s", playlist.name, item_id) - await db_conn.commit() - # return updated object - return await self.get_playlist(item_id) - - async def add_radio(self, radio: Radio): - """Add a new radio record to the database.""" - assert radio.name - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - db_conn.row_factory = aiosqlite.Row - cur_item = await self.__execute_fetchone( - "SELECT (item_id) FROM radios WHERE name=?;", (radio.name,), db_conn - ) - if cur_item: - # update existing - return await self.update_radio(cur_item[0], radio) - # insert radio - sql_query = """INSERT INTO radios (name, sort_name, metadata, provider_ids) - VALUES(?,?,?,?);""" - async with db_conn.execute( - sql_query, - ( - radio.name, - radio.sort_name, - json_serializer(radio.metadata), - json_serializer(radio.provider_ids), - ), - ) as cursor: - last_row_id = cursor.lastrowid - new_item = await self.__execute_fetchone( - "SELECT (item_id) FROM radios WHERE ROWID=?;", - (last_row_id,), - db_conn, - ) - await self._add_prov_ids( - new_item[0], MediaType.RADIO, radio.provider_ids, db_conn=db_conn - ) - await db_conn.commit() - LOGGER.debug("added radio %s to database", radio.name) - # return created object - return await self.get_radio(new_item[0]) - - async def update_radio(self, item_id: int, radio: Radio): - """Update a radio record in the database.""" - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - db_conn.row_factory = aiosqlite.Row - cur_item = Radio.from_db_row( - await self.__execute_fetchone( - "SELECT * FROM radios WHERE item_id=?;", (item_id,), db_conn - ) - ) - metadata = merge_dict(cur_item.metadata, radio.metadata) - provider_ids = merge_list(cur_item.provider_ids, radio.provider_ids) - sql_query = """UPDATE radios - SET name=?, - sort_name=?, - metadata=?, - provider_ids=? - WHERE item_id=?;""" - await db_conn.execute( - sql_query, - ( - radio.name, - radio.sort_name, - json_serializer(metadata), - json_serializer(provider_ids), - item_id, - ), - ) - await self._add_prov_ids( - item_id, MediaType.RADIO, radio.provider_ids, db_conn=db_conn - ) - LOGGER.debug("updated radio %s in database: %s", radio.name, item_id) - await db_conn.commit() - # return updated object - return await self.get_radio(item_id) - - async def add_to_library(self, item_id: int, media_type: MediaType): - """Add an item to the library (item must already be present in the db!).""" - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - item_id = try_parse_int(item_id) - db_name = media_type.value + "s" - sql_query = f"UPDATE {db_name} SET in_library=1 WHERE item_id=?;" - await db_conn.execute(sql_query, (item_id,)) - await db_conn.commit() - - async def remove_from_library( - self, - item_id: int, - media_type: MediaType, - ): - """Remove item from the library.""" - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - item_id = try_parse_int(item_id) - db_name = media_type.value + "s" - sql_query = f"UPDATE {db_name} SET in_library=0 WHERE item_id=?;" - await db_conn.execute(sql_query, (item_id,)) - await db_conn.commit() - - async def get_artists( - self, - filter_query: str = None, - orderby: str = "name", - db_conn: aiosqlite.Connection = None, - ) -> List[Artist]: - """Fetch artist records from database.""" - sql_query = "SELECT * FROM artists" - if filter_query: - sql_query += " " + filter_query - sql_query += " ORDER BY %s" % orderby - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - db_conn.row_factory = aiosqlite.Row - return [ - Artist.from_db_row(db_row) - for db_row in await db_conn.execute_fetchall(sql_query, ()) - ] - - async def get_artist(self, item_id: int) -> Artist: - """Get artist record by id.""" - item_id = try_parse_int(item_id) - for item in await self.get_artists("WHERE item_id = %d" % item_id): - return item - return None - - async def add_artist(self, artist: Artist): - """Add a new artist record to the database.""" - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - db_conn.row_factory = aiosqlite.Row - cur_item = await self.__execute_fetchone( - "SELECT (item_id) FROM artists WHERE musicbrainz_id=?;", - (artist.musicbrainz_id,), - db_conn, - ) - if cur_item: - # update existing - return await self.update_artist(cur_item[0], artist) - # insert artist - sql_query = """INSERT INTO artists - (name, sort_name, musicbrainz_id, metadata, provider_ids) - VALUES(?,?,?,?,?);""" - async with db_conn.execute( - sql_query, - ( - artist.name, - artist.sort_name, - artist.musicbrainz_id, - json_serializer(artist.metadata), - json_serializer(artist.provider_ids), - ), - ) as cursor: - last_row_id = cursor.lastrowid - new_item = await self.__execute_fetchone( - "SELECT (item_id) FROM artists WHERE ROWID=?;", - (last_row_id,), - db_conn, - ) - await self._add_prov_ids( - new_item[0], MediaType.ARTIST, artist.provider_ids, db_conn=db_conn - ) - await db_conn.commit() - LOGGER.debug("added artist %s to database", artist.name) - # return created object - return await self.get_artist(new_item[0]) - - async def update_artist(self, item_id: int, artist: Artist): - """Update a artist record in the database.""" - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - db_conn.row_factory = aiosqlite.Row - db_row = await self.__execute_fetchone( - "SELECT * FROM artists WHERE item_id=?;", (item_id,), db_conn - ) - cur_item = Artist.from_db_row(db_row) - metadata = merge_dict(cur_item.metadata, artist.metadata) - provider_ids = merge_list(cur_item.provider_ids, artist.provider_ids) - sql_query = """UPDATE artists - SET musicbrainz_id=?, - metadata=?, - provider_ids=? - WHERE item_id=?;""" - await db_conn.execute( - sql_query, - ( - artist.musicbrainz_id or cur_item.musicbrainz_id, - json_serializer(metadata), - json_serializer(provider_ids), - item_id, - ), - ) - await self._add_prov_ids( - item_id, MediaType.ARTIST, artist.provider_ids, db_conn=db_conn - ) - LOGGER.debug("updated artist %s in database: %s", artist.name, item_id) - await db_conn.commit() - # return updated object - return await self.get_artist(item_id) - - async def get_albums( - self, - filter_query: str = None, - orderby: str = "name", - db_conn: aiosqlite.Connection = None, - ) -> List[Album]: - """Fetch all album records from the database.""" - sql_query = "SELECT * FROM albums" - if filter_query: - sql_query += " " + filter_query - sql_query += " ORDER BY %s" % orderby - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - db_conn.row_factory = aiosqlite.Row - return [ - Album.from_db_row(db_row) - for db_row in await db_conn.execute_fetchall(sql_query, ()) - ] - - async def get_album(self, item_id: int) -> FullAlbum: - """Get album record by id.""" - item_id = try_parse_int(item_id) - # get from db - for item in await self.get_albums("WHERE item_id = %d" % item_id): - item.artist = ( - await self.get_artist_by_prov_id( - item.artist.provider, item.artist.item_id - ) - or item.artist - ) - return item - return None - - async def get_albums_from_provider_ids( - self, provider_id: Union[str, List[str]], prov_item_ids: List[str] - ) -> dict: - """Get album records for the given prov_ids.""" - provider_ids = provider_id if isinstance(provider_id, list) else [provider_id] - prov_id_str = ",".join([f'"{x}"' for x in provider_ids]) - prov_item_id_str = ",".join([f'"{x}"' for x in prov_item_ids]) - sql_query = f"""WHERE item_id in - (SELECT item_id FROM provider_mappings - WHERE provider in ({prov_id_str}) AND media_type = 'album' - AND prov_item_id in ({prov_item_id_str}) - )""" - return await self.get_albums(sql_query) - - async def add_album(self, album: Album): - """Add a new album record to the database.""" - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - db_conn.row_factory = aiosqlite.Row - cur_item = None - # always try to grab existing item by external_id - if album.upc: - for item in await self.get_albums(f"WHERE upc='{album.upc}'"): - cur_item = item - # fallback to matching - if not cur_item: - sql_query = "SELECT item_id from albums WHERE sort_name LIKE ?" - for db_row in await db_conn.execute_fetchall( - sql_query, (album.sort_name,) - ): - item = await self.get_album(db_row["item_id"]) - if compare_album(item, album): - cur_item = item - break - if cur_item: - # update existing - return await self.update_album(cur_item.item_id, album) - - # insert album - assert album.artist - album_artist = ItemMapping.from_item( - await self.get_artist_by_prov_id( - album.artist.provider, album.artist.item_id - ) - or album.artist - ) - sql_query = """INSERT INTO albums - (name, sort_name, album_type, year, version, upc, artist, metadata, provider_ids) - VALUES(?,?,?,?,?,?,?,?,?);""" - async with db_conn.execute( - sql_query, - ( - album.name, - album.sort_name, - album.album_type.value, - album.year, - album.version, - album.upc, - json_serializer(album_artist), - json_serializer(album.metadata), - json_serializer(album.provider_ids), - ), - ) as cursor: - last_row_id = cursor.lastrowid - new_item = await self.__execute_fetchone( - "SELECT (item_id) FROM albums WHERE ROWID=?;", - (last_row_id,), - db_conn, - ) - await self._add_prov_ids( - new_item[0], MediaType.ALBUM, album.provider_ids, db_conn=db_conn - ) - await db_conn.commit() - LOGGER.debug("added album %s to database", album.name) - # return created object - return await self.get_album(new_item[0]) - - async def update_album(self, item_id: int, album: Album): - """Update a album record in the database.""" - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - db_conn.row_factory = aiosqlite.Row - cur_item = await self.get_album(item_id) - album_artist = ItemMapping.from_item( - await self.get_artist_by_prov_id( - cur_item.artist.provider, cur_item.artist.item_id - ) - or await self.get_artist_by_prov_id( - album.artist.provider, album.artist.item_id - ) - or cur_item.artist - ) - metadata = merge_dict(cur_item.metadata, album.metadata) - provider_ids = merge_list(cur_item.provider_ids, album.provider_ids) - if cur_item.album_type == AlbumType.UNKNOWN: - album_type = album.album_type - else: - album_type = cur_item.album_type - sql_query = """UPDATE albums - SET upc=?, - artist=?, - metadata=?, - provider_ids=?, - album_type=? - WHERE item_id=?;""" - await db_conn.execute( - sql_query, - ( - album.upc or cur_item.upc, - json_serializer(album_artist), - json_serializer(metadata), - json_serializer(provider_ids), - album_type.value, - item_id, - ), - ) - await self._add_prov_ids( - item_id, MediaType.ALBUM, album.provider_ids, db_conn=db_conn - ) - LOGGER.debug("updated album %s in database: %s", album.name, item_id) - await db_conn.commit() - # return updated object - return await self.get_album(item_id) - - async def get_tracks( - self, - filter_query: str = None, - orderby: str = "name", - db_conn: aiosqlite.Connection = None, - ) -> List[Track]: - """Return all track records from the database.""" - sql_query = "SELECT * FROM tracks" - if filter_query: - sql_query += " " + filter_query - sql_query += " ORDER BY %s" % orderby - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - db_conn.row_factory = aiosqlite.Row - return [ - Track.from_db_row(db_row) - for db_row in await db_conn.execute_fetchall(sql_query, ()) - ] - - async def get_tracks_from_provider_ids( - self, - provider_id: Union[str, List[str], Set[str]], - prov_item_ids: Union[List[str], Set[str]], - ) -> List[Track]: - """Get track records for the given prov_ids.""" - provider_ids = provider_id if isinstance(provider_id, list) else [provider_id] - prov_id_str = ",".join([f'"{x}"' for x in provider_ids]) - prov_item_id_str = ",".join([f'"{x}"' for x in prov_item_ids]) - sql_query = f"""WHERE item_id in - (SELECT item_id FROM provider_mappings - WHERE provider in ({prov_id_str}) AND media_type = 'track' - AND prov_item_id in ({prov_item_id_str}) - )""" - return await self.get_tracks(sql_query) - - async def get_track(self, item_id: int) -> FullTrack: - """Get full track record by id.""" - item_id = try_parse_int(item_id) - for item in await self.get_tracks("WHERE item_id = %d" % item_id): - # include full album info - item.albums = set( - filter( - None, - [ - await self.get_album_by_prov_id(album.provider, album.item_id) - for album in item.albums - ], - ) - ) - item.album = next(iter(item.albums)) - # include full artist info - item.artists = { - await self.get_artist_by_prov_id(artist.provider, artist.item_id) - or artist - for artist in item.artists - } - return item - return None - - async def add_track(self, track: Track): - """Add a new track record to the database.""" - assert track.album, "Track is missing album" - assert track.artists, "Track is missing artist(s)" - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - db_conn.row_factory = aiosqlite.Row - cur_item = None - # always try to grab existing item by matching - if track.isrc: - for item in await self.get_tracks(f"WHERE isrc='{track.isrc}'"): - cur_item = item - # fallback to matching - if not cur_item: - sql_query = "SELECT item_id FROM tracks WHERE sort_name LIKE ?" - for db_row in await db_conn.execute_fetchall( - sql_query, (track.sort_name,) - ): - item = await self.get_track(db_row["item_id"]) - if compare_track(item, track): - cur_item = item - break - if cur_item: - # update existing - return await self.update_track(cur_item.item_id, track) - # Item does not yet exist: Insert track - sql_query = """INSERT INTO tracks - (name, sort_name, albums, artists, duration, version, isrc, metadata, provider_ids) - VALUES(?,?,?,?,?,?,?,?,?);""" - # we store a mapping to artists and albums on the track for easier access/listings - track_artists = await self._get_track_artists(track) - track_albums = await self._get_track_albums(track) - - async with db_conn.execute( - sql_query, - ( - track.name, - track.sort_name, - json_serializer(track_albums), - json_serializer(track_artists), - track.duration, - track.version, - track.isrc, - json_serializer(track.metadata), - json_serializer(track.provider_ids), - ), - ) as cursor: - last_row_id = cursor.lastrowid - new_item = await self.__execute_fetchone( - "SELECT (item_id) FROM tracks WHERE ROWID=?;", - (last_row_id,), - db_conn, - ) - await self._add_prov_ids( - new_item[0], MediaType.TRACK, track.provider_ids, db_conn=db_conn - ) - await db_conn.commit() - LOGGER.debug("added track %s to database", track.name) - # return created object - return await self.get_track(new_item[0]) - - async def update_track(self, item_id: int, track: Track): - """Update a track record in the database.""" - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - db_conn.row_factory = aiosqlite.Row - cur_item = await self.get_track(item_id) - - # we store a mapping to artists and albums on the track for easier access/listings - track_artists = await self._get_track_artists(track, cur_item.artists) - track_albums = await self._get_track_albums(track, cur_item.albums) - # merge metadata and provider id's - metadata = merge_dict(cur_item.metadata, track.metadata) - provider_ids = merge_list(cur_item.provider_ids, track.provider_ids) - sql_query = """UPDATE tracks - SET isrc=?, - metadata=?, - provider_ids=?, - artists=?, - albums=? - WHERE item_id=?;""" - await db_conn.execute( - sql_query, - ( - track.isrc or cur_item.isrc, - json_serializer(metadata), - json_serializer(provider_ids), - json_serializer(track_artists), - json_serializer(track_albums), - item_id, - ), - ) - await self._add_prov_ids( - item_id, MediaType.TRACK, track.provider_ids, db_conn=db_conn - ) - LOGGER.debug("updated track %s in database: %s", track.name, item_id) - await db_conn.commit() - # return updated object - return await self.get_track(item_id) - - async def set_track_loudness(self, item_id: str, provider: str, loudness: int): - """Set integrated loudness for a track in db.""" - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - sql_query = """INSERT or REPLACE INTO track_loudness - (item_id, provider, loudness) VALUES(?,?,?);""" - await db_conn.execute(sql_query, (item_id, provider, loudness)) - await db_conn.commit() - - async def get_track_loudness(self, provider_item_id, provider): - """Get integrated loudness for a track in db.""" - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - sql_query = """SELECT loudness FROM track_loudness WHERE - item_id = ? AND provider = ?""" - async with db_conn.execute( - sql_query, (provider_item_id, provider) - ) as cursor: - result = await cursor.fetchone() - if result: - return result[0] - return None - - async def get_provider_loudness(self, provider) -> Optional[float]: - """Get average integrated loudness for tracks of given provider.""" - all_items = [] - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - sql_query = """SELECT loudness FROM track_loudness WHERE provider = ?""" - async with db_conn.execute(sql_query, (provider,)) as cursor: - result = await cursor.fetchone() - if result: - return result[0] - sql_query = """SELECT loudness FROM track_loudness WHERE provider = ?""" - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - for db_row in await db_conn.execute_fetchall(sql_query, (provider,)): - all_items.append(db_row[0]) - if all_items: - return statistics.fmean(all_items) - return None - - async def mark_item_played(self, item_id: str, provider: str): - """Mark item as played in playlog.""" - timestamp = utc_timestamp() - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - sql_query = """INSERT or REPLACE INTO playlog - (item_id, provider, timestamp) VALUES(?,?,?);""" - await db_conn.execute(sql_query, (item_id, provider, timestamp)) - await db_conn.commit() - - async def get_thumbnail_id(self, url, size): - """Get/create id for thumbnail.""" - async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn: - sql_query = """SELECT id FROM thumbs WHERE - url = ? AND size = ?""" - async with db_conn.execute(sql_query, (url, size)) as cursor: - result = await cursor.fetchone() - if result: - return result[0] - # create if it doesnt exist - sql_query = """INSERT INTO thumbs - (url, size) VALUES(?,?);""" - async with db_conn.execute( - sql_query, - (url, size), - ) as cursor: - last_row_id = cursor.lastrowid - new_item = await self.__execute_fetchone( - "SELECT id FROM thumbs WHERE ROWID=?;", (last_row_id,), db_conn - ) - await db_conn.commit() - return new_item[0] - - async def _add_prov_ids( - self, - item_id: int, - media_type: MediaType, - provider_ids: Set[MediaItemProviderId], - db_conn: aiosqlite.Connection, - ): - """Add provider ids for media item to database.""" - - for prov in provider_ids: - sql_query = """INSERT OR REPLACE INTO provider_mappings - (item_id, media_type, prov_item_id, provider, quality, details) - VALUES(?,?,?,?,?,?);""" - await db_conn.execute( - sql_query, - ( - item_id, - media_type.value, - prov.item_id, - prov.provider, - prov.quality, - prov.details, - ), - ) - - async def __execute_fetchone( - self, query: str, query_params: tuple, db_conn: aiosqlite.Connection - ): - """Return first row of given query.""" - for item in await db_conn.execute_fetchall(query, query_params): - return item - return None - - async def _get_track_albums( - self, track: Track, cur_albums: Optional[Set[ItemMapping]] = None - ) -> Set[ItemMapping]: - """Extract all (unique) albums of track as ItemMapping.""" - if not track.albums: - track.albums.add(track.album) - if cur_albums is None: - cur_albums = set() - cur_albums.update(track.albums) - track_albums = set() - for album in cur_albums: - cur_ids = {x.item_id for x in track_albums} - if isinstance(album, ItemMapping): - track_album = await self.get_album_by_prov_id(album.provider_id, album) - else: - track_album = await self.add_album(album) - if track_album.item_id not in cur_ids: - track_albums.add(ItemMapping.from_item(album)) - return track_albums - - async def _get_track_artists( - self, track: Track, cur_artists: Optional[Set[ItemMapping]] = None - ) -> Set[ItemMapping]: - """Extract all (unique) artists of track as ItemMapping.""" - if cur_artists is None: - cur_artists = set() - cur_artists.update(track.artists) - track_artists = set() - for item in cur_artists: - cur_names = {x.name for x in track_artists} - cur_ids = {x.item_id for x in track_artists} - track_artist = ( - await self.get_artist_by_prov_id(item.provider, item.item_id) or item - ) - if ( - track_artist.name not in cur_names - and track_artist.item_id not in cur_ids - ): - track_artists.add(ItemMapping.from_item(track_artist)) - return track_artists diff --git a/music_assistant/managers/events.py b/music_assistant/managers/events.py deleted file mode 100644 index 71407d9b..00000000 --- a/music_assistant/managers/events.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Logic to process events throughout the application.""" - - -import logging -from typing import Any, Awaitable, Callable, Tuple, Union - -from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.util import callback, create_task - -LOGGER = logging.getLogger("eventbus") - - -class EventBus: - """Global EventBus handling listening for and forwarding of events.""" - - def __init__(self, mass: MusicAssistant): - """Initialize EventBus instance.""" - self.mass = mass - self._listeners = [] - - @callback - def signal(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.mass.debug: - LOGGER.debug("%s: %s", event_msg, str(event_details)) - for cb_func, event_filter in self._listeners: - if not event_filter or event_msg in event_filter: - create_task(cb_func, event_msg, event_details) - - @callback - def add_listener( - self, - cb_func: Callable[..., Union[None, Awaitable]], - event_filter: Union[None, str, Tuple] = None, - ) -> Callable: - """ - Add callback to event listeners. - - Returns function to remove the listener. - :param cb_func: callback function or coroutine - :param event_filter: Optionally only listen for these events - """ - listener = (cb_func, event_filter) - self._listeners.append(listener) - - def remove_listener(): - self._listeners.remove(listener) - - return remove_listener diff --git a/music_assistant/managers/library.py b/music_assistant/managers/library.py deleted file mode 100755 index 00906f92..00000000 --- a/music_assistant/managers/library.py +++ /dev/null @@ -1,440 +0,0 @@ -"""LibraryManager: Orchestrates synchronisation of music providers into the library.""" - -import logging -import time -from typing import Any, List, Optional - -from music_assistant.constants import EVENT_PROVIDER_REGISTERED -from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.web import api_route -from music_assistant.managers.tasks import TaskInfo -from music_assistant.models.media_types import ( - Album, - Artist, - MediaItem, - MediaType, - Playlist, - Radio, - Track, -) -from music_assistant.models.provider import ProviderType - -LOGGER = logging.getLogger("music_manager") - - -class LibraryManager: - """Manage sync of musicproviders to library.""" - - def __init__(self, mass: MusicAssistant): - """Initialize class.""" - self.running_sync_jobs = set() - self.mass = mass - self.cache = mass.cache - self._sync_tasks = set() - self.mass.eventbus.add_listener(self.mass_event, EVENT_PROVIDER_REGISTERED) - - async def setup(self): - """Async initialize of module.""" - # schedule sync task for each provider that is already registered at startup - for prov in self.mass.get_providers(ProviderType.MUSIC_PROVIDER): - if prov.id not in self._sync_tasks: - self._sync_tasks.add(prov.id) - await self.music_provider_sync(prov.id) - - async def mass_event(self, msg: str, msg_details: Any): - """Handle message on eventbus.""" - if msg == EVENT_PROVIDER_REGISTERED: - # schedule the sync task when a new provider registers - provider = self.mass.get_provider(msg_details) - if provider.type == ProviderType.MUSIC_PROVIDER: - if msg_details not in self._sync_tasks: - self._sync_tasks.add(msg_details) - await self.music_provider_sync(msg_details, periodic=3 * 3600) - - ################ GET MediaItems that are added in the library ################ - - @api_route("library/artists") - async def get_library_artists(self, orderby: str = "name") -> List[Artist]: - """Return all library artists, optionally filtered by provider.""" - return await self.mass.database.get_library_artists(orderby=orderby) - - @api_route("library/albums") - async def get_library_albums(self, orderby: str = "name") -> List[Album]: - """Return all library albums, optionally filtered by provider.""" - return await self.mass.database.get_library_albums(orderby=orderby) - - @api_route("library/tracks") - async def get_library_tracks(self, orderby: str = "name") -> List[Track]: - """Return all library tracks, optionally filtered by provider.""" - return await self.mass.database.get_library_tracks(orderby=orderby) - - @api_route("library/playlists") - async def get_library_playlists(self, orderby: str = "name") -> List[Playlist]: - """Return all library playlists, optionally filtered by provider.""" - return await self.mass.database.get_library_playlists(orderby=orderby) - - @api_route("library/radios") - async def get_library_radios(self, orderby: str = "name") -> List[Playlist]: - """Return all library radios, optionally filtered by provider.""" - return await self.mass.database.get_library_radios(orderby=orderby) - - async def get_library_playlist_by_name(self, name: str) -> Playlist: - """Get in-library playlist by name.""" - for playlist in await self.mass.music.get_library_playlists(): - if playlist.name == name: - return playlist - return None - - async def get_radio_by_name(self, name: str) -> Radio: - """Get in-library radio by name.""" - for radio in await self.mass.music.get_library_radios(): - if radio.name == name: - return radio - return None - - @api_route("library", method="POST") - async def library_add_items(self, items: List[MediaItem]) -> List[TaskInfo]: - """ - Add media item(s) to the library. - - Creates background tasks to process the action. - """ - result = [] - for media_item in items: - job_desc = f"Add {media_item.uri} to library" - result.append( - self.mass.tasks.add(job_desc, self.library_add_item, media_item) - ) - return result - - async def library_add_item(self, item: MediaItem): - """Add media item to the library.""" - # make sure we have a valid full item - item = await self.mass.music.get_item( - item.item_id, item.provider, item.media_type, lazy=False - ) - # add to provider's libraries - for prov in item.provider_ids: - provider = self.mass.get_provider(prov.provider) - if provider: - await provider.library_add(prov.item_id, item.media_type) - # mark as library item in internal db - await self.mass.database.add_to_library(item.item_id, item.media_type) - - @api_route("library", method="DELETE") - async def library_remove_items(self, items: List[MediaItem]) -> List[TaskInfo]: - """ - Remove media item(s) from the library. - - Creates background tasks to process the action. - """ - result = [] - for media_item in items: - job_desc = f"Remove {media_item.uri} from library" - result.append( - self.mass.tasks.add(job_desc, self.library_remove_item, media_item) - ) - return result - - async def library_remove_item(self, item: MediaItem) -> None: - """Remove media item(s) from the library.""" - # remove from provider's libraries - for prov in item.provider_ids: - provider = self.mass.get_provider(prov.provider) - if provider: - await provider.library_remove(prov.item_id, item.media_type) - # mark as library item in internal db - if item.provider == "database": - await self.mass.database.remove_from_library(item.item_id, item.media_type) - - @api_route("library/playlists/{db_playlist_id}/tracks", method="POST") - async def add_playlist_tracks( - self, db_playlist_id: int, tracks: List[Track] - ) -> List[TaskInfo]: - """Add multiple tracks to playlist. Creates background tasks to process the action.""" - result = [] - playlist = await self.mass.music.get_playlist(db_playlist_id, "database") - if not playlist: - raise RuntimeError("Playlist %s not found" % db_playlist_id) - if not playlist.is_editable: - raise RuntimeError("Playlist %s is not editable" % playlist.name) - for track in tracks: - job_desc = f"Add track {track.uri} to playlist {playlist.uri}" - result.append( - self.mass.tasks.add( - job_desc, self.add_playlist_track, db_playlist_id, track - ) - ) - return result - - async def add_playlist_track(self, db_playlist_id: int, track: Track) -> None: - """Add track to playlist - make sure we dont add duplicates.""" - # we can only edit playlists that are in the database (marked as editable) - playlist = await self.mass.music.get_playlist(db_playlist_id, "database") - if not playlist: - raise RuntimeError("Playlist %s not found" % db_playlist_id) - if not playlist.is_editable: - raise RuntimeError("Playlist %s is not editable" % playlist.name) - # make sure we have recent full track details - track = await self.mass.music.get_track( - track.item_id, track.provider, refresh=True, lazy=False - ) - # a playlist can only have one provider (for now) - playlist_prov = next(iter(playlist.provider_ids)) - # grab all existing track ids in the playlist so we can check for duplicates - cur_playlist_track_ids = set() - for item in await self.mass.music.get_playlist_tracks( - playlist_prov.item_id, playlist_prov.provider - ): - cur_playlist_track_ids.update( - { - i.item_id - for i in item.provider_ids - if i.provider == playlist_prov.provider - } - ) - # check for duplicates - for track_prov in track.provider_ids: - if ( - track_prov.provider == playlist_prov.provider - and track_prov.item_id in cur_playlist_track_ids - ): - raise RuntimeError( - "Track already exists in playlist %s" % playlist.name - ) - # add track to playlist - # we can only add a track to a provider playlist if track is available on that provider - # a track can contain multiple versions on the same provider - # simply sort by quality and just add the first one (assuming track is still available) - track_id_to_add = None - for track_version in sorted( - track.provider_ids, key=lambda x: x.quality, reverse=True - ): - if not track.available: - continue - if track_version.provider == playlist_prov.provider: - track_id_to_add = track_version.item_id - break - if playlist_prov.provider == "file": - # the file provider can handle uri's from all providers so simply add the uri - track_id_to_add = track.uri - break - if not track_id_to_add: - raise RuntimeError( - "Track is not available on provider %s" % playlist_prov.provider - ) - # actually add the tracks to the playlist on the provider - # invalidate cache - playlist.checksum = str(time.time()) - await self.mass.database.update_playlist(playlist.item_id, playlist) - # return result of the action on the provider - provider = self.mass.get_provider(playlist_prov.provider) - return await provider.add_playlist_tracks( - playlist_prov.item_id, [track_id_to_add] - ) - - @api_route("library/playlists/{db_playlist_id}/tracks", method="DELETE") - async def remove_playlist_tracks( - self, db_playlist_id: int, tracks: List[Track] - ) -> List[TaskInfo]: - """Remove multiple tracks from playlist. Creates background tasks to process the action.""" - result = [] - playlist = await self.mass.music.get_playlist(db_playlist_id, "database") - if not playlist: - raise RuntimeError("Playlist %s not found" % db_playlist_id) - if not playlist.is_editable: - raise RuntimeError("Playlist %s is not editable" % playlist.name) - for track in tracks: - job_desc = f"Remove track {track.uri} from playlist {playlist.uri}" - result.append( - self.mass.tasks.add( - job_desc, self.remove_playlist_track, db_playlist_id, track - ) - ) - return result - - async def remove_playlist_track(self, db_playlist_id, track: Track) -> None: - """Remove track from playlist.""" - # we can only edit playlists that are in the database (marked as editable) - playlist = await self.mass.music.get_playlist(db_playlist_id, "database") - if not playlist or not playlist.is_editable: - return False - # playlist can only have one provider (for now) - prov_playlist = next(iter(playlist.provider_ids)) - track_ids_to_remove = set() - # a track can contain multiple versions on the same provider, remove all - for track_provider in track.provider_ids: - if track_provider.provider == prov_playlist.provider: - track_ids_to_remove.add(track_provider.item_id) - # actually remove the tracks from the playlist on the provider - if track_ids_to_remove: - # invalidate cache - playlist.checksum = str(time.time()) - await self.mass.database.update_playlist(playlist.item_id, playlist) - provider = self.mass.get_provider(prov_playlist.provider) - return await provider.remove_playlist_tracks( - prov_playlist.item_id, track_ids_to_remove - ) - - async def music_provider_sync(self, prov_id: str, periodic: Optional[int] = None): - """ - Sync a music provider. - - param prov_id: {string} -- provider id to sync - """ - provider = self.mass.get_provider(prov_id) - if not provider: - return - if MediaType.ALBUM in provider.supported_mediatypes: - self.mass.tasks.add( - f"Library sync of albums for provider {provider.name}", - self.library_albums_sync, - prov_id, - periodic=periodic, - ) - if MediaType.TRACK in provider.supported_mediatypes: - self.mass.tasks.add( - f"Library sync of tracks for provider {provider.name}", - self.library_tracks_sync, - prov_id, - periodic=periodic, - ) - if MediaType.ARTIST in provider.supported_mediatypes: - self.mass.tasks.add( - f"Library sync of artists for provider {provider.name}", - self.library_artists_sync, - prov_id, - periodic=periodic, - ) - if MediaType.PLAYLIST in provider.supported_mediatypes: - self.mass.tasks.add( - f"Library sync of playlists for provider {provider.name}", - self.library_playlists_sync, - prov_id, - periodic=periodic, - ) - if MediaType.RADIO in provider.supported_mediatypes: - self.mass.tasks.add( - f"Library sync of radio for provider {provider.name}", - self.library_radios_sync, - prov_id, - periodic=periodic, - ) - - async def library_artists_sync(self, provider_id: str): - """Sync library artists for given provider.""" - music_provider = self.mass.get_provider(provider_id) - cache_key = f"library_artists_{provider_id}" - prev_db_ids = await self.mass.cache.get(cache_key, default=[]) - cur_db_ids = set() - for item in await music_provider.get_library_artists(): - db_item = await self.mass.music.get_artist( - item.item_id, provider_id, lazy=False - ) - cur_db_ids.add(db_item.item_id) - if not db_item.in_library: - await self.mass.database.add_to_library( - db_item.item_id, MediaType.ARTIST - ) - # process deletions - for db_id in prev_db_ids: - if db_id not in cur_db_ids: - await self.mass.database.remove_from_library(db_id, MediaType.ARTIST) - # store ids in cache for next sync - await self.mass.cache.set(cache_key, cur_db_ids) - - async def library_albums_sync(self, provider_id: str): - """Sync library albums for given provider.""" - music_provider = self.mass.get_provider(provider_id) - cache_key = f"library_albums_{provider_id}" - prev_db_ids = await self.mass.cache.get(cache_key, default=[]) - cur_db_ids = set() - for item in await music_provider.get_library_albums(): - db_album = await self.mass.music.get_album( - item.item_id, provider_id, lazy=False - ) - if not db_album.available and not item.available: - # album availability changed, sort this out with auto matching magic - db_album = await self.mass.music.match_album(db_album) - cur_db_ids.add(db_album.item_id) - if not db_album.in_library: - await self.mass.database.add_to_library( - db_album.item_id, MediaType.ALBUM - ) - # precache album tracks - await self.mass.music.get_album_tracks(item.item_id, provider_id) - # process album deletions - for db_id in prev_db_ids: - if db_id not in cur_db_ids: - await self.mass.database.remove_from_library(db_id, MediaType.ALBUM) - # store ids in cache for next sync - await self.mass.cache.set(cache_key, cur_db_ids) - - async def library_tracks_sync(self, provider_id: str): - """Sync library tracks for given provider.""" - music_provider = self.mass.get_provider(provider_id) - cache_key = f"library_tracks_{provider_id}" - prev_db_ids = await self.mass.cache.get(cache_key, default=[]) - cur_db_ids = set() - for item in await music_provider.get_library_tracks(): - db_item = await self.mass.music.get_track( - item.item_id, provider_id, track_details=item, lazy=False - ) - if not db_item.available and not item.available: - # track availability changed, sort this out with auto matching magic - db_item = await self.mass.music.add_track(item) - cur_db_ids.add(db_item.item_id) - if not db_item.in_library: - await self.mass.database.add_to_library( - db_item.item_id, MediaType.TRACK - ) - # process deletions - for db_id in prev_db_ids: - if db_id not in cur_db_ids: - await self.mass.database.remove_from_library(db_id, MediaType.TRACK) - # store ids in cache for next sync - await self.mass.cache.set(cache_key, cur_db_ids) - - async def library_playlists_sync(self, provider_id: str): - """Sync library playlists for given provider.""" - music_provider = self.mass.get_provider(provider_id) - cache_key = f"library_playlists_{provider_id}" - prev_db_ids = await self.mass.cache.get(cache_key, default=[]) - cur_db_ids = set() - for playlist in await music_provider.get_library_playlists(): - db_item = await self.mass.music.get_playlist( - playlist.item_id, provider_id, lazy=False - ) - if db_item.checksum != playlist.checksum: - db_item = await self.mass.database.add_playlist(playlist) - cur_db_ids.add(db_item.item_id) - await self.mass.database.add_to_library(db_item.item_id, MediaType.PLAYLIST) - - # process playlist deletions - for db_id in prev_db_ids: - if db_id not in cur_db_ids: - await self.mass.database.remove_from_library(db_id, MediaType.PLAYLIST) - # store ids in cache for next sync - await self.mass.cache.set(cache_key, cur_db_ids) - - async def library_radios_sync(self, provider_id: str): - """Sync library radios for given provider.""" - music_provider = self.mass.get_provider(provider_id) - cache_key = f"library_radios_{provider_id}" - prev_db_ids = await self.mass.cache.get(cache_key, default=[]) - cur_db_ids = set() - for item in await music_provider.get_library_radios(): - db_radio = await self.mass.music.get_radio( - item.item_id, provider_id, lazy=False - ) - cur_db_ids.add(db_radio.item_id) - await self.mass.database.add_to_library(db_radio.item_id, MediaType.RADIO) - # process deletions - for db_id in prev_db_ids: - if db_id not in cur_db_ids: - await self.mass.database.remove_from_library( - db_id, - MediaType.RADIO, - ) - # store ids in cache for next sync - await self.mass.cache.set(cache_key, cur_db_ids) diff --git a/music_assistant/managers/metadata.py b/music_assistant/managers/metadata.py deleted file mode 100755 index d60a05df..00000000 --- a/music_assistant/managers/metadata.py +++ /dev/null @@ -1,52 +0,0 @@ -"""All logic for metadata retrieval.""" - -import logging -from typing import Dict, List - -from music_assistant.helpers.cache import cached -from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.util import merge_dict -from music_assistant.models.provider import MetadataProvider, ProviderType - -LOGGER = logging.getLogger("metadata") - - -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: MusicAssistant) -> 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 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 - LOGGER.info( - "Fetching metadata for MusicBrainz Artist %s on provider %s", - mb_artist_id, - provider.name, - ) - cache_key = f"{provider.id}.artist_metadata.{mb_artist_id}" - res = await cached( - self.cache, cache_key, provider.get_artist_images, mb_artist_id - ) - if res: - metadata = merge_dict(metadata, res) - LOGGER.debug( - "Found metadata for MusicBrainz Artist %s on provider %s: %s", - mb_artist_id, - provider.name, - ", ".join(res.keys()), - ) - return metadata diff --git a/music_assistant/managers/music.py b/music_assistant/managers/music.py deleted file mode 100755 index e5245f66..00000000 --- a/music_assistant/managers/music.py +++ /dev/null @@ -1,890 +0,0 @@ -"""MusicManager: Orchestrates all data from music providers and sync to internal database.""" - -import asyncio -import logging -from typing import List, Set, Tuple - -from music_assistant.constants import ( - EVENT_ALBUM_ADDED, - EVENT_ARTIST_ADDED, - EVENT_PLAYLIST_ADDED, - EVENT_RADIO_ADDED, - EVENT_TRACK_ADDED, -) -from music_assistant.helpers.cache import cached -from music_assistant.helpers.compare import ( - compare_album, - compare_artists, - compare_strings, - compare_track, -) -from music_assistant.helpers.musicbrainz import MusicBrainz -from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.web import api_route -from music_assistant.managers.tasks import TaskInfo -from music_assistant.models.media_types import ( - Album, - AlbumType, - Artist, - FullAlbum, - ItemMapping, - MediaItem, - MediaType, - Playlist, - Radio, - SearchResult, - Track, -) -from music_assistant.models.provider import MusicProvider, ProviderType - -LOGGER = logging.getLogger("music_manager") - - -class MusicManager: - """Several helpers around the musicproviders.""" - - def __init__(self, mass: MusicAssistant): - """Initialize class.""" - self.mass = mass - self.cache = mass.cache - self.musicbrainz = MusicBrainz(mass) - self._db_add_progress = set() - - async def setup(self): - """Async initialize of module.""" - - @property - def providers(self) -> Tuple[MusicProvider]: - """Return all providers of type musicprovider.""" - return self.mass.get_providers(ProviderType.MUSIC_PROVIDER) - - ################ GET MediaItem(s) by id and provider ################# - - @api_route("artists/{provider_id}/{item_id}") - async def get_artist( - self, item_id: str, provider_id: str, refresh: bool = False, lazy: bool = True - ) -> Artist: - """Return artist details for the given provider artist id.""" - if provider_id == "database" and not refresh: - return await self.mass.database.get_artist(item_id) - db_item = await self.mass.database.get_artist_by_prov_id(provider_id, item_id) - if db_item and refresh: - provider_id, item_id = await self.__get_provider_id(db_item) - elif db_item: - return db_item - artist = await self._get_provider_artist(item_id, provider_id) - if not lazy: - return await self.add_artist(artist) - self.mass.tasks.add( - f"Add artist {artist.uri} to database", self.add_artist, artist - ) - return db_item if db_item else artist - - async def _get_provider_artist(self, item_id: str, provider_id: str) -> Artist: - """Return artist details for the given provider artist id.""" - provider = self.mass.get_provider(provider_id) - if not provider or not provider.available: - raise Exception("Provider %s is not available!" % provider_id) - cache_key = f"{provider_id}.get_artist.{item_id}" - artist = await cached(self.cache, cache_key, provider.get_artist, item_id) - if not artist: - raise Exception( - "Artist %s not found on provider %s" % (item_id, provider_id) - ) - return artist - - @api_route("albums/{provider_id}/{item_id}") - async def get_album( - self, item_id: str, provider_id: str, refresh: bool = False, lazy: bool = True - ) -> Album: - """Return album details for the given provider album id.""" - if provider_id == "database" and not refresh: - return await self.mass.database.get_album(item_id) - db_item = await self.mass.database.get_album_by_prov_id(provider_id, item_id) - if db_item and refresh: - provider_id, item_id = await self.__get_provider_id(db_item) - elif db_item: - return db_item - album = await self._get_provider_album(item_id, provider_id) - if not lazy: - return await self.add_album(album) - self.mass.tasks.add(f"Add album {album.uri} to database", self.add_album, album) - return db_item if db_item else album - - async def _get_provider_album(self, item_id: str, provider_id: str) -> Album: - """Return album details for the given provider album id.""" - provider = self.mass.get_provider(provider_id) - if not provider or not provider.available: - raise Exception("Provider %s is not available!" % provider_id) - cache_key = f"{provider_id}.get_album.{item_id}" - album = await cached(self.cache, cache_key, provider.get_album, item_id) - if not album: - raise Exception( - "Album %s not found on provider %s" % (item_id, provider_id) - ) - return album - - @api_route("tracks/{provider_id}/{item_id}") - async def get_track( - self, - item_id: str, - provider_id: str, - track_details: Track = None, - album_details: Album = None, - refresh: bool = False, - lazy: bool = True, - ) -> Track: - """Return track details for the given provider track id.""" - if provider_id == "database" and not refresh: - return await self.mass.database.get_track(item_id) - db_item = await self.mass.database.get_track_by_prov_id(provider_id, item_id) - if db_item and refresh: - provider_id, item_id = await self.__get_provider_id(db_item) - elif db_item: - return db_item - if not track_details: - track_details = await self._get_provider_track(item_id, provider_id) - if album_details: - track_details.album = album_details - if not lazy: - return await self.add_track(track_details) - self.mass.tasks.add( - f"Add track {track_details.uri} to database", self.add_track, track_details - ) - return db_item if db_item else track_details - - async def _get_provider_track(self, item_id: str, provider_id: str) -> Track: - """Return track details for the given provider track id.""" - provider = self.mass.get_provider(provider_id) - if not provider or not provider.available: - raise Exception("Provider %s is not available!" % provider_id) - cache_key = f"{provider_id}.get_track.{item_id}" - track = await cached(self.cache, cache_key, provider.get_track, item_id) - if not track: - raise Exception( - "Track %s not found on provider %s" % (item_id, provider_id) - ) - return track - - @api_route("playlists/{provider_id}/{item_id}") - async def get_playlist( - self, item_id: str, provider_id: str, refresh: bool = False, lazy: bool = True - ) -> Playlist: - """Return playlist details for the given provider playlist id.""" - assert item_id and provider_id - db_item = await self.mass.database.get_playlist_by_prov_id(provider_id, item_id) - if db_item and refresh: - provider_id, item_id = await self.__get_provider_id(db_item) - elif db_item: - return db_item - playlist = await self._get_provider_playlist(item_id, provider_id) - if not lazy: - return await self.add_playlist(playlist) - self.mass.tasks.add( - f"Add playlist {playlist.name} to database", self.add_playlist, playlist - ) - return db_item if db_item else playlist - - async def _get_provider_playlist(self, item_id: str, provider_id: str) -> Playlist: - """Return playlist details for the given provider playlist id.""" - provider = self.mass.get_provider(provider_id) - if not provider or not provider.available: - raise Exception("Provider %s is not available!" % provider_id) - cache_key = f"{provider_id}.get_playlist.{item_id}" - playlist = await cached( - self.cache, - cache_key, - provider.get_playlist, - item_id, - expires=86400 * 2, - ) - if not playlist: - raise Exception( - "Playlist %s not found on provider %s" % (item_id, provider_id) - ) - return playlist - - @api_route("radios/{provider_id}/{item_id}") - async def get_radio( - self, item_id: str, provider_id: str, refresh: bool = False, lazy: bool = True - ) -> Radio: - """Return radio details for the given provider radio id.""" - assert item_id and provider_id - db_item = await self.mass.database.get_radio_by_prov_id(provider_id, item_id) - if db_item and refresh: - provider_id, item_id = await self.__get_provider_id(db_item) - elif db_item: - return db_item - radio = await self._get_provider_radio(item_id, provider_id) - if not lazy: - return await self.add_radio(radio) - self.mass.tasks.add( - f"Add radio station {radio.name} to database", self.add_radio, radio - ) - return db_item if db_item else radio - - async def _get_provider_radio(self, item_id: str, provider_id: str) -> Radio: - """Return radio details for the given provider playlist id.""" - provider = self.mass.get_provider(provider_id) - if not provider or not provider.available: - raise Exception("Provider %s is not available!" % provider_id) - cache_key = f"{provider_id}.get_radio.{item_id}" - radio = await cached(self.cache, cache_key, provider.get_radio, item_id) - if not radio: - raise Exception( - "Radio %s not found on provider %s" % (item_id, provider_id) - ) - return radio - - @api_route("albums/{provider_id}/{item_id}/tracks") - async def get_album_tracks(self, item_id: str, provider_id: str) -> List[Track]: - """Return album tracks for the given provider album id.""" - assert item_id and provider_id - album = await self.get_album(item_id, provider_id) - if album.provider == "database": - # album tracks are not stored in db, we always fetch them (cached) from the provider. - prov_id = next(iter(album.provider_ids)) - provider_id = prov_id.provider - item_id = prov_id.item_id - provider = self.mass.get_provider(provider_id) - cache_key = f"{provider_id}.album_tracks.{item_id}" - all_prov_tracks = await cached( - self.cache, cache_key, provider.get_album_tracks, item_id - ) - # retrieve list of db items - db_tracks = await self.mass.database.get_tracks_from_provider_ids( - {x.provider for x in album.provider_ids}, - {x.item_id for x in all_prov_tracks}, - ) - # combine provider tracks with db tracks - return [ - await self.__process_item( - item, - db_tracks, - album=album, - disc_number=item.disc_number, - track_number=item.track_number, - ) - for item in all_prov_tracks - ] - - @api_route("albums/{provider_id}/{item_id}/versions") - async def get_album_versions(self, item_id: str, provider_id: str) -> Set[Album]: - """Return all versions of an album we can find on all providers.""" - album = await self.get_album(item_id, provider_id) - provider_ids = { - item.id for item in self.mass.get_providers(ProviderType.MUSIC_PROVIDER) - } - search_query = f"{album.artist.name} {album.name}" - return { - prov_item - for prov_items in await asyncio.gather( - *[ - self.search_provider(search_query, prov_id, [MediaType.ALBUM], 25) - for prov_id in provider_ids - ] - ) - for prov_item in prov_items.albums - if compare_strings(prov_item.artist.name, album.artist.name) - } - - @api_route("tracks/{provider_id}/{item_id}/versions") - async def get_track_versions(self, item_id: str, provider_id: str) -> Set[Track]: - """Return all versions of a track we can find on all providers.""" - track = await self.get_track(item_id, provider_id) - provider_ids = { - item.id for item in self.mass.get_providers(ProviderType.MUSIC_PROVIDER) - } - first_artist = next(iter(track.artists)) - search_query = f"{first_artist.name} {track.name}" - return { - prov_item - for prov_items in await asyncio.gather( - *[ - self.search_provider(search_query, prov_id, [MediaType.TRACK], 25) - for prov_id in provider_ids - ] - ) - for prov_item in prov_items.tracks - if compare_artists(prov_item.artists, track.artists) - } - - @api_route("playlists/{provider_id}/{item_id}/tracks") - async def get_playlist_tracks(self, item_id: str, provider_id: str) -> List[Track]: - """Return playlist tracks for the given provider playlist id.""" - assert item_id and provider_id - if provider_id == "database": - # playlist tracks are not stored in db, we always fetch them (cached) from the provider. - playlist = await self.mass.database.get_playlist(item_id) - prov_id = next(iter(playlist.provider_ids)) - provider_id = prov_id.provider - item_id = prov_id.item_id - provider = self.mass.get_provider(provider_id) - else: - provider = self.mass.get_provider(provider_id) - playlist = await provider.get_playlist(item_id) - cache_checksum = playlist.checksum - cache_key = f"{provider_id}.playlist_tracks.{item_id}" - return await cached( - self.cache, - cache_key, - provider.get_playlist_tracks, - item_id, - checksum=cache_checksum, - ) - - async def __process_item( - self, - item, - db_items, - index=None, - album=None, - disc_number=None, - track_number=None, - ): - """Return combined result of provider item and db result.""" - for db_item in db_items: - if item.item_id in {x.item_id for x in db_item.provider_ids}: - item = db_item - break - if index is not None and not item.position: - item.position = index - if album is not None: - item.album = album - if disc_number is not None: - item.disc_number = disc_number - if track_number is not None: - item.track_number = track_number - return item - - @api_route("artists/{provider_id}/{item_id}/tracks") - async def get_artist_toptracks(self, item_id: str, provider_id: str) -> Set[Track]: - """Return top tracks for an artist.""" - if provider_id != "database": - return await self._get_provider_artist_toptracks(item_id, provider_id) - - # db artist: get results from all providers - artist = await self.get_artist(item_id, provider_id) - all_prov_tracks = { - track - for prov_tracks in await asyncio.gather( - *[ - self._get_provider_artist_toptracks(item.item_id, item.provider) - for item in artist.provider_ids - ] - ) - for track in prov_tracks - } - # retrieve list of db items - db_tracks = await self.mass.database.get_tracks_from_provider_ids( - {x.provider for x in artist.provider_ids}, - {x.item_id for x in all_prov_tracks}, - ) - # combine provider tracks with db tracks and filter duplicate itemid's - return {await self.__process_item(item, db_tracks) for item in all_prov_tracks} - - async def _get_provider_artist_toptracks( - self, item_id: str, provider_id: str - ) -> List[Track]: - """Return top tracks for an artist on given provider.""" - provider = self.mass.get_provider(provider_id) - if not provider or not provider.available: - LOGGER.error("Provider %s is not available", provider_id) - return [] - cache_key = f"{provider_id}.artist_toptracks.{item_id}" - return await cached( - self.cache, - cache_key, - provider.get_artist_toptracks, - item_id, - ) - - @api_route("artists/{provider_id}/{item_id}/albums") - async def get_artist_albums(self, item_id: str, provider_id: str) -> Set[Album]: - """Return (all) albums for an artist.""" - if provider_id != "database": - return await self._get_provider_artist_albums(item_id, provider_id) - # db artist: get results from all providers - artist = await self.get_artist(item_id, provider_id) - all_prov_albums = { - album - for prov_albums in await asyncio.gather( - *[ - self._get_provider_artist_albums(item.item_id, item.provider) - for item in artist.provider_ids - ] - ) - for album in prov_albums - } - # retrieve list of db items - db_tracks = await self.mass.database.get_albums_from_provider_ids( - [x.provider for x in artist.provider_ids], - [x.item_id for x in all_prov_albums], - ) - # combine provider tracks with db tracks and filter duplicate itemid's - return {await self.__process_item(item, db_tracks) for item in all_prov_albums} - - async def _get_provider_artist_albums( - self, item_id: str, provider_id: str - ) -> List[Album]: - """Return albums for an artist on given provider.""" - provider = self.mass.get_provider(provider_id) - if not provider or not provider.available: - LOGGER.error("Provider %s is not available", provider_id) - return [] - cache_key = f"{provider_id}.artistalbums.{item_id}" - return await cached( - self.cache, - cache_key, - provider.get_artist_albums, - item_id, - ) - - @api_route("search/{provider_id}") - async def search_provider( - self, - search_query: str, - provider_id: str, - media_types: List[MediaType], - limit: int = 10, - ) -> SearchResult: - """ - Perform search on given provider. - - :param search_query: Search query - :param provider_id: provider_id of the provider to perform the search on. - :param media_types: A list of media_types to include. All types if None. - :param limit: number of items to return in the search (per type). - """ - if provider_id == "database": - # get results from database - return await self.mass.database.search(search_query, media_types) - provider = self.mass.get_provider(provider_id) - cache_key = f"{provider_id}.search.{search_query}.{media_types}.{limit}" - return await cached( - self.cache, - cache_key, - provider.search, - search_query, - media_types, - limit, - ) - - @api_route("search") - async def global_search( - self, search_query, media_types: List[MediaType], limit: int = 10 - ) -> SearchResult: - """ - Perform global search for media items on all providers. - - :param search_query: Search query. - :param media_types: A list of media_types to include. - :param limit: number of items to return in the search (per type). - """ - result = SearchResult([], [], [], [], []) - # include results from all music providers - provider_ids = ["database"] + [ - item.id for item in self.mass.get_providers(ProviderType.MUSIC_PROVIDER) - ] - for provider_id in provider_ids: - provider_result = await self.search_provider( - search_query, provider_id, media_types, limit - ) - result.artists += provider_result.artists - result.albums += provider_result.albums - result.tracks += provider_result.tracks - result.playlists += provider_result.playlists - result.radios += provider_result.radios - # TODO: sort by name and filter out duplicates ? - return result - - @api_route("items/by_uri") - async def get_item_by_uri(self, uri: str) -> MediaItem: - """Fetch MediaItem by uri.""" - if "://" in uri: - provider = uri.split("://")[0] - item_id = uri.split("/")[-1] - media_type = MediaType(uri.split("/")[-2]) - else: - # spotify new-style uri - provider, media_type, item_id = uri.split(":") - media_type = MediaType(media_type) - return await self.get_item(item_id, provider, media_type) - - @api_route("items/{media_type}/{provider_id}/{item_id}") - async def get_item( - self, - item_id: str, - provider_id: str, - media_type: MediaType, - refresh: bool = False, - lazy: bool = True, - ) -> MediaItem: - """Get single music item by id and media type.""" - if media_type == MediaType.ARTIST: - return await self.get_artist( - item_id, provider_id, refresh=refresh, lazy=lazy - ) - if media_type == MediaType.ALBUM: - return await self.get_album( - item_id, provider_id, refresh=refresh, lazy=lazy - ) - if media_type == MediaType.TRACK: - return await self.get_track( - item_id, provider_id, refresh=refresh, lazy=lazy - ) - if media_type == MediaType.PLAYLIST: - return await self.get_playlist( - item_id, provider_id, refresh=refresh, lazy=lazy - ) - if media_type == MediaType.RADIO: - return await self.get_radio( - item_id, provider_id, refresh=refresh, lazy=lazy - ) - return None - - @api_route("items/refresh", method="PUT") - async def refresh_items(self, items: List[MediaItem]) -> List[TaskInfo]: - """ - Refresh MediaItems to force retrieval of full info and matches. - - Creates background tasks to process the action. - """ - result = [] - for media_item in items: - job_desc = f"Refresh metadata of {media_item.uri}" - result.append(self.mass.tasks.add(job_desc, self.refresh_item, media_item)) - return result - - async def refresh_item( - self, - media_item: MediaItem, - ): - """Try to refresh a mediaitem by requesting it's full object or search for substitutes.""" - try: - return await self.get_item( - media_item.item_id, - media_item.provider, - media_item.media_type, - refresh=True, - lazy=False, - ) - except Exception: # pylint:disable=broad-except - pass - searchresult: SearchResult = await self.global_search( - media_item.name, [media_item.media_type], 20 - ) - for items in [ - searchresult.artists, - searchresult.albums, - searchresult.tracks, - searchresult.playlists, - searchresult.radios, - ]: - for item in items: - if item.available: - await self.get_item( - item.item_id, item.provider, item.media_type, lazy=False - ) - - ################ ADD MediaItem(s) to database helpers ################ - - async def add_artist(self, artist: Artist) -> Artist: - """Add artist to local db and return the database item.""" - if not artist.musicbrainz_id: - artist.musicbrainz_id = await self._get_artist_musicbrainz_id(artist) - # grab additional metadata - artist.metadata = await self.mass.metadata.get_artist_metadata( - artist.musicbrainz_id, artist.metadata - ) - db_item = await self.mass.database.add_artist(artist) - # also fetch same artist on all providers - await self.match_artist(db_item) - db_item = await self.mass.database.get_artist(db_item.item_id) - self.mass.eventbus.signal(EVENT_ARTIST_ADDED, db_item) - return db_item - - async def add_album(self, album: Album) -> Album: - """Add album to local db and return the database item.""" - # make sure we have an artist - assert album.artist - db_item = await self.mass.database.add_album(album) - # also fetch same album on all providers - await self.match_album(db_item) - db_item = await self.mass.database.get_album(db_item.item_id) - self.mass.eventbus.signal(EVENT_ALBUM_ADDED, db_item) - return db_item - - async def add_track(self, track: Track) -> Track: - """Add track to local db and return the new database item.""" - # make sure we have artists - assert track.artists - # make sure we have an album - assert track.album or track.albums - db_item = await self.mass.database.add_track(track) - # also fetch same track on all providers (will also get other quality versions) - await self.match_track(db_item) - db_item = await self.mass.database.get_track(db_item.item_id) - self.mass.eventbus.signal(EVENT_TRACK_ADDED, db_item) - return db_item - - async def add_playlist(self, playlist: Playlist) -> Playlist: - """Add playlist to local db and return the new database item.""" - db_item = await self.mass.database.add_playlist(playlist) - self.mass.eventbus.signal(EVENT_PLAYLIST_ADDED, db_item) - return db_item - - async def add_radio(self, radio: Radio) -> Radio: - """Add radio to local db and return the new database item.""" - db_item = await self.mass.database.add_radio(radio) - self.mass.eventbus.signal(EVENT_RADIO_ADDED, db_item) - return db_item - - async def _get_artist_musicbrainz_id(self, artist: Artist): - """Fetch musicbrainz id by performing search using the artist name, albums and tracks.""" - # try with album first - for lookup_album in await self._get_provider_artist_albums( - artist.item_id, artist.provider - ): - if not lookup_album: - continue - if artist.name != lookup_album.artist.name: - continue - musicbrainz_id = await self.musicbrainz.get_mb_artist_id( - artist.name, - albumname=lookup_album.name, - album_upc=lookup_album.upc, - ) - if musicbrainz_id: - return musicbrainz_id - # fallback to track - for lookup_track in await self._get_provider_artist_toptracks( - artist.item_id, artist.provider - ): - if not lookup_track: - continue - musicbrainz_id = await self.musicbrainz.get_mb_artist_id( - artist.name, - trackname=lookup_track.name, - track_isrc=lookup_track.isrc, - ) - if musicbrainz_id: - return musicbrainz_id - # lookup failed, use the shitty workaround to use the name as id. - LOGGER.warning("Unable to get musicbrainz ID for artist %s !", artist.name) - return artist.name - - async def match_artist(self, db_artist: Artist): - """ - Try to find matching artists on all providers for the provided (database) item_id. - - This is used to link objects of different providers together. - """ - assert ( - db_artist.provider == "database" - ), "Matching only supported for database items!" - cur_providers = [item.provider for item in db_artist.provider_ids] - for provider in self.mass.get_providers(ProviderType.MUSIC_PROVIDER): - if provider.id in cur_providers: - continue - if MediaType.ARTIST not in provider.supported_mediatypes: - continue - if not await self._match_prov_artist(db_artist, provider): - LOGGER.debug( - "Could not find match for Artist %s on provider %s", - db_artist.name, - provider.name, - ) - - async def _match_prov_artist(self, db_artist: Artist, provider: MusicProvider): - """Try to find matching artists on given provider for the provided (database) artist.""" - LOGGER.debug( - "Trying to match artist %s on provider %s", db_artist.name, provider.name - ) - # try to get a match with some reference tracks of this artist - for ref_track in await self.get_artist_toptracks( - db_artist.item_id, db_artist.provider - ): - # make sure we have a full track - if isinstance(ref_track.album, ItemMapping): - ref_track = await self.get_track(ref_track.item_id, ref_track.provider) - searchstr = "%s %s" % (db_artist.name, ref_track.name) - search_results = await self.search_provider( - searchstr, provider.id, [MediaType.TRACK], limit=25 - ) - for search_result_item in search_results.tracks: - if compare_track(search_result_item, ref_track): - # get matching artist from track - for search_item_artist in search_result_item.artists: - if compare_strings(db_artist.name, search_item_artist.name): - # 100% album match - # get full artist details so we have all metadata - prov_artist = await self._get_provider_artist( - search_item_artist.item_id, search_item_artist.provider - ) - await self.mass.database.update_artist( - db_artist.item_id, prov_artist - ) - return True - # try to get a match with some reference albums of this artist - artist_albums = await self.get_artist_albums( - db_artist.item_id, db_artist.provider - ) - for ref_album in artist_albums: - if ref_album.album_type == AlbumType.COMPILATION: - continue - searchstr = "%s %s" % (db_artist.name, ref_album.name) - search_result = await self.search_provider( - searchstr, provider.id, [MediaType.ALBUM], limit=25 - ) - for search_result_item in search_result.albums: - # artist must match 100% - if not compare_strings(db_artist.name, search_result_item.artist.name): - continue - if compare_album(search_result_item, ref_album): - # 100% album match - # get full artist details so we have all metadata - prov_artist = await self._get_provider_artist( - search_result_item.artist.item_id, - search_result_item.artist.provider, - ) - await self.mass.database.update_artist( - db_artist.item_id, prov_artist - ) - return True - return False - - async def match_album(self, db_album: Album): - """ - Try to find matching album on all providers for the provided (database) album_id. - - This is used to link objects of different providers/qualities together. - """ - assert ( - db_album.provider == "database" - ), "Matching only supported for database items!" - if not isinstance(db_album, FullAlbum): - # matching only works if we have a full album object - db_album = await self.mass.database.get_album(db_album.item_id) - - async def find_prov_match(provider): - LOGGER.debug( - "Trying to match album %s on provider %s", db_album.name, provider.name - ) - match_found = False - searchstr = "%s %s" % (db_album.artist.name, db_album.name) - if db_album.version: - searchstr += " " + db_album.version - search_result = await self.search_provider( - searchstr, provider.id, [MediaType.ALBUM], limit=25 - ) - for search_result_item in search_result.albums: - if not search_result_item.available: - continue - if not compare_album(search_result_item, db_album): - continue - # we must fetch the full album version, search results are simplified objects - prov_album = await self._get_provider_album( - search_result_item.item_id, search_result_item.provider - ) - if compare_album(prov_album, db_album): - # 100% match, we can simply update the db with additional provider ids - await self.mass.database.update_album(db_album.item_id, prov_album) - match_found = True - # while we're here, also match the artist - if db_album.artist.provider == "database": - prov_artist = await self._get_provider_artist( - prov_album.artist.item_id, prov_album.artist.provider - ) - await self.mass.database.update_artist( - db_album.artist.item_id, prov_artist - ) - - # no match found - if not match_found: - LOGGER.debug( - "Could not find match for Album %s on provider %s", - db_album.name, - provider.name, - ) - - # try to find match on all providers - providers = self.mass.get_providers(ProviderType.MUSIC_PROVIDER) - for provider in providers: - if MediaType.ALBUM in provider.supported_mediatypes: - await find_prov_match(provider) - - async def match_track(self, db_track: Track): - """ - Try to find matching track on all providers for the provided (database) track_id. - - This is used to link objects of different providers/qualities together. - """ - assert ( - db_track.provider == "database" - ), "Matching only supported for database items!" - if isinstance(db_track.album, ItemMapping): - # matching only works if we have a full track object - db_track = await self.mass.database.get_track(db_track.item_id) - for provider in self.mass.get_providers(ProviderType.MUSIC_PROVIDER): - if MediaType.TRACK not in provider.supported_mediatypes: - continue - LOGGER.debug( - "Trying to match track %s on provider %s", db_track.name, provider.name - ) - match_found = False - for db_track_artist in db_track.artists: - if match_found: - break - searchstr = "%s %s" % (db_track_artist.name, db_track.name) - if db_track.version: - searchstr += " " + db_track.version - search_result = await self.search_provider( - searchstr, provider.id, [MediaType.TRACK], limit=25 - ) - for search_result_item in search_result.tracks: - if not search_result_item.available: - continue - if compare_track(search_result_item, db_track): - # 100% match, we can simply update the db with additional provider ids - match_found = True - await self.mass.database.update_track( - db_track.item_id, search_result_item - ) - # while we're here, also match the artist - if db_track_artist.provider == "database": - for artist in search_result_item.artists: - if not compare_strings( - db_track_artist.name, artist.name - ): - continue - prov_artist = await self._get_provider_artist( - artist.item_id, artist.provider - ) - await self.mass.database.update_artist( - db_track_artist.item_id, prov_artist - ) - - if not match_found: - LOGGER.debug( - "Could not find match for Track %s on provider %s", - db_track.name, - provider.name, - ) - - async def __get_provider_id(self, media_item: MediaItem) -> tuple: - """Return provider and item id.""" - if media_item.provider == "database": - media_item = await self.mass.database.get_item_by_prov_id( - "database", media_item.item_id, media_item.media_type - ) - for prov in media_item.provider_ids: - if prov.available and self.mass.get_provider(prov.provider): - provider = self.mass.get_provider(prov.provider) - if provider and provider.available: - return (prov.provider, prov.item_id) - else: - provider = self.mass.get_provider(media_item.provider) - if provider and provider.available: - return (media_item.provider, media_item.item_id) - return None, None diff --git a/music_assistant/managers/players.py b/music_assistant/managers/players.py deleted file mode 100755 index 7b0c45ee..00000000 --- a/music_assistant/managers/players.py +++ /dev/null @@ -1,845 +0,0 @@ -"""PlayerManager: Orchestrates all players from player providers.""" - -import asyncio -import logging -import pathlib -from typing import Dict, List, Optional, Set, Tuple, Union - -from music_assistant.constants import ( - CONF_CROSSFADE_DURATION, - CONF_POWER_CONTROL, - CONF_VOLUME_CONTROL, - EVENT_PLAYER_ADDED, - EVENT_PLAYER_REMOVED, -) -from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.util import callback, create_task, try_parse_int -from music_assistant.helpers.web import api_route -from music_assistant.models.media_types import MediaItem, MediaType -from music_assistant.models.player import ( - Player, - PlayerControl, - PlayerControlType, - PlayerState, -) -from music_assistant.models.player_queue import PlayerQueue, QueueItem, QueueOption -from music_assistant.models.provider import PlayerProvider, ProviderType - -POLL_INTERVAL = 30 - -LOGGER = logging.getLogger("player_manager") -RESOURCES_DIR = ( - pathlib.Path(__file__).parent.resolve().parent.resolve().joinpath("resources") -) -ALERT_ANNOUNCE_FILE = str(RESOURCES_DIR.joinpath("announce.flac")) -ALERT_FINISH_FILE = str(RESOURCES_DIR.joinpath("silence.flac")) - - -class PlayerManager: - """Several helpers to handle playback through player providers.""" - - def __init__(self, mass: MusicAssistant) -> None: - """Initialize class.""" - self.mass = mass - self._players = {} - self._providers = {} - self._player_queues = {} - self._controls = {} - self._alerts_in_progress = set() - - async def setup(self) -> None: - """Async initialize of module.""" - asyncio.create_task(self.poll_task()) - - async def close(self) -> None: - """Handle stop/shutdown.""" - for player_queue in self._player_queues.values(): - await player_queue.close() - for player in self: - await player.on_remove() - - async def poll_task(self): - """Check for updates on players that need to be polled.""" - count = 0 - while True: - for player in self: - if not player.calculated_state.available: - continue - if not player.should_poll: - continue - if player.state == PlayerState.PLAYING or count == POLL_INTERVAL: - await player.on_poll() - if count == POLL_INTERVAL: - count = 0 - else: - count += 1 - await asyncio.sleep(1) - - @property - def players(self) -> Dict[str, Player]: - """Return dict of all registered players.""" - return self._players - - @property - def player_queues(self) -> Dict[str, PlayerQueue]: - """Return dict of all player queues.""" - return self._player_queues - - @property - def providers(self) -> Tuple[PlayerProvider]: - """Return tuple with all loaded player providers.""" - return self.mass.get_providers(ProviderType.PLAYER_PROVIDER) - - def __iter__(self): - """Iterate over players.""" - return iter(self._players.values()) - - @callback - @api_route("players") - def get_players(self) -> Tuple[Player]: - """Return all players in a tuple.""" - return tuple(self._players.values()) - - @callback - @api_route("queues") - def get_player_queues(self) -> Tuple[PlayerQueue]: - """Return all player queues in a tuple.""" - return tuple(self._player_queues.values()) - - @callback - @api_route("players/{player_id}") - def get_player(self, player_id: str, raise_not_found: bool = False) -> Player: - """Return Player by player_id.""" - player = self._players.get(player_id) - if not player and raise_not_found: - raise FileNotFoundError("Player not found %s" % player_id) - return player - - @callback - def get_player_by_name( - self, name: str, provider_id: Optional[str] = None - ) -> Optional[Player]: - """Return Player by name or None if no match is found.""" - for player in self: - if provider_id is not None and player.provider_id != provider_id: - continue - if name in (player.name, player.calculated_state.name): - return player - return None - - @callback - def get_player_provider(self, player_id: str) -> PlayerProvider: - """Return provider by player_id or None if player does not exist.""" - player = self.get_player(player_id) - return self.mass.get_provider(player.provider_id) if player else None - - @callback - @api_route("queues/{queue_id}") - def get_player_queue(self, queue_id: str) -> PlayerQueue: - """Return player Queue by queue id or None if queue does not exist.""" - queue = self._player_queues.get(queue_id) - if not queue: - LOGGER.warning("Player(queue) %s is not available!", queue_id) - return None - return queue - - @callback - @api_route("players/{player_id}/queue") - def get_active_player_queue( - self, player_id: str, raise_not_found: bool = True - ) -> PlayerQueue: - """Return the active queue for given player id.""" - player = self.get_player(player_id, raise_not_found) - if player: - return self.get_player_queue(player.calculated_state.active_queue) - return None - - @callback - @api_route("queues/{queue_id}/items") - def get_player_queue_items(self, queue_id: str) -> Set[QueueItem]: - """Return player's queueitems by player_id.""" - player_queue = self.get_player_queue(queue_id) - return player_queue.items if player_queue else {} - - @callback - @api_route("players/controls/{control_id}") - def get_player_control(self, control_id: str) -> PlayerControl: - """Return PlayerControl by id.""" - if control_id not in self._controls: - LOGGER.warning("PlayerControl %s is not available", control_id) - return None - return self._controls[control_id] - - @callback - @api_route("players/controls") - def get_player_controls( - self, filter_type: Optional[PlayerControlType] = None - ) -> Set[PlayerControl]: - """Return all PlayerControls, optionally filtered by type.""" - return { - item - for item in self._controls.values() - if (filter_type is None or item.type == filter_type) - } - - # ADD/REMOVE/UPDATE HELPERS - - async def add_player(self, player: Player) -> None: - """Register a new player or update an existing one.""" - player_id = player.player_id - - # guard for invalid data or exit in progress - if not player or self.mass.exit: - return - - # redirect to update if player is already added - if player_id in self._players: - player = self._players[player_id] - if player.added_to_mass: - await self.trigger_player_update(player_id) - return - else: - self._players[player.player_id] = player - # make sure that the mass instance is set on the player - player.mass = self.mass - - # make sure that the player state is created/updated - player.calculated_state.update(player.create_calculated_state()) - - # Fully initialize only if player is enabled - if not player.enabled: - LOGGER.debug( - "Ignoring player: %s/%s because it's disabled", - player.provider_id, - player.name, - ) - return - - # new player - player.added_to_mass = True - await player.on_add() - # create playerqueue instance - self._player_queues[player.player_id] = PlayerQueue(self.mass, player.player_id) - LOGGER.info( - "Player added: %s/%s", - player.provider_id, - player.name, - ) - self.mass.eventbus.signal(EVENT_PLAYER_ADDED, player.calculated_state) - - async def remove_player(self, player_id: str): - """Remove a player from the registry.""" - self._player_queues.pop(player_id, None) - player = self._players.pop(player_id, None) - if player: - await player.on_remove() - player_name = player.name if player else player_id - LOGGER.info("Player removed: %s", player_name) - self.mass.eventbus.signal(EVENT_PLAYER_REMOVED, {"player_id": player_id}) - - async def trigger_player_update(self, player_id: str): - """Trigger update of an existing player..""" - player = self.get_player(player_id, False) - if player: - await player.on_poll() - - @api_route("players/controls/{control_id}", method="POST") - async def register_player_control(self, control_id: str, control: PlayerControl): - """Register a playercontrol with the player manager.""" - control.mass = self.mass - self._controls[control_id] = control - LOGGER.info( - "New PlayerControl (%s) registered: %s\\%s", - control.type, - control.provider, - control.name, - ) - # update all players using this playercontrol - for player in self: - conf = self.mass.config.player_settings[player.player_id] - if control_id in [ - conf.get(CONF_POWER_CONTROL), - conf.get(CONF_VOLUME_CONTROL), - ]: - create_task(self.trigger_player_update(player.player_id)) - - @api_route("players/controls/{control_id}", method="PUT") - async def update_player_control(self, control_id: str, control: PlayerControl): - """Update a playercontrol's state on the player manager.""" - if control_id not in self._controls: - return await self.register_player_control(control_id, control) - new_state = control.state - if self._controls[control_id].state == new_state: - return - self._controls[control_id].state = new_state - LOGGER.debug( - "PlayerControl %s\\%s updated - new state: %s", - control.provider, - control.name, - new_state, - ) - # update all players using this playercontrol - for player in self: - conf = self.mass.config.player_settings[player.player_id] - if control_id in [ - conf.get(CONF_POWER_CONTROL), - conf.get(CONF_VOLUME_CONTROL), - ]: - create_task(self.trigger_player_update(player.player_id)) - - # SERVICE CALLS / PLAYER COMMANDS - - @api_route("players/{player_id}/play_media", method="PUT") - async def play_media( - self, - player_id: str, - items: Union[MediaItem, List[MediaItem]], - queue_opt: QueueOption = QueueOption.PLAY, - ): - """ - Play media item(s) on the given player. - - :param player_id: player_id of the player to handle the command. - :param items: media item(s) that should be played (single item or list of items) - :param queue_opt: - QueueOption.PLAY -> Insert new items in queue and start playing at inserted position - QueueOption.REPLACE -> Replace queue contents with these items - QueueOption.NEXT -> Play item(s) after current playing item - QueueOption.ADD -> Append new items at end of the queue - """ - player = self.get_player(player_id, True) - player_queue = self.get_active_player_queue(player_id, True) - # power on player if needed - if not player.calculated_state.powered: - await self.cmd_power_on(player_id) - # a single item or list of items may be provided - if not isinstance(items, list): - items = [items] - queue_items = [] - for media_item in items: - # collect tracks to play - if media_item.media_type == MediaType.ARTIST: - tracks = await self.mass.music.get_artist_toptracks( - media_item.item_id, provider_id=media_item.provider - ) - elif media_item.media_type == MediaType.ALBUM: - tracks = await self.mass.music.get_album_tracks( - media_item.item_id, provider_id=media_item.provider - ) - elif media_item.media_type == MediaType.PLAYLIST: - tracks = await self.mass.music.get_playlist_tracks( - media_item.item_id, provider_id=media_item.provider - ) - elif media_item.media_type == MediaType.RADIO: - # single radio - tracks = [ - await self.mass.music.get_radio( - media_item.item_id, provider_id=media_item.provider - ) - ] - else: - # single track - tracks = [ - await self.mass.music.get_track( - media_item.item_id, provider_id=media_item.provider - ) - ] - for track in tracks: - if not track.available: - continue - queue_item = player_queue.create_queue_item(track) - queue_items.append(queue_item) - - # load items into the queue - if queue_opt == QueueOption.REPLACE: - return await player_queue.load(queue_items) - if queue_opt in [QueueOption.PLAY, QueueOption.NEXT] and len(queue_items) > 100: - return await player_queue.load(queue_items) - if queue_opt == QueueOption.NEXT: - return await player_queue.insert(queue_items, 1) - if queue_opt == QueueOption.PLAY: - return await player_queue.insert(queue_items, 0) - if queue_opt == QueueOption.ADD: - return await player_queue.append(queue_items) - - @api_route("players/{player_id}/play_uri", method="PUT") - async def play_uri( - self, player_id: str, uri: str, queue_opt: QueueOption = QueueOption.PLAY - ): - """ - Play the specified uri/url on the given player. - - Will create a fake track on the queue. - - :param player_id: player_id of the player to handle the command. - :param uri: Url/Uri that can be played by a player. - """ - # try media uri first - if not uri.startswith("http"): - item = await self.mass.music.get_item_by_uri(uri) - if item: - return await self.play_media(player_id, item, queue_opt) - raise FileNotFoundError("Invalid uri: %s" % uri) - player = self.get_player(player_id, True) - player_queue = self.get_active_player_queue(player_id, True) - # power on player if needed - if not player.calculated_state.powered: - await self.cmd_power_on(player_id) - # load item into the queue - queue_item = player_queue.create_queue_item( - item_id=uri, provider="url", name=uri, uri=uri - ) - if queue_opt == QueueOption.REPLACE: - return await player_queue.load([queue_item]) - if queue_opt == QueueOption.NEXT: - return await player_queue.insert([queue_item], 1) - if queue_opt == QueueOption.PLAY: - return await player_queue.insert([queue_item], 0) - if queue_opt == QueueOption.ADD: - return await player_queue.append([queue_item]) - - @api_route("players/{player_id}/play_alert", method="PUT") - async def play_alert( - self, - player_id: str, - url: str, - volume: Optional[int] = None, - force: bool = True, - announce: bool = False, - ): - """ - Play alert (e.g. tts message) on selected player. - - Will pause the current playing queue and resume after the alert is played. - - :param player_id: player_id of the player to handle the command. - :param url: Url to the sound effect/tts message that should be played. - :param volume: Force volume of player to this level during the alert. - :param force: Play alert even if player is currently powered off. - :param announce: Announce the alert by prepending an alert sound. - """ - player = self.get_player(player_id, True) - player_queue = self.get_active_player_queue(player_id) - if player_queue.queue_id in self._alerts_in_progress: - LOGGER.debug( - "Ignoring Play Alert for queue %s - Another alert is already in progress.", - player_queue.queue_id, - ) - return - self._alerts_in_progress.add(player_queue.queue_id) - prev_state = player_queue.state - prev_power = player.calculated_state.powered - prev_volume = player.calculated_state.volume_level - prev_repeat = player_queue.repeat_enabled - if not player.calculated_state.powered: - if not force: - LOGGER.debug( - "Ignore alert playback: Player %s is powered off.", - player.calculated_state.name, - ) - return - # power on player if needed - if not player.calculated_state.powered: - await self.cmd_power_on(player_id) - # snapshot the (active) queue - prev_queue_items = player_queue.items - prev_queue_index = player_queue.cur_index - prev_queue_crossfade = self.mass.config.get_player_config( - player_queue.queue_id - )[CONF_CROSSFADE_DURATION] - - # pause playback - if prev_state == PlayerState.PLAYING: - await self.cmd_pause(player_queue.queue_id) - # disable crossfade and repeat if needed - if prev_queue_crossfade: - self.mass.config.player_settings[player_queue.queue_id][ - CONF_CROSSFADE_DURATION - ] = 0 - if prev_repeat: - await player_queue.set_repeat_enabled(False) - # set alert volume - if volume: - await self.cmd_volume_set(player_id, volume) - # load alert items in player queue - queue_items = [] - if announce: - queue_items.append( - player_queue.create_queue_item( - item_id="alert_announce", - provider="url", - name="alert_announce", - uri=ALERT_ANNOUNCE_FILE, - ) - ) - queue_items.append( - player_queue.create_queue_item( - item_id="alert", provider="url", name="alert", uri=url - ) - ) - queue_items.append( - # add a special (silent) file so we can detect finishing of the alert - player_queue.create_queue_item( - item_id="alert_finish", - provider="url", - name="alert_finish", - uri=ALERT_FINISH_FILE, - ) - ) - # load queue items - await player_queue.load(queue_items) - - # add listener when playback of alert finishes - async def restore_queue(): - count = 0 - while count < 30: - if ( - player_queue.cur_item == queue_items[-1] - and player_queue.cur_item_time > 2 - ): - break - count += 1 - await asyncio.sleep(1) - # restore queue - if volume: - await self.cmd_volume_set(player_id, prev_volume) - if prev_queue_crossfade: - self.mass.config.player_settings[player_queue.queue_id][ - CONF_CROSSFADE_DURATION - ] = prev_queue_crossfade - await player_queue.set_repeat_enabled(prev_repeat) - # pylint: disable=protected-access - player_queue._items = prev_queue_items - player_queue._cur_index = prev_queue_index - if prev_state == PlayerState.PLAYING: - await player_queue.resume() - if not prev_power: - await self.cmd_power_off(player_id) - self._alerts_in_progress.remove(player_queue.queue_id) - player_queue.signal_update() - - create_task(restore_queue) - - @api_route("players/{player_id}/cmd/stop", method="PUT") - async def cmd_stop(self, player_id: str) -> None: - """ - Send STOP command to given player. - - :param player_id: player_id of the player to handle the command. - """ - player_queue = self.get_active_player_queue(player_id) - await player_queue.stop() - - @api_route("players/{player_id}/cmd/play", method="PUT") - async def cmd_play(self, player_id: str) -> None: - """ - Send PLAY command to given player. - - :param player_id: player_id of the player to handle the command. - """ - player = self.get_player(player_id, True) - player_queue = self.get_active_player_queue(player_id) - # power on player if needed - if not player.calculated_state.powered: - await self.cmd_power_on(player_id) - # unpause if paused else resume queue - if player_queue.state == PlayerState.PAUSED: - await player_queue.play() - else: - await player_queue.resume() - - @api_route("players/{player_id}/cmd/pause", method="PUT") - async def cmd_pause(self, player_id: str) -> None: - """ - Send PAUSE command to given player. - - :param player_id: player_id of the player to handle the command. - """ - player_queue = self.get_active_player_queue(player_id, True) - await player_queue.pause() - - @api_route("players/{player_id}/cmd/play_pause", method="PUT") - async def cmd_play_pause(self, player_id: str) -> None: - """ - Toggle play/pause on given player. - - :param player_id: player_id of the player to handle the command. - """ - player_queue = self.get_active_player_queue(player_id, True) - if player_queue.state == PlayerState.PLAYING: - await self.cmd_pause(player_queue.queue_id) - else: - await self.cmd_play(player_queue.queue_id) - - @api_route("players/{player_id}/cmd/next", method="PUT") - async def cmd_next(self, player_id: str) -> None: - """ - Send NEXT TRACK command to given player. - - :param player_id: player_id of the player to handle the command. - """ - player_queue = self.get_active_player_queue(player_id, True) - if player_queue.state == PlayerState.PLAYING: - await player_queue.next() - else: - await self.cmd_play(player_queue.queue_id) - - @api_route("players/{player_id}/cmd/previous", method="PUT") - async def cmd_previous(self, player_id: str): - """ - Send PREVIOUS TRACK command to given player. - - :param player_id: player_id of the player to handle the command. - """ - player_queue = self.get_active_player_queue(player_id, True) - if player_queue.state == PlayerState.PLAYING: - await player_queue.previous() - else: - await self.cmd_play(player_queue.queue_id) - - @api_route("players/{player_id}/cmd/power_on", method="PUT") - async def cmd_power_on(self, player_id: str) -> None: - """ - Send POWER ON command to given player. - - :param player_id: player_id of the player to handle the command. - """ - player = self.get_player(player_id) - if not player: - return - player_config = self.mass.config.player_settings[player.player_id] - # turn on player - await player.cmd_power_on() - # player control support - if player_config.get(CONF_POWER_CONTROL): - control = self.get_player_control(player_config[CONF_POWER_CONTROL]) - if control: - await control.set_state(True) - - @api_route("players/{player_id}/cmd/power_off", method="PUT") - async def cmd_power_off(self, player_id: str) -> None: - """ - Send POWER OFF command to given player. - - :param player_id: player_id of the player to handle the command. - """ - player = self.get_player(player_id, True) - # send stop if player is active queue - if player.active_queue == player_id and player.state not in [ - PlayerState.OFF, - PlayerState.IDLE, - ]: - await self.cmd_stop(player_id) - player_config = self.mass.config.player_settings[player.player_id] - # turn off player - await player.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.set_state(False) - # handle group power - if player.is_group_player: - # player is group, turn off all childs - for child_player_id in player.group_childs: - child_player = self.get_player(child_player_id) - if child_player and child_player.calculated_state.powered: - create_task(self.cmd_power_off(child_player_id)) - else: - # if this was the last powered player in the group, turn off group - for parent_player_id in player.group_parents: - parent_player = self.get_player(parent_player_id) - if not parent_player or not parent_player.calculated_state.powered: - continue - has_powered_players = False - for child_player_id in parent_player.group_childs: - if child_player_id == player_id: - continue - child_player = self.get_player(child_player_id) - if child_player and child_player.calculated_state.powered: - has_powered_players = True - if not has_powered_players: - create_task(self.cmd_power_off(parent_player_id)) - - @api_route("players/{player_id}/cmd/power_toggle", method="PUT") - async def cmd_power_toggle(self, player_id: str): - """ - Send POWER TOGGLE command to given player. - - :param player_id: player_id of the player to handle the command. - """ - player = self.get_player(player_id, True) - if player.calculated_state.powered: - return await self.cmd_power_off(player_id) - return await self.cmd_power_on(player_id) - - @api_route("players/{player_id}/cmd/volume_set", method="PUT") - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """ - Send volume level command to given player. - - :param player_id: player_id of the player to handle the command. - :param volume_level: volume level to set (0..100). - """ - player = self.get_player(player_id, True) - player_config = self.mass.config.player_settings[player.player_id] - volume_level = try_parse_int(volume_level) - if volume_level < 0: - volume_level = 0 - elif volume_level > 100: - volume_level = 100 - # player control support - if player_config.get(CONF_VOLUME_CONTROL): - control = self.get_player_control(player_config[CONF_VOLUME_CONTROL]) - if control: - await control.set_state(volume_level) - # just force full volume on actual player if volume is outsourced to volumecontrol - await player.cmd_volume_set(100) - # handle group volume - elif player.is_group_player: - cur_volume = player.volume_level - new_volume = volume_level - volume_dif = new_volume - cur_volume - if cur_volume == 0: - volume_dif_percent = 1 + (new_volume / 100) - else: - volume_dif_percent = volume_dif / cur_volume - for child_player_id in player.group_childs: - if child_player_id == player_id: - continue - child_player = self.get_player(child_player_id) - if ( - child_player - and child_player.available - and child_player.calculated_state.powered - ): - cur_child_volume = child_player.volume_level - new_child_volume = cur_child_volume + ( - cur_child_volume * volume_dif_percent - ) - await self.cmd_volume_set(child_player_id, new_child_volume) - # regular volume command - else: - await player.cmd_volume_set(volume_level) - - @api_route("players/{player_id}/cmd/volume_up", method="PUT") - async def cmd_volume_up(self, player_id: str): - """ - Send volume UP command to given player. - - :param player_id: player_id of the player to handle the command. - """ - player = self.get_player(player_id, True) - if player.volume_level <= 10 or player.volume_level >= 90: - step_size = 2 - else: - step_size = 5 - new_level = player.volume_level + step_size - if new_level > 100: - new_level = 100 - return await self.cmd_volume_set(player_id, new_level) - - @api_route("players/{player_id}/cmd/volume_down", method="PUT") - async def cmd_volume_down(self, player_id: str): - """ - Send volume DOWN command to given player. - - :param player_id: player_id of the player to handle the command. - """ - player = self.get_player(player_id, True) - if player.volume_level <= 10 or player.volume_level >= 90: - step_size = 2 - else: - step_size = 5 - new_level = player.volume_level - step_size - if new_level < 0: - new_level = 0 - return await self.cmd_volume_set(player_id, new_level) - - @api_route("players/{player_id}/cmd/volume_mute", method="PUT") - async def cmd_volume_mute(self, player_id: str, is_muted: bool = False): - """ - Send MUTE command to given player. - - :param player_id: player_id of the player to handle the command. - :param is_muted: bool with the new mute state. - """ - player = self.get_player(player_id, True) - # TODO: handle mute on volumecontrol? - return await player.cmd_volume_mute(is_muted) - - @api_route("queues/{queue_id}", method="PUT") - async def player_queue_update( - self, - queue_id: str, - enable_shuffle: Optional[bool] = None, - enable_repeat: Optional[bool] = None, - ) -> None: - """Set options to given playerqueue.""" - player_queue = self.get_player_queue(queue_id) - if not player_queue: - raise FileNotFoundError("Unknown Queue: %s" % queue_id) - if enable_shuffle is not None: - await player_queue.set_shuffle_enabled(enable_shuffle) - if enable_repeat is not None: - await player_queue.set_repeat_enabled(enable_repeat) - - @api_route("queues/{queue_id}/cmd/next", method="PUT") - async def player_queue_cmd_next(self, queue_id: str): - """ - Send next track command to given playerqueue. - - :param queue_id: player_id of the playerqueue to handle the command. - """ - player_queue = self.get_player_queue(queue_id) - if not player_queue: - return - return await player_queue.next() - - @api_route("queues/{queue_id}/cmd/previous", method="PUT") - async def player_queue_cmd_previous(self, queue_id: str): - """ - Send previous track command to given playerqueue. - - :param queue_id: player_id of the playerqueue to handle the command. - """ - player_queue = self.get_player_queue(queue_id) - if not player_queue: - return - return await player_queue.previous() - - @api_route("queues/{queue_id}/cmd/move", method="PUT") - async def player_queue_cmd_move_item( - self, queue_id: str, queue_item_id: str, pos_shift: int = 1 - ): - """ - Move queue item x up/down the queue. - - param pos_shift: move item x positions down if positive value - move item x positions up if negative value - move item to top of queue as next item if 0 - """ - player_queue = self.get_player_queue(queue_id) - if not player_queue: - return - return await player_queue.move_item(queue_item_id, pos_shift) - - @api_route("queues/{queue_id}/cmd/play_index", method="PUT") - async def play_index(self, queue_id: str, index: Union[int, str]) -> None: - """Play item at index (or item_id) X in queue.""" - player_queue = self.get_player_queue(queue_id) - if not player_queue: - return - return await player_queue.play_index(index) - - @api_route("queues/{queue_id}/items", method="DELETE") - async def player_queue_cmd_clear(self, queue_id: str): - """ - Clear all items in player's queue. - - :param queue_id: player_id of the playerqueue to handle the command. - """ - player_queue = self.get_player_queue(queue_id) - if not player_queue: - return - return await player_queue.clear() diff --git a/music_assistant/managers/tasks.py b/music_assistant/managers/tasks.py deleted file mode 100644 index 583c1be0..00000000 --- a/music_assistant/managers/tasks.py +++ /dev/null @@ -1,186 +0,0 @@ -"""Logic to process tasks on the event loop.""" - -import asyncio -import logging -from asyncio.futures import Future -from enum import IntEnum -from typing import Any, Awaitable, Callable, Dict, List, Optional, Union -from uuid import uuid4 - -from music_assistant.constants import EVENT_TASK_UPDATED -from music_assistant.helpers.datetime import now -from music_assistant.helpers.muli_state_queue import MultiStateQueue -from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.util import create_task -from music_assistant.helpers.web import api_route - -LOGGER = logging.getLogger("task_manager") - -MAX_SIMULTANEOUS_TASKS = 2 - - -class TaskStatus(IntEnum): - """Enum for Task status.""" - - PENDING = 0 - PROGRESS = 1 - FINISHED = 2 - ERROR = 3 - CANCELLED = 4 - - -class TaskInfo: - """Model for a background task.""" - - def __init__( - self, - name: str, - target: Union[Callable, Awaitable], - args: Any, - kwargs: Any, - periodic: Optional[int] = None, - ) -> None: - """Initialize instance.""" - self.name = name - self.target = target - self.args = args - self.kwargs = kwargs - self.periodic = periodic - self.status = TaskStatus.PENDING - self.error_details = "" - self.updated_at = now() - self.execution_time = 0 # time in seconds it took to process - self.id = str(uuid4()) - - def __str__(self): - """Return string representation, used for logging.""" - return f"{self.name} ({self.id})" - - def to_dict(self) -> Dict[str, Any]: - """Return serializable dict.""" - return { - "id": self.id, - "name": self.name, - "status": self.status, - "error_details": self.error_details, - "updated_at": self.updated_at.isoformat(), - "execution_time": self.execution_time, - } - - @property - def dupe_hash(self): - """Return simple hash to identify duplicate tasks.""" - return f"{self.name}.{self.target.__qualname__}.{self.args}" - - -class TaskManager: - """Task manager that executes tasks from a queue in the background.""" - - def __init__(self, mass: MusicAssistant): - """Initialize TaskManager instance.""" - self.mass = mass - self._queue = None - - async def setup(self): - """Async initialize of module.""" - # queue can only be initialized when the loop is running - MultiStateQueue.QUEUE_ITEM_TYPE = TaskInfo - self._queue = MultiStateQueue() - create_task(self.__process_tasks()) - - def add( - self, - name: str, - target: Union[Callable, Awaitable], - *args: Any, - periodic: Optional[int] = None, - prevent_duplicate: bool = True, - **kwargs: Any, - ) -> TaskInfo: - """Add a job/task to the task manager. - - name: A name to identify this task in the task queue. - target: target to call (coroutine function or callable). - periodic: [optional] run this task every X seconds. - prevent_duplicate: [default true] prevent same task running at same time - args: [optional] parameters for method to call. - kwargs: [optional] parameters for method to call. - """ - - if self.mass.exit: - return - if self._queue is None: - raise RuntimeError("Not yet initialized") - - if periodic and asyncio.iscoroutine(target): - raise RuntimeError( - "Provide a coroutine function and not a coroutine itself" - ) - - task_info = TaskInfo( - name, periodic=periodic, target=target, args=args, kwargs=kwargs - ) - if prevent_duplicate: - for task in self._queue.progress_items + self._queue.pending_items: - if task.dupe_hash == task_info.dupe_hash: - LOGGER.debug( - "Ignoring task %s as it is already running....", task_info.name - ) - return task - self._add_task(task_info) - return task_info - - @api_route("tasks") - def get_all_tasks(self) -> List[TaskInfo]: - """Return all tasks in the TaskManager.""" - return self._queue.all_items - - def _add_task(self, task_info: TaskInfo) -> None: - """Handle adding a task to the task queue.""" - LOGGER.debug("Adding task [%s] to Task Queue...", task_info.name) - self._queue.put_nowait(task_info) - self.mass.eventbus.signal(EVENT_TASK_UPDATED, task_info) - - def __task_done_callback(self, future: Future): - task_info: TaskInfo = future.task_info - self._queue.mark_finished(task_info) - prev_timestamp = task_info.updated_at.timestamp() - task_info.updated_at = now() - task_info.execution_time = round( - task_info.updated_at.timestamp() - prev_timestamp, 2 - ) - if future.cancelled(): - future.task_info.status = TaskStatus.CANCELLED - elif future.exception(): - exc = future.exception() - task_info.status = TaskStatus.ERROR - task_info.error_details = repr(exc) - LOGGER.debug( - "Error while running task [%s]", - task_info.name, - exc_info=exc, - ) - else: - task_info.status = TaskStatus.FINISHED - LOGGER.debug( - "Task finished: [%s] in %s seconds", - task_info.name, - task_info.execution_time, - ) - self.mass.eventbus.signal(EVENT_TASK_UPDATED, task_info) - # reschedule if the task is periodic - if task_info.periodic: - self.mass.loop.call_later(task_info.periodic, self._add_task, task_info) - - async def __process_tasks(self): - """Process handling of tasks in the queue.""" - while not self.mass.exit: - while len(self._queue.progress_items) >= MAX_SIMULTANEOUS_TASKS: - await asyncio.sleep(1) - next_task = await self._queue.get() - next_task.status = TaskStatus.PROGRESS - next_task.updated_at = now() - task = create_task(next_task.target, *next_task.args, **next_task.kwargs) - setattr(task, "task_info", next_task) - task.add_done_callback(self.__task_done_callback) - self.mass.eventbus.signal(EVENT_TASK_UPDATED, next_task) diff --git a/music_assistant/mass.py b/music_assistant/mass.py index 0c764518..a393a888 100644 --- a/music_assistant/mass.py +++ b/music_assistant/mass.py @@ -1,298 +1,147 @@ """Main Music Assistant class.""" +from __future__ import annotations import asyncio -import importlib import logging -import os -from typing import Dict, Optional, Tuple +from time import time +from typing import Any, Callable, Coroutine, Optional, Tuple import aiohttp -import music_assistant.helpers.util as util -from music_assistant.constants import ( - CONF_ENABLED, - EVENT_PROVIDER_REGISTERED, - EVENT_PROVIDER_UNREGISTERED, - EVENT_SHUTDOWN, -) -from music_assistant.helpers.cache import Cache -from music_assistant.helpers.migration import check_migrations -from music_assistant.helpers.util import callback, create_task, get_ip_pton -from music_assistant.managers.config import ConfigManager -from music_assistant.managers.database import DatabaseManager -from music_assistant.managers.events import EventBus -from music_assistant.managers.library import LibraryManager -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.tasks import TaskManager -from music_assistant.models.provider import Provider, ProviderType -from music_assistant.web import WebServer -from zeroconf import InterfaceChoice, NonUniqueNameException, ServiceInfo, Zeroconf - -LOGGER = logging.getLogger("mass") +from databases import DatabaseURL +from music_assistant.constants import EventType +from music_assistant.controllers.metadata import MetaDataController +from music_assistant.controllers.music import MusicController +from music_assistant.controllers.players import PlayerController +from music_assistant.helpers import util +from music_assistant.helpers.cache import Cache +from music_assistant.helpers.database import Database +from music_assistant.helpers.util import create_task -def global_exception_handler(loop: asyncio.AbstractEventLoop, context: Dict) -> None: - """Global exception handler.""" - LOGGER.debug("Caught exception: %s", context.get("exception", context["message"])) - if "Broken pipe" in str(context.get("exception")): - # fix for the spamming subprocess - return - loop.default_exception_handler(context) +EventDetails = Any | None +EventCallBackType = Callable[[EventType, EventDetails], None] +EventSubscriptionType = Tuple[EventCallBackType, Optional[Tuple[EventType]]] class MusicAssistant: """Main MusicAssistant object.""" - def __init__(self, datapath: str, debug: bool = False, port: int = 8095) -> None: + def __init__( + self, + db_url: DatabaseURL, + stream_port: int = 8095, + session: aiohttp.ClientSession | None = None, + ) -> None: """ Create an instance of MusicAssistant. - :param datapath: file location to store the data + db_url: Database connection string/url. + stream_port: TCP port used for streaming audio. + session: Optionally provide an aiohttp clientsession """ - self._exit = False - self._loop = None - self.debug = debug - self._http_session = None + self.loop: asyncio.AbstractEventLoop = None + self.http_session: aiohttp.ClientSession = session + self.http_session_provided = session is not None + self.logger = logging.getLogger(__name__) - self._providers = {} + self._listeners = [] + self._jobs = asyncio.Queue() - # init core managers/controllers - self._eventbus = EventBus(self) - self._config = ConfigManager(self, datapath) - self._tasks = TaskManager(self) - self._database = DatabaseManager(self) - self._cache = Cache(self) - self._metadata = MetaDataManager(self) - self._web = WebServer(self, port) - self._music = MusicManager(self) - self._library = LibraryManager(self) - self._players = PlayerManager(self) - # shared zeroconf instance - self.zeroconf = Zeroconf(interfaces=InterfaceChoice.All) + # init core controllers + self.database = Database(self, db_url) + self.cache = Cache(self) + self.metadata = MetaDataController(self) + self.music = MusicController(self) + self.players = PlayerController(self, stream_port) + self._jobs_task: asyncio.Task = None - async def start(self) -> None: - """Start running the music assistant server.""" + async def setup(self) -> None: + """Async setup of music assistant.""" # initialize loop - self._loop = asyncio.get_event_loop() - util.DEFAULT_LOOP = self._loop - self._loop.set_exception_handler(global_exception_handler) - self._loop.set_debug(self.debug) + self.loop = asyncio.get_event_loop() + util.DEFAULT_LOOP = self.loop # create shared aiohttp ClientSession - self._http_session = aiohttp.ClientSession( - loop=self.loop, - connector=aiohttp.TCPConnector(enable_cleanup_closed=True, ssl=False), - ) - # run migrations if needed - await check_migrations(self) - await self._tasks.setup() - await self._config.setup() - await self._cache.setup() - await self._music.setup() - await self._players.setup() - await self._preload_providers() - await self.setup_discovery() - await self._web.setup() - await self._library.setup() - self.tasks.add("Save config", self.config.save) + if not self.http_session: + self.http_session = aiohttp.ClientSession( + loop=self.loop, + connector=aiohttp.TCPConnector(enable_cleanup_closed=True, ssl=False), + ) + # setup core controllers + await self.database.setup() + await self.cache.setup() + await self.music.setup() + await self.metadata.setup() + await self.players.setup() + self._jobs_task = create_task(self.__process_jobs()) async def stop(self) -> None: """Stop running the music assistant server.""" - self._exit = True - LOGGER.info("Application shutdown") - self._eventbus.signal(EVENT_SHUTDOWN) - await self.config.close() - await self._web.stop() - for prov in self._providers.values(): - await prov.on_stop() - await self._players.close() - await self._http_session.connector.close() - self._http_session.detach() - - @property - def loop(self) -> asyncio.AbstractEventLoop: - """Return the running event loop.""" - return self._loop - - @property - def exit(self) -> bool: - """Return bool if the main process is exiting.""" - return self._exit - - @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 library(self) -> LibraryManager: - """Return the Library controller/manager.""" - return self._library - - @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 database(self) -> DatabaseManager: - """Return the Database controller/manager.""" - return self._database - - @property - def metadata(self) -> MetaDataManager: - """Return the Metadata controller/manager.""" - return self._metadata - - @property - def tasks(self) -> TaskManager: - """Return the Tasks controller/manager.""" - return self._tasks - - @property - def eventbus(self) -> EventBus: - """Return the EventBus.""" - return self._eventbus - - @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 register_provider(self, provider: Provider) -> None: - """Register a new Provider/Plugin.""" - assert provider.id and provider.name - 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.get_provider_config(provider.id, provider.type)[CONF_ENABLED]: - if await provider.on_start() is not False: - provider.available = True - LOGGER.debug("Provider registered: %s", provider.name) - self.eventbus.signal(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 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].on_stop() - LOGGER.debug("Provider unregistered: %s", provider_id) - self.eventbus.signal(EVENT_PROVIDER_UNREGISTERED, provider_id) - return self._providers.pop(provider_id, None) - - async def reload_provider(self, provider_id: str) -> None: - """Reload an existing Provider/Plugin.""" - provider = await self.unregister_provider(provider_id) - if provider is not None: - # simply re-register the same provider again - await self.register_provider(provider) - else: - # try preloading all providers - self.tasks.add("Reload providers", self._preload_providers) + self.logger.info("Application shutdown") + self.signal_event(EventType.SHUTDOWN) + if self._jobs_task is not None: + self._jobs_task.cancel() + if self.http_session and not self.http_session_provided: + await self.http_session.connector.close() + self.http_session.detach() + + def signal_event( + self, event_type: EventType, event_details: EventDetails = None + ) -> None: + """ + Signal (systemwide) event. - @callback - def get_provider(self, provider_id: str) -> Provider: - """Return provider/plugin by id.""" - if provider_id not in self._providers: - LOGGER.warning("Provider %s is not available", provider_id) - return None - return self._providers[provider_id] + :param event_msg: the eventmessage to signal + :param event_details: optional details to send with the event. + """ + for cb_func, event_filter in self._listeners: + if not event_filter or event_type in event_filter: + create_task(cb_func, event_type, event_details) - @callback - def get_providers( + def subscribe( self, - filter_type: Optional[ProviderType] = None, - include_unavailable: bool = False, - ) -> Tuple[Provider]: - """Return all providers, optionally filtered by type.""" - return tuple( - item - for item in self._providers.values() - if (filter_type is None or item.type == filter_type) - and (include_unavailable or item.available) - ) - - async def setup_discovery(self) -> None: - """Make this Music Assistant instance discoverable on the network.""" - - def _setup_discovery(): - zeroconf_type = "_music-assistant._tcp.local." + cb_func: EventCallBackType, + event_filter: EventType | Tuple[EventType] | None = None, + ) -> Callable: + """ + Add callback to event listeners. - info = ServiceInfo( - zeroconf_type, - name=f"{self.web.server_id}.{zeroconf_type}", - addresses=[get_ip_pton()], - port=self.web.port, - properties=self.web.discovery_info, - server=f"mass_{self.web.server_id}.local.", - ) - LOGGER.debug("Starting Zeroconf broadcast...") + Returns function to remove the listener. + :param cb_func: callback function or coroutine + :param event_filter: Optionally only listen for these events + """ + if isinstance(event_filter, EventType): + event_filter = (event_filter,) + elif event_filter is None: + event_filter = tuple() + listener = (cb_func, event_filter) + self._listeners.append(listener) + + def remove_listener(): + self._listeners.remove(listener) + + return remove_listener + + def add_job(self, job: Coroutine, name: str | None = None) -> None: + """Add job to be (slowly) processed in the background (one by one).""" + if not name: + name = job.__qualname__ or job.__name__ + self._jobs.put_nowait((name, job)) + + async def __process_jobs(self): + """Process jobs in the background.""" + while True: + name, job = await self._jobs.get() + time_start = time() + self.logger.debug("Start processing job [%s].", name) try: - existing = getattr(self, "mass_zc_service_set", None) - if existing: - self.zeroconf.update_service(info) - else: - self.zeroconf.register_service(info) - setattr(self, "mass_zc_service_set", True) - except NonUniqueNameException: - LOGGER.error( - "Music Assistant instance with identical name present in the local network!" + # await job + task = asyncio.create_task(job, name=name) + await task + except Exception as err: # pylint: disable=broad-except + self.logger.error( + "Job [%s] failed with error %s.", name, str(err), exc_info=err ) - - create_task(_setup_discovery) - - async def _preload_providers(self) -> None: - """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.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) + else: + duration = round(time() - time_start, 2) + self.logger.info("Finished job [%s] in %s seconds.", name, duration) diff --git a/music_assistant/models/__init__.py b/music_assistant/models/__init__.py index 67f04471..53f3a7c6 100644 --- a/music_assistant/models/__init__.py +++ b/music_assistant/models/__init__.py @@ -1 +1 @@ -"""Models.""" +"""Models package.""" diff --git a/music_assistant/models/config_entry.py b/music_assistant/models/config_entry.py deleted file mode 100644 index 2af08bfa..00000000 --- a/music_assistant/models/config_entry.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Model and helpers for Config entries.""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum -from typing import List, Optional, Tuple, Union - -from mashumaro import DataClassDictMixin - - -class ConfigEntryType(Enum): - """Enum for the type of a config entry.""" - - BOOL = "boolean" - STRING = "string" - PASSWORD = "password" - INT = "integer" - FLOAT = "float" - LABEL = "label" - DICT = "dict" - - -ValueTypes = Union[str, int, float, bool, dict, None] - - -@dataclass -class ConfigValueOption(DataClassDictMixin): - """Model for a value with seperated name/value.""" - - text: str - value: ValueTypes - - -@dataclass -class ConfigEntry(DataClassDictMixin): - """Model for a Config Entry.""" - - entry_key: str - entry_type: ConfigEntryType - default_value: ValueTypes = None - # options: select from list of possible values/options - options: Optional[List[ConfigValueOption]] = None - range: Optional[Tuple[int, int]] = None # select values within range - label: Optional[str] = None # a friendly name for the setting - description: Optional[str] = None # extended description of the setting. - help_key: Optional[str] = None # key in the translations file - multi_value: bool = False # allow multiple values from the list - depends_on: Optional[ - str - ] = None # needs to be set before this setting shows up in frontend - hidden: bool = False # hide from UI - value: ValueTypes = None # set by the configuration manager - store_hashed: bool = False # value will be hashed, non reversible diff --git a/music_assistant/models/errors.py b/music_assistant/models/errors.py new file mode 100644 index 00000000..ae7a5b00 --- /dev/null +++ b/music_assistant/models/errors.py @@ -0,0 +1,33 @@ +"""Custom errors and exceptions.""" + + +class MusicAssistantError(Exception): + """Custom Exception for all errors.""" + + +class ProviderUnavailableError(MusicAssistantError): + """Error raised when trying to access mediaitem of unavailable provider.""" + + +class MediaNotFoundError(MusicAssistantError): + """Error raised when trying to access non existing media item.""" + + +class InvalidDataError(MusicAssistantError): + """Error raised when an object has invalid data.""" + + +class AlreadyRegisteredError(MusicAssistantError): + """Error raised when a duplicate music provider or player is registered.""" + + +class SetupFailedError(MusicAssistantError): + """Error raised when setup of a provider or player failed.""" + + +class LoginFailed(MusicAssistantError): + """Error raised when a login failed.""" + + +class AudioError(MusicAssistantError): + """Error raised when an issue arrised when processing audio.""" diff --git a/music_assistant/models/media_controller.py b/music_assistant/models/media_controller.py new file mode 100644 index 00000000..eaac3747 --- /dev/null +++ b/music_assistant/models/media_controller.py @@ -0,0 +1,185 @@ +"""Model for a base media_controller.""" +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import Generic, List, Tuple, TypeVar + +from music_assistant.helpers.cache import cached +from music_assistant.helpers.typing import MusicAssistant +from music_assistant.models.errors import MediaNotFoundError, ProviderUnavailableError + +from .media_items import MediaItemType, MediaType + +ItemCls = TypeVar("ItemCls", bound="MediaControllerBase") + + +class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): + """Base model for controller managing a MediaType.""" + + media_type: MediaType + item_cls: MediaItemType + db_table: str + + def __init__(self, mass: MusicAssistant): + """Initialize class.""" + self.mass = mass + self.logger = mass.logger.getChild(f"music.{self.media_type.value}") + + @abstractmethod + async def setup(self): + """Async initialize of module.""" + + @abstractmethod + async def add(self, item: ItemCls) -> ItemCls: + """Add item to local db and return the database item.""" + raise NotImplementedError + + async def library(self) -> List[ItemCls]: + """Get all in-library items.""" + match = {"in_library": True} + return [ + self.item_cls.from_db_row(db_row) + for db_row in await self.mass.database.get_rows(self.db_table, match) + ] + + async def get( + self, + provider_item_id: str, + provider_id: str, + force_refresh: bool = False, + lazy: bool = True, + details: ItemCls = None, + ) -> ItemCls: + """Return (full) details for a single media item.""" + db_item = await self.get_db_item_by_prov_id(provider_id, provider_item_id) + if db_item and force_refresh: + provider_id, provider_item_id = await self.get_provider_id(db_item) + elif db_item: + return db_item + if not details: + details = await self.get_provider_item(provider_item_id, provider_id) + if not lazy: + return await self.add(details) + self.mass.add_job(self.add(details), f"Add {details.uri} to database") + return db_item if db_item else details + + async def search( + self, search_query: str, provider_id: str, limit: int = 25 + ) -> List[ItemCls]: + """Search database or provider with given query.""" + if provider_id == "database": + return [ + self.item_cls.from_db_row(db_row) + for db_row in await self.mass.database.search( + self.db_table, search_query + ) + ] + + provider = self.mass.music.get_provider(provider_id) + if not provider: + return {} + cache_key = ( + f"{provider_id}.search.{self.media_type.value}.{search_query}.{limit}" + ) + return await cached( + self.mass.cache, + cache_key, + provider.search, + search_query, + [self.media_type], + limit, + ) + + async def add_to_library(self, provider_item_id: str, provider_id: str) -> None: + """Add an item to the library.""" + # make sure we have a valid full item + db_item = await self.get(provider_item_id, provider_id, lazy=False) + # add to provider libraries + for prov_id in db_item.provider_ids: + if prov := self.mass.music.get_provider(prov_id.provider): + await prov.library_add(prov_id.item_id, self.media_type) + # mark as library item in internal db + if not db_item.in_library: + await self.set_db_library(db_item.item_id, True) + + async def remove_from_library( + self, provider_item_id: str, provider_id: str + ) -> None: + """Remove item from the library.""" + # make sure we have a valid full item + db_item = await self.get(provider_item_id, provider_id, lazy=False) + # add to provider's libraries + for prov_id in db_item.provider_ids: + if prov := self.mass.music.get_provider(prov_id.provider): + await prov.library_remove(prov_id.item_id, self.media_type) + # unmark as library item in internal db + if db_item.in_library: + await self.set_db_library(db_item.item_id, False) + + async def get_provider_id(self, item: ItemCls) -> Tuple[str, str]: + """Return provider and item id.""" + if item.provider == "database": + # make sure we have a full object + item = await self.get_db_item(item.item_id) + for prov in item.provider_ids: + # returns the first provider that is available + if not prov.available: + continue + if self.mass.music.get_provider(prov.provider): + return (prov.provider, prov.item_id) + return None, None + + async def get_db_items(self, custom_query: str | None = None) -> List[ItemCls]: + """Fetch all records from database.""" + if custom_query is not None: + func = self.mass.database.get_rows_from_query(custom_query) + else: + func = self.mass.database.get_rows(self.db_table) + return [self.item_cls.from_db_row(db_row) for db_row in await func] + + async def get_db_item(self, item_id: int) -> ItemCls: + """Get record by id.""" + match = {"item_id": int(item_id)} + if db_row := await self.mass.database.get_row(self.db_table, match): + return self.item_cls.from_db_row(db_row) + return None + + async def get_db_item_by_prov_id( + self, + provider_id: str, + provider_item_id: str, + ) -> ItemCls | None: + """Get the database album for the given prov_id.""" + if provider_id == "database": + return await self.get_db_item(provider_item_id) + if item_id := await self.mass.music.get_provider_mapping( + self.media_type, provider_id, provider_item_id + ): + return await self.get_db_item(item_id) + return None + + async def set_db_library(self, item_id: int, in_library: bool) -> None: + """Set the in-library bool on a database item.""" + match = {"item_id": item_id} + await self.mass.database.update( + self.db_table, + match, + {"in_library": in_library}, + ) + + async def get_provider_item(self, item_id: str, provider_id: str) -> ItemCls: + """Return item details for the given provider item id.""" + if provider_id == "database": + return await self.get_db_item(item_id) + provider = self.mass.music.get_provider(provider_id) + if not provider: + raise ProviderUnavailableError(f"Provider {provider_id} is not available!") + cache_key = f"{provider_id}.get_{self.media_type.value}.{item_id}" + item = await cached( + self.mass.cache, cache_key, provider.get_item, self.media_type, item_id + ) + if not item: + raise MediaNotFoundError( + f"{self.media_type.value} {item_id} not found on provider {provider_id}" + ) + return item diff --git a/music_assistant/models/media_items.py b/music_assistant/models/media_items.py new file mode 100755 index 00000000..a66a9908 --- /dev/null +++ b/music_assistant/models/media_items.py @@ -0,0 +1,308 @@ +"""Models and helpers for media items.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum, IntEnum +from typing import Any, Dict, List, Mapping + +from mashumaro import DataClassDictMixin + + +from music_assistant.helpers.json import json + +from music_assistant.helpers.util import create_sort_name + + +class MediaType(Enum): + """Enum for MediaType.""" + + ARTIST = "artist" + ALBUM = "album" + TRACK = "track" + PLAYLIST = "playlist" + RADIO = "radio" + UNKNOWN = "unknown" + + +class MediaQuality(IntEnum): + """Enum for Media Quality.""" + + LOSSY_MP3 = 0 + LOSSY_OGG = 1 + LOSSY_AAC = 2 + FLAC_LOSSLESS = 6 # 44.1/48khz 16 bits + FLAC_LOSSLESS_HI_RES_1 = 7 # 44.1/48khz 24 bits HI-RES + FLAC_LOSSLESS_HI_RES_2 = 8 # 88.2/96khz 24 bits HI-RES + FLAC_LOSSLESS_HI_RES_3 = 9 # 176/192khz 24 bits HI-RES + FLAC_LOSSLESS_HI_RES_4 = 10 # above 192khz 24 bits HI-RES + UNKNOWN = 99 + + +@dataclass +class MediaItemProviderId(DataClassDictMixin): + """Model for a MediaItem's provider id.""" + + provider: str + item_id: str + quality: MediaQuality = MediaQuality.UNKNOWN + details: str = None + available: bool = True + + def __hash__(self): + """Return custom hash.""" + return hash((self.provider, self.item_id, self.quality)) + + +@dataclass +class MediaItem(DataClassDictMixin): + """Base representation of a media item.""" + + item_id: str + provider: str + name: str + sort_name: str | None = None + metadata: Dict[str, Any] = field(default_factory=dict) + provider_ids: List[MediaItemProviderId] = field(default_factory=list) + in_library: bool = False + media_type: MediaType = MediaType.UNKNOWN + uri: str = "" + + def __post_init__(self): + """Call after init.""" + if not self.uri: + self.uri = create_uri(self.media_type, self.provider, self.item_id) + if not self.sort_name: + self.sort_name = create_sort_name(self.name) + + @classmethod + def from_db_row(cls, db_row: Mapping): + """Create MediaItem object from database row.""" + db_row = dict(db_row) + for key in ["artists", "artist", "metadata", "provider_ids"]: + if key in db_row: + db_row[key] = json.loads(db_row[key]) + db_row["provider"] = "database" + if "in_library" in db_row: + db_row["in_library"] = bool(db_row["in_library"]) + if db_row.get("albums"): + db_row["album"] = db_row["albums"][0] + db_row["item_id"] = str(db_row["item_id"]) + return cls.from_dict(db_row) + + def to_db_row(self) -> dict: + """Create dict from item suitable for db.""" + return { + key: json.dumps(val) if isinstance(val, (list, dict)) else val + for key, val in self.to_dict().items() + if key + not in [ + "item_id", + "provider", + "media_type", + "uri", + "album", + "disc_number", + "track_number", + "position", + ] + } + + @property + def available(self): + """Return (calculated) availability.""" + return any(x.available for x in self.provider_ids) + + +@dataclass +class ItemMapping(DataClassDictMixin): + """Representation of a minimized item object.""" + + item_id: str + provider: str + name: str = "" + media_type: MediaType = MediaType.ARTIST + uri: str = "" + + def __post_init__(self): + """Call after init.""" + if not self.uri: + self.uri = create_uri(self.media_type, self.provider, self.item_id) + + @classmethod + def from_item(cls, item: "MediaItem"): + """Create ItemMapping object from regular item.""" + return cls.from_dict(item.to_dict()) + + def __hash__(self): + """Return custom hash.""" + return hash((self.media_type, self.provider, self.item_id)) + + +@dataclass +class Artist(MediaItem): + """Model for an artist.""" + + media_type: MediaType = MediaType.ARTIST + musicbrainz_id: str = "" + + +class AlbumType(Enum): + """Enum for Album type.""" + + ALBUM = "album" + SINGLE = "single" + COMPILATION = "compilation" + UNKNOWN = "unknown" + + +@dataclass +class Album(MediaItem): + """Model for an album.""" + + media_type: MediaType = MediaType.ALBUM + version: str = "" + year: int | None = None + artist: ItemMapping | Artist | None = None + album_type: AlbumType = AlbumType.UNKNOWN + upc: str | None = None + + def __hash__(self): + """Return custom hash.""" + return hash((self.provider, self.item_id)) + + +@dataclass +class Track(MediaItem): + """Model for a track.""" + + media_type: MediaType = MediaType.TRACK + duration: int = 0 + version: str = "" + isrc: str = "" + artists: List[ItemMapping | Artist] = field(default_factory=list) + # album track only + album: ItemMapping | Album | None = None + disc_number: int | None = None + track_number: int | None = None + # playlist track only + position: int | None = None + + def __hash__(self): + """Return custom hash.""" + return hash((self.provider, self.item_id)) + + +@dataclass +class Playlist(MediaItem): + """Model for a playlist.""" + + media_type: MediaType = MediaType.PLAYLIST + owner: str = "" + checksum: str = "" # some value to detect playlist track changes + is_editable: bool = False + + +@dataclass +class Radio(MediaItem): + """Model for a radio station.""" + + media_type: MediaType = MediaType.RADIO + duration: int = 86400 + + def to_db_row(self) -> dict: + """Create dict from item suitable for db.""" + val = super().to_db_row() + val.pop("duration", None) + return val + + +def create_uri(media_type: MediaType, provider_id: str, item_id: str): + """Create uri for mediaitem.""" + return f"{provider_id}://{media_type.value}/{item_id}" + + +MediaItemType = Artist | Album | Track | Radio | Playlist + + +class StreamType(Enum): + """Enum with stream types.""" + + EXECUTABLE = "executable" + URL = "url" + FILE = "file" + CACHE = "cache" + + +class ContentType(Enum): + """Enum with audio content types supported by ffmpeg.""" + + OGG = "ogg" + FLAC = "flac" + MP3 = "mp3" + AAC = "aac" + MPEG = "mpeg" + PCM_S16LE = "s16le" # PCM signed 16-bit little-endian + PCM_S24LE = "s24le" # PCM signed 24-bit little-endian + PCM_S32LE = "s32le" # PCM signed 32-bit little-endian + PCM_F32LE = "f32le" # PCM 32-bit floating-point little-endian + PCM_F64LE = "f64le" # PCM 64-bit floating-point little-endian + + def is_pcm(self): + """Return if contentype is PCM.""" + return self.name.startswith("PCM") + + def sox_supported(self): + """Return if ContentType is supported by SoX.""" + return self not in [ContentType.AAC, ContentType.MPEG] + + def sox_format(self): + """Convert the ContentType to SoX compatible format.""" + if not self.sox_supported(): + raise NotImplementedError + return self.value.replace("le", "") + + @classmethod + def from_bit_depth( + cls, bit_depth: int, floating_point: bool = False + ) -> "ContentType": + """Return (PCM) Contenttype from PCM bit depth.""" + if floating_point and bit_depth > 32: + return cls.PCM_F64LE + if floating_point: + return cls.PCM_F32LE + if bit_depth == 16: + return cls.PCM_S16LE + if bit_depth == 24: + return cls.PCM_S24LE + return cls.PCM_S32LE + + +@dataclass +class StreamDetails(DataClassDictMixin): + """Model for streamdetails.""" + + type: StreamType + provider: str + item_id: str + path: str + content_type: ContentType + player_id: str = "" + details: Dict[str, Any] = field(default_factory=dict) + seconds_played: int = 0 + gain_correct: float = 0 + loudness: float | None = None + sample_rate: int | None = None + bit_depth: int | None = None + channels: int = 2 + media_type: MediaType = MediaType.TRACK + queue_id: str = None + + def __post_serialize__(self, d: Dict[Any, Any]) -> Dict[Any, Any]: + """Exclude internal fields from dict.""" + d.pop("path") + d.pop("details") + return d + + def __str__(self): + """Return pretty printable string of object.""" + return f"{self.type.value}/{self.content_type.value} - {self.provider}/{self.item_id}" diff --git a/music_assistant/models/media_types.py b/music_assistant/models/media_types.py deleted file mode 100755 index 96c7cf4f..00000000 --- a/music_assistant/models/media_types.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Models and helpers for media items.""" - -from dataclasses import dataclass, field -from enum import Enum, IntEnum -from typing import Any, Dict, List, Mapping, Optional, Set - -import ujson -from mashumaro import DataClassDictMixin -from music_assistant.helpers.util import create_uri - - -class MediaType(Enum): - """Enum for MediaType.""" - - ARTIST = "artist" - ALBUM = "album" - TRACK = "track" - PLAYLIST = "playlist" - RADIO = "radio" - UNKNOWN = "unknown" - - -class ContributorRole(Enum): - """Enum for Contributor Role.""" - - ARTIST = "artist" - WRITER = "writer" - PRODUCER = "producer" - - -class AlbumType(Enum): - """Enum for Album type.""" - - ALBUM = "album" - SINGLE = "single" - COMPILATION = "compilation" - UNKNOWN = "unknown" - - -class TrackQuality(IntEnum): - """Enum for Track Quality.""" - - LOSSY_MP3 = 0 - LOSSY_OGG = 1 - LOSSY_AAC = 2 - FLAC_LOSSLESS = 6 # 44.1/48khz 16 bits - FLAC_LOSSLESS_HI_RES_1 = 7 # 44.1/48khz 24 bits HI-RES - FLAC_LOSSLESS_HI_RES_2 = 8 # 88.2/96khz 24 bits HI-RES - FLAC_LOSSLESS_HI_RES_3 = 9 # 176/192khz 24 bits HI-RES - FLAC_LOSSLESS_HI_RES_4 = 10 # above 192khz 24 bits HI-RES - UNKNOWN = 99 - - -@dataclass -class MediaItemProviderId(DataClassDictMixin): - """Model for a MediaItem's provider id.""" - - provider: str - item_id: str - quality: TrackQuality = TrackQuality.UNKNOWN - details: str = None - available: bool = True - - def __hash__(self): - """Return custom hash.""" - return hash((self.provider, self.item_id, self.quality)) - - -@dataclass -class MediaItem(DataClassDictMixin): - """Base representation of a media item.""" - - item_id: str - provider: str - name: str = "" - metadata: Dict[str, Any] = field(default_factory=dict) - provider_ids: Set[MediaItemProviderId] = field(default_factory=set) - in_library: bool = False - media_type: MediaType = MediaType.UNKNOWN - uri: str = "" - - def __post_init__(self): - """Call after init.""" - if not self.uri: - self.uri = create_uri(self.media_type, self.provider, self.item_id) - - @classmethod - def from_dict(cls, dict_obj: dict): - # pylint: disable=arguments-differ - """Parse MediaItem from dict.""" - if dict_obj["media_type"] == "artist": - return Artist.from_dict(dict_obj) - if dict_obj["media_type"] == "album": - return Album.from_dict(dict_obj) - if dict_obj["media_type"] == "track": - return Track.from_dict(dict_obj) - if dict_obj["media_type"] == "playlist": - return Playlist.from_dict(dict_obj) - if dict_obj["media_type"] == "radio": - return Radio.from_dict(dict_obj) - return super().from_dict(dict_obj) - - @classmethod - def from_db_row(cls, db_row: Mapping): - """Create MediaItem object from database row.""" - db_row = dict(db_row) - for key in ["artists", "artist", "album", "metadata", "provider_ids", "albums"]: - if key in db_row: - db_row[key] = ujson.loads(db_row[key]) - db_row["provider"] = "database" - if "in_library" in db_row: - db_row["in_library"] = bool(db_row["in_library"]) - if db_row.get("albums"): - db_row["album"] = db_row["albums"][0] - db_row["item_id"] = str(db_row["item_id"]) - return cls.from_dict(db_row) - - @property - def sort_name(self): - """Return sort name.""" - sort_name = self.name - for item in ["The ", "De ", "de ", "Les "]: - if self.name.startswith(item): - sort_name = "".join(self.name.split(item)[1:]) - return sort_name.lower() - - @property - def available(self): - """Return (calculated) availability.""" - return any(x.available for x in self.provider_ids) - - def __hash__(self): - """Return custom hash.""" - return hash((self.media_type, self.provider, self.item_id)) - - def __str__(self): - """Return string representation, used for logging.""" - return f"{self.name} ({self.uri})" - - -@dataclass -class Artist(MediaItem): - """Model for an artist.""" - - media_type: MediaType = MediaType.ARTIST - musicbrainz_id: str = "" - - def __hash__(self): - """Return custom hash.""" - return hash((self.media_type, self.provider, self.item_id)) - - -@dataclass -class ItemMapping(DataClassDictMixin): - """Representation of a minimized item object.""" - - item_id: str - provider: str - name: str = "" - media_type: MediaType = MediaType.ARTIST - uri: str = "" - - def __post_init__(self): - """Call after init.""" - if not self.uri: - self.uri = create_uri(self.media_type, self.provider, self.item_id) - - @classmethod - def from_item(cls, item: Mapping): - """Create ItemMapping object from regular item.""" - return cls.from_dict(item.to_dict()) - - def __hash__(self): - """Return custom hash.""" - return hash((self.media_type, self.provider, self.item_id)) - - -@dataclass -class Album(MediaItem): - """Model for an album.""" - - media_type: MediaType = MediaType.ALBUM - version: str = "" - year: int = 0 - artist: Optional[ItemMapping] = None - album_type: AlbumType = AlbumType.UNKNOWN - upc: str = "" - - def __hash__(self): - """Return custom hash.""" - return hash((self.media_type, self.provider, self.item_id)) - - -@dataclass -class FullAlbum(Album): - """Model for an album with full details.""" - - artist: Optional[Artist] = None - - def __hash__(self): - """Return custom hash.""" - return hash((self.media_type, self.provider, self.item_id)) - - -@dataclass -class Track(MediaItem): - """Model for a track.""" - - media_type: MediaType = MediaType.TRACK - duration: int = 0 - version: str = "" - isrc: str = "" - artists: Set[ItemMapping] = field(default_factory=set) - albums: Set[ItemMapping] = field(default_factory=set) - # album track only - album: Optional[ItemMapping] = None - disc_number: int = 0 - track_number: int = 0 - # playlist track only - position: int = 0 - - def __hash__(self): - """Return custom hash.""" - return hash((self.media_type, self.provider, self.item_id)) - - -@dataclass -class FullTrack(Track): - """Model for an album with full details.""" - - artists: Set[Artist] = field(default_factory=set) - albums: Set[Album] = field(default_factory=set) - album: Optional[Album] = None - - def __hash__(self): - """Return custom hash.""" - return hash((self.media_type, self.provider, self.item_id)) - - -@dataclass -class Playlist(MediaItem): - """Model for a playlist.""" - - media_type: MediaType = MediaType.PLAYLIST - owner: str = "" - checksum: str = "" # some value to detect playlist track changes - is_editable: bool = False - - def __hash__(self): - """Return custom hash.""" - return hash((self.media_type, self.provider, self.item_id)) - - -@dataclass -class Radio(MediaItem): - """Model for a radio station.""" - - media_type: MediaType = MediaType.RADIO - duration: int = 86400 - - def __hash__(self): - """Return custom hash.""" - return hash((self.media_type, self.provider, self.item_id)) - - -@dataclass -class SearchResult(DataClassDictMixin): - """Model for Media Item Search result.""" - - artists: List[Artist] = field(default_factory=list) - albums: List[Album] = field(default_factory=list) - tracks: List[Track] = field(default_factory=list) - playlists: List[Playlist] = field(default_factory=list) - radios: List[Radio] = field(default_factory=list) diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index 53bb7a5f..16063bc8 100755 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -1,21 +1,19 @@ """Models and helpers for a player.""" +from __future__ import annotations -from abc import abstractmethod -from dataclasses import dataclass, field +from abc import ABC +from dataclasses import dataclass from enum import Enum, IntEnum -from typing import Any, Optional, Set +from typing import TYPE_CHECKING, Any, Dict, List from mashumaro import DataClassDictMixin -from music_assistant.constants import ( - CONF_ENABLED, - CONF_NAME, - CONF_POWER_CONTROL, - CONF_VOLUME_CONTROL, - EVENT_PLAYER_CHANGED, -) -from music_assistant.helpers.typing import ConfigSubItem, MusicAssistant, QueueItems -from music_assistant.helpers.util import callback, create_task -from music_assistant.models.config_entry import ConfigEntry + +from music_assistant.constants import EventType +from music_assistant.helpers.typing import MusicAssistant +from music_assistant.helpers.util import create_task + +if TYPE_CHECKING: + from .player_queue import PlayerQueue class PlayerState(Enum): @@ -31,9 +29,9 @@ class PlayerState(Enum): class DeviceInfo(DataClassDictMixin): """Model for a player's deviceinfo.""" - model: str = "" - address: str = "" - manufacturer: str = "" + model: str = "unknown" + address: str = "unknown" + manufacturer: str = "unknown" class PlayerFeature(IntEnum): @@ -44,482 +42,236 @@ class PlayerFeature(IntEnum): CROSSFADE = 2 -class PlayerControlType(Enum): - """Enum with different player control types.""" - - POWER = 0 - VOLUME = 1 - UNKNOWN = 99 - - -@dataclass -class PlayerControl(DataClassDictMixin): - """ - Model for a player control. - - Allows for a plugin-like - structure to override common player commands. - """ - - type: PlayerControlType - control_id: str - provider: str - name: str - state: Any = None - - def __hash__(self): - """Return custom hash.""" - return hash((self.type, self.provider, self.control_id)) - - async def set_state(self, new_state: Any) -> None: - """Handle command to set the state for a player control.""" - # by default we just signal an event on the eventbus - # pickup this event (e.g. from the websocket api) - # or override this method with your own implementation. - # pylint: disable=no-member - self.mass.eventbus.signal( - f"players/controls/{self.control_id}/state", new_state - ) - - -@dataclass -class CalculatedPlayerState(DataClassDictMixin): - """Model for a (calculated) player state.""" - - player_id: str = None - provider_id: str = None - name: str = None - powered: bool = False - state: PlayerState = PlayerState.IDLE - available: bool = False - volume_level: int = 0 - muted: bool = False - is_group_player: bool = False - group_childs: Set[str] = field(default_factory=set) - device_info: DeviceInfo = field(default_factory=DeviceInfo) - group_parents: Set[str] = field(default_factory=set) - features: Set[PlayerFeature] = field(default_factory=set) - active_queue: str = None - - def __hash__(self): - """Return custom hash.""" - return hash((self.provider_id, self.player_id)) - - def __str__(self): - """Return string representation, used for logging.""" - return f"{self.name} ({self.provider_id}/{self.player_id})" - - def update(self, new_obj: "PlayerState") -> Set[str]: - """Update state from other PlayerState instance and return changed keys.""" - changed_keys = set() - # pylint: disable=no-member - for key in self.__dataclass_fields__.keys(): - new_val = getattr(new_obj, key) - if getattr(self, key) != new_val: - setattr(self, key, new_val) - changed_keys.add(key) - return changed_keys - - -class Player: +class Player(ABC): """Model for a music player.""" - # Public properties: should be overriden with provider specific implementation - - @property - @abstractmethod - def player_id(self) -> str: - """Return player id of this player.""" - return None + player_id: str + is_group: bool = False + _attr_name: str = None + _attr_powered: bool = False + _attr_elapsed_time: int = 0 + _attr_current_url: str = None + _attr_state: PlayerState = PlayerState.IDLE + _attr_available: bool = True + _attr_volume_level: int = 100 + _attr_device_info: DeviceInfo = DeviceInfo() + _attr_max_sample_rate: int = 96000 + # mass object will be set by playermanager at register + mass: MusicAssistant = None # type: ignore[assignment] @property - @abstractmethod - def provider_id(self) -> str: - """Return provider id of this player.""" - return None + def name(self) -> bool: + """Return player name.""" + return self._attr_name or self.player_id @property - def name(self) -> str: - """Return name of the player.""" - return None - - @property - @abstractmethod def powered(self) -> bool: """Return current power state of player.""" - return False + return self._attr_powered @property - @abstractmethod def elapsed_time(self) -> int: """Return elapsed time of current playing media in seconds.""" - return 0 + return self._attr_elapsed_time @property - def elapsed_milliseconds(self) -> Optional[int]: - """ - Return elapsed time of current playing media in milliseconds. - - This is an optional property. - If provided, the property must return the REALTIME value while playing. - Used for synced playback in player groups. - """ - return None + def current_url(self) -> str: + """Return URL that is currently loaded in the player.""" @property - @abstractmethod def state(self) -> PlayerState: """Return current PlayerState of player.""" - return PlayerState.IDLE + return self._attr_state @property def available(self) -> bool: """Return current availablity of player.""" - return True + return self._attr_available @property - @abstractmethod - def current_uri(self) -> Optional[str]: - """Return currently loaded uri of player (if any).""" - return None - - @property - @abstractmethod def volume_level(self) -> int: """Return current volume level of player (scale 0..100).""" - return 0 - - @property - @abstractmethod - def muted(self) -> bool: - """Return current mute state of player.""" - return False - - @property - @abstractmethod - def is_group_player(self) -> bool: - """Return True if this player is a group player.""" - return False - - @property - def group_childs(self) -> Set[str]: - """Return list of child player id's if player is a group player.""" - return {} + return self._attr_volume_level @property def device_info(self) -> DeviceInfo: - """Return the device info for this player.""" - return DeviceInfo() - - @property - def should_poll(self) -> bool: - """Return True if this player should be polled for state updates.""" - return False + """Return basic device/provider info for this player.""" + return self._attr_device_info @property - def features(self) -> Set[PlayerFeature]: - """Return list of features this player supports.""" - return {} - - @property - def config_entries(self) -> Set[ConfigEntry]: - """Return player specific config entries (if any).""" - return {} - - # Public methods / player commands: should be overriden with provider specific implementation - - async def on_poll(self) -> None: - """Call when player is periodically polled by the player manager (should_poll=True).""" - self.update_state() + def max_sample_rate(self) -> int: + """Return the maximum supported sample rate this player supports.""" + return self._attr_max_sample_rate - async def on_add(self) -> None: - """Call when player is added to the player manager.""" - - async def on_remove(self) -> None: - """Call when player is removed from the player manager.""" - - async def cmd_play_uri(self, uri: str) -> None: - """ - Play the specified uri/url on the player. - - :param uri: uri/url to send to the player. - """ + async def play_url(self, url: str) -> None: + """Play the specified url on the player.""" raise NotImplementedError - async def cmd_stop(self) -> None: + async def stop(self) -> None: """Send STOP command to player.""" raise NotImplementedError - async def cmd_play(self) -> None: - """Send PLAY command to player.""" + async def play(self) -> None: + """Send PLAY/UNPAUSE command to player.""" raise NotImplementedError - async def cmd_pause(self) -> None: + async def pause(self) -> None: """Send PAUSE command to player.""" raise NotImplementedError - async def cmd_next(self) -> None: - """Send NEXT TRACK command to player.""" + async def power(self, powered: bool) -> None: + """Send POWER command to player.""" raise NotImplementedError - async def cmd_previous(self) -> None: - """Send PREVIOUS TRACK command to player.""" + async def volume_set(self, volume_level: int) -> None: + """Send volume level (0..100) command to player.""" raise NotImplementedError - async def cmd_power_on(self) -> None: - """Send POWER ON command to player.""" - raise NotImplementedError + # SOME CONVENIENCE METHODS - async def cmd_power_off(self) -> None: - """Send POWER OFF command to player.""" - raise NotImplementedError + async def volume_up(self, step_size: int = 5): + """Send volume UP command to player.""" + new_level = min(self.volume_level + step_size, 100) + return await self.volume_set(new_level) - async def cmd_volume_set(self, volume_level: int) -> None: - """ - Send volume level command to player. + async def volume_down(self, step_size: int = 5): + """Send volume DOWN command to player.""" + new_level = max(self.volume_level - step_size, 0) + return await self.volume_set(new_level) - :param volume_level: volume level to set (0..100). - """ - raise NotImplementedError + async def play_pause(self) -> None: + """Toggle play/pause on player.""" + if self.state == PlayerState.PAUSED: + await self.play() + else: + await self.pause() - async def cmd_volume_mute(self, is_muted: bool = False) -> None: - """ - Send volume MUTE command to given player. + async def power_toggle(self) -> None: + """Toggle power on player.""" + await self.power(not self.powered) - :param is_muted: bool with new mute state. - """ - raise NotImplementedError - - # OPTIONAL: QUEUE SERVICE CALLS/COMMANDS - OVERRIDE ONLY IF SUPPORTED BY PROVIDER - - async def 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 - """ - if PlayerFeature.QUEUE in self.features: - raise NotImplementedError - - async def cmd_queue_load( - self, queue_items: QueueItems, repeat: bool = False - ) -> None: - """ - Load/overwrite given items in the player's queue implementation. - - :param queue_items: a list of QueueItems - """ - if PlayerFeature.QUEUE in self.features: - raise NotImplementedError - - async def cmd_queue_insert( - self, queue_items: QueueItems, 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 - """ - if PlayerFeature.QUEUE in self.features: - raise NotImplementedError - - async def cmd_queue_append(self, queue_items: QueueItems) -> None: - """ - Append new items at the end of the queue. - - :param queue_items: a list of QueueItems - """ - if PlayerFeature.QUEUE in self.features: - raise NotImplementedError - - async def cmd_queue_update(self, queue_items: QueueItems) -> None: - """ - Overwrite the existing items in the queue, used for reordering. - - :param queue_items: a list of QueueItems - """ - if PlayerFeature.QUEUE in self.features: - raise NotImplementedError - - async def cmd_queue_clear(self) -> None: - """Clear the player's queue.""" - if PlayerFeature.QUEUE in self.features: - raise NotImplementedError - - # Private properties and methods - # Do not override below this point! + # DO NOT OVERRIDE BELOW @property - def active_queue(self) -> str: - """Return the active parent player/queue for a player.""" - return self._calculated_state.active_queue or self.player_id + def queue(self) -> "PlayerQueue": + """Return PlayerQueue for this player.""" + return self.mass.players.get_player_queue(self.player_id, True) - @property - def group_parents(self) -> Set[str]: - """Return all groups this player belongs to.""" - return self._calculated_state.group_parents + def update_state(self) -> None: + """Update current player state in the player manager.""" + # basic throttle: do not send state changed events if player did not change + prev_state = getattr(self, "_prev_state", None) + cur_state = self.to_dict() + if prev_state == cur_state: + return + setattr(self, "_prev_state", cur_state) + self.mass.signal_event(EventType.PLAYER_CHANGED, self) + self.queue.on_player_update() + if self.is_group: + # update group player childs when parent updates + for child_player_id in self.group_childs: + if player := self.mass.players.get_player(child_player_id): + create_task(player.update_state) + else: + # update group player when child updates + for group_player_id in self.get_group_parents(): + if player := self.mass.players.get_player(group_player_id): + create_task(player.update_state) + + def get_group_parents(self) -> List[str]: + """Get any/all group player id's this player belongs to.""" + return [ + x.player_id + for x in self.mass.players + if x.is_group and self.player_id in x.group_childs + ] + + def to_dict(self) -> Dict[str, Any]: + """Export object to dict.""" + return { + "player_id": self.player_id, + "name": self.name, + "powered": self.powered, + "elapsed_time": self.elapsed_time, + "state": self.state.value, + "available": self.available, + "volume_level": int(self.volume_level), + "device_info": self.device_info.to_dict(), + } - @property - def config(self) -> ConfigSubItem: - """Return this player's configuration.""" - return self.mass.config.get_player_config(self.player_id) - @property - def enabled(self): - """Return True if this player is enabled.""" - return self.config[CONF_ENABLED] +class PlayerGroup(Player): + """Model for a player group.""" - @property - def power_control(self) -> Optional[PlayerControl]: - """Return this player's Power Control.""" - player_control_conf = self.config.get(CONF_POWER_CONTROL) - if player_control_conf: - return self.mass.players.get_player_control(player_control_conf) - return None + is_group: bool = True + _attr_group_childs: List[str] = [] + _attr_support_join_control: bool = True @property - def volume_control(self) -> Optional[PlayerControl]: - """Return this player's Volume Control.""" - player_control_conf = self.config.get(CONF_VOLUME_CONTROL) - if player_control_conf: - return self.mass.players.get_player_control(player_control_conf) - return None + def support_join_control(self) -> bool: + """Return bool if joining/unjoining of players to this group is supported.""" + return self._attr_support_join_control @property - def calculated_state(self) -> CalculatedPlayerState: - """Return calculated/final state for this player.""" - return self._calculated_state + def group_childs(self) -> List[str]: + """Return list of child player id's of this PlayerGroup.""" + return self._attr_group_childs - @callback - def update_state(self) -> None: - """Call to update current player state in the player manager.""" - if self.mass.exit: - return - if not self.added_to_mass: - if self.enabled: - # player is now enabled and can be added - create_task(self.mass.players.add_player(self)) - return - new_state = self.create_calculated_state() - changed_keys = self._calculated_state.update(new_state) - # always update the player queue - player_queue = self.mass.players.get_player_queue(self.active_queue) - if player_queue: - create_task(player_queue.update_state) - # basic throttle: do not send state changed events if player did not change - if not changed_keys: - return - self._calculated_state = new_state - self.mass.eventbus.signal(EVENT_PLAYER_CHANGED, new_state) - # update group player childs when parent updates - for child_player_id in self.group_childs: - create_task(self.mass.players.trigger_player_update(child_player_id)) - # update group player when child updates - for group_player_id in self._calculated_state.group_parents: - create_task(self.mass.players.trigger_player_update(group_player_id)) - - @callback - def _get_powered(self) -> bool: - """Return final/calculated player's power state.""" - if not self.available or not self.enabled: - return False - power_control = self.power_control - if power_control: - return power_control.state - return self.powered - - @callback - def _get_state(self, powered: bool, active_queue: str) -> PlayerState: - """Return final/calculated player's PlayerState.""" - if powered and active_queue != self.player_id: - # use group state - return self.mass.players.get_player(active_queue).state - return PlayerState.OFF if not powered else self.state - - @callback - def _get_available(self) -> bool: - """Return current availablity of player.""" - return False if not self.enabled else self.available - - @callback - def _get_volume_level(self) -> int: - """Return final/calculated player's volume_level.""" - if not self.available or not self.enabled: + @property + def volume_level(self) -> int: + """Return current volume level of player (scale 0..100).""" + if not self.available: return 0 - # handle volume control - volume_control = self.volume_control - if volume_control: - return volume_control.state + # calculate group volume from powered players for convenience + group_volume = 0 + active_players = 0 + for child_player in self._get_players(True): + group_volume += child_player.volume_level + active_players += 1 + if active_players: + group_volume = group_volume / active_players + return int(group_volume) + + async def power(self, powered: bool) -> None: + """Send POWER command to player.""" + try: + super().power(powered) + except NotImplementedError: + self._attr_powered = powered + if not powered: + # turn off all childs + for child_player in self._get_players(True): + await child_player.power(False) + + async def volume_set(self, volume_level: int) -> None: + """Send volume level (0..100) command to player.""" # handle group volume - if self.is_group_player: - group_volume = 0 - active_players = 0 - for child_player_id in self.group_childs: - child_player = self.mass.players.get_player(child_player_id) - if child_player: - group_volume += child_player.calculated_state.volume_level - active_players += 1 - if active_players: - group_volume = group_volume / active_players - return int(group_volume) - return int(self.volume_level) - - @callback - def _get_group_parents(self) -> Set[str]: - """Return all group players this player belongs to.""" - if self.is_group_player: - return {} - return { - player.player_id - for player in self.mass.players - if player.is_group_player and self.player_id in player.group_childs - } + cur_volume = self.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 in self._get_players(True): + cur_child_volume = child_player.volume_level + new_child_volume = cur_child_volume + ( + cur_child_volume * volume_dif_percent + ) + await child_player.volume_set(new_child_volume) + + async def join(self, player_id: str) -> None: + """Command to add/join a player to this group.""" + raise NotImplementedError + + async def unjoin(self, player_id: str) -> None: + """Command to remove/unjoin a player to this group.""" + raise NotImplementedError - @callback - def _get_active_queue(self) -> str: - """Return the active parent player/queue for a player.""" - # if a group is playing, 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.players.get_player(group_player_id) - if group_player and group_player.state in [ - PlayerState.PLAYING, - PlayerState.PAUSED, - ]: - return group_player_id - return self.player_id - - @callback - def create_calculated_state(self) -> CalculatedPlayerState: - """Create CalculatedPlayerState.""" - conf_name = self.config.get(CONF_NAME) - active_queue = self._get_active_queue() - powered = self._get_powered() - return CalculatedPlayerState( - player_id=self.player_id, - provider_id=self.provider_id, - name=conf_name if conf_name else self.name, - powered=powered, - state=self._get_state(powered, active_queue), - available=self._get_available(), - volume_level=self._get_volume_level(), - muted=self.muted, - is_group_player=self.is_group_player, - group_childs=self.group_childs, - device_info=self.device_info, - group_parents=self._get_group_parents(), - features=self.features, - active_queue=active_queue, - ) - - def __init__(self, *args, **kwargs) -> None: - """Initialize a Player instance.""" - self.mass: Optional[MusicAssistant] = None - self.added_to_mass = False - self._calculated_state = CalculatedPlayerState() - - def to_dict(self): - """Return playerstate for compatability with json serializer.""" - return self._calculated_state.to_dict() + def _get_players(self, only_powered: bool = False) -> List[Player]: + """Get players attached to this group.""" + return [ + x + for x in self.mass.players + if x.player_id in self.group_childs and x.powered or not only_powered + ] diff --git a/music_assistant/models/player_queue.py b/music_assistant/models/player_queue.py old mode 100755 new mode 100644 index 60f0be98..7da7f32b --- a/music_assistant/models/player_queue.py +++ b/music_assistant/models/player_queue.py @@ -1,35 +1,27 @@ -"""Models and helpers for a player queue.""" +"""Model and helpders for a PlayerQueue.""" +from __future__ import annotations -import logging +import asyncio import random import time -import uuid -from dataclasses import dataclass, field +from asyncio import Task, TimerHandle +from dataclasses import dataclass from enum import Enum -from typing import Any, Dict, List, Optional, Set, Tuple, Union - -from music_assistant.constants import ( - CONF_CROSSFADE_DURATION, - EVENT_QUEUE_ITEMS_UPDATED, - EVENT_QUEUE_UPDATED, -) -from music_assistant.helpers.datetime import now -from music_assistant.helpers.typing import ( - MusicAssistant, - OptionalInt, - OptionalStr, - Player, -) -from music_assistant.helpers.util import callback, create_task -from music_assistant.models.media_types import ItemMapping, Radio, Track -from music_assistant.models.player import PlayerFeature, PlayerState -from music_assistant.models.streamdetails import StreamDetails - -# pylint: disable=too-many-instance-attributes -# pylint: disable=too-many-public-methods -# pylint: disable=too-few-public-methods - -LOGGER = logging.getLogger("player_queue") +from typing import TYPE_CHECKING, List, Tuple +from uuid import uuid4 + +from mashumaro import DataClassDictMixin + +from music_assistant.constants import EventType +from music_assistant.helpers.audio import get_stream_details +from music_assistant.helpers.typing import MusicAssistant +from music_assistant.helpers.util import create_task +from music_assistant.models.media_items import MediaType, StreamDetails + +from .player import Player, PlayerState + +if TYPE_CHECKING: + from music_assistant.models.media_items import Radio, Track class QueueOption(Enum): @@ -42,321 +34,386 @@ class QueueOption(Enum): @dataclass -class QueueItem(ItemMapping): - """Representation of a queue item, simplified version of track.""" +class QueueItem(DataClassDictMixin): + """Representation of a queue item.""" - queue_item_id: str = "" - streamdetails: StreamDetails = None - stream_url: str = "" - duration: int = 0 - artists: Set[ItemMapping] = field(default_factory=set) + uri: str + name: str = "" + duration: int | None = None + item_id: str = "" + sort_index: int = 0 + streamdetails: StreamDetails | None = None + is_media_item: bool = False def __post_init__(self): - """Generate unique id for the QueueItem.""" - super().__post_init__() - self.queue_item_id = str(uuid.uuid4()) + """Set default values.""" + if not self.item_id: + self.item_id = str(uuid4()) + if not self.name: + self.name = self.uri @classmethod - def from_track(cls, base_item: Union[Track, Radio]): + def from_media_item(cls, media_item: "Track" | "Radio"): """Construct QueueItem from track/radio item.""" - return cls.from_dict(base_item.to_dict()) + return cls( + uri=media_item.uri, + name=media_item.name, + duration=media_item.duration, + is_media_item=True, + ) class PlayerQueue: - """Class that holds the queue items for a player.""" + """Represents a PlayerQueue object.""" - def __init__(self, mass: MusicAssistant, player_id: str) -> None: - """Initialize class.""" + def __init__(self, mass: MusicAssistant, player_id: str): + """Instantiate a PlayerQueue instance.""" self.mass = mass - self._queue_id = player_id - self._items = [] - self._shuffle_enabled = False - self._repeat_enabled = False - self._cur_index = 0 - self._cur_item_time = 0 - self._last_item = None - self._queue_stream_start_index = 0 - self._queue_stream_next_index = 0 - self._queue_stream_active = False - self._last_playback_state = PlayerState.IDLE - # load previous queue settings from disk - create_task(self._restore_saved_state()) - - def __str__(self): - """Return string representation, used for logging.""" - return f"{self.player.name} ({self._queue_id})" - - async def close(self) -> None: - """Handle shutdown/close.""" - # pylint: disable=unused-argument - await self._save_state() + self.logger = mass.players.logger + self.queue_id = player_id + self.player_id = player_id + + self._shuffle_enabled: bool = False + self._repeat_enabled: bool = False + self._crossfade_duration: int = 0 + self._volume_normalization_enabled: bool = True + self._volume_normalization_target: int = -23 + + self._current_index: int | None = None + self._current_item_time: int = 0 + self._last_item: QueueItem | None = None + self._start_index: int = 0 + self._next_index: int = 0 + self._last_state = PlayerState.IDLE + self._items: List[QueueItem] = [] + self._save_task: TimerHandle = None + self._update_task: Task = None + self._signal_next: bool = False + self._last_player_update: int = 0 + self._stream_url: str | None = None + + async def setup(self) -> None: + """Handle async setup of instance.""" + await self._restore_saved_state() + self.mass.signal_event(EventType.QUEUE_ADDED, self) @property def player(self) -> Player: - """Return handle to (master) player of this queue.""" - return self.mass.players.get_player(self._queue_id) + """Return the player attached to this queue.""" + return self.mass.players.get_player(self.player_id, include_unavailable=True) @property - def state(self) -> PlayerState: - """Return playbackstate of this (player) Queue.""" - return self.player.state + def available(self) -> bool: + """Return bool if this queue is available.""" + return self.player.available @property - def queue_id(self) -> str: - """Return the Queue's id.""" - return self._queue_id - - def get_stream_url(self) -> str: - """Return the full stream url for the player's Queue Stream.""" - url = f"{self.mass.web.stream_url}/queue/{self.queue_id}" - # we set the checksum just to invalidate cache stuf - url += f"?checksum={time.time()}" - return url + def active(self) -> bool: + """Return bool if the queue is currenty active on the player.""" + # TODO: figure out a way to handle group childs playing the parent queue + if self.player.current_url is None: + return False + return self._stream_url in self.player.current_url @property - def shuffle_enabled(self) -> bool: - """Return shuffle enabled property.""" - return self._shuffle_enabled - - async def set_shuffle_enabled(self, enable_shuffle: bool) -> None: - """Set shuffle.""" - if not self._shuffle_enabled and enable_shuffle: - # shuffle requested - self._shuffle_enabled = True - if self.cur_index is not None: - played_items = self.items[: self.cur_index] - next_items = self.__shuffle_items(self.items[self.cur_index + 1 :]) - items = played_items + [self.cur_item] + next_items - await self.update(items) - elif self._shuffle_enabled and not enable_shuffle: - # unshuffle - self._shuffle_enabled = False - if self.cur_index is not None: - played_items = self.items[: self.cur_index] - next_items = self.items[self.cur_index + 1 :] - next_items.sort(key=lambda x: x.sort_index, reverse=False) - items = played_items + [self.cur_item] + next_items - await self.update(items) - self.update_state() - self.signal_update() + def elapsed_time(self) -> int: + """Return elapsed time of current playing media in seconds.""" + if not self.active: + return self.player.elapsed_time + return self._current_item_time @property def repeat_enabled(self) -> bool: - """Return if crossfade is enabled for this player.""" + """Return if repeat is enabled.""" return self._repeat_enabled - async def set_repeat_enabled(self, enable_repeat: bool) -> None: - """Set the repeat mode for this queue.""" - if self._repeat_enabled != enable_repeat: - self._repeat_enabled = enable_repeat - self.update_state() - create_task(self._save_state()) - self.signal_update() + @property + def shuffle_enabled(self) -> bool: + """Return if shuffle is enabled.""" + return self._shuffle_enabled @property - def cur_index(self) -> OptionalInt: - """ - Return the current index of the queue. + def crossfade_duration(self) -> int: + """Return crossfade duration (0 if disabled).""" + return self._crossfade_duration - Returns None if queue is empty. - """ - if not self._items: - return None - return self._cur_index + @property + def max_sample_rate(self) -> int: + """Return the maximum supported sample rate this playerqueue supports.""" + if self.player.max_sample_rate is None: + return 96000 + return self.player.max_sample_rate @property - def cur_item_id(self) -> OptionalStr: + def items(self) -> List[QueueItem]: + """Return all items in this queue.""" + return self._items + + @property + def current_index(self) -> int | None: """ - Return the queue item id of the current item in the queue. + Return the current index of the queue. Returns None if queue is empty. """ - cur_item = self.cur_item - if not cur_item: + if self._current_index >= len(self._items): return None - return cur_item.queue_item_id + return self._current_index @property - def cur_item(self) -> Optional[QueueItem]: + def current_item(self) -> QueueItem | None: """ Return the current item in the queue. Returns None if queue is empty. """ - if ( - self.cur_index is None - or not self.items - or not len(self.items) > self.cur_index - ): + if self._current_index is None: return None - return self.items[self.cur_index] - - @property - def cur_item_time(self) -> int: - """Return the time (progress) for current (playing) item.""" - return self._cur_item_time + if self._current_index >= len(self._items): + return None + return self._items[self._current_index] @property - def next_index(self) -> OptionalInt: - """Return the next index for this player's queue. + def next_index(self) -> int | None: + """ + Return the next index for this PlayerQueue. Return None if queue is empty or no more items. """ - if not self.items: + if not self._items: # queue is empty return None - if self.cur_index is None: - # playback started + if self._current_index is None: + # playback just started return 0 # player already playing (or paused) so return the next item - if len(self.items) > (self.cur_index + 1): - return self.cur_index + 1 - if self._repeat_enabled: + if len(self._items) > (self._current_index + 1): + return self._current_index + 1 + if self.repeat_enabled: # repeat enabled, start queue at beginning return 0 return None @property - def next_item(self) -> Optional[QueueItem]: - """Return the next item in the queue. + def next_item(self) -> QueueItem | None: + """ + Return the next item in the queue. Returns None if queue is empty or no more items. """ if self.next_index is not None: - return self.items[self.next_index] + return self._items[self.next_index] return None @property - def items(self) -> List[QueueItem]: - """Return all queue items for this player's queue.""" - return self._items + def volume_normalization_enabled(self) -> bool: + """Return bool if volume normalization is enabled for this queue.""" + return self._volume_normalization_enabled @property - def use_queue_stream(self) -> bool: - """ - Indicate that we need to use the queue stream. - - For example if crossfading is requested but a player doesn't natively support it - we will send a constant stream of audio to the player with all tracks. - """ - return ( - not self.supports_crossfade - if self.crossfade_enabled - else not self.supports_queue - ) - - @property - def crossfade_duration(self) -> int: - """Return crossfade duration (if enabled).""" - player_settings = self.mass.config.get_player_config(self.queue_id) - if player_settings: - return player_settings.get(CONF_CROSSFADE_DURATION, 0) - return 0 - - @property - def crossfade_enabled(self) -> bool: - """Return bool if crossfade is enabled.""" - return self.crossfade_duration > 0 - - @property - def supports_queue(self) -> bool: - """Return if this player supports native queue.""" - return PlayerFeature.QUEUE in self.player.features - - @property - def supports_crossfade(self) -> bool: - """Return if this player supports native crossfade.""" - return PlayerFeature.CROSSFADE in self.player.features - - @callback - def get_item(self, index: int) -> Optional[QueueItem]: - """Get item by index from queue.""" - if index is not None and len(self.items) > index: - return self.items[index] + def volume_normalization_target(self) -> int: + """Return volume target (in LUFS) for volume normalization for this queue.""" + return self._volume_normalization_target + + def get_item(self, index: int) -> QueueItem | None: + """Get queue item by index.""" + if index is not None and len(self._items) > index: + return self._items[index] return None - @callback - def by_item_id(self, queue_item_id: str) -> Optional[QueueItem]: + def item_by_id(self, queue_item_id: str) -> QueueItem | None: """Get item by queue_item_id from queue.""" if not queue_item_id: return None - for item in self.items: - if item.queue_item_id == queue_item_id: - return item + return next((x for x in self.items if x.item_id == queue_item_id), None) + + def index_by_id(self, queue_item_id: str) -> int | None: + """Get index by queue_item_id.""" + for index, item in enumerate(self.items): + if item.item_id == queue_item_id: + return index return None + async def play_media( + self, + uris: str | List[str], + queue_opt: QueueOption = QueueOption.PLAY, + ): + """ + Play media item(s) on the given queue. + + :param queue_id: queue id of the PlayerQueue to handle the command. + :param uri: uri(s) that should be played (single item or list of uri's). + :param queue_opt: + QueueOption.PLAY -> Insert new items in queue and start playing at inserted position + QueueOption.REPLACE -> Replace queue contents with these items + QueueOption.NEXT -> Play item(s) after current playing item + QueueOption.ADD -> Append new items at end of the queue + """ + # a single item or list of items may be provided + if not isinstance(uris, list): + uris = [uris] + queue_items = [] + for uri in uris: + if uri.startswith("http"): + # a plain url was provided + queue_items.append(QueueItem(uri)) + continue + media_item = await self.mass.music.get_item_by_uri(uri) + if not media_item: + raise FileNotFoundError(f"Invalid uri: {uri}") + # collect tracks to play + if media_item.media_type == MediaType.ARTIST: + tracks = await self.mass.music.artists.toptracks( + media_item.item_id, provider_id=media_item.provider + ) + elif media_item.media_type == MediaType.ALBUM: + tracks = await self.mass.music.albums.tracks( + media_item.item_id, provider_id=media_item.provider + ) + elif media_item.media_type == MediaType.PLAYLIST: + tracks = await self.mass.music.playlists.tracks( + media_item.item_id, provider_id=media_item.provider + ) + elif media_item.media_type == MediaType.RADIO: + # single radio + tracks = [ + await self.mass.music.radio.get( + media_item.item_id, provider_id=media_item.provider + ) + ] + else: + # single track + tracks = [ + await self.mass.music.tracks.get( + media_item.item_id, provider_id=media_item.provider + ) + ] + for track in tracks: + if not track.available: + continue + queue_items.append(QueueItem.from_media_item(track)) + + # load items into the queue + if queue_opt == QueueOption.REPLACE: + return await self.load(queue_items) + if queue_opt in [QueueOption.PLAY, QueueOption.NEXT] and len(queue_items) > 100: + return await self.load(queue_items) + if queue_opt == QueueOption.NEXT: + return await self.insert(queue_items, 1) + if queue_opt == QueueOption.PLAY: + return await self.insert(queue_items, 0) + if queue_opt == QueueOption.ADD: + return await self.append(queue_items) + + async def set_shuffle_enabled(self, enable_shuffle: bool) -> None: + """Set shuffle.""" + if not self._shuffle_enabled and enable_shuffle: + # shuffle requested + self._shuffle_enabled = True + if self._current_index is not None: + played_items = self.items[: self._current_index] + next_items = self.__shuffle_items(self.items[self._current_index + 1 :]) + items = played_items + [self.current_item] + next_items + self.mass.signal_event(EventType.QUEUE_UPDATED, self) + await self.update(items) + elif self._shuffle_enabled and not enable_shuffle: + # unshuffle + self._shuffle_enabled = False + if self._current_index is not None: + played_items = self.items[: self._current_index] + next_items = self.items[self._current_index + 1 :] + next_items.sort(key=lambda x: x.sort_index, reverse=False) + items = played_items + [self.current_item] + next_items + self.mass.signal_event(EventType.QUEUE_UPDATED, self) + await self.update(items) + + async def set_repeat_enabled(self, enable_repeat: bool) -> None: + """Set the repeat mode for this queue.""" + if self._repeat_enabled != enable_repeat: + self._repeat_enabled = enable_repeat + self.mass.signal_event(EventType.QUEUE_UPDATED, self) + await self._save_state() + + async def set_crossfade_duration(self, duration: int) -> None: + """Set the crossfade duration for this queue, 0 to disable.""" + duration = max(duration, 10) + if self._crossfade_duration != duration: + self._crossfade_duration = duration + self.mass.signal_event(EventType.QUEUE_UPDATED, self) + await self._save_state() + + async def set_volume_normalization_enabled(self, enable: bool) -> None: + """Set volume normalization.""" + if self._repeat_enabled != enable: + self._repeat_enabled = enable + self.mass.signal_event(EventType.QUEUE_UPDATED, self) + await self._save_state() + + async def set_volume_normalization_target(self, target: int) -> None: + """Set the target for the volume normalization in LUFS (default is -23).""" + target = min(target, 0) + target = max(target, -40) + if self._volume_normalization_target != target: + self._volume_normalization_target = target + self.mass.signal_event(EventType.QUEUE_UPDATED, self) + await self._save_state() + async def stop(self) -> None: """Stop command on queue player.""" - return await self.player.cmd_stop() + # redirect to underlying player + await self.player.stop() async def play(self) -> None: """Play (unpause) command on queue player.""" - return await self.player.cmd_play() + if self.player.state == PlayerState.PAUSED: + await self.player.play() + else: + await self.resume() async def pause(self) -> None: """Pause command on queue player.""" - return await self.player.cmd_pause() + # redirect to underlying player + await self.player.pause() async def next(self) -> None: """Play the next track in the queue.""" - if self.cur_index is None: + if self._current_index is None: + return + if self.next_index is None: return - if self.use_queue_stream: - return await self.play_index(self.cur_index + 1) - return await self.player.cmd_next() + await self.play_index(self.next_index) async def previous(self) -> None: """Play the previous track in the queue.""" - if self.cur_index is None: + if self._current_index is None: return - if self.use_queue_stream: - return await self.play_index(self.cur_index - 1) - return await self.player.cmd_previous() + await self.play_index(self._current_index - 1) async def resume(self) -> None: """Resume previous queue.""" # TODO: Support skipping to last known position - if self.items: - prev_index = self.cur_index - if self.use_queue_stream: - await self.play_index(prev_index) - else: - # at this point we don't know if the queue is synced with the player - # so just to be safe we send the queue_items to the player - self._items = self._items[prev_index:] - return await self.player.cmd_queue_load( - self._items, self.repeat_enabled - ) + if self._items: + prev_index = self._current_index + await self.play_index(prev_index) else: - LOGGER.warning( + self.logger.warning( "resume queue requested for %s but queue is empty", self.queue_id ) - async def play_index(self, index: Union[int, str]) -> None: + async def play_index(self, index: int | str) -> None: """Play item at index (or item_id) X in queue.""" if not isinstance(index, int): - index = self.__index_by_id(index) + index = self.index_by_id(index) if index is None: - raise FileNotFoundError("Unknown index/id: %s" % index) + raise FileNotFoundError(f"Unknown index/id: {index}") if not len(self.items) > index: return - self._cur_index = index - self._queue_stream_next_index = index - if self.use_queue_stream: - queue_stream_url = self.get_stream_url() - return await self.player.cmd_play_uri(queue_stream_url) - if self.supports_queue: - try: - return await self.player.cmd_queue_play_index(index) - except NotImplementedError: - # not supported by player, use load queue instead - LOGGER.debug( - "cmd_queue_insert not supported by player, fallback to cmd_queue_load " - ) - self._items = self._items[index:] - return await self.player.cmd_queue_load(self._items) - else: - return await self.player.cmd_play_uri(self._items[index].stream_url) + self._current_index = index + + # send stream url to player connected to this queue + self._stream_url = self.mass.players.streams.get_stream_url(self.queue_id) + await self.player.play_url(self._stream_url) async def move_item(self, queue_item_id: str, pos_shift: int = 1) -> None: """ @@ -366,15 +423,15 @@ class PlayerQueue: move item x positions up if negative value move item to top of queue as next item if 0 """ - items = self.items.copy() - item_index = self.__index_by_id(queue_item_id) + items = self._items.copy() + item_index = self.index_by_id(queue_item_id) if pos_shift == 0 and self.player.state == PlayerState.PLAYING: - new_index = self.cur_index + 1 + new_index = self._current_index + 1 elif pos_shift == 0: - new_index = self.cur_index + new_index = self._current_index else: new_index = item_index + pos_shift - if (new_index < self.cur_index) or (new_index > len(self.items)): + if (new_index < self._current_index) or (new_index > len(self.items)): return # move the item in the list items.insert(new_index, items.pop(item_index)) @@ -387,12 +444,9 @@ class PlayerQueue: if self._shuffle_enabled and len(queue_items) > 5: queue_items = self.__shuffle_items(queue_items) self._items = queue_items - if self.use_queue_stream: - await self.play_index(0) - else: - await self.player.cmd_queue_load(queue_items, self.repeat_enabled) - self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self) - create_task(self._save_state()) + self.mass.signal_event(EventType.QUEUE_ITEMS_UPDATED, self) + await self.play_index(0) + await self._save_state() async def insert(self, queue_items: List[QueueItem], offset: int = 0) -> None: """ @@ -403,10 +457,9 @@ class PlayerQueue: :param queue_items: a list of QueueItem :param offset: offset from current queue position """ - - if not self.items or self.cur_index is None: + if not self.items or self._current_index is None: return await self.load(queue_items) - insert_at_index = self.cur_index + offset + insert_at_index = self._current_index + offset for index, item in enumerate(queue_items): item.sort_index = insert_at_index + index if self.shuffle_enabled and len(queue_items) > 5: @@ -424,142 +477,105 @@ class PlayerQueue: + queue_items + self._items[insert_at_index:] ) - if self.use_queue_stream: - if offset == 0: - await self.play_index(insert_at_index) - else: - # send queue to player's own implementation - try: - await self.player.cmd_queue_insert(queue_items, insert_at_index) - except NotImplementedError: - # not supported by player, use load queue instead - LOGGER.debug( - "cmd_queue_insert not supported by player, fallback to cmd_queue_load " - ) - self._items = self._items[self.cur_index + offset :] - return await self.player.cmd_queue_load( - self._items, self.repeat_enabled - ) - self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self) - create_task(self._save_state()) + + if offset == 0: + await self.play_index(insert_at_index) + + self.mass.signal_event(EventType.QUEUE_ITEMS_UPDATED, self) + await self._save_state() async def append(self, queue_items: List[QueueItem]) -> None: """Append new items at the end of the queue.""" for index, item in enumerate(queue_items): item.sort_index = len(self.items) + index if self.shuffle_enabled: - played_items = self.items[: self.cur_index] - next_items = self.items[self.cur_index + 1 :] + queue_items + played_items = self.items[: self._current_index] + next_items = self.items[self._current_index + 1 :] + queue_items next_items = self.__shuffle_items(next_items) - items = played_items + [self.cur_item] + next_items - return await self.update(items) + items = played_items + [self.current_item] + next_items + await self.update(items) + return self._items = self._items + queue_items - if not self.use_queue_stream: - # send queue to player's own implementation - try: - await self.player.cmd_queue_append(queue_items) - except NotImplementedError: - # not supported by player, use load queue instead - LOGGER.debug( - "cmd_queue_append not supported by player, fallback to cmd_queue_load " - ) - self._items = self._items[self.cur_index :] - return await self.player.cmd_queue_load( - self._items, self.repeat_enabled - ) - self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self) - create_task(self._save_state()) + self.mass.signal_event(EventType.QUEUE_ITEMS_UPDATED, self) + await self._save_state() async def update(self, queue_items: List[QueueItem]) -> None: """Update the existing queue items, mostly caused by reordering.""" self._items = queue_items - if not self.use_queue_stream: - # send queue to player's own implementation - try: - await self.player.cmd_queue_update(queue_items) - except NotImplementedError: - # not supported by player, use load queue instead - LOGGER.debug( - "cmd_queue_update not supported by player, fallback to cmd_queue_load " - ) - self._items = self._items[self.cur_index :] - await self.player.cmd_queue_load(self._items, self.repeat_enabled) - self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self) - create_task(self._save_state()) + self.mass.signal_event(EventType.QUEUE_ITEMS_UPDATED, self) + await self._save_state() async def clear(self) -> None: """Clear all items in the queue.""" - await self.mass.players.cmd_stop(self.queue_id) - self._items = [] - if self.supports_queue: - # send queue cmd to player's own implementation - try: - await self.player.cmd_queue_clear() - except NotImplementedError: - # not supported by player, try update instead - try: - await self.player.cmd_queue_update([]) - except NotImplementedError: - # not supported by player, ignore - pass - self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self) - - @callback - def update_state(self) -> None: + await self.stop() + await self.update([]) + + def on_player_update(self) -> None: + """Call when player updates.""" + self._last_player_update = time.time() + if self._last_state != self.player.state: + self._last_state = self.player.state + # handle case where stream stopped on purpose and we need to restart it + if self.player.state != PlayerState.PLAYING and self._signal_next: + self._signal_next = False + create_task(self.play()) + # start updater task if needed + if self.player.state == PlayerState.PLAYING: + if not self._update_task: + self._update_task = create_task(self.__update_task()) + else: + if self._update_task: + self._update_task.cancel() + self._update_task = None + + if not self.update_state(): + # fire event anyway when player updated. + self.mass.signal_event(EventType.QUEUE_UPDATED, self) + + def update_state(self) -> bool: """Update queue details, called when player updates.""" - new_index = self._cur_index - track_time = self._cur_item_time + new_index = self._current_index + track_time = self._current_item_time new_item_loaded = False - # handle queue stream - if ( - self.use_queue_stream - and self.player.state == PlayerState.PLAYING - and self.player.elapsed_time > 1 - ): + # if self.player.state == PlayerState.PLAYING and self.elapsed_time > 1: + if self.player.state == PlayerState.PLAYING: new_index, track_time = self.__get_queue_stream_index() - # normal queue based approach - elif not self.use_queue_stream: - track_time = self.player.elapsed_time - for index, queue_item in enumerate(self.items): - if queue_item.stream_url == self.player.current_uri: - new_index = index - break # process new index - if self._cur_index != new_index: + if self._current_index != new_index: # queue track updated - self._cur_index = new_index + self._current_index = new_index # check if a new track is loaded, wait for the streamdetails if ( - self.cur_item - and self._last_item != self.cur_item - and self.cur_item.streamdetails + self.current_item + and self._last_item != self.current_item + and self.current_item.streamdetails ): # new active item in queue new_item_loaded = True # invalidate previous streamdetails if self._last_item: self._last_item.streamdetails = None - self._last_item = self.cur_item + self._last_item = self.current_item # update vars and signal update on eventbus if needed - prev_item_time = int(self._cur_item_time) - self._cur_item_time = int(track_time) - if self._last_playback_state != self.state: - # fire event with updated state - self.signal_update() - self._last_playback_state = self.state - elif abs(prev_item_time - self._cur_item_time) > 3: - # only send media_position if it changed more then 3 seconds (e.g. skipping) - self.signal_update() - elif new_item_loaded: - self.signal_update() + prev_item_time = int(self._current_item_time) + self._current_item_time = int(track_time) + if new_item_loaded or abs(prev_item_time - self._current_item_time) >= 1: + self.mass.signal_event(EventType.QUEUE_UPDATED, self) + return True + return False + + async def queue_stream_prepare(self) -> StreamDetails | None: + """Call when queue_streamer is about to start playing.""" + if next_item := self.next_item: + return await get_stream_details(self.mass, next_item, self.queue_id) + return None async def queue_stream_start(self) -> None: """Call when queue_streamer starts playing the queue stream.""" - self._cur_item_time = 0 - self._cur_index = self._queue_stream_next_index - self._queue_stream_next_index += 1 - self._queue_stream_start_index = self._cur_index - return self._cur_index + self._current_item_time = 0 + self._current_index = self.next_index + self._start_index = self._current_index + return self._current_index async def queue_stream_next(self, cur_index: int) -> None: """Call when queue_streamer loads next track in buffer.""" @@ -569,50 +585,41 @@ class PlayerQueue: elif self._repeat_enabled: # repeat enabled, start queue at beginning next_index = 0 - self._queue_stream_next_index = next_index + 1 + self._next_index = next_index + 1 return next_index - def to_dict(self) -> Dict[str, Any]: - """Instance attributes as dict so it can be serialized to json.""" - return { - "queue_id": self.player.player_id, - "queue_name": self.player.calculated_state.name, - "shuffle_enabled": self.shuffle_enabled, - "repeat_enabled": self.repeat_enabled, - "crossfade_enabled": self.crossfade_enabled, - "items": len(self._items), - "cur_item_id": self.cur_item_id, - "cur_index": self.cur_index, - "next_index": self.next_index, - "cur_item": self.cur_item.to_dict() if self.cur_item else None, - "cur_item_time": int(self.cur_item_time), - "next_item": self.next_item.to_dict() if self.next_item else None, - "state": self.state.value, - "updated_at": now().isoformat(), - } - - def signal_update(self): - """Signal update of this Queue to eventbus.""" - self.mass.eventbus.signal( - EVENT_QUEUE_UPDATED, - self, - ) + async def queue_stream_signal_next(self): + """Indicate that queue stream needs to start nex index once playback finished.""" + self._signal_next = True + + async def __update_task(self) -> None: + """Update player queue every interval.""" + while True: + self.update_state() + await asyncio.sleep(1) + + def __get_total_elapsed_time(self) -> int: + """Calculate the total elapsed time of the queue(player).""" + if self.player.state == PlayerState.PLAYING: + time_diff = time.time() - self._last_player_update + return int(self.player.elapsed_time + time_diff) + if self.player.state == PlayerState.PAUSED: + return self.player.elapsed_time + return 0 - @callback def __get_queue_stream_index(self) -> Tuple[int, int]: - """Get index of queue stream.""" - # player is playing a constant stream of the queue so we need to do this the hard way + """Calculate current queue index and current track elapsed time.""" + # player is playing a constant stream so we need to do this the hard way queue_index = 0 - elapsed_time_queue = self.player.elapsed_time + elapsed_time_queue = self.__get_total_elapsed_time() total_time = 0 track_time = 0 - if self.items and len(self.items) > self._queue_stream_start_index: - queue_index = ( - self._queue_stream_start_index - ) # holds the last starting position + if self._items and len(self._items) > self._start_index: + # start_index: holds the last starting position + queue_index = self._start_index queue_track = None - while len(self.items) > queue_index: - queue_track = self.items[queue_index] + while len(self._items) > queue_index: + queue_track = self._items[queue_index] if elapsed_time_queue > (queue_track.duration + total_time): total_time += queue_track.duration queue_index += 1 @@ -622,54 +629,46 @@ class PlayerQueue: return queue_index, track_time @staticmethod - def __shuffle_items(queue_items) -> List[QueueItem]: + def __shuffle_items(queue_items: List[QueueItem]) -> List[QueueItem]: """Shuffle a list of tracks.""" # for now we use default python random function - # can be extended with some more magic last_played and stuff + # can be extended with some more magic based on last_played and stuff return random.sample(queue_items, len(queue_items)) - def __index_by_id(self, queue_item_id) -> OptionalInt: - """Get index by queue_item_id.""" - item_index = None - for index, item in enumerate(self.items): - if item.queue_item_id == queue_item_id: - item_index = index - return item_index - async def _restore_saved_state(self) -> None: - """Try to load the saved queue for this player from cache file.""" - cache_str = "queue_state_%s" % self.queue_id - cache_data = await self.mass.cache.get(cache_str) - if cache_data: - self._shuffle_enabled = cache_data["shuffle_enabled"] - self._repeat_enabled = cache_data["repeat_enabled"] - self._items = cache_data["items"] - self._cur_index = cache_data.get("cur_index", 0) - self._queue_stream_next_index = self._cur_index - - def create_queue_item(self, *args, **kwargs): - """Create QueueItem including correct stream URL.""" - if args and isinstance(args[0], (Track, Radio)): - new_item = QueueItem.from_track(args[0]) - else: - new_item = QueueItem(*args, **kwargs) - new_item.stream_url = "%s/queue/%s/%s" % ( - self.mass.web.stream_url, - self.queue_id, - new_item.queue_item_id, + """Try to load the saved state from database.""" + if db_row := await self.mass.database.get_row( + "queue_settings", {"queue_id": self.queue_id} + ): + self._shuffle_enabled = db_row["shuffle_enabled"] + self._repeat_enabled = db_row["repeat_enabled"] + self._crossfade_duration = db_row["crossfade_duration"] + if queue_cache := await self.mass.cache.get(f"queue_items.{self.queue_id}"): + self._items = queue_cache["items"] + self._current_index = queue_cache["current_index"] + + async def _save_state(self) -> None: + """Save state in database.""" + # save queue settings in db + await self.mass.database.insert_or_replace( + "queue_settings", + { + "queue_id": self.queue_id, + "shuffle_enabled": self._shuffle_enabled, + "repeat_enabled": self.repeat_enabled, + "crossfade_duration": self._crossfade_duration, + "volume_normalization_enabled": self._volume_normalization_enabled, + "volume_normalization_target": self._volume_normalization_target, + }, ) - return new_item - # pylint: enable=unused-argument + # store current items in cache + async def cache_items(): + await self.mass.cache.set( + f"queue_items.{self.queue_id}", + {"items": self._items, "current_index": self._current_index}, + ) - async def _save_state(self) -> None: - """Save current queue settings to file.""" - cache_str = "queue_state_%s" % self.queue_id - cache_data = { - "shuffle_enabled": self._shuffle_enabled, - "repeat_enabled": self._repeat_enabled, - "items": self._items, - "cur_index": self._cur_index, - } - await self.mass.cache.set(cache_str, cache_data) - LOGGER.debug("queue state saved to file for player %s", self.queue_id) + if self._save_task and not self._save_task.cancelled(): + return + self._save_task = self.mass.loop.call_later(60, create_task, cache_items) diff --git a/music_assistant/models/provider.py b/music_assistant/models/provider.py index 66ce26b2..8346ba94 100644 --- a/music_assistant/models/provider.py +++ b/music_assistant/models/provider.py @@ -1,151 +1,65 @@ -"""Models for providers/plugins.""" +"""Model for a Music Providers.""" + +from __future__ import annotations from abc import abstractmethod -from enum import Enum -from typing import Dict, List, Optional +from logging import Logger +from typing import List -from music_assistant.helpers.typing import MusicAssistant, Players -from music_assistant.models.config_entry import ConfigEntry -from music_assistant.models.media_types import ( +from music_assistant.helpers.typing import MusicAssistant +from music_assistant.models.media_items import ( Album, Artist, + MediaItemType, MediaType, Playlist, Radio, - SearchResult, Track, ) -from music_assistant.models.streamdetails import StreamDetails - - -class ProviderType(Enum): - """Enum with plugin types.""" +from music_assistant.models.player_queue import StreamDetails - MUSIC_PROVIDER = "music_provider" - PLAYER_PROVIDER = "player_provider" - METADATA_PROVIDER = "metadata_provider" - PLUGIN = "plugin" +class MusicProvider: + """Model for a Music Provider.""" -class Provider: - """Base model for a provider/plugin.""" + _attr_id: str = None + _attr_name: str = None + _attr_available: bool = True + _attr_supported_mediatypes: List[MediaType] = [] + mass: MusicAssistant = None # set by setup + logger: Logger = None # set by setup - mass: MusicAssistant = None # will be set automagically while loading the provider - available: bool = False # will be set automagically while loading the provider - - @property @abstractmethod - def type(self) -> ProviderType: - """Return ProviderType.""" + async def setup(self) -> None: + """ + Handle async initialization of the provider. + + Called when provider is registered. + """ @property - @abstractmethod def id(self) -> str: """Return provider ID for this provider.""" + return self._attr_id @property - @abstractmethod def name(self) -> str: """Return provider Name for this provider.""" + return self._attr_name @property - @abstractmethod - def config_entries(self) -> List[ConfigEntry]: - """Return Config Entries for this provider.""" - - @abstractmethod - async def on_start(self) -> bool: - """ - Handle initialization of the provider based on config. - - Return bool if start was succesfull. Called on startup. - """ - raise NotImplementedError - - @abstractmethod - async def on_stop(self) -> None: - """Handle correct close/cleanup of the provider on exit. Called on shutdown/reload.""" - - -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 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 get_artist_images(self, mb_artist_id: str) -> Dict: - """Retrieve artist metadata as dict by musicbrainz artist id.""" - raise NotImplementedError - - async def 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 + def available(self) -> bool: + """Return boolean if this provider is available/initialized.""" + return self._attr_available @property def supported_mediatypes(self) -> List[MediaType]: """Return MediaTypes the provider supports.""" - return [ - MediaType.ALBUM, - MediaType.ARTIST, - MediaType.PLAYLIST, - MediaType.RADIO, - MediaType.TRACK, - ] + return self._attr_supported_mediatypes async def search( - self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 - ) -> SearchResult: + self, search_query: str, media_types=List[MediaType] | None, limit: int = 5 + ) -> List[MediaItemType]: """ Perform search on musicprovider. @@ -175,7 +89,7 @@ class MusicProvider(Provider): if MediaType.PLAYLIST in self.supported_mediatypes: raise NotImplementedError - async def get_radios(self) -> List[Radio]: + async def get_library_radios(self) -> List[Radio]: """Retrieve library/subscribed radio stations from the provider.""" if MediaType.RADIO in self.supported_mediatypes: raise NotImplementedError @@ -250,3 +164,30 @@ class MusicProvider(Provider): async def get_stream_details(self, item_id: str) -> StreamDetails: """Get streamdetails for a track/radio.""" raise NotImplementedError + + # some helper methods below + async def get_library_items(self, media_type: MediaType) -> List[MediaItemType]: + """Return library items for given media_type.""" + if media_type == MediaType.ARTIST: + return await self.get_library_artists() + if media_type == MediaType.ALBUM: + return await self.get_library_albums() + if media_type == MediaType.TRACK: + return await self.get_library_tracks() + if media_type == MediaType.PLAYLIST: + return await self.get_library_playlists() + if media_type == MediaType.RADIO: + return await self.get_library_radios() + + async def get_item(self, media_type: MediaType, prov_item_id: str) -> MediaItemType: + """Get single MediaItem from provider.""" + if media_type == MediaType.ARTIST: + return await self.get_artist(prov_item_id) + if media_type == MediaType.ALBUM: + return await self.get_album(prov_item_id) + if media_type == MediaType.TRACK: + return await self.get_track(prov_item_id) + if media_type == MediaType.PLAYLIST: + return await self.get_playlist(prov_item_id) + if media_type == MediaType.RADIO: + return await self.get_radio(prov_item_id) diff --git a/music_assistant/models/streamdetails.py b/music_assistant/models/streamdetails.py deleted file mode 100644 index af4ed130..00000000 --- a/music_assistant/models/streamdetails.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Models and helpers for the streamdetails of a MediaItem.""" - -from dataclasses import dataclass, field -from enum import Enum -from typing import Any, Dict, Optional - -from mashumaro.serializer.base.dict import DataClassDictMixin -from music_assistant.models.media_types import MediaType - - -class StreamType(Enum): - """Enum with stream types.""" - - EXECUTABLE = "executable" - URL = "url" - FILE = "file" - CACHE = "cache" - - -class ContentType(Enum): - """Enum with audio content types supported by ffmpeg.""" - - OGG = "ogg" - FLAC = "flac" - MP3 = "mp3" - AAC = "aac" - MPEG = "mpeg" - PCM_S16LE = "s16le" # PCM signed 16-bit little-endian - PCM_S24LE = "s24le" # PCM signed 24-bit little-endian - PCM_S32LE = "s32le" # PCM signed 32-bit little-endian - PCM_F32LE = "f32le" # PCM 32-bit floating-point little-endian - PCM_F64LE = "f64le" # PCM 64-bit floating-point little-endian - - def is_pcm(self): - """Return if contentype is PCM.""" - return self.name.startswith("PCM") - - def sox_supported(self): - """Return if ContentType is supported by SoX.""" - return self not in [ContentType.AAC, ContentType.MPEG] - - def sox_format(self): - """Convert the ContentType to SoX compatible format.""" - if not self.sox_supported(): - raise NotImplementedError - return self.value.replace("le", "") - - -@dataclass -class StreamDetails(DataClassDictMixin): - """Model for streamdetails.""" - - type: StreamType - provider: str - item_id: str - path: str - content_type: ContentType - player_id: str = "" - details: Dict[str, Any] = field(default_factory=dict) - seconds_played: int = 0 - gain_correct: float = 0 - loudness: Optional[float] = None - sample_rate: Optional[int] = None - bit_depth: Optional[int] = None - media_type: MediaType = MediaType.TRACK - - def __post_serialize__(self, d: Dict[Any, Any]) -> Dict[Any, Any]: - """Exclude internal fields from dict.""" - d.pop("path") - d.pop("details") - return d - - def __str__(self): - """Return pretty printable string of object.""" - return f"{self.type.value}/{self.content_type.value} - {self.provider}/{self.item_id}" diff --git a/music_assistant/providers/__init__.py b/music_assistant/providers/__init__.py index 2209974f..01895ef6 100644 --- a/music_assistant/providers/__init__.py +++ b/music_assistant/providers/__init__.py @@ -1 +1 @@ -"""Providers package.""" +"""Package with Music Providers.""" diff --git a/music_assistant/providers/builtin_player/__init__.py b/music_assistant/providers/builtin_player/__init__.py deleted file mode 100644 index b083cdbd..00000000 --- a/music_assistant/providers/builtin_player/__init__.py +++ /dev/null @@ -1,252 +0,0 @@ -"""Builtin player provider.""" -import logging -import time -from typing import List - -from music_assistant.helpers.util import create_task, run_periodic -from music_assistant.models.config_entry import ConfigEntry -from music_assistant.models.player import DeviceInfo, Player, PlayerFeature, PlayerState -from music_assistant.models.provider import PlayerProvider - -PROV_ID = "builtin_player" -PROV_NAME = "Music Assistant" -LOGGER = logging.getLogger(PROV_ID) - -CONFIG_ENTRIES = [] -PLAYER_CONFIG_ENTRIES = [] -PLAYER_FEATURES = [] - -WS_COMMAND_WSPLAYER_CMD = "wsplayer command" -WS_COMMAND_WSPLAYER_STATE = "wsplayer state" -WS_COMMAND_WSPLAYER_REGISTER = "wsplayer register" - - -async def setup(mass): - """Perform async setup of this Plugin/Provider.""" - prov = MassPlayerProvider() - await mass.register_provider(prov) - - -class MassPlayerProvider(PlayerProvider): - """ - Built-in PlayerProvider. - - Provides virtual players in the frontend using websockets. - """ - - @property - def id(self) -> str: - """Return provider ID for this provider.""" - return PROV_ID - - @property - def name(self) -> str: - """Return provider Name for this provider.""" - return PROV_NAME - - @property - def config_entries(self) -> List[ConfigEntry]: - """Return Config Entries for this provider.""" - return [] - - async def on_start(self) -> bool: - """Handle initialization of the provider based on config.""" - # listen for websockets commands to dynamically create players - create_task(self.check_players()) - # self.mass.web.register_api_route( - # WS_COMMAND_WSPLAYER_REGISTER, self.handle_ws_player - # ) - # self.mass.web.register_api_route(WS_COMMAND_WSPLAYER_STATE, self.handle_ws_player) - return True - - async def on_stop(self): - """Handle correct close/cleanup of the provider on exit.""" - for player in self.players: - await player.cmd_stop() - - async def handle_ws_player(self, player_id: str, details: dict): - """Handle state message from ws player.""" - player = self.mass.players.get_player(player_id) - if not player: - # register new player - player = WebsocketsPlayer(player_id, details["name"]) - await self.mass.players.add_player(player) - await player.handle_player(details) - - @run_periodic(30) - async def check_players(self) -> None: - """Invalidate players that did not send a heartbeat message in a while.""" - cur_time = time.time() - offline_players = set() - for player in self.players: - if not isinstance(player, WebsocketsPlayer): - continue - if cur_time - player.last_message > 30: - offline_players.add(player.player_id) - for player_id in offline_players: - await self.mass.players.remove_player(player_id) - - -class WebsocketsPlayer(Player): - """ - Implementation of a player using pure HTML/javascript. - - Used in the front-end. - Communication is handled through the websocket connection - and our internal event bus. - """ - - def __init__(self, player_id: str, player_name: str): - """Initialize the wsplayer.""" - self._player_id = player_id - self._player_name = player_name - self._powered = True - self._elapsed_time = 0 - self._state = PlayerState.IDLE - self._current_uri = "" - self._volume_level = 100 - self._muted = False - self._device_info = DeviceInfo() - self.last_message = time.time() - super().__init__() - - async def handle_player(self, data: dict): - """Handle state event from player.""" - if "volume_level" in data: - self._volume_level = data["volume_level"] - if "muted" in data: - self._muted = data["muted"] - if "state" in data: - self._state = PlayerState(data["state"]) - if "elapsed_time" in data: - self._elapsed_time = data["elapsed_time"] - if "current_uri" in data: - self._current_uri = data["current_uri"] - if "name" in data: - self._player_name = data["name"] - if "device_info" in data: - for key, value in data["device_info"].items(): - setattr(self._device_info, key, value) - self.last_message = time.time() - self.update_state() - - @property - def player_id(self) -> str: - """Return player id of this player.""" - return self._player_id - - @property - def provider_id(self) -> str: - """Return provider id of this player.""" - return PROV_ID - - @property - def name(self) -> str: - """Return name of the player.""" - return self._player_name - - @property - def powered(self) -> bool: - """Return current power state of player.""" - return self._powered - - @property - def elapsed_time(self) -> int: - """Return elapsed time of current playing media in seconds.""" - return self._elapsed_time - - @property - def state(self) -> PlayerState: - """Return current PlayerState of player.""" - return self._state - - @property - def current_uri(self) -> str: - """Return currently loaded uri of player (if any).""" - return self._current_uri - - @property - def volume_level(self) -> int: - """Return current volume level of player (scale 0..100).""" - return self._volume_level - - @property - def muted(self) -> bool: - """Return current mute state of player.""" - return self._muted - - @property - def device_info(self) -> DeviceInfo: - """Return the device info for this player.""" - return self._device_info - - @property - def should_poll(self) -> bool: - """Return True if this player should be polled for state updates.""" - return False - - @property - def features(self) -> List[PlayerFeature]: - """Return list of features this player supports.""" - return PLAYER_FEATURES - - @property - def config_entries(self) -> List[ConfigEntry]: - """Return player specific config entries (if any).""" - return PLAYER_CONFIG_ENTRIES - - async def cmd_play_uri(self, uri: str) -> None: - """ - Play the specified uri/url on the player. - - :param uri: uri/url to send to the player. - """ - data = {"player_id": self.player_id, "cmd": "play_uri", "uri": uri} - self.mass.eventbus.signal(WS_COMMAND_WSPLAYER_CMD, data) - - async def cmd_stop(self) -> None: - """Send STOP command to player.""" - data = {"player_id": self.player_id, "cmd": "stop"} - self.mass.eventbus.signal(WS_COMMAND_WSPLAYER_CMD, data) - - async def cmd_play(self) -> None: - """Send PLAY command to player.""" - data = {"player_id": self.player_id, "cmd": "play"} - self.mass.eventbus.signal(WS_COMMAND_WSPLAYER_CMD, data) - - async def cmd_pause(self) -> None: - """Send PAUSE command to player.""" - data = {"player_id": self.player_id, "cmd": "pause"} - self.mass.eventbus.signal(WS_COMMAND_WSPLAYER_CMD, data) - - async def cmd_power_on(self) -> None: - """Send POWER ON command to player.""" - self._powered = True - self.update_state() - - async def cmd_power_off(self) -> None: - """Send POWER OFF command to player.""" - self._powered = False - self.update_state() - - async def cmd_volume_set(self, volume_level: int) -> None: - """ - Send volume level command to player. - - :param volume_level: volume level to set (0..100). - """ - data = { - "player_id": self.player_id, - "cmd": "volume_set", - "volume_level": volume_level, - } - self.mass.eventbus.signal(WS_COMMAND_WSPLAYER_CMD, data) - - async def cmd_volume_mute(self, is_muted: bool = False) -> None: - """ - Send volume MUTE command to given player. - - :param is_muted: bool with new mute state. - """ - data = {"player_id": self.player_id, "cmd": "volume_mute", "is_muted": is_muted} - self.mass.eventbus.signal(WS_COMMAND_WSPLAYER_CMD, data) diff --git a/music_assistant/providers/builtin_player/icon.png b/music_assistant/providers/builtin_player/icon.png deleted file mode 100644 index 092121e1..00000000 Binary files a/music_assistant/providers/builtin_player/icon.png and /dev/null differ diff --git a/music_assistant/providers/chromecast/__init__.py b/music_assistant/providers/chromecast/__init__.py deleted file mode 100644 index f88ead9c..00000000 --- a/music_assistant/providers/chromecast/__init__.py +++ /dev/null @@ -1,110 +0,0 @@ -"""ChromeCast playerprovider.""" - -import logging -from typing import List, Optional - -import pychromecast -from music_assistant.helpers.util import create_task -from music_assistant.models.config_entry import ConfigEntry -from music_assistant.models.provider import PlayerProvider -from pychromecast.controllers.multizone import MultizoneManager - -from .const import PROV_ID, PROV_NAME, PROVIDER_CONFIG_ENTRIES -from .helpers import ChromecastInfo -from .player import ChromecastPlayer - -LOGGER = logging.getLogger(PROV_ID) - - -async def setup(mass): - """Perform async setup of this Plugin/Provider.""" - logging.getLogger("pychromecast").setLevel(logging.WARNING) - prov = ChromecastProvider() - await mass.register_provider(prov) - - -class ChromecastProvider(PlayerProvider): - """Support for ChromeCast Audio PlayerProvider.""" - - def __init__(self, *args, **kwargs): - """Initialize.""" - self.mz_mgr = MultizoneManager() - self._browser: Optional[pychromecast.discovery.CastBrowser] = None - super().__init__(*args, **kwargs) - - @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 PROVIDER_CONFIG_ENTRIES - - async def on_start(self) -> bool: - """Handle initialization of the provider based on config.""" - self._browser = pychromecast.discovery.CastBrowser( - pychromecast.discovery.SimpleCastListener( - add_callback=self._discover_chromecast, - remove_callback=self._remove_chromecast, - update_callback=self._discover_chromecast, - ), - self.mass.zeroconf, - ) - # start discovery in executor - create_task(self._browser.start_discovery) - return True - - async def on_stop(self): - """Handle correct close/cleanup of the provider on exit.""" - if not self._browser: - return - # stop discovery - create_task(self._browser.stop_discovery) - - def _discover_chromecast(self, uuid, _): - """Discover a Chromecast.""" - cast_info: pychromecast.models.CastInfo = self._browser.devices[uuid] - - info = ChromecastInfo( - services=cast_info.services, - uuid=cast_info.uuid, - model_name=cast_info.model_name, - friendly_name=cast_info.friendly_name, - cast_type=cast_info.cast_type, - manufacturer=cast_info.manufacturer, - ) - - if info.uuid is None: - LOGGER.error("Discovered chromecast without uuid %s", info) - return - - info = info.fill_out_missing_chromecast_info(self.mass.zeroconf) - if info.is_dynamic_group: - LOGGER.warning("Discovered dynamic cast group which will be ignored.") - return - - LOGGER.debug("Discovered new or updated chromecast %s", info) - player_id = str(info.uuid) - player = self.mass.players.get_player(player_id) - if not player: - player = ChromecastPlayer(self.mass, info) - - # if player was already added, the player will take care of reconnects itself. - player.set_cast_info(info) - create_task(self.mass.players.add_player(player)) - - @staticmethod - def _remove_chromecast(uuid, service, cast_info): - """Handle zeroconf discovery of a removed chromecast.""" - # pylint: disable=unused-argument - player_id = str(service[1]) - friendly_name = service[3] - LOGGER.debug("Chromecast removed: %s - %s", friendly_name, player_id) - # we ignore this event completely as the Chromecast socket client handles this itself diff --git a/music_assistant/providers/chromecast/const.py b/music_assistant/providers/chromecast/const.py deleted file mode 100644 index fe355031..00000000 --- a/music_assistant/providers/chromecast/const.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Constants for the implementation.""" - -PROV_ID = "chromecast" -PROV_NAME = "Chromecast" - - -PROVIDER_CONFIG_ENTRIES = [] - -PLAYER_CONFIG_ENTRIES = [] diff --git a/music_assistant/providers/chromecast/helpers.py b/music_assistant/providers/chromecast/helpers.py deleted file mode 100644 index dcbadd61..00000000 --- a/music_assistant/providers/chromecast/helpers.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Helpers to deal with Cast devices.""" -from __future__ import annotations - -from typing import Optional - -import attr -from pychromecast import dial -from pychromecast.const import CAST_TYPE_GROUP - -DEFAULT_PORT = 8009 - - -@attr.s(slots=True, frozen=True) -class ChromecastInfo: - """ - Class to hold all data about a chromecast for creating connections. - - This also has the same attributes as the mDNS fields by zeroconf. - """ - - services: set | None = attr.ib() - uuid: str = attr.ib(converter=attr.converters.optional(str)) - model_name: str = attr.ib() - friendly_name: str = attr.ib() - cast_type: str = attr.ib() - manufacturer: str = attr.ib() - is_dynamic_group = attr.ib(type=Optional[bool], default=None) - - @property - def is_audio_group(self) -> bool: - """Return if the cast is an audio group.""" - return self.cast_type == CAST_TYPE_GROUP - - def fill_out_missing_chromecast_info(self, zconf) -> ChromecastInfo: - """ - Return a new ChromecastInfo object with missing attributes filled in. - - Uses blocking HTTP / HTTPS. - """ - if not self.is_audio_group or self.is_dynamic_group is not None: - # We have all information, no need to check HTTP API. - return self - - # Fill out missing group information via HTTP API. - is_dynamic_group = False - http_group_status = None - http_group_status = dial.get_multizone_status( - None, - services=self.services, - zconf=zconf, - ) - if http_group_status is not None: - is_dynamic_group = any( - str(g.uuid) == self.uuid for g in http_group_status.dynamic_groups - ) - - return ChromecastInfo( - services=self.services, - uuid=self.uuid, - friendly_name=self.friendly_name, - model_name=self.model_name, - cast_type=self.cast_type, - manufacturer=self.manufacturer, - is_dynamic_group=is_dynamic_group, - ) - - def __str__(self): - """Return pretty printable string for logging.""" - return f"{self.friendly_name} ({self.uuid})" - - -class CastStatusListener: - """ - Helper class to handle pychromecast status callbacks. - - Necessary because a CastDevice entity can create a new socket client - and therefore callbacks from multiple chromecast connections can - potentially arrive. This class allows invalidating past chromecast objects. - """ - - def __init__(self, cast_device, chromecast, mz_mgr, mz_only=False): - """Initialize the status listener.""" - self._cast_device = cast_device - self._uuid = chromecast.uuid - self._valid = True - self._mz_mgr = mz_mgr - - if cast_device._cast_info.is_audio_group: - self._mz_mgr.add_multizone(chromecast) - if mz_only: - return - - chromecast.register_status_listener(self) - chromecast.socket_client.media_controller.register_status_listener(self) - chromecast.register_connection_listener(self) - if not cast_device._cast_info.is_audio_group: - self._mz_mgr.register_listener(chromecast.uuid, self) - - def new_cast_status(self, cast_status): - """Handle reception of a new CastStatus.""" - if self._valid: - self._cast_device.new_cast_status(cast_status) - - def new_media_status(self, media_status): - """Handle reception of a new MediaStatus.""" - if self._valid: - self._cast_device.new_media_status(media_status) - - def new_connection_status(self, connection_status): - """Handle reception of a new ConnectionStatus.""" - if self._valid: - self._cast_device.new_connection_status(connection_status) - - @staticmethod - def added_to_multizone(group_uuid): - """Handle the cast added to a group.""" - - def removed_from_multizone(self, group_uuid): - """Handle the cast removed from a group.""" - if self._valid: - self._cast_device.multizone_new_media_status(group_uuid, None) - - def multizone_new_cast_status(self, group_uuid, cast_status): - """Handle reception of a new CastStatus for a group.""" - - def multizone_new_media_status(self, group_uuid, media_status): - """Handle reception of a new MediaStatus for a group.""" - if self._valid: - self._cast_device.multizone_new_media_status(group_uuid, media_status) - - def invalidate(self): - """ - Invalidate this status listener. - - All following callbacks won't be forwarded. - """ - # pylint: disable=protected-access - if self._cast_device._cast_info.is_audio_group: - self._mz_mgr.remove_multizone(self._uuid) - else: - self._mz_mgr.deregister_listener(self._uuid, self) - self._valid = False diff --git a/music_assistant/providers/chromecast/icon.png b/music_assistant/providers/chromecast/icon.png deleted file mode 100644 index e7372ee1..00000000 Binary files a/music_assistant/providers/chromecast/icon.png and /dev/null differ diff --git a/music_assistant/providers/chromecast/player.py b/music_assistant/providers/chromecast/player.py deleted file mode 100644 index daddb460..00000000 --- a/music_assistant/providers/chromecast/player.py +++ /dev/null @@ -1,446 +0,0 @@ -"""Representation of a Cast device on the network.""" -import asyncio -import logging -from typing import List, Optional - -import pychromecast -from music_assistant.helpers.compare import compare_strings -from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.util import create_task, yield_chunks -from music_assistant.models.config_entry import ConfigEntry -from music_assistant.models.player import DeviceInfo, Player, PlayerFeature, PlayerState -from music_assistant.models.player_queue import QueueItem -from pychromecast.controllers.multizone import MultizoneController, MultizoneManager -from pychromecast.socket_client import ( - CONNECTION_STATUS_CONNECTED, - CONNECTION_STATUS_DISCONNECTED, -) - -from .const import PLAYER_CONFIG_ENTRIES, PROV_ID -from .helpers import CastStatusListener, ChromecastInfo - -LOGGER = logging.getLogger(PROV_ID) -PLAYER_FEATURES = [PlayerFeature.QUEUE] - - -class ChromecastPlayer(Player): - """Representation of a Cast device on the network. - - This class is the holder of the pychromecast.Chromecast object and - handles all reconnects and audio group changing - "elected leader" itself. - """ - - def __init__(self, mass: MusicAssistant, cast_info: ChromecastInfo) -> None: - """Initialize the cast device.""" - super().__init__() - self.mass = mass - self._cast_info = cast_info - self._player_id = cast_info.uuid - - self._chromecast: Optional[pychromecast.Chromecast] = None - self.cast_status = None - self.media_status = None - self.media_status_received = None - self.mz_mgr: Optional[MultizoneManager] = None - self._available = False - self._status_listener: Optional[CastStatusListener] = None - self._is_speaker_group = False - self._command_busy = False - - @property - def player_id(self) -> str: - """Return player id of this player.""" - return self._player_id - - @property - def provider_id(self) -> str: - """Return provider id of this player.""" - return PROV_ID - - @property - def name(self) -> str: - """Return name of this player.""" - return self._cast_info.friendly_name - - @property - def powered(self) -> bool: - """Return power state of this player.""" - if not self._chromecast or not self.cast_status: - return False - if self.is_group_player: - return ( - self._chromecast.media_controller.is_active - and self.cast_status.app_id == pychromecast.APP_MEDIA_RECEIVER - ) - - # Chromecast does not support power so we (ab)use mute instead - if self.cast_status.app_id is None: - return not self.cast_status.volume_muted - return ( - self.cast_status.app_id in ["705D30C6", pychromecast.APP_MEDIA_RECEIVER] - and not self.cast_status.volume_muted - ) - - @property - def should_poll(self) -> bool: - """Return bool if this player needs to be polled for state changes.""" - return self.media_status and self.media_status.player_is_playing - - @property - def state(self) -> PlayerState: - """Return the state of the player.""" - if self.media_status is None: - return PlayerState.IDLE - if self.media_status.player_is_playing: - return PlayerState.PLAYING - if self.media_status.player_is_paused: - return PlayerState.PAUSED - if self.media_status.player_is_idle: - return PlayerState.IDLE - return PlayerState.IDLE - - @property - def elapsed_time(self) -> int: - """Return position of current playing media in seconds.""" - if self.media_status is None or not ( - self.media_status.player_is_playing - or self.media_status.player_is_paused - or self.media_status.player_is_idle - ): - return 0 - if self.media_status.player_is_playing: - # Add time since last update - return self.media_status.adjusted_current_time - # Not playing, return last reported seek time - return self.media_status.current_time - - @property - def available(self) -> bool: - """Return availablity state of this player.""" - return self._available - - @property - def current_uri(self) -> str: - """Return current_uri of this player.""" - return self.media_status.content_id if self.media_status else None - - @property - def volume_level(self) -> int: - """Return volume_level of this player.""" - return self.cast_status.volume_level * 100 if self.cast_status else 0 - - @property - def muted(self) -> bool: - """Return mute state of this player.""" - return self.cast_status.volume_muted if self.cast_status else False - - @property - def is_group_player(self) -> bool: - """Return if this player is a group player.""" - return self._cast_info.is_audio_group and not self._is_speaker_group - - @property - def group_childs(self) -> List[str]: - """Return group_childs.""" - if ( - self._cast_info.is_audio_group - and self._chromecast - and not self._is_speaker_group - ): - return self._chromecast.mz_controller.members - return [] - - @property - def device_info(self) -> DeviceInfo: - """Return deviceinfo.""" - return DeviceInfo( - model=self._cast_info.model_name, - address=f"{self._chromecast.uri}" if self._chromecast else "", - manufacturer=self._cast_info.manufacturer, - ) - - @property - def features(self) -> List[PlayerFeature]: - """Return list of features this player supports.""" - return PLAYER_FEATURES - - @property - def config_entries(self) -> List[ConfigEntry]: - """Return player specific config entries (if any).""" - return PLAYER_CONFIG_ENTRIES - - async def on_add(self) -> None: - """Call when player is added to the player manager.""" - chromecast = await self.mass.loop.run_in_executor( - None, - pychromecast.get_chromecast_from_cast_info, - pychromecast.discovery.CastInfo( - self._cast_info.services, - self._cast_info.uuid, - self._cast_info.model_name, - self._cast_info.friendly_name, - None, - None, - self._cast_info.cast_type, - self._cast_info.manufacturer, - ), - self.mass.zeroconf, - ) - self._chromecast = chromecast - self.mz_mgr: MultizoneManager = self.mass.get_provider(PROV_ID).mz_mgr - self._status_listener = CastStatusListener(self, chromecast, self.mz_mgr) - self._available = False - self.cast_status = chromecast.status - self.media_status = chromecast.media_controller.status - if self._cast_info.is_audio_group: - mz_controller = MultizoneController(chromecast.uuid) - chromecast.register_handler(mz_controller) - chromecast.mz_controller = mz_controller - self._chromecast.start() - - def set_cast_info(self, cast_info: ChromecastInfo) -> None: - """Set (or update) the cast discovery info.""" - self._cast_info = cast_info - - async def disconnect(self): - """Disconnect Chromecast object if it is set.""" - if self._chromecast is None: - # Can't disconnect if not connected. - return - LOGGER.debug( - "[%s %s] Disconnecting from chromecast socket", - self.player_id, - self._cast_info.friendly_name, - ) - self._available = False - self.update_state() - - await self.mass.loop.run_in_executor(None, self._chromecast.disconnect) - - self._invalidate() - self.update_state() - - def _invalidate(self) -> None: - """Invalidate some attributes.""" - self._chromecast = None - self.cast_status = None - self.media_status = None - self.media_status_received = None - self.mz_mgr = None - if self._status_listener is not None: - self._status_listener.invalidate() - self._status_listener = None - - async def on_remove(self) -> None: - """Call when player is removed from the player manager.""" - await self.disconnect() - - # ========== Callbacks ========== - - def new_cast_status(self, cast_status) -> None: - """Handle updates of the cast status.""" - self.cast_status = cast_status - self._is_speaker_group = ( - self._cast_info.is_audio_group - and self._chromecast.mz_controller - and self._chromecast.mz_controller.members - and compare_strings( - self._chromecast.mz_controller.members[0], self.player_id - ) - ) - self.update_state() - - def new_media_status(self, media_status) -> None: - """Handle updates of the media status.""" - self.media_status = media_status - self.update_state() - - def new_connection_status(self, connection_status) -> None: - """Handle updates of connection status.""" - if connection_status.status == CONNECTION_STATUS_DISCONNECTED: - self._available = False - self._invalidate() - self.update_state() - return - - new_available = connection_status.status == CONNECTION_STATUS_CONNECTED - if new_available != self._available: - # Connection status callbacks happen often when disconnected. - # Only update state when availability changed to put less pressure - # on state machine. - LOGGER.debug( - "[%s] Cast device availability changed: %s", - self._cast_info.friendly_name, - connection_status.status, - ) - self._available = new_available - self.update_state() - if self._cast_info.is_audio_group and new_available: - create_task(self._chromecast.mz_controller.update_members) - - # ========== Service Calls ========== - - async def cmd_stop(self) -> None: - """Send stop command to player.""" - if self._chromecast.media_controller: - await self.chromecast_command(self._chromecast.media_controller.stop) - - async def cmd_play(self) -> None: - """Send play command to player.""" - if self._chromecast.media_controller: - await self.chromecast_command(self._chromecast.media_controller.play) - - async def cmd_pause(self) -> None: - """Send pause command to player.""" - if self._chromecast.media_controller: - await self.chromecast_command(self._chromecast.media_controller.pause) - - async def cmd_next(self) -> None: - """Send next track command to player.""" - if self._chromecast.media_controller: - await self.chromecast_command(self._chromecast.media_controller.queue_next) - - async def cmd_previous(self) -> None: - """Send previous track command to player.""" - if self._chromecast.media_controller: - await self.chromecast_command(self._chromecast.media_controller.queue_prev) - - async def cmd_power_on(self) -> None: - """Send power ON command to player.""" - if self.is_group_player: - await self.launch_app() - else: - # chromecast has no real poweroff so we (ab)use mute instead - await self.chromecast_command(self._chromecast.set_volume_muted, False) - - async def cmd_power_off(self) -> None: - """Send power OFF command to player.""" - if self.is_group_player or ( - self._chromecast.media_controller.is_active - and self.cast_status.app_id == self._chromecast.media_controller.app_id - ): - await self.chromecast_command(self._chromecast.quit_app) - if not self.is_group_player: - # chromecast has no real poweroff so we (ab)use mute instead - await self.chromecast_command(self._chromecast.set_volume_muted, True) - - async def cmd_volume_set(self, volume_level: int) -> None: - """Send new volume level command to player.""" - await self.chromecast_command(self._chromecast.set_volume, volume_level / 100) - - async def cmd_volume_mute(self, is_muted: bool = False) -> None: - """Send mute command to player.""" - await self.chromecast_command(self._chromecast.set_volume_muted, is_muted) - - async def cmd_play_uri(self, uri: str) -> None: - """Play single uri on player.""" - # create (fake) CC queue so that skip and previous will work - queue_item = QueueItem( - item_id=uri, provider="mass", name="Music Assistant", stream_url=uri - ) - await self.cmd_queue_load([queue_item, queue_item], True) - - async def cmd_queue_load( - self, queue_items: List[QueueItem], repeat: bool = False - ) -> None: - """Load (overwrite) queue with new items.""" - cc_queue_items = self.__create_queue_items(queue_items[:25]) - queuedata = { - "type": "QUEUE_LOAD", - "repeatMode": "REPEAT_ALL" if repeat else "REPEAT_OFF", - "shuffle": False, # handled by our queue controller - "queueType": "PLAYLIST", - "startIndex": 0, # Item index to play after this request or keep same item if undefined - "items": cc_queue_items, # only load 25 tracks at once or the socket will crash - } - await self.launch_app() - await self.chromecast_command(self.__send_player_queue, queuedata) - if len(queue_items) > 25: - await asyncio.sleep(5) - await self.cmd_queue_append(queue_items[26:]) - - async def cmd_queue_append(self, queue_items: List[QueueItem]) -> None: - """Append new items at the end of the queue.""" - cc_queue_items = self.__create_queue_items(queue_items) - async for chunk in yield_chunks(cc_queue_items, 25): - queuedata = { - "type": "QUEUE_INSERT", - "insertBefore": None, - "items": chunk, - } - await self.chromecast_command(self.__send_player_queue, queuedata) - await asyncio.sleep(2) - - def __create_queue_items(self, tracks) -> None: - """Create list of CC queue items from tracks.""" - return [self.__create_queue_item(track) for track in tracks] - - @staticmethod - def __create_queue_item(queue_item: QueueItem): - """Create CC queue item from track info.""" - return { - "opt_itemId": queue_item.queue_item_id, - "autoplay": True, - "preloadTime": 0, - "playbackDuration": int(queue_item.duration), - "startTime": 0, - "activeTrackIds": [], - "media": { - "contentId": queue_item.stream_url, - "customData": { - "provider": queue_item.provider, - "uri": queue_item.stream_url, - "item_id": queue_item.queue_item_id, - }, - "contentType": "audio/flac", - "streamType": pychromecast.STREAM_TYPE_BUFFERED, - "metadata": { - "title": queue_item.name, - "artist": "/".join(x.name for x in queue_item.artists), - }, - "duration": int(queue_item.duration), - }, - } - - def __send_player_queue(self, queuedata: dict) -> None: - """Send new data to the CC queue.""" - media_controller = self._chromecast.media_controller - queuedata["mediaSessionId"] = media_controller.status.media_session_id - media_controller.send_message(queuedata, False) - - async def launch_app(self): - """Launch the default media receiver app and wait until its launched.""" - media_controller = self._chromecast.media_controller - event = asyncio.Event() - - def launched_callback(): - self.mass.loop.call_soon_threadsafe(event.set) - - # pylint: disable=protected-access - receiver_ctrl = media_controller._socket_client.receiver_controller - await self.mass.loop.run_in_executor( - None, - receiver_ctrl.launch_app, - media_controller.app_id, - False, - launched_callback, - ) - await event.wait() - - async def chromecast_command(self, func, *args): - """Execute command on Chromecast.""" - if not self.available: - LOGGER.warning( - "Player %s is not available, command can't be executed", self.name - ) - return - while self._command_busy: - await asyncio.sleep(0.05) - try: - # Sending multiple commands at the same time to the cast socket - # will make things unstable, make sure to throttle it. - self._command_busy = True - await self.mass.loop.run_in_executor(None, func, *args) - finally: - self._command_busy = False diff --git a/music_assistant/providers/fanarttv/__init__.py b/music_assistant/providers/fanarttv/__init__.py deleted file mode 100755 index 3856e07c..00000000 --- a/music_assistant/providers/fanarttv/__init__.py +++ /dev/null @@ -1,108 +0,0 @@ -"""FanartTv Metadata provider.""" - -import logging -from json.decoder import JSONDecodeError -from typing import Dict, List - -import aiohttp -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 setup(mass) -> None: - """Perform async setup of this Plugin/Provider.""" - prov = FanartTvProvider(mass) - await mass.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 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 get_artist_images(self, mb_artist_id: str) -> Dict: - """Retrieve images by musicbrainz artist id.""" - metadata = {} - data = await self._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 _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, - 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 deleted file mode 100644 index 17b39a4c..00000000 Binary files a/music_assistant/providers/fanarttv/icon.png and /dev/null differ diff --git a/music_assistant/providers/file/__init__.py b/music_assistant/providers/file/__init__.py deleted file mode 100644 index d00df3e6..00000000 --- a/music_assistant/providers/file/__init__.py +++ /dev/null @@ -1,430 +0,0 @@ -"""Filesystem musicprovider support for MusicAssistant.""" -import base64 -import logging -import os -import re -from typing import List, Optional - -import taglib -from music_assistant.helpers.util import parse_title_and_version -from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType -from music_assistant.models.media_types import ( - Album, - Artist, - MediaItemProviderId, - MediaType, - Playlist, - SearchResult, - Track, - TrackQuality, -) -from music_assistant.models.provider import MusicProvider -from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType - -PROV_ID = "file" -PROV_NAME = "Local files and playlists" - -LOGGER = logging.getLogger(PROV_ID) - -CONF_MUSIC_DIR = "music_dir" -CONF_PLAYLISTS_DIR = "playlists_dir" - -CONFIG_ENTRIES = [ - ConfigEntry( - entry_key=CONF_MUSIC_DIR, - entry_type=ConfigEntryType.STRING, - label="file_prov_music_path", - description="file_prov_music_path_desc", - ), - ConfigEntry( - entry_key=CONF_PLAYLISTS_DIR, - entry_type=ConfigEntryType.STRING, - label="file_prov_playlists_path", - description="file_prov_playlists_path_desc", - ), -] - - -async def setup(mass): - """Perform async setup of this Plugin/Provider.""" - prov = FileProvider() - await mass.register_provider(prov) - - -class FileProvider(MusicProvider): - """ - Very basic implementation of a musicprovider for local files. - - Assumes files are stored on disk in format // - Reads ID3 tags from file and falls back to parsing filename - Supports m3u files only for playlists - Supports having URI's from streaming providers within m3u playlist - Should be compatible with LMS - """ - - # pylint chokes on taglib so ignore these - # pylint: disable=unsubscriptable-object,unsupported-membership-test - - _music_dir = None - _playlists_dir = None - - @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 - - @property - def supported_mediatypes(self) -> List[MediaType]: - """Return MediaTypes the provider supports.""" - return [MediaType.ALBUM, MediaType.ARTIST, MediaType.PLAYLIST, MediaType.TRACK] - - async def on_start(self) -> bool: - """Handle initialization of the provider based on config.""" - conf = self.mass.config.get_provider_config(self.id) - if not conf[CONF_MUSIC_DIR]: - return False - if not os.path.isdir(conf[CONF_MUSIC_DIR]): - raise FileNotFoundError(f"Directory {conf[CONF_MUSIC_DIR]} does not exist") - self._music_dir = conf["music_dir"] - if os.path.isdir(conf[CONF_PLAYLISTS_DIR]): - self._playlists_dir = conf[CONF_PLAYLISTS_DIR] - else: - self._playlists_dir = None - - async def on_stop(self): - """Handle correct close/cleanup of the provider on exit.""" - # nothing to be done - - async def 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). - """ - result = SearchResult() - # TODO ! - return result - - async def get_library_artists(self) -> List[Artist]: - """Retrieve all library artists.""" - if not os.path.isdir(self._music_dir): - LOGGER.error("music path does not exist: %s", self._music_dir) - return None - result = [] - for dirname in os.listdir(self._music_dir): - dirpath = os.path.join(self._music_dir, dirname) - if os.path.isdir(dirpath) and not dirpath.startswith("."): - artist = await self.get_artist(dirpath) - if artist: - result.append(artist) - return result - - async def get_library_albums(self) -> List[Album]: - """Get album folders recursively.""" - result = [] - for artist in await self.get_library_artists(): - for album in await self.get_artist_albums(artist.item_id): - result.append(album) - return result - - async def get_library_tracks(self) -> List[Track]: - """Get all tracks recursively.""" - # TODO: support disk subfolders - result = [] - for album in await self.get_library_albums(): - for track in await self.get_album_tracks(album.item_id): - result.append(track) - return result - - async def get_library_playlists(self) -> List[Playlist]: - """Retrieve playlists from disk.""" - if not self._playlists_dir: - return [] - result = [] - for filename in os.listdir(self._playlists_dir): - filepath = os.path.join(self._playlists_dir, filename) - if ( - os.path.isfile(filepath) - and not filename.startswith(".") - and filename.lower().endswith(".m3u") - ): - playlist = await self.get_playlist(filepath) - if playlist: - result.append(playlist) - return result - - async def get_artist(self, prov_artist_id: str) -> Artist: - """Get full artist details by id.""" - if os.sep not in prov_artist_id: - itempath = base64.b64decode(prov_artist_id).decode("utf-8") - else: - itempath = prov_artist_id - prov_artist_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8") - if not os.path.isdir(itempath): - LOGGER.error("Artist path does not exist: %s", itempath) - return None - name = itempath.split(os.sep)[-1] - artist = Artist(item_id=prov_artist_id, provider=PROV_ID, name=name) - artist.provider_ids.add( - MediaItemProviderId(provider=PROV_ID, item_id=artist.item_id) - ) - return artist - - async def get_album(self, prov_album_id: str) -> Album: - """Get full album details by id.""" - if os.sep not in prov_album_id: - itempath = base64.b64decode(prov_album_id).decode("utf-8") - else: - itempath = prov_album_id - prov_album_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8") - if not os.path.isdir(itempath): - LOGGER.error("album path does not exist: %s", itempath) - return None - name = itempath.split(os.sep)[-1] - artistpath = itempath.rsplit(os.sep, 1)[0] - album = Album(item_id=prov_album_id, provider=PROV_ID) - album.name, album.version = parse_title_and_version(name) - album.artist = await self.get_artist(artistpath) - if not album.artist: - raise Exception("No album artist ! %s" % artistpath) - album.provider_ids.add( - MediaItemProviderId(provider=PROV_ID, item_id=prov_album_id) - ) - return album - - async def get_track(self, prov_track_id: str) -> Track: - """Get full track details by id.""" - if os.sep not in prov_track_id: - itempath = base64.b64decode(prov_track_id).decode("utf-8") - else: - itempath = prov_track_id - if not os.path.isfile(itempath): - LOGGER.error("track path does not exist: %s", itempath) - return None - return await self._parse_track(itempath) - - async def get_playlist(self, prov_playlist_id: str) -> Playlist: - """Get full playlist details by id.""" - if os.sep not in prov_playlist_id: - itempath = base64.b64decode(prov_playlist_id).decode("utf-8") - else: - itempath = prov_playlist_id - prov_playlist_id = base64.b64encode(itempath.encode("utf-8")).decode( - "utf-8" - ) - if not os.path.isfile(itempath): - LOGGER.error("playlist path does not exist: %s", itempath) - return None - playlist = Playlist() - playlist.item_id = prov_playlist_id - playlist.provider = PROV_ID - playlist.name = itempath.split(os.sep)[-1].replace(".m3u", "") - playlist.is_editable = True - playlist.provider_ids.add( - MediaItemProviderId(provider=PROV_ID, item_id=prov_playlist_id) - ) - playlist.owner = "disk" - playlist.checksum = os.path.getmtime(itempath) - return playlist - - async def get_album_tracks(self, prov_album_id) -> List[Track]: - """Get album tracks for given album id.""" - result = [] - if os.sep not in prov_album_id: - albumpath = base64.b64decode(prov_album_id).decode("utf-8") - else: - albumpath = prov_album_id - if not os.path.isdir(albumpath): - LOGGER.error("album path does not exist: %s", albumpath) - return [] - album = await self.get_album(albumpath) - for filename in os.listdir(albumpath): - filepath = os.path.join(albumpath, filename) - if os.path.isfile(filepath) and not filepath.startswith("."): - track = await self._parse_track(filepath) - if track: - track.album = album - result.append(track) - return result - - async def get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]: - """Get playlist tracks for given playlist id.""" - result = [] - if os.sep not in prov_playlist_id: - itempath = base64.b64decode(prov_playlist_id).decode("utf-8") - else: - itempath = prov_playlist_id - if not os.path.isfile(itempath): - LOGGER.error("playlist path does not exist: %s", itempath) - return result - index = 0 - with open(itempath) as _file: - for line in _file.readlines(): - line = line.strip() - if line and not line.startswith("#"): - track = await self._parse_track_from_uri(line) - if track: - result.append(track) - index += 1 - return result - - async def get_artist_albums(self, prov_artist_id: str) -> List[Album]: - """Get a list of albums for the given artist.""" - result = [] - if os.sep not in prov_artist_id: - artistpath = base64.b64decode(prov_artist_id).decode("utf-8") - else: - artistpath = prov_artist_id - if not os.path.isdir(artistpath): - LOGGER.error("artist path does not exist: %s", artistpath) - return - for dirname in os.listdir(artistpath): - dirpath = os.path.join(artistpath, dirname) - if os.path.isdir(dirpath) and not dirpath.startswith("."): - album = await self.get_album(dirpath) - if album: - result.append(album) - return result - - async def get_artist_toptracks(self, prov_artist_id: str) -> List[Track]: - """Get a list of random tracks as we have no clue about preference.""" - result = [] - for album in await self.get_artist_albums(prov_artist_id): - for track in await self.get_album_tracks(album.item_id): - result.append(track) - return result - - async def get_stream_details(self, item_id: str) -> StreamDetails: - """Return the content details for the given track when it will be streamed.""" - if os.sep not in item_id: - track_id = base64.b64decode(item_id).decode("utf-8") - if not os.path.isfile(track_id): - return None - # TODO: retrieve sanple rate and bitdepth - return StreamDetails( - type=StreamType.FILE, - provider=PROV_ID, - item_id=item_id, - content_type=ContentType(track_id.split(".")[-1]), - path=track_id, - sample_rate=44100, - bit_depth=16, - ) - - async def _parse_track(self, filename): - """Try to parse a track from a filename with taglib.""" - # pylint: disable=broad-except - try: - song = taglib.File(filename) - except Exception: - return None # not a media file ? - prov_item_id = base64.b64encode(filename.encode("utf-8")).decode("utf-8") - track = Track(item_id=prov_item_id, provider=PROV_ID) - track.duration = song.length - try: - name = song.tags["TITLE"][0] - except KeyError: - name = filename.split("/")[-1].split(".")[0] - track.name, track.version = parse_title_and_version(name) - albumpath = filename.rsplit(os.sep, 1)[0] - track.album = await self.get_album(albumpath) - if "ARTIST" in song.tags: - artists = set() - for artist_str in song.tags["ARTIST"]: - local_artist_path = os.path.join(self._music_dir, artist_str) - if os.path.isfile(local_artist_path): - artist = await self.get_artist(local_artist_path) - else: - fake_artistpath = os.path.join(self._music_dir, artist_str) - artist = Artist( - item_id=fake_artistpath, provider=PROV_ID, name=artist_str - ) - artist.provider_ids.add( - MediaItemProviderId( - provider=PROV_ID, - item_id=base64.b64encode( - fake_artistpath.encode("utf-8") - ).decode("utf-8"), - ) - ) - artists.add(artist) - track.artists = artists - else: - artistpath = filename.rsplit(os.sep, 2)[0] - artist = await self.get_artist(artistpath) - track.artists.add(artist) - if "GENRE" in song.tags: - track.metadata["genres"] = song.tags["GENRE"] - if "ISRC" in song.tags and song.tags["ISRC"]: - track.isrc = song.tags["ISRC"][0] - if "DISCNUMBER" in song.tags and song.tags["DISCNUMBER"]: - regexp_numbers = re.findall(r"\d+", song.tags["DISCNUMBER"][0]) - track.disc_number = int(regexp_numbers[0] if regexp_numbers else "0") - if "TRACKNUMBER" in song.tags and song.tags["TRACKNUMBER"]: - regexp_numbers = re.findall(r"\d+", song.tags["TRACKNUMBER"][0]) - track.track_number = int(regexp_numbers[0] if regexp_numbers else "0") - quality_details = "" - if filename.endswith(".flac"): - # TODO: get bit depth - quality = TrackQuality.FLAC_LOSSLESS - if song.sampleRate > 192000: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4 - elif song.sampleRate > 96000: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3 - elif song.sampleRate > 48000: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2 - quality_details = "%s Khz" % (song.sampleRate / 1000) - elif filename.endswith(".ogg"): - quality = TrackQuality.LOSSY_OGG - quality_details = "%s kbps" % (song.bitrate) - elif filename.endswith(".m4a"): - quality = TrackQuality.LOSSY_AAC - quality_details = "%s kbps" % (song.bitrate) - else: - quality = TrackQuality.LOSSY_MP3 - quality_details = "%s kbps" % (song.bitrate) - track.provider_ids.add( - MediaItemProviderId( - provider=PROV_ID, - item_id=prov_item_id, - quality=quality, - details=quality_details, - ) - ) - return track - - async def _parse_track_from_uri(self, uri): - """Try to parse a track from an uri found in playlist.""" - # pylint: disable=broad-except - if "://" in uri: - # track is uri from external provider? - prov_id = uri.split("://")[0] - prov_item_id = uri.split("/")[-1].split(".")[0].split(":")[-1] - try: - return await self.mass.music.get_track(prov_item_id, prov_id) - except Exception as exc: - LOGGER.warning("Could not parse uri %s to track: %s", uri, str(exc)) - return None - # try to treat uri as filename - # TODO: filename could be related to musicdir or full path - track = await self.get_track(uri) - if track: - return track - track = await self.get_track(os.path.join(self._music_dir, uri)) - if track: - return track - return None diff --git a/music_assistant/providers/file/icon.png b/music_assistant/providers/file/icon.png deleted file mode 100644 index bd2df042..00000000 Binary files a/music_assistant/providers/file/icon.png and /dev/null differ diff --git a/music_assistant/providers/filesystem.py b/music_assistant/providers/filesystem.py new file mode 100644 index 00000000..b70710d3 --- /dev/null +++ b/music_assistant/providers/filesystem.py @@ -0,0 +1,394 @@ +"""Filesystem musicprovider support for MusicAssistant.""" +import base64 +import os +import re +from typing import List, Optional +import aiofiles + +import taglib + +from music_assistant.models.errors import InvalidDataError +from music_assistant.helpers.util import parse_title_and_version +from music_assistant.models.media_items import ( + Album, + Artist, + ContentType, + MediaItemProviderId, + MediaItemType, + MediaQuality, + MediaType, + Playlist, + StreamDetails, + StreamType, + Track, +) +from music_assistant.models.provider import MusicProvider + + +class FileSystemProvider(MusicProvider): + """ + Very basic implementation of a musicprovider for local files. + + Assumes files are stored on disk in format // + Reads ID3 tags from file and falls back to parsing filename + Supports m3u files only for playlists + Supports having URI's from streaming providers within m3u playlist + Should be compatible with LMS + """ + + # pylint chokes on taglib so ignore these + # pylint: disable=unsubscriptable-object,unsupported-membership-test + + def __init__(self, music_dir: str, playlist_dir: str | None = None) -> None: + """ + Initialize the Filesystem provider. + + music_dir: Directory on disk containing music files + playlist_dir: Directory on disk containing playlist files (optional) + + """ + self._attr_id = "filesystem" + self._attr_name = "Filesystem" + self._playlists_dir = playlist_dir + self._music_dir = music_dir + self._attr_supported_mediatypes = [ + MediaType.ARTIST, + MediaType.ALBUM, + MediaType.TRACK, + ] + if playlist_dir is not None: + self._attr_supported_mediatypes.append(MediaType.PLAYLIST) + + async def setup(self) -> None: + """Handle async initialization of the provider.""" + if not os.path.isdir(self._music_dir): + raise FileNotFoundError(f"Music Directory {self._music_dir} does not exist") + if self._playlists_dir is not None and not os.path.isdir(self._playlists_dir): + raise FileNotFoundError( + f"Playlist Directory {self._playlists_dir} does not exist" + ) + + async def search( + self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 + ) -> List[MediaItemType]: + """ + 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). + """ + # TODO ! + return [] + + async def get_library_artists(self) -> List[Artist]: + """Retrieve all library artists.""" + if not os.path.isdir(self._music_dir): + self.logger.error("music path does not exist: %s", self._music_dir) + return None + result = [] + for dirname in os.listdir(self._music_dir): + dirpath = os.path.join(self._music_dir, dirname) + if os.path.isdir(dirpath) and not dirpath.startswith("."): + artist = await self.get_artist(dirpath) + if artist: + result.append(artist) + return result + + async def get_library_albums(self) -> List[Album]: + """Get album folders recursively.""" + result = [] + for artist in await self.get_library_artists(): + for album in await self.get_artist_albums(artist.item_id): + result.append(album) + return result + + async def get_library_tracks(self) -> List[Track]: + """Get all tracks recursively.""" + # TODO: support disk subfolders + result = [] + for album in await self.get_library_albums(): + for track in await self.get_album_tracks(album.item_id): + result.append(track) + return result + + async def get_library_playlists(self) -> List[Playlist]: + """Retrieve playlists from disk.""" + if not self._playlists_dir: + return [] + result = [] + for filename in os.listdir(self._playlists_dir): + filepath = os.path.join(self._playlists_dir, filename) + if ( + os.path.isfile(filepath) + and not filename.startswith(".") + and filename.lower().endswith(".m3u") + ): + playlist = await self.get_playlist(filepath) + if playlist: + result.append(playlist) + return result + + async def get_artist(self, prov_artist_id: str) -> Artist: + """Get full artist details by id.""" + if os.sep not in prov_artist_id: + itempath = base64.b64decode(prov_artist_id).decode("utf-8") + else: + itempath = prov_artist_id + prov_artist_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8") + if not os.path.isdir(itempath): + self.logger.error("Artist path does not exist: %s", itempath) + return None + name = itempath.split(os.sep)[-1] + artist = Artist(item_id=prov_artist_id, provider=self.id, name=name) + artist.provider_ids.append( + MediaItemProviderId(provider=self.id, item_id=artist.item_id) + ) + return artist + + async def get_album(self, prov_album_id: str) -> Album: + """Get full album details by id.""" + if os.sep not in prov_album_id: + itempath = base64.b64decode(prov_album_id).decode("utf-8") + else: + itempath = prov_album_id + prov_album_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8") + if not os.path.isdir(itempath): + self.logger.error("album path does not exist: %s", itempath) + return None + name = itempath.split(os.sep)[-1] + artistpath = itempath.rsplit(os.sep, 1)[0] + name, version = parse_title_and_version(name) + album = Album( + item_id=prov_album_id, provider=self.id, name=name, version=version + ) + album.artist = await self.get_artist(artistpath) + if not album.artist: + raise InvalidDataError(f"No album artist ! {artistpath}") + album.provider_ids.append( + MediaItemProviderId(provider=self.id, item_id=prov_album_id) + ) + return album + + async def get_track(self, prov_track_id: str) -> Track: + """Get full track details by id.""" + if os.sep not in prov_track_id: + itempath = base64.b64decode(prov_track_id).decode("utf-8") + else: + itempath = prov_track_id + if not os.path.isfile(itempath): + self.logger.error("track path does not exist: %s", itempath) + return None + return await self._parse_track(itempath) + + async def get_playlist(self, prov_playlist_id: str) -> Playlist: + """Get full playlist details by id.""" + if os.sep not in prov_playlist_id: + itempath = base64.b64decode(prov_playlist_id).decode("utf-8") + else: + itempath = prov_playlist_id + prov_playlist_id = base64.b64encode(itempath.encode("utf-8")).decode( + "utf-8" + ) + if not os.path.isfile(itempath): + self.logger.error("playlist path does not exist: %s", itempath) + return None + name = itempath.split(os.sep)[-1].replace(".m3u", "") + playlist = Playlist(prov_playlist_id, provider=self.id, name=name) + playlist.is_editable = True + playlist.provider_ids.append( + MediaItemProviderId(provider=self.id, item_id=prov_playlist_id) + ) + playlist.owner = "disk" + playlist.checksum = os.path.getmtime(itempath) + return playlist + + async def get_album_tracks(self, prov_album_id) -> List[Track]: + """Get album tracks for given album id.""" + result = [] + if os.sep not in prov_album_id: + albumpath = base64.b64decode(prov_album_id).decode("utf-8") + else: + albumpath = prov_album_id + if not os.path.isdir(albumpath): + self.logger.error("album path does not exist: %s", albumpath) + return [] + album = await self.get_album(albumpath) + for filename in os.listdir(albumpath): + filepath = os.path.join(albumpath, filename) + if os.path.isfile(filepath) and not filepath.startswith("."): + track = await self._parse_track(filepath) + if track: + track.album = album + result.append(track) + return result + + async def get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]: + """Get playlist tracks for given playlist id.""" + result = [] + if os.sep not in prov_playlist_id: + itempath = base64.b64decode(prov_playlist_id).decode("utf-8") + else: + itempath = prov_playlist_id + if not os.path.isfile(itempath): + self.logger.error("playlist path does not exist: %s", itempath) + return result + index = 0 + async with aiofiles.open(itempath, "r") as _file: + for line in await _file.readlines(): + line = line.strip() + if line and not line.startswith("#"): + track = await self._parse_track_from_uri(line) + if track: + result.append(track) + index += 1 + return result + + async def get_artist_albums(self, prov_artist_id: str) -> List[Album]: + """Get a list of albums for the given artist.""" + result = [] + if os.sep not in prov_artist_id: + artistpath = base64.b64decode(prov_artist_id).decode("utf-8") + else: + artistpath = prov_artist_id + if not os.path.isdir(artistpath): + self.logger.error("artist path does not exist: %s", artistpath) + return + for dirname in os.listdir(artistpath): + dirpath = os.path.join(artistpath, dirname) + if os.path.isdir(dirpath) and not dirpath.startswith("."): + album = await self.get_album(dirpath) + if album: + result.append(album) + return result + + async def get_artist_toptracks(self, prov_artist_id: str) -> List[Track]: + """Get a list of random tracks as we have no clue about preference.""" + result = [] + for album in await self.get_artist_albums(prov_artist_id): + for track in await self.get_album_tracks(album.item_id): + result.append(track) + return result + + async def get_stream_details(self, item_id: str) -> StreamDetails: + """Return the content details for the given track when it will be streamed.""" + if os.sep not in item_id: + track_id = base64.b64decode(item_id).decode("utf-8") + if not os.path.isfile(track_id): + return None + # TODO: retrieve sanple rate and bitdepth + return StreamDetails( + type=StreamType.FILE, + provider=self.id, + item_id=item_id, + content_type=ContentType(track_id.split(".")[-1]), + path=track_id, + sample_rate=44100, + bit_depth=16, + ) + + async def _parse_track(self, filename): + """Try to parse a track from a filename with taglib.""" + # pylint: disable=broad-except + try: + song = taglib.File(filename) + except Exception: + return None # not a media file ? + prov_item_id = base64.b64encode(filename.encode("utf-8")).decode("utf-8") + try: + name = song.tags["TITLE"][0] + except KeyError: + name = filename.split("/")[-1].split(".")[0] + name, version = parse_title_and_version(name) + track = Track( + item_id=prov_item_id, provider=self.id, name=name, version=version + ) + track.duration = song.length + albumpath = filename.rsplit(os.sep, 1)[0] + track.album = await self.get_album(albumpath) + if "ARTIST" in song.tags: + artists = [] + for artist_str in song.tags["ARTIST"]: + local_artist_path = os.path.join(self._music_dir, artist_str) + if os.path.isfile(local_artist_path): + artist = await self.get_artist(local_artist_path) + else: + fake_artistpath = os.path.join(self._music_dir, artist_str) + artist = Artist( + item_id=fake_artistpath, provider=self.id, name=artist_str + ) + artist.provider_ids.append( + MediaItemProviderId( + provider=self.id, + item_id=base64.b64encode( + fake_artistpath.encode("utf-8") + ).decode("utf-8"), + ) + ) + artists.append(artist) + track.artists = artists + else: + artistpath = filename.rsplit(os.sep, 2)[0] + artist = await self.get_artist(artistpath) + track.artists.append(artist) + if "GENRE" in song.tags: + track.metadata["genres"] = song.tags["GENRE"] + if "ISRC" in song.tags and song.tags["ISRC"]: + track.isrc = song.tags["ISRC"][0] + if "DISCNUMBER" in song.tags and song.tags["DISCNUMBER"]: + regexp_numbers = re.findall(r"\d+", song.tags["DISCNUMBER"][0]) + track.disc_number = int(regexp_numbers[0] if regexp_numbers else "0") + if "TRACKNUMBER" in song.tags and song.tags["TRACKNUMBER"]: + regexp_numbers = re.findall(r"\d+", song.tags["TRACKNUMBER"][0]) + track.track_number = int(regexp_numbers[0] if regexp_numbers else "0") + quality_details = "" + if filename.endswith(".flac"): + # TODO: get bit depth + quality = MediaQuality.FLAC_LOSSLESS + if song.sampleRate > 192000: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4 + elif song.sampleRate > 96000: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3 + elif song.sampleRate > 48000: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2 + quality_details = f"{song.sampleRate / 1000} Khz" + elif filename.endswith(".ogg"): + quality = MediaQuality.LOSSY_OGG + quality_details = f"{song.bitrate} kbps" + elif filename.endswith(".m4a"): + quality = MediaQuality.LOSSY_AAC + quality_details = f"{song.bitrate} kbps" + else: + quality = MediaQuality.LOSSY_MP3 + quality_details = f"{song.bitrate} kbps" + track.provider_ids.append( + MediaItemProviderId( + provider=self.id, + item_id=prov_item_id, + quality=quality, + details=quality_details, + ) + ) + return track + + async def _parse_track_from_uri(self, uri): + """Try to parse a track from an uri found in playlist.""" + # pylint: disable=broad-except + if "://" in uri: + # track is uri from external provider? + try: + return await self.mass.music.get_item_by_uri(uri) + except Exception as exc: + self.logger.warning( + "Could not parse uri %s to track: %s", uri, str(exc) + ) + return None + # try to treat uri as filename + # TODO: filename could be related to musicdir or full path + track = await self.get_track(uri) + if track: + return track + track = await self.get_track(os.path.join(self._music_dir, uri)) + if track: + return track + return None diff --git a/music_assistant/providers/qobuz.py b/music_assistant/providers/qobuz.py new file mode 100644 index 00000000..92d59e39 --- /dev/null +++ b/music_assistant/providers/qobuz.py @@ -0,0 +1,703 @@ +"""Qobuz musicprovider support for MusicAssistant.""" + +import datetime +import hashlib +import time +from typing import List, Optional + +from asyncio_throttle import Throttler + +from music_assistant.constants import EventType +from music_assistant.helpers.app_vars import ( # pylint: disable=no-name-in-module + get_app_var, +) +from music_assistant.models.errors import LoginFailed +from music_assistant.helpers.util import parse_title_and_version, try_parse_int +from music_assistant.models.media_items import ( + Album, + AlbumType, + Artist, + ContentType, + MediaItemProviderId, + MediaItemType, + MediaQuality, + MediaType, + Playlist, + StreamDetails, + StreamType, + Track, +) +from music_assistant.models.provider import MusicProvider + + +class QobuzProvider(MusicProvider): + """Provider for the Qobux music service.""" + + def __init__(self, username: str, password: str) -> None: + """Initialize the Spotify provider.""" + self._attr_id = "qobuz" + self._attr_name = "Qobuz" + self._attr_supported_mediatypes = [ + MediaType.ARTIST, + MediaType.ALBUM, + MediaType.TRACK, + MediaType.PLAYLIST, + ] + self._username = username + self._password = password + self.__user_auth_info = None + self._throttler = Throttler(rate_limit=4, period=1) + + async def setup(self) -> None: + """Handle async initialization of the provider.""" + # try to get a token, raise if that fails + token = await self._auth_token() + if not token: + raise LoginFailed(f"Login failed for user {self._username}") + # subscribe to stream events so we can report playback to Qobuz + self.mass.subscribe( + self.on_stream_event, (EventType.STREAM_STARTED, EventType.STREAM_ENDED) + ) + + async def search( + self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 + ) -> List[MediaItemType]: + """ + 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). + """ + result = [] + params = {"query": search_query, "limit": limit} + if len(media_types) == 1: + # qobuz does not support multiple searchtypes, falls back to all if no type given + if media_types[0] == MediaType.ARTIST: + params["type"] = "artists" + if media_types[0] == MediaType.ALBUM: + params["type"] = "albums" + if media_types[0] == MediaType.TRACK: + params["type"] = "tracks" + if media_types[0] == MediaType.PLAYLIST: + params["type"] = "playlists" + if searchresult := await self._get_data("catalog/search", params): + if "artists" in searchresult: + result += [ + await self._parse_artist(item) + for item in searchresult["artists"]["items"] + if (item and item["id"]) + ] + if "albums" in searchresult: + result += [ + await self._parse_album(item) + for item in searchresult["albums"]["items"] + if (item and item["id"]) + ] + if "tracks" in searchresult: + result += [ + await self._parse_track(item) + for item in searchresult["tracks"]["items"] + if (item and item["id"]) + ] + if "playlists" in searchresult: + result += [ + await self._parse_playlist(item) + for item in searchresult["playlists"]["items"] + if (item and item["id"]) + ] + return result + + async def get_library_artists(self) -> List[Artist]: + """Retrieve all library artists from Qobuz.""" + params = {"type": "artists"} + endpoint = "favorite/getUserFavorites" + return [ + await self._parse_artist(item) + for item in await self._get_all_items(endpoint, params, key="artists") + if (item and item["id"]) + ] + + async def get_library_albums(self) -> List[Album]: + """Retrieve all library albums from Qobuz.""" + params = {"type": "albums"} + endpoint = "favorite/getUserFavorites" + return [ + await self._parse_album(item) + for item in await self._get_all_items(endpoint, params, key="albums") + if (item and item["id"]) + ] + + async def get_library_tracks(self) -> List[Track]: + """Retrieve library tracks from Qobuz.""" + params = {"type": "tracks"} + endpoint = "favorite/getUserFavorites" + return [ + await self._parse_track(item) + for item in await self._get_all_items(endpoint, params, key="tracks") + if (item and item["id"]) + ] + + async def get_library_playlists(self) -> List[Playlist]: + """Retrieve all library playlists from the provider.""" + endpoint = "playlist/getUserPlaylists" + return [ + await self._parse_playlist(item) + for item in await self._get_all_items(endpoint, key="playlists") + if (item and item["id"]) + ] + + async def get_artist(self, prov_artist_id) -> Artist: + """Get full artist details by id.""" + params = {"artist_id": prov_artist_id} + artist_obj = await self._get_data("artist/get", params) + return ( + await self._parse_artist(artist_obj) + if artist_obj and artist_obj["id"] + else None + ) + + async def get_album(self, prov_album_id) -> Album: + """Get full album details by id.""" + params = {"album_id": prov_album_id} + album_obj = await self._get_data("album/get", params) + return ( + await self._parse_album(album_obj) + if album_obj and album_obj["id"] + else None + ) + + async def get_track(self, prov_track_id) -> Track: + """Get full track details by id.""" + params = {"track_id": prov_track_id} + track_obj = await self._get_data("track/get", params) + return ( + await self._parse_track(track_obj) + if track_obj and track_obj["id"] + else None + ) + + async def get_playlist(self, prov_playlist_id) -> Playlist: + """Get full playlist details by id.""" + params = {"playlist_id": prov_playlist_id} + playlist_obj = await self._get_data("playlist/get", params) + return ( + await self._parse_playlist(playlist_obj) + if playlist_obj and playlist_obj["id"] + else None + ) + + async def get_album_tracks(self, prov_album_id) -> List[Track]: + """Get all album tracks for given album id.""" + params = {"album_id": prov_album_id} + return [ + await self._parse_track(item) + for item in await self._get_all_items("album/get", params, key="tracks") + if (item and item["id"]) + ] + + async def get_playlist_tracks(self, prov_playlist_id) -> List[Track]: + """Get all playlist tracks for given playlist id.""" + params = {"playlist_id": prov_playlist_id, "extra": "tracks"} + endpoint = "playlist/get" + return [ + await self._parse_track(item) + for item in await self._get_all_items(endpoint, params, key="tracks") + if (item and item["id"]) + ] + + async def get_artist_albums(self, prov_artist_id) -> List[Album]: + """Get a list of albums for the given artist.""" + params = {"artist_id": prov_artist_id, "extra": "albums"} + endpoint = "artist/get" + return [ + await self._parse_album(item) + for item in await self._get_all_items(endpoint, params, key="albums") + if (item and item["id"] and str(item["artist"]["id"]) == prov_artist_id) + ] + + async def get_artist_toptracks(self, prov_artist_id) -> List[Track]: + """Get a list of most popular tracks for the given artist.""" + params = { + "artist_id": prov_artist_id, + "extra": "playlists", + "offset": 0, + "limit": 25, + } + result = await self._get_data("artist/get", params) + if result and result["playlists"]: + return [ + await self._parse_track(item) + for item in result["playlists"][0]["tracks"]["items"] + if (item and item["id"]) + ] + # fallback to search + artist = await self.get_artist(prov_artist_id) + params = {"query": artist.name, "limit": 25, "type": "tracks"} + searchresult = await self._get_data("catalog/search", params) + return [ + await self._parse_track(item) + for item in searchresult["tracks"]["items"] + if ( + item + and item["id"] + and "performer" in item + and str(item["performer"]["id"]) == str(prov_artist_id) + ) + ] + + async def get_similar_artists(self, prov_artist_id): + """Get similar artists for given artist.""" + # https://www.qobuz.com/api.json/0.2/artist/getSimilarArtists?artist_id=220020&offset=0&limit=3 + + async def library_add(self, prov_item_id, media_type: MediaType): + """Add item to library.""" + result = None + if media_type == MediaType.ARTIST: + result = await self._get_data( + "favorite/create", {"artist_ids": prov_item_id} + ) + elif media_type == MediaType.ALBUM: + result = await self._get_data( + "favorite/create", {"album_ids": prov_item_id} + ) + elif media_type == MediaType.TRACK: + result = await self._get_data( + "favorite/create", {"track_ids": prov_item_id} + ) + elif media_type == MediaType.PLAYLIST: + result = await self._get_data( + "playlist/subscribe", {"playlist_id": prov_item_id} + ) + return result + + async def library_remove(self, prov_item_id, media_type: MediaType): + """Remove item from library.""" + result = None + if media_type == MediaType.ARTIST: + result = await self._get_data( + "favorite/delete", {"artist_ids": prov_item_id} + ) + elif media_type == MediaType.ALBUM: + result = await self._get_data( + "favorite/delete", {"album_ids": prov_item_id} + ) + elif media_type == MediaType.TRACK: + result = await self._get_data( + "favorite/delete", {"track_ids": prov_item_id} + ) + elif media_type == MediaType.PLAYLIST: + playlist = await self.get_playlist(prov_item_id) + if playlist.is_editable: + result = await self._get_data( + "playlist/delete", {"playlist_id": prov_item_id} + ) + else: + result = await self._get_data( + "playlist/unsubscribe", {"playlist_id": prov_item_id} + ) + return result + + async def add_playlist_tracks(self, prov_playlist_id, prov_track_ids): + """Add track(s) to playlist.""" + params = { + "playlist_id": prov_playlist_id, + "track_ids": ",".join(prov_track_ids), + "playlist_track_ids": ",".join(prov_track_ids), + } + return await self._get_data("playlist/addTracks", params) + + async def remove_playlist_tracks(self, prov_playlist_id, prov_track_ids): + """Remove track(s) from playlist.""" + playlist_track_ids = set() + params = {"playlist_id": prov_playlist_id, "extra": "tracks"} + for track in await self._get_all_items("playlist/get", params, key="tracks"): + if str(track["id"]) in prov_track_ids: + playlist_track_ids.add(str(track["playlist_track_id"])) + params = { + "playlist_id": prov_playlist_id, + "playlist_track_ids": ",".join(playlist_track_ids), + } + return await self._get_data("playlist/deleteTracks", params) + + async def get_stream_details(self, item_id: str) -> StreamDetails: + """Return the content details for the given track when it will be streamed.""" + streamdata = None + for format_id in [27, 7, 6, 5]: + # it seems that simply requesting for highest available quality does not work + # from time to time the api response is empty for this request ?! + params = {"format_id": format_id, "track_id": item_id, "intent": "stream"} + result = await self._get_data("track/getFileUrl", params, sign_request=True) + if result and result.get("url"): + streamdata = result + break + if not streamdata: + self.logger.error("Unable to retrieve stream details for track %s", item_id) + return None + if streamdata["mime_type"] == "audio/mpeg": + content_type = ContentType.MPEG + elif streamdata["mime_type"] == "audio/flac": + content_type = ContentType.FLAC + else: + self.logger.error("Unsupported mime type for track %s", item_id) + return None + return StreamDetails( + type=StreamType.URL, + item_id=str(item_id), + provider=self.id, + path=streamdata["url"], + content_type=content_type, + sample_rate=int(streamdata["sampling_rate"] * 1000), + bit_depth=streamdata["bit_depth"], + details=streamdata, # we need these details for reporting playback + ) + + async def on_stream_event(self, msg, msg_details): + """ + Received event from mass. + + We use this to report playback start/stop to qobuz. + """ + if not self.__user_auth_info: + return + # TODO: need to figure out if the streamed track is purchased by user + # https://www.qobuz.com/api.json/0.2/purchase/getUserPurchasesIds?limit=5000&user_id=xxxxxxx + # {"albums":{"total":0,"items":[]},"tracks":{"total":0,"items":[]},"user":{"id":xxxx,"login":"xxxxx"}} + if msg == EventType.STREAM_STARTED and msg_details.provider == self.id: + # report streaming started to qobuz + device_id = self.__user_auth_info["user"]["device"]["id"] + credential_id = self.__user_auth_info["user"]["credential"]["id"] + user_id = self.__user_auth_info["user"]["id"] + format_id = msg_details.details["format_id"] + timestamp = int(time.time()) + events = [ + { + "online": True, + "sample": False, + "intent": "stream", + "device_id": device_id, + "track_id": str(msg_details.item_id), + "purchase": False, + "date": timestamp, + "credential_id": credential_id, + "user_id": user_id, + "local": False, + "format_id": format_id, + } + ] + await self._post_data("track/reportStreamingStart", data=events) + elif msg == EventType.STREAM_ENDED and msg_details.provider == self.id: + # report streaming ended to qobuz + user_id = self.__user_auth_info["user"]["id"] + params = { + "user_id": user_id, + "track_id": str(msg_details.item_id), + "duration": try_parse_int(msg_details.seconds_played), + } + await self._get_data("/track/reportStreamingEnd", params) + + async def _parse_artist(self, artist_obj): + """Parse qobuz artist object to generic layout.""" + artist = Artist( + item_id=str(artist_obj["id"]), provider=self.id, name=artist_obj["name"] + ) + artist.provider_ids.append( + MediaItemProviderId(provider=self.id, item_id=str(artist_obj["id"])) + ) + artist.metadata["image"] = self.__get_image(artist_obj) + if artist_obj.get("biography"): + artist.metadata["biography"] = artist_obj["biography"].get("content", "") + if artist_obj.get("url"): + artist.metadata["qobuz_url"] = artist_obj["url"] + return artist + + async def _parse_album(self, album_obj: dict, artist_obj: dict = None): + """Parse qobuz album object to generic layout.""" + if not artist_obj and "artist" not in album_obj: + # artist missing in album info, return full abum instead + return await self.get_album(album_obj["id"]) + name, version = parse_title_and_version( + album_obj["title"], album_obj.get("version") + ) + album = Album( + item_id=str(album_obj["id"]), provider=self.id, name=name, version=version + ) + if album_obj["maximum_sampling_rate"] > 192: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4 + elif album_obj["maximum_sampling_rate"] > 96: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3 + elif album_obj["maximum_sampling_rate"] > 48: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2 + elif album_obj["maximum_bit_depth"] > 16: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_1 + elif album_obj.get("format_id", 0) == 5: + quality = MediaQuality.LOSSY_AAC + else: + quality = MediaQuality.FLAC_LOSSLESS + album.provider_ids.append( + MediaItemProviderId( + provider=self.id, + item_id=str(album_obj["id"]), + quality=quality, + details=f'{album_obj["maximum_sampling_rate"]}kHz {album_obj["maximum_bit_depth"]}bit', + available=album_obj["streamable"] and album_obj["displayable"], + ) + ) + + if artist_obj: + album.artist = artist_obj + else: + album.artist = await self._parse_artist(album_obj["artist"]) + if ( + album_obj.get("product_type", "") == "single" + or album_obj.get("release_type", "") == "single" + ): + album.album_type = AlbumType.SINGLE + elif ( + album_obj.get("product_type", "") == "compilation" + or "Various" in album.artist.name + ): + album.album_type = AlbumType.COMPILATION + elif ( + album_obj.get("product_type", "") == "album" + or album_obj.get("release_type", "") == "album" + ): + album.album_type = AlbumType.ALBUM + if "genre" in album_obj: + album.metadata["genre"] = album_obj["genre"]["name"] + album.metadata["image"] = self.__get_image(album_obj) + if len(album_obj["upc"]) == 13: + # qobuz writes ean as upc ?! + album.metadata["ean"] = album_obj["upc"] + album.upc = album_obj["upc"][1:] + else: + album.upc = album_obj["upc"] + if "label" in album_obj: + album.metadata["label"] = album_obj["label"]["name"] + if album_obj.get("released_at"): + album.year = datetime.datetime.fromtimestamp(album_obj["released_at"]).year + if album_obj.get("copyright"): + album.metadata["copyright"] = album_obj["copyright"] + if album_obj.get("hires"): + album.metadata["hires"] = "true" + if album_obj.get("url"): + album.metadata["qobuz_url"] = album_obj["url"] + if album_obj.get("description"): + album.metadata["description"] = album_obj["description"] + return album + + async def _parse_track(self, track_obj): + """Parse qobuz track object to generic layout.""" + name, version = parse_title_and_version( + track_obj["title"], track_obj.get("version") + ) + track = Track( + item_id=str(track_obj["id"]), + provider=self.id, + name=name, + version=version, + disc_number=track_obj["media_number"], + track_number=track_obj["track_number"], + duration=track_obj["duration"], + ) + if track_obj.get("performer") and "Various " not in track_obj["performer"]: + artist = await self._parse_artist(track_obj["performer"]) + if artist: + track.artists.append(artist) + if not track.artists: + # try to grab artist from album + if ( + track_obj.get("album") + and track_obj["album"].get("artist") + and "Various " not in track_obj["album"]["artist"] + ): + artist = await self._parse_artist(track_obj["album"]["artist"]) + if artist: + track.artists.append(artist) + if not track.artists: + # last resort: parse from performers string + for performer_str in track_obj["performers"].split(" - "): + role = performer_str.split(", ")[1] + name = performer_str.split(", ")[0] + if "artist" in role.lower(): + artist = Artist(name, self.id, name) + track.artists.append(artist) + # TODO: fix grabbing composer from details + + if "album" in track_obj: + album = await self._parse_album(track_obj["album"]) + if album: + track.album = album + if track_obj.get("hires"): + track.metadata["hires"] = "true" + if track_obj.get("url"): + track.metadata["qobuz_url"] = track_obj["url"] + if track_obj.get("isrc"): + track.isrc = track_obj["isrc"] + if track_obj.get("performers"): + track.metadata["performers"] = track_obj["performers"] + if track_obj.get("copyright"): + track.metadata["copyright"] = track_obj["copyright"] + if track_obj.get("audio_info"): + track.metadata["replaygain"] = track_obj["audio_info"][ + "replaygain_track_gain" + ] + if track_obj.get("parental_warning"): + track.metadata["explicit"] = True + track.metadata["image"] = self.__get_image(track_obj) + # get track quality + if track_obj["maximum_sampling_rate"] > 192: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4 + elif track_obj["maximum_sampling_rate"] > 96: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3 + elif track_obj["maximum_sampling_rate"] > 48: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2 + elif track_obj["maximum_bit_depth"] > 16: + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_1 + elif track_obj.get("format_id", 0) == 5: + quality = MediaQuality.LOSSY_AAC + else: + quality = MediaQuality.FLAC_LOSSLESS + track.provider_ids.append( + MediaItemProviderId( + provider=self.id, + item_id=str(track_obj["id"]), + quality=quality, + details=f'{track_obj["maximum_sampling_rate"]}kHz {track_obj["maximum_bit_depth"]}bit', + available=track_obj["streamable"] and track_obj["displayable"], + ) + ) + return track + + async def _parse_playlist(self, playlist_obj): + """Parse qobuz playlist object to generic layout.""" + playlist = Playlist( + item_id=str(playlist_obj["id"]), + provider=self.id, + name=playlist_obj["name"], + owner=playlist_obj["owner"]["name"], + ) + playlist.provider_ids.append( + MediaItemProviderId(provider=self.id, item_id=str(playlist_obj["id"])) + ) + playlist.is_editable = ( + playlist_obj["owner"]["id"] == self.__user_auth_info["user"]["id"] + or playlist_obj["is_collaborative"] + ) + playlist.metadata["image"] = self.__get_image(playlist_obj) + if playlist_obj.get("url"): + playlist.metadata["qobuz_url"] = playlist_obj["url"] + playlist.checksum = playlist_obj["updated_at"] + return playlist + + async def _auth_token(self): + """Login to qobuz and store the token.""" + if self.__user_auth_info: + return self.__user_auth_info["user_auth_token"] + params = { + "username": self._username, + "password": self._password, + "device_manufacturer_id": "music_assistant", + } + details = await self._get_data("user/login", params) + if details and "user" in details: + self.__user_auth_info = details + self.logger.info( + "Succesfully logged in to Qobuz as %s", details["user"]["display_name"] + ) + return details["user_auth_token"] + + async def _get_all_items(self, endpoint, params=None, key="tracks"): + """Get all items from a paged list.""" + if not params: + params = {} + limit = 50 + offset = 0 + all_items = [] + while True: + params["limit"] = limit + params["offset"] = offset + result = await self._get_data(endpoint, params=params) + offset += limit + if not result: + break + if not result.get(key) or not result[key].get("items"): + break + all_items += result[key]["items"] + if len(result[key]["items"]) < limit: + break + return all_items + + async def _get_data(self, endpoint, params=None, sign_request=False): + """Get data from api.""" + if not params: + params = {} + url = f"http://www.qobuz.com/api.json/0.2/{endpoint}" + headers = {"X-App-Id": get_app_var(0)} + if endpoint != "user/login": + auth_token = await self._auth_token() + if not auth_token: + self.logger.debug("Not logged in") + return None + headers["X-User-Auth-Token"] = auth_token + if sign_request: + signing_data = "".join(endpoint.split("/")) + keys = list(params.keys()) + keys.sort() + for key in keys: + signing_data += f"{key}{params[key]}" + request_ts = str(time.time()) + request_sig = signing_data + request_ts + get_app_var(1) + request_sig = str(hashlib.md5(request_sig.encode()).hexdigest()) + params["request_ts"] = request_ts + params["request_sig"] = request_sig + params["app_id"] = get_app_var(0) + params["user_auth_token"] = await self._auth_token() + async with self._throttler: + async with self.mass.http_session.get( + url, headers=headers, params=params, verify_ssl=False + ) as response: + result = await response.json() + if "error" in result or ( + "status" in result and "error" in result["status"] + ): + self.logger.error("%s - %s", endpoint, result) + return None + return result + + async def _post_data(self, endpoint, params=None, data=None): + """Post data to api.""" + if not params: + params = {} + if not data: + data = {} + url = f"http://www.qobuz.com/api.json/0.2/{endpoint}" + params["app_id"] = get_app_var(0) + params["user_auth_token"] = await self._auth_token() + async with self.mass.http_session.post( + url, params=params, json=data, verify_ssl=False + ) as response: + result = await response.json() + if "error" in result or ( + "status" in result and "error" in result["status"] + ): + self.logger.error("%s - %s", endpoint, result) + return None + return result + + def __get_image(self, obj: dict) -> Optional[str]: + """Try to parse image from Qobuz media object.""" + if obj.get("image"): + for key in ["extralarge", "large", "medium", "small"]: + if obj["image"].get(key): + if "2a96cbd8b46e442fc41c2b86b821562f" in obj["image"][key]: + continue + return obj["image"][key] + if obj.get("images300"): + # playlists seem to use this strange format + return obj["images300"][0] + if obj.get("album"): + return self.__get_image(obj["album"]) + if obj.get("artist"): + return self.__get_image(obj["artist"]) + return None diff --git a/music_assistant/providers/qobuz/__init__.py b/music_assistant/providers/qobuz/__init__.py deleted file mode 100644 index 6ddf0e86..00000000 --- a/music_assistant/providers/qobuz/__init__.py +++ /dev/null @@ -1,746 +0,0 @@ -"""Qobuz musicprovider support for MusicAssistant.""" -import datetime -import hashlib -import logging -import time -from typing import List, Optional - -from asyncio_throttle import Throttler -from music_assistant.constants import ( - CONF_PASSWORD, - CONF_USERNAME, - EVENT_STREAM_ENDED, - EVENT_STREAM_STARTED, -) -from music_assistant.helpers.app_vars import get_app_var # noqa # pylint: disable=all -from music_assistant.helpers.util import parse_title_and_version, try_parse_int -from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType -from music_assistant.models.media_types import ( - Album, - AlbumType, - Artist, - MediaItemProviderId, - MediaType, - Playlist, - Radio, - SearchResult, - Track, - TrackQuality, -) -from music_assistant.models.provider import MusicProvider -from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType - -PROV_ID = "qobuz" -PROV_NAME = "Qobuz" -LOGGER = logging.getLogger(PROV_ID) - -CONFIG_ENTRIES = [ - ConfigEntry( - entry_key=CONF_USERNAME, - entry_type=ConfigEntryType.STRING, - description=CONF_USERNAME, - ), - ConfigEntry( - entry_key=CONF_PASSWORD, - entry_type=ConfigEntryType.PASSWORD, - description=CONF_PASSWORD, - ), -] - - -async def setup(mass): - """Perform async setup of this Plugin/Provider.""" - prov = QobuzProvider() - await mass.register_provider(prov) - - -class QobuzProvider(MusicProvider): - """Provider for the Qobux music service.""" - - # pylint: disable=abstract-method - - __user_auth_info = None - - @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 - - @property - def supported_mediatypes(self) -> List[MediaType]: - """Return MediaTypes the provider supports.""" - return [MediaType.ALBUM, MediaType.ARTIST, MediaType.PLAYLIST, MediaType.TRACK] - - async def on_start(self) -> bool: - """Handle initialization of the provider based on config.""" - # pylint: disable=attribute-defined-outside-init - config = self.mass.config.get_provider_config(self.id) - if not config[CONF_USERNAME] or not config[CONF_PASSWORD]: - LOGGER.debug("Username and password not set. Abort load of provider.") - return False - self.__username = config[CONF_USERNAME] - self.__password = config[CONF_PASSWORD] - - self.__user_auth_info = None - self.__logged_in = False - self._throttler = Throttler(rate_limit=4, period=1) - self.mass.eventbus.add_listener( - self.mass_event, (EVENT_STREAM_STARTED, EVENT_STREAM_ENDED) - ) - return True - - async def 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). - """ - result = SearchResult() - params = {"query": search_query, "limit": limit} - if len(media_types) == 1: - # qobuz does not support multiple searchtypes, falls back to all if no type given - if media_types[0] == MediaType.ARTIST: - params["type"] = "artists" - if media_types[0] == MediaType.ALBUM: - params["type"] = "albums" - if media_types[0] == MediaType.TRACK: - params["type"] = "tracks" - if media_types[0] == MediaType.PLAYLIST: - params["type"] = "playlists" - searchresult = await self._get_data("catalog/search", params) - if searchresult: - if "artists" in searchresult: - result.artists = [ - await self._parse_artist(item) - for item in searchresult["artists"]["items"] - if (item and item["id"]) - ] - if "albums" in searchresult: - result.albums = [ - await self._parse_album(item) - for item in searchresult["albums"]["items"] - if (item and item["id"]) - ] - if "tracks" in searchresult: - result.tracks = [ - await self._parse_track(item) - for item in searchresult["tracks"]["items"] - if (item and item["id"]) - ] - if "playlists" in searchresult: - result.playlists = [ - await self._parse_playlist(item) - for item in searchresult["playlists"]["items"] - if (item and item["id"]) - ] - return result - - async def get_library_artists(self) -> List[Artist]: - """Retrieve all library artists from Qobuz.""" - params = {"type": "artists"} - endpoint = "favorite/getUserFavorites" - return [ - await self._parse_artist(item) - for item in await self._get_all_items(endpoint, params, key="artists") - if (item and item["id"]) - ] - - async def get_library_albums(self) -> List[Album]: - """Retrieve all library albums from Qobuz.""" - params = {"type": "albums"} - endpoint = "favorite/getUserFavorites" - return [ - await self._parse_album(item) - for item in await self._get_all_items(endpoint, params, key="albums") - if (item and item["id"]) - ] - - async def get_library_tracks(self) -> List[Track]: - """Retrieve library tracks from Qobuz.""" - params = {"type": "tracks"} - endpoint = "favorite/getUserFavorites" - return [ - await self._parse_track(item) - for item in await self._get_all_items(endpoint, params, key="tracks") - if (item and item["id"]) - ] - - async def get_library_playlists(self) -> List[Playlist]: - """Retrieve all library playlists from the provider.""" - endpoint = "playlist/getUserPlaylists" - return [ - await self._parse_playlist(item) - for item in await self._get_all_items(endpoint, key="playlists") - if (item and item["id"]) - ] - - async def get_radios(self) -> List[Radio]: - """Retrieve library/subscribed radio stations from the provider.""" - return [] # TODO - - async def get_artist(self, prov_artist_id) -> Artist: - """Get full artist details by id.""" - params = {"artist_id": prov_artist_id} - artist_obj = await self._get_data("artist/get", params) - return ( - await self._parse_artist(artist_obj) - if artist_obj and artist_obj["id"] - else None - ) - - async def get_album(self, prov_album_id) -> Album: - """Get full album details by id.""" - params = {"album_id": prov_album_id} - album_obj = await self._get_data("album/get", params) - return ( - await self._parse_album(album_obj) - if album_obj and album_obj["id"] - else None - ) - - async def get_track(self, prov_track_id) -> Track: - """Get full track details by id.""" - params = {"track_id": prov_track_id} - track_obj = await self._get_data("track/get", params) - return ( - await self._parse_track(track_obj) - if track_obj and track_obj["id"] - else None - ) - - async def get_playlist(self, prov_playlist_id) -> Playlist: - """Get full playlist details by id.""" - params = {"playlist_id": prov_playlist_id} - playlist_obj = await self._get_data("playlist/get", params) - return ( - await self._parse_playlist(playlist_obj) - if playlist_obj and playlist_obj["id"] - else None - ) - - async def get_album_tracks(self, prov_album_id) -> List[Track]: - """Get all album tracks for given album id.""" - params = {"album_id": prov_album_id} - return [ - await self._parse_track(item) - for item in await self._get_all_items("album/get", params, key="tracks") - if (item and item["id"]) - ] - - async def get_playlist_tracks(self, prov_playlist_id) -> List[Track]: - """Get all playlist tracks for given playlist id.""" - params = {"playlist_id": prov_playlist_id, "extra": "tracks"} - endpoint = "playlist/get" - return [ - await self._parse_track(item) - for item in await self._get_all_items(endpoint, params, key="tracks") - if (item and item["id"]) - ] - - async def get_artist_albums(self, prov_artist_id) -> List[Album]: - """Get a list of albums for the given artist.""" - params = {"artist_id": prov_artist_id, "extra": "albums"} - endpoint = "artist/get" - return [ - await self._parse_album(item) - for item in await self._get_all_items(endpoint, params, key="albums") - if (item and item["id"] and str(item["artist"]["id"]) == prov_artist_id) - ] - - async def get_artist_toptracks(self, prov_artist_id) -> List[Track]: - """Get a list of most popular tracks for the given artist.""" - params = { - "artist_id": prov_artist_id, - "extra": "playlists", - "offset": 0, - "limit": 25, - } - result = await self._get_data("artist/get", params) - if result and result["playlists"]: - return [ - await self._parse_track(item) - for item in result["playlists"][0]["tracks"]["items"] - if (item and item["id"]) - ] - # fallback to search - artist = await self.get_artist(prov_artist_id) - params = {"query": artist.name, "limit": 25, "type": "tracks"} - searchresult = await self._get_data("catalog/search", params) - return [ - await self._parse_track(item) - for item in searchresult["tracks"]["items"] - if ( - item - and item["id"] - and "performer" in item - and str(item["performer"]["id"]) == str(prov_artist_id) - ) - ] - - async def get_similar_artists(self, prov_artist_id): - """Get similar artists for given artist.""" - # https://www.qobuz.com/api.json/0.2/artist/getSimilarArtists?artist_id=220020&offset=0&limit=3 - - async def library_add(self, prov_item_id, media_type: MediaType): - """Add item to library.""" - result = None - if media_type == MediaType.ARTIST: - result = await self._get_data( - "favorite/create", {"artist_ids": prov_item_id} - ) - elif media_type == MediaType.ALBUM: - result = await self._get_data( - "favorite/create", {"album_ids": prov_item_id} - ) - elif media_type == MediaType.TRACK: - result = await self._get_data( - "favorite/create", {"track_ids": prov_item_id} - ) - elif media_type == MediaType.PLAYLIST: - result = await self._get_data( - "playlist/subscribe", {"playlist_id": prov_item_id} - ) - return result - - async def library_remove(self, prov_item_id, media_type: MediaType): - """Remove item from library.""" - result = None - if media_type == MediaType.ARTIST: - result = await self._get_data( - "favorite/delete", {"artist_ids": prov_item_id} - ) - elif media_type == MediaType.ALBUM: - result = await self._get_data( - "favorite/delete", {"album_ids": prov_item_id} - ) - elif media_type == MediaType.TRACK: - result = await self._get_data( - "favorite/delete", {"track_ids": prov_item_id} - ) - elif media_type == MediaType.PLAYLIST: - playlist = await self.get_playlist(prov_item_id) - if playlist.is_editable: - result = await self._get_data( - "playlist/delete", {"playlist_id": prov_item_id} - ) - else: - result = await self._get_data( - "playlist/unsubscribe", {"playlist_id": prov_item_id} - ) - return result - - async def add_playlist_tracks(self, prov_playlist_id, prov_track_ids): - """Add track(s) to playlist.""" - params = { - "playlist_id": prov_playlist_id, - "track_ids": ",".join(prov_track_ids), - "playlist_track_ids": ",".join(prov_track_ids), - } - return await self._get_data("playlist/addTracks", params) - - async def remove_playlist_tracks(self, prov_playlist_id, prov_track_ids): - """Remove track(s) from playlist.""" - playlist_track_ids = set() - params = {"playlist_id": prov_playlist_id, "extra": "tracks"} - for track in await self._get_all_items("playlist/get", params, key="tracks"): - if str(track["id"]) in prov_track_ids: - playlist_track_ids.add(str(track["playlist_track_id"])) - params = { - "playlist_id": prov_playlist_id, - "playlist_track_ids": ",".join(playlist_track_ids), - } - return await self._get_data("playlist/deleteTracks", params) - - async def get_stream_details(self, item_id: str) -> StreamDetails: - """Return the content details for the given track when it will be streamed.""" - streamdata = None - for format_id in [27, 7, 6, 5]: - # it seems that simply requesting for highest available quality does not work - # from time to time the api response is empty for this request ?! - params = {"format_id": format_id, "track_id": item_id, "intent": "stream"} - result = await self._get_data("track/getFileUrl", params, sign_request=True) - if result and result.get("url"): - streamdata = result - break - if not streamdata: - LOGGER.error("Unable to retrieve stream details for track %s", item_id) - return None - if streamdata["mime_type"] == "audio/mpeg": - content_type = ContentType.MPEG - elif streamdata["mime_type"] == "audio/flac": - content_type = ContentType.FLAC - else: - LOGGER.error("Unsupported mime type for track %s", item_id) - return None - return StreamDetails( - type=StreamType.URL, - item_id=str(item_id), - provider=PROV_ID, - path=streamdata["url"], - content_type=content_type, - sample_rate=int(streamdata["sampling_rate"] * 1000), - bit_depth=streamdata["bit_depth"], - details=streamdata, # we need these details for reporting playback - ) - - async def mass_event(self, msg, msg_details): - """ - Received event from mass. - - We use this to report playback start/stop to qobuz. - """ - if not self.__user_auth_info: - return - # TODO: need to figure out if the streamed track is purchased by user - # https://www.qobuz.com/api.json/0.2/purchase/getUserPurchasesIds?limit=5000&user_id=xxxxxxx - # {"albums":{"total":0,"items":[]},"tracks":{"total":0,"items":[]},"user":{"id":xxxx,"login":"xxxxx"}} - if msg == EVENT_STREAM_STARTED and msg_details.provider == PROV_ID: - # report streaming started to qobuz - device_id = self.__user_auth_info["user"]["device"]["id"] - credential_id = self.__user_auth_info["user"]["credential"]["id"] - user_id = self.__user_auth_info["user"]["id"] - format_id = msg_details.details["format_id"] - timestamp = int(time.time()) - events = [ - { - "online": True, - "sample": False, - "intent": "stream", - "device_id": device_id, - "track_id": str(msg_details.item_id), - "purchase": False, - "date": timestamp, - "credential_id": credential_id, - "user_id": user_id, - "local": False, - "format_id": format_id, - } - ] - await self._post_data("track/reportStreamingStart", data=events) - elif msg == EVENT_STREAM_ENDED and msg_details.provider == PROV_ID: - # report streaming ended to qobuz - # if msg_details.details < 6: - # return ????????????? TODO - user_id = self.__user_auth_info["user"]["id"] - params = { - "user_id": user_id, - "track_id": str(msg_details.item_id), - "duration": try_parse_int(msg_details.seconds_played), - } - await self._get_data("/track/reportStreamingEnd", params) - - async def _parse_artist(self, artist_obj): - """Parse qobuz artist object to generic layout.""" - artist = Artist( - item_id=str(artist_obj["id"]), provider=PROV_ID, name=artist_obj["name"] - ) - artist.provider_ids.add( - MediaItemProviderId(provider=PROV_ID, item_id=str(artist_obj["id"])) - ) - artist.metadata["image"] = self.__get_image(artist_obj) - if artist_obj.get("biography"): - artist.metadata["biography"] = artist_obj["biography"].get("content", "") - if artist_obj.get("url"): - artist.metadata["qobuz_url"] = artist_obj["url"] - return artist - - async def _parse_album(self, album_obj: dict, artist_obj: dict = None): - """Parse qobuz album object to generic layout.""" - if not artist_obj and "artist" not in album_obj: - # artist missing in album info, return full abum instead - return await self.get_album(album_obj["id"]) - album = Album(item_id=str(album_obj["id"]), provider=PROV_ID) - if album_obj["maximum_sampling_rate"] > 192: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4 - elif album_obj["maximum_sampling_rate"] > 96: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3 - elif album_obj["maximum_sampling_rate"] > 48: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2 - elif album_obj["maximum_bit_depth"] > 16: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_1 - elif album_obj.get("format_id", 0) == 5: - quality = TrackQuality.LOSSY_AAC - else: - quality = TrackQuality.FLAC_LOSSLESS - album.provider_ids.add( - MediaItemProviderId( - provider=PROV_ID, - item_id=str(album_obj["id"]), - quality=quality, - details=f'{album_obj["maximum_sampling_rate"]}kHz {album_obj["maximum_bit_depth"]}bit', - available=album_obj["streamable"] and album_obj["displayable"], - ) - ) - album.name, album.version = parse_title_and_version( - album_obj["title"], album_obj.get("version") - ) - if artist_obj: - album.artist = artist_obj - else: - album.artist = await self._parse_artist(album_obj["artist"]) - if ( - album_obj.get("product_type", "") == "single" - or album_obj.get("release_type", "") == "single" - ): - album.album_type = AlbumType.SINGLE - elif ( - album_obj.get("product_type", "") == "compilation" - or "Various" in album.artist.name - ): - album.album_type = AlbumType.COMPILATION - elif ( - album_obj.get("product_type", "") == "album" - or album_obj.get("release_type", "") == "album" - ): - album.album_type = AlbumType.ALBUM - if "genre" in album_obj: - album.metadata["genre"] = album_obj["genre"]["name"] - album.metadata["image"] = self.__get_image(album_obj) - if len(album_obj["upc"]) == 13: - # qobuz writes ean as upc ?! - album.metadata["ean"] = album_obj["upc"] - album.upc = album_obj["upc"][1:] - else: - album.upc = album_obj["upc"] - if "label" in album_obj: - album.metadata["label"] = album_obj["label"]["name"] - if album_obj.get("released_at"): - album.year = datetime.datetime.fromtimestamp(album_obj["released_at"]).year - if album_obj.get("copyright"): - album.metadata["copyright"] = album_obj["copyright"] - if album_obj.get("hires"): - album.metadata["hires"] = "true" - if album_obj.get("url"): - album.metadata["qobuz_url"] = album_obj["url"] - if album_obj.get("description"): - album.metadata["description"] = album_obj["description"] - return album - - async def _parse_track(self, track_obj): - """Parse qobuz track object to generic layout.""" - track = Track( - item_id=str(track_obj["id"]), - provider=PROV_ID, - disc_number=track_obj["media_number"], - track_number=track_obj["track_number"], - duration=track_obj["duration"], - ) - if track_obj.get("performer") and "Various " not in track_obj["performer"]: - artist = await self._parse_artist(track_obj["performer"]) - if artist: - track.artists.add(artist) - if not track.artists: - # try to grab artist from album - if ( - track_obj.get("album") - and track_obj["album"].get("artist") - and "Various " not in track_obj["album"]["artist"] - ): - artist = await self._parse_artist(track_obj["album"]["artist"]) - if artist: - track.artists.add(artist) - if not track.artists: - # last resort: parse from performers string - for performer_str in track_obj["performers"].split(" - "): - role = performer_str.split(", ")[1] - name = performer_str.split(", ")[0] - if "artist" in role.lower(): - artist = Artist() - artist.name = name - artist.item_id = name - track.artists.add(artist) - # TODO: fix grabbing composer from details - track.name, track.version = parse_title_and_version( - track_obj["title"], track_obj.get("version") - ) - if "album" in track_obj: - album = await self._parse_album(track_obj["album"]) - if album: - track.album = album - if track_obj.get("hires"): - track.metadata["hires"] = "true" - if track_obj.get("url"): - track.metadata["qobuz_url"] = track_obj["url"] - if track_obj.get("isrc"): - track.isrc = track_obj["isrc"] - if track_obj.get("performers"): - track.metadata["performers"] = track_obj["performers"] - if track_obj.get("copyright"): - track.metadata["copyright"] = track_obj["copyright"] - if track_obj.get("audio_info"): - track.metadata["replaygain"] = track_obj["audio_info"][ - "replaygain_track_gain" - ] - if track_obj.get("parental_warning"): - track.metadata["explicit"] = True - track.metadata["image"] = self.__get_image(track_obj) - # get track quality - if track_obj["maximum_sampling_rate"] > 192: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4 - elif track_obj["maximum_sampling_rate"] > 96: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3 - elif track_obj["maximum_sampling_rate"] > 48: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2 - elif track_obj["maximum_bit_depth"] > 16: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_1 - elif track_obj.get("format_id", 0) == 5: - quality = TrackQuality.LOSSY_AAC - else: - quality = TrackQuality.FLAC_LOSSLESS - track.provider_ids.add( - MediaItemProviderId( - provider=PROV_ID, - item_id=str(track_obj["id"]), - quality=quality, - details=f'{track_obj["maximum_sampling_rate"]}kHz {track_obj["maximum_bit_depth"]}bit', - available=track_obj["streamable"] and track_obj["displayable"], - ) - ) - return track - - async def _parse_playlist(self, playlist_obj): - """Parse qobuz playlist object to generic layout.""" - playlist = Playlist( - item_id=str(playlist_obj["id"]), - provider=PROV_ID, - name=playlist_obj["name"], - owner=playlist_obj["owner"]["name"], - ) - playlist.provider_ids.add( - MediaItemProviderId(provider=PROV_ID, item_id=str(playlist_obj["id"])) - ) - playlist.is_editable = ( - playlist_obj["owner"]["id"] == self.__user_auth_info["user"]["id"] - or playlist_obj["is_collaborative"] - ) - playlist.metadata["image"] = self.__get_image(playlist_obj) - if playlist_obj.get("url"): - playlist.metadata["qobuz_url"] = playlist_obj["url"] - playlist.checksum = playlist_obj["updated_at"] - return playlist - - async def _auth_token(self): - """Login to qobuz and store the token.""" - if self.__user_auth_info: - return self.__user_auth_info["user_auth_token"] - params = { - "username": self.__username, - "password": self.__password, - "device_manufacturer_id": "music_assistant", - } - details = await self._get_data("user/login", params) - if details and "user" in details: - self.__user_auth_info = details - LOGGER.info( - "Succesfully logged in to Qobuz as %s", details["user"]["display_name"] - ) - return details["user_auth_token"] - - async def _get_all_items(self, endpoint, params=None, key="tracks"): - """Get all items from a paged list.""" - if not params: - params = {} - limit = 50 - offset = 0 - all_items = [] - while True: - params["limit"] = limit - params["offset"] = offset - result = await self._get_data(endpoint, params=params) - offset += limit - if not result: - break - if not result.get(key) or not result[key].get("items"): - break - all_items += result[key]["items"] - if len(result[key]["items"]) < limit: - break - return all_items - - async def _get_data(self, endpoint, params=None, sign_request=False): - """Get data from api.""" - if not params: - params = {} - url = "http://www.qobuz.com/api.json/0.2/%s" % endpoint - headers = {"X-App-Id": get_app_var(0)} - if endpoint != "user/login": - auth_token = await self._auth_token() - if not auth_token: - LOGGER.debug("Not logged in") - return None - headers["X-User-Auth-Token"] = auth_token - if sign_request: - signing_data = "".join(endpoint.split("/")) - keys = list(params.keys()) - keys.sort() - for key in keys: - signing_data += "%s%s" % (key, params[key]) - request_ts = str(time.time()) - request_sig = signing_data + request_ts + get_app_var(1) - request_sig = str(hashlib.md5(request_sig.encode()).hexdigest()) - params["request_ts"] = request_ts - params["request_sig"] = request_sig - params["app_id"] = get_app_var(0) - params["user_auth_token"] = await self._auth_token() - async with self._throttler: - async with self.mass.http_session.get( - url, headers=headers, params=params, verify_ssl=False - ) as response: - result = await response.json() - if "error" in result or ( - "status" in result and "error" in result["status"] - ): - LOGGER.error("%s - %s", endpoint, result) - return None - return result - - async def _post_data(self, endpoint, params=None, data=None): - """Post data to api.""" - if not params: - params = {} - if not data: - data = {} - url = "http://www.qobuz.com/api.json/0.2/%s" % endpoint - params["app_id"] = get_app_var(0) - params["user_auth_token"] = await self._auth_token() - async with self.mass.http_session.post( - url, params=params, json=data, verify_ssl=False - ) as response: - result = await response.json() - if "error" in result or ( - "status" in result and "error" in result["status"] - ): - LOGGER.error("%s - %s", endpoint, result) - return None - return result - - def __get_image(self, obj: dict) -> Optional[str]: - """Try to parse image from Qobuz media object.""" - if obj.get("image"): - for key in ["extralarge", "large", "medium", "small"]: - if obj["image"].get(key): - if "2a96cbd8b46e442fc41c2b86b821562f" in obj["image"][key]: - continue - return obj["image"][key] - if obj.get("images300"): - # playlists seem to use this strange format - return obj["images300"][0] - if obj.get("album"): - return self.__get_image(obj["album"]) - if obj.get("artist"): - return self.__get_image(obj["artist"]) - return None diff --git a/music_assistant/providers/qobuz/icon.png b/music_assistant/providers/qobuz/icon.png deleted file mode 100644 index 9d7b726c..00000000 Binary files a/music_assistant/providers/qobuz/icon.png and /dev/null differ diff --git a/music_assistant/providers/sonos/__init__.py b/music_assistant/providers/sonos/__init__.py deleted file mode 100644 index 782c3451..00000000 --- a/music_assistant/providers/sonos/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Player provider for Sonos speakers.""" - -from .sonos import SonosProvider - - -async def setup(mass): - """Perform async setup of this Plugin/Provider.""" - prov = SonosProvider() - await mass.register_provider(prov) diff --git a/music_assistant/providers/sonos/icon.png b/music_assistant/providers/sonos/icon.png deleted file mode 100644 index d00f12ac..00000000 Binary files a/music_assistant/providers/sonos/icon.png and /dev/null differ diff --git a/music_assistant/providers/sonos/sonos.py b/music_assistant/providers/sonos/sonos.py deleted file mode 100644 index cd673968..00000000 --- a/music_assistant/providers/sonos/sonos.py +++ /dev/null @@ -1,420 +0,0 @@ -"""Player provider for Sonos speakers.""" - -import asyncio -import logging -import time -from typing import List - -import soco -from music_assistant.helpers.util import create_task -from music_assistant.models.config_entry import ConfigEntry -from music_assistant.models.player import DeviceInfo, Player, PlayerFeature, PlayerState -from music_assistant.models.player_queue import QueueItem -from music_assistant.models.provider import PlayerProvider - -PROV_ID = "sonos" -PROV_NAME = "Sonos" -LOGGER = logging.getLogger(PROV_ID) - -CONFIG_ENTRIES = [] # we don't have any provider config entries (for now) -PLAYER_FEATURES = [PlayerFeature.QUEUE, PlayerFeature.CROSSFADE, PlayerFeature.GAPLESS] -PLAYER_CONFIG_ENTRIES = [] # we don't have any player config entries (for now) - - -class SonosProvider(PlayerProvider): - """Support for Sonos speakers.""" - - # pylint: disable=abstract-method - - _discovery_running = False - _tasks = [] - _players = {} - _report_progress_tasks = [] - - @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 on_start(self) -> bool: - """Handle initialization of the provider.""" - self.mass.tasks.add("Run Sonos discovery", self.__run_discovery, periodic=1800) - - async def on_stop(self): - """Handle correct close/cleanup of the provider on exit.""" - for task in self._tasks: - task.cancel() - - async def cmd_play_uri(self, player_id: str, uri: str): - """ - Play the specified uri/url on the goven player. - - :param player_id: player_id of the player to handle the command. - """ - player = self._players.get(player_id) - if player: - create_task(player.soco.play_uri, uri) - else: - LOGGER.warning("Received command for unavailable player: %s", player_id) - - async def cmd_stop(self, player_id: str) -> None: - """ - Send STOP command to given player. - - :param player_id: player_id of the player to handle the command. - """ - player = self._players.get(player_id) - if player: - create_task(player.soco.stop) - else: - LOGGER.warning("Received command for unavailable player: %s", player_id) - - async def cmd_play(self, player_id: str) -> None: - """ - Send STOP command to given player. - - :param player_id: player_id of the player to handle the command. - """ - player = self._players.get(player_id) - if player: - create_task(player.soco.play) - else: - LOGGER.warning("Received command for unavailable player: %s", player_id) - - async def cmd_pause(self, player_id: str): - """ - Send PAUSE command to given player. - - :param player_id: player_id of the player to handle the command. - """ - player = self._players.get(player_id) - if player: - create_task(player.soco.pause) - else: - LOGGER.warning("Received command for unavailable player: %s", player_id) - - async def cmd_next(self, player_id: str): - """ - Send NEXT TRACK command to given player. - - :param player_id: player_id of the player to handle the command. - """ - player = self._players.get(player_id) - if player: - create_task(player.soco.next) - else: - LOGGER.warning("Received command for unavailable player: %s", player_id) - - async def cmd_previous(self, player_id: str): - """ - Send PREVIOUS TRACK command to given player. - - :param player_id: player_id of the player to handle the command. - """ - player = self._players.get(player_id) - if player: - create_task(player.soco.previous) - else: - LOGGER.warning("Received command for unavailable player: %s", player_id) - - async def cmd_power_on(self, player_id: str) -> None: - """ - Send POWER ON command to given player. - - :param player_id: player_id of the player to handle the command. - """ - player = self._players.get(player_id) - if player: - # power is not supported so abuse mute instead - player.soco.mute = False - player.powered = True - else: - LOGGER.warning("Received command for unavailable player: %s", player_id) - - async def cmd_power_off(self, player_id: str) -> None: - """ - Send POWER OFF command to given player. - - :param player_id: player_id of the player to handle the command. - """ - player = self._players.get(player_id) - if player: - # power is not supported so abuse mute instead - player.soco.mute = True - player.powered = False - else: - LOGGER.warning("Received command for unavailable player: %s", player_id) - - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """ - Send volume level command to given player. - - :param player_id: player_id of the player to handle the command. - :param volume_level: volume level to set (0..100). - """ - player = self._players.get(player_id) - if player: - player.soco.volume = volume_level - else: - LOGGER.warning("Received command for unavailable player: %s", player_id) - - async def cmd_volume_mute(self, player_id: str, is_muted=False): - """ - Send volume MUTE command to given player. - - :param player_id: player_id of the player to handle the command. - :param is_muted: bool with new mute state. - """ - player = self._players.get(player_id) - if player: - player.soco.mute = is_muted - else: - LOGGER.warning("Received command for unavailable player: %s", player_id) - - async def cmd_queue_play_index(self, player_id: str, index: int): - """ - Play item at index X on player's queue. - - :param player_id: player_id of the player to handle the command. - :param index: (int) index of the queue item that should start playing - """ - player = self._players.get(player_id) - if player: - create_task(player.soco.play_from_queue, index) - else: - LOGGER.warning("Received command for unavailable player: %s", player_id) - - async def cmd_queue_load( - self, player_id: str, queue_items: List[QueueItem], repeat: bool = False - ): - """ - Load/overwrite given items in the player's queue implementation. - - :param player_id: player_id of the player to handle the command. - :param queue_items: a list of QueueItems - """ - player = self._players.get(player_id) - if player: - create_task(player.soco.clear_queue) - for pos, item in enumerate(queue_items): - create_task(player.soco.add_uri_to_queue, item.stream_url, pos) - else: - LOGGER.warning("Received command for unavailable player: %s", player_id) - - async def cmd_queue_insert( - self, player_id: str, queue_items: List[QueueItem], insert_at_index: int - ): - """ - Insert new items at position X into existing queue. - - If insert_at_index 0 or None, will start playing newly added item(s) - :param player_id: player_id of the player to handle the command. - :param queue_items: a list of QueueItems - :param insert_at_index: queue position to insert new items - """ - player = self._players.get(player_id) - if player: - for pos, item in enumerate(queue_items): - create_task( - player.soco.add_uri_to_queue, item.stream_url, insert_at_index + pos - ) - else: - LOGGER.warning("Received command for unavailable player: %s", player_id) - - async def cmd_queue_append(self, player_id: str, queue_items: List[QueueItem]): - """ - Append new items at the end of the queue. - - :param player_id: player_id of the player to handle the command. - :param queue_items: a list of QueueItems - """ - player_queue = self.mass.players.get_player_queue(player_id) - if player_queue: - return await self.cmd_queue_insert( - player_id, queue_items, len(player_queue.items) - ) - LOGGER.warning("Received command for unavailable player: %s", player_id) - - async def cmd_queue_clear(self, player_id: str): - """ - Clear the player's queue. - - :param player_id: player_id of the player to handle the command. - """ - player = self._players.get(player_id) - if player: - create_task(player.soco.clear_queue) - else: - LOGGER.warning("Received command for unavailable player: %s", player_id) - - def __run_discovery(self): - """Background Sonos discovery and handler, runs in executor thread.""" - if self._discovery_running: - return - self._discovery_running = True - LOGGER.debug("Sonos discovery started...") - discovered_devices = soco.discover() - if discovered_devices is None: - discovered_devices = [] - new_device_ids = [item.uid for item in discovered_devices] - cur_player_ids = [item.player_id for item in self._players.values()] - # remove any disconnected players... - for player in list(self._players.values()): - if not player.is_group and player.soco.uid not in new_device_ids: - create_task(self.mass.players.remove_player(player.player_id)) - for sub in player.subscriptions: - sub.unsubscribe() - self._players.pop(player, None) - # process new players - for device in discovered_devices: - if device.uid not in cur_player_ids and device.is_visible: - self.__device_discovered(device) - # handle groups - if len(discovered_devices) > 0: - self.__process_groups(discovered_devices[0].all_groups) - else: - self.__process_groups([]) - - def __device_discovered(self, soco_device: soco.SoCo): - """Handle discovered Sonos player.""" - speaker_info = soco_device.get_speaker_info(True) - player = Player( - player_id=soco_device.uid, - provider_id=PROV_ID, - name=soco_device.player_name, - features=PLAYER_FEATURES, - config_entries=PLAYER_CONFIG_ENTRIES, - device_info=DeviceInfo( - model=speaker_info["model_name"], - address=speaker_info["mac_address"], - manufacturer=PROV_NAME, - ), - ) - # store soco object on player - player.soco = soco_device - player.media_position_updated_at = 0 - # handle subscriptions to events - player.subscriptions = [] - - def subscribe(service, _callback): - queue = ProcessSonosEventQueue(soco_device.uid, _callback) - sub = service.subscribe(auto_renew=True, event_queue=queue) - player.subscriptions.append(sub) - - subscribe(soco_device.avTransport, self.__player_event) - subscribe(soco_device.renderingControl, self.__player_event) - subscribe(soco_device.zoneGroupTopology, self.__topology_changed) - create_task(self.mass.players.add_player(player)) - return player - - def __player_event(self, player_id: str, event): - """Handle a SoCo player event.""" - player = self._players[player_id] - if event: - variables = event.variables - if "volume" in variables: - player.volume_level = int(variables["volume"]["Master"]) - if "mute" in variables: - player.muted = variables["mute"]["Master"] == "1" - else: - player.volume_level = player.soco.volume - player.muted = player.soco.mute - transport_info = player.soco.get_current_transport_info() - current_transport_state = transport_info.get("current_transport_state") - if current_transport_state == "TRANSITIONING": - return - if player.soco.is_playing_tv or player.soco.is_playing_line_in: - player.powered = False - else: - new_state = __convert_state(current_transport_state) - player.state = new_state - track_info = player.soco.get_current_track_info() - player.current_uri = track_info["uri"] - position_info = player.soco.avTransport.GetPositionInfo( - [("InstanceID", 0), ("Channel", "Master")] - ) - rel_time = __timespan_secs(position_info.get("RelTime")) - player.elapsed_time = rel_time - if player.state == PlayerState.PLAYING: - create_task(self._report_progress(player_id)) - player.update_state() - - def __process_groups(self, sonos_groups): - """Process all sonos groups.""" - all_group_ids = [] - for group in sonos_groups: - all_group_ids.append(group.uid) - if group.uid not in self._players: - # new group player - group_player = self.__device_discovered(group.coordinator) - else: - group_player = self._players[group.uid] - # check members - group_player.is_group_player = True - group_player.name = group.label - group_player.group_childs = [item.uid for item in group.members] - create_task(self.mass.players.update_player(group_player)) - - async def __topology_changed(self, player_id, event=None): - """Received topology changed event from one of the sonos players.""" - # pylint: disable=unused-argument - # Schedule discovery to work out the changes. - create_task(self.__run_discovery) - - async def _report_progress(self, player_id: str): - """Report current progress while playing.""" - if player_id in self._report_progress_tasks: - return # already running - # sonos does not send instant updates of the player's progress (elapsed time) - # so we need to send it in periodically - player = self._players[player_id] - player.should_poll = True - while player and player.state == PlayerState.PLAYING: - time_diff = time.time() - player.media_position_updated_at - adjusted_current_time = player.elapsed_time + time_diff - player.elapsed_time = adjusted_current_time - await asyncio.sleep(1) - player.should_poll = False - self._report_progress_tasks.pop(player_id, None) - - -def __convert_state(sonos_state: str) -> PlayerState: - """Convert Sonos state to PlayerState.""" - if sonos_state == "PLAYING": - return PlayerState.PLAYING - if sonos_state == "TRANSITIONING": - return PlayerState.PLAYING - if sonos_state == "PAUSED_PLAYBACK": - return PlayerState.PAUSED - return PlayerState.IDLE - - -def __timespan_secs(timespan): - """Parse a time-span into number of seconds.""" - if timespan in ("", "NOT_IMPLEMENTED", None): - return None - return sum(60 ** x[0] * int(x[1]) for x in enumerate(reversed(timespan.split(":")))) - - -class ProcessSonosEventQueue: - """Queue like object for dispatching sonos events.""" - - def __init__(self, player_id, callback_handler): - """Initialize Sonos event queue.""" - self._callback_handler = callback_handler - self._player_id = player_id - - def put(self, item, block=True, timeout=None): - """Process event.""" - # pylint: disable=unused-argument - self._callback_handler(self._player_id, item) diff --git a/music_assistant/providers/spotify/__init__.py b/music_assistant/providers/spotify/__init__.py index e7cd92a9..2bb23e99 100644 --- a/music_assistant/providers/spotify/__init__.py +++ b/music_assistant/providers/spotify/__init__.py @@ -1,7 +1,6 @@ """Spotify musicprovider support for MusicAssistant.""" import asyncio import json -import logging import os import platform import time @@ -9,108 +8,61 @@ from json.decoder import JSONDecodeError from typing import List, Optional from asyncio_throttle import Throttler -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.app_vars import ( # noqa # pylint: disable=no-name-in-module + get_app_var, +) +from music_assistant.models.errors import LoginFailed 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 ( +from music_assistant.models.media_items import ( Album, AlbumType, Artist, + ContentType, MediaItemProviderId, + MediaItemType, + MediaQuality, MediaType, Playlist, - Radio, - SearchResult, + StreamDetails, + StreamType, Track, - TrackQuality, ) from music_assistant.models.provider import MusicProvider -from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType - -PROV_ID = "spotify" -PROV_NAME = "Spotify" - -LOGGER = logging.getLogger(PROV_ID) - -CONFIG_ENTRIES = [ - ConfigEntry( - entry_key=CONF_USERNAME, - entry_type=ConfigEntryType.STRING, - label=CONF_USERNAME, - description="desc_spotify_username", - ), - ConfigEntry( - entry_key=CONF_PASSWORD, - entry_type=ConfigEntryType.PASSWORD, - label=CONF_PASSWORD, - description="desc_spotify_password", - ), -] - - -async def setup(mass): - """Perform async setup of this Plugin/Provider.""" - prov = SpotifyProvider() - await mass.register_provider(prov) class SpotifyProvider(MusicProvider): - """Implementation for the Spotify MusicProvider.""" - - # pylint: disable=abstract-method + """Implementation of a Spotify MusicProvider.""" - __auth_token = None - sp_user = None - _username = None - _password = None - - @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 - - @property - def supported_mediatypes(self) -> List[MediaType]: - """Return MediaTypes the provider supports.""" - return [ - MediaType.ALBUM, + def __init__(self, username: str, password: str) -> None: + """Initialize the Spotify provider.""" + self._attr_id = "spotify" + self._attr_name = "Spotify" + self._attr_supported_mediatypes = [ MediaType.ARTIST, - MediaType.PLAYLIST, - # MediaType.RADIO, # TODO! + MediaType.ALBUM, MediaType.TRACK, + MediaType.PLAYLIST + # TODO: Return spotify radio ] - - async def on_start(self) -> bool: - """Handle initialization of the provider based on config.""" - config = self.mass.config.get_provider_config(self.id) - # pylint: disable=attribute-defined-outside-init - self._cur_user = None - self.sp_user = None - if not config[CONF_USERNAME] or not config[CONF_PASSWORD]: - LOGGER.debug("Username and password not set. Abort load of provider.") - return False - self._username = config[CONF_USERNAME] - self._password = config[CONF_PASSWORD] - self.__auth_token = {} + self._username = username + self._password = password + self._auth_token = None + self._sp_user = None self._throttler = Throttler(rate_limit=4, period=1) - token = await self.get_token() - return token is not None + async def setup(self) -> None: + """Handle async initialization of the provider.""" + if not self._username or not self._password: + raise LoginFailed("Invalid login credentials") + # try to get a token, raise if that fails + token = await self.get_token() + if not token: + raise LoginFailed(f"Login failed for user {self._username}") async def search( self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 - ) -> SearchResult: + ) -> List[MediaItemType]: """ Perform search on musicprovider. @@ -118,7 +70,7 @@ class SpotifyProvider(MusicProvider): :param media_types: A list of media_types to include. All types if None. :param limit: Number of items to return in the search (per type). """ - result = SearchResult() + result = [] searchtypes = [] if MediaType.ARTIST in media_types: searchtypes.append("artist") @@ -130,28 +82,27 @@ class SpotifyProvider(MusicProvider): searchtypes.append("playlist") searchtype = ",".join(searchtypes) params = {"q": search_query, "type": searchtype, "limit": limit} - searchresult = await self._get_data("search", params=params) - if searchresult: + if searchresult := await self._get_data("search", params=params): if "artists" in searchresult: - result.artists = [ + result += [ await self._parse_artist(item) for item in searchresult["artists"]["items"] if (item and item["id"]) ] if "albums" in searchresult: - result.albums = [ + result += [ await self._parse_album(item) for item in searchresult["albums"]["items"] if (item and item["id"]) ] if "tracks" in searchresult: - result.tracks = [ + result += [ await self._parse_track(item) for item in searchresult["tracks"]["items"] if (item and item["id"]) ] if "playlists" in searchresult: - result.playlists = [ + result += [ await self._parse_playlist(item) for item in searchresult["playlists"]["items"] if (item and item["id"]) @@ -191,23 +142,19 @@ class SpotifyProvider(MusicProvider): if (item and item["id"]) ] - async def get_radios(self) -> List[Radio]: - """Retrieve library/subscribed radio stations from the provider.""" - return [] # TODO: Return spotify radio - async def get_artist(self, prov_artist_id) -> Artist: """Get full artist details by id.""" - artist_obj = await self._get_data("artists/%s" % prov_artist_id) + artist_obj = await self._get_data(f"artists/{prov_artist_id}") return await self._parse_artist(artist_obj) if artist_obj else None async def get_album(self, prov_album_id) -> Album: """Get full album details by id.""" - album_obj = await self._get_data("albums/%s" % prov_album_id) + album_obj = await self._get_data(f"albums/{prov_album_id}") return await self._parse_album(album_obj) if album_obj else None async def get_track(self, prov_track_id) -> Track: """Get full track details by id.""" - track_obj = await self._get_data("tracks/%s" % prov_track_id) + track_obj = await self._get_data("tracks/{prov_track_id}") return await self._parse_track(track_obj) if track_obj else None async def get_playlist(self, prov_playlist_id) -> Playlist: @@ -290,7 +237,7 @@ class SpotifyProvider(MusicProvider): """Add track(s) to playlist.""" track_uris = [] for track_id in prov_track_ids: - track_uris.append("spotify:track:%s" % track_id) + track_uris.append(f"spotify:track:{track_id}") data = {"uris": track_uris} return await self._post_data(f"playlists/{prov_playlist_id}/tracks", data=data) @@ -298,7 +245,7 @@ class SpotifyProvider(MusicProvider): """Remove track(s) from playlist.""" track_uris = [] for track_id in prov_track_ids: - track_uris.append({"uri": "spotify:track:%s" % track_id}) + track_uris.append({"uri": f"spotify:track:{track_id}"}) data = {"tracks": track_uris} return await self._delete_data( f"playlists/{prov_playlist_id}/tracks", data=data @@ -313,18 +260,11 @@ class SpotifyProvider(MusicProvider): # make sure that the token is still valid by just requesting it await self.get_token() spotty = self.get_spotty_binary() - spotty_exec = ( - '%s -n temp -c "%s" -b 320 --pass-through --single-track spotify://track:%s' - % ( - spotty, - self.mass.config.data_path, - track.item_id, - ) - ) + spotty_exec = f'{spotty} -n temp -c "/tmp" -b 320 --pass-through --single-track spotify://track:{track.item_id}' return StreamDetails( type=StreamType.EXECUTABLE, item_id=track.item_id, - provider=PROV_ID, + provider=self.id, path=spotty_exec, content_type=ContentType.OGG, sample_rate=44100, @@ -336,8 +276,8 @@ class SpotifyProvider(MusicProvider): artist = Artist( item_id=artist_obj["id"], provider=self.id, name=artist_obj["name"] ) - artist.provider_ids.add( - MediaItemProviderId(provider=PROV_ID, item_id=artist_obj["id"]) + artist.provider_ids.append( + MediaItemProviderId(provider=self.id, item_id=artist_obj["id"]) ) if "genres" in artist_obj: artist.metadata["genres"] = artist_obj["genres"] @@ -353,8 +293,10 @@ class SpotifyProvider(MusicProvider): async def _parse_album(self, album_obj): """Parse spotify album object to generic layout.""" - album = Album(item_id=album_obj["id"], provider=self.id) - album.name, album.version = parse_title_and_version(album_obj["name"]) + name, version = parse_title_and_version(album_obj["name"]) + album = Album( + item_id=album_obj["id"], provider=self.id, name=name, version=version + ) for artist in album_obj["artists"]: album.artist = await self._parse_artist(artist) if album.artist: @@ -381,31 +323,34 @@ class SpotifyProvider(MusicProvider): album.metadata["spotify_url"] = album_obj["external_urls"]["spotify"] if album_obj.get("explicit"): album.metadata["explicit"] = str(album_obj["explicit"]).lower() - album.provider_ids.add( + album.provider_ids.append( MediaItemProviderId( - provider=PROV_ID, + provider=self.id, item_id=album_obj["id"], - quality=TrackQuality.LOSSY_OGG, + quality=MediaQuality.LOSSY_OGG, ) ) return album async def _parse_track(self, track_obj, artist=None): """Parse spotify track object to generic layout.""" + name, version = parse_title_and_version(track_obj["name"]) track = Track( item_id=track_obj["id"], provider=self.id, + name=name, + version=version, duration=track_obj["duration_ms"] / 1000, disc_number=track_obj["disc_number"], track_number=track_obj["track_number"], ) if artist: - track.artists.add(artist) + track.artists.append(artist) for track_artist in track_obj.get("artists", []): artist = await self._parse_artist(track_artist) if artist and artist.item_id not in {x.item_id for x in track.artists}: - track.artists.add(artist) - track.name, track.version = parse_title_and_version(track_obj["name"]) + track.artists.append(artist) + track.metadata["explicit"] = str(track_obj["explicit"]).lower() if "external_ids" in track_obj and "isrc" in track_obj["external_ids"]: track.isrc = track_obj["external_ids"]["isrc"] @@ -419,11 +364,13 @@ class SpotifyProvider(MusicProvider): track.metadata["explicit"] = True if track_obj.get("external_urls"): track.metadata["spotify_url"] = track_obj["external_urls"]["spotify"] - track.provider_ids.add( + if track_obj.get("popularity"): + track.metadata["popularity"] = track_obj["popularity"] + track.provider_ids.append( MediaItemProviderId( - provider=PROV_ID, + provider=self.id, item_id=track_obj["id"], - quality=TrackQuality.LOSSY_OGG, + quality=MediaQuality.LOSSY_OGG, available=not track_obj["is_local"] and track_obj["is_playable"], ) ) @@ -431,14 +378,17 @@ class SpotifyProvider(MusicProvider): async def _parse_playlist(self, playlist_obj): """Parse spotify playlist object to generic layout.""" - playlist = Playlist(item_id=playlist_obj["id"], provider=self.id) - playlist.provider_ids.add( - MediaItemProviderId(provider=PROV_ID, item_id=playlist_obj["id"]) + playlist = Playlist( + item_id=playlist_obj["id"], + provider=self.id, + name=playlist_obj["name"], + owner=playlist_obj["owner"]["display_name"], + ) + playlist.provider_ids.append( + MediaItemProviderId(provider=self.id, item_id=playlist_obj["id"]) ) - playlist.name = playlist_obj["name"] - playlist.owner = playlist_obj["owner"]["display_name"] playlist.is_editable = ( - playlist_obj["owner"]["id"] == self.sp_user["id"] + playlist_obj["owner"]["id"] == self._sp_user["id"] or playlist_obj["collaborative"] ) if playlist_obj.get("images"): @@ -451,22 +401,22 @@ class SpotifyProvider(MusicProvider): async def get_token(self): """Get auth token on spotify.""" # return existing token if we have one in memory - if self.__auth_token and ( - self.__auth_token["expiresAt"] > int(time.time()) + 20 - ): - return self.__auth_token + if self._auth_token and (self._auth_token["expiresAt"] > int(time.time()) + 20): + return self._auth_token tokeninfo = {} if not self._username or not self._password: return tokeninfo # retrieve token with spotty tokeninfo = await self._get_token() if tokeninfo: - self.__auth_token = tokeninfo - self.sp_user = await self._get_data("me") - LOGGER.info("Succesfully logged in to Spotify as %s", self.sp_user["id"]) - self.__auth_token = tokeninfo + self._auth_token = tokeninfo + self._sp_user = await self._get_data("me") + self.logger.info( + "Succesfully logged in to Spotify as %s", self._sp_user["id"] + ) + self._auth_token = tokeninfo else: - LOGGER.error("Login failed for user %s", self._username) + self.logger.error("Login failed for user %s", self._username) return tokeninfo async def _get_token(self): @@ -504,7 +454,7 @@ class SpotifyProvider(MusicProvider): "-p", self._password, "-c", - self.mass.config.data_path, + "/tmp", "--disable-discovery", ] spotty = await asyncio.create_subprocess_exec( @@ -514,7 +464,7 @@ class SpotifyProvider(MusicProvider): try: result = json.loads(stdout) except JSONDecodeError: - LOGGER.warning("Error while retrieving Spotify token!") + self.logger.warning("Error while retrieving Spotify token!") return None # transform token info to spotipy compatible format if result and "accessToken" in result: @@ -546,20 +496,20 @@ class SpotifyProvider(MusicProvider): """Get data from api.""" if not params: params = {} - url = "https://api.spotify.com/v1/%s" % endpoint + url = f"https://api.spotify.com/v1/{endpoint}" params["market"] = "from_token" params["country"] = "from_token" token = await self.get_token() if not token: return None - headers = {"Authorization": "Bearer %s" % token["accessToken"]} + headers = {"Authorization": f'Bearer {token["accessToken"]}'} async with self._throttler: async with self.mass.http_session.get( url, headers=headers, params=params, verify_ssl=False ) as response: result = await response.json() if not result or "error" in result: - LOGGER.error("%s - %s", endpoint, result) + self.logger.error("%s - %s", endpoint, result) result = None return result @@ -567,11 +517,11 @@ class SpotifyProvider(MusicProvider): """Delete data from api.""" if not params: params = {} - url = "https://api.spotify.com/v1/%s" % endpoint + url = f"https://api.spotify.com/v1/{endpoint}" token = await self.get_token() if not token: return None - headers = {"Authorization": "Bearer %s" % token["accessToken"]} + headers = {"Authorization": f'Bearer {token["accessToken"]}'} async with self.mass.http_session.delete( url, headers=headers, params=params, json=data, verify_ssl=False ) as response: @@ -581,11 +531,11 @@ class SpotifyProvider(MusicProvider): """Put data on api.""" if not params: params = {} - url = "https://api.spotify.com/v1/%s" % endpoint + url = f"https://api.spotify.com/v1/{endpoint}" token = await self.get_token() if not token: return None - headers = {"Authorization": "Bearer %s" % token["accessToken"]} + headers = {"Authorization": f'Bearer {token["accessToken"]}'} async with self.mass.http_session.put( url, headers=headers, params=params, json=data, verify_ssl=False ) as response: @@ -595,11 +545,11 @@ class SpotifyProvider(MusicProvider): """Post data on api.""" if not params: params = {} - url = "https://api.spotify.com/v1/%s" % endpoint + url = f"https://api.spotify.com/v1/{endpoint}" token = await self.get_token() if not token: return None - headers = {"Authorization": "Bearer %s" % token["accessToken"]} + headers = {"Authorization": f'Bearer {token["accessToken"]}'} async with self.mass.http_session.post( url, headers=headers, params=params, json=data, verify_ssl=False ) as response: diff --git a/music_assistant/providers/spotify/icon.png b/music_assistant/providers/spotify/icon.png deleted file mode 100644 index 1ed40491..00000000 Binary files a/music_assistant/providers/spotify/icon.png and /dev/null differ diff --git a/music_assistant/providers/squeezebox/__init__.py b/music_assistant/providers/squeezebox/__init__.py deleted file mode 100644 index eeeb54f5..00000000 --- a/music_assistant/providers/squeezebox/__init__.py +++ /dev/null @@ -1,342 +0,0 @@ -"""Squeezebox emulated player provider.""" - -import asyncio -import logging -from typing import List - -from music_assistant.constants import CONF_CROSSFADE_DURATION -from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.util import callback, create_task -from music_assistant.models.config_entry import ConfigEntry -from music_assistant.models.player import DeviceInfo, Player, PlayerFeature, PlayerState -from music_assistant.models.player_queue import QueueItem -from music_assistant.models.provider import PlayerProvider - -from .constants import PROV_ID, PROV_NAME -from .discovery import DiscoveryProtocol -from .socket_client import SqueezeEvent, SqueezeSocketClient - -CONF_LAST_POWER = "last_power" -CONF_LAST_VOLUME = "last_volume" - -LOGGER = logging.getLogger(PROV_ID) - -CONFIG_ENTRIES = [] # we don't have any provider config entries (for now) -PLAYER_FEATURES = [PlayerFeature.QUEUE, PlayerFeature.CROSSFADE, PlayerFeature.GAPLESS] -PLAYER_CONFIG_ENTRIES = [] # we don't have any player config entries (for now) - - -async def setup(mass): - """Perform async setup of this Plugin/Provider.""" - prov = PySqueezeProvider() - await mass.register_provider(prov) - - -class PySqueezeProvider(PlayerProvider): - """Python implementation of SlimProto server.""" - - _tasks = [] - - @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 on_start(self) -> bool: - """Handle initialization of the provider. Called on startup.""" - # start slimproto server - create_task(asyncio.start_server(self._client_connected, "0.0.0.0", 3483)) - - # setup discovery - create_task(self.start_discovery()) - - async def start_discovery(self): - """Start discovery for players.""" - transport, _ = await self.mass.loop.create_datagram_endpoint( - lambda: DiscoveryProtocol(self.mass.web.port), - local_addr=("0.0.0.0", 3483), - ) - try: - while True: - await asyncio.sleep(60) # serve forever - finally: - transport.close() - - async def _client_connected(self, reader, writer): - """Handle a client connection on the socket.""" - addr = writer.get_extra_info("peername") - LOGGER.debug("Socket client connected: %s", addr) - socket_client = SqueezeSocketClient(self.mass, reader, writer) - - def handle_event(event: SqueezeEvent, socket_client: SqueezeSocketClient): - player_id = socket_client.player_id - if not player_id: - return - # always check if we already have this player as it might be reconnected - player = self.mass.players.get_player(player_id) - if not player: - player = SqueezePlayer(self.mass, socket_client) - player.set_socket_client(socket_client) - # just update, the playermanager will take care of adding it if it's a new player - player.handle_socket_client_event(event) - - socket_client.register_callback(handle_event) - - -class SqueezePlayer(Player): - """Squeezebox player.""" - - def __init__(self, mass: MusicAssistant, socket_client: SqueezeSocketClient): - """Initialize.""" - super().__init__() - self.mass = mass - self._socket_client = socket_client - - @property - def available(self) -> bool: - """Return current availablity of player.""" - return self._socket_client.connected - - @property - def should_poll(self) -> bool: - """Return True if this player should be polled for state updates.""" - return False - - @property - def socket_client(self): - """Return the uinderluing socket client for the player.""" - return self._socket_client - - def set_socket_client(self, socket_client: SqueezeSocketClient): - """Set a (new) socket client to this player.""" - self._socket_client = socket_client - - async def on_remove(self) -> None: - """Call when player is removed from the player manager.""" - self.socket_client.disconnect() - - @property - def player_id(self) -> str: - """Return player id (=mac address) of the player.""" - return self.socket_client.player_id - - @property - def provider_id(self) -> str: - """Return provider id of this player.""" - return PROV_ID - - @property - def name(self) -> str: - """Return name of the player.""" - return self.socket_client.name - - @property - def volume_level(self): - """Return current volume level of player.""" - return self.socket_client.volume_level - - @property - def powered(self): - """Return current power state of player.""" - return self.socket_client.powered - - @property - def muted(self): - """Return current mute state of player.""" - return self.socket_client.muted - - @property - def state(self): - """Return current state of player.""" - return PlayerState(self.socket_client.state) - - @property - def elapsed_time(self): - """Return elapsed_time of current playing track in (fractions of) seconds.""" - return self.socket_client.elapsed_seconds - - @property - def elapsed_milliseconds(self) -> int: - """Return (realtime) elapsed time of current playing media in milliseconds.""" - return self.socket_client.elapsed_milliseconds - - @property - def current_uri(self): - """Return uri of currently loaded track.""" - return self.socket_client.current_uri - - @property - def features(self) -> List[PlayerFeature]: - """Return list of features this player supports.""" - return PLAYER_FEATURES - - @property - def config_entries(self) -> List[ConfigEntry]: - """Return player specific config entries (if any).""" - return PLAYER_CONFIG_ENTRIES - - @property - def device_info(self) -> DeviceInfo: - """Return the device info for this player.""" - return DeviceInfo( - model=self.socket_client.device_type, - address=self.socket_client.device_address, - ) - - async def cmd_stop(self): - """Send stop command to player.""" - return await self.socket_client.cmd_stop() - - async def cmd_play(self): - """Send play (unpause) command to player.""" - return await self.socket_client.cmd_play() - - async def cmd_pause(self): - """Send pause command to player.""" - return await self.socket_client.cmd_pause() - - async def cmd_power_on(self) -> None: - """Send POWER ON command to player.""" - # save power and volume state in cache - cache_str = f"squeezebox_player_{self.player_id}" - await self.mass.cache.set(cache_str, (True, self.volume_level)) - return await self.socket_client.cmd_power(True) - - async def cmd_power_off(self) -> None: - """Send POWER OFF command to player.""" - # save power and volume state in cache - cache_str = f"squeezebox_player_{self.player_id}" - await self.mass.cache.set(cache_str, (False, self.volume_level)) - return await self.socket_client.cmd_power(False) - - async def cmd_volume_set(self, volume_level: int): - """Send new volume level command to player.""" - return await self.socket_client.cmd_volume_set(volume_level) - - async def cmd_mute(self, muted: bool = False): - """Send mute command to player.""" - return await self.socket_client.cmd_mute(muted) - - async def cmd_play_uri(self, uri: str): - """Request player to start playing a single uri.""" - crossfade = self.mass.config.player_settings[self.player_id][ - CONF_CROSSFADE_DURATION - ] - return await self.socket_client.play_uri(uri, crossfade_duration=crossfade) - - async def cmd_next(self): - """Send NEXT TRACK command to player.""" - queue = self.mass.players.get_player_queue(self.player_id) - if queue: - new_track = queue.get_item(queue.cur_index + 1) - if new_track: - return await self.cmd_play_uri(new_track.stream_url) - - async def cmd_previous(self): - """Send PREVIOUS TRACK command to player.""" - queue = self.mass.players.get_player_queue(self.player_id) - if queue: - new_track = queue.get_item(queue.cur_index - 1) - if new_track: - return await self.cmd_play_uri(new_track.stream_url) - - async def cmd_queue_play_index(self, index: int): - """ - Play item at index X on player's queue. - - :param index: (int) index of the queue item that should start playing - """ - queue = self.mass.players.get_player_queue(self.player_id) - if queue: - new_track = queue.get_item(index) - if new_track: - return await self.cmd_play_uri(new_track.stream_url) - - async def cmd_queue_load(self, queue_items: List[QueueItem], repeat: bool = False): - """ - Load/overwrite given items in the player's queue implementation. - - :param queue_items: a list of QueueItems - """ - if queue_items: - await self.cmd_play_uri(queue_items[0].stream_url) - return await self.cmd_play_uri(queue_items[0].stream_url) - - async def cmd_queue_insert( - self, queue_items: List[QueueItem], insert_at_index: int - ): - """ - 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 - """ - # queue handled by built-in queue controller - # we only check the start index - queue = self.mass.players.get_player_queue(self.player_id) - if queue and insert_at_index == queue.cur_index: - return await self.cmd_queue_play_index(insert_at_index) - - async def cmd_queue_append(self, queue_items: List[QueueItem]): - """ - Append new items at the end of the queue. - - :param queue_items: a list of QueueItems - """ - # automagically handled by built-in queue controller - - async def cmd_queue_update(self, queue_items: List[QueueItem]): - """ - Overwrite the existing items in the queue, used for reordering. - - :param queue_items: a list of QueueItems - """ - # automagically handled by built-in queue controller - - async def cmd_queue_clear(self): - """Clear the player's queue.""" - # queue is handled by built-in queue controller but send stop - return await self.cmd_stop() - - async def restore_states(self): - """Restore power/volume states.""" - cache_str = f"squeezebox_player_{self.player_id}" - cache_data = await self.mass.cache.get(cache_str) - last_power, last_volume = cache_data if cache_data else (False, 40) - await self.socket_client.cmd_volume_set(last_volume) - await self.socket_client.cmd_power(last_power) - - @callback - def handle_socket_client_event(self, event: SqueezeEvent): - """Process incoming event from the socket client.""" - if event == SqueezeEvent.CONNECTED: - # restore previous power/volume - create_task(self.restore_states()) - elif event == SqueezeEvent.DECODER_READY: - # tell player to load next queue track - queue = self.mass.players.get_player_queue(self.player_id) - if queue: - next_item = queue.next_item - if next_item: - crossfade = self.mass.config.player_settings[self.player_id][ - CONF_CROSSFADE_DURATION - ] - create_task( - self.socket_client.play_uri( - next_item.stream_url, - send_flush=False, - crossfade_duration=crossfade, - ) - ) - self.update_state() diff --git a/music_assistant/providers/squeezebox/constants.py b/music_assistant/providers/squeezebox/constants.py deleted file mode 100644 index ecd683c1..00000000 --- a/music_assistant/providers/squeezebox/constants.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Constants for Squeezebox emulation.""" - -PROV_ID = "squeezebox" -PROV_NAME = "Squeezebox emulation" diff --git a/music_assistant/providers/squeezebox/discovery.py b/music_assistant/providers/squeezebox/discovery.py deleted file mode 100644 index 10c840a4..00000000 --- a/music_assistant/providers/squeezebox/discovery.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Squeezebox emulation discovery implementation.""" - -import logging -import socket -import struct -from collections import OrderedDict - -from music_assistant.helpers.util import get_hostname, get_ip - -LOGGER = logging.getLogger("squeezebox") - - -class Datagram: - """Description of a discovery datagram.""" - - @classmethod - def decode(cls, data): - """Decode a datagram message.""" - if data[0] == "e": - return TLVDiscoveryRequestDatagram(data) - if data[0] == "E": - return TLVDiscoveryResponseDatagram(data) - if data[0] == "d": - return ClientDiscoveryDatagram(data) - if data[0] == "h": - pass # Hello! - if data[0] == "i": - pass # IR - if data[0] == "2": - pass # i2c? - if data[0] == "a": - pass # ack! - - -class ClientDiscoveryDatagram(Datagram): - """Description of a client discovery datagram.""" - - device = None - firmware = None - client = None - - def __init__(self, data): - """Initialize class.""" - msg = struct.unpack("!cxBB8x6B", data.encode()) - assert msg[0] == "d" - self.device = msg[1] - self.firmware = hex(msg[2]) - self.client = ":".join(["%02x" % (x,) for x in msg[3:]]) - - def __repr__(self): - """Print the class contents.""" - return "<%s device=%r firmware=%r client=%r>" % ( - self.__class__.__name__, - self.device, - self.firmware, - self.client, - ) - - -class DiscoveryResponseDatagram(Datagram): - """Description of a discovery response datagram.""" - - def __init__(self, hostname, port): - """Initialize class.""" - # pylint: disable=unused-argument - hostname = hostname[:16].encode("UTF-8") - hostname += (16 - len(hostname)) * "\x00" - self.packet = struct.pack("!c16s", "D", hostname).decode() - - -class TLVDiscoveryRequestDatagram(Datagram): - """Description of a discovery request datagram.""" - - def __init__(self, data): - """Initialize class.""" - requestdata = OrderedDict() - assert data[0] == "e" - idx = 1 - length = len(data) - 5 - while idx <= length: - typ, _len = struct.unpack_from("4sB", data.encode(), idx) - if _len: - val = data[idx + 5 : idx + 5 + _len] - idx += 5 + _len - else: - val = None - idx += 5 - typ = typ.decode() - requestdata[typ] = val - self.data = requestdata - - def __repr__(self): - """Pretty print class.""" - return "<%s data=%r>" % (self.__class__.__name__, self.data.items()) - - -class TLVDiscoveryResponseDatagram(Datagram): - """Description of a TLV discovery response datagram.""" - - def __init__(self, responsedata): - """Initialize class.""" - parts = ["E"] # new discovery format - for typ, value in responsedata.items(): - if value is None: - value = "" - elif len(value) > 255: - # Response too long, truncating to 255 bytes - value = value[:255] - parts.extend((typ, chr(len(value)), value)) - self.packet = "".join(parts) - - -class DiscoveryProtocol: - """Description of a discovery protocol.""" - - def __init__(self, web_port): - """Initialze class.""" - self.web_port = web_port - self.transport = None - - def connection_made(self, transport): - """Call on connection.""" - self.transport = transport - # Allow receiving multicast broadcasts - sock = self.transport.get_extra_info("socket") - group = socket.inet_aton("239.255.255.250") - mreq = struct.pack("4sL", group, socket.INADDR_ANY) - sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) - - @classmethod - def error_received(cls, exc): - """Call on Error.""" - LOGGER.error(exc) - - @classmethod - def connection_lost(cls, *args, **kwargs): - """Call on Connection lost.""" - # pylint: disable=unused-argument - LOGGER.debug("Connection lost to discovery") - - def build_tlv_response(self, requestdata): - """Build TLV Response message.""" - responsedata = OrderedDict() - for typ, value in requestdata.items(): - if typ == "NAME": - # send full host name - no truncation - value = get_hostname() - elif typ == "IPAD": - # send ipaddress as a string only if it is set - value = get_ip() - # :todo: IPv6 - if value == "0.0.0.0": - # do not send back an ip address - typ = None - elif typ == "JSON": - # send port as a string - json_port = self.web_port - value = str(json_port) - elif typ == "VERS": - # send server version - value = "7.9" - elif typ == "UUID": - # send server uuid - value = "musicassistant" - else: - LOGGER.debug("Unexpected information request: %r", typ) - typ = None - if typ: - responsedata[typ] = value - return responsedata - - def datagram_received(self, data, addr): - """Datagram received callback.""" - # pylint: disable=broad-except - try: - data = data.decode() - dgram = Datagram.decode(data) - if isinstance(dgram, ClientDiscoveryDatagram): - self.send_discovery_response(addr) - elif isinstance(dgram, TLVDiscoveryRequestDatagram): - resonsedata = self.build_tlv_response(dgram.data) - self.send_tlv_discovery_response(resonsedata, addr) - except Exception as exc: - LOGGER.exception(exc) - - def send_discovery_response(self, addr): - """Send discovery response message.""" - dgram = DiscoveryResponseDatagram(get_hostname(), 3483) - self.transport.sendto(dgram.packet.encode(), addr) - - def send_tlv_discovery_response(self, resonsedata, addr): - """Send TLV discovery response message.""" - dgram = TLVDiscoveryResponseDatagram(resonsedata) - self.transport.sendto(dgram.packet.encode(), addr) diff --git a/music_assistant/providers/squeezebox/icon.png b/music_assistant/providers/squeezebox/icon.png deleted file mode 100644 index 18531d79..00000000 Binary files a/music_assistant/providers/squeezebox/icon.png and /dev/null differ diff --git a/music_assistant/providers/squeezebox/socket_client.py b/music_assistant/providers/squeezebox/socket_client.py deleted file mode 100644 index c9ac0603..00000000 --- a/music_assistant/providers/squeezebox/socket_client.py +++ /dev/null @@ -1,660 +0,0 @@ -"""Socketclient implementation for Squeezebox emulated player provider.""" - -import asyncio -import logging -import re -import struct -import time -from enum import Enum -from typing import Callable - -from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.util import create_task, run_periodic - -from .constants import PROV_ID - -LOGGER = logging.getLogger(PROV_ID) - - -# from http://wiki.slimdevices.com/index.php/SlimProtoTCPProtocol#HELO -DEVICE_TYPE = { - 2: "squeezebox", - 3: "softsqueeze", - 4: "squeezebox2", - 5: "transporter", - 6: "softsqueeze3", - 7: "receiver", - 8: "squeezeslave", - 9: "controller", - 10: "boom", - 11: "softboom", - 12: "squeezeplay", -} - -STATE_PLAYING = "playing" -STATE_IDLE = "idle" -STATE_PAUSED = "paused" - - -class SqueezeEvent(Enum): - """Enum with the events that can happen in the socket client.""" - - CONNECTED = 0 - STATE_UPDATED = 1 - DECODER_READY = 2 - DISCONNECTED = 3 - - -class SqueezeSocketClient: - """Squeezebox socket client.""" - - def __init__( - self, - mass: MusicAssistant, - reader: asyncio.StreamReader, - writer: asyncio.StreamWriter, - event_callback: Callable = None, - ): - """Initialize the socket client.""" - self.mass = mass - self._reader = reader - self._writer = writer - self._player_id = "" - self._device_type = "" - self._device_name = "" - self._last_volume = 0 - self._last_heartbeat = 0 - self._volume_control = PySqueezeVolume() - self._powered = False - self._muted = False - self._state = STATE_IDLE - self._elapsed_seconds = 0 - self._elapsed_milliseconds = 0 - self._current_uri = "" - self._connected = True - self._event_callbacks = [] - self._tasks = [ - create_task(self._socket_reader()), - create_task(self._send_heartbeat()), - ] - - def disconnect(self) -> None: - """Disconnect socket client.""" - for task in self._tasks: - if not task.cancelled(): - task.cancel() - - def register_callback(self, callb: Callable): - """Register event callback. Returns function to deregister.""" - - def unregister(): - self._event_callbacks.remove(callb) - - self._event_callbacks.append(callb) - return unregister - - def signal_event(self, event): - """Signal event to registered listeners.""" - for listener in self._event_callbacks: - listener(event, self) - - @property - def connected(self): - """Return connection state of the socket.""" - return self._connected - - @property - def player_id(self) -> str: - """Return player id (=mac address) of the player.""" - return self._player_id - - @property - def device_type(self) -> str: - """Return device type of the player.""" - return self._device_type - - @property - def device_address(self) -> str: - """Return device IP address of the player.""" - dev_address = self._writer.get_extra_info("peername") - return dev_address[0] if dev_address else "" - - @property - def name(self) -> str: - """Return name of the player.""" - if self._device_name: - return self._device_name - return f"{self.device_type}: {self.player_id}" - - @property - def volume_level(self): - """Return current volume level of player.""" - return self._volume_control.volume - - @property - def powered(self): - """Return current power state of player.""" - return self._powered - - @property - def muted(self): - """Return current mute state of player.""" - return self._muted - - @property - def state(self): - """Return current state of player.""" - return self._state - - @property - def elapsed_seconds(self): - """Return elapsed_time of current playing track in (fractions of) seconds.""" - return self._elapsed_seconds - - @property - def elapsed_milliseconds(self) -> int: - """Return (realtime) elapsed time of current playing media in milliseconds.""" - return self._elapsed_milliseconds + int( - (time.time() * 1000) - (self._last_heartbeat * 1000) - ) - - @property - def current_uri(self): - """Return uri of currently loaded track.""" - return self._current_uri - - async def _initialize_player(self): - """Set some startup settings for the player.""" - # send version - await self._send_frame(b"vers", b"7.8") - await self._send_frame(b"setd", struct.pack("B", 0)) - await self._send_frame(b"setd", struct.pack("B", 4)) - - async def cmd_stop(self): - """Send stop command to player.""" - await self.send_strm(b"q") - - async def cmd_play(self): - """Send play (unpause) command to player.""" - await self.send_strm(b"u") - - async def cmd_pause(self): - """Send pause command to player.""" - await self.send_strm(b"p") - - async def cmd_power(self, powered: bool = True): - """Send power command to player.""" - # power is not supported so abuse mute instead - power_int = 1 if powered else 0 - await self._send_frame(b"aude", struct.pack("2B", power_int, 1)) - self._powered = powered - self.signal_event(SqueezeEvent.STATE_UPDATED) - - async def cmd_volume_set(self, volume_level: int): - """Send new volume level command to player.""" - self._volume_control.volume = volume_level - old_gain = self._volume_control.old_gain() - new_gain = self._volume_control.new_gain() - await self._send_frame( - b"audg", - struct.pack("!LLBBLL", old_gain, old_gain, 1, 255, new_gain, new_gain), - ) - - async def cmd_mute(self, muted: bool = False): - """Send mute command to player.""" - muted_int = 0 if muted else 1 - await self._send_frame(b"aude", struct.pack("2B", muted_int, 0)) - self.muted = muted - self.signal_event(SqueezeEvent.STATE_UPDATED) - - async def play_uri( - self, uri: str, send_flush: bool = True, crossfade_duration: int = 0 - ): - """Request player to start playing a single uri.""" - if send_flush: - await self.send_strm(b"f", autostart=b"0") - self._current_uri = uri - self._powered = True - enable_crossfade = crossfade_duration > 0 - command = b"s" - # we use direct stream for now so let the player do the messy work with buffers - autostart = b"3" - trans_type = b"1" if enable_crossfade else b"0" - uri = "/stream" + uri.split("/stream")[1] - # extract host and port from uri - regex = "(?:http.*://)?(?P[^:/ ]+).?(?P[0-9]*).*" - regex_result = re.search(regex, uri) - host = regex_result.group("host") # 'www.abc.com' - port = regex_result.group("port") # '123' - if not port and uri.startswith("https"): - port = 443 - elif not port: - port = 80 - headers = f"Connection: close\r\nAccept: */*\r\nHost: {host}:{port}\r\n" - httpreq = "GET %s HTTP/1.0\r\n%s\r\n" % (uri, headers) - await self.send_strm( - command, - autostart=autostart, - trans_type=trans_type, - trans_duration=crossfade_duration, - httpreq=httpreq.encode("utf-8"), - ) - - @run_periodic(5) - async def _send_heartbeat(self): - """Send periodic heartbeat message to player.""" - if not self._connected: - return - timestamp = int(time.time()) - await self.send_strm(b"t", replay_gain=timestamp, flags=0) - - async def _send_frame(self, command, data): - """Send command to Squeeze player.""" - if self._reader.at_eof() or self._writer.is_closing(): - LOGGER.debug("Socket is disconnected.") - self._connected = False - return - packet = struct.pack("!H", len(data) + 4) + command + data - try: - self._writer.write(packet) - await self._writer.drain() - except ConnectionResetError: - self._connected = False - self.signal_event(SqueezeEvent.DISCONNECTED) - - async def _socket_reader(self): - """Handle incoming data from socket.""" - buffer = b"" - # keep reading bytes from the socket - while not (self._reader.at_eof() or self._writer.is_closing()): - data = await self._reader.read(64) - # handle incoming data from socket - buffer = buffer + data - del data - if len(buffer) > 8: - # construct operation and - operation, length = buffer[:4], buffer[4:8] - plen = struct.unpack("!I", length)[0] + 8 - if len(buffer) >= plen: - packet, buffer = buffer[8:plen], buffer[plen:] - operation = operation.strip(b"!").strip().decode().lower() - handler = getattr(self, f"_process_{operation}", None) - if handler is None: - LOGGER.warning("No handler for %s", operation) - else: - handler(packet) - # EOF reached: socket is disconnected - LOGGER.debug("Socket disconnected: %s", self._writer.get_extra_info("peername")) - self._connected = False - self.signal_event(SqueezeEvent.DISCONNECTED) - - async def send_strm( - self, - command=b"q", - formatbyte=b"f", - autostart=b"0", - samplesize=b"?", - samplerate=b"?", - channels=b"?", - endian=b"?", - threshold=0, - spdif=b"0", - trans_duration=0, - trans_type=b"0", - flags=0x40, - output_threshold=0, - replay_gain=0, - server_port=8095, - server_ip=0, - httpreq=b"", - ): - """Create stream request message based on given arguments.""" - data = struct.pack( - "!cccccccBcBcBBBLHL", - command, - autostart, - formatbyte, - samplesize, - samplerate, - channels, - endian, - threshold, - spdif, - trans_duration, - trans_type, - flags, - output_threshold, - 0, - replay_gain, - server_port, - server_ip, - ) - await self._send_frame(b"strm", data + httpreq) - - def _process_helo(self, data): - """Process incoming HELO event from player (player connected).""" - # pylint: disable=unused-variable - # player connected - (dev_id, rev, mac) = struct.unpack("BB6s", data[:8]) - device_mac = ":".join("%02x" % x for x in mac) - self._player_id = str(device_mac).lower() - self._device_type = DEVICE_TYPE.get(dev_id, "unknown device") - LOGGER.debug("Player connected: %s", self.name) - create_task(self._initialize_player()) - self.signal_event(SqueezeEvent.CONNECTED) - - def _process_stat(self, data): - """Redirect incoming STAT event from player to correct method.""" - event = data[:4].decode() - event_data = data[4:] - if event == b"\x00\x00\x00\x00": - # Presumed informational stat message - return - event_handler = getattr(self, "_process_stat_%s" % event.lower(), None) - if event_handler is None: - LOGGER.debug("Unhandled event: %s - event_data: %s", event, event_data) - else: - create_task(event_handler, data[4:]) - - def _process_stat_aude(self, data): - """Process incoming stat AUDe message (power level and mute).""" - (spdif_enable, dac_enable) = struct.unpack("2B", data[:4]) - powered = spdif_enable or dac_enable - self._powered = powered - self._muted = not powered - self.signal_event(SqueezeEvent.STATE_UPDATED) - - def _process_stat_audg(self, data): - """Process incoming stat AUDg message (volume level).""" - # TODO: process volume level - LOGGER.debug("AUDg received - Volume level: %s", data) - self.signal_event(SqueezeEvent.STATE_UPDATED) - - def _process_stat_stmd(self, data): - """Process incoming stat STMd message (decoder ready).""" - # pylint: disable=unused-argument - LOGGER.debug("STMd received - Decoder Ready for next track.") - self.signal_event(SqueezeEvent.DECODER_READY) - - def _process_stat_stmf(self, data): - """Process incoming stat STMf message (connection closed).""" - # pylint: disable=unused-argument - LOGGER.debug("STMf received - connection closed.") - self._state = STATE_IDLE - self._elapsed_milliseconds = 0 - self._elapsed_seconds = 0 - self.signal_event(SqueezeEvent.STATE_UPDATED) - - @classmethod - def _process_stat_stmo(cls, data): - """ - Process incoming stat STMo message. - - No more decoded (uncompressed) data to play; triggers rebuffering. - """ - # pylint: disable=unused-argument - LOGGER.warning("STMo received - output underrun.") - - def _process_stat_stmp(self, data): - """Process incoming stat STMp message: Pause confirmed.""" - # pylint: disable=unused-argument - LOGGER.debug("STMp received - pause confirmed.") - self._state = STATE_PAUSED - self.signal_event(SqueezeEvent.STATE_UPDATED) - - def _process_stat_stmr(self, data): - """Process incoming stat STMr message: Resume confirmed.""" - # pylint: disable=unused-argument - LOGGER.debug("STMr received - resume confirmed.") - self._state = STATE_PLAYING - self.signal_event(SqueezeEvent.STATE_UPDATED) - - def _process_stat_stms(self, data): - # pylint: disable=unused-argument - """Process incoming stat STMs message: Playback of new track has started.""" - LOGGER.debug("STMs received - playback of new track has started.") - self._state = STATE_PLAYING - self.signal_event(SqueezeEvent.STATE_UPDATED) - - def _process_stat_stmt(self, data): - """Process incoming stat STMt message: heartbeat from client.""" - # pylint: disable=unused-variable - self._last_heartbeat = time.time() - ( - num_crlf, - mas_initialized, - mas_mode, - rptr, - wptr, - bytes_received_h, - bytes_received_l, - signal_strength, - jiffies, - output_buffer_size, - output_buffer_fullness, - elapsed_seconds, - voltage, - elapsed_milliseconds, - timestamp, - error_code, - ) = struct.unpack("!BBBLLLLHLLLLHLLH", data) - if self.state == STATE_PLAYING: - # elapsed seconds is weird when player is buffering etc. - # only rely on it if player is playing - self._elapsed_milliseconds = elapsed_milliseconds - if self._elapsed_seconds != elapsed_seconds: - self._elapsed_seconds = elapsed_seconds - self.signal_event(SqueezeEvent.STATE_UPDATED) - - def _process_stat_stmu(self, data): - """Process incoming stat STMu message: Buffer underrun: Normal end of playback.""" - # pylint: disable=unused-argument - LOGGER.debug("STMu received - end of playback.") - self._state = STATE_IDLE - self.signal_event(SqueezeEvent.STATE_UPDATED) - - def _process_stat_stml(self, data): - """Process incoming stat STMl message: Buffer threshold reached.""" - # pylint: disable=unused-argument - LOGGER.debug("STMl received - Buffer threshold reached.") - # autoplay 0 or 2: start playing by send unpause command when buffer full - # create_task(self.send_strm(b"u")) - - def _process_stat_stmn(self, data): - """Process incoming stat STMn message: player couldn't decode stream.""" - # pylint: disable=unused-argument - LOGGER.debug("STMn received - player couldn't decode stream.") - # request next track when this happens - self.signal_event(SqueezeEvent.DECODER_READY) - - def _process_resp(self, data): - """Process incoming RESP message: Response received at player.""" - LOGGER.debug("RESP received - Response received at player.") - # send continue (used when autoplay 1 or 3) - create_task(self._send_frame, b"cont", b"0") - - def _process_setd(self, data): - """Process incoming SETD message: Get/set player firmware settings.""" - cmd_id = data[0] - if cmd_id == 0: - # received player name - data = data[1:].decode() - self._device_name = data - self.signal_event(SqueezeEvent.STATE_UPDATED) - - -class PySqueezeVolume: - """Represents a sound volume. This is an awful lot more complex than it sounds.""" - - minimum = 0 - maximum = 100 - step = 1 - - # this map is taken from Slim::Player::Squeezebox2 in the squeezecenter source - # i don't know how much magic it contains, or any way I can test it - old_map = [ - 0, - 1, - 1, - 1, - 2, - 2, - 2, - 3, - 3, - 4, - 5, - 5, - 6, - 6, - 7, - 8, - 9, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 16, - 17, - 18, - 19, - 20, - 22, - 23, - 24, - 25, - 26, - 27, - 28, - 29, - 30, - 32, - 33, - 34, - 35, - 37, - 38, - 39, - 40, - 42, - 43, - 44, - 46, - 47, - 48, - 50, - 51, - 53, - 54, - 56, - 57, - 59, - 60, - 61, - 63, - 65, - 66, - 68, - 69, - 71, - 72, - 74, - 75, - 77, - 79, - 80, - 82, - 84, - 85, - 87, - 89, - 90, - 92, - 94, - 96, - 97, - 99, - 101, - 103, - 104, - 106, - 108, - 110, - 112, - 113, - 115, - 117, - 119, - 121, - 123, - 125, - 127, - 128, - ] - - # new gain parameters, from the same place - total_volume_range = -50 # dB - step_point = ( - -1 - ) # Number of steps, up from the bottom, where a 2nd volume ramp kicks in. - step_fraction = ( - 1 # fraction of totalVolumeRange where alternate volume ramp kicks in. - ) - - def __init__(self): - """Initialize class.""" - self.volume = 50 - - def increment(self): - """Increment the volume.""" - self.volume += self.step - if self.volume > self.maximum: - self.volume = self.maximum - - def decrement(self): - """Decrement the volume.""" - self.volume -= self.step - if self.volume < self.minimum: - self.volume = self.minimum - - def old_gain(self): - """Return the "Old" gain value as required by the squeezebox.""" - return self.old_map[self.volume] - - def decibels(self): - """Return the "new" gain value.""" - # pylint: disable=invalid-name - - step_db = self.total_volume_range * self.step_fraction - max_volume_db = 0 # different on the boom? - - # Equation for a line: - # y = mx+b - # y1 = mx1+b, y2 = mx2+b. - # y2-y1 = m(x2 - x1) - # y2 = m(x2 - x1) + y1 - slope_high = max_volume_db - step_db / (100.0 - self.step_point) - slope_low = step_db - self.total_volume_range / (self.step_point - 0.0) - x2 = self.volume - if x2 > self.step_point: - m = slope_high - x1 = 100 - y1 = max_volume_db - else: - m = slope_low - x1 = 0 - y1 = self.total_volume_range - return m * (x2 - x1) + y1 - - def new_gain(self): - """Return new gainvalue of the volume control.""" - decibel = self.decibels() - floatmult = 10 ** (decibel / 20.0) - # avoid rounding errors somehow - if -30 <= decibel <= 0: - return int(floatmult * (1 << 8) + 0.5) * (1 << 8) - return int((floatmult * (1 << 16)) + 0.5) diff --git a/music_assistant/providers/tunein.py b/music_assistant/providers/tunein.py new file mode 100644 index 00000000..da96aa2d --- /dev/null +++ b/music_assistant/providers/tunein.py @@ -0,0 +1,154 @@ +"""Tune-In musicprovider support for MusicAssistant.""" +from typing import List, Optional + +from asyncio_throttle import Throttler + +from music_assistant.models.media_items import ( + ContentType, + MediaItemProviderId, + MediaItemType, + MediaQuality, + MediaType, + Radio, + StreamDetails, + StreamType, +) +from music_assistant.models.provider import MusicProvider + + +class TuneInProvider(MusicProvider): + """Provider implementation for Tune In.""" + + def __init__(self, username: str | None) -> None: + """Initialize the provider.""" + self._attr_id = "tunein" + self._attr_name = "Tune-in Radio" + self._attr_supported_mediatypes = [MediaType.RADIO] + self._username = username + self._throttler = Throttler(rate_limit=1, period=1) + + async def setup(self) -> None: + """Handle async initialization of the provider.""" + # we have nothing to setup + + async def search( + self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 + ) -> List[MediaItemType]: + """ + 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). + """ + result = [] + # TODO: search for radio stations + return result + + async def get_library_radios(self) -> List[Radio]: + """Retrieve library/subscribed radio stations from the provider.""" + params = {"c": "presets"} + result = await self._get_data("Browse.ashx", params) + if result and "body" in result: + return [ + await self._parse_radio(item) + for item in result["body"] + if item["type"] == "audio" + ] + return [] + + async def get_radio(self, prov_radio_id: str) -> Radio: + """Get radio station details.""" + prov_radio_id = prov_radio_id.split("--")[0] + radio = None + params = {"c": "composite", "detail": "listing", "id": prov_radio_id} + result = await self._get_data("Describe.ashx", params) + if result and result.get("body") and result["body"][0].get("children"): + item = result["body"][0]["children"][0] + radio = await self._parse_radio(item) + return radio + + async def _parse_radio(self, details: dict) -> Radio: + """Parse Radio object from json obj returned from api.""" + if "name" in details: + name = details["name"] + else: + # parse name from text attr + name = details["text"] + if " | " in name: + name = name.split(" | ")[1] + name = name.split(" (")[0] + radio = Radio(item_id=details["preset_id"], provider=self.id, name=name) + # parse stream urls and format + stream_info = await self._get_stream_urls(radio.item_id) + for stream in stream_info["body"]: + if stream["media_type"] == "aac": + quality = MediaQuality.LOSSY_AAC + elif stream["media_type"] == "ogg": + quality = MediaQuality.LOSSY_OGG + else: + quality = MediaQuality.LOSSY_MP3 + radio.provider_ids.append( + MediaItemProviderId( + provider=self.id, + item_id=f'{details["preset_id"]}--{stream["media_type"]}', + quality=quality, + details=stream["url"], + ) + ) + # image + if "image" in details: + radio.metadata["image"] = details["image"] + elif "logo" in details: + radio.metadata["image"] = details["logo"] + return radio + + async def _get_stream_urls(self, radio_id): + """Return the stream urls for the given radio id.""" + radio_id = radio_id.split("--")[0] + params = {"id": radio_id} + res = await self._get_data("Tune.ashx", params) + return res + + async def get_stream_details(self, item_id: str) -> StreamDetails: + """Get streamdetails for a radio station.""" + radio_id = item_id.split("--")[0] + if len(item_id.split("--")) > 1: + media_type = item_id.split("--")[1] + else: + media_type = "" + stream_info = await self._get_stream_urls(radio_id) + for stream in stream_info["body"]: + if stream["media_type"] == media_type or not media_type: + return StreamDetails( + type=StreamType.URL, + item_id=item_id, + provider=self.id, + path=stream["url"], + content_type=ContentType(stream["media_type"]), + sample_rate=44100, + bit_depth=16, + media_type=MediaType.RADIO, + details=stream, + ) + return None + + async def _get_data(self, endpoint, params=None): + """Get data from api.""" + if not params: + params = {} + url = f"https://opml.radiotime.com/{endpoint}" + params["render"] = "json" + params["formats"] = "ogg,aac,wma,mp3" + params["username"] = self._username + params["partnerId"] = "1" + async with self._throttler: + async with self.mass.http_session.get( + url, params=params, verify_ssl=False + ) as response: + result = await response.json() + if not result or "error" in result: + self.logger.error(url) + self.logger.error(params) + result = None + return result diff --git a/music_assistant/providers/tunein/__init__.py b/music_assistant/providers/tunein/__init__.py deleted file mode 100644 index 821d4b3e..00000000 --- a/music_assistant/providers/tunein/__init__.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Tune-In musicprovider support for MusicAssistant.""" -import logging -from typing import List, Optional - -from asyncio_throttle import Throttler -from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME -from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType -from music_assistant.models.media_types import ( - MediaItemProviderId, - MediaType, - Radio, - SearchResult, - TrackQuality, -) -from music_assistant.models.provider import MusicProvider -from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType - -PROV_ID = "tunein" -PROV_NAME = "TuneIn Radio" -LOGGER = logging.getLogger(PROV_ID) - -CONFIG_ENTRIES = [ - ConfigEntry( - entry_key=CONF_USERNAME, - entry_type=ConfigEntryType.STRING, - description=CONF_USERNAME, - ), - ConfigEntry( - entry_key=CONF_PASSWORD, - entry_type=ConfigEntryType.PASSWORD, - description=CONF_PASSWORD, - ), -] - - -async def setup(mass): - """Perform async setup of this Plugin/Provider.""" - prov = TuneInProvider() - await mass.register_provider(prov) - - -class TuneInProvider(MusicProvider): - """Provider implementation for Tune In.""" - - # pylint: disable=abstract-method - - _username = None - _password = None - _throttler = None - - @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 - - @property - def supported_mediatypes(self) -> List[MediaType]: - """Return MediaTypes the provider supports.""" - return [MediaType.RADIO] - - async def on_start(self) -> bool: - """Handle initialization of the provider based on config.""" - # pylint: disable=attribute-defined-outside-init - config = self.mass.config.get_provider_config(self.id) - if not config[CONF_USERNAME] or not config[CONF_PASSWORD]: - LOGGER.debug("Username and password not set. Abort load of provider.") - return False - self._username = config[CONF_USERNAME] - self._password = config[CONF_PASSWORD] - self._throttler = Throttler(rate_limit=1, period=1) - return True - - async def 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). - """ - result = SearchResult() - # TODO: search for radio stations - return result - - async def get_library_radios(self) -> List[Radio]: - """Retrieve library/subscribed radio stations from the provider.""" - params = {"c": "presets"} - result = await self._get_data("Browse.ashx", params) - if result and "body" in result: - return [ - await self._parse_radio(item) - for item in result["body"] - if item["type"] == "audio" - ] - return [] - - async def get_radio(self, prov_radio_id: str) -> Radio: - """Get radio station details.""" - radio = None - params = {"c": "composite", "detail": "listing", "id": prov_radio_id} - result = await self._get_data("Describe.ashx", params) - if result and result.get("body") and result["body"][0].get("children"): - item = result["body"][0]["children"][0] - radio = await self._parse_radio(item) - return radio - - async def _parse_radio(self, details: dict) -> Radio: - """Parse Radio object from json obj returned from api.""" - radio = Radio(item_id=details["preset_id"], provider=PROV_ID) - if "name" in details: - radio.name = details["name"] - else: - # parse name from text attr - name = details["text"] - if " | " in name: - name = name.split(" | ")[1] - name = name.split(" (")[0] - radio.name = name - # parse stream urls and format - stream_info = await self._get_stream_urls(radio.item_id) - for stream in stream_info["body"]: - if stream["media_type"] == "aac": - quality = TrackQuality.LOSSY_AAC - elif stream["media_type"] == "ogg": - quality = TrackQuality.LOSSY_OGG - else: - quality = TrackQuality.LOSSY_MP3 - radio.provider_ids.add( - MediaItemProviderId( - provider=PROV_ID, - item_id="%s--%s" % (details["preset_id"], stream["media_type"]), - quality=quality, - details=stream["url"], - ) - ) - # image - if "image" in details: - radio.metadata["image"] = details["image"] - elif "logo" in details: - radio.metadata["image"] = details["logo"] - return radio - - async def _get_stream_urls(self, radio_id): - """Return the stream urls for the given radio id.""" - params = {"id": radio_id} - res = await self._get_data("Tune.ashx", params) - return res - - async def get_stream_details(self, item_id: str) -> StreamDetails: - """Get streamdetails for a radio station.""" - radio_id = item_id.split("--")[0] - if len(item_id.split("--")) > 1: - media_type = item_id.split("--")[1] - else: - media_type = "" - stream_info = await self._get_stream_urls(radio_id) - for stream in stream_info["body"]: - if stream["media_type"] == media_type or not media_type: - return StreamDetails( - type=StreamType.URL, - item_id=item_id, - provider=PROV_ID, - path=stream["url"], - content_type=ContentType(stream["media_type"]), - sample_rate=44100, - bit_depth=16, - media_type=MediaType.RADIO, - details=stream, - ) - return None - - async def _get_data(self, endpoint, params=None): - """Get data from api.""" - if not params: - params = {} - url = "https://opml.radiotime.com/%s" % endpoint - params["render"] = "json" - params["formats"] = "ogg,aac,wma,mp3" - params["username"] = self._username - params["partnerId"] = "1" - async with self._throttler: - async with self.mass.http_session.get( - url, params=params, verify_ssl=False - ) as response: - result = await response.json() - if not result or "error" in result: - LOGGER.error(url) - LOGGER.error(params) - result = None - return result diff --git a/music_assistant/providers/tunein/icon.png b/music_assistant/providers/tunein/icon.png deleted file mode 100644 index 18c537c3..00000000 Binary files a/music_assistant/providers/tunein/icon.png and /dev/null differ diff --git a/music_assistant/providers/universal_group/__init__.py b/music_assistant/providers/universal_group/__init__.py deleted file mode 100644 index 35141d90..00000000 --- a/music_assistant/providers/universal_group/__init__.py +++ /dev/null @@ -1,489 +0,0 @@ -"""Group player provider: enables grouping of all Players.""" - -import asyncio -import logging -from typing import List - -from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.util import create_task -from music_assistant.models.config_entry import ( - ConfigEntry, - ConfigEntryType, - ConfigValueOption, -) -from music_assistant.models.player import DeviceInfo, Player, PlayerState -from music_assistant.models.provider import PlayerProvider - -PROV_ID = "universal_group" -PROV_NAME = "Universal Group player" -LOGGER = logging.getLogger(PROV_ID) - -CONF_PLAYER_COUNT = "group_player_count" -CONF_PLAYERS = "group_player_players" -CONF_MASTER = "group_player_master" - -CONFIG_ENTRIES = [ - ConfigEntry( - entry_key=CONF_PLAYER_COUNT, - entry_type=ConfigEntryType.INT, - description=CONF_PLAYER_COUNT, - default_value=1, - range=(0, 10), - ) -] - - -async def setup(mass): - """Perform async setup of this Plugin/Provider.""" - prov = GroupPlayerProvider() - await mass.register_provider(prov) - - -class GroupPlayerProvider(PlayerProvider): - """PlayerProvider which allows users to group players.""" - - @property - def id(self) -> str: - """Return provider ID for this provider.""" - return PROV_ID - - @property - def name(self) -> str: - """Return provider Name for this provider.""" - return PROV_NAME - - @property - def config_entries(self) -> List[ConfigEntry]: - """Return Config Entries for this provider.""" - return CONFIG_ENTRIES - - async def on_start(self) -> bool: - """Handle initialization of the provider based on config.""" - conf = self.mass.config.player_providers[PROV_ID] - for index in range(conf[CONF_PLAYER_COUNT]): - player = GroupPlayer(self.mass, index) - await self.mass.players.add_player(player) - return True - - async def on_stop(self): - """Handle correct close/cleanup of the provider on exit. Called on shutdown.""" - for player in self.players: - await player.cmd_stop() - - -class GroupPlayer(Player): - """Model for a group player.""" - - def __init__(self, mass: MusicAssistant, player_index: int): - """Initialize.""" - super().__init__() - self.mass = mass - self._player_index = player_index - self._player_id = f"{PROV_ID}_{player_index}" - self._provider_id = PROV_ID - self._name = f"{PROV_NAME} {player_index}" - self._powered = False - self._state = PlayerState.IDLE - self._available = True - self._current_uri = "" - self._volume_level = 0 - self._muted = False - self.connected_clients = {} - self.stream_task = None - self.sync_task = None - self._config_entries = self.__get_config_entries() - self._group_childs = self.__get_group_childs() - - @property - def player_id(self) -> str: - """Return player id of this player.""" - return self._player_id - - @property - def provider_id(self) -> str: - """Return provider id of this player.""" - return self._provider_id - - @property - def name(self) -> str: - """Return name of the player.""" - return self._name - - @property - def powered(self) -> bool: - """Return current power state of player.""" - return self._powered - - @property - def state(self) -> PlayerState: - """Return current PlayerState of player.""" - return self._state - - @property - def available(self) -> bool: - """Return current availablity of player.""" - return True - - @property - def current_uri(self) -> str: - """Return currently loaded uri of player (if any).""" - return self._current_uri - - @property - def volume_level(self) -> int: - """Return current volume level of player (scale 0..100).""" - return self._volume_level - - @property - def muted(self) -> bool: - """Return current mute state of player.""" - return self._muted - - @property - def elapsed_time(self): - """Return elapsed time for first child player.""" - if self.state in [PlayerState.PLAYING, PlayerState.PAUSED]: - for player_id in self.group_childs: - player = self.mass.players.get_player(player_id) - if player: - return player.elapsed_time - return 0 - - @property - def should_poll(self): - """Return True if this player should be polled for state.""" - return True - - @property - def is_group_player(self) -> bool: - """Return True if this player is a group player.""" - return True - - @property - def group_childs(self): - """Return group childs of this group player.""" - return self._group_childs - - @property - def device_info(self) -> DeviceInfo: - """Return deviceinfo.""" - return DeviceInfo( - model="Group Player", - manufacturer=PROV_ID, - ) - - @property - def config_entries(self): - """Return config entries for this group player.""" - return self._config_entries - - async def on_poll(self) -> None: - """Call when player is periodically polled by the player manager (should_poll=True).""" - self._config_entries = self.__get_config_entries() - self._group_childs = self.__get_group_childs() - self.update_state() - - def __get_group_childs(self): - """Return group childs of this group player.""" - player_conf = self.mass.config.get_player_config(self.player_id) - if player_conf and player_conf.get(CONF_PLAYERS): - return player_conf[CONF_PLAYERS] - return [] - - def __get_config_entries(self): - """Return config entries for this group player.""" - all_players = [ - ConfigValueOption(text=item.name, value=item.player_id) - for item in self.mass.players - 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: - if player := self.mass.players.get_player(player_id): - selected_players.append( - ConfigValueOption(text=player.name, value=player.player_id) - ) - default_master = "" - if selected_players: - default_master = selected_players[0]["value"] - return [ - ConfigEntry( - entry_key=CONF_PLAYERS, - entry_type=ConfigEntryType.STRING, - default_value=[], - options=all_players, - label=CONF_PLAYERS, - description="group_player_players_desc", - multi_value=True, - ), - ConfigEntry( - entry_key=CONF_MASTER, - entry_type=ConfigEntryType.STRING, - default_value=default_master, - options=selected_players, - label=CONF_MASTER, - description="group_player_master_desc", - multi_value=False, - depends_on=CONF_PLAYERS, - ), - ] - - # SERVICE CALLS / PLAYER COMMANDS - - async def cmd_play_uri(self, uri: str): - """Play the specified uri/url on the player.""" - await self.cmd_stop() - self._current_uri = uri - self._state = PlayerState.PLAYING - self._powered = True - # forward this command to each child 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.players.get_player(child_player_id) - if child_player: - queue_stream_uri = f"{self.mass.web.stream_url}/group/{self.player_id}?player_id={child_player_id}" - await child_player.cmd_play_uri(queue_stream_uri) - self.update_state() - self.stream_task = create_task(self.queue_stream_task()) - - async def cmd_stop(self) -> None: - """Send STOP command to player.""" - self._state = PlayerState.IDLE - if self.stream_task: - # cancel existing stream task if any - self.stream_task.cancel() - self.connected_clients = {} - await asyncio.sleep(0.5) - if self.sync_task: - self.sync_task.cancel() - # 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.players.get_player(child_player_id) - if child_player: - await child_player.cmd_stop() - self.update_state() - - async def cmd_play(self) -> None: - """Send PLAY command to player.""" - if not self.state == PlayerState.PAUSED: - return - # forward this command to each child player - for child_player_id in self.group_childs: - child_player = self.mass.players.get_player(child_player_id) - if child_player: - await child_player.cmd_play() - self._state = PlayerState.PLAYING - self.update_state() - - async def cmd_pause(self): - """Send PAUSE command to player.""" - # forward this command to each child player - for child_player_id in self.group_childs: - child_player = self.mass.players.get_player(child_player_id) - if child_player: - await child_player.cmd_pause() - self._state = PlayerState.PAUSED - self.update_state() - - async def cmd_power_on(self) -> None: - """Send POWER ON command to player.""" - self._powered = True - self.update_state() - - async def cmd_power_off(self) -> None: - """Send POWER OFF command to player.""" - self._powered = False - self.update_state() - - async def cmd_volume_set(self, volume_level: int) -> None: - """ - Send volume level command to player. - - :param volume_level: volume level to set (0..100). - """ - # this is already handled by the player manager - - async def cmd_volume_mute(self, is_muted=False): - """ - Send volume MUTE command to given player. - - :param is_muted: bool with new mute state. - """ - for child_player_id in self.group_childs: - self.mass.players.cmd_volume_mute(child_player_id) - self.muted = is_muted - - async def subscribe_stream_client(self, child_player_id): - """Handle streaming to all players of a group. Highly experimental.""" - - # each connected client gets its own Queue to which audio chunks (flac) are sent - try: - # report this client as connected - queue = asyncio.Queue() - self.connected_clients[child_player_id] = queue - LOGGER.debug( - "[%s] child player connected: %s", - self.player_id, - child_player_id, - ) - # yield flac chunks from stdout to the http streamresponse - while True: - chunk = await queue.get() - yield chunk - queue.task_done() - if not chunk: - break - except (GeneratorExit, Exception): # pylint: disable=broad-except - LOGGER.warning( - "[%s] child player aborted stream: %s", self.player_id, child_player_id - ) - self.connected_clients.pop(child_player_id, None) - else: - self.connected_clients.pop(child_player_id, None) - LOGGER.debug( - "[%s] child player completed streaming: %s", - self.player_id, - child_player_id, - ) - - async def queue_stream_task(self): - """Handle streaming queue to connected child players.""" - ticks = 0 - while ticks < 60 and (len(self.connected_clients) != len(self.group_childs)): - # TODO: Support situation where not all clients of the group are powered - await asyncio.sleep(0.1) - ticks += 1 - if not self.connected_clients: - LOGGER.warning("no clients!") - return - LOGGER.debug( - "start queue stream with %s connected clients", len(self.connected_clients) - ) - self.sync_task = create_task(self.__synchronize_players()) - - async for audio_chunk in self.mass.streams.queue_stream_flac(self.player_id): - - # make sure we still have clients connected - if not self.connected_clients: - LOGGER.warning("no more clients!") - return - - # send the audio chunk to all connected players - tasks = [] - for _queue in self.connected_clients.values(): - tasks.append(create_task(_queue.put(audio_chunk))) - # wait for clients to consume the data - await asyncio.wait(tasks) - - if not self.connected_clients: - LOGGER.warning("no more clients!") - return - self.sync_task.cancel() - - async def __synchronize_players(self): - """Handle drifting/lagging by monitoring progress and compare to master player.""" - - master_player_id = self.mass.config.player_settings[self.player_id].get( - CONF_MASTER - ) - 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.name - ) - - # wait until master is playing - while master_player.state != PlayerState.PLAYING: - await asyncio.sleep(0.1) - await asyncio.sleep(0.5) - - prev_lags = {} - prev_drifts = {} - - while self.connected_clients: - - # check every 0.5 seconds for player sync - await asyncio.sleep(0.5) - - for child_player_id in self.connected_clients: - - if child_player_id == master_player_id: - continue - child_player = self.mass.players.get_player(child_player_id) - - if ( - not child_player - or child_player.state != PlayerState.PLAYING - or child_player.elapsed_milliseconds is None - ): - continue - - if child_player_id not in prev_lags: - prev_lags[child_player_id] = [] - if child_player_id not in prev_drifts: - prev_drifts[child_player_id] = [] - - # calculate lag (player is too slow in relation to the master) - lag = ( - master_player.elapsed_milliseconds - - child_player.elapsed_milliseconds - ) - prev_lags[child_player_id].append(lag) - if len(prev_lags[child_player_id]) == 5: - # if we have 5 samples calclate the average lag - avg_lag = sum(prev_lags[child_player_id]) / len( - prev_lags[child_player_id] - ) - prev_lags[child_player_id] = [] - if avg_lag > 25: - LOGGER.debug( - "child player %s is lagging behind with %s milliseconds", - child_player_id, - avg_lag, - ) - # we correct the lag by pausing the master player for a very short time - await master_player.cmd_pause() - # sending the command takes some time, account for that too - if avg_lag > 20: - sleep_time = avg_lag - 20 - await asyncio.sleep(sleep_time / 1000) - create_task(master_player.cmd_play()) - break # no more processing this round if we've just corrected a lag - - # calculate drift (player is going faster in relation to the master) - drift = ( - child_player.elapsed_milliseconds - - master_player.elapsed_milliseconds - ) - prev_drifts[child_player_id].append(drift) - if len(prev_drifts[child_player_id]) == 5: - # if we have 5 samples calculate the average drift - avg_drift = sum(prev_drifts[child_player_id]) / len( - prev_drifts[child_player_id] - ) - prev_drifts[child_player_id] = [] - - if avg_drift > 25: - LOGGER.debug( - "child player %s is drifting ahead with %s milliseconds", - child_player_id, - avg_drift, - ) - # we correct the drift by pausing the player for a very short time - # this is not the best approach but works with all Players - # temporary solution until I find something better like sending more/less pcm chunks - await child_player.cmd_pause() - # sending the command takes some time, account for that too - if avg_drift > 20: - sleep_time = drift - 20 - await asyncio.sleep(sleep_time / 1000) - await child_player.cmd_play() - break # no more processing this round if we've just corrected a lag diff --git a/music_assistant/providers/universal_group/icon.png b/music_assistant/providers/universal_group/icon.png deleted file mode 100644 index 092121e1..00000000 Binary files a/music_assistant/providers/universal_group/icon.png and /dev/null differ diff --git a/music_assistant/resources/announce.flac b/music_assistant/resources/announce.flac deleted file mode 100644 index 95c7caec..00000000 Binary files a/music_assistant/resources/announce.flac and /dev/null differ diff --git a/music_assistant/resources/silence.flac b/music_assistant/resources/silence.flac deleted file mode 100644 index 352dd6ff..00000000 Binary files a/music_assistant/resources/silence.flac and /dev/null differ diff --git a/music_assistant/resources/strings/en.json b/music_assistant/resources/strings/en.json deleted file mode 100644 index a6a7535d..00000000 --- a/music_assistant/resources/strings/en.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "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", - "desc_player_name": "Set a custom name for this player.", - "crossfade_duration": "Enable crossfade", - "group_delay": "Correction of groupdelay", - "security": "Security", - "app_tokens": "App tokens", - "power_control": "Power Control", - "volume_control": "Volume Control", - - "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_crossfade": "Enable crossfading of Queue tracks by setting a crossfade duration in seconds.", - "desc_enable_provider": "Enable this provider.", - "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.", - "desc_group_delay": "Only used on grouped playback. Adjust the delay of the grouped playback on this player", - "desc_power_control": "Use an external device as power control for this player.", - "desc_volume_control": "Use an external device as volume control for this player.", - - "Universal Group player": "Universal Group Player", - "group_player_count": "Number of group players", - "group_player_count_desc": "Select how many Universal group players should be created.", - "group_player_players": "Players in group", - "group_player_players_desc": "Select the players that should be part of this group.", - "group_player_master": "Group master", - "group_player_master_desc": "Select the player that should act as group master.", - - "desc_spotify_username": "Username for your Spotify account", - "desc_spotify_password": "Password for your Spotify account", - - "file_prov_music_path": "Music path", - "file_prov_music_path_desc": "Path on disk to your music files.", - "file_prov_playlists_path": "Playlists path", - "file_prov_playlists_path_desc": "Path on disk to your playlists (.m3u) files." -} \ No newline at end of file diff --git a/music_assistant/resources/strings/nl.json b/music_assistant/resources/strings/nl.json deleted file mode 100644 index d7e3d235..00000000 --- a/music_assistant/resources/strings/nl.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "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", - "desc_player_name": "Stel een aangepaste naam in voor deze speler.", - "crossfade_duration": "Crossfade inschakelen", - "security": "Beveiliging", - "app_tokens": "App tokens", - "group_delay": "Correctie van groepsvertraging", - "power_control": "Power Control", - "volume_control": "Volume Control", - - "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_crossfade": "Crossfade inschakelen door het instellen van een crossfade duur in seconden.", - "desc_enable_provider": "Deze provider inschakelen.", - "desc_base_username": "Gebruikersnaam waarmee deze server beveiligd moet worden.", - "desc_base_password": "Wachtwoord waarmee deze server beveiligd moet worden. Mag worden leeggelaten maar dit is extreem gevaarlijk indien je besluit de server extern toegankelijk te maken.", - "desc_group_delay": "Gebruikt bij afspelen in groep. Pas de vertraging aan voor deze player.", - "desc_power_control": "Gebruik een extern apparaat als aan/uit control voor deze speler.", - "desc_volume_control": "Gebruik een extern apparaat als volume control voor deze speler.", - - "Universal Group player": "Universele groep speler", - "group_player_count": "Aantal groep spelers", - "group_player_count_desc": "Selecteer hoeveel groep spelers er aangemaakt moeten worden.", - "group_player_players": "Groepsspelers", - "group_player_players_desc": "Selecteer de spelers die deel uitmaken van deze groep.", - "group_player_master": "Groepsbeheerder", - "group_player_master_desc": "Selecteer de speler die dient als groepsbeheerder.", - - "desc_spotify_username": "Gebruikersnaam van jouw Spotify account", - "desc_spotify_password": "Wachtwoord van jouw Spotify account", - - "file_prov_music_path": "Muzieklocatie", - "file_prov_music_path_desc": "Locatie op schijf waar jouw muziekbestanden staan.", - "file_prov_playlists_path": "Playlists locatie", - "file_prov_playlists_path_desc": "Locatie op schijf waar jouw playlists (.m3u) staan." -} \ No newline at end of file diff --git a/music_assistant/web/__init__.py b/music_assistant/web/__init__.py deleted file mode 100755 index 17411afc..00000000 --- a/music_assistant/web/__init__.py +++ /dev/null @@ -1,269 +0,0 @@ -""" -The web module handles serving the custom websocket api on a custom port (default is 8095). - -All MusicAssistant clients communicate locally with this websockets api. -The server is intended to be used locally only and not exposed outside, -so it is HTTP only. Secure remote connections will be offered by a remote connect broker. -""" -import logging -import os -import uuid -from json.decoder import JSONDecodeError -from typing import Callable, List, Tuple - -import aiofiles -import aiohttp_cors -import jwt -import music_assistant.web.api as api -from aiohttp import web -from aiohttp.web_exceptions import HTTPNotFound, HTTPUnauthorized -from music_assistant.constants import ( - CONF_KEY_SECURITY_LOGIN, - CONF_PASSWORD, - CONF_USERNAME, -) -from music_assistant.constants import __version__ as MASS_VERSION -from music_assistant.helpers.datetime import future_timestamp -from music_assistant.helpers.encryption import decrypt_string -from music_assistant.helpers.errors import AuthenticationError -from music_assistant.helpers.images import get_thumb_file -from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.util import get_hostname, get_ip -from music_assistant.helpers.web import APIRoute, create_api_route - -from .json_rpc import json_rpc_endpoint -from .stream import routes as stream_routes - -LOGGER = logging.getLogger("webserver") - - -class WebServer: - """Webserver and json/websocket api.""" - - def __init__(self, mass: MusicAssistant, port: int) -> None: - """Initialize class.""" - self.jwt_key = None - self.app = None - self.mass = mass - self._port = port - # load/create/update config - self._hostname = get_hostname().lower() - self._ip_address = get_ip() - self.config = mass.config.base["web"] - self._runner = None - self.api_routes: List[APIRoute] = [] - - async def setup(self) -> None: - """Perform async setup.""" - self.jwt_key = await decrypt_string(self.mass.config.stored_config["jwt_key"]) - self.app = web.Application() - self.app["mass"] = self.mass - self.app["ws_clients"] = [] - # add all routes - self.app.add_routes(stream_routes) - self.app.router.add_route("*", "/jsonrpc.js", json_rpc_endpoint) - self.app.router.add_view("/ws", api.WebSocketApi) - - # Add server discovery on info including CORS support - cors = aiohttp_cors.setup( - self.app, - defaults={ - "*": aiohttp_cors.ResourceOptions( - allow_credentials=True, - allow_headers="*", - ) - }, - ) - cors.add(self.app.router.add_get("/info", self.info)) - cors.add(self.app.router.add_post("/login", self.login)) - cors.add(self.app.router.add_post("/setup", self.first_setup)) - cors.add(self.app.router.add_get("/thumb", self.image_thumb)) - self.app.router.add_route("*", "/api/{tail:.+}", api.handle_api_request) - # Host the frontend app - webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static/") - if os.path.isdir(webdir): - self.app.router.add_get("/", self.index) - self.app.router.add_static("/", webdir, append_version=True) - - self._runner = web.AppRunner(self.app, access_log=None) - await self._runner.setup() - # set host to None to bind to all addresses on both IPv4 and IPv6 - http_site = web.TCPSite(self._runner, host=None, port=self.port) - await http_site.start() - self.add_api_routes() - LOGGER.info("Started Music Assistant server on port %s", self.port) - - async def stop(self) -> None: - """Stop the webserver.""" - for ws_client in self.app["ws_clients"]: - await ws_client.close(message=b"server shutdown") - - def add_api_routes(self) -> None: - """Register all methods decorated as api_route.""" - for cls in [ - api, - self.mass.music, - self.mass.players, - self.mass.config, - self.mass.library, - self.mass.tasks, - ]: - for item in dir(cls): - func = getattr(cls, item) - if not hasattr(func, "api_path"): - continue - # method is decorated with our api decorator - self.register_api_route(func.api_path, func, func.api_method) - - def register_api_route( - self, - path: str, - handler: Callable, - method: str = "GET", - ) -> None: - """Dynamically register a path/route on the API.""" - route = create_api_route(path, handler, method) - # TODO: swagger generation - self.api_routes.append(route) - - @property - def hostname(self) -> str: - """Return the hostname for this Music Assistant instance.""" - if not self._hostname.endswith(".local"): - # probably running in docker, use mdns name instead - return f"mass_{self.server_id}.local" - return self._hostname - - @property - def ip_address(self) -> str: - """Return the local IP(v4) address for this Music Assistant instance.""" - return self._ip_address - - @property - def port(self) -> int: - """Return the port for this Music Assistant instance.""" - return self._port - - @property - def stream_url(self) -> str: - """Return the base stream URL for this Music Assistant instance.""" - # dns resolving often fails on stream devices so use IP-address - return f"http://{self.ip_address}:{self.port}/stream" - - @property - def address(self) -> str: - """Return the base HTTP address for this Music Assistant instance.""" - return f"http://{self.ip_address}:{self.port}" - - @property - def server_id(self) -> str: - """Return the device ID for this Music Assistant Server.""" - return self.mass.config.stored_config["server_id"] - - @property - def discovery_info(self) -> dict: - """Return discovery info for this Music Assistant server.""" - return { - "id": self.server_id, - "address": self.address, - "hostname": self.hostname, - "ip_address": self.ip_address, - "port": self.port, - "version": MASS_VERSION, - "friendly_name": self.mass.config.stored_config["friendly_name"], - "initialized": self.mass.config.stored_config["initialized"], - } - - async def index(self, request: web.Request) -> web.FileResponse: - """Get the index page.""" - # pylint: disable=unused-argument - html_app = os.path.join( - os.path.dirname(os.path.abspath(__file__)), "static/index.html" - ) - return web.FileResponse(html_app) - - async def info(self, request: web.Request) -> web.Response: - """Return server discovery info.""" - return web.json_response(self.discovery_info) - - async def login(self, request: web.Request) -> web.Response: - """ - Validate given credentials and return JWT token. - - If app_id is provided, a long lived token will be issued which can be withdrawn by the user. - """ - try: - data = await request.post() - if not data: - data = await request.json() - except JSONDecodeError: - data = await request.json() - username = data["username"] - password = data["password"] - app_id = data.get("app_id", "") - verified = self.mass.config.security.validate_credentials(username, password) - if verified: - client_id = str(uuid.uuid4()) - token_info = { - "username": username, - "server_id": self.server_id, - "client_id": client_id, - "app_id": app_id, - } - if app_id: - token_info["enabled"] = True - token_info["exp"] = future_timestamp(days=365 * 10) - else: - token_info["exp"] = future_timestamp(hours=8) - token = jwt.encode(token_info, self.jwt_key, algorithm="HS256") - if app_id: - self.mass.config.security.add_app_token(token_info) - token_info["token"] = token - return web.json_response(token_info) - raise HTTPUnauthorized(reason="Invalid credentials") - - async def first_setup(self, request: web.Request) -> web.Response: - """Handle first-time server setup through onboarding wizard.""" - try: - data = await request.post() - if not data: - data = await request.json() - except JSONDecodeError: - data = await request.json() - username = data["username"] - password = data["password"] - if self.mass.config.stored_config["initialized"]: - raise AuthenticationError("Already initialized") - # save credentials in config - self.mass.config.security[CONF_KEY_SECURITY_LOGIN][CONF_USERNAME] = username - self.mass.config.security[CONF_KEY_SECURITY_LOGIN][CONF_PASSWORD] = password - self.mass.config.stored_config["initialized"] = True - self.mass.config.save() - # fix discovery info - await self.mass.setup_discovery() - return web.json_response(self.discovery_info) - - async def image_thumb(self, request: web.Request) -> web.Response: - """Get (resized) thumb image for given URL.""" - url = request.query.get("url") - size = int(request.query.get("size", 150)) - - img_file = await get_thumb_file(self.mass, url, size) - if img_file: - async with aiofiles.open(img_file, "rb") as _file: - img_data = await _file.read() - headers = { - "Content-Type": "image/png", - "Cache-Control": "public, max-age=604800", - } - return web.Response(body=img_data, headers=headers) - raise KeyError("Invalid url!") - - def get_api_handler(self, path: str, method: str) -> Tuple[APIRoute, dict]: - """Find API route match for given path.""" - matchpath = path.replace("/api/", "") - for route in self.api_routes: - match = route.match(matchpath, method) - if match: - return match[0], match[1] - raise HTTPNotFound(reason="Invalid path: %s" % path) diff --git a/music_assistant/web/api.py b/music_assistant/web/api.py deleted file mode 100644 index 504ce491..00000000 --- a/music_assistant/web/api.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Custom API implementation using websockets.""" - -import asyncio -import logging -import os -from base64 import b64encode -from typing import Any, Dict, Optional, Union - -import aiofiles -import jwt -import ujson -from aiohttp import WSMsgType, web -from aiohttp.http_websocket import WSMessage -from music_assistant.helpers.errors import AuthenticationError -from music_assistant.helpers.images import get_image_url, get_thumb_file -from music_assistant.helpers.logger import HistoryLogHandler -from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.web import ( - api_route, - async_json_response, - async_json_serializer, - parse_arguments, -) -from music_assistant.models.media_types import MediaType - -LOGGER = logging.getLogger("api") - - -@api_route("log") -async def get_log(tail: int = 200) -> str: - """Return current application log.""" - for handler in logging.getLogger().handlers: - if isinstance(handler, HistoryLogHandler): - return handler.get_history()[-tail:] - - -@api_route("images/{media_type}/{provider}/{item_id}") -async def get_media_item_image_url( - mass: MusicAssistant, media_type: MediaType, provider: str, item_id: str -) -> str: - """Return image URL for given media item.""" - if provider == "url": - return None - return await get_image_url(mass, item_id, provider, media_type) - - -@api_route("images/thumb") -async def get_image_thumb(mass: MusicAssistant, url: str, size: int = 150) -> str: - """Get (resized) thumb image for given URL as base64 string.""" - img_file = await get_thumb_file(mass, url, size) - if img_file: - async with aiofiles.open(img_file, "rb") as _file: - img_data = await _file.read() - return "data:image/png;base64," + b64encode(img_data).decode() - raise KeyError("Invalid url!") - - -@api_route("images/provider-icons/{provider_id}") -async def get_provider_icon(provider_id: str) -> str: - """Get Provider icon as base64 string.""" - 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): - async with aiofiles.open(icon_path, "rb") as _file: - img_data = await _file.read() - return "data:image/png;base64," + b64encode(img_data).decode() - raise KeyError("Invalid provider: %s" % provider_id) - - -@api_route("images/provider-icons") -async def get_provider_icons(mass: MusicAssistant) -> Dict[str, str]: - """Get Provider icons as base64 strings.""" - return { - prov.id: await get_provider_icon(prov.id) - for prov in mass.get_providers(include_unavailable=True) - } - - -async def handle_api_request(request: web.Request): - """Handle API requests.""" - mass: MusicAssistant = request.app["mass"] - LOGGER.debug("Handling %s", request.path) - - # check auth token - auth_token = request.headers.get("Authorization", "").split("Bearer ")[-1] - if not auth_token: - raise web.HTTPUnauthorized( - reason="Missing authorization token", - ) - try: - token_info = jwt.decode(auth_token, mass.web.jwt_key, algorithms=["HS256"]) - except jwt.InvalidTokenError as exc: - LOGGER.exception(exc, exc_info=exc) - msg = "Invalid authorization token, " + str(exc) - raise web.HTTPUnauthorized(reason=msg) - if mass.config.security.is_token_revoked(token_info): - raise web.HTTPUnauthorized(reason="Token is revoked") - mass.config.security.set_last_login(token_info["client_id"]) - - # handle request - handler, path_params = mass.web.get_api_handler(request.path, request.method) - data = await request.json() if request.can_read_body else {} - # execute handler and return results - try: - all_params = {**path_params, **request.query, **data} - params = parse_arguments(mass, handler.signature, all_params) - res = handler.target(**params) - if asyncio.iscoroutine(res): - res = await res - except Exception as exc: # pylint: disable=broad-except - LOGGER.debug("Error while handling %s", request.path, exc_info=exc) - raise web.HTTPInternalServerError(reason=str(exc)) - return await async_json_response(res) - - -class WebSocketApi(web.View): - """RPC-like API implementation using websockets.""" - - def __init__(self, request: web.Request): - """Initialize.""" - super().__init__(request) - self.authenticated = False - self.ws_client: Optional[web.WebSocketResponse] = None - - @property - def mass(self) -> MusicAssistant: - """Return MusicAssistant instance.""" - return self.request.app["mass"] - - async def get(self): - """Handle GET.""" - ws_client = web.WebSocketResponse() - self.ws_client = ws_client - await ws_client.prepare(self.request) - self.request.app["ws_clients"].append(ws_client) - await self._send_json(msg_type="info", data=self.mass.web.discovery_info) - - # add listener for mass events - remove_listener = self.mass.eventbus.add_listener(self._handle_mass_event) - - # handle incoming messages - try: - async for msg in ws_client: - await self.__handle_msg(msg) - finally: - # websocket disconnected - remove_listener() - self.request.app["ws_clients"].remove(ws_client) - LOGGER.debug("websocket connection closed: %s", self.request.remote) - - return ws_client - - async def __handle_msg(self, msg: WSMessage): - """Handle incoming message.""" - try: - if msg.type == WSMsgType.error: - LOGGER.warning( - "ws connection closed with exception %s", self.ws_client.exception() - ) - return - if msg.type != WSMsgType.text: - return - if msg.data == "close": - await self.ws_client.close() - return - # process message - json_msg = msg.json(loads=ujson.loads) - # handle auth command - if json_msg["type"] == "auth": - token_info = jwt.decode( - json_msg["data"], self.mass.web.jwt_key, algorithms=["HS256"] - ) - if self.mass.config.security.is_token_revoked(token_info): - raise AuthenticationError("Token is revoked") - self.authenticated = True - self.mass.config.security.set_last_login(token_info["client_id"]) - # TODO: store token/app_id on ws_client obj and periodically check if token is expired or revoked - await self._send_json( - msg_type="result", - msg_id=json_msg.get("id"), - data=token_info, - ) - elif not self.authenticated: - raise AuthenticationError("Not authenticated") - # handle regular command - elif json_msg["type"] == "command": - await self._handle_command( - json_msg["data"], - msg_id=json_msg.get("id"), - ) - except AuthenticationError as exc: # pylint:disable=broad-except - # disconnect client on auth errors - await self._send_json( - msg_type="error", msg_id=json_msg.get("id"), data=str(exc) - ) - await self.ws_client.close(message=str(exc).encode()) - except Exception as exc: # pylint:disable=broad-except - # log the error only - await self._send_json( - msg_type="error", msg_id=json_msg.get("id"), data=str(exc) - ) - LOGGER.error("Error with WS client", exc_info=exc) - - async def _handle_command( - self, - cmd_data: Union[str, dict], - msg_id: Any = None, - ): - """Handle websocket command.""" - # Command may be provided as string or a dict - if isinstance(cmd_data, str): - path = cmd_data - method = "GET" - params = {} - else: - path = cmd_data["path"] - method = cmd_data.get("method", "GET") - params = {x: cmd_data[x] for x in cmd_data if x not in ["path", "method"]} - LOGGER.debug("Handling command %s/%s", method, path) - # work out handler for the given path/command - route, path_params = self.mass.web.get_api_handler(path, method) - args = parse_arguments(self.mass, route.signature, {**params, **path_params}) - res = route.target(**args) - if asyncio.iscoroutine(res): - res = await res - # return result of command to client - return await self._send_json(msg_type="result", msg_id=msg_id, data=res) - - async def _send_json( - self, - msg_type: str, - msg_id: Optional[int] = None, - data: Optional[Any] = None, - ): - """Send message (back) to websocket client.""" - await self.ws_client.send_str( - await async_json_serializer({"type": msg_type, "id": msg_id, "data": data}) - ) - - async def _handle_mass_event(self, event: str, event_data: Any): - """Broadcast events to connected client.""" - if not self.authenticated: - return - try: - await self._send_json( - msg_type="event", - data={"event": event, "event_data": event_data}, - ) - except ConnectionResetError as exc: - LOGGER.debug("Error while sending message to api client", exc_info=exc) - await self.ws_client.close() diff --git a/music_assistant/web/json_rpc.py b/music_assistant/web/json_rpc.py deleted file mode 100644 index 8c3fbb9a..00000000 --- a/music_assistant/web/json_rpc.py +++ /dev/null @@ -1,63 +0,0 @@ -"""JSON RPC API endpoint (mostly) compatible with LMS.""" - -from aiohttp.web import Request, Response -from music_assistant.helpers.web import require_local_subnet - - -@require_local_subnet -async def json_rpc_endpoint(request: Request): - """ - Implement basic jsonrpc interface compatible with LMS. - - for some compatability with tools that talk to LMS - only support for basic commands - """ - # pylint: disable=too-many-branches - data = await request.json() - params = data["params"] - player_id = params[0] - cmds = params[1] - cmd_str = " ".join(cmds) - if cmd_str == "play": - await request.app["mass"].players.cmd_play(player_id) - elif cmd_str == "pause": - await request.app["mass"].players.cmd_pause(player_id) - elif cmd_str == "stop": - await request.app["mass"].players.cmd_stop(player_id) - elif cmd_str == "next": - await request.app["mass"].players.cmd_next(player_id) - elif cmd_str == "previous": - await request.app["mass"].players.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.cmd_power_on(player_id) - else: - await request.app["mass"].players.cmd_power_off(player_id) - elif cmd_str == "playlist index +1": - await request.app["mass"].players.cmd_next(player_id) - elif cmd_str == "playlist index -1": - await request.app["mass"].players.cmd_previous(player_id) - elif "mixer volume" in cmd_str and "+" in cmds[2]: - player = request.app["mass"].players.get_player(player_id) - volume_level = player.volume_level + int(cmds[2].split("+")[1]) - await request.app["mass"].players.cmd_volume_set(player_id, volume_level) - elif "mixer volume" in cmd_str and "-" in cmds[2]: - player = request.app["mass"].players.get_player(player_id) - volume_level = player.volume_level - int(cmds[2].split("-")[1]) - await request.app["mass"].players.cmd_volume_set(player_id, volume_level) - elif "mixer volume" in cmd_str: - await request.app["mass"].players.cmd_volume_set(player_id, cmds[2]) - elif cmd_str == "mixer muting 1": - await request.app["mass"].players.cmd_volume_mute(player_id, True) - elif cmd_str == "mixer muting 0": - await request.app["mass"].players.cmd_volume_mute(player_id, False) - elif cmd_str == "button volup": - await request.app["mass"].players.cmd_volume_up(player_id) - elif cmd_str == "button voldown": - await request.app["mass"].players.cmd_volume_down(player_id) - elif cmd_str == "button power": - await request.app["mass"].players.cmd_power_toggle(player_id) - else: - return Response(text="command not supported") - return Response(text="success") diff --git a/music_assistant/web/stream.py b/music_assistant/web/stream.py deleted file mode 100644 index 9e6e7c0f..00000000 --- a/music_assistant/web/stream.py +++ /dev/null @@ -1,413 +0,0 @@ -""" -StreamManager: handles all audio streaming to players. - -Either by sending tracks one by one or send one continuous stream -of music with crossfade/gapless support (queue stream). - -All audio is processed by SoX and/or ffmpeg, using various subprocess streams. -""" - -import asyncio -import logging -from typing import AsyncGenerator, Optional, Tuple - -from aiohttp.web import Request, Response, RouteTableDef, StreamResponse -from aiohttp.web_exceptions import HTTPNotFound -from music_assistant.constants import ( - CONF_MAX_SAMPLE_RATE, - EVENT_STREAM_ENDED, - EVENT_STREAM_STARTED, -) -from music_assistant.helpers.audio import ( - analyze_audio, - crossfade_pcm_parts, - get_sox_args, - get_stream_details, - strip_silence, -) -from music_assistant.helpers.process import AsyncProcess -from music_assistant.helpers.typing import MusicAssistant -from music_assistant.helpers.util import create_task -from music_assistant.helpers.web import require_local_subnet -from music_assistant.models.player_queue import PlayerQueue -from music_assistant.models.streamdetails import ContentType, StreamDetails - -routes = RouteTableDef() - -LOGGER = logging.getLogger("stream") - - -@routes.get("/stream/queue/{player_id}") -@require_local_subnet -async def stream_queue(request: Request): - """Stream all items in player's queue as continuous stream in FLAC audio format.""" - mass: MusicAssistant = request.app["mass"] - player_id = request.match_info["player_id"] - player_queue = mass.players.get_player_queue(player_id) - if not player_queue: - raise HTTPNotFound(reason="invalid player_id") - - # prepare request - resp = StreamResponse( - status=200, reason="OK", headers={"Content-Type": "audio/flac"} - ) - await resp.prepare(request) - - player_conf = player_queue.player.config - # determine sample rate and pcm format for the queue stream, depending on player capabilities - player_max_sample_rate = player_conf.get(CONF_MAX_SAMPLE_RATE, 48000) - sample_rate = min(player_max_sample_rate, 96000) - if player_max_sample_rate > 96000: - # assume that highest possible quality is needed - # if player supports sample rates > 96000 - # we use float64 PCM format internally which is heavy on CPU - pcm_format = ContentType.PCM_F64LE - elif sample_rate > 48000: - # prefer internal PCM_S32LE format - pcm_format = ContentType.PCM_S32LE - else: - # fallback to 24 bits - pcm_format = ContentType.PCM_S24LE - - args = [ - "sox", - "-t", - pcm_format.sox_format(), - "-c", - "2", - "-r", - str(sample_rate), - "-", - "-t", - "flac", - "-", - ] - async with AsyncProcess(args, enable_write=True) as sox_proc: - - LOGGER.info( - "Start Queue Stream for player %s", - player_queue.player.name, - ) - - # feed stdin with pcm samples - async def fill_buffer(): - """Feed audio data into sox stdin for processing.""" - async for audio_chunk in get_pcm_queue_stream( - mass, player_queue, sample_rate, pcm_format - ): - await sox_proc.write(audio_chunk) - del audio_chunk - - fill_buffer_task = create_task(fill_buffer()) - - # start delivering audio chunks - try: - async for audio_chunk in sox_proc.iterate_chunks(None): - await resp.write(audio_chunk) - except (asyncio.CancelledError, GeneratorExit) as err: - LOGGER.debug( - "Queue stream aborted for: %s", - player_queue.player.name, - ) - fill_buffer_task.cancel() - raise err - else: - LOGGER.debug( - "Queue stream finished for: %s", - player_queue.player.name, - ) - return resp - - -@routes.get("/stream/queue/{player_id}/{queue_item_id}") -@require_local_subnet -async def stream_single_queue_item(request: Request): - """Stream a single queue item.""" - mass: MusicAssistant = request.app["mass"] - player_id = request.match_info["player_id"] - queue_item_id = request.match_info["queue_item_id"] - player_queue = mass.players.get_player_queue(player_id) - if not player_queue: - raise HTTPNotFound(reason="invalid player_id") - if player_queue.use_queue_stream: - # redirect request if player switched to queue streaming - return await stream_queue(request) - LOGGER.debug("Stream request for %s", player_queue.player.name) - - queue_item = player_queue.by_item_id(queue_item_id) - if not queue_item: - raise HTTPNotFound(reason="invalid queue_item_id") - - streamdetails = await get_stream_details(mass, queue_item, player_id) - - # prepare request - resp = StreamResponse( - status=200, - reason="OK", - headers={"Content-Type": "audio/flac"}, - ) - await resp.prepare(request) - - # start streaming - LOGGER.debug( - "Start streaming %s (%s) on player %s", - queue_item_id, - queue_item.name, - player_queue.player.name, - ) - - async for _, audio_chunk in get_media_stream(mass, streamdetails, ContentType.FLAC): - await resp.write(audio_chunk) - del audio_chunk - LOGGER.debug( - "Finished streaming %s (%s) on player %s", - queue_item_id, - queue_item.name, - player_queue.player.name, - ) - - return resp - - -@routes.get("/stream/group/{group_player_id}") -@require_local_subnet -async def stream_group(request: Request): - """Handle streaming to all players of a group. Highly experimental.""" - group_player_id = request.match_info["group_player_id"] - if not request.app["mass"].players.get_player_queue(group_player_id): - return Response(text="invalid player id", status=404) - child_player_id = request.rel_url.query.get("player_id", request.remote) - - # prepare request - resp = StreamResponse( - status=200, reason="OK", headers={"Content-Type": "audio/flac"} - ) - await resp.prepare(request) - - # stream queue - player = request.app["mass"].players.get_player(group_player_id) - async for audio_chunk in player.subscribe_stream_client(child_player_id): - await resp.write(audio_chunk) - return resp - - -async def get_media_stream( - mass: MusicAssistant, - streamdetails: StreamDetails, - output_format: Optional[ContentType] = None, - resample: Optional[int] = None, - chunk_size: Optional[int] = None, -) -> AsyncGenerator[Tuple[bool, bytes], None]: - """Get the audio stream for the given streamdetails.""" - - mass.eventbus.signal(EVENT_STREAM_STARTED, streamdetails) - args = get_sox_args(streamdetails, output_format, resample) - async with AsyncProcess(args) as sox_proc: - - LOGGER.debug( - "start media stream for: %s/%s (%s)", - streamdetails.provider, - streamdetails.item_id, - streamdetails.type, - ) - - # yield chunks from stdout - # we keep 1 chunk behind to detect end of stream properly - try: - prev_chunk = b"" - async for chunk in sox_proc.iterate_chunks(chunk_size): - if prev_chunk: - yield (False, prev_chunk) - prev_chunk = chunk - # send last chunk - yield (True, prev_chunk) - except (asyncio.CancelledError, GeneratorExit) as err: - LOGGER.debug( - "media stream aborted for: %s/%s", - streamdetails.provider, - streamdetails.item_id, - ) - raise err - else: - LOGGER.debug( - "finished media stream for: %s/%s", - streamdetails.provider, - streamdetails.item_id, - ) - await mass.database.mark_item_played( - streamdetails.item_id, streamdetails.provider - ) - finally: - mass.eventbus.signal(EVENT_STREAM_ENDED, streamdetails) - # send analyze job to background worker - if streamdetails.loudness is None: - uri = f"{streamdetails.provider}://{streamdetails.media_type.value}/{streamdetails.item_id}" - mass.tasks.add( - f"Analyze audio for {uri}", analyze_audio(mass, streamdetails) - ) - - -async def get_pcm_queue_stream( - mass: MusicAssistant, - player_queue: PlayerQueue, - sample_rate, - pcm_format: ContentType, - channels: int = 2, -) -> AsyncGenerator[bytes, None]: - """Stream the PlayerQueue's tracks as constant feed in PCM raw audio.""" - last_fadeout_data = b"" - queue_index = None - # get crossfade details - fade_length = player_queue.crossfade_duration - if pcm_format == ContentType.PCM_F64LE: - bit_depth = 64 - elif pcm_format in [ContentType.PCM_F32LE, ContentType.PCM_S32LE]: - bit_depth = 32 - elif pcm_format == ContentType.PCM_S24LE: - bit_depth = 24 - else: - bit_depth = 16 - pcm_args = [pcm_format.sox_format(), "-c", "2", "-r", str(sample_rate)] - sample_size = int(sample_rate * (bit_depth / 8) * channels) # 1 second - buffer_size = sample_size * fade_length if fade_length else sample_size * 10 - # stream queue tracks one by one - while True: - # get the (next) track in queue - if queue_index is None: - # report start of queue playback so we can calculate current track/duration etc. - queue_index = await player_queue.queue_stream_start() - else: - queue_index = await player_queue.queue_stream_next(queue_index) - queue_track = player_queue.get_item(queue_index) - if not queue_track: - LOGGER.debug("no (more) tracks in queue") - break - - # get streamdetails - streamdetails = await get_stream_details( - mass, queue_track, player_queue.queue_id - ) - - LOGGER.debug( - "Start Streaming queue track: %s (%s) for player %s", - queue_track.item_id, - queue_track.name, - player_queue.player.name, - ) - fade_in_part = b"" - cur_chunk = 0 - prev_chunk = None - bytes_written = 0 - # handle incoming audio chunks - async for is_last_chunk, chunk in get_media_stream( - mass, - streamdetails, - pcm_format, - resample=sample_rate, - chunk_size=buffer_size, - ): - cur_chunk += 1 - - # HANDLE FIRST PART OF TRACK - if not chunk and bytes_written == 0: - # stream error: got empy first chunk - LOGGER.error("Stream error on track %s", queue_track.item_id) - # prevent player queue get stuck by just skipping to the next track - queue_track.duration = 0 - continue - if cur_chunk <= 2 and not last_fadeout_data: - # no fadeout_part available so just pass it to the output directly - yield chunk - bytes_written += len(chunk) - del chunk - elif cur_chunk == 1 and last_fadeout_data: - prev_chunk = chunk - del chunk - # HANDLE CROSSFADE OF PREVIOUS TRACK FADE_OUT AND THIS TRACK FADE_IN - elif cur_chunk == 2 and last_fadeout_data: - # combine the first 2 chunks and strip off silence - first_part = await strip_silence(prev_chunk + chunk, pcm_args) - if len(first_part) < buffer_size: - # part is too short after the strip action?! - # so we just use the full first part - first_part = prev_chunk + chunk - fade_in_part = first_part[:buffer_size] - remaining_bytes = first_part[buffer_size:] - del first_part - # do crossfade - crossfade_part = await crossfade_pcm_parts( - fade_in_part, last_fadeout_data, pcm_args, fade_length - ) - # send crossfade_part - yield crossfade_part - bytes_written += len(crossfade_part) - del crossfade_part - del fade_in_part - last_fadeout_data = b"" - # also write the leftover bytes from the strip action - yield remaining_bytes - bytes_written += len(remaining_bytes) - del remaining_bytes - del chunk - prev_chunk = None # needed to prevent this chunk being sent again - # HANDLE LAST PART OF TRACK - elif prev_chunk and is_last_chunk: - # last chunk received so create the last_part - # with the previous chunk and this chunk - # and strip off silence - last_part = await strip_silence(prev_chunk + chunk, pcm_args, True) - if len(last_part) < buffer_size: - # part is too short after the strip action - # so we just use the entire original data - last_part = prev_chunk + chunk - if not player_queue.crossfade_enabled or len(last_part) < buffer_size: - # crossfading is not enabled or not enough data, - # so just pass the (stripped) audio data - if not player_queue.crossfade_enabled: - LOGGER.warning( - "Not enough data for crossfade: %s", len(last_part) - ) - - yield last_part - bytes_written += len(last_part) - del last_part - del chunk - else: - # handle crossfading support - # store fade section to be picked up for next track - last_fadeout_data = last_part[-buffer_size:] - remaining_bytes = last_part[:-buffer_size] - # write remaining bytes - if remaining_bytes: - yield remaining_bytes - bytes_written += len(remaining_bytes) - del last_part - del remaining_bytes - del chunk - # MIDDLE PARTS OF TRACK - else: - # middle part of the track - # keep previous chunk in memory so we have enough - # samples to perform the crossfade - if prev_chunk: - yield prev_chunk - bytes_written += len(prev_chunk) - prev_chunk = chunk - else: - prev_chunk = chunk - del chunk - # end of the track reached - # update actual duration to the queue for more accurate now playing info - accurate_duration = bytes_written / sample_size - queue_track.duration = accurate_duration - LOGGER.debug( - "Finished Streaming queue track: %s (%s) on queue %s", - queue_track.item_id, - queue_track.name, - player_queue.player.name, - ) - # end of queue reached, pass last fadeout bits to final output - if last_fadeout_data: - yield last_fadeout_data - del last_fadeout_data - # END OF QUEUE STREAM diff --git a/requirements.txt b/requirements.txt index 199c245b..3551c6ab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,23 +1,16 @@ -argparse==1.4.0 -async-timeout==4.0.2 -pychromecast==10.3.0 -aiohttp[speedups]==3.8.1 -asyncio-throttle==1.0.2 -aiofiles==0.8.0 -aiosqlite==0.17.0 -pytaglib==1.5.0 -python-slugify==6.1.1 -memory-tempfile==2.2.3 -aiorun==2021.10.1 -soco==0.27.1 -pillow==9.0.1 -aiohttp_cors==0.7.0 -unidecode==1.3.4 -PyJWT==2.3.0 -zeroconf==0.38.4 -passlib==1.7.4 -cryptography==36.0.2 -ujson==5.1.0 -mashumaro==3.0 -typing-inspect==0.6.0; python_version < '3.8' -uvloop==0.16.0; sys_platform != 'win32' +argparse>=1.3.0,<=1.4 +async-timeout>=3.0,<=4.0.2 +aiohttp[speedups]>=3.7.0 +asyncio-throttle>=1.0,<=1.0.2 +aiofiles>=0.7,<=0.8.0 +databases>=0.5,<=0.5.5 +aiosqlite>=0.13,<=0.17 +pytaglib>=1.4,<=1.5 +python-slugify>=4.0,<=6.1.1 +memory-tempfile<=2.2.3 +aiorun>=2021.10,<=2021.10.1 +pillow>=8.0,<=9.0.1 +unidecode>=1.0,<=1.3.4 +ujson>=4.0,<=5.1.0 +mashumaro>=3.0,<=3.1 +uvloop>=0.15.0; sys_platform != 'win32' diff --git a/setup.py b/setup.py index 8db38b6b..6ff5766d 100644 --- a/setup.py +++ b/setup.py @@ -4,10 +4,10 @@ from pathlib import Path from setuptools import find_packages, setup -import music_assistant.constants as mass_const - PROJECT_NAME = "Music Assistant" PROJECT_PACKAGE_NAME = "music_assistant" +PROJECT_VERSION = "1.0.0" +PROJECT_REQ_PYTHON_VERSION = "3.9" PROJECT_LICENSE = "Apache License 2.0" PROJECT_AUTHOR = "Marcel van der Veldt" PROJECT_URL = "https://music-assistant.github.io/" @@ -20,7 +20,7 @@ PYPI_URL = f"https://pypi.python.org/pypi/{PROJECT_PACKAGE_NAME}" GITHUB_PATH = f"{PROJECT_GITHUB_USERNAME}/{PROJECT_GITHUB_REPOSITORY}" GITHUB_URL = f"https://github.com/{GITHUB_PATH}" -DOWNLOAD_URL = f"{GITHUB_URL}/archive/{mass_const.__version__}.zip" +DOWNLOAD_URL = f"{GITHUB_URL}/archive/{PROJECT_VERSION}.zip" PROJECT_URLS = { "Bug Reports": f"{GITHUB_URL}/issues", "Website": "https://music-assistant.github.io/", @@ -41,7 +41,7 @@ if os.name != "nt": setup( name=PROJECT_PACKAGE_NAME, - version=mass_const.__version__, + version=PROJECT_VERSION, url=PROJECT_URL, download_url=DOWNLOAD_URL, project_urls=PROJECT_URLS, @@ -53,7 +53,7 @@ setup( include_package_data=True, zip_safe=False, install_requires=REQUIRES, - python_requires=f">={mass_const.REQUIRED_PYTHON_VER}", + python_requires=f">={PROJECT_REQ_PYTHON_VERSION}", test_suite="tests", entry_points={ "console_scripts": [ diff --git a/tox.ini b/tox.ini index 60d1182c..b05bbca5 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,11 @@ [tox] -envlist = py37, py38, lint, mypy +envlist = py38, py310, lint, mypy skip_missing_interpreters = True [gh-actions] python = - 3.7: py37, lint, mypy - 3.8: py38 + 3.9: py39, lint, mypy + 3.10: py310 [testenv:lint] basepython = python3