From b54bd9c513d1f8920ffd07fce854f3871997a9b4 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 5 Apr 2022 00:38:11 +0200 Subject: [PATCH] Refactor into standalone library (#238) * drop authentication * simplify logging * refactor database * improve streaming stability * drop player providers from main library --- .vscode/launch.json | 30 - examples/full.py | 193 ++++ examples/simple.py | 84 ++ music_assistant.code-workspace | 10 - music_assistant/__init__.py | 4 +- music_assistant/__main__.py | 71 -- music_assistant/constants.py | 72 +- .../controllers/metadata/__init__.py | 73 ++ .../metadata/fanarttv.py} | 67 +- .../metadata}/musicbrainz.py | 47 +- music_assistant/controllers/music/__init__.py | 498 +++++++++ music_assistant/controllers/music/albums.py | 263 +++++ music_assistant/controllers/music/artists.py | 269 +++++ .../controllers/music/playlists.py | 292 ++++++ music_assistant/controllers/music/radio.py | 88 ++ music_assistant/controllers/music/tracks.py | 243 +++++ music_assistant/controllers/players.py | 100 ++ music_assistant/controllers/stream.py | 465 +++++++++ music_assistant/helpers/audio.py | 342 ++++++- music_assistant/helpers/cache.py | 154 +-- music_assistant/helpers/compare.py | 22 +- music_assistant/helpers/database.py | 125 +++ music_assistant/helpers/encryption.py | 60 -- music_assistant/helpers/errors.py | 5 - music_assistant/helpers/images.py | 80 +- music_assistant/helpers/json.py | 43 + music_assistant/helpers/logger.py | 78 -- music_assistant/helpers/migration.py | 209 ---- music_assistant/helpers/muli_state_queue.py | 66 -- music_assistant/helpers/process.py | 38 +- music_assistant/helpers/typing.py | 27 +- music_assistant/helpers/util.py | 141 +-- music_assistant/helpers/web.py | 213 ---- music_assistant/managers/__init__.py | 1 - music_assistant/managers/config.py | 709 ------------- music_assistant/managers/database.py | 969 ------------------ music_assistant/managers/events.py | 54 - music_assistant/managers/library.py | 440 -------- music_assistant/managers/metadata.py | 52 - music_assistant/managers/music.py | 890 ---------------- music_assistant/managers/players.py | 845 --------------- music_assistant/managers/tasks.py | 186 ---- music_assistant/mass.py | 381 +++---- music_assistant/models/__init__.py | 2 +- music_assistant/models/config_entry.py | 54 - music_assistant/models/errors.py | 33 + music_assistant/models/media_controller.py | 185 ++++ music_assistant/models/media_items.py | 308 ++++++ music_assistant/models/media_types.py | 274 ----- music_assistant/models/player.py | 612 ++++------- music_assistant/models/player_queue.py | 875 ++++++++-------- music_assistant/models/provider.py | 177 ++-- music_assistant/models/streamdetails.py | 75 -- music_assistant/providers/__init__.py | 2 +- .../providers/builtin_player/__init__.py | 252 ----- .../providers/builtin_player/icon.png | Bin 15778 -> 0 bytes .../providers/chromecast/__init__.py | 110 -- music_assistant/providers/chromecast/const.py | 9 - .../providers/chromecast/helpers.py | 142 --- music_assistant/providers/chromecast/icon.png | Bin 2432 -> 0 bytes .../providers/chromecast/player.py | 446 -------- music_assistant/providers/fanarttv/icon.png | Bin 5877 -> 0 bytes music_assistant/providers/file/icon.png | Bin 9623 -> 0 bytes .../{file/__init__.py => filesystem.py} | 218 ++-- .../providers/{qobuz/__init__.py => qobuz.py} | 235 ++--- music_assistant/providers/qobuz/icon.png | Bin 11363 -> 0 bytes music_assistant/providers/sonos/__init__.py | 9 - music_assistant/providers/sonos/icon.png | Bin 40528 -> 0 bytes music_assistant/providers/sonos/sonos.py | 420 -------- music_assistant/providers/spotify/__init__.py | 240 ++--- music_assistant/providers/spotify/icon.png | Bin 20017 -> 0 bytes .../providers/squeezebox/__init__.py | 342 ------- .../providers/squeezebox/constants.py | 4 - .../providers/squeezebox/discovery.py | 194 ---- music_assistant/providers/squeezebox/icon.png | Bin 20186 -> 0 bytes .../providers/squeezebox/socket_client.py | 660 ------------ .../{tunein/__init__.py => tunein.py} | 114 +-- music_assistant/providers/tunein/icon.png | Bin 23800 -> 0 bytes .../providers/universal_group/__init__.py | 489 --------- .../providers/universal_group/icon.png | Bin 15778 -> 0 bytes music_assistant/resources/announce.flac | Bin 59310 -> 0 bytes music_assistant/resources/silence.flac | Bin 424 -> 0 bytes music_assistant/resources/strings/en.json | 45 - music_assistant/resources/strings/nl.json | 45 - music_assistant/web/__init__.py | 269 ----- music_assistant/web/api.py | 251 ----- music_assistant/web/json_rpc.py | 63 -- music_assistant/web/stream.py | 413 -------- requirements.txt | 39 +- setup.py | 10 +- tox.ini | 6 +- 91 files changed, 4895 insertions(+), 11726 deletions(-) delete mode 100644 .vscode/launch.json create mode 100644 examples/full.py create mode 100644 examples/simple.py delete mode 100644 music_assistant.code-workspace delete mode 100755 music_assistant/__main__.py create mode 100755 music_assistant/controllers/metadata/__init__.py rename music_assistant/{providers/fanarttv/__init__.py => controllers/metadata/fanarttv.py} (54%) rename music_assistant/{helpers => controllers/metadata}/musicbrainz.py (83%) create mode 100755 music_assistant/controllers/music/__init__.py create mode 100644 music_assistant/controllers/music/albums.py create mode 100644 music_assistant/controllers/music/artists.py create mode 100644 music_assistant/controllers/music/playlists.py create mode 100644 music_assistant/controllers/music/radio.py create mode 100644 music_assistant/controllers/music/tracks.py create mode 100755 music_assistant/controllers/players.py create mode 100644 music_assistant/controllers/stream.py create mode 100755 music_assistant/helpers/database.py delete mode 100644 music_assistant/helpers/encryption.py delete mode 100644 music_assistant/helpers/errors.py create mode 100644 music_assistant/helpers/json.py delete mode 100644 music_assistant/helpers/logger.py delete mode 100644 music_assistant/helpers/migration.py delete mode 100644 music_assistant/helpers/muli_state_queue.py delete mode 100644 music_assistant/helpers/web.py delete mode 100644 music_assistant/managers/__init__.py delete mode 100755 music_assistant/managers/config.py delete mode 100755 music_assistant/managers/database.py delete mode 100644 music_assistant/managers/events.py delete mode 100755 music_assistant/managers/library.py delete mode 100755 music_assistant/managers/metadata.py delete mode 100755 music_assistant/managers/music.py delete mode 100755 music_assistant/managers/players.py delete mode 100644 music_assistant/managers/tasks.py delete mode 100644 music_assistant/models/config_entry.py create mode 100644 music_assistant/models/errors.py create mode 100644 music_assistant/models/media_controller.py create mode 100755 music_assistant/models/media_items.py delete mode 100755 music_assistant/models/media_types.py mode change 100755 => 100644 music_assistant/models/player_queue.py delete mode 100644 music_assistant/models/streamdetails.py delete mode 100644 music_assistant/providers/builtin_player/__init__.py delete mode 100644 music_assistant/providers/builtin_player/icon.png delete mode 100644 music_assistant/providers/chromecast/__init__.py delete mode 100644 music_assistant/providers/chromecast/const.py delete mode 100644 music_assistant/providers/chromecast/helpers.py delete mode 100644 music_assistant/providers/chromecast/icon.png delete mode 100644 music_assistant/providers/chromecast/player.py delete mode 100644 music_assistant/providers/fanarttv/icon.png delete mode 100644 music_assistant/providers/file/icon.png rename music_assistant/providers/{file/__init__.py => filesystem.py} (69%) rename music_assistant/providers/{qobuz/__init__.py => qobuz.py} (82%) delete mode 100644 music_assistant/providers/qobuz/icon.png delete mode 100644 music_assistant/providers/sonos/__init__.py delete mode 100644 music_assistant/providers/sonos/icon.png delete mode 100644 music_assistant/providers/sonos/sonos.py delete mode 100644 music_assistant/providers/spotify/icon.png delete mode 100644 music_assistant/providers/squeezebox/__init__.py delete mode 100644 music_assistant/providers/squeezebox/constants.py delete mode 100644 music_assistant/providers/squeezebox/discovery.py delete mode 100644 music_assistant/providers/squeezebox/icon.png delete mode 100644 music_assistant/providers/squeezebox/socket_client.py rename music_assistant/providers/{tunein/__init__.py => tunein.py} (62%) delete mode 100644 music_assistant/providers/tunein/icon.png delete mode 100644 music_assistant/providers/universal_group/__init__.py delete mode 100644 music_assistant/providers/universal_group/icon.png delete mode 100644 music_assistant/resources/announce.flac delete mode 100644 music_assistant/resources/silence.flac delete mode 100644 music_assistant/resources/strings/en.json delete mode 100644 music_assistant/resources/strings/nl.json delete mode 100755 music_assistant/web/__init__.py delete mode 100644 music_assistant/web/api.py delete mode 100644 music_assistant/web/json_rpc.py delete mode 100644 music_assistant/web/stream.py 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/providers/fanarttv/__init__.py b/music_assistant/controllers/metadata/fanarttv.py similarity index 54% rename from music_assistant/providers/fanarttv/__init__.py rename to music_assistant/controllers/metadata/fanarttv.py index 3856e07c..a5c9c5fc 100755 --- a/music_assistant/providers/fanarttv/__init__.py +++ b/music_assistant/controllers/metadata/fanarttv.py @@ -1,66 +1,31 @@ """FanartTv Metadata provider.""" -import logging from json.decoder import JSONDecodeError -from typing import Dict, List +from typing import Dict 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" +from music_assistant.helpers.typing import MusicAssistant -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) +# TODO: add support for personal api keys ? +# TODO: Add support for album artwork ? -class FanartTvProvider(MetadataProvider): +class FanartTv: """Fanart.tv metadata provider.""" - def __init__(self, mass): + 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 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) + data = await self._get_data(f"music/{mb_artist_id}") if data: if data.get("hdmusiclogo"): metadata["logo"] = data["hdmusiclogo"][0]["url"] @@ -69,7 +34,7 @@ class FanartTvProvider(MetadataProvider): if data.get("artistbackground"): count = 0 for item in data["artistbackground"]: - key = "fanart" if count == 0 else "fanart.%s" % count + key = "fanart" if count == 0 else f"fanart.{count}" metadata[key] = item["url"] if data.get("artistthumb"): url = data["artistthumb"][0]["url"] @@ -83,7 +48,7 @@ class FanartTvProvider(MetadataProvider): """Get data from api.""" if params is None: params = {} - url = "http://webservice.fanart.tv/v3/%s" % endpoint + url = f"http://webservice.fanart.tv/v3/{endpoint}" params["api_key"] = "639191cb0774661597f28a47e7e2bad5" async with self.throttler: async with self.mass.http_session.get( @@ -92,17 +57,17 @@ class FanartTvProvider(MetadataProvider): try: result = await response.json() except ( - aiohttp.client_exceptions.ContentTypeError, + aiohttp.ContentTypeError, JSONDecodeError, ): - LOGGER.error("Failed to retrieve %s", endpoint) + self.logger.error("Failed to retrieve %s", endpoint) text_result = await response.text() - LOGGER.debug(text_result) + self.logger.debug(text_result) return None - except aiohttp.client_exceptions.ClientConnectorError: - LOGGER.error("Failed to retrieve %s", endpoint) + except aiohttp.ClientConnectorError: + self.logger.error("Failed to retrieve %s", endpoint) return None if "error" in result and "limit" in result["error"]: - LOGGER.error(result["error"]) + self.logger.error(result["error"]) return None return result diff --git a/music_assistant/helpers/musicbrainz.py b/music_assistant/controllers/metadata/musicbrainz.py similarity index 83% rename from music_assistant/helpers/musicbrainz.py rename to music_assistant/controllers/metadata/musicbrainz.py index 5660d414..57b32e37 100644 --- a/music_assistant/helpers/musicbrainz.py +++ b/music_assistant/controllers/metadata/musicbrainz.py @@ -1,27 +1,27 @@ """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.cache import cached from music_assistant.helpers.compare import compare_strings, get_compare_string +from music_assistant.helpers.typing import MusicAssistant LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])' -LOGGER = logging.getLogger("musicbrainz") - class MusicBrainz: """Handle getting Id's from MusicBrainz.""" - def __init__(self, mass): + 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( @@ -33,7 +33,7 @@ class MusicBrainz: track_isrc=None, ): """Retrieve musicbrainz artist id for the given details.""" - LOGGER.debug( + self.logger.debug( "searching musicbrainz for %s \ (albumname: %s - album_upc: %s - trackname: %s - track_isrc: %s)", artistname, @@ -48,7 +48,7 @@ class MusicBrainz: artistname, None, album_upc ) if mb_artist_id: - LOGGER.debug( + self.logger.debug( "Got MusicbrainzArtistId for %s after search on upc %s --> %s", artistname, album_upc, @@ -59,7 +59,7 @@ class MusicBrainz: artistname, None, track_isrc ) if mb_artist_id: - LOGGER.debug( + self.logger.debug( "Got MusicbrainzArtistId for %s after search on isrc %s --> %s", artistname, track_isrc, @@ -68,7 +68,7 @@ class MusicBrainz: if not mb_artist_id and albumname: mb_artist_id = await self.search_artist_by_album(artistname, albumname) if mb_artist_id: - LOGGER.debug( + self.logger.debug( "Got MusicbrainzArtistId for %s after search on albumname %s --> %s", artistname, albumname, @@ -77,7 +77,7 @@ class MusicBrainz: if not mb_artist_id and trackname: mb_artist_id = await self.search_artist_by_track(artistname, trackname) if mb_artist_id: - LOGGER.debug( + self.logger.debug( "Got MusicbrainzArtistId for %s after search on trackname %s --> %s", artistname, trackname, @@ -93,15 +93,18 @@ class MusicBrainz: ]: if album_upc: endpoint = "release" - params = {"query": "barcode:%s" % album_upc} + 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": 'artist:"%s" AND release:"%s"' - % (searchartist, searchalbum) + "query": f'artist:"{searchartist}" AND release:"{searchalbum}"' } - result = await self.get_data(endpoint, params) + 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"]: @@ -124,15 +127,18 @@ class MusicBrainz: """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 + 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": '"%s" AND artist:"%s"' % (searchtrack, searchartist)} - result = await self.get_data(endpoint, params) + 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"]: @@ -151,12 +157,11 @@ class MusicBrainz: 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 + 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: @@ -170,6 +175,6 @@ class MusicBrainz: JSONDecodeError, ) as exc: msg = await response.text() - LOGGER.error("%s - %s", str(exc), msg) + 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/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 092121e1e21e1d799b1de9ef58ca6914c4e14096..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15778 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 z 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 e7372ee1fd0786ee7bbffeffeb54ae15784491c3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2432 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 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/icon.png b/music_assistant/providers/fanarttv/icon.png deleted file mode 100644 index 17b39a4c7ea43a69790c4a2e1ea16e08c3064b9c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5877 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 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] + def __init__(self, music_dir: str, playlist_dir: str | None = None) -> None: + """ + Initialize the Filesystem provider. - 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 + music_dir: Directory on disk containing music files + playlist_dir: Directory on disk containing playlist files (optional) - async def on_stop(self): - """Handle correct close/cleanup of the provider on exit.""" - # nothing to be done + """ + 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 - ) -> SearchResult: + ) -> List[MediaItemType]: """ Perform search on musicprovider. @@ -115,14 +78,13 @@ class FileProvider(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() # TODO ! - return result + return [] 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) + self.logger.error("music path does not exist: %s", self._music_dir) return None result = [] for dirname in os.listdir(self._music_dir): @@ -175,12 +137,12 @@ class FileProvider(MusicProvider): 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) + 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=PROV_ID, name=name) - artist.provider_ids.add( - MediaItemProviderId(provider=PROV_ID, item_id=artist.item_id) + 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 @@ -192,17 +154,19 @@ class FileProvider(MusicProvider): 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) + 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] - album = Album(item_id=prov_album_id, provider=PROV_ID) - album.name, album.version = parse_title_and_version(name) + 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 Exception("No album artist ! %s" % artistpath) - album.provider_ids.add( - MediaItemProviderId(provider=PROV_ID, item_id=prov_album_id) + raise InvalidDataError(f"No album artist ! {artistpath}") + album.provider_ids.append( + MediaItemProviderId(provider=self.id, item_id=prov_album_id) ) return album @@ -213,7 +177,7 @@ class FileProvider(MusicProvider): else: itempath = prov_track_id if not os.path.isfile(itempath): - LOGGER.error("track path does not exist: %s", itempath) + self.logger.error("track path does not exist: %s", itempath) return None return await self._parse_track(itempath) @@ -227,15 +191,13 @@ class FileProvider(MusicProvider): "utf-8" ) if not os.path.isfile(itempath): - LOGGER.error("playlist path does not exist: %s", itempath) + self.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", "") + 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.add( - MediaItemProviderId(provider=PROV_ID, item_id=prov_playlist_id) + playlist.provider_ids.append( + MediaItemProviderId(provider=self.id, item_id=prov_playlist_id) ) playlist.owner = "disk" playlist.checksum = os.path.getmtime(itempath) @@ -249,7 +211,7 @@ class FileProvider(MusicProvider): else: albumpath = prov_album_id if not os.path.isdir(albumpath): - LOGGER.error("album path does not exist: %s", albumpath) + self.logger.error("album path does not exist: %s", albumpath) return [] album = await self.get_album(albumpath) for filename in os.listdir(albumpath): @@ -269,11 +231,11 @@ class FileProvider(MusicProvider): else: itempath = prov_playlist_id if not os.path.isfile(itempath): - LOGGER.error("playlist path does not exist: %s", itempath) + self.logger.error("playlist path does not exist: %s", itempath) return result index = 0 - with open(itempath) as _file: - for line in _file.readlines(): + 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) @@ -290,7 +252,7 @@ class FileProvider(MusicProvider): else: artistpath = prov_artist_id if not os.path.isdir(artistpath): - LOGGER.error("artist path does not exist: %s", artistpath) + self.logger.error("artist path does not exist: %s", artistpath) return for dirname in os.listdir(artistpath): dirpath = os.path.join(artistpath, dirname) @@ -317,7 +279,7 @@ class FileProvider(MusicProvider): # TODO: retrieve sanple rate and bitdepth return StreamDetails( type=StreamType.FILE, - provider=PROV_ID, + provider=self.id, item_id=item_id, content_type=ContentType(track_id.split(".")[-1]), path=track_id, @@ -333,17 +295,19 @@ class FileProvider(MusicProvider): 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) + 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 = set() + 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): @@ -351,22 +315,22 @@ class FileProvider(MusicProvider): else: fake_artistpath = os.path.join(self._music_dir, artist_str) artist = Artist( - item_id=fake_artistpath, provider=PROV_ID, name=artist_str + item_id=fake_artistpath, provider=self.id, name=artist_str ) - artist.provider_ids.add( + artist.provider_ids.append( MediaItemProviderId( - provider=PROV_ID, + provider=self.id, item_id=base64.b64encode( fake_artistpath.encode("utf-8") ).decode("utf-8"), ) ) - artists.add(artist) + artists.append(artist) track.artists = artists else: artistpath = filename.rsplit(os.sep, 2)[0] artist = await self.get_artist(artistpath) - track.artists.add(artist) + track.artists.append(artist) if "GENRE" in song.tags: track.metadata["genres"] = song.tags["GENRE"] if "ISRC" in song.tags and song.tags["ISRC"]: @@ -380,26 +344,26 @@ class FileProvider(MusicProvider): quality_details = "" if filename.endswith(".flac"): # TODO: get bit depth - quality = TrackQuality.FLAC_LOSSLESS + quality = MediaQuality.FLAC_LOSSLESS if song.sampleRate > 192000: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4 + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4 elif song.sampleRate > 96000: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3 + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3 elif song.sampleRate > 48000: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2 - quality_details = "%s Khz" % (song.sampleRate / 1000) + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2 + quality_details = f"{song.sampleRate / 1000} Khz" elif filename.endswith(".ogg"): - quality = TrackQuality.LOSSY_OGG - quality_details = "%s kbps" % (song.bitrate) + quality = MediaQuality.LOSSY_OGG + quality_details = f"{song.bitrate} kbps" elif filename.endswith(".m4a"): - quality = TrackQuality.LOSSY_AAC - quality_details = "%s kbps" % (song.bitrate) + quality = MediaQuality.LOSSY_AAC + quality_details = f"{song.bitrate} kbps" else: - quality = TrackQuality.LOSSY_MP3 - quality_details = "%s kbps" % (song.bitrate) - track.provider_ids.add( + quality = MediaQuality.LOSSY_MP3 + quality_details = f"{song.bitrate} kbps" + track.provider_ids.append( MediaItemProviderId( - provider=PROV_ID, + provider=self.id, item_id=prov_item_id, quality=quality, details=quality_details, @@ -412,12 +376,12 @@ class FileProvider(MusicProvider): # 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) + return await self.mass.music.get_item_by_uri(uri) except Exception as exc: - LOGGER.warning("Could not parse uri %s to track: %s", uri, str(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 diff --git a/music_assistant/providers/qobuz/__init__.py b/music_assistant/providers/qobuz.py similarity index 82% rename from music_assistant/providers/qobuz/__init__.py rename to music_assistant/providers/qobuz.py index 6ddf0e86..92d59e39 100644 --- a/music_assistant/providers/qobuz/__init__.py +++ b/music_assistant/providers/qobuz.py @@ -1,107 +1,67 @@ """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.constants import EventType +from music_assistant.helpers.app_vars import ( # pylint: disable=no-name-in-module + get_app_var, ) -from music_assistant.helpers.app_vars import get_app_var # noqa # pylint: disable=all +from music_assistant.models.errors import LoginFailed 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 ( +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 = "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] - + 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.__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) + + 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) ) - return True async def search( self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5 - ) -> SearchResult: + ) -> List[MediaItemType]: """ Perform search on musicprovider. @@ -109,7 +69,7 @@ class QobuzProvider(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 = [] 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 @@ -121,28 +81,27 @@ class QobuzProvider(MusicProvider): params["type"] = "tracks" if media_types[0] == MediaType.PLAYLIST: params["type"] = "playlists" - searchresult = await self._get_data("catalog/search", params) - if searchresult: + if searchresult := await self._get_data("catalog/search", 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"]) @@ -188,10 +147,6 @@ class QobuzProvider(MusicProvider): 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} @@ -377,19 +332,19 @@ class QobuzProvider(MusicProvider): streamdata = result break if not streamdata: - LOGGER.error("Unable to retrieve stream details for track %s", item_id) + 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: - LOGGER.error("Unsupported mime type for track %s", item_id) + self.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, + provider=self.id, path=streamdata["url"], content_type=content_type, sample_rate=int(streamdata["sampling_rate"] * 1000), @@ -397,7 +352,7 @@ class QobuzProvider(MusicProvider): details=streamdata, # we need these details for reporting playback ) - async def mass_event(self, msg, msg_details): + async def on_stream_event(self, msg, msg_details): """ Received event from mass. @@ -408,7 +363,7 @@ class QobuzProvider(MusicProvider): # 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: + 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"] @@ -431,10 +386,8 @@ class QobuzProvider(MusicProvider): } ] await self._post_data("track/reportStreamingStart", data=events) - elif msg == EVENT_STREAM_ENDED and msg_details.provider == PROV_ID: + elif msg == EventType.STREAM_ENDED and msg_details.provider == self.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, @@ -446,10 +399,10 @@ class QobuzProvider(MusicProvider): 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"] + item_id=str(artist_obj["id"]), provider=self.id, name=artist_obj["name"] ) - artist.provider_ids.add( - MediaItemProviderId(provider=PROV_ID, item_id=str(artist_obj["id"])) + 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"): @@ -463,31 +416,34 @@ class QobuzProvider(MusicProvider): 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) + 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 = TrackQuality.FLAC_LOSSLESS_HI_RES_4 + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4 elif album_obj["maximum_sampling_rate"] > 96: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3 + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3 elif album_obj["maximum_sampling_rate"] > 48: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2 + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2 elif album_obj["maximum_bit_depth"] > 16: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_1 + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_1 elif album_obj.get("format_id", 0) == 5: - quality = TrackQuality.LOSSY_AAC + quality = MediaQuality.LOSSY_AAC else: - quality = TrackQuality.FLAC_LOSSLESS - album.provider_ids.add( + quality = MediaQuality.FLAC_LOSSLESS + album.provider_ids.append( MediaItemProviderId( - provider=PROV_ID, + 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"], ) ) - album.name, album.version = parse_title_and_version( - album_obj["title"], album_obj.get("version") - ) + if artist_obj: album.artist = artist_obj else: @@ -532,9 +488,14 @@ class QobuzProvider(MusicProvider): 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=PROV_ID, + provider=self.id, + name=name, + version=version, disc_number=track_obj["media_number"], track_number=track_obj["track_number"], duration=track_obj["duration"], @@ -542,7 +503,7 @@ class QobuzProvider(MusicProvider): 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) + track.artists.append(artist) if not track.artists: # try to grab artist from album if ( @@ -552,21 +513,17 @@ class QobuzProvider(MusicProvider): ): artist = await self._parse_artist(track_obj["album"]["artist"]) if artist: - track.artists.add(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() - artist.name = name - artist.item_id = name - track.artists.add(artist) + artist = Artist(name, self.id, name) + track.artists.append(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: @@ -590,20 +547,20 @@ class QobuzProvider(MusicProvider): 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 + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4 elif track_obj["maximum_sampling_rate"] > 96: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3 + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3 elif track_obj["maximum_sampling_rate"] > 48: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2 + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2 elif track_obj["maximum_bit_depth"] > 16: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_1 + quality = MediaQuality.FLAC_LOSSLESS_HI_RES_1 elif track_obj.get("format_id", 0) == 5: - quality = TrackQuality.LOSSY_AAC + quality = MediaQuality.LOSSY_AAC else: - quality = TrackQuality.FLAC_LOSSLESS - track.provider_ids.add( + quality = MediaQuality.FLAC_LOSSLESS + track.provider_ids.append( MediaItemProviderId( - provider=PROV_ID, + 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', @@ -616,12 +573,12 @@ class QobuzProvider(MusicProvider): """Parse qobuz playlist object to generic layout.""" playlist = Playlist( item_id=str(playlist_obj["id"]), - provider=PROV_ID, + provider=self.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.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"] @@ -638,14 +595,14 @@ class QobuzProvider(MusicProvider): if self.__user_auth_info: return self.__user_auth_info["user_auth_token"] params = { - "username": self.__username, - "password": self.__password, + "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( + self.logger.info( "Succesfully logged in to Qobuz as %s", details["user"]["display_name"] ) return details["user_auth_token"] @@ -675,12 +632,12 @@ class QobuzProvider(MusicProvider): """Get data from api.""" if not params: params = {} - url = "http://www.qobuz.com/api.json/0.2/%s" % endpoint + 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: - LOGGER.debug("Not logged in") + self.logger.debug("Not logged in") return None headers["X-User-Auth-Token"] = auth_token if sign_request: @@ -688,7 +645,7 @@ class QobuzProvider(MusicProvider): keys = list(params.keys()) keys.sort() for key in keys: - signing_data += "%s%s" % (key, params[key]) + 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()) @@ -704,7 +661,7 @@ class QobuzProvider(MusicProvider): if "error" in result or ( "status" in result and "error" in result["status"] ): - LOGGER.error("%s - %s", endpoint, result) + self.logger.error("%s - %s", endpoint, result) return None return result @@ -714,7 +671,7 @@ class QobuzProvider(MusicProvider): params = {} if not data: data = {} - url = "http://www.qobuz.com/api.json/0.2/%s" % endpoint + 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( @@ -724,7 +681,7 @@ class QobuzProvider(MusicProvider): if "error" in result or ( "status" in result and "error" in result["status"] ): - LOGGER.error("%s - %s", endpoint, result) + self.logger.error("%s - %s", endpoint, result) return None return result diff --git a/music_assistant/providers/qobuz/icon.png b/music_assistant/providers/qobuz/icon.png deleted file mode 100644 index 9d7b726cac2450e4662346df950675be7949cf79..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11363 zcma)i1yCGav-aW=EVu;-!QF!gLU5PG9Tu0xC9qit65L^NhrllGgy62hgCtlWXo9=^ z`+oPkQn%`_x>GgQ^K|z)r_a=!>8GQ#G!*f1sc``S0KT%)J00YC2)U19VeM@1`XO4P?-iGRG;x_KCJXW^u)*v2#S1?io0Fd+-M_#&uysa4gU0vL~#Qmih|Ai1o zUjHNJWn}ml#M@bl(NJBBLC)P1#30Ng#KXrZjmyBmAn9pqC$95O;os`WJ1Is7Z*Q*>OYM9r=52oFB?xsu(zYT z8^b?#t*qUByrmc!|8ewR+kdpv+tKd7J-K=PyIaT(^8WLLm!F4^_rGi-RVDwCitB*9 z++BSBF%NWe^p+No{1^Cti~rl(e`v|MyMjGIUS3EwY5sq!{7>2c#%p>yf{^|D$IXA? z|EKJKzz1@*x?dfQv?B)&fL^}Jo_J20;|Bv{$ElJ*ghUGEYO!P>4@oe$Skn1F;TyVGO=m@I>;T7 zqbgwwC0qPbAX#g$c2y;5Zb!0`eH!Xz?^n#j1M5z4^1@)A2GV@M`63R`3Y zly@c3WjCqi<|lRQ>$CPpBDh2lX!cj!=Mh+5>3qwgteA@~j^jscoo`QUFYT1PAscf|If{nA{qD^*;KVe!$bmbtb|FN2i)MGJ#m3KPzoiZxI*aL$>0 zV~&~7dNIAB0&e)*<4f%r1l**Q8Q_uXEh+$0N^O85( zveSRBNGKWBMWO&WLC5Poq+C!|;t6Fj1PeWnMH22&G*tq6pw1EExRz_Il5e#hGzNZp zeCTVQuCp16knlaE)X>z-Nbd`!O>)$1Ehbm7R9TjMWvW62KvxO(RL9Mg4Db|{kf3?S z21PgWnEHZhSmnV~q4Nfo~` z$^NKuKksd=;KjEbW|{n5&hI|JP9{T^c7)v9!dGCL+@}LF}&Vw$8D>?-{7N{fDVJ=x-Cqr(im3EMg(=d63pQ6%_`j=|I{P~lXIG6&e99vlkyj)Em_T!g{zEUTA3B1@R6`~e# z$^>q&w0YUr`y4EM+x*RBbpRk|*=L&K$_ijc2LJ*`U2J-8Wv1!W1m) zIU&5rlws?ih)pYB;+4+^@HECqq%ODo1{`14rgFIxfG=ms+Y@Zjv5^y&0 zI)%dA-|AO$ViFo2fnc-iivFE4rNFijCv=BNU8@&bU&LBLO^fDOM?sl02-X{eF33Vr zWx&g!U2M~noHZQ+ZI{c=x-x+17@l^vo9{!?>8L0MRA3ln9~J%Y*Q=jYc!ol14?_v` znzE=`kB?Vfth}Aq*3)HWdmxdT7fdODM2?<14TYUVcOfd#c&CE`fr8e@+k=!Q=S3Fh z1%$Hn!=7e73OWgZg=nsZXRkmK%*F>MquqDX{_Yp^RWaV zV<@$Q>r)od4-7%GJf!gD!SF%-7_zA*?OiJRbbppI1-We4+XwgIzvimOc48?!E-awL zp#9rnhXL?x@wT)8vpOjmVu%9J zy8#UPmCgx0CQdSi7S9_+9A0kf=HKdhj&Zk?bhjS&KBGV>kkML1kEAM-8TL}f0vQmH zU@ws|(F{2iAshoBk2k+l?DK;~ZpJJg>%Wn-d~_U!y!)ftMxyWuAAOj;!m0Rp0~@Ho zUW5w<#CF1cd;lNGtnV(5_Bwm#$>VI&T2BUPQdD`r3SoqCbW8~M6h3-QNxh!P{_RA) zFW3`udZ1DO(cPP`ajdU)3=BT*!P>KX+$+!5n5cRE(_ik3y${v1apP4Eb|QwenTU&C z0O;_1Lyx?MN>FF68)*8X+p?+kp!!R)B7N}Oe5%kN!TcMw)3ll(kVmOuvx`ySOsbEV z<7mX5pa&18C3Ow{+azrlQ+l+rZ;h!_j!Qm^W*OknK_--4moUfX_g8-!-DzX>n)|E*WKctGZ8Q`iX8x?20t<>&4(4>Vx5xZk9h zZO-xg84v^23V&^Of9n$Z?t$&iNg3R-yD8+=GhBccIyNpJ4OseSM6>L{q|RFYH2>+I z)vVB^_njj>WW)}g@<0bwmC0M!^XffEDVSKE(7U{ylo~=3M4@m;LVMJxJ^t-3q+`f4{NnkZF z^^nK46#0{yrifsCe|e~^Uu6tj5f1mPC9}9O>BtCY zX`d1M+W<@^T9bU~VDW&vqn0Kk`&L+Gg4Cs<=kHvE9^%rRvAb^lI3cd03OqMmSmpINKjGo7N+ z(#El)hZ?WkDQkVXrO(whRE}$tMN+n=M-1efNV&=ZPA1wn{4HroqAGt(JXKC{ZHU;u zn2POlAN`n-n&aSMn4~DZ^m`iz=UF2UrjGubk`9=o0tS{+^PjR^@ZnfkXOER;cXZEs z5hY%S*mYUGLy*XLkiNWt+5Jlw0-`-Rwi45sZwfw5UvClBnGt*$w2gQ(GKX^h0Wif* z{^2na;=xXDJG;RQ?}ZEo-+Q+8UDa1Ps;J$^IqyjS4E^w1b8ULRbgV;P%@Nv;*Ex|7 zWIS6s&sHhl2m@tmIKpn#DY}$PfTOofw6pW=^Yfy4a^G37@C`Dq7=6&f-kU@FWu>CB zJ^B?Bs4-|Y?rLzoqIKDSf_^GJ1! zZ1R&oYqp(!Q0MzJ z5td~yY9775aXrb3h{C$rdCo!(7jxs^!BAZ~-UNl_wOthnO%C)j0r#@Hwd z@x)=*7Yj~5yR)OW&fuqx;(2mQF1!JzC7|R%HTXtrqpX>$rbw*+ZBEAI_(uy+v8SM! zAim-WSlJpTF0xlV`oiHUSv{d_;0 zp;wq`q-;5SZlB-aINlieGXZJ*)}{S=!ajJ(zWq8)8D66DIT}9I3Ro@c@3%jLIjVOV zl~GkgOfP&vrtadea|+Rx6PmpO-aqhmXs8?BT_m%HgJ<4lG2tI2iT zARHU2(`%}Cv|pRVm+2a;9C&692MUMRcp&Ej-l(@Zu{MXn7%`JLO@WQ5~=oedr6h1?fPxv9&J zHjN(2oG-({4K-OBeL*~XF+?`xXUtaJI~}KA6|qGD80F~ty&AFdhy1uf1^#u$x>Rp5 z^MtY#$rR1vX6;ucG3NPz`p8+!D>AdmKU?{xMgxD=*HL1A{urU!G1Bi$hrh^&U;aWK z2im_}fTh-o=4eslKoe6!$PfRMgt}q6>86HwlCx`KYoFqy0TF!8QgxzE;-M+?D1(=g zSnV6kD)*fmsh1u$WEoJ`JTa<@Gtb^@hwI5tv@bPMyK^)~Y$X2-5_xzTpeE+@Ue0vf zl8674P?sNDH|~kogNVcr^ge+Ie3*u5s9p(M%ieT2-ktWO;F|~mmMXReK=9=p=M&&l zWpD^-o>1j=Sl=xk(Rn7HC?RcGZ~HxuJSz`P4Kn_+`3*kbWvoXD+sH~;^MeHTVxr=# zGnhxe@qRZMHD=xT5~gXgsfzJIC~ccUkh|yWJ)2m2@)uqHFqs>eZZk!2^R4Sfa|od} z(S&pRwH4`*_q=8+M_NRt>#V3CURQ)O#HR^wcHpn|5sl+Cl0ZM#McuXYjZOxuzj`8H-Z^Bapkh!0ovJe>H+geXRDnk^yIe8lu6_AFTsoBL>tMJSTWo~)U&(NXe;mjk@pzSdbMXhxBEL#w9E1WC6 zl@+UVg0K`aTi(cbZ~+|0wa;5b2T$L`&aE&!v}i%&dg}u4M}rv+ z&`f!PkWl(tis163MLG*u1%T@+F-v2|OC(2BxO5H~*8l_y`5%eD9Vw9Loiqb;bIp6iPG9rjwPa@>3hJIIS9Pob*xiU6V2R8@}D;SZvwJ?%0U zqr__%zK6;;e#=t5<)aHWJ5yc2%MF2fL6R&;BJIarL~$Q~r6Cs@2EVebEG1-HGJKJ5 zv^!z*01*cl?6@b^y9IiWM}2ra%O625H$eJ(=Vvdfgq;^kEKGkL#LU?&gybW?1aG4A ze3Z)pT3ym`)jaV}H5#ai^1`sUfUSeav|JZ>mRlyY4-22fUs$W5S1FA1p9h5pP)< zRJy||*1S8=mwB|Qx-Q#2VksI zVxbUWgmQGD%IB-gz#tfyU6~8$4W+r&@X6nzNlp-L-XY(86*!YQ^Axgj-x=RG0LJpRL*9hi>q%;iO+}zw^8xu(na@I`&e?CryIwDm*9giWPLrZ|1ht zTgDP^8rECaTU9ET)r0xj+_!79^t|H_7(r2`&lBUaDfxu!yi13(v?Lp9EI&%&o=jOZ z!&k?l@gctYLyATGExRAvqJLG<1(1X^97i+S90HfTYOlardb6X5NHQA-8~zGgd0xf) z(Tb{jkFXd|{DB(s4{mnU0^6;&Eyt{ml(&)Pt}9v99G z7Y1(ybt;=SDc`Kc5V!7AD$)Xy$gH0;oT7=y-Gn*dW+h(o8F~r^unF4i^(YRiy@hg> zxXpZeA)We^M>Fb9MzD?MD#!nx1`BU_DP>JHLDssb)mHasW76QxGz4e73f2=XE06hO zA)TX_i~&K)Mmg0P2*8M@wV4@C;f5#VocpiHN*J;C&pC1N>7}1naJ}^6<{bi=XyF)f z#?>)dODn-?#+h-D7PGEG^;>$#lj)36<7U(DICRv4Qk^1F9h6Idy`DLOsyAmPzv5hzOfam=5 zxv0yJcKv7B9sKN)G&6%^u(VpP0G8$5yZ8r@?fyU9swk>f#aO}-Vp{qUS@~)334mEfJx@u*ymdR6&L|K}0@dmI8n>DiN0dhW{I_1m$79pxuN%_#!geA7i!SNKD{Rh7?>q7LMWAD@Mimg?Ijd5C%TrAqF2C1?& z90I|Ok~{eF@Tn}IlPcTZ!b@NfN7qG5e?Ei5yMRly(jg^1$_VJRHyyhCC-7%;{G61X zduM7A79K8>^ra@5E{~vxOSkqPombsfrIhapvaJm{U+BJb0rke72IcY|v1iX1TD*EK zPhoR7ermk`H^Z@ogqEMQjC=i)YxLoA6Q{qap{(^7``~vBzk+vfe?-O^ze{`Y4z-!X z^t&Nm#7giQ*t(YSbPKdt*D(^SJhaCm$WMm4AEq*@$?zTf4_!uWioY+iJB^ z47xbj5M7tWKXsPzhSV{W<4Xq_GP^w#s%6tv#eJKe(pNex`INdp<4aVGO&PDh-J_=H=Krgr)qF~c3U1=HqYZXV`I@(Q zuG%-j>e4@vpYosPl4@OZUkxJY!>oZo0C4q~CpjqjkOeume z*_r-M=1K%d6YHo|IgakjL)|7b_7X$4YO9C{1zeXVu@m^^a#?W2=KKXEyfx`WFpgKG*L?a89V$hN*eAVN9uKAlVs zdBLqZUCzj8HB!@>dNP^Uo1fDwI<(JNp{Bpc_lQ-+bMBq}`#JxTK047ebM1^%)w7GO z#l<979VY|me%Qs}obq*b%&An3YK0oY?P9zir4U(CVro_V9Tc@j)|j?-e$0YeGSMxQ z2f_gdoR_hlOgreSj871akT+gq62xMI5#%PiyJ4YtRv9wm4vf~o_v zNr#wvG-(r3mYwplwkYKy=)?}wZi{Bb*(WMH#`QrJM}m2ytb=rj&s^GU`n;}kCO;_k zvFnRzBk`J*Oufa~nRoQ5ZPq+ae~qlxyZVTTnZ`3lp5{a3eLBE_wk$)VB(GX1S%BS; zk5SU%b+p?A@3{I~el(7bMn3AH5MzXQu#LS)aFJL(rR^&@HDpjbPWvXK(nO#T8{c#@ zH_A+-Mu&-C$|E-n0|w5>gdAGu=)5$ULZ@--pg#$Lk7G z2Pq0s>k0H9``Y-Tqi#Vqs7&eAwI4k9O8V!mb(E#pjWnx}^xp_tgKSG+KNhwpw<5VjcSol!~Fm^}>qze0LZzT%?a0 z#TxEK_IhD3N7k_NuarwYHw~S0GY=Zkc`+hD@j(j;IhVI@RkrdQB(zrxwB8D`d(>$}sWB63&3U^M%_7ba)UbypxBk*j!Oy&!`r!VY z(pqh)-rDBA`N2}^*96f194@qO(N{JSzLl;QyW^a9@0g6W{5?ODFd$PS`Uq&0CjuT( zs!kO{G@)UzIg64GHpj zE|r){yZDx*Yl{{VyGClt_koeRpDw5X#c7`AP4#}lZvWu za2n#61bk^@X1|yjzBGJhH@8e~Ad%r|N(PudiQd3mEu_A~NyASI&bJ;gHGl5g9VL%N zG!&Nh+FTt|2`VP_D%gqZC@tNHOkm;7basOl7SWduGnRI4u#kGdi52pWwY6s0_JL z+v#82O;0z6FcP~-MyMRU4jdvIzvCntKDiUHsqKsGxwYL5GV^x2L63RH&OU7HF1*~( zPhRYZe(>abmiR|X$uw~ehiIR;>guFjpV6jpI@kgWzWf0|n!XKv=G2L@yNS`?Li7IK7G^JVBCABjEW;U>}kh^1SY&f2>X3z!eIgW*xtbD#$zEEU86Kh^H1& z_g|s_Y|_q#+)jv^_5ElXXXK*!`y;K+&^H}BSKYSKT?^@y2?N0tT+Zlvi zPY-{yH{&+l(PSo{gW{DHOZn1!C0xVv{z*CS7DgtkXNxe`2dyNh;aVVh1J5a`tCFsm zlgKysg~@9jZ1dfipw7ekv1A&z)|YMTq0c5A(ikjHZYs+2RPx^cY5V1Dakp%3Xp}%Z z!{nKmEO~$2*IA^MtlmqAlYhw^Peo5d+jzup6~DG{nyBal2nz&}tNmhNc;OHETw0vK z1fG_sV6N9;s&7%UMSIi^yH>MwKnI9H?yr+kmB`j*@&M?KCK;H3Uc@Bcl$r&Zc5{wx zDT5U_flkU82&`heTJ@SzH<-&t(g<8_Xwj0E4nw327EiZ}!k-&gevy@MfNSqj5<_Z% zXPP;pNs^b}ZANnaei~&)F<+E3z0W261i(lfpPJ(B=feNpaW<*y*0!Dg3rR+KhH{-k z*HI%>aG(rdN&$(W<-KUrari^;p&X*$a)dqQqU8)hrTWg&&ULL*D%0ER;Qnk%bD_o} zKTGS=0n&x?%~8vm#t&qkk~aXEAt5oPPx*tKKAN))n@_)3->~ze+S})5XRC_1{|xf& zUwRqI>`;7M(NMe@u7^)xyle>lCTb@uQ}4dk8MWLx#_w`k${WnycC(XBfs%v~Ax(i2 ziNgRmUH?ePIOs6P1l0rSXZDJ}B;>`>%nmW?a+_It*XQ#bPTtjc`{OU^`1|O7*1IE2S+;7p(j%?GcT}M!vj;@tR zDZN;iG35F<6#fy=`<(u5Wy<$fT~>yTFz2Ou5W7<4^&WC5(!LchK}>r7e(lb@T?dei zc*}`BH!rf=P__v65jHi$H!wgsop6WfepLCQj-vF!E{z=#A~~LgBJu{8gE!h`x+&bo z*UHm_ZY}iz=Wk@^?R-tyUsgl*Ij&ydplz>v`~KoXL>BPYB}?tPY1PI5GV^}zX40=H zVX;*m(cTlHLm1|Vt%)NMmBQTSmYi2DO0qzSBSSzZ2|Ipu#kXFOWk^-s^?3X4rzva8 z&p>C!t9Ejzj-Ev$%ds?Te(A%UIuu zZ0j+LnB9-ENkL|T44<9RK2>S-j@pO8VF`3k5Wp-niA%^qTVwIKp(LMg_U(elNJO1FpqMS4>*4>}njWeE7l&km?? zmdj(6iw|q%ouz2*RL*Ek<+HE7YfBk$n(?-l%V$O=C|y&sDeP5MUr?93HJjrj2}GlX zdLgrB+B^%$&GxTw3Uj5NEDB9ZPWEz04(ec$hs(?m4#6*96-p@B_du#^`;*U9p446w z*3Zh85H%Tx_h-Fo;r&Qbbz`R?z1*rS#zKrYw@i3Th$}lm2*L5;;lM&ySLLjART(hc z+7lAg=7cI^NYFt&bw^a4Q_F5AQn7DW(^x805vofB0d^K)*q^oKospUx%m$TZtLMkP z;*BH#pe<1pHlvl`tZM~$Q5<|aX4xR4cJQLA{qe=GTm?(1vZKSnSx%`HC(-5BC5OXK zPL*XS-$5G_hxs7zAVRXtV2$BHIFI4lc0W@Ywn0CPdWw8ltn+hZ-_jcz>#; z00RYUZiVr0z8e$_o1$(TwgVQH)N?myav&nu^%l==uvXzZgL7)2W^)NruE z%fr9gk!O1MO+wJ2-RdtfWDu91J%6W_d+guN)MogfjPKS`LF+uW21$n(Mx~>_bNy^fpz8}($He*yqp%}0`qI659rb`+Zmf=`tPKt0 z@Y83slfGw3(cfANJgCxB=(^wLCAk@GJM$MYiIfy1S!3>Se;NPecZ6ero7fJO&Cenr zSoSxD%_2Vb25+!$Fz6&A-;5(=7lyjgL{ zKyGp)jD36<*3AOMO%xB2Enfx@kUe(zUt)K0uTl`OgpERM7pu)ikaT|PAJxGN#%Xlu zg!=)k>@}JBL+hv=L@+Py-2Tm-Q6Tgr;lq8O8u`9`TC2&mwqk?|K<72z?wWuiYc}nC z%Ag0Rg;$c;qyC~Bjj+VsV+e&5XgfR4$sD%lLySN6#ky!w#neDGFZgolmqFJpu)Tbp z)ahMTMi7D&D@-oxgzB0&(o~oVi_gK&q^D5eZ%iajr-p<9$%<*USkQsVS|u6VPHZ&* z1sfL+BfLHEceMQlCtLj=!70g<``g7X4an7(weY;1@9#e1Gy(l5)4NH`sTsCVF&~^H zM7>=wH#hXHHCsvRiU8@zIxRg2iRiOLF40elC+bk!9$CpNn5gTfGY@TFC$gxd)RrL= zWq@ZR_Zh@Pp)3hSK*KV2bp_L&R%s<3htQ_ZG)?nESmadfDf7U0rcr594EpFOq12UH zxwaP)sm1e!IZXHic+}BFgxk4&$H)vh)o5u~;bCS(*Uf}`#6RSHGX6Qdu*Wfo;~B5d`ASJ5)G_3+T5W%NqgtB{^`}i z<6C|y4=aaKzIB|!zU1Bc@UZg!Z7T7^M>FaVE&!@$eCZLxU(B18Q319N#|7$ff0vh) z3!HG+WH5mSb7^bJ1#!gK!ip>3TdF7iF9nhciQP|VTgBtH?pd#qC7u9)vb@H->bI7m F{||p8c8~x7 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 d00f12ac4780e2e70c33e978203e6933b87c9543..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40528 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 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 1ed404913820493f5bb22663415827a1a44d174f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20017 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

2{&B1&;a7_3EjVnU?SvqReTw* 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 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 18531d79f8ae19302a450e0f9623f6296605c31a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20186 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 l>s|-{zvF-85{=QxT}xwORfqR 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/__init__.py b/music_assistant/providers/tunein.py similarity index 62% rename from music_assistant/providers/tunein/__init__.py rename to music_assistant/providers/tunein.py index 821d4b3e..da96aa2d 100644 --- a/music_assistant/providers/tunein/__init__.py +++ b/music_assistant/providers/tunein.py @@ -1,88 +1,39 @@ """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 ( + +from music_assistant.models.media_items import ( + ContentType, MediaItemProviderId, + MediaItemType, + MediaQuality, MediaType, Radio, - SearchResult, - TrackQuality, + StreamDetails, + StreamType, ) 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] + 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) - return True + + 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 - ) -> SearchResult: + ) -> List[MediaItemType]: """ Perform search on musicprovider. @@ -90,7 +41,7 @@ class TuneInProvider(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 = [] # TODO: search for radio stations return result @@ -108,6 +59,7 @@ class TuneInProvider(MusicProvider): 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) @@ -118,29 +70,28 @@ class TuneInProvider(MusicProvider): 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"] + 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 + 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 = TrackQuality.LOSSY_AAC + quality = MediaQuality.LOSSY_AAC elif stream["media_type"] == "ogg": - quality = TrackQuality.LOSSY_OGG + quality = MediaQuality.LOSSY_OGG else: - quality = TrackQuality.LOSSY_MP3 - radio.provider_ids.add( + quality = MediaQuality.LOSSY_MP3 + radio.provider_ids.append( MediaItemProviderId( - provider=PROV_ID, - item_id="%s--%s" % (details["preset_id"], stream["media_type"]), + provider=self.id, + item_id=f'{details["preset_id"]}--{stream["media_type"]}', quality=quality, details=stream["url"], ) @@ -154,6 +105,7 @@ class TuneInProvider(MusicProvider): 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 @@ -171,7 +123,7 @@ class TuneInProvider(MusicProvider): return StreamDetails( type=StreamType.URL, item_id=item_id, - provider=PROV_ID, + provider=self.id, path=stream["url"], content_type=ContentType(stream["media_type"]), sample_rate=44100, @@ -185,7 +137,7 @@ class TuneInProvider(MusicProvider): """Get data from api.""" if not params: params = {} - url = "https://opml.radiotime.com/%s" % endpoint + url = f"https://opml.radiotime.com/{endpoint}" params["render"] = "json" params["formats"] = "ogg,aac,wma,mp3" params["username"] = self._username @@ -196,7 +148,7 @@ class TuneInProvider(MusicProvider): ) as response: result = await response.json() if not result or "error" in result: - LOGGER.error(url) - LOGGER.error(params) + self.logger.error(url) + self.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 18c537c3e801992719e3cf03eb9f46385eb8a065..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23800 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 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 092121e1e21e1d799b1de9ef58ca6914c4e14096..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15778 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

x(S56%X*v{GcN-$k7B+zmL% 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 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 -- 2.34.1