Refactor into standalone library (#238)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Mon, 4 Apr 2022 22:38:11 +0000 (00:38 +0200)
committerGitHub <noreply@github.com>
Mon, 4 Apr 2022 22:38:11 +0000 (00:38 +0200)
* drop authentication

* simplify logging

* refactor database

* improve streaming stability

* drop player providers  from main library

96 files changed:
.vscode/launch.json [deleted file]
examples/full.py [new file with mode: 0644]
examples/simple.py [new file with mode: 0644]
music_assistant.code-workspace [deleted file]
music_assistant/__init__.py
music_assistant/__main__.py [deleted file]
music_assistant/constants.py
music_assistant/controllers/metadata/__init__.py [new file with mode: 0755]
music_assistant/controllers/metadata/fanarttv.py [new file with mode: 0755]
music_assistant/controllers/metadata/musicbrainz.py [new file with mode: 0644]
music_assistant/controllers/music/__init__.py [new file with mode: 0755]
music_assistant/controllers/music/albums.py [new file with mode: 0644]
music_assistant/controllers/music/artists.py [new file with mode: 0644]
music_assistant/controllers/music/playlists.py [new file with mode: 0644]
music_assistant/controllers/music/radio.py [new file with mode: 0644]
music_assistant/controllers/music/tracks.py [new file with mode: 0644]
music_assistant/controllers/players.py [new file with mode: 0755]
music_assistant/controllers/stream.py [new file with mode: 0644]
music_assistant/helpers/audio.py
music_assistant/helpers/cache.py
music_assistant/helpers/compare.py
music_assistant/helpers/database.py [new file with mode: 0755]
music_assistant/helpers/encryption.py [deleted file]
music_assistant/helpers/errors.py [deleted file]
music_assistant/helpers/images.py
music_assistant/helpers/json.py [new file with mode: 0644]
music_assistant/helpers/logger.py [deleted file]
music_assistant/helpers/migration.py [deleted file]
music_assistant/helpers/muli_state_queue.py [deleted file]
music_assistant/helpers/musicbrainz.py [deleted file]
music_assistant/helpers/process.py
music_assistant/helpers/typing.py
music_assistant/helpers/util.py
music_assistant/helpers/web.py [deleted file]
music_assistant/managers/__init__.py [deleted file]
music_assistant/managers/config.py [deleted file]
music_assistant/managers/database.py [deleted file]
music_assistant/managers/events.py [deleted file]
music_assistant/managers/library.py [deleted file]
music_assistant/managers/metadata.py [deleted file]
music_assistant/managers/music.py [deleted file]
music_assistant/managers/players.py [deleted file]
music_assistant/managers/tasks.py [deleted file]
music_assistant/mass.py
music_assistant/models/__init__.py
music_assistant/models/config_entry.py [deleted file]
music_assistant/models/errors.py [new file with mode: 0644]
music_assistant/models/media_controller.py [new file with mode: 0644]
music_assistant/models/media_items.py [new file with mode: 0755]
music_assistant/models/media_types.py [deleted file]
music_assistant/models/player.py
music_assistant/models/player_queue.py [changed mode: 0755->0644]
music_assistant/models/provider.py
music_assistant/models/streamdetails.py [deleted file]
music_assistant/providers/__init__.py
music_assistant/providers/builtin_player/__init__.py [deleted file]
music_assistant/providers/builtin_player/icon.png [deleted file]
music_assistant/providers/chromecast/__init__.py [deleted file]
music_assistant/providers/chromecast/const.py [deleted file]
music_assistant/providers/chromecast/helpers.py [deleted file]
music_assistant/providers/chromecast/icon.png [deleted file]
music_assistant/providers/chromecast/player.py [deleted file]
music_assistant/providers/fanarttv/__init__.py [deleted file]
music_assistant/providers/fanarttv/icon.png [deleted file]
music_assistant/providers/file/__init__.py [deleted file]
music_assistant/providers/file/icon.png [deleted file]
music_assistant/providers/filesystem.py [new file with mode: 0644]
music_assistant/providers/qobuz.py [new file with mode: 0644]
music_assistant/providers/qobuz/__init__.py [deleted file]
music_assistant/providers/qobuz/icon.png [deleted file]
music_assistant/providers/sonos/__init__.py [deleted file]
music_assistant/providers/sonos/icon.png [deleted file]
music_assistant/providers/sonos/sonos.py [deleted file]
music_assistant/providers/spotify/__init__.py
music_assistant/providers/spotify/icon.png [deleted file]
music_assistant/providers/squeezebox/__init__.py [deleted file]
music_assistant/providers/squeezebox/constants.py [deleted file]
music_assistant/providers/squeezebox/discovery.py [deleted file]
music_assistant/providers/squeezebox/icon.png [deleted file]
music_assistant/providers/squeezebox/socket_client.py [deleted file]
music_assistant/providers/tunein.py [new file with mode: 0644]
music_assistant/providers/tunein/__init__.py [deleted file]
music_assistant/providers/tunein/icon.png [deleted file]
music_assistant/providers/universal_group/__init__.py [deleted file]
music_assistant/providers/universal_group/icon.png [deleted file]
music_assistant/resources/announce.flac [deleted file]
music_assistant/resources/silence.flac [deleted file]
music_assistant/resources/strings/en.json [deleted file]
music_assistant/resources/strings/nl.json [deleted file]
music_assistant/web/__init__.py [deleted file]
music_assistant/web/api.py [deleted file]
music_assistant/web/json_rpc.py [deleted file]
music_assistant/web/stream.py [deleted file]
requirements.txt
setup.py
tox.ini

diff --git a/.vscode/launch.json b/.vscode/launch.json
deleted file mode 100644 (file)
index 1c4a7a3..0000000
+++ /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 (file)
index 0000000..f416588
--- /dev/null
@@ -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 (file)
index 0000000..8f235b2
--- /dev/null
@@ -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 (file)
index c6efe05..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-       "folders": [
-               {
-                       "path": "."
-               }
-       ],
-       "settings": {
-               "python.pythonPath": "venv/bin/python"
-       }
-}
\ No newline at end of file
index a6634771469cb779ba5730d580be3d352464cd81..e7243716ade8bc7456dcff86a4ea6bc98e59a9d5 100644 (file)
@@ -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 (executable)
index 65befb7..0000000
+++ /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()
index 3a82775f2b1cb32339a10cdcd7cd9865193c5dcb..8a054cf60be42ae5fdfdfe528a473cac6cecf3e0 100755 (executable)
@@ -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 (executable)
index 0000000..5ad7100
--- /dev/null
@@ -0,0 +1,73 @@
+"""All logic for metadata retrieval."""
+
+from music_assistant.helpers.cache import cached
+from music_assistant.helpers.images import create_thumbnail
+from music_assistant.helpers.typing import MusicAssistant
+from music_assistant.helpers.util import merge_dict
+
+from .fanarttv import FanartTv
+from .musicbrainz import MusicBrainz
+
+# TODO: add more metadata providers such as theaudiodb
+# TODO: add metadata support for albums and other media types
+
+TABLE_THUMBS = "thumbnails"
+
+
+class MetaDataController:
+    """Several helpers to search and store metadata for mediaitems."""
+
+    # TODO: create periodic task to search for missing metadata
+    def __init__(self, mass: MusicAssistant) -> None:
+        """Initialize class."""
+        self.mass = mass
+        self.cache = mass.cache
+        self.logger = mass.logger.getChild("metadata")
+        self.fanarttv = FanartTv(mass)
+        self.musicbrainz = MusicBrainz(mass)
+
+    async def setup(self):
+        """Async initialize of module."""
+        async with self.mass.database.get_db() as _db:
+            await _db.execute(
+                f"""CREATE TABLE IF NOT EXISTS {TABLE_THUMBS}(
+                    id INTEGER PRIMARY KEY AUTOINCREMENT,
+                    url TEXT NOT NULL,
+                    size INTEGER,
+                    img BLOB,
+                    UNIQUE(url, size));"""
+            )
+
+    async def get_artist_metadata(self, mb_artist_id: str, cur_metadata: dict) -> dict:
+        """Get/update rich metadata for an artist by providing the musicbrainz artist id."""
+        metadata = cur_metadata
+        if "fanart" in metadata:
+            # no need to query (other) metadata providers if we already have a result
+            return metadata
+        self.logger.info(
+            "Fetching metadata for MusicBrainz Artist %s on Fanrt.tv", mb_artist_id
+        )
+        cache_key = f"fanarttv.artist_metadata.{mb_artist_id}"
+        res = await cached(
+            self.cache, cache_key, self.fanarttv.get_artist_images, mb_artist_id
+        )
+        if res:
+            metadata = merge_dict(metadata, res)
+            self.logger.debug(
+                "Found metadata for MusicBrainz Artist %s on Fanart.tv: %s",
+                mb_artist_id,
+                ", ".join(res.keys()),
+            )
+        return metadata
+
+    async def get_thumbnail(self, url, size) -> bytes:
+        """Get/create thumbnail image for url."""
+        match = {"url": url, "size": size}
+        if result := await self.mass.database.get_row(TABLE_THUMBS, match):
+            return result["img"]
+        # create thumbnail if it doesn't exist
+        thumbnail = await create_thumbnail(self.mass, url, size)
+        await self.mass.database.insert_or_replace(
+            TABLE_THUMBS, {**match, "img": thumbnail}
+        )
+        return thumbnail
diff --git a/music_assistant/controllers/metadata/fanarttv.py b/music_assistant/controllers/metadata/fanarttv.py
new file mode 100755 (executable)
index 0000000..a5c9c5f
--- /dev/null
@@ -0,0 +1,73 @@
+"""FanartTv Metadata provider."""
+
+from json.decoder import JSONDecodeError
+from typing import Dict
+
+import aiohttp
+from asyncio_throttle import Throttler
+
+from music_assistant.helpers.typing import MusicAssistant
+
+
+# TODO: add support for personal api keys ?
+# TODO: Add support for album artwork ?
+
+
+class FanartTv:
+    """Fanart.tv metadata provider."""
+
+    def __init__(self, mass: MusicAssistant):
+        """Initialize class."""
+        self.mass = mass
+        self.logger = mass.logger.getChild("fanarttv")
+        self.throttler = Throttler(rate_limit=1, period=2)
+
+    async def get_artist_images(self, mb_artist_id: str) -> Dict:
+        """Retrieve images by musicbrainz artist id."""
+        metadata = {}
+        data = await self._get_data(f"music/{mb_artist_id}")
+        if data:
+            if data.get("hdmusiclogo"):
+                metadata["logo"] = data["hdmusiclogo"][0]["url"]
+            elif data.get("musiclogo"):
+                metadata["logo"] = data["musiclogo"][0]["url"]
+            if data.get("artistbackground"):
+                count = 0
+                for item in data["artistbackground"]:
+                    key = "fanart" if count == 0 else f"fanart.{count}"
+                    metadata[key] = item["url"]
+            if data.get("artistthumb"):
+                url = data["artistthumb"][0]["url"]
+                if "2a96cbd8b46e442fc41c2b86b821562f" not in url:
+                    metadata["image"] = url
+            if data.get("musicbanner"):
+                metadata["banner"] = data["musicbanner"][0]["url"]
+        return metadata
+
+    async def _get_data(self, endpoint, params=None):
+        """Get data from api."""
+        if params is None:
+            params = {}
+        url = f"http://webservice.fanart.tv/v3/{endpoint}"
+        params["api_key"] = "639191cb0774661597f28a47e7e2bad5"
+        async with self.throttler:
+            async with self.mass.http_session.get(
+                url, params=params, verify_ssl=False
+            ) as response:
+                try:
+                    result = await response.json()
+                except (
+                    aiohttp.ContentTypeError,
+                    JSONDecodeError,
+                ):
+                    self.logger.error("Failed to retrieve %s", endpoint)
+                    text_result = await response.text()
+                    self.logger.debug(text_result)
+                    return None
+                except aiohttp.ClientConnectorError:
+                    self.logger.error("Failed to retrieve %s", endpoint)
+                    return None
+                if "error" in result and "limit" in result["error"]:
+                    self.logger.error(result["error"])
+                    return None
+                return result
diff --git a/music_assistant/controllers/metadata/musicbrainz.py b/music_assistant/controllers/metadata/musicbrainz.py
new file mode 100644 (file)
index 0000000..57b32e3
--- /dev/null
@@ -0,0 +1,180 @@
+"""Handle getting Id's from MusicBrainz."""
+
+import re
+from json.decoder import JSONDecodeError
+from typing import Optional
+
+import aiohttp
+from asyncio_throttle import Throttler
+
+from music_assistant.helpers.cache import cached
+from music_assistant.helpers.compare import compare_strings, get_compare_string
+from music_assistant.helpers.typing import MusicAssistant
+
+LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])'
+
+
+class MusicBrainz:
+    """Handle getting Id's from MusicBrainz."""
+
+    def __init__(self, mass: MusicAssistant):
+        """Initialize class."""
+        self.mass = mass
+        self.cache = mass.cache
+        self.logger = mass.logger.getChild("musicbrainz")
+        self.throttler = Throttler(rate_limit=1, period=1)
+
+    async def get_mb_artist_id(
+        self,
+        artistname,
+        albumname=None,
+        album_upc=None,
+        trackname=None,
+        track_isrc=None,
+    ):
+        """Retrieve musicbrainz artist id for the given details."""
+        self.logger.debug(
+            "searching musicbrainz for %s \
+                (albumname: %s - album_upc: %s - trackname: %s - track_isrc: %s)",
+            artistname,
+            albumname,
+            album_upc,
+            trackname,
+            track_isrc,
+        )
+        mb_artist_id = None
+        if album_upc:
+            mb_artist_id = await self.search_artist_by_album(
+                artistname, None, album_upc
+            )
+            if mb_artist_id:
+                self.logger.debug(
+                    "Got MusicbrainzArtistId for %s after search on upc %s --> %s",
+                    artistname,
+                    album_upc,
+                    mb_artist_id,
+                )
+        if not mb_artist_id and track_isrc:
+            mb_artist_id = await self.search_artist_by_track(
+                artistname, None, track_isrc
+            )
+            if mb_artist_id:
+                self.logger.debug(
+                    "Got MusicbrainzArtistId for %s after search on isrc %s --> %s",
+                    artistname,
+                    track_isrc,
+                    mb_artist_id,
+                )
+        if not mb_artist_id and albumname:
+            mb_artist_id = await self.search_artist_by_album(artistname, albumname)
+            if mb_artist_id:
+                self.logger.debug(
+                    "Got MusicbrainzArtistId for %s after search on albumname %s --> %s",
+                    artistname,
+                    albumname,
+                    mb_artist_id,
+                )
+        if not mb_artist_id and trackname:
+            mb_artist_id = await self.search_artist_by_track(artistname, trackname)
+            if mb_artist_id:
+                self.logger.debug(
+                    "Got MusicbrainzArtistId for %s after search on trackname %s --> %s",
+                    artistname,
+                    trackname,
+                    mb_artist_id,
+                )
+        return mb_artist_id
+
+    async def search_artist_by_album(self, artistname, albumname=None, album_upc=None):
+        """Retrieve musicbrainz artist id by providing the artist name and albumname or upc."""
+        for searchartist in [
+            re.sub(LUCENE_SPECIAL, r"\\\1", artistname),
+            get_compare_string(artistname),
+        ]:
+            if album_upc:
+                endpoint = "release"
+                params = {"query": f"barcode:{album_upc}"}
+                cache_key = f"{endpoint}.barcode.{album_upc}"
+            else:
+                searchalbum = re.sub(LUCENE_SPECIAL, r"\\\1", albumname)
+                endpoint = "release"
+                params = {
+                    "query": f'artist:"{searchartist}" AND release:"{searchalbum}"'
+                }
+                cache_key = f"{endpoint}.{searchartist}.{searchalbum}"
+            result = await cached(
+                self.mass.cache, cache_key, self.get_data, endpoint, params
+            )
+            if result and "releases" in result:
+                for strictness in [True, False]:
+                    for item in result["releases"]:
+                        if album_upc or compare_strings(
+                            item["title"], albumname, strictness
+                        ):
+                            for artist in item["artist-credit"]:
+                                if compare_strings(
+                                    artist["artist"]["name"], artistname, strictness
+                                ):
+                                    return artist["artist"]["id"]
+                                for alias in artist.get("aliases", []):
+                                    if compare_strings(
+                                        alias["name"], artistname, strictness
+                                    ):
+                                        return artist["id"]
+        return ""
+
+    async def search_artist_by_track(self, artistname, trackname=None, track_isrc=None):
+        """Retrieve artist id by providing the artist name and trackname or track isrc."""
+        endpoint = "recording"
+        searchartist = re.sub(LUCENE_SPECIAL, r"\\\1", artistname)
+        if track_isrc:
+            endpoint = f"isrc/{track_isrc}"
+            params = {"inc": "artist-credits"}
+            cache_key = endpoint
+        else:
+            searchtrack = re.sub(LUCENE_SPECIAL, r"\\\1", trackname)
+            endpoint = "recording"
+            params = {"query": '"{searchtrack}" AND artist:"{searchartist}"'}
+            cache_key = f"{endpoint}.{searchtrack}.{searchartist}"
+        result = await cached(
+            self.mass.cache, cache_key, self.get_data(endpoint, params)
+        )
+        if result and "recordings" in result:
+            for strictness in [True, False]:
+                for item in result["recordings"]:
+                    if track_isrc or compare_strings(
+                        item["title"], trackname, strictness
+                    ):
+                        for artist in item["artist-credit"]:
+                            if compare_strings(
+                                artist["artist"]["name"], artistname, strictness
+                            ):
+                                return artist["artist"]["id"]
+                            for alias in artist.get("aliases", []):
+                                if compare_strings(
+                                    alias["name"], artistname, strictness
+                                ):
+                                    return artist["id"]
+        return ""
+
+    async def get_data(self, endpoint: str, params: Optional[dict] = None):
+        """Get data from api."""
+        if params is None:
+            params = {}
+        url = f"http://musicbrainz.org/ws/2/{endpoint}"
+        headers = {"User-Agent": "Music Assistant/1.0.0 https://github.com/marcelveldt"}
+        params["fmt"] = "json"
+        async with self.throttler:
+            async with self.mass.http_session.get(
+                url, headers=headers, params=params, verify_ssl=False
+            ) as response:
+                try:
+                    result = await response.json()
+                except (
+                    aiohttp.client_exceptions.ContentTypeError,
+                    JSONDecodeError,
+                ) as exc:
+                    msg = await response.text()
+                    self.logger.error("%s - %s", str(exc), msg)
+                    result = None
+                return result
diff --git a/music_assistant/controllers/music/__init__.py b/music_assistant/controllers/music/__init__.py
new file mode 100755 (executable)
index 0000000..ed8ee75
--- /dev/null
@@ -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 (file)
index 0000000..6e0abe2
--- /dev/null
@@ -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 (file)
index 0000000..f03cf95
--- /dev/null
@@ -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 (file)
index 0000000..7374443
--- /dev/null
@@ -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 (file)
index 0000000..cafda65
--- /dev/null
@@ -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 (file)
index 0000000..867bf8d
--- /dev/null
@@ -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 (executable)
index 0000000..0c77756
--- /dev/null
@@ -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 (file)
index 0000000..b499311
--- /dev/null
@@ -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
index b57fddb3ee61b7cdc6da9cec8acde2218534d8da..394f202fa00a468e263d1e24ebed869f66135d61 100644 (file)
@@ -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
index fc7ea370cf8db9f6248544d0e38d141972e9e74a..404b016d35f5003f02b33b14937a839069b3655e 100644 (file)
@@ -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
index 098fe70424f35cd137790f43a2079e7728adc94e..c86f670ede56e8dfdb05c150f9735827a006d982 100644 (file)
@@ -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 (executable)
index 0000000..cc496cd
--- /dev/null
@@ -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 (file)
index eca69ba..0000000
+++ /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 (file)
index 076fd86..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-"""Custom errors and exceptions."""
-
-
-class AuthenticationError(Exception):
-    """Custom Exception for all authentication errors."""
index 4f7267c79ad9fe1bcb26f71b62af0bb5af65c39a..86b2d8660294495c29118e589666d239ffe04a76 100644 (file)
@@ -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 (file)
index 0000000..4a3b1a3
--- /dev/null
@@ -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 (file)
index c695666..0000000
+++ /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 (file)
index fa81328..0000000
+++ /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 (file)
index 7655704..0000000
+++ /dev/null
@@ -1,66 +0,0 @@
-"""Special queue-like to process items in different states."""
-import asyncio
-from collections import deque
-from typing import Any, List, Type
-
-
-class MultiStateQueue:
-    """Special queue-like to process items in different states."""
-
-    QUEUE_ITEM_TYPE: Type = Any
-
-    def __init__(self, max_finished_items: int = 50) -> None:
-        """Initialize class."""
-        self._pending_items = asyncio.Queue()
-        self._progress_items = deque()
-        self._finished_items = deque(maxlen=max_finished_items)
-
-    @property
-    def pending_items(self) -> List[QUEUE_ITEM_TYPE]:
-        """Return all pending items."""
-        # pylint: disable=protected-access
-        return list(self._pending_items._queue)
-
-    @property
-    def progress_items(self) -> List[QUEUE_ITEM_TYPE]:
-        """Return all in-progress items."""
-        return list(self._progress_items)
-
-    @property
-    def finished_items(self) -> List[QUEUE_ITEM_TYPE]:
-        """Return all finished items."""
-        return list(self._finished_items)
-
-    @property
-    def all_items(self) -> List[QUEUE_ITEM_TYPE]:
-        """Return all items."""
-        return list(self.pending_items + self.progress_items + self.finished_items)
-
-    def put_nowait(self, item: QUEUE_ITEM_TYPE) -> None:
-        """Put item in the queue to progress."""
-        if item in self._finished_items:
-            self._finished_items.remove(item)
-        return self._pending_items.put_nowait(item)
-
-    async def put(self, item: QUEUE_ITEM_TYPE) -> None:
-        """Put item on the queue to progress."""
-        if item in self._finished_items:
-            self._finished_items.remove(item)
-        return await self._pending_items.put(item)
-
-    async def get_nowait(self) -> QUEUE_ITEM_TYPE:
-        """Get next item in Queue, raises QueueEmpty if no items in Queue."""
-        next_item = self._pending_items.get_nowait()
-        self._progress_items.append(next_item)
-        return next_item
-
-    async def get(self) -> QUEUE_ITEM_TYPE:
-        """Get next item in Queue, waits until item is available."""
-        next_item = await self._pending_items.get()
-        self._progress_items.append(next_item)
-        return next_item
-
-    def mark_finished(self, item: QUEUE_ITEM_TYPE) -> None:
-        """Mark item as finished."""
-        self._progress_items.remove(item)
-        self._finished_items.append(item)
diff --git a/music_assistant/helpers/musicbrainz.py b/music_assistant/helpers/musicbrainz.py
deleted file mode 100644 (file)
index 5660d41..0000000
+++ /dev/null
@@ -1,175 +0,0 @@
-"""Handle getting Id's from MusicBrainz."""
-
-import logging
-import re
-from json.decoder import JSONDecodeError
-from typing import Optional
-
-import aiohttp
-from asyncio_throttle import Throttler
-from music_assistant.helpers.cache import use_cache
-from music_assistant.helpers.compare import compare_strings, get_compare_string
-
-LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])'
-
-LOGGER = logging.getLogger("musicbrainz")
-
-
-class MusicBrainz:
-    """Handle getting Id's from MusicBrainz."""
-
-    def __init__(self, mass):
-        """Initialize class."""
-        self.mass = mass
-        self.cache = mass.cache
-        self.throttler = Throttler(rate_limit=1, period=1)
-
-    async def get_mb_artist_id(
-        self,
-        artistname,
-        albumname=None,
-        album_upc=None,
-        trackname=None,
-        track_isrc=None,
-    ):
-        """Retrieve musicbrainz artist id for the given details."""
-        LOGGER.debug(
-            "searching musicbrainz for %s \
-                (albumname: %s - album_upc: %s - trackname: %s - track_isrc: %s)",
-            artistname,
-            albumname,
-            album_upc,
-            trackname,
-            track_isrc,
-        )
-        mb_artist_id = None
-        if album_upc:
-            mb_artist_id = await self.search_artist_by_album(
-                artistname, None, album_upc
-            )
-            if mb_artist_id:
-                LOGGER.debug(
-                    "Got MusicbrainzArtistId for %s after search on upc %s --> %s",
-                    artistname,
-                    album_upc,
-                    mb_artist_id,
-                )
-        if not mb_artist_id and track_isrc:
-            mb_artist_id = await self.search_artist_by_track(
-                artistname, None, track_isrc
-            )
-            if mb_artist_id:
-                LOGGER.debug(
-                    "Got MusicbrainzArtistId for %s after search on isrc %s --> %s",
-                    artistname,
-                    track_isrc,
-                    mb_artist_id,
-                )
-        if not mb_artist_id and albumname:
-            mb_artist_id = await self.search_artist_by_album(artistname, albumname)
-            if mb_artist_id:
-                LOGGER.debug(
-                    "Got MusicbrainzArtistId for %s after search on albumname %s --> %s",
-                    artistname,
-                    albumname,
-                    mb_artist_id,
-                )
-        if not mb_artist_id and trackname:
-            mb_artist_id = await self.search_artist_by_track(artistname, trackname)
-            if mb_artist_id:
-                LOGGER.debug(
-                    "Got MusicbrainzArtistId for %s after search on trackname %s --> %s",
-                    artistname,
-                    trackname,
-                    mb_artist_id,
-                )
-        return mb_artist_id
-
-    async def search_artist_by_album(self, artistname, albumname=None, album_upc=None):
-        """Retrieve musicbrainz artist id by providing the artist name and albumname or upc."""
-        for searchartist in [
-            re.sub(LUCENE_SPECIAL, r"\\\1", artistname),
-            get_compare_string(artistname),
-        ]:
-            if album_upc:
-                endpoint = "release"
-                params = {"query": "barcode:%s" % album_upc}
-            else:
-                searchalbum = re.sub(LUCENE_SPECIAL, r"\\\1", albumname)
-                endpoint = "release"
-                params = {
-                    "query": 'artist:"%s" AND release:"%s"'
-                    % (searchartist, searchalbum)
-                }
-            result = await self.get_data(endpoint, params)
-            if result and "releases" in result:
-                for strictness in [True, False]:
-                    for item in result["releases"]:
-                        if album_upc or compare_strings(
-                            item["title"], albumname, strictness
-                        ):
-                            for artist in item["artist-credit"]:
-                                if compare_strings(
-                                    artist["artist"]["name"], artistname, strictness
-                                ):
-                                    return artist["artist"]["id"]
-                                for alias in artist.get("aliases", []):
-                                    if compare_strings(
-                                        alias["name"], artistname, strictness
-                                    ):
-                                        return artist["id"]
-        return ""
-
-    async def search_artist_by_track(self, artistname, trackname=None, track_isrc=None):
-        """Retrieve artist id by providing the artist name and trackname or track isrc."""
-        endpoint = "recording"
-        searchartist = re.sub(LUCENE_SPECIAL, r"\\\1", artistname)
-        # searchartist = searchartist.replace('/','').replace('\\','').replace('-', '')
-        if track_isrc:
-            endpoint = "isrc/%s" % track_isrc
-            params = {"inc": "artist-credits"}
-        else:
-            searchtrack = re.sub(LUCENE_SPECIAL, r"\\\1", trackname)
-            endpoint = "recording"
-            params = {"query": '"%s" AND artist:"%s"' % (searchtrack, searchartist)}
-        result = await self.get_data(endpoint, params)
-        if result and "recordings" in result:
-            for strictness in [True, False]:
-                for item in result["recordings"]:
-                    if track_isrc or compare_strings(
-                        item["title"], trackname, strictness
-                    ):
-                        for artist in item["artist-credit"]:
-                            if compare_strings(
-                                artist["artist"]["name"], artistname, strictness
-                            ):
-                                return artist["artist"]["id"]
-                            for alias in artist.get("aliases", []):
-                                if compare_strings(
-                                    alias["name"], artistname, strictness
-                                ):
-                                    return artist["id"]
-        return ""
-
-    @use_cache(2)
-    async def get_data(self, endpoint: str, params: Optional[dict] = None):
-        """Get data from api."""
-        if params is None:
-            params = {}
-        url = "http://musicbrainz.org/ws/2/%s" % endpoint
-        headers = {"User-Agent": "Music Assistant/1.0.0 https://github.com/marcelveldt"}
-        params["fmt"] = "json"
-        async with self.throttler:
-            async with self.mass.http_session.get(
-                url, headers=headers, params=params, verify_ssl=False
-            ) as response:
-                try:
-                    result = await response.json()
-                except (
-                    aiohttp.client_exceptions.ContentTypeError,
-                    JSONDecodeError,
-                ) as exc:
-                    msg = await response.text()
-                    LOGGER.error("%s - %s", str(exc), msg)
-                    result = None
-                return result
index 13ce82b23ca1c4ebea20694089ed102ee437b88a..eed32d62a30e1b01408a313c30a3f3ab49035532 100644 (file)
@@ -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)
index bb5b3b84b179dfcfb7c3feaf7f492c4ad0a4e6ff..56ece3082500200f3378f28351452594816c305e 100644 (file)
@@ -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]
index 38a3b7633ce7028470fcf51af69bea8d344ba0f5..6743b7336936e40cb86620aae51bc5a66a938d38 100755 (executable)
@@ -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 (file)
index 0a34e7c..0000000
+++ /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 (file)
index bc6f8f9..0000000
+++ /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 (executable)
index 06c4645..0000000
+++ /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 (executable)
index 0b1f58b..0000000
+++ /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 (file)
index 71407d9..0000000
+++ /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 (executable)
index 00906f9..0000000
+++ /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 (executable)
index d60a05d..0000000
+++ /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 (executable)
index e5245f6..0000000
+++ /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 (executable)
index 7b0c45e..0000000
+++ /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 (file)
index 583c1be..0000000
+++ /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)
index 0c7645181f36cb857bdc5fd6e2fca37d2113698f..a393a8886a1f998075d3d1a3a8391e33e112bd84 100644 (file)
 """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)
index 67f0447179d6a1a4b4d20bea05f33653905ac0fb..53f3a7c6cc32afea1672d4340b8e8fbe677470c5 100644 (file)
@@ -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 (file)
index 2af08bf..0000000
+++ /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 (file)
index 0000000..ae7a5b0
--- /dev/null
@@ -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 (file)
index 0000000..eaac374
--- /dev/null
@@ -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 (executable)
index 0000000..a66a990
--- /dev/null
@@ -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 (executable)
index 96c7cf4..0000000
+++ /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)
index 53bb7a5f0e84e78743de77e76e3688cce88d5ef2..16063bc8318336a927b67782e72acef4e1980c91 100755 (executable)
@@ -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
+        ]
old mode 100755 (executable)
new mode 100644 (file)
index 60f0be9..7da7f32
@@ -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)
index 66ce26b2cf50edf455cdeb2a38f03384f5bad415..8346ba946b9aeb0d700db146c472658d5aab2e89 100644 (file)
-"""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 (file)
index af4ed13..0000000
+++ /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}"
index 2209974fe6e0ade8d9011f649569b5077d72a821..01895ef623f0829d266aca341bad55f0596e2d65 100644 (file)
@@ -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 (file)
index b083cdb..0000000
+++ /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 (file)
index 092121e..0000000
Binary files a/music_assistant/providers/builtin_player/icon.png and /dev/null differ
diff --git a/music_assistant/providers/chromecast/__init__.py b/music_assistant/providers/chromecast/__init__.py
deleted file mode 100644 (file)
index f88ead9..0000000
+++ /dev/null
@@ -1,110 +0,0 @@
-"""ChromeCast playerprovider."""
-
-import logging
-from typing import List, Optional
-
-import pychromecast
-from music_assistant.helpers.util import create_task
-from music_assistant.models.config_entry import ConfigEntry
-from music_assistant.models.provider import PlayerProvider
-from pychromecast.controllers.multizone import MultizoneManager
-
-from .const import PROV_ID, PROV_NAME, PROVIDER_CONFIG_ENTRIES
-from .helpers import ChromecastInfo
-from .player import ChromecastPlayer
-
-LOGGER = logging.getLogger(PROV_ID)
-
-
-async def setup(mass):
-    """Perform async setup of this Plugin/Provider."""
-    logging.getLogger("pychromecast").setLevel(logging.WARNING)
-    prov = ChromecastProvider()
-    await mass.register_provider(prov)
-
-
-class ChromecastProvider(PlayerProvider):
-    """Support for ChromeCast Audio PlayerProvider."""
-
-    def __init__(self, *args, **kwargs):
-        """Initialize."""
-        self.mz_mgr = MultizoneManager()
-        self._browser: Optional[pychromecast.discovery.CastBrowser] = None
-        super().__init__(*args, **kwargs)
-
-    @property
-    def id(self) -> str:
-        """Return provider ID for this provider."""
-        return PROV_ID
-
-    @property
-    def name(self) -> str:
-        """Return provider Name for this provider."""
-        return PROV_NAME
-
-    @property
-    def config_entries(self) -> List[ConfigEntry]:
-        """Return Config Entries for this provider."""
-        return PROVIDER_CONFIG_ENTRIES
-
-    async def on_start(self) -> bool:
-        """Handle initialization of the provider based on config."""
-        self._browser = pychromecast.discovery.CastBrowser(
-            pychromecast.discovery.SimpleCastListener(
-                add_callback=self._discover_chromecast,
-                remove_callback=self._remove_chromecast,
-                update_callback=self._discover_chromecast,
-            ),
-            self.mass.zeroconf,
-        )
-        # start discovery in executor
-        create_task(self._browser.start_discovery)
-        return True
-
-    async def on_stop(self):
-        """Handle correct close/cleanup of the provider on exit."""
-        if not self._browser:
-            return
-        # stop discovery
-        create_task(self._browser.stop_discovery)
-
-    def _discover_chromecast(self, uuid, _):
-        """Discover a Chromecast."""
-        cast_info: pychromecast.models.CastInfo = self._browser.devices[uuid]
-
-        info = ChromecastInfo(
-            services=cast_info.services,
-            uuid=cast_info.uuid,
-            model_name=cast_info.model_name,
-            friendly_name=cast_info.friendly_name,
-            cast_type=cast_info.cast_type,
-            manufacturer=cast_info.manufacturer,
-        )
-
-        if info.uuid is None:
-            LOGGER.error("Discovered chromecast without uuid %s", info)
-            return
-
-        info = info.fill_out_missing_chromecast_info(self.mass.zeroconf)
-        if info.is_dynamic_group:
-            LOGGER.warning("Discovered dynamic cast group which will be ignored.")
-            return
-
-        LOGGER.debug("Discovered new or updated chromecast %s", info)
-        player_id = str(info.uuid)
-        player = self.mass.players.get_player(player_id)
-        if not player:
-            player = ChromecastPlayer(self.mass, info)
-
-        # if player was already added, the player will take care of reconnects itself.
-        player.set_cast_info(info)
-        create_task(self.mass.players.add_player(player))
-
-    @staticmethod
-    def _remove_chromecast(uuid, service, cast_info):
-        """Handle zeroconf discovery of a removed chromecast."""
-        # pylint: disable=unused-argument
-        player_id = str(service[1])
-        friendly_name = service[3]
-        LOGGER.debug("Chromecast removed: %s - %s", friendly_name, player_id)
-        # we ignore this event completely as the Chromecast socket client handles this itself
diff --git a/music_assistant/providers/chromecast/const.py b/music_assistant/providers/chromecast/const.py
deleted file mode 100644 (file)
index fe35503..0000000
+++ /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 (file)
index dcbadd6..0000000
+++ /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 (file)
index e7372ee..0000000
Binary files a/music_assistant/providers/chromecast/icon.png and /dev/null differ
diff --git a/music_assistant/providers/chromecast/player.py b/music_assistant/providers/chromecast/player.py
deleted file mode 100644 (file)
index daddb46..0000000
+++ /dev/null
@@ -1,446 +0,0 @@
-"""Representation of a Cast device on the network."""
-import asyncio
-import logging
-from typing import List, Optional
-
-import pychromecast
-from music_assistant.helpers.compare import compare_strings
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import create_task, yield_chunks
-from music_assistant.models.config_entry import ConfigEntry
-from music_assistant.models.player import DeviceInfo, Player, PlayerFeature, PlayerState
-from music_assistant.models.player_queue import QueueItem
-from pychromecast.controllers.multizone import MultizoneController, MultizoneManager
-from pychromecast.socket_client import (
-    CONNECTION_STATUS_CONNECTED,
-    CONNECTION_STATUS_DISCONNECTED,
-)
-
-from .const import PLAYER_CONFIG_ENTRIES, PROV_ID
-from .helpers import CastStatusListener, ChromecastInfo
-
-LOGGER = logging.getLogger(PROV_ID)
-PLAYER_FEATURES = [PlayerFeature.QUEUE]
-
-
-class ChromecastPlayer(Player):
-    """Representation of a Cast device on the network.
-
-    This class is the holder of the pychromecast.Chromecast object and
-    handles all reconnects and audio group changing
-    "elected leader" itself.
-    """
-
-    def __init__(self, mass: MusicAssistant, cast_info: ChromecastInfo) -> None:
-        """Initialize the cast device."""
-        super().__init__()
-        self.mass = mass
-        self._cast_info = cast_info
-        self._player_id = cast_info.uuid
-
-        self._chromecast: Optional[pychromecast.Chromecast] = None
-        self.cast_status = None
-        self.media_status = None
-        self.media_status_received = None
-        self.mz_mgr: Optional[MultizoneManager] = None
-        self._available = False
-        self._status_listener: Optional[CastStatusListener] = None
-        self._is_speaker_group = False
-        self._command_busy = False
-
-    @property
-    def player_id(self) -> str:
-        """Return player id of this player."""
-        return self._player_id
-
-    @property
-    def provider_id(self) -> str:
-        """Return provider id of this player."""
-        return PROV_ID
-
-    @property
-    def name(self) -> str:
-        """Return name of this player."""
-        return self._cast_info.friendly_name
-
-    @property
-    def powered(self) -> bool:
-        """Return power state of this player."""
-        if not self._chromecast or not self.cast_status:
-            return False
-        if self.is_group_player:
-            return (
-                self._chromecast.media_controller.is_active
-                and self.cast_status.app_id == pychromecast.APP_MEDIA_RECEIVER
-            )
-
-        # Chromecast does not support power so we (ab)use mute instead
-        if self.cast_status.app_id is None:
-            return not self.cast_status.volume_muted
-        return (
-            self.cast_status.app_id in ["705D30C6", pychromecast.APP_MEDIA_RECEIVER]
-            and not self.cast_status.volume_muted
-        )
-
-    @property
-    def should_poll(self) -> bool:
-        """Return bool if this player needs to be polled for state changes."""
-        return self.media_status and self.media_status.player_is_playing
-
-    @property
-    def state(self) -> PlayerState:
-        """Return the state of the player."""
-        if self.media_status is None:
-            return PlayerState.IDLE
-        if self.media_status.player_is_playing:
-            return PlayerState.PLAYING
-        if self.media_status.player_is_paused:
-            return PlayerState.PAUSED
-        if self.media_status.player_is_idle:
-            return PlayerState.IDLE
-        return PlayerState.IDLE
-
-    @property
-    def elapsed_time(self) -> int:
-        """Return position of current playing media in seconds."""
-        if self.media_status is None or not (
-            self.media_status.player_is_playing
-            or self.media_status.player_is_paused
-            or self.media_status.player_is_idle
-        ):
-            return 0
-        if self.media_status.player_is_playing:
-            # Add time since last update
-            return self.media_status.adjusted_current_time
-        # Not playing, return last reported seek time
-        return self.media_status.current_time
-
-    @property
-    def available(self) -> bool:
-        """Return availablity state of this player."""
-        return self._available
-
-    @property
-    def current_uri(self) -> str:
-        """Return current_uri of this player."""
-        return self.media_status.content_id if self.media_status else None
-
-    @property
-    def volume_level(self) -> int:
-        """Return volume_level of this player."""
-        return self.cast_status.volume_level * 100 if self.cast_status else 0
-
-    @property
-    def muted(self) -> bool:
-        """Return mute state of this player."""
-        return self.cast_status.volume_muted if self.cast_status else False
-
-    @property
-    def is_group_player(self) -> bool:
-        """Return if this player is a group player."""
-        return self._cast_info.is_audio_group and not self._is_speaker_group
-
-    @property
-    def group_childs(self) -> List[str]:
-        """Return group_childs."""
-        if (
-            self._cast_info.is_audio_group
-            and self._chromecast
-            and not self._is_speaker_group
-        ):
-            return self._chromecast.mz_controller.members
-        return []
-
-    @property
-    def device_info(self) -> DeviceInfo:
-        """Return deviceinfo."""
-        return DeviceInfo(
-            model=self._cast_info.model_name,
-            address=f"{self._chromecast.uri}" if self._chromecast else "",
-            manufacturer=self._cast_info.manufacturer,
-        )
-
-    @property
-    def features(self) -> List[PlayerFeature]:
-        """Return list of features this player supports."""
-        return PLAYER_FEATURES
-
-    @property
-    def config_entries(self) -> List[ConfigEntry]:
-        """Return player specific config entries (if any)."""
-        return PLAYER_CONFIG_ENTRIES
-
-    async def on_add(self) -> None:
-        """Call when player is added to the player manager."""
-        chromecast = await self.mass.loop.run_in_executor(
-            None,
-            pychromecast.get_chromecast_from_cast_info,
-            pychromecast.discovery.CastInfo(
-                self._cast_info.services,
-                self._cast_info.uuid,
-                self._cast_info.model_name,
-                self._cast_info.friendly_name,
-                None,
-                None,
-                self._cast_info.cast_type,
-                self._cast_info.manufacturer,
-            ),
-            self.mass.zeroconf,
-        )
-        self._chromecast = chromecast
-        self.mz_mgr: MultizoneManager = self.mass.get_provider(PROV_ID).mz_mgr
-        self._status_listener = CastStatusListener(self, chromecast, self.mz_mgr)
-        self._available = False
-        self.cast_status = chromecast.status
-        self.media_status = chromecast.media_controller.status
-        if self._cast_info.is_audio_group:
-            mz_controller = MultizoneController(chromecast.uuid)
-            chromecast.register_handler(mz_controller)
-            chromecast.mz_controller = mz_controller
-        self._chromecast.start()
-
-    def set_cast_info(self, cast_info: ChromecastInfo) -> None:
-        """Set (or update) the cast discovery info."""
-        self._cast_info = cast_info
-
-    async def disconnect(self):
-        """Disconnect Chromecast object if it is set."""
-        if self._chromecast is None:
-            # Can't disconnect if not connected.
-            return
-        LOGGER.debug(
-            "[%s %s] Disconnecting from chromecast socket",
-            self.player_id,
-            self._cast_info.friendly_name,
-        )
-        self._available = False
-        self.update_state()
-
-        await self.mass.loop.run_in_executor(None, self._chromecast.disconnect)
-
-        self._invalidate()
-        self.update_state()
-
-    def _invalidate(self) -> None:
-        """Invalidate some attributes."""
-        self._chromecast = None
-        self.cast_status = None
-        self.media_status = None
-        self.media_status_received = None
-        self.mz_mgr = None
-        if self._status_listener is not None:
-            self._status_listener.invalidate()
-            self._status_listener = None
-
-    async def on_remove(self) -> None:
-        """Call when player is removed from the player manager."""
-        await self.disconnect()
-
-    # ========== Callbacks ==========
-
-    def new_cast_status(self, cast_status) -> None:
-        """Handle updates of the cast status."""
-        self.cast_status = cast_status
-        self._is_speaker_group = (
-            self._cast_info.is_audio_group
-            and self._chromecast.mz_controller
-            and self._chromecast.mz_controller.members
-            and compare_strings(
-                self._chromecast.mz_controller.members[0], self.player_id
-            )
-        )
-        self.update_state()
-
-    def new_media_status(self, media_status) -> None:
-        """Handle updates of the media status."""
-        self.media_status = media_status
-        self.update_state()
-
-    def new_connection_status(self, connection_status) -> None:
-        """Handle updates of connection status."""
-        if connection_status.status == CONNECTION_STATUS_DISCONNECTED:
-            self._available = False
-            self._invalidate()
-            self.update_state()
-            return
-
-        new_available = connection_status.status == CONNECTION_STATUS_CONNECTED
-        if new_available != self._available:
-            # Connection status callbacks happen often when disconnected.
-            # Only update state when availability changed to put less pressure
-            # on state machine.
-            LOGGER.debug(
-                "[%s] Cast device availability changed: %s",
-                self._cast_info.friendly_name,
-                connection_status.status,
-            )
-            self._available = new_available
-            self.update_state()
-            if self._cast_info.is_audio_group and new_available:
-                create_task(self._chromecast.mz_controller.update_members)
-
-    # ========== Service Calls ==========
-
-    async def cmd_stop(self) -> None:
-        """Send stop command to player."""
-        if self._chromecast.media_controller:
-            await self.chromecast_command(self._chromecast.media_controller.stop)
-
-    async def cmd_play(self) -> None:
-        """Send play command to player."""
-        if self._chromecast.media_controller:
-            await self.chromecast_command(self._chromecast.media_controller.play)
-
-    async def cmd_pause(self) -> None:
-        """Send pause command to player."""
-        if self._chromecast.media_controller:
-            await self.chromecast_command(self._chromecast.media_controller.pause)
-
-    async def cmd_next(self) -> None:
-        """Send next track command to player."""
-        if self._chromecast.media_controller:
-            await self.chromecast_command(self._chromecast.media_controller.queue_next)
-
-    async def cmd_previous(self) -> None:
-        """Send previous track command to player."""
-        if self._chromecast.media_controller:
-            await self.chromecast_command(self._chromecast.media_controller.queue_prev)
-
-    async def cmd_power_on(self) -> None:
-        """Send power ON command to player."""
-        if self.is_group_player:
-            await self.launch_app()
-        else:
-            # chromecast has no real poweroff so we (ab)use mute instead
-            await self.chromecast_command(self._chromecast.set_volume_muted, False)
-
-    async def cmd_power_off(self) -> None:
-        """Send power OFF command to player."""
-        if self.is_group_player or (
-            self._chromecast.media_controller.is_active
-            and self.cast_status.app_id == self._chromecast.media_controller.app_id
-        ):
-            await self.chromecast_command(self._chromecast.quit_app)
-        if not self.is_group_player:
-            # chromecast has no real poweroff so we (ab)use mute instead
-            await self.chromecast_command(self._chromecast.set_volume_muted, True)
-
-    async def cmd_volume_set(self, volume_level: int) -> None:
-        """Send new volume level command to player."""
-        await self.chromecast_command(self._chromecast.set_volume, volume_level / 100)
-
-    async def cmd_volume_mute(self, is_muted: bool = False) -> None:
-        """Send mute command to player."""
-        await self.chromecast_command(self._chromecast.set_volume_muted, is_muted)
-
-    async def cmd_play_uri(self, uri: str) -> None:
-        """Play single uri on player."""
-        # create (fake) CC queue so that skip and previous will work
-        queue_item = QueueItem(
-            item_id=uri, provider="mass", name="Music Assistant", stream_url=uri
-        )
-        await self.cmd_queue_load([queue_item, queue_item], True)
-
-    async def cmd_queue_load(
-        self, queue_items: List[QueueItem], repeat: bool = False
-    ) -> None:
-        """Load (overwrite) queue with new items."""
-        cc_queue_items = self.__create_queue_items(queue_items[:25])
-        queuedata = {
-            "type": "QUEUE_LOAD",
-            "repeatMode": "REPEAT_ALL" if repeat else "REPEAT_OFF",
-            "shuffle": False,  # handled by our queue controller
-            "queueType": "PLAYLIST",
-            "startIndex": 0,  # Item index to play after this request or keep same item if undefined
-            "items": cc_queue_items,  # only load 25 tracks at once or the socket will crash
-        }
-        await self.launch_app()
-        await self.chromecast_command(self.__send_player_queue, queuedata)
-        if len(queue_items) > 25:
-            await asyncio.sleep(5)
-            await self.cmd_queue_append(queue_items[26:])
-
-    async def cmd_queue_append(self, queue_items: List[QueueItem]) -> None:
-        """Append new items at the end of the queue."""
-        cc_queue_items = self.__create_queue_items(queue_items)
-        async for chunk in yield_chunks(cc_queue_items, 25):
-            queuedata = {
-                "type": "QUEUE_INSERT",
-                "insertBefore": None,
-                "items": chunk,
-            }
-            await self.chromecast_command(self.__send_player_queue, queuedata)
-            await asyncio.sleep(2)
-
-    def __create_queue_items(self, tracks) -> None:
-        """Create list of CC queue items from tracks."""
-        return [self.__create_queue_item(track) for track in tracks]
-
-    @staticmethod
-    def __create_queue_item(queue_item: QueueItem):
-        """Create CC queue item from track info."""
-        return {
-            "opt_itemId": queue_item.queue_item_id,
-            "autoplay": True,
-            "preloadTime": 0,
-            "playbackDuration": int(queue_item.duration),
-            "startTime": 0,
-            "activeTrackIds": [],
-            "media": {
-                "contentId": queue_item.stream_url,
-                "customData": {
-                    "provider": queue_item.provider,
-                    "uri": queue_item.stream_url,
-                    "item_id": queue_item.queue_item_id,
-                },
-                "contentType": "audio/flac",
-                "streamType": pychromecast.STREAM_TYPE_BUFFERED,
-                "metadata": {
-                    "title": queue_item.name,
-                    "artist": "/".join(x.name for x in queue_item.artists),
-                },
-                "duration": int(queue_item.duration),
-            },
-        }
-
-    def __send_player_queue(self, queuedata: dict) -> None:
-        """Send new data to the CC queue."""
-        media_controller = self._chromecast.media_controller
-        queuedata["mediaSessionId"] = media_controller.status.media_session_id
-        media_controller.send_message(queuedata, False)
-
-    async def launch_app(self):
-        """Launch the default media receiver app and wait until its launched."""
-        media_controller = self._chromecast.media_controller
-        event = asyncio.Event()
-
-        def launched_callback():
-            self.mass.loop.call_soon_threadsafe(event.set)
-
-        # pylint: disable=protected-access
-        receiver_ctrl = media_controller._socket_client.receiver_controller
-        await self.mass.loop.run_in_executor(
-            None,
-            receiver_ctrl.launch_app,
-            media_controller.app_id,
-            False,
-            launched_callback,
-        )
-        await event.wait()
-
-    async def chromecast_command(self, func, *args):
-        """Execute command on Chromecast."""
-        if not self.available:
-            LOGGER.warning(
-                "Player %s is not available, command can't be executed", self.name
-            )
-            return
-        while self._command_busy:
-            await asyncio.sleep(0.05)
-        try:
-            # Sending multiple commands at the same time to the cast socket
-            # will make things unstable, make sure to throttle it.
-            self._command_busy = True
-            await self.mass.loop.run_in_executor(None, func, *args)
-        finally:
-            self._command_busy = False
diff --git a/music_assistant/providers/fanarttv/__init__.py b/music_assistant/providers/fanarttv/__init__.py
deleted file mode 100755 (executable)
index 3856e07..0000000
+++ /dev/null
@@ -1,108 +0,0 @@
-"""FanartTv Metadata provider."""
-
-import logging
-from json.decoder import JSONDecodeError
-from typing import Dict, List
-
-import aiohttp
-from asyncio_throttle import Throttler
-from music_assistant.models.config_entry import ConfigEntry
-from music_assistant.models.provider import MetadataProvider
-
-# TODO: add support for personal api keys ?
-# TODO: Add support for album artwork ?
-
-PROV_ID = "fanarttv"
-PROV_NAME = "Fanart.tv"
-
-LOGGER = logging.getLogger(PROV_ID)
-
-CONFIG_ENTRIES = []
-
-
-async def setup(mass) -> None:
-    """Perform async setup of this Plugin/Provider."""
-    prov = FanartTvProvider(mass)
-    await mass.register_provider(prov)
-
-
-class FanartTvProvider(MetadataProvider):
-    """Fanart.tv metadata provider."""
-
-    def __init__(self, mass):
-        """Initialize class."""
-        self.mass = mass
-        self.throttler = Throttler(rate_limit=1, period=2)
-
-    async def on_start(self) -> bool:
-        """
-        Handle initialization of the provider based on config.
-
-        Return bool if start was succesfull. Called on startup.
-        """
-        return True  # we have nothing to initialize
-
-    @property
-    def id(self) -> str:
-        """Return provider ID for this provider."""
-        return PROV_ID
-
-    @property
-    def name(self) -> str:
-        """Return provider Name for this provider."""
-        return PROV_NAME
-
-    @property
-    def config_entries(self) -> List[ConfigEntry]:
-        """Return Config Entries for this provider."""
-        return CONFIG_ENTRIES
-
-    async def get_artist_images(self, mb_artist_id: str) -> Dict:
-        """Retrieve images by musicbrainz artist id."""
-        metadata = {}
-        data = await self._get_data("music/%s" % mb_artist_id)
-        if data:
-            if data.get("hdmusiclogo"):
-                metadata["logo"] = data["hdmusiclogo"][0]["url"]
-            elif data.get("musiclogo"):
-                metadata["logo"] = data["musiclogo"][0]["url"]
-            if data.get("artistbackground"):
-                count = 0
-                for item in data["artistbackground"]:
-                    key = "fanart" if count == 0 else "fanart.%s" % count
-                    metadata[key] = item["url"]
-            if data.get("artistthumb"):
-                url = data["artistthumb"][0]["url"]
-                if "2a96cbd8b46e442fc41c2b86b821562f" not in url:
-                    metadata["image"] = url
-            if data.get("musicbanner"):
-                metadata["banner"] = data["musicbanner"][0]["url"]
-        return metadata
-
-    async def _get_data(self, endpoint, params=None):
-        """Get data from api."""
-        if params is None:
-            params = {}
-        url = "http://webservice.fanart.tv/v3/%s" % endpoint
-        params["api_key"] = "639191cb0774661597f28a47e7e2bad5"
-        async with self.throttler:
-            async with self.mass.http_session.get(
-                url, params=params, verify_ssl=False
-            ) as response:
-                try:
-                    result = await response.json()
-                except (
-                    aiohttp.client_exceptions.ContentTypeError,
-                    JSONDecodeError,
-                ):
-                    LOGGER.error("Failed to retrieve %s", endpoint)
-                    text_result = await response.text()
-                    LOGGER.debug(text_result)
-                    return None
-                except aiohttp.client_exceptions.ClientConnectorError:
-                    LOGGER.error("Failed to retrieve %s", endpoint)
-                    return None
-                if "error" in result and "limit" in result["error"]:
-                    LOGGER.error(result["error"])
-                    return None
-                return result
diff --git a/music_assistant/providers/fanarttv/icon.png b/music_assistant/providers/fanarttv/icon.png
deleted file mode 100644 (file)
index 17b39a4..0000000
Binary files a/music_assistant/providers/fanarttv/icon.png and /dev/null differ
diff --git a/music_assistant/providers/file/__init__.py b/music_assistant/providers/file/__init__.py
deleted file mode 100644 (file)
index d00df3e..0000000
+++ /dev/null
@@ -1,430 +0,0 @@
-"""Filesystem musicprovider support for MusicAssistant."""
-import base64
-import logging
-import os
-import re
-from typing import List, Optional
-
-import taglib
-from music_assistant.helpers.util import parse_title_and_version
-from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType
-from music_assistant.models.media_types import (
-    Album,
-    Artist,
-    MediaItemProviderId,
-    MediaType,
-    Playlist,
-    SearchResult,
-    Track,
-    TrackQuality,
-)
-from music_assistant.models.provider import MusicProvider
-from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
-
-PROV_ID = "file"
-PROV_NAME = "Local files and playlists"
-
-LOGGER = logging.getLogger(PROV_ID)
-
-CONF_MUSIC_DIR = "music_dir"
-CONF_PLAYLISTS_DIR = "playlists_dir"
-
-CONFIG_ENTRIES = [
-    ConfigEntry(
-        entry_key=CONF_MUSIC_DIR,
-        entry_type=ConfigEntryType.STRING,
-        label="file_prov_music_path",
-        description="file_prov_music_path_desc",
-    ),
-    ConfigEntry(
-        entry_key=CONF_PLAYLISTS_DIR,
-        entry_type=ConfigEntryType.STRING,
-        label="file_prov_playlists_path",
-        description="file_prov_playlists_path_desc",
-    ),
-]
-
-
-async def setup(mass):
-    """Perform async setup of this Plugin/Provider."""
-    prov = FileProvider()
-    await mass.register_provider(prov)
-
-
-class FileProvider(MusicProvider):
-    """
-    Very basic implementation of a musicprovider for local files.
-
-    Assumes files are stored on disk in format <artist>/<album>/<track.ext>
-    Reads ID3 tags from file and falls back to parsing filename
-    Supports m3u files only for playlists
-    Supports having URI's from streaming providers within m3u playlist
-    Should be compatible with LMS
-    """
-
-    # pylint chokes on taglib so ignore these
-    # pylint: disable=unsubscriptable-object,unsupported-membership-test
-
-    _music_dir = None
-    _playlists_dir = None
-
-    @property
-    def id(self) -> str:
-        """Return provider ID for this provider."""
-        return PROV_ID
-
-    @property
-    def name(self) -> str:
-        """Return provider Name for this provider."""
-        return PROV_NAME
-
-    @property
-    def config_entries(self) -> List[ConfigEntry]:
-        """Return Config Entries for this provider."""
-        return CONFIG_ENTRIES
-
-    @property
-    def supported_mediatypes(self) -> List[MediaType]:
-        """Return MediaTypes the provider supports."""
-        return [MediaType.ALBUM, MediaType.ARTIST, MediaType.PLAYLIST, MediaType.TRACK]
-
-    async def on_start(self) -> bool:
-        """Handle initialization of the provider based on config."""
-        conf = self.mass.config.get_provider_config(self.id)
-        if not conf[CONF_MUSIC_DIR]:
-            return False
-        if not os.path.isdir(conf[CONF_MUSIC_DIR]):
-            raise FileNotFoundError(f"Directory {conf[CONF_MUSIC_DIR]} does not exist")
-        self._music_dir = conf["music_dir"]
-        if os.path.isdir(conf[CONF_PLAYLISTS_DIR]):
-            self._playlists_dir = conf[CONF_PLAYLISTS_DIR]
-        else:
-            self._playlists_dir = None
-
-    async def on_stop(self):
-        """Handle correct close/cleanup of the provider on exit."""
-        # nothing to be done
-
-    async def search(
-        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
-    ) -> SearchResult:
-        """
-        Perform search on musicprovider.
-
-            :param search_query: Search query.
-            :param media_types: A list of media_types to include. All types if None.
-            :param limit: Number of items to return in the search (per type).
-        """
-        result = SearchResult()
-        # TODO !
-        return result
-
-    async def get_library_artists(self) -> List[Artist]:
-        """Retrieve all library artists."""
-        if not os.path.isdir(self._music_dir):
-            LOGGER.error("music path does not exist: %s", self._music_dir)
-            return None
-        result = []
-        for dirname in os.listdir(self._music_dir):
-            dirpath = os.path.join(self._music_dir, dirname)
-            if os.path.isdir(dirpath) and not dirpath.startswith("."):
-                artist = await self.get_artist(dirpath)
-                if artist:
-                    result.append(artist)
-        return result
-
-    async def get_library_albums(self) -> List[Album]:
-        """Get album folders recursively."""
-        result = []
-        for artist in await self.get_library_artists():
-            for album in await self.get_artist_albums(artist.item_id):
-                result.append(album)
-        return result
-
-    async def get_library_tracks(self) -> List[Track]:
-        """Get all tracks recursively."""
-        # TODO: support disk subfolders
-        result = []
-        for album in await self.get_library_albums():
-            for track in await self.get_album_tracks(album.item_id):
-                result.append(track)
-        return result
-
-    async def get_library_playlists(self) -> List[Playlist]:
-        """Retrieve playlists from disk."""
-        if not self._playlists_dir:
-            return []
-        result = []
-        for filename in os.listdir(self._playlists_dir):
-            filepath = os.path.join(self._playlists_dir, filename)
-            if (
-                os.path.isfile(filepath)
-                and not filename.startswith(".")
-                and filename.lower().endswith(".m3u")
-            ):
-                playlist = await self.get_playlist(filepath)
-                if playlist:
-                    result.append(playlist)
-        return result
-
-    async def get_artist(self, prov_artist_id: str) -> Artist:
-        """Get full artist details by id."""
-        if os.sep not in prov_artist_id:
-            itempath = base64.b64decode(prov_artist_id).decode("utf-8")
-        else:
-            itempath = prov_artist_id
-            prov_artist_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8")
-        if not os.path.isdir(itempath):
-            LOGGER.error("Artist path does not exist: %s", itempath)
-            return None
-        name = itempath.split(os.sep)[-1]
-        artist = Artist(item_id=prov_artist_id, provider=PROV_ID, name=name)
-        artist.provider_ids.add(
-            MediaItemProviderId(provider=PROV_ID, item_id=artist.item_id)
-        )
-        return artist
-
-    async def get_album(self, prov_album_id: str) -> Album:
-        """Get full album details by id."""
-        if os.sep not in prov_album_id:
-            itempath = base64.b64decode(prov_album_id).decode("utf-8")
-        else:
-            itempath = prov_album_id
-            prov_album_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8")
-        if not os.path.isdir(itempath):
-            LOGGER.error("album path does not exist: %s", itempath)
-            return None
-        name = itempath.split(os.sep)[-1]
-        artistpath = itempath.rsplit(os.sep, 1)[0]
-        album = Album(item_id=prov_album_id, provider=PROV_ID)
-        album.name, album.version = parse_title_and_version(name)
-        album.artist = await self.get_artist(artistpath)
-        if not album.artist:
-            raise Exception("No album artist ! %s" % artistpath)
-        album.provider_ids.add(
-            MediaItemProviderId(provider=PROV_ID, item_id=prov_album_id)
-        )
-        return album
-
-    async def get_track(self, prov_track_id: str) -> Track:
-        """Get full track details by id."""
-        if os.sep not in prov_track_id:
-            itempath = base64.b64decode(prov_track_id).decode("utf-8")
-        else:
-            itempath = prov_track_id
-        if not os.path.isfile(itempath):
-            LOGGER.error("track path does not exist: %s", itempath)
-            return None
-        return await self._parse_track(itempath)
-
-    async def get_playlist(self, prov_playlist_id: str) -> Playlist:
-        """Get full playlist details by id."""
-        if os.sep not in prov_playlist_id:
-            itempath = base64.b64decode(prov_playlist_id).decode("utf-8")
-        else:
-            itempath = prov_playlist_id
-            prov_playlist_id = base64.b64encode(itempath.encode("utf-8")).decode(
-                "utf-8"
-            )
-        if not os.path.isfile(itempath):
-            LOGGER.error("playlist path does not exist: %s", itempath)
-            return None
-        playlist = Playlist()
-        playlist.item_id = prov_playlist_id
-        playlist.provider = PROV_ID
-        playlist.name = itempath.split(os.sep)[-1].replace(".m3u", "")
-        playlist.is_editable = True
-        playlist.provider_ids.add(
-            MediaItemProviderId(provider=PROV_ID, item_id=prov_playlist_id)
-        )
-        playlist.owner = "disk"
-        playlist.checksum = os.path.getmtime(itempath)
-        return playlist
-
-    async def get_album_tracks(self, prov_album_id) -> List[Track]:
-        """Get album tracks for given album id."""
-        result = []
-        if os.sep not in prov_album_id:
-            albumpath = base64.b64decode(prov_album_id).decode("utf-8")
-        else:
-            albumpath = prov_album_id
-        if not os.path.isdir(albumpath):
-            LOGGER.error("album path does not exist: %s", albumpath)
-            return []
-        album = await self.get_album(albumpath)
-        for filename in os.listdir(albumpath):
-            filepath = os.path.join(albumpath, filename)
-            if os.path.isfile(filepath) and not filepath.startswith("."):
-                track = await self._parse_track(filepath)
-                if track:
-                    track.album = album
-                    result.append(track)
-        return result
-
-    async def get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]:
-        """Get playlist tracks for given playlist id."""
-        result = []
-        if os.sep not in prov_playlist_id:
-            itempath = base64.b64decode(prov_playlist_id).decode("utf-8")
-        else:
-            itempath = prov_playlist_id
-        if not os.path.isfile(itempath):
-            LOGGER.error("playlist path does not exist: %s", itempath)
-            return result
-        index = 0
-        with open(itempath) as _file:
-            for line in _file.readlines():
-                line = line.strip()
-                if line and not line.startswith("#"):
-                    track = await self._parse_track_from_uri(line)
-                    if track:
-                        result.append(track)
-                        index += 1
-        return result
-
-    async def get_artist_albums(self, prov_artist_id: str) -> List[Album]:
-        """Get a list of albums for the given artist."""
-        result = []
-        if os.sep not in prov_artist_id:
-            artistpath = base64.b64decode(prov_artist_id).decode("utf-8")
-        else:
-            artistpath = prov_artist_id
-        if not os.path.isdir(artistpath):
-            LOGGER.error("artist path does not exist: %s", artistpath)
-            return
-        for dirname in os.listdir(artistpath):
-            dirpath = os.path.join(artistpath, dirname)
-            if os.path.isdir(dirpath) and not dirpath.startswith("."):
-                album = await self.get_album(dirpath)
-                if album:
-                    result.append(album)
-        return result
-
-    async def get_artist_toptracks(self, prov_artist_id: str) -> List[Track]:
-        """Get a list of random tracks as we have no clue about preference."""
-        result = []
-        for album in await self.get_artist_albums(prov_artist_id):
-            for track in await self.get_album_tracks(album.item_id):
-                result.append(track)
-        return result
-
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
-        """Return the content details for the given track when it will be streamed."""
-        if os.sep not in item_id:
-            track_id = base64.b64decode(item_id).decode("utf-8")
-        if not os.path.isfile(track_id):
-            return None
-        # TODO: retrieve sanple rate and bitdepth
-        return StreamDetails(
-            type=StreamType.FILE,
-            provider=PROV_ID,
-            item_id=item_id,
-            content_type=ContentType(track_id.split(".")[-1]),
-            path=track_id,
-            sample_rate=44100,
-            bit_depth=16,
-        )
-
-    async def _parse_track(self, filename):
-        """Try to parse a track from a filename with taglib."""
-        # pylint: disable=broad-except
-        try:
-            song = taglib.File(filename)
-        except Exception:
-            return None  # not a media file ?
-        prov_item_id = base64.b64encode(filename.encode("utf-8")).decode("utf-8")
-        track = Track(item_id=prov_item_id, provider=PROV_ID)
-        track.duration = song.length
-        try:
-            name = song.tags["TITLE"][0]
-        except KeyError:
-            name = filename.split("/")[-1].split(".")[0]
-        track.name, track.version = parse_title_and_version(name)
-        albumpath = filename.rsplit(os.sep, 1)[0]
-        track.album = await self.get_album(albumpath)
-        if "ARTIST" in song.tags:
-            artists = set()
-            for artist_str in song.tags["ARTIST"]:
-                local_artist_path = os.path.join(self._music_dir, artist_str)
-                if os.path.isfile(local_artist_path):
-                    artist = await self.get_artist(local_artist_path)
-                else:
-                    fake_artistpath = os.path.join(self._music_dir, artist_str)
-                    artist = Artist(
-                        item_id=fake_artistpath, provider=PROV_ID, name=artist_str
-                    )
-                    artist.provider_ids.add(
-                        MediaItemProviderId(
-                            provider=PROV_ID,
-                            item_id=base64.b64encode(
-                                fake_artistpath.encode("utf-8")
-                            ).decode("utf-8"),
-                        )
-                    )
-                artists.add(artist)
-            track.artists = artists
-        else:
-            artistpath = filename.rsplit(os.sep, 2)[0]
-            artist = await self.get_artist(artistpath)
-            track.artists.add(artist)
-        if "GENRE" in song.tags:
-            track.metadata["genres"] = song.tags["GENRE"]
-        if "ISRC" in song.tags and song.tags["ISRC"]:
-            track.isrc = song.tags["ISRC"][0]
-        if "DISCNUMBER" in song.tags and song.tags["DISCNUMBER"]:
-            regexp_numbers = re.findall(r"\d+", song.tags["DISCNUMBER"][0])
-            track.disc_number = int(regexp_numbers[0] if regexp_numbers else "0")
-        if "TRACKNUMBER" in song.tags and song.tags["TRACKNUMBER"]:
-            regexp_numbers = re.findall(r"\d+", song.tags["TRACKNUMBER"][0])
-            track.track_number = int(regexp_numbers[0] if regexp_numbers else "0")
-        quality_details = ""
-        if filename.endswith(".flac"):
-            # TODO: get bit depth
-            quality = TrackQuality.FLAC_LOSSLESS
-            if song.sampleRate > 192000:
-                quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4
-            elif song.sampleRate > 96000:
-                quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3
-            elif song.sampleRate > 48000:
-                quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2
-            quality_details = "%s Khz" % (song.sampleRate / 1000)
-        elif filename.endswith(".ogg"):
-            quality = TrackQuality.LOSSY_OGG
-            quality_details = "%s kbps" % (song.bitrate)
-        elif filename.endswith(".m4a"):
-            quality = TrackQuality.LOSSY_AAC
-            quality_details = "%s kbps" % (song.bitrate)
-        else:
-            quality = TrackQuality.LOSSY_MP3
-            quality_details = "%s kbps" % (song.bitrate)
-        track.provider_ids.add(
-            MediaItemProviderId(
-                provider=PROV_ID,
-                item_id=prov_item_id,
-                quality=quality,
-                details=quality_details,
-            )
-        )
-        return track
-
-    async def _parse_track_from_uri(self, uri):
-        """Try to parse a track from an uri found in playlist."""
-        # pylint: disable=broad-except
-        if "://" in uri:
-            # track is uri from external provider?
-            prov_id = uri.split("://")[0]
-            prov_item_id = uri.split("/")[-1].split(".")[0].split(":")[-1]
-            try:
-                return await self.mass.music.get_track(prov_item_id, prov_id)
-            except Exception as exc:
-                LOGGER.warning("Could not parse uri %s to track: %s", uri, str(exc))
-                return None
-        # try to treat uri as filename
-        # TODO: filename could be related to musicdir or full path
-        track = await self.get_track(uri)
-        if track:
-            return track
-        track = await self.get_track(os.path.join(self._music_dir, uri))
-        if track:
-            return track
-        return None
diff --git a/music_assistant/providers/file/icon.png b/music_assistant/providers/file/icon.png
deleted file mode 100644 (file)
index bd2df04..0000000
Binary files a/music_assistant/providers/file/icon.png and /dev/null differ
diff --git a/music_assistant/providers/filesystem.py b/music_assistant/providers/filesystem.py
new file mode 100644 (file)
index 0000000..b70710d
--- /dev/null
@@ -0,0 +1,394 @@
+"""Filesystem musicprovider support for MusicAssistant."""
+import base64
+import os
+import re
+from typing import List, Optional
+import aiofiles
+
+import taglib
+
+from music_assistant.models.errors import InvalidDataError
+from music_assistant.helpers.util import parse_title_and_version
+from music_assistant.models.media_items import (
+    Album,
+    Artist,
+    ContentType,
+    MediaItemProviderId,
+    MediaItemType,
+    MediaQuality,
+    MediaType,
+    Playlist,
+    StreamDetails,
+    StreamType,
+    Track,
+)
+from music_assistant.models.provider import MusicProvider
+
+
+class FileSystemProvider(MusicProvider):
+    """
+    Very basic implementation of a musicprovider for local files.
+
+    Assumes files are stored on disk in format <artist>/<album>/<track.ext>
+    Reads ID3 tags from file and falls back to parsing filename
+    Supports m3u files only for playlists
+    Supports having URI's from streaming providers within m3u playlist
+    Should be compatible with LMS
+    """
+
+    # pylint chokes on taglib so ignore these
+    # pylint: disable=unsubscriptable-object,unsupported-membership-test
+
+    def __init__(self, music_dir: str, playlist_dir: str | None = None) -> None:
+        """
+        Initialize the Filesystem provider.
+
+        music_dir: Directory on disk containing music files
+        playlist_dir: Directory on disk containing playlist files (optional)
+
+        """
+        self._attr_id = "filesystem"
+        self._attr_name = "Filesystem"
+        self._playlists_dir = playlist_dir
+        self._music_dir = music_dir
+        self._attr_supported_mediatypes = [
+            MediaType.ARTIST,
+            MediaType.ALBUM,
+            MediaType.TRACK,
+        ]
+        if playlist_dir is not None:
+            self._attr_supported_mediatypes.append(MediaType.PLAYLIST)
+
+    async def setup(self) -> None:
+        """Handle async initialization of the provider."""
+        if not os.path.isdir(self._music_dir):
+            raise FileNotFoundError(f"Music Directory {self._music_dir} does not exist")
+        if self._playlists_dir is not None and not os.path.isdir(self._playlists_dir):
+            raise FileNotFoundError(
+                f"Playlist Directory {self._playlists_dir} does not exist"
+            )
+
+    async def search(
+        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
+    ) -> List[MediaItemType]:
+        """
+        Perform search on musicprovider.
+
+            :param search_query: Search query.
+            :param media_types: A list of media_types to include. All types if None.
+            :param limit: Number of items to return in the search (per type).
+        """
+        # TODO !
+        return []
+
+    async def get_library_artists(self) -> List[Artist]:
+        """Retrieve all library artists."""
+        if not os.path.isdir(self._music_dir):
+            self.logger.error("music path does not exist: %s", self._music_dir)
+            return None
+        result = []
+        for dirname in os.listdir(self._music_dir):
+            dirpath = os.path.join(self._music_dir, dirname)
+            if os.path.isdir(dirpath) and not dirpath.startswith("."):
+                artist = await self.get_artist(dirpath)
+                if artist:
+                    result.append(artist)
+        return result
+
+    async def get_library_albums(self) -> List[Album]:
+        """Get album folders recursively."""
+        result = []
+        for artist in await self.get_library_artists():
+            for album in await self.get_artist_albums(artist.item_id):
+                result.append(album)
+        return result
+
+    async def get_library_tracks(self) -> List[Track]:
+        """Get all tracks recursively."""
+        # TODO: support disk subfolders
+        result = []
+        for album in await self.get_library_albums():
+            for track in await self.get_album_tracks(album.item_id):
+                result.append(track)
+        return result
+
+    async def get_library_playlists(self) -> List[Playlist]:
+        """Retrieve playlists from disk."""
+        if not self._playlists_dir:
+            return []
+        result = []
+        for filename in os.listdir(self._playlists_dir):
+            filepath = os.path.join(self._playlists_dir, filename)
+            if (
+                os.path.isfile(filepath)
+                and not filename.startswith(".")
+                and filename.lower().endswith(".m3u")
+            ):
+                playlist = await self.get_playlist(filepath)
+                if playlist:
+                    result.append(playlist)
+        return result
+
+    async def get_artist(self, prov_artist_id: str) -> Artist:
+        """Get full artist details by id."""
+        if os.sep not in prov_artist_id:
+            itempath = base64.b64decode(prov_artist_id).decode("utf-8")
+        else:
+            itempath = prov_artist_id
+            prov_artist_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8")
+        if not os.path.isdir(itempath):
+            self.logger.error("Artist path does not exist: %s", itempath)
+            return None
+        name = itempath.split(os.sep)[-1]
+        artist = Artist(item_id=prov_artist_id, provider=self.id, name=name)
+        artist.provider_ids.append(
+            MediaItemProviderId(provider=self.id, item_id=artist.item_id)
+        )
+        return artist
+
+    async def get_album(self, prov_album_id: str) -> Album:
+        """Get full album details by id."""
+        if os.sep not in prov_album_id:
+            itempath = base64.b64decode(prov_album_id).decode("utf-8")
+        else:
+            itempath = prov_album_id
+            prov_album_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8")
+        if not os.path.isdir(itempath):
+            self.logger.error("album path does not exist: %s", itempath)
+            return None
+        name = itempath.split(os.sep)[-1]
+        artistpath = itempath.rsplit(os.sep, 1)[0]
+        name, version = parse_title_and_version(name)
+        album = Album(
+            item_id=prov_album_id, provider=self.id, name=name, version=version
+        )
+        album.artist = await self.get_artist(artistpath)
+        if not album.artist:
+            raise InvalidDataError(f"No album artist ! {artistpath}")
+        album.provider_ids.append(
+            MediaItemProviderId(provider=self.id, item_id=prov_album_id)
+        )
+        return album
+
+    async def get_track(self, prov_track_id: str) -> Track:
+        """Get full track details by id."""
+        if os.sep not in prov_track_id:
+            itempath = base64.b64decode(prov_track_id).decode("utf-8")
+        else:
+            itempath = prov_track_id
+        if not os.path.isfile(itempath):
+            self.logger.error("track path does not exist: %s", itempath)
+            return None
+        return await self._parse_track(itempath)
+
+    async def get_playlist(self, prov_playlist_id: str) -> Playlist:
+        """Get full playlist details by id."""
+        if os.sep not in prov_playlist_id:
+            itempath = base64.b64decode(prov_playlist_id).decode("utf-8")
+        else:
+            itempath = prov_playlist_id
+            prov_playlist_id = base64.b64encode(itempath.encode("utf-8")).decode(
+                "utf-8"
+            )
+        if not os.path.isfile(itempath):
+            self.logger.error("playlist path does not exist: %s", itempath)
+            return None
+        name = itempath.split(os.sep)[-1].replace(".m3u", "")
+        playlist = Playlist(prov_playlist_id, provider=self.id, name=name)
+        playlist.is_editable = True
+        playlist.provider_ids.append(
+            MediaItemProviderId(provider=self.id, item_id=prov_playlist_id)
+        )
+        playlist.owner = "disk"
+        playlist.checksum = os.path.getmtime(itempath)
+        return playlist
+
+    async def get_album_tracks(self, prov_album_id) -> List[Track]:
+        """Get album tracks for given album id."""
+        result = []
+        if os.sep not in prov_album_id:
+            albumpath = base64.b64decode(prov_album_id).decode("utf-8")
+        else:
+            albumpath = prov_album_id
+        if not os.path.isdir(albumpath):
+            self.logger.error("album path does not exist: %s", albumpath)
+            return []
+        album = await self.get_album(albumpath)
+        for filename in os.listdir(albumpath):
+            filepath = os.path.join(albumpath, filename)
+            if os.path.isfile(filepath) and not filepath.startswith("."):
+                track = await self._parse_track(filepath)
+                if track:
+                    track.album = album
+                    result.append(track)
+        return result
+
+    async def get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]:
+        """Get playlist tracks for given playlist id."""
+        result = []
+        if os.sep not in prov_playlist_id:
+            itempath = base64.b64decode(prov_playlist_id).decode("utf-8")
+        else:
+            itempath = prov_playlist_id
+        if not os.path.isfile(itempath):
+            self.logger.error("playlist path does not exist: %s", itempath)
+            return result
+        index = 0
+        async with aiofiles.open(itempath, "r") as _file:
+            for line in await _file.readlines():
+                line = line.strip()
+                if line and not line.startswith("#"):
+                    track = await self._parse_track_from_uri(line)
+                    if track:
+                        result.append(track)
+                        index += 1
+        return result
+
+    async def get_artist_albums(self, prov_artist_id: str) -> List[Album]:
+        """Get a list of albums for the given artist."""
+        result = []
+        if os.sep not in prov_artist_id:
+            artistpath = base64.b64decode(prov_artist_id).decode("utf-8")
+        else:
+            artistpath = prov_artist_id
+        if not os.path.isdir(artistpath):
+            self.logger.error("artist path does not exist: %s", artistpath)
+            return
+        for dirname in os.listdir(artistpath):
+            dirpath = os.path.join(artistpath, dirname)
+            if os.path.isdir(dirpath) and not dirpath.startswith("."):
+                album = await self.get_album(dirpath)
+                if album:
+                    result.append(album)
+        return result
+
+    async def get_artist_toptracks(self, prov_artist_id: str) -> List[Track]:
+        """Get a list of random tracks as we have no clue about preference."""
+        result = []
+        for album in await self.get_artist_albums(prov_artist_id):
+            for track in await self.get_album_tracks(album.item_id):
+                result.append(track)
+        return result
+
+    async def get_stream_details(self, item_id: str) -> StreamDetails:
+        """Return the content details for the given track when it will be streamed."""
+        if os.sep not in item_id:
+            track_id = base64.b64decode(item_id).decode("utf-8")
+        if not os.path.isfile(track_id):
+            return None
+        # TODO: retrieve sanple rate and bitdepth
+        return StreamDetails(
+            type=StreamType.FILE,
+            provider=self.id,
+            item_id=item_id,
+            content_type=ContentType(track_id.split(".")[-1]),
+            path=track_id,
+            sample_rate=44100,
+            bit_depth=16,
+        )
+
+    async def _parse_track(self, filename):
+        """Try to parse a track from a filename with taglib."""
+        # pylint: disable=broad-except
+        try:
+            song = taglib.File(filename)
+        except Exception:
+            return None  # not a media file ?
+        prov_item_id = base64.b64encode(filename.encode("utf-8")).decode("utf-8")
+        try:
+            name = song.tags["TITLE"][0]
+        except KeyError:
+            name = filename.split("/")[-1].split(".")[0]
+        name, version = parse_title_and_version(name)
+        track = Track(
+            item_id=prov_item_id, provider=self.id, name=name, version=version
+        )
+        track.duration = song.length
+        albumpath = filename.rsplit(os.sep, 1)[0]
+        track.album = await self.get_album(albumpath)
+        if "ARTIST" in song.tags:
+            artists = []
+            for artist_str in song.tags["ARTIST"]:
+                local_artist_path = os.path.join(self._music_dir, artist_str)
+                if os.path.isfile(local_artist_path):
+                    artist = await self.get_artist(local_artist_path)
+                else:
+                    fake_artistpath = os.path.join(self._music_dir, artist_str)
+                    artist = Artist(
+                        item_id=fake_artistpath, provider=self.id, name=artist_str
+                    )
+                    artist.provider_ids.append(
+                        MediaItemProviderId(
+                            provider=self.id,
+                            item_id=base64.b64encode(
+                                fake_artistpath.encode("utf-8")
+                            ).decode("utf-8"),
+                        )
+                    )
+                artists.append(artist)
+            track.artists = artists
+        else:
+            artistpath = filename.rsplit(os.sep, 2)[0]
+            artist = await self.get_artist(artistpath)
+            track.artists.append(artist)
+        if "GENRE" in song.tags:
+            track.metadata["genres"] = song.tags["GENRE"]
+        if "ISRC" in song.tags and song.tags["ISRC"]:
+            track.isrc = song.tags["ISRC"][0]
+        if "DISCNUMBER" in song.tags and song.tags["DISCNUMBER"]:
+            regexp_numbers = re.findall(r"\d+", song.tags["DISCNUMBER"][0])
+            track.disc_number = int(regexp_numbers[0] if regexp_numbers else "0")
+        if "TRACKNUMBER" in song.tags and song.tags["TRACKNUMBER"]:
+            regexp_numbers = re.findall(r"\d+", song.tags["TRACKNUMBER"][0])
+            track.track_number = int(regexp_numbers[0] if regexp_numbers else "0")
+        quality_details = ""
+        if filename.endswith(".flac"):
+            # TODO: get bit depth
+            quality = MediaQuality.FLAC_LOSSLESS
+            if song.sampleRate > 192000:
+                quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4
+            elif song.sampleRate > 96000:
+                quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3
+            elif song.sampleRate > 48000:
+                quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2
+            quality_details = f"{song.sampleRate / 1000} Khz"
+        elif filename.endswith(".ogg"):
+            quality = MediaQuality.LOSSY_OGG
+            quality_details = f"{song.bitrate} kbps"
+        elif filename.endswith(".m4a"):
+            quality = MediaQuality.LOSSY_AAC
+            quality_details = f"{song.bitrate} kbps"
+        else:
+            quality = MediaQuality.LOSSY_MP3
+            quality_details = f"{song.bitrate} kbps"
+        track.provider_ids.append(
+            MediaItemProviderId(
+                provider=self.id,
+                item_id=prov_item_id,
+                quality=quality,
+                details=quality_details,
+            )
+        )
+        return track
+
+    async def _parse_track_from_uri(self, uri):
+        """Try to parse a track from an uri found in playlist."""
+        # pylint: disable=broad-except
+        if "://" in uri:
+            # track is uri from external provider?
+            try:
+                return await self.mass.music.get_item_by_uri(uri)
+            except Exception as exc:
+                self.logger.warning(
+                    "Could not parse uri %s to track: %s", uri, str(exc)
+                )
+                return None
+        # try to treat uri as filename
+        # TODO: filename could be related to musicdir or full path
+        track = await self.get_track(uri)
+        if track:
+            return track
+        track = await self.get_track(os.path.join(self._music_dir, uri))
+        if track:
+            return track
+        return None
diff --git a/music_assistant/providers/qobuz.py b/music_assistant/providers/qobuz.py
new file mode 100644 (file)
index 0000000..92d59e3
--- /dev/null
@@ -0,0 +1,703 @@
+"""Qobuz musicprovider support for MusicAssistant."""
+
+import datetime
+import hashlib
+import time
+from typing import List, Optional
+
+from asyncio_throttle import Throttler
+
+from music_assistant.constants import EventType
+from music_assistant.helpers.app_vars import (  # pylint: disable=no-name-in-module
+    get_app_var,
+)
+from music_assistant.models.errors import LoginFailed
+from music_assistant.helpers.util import parse_title_and_version, try_parse_int
+from music_assistant.models.media_items import (
+    Album,
+    AlbumType,
+    Artist,
+    ContentType,
+    MediaItemProviderId,
+    MediaItemType,
+    MediaQuality,
+    MediaType,
+    Playlist,
+    StreamDetails,
+    StreamType,
+    Track,
+)
+from music_assistant.models.provider import MusicProvider
+
+
+class QobuzProvider(MusicProvider):
+    """Provider for the Qobux music service."""
+
+    def __init__(self, username: str, password: str) -> None:
+        """Initialize the Spotify provider."""
+        self._attr_id = "qobuz"
+        self._attr_name = "Qobuz"
+        self._attr_supported_mediatypes = [
+            MediaType.ARTIST,
+            MediaType.ALBUM,
+            MediaType.TRACK,
+            MediaType.PLAYLIST,
+        ]
+        self._username = username
+        self._password = password
+        self.__user_auth_info = None
+        self._throttler = Throttler(rate_limit=4, period=1)
+
+    async def setup(self) -> None:
+        """Handle async initialization of the provider."""
+        # try to get a token, raise if that fails
+        token = await self._auth_token()
+        if not token:
+            raise LoginFailed(f"Login failed for user {self._username}")
+        # subscribe to stream events so we can report playback to Qobuz
+        self.mass.subscribe(
+            self.on_stream_event, (EventType.STREAM_STARTED, EventType.STREAM_ENDED)
+        )
+
+    async def search(
+        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
+    ) -> List[MediaItemType]:
+        """
+        Perform search on musicprovider.
+
+            :param search_query: Search query.
+            :param media_types: A list of media_types to include. All types if None.
+            :param limit: Number of items to return in the search (per type).
+        """
+        result = []
+        params = {"query": search_query, "limit": limit}
+        if len(media_types) == 1:
+            # qobuz does not support multiple searchtypes, falls back to all if no type given
+            if media_types[0] == MediaType.ARTIST:
+                params["type"] = "artists"
+            if media_types[0] == MediaType.ALBUM:
+                params["type"] = "albums"
+            if media_types[0] == MediaType.TRACK:
+                params["type"] = "tracks"
+            if media_types[0] == MediaType.PLAYLIST:
+                params["type"] = "playlists"
+        if searchresult := await self._get_data("catalog/search", params):
+            if "artists" in searchresult:
+                result += [
+                    await self._parse_artist(item)
+                    for item in searchresult["artists"]["items"]
+                    if (item and item["id"])
+                ]
+            if "albums" in searchresult:
+                result += [
+                    await self._parse_album(item)
+                    for item in searchresult["albums"]["items"]
+                    if (item and item["id"])
+                ]
+            if "tracks" in searchresult:
+                result += [
+                    await self._parse_track(item)
+                    for item in searchresult["tracks"]["items"]
+                    if (item and item["id"])
+                ]
+            if "playlists" in searchresult:
+                result += [
+                    await self._parse_playlist(item)
+                    for item in searchresult["playlists"]["items"]
+                    if (item and item["id"])
+                ]
+        return result
+
+    async def get_library_artists(self) -> List[Artist]:
+        """Retrieve all library artists from Qobuz."""
+        params = {"type": "artists"}
+        endpoint = "favorite/getUserFavorites"
+        return [
+            await self._parse_artist(item)
+            for item in await self._get_all_items(endpoint, params, key="artists")
+            if (item and item["id"])
+        ]
+
+    async def get_library_albums(self) -> List[Album]:
+        """Retrieve all library albums from Qobuz."""
+        params = {"type": "albums"}
+        endpoint = "favorite/getUserFavorites"
+        return [
+            await self._parse_album(item)
+            for item in await self._get_all_items(endpoint, params, key="albums")
+            if (item and item["id"])
+        ]
+
+    async def get_library_tracks(self) -> List[Track]:
+        """Retrieve library tracks from Qobuz."""
+        params = {"type": "tracks"}
+        endpoint = "favorite/getUserFavorites"
+        return [
+            await self._parse_track(item)
+            for item in await self._get_all_items(endpoint, params, key="tracks")
+            if (item and item["id"])
+        ]
+
+    async def get_library_playlists(self) -> List[Playlist]:
+        """Retrieve all library playlists from the provider."""
+        endpoint = "playlist/getUserPlaylists"
+        return [
+            await self._parse_playlist(item)
+            for item in await self._get_all_items(endpoint, key="playlists")
+            if (item and item["id"])
+        ]
+
+    async def get_artist(self, prov_artist_id) -> Artist:
+        """Get full artist details by id."""
+        params = {"artist_id": prov_artist_id}
+        artist_obj = await self._get_data("artist/get", params)
+        return (
+            await self._parse_artist(artist_obj)
+            if artist_obj and artist_obj["id"]
+            else None
+        )
+
+    async def get_album(self, prov_album_id) -> Album:
+        """Get full album details by id."""
+        params = {"album_id": prov_album_id}
+        album_obj = await self._get_data("album/get", params)
+        return (
+            await self._parse_album(album_obj)
+            if album_obj and album_obj["id"]
+            else None
+        )
+
+    async def get_track(self, prov_track_id) -> Track:
+        """Get full track details by id."""
+        params = {"track_id": prov_track_id}
+        track_obj = await self._get_data("track/get", params)
+        return (
+            await self._parse_track(track_obj)
+            if track_obj and track_obj["id"]
+            else None
+        )
+
+    async def get_playlist(self, prov_playlist_id) -> Playlist:
+        """Get full playlist details by id."""
+        params = {"playlist_id": prov_playlist_id}
+        playlist_obj = await self._get_data("playlist/get", params)
+        return (
+            await self._parse_playlist(playlist_obj)
+            if playlist_obj and playlist_obj["id"]
+            else None
+        )
+
+    async def get_album_tracks(self, prov_album_id) -> List[Track]:
+        """Get all album tracks for given album id."""
+        params = {"album_id": prov_album_id}
+        return [
+            await self._parse_track(item)
+            for item in await self._get_all_items("album/get", params, key="tracks")
+            if (item and item["id"])
+        ]
+
+    async def get_playlist_tracks(self, prov_playlist_id) -> List[Track]:
+        """Get all playlist tracks for given playlist id."""
+        params = {"playlist_id": prov_playlist_id, "extra": "tracks"}
+        endpoint = "playlist/get"
+        return [
+            await self._parse_track(item)
+            for item in await self._get_all_items(endpoint, params, key="tracks")
+            if (item and item["id"])
+        ]
+
+    async def get_artist_albums(self, prov_artist_id) -> List[Album]:
+        """Get a list of albums for the given artist."""
+        params = {"artist_id": prov_artist_id, "extra": "albums"}
+        endpoint = "artist/get"
+        return [
+            await self._parse_album(item)
+            for item in await self._get_all_items(endpoint, params, key="albums")
+            if (item and item["id"] and str(item["artist"]["id"]) == prov_artist_id)
+        ]
+
+    async def get_artist_toptracks(self, prov_artist_id) -> List[Track]:
+        """Get a list of most popular tracks for the given artist."""
+        params = {
+            "artist_id": prov_artist_id,
+            "extra": "playlists",
+            "offset": 0,
+            "limit": 25,
+        }
+        result = await self._get_data("artist/get", params)
+        if result and result["playlists"]:
+            return [
+                await self._parse_track(item)
+                for item in result["playlists"][0]["tracks"]["items"]
+                if (item and item["id"])
+            ]
+        # fallback to search
+        artist = await self.get_artist(prov_artist_id)
+        params = {"query": artist.name, "limit": 25, "type": "tracks"}
+        searchresult = await self._get_data("catalog/search", params)
+        return [
+            await self._parse_track(item)
+            for item in searchresult["tracks"]["items"]
+            if (
+                item
+                and item["id"]
+                and "performer" in item
+                and str(item["performer"]["id"]) == str(prov_artist_id)
+            )
+        ]
+
+    async def get_similar_artists(self, prov_artist_id):
+        """Get similar artists for given artist."""
+        # https://www.qobuz.com/api.json/0.2/artist/getSimilarArtists?artist_id=220020&offset=0&limit=3
+
+    async def library_add(self, prov_item_id, media_type: MediaType):
+        """Add item to library."""
+        result = None
+        if media_type == MediaType.ARTIST:
+            result = await self._get_data(
+                "favorite/create", {"artist_ids": prov_item_id}
+            )
+        elif media_type == MediaType.ALBUM:
+            result = await self._get_data(
+                "favorite/create", {"album_ids": prov_item_id}
+            )
+        elif media_type == MediaType.TRACK:
+            result = await self._get_data(
+                "favorite/create", {"track_ids": prov_item_id}
+            )
+        elif media_type == MediaType.PLAYLIST:
+            result = await self._get_data(
+                "playlist/subscribe", {"playlist_id": prov_item_id}
+            )
+        return result
+
+    async def library_remove(self, prov_item_id, media_type: MediaType):
+        """Remove item from library."""
+        result = None
+        if media_type == MediaType.ARTIST:
+            result = await self._get_data(
+                "favorite/delete", {"artist_ids": prov_item_id}
+            )
+        elif media_type == MediaType.ALBUM:
+            result = await self._get_data(
+                "favorite/delete", {"album_ids": prov_item_id}
+            )
+        elif media_type == MediaType.TRACK:
+            result = await self._get_data(
+                "favorite/delete", {"track_ids": prov_item_id}
+            )
+        elif media_type == MediaType.PLAYLIST:
+            playlist = await self.get_playlist(prov_item_id)
+            if playlist.is_editable:
+                result = await self._get_data(
+                    "playlist/delete", {"playlist_id": prov_item_id}
+                )
+            else:
+                result = await self._get_data(
+                    "playlist/unsubscribe", {"playlist_id": prov_item_id}
+                )
+        return result
+
+    async def add_playlist_tracks(self, prov_playlist_id, prov_track_ids):
+        """Add track(s) to playlist."""
+        params = {
+            "playlist_id": prov_playlist_id,
+            "track_ids": ",".join(prov_track_ids),
+            "playlist_track_ids": ",".join(prov_track_ids),
+        }
+        return await self._get_data("playlist/addTracks", params)
+
+    async def remove_playlist_tracks(self, prov_playlist_id, prov_track_ids):
+        """Remove track(s) from playlist."""
+        playlist_track_ids = set()
+        params = {"playlist_id": prov_playlist_id, "extra": "tracks"}
+        for track in await self._get_all_items("playlist/get", params, key="tracks"):
+            if str(track["id"]) in prov_track_ids:
+                playlist_track_ids.add(str(track["playlist_track_id"]))
+        params = {
+            "playlist_id": prov_playlist_id,
+            "playlist_track_ids": ",".join(playlist_track_ids),
+        }
+        return await self._get_data("playlist/deleteTracks", params)
+
+    async def get_stream_details(self, item_id: str) -> StreamDetails:
+        """Return the content details for the given track when it will be streamed."""
+        streamdata = None
+        for format_id in [27, 7, 6, 5]:
+            # it seems that simply requesting for highest available quality does not work
+            # from time to time the api response is empty for this request ?!
+            params = {"format_id": format_id, "track_id": item_id, "intent": "stream"}
+            result = await self._get_data("track/getFileUrl", params, sign_request=True)
+            if result and result.get("url"):
+                streamdata = result
+                break
+        if not streamdata:
+            self.logger.error("Unable to retrieve stream details for track %s", item_id)
+            return None
+        if streamdata["mime_type"] == "audio/mpeg":
+            content_type = ContentType.MPEG
+        elif streamdata["mime_type"] == "audio/flac":
+            content_type = ContentType.FLAC
+        else:
+            self.logger.error("Unsupported mime type for track %s", item_id)
+            return None
+        return StreamDetails(
+            type=StreamType.URL,
+            item_id=str(item_id),
+            provider=self.id,
+            path=streamdata["url"],
+            content_type=content_type,
+            sample_rate=int(streamdata["sampling_rate"] * 1000),
+            bit_depth=streamdata["bit_depth"],
+            details=streamdata,  # we need these details for reporting playback
+        )
+
+    async def on_stream_event(self, msg, msg_details):
+        """
+        Received event from mass.
+
+        We use this to report playback start/stop to qobuz.
+        """
+        if not self.__user_auth_info:
+            return
+        # TODO: need to figure out if the streamed track is purchased by user
+        # https://www.qobuz.com/api.json/0.2/purchase/getUserPurchasesIds?limit=5000&user_id=xxxxxxx
+        # {"albums":{"total":0,"items":[]},"tracks":{"total":0,"items":[]},"user":{"id":xxxx,"login":"xxxxx"}}
+        if msg == EventType.STREAM_STARTED and msg_details.provider == self.id:
+            # report streaming started to qobuz
+            device_id = self.__user_auth_info["user"]["device"]["id"]
+            credential_id = self.__user_auth_info["user"]["credential"]["id"]
+            user_id = self.__user_auth_info["user"]["id"]
+            format_id = msg_details.details["format_id"]
+            timestamp = int(time.time())
+            events = [
+                {
+                    "online": True,
+                    "sample": False,
+                    "intent": "stream",
+                    "device_id": device_id,
+                    "track_id": str(msg_details.item_id),
+                    "purchase": False,
+                    "date": timestamp,
+                    "credential_id": credential_id,
+                    "user_id": user_id,
+                    "local": False,
+                    "format_id": format_id,
+                }
+            ]
+            await self._post_data("track/reportStreamingStart", data=events)
+        elif msg == EventType.STREAM_ENDED and msg_details.provider == self.id:
+            # report streaming ended to qobuz
+            user_id = self.__user_auth_info["user"]["id"]
+            params = {
+                "user_id": user_id,
+                "track_id": str(msg_details.item_id),
+                "duration": try_parse_int(msg_details.seconds_played),
+            }
+            await self._get_data("/track/reportStreamingEnd", params)
+
+    async def _parse_artist(self, artist_obj):
+        """Parse qobuz artist object to generic layout."""
+        artist = Artist(
+            item_id=str(artist_obj["id"]), provider=self.id, name=artist_obj["name"]
+        )
+        artist.provider_ids.append(
+            MediaItemProviderId(provider=self.id, item_id=str(artist_obj["id"]))
+        )
+        artist.metadata["image"] = self.__get_image(artist_obj)
+        if artist_obj.get("biography"):
+            artist.metadata["biography"] = artist_obj["biography"].get("content", "")
+        if artist_obj.get("url"):
+            artist.metadata["qobuz_url"] = artist_obj["url"]
+        return artist
+
+    async def _parse_album(self, album_obj: dict, artist_obj: dict = None):
+        """Parse qobuz album object to generic layout."""
+        if not artist_obj and "artist" not in album_obj:
+            # artist missing in album info, return full abum instead
+            return await self.get_album(album_obj["id"])
+        name, version = parse_title_and_version(
+            album_obj["title"], album_obj.get("version")
+        )
+        album = Album(
+            item_id=str(album_obj["id"]), provider=self.id, name=name, version=version
+        )
+        if album_obj["maximum_sampling_rate"] > 192:
+            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4
+        elif album_obj["maximum_sampling_rate"] > 96:
+            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3
+        elif album_obj["maximum_sampling_rate"] > 48:
+            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2
+        elif album_obj["maximum_bit_depth"] > 16:
+            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_1
+        elif album_obj.get("format_id", 0) == 5:
+            quality = MediaQuality.LOSSY_AAC
+        else:
+            quality = MediaQuality.FLAC_LOSSLESS
+        album.provider_ids.append(
+            MediaItemProviderId(
+                provider=self.id,
+                item_id=str(album_obj["id"]),
+                quality=quality,
+                details=f'{album_obj["maximum_sampling_rate"]}kHz {album_obj["maximum_bit_depth"]}bit',
+                available=album_obj["streamable"] and album_obj["displayable"],
+            )
+        )
+
+        if artist_obj:
+            album.artist = artist_obj
+        else:
+            album.artist = await self._parse_artist(album_obj["artist"])
+        if (
+            album_obj.get("product_type", "") == "single"
+            or album_obj.get("release_type", "") == "single"
+        ):
+            album.album_type = AlbumType.SINGLE
+        elif (
+            album_obj.get("product_type", "") == "compilation"
+            or "Various" in album.artist.name
+        ):
+            album.album_type = AlbumType.COMPILATION
+        elif (
+            album_obj.get("product_type", "") == "album"
+            or album_obj.get("release_type", "") == "album"
+        ):
+            album.album_type = AlbumType.ALBUM
+        if "genre" in album_obj:
+            album.metadata["genre"] = album_obj["genre"]["name"]
+        album.metadata["image"] = self.__get_image(album_obj)
+        if len(album_obj["upc"]) == 13:
+            # qobuz writes ean as upc ?!
+            album.metadata["ean"] = album_obj["upc"]
+            album.upc = album_obj["upc"][1:]
+        else:
+            album.upc = album_obj["upc"]
+        if "label" in album_obj:
+            album.metadata["label"] = album_obj["label"]["name"]
+        if album_obj.get("released_at"):
+            album.year = datetime.datetime.fromtimestamp(album_obj["released_at"]).year
+        if album_obj.get("copyright"):
+            album.metadata["copyright"] = album_obj["copyright"]
+        if album_obj.get("hires"):
+            album.metadata["hires"] = "true"
+        if album_obj.get("url"):
+            album.metadata["qobuz_url"] = album_obj["url"]
+        if album_obj.get("description"):
+            album.metadata["description"] = album_obj["description"]
+        return album
+
+    async def _parse_track(self, track_obj):
+        """Parse qobuz track object to generic layout."""
+        name, version = parse_title_and_version(
+            track_obj["title"], track_obj.get("version")
+        )
+        track = Track(
+            item_id=str(track_obj["id"]),
+            provider=self.id,
+            name=name,
+            version=version,
+            disc_number=track_obj["media_number"],
+            track_number=track_obj["track_number"],
+            duration=track_obj["duration"],
+        )
+        if track_obj.get("performer") and "Various " not in track_obj["performer"]:
+            artist = await self._parse_artist(track_obj["performer"])
+            if artist:
+                track.artists.append(artist)
+        if not track.artists:
+            # try to grab artist from album
+            if (
+                track_obj.get("album")
+                and track_obj["album"].get("artist")
+                and "Various " not in track_obj["album"]["artist"]
+            ):
+                artist = await self._parse_artist(track_obj["album"]["artist"])
+                if artist:
+                    track.artists.append(artist)
+        if not track.artists:
+            # last resort: parse from performers string
+            for performer_str in track_obj["performers"].split(" - "):
+                role = performer_str.split(", ")[1]
+                name = performer_str.split(", ")[0]
+                if "artist" in role.lower():
+                    artist = Artist(name, self.id, name)
+                track.artists.append(artist)
+        # TODO: fix grabbing composer from details
+
+        if "album" in track_obj:
+            album = await self._parse_album(track_obj["album"])
+            if album:
+                track.album = album
+        if track_obj.get("hires"):
+            track.metadata["hires"] = "true"
+        if track_obj.get("url"):
+            track.metadata["qobuz_url"] = track_obj["url"]
+        if track_obj.get("isrc"):
+            track.isrc = track_obj["isrc"]
+        if track_obj.get("performers"):
+            track.metadata["performers"] = track_obj["performers"]
+        if track_obj.get("copyright"):
+            track.metadata["copyright"] = track_obj["copyright"]
+        if track_obj.get("audio_info"):
+            track.metadata["replaygain"] = track_obj["audio_info"][
+                "replaygain_track_gain"
+            ]
+        if track_obj.get("parental_warning"):
+            track.metadata["explicit"] = True
+        track.metadata["image"] = self.__get_image(track_obj)
+        # get track quality
+        if track_obj["maximum_sampling_rate"] > 192:
+            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4
+        elif track_obj["maximum_sampling_rate"] > 96:
+            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3
+        elif track_obj["maximum_sampling_rate"] > 48:
+            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2
+        elif track_obj["maximum_bit_depth"] > 16:
+            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_1
+        elif track_obj.get("format_id", 0) == 5:
+            quality = MediaQuality.LOSSY_AAC
+        else:
+            quality = MediaQuality.FLAC_LOSSLESS
+        track.provider_ids.append(
+            MediaItemProviderId(
+                provider=self.id,
+                item_id=str(track_obj["id"]),
+                quality=quality,
+                details=f'{track_obj["maximum_sampling_rate"]}kHz {track_obj["maximum_bit_depth"]}bit',
+                available=track_obj["streamable"] and track_obj["displayable"],
+            )
+        )
+        return track
+
+    async def _parse_playlist(self, playlist_obj):
+        """Parse qobuz playlist object to generic layout."""
+        playlist = Playlist(
+            item_id=str(playlist_obj["id"]),
+            provider=self.id,
+            name=playlist_obj["name"],
+            owner=playlist_obj["owner"]["name"],
+        )
+        playlist.provider_ids.append(
+            MediaItemProviderId(provider=self.id, item_id=str(playlist_obj["id"]))
+        )
+        playlist.is_editable = (
+            playlist_obj["owner"]["id"] == self.__user_auth_info["user"]["id"]
+            or playlist_obj["is_collaborative"]
+        )
+        playlist.metadata["image"] = self.__get_image(playlist_obj)
+        if playlist_obj.get("url"):
+            playlist.metadata["qobuz_url"] = playlist_obj["url"]
+        playlist.checksum = playlist_obj["updated_at"]
+        return playlist
+
+    async def _auth_token(self):
+        """Login to qobuz and store the token."""
+        if self.__user_auth_info:
+            return self.__user_auth_info["user_auth_token"]
+        params = {
+            "username": self._username,
+            "password": self._password,
+            "device_manufacturer_id": "music_assistant",
+        }
+        details = await self._get_data("user/login", params)
+        if details and "user" in details:
+            self.__user_auth_info = details
+            self.logger.info(
+                "Succesfully logged in to Qobuz as %s", details["user"]["display_name"]
+            )
+            return details["user_auth_token"]
+
+    async def _get_all_items(self, endpoint, params=None, key="tracks"):
+        """Get all items from a paged list."""
+        if not params:
+            params = {}
+        limit = 50
+        offset = 0
+        all_items = []
+        while True:
+            params["limit"] = limit
+            params["offset"] = offset
+            result = await self._get_data(endpoint, params=params)
+            offset += limit
+            if not result:
+                break
+            if not result.get(key) or not result[key].get("items"):
+                break
+            all_items += result[key]["items"]
+            if len(result[key]["items"]) < limit:
+                break
+        return all_items
+
+    async def _get_data(self, endpoint, params=None, sign_request=False):
+        """Get data from api."""
+        if not params:
+            params = {}
+        url = f"http://www.qobuz.com/api.json/0.2/{endpoint}"
+        headers = {"X-App-Id": get_app_var(0)}
+        if endpoint != "user/login":
+            auth_token = await self._auth_token()
+            if not auth_token:
+                self.logger.debug("Not logged in")
+                return None
+            headers["X-User-Auth-Token"] = auth_token
+        if sign_request:
+            signing_data = "".join(endpoint.split("/"))
+            keys = list(params.keys())
+            keys.sort()
+            for key in keys:
+                signing_data += f"{key}{params[key]}"
+            request_ts = str(time.time())
+            request_sig = signing_data + request_ts + get_app_var(1)
+            request_sig = str(hashlib.md5(request_sig.encode()).hexdigest())
+            params["request_ts"] = request_ts
+            params["request_sig"] = request_sig
+            params["app_id"] = get_app_var(0)
+            params["user_auth_token"] = await self._auth_token()
+        async with self._throttler:
+            async with self.mass.http_session.get(
+                url, headers=headers, params=params, verify_ssl=False
+            ) as response:
+                result = await response.json()
+                if "error" in result or (
+                    "status" in result and "error" in result["status"]
+                ):
+                    self.logger.error("%s - %s", endpoint, result)
+                    return None
+                return result
+
+    async def _post_data(self, endpoint, params=None, data=None):
+        """Post data to api."""
+        if not params:
+            params = {}
+        if not data:
+            data = {}
+        url = f"http://www.qobuz.com/api.json/0.2/{endpoint}"
+        params["app_id"] = get_app_var(0)
+        params["user_auth_token"] = await self._auth_token()
+        async with self.mass.http_session.post(
+            url, params=params, json=data, verify_ssl=False
+        ) as response:
+            result = await response.json()
+            if "error" in result or (
+                "status" in result and "error" in result["status"]
+            ):
+                self.logger.error("%s - %s", endpoint, result)
+                return None
+            return result
+
+    def __get_image(self, obj: dict) -> Optional[str]:
+        """Try to parse image from Qobuz media object."""
+        if obj.get("image"):
+            for key in ["extralarge", "large", "medium", "small"]:
+                if obj["image"].get(key):
+                    if "2a96cbd8b46e442fc41c2b86b821562f" in obj["image"][key]:
+                        continue
+                    return obj["image"][key]
+        if obj.get("images300"):
+            # playlists seem to use this strange format
+            return obj["images300"][0]
+        if obj.get("album"):
+            return self.__get_image(obj["album"])
+        if obj.get("artist"):
+            return self.__get_image(obj["artist"])
+        return None
diff --git a/music_assistant/providers/qobuz/__init__.py b/music_assistant/providers/qobuz/__init__.py
deleted file mode 100644 (file)
index 6ddf0e8..0000000
+++ /dev/null
@@ -1,746 +0,0 @@
-"""Qobuz musicprovider support for MusicAssistant."""
-import datetime
-import hashlib
-import logging
-import time
-from typing import List, Optional
-
-from asyncio_throttle import Throttler
-from music_assistant.constants import (
-    CONF_PASSWORD,
-    CONF_USERNAME,
-    EVENT_STREAM_ENDED,
-    EVENT_STREAM_STARTED,
-)
-from music_assistant.helpers.app_vars import get_app_var  # noqa # pylint: disable=all
-from music_assistant.helpers.util import parse_title_and_version, try_parse_int
-from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType
-from music_assistant.models.media_types import (
-    Album,
-    AlbumType,
-    Artist,
-    MediaItemProviderId,
-    MediaType,
-    Playlist,
-    Radio,
-    SearchResult,
-    Track,
-    TrackQuality,
-)
-from music_assistant.models.provider import MusicProvider
-from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
-
-PROV_ID = "qobuz"
-PROV_NAME = "Qobuz"
-LOGGER = logging.getLogger(PROV_ID)
-
-CONFIG_ENTRIES = [
-    ConfigEntry(
-        entry_key=CONF_USERNAME,
-        entry_type=ConfigEntryType.STRING,
-        description=CONF_USERNAME,
-    ),
-    ConfigEntry(
-        entry_key=CONF_PASSWORD,
-        entry_type=ConfigEntryType.PASSWORD,
-        description=CONF_PASSWORD,
-    ),
-]
-
-
-async def setup(mass):
-    """Perform async setup of this Plugin/Provider."""
-    prov = QobuzProvider()
-    await mass.register_provider(prov)
-
-
-class QobuzProvider(MusicProvider):
-    """Provider for the Qobux music service."""
-
-    # pylint: disable=abstract-method
-
-    __user_auth_info = None
-
-    @property
-    def id(self) -> str:
-        """Return provider ID for this provider."""
-        return PROV_ID
-
-    @property
-    def name(self) -> str:
-        """Return provider Name for this provider."""
-        return PROV_NAME
-
-    @property
-    def config_entries(self) -> List[ConfigEntry]:
-        """Return Config Entries for this provider."""
-        return CONFIG_ENTRIES
-
-    @property
-    def supported_mediatypes(self) -> List[MediaType]:
-        """Return MediaTypes the provider supports."""
-        return [MediaType.ALBUM, MediaType.ARTIST, MediaType.PLAYLIST, MediaType.TRACK]
-
-    async def on_start(self) -> bool:
-        """Handle initialization of the provider based on config."""
-        # pylint: disable=attribute-defined-outside-init
-        config = self.mass.config.get_provider_config(self.id)
-        if not config[CONF_USERNAME] or not config[CONF_PASSWORD]:
-            LOGGER.debug("Username and password not set. Abort load of provider.")
-            return False
-        self.__username = config[CONF_USERNAME]
-        self.__password = config[CONF_PASSWORD]
-
-        self.__user_auth_info = None
-        self.__logged_in = False
-        self._throttler = Throttler(rate_limit=4, period=1)
-        self.mass.eventbus.add_listener(
-            self.mass_event, (EVENT_STREAM_STARTED, EVENT_STREAM_ENDED)
-        )
-        return True
-
-    async def search(
-        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
-    ) -> SearchResult:
-        """
-        Perform search on musicprovider.
-
-            :param search_query: Search query.
-            :param media_types: A list of media_types to include. All types if None.
-            :param limit: Number of items to return in the search (per type).
-        """
-        result = SearchResult()
-        params = {"query": search_query, "limit": limit}
-        if len(media_types) == 1:
-            # qobuz does not support multiple searchtypes, falls back to all if no type given
-            if media_types[0] == MediaType.ARTIST:
-                params["type"] = "artists"
-            if media_types[0] == MediaType.ALBUM:
-                params["type"] = "albums"
-            if media_types[0] == MediaType.TRACK:
-                params["type"] = "tracks"
-            if media_types[0] == MediaType.PLAYLIST:
-                params["type"] = "playlists"
-        searchresult = await self._get_data("catalog/search", params)
-        if searchresult:
-            if "artists" in searchresult:
-                result.artists = [
-                    await self._parse_artist(item)
-                    for item in searchresult["artists"]["items"]
-                    if (item and item["id"])
-                ]
-            if "albums" in searchresult:
-                result.albums = [
-                    await self._parse_album(item)
-                    for item in searchresult["albums"]["items"]
-                    if (item and item["id"])
-                ]
-            if "tracks" in searchresult:
-                result.tracks = [
-                    await self._parse_track(item)
-                    for item in searchresult["tracks"]["items"]
-                    if (item and item["id"])
-                ]
-            if "playlists" in searchresult:
-                result.playlists = [
-                    await self._parse_playlist(item)
-                    for item in searchresult["playlists"]["items"]
-                    if (item and item["id"])
-                ]
-        return result
-
-    async def get_library_artists(self) -> List[Artist]:
-        """Retrieve all library artists from Qobuz."""
-        params = {"type": "artists"}
-        endpoint = "favorite/getUserFavorites"
-        return [
-            await self._parse_artist(item)
-            for item in await self._get_all_items(endpoint, params, key="artists")
-            if (item and item["id"])
-        ]
-
-    async def get_library_albums(self) -> List[Album]:
-        """Retrieve all library albums from Qobuz."""
-        params = {"type": "albums"}
-        endpoint = "favorite/getUserFavorites"
-        return [
-            await self._parse_album(item)
-            for item in await self._get_all_items(endpoint, params, key="albums")
-            if (item and item["id"])
-        ]
-
-    async def get_library_tracks(self) -> List[Track]:
-        """Retrieve library tracks from Qobuz."""
-        params = {"type": "tracks"}
-        endpoint = "favorite/getUserFavorites"
-        return [
-            await self._parse_track(item)
-            for item in await self._get_all_items(endpoint, params, key="tracks")
-            if (item and item["id"])
-        ]
-
-    async def get_library_playlists(self) -> List[Playlist]:
-        """Retrieve all library playlists from the provider."""
-        endpoint = "playlist/getUserPlaylists"
-        return [
-            await self._parse_playlist(item)
-            for item in await self._get_all_items(endpoint, key="playlists")
-            if (item and item["id"])
-        ]
-
-    async def get_radios(self) -> List[Radio]:
-        """Retrieve library/subscribed radio stations from the provider."""
-        return []  # TODO
-
-    async def get_artist(self, prov_artist_id) -> Artist:
-        """Get full artist details by id."""
-        params = {"artist_id": prov_artist_id}
-        artist_obj = await self._get_data("artist/get", params)
-        return (
-            await self._parse_artist(artist_obj)
-            if artist_obj and artist_obj["id"]
-            else None
-        )
-
-    async def get_album(self, prov_album_id) -> Album:
-        """Get full album details by id."""
-        params = {"album_id": prov_album_id}
-        album_obj = await self._get_data("album/get", params)
-        return (
-            await self._parse_album(album_obj)
-            if album_obj and album_obj["id"]
-            else None
-        )
-
-    async def get_track(self, prov_track_id) -> Track:
-        """Get full track details by id."""
-        params = {"track_id": prov_track_id}
-        track_obj = await self._get_data("track/get", params)
-        return (
-            await self._parse_track(track_obj)
-            if track_obj and track_obj["id"]
-            else None
-        )
-
-    async def get_playlist(self, prov_playlist_id) -> Playlist:
-        """Get full playlist details by id."""
-        params = {"playlist_id": prov_playlist_id}
-        playlist_obj = await self._get_data("playlist/get", params)
-        return (
-            await self._parse_playlist(playlist_obj)
-            if playlist_obj and playlist_obj["id"]
-            else None
-        )
-
-    async def get_album_tracks(self, prov_album_id) -> List[Track]:
-        """Get all album tracks for given album id."""
-        params = {"album_id": prov_album_id}
-        return [
-            await self._parse_track(item)
-            for item in await self._get_all_items("album/get", params, key="tracks")
-            if (item and item["id"])
-        ]
-
-    async def get_playlist_tracks(self, prov_playlist_id) -> List[Track]:
-        """Get all playlist tracks for given playlist id."""
-        params = {"playlist_id": prov_playlist_id, "extra": "tracks"}
-        endpoint = "playlist/get"
-        return [
-            await self._parse_track(item)
-            for item in await self._get_all_items(endpoint, params, key="tracks")
-            if (item and item["id"])
-        ]
-
-    async def get_artist_albums(self, prov_artist_id) -> List[Album]:
-        """Get a list of albums for the given artist."""
-        params = {"artist_id": prov_artist_id, "extra": "albums"}
-        endpoint = "artist/get"
-        return [
-            await self._parse_album(item)
-            for item in await self._get_all_items(endpoint, params, key="albums")
-            if (item and item["id"] and str(item["artist"]["id"]) == prov_artist_id)
-        ]
-
-    async def get_artist_toptracks(self, prov_artist_id) -> List[Track]:
-        """Get a list of most popular tracks for the given artist."""
-        params = {
-            "artist_id": prov_artist_id,
-            "extra": "playlists",
-            "offset": 0,
-            "limit": 25,
-        }
-        result = await self._get_data("artist/get", params)
-        if result and result["playlists"]:
-            return [
-                await self._parse_track(item)
-                for item in result["playlists"][0]["tracks"]["items"]
-                if (item and item["id"])
-            ]
-        # fallback to search
-        artist = await self.get_artist(prov_artist_id)
-        params = {"query": artist.name, "limit": 25, "type": "tracks"}
-        searchresult = await self._get_data("catalog/search", params)
-        return [
-            await self._parse_track(item)
-            for item in searchresult["tracks"]["items"]
-            if (
-                item
-                and item["id"]
-                and "performer" in item
-                and str(item["performer"]["id"]) == str(prov_artist_id)
-            )
-        ]
-
-    async def get_similar_artists(self, prov_artist_id):
-        """Get similar artists for given artist."""
-        # https://www.qobuz.com/api.json/0.2/artist/getSimilarArtists?artist_id=220020&offset=0&limit=3
-
-    async def library_add(self, prov_item_id, media_type: MediaType):
-        """Add item to library."""
-        result = None
-        if media_type == MediaType.ARTIST:
-            result = await self._get_data(
-                "favorite/create", {"artist_ids": prov_item_id}
-            )
-        elif media_type == MediaType.ALBUM:
-            result = await self._get_data(
-                "favorite/create", {"album_ids": prov_item_id}
-            )
-        elif media_type == MediaType.TRACK:
-            result = await self._get_data(
-                "favorite/create", {"track_ids": prov_item_id}
-            )
-        elif media_type == MediaType.PLAYLIST:
-            result = await self._get_data(
-                "playlist/subscribe", {"playlist_id": prov_item_id}
-            )
-        return result
-
-    async def library_remove(self, prov_item_id, media_type: MediaType):
-        """Remove item from library."""
-        result = None
-        if media_type == MediaType.ARTIST:
-            result = await self._get_data(
-                "favorite/delete", {"artist_ids": prov_item_id}
-            )
-        elif media_type == MediaType.ALBUM:
-            result = await self._get_data(
-                "favorite/delete", {"album_ids": prov_item_id}
-            )
-        elif media_type == MediaType.TRACK:
-            result = await self._get_data(
-                "favorite/delete", {"track_ids": prov_item_id}
-            )
-        elif media_type == MediaType.PLAYLIST:
-            playlist = await self.get_playlist(prov_item_id)
-            if playlist.is_editable:
-                result = await self._get_data(
-                    "playlist/delete", {"playlist_id": prov_item_id}
-                )
-            else:
-                result = await self._get_data(
-                    "playlist/unsubscribe", {"playlist_id": prov_item_id}
-                )
-        return result
-
-    async def add_playlist_tracks(self, prov_playlist_id, prov_track_ids):
-        """Add track(s) to playlist."""
-        params = {
-            "playlist_id": prov_playlist_id,
-            "track_ids": ",".join(prov_track_ids),
-            "playlist_track_ids": ",".join(prov_track_ids),
-        }
-        return await self._get_data("playlist/addTracks", params)
-
-    async def remove_playlist_tracks(self, prov_playlist_id, prov_track_ids):
-        """Remove track(s) from playlist."""
-        playlist_track_ids = set()
-        params = {"playlist_id": prov_playlist_id, "extra": "tracks"}
-        for track in await self._get_all_items("playlist/get", params, key="tracks"):
-            if str(track["id"]) in prov_track_ids:
-                playlist_track_ids.add(str(track["playlist_track_id"]))
-        params = {
-            "playlist_id": prov_playlist_id,
-            "playlist_track_ids": ",".join(playlist_track_ids),
-        }
-        return await self._get_data("playlist/deleteTracks", params)
-
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
-        """Return the content details for the given track when it will be streamed."""
-        streamdata = None
-        for format_id in [27, 7, 6, 5]:
-            # it seems that simply requesting for highest available quality does not work
-            # from time to time the api response is empty for this request ?!
-            params = {"format_id": format_id, "track_id": item_id, "intent": "stream"}
-            result = await self._get_data("track/getFileUrl", params, sign_request=True)
-            if result and result.get("url"):
-                streamdata = result
-                break
-        if not streamdata:
-            LOGGER.error("Unable to retrieve stream details for track %s", item_id)
-            return None
-        if streamdata["mime_type"] == "audio/mpeg":
-            content_type = ContentType.MPEG
-        elif streamdata["mime_type"] == "audio/flac":
-            content_type = ContentType.FLAC
-        else:
-            LOGGER.error("Unsupported mime type for track %s", item_id)
-            return None
-        return StreamDetails(
-            type=StreamType.URL,
-            item_id=str(item_id),
-            provider=PROV_ID,
-            path=streamdata["url"],
-            content_type=content_type,
-            sample_rate=int(streamdata["sampling_rate"] * 1000),
-            bit_depth=streamdata["bit_depth"],
-            details=streamdata,  # we need these details for reporting playback
-        )
-
-    async def mass_event(self, msg, msg_details):
-        """
-        Received event from mass.
-
-        We use this to report playback start/stop to qobuz.
-        """
-        if not self.__user_auth_info:
-            return
-        # TODO: need to figure out if the streamed track is purchased by user
-        # https://www.qobuz.com/api.json/0.2/purchase/getUserPurchasesIds?limit=5000&user_id=xxxxxxx
-        # {"albums":{"total":0,"items":[]},"tracks":{"total":0,"items":[]},"user":{"id":xxxx,"login":"xxxxx"}}
-        if msg == EVENT_STREAM_STARTED and msg_details.provider == PROV_ID:
-            # report streaming started to qobuz
-            device_id = self.__user_auth_info["user"]["device"]["id"]
-            credential_id = self.__user_auth_info["user"]["credential"]["id"]
-            user_id = self.__user_auth_info["user"]["id"]
-            format_id = msg_details.details["format_id"]
-            timestamp = int(time.time())
-            events = [
-                {
-                    "online": True,
-                    "sample": False,
-                    "intent": "stream",
-                    "device_id": device_id,
-                    "track_id": str(msg_details.item_id),
-                    "purchase": False,
-                    "date": timestamp,
-                    "credential_id": credential_id,
-                    "user_id": user_id,
-                    "local": False,
-                    "format_id": format_id,
-                }
-            ]
-            await self._post_data("track/reportStreamingStart", data=events)
-        elif msg == EVENT_STREAM_ENDED and msg_details.provider == PROV_ID:
-            # report streaming ended to qobuz
-            # if msg_details.details < 6:
-            #     return ????????????? TODO
-            user_id = self.__user_auth_info["user"]["id"]
-            params = {
-                "user_id": user_id,
-                "track_id": str(msg_details.item_id),
-                "duration": try_parse_int(msg_details.seconds_played),
-            }
-            await self._get_data("/track/reportStreamingEnd", params)
-
-    async def _parse_artist(self, artist_obj):
-        """Parse qobuz artist object to generic layout."""
-        artist = Artist(
-            item_id=str(artist_obj["id"]), provider=PROV_ID, name=artist_obj["name"]
-        )
-        artist.provider_ids.add(
-            MediaItemProviderId(provider=PROV_ID, item_id=str(artist_obj["id"]))
-        )
-        artist.metadata["image"] = self.__get_image(artist_obj)
-        if artist_obj.get("biography"):
-            artist.metadata["biography"] = artist_obj["biography"].get("content", "")
-        if artist_obj.get("url"):
-            artist.metadata["qobuz_url"] = artist_obj["url"]
-        return artist
-
-    async def _parse_album(self, album_obj: dict, artist_obj: dict = None):
-        """Parse qobuz album object to generic layout."""
-        if not artist_obj and "artist" not in album_obj:
-            # artist missing in album info, return full abum instead
-            return await self.get_album(album_obj["id"])
-        album = Album(item_id=str(album_obj["id"]), provider=PROV_ID)
-        if album_obj["maximum_sampling_rate"] > 192:
-            quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4
-        elif album_obj["maximum_sampling_rate"] > 96:
-            quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3
-        elif album_obj["maximum_sampling_rate"] > 48:
-            quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2
-        elif album_obj["maximum_bit_depth"] > 16:
-            quality = TrackQuality.FLAC_LOSSLESS_HI_RES_1
-        elif album_obj.get("format_id", 0) == 5:
-            quality = TrackQuality.LOSSY_AAC
-        else:
-            quality = TrackQuality.FLAC_LOSSLESS
-        album.provider_ids.add(
-            MediaItemProviderId(
-                provider=PROV_ID,
-                item_id=str(album_obj["id"]),
-                quality=quality,
-                details=f'{album_obj["maximum_sampling_rate"]}kHz {album_obj["maximum_bit_depth"]}bit',
-                available=album_obj["streamable"] and album_obj["displayable"],
-            )
-        )
-        album.name, album.version = parse_title_and_version(
-            album_obj["title"], album_obj.get("version")
-        )
-        if artist_obj:
-            album.artist = artist_obj
-        else:
-            album.artist = await self._parse_artist(album_obj["artist"])
-        if (
-            album_obj.get("product_type", "") == "single"
-            or album_obj.get("release_type", "") == "single"
-        ):
-            album.album_type = AlbumType.SINGLE
-        elif (
-            album_obj.get("product_type", "") == "compilation"
-            or "Various" in album.artist.name
-        ):
-            album.album_type = AlbumType.COMPILATION
-        elif (
-            album_obj.get("product_type", "") == "album"
-            or album_obj.get("release_type", "") == "album"
-        ):
-            album.album_type = AlbumType.ALBUM
-        if "genre" in album_obj:
-            album.metadata["genre"] = album_obj["genre"]["name"]
-        album.metadata["image"] = self.__get_image(album_obj)
-        if len(album_obj["upc"]) == 13:
-            # qobuz writes ean as upc ?!
-            album.metadata["ean"] = album_obj["upc"]
-            album.upc = album_obj["upc"][1:]
-        else:
-            album.upc = album_obj["upc"]
-        if "label" in album_obj:
-            album.metadata["label"] = album_obj["label"]["name"]
-        if album_obj.get("released_at"):
-            album.year = datetime.datetime.fromtimestamp(album_obj["released_at"]).year
-        if album_obj.get("copyright"):
-            album.metadata["copyright"] = album_obj["copyright"]
-        if album_obj.get("hires"):
-            album.metadata["hires"] = "true"
-        if album_obj.get("url"):
-            album.metadata["qobuz_url"] = album_obj["url"]
-        if album_obj.get("description"):
-            album.metadata["description"] = album_obj["description"]
-        return album
-
-    async def _parse_track(self, track_obj):
-        """Parse qobuz track object to generic layout."""
-        track = Track(
-            item_id=str(track_obj["id"]),
-            provider=PROV_ID,
-            disc_number=track_obj["media_number"],
-            track_number=track_obj["track_number"],
-            duration=track_obj["duration"],
-        )
-        if track_obj.get("performer") and "Various " not in track_obj["performer"]:
-            artist = await self._parse_artist(track_obj["performer"])
-            if artist:
-                track.artists.add(artist)
-        if not track.artists:
-            # try to grab artist from album
-            if (
-                track_obj.get("album")
-                and track_obj["album"].get("artist")
-                and "Various " not in track_obj["album"]["artist"]
-            ):
-                artist = await self._parse_artist(track_obj["album"]["artist"])
-                if artist:
-                    track.artists.add(artist)
-        if not track.artists:
-            # last resort: parse from performers string
-            for performer_str in track_obj["performers"].split(" - "):
-                role = performer_str.split(", ")[1]
-                name = performer_str.split(", ")[0]
-                if "artist" in role.lower():
-                    artist = Artist()
-                    artist.name = name
-                    artist.item_id = name
-                track.artists.add(artist)
-        # TODO: fix grabbing composer from details
-        track.name, track.version = parse_title_and_version(
-            track_obj["title"], track_obj.get("version")
-        )
-        if "album" in track_obj:
-            album = await self._parse_album(track_obj["album"])
-            if album:
-                track.album = album
-        if track_obj.get("hires"):
-            track.metadata["hires"] = "true"
-        if track_obj.get("url"):
-            track.metadata["qobuz_url"] = track_obj["url"]
-        if track_obj.get("isrc"):
-            track.isrc = track_obj["isrc"]
-        if track_obj.get("performers"):
-            track.metadata["performers"] = track_obj["performers"]
-        if track_obj.get("copyright"):
-            track.metadata["copyright"] = track_obj["copyright"]
-        if track_obj.get("audio_info"):
-            track.metadata["replaygain"] = track_obj["audio_info"][
-                "replaygain_track_gain"
-            ]
-        if track_obj.get("parental_warning"):
-            track.metadata["explicit"] = True
-        track.metadata["image"] = self.__get_image(track_obj)
-        # get track quality
-        if track_obj["maximum_sampling_rate"] > 192:
-            quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4
-        elif track_obj["maximum_sampling_rate"] > 96:
-            quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3
-        elif track_obj["maximum_sampling_rate"] > 48:
-            quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2
-        elif track_obj["maximum_bit_depth"] > 16:
-            quality = TrackQuality.FLAC_LOSSLESS_HI_RES_1
-        elif track_obj.get("format_id", 0) == 5:
-            quality = TrackQuality.LOSSY_AAC
-        else:
-            quality = TrackQuality.FLAC_LOSSLESS
-        track.provider_ids.add(
-            MediaItemProviderId(
-                provider=PROV_ID,
-                item_id=str(track_obj["id"]),
-                quality=quality,
-                details=f'{track_obj["maximum_sampling_rate"]}kHz {track_obj["maximum_bit_depth"]}bit',
-                available=track_obj["streamable"] and track_obj["displayable"],
-            )
-        )
-        return track
-
-    async def _parse_playlist(self, playlist_obj):
-        """Parse qobuz playlist object to generic layout."""
-        playlist = Playlist(
-            item_id=str(playlist_obj["id"]),
-            provider=PROV_ID,
-            name=playlist_obj["name"],
-            owner=playlist_obj["owner"]["name"],
-        )
-        playlist.provider_ids.add(
-            MediaItemProviderId(provider=PROV_ID, item_id=str(playlist_obj["id"]))
-        )
-        playlist.is_editable = (
-            playlist_obj["owner"]["id"] == self.__user_auth_info["user"]["id"]
-            or playlist_obj["is_collaborative"]
-        )
-        playlist.metadata["image"] = self.__get_image(playlist_obj)
-        if playlist_obj.get("url"):
-            playlist.metadata["qobuz_url"] = playlist_obj["url"]
-        playlist.checksum = playlist_obj["updated_at"]
-        return playlist
-
-    async def _auth_token(self):
-        """Login to qobuz and store the token."""
-        if self.__user_auth_info:
-            return self.__user_auth_info["user_auth_token"]
-        params = {
-            "username": self.__username,
-            "password": self.__password,
-            "device_manufacturer_id": "music_assistant",
-        }
-        details = await self._get_data("user/login", params)
-        if details and "user" in details:
-            self.__user_auth_info = details
-            LOGGER.info(
-                "Succesfully logged in to Qobuz as %s", details["user"]["display_name"]
-            )
-            return details["user_auth_token"]
-
-    async def _get_all_items(self, endpoint, params=None, key="tracks"):
-        """Get all items from a paged list."""
-        if not params:
-            params = {}
-        limit = 50
-        offset = 0
-        all_items = []
-        while True:
-            params["limit"] = limit
-            params["offset"] = offset
-            result = await self._get_data(endpoint, params=params)
-            offset += limit
-            if not result:
-                break
-            if not result.get(key) or not result[key].get("items"):
-                break
-            all_items += result[key]["items"]
-            if len(result[key]["items"]) < limit:
-                break
-        return all_items
-
-    async def _get_data(self, endpoint, params=None, sign_request=False):
-        """Get data from api."""
-        if not params:
-            params = {}
-        url = "http://www.qobuz.com/api.json/0.2/%s" % endpoint
-        headers = {"X-App-Id": get_app_var(0)}
-        if endpoint != "user/login":
-            auth_token = await self._auth_token()
-            if not auth_token:
-                LOGGER.debug("Not logged in")
-                return None
-            headers["X-User-Auth-Token"] = auth_token
-        if sign_request:
-            signing_data = "".join(endpoint.split("/"))
-            keys = list(params.keys())
-            keys.sort()
-            for key in keys:
-                signing_data += "%s%s" % (key, params[key])
-            request_ts = str(time.time())
-            request_sig = signing_data + request_ts + get_app_var(1)
-            request_sig = str(hashlib.md5(request_sig.encode()).hexdigest())
-            params["request_ts"] = request_ts
-            params["request_sig"] = request_sig
-            params["app_id"] = get_app_var(0)
-            params["user_auth_token"] = await self._auth_token()
-        async with self._throttler:
-            async with self.mass.http_session.get(
-                url, headers=headers, params=params, verify_ssl=False
-            ) as response:
-                result = await response.json()
-                if "error" in result or (
-                    "status" in result and "error" in result["status"]
-                ):
-                    LOGGER.error("%s - %s", endpoint, result)
-                    return None
-                return result
-
-    async def _post_data(self, endpoint, params=None, data=None):
-        """Post data to api."""
-        if not params:
-            params = {}
-        if not data:
-            data = {}
-        url = "http://www.qobuz.com/api.json/0.2/%s" % endpoint
-        params["app_id"] = get_app_var(0)
-        params["user_auth_token"] = await self._auth_token()
-        async with self.mass.http_session.post(
-            url, params=params, json=data, verify_ssl=False
-        ) as response:
-            result = await response.json()
-            if "error" in result or (
-                "status" in result and "error" in result["status"]
-            ):
-                LOGGER.error("%s - %s", endpoint, result)
-                return None
-            return result
-
-    def __get_image(self, obj: dict) -> Optional[str]:
-        """Try to parse image from Qobuz media object."""
-        if obj.get("image"):
-            for key in ["extralarge", "large", "medium", "small"]:
-                if obj["image"].get(key):
-                    if "2a96cbd8b46e442fc41c2b86b821562f" in obj["image"][key]:
-                        continue
-                    return obj["image"][key]
-        if obj.get("images300"):
-            # playlists seem to use this strange format
-            return obj["images300"][0]
-        if obj.get("album"):
-            return self.__get_image(obj["album"])
-        if obj.get("artist"):
-            return self.__get_image(obj["artist"])
-        return None
diff --git a/music_assistant/providers/qobuz/icon.png b/music_assistant/providers/qobuz/icon.png
deleted file mode 100644 (file)
index 9d7b726..0000000
Binary files a/music_assistant/providers/qobuz/icon.png and /dev/null differ
diff --git a/music_assistant/providers/sonos/__init__.py b/music_assistant/providers/sonos/__init__.py
deleted file mode 100644 (file)
index 782c345..0000000
+++ /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 (file)
index d00f12a..0000000
Binary files a/music_assistant/providers/sonos/icon.png and /dev/null differ
diff --git a/music_assistant/providers/sonos/sonos.py b/music_assistant/providers/sonos/sonos.py
deleted file mode 100644 (file)
index cd67396..0000000
+++ /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)
index e7cd92a9d5e6700915c0177749cb30d93cb7f83d..2bb23e993bb45b08497fab607dde9a48c17d866e 100644 (file)
@@ -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 (file)
index 1ed4049..0000000
Binary files a/music_assistant/providers/spotify/icon.png and /dev/null differ
diff --git a/music_assistant/providers/squeezebox/__init__.py b/music_assistant/providers/squeezebox/__init__.py
deleted file mode 100644 (file)
index eeeb54f..0000000
+++ /dev/null
@@ -1,342 +0,0 @@
-"""Squeezebox emulated player provider."""
-
-import asyncio
-import logging
-from typing import List
-
-from music_assistant.constants import CONF_CROSSFADE_DURATION
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import callback, create_task
-from music_assistant.models.config_entry import ConfigEntry
-from music_assistant.models.player import DeviceInfo, Player, PlayerFeature, PlayerState
-from music_assistant.models.player_queue import QueueItem
-from music_assistant.models.provider import PlayerProvider
-
-from .constants import PROV_ID, PROV_NAME
-from .discovery import DiscoveryProtocol
-from .socket_client import SqueezeEvent, SqueezeSocketClient
-
-CONF_LAST_POWER = "last_power"
-CONF_LAST_VOLUME = "last_volume"
-
-LOGGER = logging.getLogger(PROV_ID)
-
-CONFIG_ENTRIES = []  # we don't have any provider config entries (for now)
-PLAYER_FEATURES = [PlayerFeature.QUEUE, PlayerFeature.CROSSFADE, PlayerFeature.GAPLESS]
-PLAYER_CONFIG_ENTRIES = []  # we don't have any player config entries (for now)
-
-
-async def setup(mass):
-    """Perform async setup of this Plugin/Provider."""
-    prov = PySqueezeProvider()
-    await mass.register_provider(prov)
-
-
-class PySqueezeProvider(PlayerProvider):
-    """Python implementation of SlimProto server."""
-
-    _tasks = []
-
-    @property
-    def id(self) -> str:
-        """Return provider ID for this provider."""
-        return PROV_ID
-
-    @property
-    def name(self) -> str:
-        """Return provider Name for this provider."""
-        return PROV_NAME
-
-    @property
-    def config_entries(self) -> List[ConfigEntry]:
-        """Return Config Entries for this provider."""
-        return CONFIG_ENTRIES
-
-    async def on_start(self) -> bool:
-        """Handle initialization of the provider. Called on startup."""
-        # start slimproto server
-        create_task(asyncio.start_server(self._client_connected, "0.0.0.0", 3483))
-
-        # setup discovery
-        create_task(self.start_discovery())
-
-    async def start_discovery(self):
-        """Start discovery for players."""
-        transport, _ = await self.mass.loop.create_datagram_endpoint(
-            lambda: DiscoveryProtocol(self.mass.web.port),
-            local_addr=("0.0.0.0", 3483),
-        )
-        try:
-            while True:
-                await asyncio.sleep(60)  # serve forever
-        finally:
-            transport.close()
-
-    async def _client_connected(self, reader, writer):
-        """Handle a client connection on the socket."""
-        addr = writer.get_extra_info("peername")
-        LOGGER.debug("Socket client connected: %s", addr)
-        socket_client = SqueezeSocketClient(self.mass, reader, writer)
-
-        def handle_event(event: SqueezeEvent, socket_client: SqueezeSocketClient):
-            player_id = socket_client.player_id
-            if not player_id:
-                return
-            # always check if we already have this player as it might be reconnected
-            player = self.mass.players.get_player(player_id)
-            if not player:
-                player = SqueezePlayer(self.mass, socket_client)
-            player.set_socket_client(socket_client)
-            # just update, the playermanager will take care of adding it if it's a new player
-            player.handle_socket_client_event(event)
-
-        socket_client.register_callback(handle_event)
-
-
-class SqueezePlayer(Player):
-    """Squeezebox player."""
-
-    def __init__(self, mass: MusicAssistant, socket_client: SqueezeSocketClient):
-        """Initialize."""
-        super().__init__()
-        self.mass = mass
-        self._socket_client = socket_client
-
-    @property
-    def available(self) -> bool:
-        """Return current availablity of player."""
-        return self._socket_client.connected
-
-    @property
-    def should_poll(self) -> bool:
-        """Return True if this player should be polled for state updates."""
-        return False
-
-    @property
-    def socket_client(self):
-        """Return the uinderluing socket client for the player."""
-        return self._socket_client
-
-    def set_socket_client(self, socket_client: SqueezeSocketClient):
-        """Set a (new) socket client to this player."""
-        self._socket_client = socket_client
-
-    async def on_remove(self) -> None:
-        """Call when player is removed from the player manager."""
-        self.socket_client.disconnect()
-
-    @property
-    def player_id(self) -> str:
-        """Return player id (=mac address) of the player."""
-        return self.socket_client.player_id
-
-    @property
-    def provider_id(self) -> str:
-        """Return provider id of this player."""
-        return PROV_ID
-
-    @property
-    def name(self) -> str:
-        """Return name of the player."""
-        return self.socket_client.name
-
-    @property
-    def volume_level(self):
-        """Return current volume level of player."""
-        return self.socket_client.volume_level
-
-    @property
-    def powered(self):
-        """Return current power state of player."""
-        return self.socket_client.powered
-
-    @property
-    def muted(self):
-        """Return current mute state of player."""
-        return self.socket_client.muted
-
-    @property
-    def state(self):
-        """Return current state of player."""
-        return PlayerState(self.socket_client.state)
-
-    @property
-    def elapsed_time(self):
-        """Return elapsed_time of current playing track in (fractions of) seconds."""
-        return self.socket_client.elapsed_seconds
-
-    @property
-    def elapsed_milliseconds(self) -> int:
-        """Return (realtime) elapsed time of current playing media in milliseconds."""
-        return self.socket_client.elapsed_milliseconds
-
-    @property
-    def current_uri(self):
-        """Return uri of currently loaded track."""
-        return self.socket_client.current_uri
-
-    @property
-    def features(self) -> List[PlayerFeature]:
-        """Return list of features this player supports."""
-        return PLAYER_FEATURES
-
-    @property
-    def config_entries(self) -> List[ConfigEntry]:
-        """Return player specific config entries (if any)."""
-        return PLAYER_CONFIG_ENTRIES
-
-    @property
-    def device_info(self) -> DeviceInfo:
-        """Return the device info for this player."""
-        return DeviceInfo(
-            model=self.socket_client.device_type,
-            address=self.socket_client.device_address,
-        )
-
-    async def cmd_stop(self):
-        """Send stop command to player."""
-        return await self.socket_client.cmd_stop()
-
-    async def cmd_play(self):
-        """Send play (unpause) command to player."""
-        return await self.socket_client.cmd_play()
-
-    async def cmd_pause(self):
-        """Send pause command to player."""
-        return await self.socket_client.cmd_pause()
-
-    async def cmd_power_on(self) -> None:
-        """Send POWER ON command to player."""
-        # save power and volume state in cache
-        cache_str = f"squeezebox_player_{self.player_id}"
-        await self.mass.cache.set(cache_str, (True, self.volume_level))
-        return await self.socket_client.cmd_power(True)
-
-    async def cmd_power_off(self) -> None:
-        """Send POWER OFF command to player."""
-        # save power and volume state in cache
-        cache_str = f"squeezebox_player_{self.player_id}"
-        await self.mass.cache.set(cache_str, (False, self.volume_level))
-        return await self.socket_client.cmd_power(False)
-
-    async def cmd_volume_set(self, volume_level: int):
-        """Send new volume level command to player."""
-        return await self.socket_client.cmd_volume_set(volume_level)
-
-    async def cmd_mute(self, muted: bool = False):
-        """Send mute command to player."""
-        return await self.socket_client.cmd_mute(muted)
-
-    async def cmd_play_uri(self, uri: str):
-        """Request player to start playing a single uri."""
-        crossfade = self.mass.config.player_settings[self.player_id][
-            CONF_CROSSFADE_DURATION
-        ]
-        return await self.socket_client.play_uri(uri, crossfade_duration=crossfade)
-
-    async def cmd_next(self):
-        """Send NEXT TRACK command to player."""
-        queue = self.mass.players.get_player_queue(self.player_id)
-        if queue:
-            new_track = queue.get_item(queue.cur_index + 1)
-            if new_track:
-                return await self.cmd_play_uri(new_track.stream_url)
-
-    async def cmd_previous(self):
-        """Send PREVIOUS TRACK command to player."""
-        queue = self.mass.players.get_player_queue(self.player_id)
-        if queue:
-            new_track = queue.get_item(queue.cur_index - 1)
-            if new_track:
-                return await self.cmd_play_uri(new_track.stream_url)
-
-    async def cmd_queue_play_index(self, index: int):
-        """
-        Play item at index X on player's queue.
-
-            :param index: (int) index of the queue item that should start playing
-        """
-        queue = self.mass.players.get_player_queue(self.player_id)
-        if queue:
-            new_track = queue.get_item(index)
-            if new_track:
-                return await self.cmd_play_uri(new_track.stream_url)
-
-    async def cmd_queue_load(self, queue_items: List[QueueItem], repeat: bool = False):
-        """
-        Load/overwrite given items in the player's queue implementation.
-
-            :param queue_items: a list of QueueItems
-        """
-        if queue_items:
-            await self.cmd_play_uri(queue_items[0].stream_url)
-            return await self.cmd_play_uri(queue_items[0].stream_url)
-
-    async def cmd_queue_insert(
-        self, queue_items: List[QueueItem], insert_at_index: int
-    ):
-        """
-        Insert new items at position X into existing queue.
-
-        If insert_at_index 0 or None, will start playing newly added item(s)
-            :param queue_items: a list of QueueItems
-            :param insert_at_index: queue position to insert new items
-        """
-        # queue handled by built-in queue controller
-        # we only check the start index
-        queue = self.mass.players.get_player_queue(self.player_id)
-        if queue and insert_at_index == queue.cur_index:
-            return await self.cmd_queue_play_index(insert_at_index)
-
-    async def cmd_queue_append(self, queue_items: List[QueueItem]):
-        """
-        Append new items at the end of the queue.
-
-            :param queue_items: a list of QueueItems
-        """
-        # automagically handled by built-in queue controller
-
-    async def cmd_queue_update(self, queue_items: List[QueueItem]):
-        """
-        Overwrite the existing items in the queue, used for reordering.
-
-            :param queue_items: a list of QueueItems
-        """
-        # automagically handled by built-in queue controller
-
-    async def cmd_queue_clear(self):
-        """Clear the player's queue."""
-        # queue is handled by built-in queue controller but send stop
-        return await self.cmd_stop()
-
-    async def restore_states(self):
-        """Restore power/volume states."""
-        cache_str = f"squeezebox_player_{self.player_id}"
-        cache_data = await self.mass.cache.get(cache_str)
-        last_power, last_volume = cache_data if cache_data else (False, 40)
-        await self.socket_client.cmd_volume_set(last_volume)
-        await self.socket_client.cmd_power(last_power)
-
-    @callback
-    def handle_socket_client_event(self, event: SqueezeEvent):
-        """Process incoming event from the socket client."""
-        if event == SqueezeEvent.CONNECTED:
-            # restore previous power/volume
-            create_task(self.restore_states())
-        elif event == SqueezeEvent.DECODER_READY:
-            # tell player to load next queue track
-            queue = self.mass.players.get_player_queue(self.player_id)
-            if queue:
-                next_item = queue.next_item
-                if next_item:
-                    crossfade = self.mass.config.player_settings[self.player_id][
-                        CONF_CROSSFADE_DURATION
-                    ]
-                    create_task(
-                        self.socket_client.play_uri(
-                            next_item.stream_url,
-                            send_flush=False,
-                            crossfade_duration=crossfade,
-                        )
-                    )
-        self.update_state()
diff --git a/music_assistant/providers/squeezebox/constants.py b/music_assistant/providers/squeezebox/constants.py
deleted file mode 100644 (file)
index ecd683c..0000000
+++ /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 (file)
index 10c840a..0000000
+++ /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 (file)
index 18531d7..0000000
Binary files a/music_assistant/providers/squeezebox/icon.png and /dev/null differ
diff --git a/music_assistant/providers/squeezebox/socket_client.py b/music_assistant/providers/squeezebox/socket_client.py
deleted file mode 100644 (file)
index c9ac060..0000000
+++ /dev/null
@@ -1,660 +0,0 @@
-"""Socketclient implementation for Squeezebox emulated player provider."""
-
-import asyncio
-import logging
-import re
-import struct
-import time
-from enum import Enum
-from typing import Callable
-
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import create_task, run_periodic
-
-from .constants import PROV_ID
-
-LOGGER = logging.getLogger(PROV_ID)
-
-
-# from http://wiki.slimdevices.com/index.php/SlimProtoTCPProtocol#HELO
-DEVICE_TYPE = {
-    2: "squeezebox",
-    3: "softsqueeze",
-    4: "squeezebox2",
-    5: "transporter",
-    6: "softsqueeze3",
-    7: "receiver",
-    8: "squeezeslave",
-    9: "controller",
-    10: "boom",
-    11: "softboom",
-    12: "squeezeplay",
-}
-
-STATE_PLAYING = "playing"
-STATE_IDLE = "idle"
-STATE_PAUSED = "paused"
-
-
-class SqueezeEvent(Enum):
-    """Enum with the events that can happen in the socket client."""
-
-    CONNECTED = 0
-    STATE_UPDATED = 1
-    DECODER_READY = 2
-    DISCONNECTED = 3
-
-
-class SqueezeSocketClient:
-    """Squeezebox socket client."""
-
-    def __init__(
-        self,
-        mass: MusicAssistant,
-        reader: asyncio.StreamReader,
-        writer: asyncio.StreamWriter,
-        event_callback: Callable = None,
-    ):
-        """Initialize the socket client."""
-        self.mass = mass
-        self._reader = reader
-        self._writer = writer
-        self._player_id = ""
-        self._device_type = ""
-        self._device_name = ""
-        self._last_volume = 0
-        self._last_heartbeat = 0
-        self._volume_control = PySqueezeVolume()
-        self._powered = False
-        self._muted = False
-        self._state = STATE_IDLE
-        self._elapsed_seconds = 0
-        self._elapsed_milliseconds = 0
-        self._current_uri = ""
-        self._connected = True
-        self._event_callbacks = []
-        self._tasks = [
-            create_task(self._socket_reader()),
-            create_task(self._send_heartbeat()),
-        ]
-
-    def disconnect(self) -> None:
-        """Disconnect socket client."""
-        for task in self._tasks:
-            if not task.cancelled():
-                task.cancel()
-
-    def register_callback(self, callb: Callable):
-        """Register event callback. Returns function to deregister."""
-
-        def unregister():
-            self._event_callbacks.remove(callb)
-
-        self._event_callbacks.append(callb)
-        return unregister
-
-    def signal_event(self, event):
-        """Signal event to registered listeners."""
-        for listener in self._event_callbacks:
-            listener(event, self)
-
-    @property
-    def connected(self):
-        """Return connection state of the socket."""
-        return self._connected
-
-    @property
-    def player_id(self) -> str:
-        """Return player id (=mac address) of the player."""
-        return self._player_id
-
-    @property
-    def device_type(self) -> str:
-        """Return device type of the player."""
-        return self._device_type
-
-    @property
-    def device_address(self) -> str:
-        """Return device IP address of the player."""
-        dev_address = self._writer.get_extra_info("peername")
-        return dev_address[0] if dev_address else ""
-
-    @property
-    def name(self) -> str:
-        """Return name of the player."""
-        if self._device_name:
-            return self._device_name
-        return f"{self.device_type}: {self.player_id}"
-
-    @property
-    def volume_level(self):
-        """Return current volume level of player."""
-        return self._volume_control.volume
-
-    @property
-    def powered(self):
-        """Return current power state of player."""
-        return self._powered
-
-    @property
-    def muted(self):
-        """Return current mute state of player."""
-        return self._muted
-
-    @property
-    def state(self):
-        """Return current state of player."""
-        return self._state
-
-    @property
-    def elapsed_seconds(self):
-        """Return elapsed_time of current playing track in (fractions of) seconds."""
-        return self._elapsed_seconds
-
-    @property
-    def elapsed_milliseconds(self) -> int:
-        """Return (realtime) elapsed time of current playing media in milliseconds."""
-        return self._elapsed_milliseconds + int(
-            (time.time() * 1000) - (self._last_heartbeat * 1000)
-        )
-
-    @property
-    def current_uri(self):
-        """Return uri of currently loaded track."""
-        return self._current_uri
-
-    async def _initialize_player(self):
-        """Set some startup settings for the player."""
-        # send version
-        await self._send_frame(b"vers", b"7.8")
-        await self._send_frame(b"setd", struct.pack("B", 0))
-        await self._send_frame(b"setd", struct.pack("B", 4))
-
-    async def cmd_stop(self):
-        """Send stop command to player."""
-        await self.send_strm(b"q")
-
-    async def cmd_play(self):
-        """Send play (unpause) command to player."""
-        await self.send_strm(b"u")
-
-    async def cmd_pause(self):
-        """Send pause command to player."""
-        await self.send_strm(b"p")
-
-    async def cmd_power(self, powered: bool = True):
-        """Send power command to player."""
-        # power is not supported so abuse mute instead
-        power_int = 1 if powered else 0
-        await self._send_frame(b"aude", struct.pack("2B", power_int, 1))
-        self._powered = powered
-        self.signal_event(SqueezeEvent.STATE_UPDATED)
-
-    async def cmd_volume_set(self, volume_level: int):
-        """Send new volume level command to player."""
-        self._volume_control.volume = volume_level
-        old_gain = self._volume_control.old_gain()
-        new_gain = self._volume_control.new_gain()
-        await self._send_frame(
-            b"audg",
-            struct.pack("!LLBBLL", old_gain, old_gain, 1, 255, new_gain, new_gain),
-        )
-
-    async def cmd_mute(self, muted: bool = False):
-        """Send mute command to player."""
-        muted_int = 0 if muted else 1
-        await self._send_frame(b"aude", struct.pack("2B", muted_int, 0))
-        self.muted = muted
-        self.signal_event(SqueezeEvent.STATE_UPDATED)
-
-    async def play_uri(
-        self, uri: str, send_flush: bool = True, crossfade_duration: int = 0
-    ):
-        """Request player to start playing a single uri."""
-        if send_flush:
-            await self.send_strm(b"f", autostart=b"0")
-        self._current_uri = uri
-        self._powered = True
-        enable_crossfade = crossfade_duration > 0
-        command = b"s"
-        # we use direct stream for now so let the player do the messy work with buffers
-        autostart = b"3"
-        trans_type = b"1" if enable_crossfade else b"0"
-        uri = "/stream" + uri.split("/stream")[1]
-        # extract host and port from uri
-        regex = "(?:http.*://)?(?P<host>[^:/ ]+).?(?P<port>[0-9]*).*"
-        regex_result = re.search(regex, uri)
-        host = regex_result.group("host")  # 'www.abc.com'
-        port = regex_result.group("port")  # '123'
-        if not port and uri.startswith("https"):
-            port = 443
-        elif not port:
-            port = 80
-        headers = f"Connection: close\r\nAccept: */*\r\nHost: {host}:{port}\r\n"
-        httpreq = "GET %s HTTP/1.0\r\n%s\r\n" % (uri, headers)
-        await self.send_strm(
-            command,
-            autostart=autostart,
-            trans_type=trans_type,
-            trans_duration=crossfade_duration,
-            httpreq=httpreq.encode("utf-8"),
-        )
-
-    @run_periodic(5)
-    async def _send_heartbeat(self):
-        """Send periodic heartbeat message to player."""
-        if not self._connected:
-            return
-        timestamp = int(time.time())
-        await self.send_strm(b"t", replay_gain=timestamp, flags=0)
-
-    async def _send_frame(self, command, data):
-        """Send command to Squeeze player."""
-        if self._reader.at_eof() or self._writer.is_closing():
-            LOGGER.debug("Socket is disconnected.")
-            self._connected = False
-            return
-        packet = struct.pack("!H", len(data) + 4) + command + data
-        try:
-            self._writer.write(packet)
-            await self._writer.drain()
-        except ConnectionResetError:
-            self._connected = False
-            self.signal_event(SqueezeEvent.DISCONNECTED)
-
-    async def _socket_reader(self):
-        """Handle incoming data from socket."""
-        buffer = b""
-        # keep reading bytes from the socket
-        while not (self._reader.at_eof() or self._writer.is_closing()):
-            data = await self._reader.read(64)
-            # handle incoming data from socket
-            buffer = buffer + data
-            del data
-            if len(buffer) > 8:
-                # construct operation and
-                operation, length = buffer[:4], buffer[4:8]
-                plen = struct.unpack("!I", length)[0] + 8
-                if len(buffer) >= plen:
-                    packet, buffer = buffer[8:plen], buffer[plen:]
-                    operation = operation.strip(b"!").strip().decode().lower()
-                    handler = getattr(self, f"_process_{operation}", None)
-                    if handler is None:
-                        LOGGER.warning("No handler for %s", operation)
-                    else:
-                        handler(packet)
-        # EOF reached: socket is disconnected
-        LOGGER.debug("Socket disconnected: %s", self._writer.get_extra_info("peername"))
-        self._connected = False
-        self.signal_event(SqueezeEvent.DISCONNECTED)
-
-    async def send_strm(
-        self,
-        command=b"q",
-        formatbyte=b"f",
-        autostart=b"0",
-        samplesize=b"?",
-        samplerate=b"?",
-        channels=b"?",
-        endian=b"?",
-        threshold=0,
-        spdif=b"0",
-        trans_duration=0,
-        trans_type=b"0",
-        flags=0x40,
-        output_threshold=0,
-        replay_gain=0,
-        server_port=8095,
-        server_ip=0,
-        httpreq=b"",
-    ):
-        """Create stream request message based on given arguments."""
-        data = struct.pack(
-            "!cccccccBcBcBBBLHL",
-            command,
-            autostart,
-            formatbyte,
-            samplesize,
-            samplerate,
-            channels,
-            endian,
-            threshold,
-            spdif,
-            trans_duration,
-            trans_type,
-            flags,
-            output_threshold,
-            0,
-            replay_gain,
-            server_port,
-            server_ip,
-        )
-        await self._send_frame(b"strm", data + httpreq)
-
-    def _process_helo(self, data):
-        """Process incoming HELO event from player (player connected)."""
-        # pylint: disable=unused-variable
-        # player connected
-        (dev_id, rev, mac) = struct.unpack("BB6s", data[:8])
-        device_mac = ":".join("%02x" % x for x in mac)
-        self._player_id = str(device_mac).lower()
-        self._device_type = DEVICE_TYPE.get(dev_id, "unknown device")
-        LOGGER.debug("Player connected: %s", self.name)
-        create_task(self._initialize_player())
-        self.signal_event(SqueezeEvent.CONNECTED)
-
-    def _process_stat(self, data):
-        """Redirect incoming STAT event from player to correct method."""
-        event = data[:4].decode()
-        event_data = data[4:]
-        if event == b"\x00\x00\x00\x00":
-            # Presumed informational stat message
-            return
-        event_handler = getattr(self, "_process_stat_%s" % event.lower(), None)
-        if event_handler is None:
-            LOGGER.debug("Unhandled event: %s - event_data: %s", event, event_data)
-        else:
-            create_task(event_handler, data[4:])
-
-    def _process_stat_aude(self, data):
-        """Process incoming stat AUDe message (power level and mute)."""
-        (spdif_enable, dac_enable) = struct.unpack("2B", data[:4])
-        powered = spdif_enable or dac_enable
-        self._powered = powered
-        self._muted = not powered
-        self.signal_event(SqueezeEvent.STATE_UPDATED)
-
-    def _process_stat_audg(self, data):
-        """Process incoming stat AUDg message (volume level)."""
-        # TODO: process volume level
-        LOGGER.debug("AUDg received - Volume level: %s", data)
-        self.signal_event(SqueezeEvent.STATE_UPDATED)
-
-    def _process_stat_stmd(self, data):
-        """Process incoming stat STMd message (decoder ready)."""
-        # pylint: disable=unused-argument
-        LOGGER.debug("STMd received - Decoder Ready for next track.")
-        self.signal_event(SqueezeEvent.DECODER_READY)
-
-    def _process_stat_stmf(self, data):
-        """Process incoming stat STMf message (connection closed)."""
-        # pylint: disable=unused-argument
-        LOGGER.debug("STMf received - connection closed.")
-        self._state = STATE_IDLE
-        self._elapsed_milliseconds = 0
-        self._elapsed_seconds = 0
-        self.signal_event(SqueezeEvent.STATE_UPDATED)
-
-    @classmethod
-    def _process_stat_stmo(cls, data):
-        """
-        Process incoming stat STMo message.
-
-        No more decoded (uncompressed) data to play; triggers rebuffering.
-        """
-        # pylint: disable=unused-argument
-        LOGGER.warning("STMo received - output underrun.")
-
-    def _process_stat_stmp(self, data):
-        """Process incoming stat STMp message: Pause confirmed."""
-        # pylint: disable=unused-argument
-        LOGGER.debug("STMp received - pause confirmed.")
-        self._state = STATE_PAUSED
-        self.signal_event(SqueezeEvent.STATE_UPDATED)
-
-    def _process_stat_stmr(self, data):
-        """Process incoming stat STMr message: Resume confirmed."""
-        # pylint: disable=unused-argument
-        LOGGER.debug("STMr received - resume confirmed.")
-        self._state = STATE_PLAYING
-        self.signal_event(SqueezeEvent.STATE_UPDATED)
-
-    def _process_stat_stms(self, data):
-        # pylint: disable=unused-argument
-        """Process incoming stat STMs message: Playback of new track has started."""
-        LOGGER.debug("STMs received - playback of new track has started.")
-        self._state = STATE_PLAYING
-        self.signal_event(SqueezeEvent.STATE_UPDATED)
-
-    def _process_stat_stmt(self, data):
-        """Process incoming stat STMt message: heartbeat from client."""
-        # pylint: disable=unused-variable
-        self._last_heartbeat = time.time()
-        (
-            num_crlf,
-            mas_initialized,
-            mas_mode,
-            rptr,
-            wptr,
-            bytes_received_h,
-            bytes_received_l,
-            signal_strength,
-            jiffies,
-            output_buffer_size,
-            output_buffer_fullness,
-            elapsed_seconds,
-            voltage,
-            elapsed_milliseconds,
-            timestamp,
-            error_code,
-        ) = struct.unpack("!BBBLLLLHLLLLHLLH", data)
-        if self.state == STATE_PLAYING:
-            # elapsed seconds is weird when player is buffering etc.
-            # only rely on it if player is playing
-            self._elapsed_milliseconds = elapsed_milliseconds
-            if self._elapsed_seconds != elapsed_seconds:
-                self._elapsed_seconds = elapsed_seconds
-                self.signal_event(SqueezeEvent.STATE_UPDATED)
-
-    def _process_stat_stmu(self, data):
-        """Process incoming stat STMu message: Buffer underrun: Normal end of playback."""
-        # pylint: disable=unused-argument
-        LOGGER.debug("STMu received - end of playback.")
-        self._state = STATE_IDLE
-        self.signal_event(SqueezeEvent.STATE_UPDATED)
-
-    def _process_stat_stml(self, data):
-        """Process incoming stat STMl message: Buffer threshold reached."""
-        # pylint: disable=unused-argument
-        LOGGER.debug("STMl received - Buffer threshold reached.")
-        # autoplay 0 or 2: start playing by send unpause command when buffer full
-        # create_task(self.send_strm(b"u"))
-
-    def _process_stat_stmn(self, data):
-        """Process incoming stat STMn message: player couldn't decode stream."""
-        # pylint: disable=unused-argument
-        LOGGER.debug("STMn received - player couldn't decode stream.")
-        # request next track when this happens
-        self.signal_event(SqueezeEvent.DECODER_READY)
-
-    def _process_resp(self, data):
-        """Process incoming RESP message: Response received at player."""
-        LOGGER.debug("RESP received - Response received at player.")
-        # send continue (used when autoplay 1 or 3)
-        create_task(self._send_frame, b"cont", b"0")
-
-    def _process_setd(self, data):
-        """Process incoming SETD message: Get/set player firmware settings."""
-        cmd_id = data[0]
-        if cmd_id == 0:
-            # received player name
-            data = data[1:].decode()
-            self._device_name = data
-        self.signal_event(SqueezeEvent.STATE_UPDATED)
-
-
-class PySqueezeVolume:
-    """Represents a sound volume. This is an awful lot more complex than it sounds."""
-
-    minimum = 0
-    maximum = 100
-    step = 1
-
-    # this map is taken from Slim::Player::Squeezebox2 in the squeezecenter source
-    # i don't know how much magic it contains, or any way I can test it
-    old_map = [
-        0,
-        1,
-        1,
-        1,
-        2,
-        2,
-        2,
-        3,
-        3,
-        4,
-        5,
-        5,
-        6,
-        6,
-        7,
-        8,
-        9,
-        9,
-        10,
-        11,
-        12,
-        13,
-        14,
-        15,
-        16,
-        16,
-        17,
-        18,
-        19,
-        20,
-        22,
-        23,
-        24,
-        25,
-        26,
-        27,
-        28,
-        29,
-        30,
-        32,
-        33,
-        34,
-        35,
-        37,
-        38,
-        39,
-        40,
-        42,
-        43,
-        44,
-        46,
-        47,
-        48,
-        50,
-        51,
-        53,
-        54,
-        56,
-        57,
-        59,
-        60,
-        61,
-        63,
-        65,
-        66,
-        68,
-        69,
-        71,
-        72,
-        74,
-        75,
-        77,
-        79,
-        80,
-        82,
-        84,
-        85,
-        87,
-        89,
-        90,
-        92,
-        94,
-        96,
-        97,
-        99,
-        101,
-        103,
-        104,
-        106,
-        108,
-        110,
-        112,
-        113,
-        115,
-        117,
-        119,
-        121,
-        123,
-        125,
-        127,
-        128,
-    ]
-
-    # new gain parameters, from the same place
-    total_volume_range = -50  # dB
-    step_point = (
-        -1
-    )  # Number of steps, up from the bottom, where a 2nd volume ramp kicks in.
-    step_fraction = (
-        1  # fraction of totalVolumeRange where alternate volume ramp kicks in.
-    )
-
-    def __init__(self):
-        """Initialize class."""
-        self.volume = 50
-
-    def increment(self):
-        """Increment the volume."""
-        self.volume += self.step
-        if self.volume > self.maximum:
-            self.volume = self.maximum
-
-    def decrement(self):
-        """Decrement the volume."""
-        self.volume -= self.step
-        if self.volume < self.minimum:
-            self.volume = self.minimum
-
-    def old_gain(self):
-        """Return the "Old" gain value as required by the squeezebox."""
-        return self.old_map[self.volume]
-
-    def decibels(self):
-        """Return the "new" gain value."""
-        # pylint: disable=invalid-name
-
-        step_db = self.total_volume_range * self.step_fraction
-        max_volume_db = 0  # different on the boom?
-
-        # Equation for a line:
-        # y = mx+b
-        # y1 = mx1+b, y2 = mx2+b.
-        # y2-y1 = m(x2 - x1)
-        # y2 = m(x2 - x1) + y1
-        slope_high = max_volume_db - step_db / (100.0 - self.step_point)
-        slope_low = step_db - self.total_volume_range / (self.step_point - 0.0)
-        x2 = self.volume
-        if x2 > self.step_point:
-            m = slope_high
-            x1 = 100
-            y1 = max_volume_db
-        else:
-            m = slope_low
-            x1 = 0
-            y1 = self.total_volume_range
-        return m * (x2 - x1) + y1
-
-    def new_gain(self):
-        """Return new gainvalue of the volume control."""
-        decibel = self.decibels()
-        floatmult = 10 ** (decibel / 20.0)
-        # avoid rounding errors somehow
-        if -30 <= decibel <= 0:
-            return int(floatmult * (1 << 8) + 0.5) * (1 << 8)
-        return int((floatmult * (1 << 16)) + 0.5)
diff --git a/music_assistant/providers/tunein.py b/music_assistant/providers/tunein.py
new file mode 100644 (file)
index 0000000..da96aa2
--- /dev/null
@@ -0,0 +1,154 @@
+"""Tune-In musicprovider support for MusicAssistant."""
+from typing import List, Optional
+
+from asyncio_throttle import Throttler
+
+from music_assistant.models.media_items import (
+    ContentType,
+    MediaItemProviderId,
+    MediaItemType,
+    MediaQuality,
+    MediaType,
+    Radio,
+    StreamDetails,
+    StreamType,
+)
+from music_assistant.models.provider import MusicProvider
+
+
+class TuneInProvider(MusicProvider):
+    """Provider implementation for Tune In."""
+
+    def __init__(self, username: str | None) -> None:
+        """Initialize the provider."""
+        self._attr_id = "tunein"
+        self._attr_name = "Tune-in Radio"
+        self._attr_supported_mediatypes = [MediaType.RADIO]
+        self._username = username
+        self._throttler = Throttler(rate_limit=1, period=1)
+
+    async def setup(self) -> None:
+        """Handle async initialization of the provider."""
+        # we have nothing to setup
+
+    async def search(
+        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
+    ) -> List[MediaItemType]:
+        """
+        Perform search on musicprovider.
+
+            :param search_query: Search query.
+            :param media_types: A list of media_types to include. All types if None.
+            :param limit: Number of items to return in the search (per type).
+        """
+        result = []
+        # TODO: search for radio stations
+        return result
+
+    async def get_library_radios(self) -> List[Radio]:
+        """Retrieve library/subscribed radio stations from the provider."""
+        params = {"c": "presets"}
+        result = await self._get_data("Browse.ashx", params)
+        if result and "body" in result:
+            return [
+                await self._parse_radio(item)
+                for item in result["body"]
+                if item["type"] == "audio"
+            ]
+        return []
+
+    async def get_radio(self, prov_radio_id: str) -> Radio:
+        """Get radio station details."""
+        prov_radio_id = prov_radio_id.split("--")[0]
+        radio = None
+        params = {"c": "composite", "detail": "listing", "id": prov_radio_id}
+        result = await self._get_data("Describe.ashx", params)
+        if result and result.get("body") and result["body"][0].get("children"):
+            item = result["body"][0]["children"][0]
+            radio = await self._parse_radio(item)
+        return radio
+
+    async def _parse_radio(self, details: dict) -> Radio:
+        """Parse Radio object from json obj returned from api."""
+        if "name" in details:
+            name = details["name"]
+        else:
+            # parse name from text attr
+            name = details["text"]
+            if " | " in name:
+                name = name.split(" | ")[1]
+            name = name.split(" (")[0]
+        radio = Radio(item_id=details["preset_id"], provider=self.id, name=name)
+        # parse stream urls and format
+        stream_info = await self._get_stream_urls(radio.item_id)
+        for stream in stream_info["body"]:
+            if stream["media_type"] == "aac":
+                quality = MediaQuality.LOSSY_AAC
+            elif stream["media_type"] == "ogg":
+                quality = MediaQuality.LOSSY_OGG
+            else:
+                quality = MediaQuality.LOSSY_MP3
+            radio.provider_ids.append(
+                MediaItemProviderId(
+                    provider=self.id,
+                    item_id=f'{details["preset_id"]}--{stream["media_type"]}',
+                    quality=quality,
+                    details=stream["url"],
+                )
+            )
+        # image
+        if "image" in details:
+            radio.metadata["image"] = details["image"]
+        elif "logo" in details:
+            radio.metadata["image"] = details["logo"]
+        return radio
+
+    async def _get_stream_urls(self, radio_id):
+        """Return the stream urls for the given radio id."""
+        radio_id = radio_id.split("--")[0]
+        params = {"id": radio_id}
+        res = await self._get_data("Tune.ashx", params)
+        return res
+
+    async def get_stream_details(self, item_id: str) -> StreamDetails:
+        """Get streamdetails for a radio station."""
+        radio_id = item_id.split("--")[0]
+        if len(item_id.split("--")) > 1:
+            media_type = item_id.split("--")[1]
+        else:
+            media_type = ""
+        stream_info = await self._get_stream_urls(radio_id)
+        for stream in stream_info["body"]:
+            if stream["media_type"] == media_type or not media_type:
+                return StreamDetails(
+                    type=StreamType.URL,
+                    item_id=item_id,
+                    provider=self.id,
+                    path=stream["url"],
+                    content_type=ContentType(stream["media_type"]),
+                    sample_rate=44100,
+                    bit_depth=16,
+                    media_type=MediaType.RADIO,
+                    details=stream,
+                )
+        return None
+
+    async def _get_data(self, endpoint, params=None):
+        """Get data from api."""
+        if not params:
+            params = {}
+        url = f"https://opml.radiotime.com/{endpoint}"
+        params["render"] = "json"
+        params["formats"] = "ogg,aac,wma,mp3"
+        params["username"] = self._username
+        params["partnerId"] = "1"
+        async with self._throttler:
+            async with self.mass.http_session.get(
+                url, params=params, verify_ssl=False
+            ) as response:
+                result = await response.json()
+                if not result or "error" in result:
+                    self.logger.error(url)
+                    self.logger.error(params)
+                    result = None
+                return result
diff --git a/music_assistant/providers/tunein/__init__.py b/music_assistant/providers/tunein/__init__.py
deleted file mode 100644 (file)
index 821d4b3..0000000
+++ /dev/null
@@ -1,202 +0,0 @@
-"""Tune-In musicprovider support for MusicAssistant."""
-import logging
-from typing import List, Optional
-
-from asyncio_throttle import Throttler
-from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME
-from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType
-from music_assistant.models.media_types import (
-    MediaItemProviderId,
-    MediaType,
-    Radio,
-    SearchResult,
-    TrackQuality,
-)
-from music_assistant.models.provider import MusicProvider
-from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
-
-PROV_ID = "tunein"
-PROV_NAME = "TuneIn Radio"
-LOGGER = logging.getLogger(PROV_ID)
-
-CONFIG_ENTRIES = [
-    ConfigEntry(
-        entry_key=CONF_USERNAME,
-        entry_type=ConfigEntryType.STRING,
-        description=CONF_USERNAME,
-    ),
-    ConfigEntry(
-        entry_key=CONF_PASSWORD,
-        entry_type=ConfigEntryType.PASSWORD,
-        description=CONF_PASSWORD,
-    ),
-]
-
-
-async def setup(mass):
-    """Perform async setup of this Plugin/Provider."""
-    prov = TuneInProvider()
-    await mass.register_provider(prov)
-
-
-class TuneInProvider(MusicProvider):
-    """Provider implementation for Tune In."""
-
-    # pylint: disable=abstract-method
-
-    _username = None
-    _password = None
-    _throttler = None
-
-    @property
-    def id(self) -> str:
-        """Return provider ID for this provider."""
-        return PROV_ID
-
-    @property
-    def name(self) -> str:
-        """Return provider Name for this provider."""
-        return PROV_NAME
-
-    @property
-    def config_entries(self) -> List[ConfigEntry]:
-        """Return Config Entries for this provider."""
-        return CONFIG_ENTRIES
-
-    @property
-    def supported_mediatypes(self) -> List[MediaType]:
-        """Return MediaTypes the provider supports."""
-        return [MediaType.RADIO]
-
-    async def on_start(self) -> bool:
-        """Handle initialization of the provider based on config."""
-        # pylint: disable=attribute-defined-outside-init
-        config = self.mass.config.get_provider_config(self.id)
-        if not config[CONF_USERNAME] or not config[CONF_PASSWORD]:
-            LOGGER.debug("Username and password not set. Abort load of provider.")
-            return False
-        self._username = config[CONF_USERNAME]
-        self._password = config[CONF_PASSWORD]
-        self._throttler = Throttler(rate_limit=1, period=1)
-        return True
-
-    async def search(
-        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
-    ) -> SearchResult:
-        """
-        Perform search on musicprovider.
-
-            :param search_query: Search query.
-            :param media_types: A list of media_types to include. All types if None.
-            :param limit: Number of items to return in the search (per type).
-        """
-        result = SearchResult()
-        # TODO: search for radio stations
-        return result
-
-    async def get_library_radios(self) -> List[Radio]:
-        """Retrieve library/subscribed radio stations from the provider."""
-        params = {"c": "presets"}
-        result = await self._get_data("Browse.ashx", params)
-        if result and "body" in result:
-            return [
-                await self._parse_radio(item)
-                for item in result["body"]
-                if item["type"] == "audio"
-            ]
-        return []
-
-    async def get_radio(self, prov_radio_id: str) -> Radio:
-        """Get radio station details."""
-        radio = None
-        params = {"c": "composite", "detail": "listing", "id": prov_radio_id}
-        result = await self._get_data("Describe.ashx", params)
-        if result and result.get("body") and result["body"][0].get("children"):
-            item = result["body"][0]["children"][0]
-            radio = await self._parse_radio(item)
-        return radio
-
-    async def _parse_radio(self, details: dict) -> Radio:
-        """Parse Radio object from json obj returned from api."""
-        radio = Radio(item_id=details["preset_id"], provider=PROV_ID)
-        if "name" in details:
-            radio.name = details["name"]
-        else:
-            # parse name from text attr
-            name = details["text"]
-            if " | " in name:
-                name = name.split(" | ")[1]
-            name = name.split(" (")[0]
-            radio.name = name
-        # parse stream urls and format
-        stream_info = await self._get_stream_urls(radio.item_id)
-        for stream in stream_info["body"]:
-            if stream["media_type"] == "aac":
-                quality = TrackQuality.LOSSY_AAC
-            elif stream["media_type"] == "ogg":
-                quality = TrackQuality.LOSSY_OGG
-            else:
-                quality = TrackQuality.LOSSY_MP3
-            radio.provider_ids.add(
-                MediaItemProviderId(
-                    provider=PROV_ID,
-                    item_id="%s--%s" % (details["preset_id"], stream["media_type"]),
-                    quality=quality,
-                    details=stream["url"],
-                )
-            )
-        # image
-        if "image" in details:
-            radio.metadata["image"] = details["image"]
-        elif "logo" in details:
-            radio.metadata["image"] = details["logo"]
-        return radio
-
-    async def _get_stream_urls(self, radio_id):
-        """Return the stream urls for the given radio id."""
-        params = {"id": radio_id}
-        res = await self._get_data("Tune.ashx", params)
-        return res
-
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
-        """Get streamdetails for a radio station."""
-        radio_id = item_id.split("--")[0]
-        if len(item_id.split("--")) > 1:
-            media_type = item_id.split("--")[1]
-        else:
-            media_type = ""
-        stream_info = await self._get_stream_urls(radio_id)
-        for stream in stream_info["body"]:
-            if stream["media_type"] == media_type or not media_type:
-                return StreamDetails(
-                    type=StreamType.URL,
-                    item_id=item_id,
-                    provider=PROV_ID,
-                    path=stream["url"],
-                    content_type=ContentType(stream["media_type"]),
-                    sample_rate=44100,
-                    bit_depth=16,
-                    media_type=MediaType.RADIO,
-                    details=stream,
-                )
-        return None
-
-    async def _get_data(self, endpoint, params=None):
-        """Get data from api."""
-        if not params:
-            params = {}
-        url = "https://opml.radiotime.com/%s" % endpoint
-        params["render"] = "json"
-        params["formats"] = "ogg,aac,wma,mp3"
-        params["username"] = self._username
-        params["partnerId"] = "1"
-        async with self._throttler:
-            async with self.mass.http_session.get(
-                url, params=params, verify_ssl=False
-            ) as response:
-                result = await response.json()
-                if not result or "error" in result:
-                    LOGGER.error(url)
-                    LOGGER.error(params)
-                    result = None
-                return result
diff --git a/music_assistant/providers/tunein/icon.png b/music_assistant/providers/tunein/icon.png
deleted file mode 100644 (file)
index 18c537c..0000000
Binary files a/music_assistant/providers/tunein/icon.png and /dev/null differ
diff --git a/music_assistant/providers/universal_group/__init__.py b/music_assistant/providers/universal_group/__init__.py
deleted file mode 100644 (file)
index 35141d9..0000000
+++ /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 (file)
index 092121e..0000000
Binary files a/music_assistant/providers/universal_group/icon.png and /dev/null differ
diff --git a/music_assistant/resources/announce.flac b/music_assistant/resources/announce.flac
deleted file mode 100644 (file)
index 95c7cae..0000000
Binary files a/music_assistant/resources/announce.flac and /dev/null differ
diff --git a/music_assistant/resources/silence.flac b/music_assistant/resources/silence.flac
deleted file mode 100644 (file)
index 352dd6f..0000000
Binary files a/music_assistant/resources/silence.flac and /dev/null differ
diff --git a/music_assistant/resources/strings/en.json b/music_assistant/resources/strings/en.json
deleted file mode 100644 (file)
index a6a7535..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-{
-  "enabled": "Enabled",
-  "name": "Name",
-  "username": "Username",
-  "password": "Password",
-  "enable_player": "Enable this player",
-  "custom_name": "Custom name",
-  "max_sample_rate": "Maximum sample rate",
-  "volume_normalisation": "Enable Volume normalisation",
-  "target_volume": "Target Volume level",
-  "desc_player_name": "Set a custom name for this player.",
-  "crossfade_duration": "Enable crossfade",
-  "group_delay": "Correction of groupdelay",
-  "security": "Security",
-  "app_tokens": "App tokens",
-  "power_control": "Power Control",
-  "volume_control": "Volume Control",
-
-  "desc_sample_rate": "Set the maximum sample rate this player can handle.",
-  "desc_volume_normalisation": "Enable R128 volume normalisation to play music at an equally loud volume.",
-  "desc_target_volume": "Set the preferred target volume level in LUFS. The R128 default is -22 LUFS.",
-  "desc_crossfade": "Enable crossfading of Queue tracks by setting a crossfade duration in seconds.",
-  "desc_enable_provider": "Enable this provider.",
-  "desc_base_username": "Username to access this Music Assistant server.",
-  "desc_base_password": "A password to protect this Music Assistant server. Can be left blank but this is extremely dangerous if this server is reachable from outside.",
-  "desc_group_delay": "Only used on grouped playback. Adjust the delay of the grouped playback on this player",
-  "desc_power_control": "Use an external device as power control for this player.",
-  "desc_volume_control": "Use an external device as volume control for this player.",
-
-  "Universal Group player": "Universal Group Player",
-  "group_player_count": "Number of group players",
-  "group_player_count_desc": "Select how many Universal group players should be created.",
-  "group_player_players": "Players in group",
-  "group_player_players_desc": "Select the players that should be part of this group.",
-  "group_player_master": "Group master",
-  "group_player_master_desc": "Select the player that should act as group master.",
-
-  "desc_spotify_username": "Username for your Spotify account",
-  "desc_spotify_password": "Password for your Spotify account",
-
-  "file_prov_music_path": "Music path",
-  "file_prov_music_path_desc": "Path on disk to your music files.",
-  "file_prov_playlists_path": "Playlists path",
-  "file_prov_playlists_path_desc": "Path on disk to your playlists (.m3u) files."
-}
\ No newline at end of file
diff --git a/music_assistant/resources/strings/nl.json b/music_assistant/resources/strings/nl.json
deleted file mode 100644 (file)
index d7e3d23..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-{
-  "enabled": "Ingeschakeld",
-  "name": "Naam",
-  "username": "Gebruikersnaam",
-  "password": "Wachtwoord",
-  "enable_player": "Deze speler inschakelen",
-  "custom_name": "Aangepaste name",
-  "max_sample_rate": "Maximale sample rate",
-  "volume_normalisation": "Volume normalisering inschakelen",
-  "target_volume": "Doel volume",
-  "desc_player_name": "Stel een aangepaste naam in voor deze speler.",
-  "crossfade_duration": "Crossfade inschakelen",
-  "security": "Beveiliging",
-  "app_tokens": "App tokens",
-  "group_delay": "Correctie van groepsvertraging",
-  "power_control": "Power Control",
-  "volume_control": "Volume Control",
-
-  "desc_sample_rate": "Stel de maximale sample rate in die deze speler aankan.",
-  "desc_volume_normalisation": "R128 volume normalisatie inschakelen om muziek altijd op een gelijk volume af te spelen.",
-  "desc_target_volume": "Selecteer het gewenste doelvolume in LUFS. De R128 standaard is -22 LUFS.",
-  "desc_crossfade": "Crossfade inschakelen door het instellen van een crossfade duur in seconden.",
-  "desc_enable_provider": "Deze provider inschakelen.",
-  "desc_base_username": "Gebruikersnaam waarmee deze server beveiligd moet worden.",
-  "desc_base_password": "Wachtwoord waarmee deze server beveiligd moet worden. Mag worden leeggelaten maar dit is extreem gevaarlijk indien je besluit de server extern toegankelijk te maken.",
-  "desc_group_delay": "Gebruikt bij afspelen in groep. Pas de vertraging aan voor deze player.",
-  "desc_power_control": "Gebruik een extern apparaat als aan/uit control voor deze speler.",
-  "desc_volume_control": "Gebruik een extern apparaat als volume control voor deze speler.",
-
-  "Universal Group player": "Universele groep speler",
-  "group_player_count": "Aantal groep spelers",
-  "group_player_count_desc": "Selecteer hoeveel groep spelers er aangemaakt moeten worden.",
-  "group_player_players": "Groepsspelers",
-  "group_player_players_desc": "Selecteer de spelers die deel uitmaken van deze groep.",
-  "group_player_master": "Groepsbeheerder",
-  "group_player_master_desc": "Selecteer de speler die dient als groepsbeheerder.",
-
-  "desc_spotify_username": "Gebruikersnaam van jouw Spotify account",
-  "desc_spotify_password": "Wachtwoord van jouw Spotify account",
-
-  "file_prov_music_path": "Muzieklocatie",
-  "file_prov_music_path_desc": "Locatie op schijf waar jouw muziekbestanden staan.",
-  "file_prov_playlists_path": "Playlists locatie",
-  "file_prov_playlists_path_desc": "Locatie op schijf waar jouw playlists (.m3u) staan."
-}
\ No newline at end of file
diff --git a/music_assistant/web/__init__.py b/music_assistant/web/__init__.py
deleted file mode 100755 (executable)
index 17411af..0000000
+++ /dev/null
@@ -1,269 +0,0 @@
-"""
-The web module handles serving the custom websocket api on a custom port (default is 8095).
-
-All MusicAssistant clients communicate locally with this websockets api.
-The server is intended to be used locally only and not exposed outside,
-so it is HTTP only. Secure remote connections will be offered by a remote connect broker.
-"""
-import logging
-import os
-import uuid
-from json.decoder import JSONDecodeError
-from typing import Callable, List, Tuple
-
-import aiofiles
-import aiohttp_cors
-import jwt
-import music_assistant.web.api as api
-from aiohttp import web
-from aiohttp.web_exceptions import HTTPNotFound, HTTPUnauthorized
-from music_assistant.constants import (
-    CONF_KEY_SECURITY_LOGIN,
-    CONF_PASSWORD,
-    CONF_USERNAME,
-)
-from music_assistant.constants import __version__ as MASS_VERSION
-from music_assistant.helpers.datetime import future_timestamp
-from music_assistant.helpers.encryption import decrypt_string
-from music_assistant.helpers.errors import AuthenticationError
-from music_assistant.helpers.images import get_thumb_file
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import get_hostname, get_ip
-from music_assistant.helpers.web import APIRoute, create_api_route
-
-from .json_rpc import json_rpc_endpoint
-from .stream import routes as stream_routes
-
-LOGGER = logging.getLogger("webserver")
-
-
-class WebServer:
-    """Webserver and json/websocket api."""
-
-    def __init__(self, mass: MusicAssistant, port: int) -> None:
-        """Initialize class."""
-        self.jwt_key = None
-        self.app = None
-        self.mass = mass
-        self._port = port
-        # load/create/update config
-        self._hostname = get_hostname().lower()
-        self._ip_address = get_ip()
-        self.config = mass.config.base["web"]
-        self._runner = None
-        self.api_routes: List[APIRoute] = []
-
-    async def setup(self) -> None:
-        """Perform async setup."""
-        self.jwt_key = await decrypt_string(self.mass.config.stored_config["jwt_key"])
-        self.app = web.Application()
-        self.app["mass"] = self.mass
-        self.app["ws_clients"] = []
-        # add all routes
-        self.app.add_routes(stream_routes)
-        self.app.router.add_route("*", "/jsonrpc.js", json_rpc_endpoint)
-        self.app.router.add_view("/ws", api.WebSocketApi)
-
-        # Add server discovery on info including CORS support
-        cors = aiohttp_cors.setup(
-            self.app,
-            defaults={
-                "*": aiohttp_cors.ResourceOptions(
-                    allow_credentials=True,
-                    allow_headers="*",
-                )
-            },
-        )
-        cors.add(self.app.router.add_get("/info", self.info))
-        cors.add(self.app.router.add_post("/login", self.login))
-        cors.add(self.app.router.add_post("/setup", self.first_setup))
-        cors.add(self.app.router.add_get("/thumb", self.image_thumb))
-        self.app.router.add_route("*", "/api/{tail:.+}", api.handle_api_request)
-        # Host the frontend app
-        webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static/")
-        if os.path.isdir(webdir):
-            self.app.router.add_get("/", self.index)
-            self.app.router.add_static("/", webdir, append_version=True)
-
-        self._runner = web.AppRunner(self.app, access_log=None)
-        await self._runner.setup()
-        # set host to None to bind to all addresses on both IPv4 and IPv6
-        http_site = web.TCPSite(self._runner, host=None, port=self.port)
-        await http_site.start()
-        self.add_api_routes()
-        LOGGER.info("Started Music Assistant server on port %s", self.port)
-
-    async def stop(self) -> None:
-        """Stop the webserver."""
-        for ws_client in self.app["ws_clients"]:
-            await ws_client.close(message=b"server shutdown")
-
-    def add_api_routes(self) -> None:
-        """Register all methods decorated as api_route."""
-        for cls in [
-            api,
-            self.mass.music,
-            self.mass.players,
-            self.mass.config,
-            self.mass.library,
-            self.mass.tasks,
-        ]:
-            for item in dir(cls):
-                func = getattr(cls, item)
-                if not hasattr(func, "api_path"):
-                    continue
-                # method is decorated with our api decorator
-                self.register_api_route(func.api_path, func, func.api_method)
-
-    def register_api_route(
-        self,
-        path: str,
-        handler: Callable,
-        method: str = "GET",
-    ) -> None:
-        """Dynamically register a path/route on the API."""
-        route = create_api_route(path, handler, method)
-        # TODO: swagger generation
-        self.api_routes.append(route)
-
-    @property
-    def hostname(self) -> str:
-        """Return the hostname for this Music Assistant instance."""
-        if not self._hostname.endswith(".local"):
-            # probably running in docker, use mdns name instead
-            return f"mass_{self.server_id}.local"
-        return self._hostname
-
-    @property
-    def ip_address(self) -> str:
-        """Return the local IP(v4) address for this Music Assistant instance."""
-        return self._ip_address
-
-    @property
-    def port(self) -> int:
-        """Return the port for this Music Assistant instance."""
-        return self._port
-
-    @property
-    def stream_url(self) -> str:
-        """Return the base stream URL for this Music Assistant instance."""
-        # dns resolving often fails on stream devices so use IP-address
-        return f"http://{self.ip_address}:{self.port}/stream"
-
-    @property
-    def address(self) -> str:
-        """Return the base HTTP address for this Music Assistant instance."""
-        return f"http://{self.ip_address}:{self.port}"
-
-    @property
-    def server_id(self) -> str:
-        """Return the device ID for this Music Assistant Server."""
-        return self.mass.config.stored_config["server_id"]
-
-    @property
-    def discovery_info(self) -> dict:
-        """Return discovery info for this Music Assistant server."""
-        return {
-            "id": self.server_id,
-            "address": self.address,
-            "hostname": self.hostname,
-            "ip_address": self.ip_address,
-            "port": self.port,
-            "version": MASS_VERSION,
-            "friendly_name": self.mass.config.stored_config["friendly_name"],
-            "initialized": self.mass.config.stored_config["initialized"],
-        }
-
-    async def index(self, request: web.Request) -> web.FileResponse:
-        """Get the index page."""
-        # pylint: disable=unused-argument
-        html_app = os.path.join(
-            os.path.dirname(os.path.abspath(__file__)), "static/index.html"
-        )
-        return web.FileResponse(html_app)
-
-    async def info(self, request: web.Request) -> web.Response:
-        """Return server discovery info."""
-        return web.json_response(self.discovery_info)
-
-    async def login(self, request: web.Request) -> web.Response:
-        """
-        Validate given credentials and return JWT token.
-
-        If app_id is provided, a long lived token will be issued which can be withdrawn by the user.
-        """
-        try:
-            data = await request.post()
-            if not data:
-                data = await request.json()
-        except JSONDecodeError:
-            data = await request.json()
-        username = data["username"]
-        password = data["password"]
-        app_id = data.get("app_id", "")
-        verified = self.mass.config.security.validate_credentials(username, password)
-        if verified:
-            client_id = str(uuid.uuid4())
-            token_info = {
-                "username": username,
-                "server_id": self.server_id,
-                "client_id": client_id,
-                "app_id": app_id,
-            }
-            if app_id:
-                token_info["enabled"] = True
-                token_info["exp"] = future_timestamp(days=365 * 10)
-            else:
-                token_info["exp"] = future_timestamp(hours=8)
-            token = jwt.encode(token_info, self.jwt_key, algorithm="HS256")
-            if app_id:
-                self.mass.config.security.add_app_token(token_info)
-            token_info["token"] = token
-            return web.json_response(token_info)
-        raise HTTPUnauthorized(reason="Invalid credentials")
-
-    async def first_setup(self, request: web.Request) -> web.Response:
-        """Handle first-time server setup through onboarding wizard."""
-        try:
-            data = await request.post()
-            if not data:
-                data = await request.json()
-        except JSONDecodeError:
-            data = await request.json()
-        username = data["username"]
-        password = data["password"]
-        if self.mass.config.stored_config["initialized"]:
-            raise AuthenticationError("Already initialized")
-        # save credentials in config
-        self.mass.config.security[CONF_KEY_SECURITY_LOGIN][CONF_USERNAME] = username
-        self.mass.config.security[CONF_KEY_SECURITY_LOGIN][CONF_PASSWORD] = password
-        self.mass.config.stored_config["initialized"] = True
-        self.mass.config.save()
-        # fix discovery info
-        await self.mass.setup_discovery()
-        return web.json_response(self.discovery_info)
-
-    async def image_thumb(self, request: web.Request) -> web.Response:
-        """Get (resized) thumb image for given URL."""
-        url = request.query.get("url")
-        size = int(request.query.get("size", 150))
-
-        img_file = await get_thumb_file(self.mass, url, size)
-        if img_file:
-            async with aiofiles.open(img_file, "rb") as _file:
-                img_data = await _file.read()
-                headers = {
-                    "Content-Type": "image/png",
-                    "Cache-Control": "public, max-age=604800",
-                }
-                return web.Response(body=img_data, headers=headers)
-        raise KeyError("Invalid url!")
-
-    def get_api_handler(self, path: str, method: str) -> Tuple[APIRoute, dict]:
-        """Find API route match for given path."""
-        matchpath = path.replace("/api/", "")
-        for route in self.api_routes:
-            match = route.match(matchpath, method)
-            if match:
-                return match[0], match[1]
-        raise HTTPNotFound(reason="Invalid path: %s" % path)
diff --git a/music_assistant/web/api.py b/music_assistant/web/api.py
deleted file mode 100644 (file)
index 504ce49..0000000
+++ /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 (file)
index 8c3fbb9..0000000
+++ /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 (file)
index 9e6e7c0..0000000
+++ /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
index 199c245baf8da44d800bf4c317c9da30b7cf4a07..3551c6ab075fc49a0cc344973b76bcd465d08285 100644 (file)
@@ -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'
index 8db38b6b392c70d579888305648040ba520fae0c..6ff5766d0dc1c8222651824afe1cad9d89217289 100644 (file)
--- 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 60d1182c191e28aa351a49b350ccb3d732ce1b7a..b05bbca5b52b845fcf84b0e6494ea5e51276f67d 100644 (file)
--- 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