0.0.30 (#142)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sun, 1 Aug 2021 14:44:18 +0000 (16:44 +0200)
committerGitHub <noreply@github.com>
Sun, 1 Aug 2021 14:44:18 +0000 (16:44 +0200)
* Add dedicated task manager and eventbus

* fix typos

* some refactoring

* bump requirements

45 files changed:
.vscode/settings.json
music_assistant/constants.py
music_assistant/helpers/alert.mp3 [new file with mode: 0644]
music_assistant/helpers/audio.py [new file with mode: 0644]
music_assistant/helpers/cache.py
music_assistant/helpers/datetime.py [new file with mode: 0644]
music_assistant/helpers/errors.py [new file with mode: 0644]
music_assistant/helpers/images.py
music_assistant/helpers/muli_state_queue.py [new file with mode: 0644]
music_assistant/helpers/process.py
music_assistant/helpers/repath.py [deleted file]
music_assistant/helpers/typing.py
music_assistant/helpers/util.py
music_assistant/helpers/web.py
music_assistant/managers/config.py
music_assistant/managers/database.py
music_assistant/managers/events.py [new file with mode: 0644]
music_assistant/managers/library.py
music_assistant/managers/metadata.py
music_assistant/managers/music.py
music_assistant/managers/players.py
music_assistant/managers/streams.py [deleted file]
music_assistant/managers/tasks.py [new file with mode: 0644]
music_assistant/mass.py
music_assistant/models/media_types.py
music_assistant/models/player.py
music_assistant/models/player_queue.py
music_assistant/models/provider.py
music_assistant/models/streamdetails.py
music_assistant/providers/builtin_player/__init__.py
music_assistant/providers/chromecast/__init__.py
music_assistant/providers/chromecast/player.py
music_assistant/providers/file/__init__.py
music_assistant/providers/qobuz/__init__.py
music_assistant/providers/sonos/sonos.py
music_assistant/providers/spotify/__init__.py
music_assistant/providers/squeezebox/__init__.py
music_assistant/providers/squeezebox/socket_client.py
music_assistant/providers/tunein/__init__.py
music_assistant/providers/universal_group/__init__.py
music_assistant/web/__init__.py [changed mode: 0644->0755]
music_assistant/web/api.py [new file with mode: 0644]
music_assistant/web/server.py [deleted file]
music_assistant/web/stream.py [new file with mode: 0644]
music_assistant/web/streams.py [deleted file]

index 889b7d543f56c959b0e70b8ace4fc749c06a4dbe..efcfe8f466f75953dbf698d189e7930b742cfd4f 100644 (file)
@@ -2,7 +2,7 @@
     "python.linting.pylintEnabled": true,
     "python.linting.pylintArgs": ["--rcfile=${workspaceFolder}/setup.cfg"],
     "python.linting.enabled": true,
-    "python.pythonPath": "/Users/marcelvanderveldt/Workdir/music-assistant/server/.venv/bin/python3.9",
+    "python.pythonPath": ".venv/bin/python3.9",
     "python.linting.flake8Enabled": true,
     "python.linting.flake8Args": ["--config=${workspaceFolder}/setup.cfg"],
     "python.linting.mypyEnabled": false,
index 89733dd5e0aa09a49eabe9608e78cda03cd30c95..31a11b9d99abd1381132ede1842c8e46de3ba323 100755 (executable)
@@ -46,7 +46,6 @@ 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_QUEUE_TIME_UPDATED = "queue time updated"
 EVENT_SHUTDOWN = "application shutdown"
 EVENT_PROVIDER_REGISTERED = "provider registered"
 EVENT_PROVIDER_UNREGISTERED = "provider unregistered"
@@ -55,6 +54,7 @@ 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/helpers/alert.mp3 b/music_assistant/helpers/alert.mp3
new file mode 100644 (file)
index 0000000..133b3ac
Binary files /dev/null and b/music_assistant/helpers/alert.mp3 differ
diff --git a/music_assistant/helpers/audio.py b/music_assistant/helpers/audio.py
new file mode 100644 (file)
index 0000000..dab87cd
--- /dev/null
@@ -0,0 +1,206 @@
+"""Various helpers for audio manipulation."""
+
+import asyncio
+import logging
+from typing import List, Tuple
+
+from music_assistant.helpers.process import AsyncProcess
+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
+
+LOGGER = logging.getLogger("audio")
+
+
+async def crossfade_pcm_parts(
+    fade_in_part: bytes, fade_out_part: bytes, pcm_args: List[str], fade_length: int
+) -> bytes:
+    """Crossfade two chunks of pcm/raw audio using sox."""
+    # create fade-in part
+    fadeinfile = create_tempfile()
+    args = ["sox", "--ignore-length", "-t"] + pcm_args
+    args += ["-", "-t"] + pcm_args + [fadeinfile.name, "fade", "t", str(fade_length)]
+    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 += [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 + ["-"]
+    async with AsyncProcess(args, enable_write=False) as sox_proc:
+        crossfade_part, _ = await sox_proc.communicate()
+    fadeinfile.close()
+    fadeoutfile.close()
+    del fadeinfile
+    del fadeoutfile
+    return crossfade_part
+
+
+async def strip_silence(audio_data: bytes, pcm_args: List[str], reverse=False) -> bytes:
+    """Strip silence from (a chunk of) pcm audio."""
+    args = ["sox", "--ignore-length", "-t"] + pcm_args + ["-", "-t"] + pcm_args + ["-"]
+    if reverse:
+        args.append("reverse")
+    args += ["silence", "1", "0.1", "1%"]
+    if reverse:
+        args.append("reverse")
+    async with AsyncProcess(args, enable_write=True) as sox_proc:
+        stripped_data, _ = await sox_proc.communicate(audio_data)
+    return stripped_data
+
+
+async def analyze_audio(mass: MusicAssistant, streamdetails: StreamDetails) -> None:
+    """Analyze track audio, for now we only calculate EBU R128 loudness."""
+
+    if streamdetails.loudness is not None:
+        # only when needed we do the analyze stuff
+        return
+
+    # only when needed we do the analyze stuff
+    LOGGER.debug(
+        "Start analyzing track %s/%s",
+        streamdetails.provider,
+        streamdetails.item_id,
+    )
+    # calculate BS.1770 R128 integrated loudness with ffmpeg
+    if streamdetails.type == StreamType.EXECUTABLE:
+        proc_args = (
+            "%s | ffmpeg -i pipe: -af ebur128=framelog=verbose -f null - 2>&1 | awk '/I:/{print $2}'"
+            % streamdetails.path
+        )
+    else:
+        proc_args = (
+            "ffmpeg -i '%s' -af ebur128=framelog=verbose -f null - 2>&1 | awk '/I:/{print $2}'"
+            % streamdetails.path
+        )
+    audio_data = b""
+    if streamdetails.media_type == MediaType.RADIO:
+        proc_args = "ffmpeg -i pipe: -af ebur128=framelog=verbose -f null - 2>&1 | awk '/I:/{print $2}'"
+        # for radio we collect ~10 minutes of audio data to process
+        async with mass.http_session.get(streamdetails.path) as response:
+            async for chunk, _ in response.content.iter_chunks():
+                audio_data += chunk
+                if len(audio_data) >= 20000:
+                    break
+
+    proc = await asyncio.create_subprocess_shell(
+        proc_args,
+        stdout=asyncio.subprocess.PIPE,
+        stdin=asyncio.subprocess.PIPE if audio_data else None,
+    )
+    value, _ = await proc.communicate(audio_data or None)
+    loudness = float(value.decode().strip())
+    await mass.database.set_track_loudness(
+        streamdetails.item_id, streamdetails.provider, loudness
+    )
+    LOGGER.debug(
+        "Integrated loudness of %s/%s is: %s",
+        streamdetails.provider,
+        streamdetails.item_id,
+        loudness,
+    )
+
+
+async def get_stream_details(
+    mass: MusicAssistant, queue_item: QueueItem, player_id: str = ""
+) -> StreamDetails:
+    """
+    Get streamdetails for the given media_item.
+
+    This is called just-in-time when a player/queue wants a MediaItem to be played.
+    Do not try to request streamdetails in advance as this is expiring data.
+        param media_item: The MediaItem (track/radio) for which to request the streamdetails for.
+        param player_id: Optionally provide the player_id which will play this stream.
+    """
+    if queue_item.provider == "url":
+        # special case: a plain url was added to the queue
+        if queue_item.streamdetails is not None:
+            streamdetails = queue_item.streamdetails
+        else:
+            streamdetails = StreamDetails(
+                type=StreamType.URL,
+                provider="url",
+                item_id=queue_item.item_id,
+                path=queue_item.uri,
+                content_type=ContentType(queue_item.uri.split(".")[-1]),
+                sample_rate=44100,
+                bit_depth=16,
+            )
+    else:
+        # always request the full db track as there might be other qualities available
+        # except for radio
+        if 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:
+            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
+        ):
+            if not prov_media.available:
+                continue
+            # get streamdetails from provider
+            music_prov = mass.get_provider(prov_media.provider)
+            if not music_prov or not music_prov.available:
+                continue  # provider temporary unavailable ?
+
+            streamdetails: StreamDetails = await music_prov.get_stream_details(
+                prov_media.item_id
+            )
+            if streamdetails:
+                try:
+                    streamdetails.content_type = ContentType(streamdetails.content_type)
+                except KeyError:
+                    LOGGER.warning("Invalid content type!")
+                else:
+                    break
+
+    if streamdetails:
+        # set player_id on the streamdetails so we know what players stream
+        streamdetails.player_id = player_id
+        # get gain correct / replaygain
+        if not queue_item.name == "alert":
+            loudness, gain_correct = await get_gain_correct(
+                mass, player_id, streamdetails.item_id, streamdetails.provider
+            )
+            streamdetails.gain_correct = gain_correct
+            streamdetails.loudness = loudness
+        # set streamdetails as attribute on the media_item
+        # this way the app knows what content is playing
+        queue_item.streamdetails = streamdetails
+        return streamdetails
+    return None
+
+
+async def get_gain_correct(
+    mass: MusicAssistant, player_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"]:
+        return 0
+    target_gain = int(player_conf["target_volume"])
+    track_loudness = await mass.database.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)
+        if fallback_track_loudness is None:
+            # fallback to some (hopefully sane) average value for now
+            fallback_track_loudness = -8.5
+        gain_correct = target_gain - fallback_track_loudness
+    else:
+        gain_correct = target_gain - track_loudness
+    gain_correct = round(gain_correct, 2)
+    return (track_loudness, gain_correct)
index dca7611ad719107c78157c269b56b6d537102a0b..fc7ea370cf8db9f6248544d0e38d141972e9e74a 100644 (file)
@@ -9,7 +9,8 @@ import time
 from typing import Awaitable
 
 import aiosqlite
-from music_assistant.helpers.util import run_periodic
+from music_assistant.helpers.typing import MusicAssistant
+from music_assistant.helpers.util import create_task
 
 LOGGER = logging.getLogger("cache")
 
@@ -19,13 +20,13 @@ class Cache:
 
     _db = None
 
-    def __init__(self, mass):
+    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._mem_cache = {}
 
-    async def setup(self):
+    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(
@@ -35,7 +36,7 @@ class Cache:
             await db_conn.commit()
             await db_conn.execute("VACUUM;")
             await db_conn.commit()
-        self.mass.add_job(self.auto_cleanup())
+        self.mass.tasks.add("Cleanup cache", self.auto_cleanup, periodic=3600)
 
     async def get(self, cache_key, checksum="", default=None):
         """
@@ -102,13 +103,11 @@ class Cache:
             await db_conn.execute(sql_query, (cache_key,))
             await db_conn.commit()
 
-    @run_periodic(3600)
     async def auto_cleanup(self):
         """Sceduled auto cleanup task."""
         # for now we simply rest the memory cache
         self._mem_cache = {}
         cur_timestamp = int(time.time())
-        LOGGER.debug("Running cleanup...")
         sql_query = "SELECT id, expires FROM simplecache"
         async with aiosqlite.connect(self._dbfile, timeout=600) as db_conn:
             db_conn.row_factory = aiosqlite.Row
@@ -122,7 +121,6 @@ class Cache:
                     await db_conn.execute(sql_query, (cache_id,))
             # compact db
             await db_conn.commit()
-        LOGGER.debug("Auto cleanup done")
 
     @staticmethod
     def _get_checksum(stringinput):
@@ -146,7 +144,7 @@ async def cached(
     if cache_result is not None:
         return cache_result
     result = await coro_func(*args)
-    asyncio.create_task(cache.set(cache_key, result, checksum, expires))
+    create_task(cache.set(cache_key, result, checksum, expires))
     return result
 
 
@@ -165,7 +163,7 @@ def use_cache(cache_days=14, cache_checksum=None):
             if cachedata is not None:
                 return cachedata
             result = await func(*args, **kwargs)
-            asyncio.create_task(
+            create_task(
                 method_class.cache.set(
                     cache_str,
                     result,
diff --git a/music_assistant/helpers/datetime.py b/music_assistant/helpers/datetime.py
new file mode 100644 (file)
index 0000000..3373c0b
--- /dev/null
@@ -0,0 +1,40 @@
+"""Helpers for date and time."""
+
+import datetime
+
+LOCAL_TIMEZONE = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
+
+
+def utc() -> datetime.datetime:
+    """Get current UTC datetime."""
+    return datetime.datetime.now(datetime.timezone.utc)
+
+
+def utc_timestamp() -> float:
+    """Return UTC timestamp in seconds as float."""
+    return utc().timestamp()
+
+
+def now() -> datetime.datetime:
+    """Get current datetime in local timezone."""
+    return datetime.datetime.now(LOCAL_TIMEZONE)
+
+
+def now_timestamp() -> float:
+    """Return current datetime as timestamp in local timezone."""
+    return now().timestamp()
+
+
+def future_timestamp(**kwargs) -> float:
+    """Return current timestamp + timedelta."""
+    return (now() + datetime.timedelta(**kwargs)).timestamp()
+
+
+def from_utc_timestamp(timestamp: float) -> datetime.datetime:
+    """Return datetime from UTC timestamp."""
+    return datetime.datetime.fromtimestamp(timestamp, datetime.timezone.utc)
+
+
+def iso_from_utc_timestamp(timestamp: float) -> str:
+    """Return ISO 8601 datetime string from UTC timestamp."""
+    return from_utc_timestamp(timestamp).isoformat()
diff --git a/music_assistant/helpers/errors.py b/music_assistant/helpers/errors.py
new file mode 100644 (file)
index 0000000..076fd86
--- /dev/null
@@ -0,0 +1,5 @@
+"""Custom errors and exceptions."""
+
+
+class AuthenticationError(Exception):
+    """Custom Exception for all authentication errors."""
index ebdea7aa2f3604dd5e077108b0ca49c9d319ec19..2489c6db5e5d3f03eae60379a566194b32212050 100644 (file)
@@ -64,14 +64,14 @@ async def get_image_url(
         and item.artist.metadata.get("image")
     ):
         return item.album.metadata["image"]
-    if media_type == MediaType.Track and item.album:
+    if media_type == MediaType.TRACK and item.album:
         # try album instead for tracks
         return await get_image_url(
-            mass, item.album.item_id, item.album.provider, MediaType.Album
+            mass, item.album.item_id, item.album.provider, MediaType.ALBUM
         )
-    if media_type == MediaType.Album and item.artist:
+    if media_type == MediaType.ALBUM and item.artist:
         # try artist instead for albums
         return await get_image_url(
-            mass, item.artist.item_id, item.artist.provider, MediaType.Artist
+            mass, item.artist.item_id, item.artist.provider, MediaType.ARTIST
         )
     return None
diff --git a/music_assistant/helpers/muli_state_queue.py b/music_assistant/helpers/muli_state_queue.py
new file mode 100644 (file)
index 0000000..7655704
--- /dev/null
@@ -0,0 +1,66 @@
+"""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)
index 27309da973c49968c6bf23195799a69a7601c445..db211cd476807d39a3f4a5595c96b6800d87d398 100644 (file)
@@ -7,32 +7,46 @@ even when properly handling reading/writes from different tasks.
 
 import asyncio
 import logging
-from typing import AsyncGenerator, List, Optional
+from typing import AsyncGenerator, List, Optional, Union
 
 from async_timeout import timeout
 
 LOGGER = logging.getLogger("AsyncProcess")
 
-DEFAULT_CHUNKSIZE = 512000
-DEFAULT_TIMEOUT = 60
+DEFAULT_CHUNKSIZE = 256000
+DEFAULT_TIMEOUT = 10
 
 
 class AsyncProcess:
     """Implementation of a (truly) non blocking subprocess."""
 
-    def __init__(self, process_args: List, enable_write: bool = False):
+    def __init__(self, args: Union[List, str], enable_write: bool = False):
         """Initialize."""
         self._proc = None
-        self._process_args = process_args
+        self._args = args
         self._enable_write = enable_write
 
     async def __aenter__(self) -> "AsyncProcess":
         """Enter context manager."""
-        self._proc = await asyncio.create_subprocess_exec(
-            *self._process_args,
-            stdin=asyncio.subprocess.PIPE if self._enable_write else None,
-            stdout=asyncio.subprocess.PIPE
-        )
+        if "|" in self._args:
+            self._args = " ".join(self._args)
+
+        if isinstance(self._args, str):
+            self._proc = await asyncio.create_subprocess_shell(
+                self._args,
+                stdin=asyncio.subprocess.PIPE if self._enable_write else None,
+                stdout=asyncio.subprocess.PIPE,
+                limit=8000000,
+                close_fds=True,
+            )
+        else:
+            self._proc = await asyncio.create_subprocess_exec(
+                *self._args,
+                stdin=asyncio.subprocess.PIPE if self._enable_write else None,
+                stdout=asyncio.subprocess.PIPE,
+                limit=8000000,
+                close_fds=True,
+            )
         return self
 
     async def __aexit__(self, exc_type, exc_value, traceback) -> bool:
@@ -54,21 +68,25 @@ class AsyncProcess:
         """Yield chunks from the process stdout. Generator."""
         while True:
             chunk = await self.read(chunk_size)
+            if not chunk:
+                break
             yield chunk
-            if len(chunk) < chunk_size:
+            if chunk_size is not None and len(chunk) < chunk_size:
                 break
 
-    async def read(
-        self, chunk_size: int = DEFAULT_CHUNKSIZE, time_out: int = DEFAULT_TIMEOUT
-    ) -> bytes:
+    async def read(self, chunk_size: int = DEFAULT_CHUNKSIZE) -> bytes:
         """Read x bytes from the process stdout."""
+        if self._proc.stdout.at_eof() or self._proc.returncode is not None:
+            return b""
         try:
-            async with timeout(time_out):
+            async with timeout(DEFAULT_TIMEOUT):
+                if chunk_size is None:
+                    return await self._proc.stdout.read(DEFAULT_CHUNKSIZE)
                 return await self._proc.stdout.readexactly(chunk_size)
         except asyncio.IncompleteReadError as err:
             return err.partial
-        except AttributeError:
-            raise asyncio.CancelledError()
+        except AttributeError as exc:
+            raise asyncio.CancelledError() from exc
 
     async def write(self, data: bytes) -> None:
         """Write data to process stdin."""
diff --git a/music_assistant/helpers/repath.py b/music_assistant/helpers/repath.py
deleted file mode 100644 (file)
index f5857e6..0000000
+++ /dev/null
@@ -1,264 +0,0 @@
-"""
-Helper functionalities for path detection in api routes.
-
-Based on the original work by nickcoutsos and synacor
-https://github.com/nickcoutsos/python-repath
-"""
-import re
-import urllib
-import urllib.parse
-
-REGEXP_TYPE = type(re.compile(""))
-PATH_REGEXP = re.compile(
-    "|".join(
-        [
-            # Match escaped characters that would otherwise appear in future matches.
-            # This allows the user to escape special characters that won't transform.
-            "(\\\\.)",
-            # Match Express-style parameters and un-named parameters with a prefix
-            # and optional suffixes. Matches appear as:
-            #
-            # "/:test(\\d+)?" => ["/", "test", "\d+", undefined, "?", undefined]
-            # "/route(\\d+)"  => [undefined, undefined, undefined, "\d+", undefined, undefined]
-            # "/*"            => ["/", undefined, undefined, undefined, undefined, "*"]
-            "([\\/.])?(?:(?:\\:(\\w+)(?:\\(((?:\\\\.|[^()])+)\\))?|\\(((?:\\\\.|[^()])+)\\))([+*?])?|(\\*))",
-        ]
-    )
-)
-
-
-def escape_string(string):
-    """Escape URL-acceptable regex special-characters."""
-    return re.sub("([.+*?=^!:${}()[\\]|])", r"\\\1", string)
-
-
-def escape_group(group):
-    """Escape group."""
-    return re.sub("([=!:$()])", r"\\\1", group)
-
-
-def parse(string):
-    """Parse a string for the raw tokens."""
-    tokens = []
-    key = 0
-    index = 0
-    path = ""
-
-    for match in PATH_REGEXP.finditer(string):
-        matched = match.group(0)
-        escaped = match.group(1)
-        offset = match.start(0)
-        path += string[index:offset]
-        index = offset + len(matched)
-
-        if escaped:
-            path += escaped[1]
-            continue
-
-        if path:
-            tokens.append(path)
-            path = ""
-
-        prefix, name, capture, group, suffix, asterisk = match.groups()[1:]
-        repeat = suffix in ("+", "*")
-        optional = suffix in ("?", "*")
-        delimiter = prefix or "/"
-        pattern = capture or group or (".*" if asterisk else "[^%s]+?" % delimiter)
-
-        if not name:
-            name = key
-            key += 1
-
-        token = {
-            "name": str(name),
-            "prefix": prefix or "",
-            "delimiter": delimiter,
-            "optional": optional,
-            "repeat": repeat,
-            "pattern": escape_group(pattern),
-        }
-
-        tokens.append(token)
-
-    if index < len(string):
-        path += string[index:]
-
-    if path:
-        tokens.append(path)
-
-    return tokens
-
-
-def tokens_to_function(tokens):
-    """Expose a method for transforming tokens into the path function."""
-
-    def transform(obj):
-        path = ""
-        obj = obj or {}
-
-        for key in tokens:
-            if isinstance(key, str):
-                path += key
-                continue
-
-            regexp = re.compile("^%s$" % key["pattern"])
-
-            value = obj.get(key["name"])
-            if value is None:
-                if key["optional"]:
-                    continue
-                raise KeyError('Expected "{name}" to be defined'.format(**key))
-
-            if isinstance(value, list):
-                if not key["repeat"]:
-                    raise TypeError('Expected "{name}" to not repeat'.format(**key))
-
-                if not value:
-                    if key["optional"]:
-                        continue
-                    raise ValueError('Expected "{name}" to not be empty'.format(**key))
-
-                for i, val in enumerate(value):
-                    val = str(val)
-                    if not regexp.search(val):
-                        raise ValueError(
-                            'Expected all "{name}" to match "{pattern}"'.format(**key)
-                        )
-
-                    path += key["prefix"] if i == 0 else key["delimiter"]
-                    path += urllib.parse.quote(val, "")
-
-                continue
-
-            value = str(value)
-            if not regexp.search(value):
-                raise ValueError('Expected "{name}" to match "{pattern}"'.format(**key))
-
-            path += key["prefix"] + urllib.parse.quote(
-                value.encode("utf8"), "-_.!~*'()"
-            )
-
-        return path
-
-    return transform
-
-
-def regexp_to_pattern(regexp, keys):
-    """
-    Generate a pattern based on a compiled regular expression.
-
-    This function exists for a semblance of compatibility with pathToRegexp
-    and serves basically no purpose beyond making sure the pre-existing tests
-    continue to pass.
-
-    """
-    _match = re.search(r"\((?!\?)", regexp.pattern)
-
-    if _match:
-        keys.extend(
-            [
-                {
-                    "name": i,
-                    "prefix": None,
-                    "delimiter": None,
-                    "optional": False,
-                    "repeat": False,
-                    "pattern": None,
-                }
-                for i in range(len(_match.groups()))
-            ]
-        )
-
-    return regexp.pattern
-
-
-def tokens_to_pattern(tokens, options=None):
-    """Generate a pattern for the given list of tokens."""
-    options = options or {}
-
-    strict = options.get("strict")
-    end = options.get("end") is not False
-    route = ""
-    last_token = tokens[-1]
-    ends_with_slash = isinstance(last_token, str) and last_token.endswith("/")
-
-    patterns = dict(
-        REPEAT="(?:{prefix}{capture})*",
-        OPTIONAL="(?:{prefix}({name}{capture}))?",
-        REQUIRED="{prefix}({name}{capture})",
-    )
-
-    for token in tokens:
-        if isinstance(token, str):
-            route += escape_string(token)
-            continue
-
-        parts = {
-            "prefix": escape_string(token["prefix"]),
-            "capture": token["pattern"],
-            "name": "",
-        }
-
-        if token["name"] and re.search("[a-zA-Z]", token["name"]):
-            parts["name"] = "?P<%s>" % re.escape(token["name"])
-
-        if token["repeat"]:
-            parts["capture"] += patterns["REPEAT"].format(**parts)
-
-        template = patterns["OPTIONAL" if token["optional"] else "REQUIRED"]
-        route += template.format(**parts)
-
-    if not strict:
-        route = route[:-1] if ends_with_slash else route
-        route += "(?:/(?=$))?"
-
-    if end:
-        route += "$"
-    else:
-        route += "" if strict and ends_with_slash else "(?=/|$)"
-
-    return "^%s" % route
-
-
-def array_to_pattern(paths, keys, options):
-    """Generate a single pattern from an array of path pattern values."""
-    parts = [path_to_pattern(path, keys, options) for path in paths]
-
-    return "(?:%s)" % ("|".join(parts))
-
-
-def string_to_pattern(path, keys, options):
-    """
-    Generate pattern for a string.
-
-    Equivalent to `tokens_to_pattern(parse(string))`.
-    """
-    tokens = parse(path)
-    pattern = tokens_to_pattern(tokens, options)
-
-    tokens = filter(lambda t: not isinstance(t, str), tokens)
-    keys.extend(tokens)
-
-    return pattern
-
-
-def path_to_pattern(path, keys=None, options=None):
-    """
-    Generate a pattern from any kind of path value.
-
-    This function selects the appropriate function array/regex/string paths,
-    and calls it with the provided values.
-    """
-    keys = keys if keys is not None else []
-    options = options if options is not None else {}
-
-    if isinstance(path, REGEXP_TYPE):
-        return regexp_to_pattern(path, keys)
-    if isinstance(path, list):
-        return array_to_pattern(path, keys, options)
-    return string_to_pattern(path, keys, options)
-
-
-def match_pattern(pattrn, requested_url_path):
-    """Return shorthand to match function."""
-    return re.match(pattrn, requested_url_path)
index 2b567a7d4c82add95f64fc356a2872f5b85251cb..bb5b3b84b179dfcfb7c3feaf7f492c4ad0a4e6ff 100644 (file)
@@ -9,9 +9,10 @@ if TYPE_CHECKING:
         QueueItem,
         PlayerQueue,
     )
-    from music_assistant.models.streamdetails import StreamDetails
+    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,6 +21,8 @@ else:
     StreamDetails = "StreamDetails"
     Player = "Player"
     ConfigSubItem = "ConfigSubItem"
+    MediaType = "MediaType"
+    StreamType = "StreamType"
 
 
 QueueItems = Set[QueueItem]
index 8a7cc408951eac723ab6f41b7ca392f108136592..682144f4df9d0922d6f5804c7a45af57ae8df970 100755 (executable)
@@ -1,18 +1,23 @@
 """Helper and utility functions."""
 import asyncio
+import functools
 import logging
 import os
 import platform
 import socket
 import struct
 import tempfile
+import threading
 import urllib.request
+from asyncio.events import AbstractEventLoop
 from io import BytesIO
-from typing import Any, Callable, Dict, Optional, Set, TypeVar
+from typing import Any, Callable, Dict, Optional, Set, TypeVar, Union
 
 import memory_tempfile
 import ujson
 
+from .typing import MediaType
+
 # pylint: disable=invalid-name
 T = TypeVar("T")
 _UNDEF: dict = {}
@@ -20,6 +25,8 @@ CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable)
 CALLBACK_TYPE = Callable[[], None]
 # pylint: enable=invalid-name
 
+DEFAULT_LOOP = None
+
 
 def callback(func: CALLABLE_T) -> CALLABLE_T:
     """Annotation to mark method as safe to call from within the event loop."""
@@ -32,6 +39,58 @@ def is_callback(func: Callable[..., Any]) -> bool:
     return getattr(func, "_mass_callback", False) is True
 
 
+def create_task(
+    target: Callable[..., Any],
+    *args: Any,
+    loop: AbstractEventLoop = None,
+    **kwargs: Any,
+) -> Union[asyncio.Task, asyncio.Future]:
+    """Create Task on (main) event loop from Callable or awaitable.
+
+    target: target to call.
+    loop: Running (main) event loop, defaults to loop in current thread
+    args/kwargs: parameters for method to call.
+    """
+    try:
+        loop = loop or asyncio.get_running_loop()
+    except RuntimeError:
+        # try to fetch the default loop from global variable
+        loop = DEFAULT_LOOP
+
+    # Check for partials to properly determine if coroutine function
+    check_target = target
+    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)
+
+    # called from other thread
+    if threading.current_thread() is not threading.main_thread():
+        if asyncio.iscoroutine(check_target):
+            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
+        )
+
+    if asyncio.iscoroutine(check_target):
+        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))
+
+
 def run_periodic(period):
     """Run a coroutine at interval."""
 
@@ -61,25 +120,6 @@ def filename_from_string(string):
     return "".join(c for c in string if c.isalnum() or c in keepcharacters).rstrip()
 
 
-def run_background_task(corofn, *args, executor=None):
-    """Run non-async task in background."""
-    return asyncio.get_event_loop().run_in_executor(executor, corofn, *args)
-
-
-def run__background_task(executor, corofn, *args):
-    """Run async task in background."""
-
-    def run_task(corofn, *args):
-        new_loop = asyncio.new_event_loop()
-        asyncio.set_event_loop(new_loop)
-        coro = corofn(*args)
-        res = new_loop.run_until_complete(coro)
-        new_loop.close()
-        return res
-
-    return asyncio.get_event_loop().run_in_executor(executor, run_task, corofn, *args)
-
-
 def try_parse_int(possible_int):
     """Try to parse an int."""
     try:
@@ -298,6 +338,11 @@ def get_changed_keys(
     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}"
+
+
 def create_wave_header(samplerate=44100, channels=2, bitspersample=16, duration=3600):
     """Generate a wave header from given params."""
     file = BytesIO()
index 98c35a4e38b38f3287ef6b9413a74153e5f6abbd..29704b93b6e9e7d2a78f809a460cdffabddde4b0 100644 (file)
@@ -1,19 +1,16 @@
 """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, Union
+from typing import Any, Callable, Dict, Optional, Tuple, Union, get_args, get_origin
 
 import ujson
 from aiohttp import web
-
-try:
-    # python 3.8+
-    from typing import get_args, get_origin
-except ImportError:
-    # python 3.7
-    from typing_inspect import get_args, get_origin
+from music_assistant.helpers.typing import MusicAssistant
 
 
 def require_local_subnet(func):
@@ -43,23 +40,33 @@ def serialize_values(obj):
     """Recursively create serializable values for (custom) data types."""
 
     def get_val(val):
-        if hasattr(val, "to_dict"):
-            return val.to_dict()
-        if isinstance(val, (list, set, filter, tuple)):
-            return [get_val(x) for x in val]
-        if val.__class__ == "dict_valueiterator":
+        if (
+            isinstance(val, (list, set, filter, tuple))
+            or val.__class__ == "dict_valueiterator"
+        ):
             return [get_val(x) for x in val]
         if isinstance(val, dict):
             return {key: get_val(value) for key, value in val.items()}
-        return val
+        try:
+            return val.to_dict()
+        except AttributeError:
+            return val
 
     return get_val(obj)
 
 
-def json_serializer(obj):
+def json_serializer(data):
     """Json serializer to recursively create serializable values for custom data types."""
-    return ujson.dumps(serialize_values(obj))
-    # return ujson.dumps(obj)
+    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):
@@ -69,39 +76,70 @@ def json_response(data: Any, status: int = 200):
     )
 
 
-def api_route(ws_cmd_path, ws_require_auth=True):
-    """Decorate a function as websocket command."""
+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.ws_cmd_path = ws_cmd_path
-        func.ws_require_auth = ws_require_auth
+        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 vaildation and/or api spec generation."""
+    """Parse signature of function to do type validation and/or api spec generation."""
     signature = inspect.signature(call)
-    typed_params = [
-        inspect.Parameter(
-            name=param.name,
-            kind=param.kind,
-            default=param.default,
-            annotation=param.annotation,
-        )
-        for param in signature.parameters.values()
-    ]
-    typed_signature = inspect.Signature(typed_params)
-    return typed_signature
+    return signature
 
 
-def parse_arguments(call: Callable, args: dict):
+def parse_arguments(mass: MusicAssistant, func_sig: inspect.Signature, args: dict):
     """Parse (and convert) incoming arguments to correct types."""
     final_args = {}
-    if isinstance(call, type({}.values)):
-        return args
-    func_sig = get_typed_signature(call)
     for key, value in args.items():
         if key not in func_sig.parameters:
             raise KeyError("Invalid parameter: '%s'" % key)
@@ -109,7 +147,9 @@ def parse_arguments(call: Callable, args: dict):
         final_args[key] = convert_value(key, value, arg_type)
     # check for missing args
     for key, value in func_sig.parameters.items():
-        if value.default is inspect.Parameter.empty:
+        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
@@ -138,3 +178,34 @@ def convert_value(arg_key, value, arg_type):
     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
index e3a52a140e34db90c749fead0034437ba25a5cf8..7eb266ef0ea6a951520ad08f662649abb771e271 100755 (executable)
@@ -1,7 +1,6 @@
 """All classes and helpers for the Configuration."""
 
 import copy
-import datetime
 import json
 import logging
 import os
@@ -31,8 +30,10 @@ from music_assistant.constants import (
     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.util import merge_dict, try_load_json_file
+from music_assistant.helpers.typing import MusicAssistant
+from music_assistant.helpers.util import create_task, merge_dict, try_load_json_file
 from music_assistant.helpers.web import api_route
 from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType
 from music_assistant.models.player import PlayerControlType
@@ -134,7 +135,7 @@ PROVIDER_TYPE_MAPPINGS = {
 class ConfigManager:
     """Class which holds our configuration."""
 
-    def __init__(self, mass, data_path: str):
+    def __init__(self, mass: MusicAssistant, data_path: str):
         """Initialize class."""
         self._data_path = data_path
         self._stored_config = {}
@@ -149,19 +150,25 @@ class ConfigManager:
         """Async initialize of module."""
         self._translations = await self._fetch_translations()
 
-    @api_route("config/:conf_base?/:conf_key?")
-    def all_items(self, conf_base: str = "", conf_key: str = "") -> dict:
+    @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."""
-        if conf_base and conf_key:
-            obj = getattr(self, conf_base)[conf_key]
-            if isinstance(obj, dict):
-                return obj
-            return obj.all_items()
-        if conf_base:
-            obj = getattr(self, conf_base)
-            if isinstance(obj, dict):
-                return obj
-            return obj.all_items()
         return {
             key: getattr(self, key).all_items()
             for key in [
@@ -175,7 +182,7 @@ class ConfigManager:
             ]
         }
 
-    @api_route("config/:conf_base/:conf_key/:conf_val")
+    @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:
@@ -185,7 +192,7 @@ class ConfigManager:
         self[conf_base][conf_key][conf_val] = new_value
         return self[conf_base][conf_key].all_items()
 
-    @api_route("config/delete/:conf_base/:conf_key")
+    @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)
@@ -408,7 +415,7 @@ class SecuritySettings(ConfigBaseItem):
             return
         self.conf_mgr.stored_config[CONF_KEY_SECURITY][CONF_KEY_SECURITY_APP_TOKENS][
             client_id
-        ]["last_login"] = datetime.datetime.utcnow().timestamp()
+        ]["last_login"] = utc_timestamp()
         self.conf_mgr.save()
 
     def revoke_app_token(self, client_id):
@@ -417,7 +424,7 @@ class SecuritySettings(ConfigBaseItem):
             CONF_KEY_SECURITY_APP_TOKENS
         ].pop(client_id)
         self.conf_mgr.save()
-        self.conf_mgr.mass.signal_event(
+        self.conf_mgr.mass.eventbus.signal(
             EVENT_CONFIG_CHANGED, (CONF_KEY_SECURITY, CONF_KEY_SECURITY_APP_TOKENS)
         )
         return return_info
@@ -663,19 +670,17 @@ class ConfigSubItem:
                     stored_conf[self.parent_conf_key][self.conf_key] = {}
                 stored_conf[self.parent_conf_key][self.conf_key][key] = value
 
-                self.conf_mgr.mass.add_job(self.conf_mgr.save)
+                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:
-                    self.conf_mgr.mass.add_job(
-                        self.conf_mgr.mass.reload_provider(self.conf_key)
-                    )
+                    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
-                    self.conf_mgr.mass.add_job(
+                    create_task(
                         self.conf_mgr.mass.players.trigger_player_update(self.conf_key)
                     )
                 # signal config changed event
-                self.conf_mgr.mass.signal_event(
+                self.conf_mgr.mass.eventbus.signal(
                     EVENT_CONFIG_CHANGED, (self.parent_conf_key, self.conf_key)
                 )
             return
index a6a9aeb9165ab89ce3d83cf4b0c5b99ab2321c44..0b1f58bfc41dcf082306c3c71f01db8e96dfc8a2 100755 (executable)
@@ -3,11 +3,11 @@
 import logging
 import os
 import statistics
-from datetime import datetime
 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 (
@@ -49,15 +49,15 @@ class DatabaseManager:
         media_type: MediaType,
     ) -> Optional[MediaItem]:
         """Get the database item for the given prov_id."""
-        if media_type == MediaType.Artist:
+        if media_type == MediaType.ARTIST:
             return await self.get_artist_by_prov_id(provider_id, prov_item_id)
-        if media_type == MediaType.Album:
+        if media_type == MediaType.ALBUM:
             return await self.get_album_by_prov_id(provider_id, prov_item_id)
-        if media_type == MediaType.Track:
+        if media_type == MediaType.TRACK:
             return await self.get_track_by_prov_id(provider_id, prov_item_id)
-        if media_type == MediaType.Playlist:
+        if media_type == MediaType.PLAYLIST:
             return await self.get_playlist_by_prov_id(provider_id, prov_item_id)
-        if media_type == MediaType.Radio:
+        if media_type == MediaType.RADIO:
             return await self.get_radio_by_prov_id(provider_id, prov_item_id)
         return None
 
@@ -145,19 +145,19 @@ class DatabaseManager:
         """Search library for the given searchphrase."""
         result = SearchResult([], [], [], [], [])
         searchquery = "%" + searchquery + "%"
-        if media_types is None or MediaType.Artist in media_types:
+        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:
+        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:
+        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:
+        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:
+        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
@@ -195,7 +195,7 @@ class DatabaseManager:
         orderby: str = "name",
     ) -> List[Playlist]:
         """Get all playlists from database."""
-        async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn:
+        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:
@@ -224,7 +224,7 @@ class DatabaseManager:
         if filter_query:
             sql_query += " " + filter_query
         sql_query += " ORDER BY %s" % orderby
-        async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn:
+        async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
             db_conn.row_factory = aiosqlite.Row
             return [
                 Radio.from_db_row(db_row)
@@ -241,7 +241,7 @@ class DatabaseManager:
     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=120) as db_conn:
+        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=?;",
@@ -275,7 +275,7 @@ class DatabaseManager:
                     db_conn,
                 )
             await self._add_prov_ids(
-                new_item[0], MediaType.Playlist, playlist.provider_ids, db_conn=db_conn
+                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)
@@ -284,7 +284,7 @@ class DatabaseManager:
 
     async def update_playlist(self, item_id: int, playlist: Playlist):
         """Update a playlist record in the database."""
-        async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn:
+        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(
@@ -316,7 +316,7 @@ class DatabaseManager:
                 ),
             )
             await self._add_prov_ids(
-                item_id, MediaType.Playlist, playlist.provider_ids, db_conn=db_conn
+                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()
@@ -326,7 +326,7 @@ class DatabaseManager:
     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=120) as db_conn:
+        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
@@ -353,7 +353,7 @@ class DatabaseManager:
                     db_conn,
                 )
             await self._add_prov_ids(
-                new_item[0], MediaType.Radio, radio.provider_ids, db_conn=db_conn
+                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)
@@ -362,7 +362,7 @@ class DatabaseManager:
 
     async def update_radio(self, item_id: int, radio: Radio):
         """Update a radio record in the database."""
-        async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn:
+        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(
@@ -388,16 +388,16 @@ class DatabaseManager:
                 ),
             )
             await self._add_prov_ids(
-                item_id, MediaType.Radio, radio.provider_ids, db_conn=db_conn
+                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, provider: str):
+    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=120) as db_conn:
+        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=?;"
@@ -405,10 +405,12 @@ class DatabaseManager:
             await db_conn.commit()
 
     async def remove_from_library(
-        self, item_id: int, media_type: MediaType, provider: str
+        self,
+        item_id: int,
+        media_type: MediaType,
     ):
         """Remove item from the library."""
-        async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn:
+        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=?;"
@@ -426,7 +428,7 @@ class DatabaseManager:
         if filter_query:
             sql_query += " " + filter_query
         sql_query += " ORDER BY %s" % orderby
-        async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn:
+        async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
             db_conn.row_factory = aiosqlite.Row
             return [
                 Artist.from_db_row(db_row)
@@ -442,7 +444,7 @@ class DatabaseManager:
 
     async def add_artist(self, artist: Artist):
         """Add a new artist record to the database."""
-        async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn:
+        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=?;",
@@ -473,7 +475,7 @@ class DatabaseManager:
                     db_conn,
                 )
             await self._add_prov_ids(
-                new_item[0], MediaType.Artist, artist.provider_ids, db_conn=db_conn
+                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)
@@ -482,7 +484,7 @@ class DatabaseManager:
 
     async def update_artist(self, item_id: int, artist: Artist):
         """Update a artist record in the database."""
-        async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn:
+        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
@@ -505,7 +507,7 @@ class DatabaseManager:
                 ),
             )
             await self._add_prov_ids(
-                item_id, MediaType.Artist, artist.provider_ids, db_conn=db_conn
+                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()
@@ -523,7 +525,7 @@ class DatabaseManager:
         if filter_query:
             sql_query += " " + filter_query
         sql_query += " ORDER BY %s" % orderby
-        async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn:
+        async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
             db_conn.row_factory = aiosqlite.Row
             return [
                 Album.from_db_row(db_row)
@@ -560,7 +562,7 @@ class DatabaseManager:
 
     async def add_album(self, album: Album):
         """Add a new album record to the database."""
-        async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn:
+        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
@@ -613,7 +615,7 @@ class DatabaseManager:
                     db_conn,
                 )
             await self._add_prov_ids(
-                new_item[0], MediaType.Album, album.provider_ids, db_conn=db_conn
+                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)
@@ -622,7 +624,7 @@ class DatabaseManager:
 
     async def update_album(self, item_id: int, album: Album):
         """Update a album record in the database."""
-        async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn:
+        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(
@@ -636,7 +638,7 @@ class DatabaseManager:
             )
             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:
+            if cur_item.album_type == AlbumType.UNKNOWN:
                 album_type = album.album_type
             else:
                 album_type = cur_item.album_type
@@ -659,7 +661,7 @@ class DatabaseManager:
                 ),
             )
             await self._add_prov_ids(
-                item_id, MediaType.Album, album.provider_ids, db_conn=db_conn
+                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()
@@ -677,7 +679,7 @@ class DatabaseManager:
         if filter_query:
             sql_query += " " + filter_query
         sql_query += " ORDER BY %s" % orderby
-        async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn:
+        async with aiosqlite.connect(self._dbfile, timeout=360) as db_conn:
             db_conn.row_factory = aiosqlite.Row
             return [
                 Track.from_db_row(db_row)
@@ -728,7 +730,7 @@ class DatabaseManager:
         """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=120) as db_conn:
+        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
@@ -777,7 +779,7 @@ class DatabaseManager:
                     db_conn,
                 )
             await self._add_prov_ids(
-                new_item[0], MediaType.Track, track.provider_ids, db_conn=db_conn
+                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)
@@ -786,7 +788,7 @@ class DatabaseManager:
 
     async def update_track(self, item_id: int, track: Track):
         """Update a track record in the database."""
-        async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn:
+        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)
 
@@ -815,7 +817,7 @@ class DatabaseManager:
                 ),
             )
             await self._add_prov_ids(
-                item_id, MediaType.Track, track.provider_ids, db_conn=db_conn
+                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()
@@ -824,7 +826,7 @@ class DatabaseManager:
 
     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=120) as db_conn:
+        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))
@@ -832,7 +834,7 @@ class DatabaseManager:
 
     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=120) as db_conn:
+        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(
@@ -846,14 +848,14 @@ class DatabaseManager:
     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=120) as db_conn:
+        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=120) as db_conn:
+        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:
@@ -862,8 +864,8 @@ class DatabaseManager:
 
     async def mark_item_played(self, item_id: str, provider: str):
         """Mark item as played in playlog."""
-        timestamp = datetime.utcnow().timestamp()
-        async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn:
+        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))
@@ -871,7 +873,7 @@ class DatabaseManager:
 
     async def get_thumbnail_id(self, url, size):
         """Get/create id for thumbnail."""
-        async with aiosqlite.connect(self._dbfile, timeout=120) as db_conn:
+        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:
diff --git a/music_assistant/managers/events.py b/music_assistant/managers/events.py
new file mode 100644 (file)
index 0000000..2c07da4
--- /dev/null
@@ -0,0 +1,57 @@
+"""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 LOGGER.isEnabledFor(logging.DEBUG):
+            log_details = getattr(
+                event_details, "name", getattr(event_details, "id", event_details)
+            )
+            LOGGER.debug("%s: %s", event_msg, log_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
index ade46ab43655e1e41d1608dc670058b1762f85be..549251355417d5411a9462bd87e69623355b883a 100755 (executable)
@@ -1,13 +1,13 @@
 """LibraryManager: Orchestrates synchronisation of music providers into the library."""
-import asyncio
-import functools
+
 import logging
 import time
-from typing import Any, List
+from typing import Any, List, Optional
 
-from music_assistant.constants import EVENT_MUSIC_SYNC_STATUS, EVENT_PROVIDER_REGISTERED
-from music_assistant.helpers.util import callback, run_periodic
+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,
@@ -22,62 +22,34 @@ from music_assistant.models.provider import ProviderType
 LOGGER = logging.getLogger("music_manager")
 
 
-def sync_task(desc):
-    """Return decorator to report a sync task."""
-
-    def wrapper(func):
-        @functools.wraps(func)
-        async def wrapped(*args):
-            method_class = args[0]
-            prov_id = args[1]
-            # check if this sync task is not already running
-            for sync_prov_id, sync_desc in method_class.running_sync_jobs:
-                if sync_prov_id == prov_id and sync_desc == desc:
-                    LOGGER.debug(
-                        "Syncjob %s for provider %s is already running!", desc, prov_id
-                    )
-                    return
-            LOGGER.info("Start syncjob %s for provider %s.", desc, prov_id)
-            sync_job = (prov_id, desc)
-            method_class.running_sync_jobs.add(sync_job)
-            method_class.mass.signal_event(
-                EVENT_MUSIC_SYNC_STATUS, method_class.running_sync_jobs
-            )
-            await func(*args)
-            LOGGER.info("Finished syncing %s for provider %s", desc, prov_id)
-            method_class.running_sync_jobs.remove(sync_job)
-            method_class.mass.signal_event(
-                EVENT_MUSIC_SYNC_STATUS, method_class.running_sync_jobs
-            )
-
-        return wrapped
-
-    return wrapper
-
-
 class LibraryManager:
     """Manage sync of musicproviders to library."""
 
-    def __init__(self, mass):
+    def __init__(self, mass: MusicAssistant):
         """Initialize class."""
         self.running_sync_jobs = set()
         self.mass = mass
         self.cache = mass.cache
-        self.mass.add_event_listener(self.mass_event, EVENT_PROVIDER_REGISTERED)
+        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
-        self.mass.add_job(self._music_providers_sync())
+        # 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)
 
-    @callback
-    def mass_event(self, msg: str, msg_details: Any):
+    async def mass_event(self, msg: str, msg_details: Any):
         """Handle message on eventbus."""
         if msg == EVENT_PROVIDER_REGISTERED:
-            # schedule a sync task when a new provider registers
+            # schedule the sync task when a new provider registers
             provider = self.mass.get_provider(msg_details)
             if provider.type == ProviderType.MUSIC_PROVIDER:
-                self.mass.add_job(self.music_provider_sync(msg_details))
+                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 ################
 
@@ -120,99 +92,169 @@ class LibraryManager:
                 return radio
         return None
 
-    @api_route("library/add")
-    async def library_add(self, items: List[MediaItem]):
-        """Add media item(s) to the library."""
-        result = False
+    @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:
-            # add to provider's libraries
-            for prov in media_item.provider_ids:
-                provider = self.mass.get_provider(prov.provider)
-                if provider:
-                    result = await provider.library_add(
-                        prov.item_id, media_item.media_type
-                    )
-            # mark as library item in internal db
-            if media_item.provider == "database":
-                await self.mass.database.add_to_library(
-                    media_item.item_id, media_item.media_type, media_item.provider
-                )
+            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
 
-    @api_route("library/remove")
-    async def library_remove(self, items: List[MediaItem]):
-        """Remove media item(s) from the library."""
-        result = False
+    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:
-            # remove from provider's libraries
-            for prov in media_item.provider_ids:
-                provider = self.mass.get_provider(prov.provider)
-                if provider:
-                    result = await provider.library_remove(
-                        prov.item_id, media_item.media_type
-                    )
-            # mark as library item in internal db
-            if media_item.provider == "database":
-                await self.mass.database.remove_from_library(
-                    media_item.item_id, media_item.media_type, media_item.provider
+            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
 
-    @api_route("library/playlists/:db_playlist_id/tracks/add")
-    async def add_playlist_tracks(self, db_playlist_id: int, tracks: List[Track]):
-        """Add tracks to playlist - make sure we dont add duplicates."""
+    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 or not playlist.is_editable:
-            return False
-        # playlist can only have one provider (for now)
+        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.add(item.item_id)
-            cur_playlist_track_ids.update({i.item_id for i in item.provider_ids})
-        track_ids_to_add = set()
-        for track in tracks:
-            # check for duplicates
-            already_exists = track.item_id in cur_playlist_track_ids
-            for track_prov in track.provider_ids:
-                if track_prov.item_id in cur_playlist_track_ids:
-                    already_exists = True
-            if already_exists:
-                continue
-            # we can only add a track to a provider playlist if track is available on that provider
-            # this should all be handled in the frontend but these checks are here just to be safe
-            # a track can contain multiple versions on the same provider
-            # simply sort by quality and just add the first one (assuming track is still available)
-            for track_version in sorted(
-                track.provider_ids, key=lambda x: x.quality, reverse=True
+            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
             ):
-                if track_version.provider == playlist_prov.provider:
-                    track_ids_to_add.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
-                    uri = f"{track_version.provider}://{track_version.item_id}"
-                    track_ids_to_add.add(uri)
-                    break
+                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
-        if track_ids_to_add:
-            # 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_ids_to_add
+        # 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 False
+        return result
 
-    @api_route("library/playlists/:db_playlist_id/tracks/remove")
-    async def remove_playlist_tracks(self, db_playlist_id, tracks: List[Track]):
-        """Remove tracks from playlist."""
+    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:
@@ -220,11 +262,10 @@ class LibraryManager:
         # playlist can only have one provider (for now)
         prov_playlist = next(iter(playlist.provider_ids))
         track_ids_to_remove = set()
-        for track in tracks:
-            # a track can contain multiple versions on the same provider, remove all
-            for track_provider in track.provider_ids:
-                if track_provider.provider == prov_playlist.provider:
-                    track_ids_to_remove.add(track_provider.item_id)
+        # 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
@@ -235,14 +276,7 @@ class LibraryManager:
                 prov_playlist.item_id, track_ids_to_remove
             )
 
-    @run_periodic(3600 * 3)
-    async def _music_providers_sync(self):
-        """Periodic sync of all music providers."""
-        await asyncio.sleep(10)
-        for prov in self.mass.get_providers(ProviderType.MUSIC_PROVIDER):
-            await self.music_provider_sync(prov.id)
-
-    async def music_provider_sync(self, prov_id: str):
+    async def music_provider_sync(self, prov_id: str, periodic: Optional[int] = None):
         """
         Sync a music provider.
 
@@ -251,18 +285,42 @@ class LibraryManager:
         provider = self.mass.get_provider(prov_id)
         if not provider:
             return
-        if MediaType.Album in provider.supported_mediatypes:
-            await self.library_albums_sync(prov_id)
-        if MediaType.Track in provider.supported_mediatypes:
-            await self.library_tracks_sync(prov_id)
-        if MediaType.Artist in provider.supported_mediatypes:
-            await self.library_artists_sync(prov_id)
-        if MediaType.Playlist in provider.supported_mediatypes:
-            await self.library_playlists_sync(prov_id)
-        if MediaType.Radio in provider.supported_mediatypes:
-            await self.library_radios_sync(prov_id)
-
-    @sync_task("artists")
+        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)
@@ -276,18 +334,15 @@ class LibraryManager:
             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, provider_id
+                    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, provider_id
-                )
+                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)
 
-    @sync_task("albums")
     async def library_albums_sync(self, provider_id: str):
         """Sync library albums for given provider."""
         music_provider = self.mass.get_provider(provider_id)
@@ -304,7 +359,7 @@ class LibraryManager:
             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, provider_id
+                    db_album.item_id, MediaType.ALBUM
                 )
             # precache album tracks
             await self.mass.music.get_album_tracks(item.item_id, provider_id)
@@ -312,12 +367,11 @@ class LibraryManager:
         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, provider_id
+                    db_id, MediaType.ALBUM, provider_id
                 )
         # store ids in cache for next sync
         await self.mass.cache.set(cache_key, cur_db_ids)
 
-    @sync_task("tracks")
     async def library_tracks_sync(self, provider_id: str):
         """Sync library tracks for given provider."""
         music_provider = self.mass.get_provider(provider_id)
@@ -334,18 +388,15 @@ class LibraryManager:
             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, provider_id
+                    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, provider_id
-                )
+                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)
 
-    @sync_task("playlists")
     async def library_playlists_sync(self, provider_id: str):
         """Sync library playlists for given provider."""
         music_provider = self.mass.get_provider(provider_id)
@@ -358,32 +409,16 @@ class LibraryManager:
             )
             if db_item.checksum != playlist.checksum:
                 db_item = await self.mass.database.add_playlist(playlist)
-                # precache playlist tracks
-                for playlist_track in await self.mass.music.get_playlist_tracks(
-                    playlist.item_id, provider_id
-                ):
-                    # try to find substitutes for unavailable tracks with matching technique
-                    if not playlist_track.available:
-                        await self.mass.music.get_track(
-                            playlist_track.item_id,
-                            playlist_track.provider,
-                            playlist_track,
-                        )
             cur_db_ids.add(db_item.item_id)
-            await self.mass.database.add_to_library(
-                db_item.item_id, MediaType.Playlist, playlist.provider
-            )
+            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, provider_id
-                )
+                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)
 
-    @sync_task("radios")
     async def library_radios_sync(self, provider_id: str):
         """Sync library radios for given provider."""
         music_provider = self.mass.get_provider(provider_id)
@@ -395,14 +430,13 @@ class LibraryManager:
                 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, provider_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, provider_id
+                    db_id,
+                    MediaType.RADIO,
                 )
         # store ids in cache for next sync
         await self.mass.cache.set(cache_key, cur_db_ids)
index 9533f24d7efb31ec3dc3016d138d9a70a5be96ba..d60a05df179998f2ce7b078749a1408ddd77176a 100755 (executable)
@@ -8,7 +8,7 @@ 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_manager")
+LOGGER = logging.getLogger("metadata")
 
 
 class MetaDataManager:
@@ -32,10 +32,21 @@ class MetaDataManager:
             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
index 28c6cc528cfab7ac95eb0a78c3f821d97fbb26d3..e5245f664db1894df48f5726776b85b2f79b621e 100755 (executable)
@@ -19,7 +19,9 @@ from music_assistant.helpers.compare import (
     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,
@@ -34,7 +36,6 @@ from music_assistant.models.media_types import (
     Track,
 )
 from music_assistant.models.provider import MusicProvider, ProviderType
-from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
 
 LOGGER = logging.getLogger("music_manager")
 
@@ -42,11 +43,12 @@ LOGGER = logging.getLogger("music_manager")
 class MusicManager:
     """Several helpers around the musicproviders."""
 
-    def __init__(self, mass):
+    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."""
@@ -58,39 +60,7 @@ class MusicManager:
 
     ################ GET MediaItem(s) by id and provider #################
 
-    @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,
-    ):
-        """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("artists/:provider_id/:item_id")
+    @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:
@@ -105,7 +75,9 @@ class MusicManager:
         artist = await self._get_provider_artist(item_id, provider_id)
         if not lazy:
             return await self.add_artist(artist)
-        self.mass.add_background_task(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:
@@ -121,7 +93,7 @@ class MusicManager:
             )
         return artist
 
-    @api_route("albums/:provider_id/:item_id")
+    @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:
@@ -136,7 +108,7 @@ class MusicManager:
         album = await self._get_provider_album(item_id, provider_id)
         if not lazy:
             return await self.add_album(album)
-        self.mass.add_background_task(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:
@@ -152,7 +124,7 @@ class MusicManager:
             )
         return album
 
-    @api_route("tracks/:provider_id/:item_id")
+    @api_route("tracks/{provider_id}/{item_id}")
     async def get_track(
         self,
         item_id: str,
@@ -176,7 +148,9 @@ class MusicManager:
             track_details.album = album_details
         if not lazy:
             return await self.add_track(track_details)
-        self.mass.add_background_task(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:
@@ -192,7 +166,7 @@ class MusicManager:
             )
         return track
 
-    @api_route("playlists/:provider_id/:item_id")
+    @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:
@@ -206,7 +180,9 @@ class MusicManager:
         playlist = await self._get_provider_playlist(item_id, provider_id)
         if not lazy:
             return await self.add_playlist(playlist)
-        self.mass.add_background_task(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:
@@ -228,7 +204,7 @@ class MusicManager:
             )
         return playlist
 
-    @api_route("radios/:provider_id/:item_id")
+    @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:
@@ -242,7 +218,9 @@ class MusicManager:
         radio = await self._get_provider_radio(item_id, provider_id)
         if not lazy:
             return await self.add_radio(radio)
-        self.mass.add_background_task(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:
@@ -258,7 +236,7 @@ class MusicManager:
             )
         return radio
 
-    @api_route("albums/:provider_id/:item_id/tracks")
+    @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
@@ -290,7 +268,7 @@ class MusicManager:
             for item in all_prov_tracks
         ]
 
-    @api_route("albums/:provider_id/:item_id/versions")
+    @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)
@@ -302,7 +280,7 @@ class MusicManager:
             prov_item
             for prov_items in await asyncio.gather(
                 *[
-                    self.search_provider(search_query, prov_id, [MediaType.Album], 25)
+                    self.search_provider(search_query, prov_id, [MediaType.ALBUM], 25)
                     for prov_id in provider_ids
                 ]
             )
@@ -310,7 +288,7 @@ class MusicManager:
             if compare_strings(prov_item.artist.name, album.artist.name)
         }
 
-    @api_route("tracks/:provider_id/:item_id/versions")
+    @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)
@@ -323,7 +301,7 @@ class MusicManager:
             prov_item
             for prov_items in await asyncio.gather(
                 *[
-                    self.search_provider(search_query, prov_id, [MediaType.Track], 25)
+                    self.search_provider(search_query, prov_id, [MediaType.TRACK], 25)
                     for prov_id in provider_ids
                 ]
             )
@@ -331,7 +309,7 @@ class MusicManager:
             if compare_artists(prov_item.artists, track.artists)
         }
 
-    @api_route("playlists/:provider_id/:item_id/tracks")
+    @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
@@ -347,21 +325,13 @@ class MusicManager:
             playlist = await provider.get_playlist(item_id)
         cache_checksum = playlist.checksum
         cache_key = f"{provider_id}.playlist_tracks.{item_id}"
-        playlist_tracks = await cached(
+        return await cached(
             self.cache,
             cache_key,
             provider.get_playlist_tracks,
             item_id,
             checksum=cache_checksum,
         )
-        db_tracks = await self.mass.database.get_tracks_from_provider_ids(
-            provider_id, {x.item_id for x in playlist_tracks}
-        )
-        # combine provider tracks with db tracks
-        return [
-            await self.__process_item(item, db_tracks, index)
-            for index, item in enumerate(playlist_tracks)
-        ]
 
     async def __process_item(
         self,
@@ -387,11 +357,14 @@ class MusicManager:
             item.track_number = track_number
         return item
 
-    @api_route("artists/:provider_id/:item_id/tracks")
+    @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)
-        # get results from all providers
         all_prov_tracks = {
             track
             for prov_tracks in await asyncio.gather(
@@ -426,11 +399,13 @@ class MusicManager:
             item_id,
         )
 
-    @api_route("artists/:provider_id/:item_id/albums")
+    @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)
-        # get results from all providers
         all_prov_albums = {
             album
             for prov_albums in await asyncio.gather(
@@ -465,7 +440,7 @@ class MusicManager:
             item_id,
         )
 
-    @api_route("search/:provider_id")
+    @api_route("search/{provider_id}")
     async def search_provider(
         self,
         search_query: str,
@@ -523,70 +498,94 @@ class MusicManager:
             # TODO: sort by name and filter out duplicates ?
         return result
 
-    async def get_stream_details(
-        self, media_item: MediaItem, player_id: str = ""
-    ) -> StreamDetails:
+    @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]:
         """
-        Get streamdetails for the given media_item.
+        Refresh MediaItems to force retrieval of full info and matches.
 
-        This is called just-in-time when a player/queue wants a MediaItem to be played.
-        Do not try to request streamdetails in advance as this is expiring data.
-            param media_item: The MediaItem (track/radio) for which to request the streamdetails for.
-            param player_id: Optionally provide the player_id which will play this stream.
+        Creates background tasks to process the action.
         """
-        if media_item.provider == "uri":
-            # special type: a plain uri was added to the queue
-            streamdetails = StreamDetails(
-                type=StreamType.URL,
-                provider="uri",
-                item_id=media_item.item_id,
-                path=media_item.item_id,
-                content_type=ContentType(media_item.item_id.split(".")[-1]),
-                sample_rate=44100,
-                bit_depth=16,
-            )
-        else:
-            # always request the full db track as there might be other qualities available
-            # except for radio
-            if media_item.media_type == MediaType.Radio:
-                full_track = media_item
-            else:
-                full_track = (
-                    await self.get_track(media_item.item_id, media_item.provider)
-                    or media_item
-                )
-            # sort by quality and check track availability
-            for prov_media in sorted(
-                full_track.provider_ids, key=lambda x: x.quality, reverse=True
-            ):
-                if not prov_media.available:
-                    continue
-                # get streamdetails from provider
-                music_prov = self.mass.get_provider(prov_media.provider)
-                if not music_prov or not music_prov.available:
-                    continue  # provider temporary unavailable ?
+        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
 
-                streamdetails: StreamDetails = await music_prov.get_stream_details(
-                    prov_media.item_id
-                )
-                if streamdetails:
-                    try:
-                        streamdetails.content_type = ContentType(
-                            streamdetails.content_type
-                        )
-                    except KeyError:
-                        LOGGER.warning("Invalid content type!")
-                    else:
-                        break
-
-        if streamdetails:
-            # set player_id on the streamdetails so we know what players stream
-            streamdetails.player_id = player_id
-            # set streamdetails as attribute on the media_item
-            # this way the app knows what content is playing
-            media_item.streamdetails = streamdetails
-            return streamdetails
-        return None
+    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 ################
 
@@ -601,7 +600,8 @@ class MusicManager:
         db_item = await self.mass.database.add_artist(artist)
         # also fetch same artist on all providers
         await self.match_artist(db_item)
-        self.mass.signal_event(EVENT_ARTIST_ADDED, 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:
@@ -611,7 +611,8 @@ class MusicManager:
         db_item = await self.mass.database.add_album(album)
         # also fetch same album on all providers
         await self.match_album(db_item)
-        self.mass.signal_event(EVENT_ALBUM_ADDED, 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:
@@ -623,19 +624,20 @@ class MusicManager:
         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)
-        self.mass.signal_event(EVENT_TRACK_ADDED, 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.signal_event(EVENT_PLAYLIST_ADDED, db_item)
+        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.signal_event(EVENT_RADIO_ADDED, db_item)
+        self.mass.eventbus.signal(EVENT_RADIO_ADDED, db_item)
         return db_item
 
     async def _get_artist_musicbrainz_id(self, artist: Artist):
@@ -685,7 +687,7 @@ class MusicManager:
         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:
+            if MediaType.ARTIST not in provider.supported_mediatypes:
                 continue
             if not await self._match_prov_artist(db_artist, provider):
                 LOGGER.debug(
@@ -708,7 +710,7 @@ class MusicManager:
                 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
+                searchstr, provider.id, [MediaType.TRACK], limit=25
             )
             for search_result_item in search_results.tracks:
                 if compare_track(search_result_item, ref_track):
@@ -729,11 +731,11 @@ class MusicManager:
             db_artist.item_id, db_artist.provider
         )
         for ref_album in artist_albums:
-            if ref_album.album_type == AlbumType.Compilation:
+            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
+                searchstr, provider.id, [MediaType.ALBUM], limit=25
             )
             for search_result_item in search_result.albums:
                 # artist must match 100%
@@ -774,7 +776,7 @@ class MusicManager:
             if db_album.version:
                 searchstr += " " + db_album.version
             search_result = await self.search_provider(
-                searchstr, provider.id, [MediaType.Album], limit=25
+                searchstr, provider.id, [MediaType.ALBUM], limit=25
             )
             for search_result_item in search_result.albums:
                 if not search_result_item.available:
@@ -809,7 +811,7 @@ class MusicManager:
         # 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:
+            if MediaType.ALBUM in provider.supported_mediatypes:
                 await find_prov_match(provider)
 
     async def match_track(self, db_track: Track):
@@ -825,7 +827,7 @@ class MusicManager:
             # 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:
+            if MediaType.TRACK not in provider.supported_mediatypes:
                 continue
             LOGGER.debug(
                 "Trying to match track %s on provider %s", db_track.name, provider.name
@@ -838,7 +840,7 @@ class MusicManager:
                 if db_track.version:
                     searchstr += " " + db_track.version
                 search_result = await self.search_provider(
-                    searchstr, provider.id, [MediaType.Track], limit=25
+                    searchstr, provider.id, [MediaType.TRACK], limit=25
                 )
                 for search_result_item in search_result.tracks:
                     if not search_result_item.available:
index 71bd79dd0edef79a52700d47c931ec5247bd21a8..c7d9daff2c3c9520f1a15f9de567413df5ed8ef5 100755 (executable)
@@ -2,6 +2,7 @@
 
 import asyncio
 import logging
+import pathlib
 from typing import Dict, List, Optional, Set, Tuple, Union
 
 from music_assistant.constants import (
@@ -11,17 +12,18 @@ from music_assistant.constants import (
     EVENT_PLAYER_REMOVED,
 )
 from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import callback, try_parse_int
+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 (
-    PlaybackState,
     Player,
     PlayerControl,
     PlayerControlType,
+    PlayerState,
 )
 from music_assistant.models.player_queue import PlayerQueue, QueueItem, QueueOption
 from music_assistant.models.provider import PlayerProvider, ProviderType
+from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
 
 POLL_INTERVAL = 30
 
@@ -41,7 +43,7 @@ class PlayerManager:
 
     async def setup(self) -> None:
         """Async initialize of module."""
-        self.mass.add_job(self.poll_task())
+        asyncio.create_task(self.poll_task())
 
     async def close(self) -> None:
         """Handle stop/shutdown."""
@@ -55,11 +57,11 @@ class PlayerManager:
         count = 0
         while True:
             for player in self:
-                if not player.player_state.available:
+                if not player.calculated_state.available:
                     continue
                 if not player.should_poll:
                     continue
-                if player.state == PlaybackState.Playing or count == POLL_INTERVAL:
+                if player.state == PlayerState.PLAYING or count == POLL_INTERVAL:
                     await player.on_poll()
             if count == POLL_INTERVAL:
                 count = 0
@@ -93,12 +95,13 @@ class PlayerManager:
         return tuple(self._players.values())
 
     @callback
-    @api_route("players/queues")
+    @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) -> Player:
         """Return Player by player_id or None if player does not exist."""
         return self._players.get(player_id)
@@ -111,7 +114,7 @@ class PlayerManager:
         for player in self:
             if provider_id is not None and player.provider_id != provider_id:
                 continue
-            if player.name == name or player.player_state.name == name:
+            if player.name == name or player.calculated_state.name == name:
                 return player
         return None
 
@@ -122,24 +125,24 @@ class PlayerManager:
         return self.mass.get_provider(player.provider_id) if player else None
 
     @callback
-    @api_route("players/:player_id/queue")
-    def get_player_queue(self, player_id: str) -> PlayerQueue:
+    @api_route("queues/{queue_id}")
+    def get_player_queue(self, queue_id: str) -> PlayerQueue:
         """Return player's queue by player_id or None if player does not exist."""
-        player = self.get_player(player_id)
+        player = self.get_player(queue_id)
         if not player:
-            LOGGER.warning("Player(queue) %s is not available!", player_id)
+            LOGGER.warning("Player(queue) %s is not available!", queue_id)
             return None
         return self._player_queues.get(player.active_queue)
 
     @callback
-    @api_route("players/:queue_id/queue/items")
+    @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")
+    @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:
@@ -181,7 +184,7 @@ class PlayerManager:
             player.mass = self.mass
 
         # make sure that the player state is created/updated
-        player.player_state.update(player.create_state())
+        player.calculated_state.update(player.create_calculated_state())
 
         # Fully initialize only if player is enabled
         if not player.enabled:
@@ -202,7 +205,7 @@ class PlayerManager:
             player.provider_id,
             player.name,
         )
-        self.mass.signal_event(EVENT_PLAYER_ADDED, player)
+        self.mass.eventbus.signal(EVENT_PLAYER_ADDED, player)
 
     async def remove_player(self, player_id: str):
         """Remove a player from the registry."""
@@ -212,7 +215,7 @@ class PlayerManager:
             await player.on_remove()
         player_name = player.name if player else player_id
         LOGGER.info("Player removed: %s", player_name)
-        self.mass.signal_event(EVENT_PLAYER_REMOVED, {"player_id": player_id})
+        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.."""
@@ -220,7 +223,7 @@ class PlayerManager:
         if player:
             await player.on_poll()
 
-    @api_route("players/controls/:control_id/register")
+    @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
@@ -238,9 +241,9 @@ class PlayerManager:
                 conf.get(CONF_POWER_CONTROL),
                 conf.get(CONF_VOLUME_CONTROL),
             ]:
-                self.mass.add_job(self.trigger_player_update(player.player_id))
+                create_task(self.trigger_player_update(player.player_id))
 
-    @api_route("players/controls/:control_id/update")
+    @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:
@@ -262,16 +265,16 @@ class PlayerManager:
                 conf.get(CONF_POWER_CONTROL),
                 conf.get(CONF_VOLUME_CONTROL),
             ]:
-                self.mass.add_job(self.trigger_player_update(player.player_id))
+                create_task(self.trigger_player_update(player.player_id))
 
     # SERVICE CALLS / PLAYER COMMANDS
 
-    @api_route("players/:player_id/play_media")
+    @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,
+        queue_opt: QueueOption = QueueOption.PLAY,
     ):
         """
         Play media item(s) on the given player.
@@ -279,10 +282,10 @@ class PlayerManager:
             :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
+                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(items, list):
@@ -290,19 +293,19 @@ class PlayerManager:
         queue_items = []
         for media_item in items:
             # collect tracks to play
-            if media_item.media_type == MediaType.Artist:
+            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:
+            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:
+            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:
+            elif media_item.media_type == MediaType.RADIO:
                 # single radio
                 tracks = [
                     await self.mass.music.get_radio(
@@ -320,30 +323,36 @@ class PlayerManager:
                 if not track.available:
                     continue
                 queue_item = QueueItem.from_track(track)
-                # generate uri for this queue item
-                queue_item.uri = "%s/queue/%s/%s" % (
+                # generate url for this queue item
+                queue_item.stream_url = "%s/queue/%s/%s" % (
                     self.mass.web.stream_url,
                     player_id,
                     queue_item.queue_item_id,
                 )
                 queue_items.append(queue_item)
         # turn on player
-        await self.cmd_power_on(player_id)
+        player = self.get_player(player_id)
+        if not player:
+            raise FileNotFoundError("Player not found %s" % player_id)
+        if not player.calculated_state.powered:
+            await self.cmd_power_on(player_id)
         # load items into the queue
         player_queue = self.get_player_queue(player_id)
-        if queue_opt == QueueOption.Replace:
+        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:
+        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:
+        if queue_opt == QueueOption.NEXT:
             return await player_queue.insert(queue_items, 1)
-        if queue_opt == QueueOption.Play:
+        if queue_opt == QueueOption.PLAY:
             return await player_queue.insert(queue_items, 0)
-        if queue_opt == QueueOption.Add:
+        if queue_opt == QueueOption.ADD:
             return await player_queue.append(queue_items)
 
-    @api_route("players/:player_id/play_uri")
-    async def cmd_play_uri(self, player_id: str, uri: str):
+    @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.
 
@@ -352,20 +361,143 @@ class PlayerManager:
             :param player_id: player_id of the player to handle the command.
             :param uri: Url/Uri that can be played by a player.
         """
-        queue_item = QueueItem(item_id=uri, provider="uri", name=uri)
-        # generate uri for this queue item
-        queue_item.uri = "%s/%s/%s" % (
+        # 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)
+        # fallback to regular url
+        queue_item = QueueItem(item_id=uri, provider="url", name=uri, uri=uri)
+        # generate url for this queue item
+        queue_item.stream_url = "%s/queue/%s/%s" % (
             self.mass.web.stream_url,
             player_id,
             queue_item.queue_item_id,
         )
         # turn on player
-        await self.cmd_power_on(player_id)
-        # load item into the queue
+        player = self.get_player(player_id)
+        if not player:
+            raise FileNotFoundError("Player not found %s" % player_id)
+        if not player.calculated_state.powered:
+            await self.cmd_power_on(player_id)
+        # load items into the queue
+        player_queue = self.get_player_queue(player_id)
+        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,
+        gain_adjust: int = 0,
+        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 gain_adjust: Adjust volume/gain of audio.
+            :param force: Play alert even if player is currently powered off.
+            :param announce: Prepend alert sound.
+        """
+        player = self.get_player(player_id)
         player_queue = self.get_player_queue(player_id)
-        return await player_queue.insert([queue_item], 0)
+        prev_state = player.calculated_state.state
+        prev_power = player.calculated_state.powered
+        if not player.calculated_state.powered:
+            if not force:
+                LOGGER.debug(
+                    "Ignore alert playback: Player %s is powered off.",
+                    player.calculated_state.name,
+                )
+                return
+            await self.cmd_power_on(player_id)
 
-    @api_route("players/:player_id/cmd/stop")
+        queue_items = []
+        if announce:
+            alert_announce = (
+                pathlib.Path(__file__)
+                .parent.resolve()
+                .parent.resolve()
+                .joinpath("helpers", "alert.mp3")
+            )
+            queue_item = QueueItem(
+                item_id="alert_announce",
+                provider="url",
+                name="alert",
+                duration=2,
+                streamdetails=StreamDetails(
+                    type=StreamType.URL,
+                    provider="url",
+                    item_id="alert_announce",
+                    path=str(alert_announce),
+                    content_type=ContentType(url.split(".")[-1]),
+                    gain_correct=10,
+                ),
+            )
+            queue_item.stream_url = "%s/queue/%s/%s" % (
+                self.mass.web.stream_url,
+                player_id,
+                queue_item.queue_item_id,
+            )
+            queue_items.append(queue_item)
+
+        queue_item = QueueItem(
+            item_id="alert_sound",
+            provider="url",
+            name="alert",
+            duration=10,
+            streamdetails=StreamDetails(
+                type=StreamType.URL,
+                provider="url",
+                item_id="alert_sound",
+                path=url,
+                content_type=ContentType(url.split(".")[-1]),
+                gain_correct=gain_adjust,
+            ),
+        )
+        queue_item.stream_url = "%s/queue/%s/%s?alert=true" % (
+            self.mass.web.stream_url,
+            player_id,
+            queue_item.queue_item_id,
+        )
+        queue_items.append(queue_item)
+
+        await player_queue.insert(queue_items, 0)
+
+        if prev_power and prev_state in [PlayerState.PLAYING, PlayerState.PAUSED]:
+            return
+
+        # wait until playback completed
+        playback_started = False
+        count = 0
+        while True:
+            if not playback_started and player_queue.state == PlayerState.PLAYING:
+                playback_started = True
+            elif playback_started and (
+                player_queue.state != PlayerState.PLAYING
+                or (player_queue.cur_item and player_queue.cur_item.name != "alert")
+            ):
+                break
+            if count == 20:
+                break
+            count += 0.2
+            await asyncio.sleep(0.2)
+        await self.cmd_power_off(player_id)
+
+    @api_route("players/{player_id}/cmd/stop", method="PUT")
     async def cmd_stop(self, player_id: str) -> None:
         """
         Send STOP command to given player.
@@ -379,7 +511,7 @@ class PlayerManager:
         queue_player = self.get_player(queue_id)
         return await queue_player.cmd_stop()
 
-    @api_route("players/:player_id/cmd/play")
+    @api_route("players/{player_id}/cmd/play", method="PUT")
     async def cmd_play(self, player_id: str) -> None:
         """
         Send PLAY command to given player.
@@ -392,13 +524,13 @@ class PlayerManager:
         queue_id = player.active_queue
         queue_player = self.get_player(queue_id)
         # unpause if paused else resume queue
-        if queue_player.state == PlaybackState.Paused:
+        if queue_player.state == PlayerState.PAUSED:
             return await queue_player.cmd_play()
         # power on at play request
         await self.cmd_power_on(player_id)
         return await self._player_queues[queue_id].resume()
 
-    @api_route("players/:player_id/cmd/pause")
+    @api_route("players/{player_id}/cmd/pause", method="PUT")
     async def cmd_pause(self, player_id: str):
         """
         Send PAUSE command to given player.
@@ -412,7 +544,7 @@ class PlayerManager:
         queue_player = self.get_player(queue_id)
         return await queue_player.cmd_pause()
 
-    @api_route("players/:player_id/cmd/play_pause")
+    @api_route("players/{player_id}/cmd/play_pause", method="PUT")
     async def cmd_play_pause(self, player_id: str):
         """
         Toggle play/pause on given player.
@@ -422,11 +554,11 @@ class PlayerManager:
         player = self.get_player(player_id)
         if not player:
             return
-        if player.state == PlaybackState.Playing:
+        if player.state == PlayerState.PLAYING:
             return await self.cmd_pause(player_id)
         return await self.cmd_play(player_id)
 
-    @api_route("players/:player_id/cmd/next")
+    @api_route("players/{player_id}/cmd/next", method="PUT")
     async def cmd_next(self, player_id: str):
         """
         Send NEXT TRACK command to given player.
@@ -439,7 +571,7 @@ class PlayerManager:
         queue_id = player.active_queue
         return await self.get_player_queue(queue_id).next()
 
-    @api_route("players/:player_id/cmd/previous")
+    @api_route("players/{player_id}/cmd/previous", method="PUT")
     async def cmd_previous(self, player_id: str):
         """
         Send PREVIOUS TRACK command to given player.
@@ -452,7 +584,7 @@ class PlayerManager:
         queue_id = player.active_queue
         return await self.get_player_queue(queue_id).previous()
 
-    @api_route("players/:player_id/cmd/power_on")
+    @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.
@@ -471,7 +603,7 @@ class PlayerManager:
             if control:
                 await control.set_state(True)
 
-    @api_route("players/:player_id/cmd/power_off")
+    @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.
@@ -483,8 +615,8 @@ class PlayerManager:
             return
         # send stop if player is playing
         if player.active_queue == player_id and player.state in [
-            PlaybackState.Playing,
-            PlaybackState.Paused,
+            PlayerState.PLAYING,
+            PlayerState.PAUSED,
         ]:
             await self.cmd_stop(player_id)
         player_config = self.mass.config.player_settings[player.player_id]
@@ -500,25 +632,25 @@ class PlayerManager:
             # 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.player_state.powered:
-                    self.mass.add_job(self.cmd_power_off(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.player_state.powered:
+                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.player_state.powered:
+                    if child_player and child_player.calculated_state.powered:
                         has_powered_players = True
                 if not has_powered_players:
-                    self.mass.add_job(self.cmd_power_off(parent_player_id))
+                    create_task(self.cmd_power_off(parent_player_id))
 
-    @api_route("players/:player_id/cmd/power_toggle")
+    @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.
@@ -528,11 +660,11 @@ class PlayerManager:
         player = self.get_player(player_id)
         if not player:
             return
-        if player.player_state.powered:
+        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/:volume_level?")
+    @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.
@@ -572,7 +704,7 @@ class PlayerManager:
                 if (
                     child_player
                     and child_player.available
-                    and child_player.player_state.powered
+                    and child_player.calculated_state.powered
                 ):
                     cur_child_volume = child_player.volume_level
                     new_child_volume = cur_child_volume + (
@@ -583,7 +715,7 @@ class PlayerManager:
         else:
             await player.cmd_volume_set(volume_level)
 
-    @api_route("players/:player_id/cmd/volume_up")
+    @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.
@@ -602,7 +734,7 @@ class PlayerManager:
             new_level = 100
         return await self.cmd_volume_set(player_id, new_level)
 
-    @api_route("players/:player_id/cmd/volume_down")
+    @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.
@@ -621,7 +753,7 @@ class PlayerManager:
             new_level = 0
         return await self.cmd_volume_set(player_id, new_level)
 
-    @api_route("players/:player_id/cmd/volume_mute/:is_muted")
+    @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.
@@ -635,37 +767,23 @@ class PlayerManager:
         # TODO: handle mute on volumecontrol?
         return await player.cmd_volume_mute(is_muted)
 
-    @api_route("players/:queue_id/queue/cmd/shuffle_enabled/:enable_shuffle?")
-    async def player_queue_cmd_set_shuffle(
-        self, queue_id: str, enable_shuffle: bool = False
-    ):
-        """
-        Send enable/disable shuffle command to given playerqueue.
-
-            :param queue_id: player_id of the playerqueue to handle the command.
-            :param enable_shuffle: bool with the new ahuffle state.
-        """
-        player_queue = self.get_player_queue(queue_id)
-        if not player_queue:
-            return
-        return await player_queue.set_shuffle_enabled(enable_shuffle)
-
-    @api_route("players/:queue_id/queue/cmd/repeat_enabled/:enable_repeat?")
-    async def player_queue_cmd_set_repeat(
-        self, queue_id: str, enable_repeat: bool = False
-    ):
-        """
-        Send enable/disable repeat command to given playerqueue.
-
-            :param queue_id: player_id of the playerqueue to handle the command.
-            :param enable_repeat: bool with the new ahuffle state.
-        """
+    @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:
-            return
-        return await player_queue.set_repeat_enabled(enable_repeat)
+            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("players/:queue_id/queue/cmd/next")
+    @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.
@@ -677,7 +795,7 @@ class PlayerManager:
             return
         return await player_queue.next()
 
-    @api_route("players/:queue_id/queue/cmd/previous")
+    @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.
@@ -689,7 +807,7 @@ class PlayerManager:
             return
         return await player_queue.previous()
 
-    @api_route("players/:queue_id/queue/cmd/move/:queue_item_id?/:pos_shift?")
+    @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
     ):
@@ -705,7 +823,7 @@ class PlayerManager:
             return
         return await player_queue.move_item(queue_item_id, pos_shift)
 
-    @api_route("players/:queue_id/queue/cmd/play_index/:index?")
+    @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)
@@ -713,8 +831,8 @@ class PlayerManager:
             return
         return await player_queue.play_index(index)
 
-    @api_route("players/:queue_id/queue/cmd/clear")
-    async def player_queue_cmd_clear(self, queue_id: str, enable_repeat: bool = False):
+    @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.
 
@@ -724,24 +842,3 @@ class PlayerManager:
         if not player_queue:
             return
         return await player_queue.clear()
-
-    # OTHER/HELPER FUNCTIONS
-
-    async def get_gain_correct(self, player_id: str, item_id: str, provider_id: str):
-        """Get gain correction for given player / track combination."""
-        player_conf = self.mass.config.get_player_config(player_id)
-        if not player_conf["volume_normalisation"]:
-            return 0
-        target_gain = int(player_conf["target_volume"])
-        track_loudness = await self.mass.database.get_track_loudness(
-            item_id, provider_id
-        )
-        if track_loudness is None:
-            # fallback to provider average
-            track_loudness = await self.mass.database.get_provider_loudness(provider_id)
-            if track_loudness is None:
-                # fallback to some (hopefully sane) average value for now
-                track_loudness = -8.5
-        gain_correct = target_gain - track_loudness
-        gain_correct = round(gain_correct, 2)
-        return gain_correct
diff --git a/music_assistant/managers/streams.py b/music_assistant/managers/streams.py
deleted file mode 100755 (executable)
index f24affa..0000000
+++ /dev/null
@@ -1,496 +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 the SoX executable, using various subprocess streams.
-"""
-import asyncio
-import logging
-import shlex
-import subprocess
-from enum import Enum
-from typing import AsyncGenerator, List, Optional, Tuple
-
-import aiofiles
-from music_assistant.constants import (
-    CONF_MAX_SAMPLE_RATE,
-    EVENT_STREAM_ENDED,
-    EVENT_STREAM_STARTED,
-)
-from music_assistant.helpers.process import AsyncProcess
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import create_tempfile, get_ip
-from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
-
-LOGGER = logging.getLogger("stream_manager")
-
-
-class SoxOutputFormat(Enum):
-    """Enum representing the various output formats."""
-
-    MP3 = "mp3"  # Lossy mp3
-    OGG = "ogg"  # Lossy Ogg Vorbis
-    FLAC = "flac"  # Flac (with default compression)
-    S24 = "s24"  # Raw PCM 24bits signed
-    S32 = "s32"  # Raw PCM 32bits signed
-    S64 = "s64"  # Raw PCM 64bits signed
-
-
-class StreamManager:
-    """Built-in streamer utilizing SoX."""
-
-    def __init__(self, mass: MusicAssistant) -> None:
-        """Initialize class."""
-        self.mass = mass
-        self.local_ip = get_ip()
-        self.analyze_jobs = {}
-
-    async def get_sox_stream(
-        self,
-        streamdetails: StreamDetails,
-        output_format: SoxOutputFormat = SoxOutputFormat.FLAC,
-        resample: Optional[int] = None,
-        gain_db_adjust: Optional[float] = None,
-        chunk_size: int = 512000,
-    ) -> AsyncGenerator[Tuple[bool, bytes], None]:
-        """Get the sox manipulated audio data for the given streamdetails."""
-        # collect all args for sox
-        if output_format in [
-            SoxOutputFormat.S24,
-            SoxOutputFormat.S32,
-            SoxOutputFormat.S64,
-        ]:
-            output_format = [output_format.value, "-c", "2"]
-        else:
-            output_format = [output_format.value]
-        if streamdetails.content_type in [ContentType.AAC, ContentType.MPEG]:
-            input_format = "flac"
-        else:
-            input_format = streamdetails.content_type.value
-
-        args = ["sox", "-t", input_format, "-", "-t"] + output_format + ["-"]
-        if gain_db_adjust:
-            args += ["vol", str(gain_db_adjust), "dB"]
-        if resample:
-            args += ["rate", "-v", str(resample)]
-
-        LOGGER.debug(
-            "start sox stream for: %s/%s", streamdetails.provider, streamdetails.item_id
-        )
-
-        async with AsyncProcess(args, enable_write=True) as sox_proc:
-
-            async def fill_buffer():
-                """Forward audio chunks to sox stdin."""
-                # feed audio data into sox stdin for processing
-                async for chunk in self.get_media_stream(streamdetails):
-                    await sox_proc.write(chunk)
-                await sox_proc.write_eof()
-
-            fill_buffer_task = self.mass.loop.create_task(fill_buffer())
-            # 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(
-                    "get_sox_stream aborted for: %s/%s",
-                    streamdetails.provider,
-                    streamdetails.item_id,
-                )
-                fill_buffer_task.cancel()
-                raise err
-            else:
-                LOGGER.debug(
-                    "finished sox stream for: %s/%s",
-                    streamdetails.provider,
-                    streamdetails.item_id,
-                )
-
-    async def queue_stream_flac(self, player_id) -> AsyncGenerator[bytes, None]:
-        """Stream the PlayerQueue's tracks as constant feed in flac format."""
-        player_conf = self.mass.config.get_player_config(player_id)
-        sample_rate = player_conf.get(CONF_MAX_SAMPLE_RATE, 96000)
-
-        args = [
-            "sox",
-            "-t",
-            "s32",
-            "-c",
-            "2",
-            "-r",
-            str(sample_rate),
-            "-",
-            "-t",
-            "flac",
-            "-",
-        ]
-        async with AsyncProcess(args, enable_write=True) as sox_proc:
-
-            # feed stdin with pcm samples
-            async def fill_buffer():
-                """Feed audio data into sox stdin for processing."""
-                async for chunk in self.queue_stream_pcm(player_id, sample_rate, 32):
-                    await sox_proc.write(chunk)
-
-            fill_buffer_task = self.mass.loop.create_task(fill_buffer())
-
-            # start yielding audio chunks
-            try:
-                async for chunk in sox_proc.iterate_chunks():
-                    yield chunk
-            except (asyncio.CancelledError, GeneratorExit) as err:
-                LOGGER.debug(
-                    "queue_stream_flac aborted for: %s",
-                    player_id,
-                )
-                fill_buffer_task.cancel()
-                raise err
-            else:
-                LOGGER.debug(
-                    "finished queue_stream_flac for: %s",
-                    player_id,
-                )
-
-    async def queue_stream_pcm(
-        self, player_id, sample_rate=96000, bit_depth=32
-    ) -> AsyncGenerator[bytes, None]:
-        """Stream the PlayerQueue's tracks as constant feed in PCM raw audio."""
-        player_queue = self.mass.players.get_player_queue(player_id)
-
-        LOGGER.info("Start Queue Stream for player %s ", player_id)
-
-        last_fadeout_data = b""
-        queue_index = None
-        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.info("no (more) tracks left in queue")
-                break
-
-            # get crossfade details
-            fade_length = player_queue.crossfade_duration
-            pcm_args = ["s32", "-c", "2", "-r", str(sample_rate)]
-            sample_size = int(sample_rate * (bit_depth / 8) * 2)  # 1 second
-            buffer_size = sample_size * fade_length if fade_length else sample_size * 10
-
-            # get streamdetails
-            streamdetails = await self.mass.music.get_stream_details(
-                queue_track, player_id
-            )
-            # get gain correct / replaygain
-            gain_correct = await self.mass.players.get_gain_correct(
-                player_id, streamdetails.item_id, streamdetails.provider
-            )
-            streamdetails.gain_correct = gain_correct
-
-            LOGGER.debug(
-                "Start Streaming queue track: %s (%s) for player %s",
-                queue_track.item_id,
-                queue_track.name,
-                player_id,
-            )
-            fade_in_part = b""
-            cur_chunk = 0
-            prev_chunk = None
-            bytes_written = 0
-            # handle incoming audio chunks
-            async for is_last_chunk, chunk in self.mass.streams.get_sox_stream(
-                streamdetails,
-                SoxOutputFormat.S32,
-                resample=sample_rate,
-                gain_db_adjust=gain_correct,
-                chunk_size=buffer_size,
-            ):
-                cur_chunk += 1
-
-                # HANDLE FIRST PART OF TRACK
-                if not chunk and 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_id,
-            )
-        # 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
-        LOGGER.info("streaming of queue for player %s completed", player_id)
-
-    async def stream_queue_item(
-        self, player_id: str, queue_item_id: str
-    ) -> AsyncGenerator[bytes, None]:
-        """Stream a single Queue item."""
-        # collect streamdetails
-        player_queue = self.mass.players.get_player_queue(player_id)
-        if not player_queue:
-            raise FileNotFoundError("invalid player_id")
-        queue_item = player_queue.by_item_id(queue_item_id)
-        if not queue_item:
-            raise FileNotFoundError("invalid queue_item_id")
-        streamdetails = await self.mass.music.get_stream_details(queue_item, player_id)
-
-        # get gain correct / replaygain
-        gain_correct = await self.mass.players.get_gain_correct(
-            player_id, streamdetails.item_id, streamdetails.provider
-        )
-        streamdetails.gain_correct = gain_correct
-
-        # start streaming
-        LOGGER.debug("Start streaming %s (%s)", queue_item_id, queue_item.name)
-        async for _, audio_chunk in self.get_sox_stream(
-            streamdetails, gain_db_adjust=gain_correct, chunk_size=4000000
-        ):
-            yield audio_chunk
-        LOGGER.debug("Finished streaming %s (%s)", queue_item_id, queue_item.name)
-
-    async def get_media_stream(
-        self, streamdetails: StreamDetails
-    ) -> AsyncGenerator[bytes, None]:
-        """Get the (original/untouched) audio data for the given streamdetails. Generator."""
-        stream_path = streamdetails.path
-        stream_type = StreamType(streamdetails.type)
-        audio_data = b""
-        track_loudness = await self.mass.database.get_track_loudness(
-            streamdetails.item_id, streamdetails.provider
-        )
-        needs_analyze = track_loudness is None
-
-        # support for AAC/MPEG created with ffmpeg in between
-        if streamdetails.content_type in [ContentType.AAC, ContentType.MPEG]:
-            stream_type = StreamType.EXECUTABLE
-            stream_path = f'ffmpeg -v quiet -i "{stream_path}" -f flac -'
-
-        # signal start of stream event
-        self.mass.signal_event(EVENT_STREAM_STARTED, streamdetails)
-        LOGGER.debug(
-            "start media stream for: %s/%s (%s)",
-            streamdetails.provider,
-            streamdetails.item_id,
-            streamdetails.type,
-        )
-        # stream from URL
-        if stream_type == StreamType.URL:
-            async with self.mass.http_session.get(stream_path) as response:
-                async for chunk, _ in response.content.iter_chunks():
-                    yield chunk
-                    if needs_analyze and len(audio_data) < 100000000:
-                        audio_data += chunk
-        # stream from file
-        elif stream_type == StreamType.FILE:
-            async with aiofiles.open(stream_path) as afp:
-                async for chunk in afp:
-                    yield chunk
-                    if needs_analyze and len(audio_data) < 100000000:
-                        audio_data += chunk
-        # stream from executable's stdout
-        elif stream_type == StreamType.EXECUTABLE:
-            args = shlex.split(stream_path)
-            async with AsyncProcess(args) as process:
-                async for chunk in process.iterate_chunks():
-                    yield chunk
-                    if needs_analyze and len(audio_data) < 100000000:
-                        audio_data += chunk
-
-        # signal end of stream event
-        self.mass.signal_event(EVENT_STREAM_ENDED, streamdetails)
-        LOGGER.debug(
-            "finished media stream for: %s/%s",
-            streamdetails.provider,
-            streamdetails.item_id,
-        )
-        await self.mass.database.mark_item_played(
-            streamdetails.item_id, streamdetails.provider
-        )
-
-        # send analyze job to background worker
-        # TODO: feed audio chunks to analyzer while streaming
-        # so we don't have to load this large chunk in memory
-        if needs_analyze and audio_data:
-            self.mass.add_job(self.__analyze_audio, streamdetails, audio_data)
-
-    def __analyze_audio(self, streamdetails, audio_data) -> None:
-        """Analyze track audio, for now we only calculate EBU R128 loudness."""
-        item_key = "%s%s" % (streamdetails.item_id, streamdetails.provider)
-        if item_key in self.analyze_jobs:
-            return  # prevent multiple analyze jobs for same track
-        self.analyze_jobs[item_key] = True
-
-        # get track loudness
-        track_loudness = self.mass.add_job(
-            self.mass.database.get_track_loudness(
-                streamdetails.item_id, streamdetails.provider
-            )
-        ).result()
-        if track_loudness is None:
-            # only when needed we do the analyze stuff
-            LOGGER.debug("Start analyzing track %s", item_key)
-            # calculate BS.1770 R128 integrated loudness with ffmpeg
-            # we used pyloudnorm here before but the numpy/scipy requirements were too heavy,
-            # considered the same feature is also included in ffmpeg
-            value = subprocess.check_output(
-                "ffmpeg -i pipe: -af ebur128=framelog=verbose -f null - 2>&1 | awk '/I:/{print $2}'",
-                shell=True,
-                input=audio_data,
-            )
-            loudness = float(value.decode().strip())
-            self.mass.add_job(
-                self.mass.database.set_track_loudness(
-                    streamdetails.item_id, streamdetails.provider, loudness
-                )
-            )
-            LOGGER.debug("Integrated loudness of track %s is: %s", item_key, loudness)
-        del audio_data
-        self.analyze_jobs.pop(item_key, None)
-
-
-async def crossfade_pcm_parts(
-    fade_in_part: bytes, fade_out_part: bytes, pcm_args: List[str], fade_length: int
-) -> bytes:
-    """Crossfade two chunks of pcm/raw audio using sox."""
-    # create fade-in part
-    fadeinfile = create_tempfile()
-    args = ["sox", "--ignore-length", "-t"] + pcm_args
-    args += ["-", "-t"] + pcm_args + [fadeinfile.name, "fade", "t", str(fade_length)]
-    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 += [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 + ["-"]
-    async with AsyncProcess(args, enable_write=False) as sox_proc:
-        crossfade_part, _ = await sox_proc.communicate()
-    fadeinfile.close()
-    fadeoutfile.close()
-    del fadeinfile
-    del fadeoutfile
-    return crossfade_part
-
-
-async def strip_silence(audio_data: bytes, pcm_args: List[str], reverse=False) -> bytes:
-    """Strip silence from (a chunk of) pcm audio."""
-    args = ["sox", "--ignore-length", "-t"] + pcm_args + ["-", "-t"] + pcm_args + ["-"]
-    if reverse:
-        args.append("reverse")
-    args += ["silence", "1", "0.1", "1%"]
-    if reverse:
-        args.append("reverse")
-    async with AsyncProcess(args, enable_write=True) as sox_proc:
-        stripped_data, _ = await sox_proc.communicate(audio_data)
-    return stripped_data
diff --git a/music_assistant/managers/tasks.py b/music_assistant/managers/tasks.py
new file mode 100644 (file)
index 0000000..85b1278
--- /dev/null
@@ -0,0 +1,182 @@
+"""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 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 49c50e363c23dd8ab5f3fba7fce2cd9f25618f17..4b3ddaa361979bb28e71e6a1931a7a7cff48f422 100644 (file)
@@ -1,14 +1,13 @@
 """Main Music Assistant class."""
 
 import asyncio
-import functools
 import importlib
 import logging
 import os
-import threading
-from typing import Any, Awaitable, Callable, Coroutine, Dict, Optional, Tuple, Union
+from typing import Dict, Optional, Tuple
 
 import aiohttp
+import music_assistant.helpers.util as util
 from music_assistant.constants import (
     CONF_ENABLED,
     EVENT_PROVIDER_REGISTERED,
@@ -17,16 +16,17 @@ from music_assistant.constants import (
 )
 from music_assistant.helpers.cache import Cache
 from music_assistant.helpers.migration import check_migrations
-from music_assistant.helpers.util import callback, get_ip_pton, is_callback
+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.streams import StreamManager
+from music_assistant.managers.tasks import TaskManager
 from music_assistant.models.provider import Provider, ProviderType
-from music_assistant.web.server import WebServer
+from music_assistant.web import WebServer
 from zeroconf import InterfaceChoice, NonUniqueNameException, ServiceInfo, Zeroconf
 
 LOGGER = logging.getLogger("mass")
@@ -55,12 +55,13 @@ class MusicAssistant:
         self._loop = None
         self._debug = debug
         self._http_session = None
-        self._event_listeners = []
+
         self._providers = {}
-        self._background_tasks = None
 
         # 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)
@@ -68,7 +69,6 @@ class MusicAssistant:
         self._music = MusicManager(self)
         self._library = LibraryManager(self)
         self._players = PlayerManager(self)
-        self._streams = StreamManager(self)
         # shared zeroconf instance
         self.zeroconf = Zeroconf(interfaces=InterfaceChoice.All)
 
@@ -76,6 +76,7 @@ class MusicAssistant:
         """Start running the music assistant server."""
         # 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)
         # create shared aiohttp ClientSession
@@ -85,6 +86,7 @@ class MusicAssistant:
         )
         # run migrations if needed
         await check_migrations(self)
+        await self._tasks.setup()
         await self._config.setup()
         await self._cache.setup()
         await self._music.setup()
@@ -93,13 +95,13 @@ class MusicAssistant:
         await self.setup_discovery()
         await self._web.setup()
         await self._library.setup()
-        self.loop.create_task(self.__process_background_tasks())
+        self.tasks.add("Save config", self.config.save)
 
     async def stop(self) -> None:
         """Stop running the music assistant server."""
         self._exit = True
         LOGGER.info("Application shutdown")
-        self.signal_event(EVENT_SHUTDOWN)
+        self._eventbus.signal(EVENT_SHUTDOWN)
         await self.config.close()
         await self._web.stop()
         for prov in self._providers.values():
@@ -143,11 +145,6 @@ class MusicAssistant:
         """Return the Cache instance."""
         return self._cache
 
-    @property
-    def streams(self) -> StreamManager:
-        """Return the Streams controller/manager."""
-        return self._streams
-
     @property
     def database(self) -> DatabaseManager:
         """Return the Database controller/manager."""
@@ -158,6 +155,16 @@ class MusicAssistant:
         """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."""
@@ -181,7 +188,7 @@ class MusicAssistant:
             if await provider.on_start() is not False:
                 provider.available = True
                 LOGGER.debug("Provider registered: %s", provider.name)
-                self.signal_event(EVENT_PROVIDER_REGISTERED, provider.id)
+                self.eventbus.signal(EVENT_PROVIDER_REGISTERED, provider.id)
             else:
                 LOGGER.debug(
                     "Provider registered but loading failed: %s", provider.name
@@ -195,7 +202,7 @@ class MusicAssistant:
             # unload it if it's loaded
             await self._providers[provider_id].on_stop()
             LOGGER.debug("Provider unregistered: %s", provider_id)
-            self.signal_event(EVENT_PROVIDER_UNREGISTERED, 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:
@@ -206,7 +213,7 @@ class MusicAssistant:
             await self.register_provider(provider)
         else:
             # try preloading all providers
-            self.add_job(self._preload_providers())
+            self.tasks.add("Reload providers", self._preload_providers)
 
     @callback
     def get_provider(self, provider_id: str) -> Provider:
@@ -230,100 +237,6 @@ class MusicAssistant:
             and (include_unavailable or item.available)
         )
 
-    @callback
-    def signal_event(self, event_msg: str, event_details: Any = None) -> None:
-        """
-        Signal (systemwide) event.
-
-            :param event_msg: the eventmessage to signal
-            :param event_details: optional details to send with the event.
-        """
-        for cb_func, event_filter in self._event_listeners:
-            if not event_filter or event_msg in event_filter:
-                self.add_job(cb_func, event_msg, event_details)
-
-    @callback
-    def add_event_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._event_listeners.append(listener)
-
-        def remove_listener():
-            self._event_listeners.remove(listener)
-
-        return remove_listener
-
-    @callback
-    def add_background_task(self, task: Coroutine):
-        """Add a coroutine/task to the end of the job queue."""
-        if self._background_tasks is None:
-            self._background_tasks = asyncio.Queue()
-        self._background_tasks.put_nowait(task)
-
-    @callback
-    def add_job(
-        self, target: Callable[..., Any], *args: Any, **kwargs: Any
-    ) -> Optional[asyncio.Task]:
-        """Add a job/task to the event loop.
-
-        target: target to call.
-        args: parameters for method to call.
-        """
-        task = None
-
-        # Check for partials to properly determine if coroutine function
-        check_target = target
-        while isinstance(check_target, functools.partial):
-            check_target = check_target.func
-
-        if threading.current_thread() is not threading.main_thread():
-            # called from other thread
-            if asyncio.iscoroutine(check_target):
-                task = asyncio.run_coroutine_threadsafe(target, self.loop)  # type: ignore
-            elif asyncio.iscoroutinefunction(check_target):
-                task = asyncio.run_coroutine_threadsafe(
-                    target(*args, **kwargs), self.loop
-                )
-            elif is_callback(check_target):
-                task = self.loop.call_soon_threadsafe(target, *args, **kwargs)
-            else:
-                task = self.loop.run_in_executor(None, target, *args, **kwargs)  # type: ignore
-        else:
-            # called from mainthread
-            if asyncio.iscoroutine(check_target):
-                task = self.loop.create_task(target)  # type: ignore
-            elif asyncio.iscoroutinefunction(check_target):
-                task = self.loop.create_task(target(*args, **kwargs))
-            elif is_callback(check_target):
-                task = self.loop.call_soon(target, *args, *kwargs)
-            else:
-                task = self.loop.run_in_executor(None, target, *args, *kwargs)  # type: ignore
-        return task
-
-    async def __process_background_tasks(self):
-        """Background tasks that takes care of slowly handling jobs in the queue."""
-        if self._background_tasks is None:
-            self._background_tasks = asyncio.Queue()
-        while not self.exit:
-            task = await self._background_tasks.get()
-            await task
-            if self._background_tasks.qsize() > 200:
-                await asyncio.sleep(0.5)
-            elif self._background_tasks.qsize() == 0:
-                await asyncio.sleep(10)
-            else:
-                await asyncio.sleep(1)
-
     async def setup_discovery(self) -> None:
         """Make this Music Assistant instance discoverable on the network."""
 
@@ -351,7 +264,7 @@ class MusicAssistant:
                     "Music Assistant instance with identical name present in the local network!"
                 )
 
-        self.add_job(_setup_discovery)
+        create_task(_setup_discovery)
 
     async def _preload_providers(self) -> None:
         """Dynamically load all providermodules."""
index 7d9151e40a0b55719b00f299243345720406f8ab..2acea7bfcef8724ea61ea70f3111486b2563181a 100755 (executable)
@@ -2,37 +2,39 @@
 
 from dataclasses import dataclass, field
 from enum import Enum, IntEnum
-from typing import Any, Dict, List, Mapping, Set
+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"
+    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"
+    ARTIST = "artist"
+    WRITER = "writer"
+    PRODUCER = "producer"
 
 
 class AlbumType(Enum):
     """Enum for Album type."""
 
-    Album = "album"
-    Single = "single"
-    Compilation = "compilation"
-    Unknown = "unknown"
+    ALBUM = "album"
+    SINGLE = "single"
+    COMPILATION = "compilation"
+    UNKNOWN = "unknown"
 
 
 class TrackQuality(IntEnum):
@@ -66,7 +68,7 @@ class MediaItemProviderId(DataClassDictMixin):
 
 @dataclass
 class MediaItem(DataClassDictMixin):
-    """Representation of a media item."""
+    """Base representation of a media item."""
 
     item_id: str
     provider: str
@@ -74,10 +76,16 @@ class MediaItem(DataClassDictMixin):
     metadata: Dict[str, Any] = field(default_factory=dict)
     provider_ids: Set[MediaItemProviderId] = field(default_factory=set)
     in_library: bool = False
-    media_type: MediaType = MediaType.Track
+    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):
+    def from_dict(cls, dict_obj: dict):
         # pylint: disable=arguments-differ
         """Parse MediaItem from dict."""
         if dict_obj["media_type"] == "artist":
@@ -130,7 +138,7 @@ class MediaItem(DataClassDictMixin):
 class Artist(MediaItem):
     """Model for an artist."""
 
-    media_type: MediaType = MediaType.Artist
+    media_type: MediaType = MediaType.ARTIST
     musicbrainz_id: str = ""
 
     def __hash__(self):
@@ -145,7 +153,13 @@ class ItemMapping(DataClassDictMixin):
     item_id: str
     provider: str
     name: str = ""
-    media_type: MediaType = MediaType.Artist
+    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):
@@ -161,11 +175,11 @@ class ItemMapping(DataClassDictMixin):
 class Album(MediaItem):
     """Model for an album."""
 
-    media_type: MediaType = MediaType.Album
+    media_type: MediaType = MediaType.ALBUM
     version: str = ""
     year: int = 0
-    artist: ItemMapping = None
-    album_type: AlbumType = AlbumType.Unknown
+    artist: Optional[ItemMapping] = None
+    album_type: AlbumType = AlbumType.UNKNOWN
     upc: str = ""
 
     def __hash__(self):
@@ -177,7 +191,7 @@ class Album(MediaItem):
 class FullAlbum(Album):
     """Model for an album with full details."""
 
-    artist: Artist = None
+    artist: Optional[Artist] = None
 
     def __hash__(self):
         """Return custom hash."""
@@ -188,14 +202,14 @@ class FullAlbum(Album):
 class Track(MediaItem):
     """Model for a track."""
 
-    media_type: MediaType = MediaType.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: ItemMapping = None
+    album: Optional[ItemMapping] = None
     disc_number: int = 0
     track_number: int = 0
     # playlist track only
@@ -212,7 +226,7 @@ class FullTrack(Track):
 
     artists: Set[Artist] = field(default_factory=set)
     albums: Set[Album] = field(default_factory=set)
-    album: Album = None
+    album: Optional[Album] = None
 
     def __hash__(self):
         """Return custom hash."""
@@ -223,7 +237,7 @@ class FullTrack(Track):
 class Playlist(MediaItem):
     """Model for a playlist."""
 
-    media_type: MediaType = MediaType.Playlist
+    media_type: MediaType = MediaType.PLAYLIST
     owner: str = ""
     checksum: str = ""  # some value to detect playlist track changes
     is_editable: bool = False
@@ -237,7 +251,7 @@ class Playlist(MediaItem):
 class Radio(MediaItem):
     """Model for a radio station."""
 
-    media_type: MediaType = MediaType.Radio
+    media_type: MediaType = MediaType.RADIO
     duration: int = 86400
 
     def __hash__(self):
index 8968e086e47e5bbd77f6d35227ebba7e48c815af..24f89e73f58fabcbb7c20e6fcf9f06c85bf64ea9 100755 (executable)
@@ -2,7 +2,6 @@
 
 from abc import abstractmethod
 from dataclasses import dataclass, field
-from datetime import datetime
 from enum import Enum, IntEnum
 from typing import Any, Optional, Set
 
@@ -15,17 +14,17 @@ from music_assistant.constants import (
     EVENT_PLAYER_CHANGED,
 )
 from music_assistant.helpers.typing import ConfigSubItem, MusicAssistant, QueueItems
-from music_assistant.helpers.util import callback
+from music_assistant.helpers.util import callback, create_task
 from music_assistant.models.config_entry import ConfigEntry
 
 
-class PlaybackState(Enum):
-    """Enum for the playstate of a player."""
+class PlayerState(Enum):
+    """Enum for the (playback)state of a player."""
 
-    Stopped = "stopped"
-    Paused = "paused"
-    Playing = "playing"
-    Off = "off"
+    IDLE = "idle"
+    PAUSED = "paused"
+    PLAYING = "playing"
+    OFF = "off"
 
 
 @dataclass(frozen=True)
@@ -78,26 +77,26 @@ class PlayerControl(DataClassDictMixin):
         # pickup this event (e.g. from the websocket api)
         # or override this method with your own implementation.
         # pylint: disable=no-member
-        self.mass.signal_event(f"players/controls/{self.control_id}/state", new_state)
+        self.mass.eventbus.signal(
+            f"players/controls/{self.control_id}/state", new_state
+        )
 
 
 @dataclass
-class PlayerState(DataClassDictMixin):
+class CalculatedPlayerState(DataClassDictMixin):
     """Model for a (calculated) player state."""
 
     player_id: str = None
     provider_id: str = None
     name: str = None
     powered: bool = False
-    state: PlaybackState = PlaybackState.Off
+    state: PlayerState = PlayerState.IDLE
     available: bool = False
     volume_level: int = 0
-    elapsed_time: 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)
-    updated_at: datetime = datetime.now()
     group_parents: Set[str] = field(default_factory=set)
     features: Set[PlayerFeature] = field(default_factory=set)
     active_queue: str = None
@@ -114,10 +113,7 @@ class PlayerState(DataClassDictMixin):
             new_val = getattr(new_obj, key)
             if getattr(self, key) != new_val:
                 setattr(self, key, new_val)
-                if key != "updated_at":
-                    changed_keys.add(key)
-        if changed_keys:
-            self.updated_at = datetime.now()
+                changed_keys.add(key)
         return changed_keys
 
 
@@ -168,9 +164,9 @@ class Player:
 
     @property
     @abstractmethod
-    def state(self) -> PlaybackState:
-        """Return current PlaybackState of player."""
-        return PlaybackState.Stopped
+    def state(self) -> PlayerState:
+        """Return current PlayerState of player."""
+        return PlayerState.IDLE
 
     @property
     def available(self) -> bool:
@@ -352,12 +348,12 @@ class Player:
     @property
     def active_queue(self) -> str:
         """Return the active parent player/queue for a player."""
-        return self._cur_state.active_queue or self.player_id
+        return self._calculated_state.active_queue or self.player_id
 
     @property
     def group_parents(self) -> Set[str]:
         """Return all groups this player belongs to."""
-        return self._cur_state.group_parents
+        return self._calculated_state.group_parents
 
     @property
     def config(self) -> ConfigSubItem:
@@ -386,9 +382,9 @@ class Player:
         return None
 
     @property
-    def player_state(self) -> PlayerState:
+    def calculated_state(self) -> CalculatedPlayerState:
         """Return calculated/final state for this player."""
-        return self._cur_state
+        return self._calculated_state
 
     @callback
     def update_state(self) -> None:
@@ -398,35 +394,25 @@ class Player:
         if not self.added_to_mass:
             if self.enabled:
                 # player is now enabled and can be added
-                self.mass.add_job(self.mass.players.add_player(self))
+                create_task(self.mass.players.add_player(self))
             return
-        new_state = self.create_state()
-        changed_keys = self._cur_state.update(new_state)
-        # basic throttle: do not send state changed events if player did not change
-        if not changed_keys:
-            return
-        self._cur_state = new_state
+        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:
-            self.mass.add_job(player_queue.update_state)
-        if len(changed_keys) == 1 and "elapsed_time" in changed_keys:
-            # no need to send player update if only the elapsed time changes
-            # this is already handled by the queue manager
+            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.mass.signal_event(EVENT_PLAYER_CHANGED, new_state)
+        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:
-            self.mass.add_job(self.mass.players.trigger_player_update(child_player_id))
+            create_task(self.mass.players.trigger_player_update(child_player_id))
         # update group player when child updates
-        for group_player_id in self._cur_state.group_parents:
-            self.mass.add_job(self.mass.players.trigger_player_update(group_player_id))
-
-    @callback
-    def _get_name(self) -> str:
-        """Return final/calculated player name."""
-        conf_name = self.config.get(CONF_NAME)
-        return conf_name if conf_name else self.name
+        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:
@@ -439,14 +425,12 @@ class Player:
         return self.powered
 
     @callback
-    def _get_state(self) -> PlaybackState:
-        """Return final/calculated player's playback state."""
-        if self.powered and self.active_queue != self.player_id:
+    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(self.active_queue).state
-        if self.state == PlaybackState.Stopped and not self.powered:
-            return PlaybackState.Off
-        return self.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:
@@ -469,7 +453,7 @@ class Player:
             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.player_state.volume_level
+                    group_volume += child_player.calculated_state.volume_level
                     active_players += 1
             if active_players:
                 group_volume = group_volume / active_players
@@ -495,39 +479,41 @@ class Player:
         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 [
-                PlaybackState.Playing,
-                PlaybackState.Paused,
+                PlayerState.PLAYING,
+                PlayerState.PAUSED,
             ]:
                 return group_player_id
         return self.player_id
 
     @callback
-    def create_state(self) -> PlayerState:
-        """Create PlayerState."""
-        return PlayerState(
+    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=self._get_name(),
-            powered=self._get_powered(),
-            state=self._get_state(),
+            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(),
-            elapsed_time=self.elapsed_time,
             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=self._get_active_queue(),
+            active_queue=active_queue,
         )
 
     def to_dict(self) -> dict:
         """Return playerstate for compatability with json serializer."""
-        return self._cur_state.to_dict()
+        return self._calculated_state.to_dict()
 
     def __init__(self, *args, **kwargs) -> None:
         """Initialize a Player instance."""
         self.mass: Optional[MusicAssistant] = None
         self.added_to_mass = False
-        self._cur_state = PlayerState()
+        self._calculated_state = CalculatedPlayerState()
index f538cc5771346fad1079db5a14bf62f12eff6bf8..9afcdec3fc5429d22e026841cdd1e781ebff5420 100755 (executable)
@@ -4,25 +4,25 @@ import logging
 import random
 import time
 import uuid
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from enum import Enum
-from typing import Any, Dict, List, Optional, Tuple, Union
+from typing import Any, Dict, List, Optional, Set, Tuple, Union
 
 from music_assistant.constants import (
     CONF_CROSSFADE_DURATION,
     EVENT_QUEUE_ITEMS_UPDATED,
-    EVENT_QUEUE_TIME_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
-from music_assistant.models.media_types import Radio, Track
-from music_assistant.models.player import PlaybackState, PlayerFeature
+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
@@ -35,28 +35,31 @@ LOGGER = logging.getLogger("player_queue")
 class QueueOption(Enum):
     """Enum representation of the queue (play) options."""
 
-    Play = "play"
-    Replace = "replace"
-    Next = "next"
-    Add = "add"
+    PLAY = "play"
+    REPLACE = "replace"
+    NEXT = "next"
+    ADD = "add"
 
 
 @dataclass
-class QueueItem(Track):
-    """Representation of a queue item, extended version of track."""
+class QueueItem(ItemMapping):
+    """Representation of a queue item, simplified version of track."""
 
-    streamdetails: StreamDetails = None
-    uri: str = ""
     queue_item_id: str = ""
+    streamdetails: StreamDetails = None
+    stream_url: str = ""
+    duration: int = 0
+    artists: Set[ItemMapping] = field(default_factory=set)
 
     def __post_init__(self):
         """Generate unique id for the QueueItem."""
+        super().__post_init__()
         self.queue_item_id = str(uuid.uuid4())
 
     @classmethod
-    def from_track(cls, track: Union[Track, Radio]):
+    def from_track(cls, base_item: Union[Track, Radio]):
         """Construct QueueItem from track/radio item."""
-        return cls.from_dict(track.to_dict())
+        return cls.from_dict(base_item.to_dict())
 
 
 class PlayerQueue:
@@ -74,9 +77,14 @@ class PlayerQueue:
         self._last_item = None
         self._queue_stream_start_index = 0
         self._queue_stream_next_index = 0
-        self._last_player = PlaybackState.Stopped
+        self._queue_stream_active = False
+        self._last_playback_state = PlayerState.IDLE
         # load previous queue settings from disk
-        self.mass.add_job(self._restore_saved_state())
+        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."""
@@ -88,6 +96,11 @@ class PlayerQueue:
         """Return handle to (master) player of this queue."""
         return self.mass.players.get_player(self._queue_id)
 
+    @property
+    def state(self) -> PlayerState:
+        """Return playbackstate of this (player) Queue."""
+        return self.player.state
+
     @property
     def queue_id(self) -> str:
         """Return the Queue's id."""
@@ -95,10 +108,10 @@ class PlayerQueue:
 
     def get_stream_url(self) -> str:
         """Return the full stream url for the player's Queue Stream."""
-        uri = f"{self.mass.web.stream_url}/queue/{self.queue_id}"
+        url = f"{self.mass.web.stream_url}/queue/{self.queue_id}"
         # we set the checksum just to invalidate cache stuf
-        uri += f"?checksum={time.time()}"
-        return uri
+        url += f"?checksum={time.time()}"
+        return url
 
     @property
     def shuffle_enabled(self) -> bool:
@@ -114,7 +127,7 @@ class PlayerQueue:
                 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
-                self.mass.add_job(self.update(items))
+                await self.update(items)
         elif self._shuffle_enabled and not enable_shuffle:
             # unshuffle
             self._shuffle_enabled = False
@@ -123,9 +136,9 @@ class PlayerQueue:
                 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
-                self.mass.add_job(self.update(items))
+                await self.update(items)
         self.update_state()
-        self.mass.signal_event(EVENT_QUEUE_UPDATED, self)
+        self.signal_update()
 
     @property
     def repeat_enabled(self) -> bool:
@@ -137,8 +150,8 @@ class PlayerQueue:
         if self._repeat_enabled != enable_repeat:
             self._repeat_enabled = enable_repeat
             self.update_state()
-            self.mass.add_job(self._save_state())
-            self.mass.signal_event(EVENT_QUEUE_UPDATED, self)
+            create_task(self._save_state())
+            self.signal_update()
 
     @property
     def cur_index(self) -> OptionalInt:
@@ -290,9 +303,10 @@ class PlayerQueue:
 
     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 or not self.supports_queue:
+            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
@@ -308,13 +322,15 @@ class PlayerQueue:
         """Play item at index (or item_id) X in queue."""
         if not isinstance(index, int):
             index = self.__index_by_id(index)
+        if index is None:
+            raise FileNotFoundError("Unknown index/id: %s" % 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_uri = self.get_stream_url()
-            return await self.player.cmd_play_uri(queue_stream_uri)
+            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)
@@ -326,7 +342,7 @@ class PlayerQueue:
                 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].uri)
+            return await self.player.cmd_play_uri(self._items[index].stream_url)
 
     async def move_item(self, queue_item_id: str, pos_shift: int = 1) -> None:
         """
@@ -338,7 +354,7 @@ class PlayerQueue:
         """
         items = self.items.copy()
         item_index = self.__index_by_id(queue_item_id)
-        if pos_shift == 0 and self.player.state == PlaybackState.Playing:
+        if pos_shift == 0 and self.player.state == PlayerState.PLAYING:
             new_index = self.cur_index + 1
         elif pos_shift == 0:
             new_index = self.cur_index
@@ -357,12 +373,12 @@ class PlayerQueue:
         if self._shuffle_enabled:
             queue_items = self.__shuffle_items(queue_items)
         self._items = queue_items
-        if self.use_queue_stream or not self.supports_queue:
+        if self.use_queue_stream:
             await self.play_index(0)
         else:
             await self.player.cmd_queue_load(queue_items)
-        self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self)
-        self.mass.add_job(self._save_state())
+        self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self)
+        create_task(self._save_state())
 
     async def insert(self, queue_items: List[QueueItem], offset: int = 0) -> None:
         """
@@ -379,7 +395,7 @@ class PlayerQueue:
         insert_at_index = self.cur_index + offset
         for index, item in enumerate(queue_items):
             item.sort_index = insert_at_index + index
-        if self.shuffle_enabled:
+        if self.shuffle_enabled and len(queue_items) > 10:
             queue_items = self.__shuffle_items(queue_items)
         if offset == 0:
             # replace current item with new
@@ -408,8 +424,8 @@ class PlayerQueue:
                 )
                 self._items = self._items[self.cur_index + offset :]
                 return await self.player.cmd_queue_load(self._items)
-        self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self)
-        self.mass.add_job(self._save_state())
+        self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self)
+        create_task(self._save_state())
 
     async def append(self, queue_items: List[QueueItem]) -> None:
         """Append new items at the end of the queue."""
@@ -422,7 +438,7 @@ class PlayerQueue:
             items = played_items + [self.cur_item] + next_items
             return await self.update(items)
         self._items = self._items + queue_items
-        if self.supports_queue and not self.use_queue_stream:
+        if not self.use_queue_stream:
             # send queue to player's own implementation
             try:
                 await self.player.cmd_queue_append(queue_items)
@@ -433,13 +449,13 @@ class PlayerQueue:
                 )
                 self._items = self._items[self.cur_index :]
                 return await self.player.cmd_queue_load(self._items)
-        self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self)
-        self.mass.add_job(self._save_state())
+        self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self)
+        create_task(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 self.supports_queue and not self.use_queue_stream:
+        if not self.use_queue_stream:
             # send queue to player's own implementation
             try:
                 await self.player.cmd_queue_update(queue_items)
@@ -450,8 +466,8 @@ class PlayerQueue:
                 )
                 self._items = self._items[self.cur_index :]
                 await self.player.cmd_queue_load(self._items)
-        self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self)
-        self.mass.add_job(self._save_state())
+        self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self)
+        create_task(self._save_state())
 
     async def clear(self) -> None:
         """Clear all items in the queue."""
@@ -468,17 +484,18 @@ class PlayerQueue:
                 except NotImplementedError:
                     # not supported by player, ignore
                     pass
-        self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self)
+        self.mass.eventbus.signal(EVENT_QUEUE_ITEMS_UPDATED, self)
 
     @callback
     def update_state(self) -> None:
         """Update queue details, called when player updates."""
         new_index = self._cur_index
         track_time = self._cur_item_time
+        new_item_loaded = False
         # handle queue stream
         if (
             self.use_queue_stream
-            and self.player.state == PlaybackState.Playing
+            and self.player.state == PlayerState.PLAYING
             and self.player.elapsed_time > 1
         ):
             new_index, track_time = self.__get_queue_stream_index()
@@ -486,7 +503,7 @@ class PlayerQueue:
         elif not self.use_queue_stream:
             track_time = self.player.elapsed_time
             for index, queue_item in enumerate(self.items):
-                if queue_item.uri == self.player.current_uri:
+                if queue_item.stream_url == self.player.current_uri:
                     new_index = index
                     break
         # process new index
@@ -500,18 +517,22 @@ class PlayerQueue:
             and self.cur_item.streamdetails
         ):
             # new active item in queue
-            self.mass.signal_event(EVENT_QUEUE_UPDATED, self)
+            new_item_loaded = True
             # invalidate previous streamdetails
             if self._last_item:
                 self._last_item.streamdetails = None
             self._last_item = self.cur_item
-        # update vars
-        if self._cur_item_time != track_time:
-            self._cur_item_time = track_time
-            self.mass.signal_event(
-                EVENT_QUEUE_TIME_UPDATED,
-                {"queue_id": self.queue_id, "cur_item_time": track_time},
-            )
+        # 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:
+            self._last_playback_state = self.state
+            self.signal_update()
+        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()
 
     async def queue_stream_start(self) -> None:
         """Call when queue_streamer starts playing the queue stream."""
@@ -536,7 +557,7 @@ class PlayerQueue:
         """Instance attributes as dict so it can be serialized to json."""
         return {
             "queue_id": self.player.player_id,
-            "queue_name": self.player.player_state.name,
+            "queue_name": self.player.calculated_state.name,
             "shuffle_enabled": self.shuffle_enabled,
             "repeat_enabled": self.repeat_enabled,
             "crossfade_enabled": self.crossfade_enabled,
@@ -545,11 +566,19 @@ class PlayerQueue:
             "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": self.cur_item_time,
+            "cur_item_time": int(self.cur_item_time),
             "next_item": self.next_item.to_dict() if self.next_item else None,
-            "queue_stream_enabled": self.use_queue_stream,
+            "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,
+        )
+
     @callback
     def __get_queue_stream_index(self) -> Tuple[int, int]:
         """Get index of queue stream."""
index 77d731c316ba6caba90504410e6d066d83a32416..66ce26b2cf50edf455cdeb2a38f03384f5bad415 100644 (file)
@@ -136,11 +136,11 @@ class MusicProvider(Provider):
     def supported_mediatypes(self) -> List[MediaType]:
         """Return MediaTypes the provider supports."""
         return [
-            MediaType.Album,
-            MediaType.Artist,
-            MediaType.Playlist,
-            MediaType.Radio,
-            MediaType.Track,
+            MediaType.ALBUM,
+            MediaType.ARTIST,
+            MediaType.PLAYLIST,
+            MediaType.RADIO,
+            MediaType.TRACK,
         ]
 
     async def search(
@@ -157,72 +157,72 @@ class MusicProvider(Provider):
 
     async def get_library_artists(self) -> List[Artist]:
         """Retrieve library artists from the provider."""
-        if MediaType.Artist in self.supported_mediatypes:
+        if MediaType.ARTIST in self.supported_mediatypes:
             raise NotImplementedError
 
     async def get_library_albums(self) -> List[Album]:
         """Retrieve library albums from the provider."""
-        if MediaType.Album in self.supported_mediatypes:
+        if MediaType.ALBUM in self.supported_mediatypes:
             raise NotImplementedError
 
     async def get_library_tracks(self) -> List[Track]:
         """Retrieve library tracks from the provider."""
-        if MediaType.Track in self.supported_mediatypes:
+        if MediaType.TRACK in self.supported_mediatypes:
             raise NotImplementedError
 
     async def get_library_playlists(self) -> List[Playlist]:
         """Retrieve library/subscribed playlists from the provider."""
-        if MediaType.Playlist in self.supported_mediatypes:
+        if MediaType.PLAYLIST in self.supported_mediatypes:
             raise NotImplementedError
 
     async def get_radios(self) -> List[Radio]:
         """Retrieve library/subscribed radio stations from the provider."""
-        if MediaType.Radio in self.supported_mediatypes:
+        if MediaType.RADIO in self.supported_mediatypes:
             raise NotImplementedError
 
     async def get_artist(self, prov_artist_id: str) -> Artist:
         """Get full artist details by id."""
-        if MediaType.Artist in self.supported_mediatypes:
+        if MediaType.ARTIST in self.supported_mediatypes:
             raise NotImplementedError
 
     async def get_artist_albums(self, prov_artist_id: str) -> List[Album]:
         """Get a list of all albums for the given artist."""
-        if MediaType.Album in self.supported_mediatypes:
+        if MediaType.ALBUM in self.supported_mediatypes:
             raise NotImplementedError
 
     async def get_artist_toptracks(self, prov_artist_id: str) -> List[Track]:
         """Get a list of most popular tracks for the given artist."""
-        if MediaType.Track in self.supported_mediatypes:
+        if MediaType.TRACK in self.supported_mediatypes:
             raise NotImplementedError
 
     async def get_album(self, prov_album_id: str) -> Album:
         """Get full album details by id."""
-        if MediaType.Album in self.supported_mediatypes:
+        if MediaType.ALBUM in self.supported_mediatypes:
             raise NotImplementedError
 
     async def get_track(self, prov_track_id: str) -> Track:
         """Get full track details by id."""
-        if MediaType.Track in self.supported_mediatypes:
+        if MediaType.TRACK in self.supported_mediatypes:
             raise NotImplementedError
 
     async def get_playlist(self, prov_playlist_id: str) -> Playlist:
         """Get full playlist details by id."""
-        if MediaType.Playlist in self.supported_mediatypes:
+        if MediaType.PLAYLIST in self.supported_mediatypes:
             raise NotImplementedError
 
     async def get_radio(self, prov_radio_id: str) -> Radio:
         """Get full radio details by id."""
-        if MediaType.Radio in self.supported_mediatypes:
+        if MediaType.RADIO in self.supported_mediatypes:
             raise NotImplementedError
 
     async def get_album_tracks(self, prov_album_id: str) -> List[Track]:
         """Get album tracks for given album id."""
-        if MediaType.Album in self.supported_mediatypes:
+        if MediaType.ALBUM in self.supported_mediatypes:
             raise NotImplementedError
 
     async def get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]:
         """Get all playlist tracks for given playlist id."""
-        if MediaType.Playlist in self.supported_mediatypes:
+        if MediaType.PLAYLIST in self.supported_mediatypes:
             raise NotImplementedError
 
     async def library_add(self, prov_item_id: str, media_type: MediaType) -> bool:
@@ -237,14 +237,14 @@ class MusicProvider(Provider):
         self, prov_playlist_id: str, prov_track_ids: List[str]
     ) -> bool:
         """Add track(s) to playlist. Return true on succes."""
-        if MediaType.Playlist in self.supported_mediatypes:
+        if MediaType.PLAYLIST in self.supported_mediatypes:
             raise NotImplementedError
 
     async def remove_playlist_tracks(
         self, prov_playlist_id: str, prov_track_ids: List[str]
     ) -> bool:
         """Remove track(s) from playlist. Return true on succes."""
-        if MediaType.Playlist in self.supported_mediatypes:
+        if MediaType.PLAYLIST in self.supported_mediatypes:
             raise NotImplementedError
 
     async def get_stream_details(self, item_id: str) -> StreamDetails:
index 61da129eab56cf68f3a7692c125cf261220ea94a..f7452ccaef6ce5a4c50be31065e84df8e420a395 100644 (file)
@@ -2,9 +2,10 @@
 
 from dataclasses import dataclass
 from enum import Enum
-from typing import Any
+from typing import Any, Optional
 
 from mashumaro.serializer.base.dict import DataClassDictMixin
+from music_assistant.models.media_types import MediaType
 
 
 class StreamType(Enum):
@@ -24,6 +25,9 @@ class ContentType(Enum):
     MP3 = "mp3"
     AAC = "aac"
     MPEG = "mpeg"
+    S24 = "s24"
+    S32 = "s32"
+    S64 = "s64"
 
 
 @dataclass
@@ -35,27 +39,34 @@ class StreamDetails(DataClassDictMixin):
     item_id: str
     path: str
     content_type: ContentType
-    sample_rate: int
-    bit_depth: int
     player_id: str = ""
     details: Any = None
     seconds_played: int = 0
     gain_correct: float = 0
+    loudness: Optional[float] = None
+    sample_rate: int = 44100
+    bit_depth: int = 16
+    media_type: MediaType = MediaType.TRACK
 
     def to_dict(
         self,
         use_bytes: bool = False,
         use_enum: bool = False,
         use_datetime: bool = False,
-        **kwargs
+        **kwargs,
     ):
         """Handle conversion to dict."""
         return {
             "provider": self.provider,
             "item_id": self.item_id,
             "content_type": self.content_type.value,
+            "media_type": self.media_type.value,
             "sample_rate": self.sample_rate,
             "bit_depth": self.bit_depth,
             "gain_correct": self.gain_correct,
             "seconds_played": self.seconds_played,
         }
+
+    def __str__(self):
+        """Return pretty printable string of object."""
+        return f"{self.type.value}/{self.content_type.value} - {self.provider}/{self.item_id}"
index f78b33fdd53fad2f0259eebc14645b63c238fe52..b083cdbd1b0f5755c34d0e7d2a17036a0f8af0f2 100644 (file)
@@ -3,15 +3,9 @@ import logging
 import time
 from typing import List
 
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import run_periodic
+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,
-    PlaybackState,
-    Player,
-    PlayerFeature,
-)
+from music_assistant.models.player import DeviceInfo, Player, PlayerFeature, PlayerState
 from music_assistant.models.provider import PlayerProvider
 
 PROV_ID = "builtin_player"
@@ -23,7 +17,7 @@ PLAYER_CONFIG_ENTRIES = []
 PLAYER_FEATURES = []
 
 WS_COMMAND_WSPLAYER_CMD = "wsplayer command"
-WS_COMMAND_WSplayer = "wsplayer state"
+WS_COMMAND_WSPLAYER_STATE = "wsplayer state"
 WS_COMMAND_WSPLAYER_REGISTER = "wsplayer register"
 
 
@@ -58,11 +52,11 @@ class MassPlayerProvider(PlayerProvider):
     async def on_start(self) -> bool:
         """Handle initialization of the provider based on config."""
         # listen for websockets commands to dynamically create players
-        self.mass.add_job(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, self.handle_ws_player)
+        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):
@@ -75,7 +69,7 @@ class MassPlayerProvider(PlayerProvider):
         player = self.mass.players.get_player(player_id)
         if not player:
             # register new player
-            player = WebsocketsPlayer(self.mass, player_id, details["name"])
+            player = WebsocketsPlayer(player_id, details["name"])
             await self.mass.players.add_player(player)
         await player.handle_player(details)
 
@@ -102,18 +96,19 @@ class WebsocketsPlayer(Player):
     and our internal event bus.
     """
 
-    def __init__(self, mass: MusicAssistant, player_id: str, player_name: str):
+    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 = PlaybackState.Stopped
+        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."""
@@ -122,7 +117,7 @@ class WebsocketsPlayer(Player):
         if "muted" in data:
             self._muted = data["muted"]
         if "state" in data:
-            self._state = PlaybackState(data["state"])
+            self._state = PlayerState(data["state"])
         if "elapsed_time" in data:
             self._elapsed_time = data["elapsed_time"]
         if "current_uri" in data:
@@ -161,8 +156,8 @@ class WebsocketsPlayer(Player):
         return self._elapsed_time
 
     @property
-    def state(self) -> PlaybackState:
-        """Return current PlaybackState of player."""
+    def state(self) -> PlayerState:
+        """Return current PlayerState of player."""
         return self._state
 
     @property
@@ -207,22 +202,22 @@ class WebsocketsPlayer(Player):
             :param uri: uri/url to send to the player.
         """
         data = {"player_id": self.player_id, "cmd": "play_uri", "uri": uri}
-        self.mass.signal_event(WS_COMMAND_WSPLAYER_CMD, data)
+        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.signal_event(WS_COMMAND_WSPLAYER_CMD, data)
+        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.signal_event(WS_COMMAND_WSPLAYER_CMD, data)
+        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.signal_event(WS_COMMAND_WSPLAYER_CMD, data)
+        self.mass.eventbus.signal(WS_COMMAND_WSPLAYER_CMD, data)
 
     async def cmd_power_on(self) -> None:
         """Send POWER ON command to player."""
@@ -245,7 +240,7 @@ class WebsocketsPlayer(Player):
             "cmd": "volume_set",
             "volume_level": volume_level,
         }
-        self.mass.signal_event(WS_COMMAND_WSPLAYER_CMD, data)
+        self.mass.eventbus.signal(WS_COMMAND_WSPLAYER_CMD, data)
 
     async def cmd_volume_mute(self, is_muted: bool = False) -> None:
         """
@@ -254,4 +249,4 @@ class WebsocketsPlayer(Player):
             :param is_muted: bool with new mute state.
         """
         data = {"player_id": self.player_id, "cmd": "volume_mute", "is_muted": is_muted}
-        self.mass.signal_event(WS_COMMAND_WSPLAYER_CMD, data)
+        self.mass.eventbus.signal(WS_COMMAND_WSPLAYER_CMD, data)
index 155b503e3a3ddd96824dce25f5e2c79b407c40a6..e6b0dc2694e1d207e0537d6d2cf4ca8a1907d06d 100644 (file)
@@ -4,6 +4,7 @@ import logging
 from typing import List
 
 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
@@ -60,7 +61,7 @@ class ChromecastProvider(PlayerProvider):
                 self._listener, self.mass.zeroconf
             )
 
-        self.mass.add_job(start_discovery)
+        create_task(start_discovery)
         return True
 
     async def on_stop(self):
@@ -68,7 +69,7 @@ class ChromecastProvider(PlayerProvider):
         if not self._browser:
             return
         # stop discovery
-        self.mass.add_job(pychromecast.stop_discovery, self._browser)
+        create_task(pychromecast.stop_discovery, self._browser)
 
     def __chromecast_add_update_callback(self, cast_uuid, cast_service_name):
         """Handle zeroconf discovery of a new or updated chromecast."""
@@ -99,7 +100,7 @@ class ChromecastProvider(PlayerProvider):
             player = ChromecastPlayer(self.mass, cast_info)
         # if player was already added, the player will take care of reconnects itself.
         player.set_cast_info(cast_info)
-        self.mass.add_job(self.mass.players.add_player(player))
+        create_task(self.mass.players.add_player(player))
 
     @staticmethod
     def __chromecast_remove_callback(cast_uuid, cast_service_name, cast_service):
index 4d0db6d1006a240cfde48b2f9d882d695e48615d..874e4e4e307442f5f8106c054b86e5cd25728f8b 100644 (file)
@@ -7,14 +7,9 @@ import pychromecast
 from asyncio_throttle import Throttler
 from music_assistant.helpers.compare import compare_strings
 from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import yield_chunks
+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,
-    PlaybackState,
-    Player,
-    PlayerFeature,
-)
+from music_assistant.models.player import DeviceInfo, Player, PlayerFeature, PlayerState
 from music_assistant.models.player_queue import QueueItem
 from pychromecast.controllers.multizone import MultizoneController
 from pychromecast.socket_client import (
@@ -54,7 +49,7 @@ class ChromecastPlayer(Player):
         self._available = False
         self._status_listener: Optional[CastStatusListener] = None
         self._is_speaker_group = False
-        self._throttler = Throttler(rate_limit=1, period=0.1)
+        self._throttler = Throttler(rate_limit=1, period=0.2)
 
     @property
     def player_id(self) -> str:
@@ -94,19 +89,17 @@ class ChromecastPlayer(Player):
         return self.media_status and self.media_status.player_is_playing
 
     @property
-    def state(self) -> PlaybackState:
+    def state(self) -> PlayerState:
         """Return the state of the player."""
-        if not self.powered:
-            return PlaybackState.Off
         if self.media_status is None:
-            return PlaybackState.Stopped
+            return PlayerState.IDLE
         if self.media_status.player_is_playing:
-            return PlaybackState.Playing
+            return PlayerState.PLAYING
         if self.media_status.player_is_paused:
-            return PlaybackState.Paused
+            return PlayerState.PAUSED
         if self.media_status.player_is_idle:
-            return PlaybackState.Stopped
-        return PlaybackState.Stopped
+            return PlayerState.IDLE
+        return PlayerState.IDLE
 
     @property
     def elapsed_time(self) -> int:
@@ -286,7 +279,7 @@ class ChromecastPlayer(Player):
             self._available = new_available
             self.update_state()
             if self._cast_info.is_audio_group and new_available:
-                self.mass.add_job(self._chromecast.mz_controller.update_members)
+                create_task(self._chromecast.mz_controller.update_members)
 
     # ========== Service Calls ==========
 
@@ -338,7 +331,7 @@ class ChromecastPlayer(Player):
         if player_queue.use_queue_stream:
             # create (fake) CC queue so that skip and previous will work
             queue_item = QueueItem(
-                item_id=uri, provider="mass", name="Music Assistant", uri=uri
+                item_id=uri, provider="mass", name="Music Assistant", stream_url=uri
             )
             return await self.cmd_queue_load([queue_item, queue_item])
         await self.chromecast_command(self._chromecast.play_media, uri, "audio/flac")
@@ -346,7 +339,7 @@ class ChromecastPlayer(Player):
     async def cmd_queue_load(self, queue_items: List[QueueItem]) -> None:
         """Load (overwrite) queue with new items."""
         player_queue = self.mass.players.get_player_queue(self.player_id)
-        cc_queue_items = self.__create_queue_items(queue_items[:50])
+        cc_queue_items = self.__create_queue_items(queue_items[:25])
         repeat_enabled = player_queue.use_queue_stream or player_queue.repeat_enabled
         queuedata = {
             "type": "QUEUE_LOAD",
@@ -354,16 +347,16 @@ class ChromecastPlayer(Player):
             "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 50 tracks at once or the socket will crash
+            "items": cc_queue_items,  # only load 25 tracks at once or the socket will crash
         }
         await self.chromecast_command(self.__send_player_queue, queuedata)
         if len(queue_items) > 50:
-            await self.cmd_queue_append(queue_items[51:])
+            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, 50):
+        async for chunk in yield_chunks(cc_queue_items, 25):
             queuedata = {
                 "type": "QUEUE_INSERT",
                 "insertBefore": None,
@@ -375,30 +368,32 @@ class ChromecastPlayer(Player):
         """Create list of CC queue items from tracks."""
         return [self.__create_queue_item(track) for track in tracks]
 
-    def __create_queue_item(self, track):
+    def __create_queue_item(self, queue_item: QueueItem):
         """Create CC queue item from track info."""
         player_queue = self.mass.players.get_player_queue(self.player_id)
         return {
-            "opt_itemId": track.queue_item_id,
+            "opt_itemId": queue_item.queue_item_id,
             "autoplay": True,
             "preloadTime": 10,
-            "playbackDuration": int(track.duration),
+            "playbackDuration": int(queue_item.duration),
             "startTime": 0,
             "activeTrackIds": [],
             "media": {
-                "contentId": track.uri,
+                "contentId": queue_item.stream_url,
                 "customData": {
-                    "provider": track.provider,
-                    "uri": track.uri,
-                    "item_id": track.queue_item_id,
+                    "provider": queue_item.provider,
+                    "uri": queue_item.stream_url,
+                    "item_id": queue_item.queue_item_id,
                 },
                 "contentType": "audio/flac",
                 "streamType": "LIVE" if player_queue.use_queue_stream else "BUFFERED",
                 "metadata": {
-                    "title": track.name,
-                    "artist": next(iter(track.artists)).name if track.artists else "",
+                    "title": queue_item.name,
+                    "artist": next(iter(queue_item.artists)).name
+                    if queue_item.artists
+                    else "",
                 },
-                "duration": int(track.duration),
+                "duration": int(queue_item.duration),
             },
         }
 
@@ -431,4 +426,4 @@ class ChromecastPlayer(Player):
             )
             return
         async with self._throttler:
-            self.mass.add_job(func, *args, **kwargs)
+            create_task(func, *args, **kwargs)
index 68a11b1279ceaa0d4b13a20f6bfdc00901dfa473..9a264dcb8c224cb8644360a420b8585b0ca7516f 100644 (file)
@@ -83,7 +83,7 @@ class FileProvider(MusicProvider):
     @property
     def supported_mediatypes(self) -> List[MediaType]:
         """Return MediaTypes the provider supports."""
-        return [MediaType.Album, MediaType.Artist, MediaType.Playlist, MediaType.Track]
+        return [MediaType.ALBUM, MediaType.ARTIST, MediaType.PLAYLIST, MediaType.TRACK]
 
     async def on_start(self) -> bool:
         """Handle initialization of the provider based on config."""
index 361ed38b2d6aa758573817e371c033035e597f17..6ddf0e8607e5a0eb5c0af358b723017771c77ec2 100644 (file)
@@ -79,7 +79,7 @@ class QobuzProvider(MusicProvider):
     @property
     def supported_mediatypes(self) -> List[MediaType]:
         """Return MediaTypes the provider supports."""
-        return [MediaType.Album, MediaType.Artist, MediaType.Playlist, MediaType.Track]
+        return [MediaType.ALBUM, MediaType.ARTIST, MediaType.PLAYLIST, MediaType.TRACK]
 
     async def on_start(self) -> bool:
         """Handle initialization of the provider based on config."""
@@ -94,7 +94,7 @@ class QobuzProvider(MusicProvider):
         self.__user_auth_info = None
         self.__logged_in = False
         self._throttler = Throttler(rate_limit=4, period=1)
-        self.mass.add_event_listener(
+        self.mass.eventbus.add_listener(
             self.mass_event, (EVENT_STREAM_STARTED, EVENT_STREAM_ENDED)
         )
         return True
@@ -113,13 +113,13 @@ class QobuzProvider(MusicProvider):
         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:
+            if media_types[0] == MediaType.ARTIST:
                 params["type"] = "artists"
-            if media_types[0] == MediaType.Album:
+            if media_types[0] == MediaType.ALBUM:
                 params["type"] = "albums"
-            if media_types[0] == MediaType.Track:
+            if media_types[0] == MediaType.TRACK:
                 params["type"] = "tracks"
-            if media_types[0] == MediaType.Playlist:
+            if media_types[0] == MediaType.PLAYLIST:
                 params["type"] = "playlists"
         searchresult = await self._get_data("catalog/search", params)
         if searchresult:
@@ -298,19 +298,19 @@ class QobuzProvider(MusicProvider):
     async def library_add(self, prov_item_id, media_type: MediaType):
         """Add item to library."""
         result = None
-        if media_type == MediaType.Artist:
+        if media_type == MediaType.ARTIST:
             result = await self._get_data(
                 "favorite/create", {"artist_ids": prov_item_id}
             )
-        elif media_type == MediaType.Album:
+        elif media_type == MediaType.ALBUM:
             result = await self._get_data(
                 "favorite/create", {"album_ids": prov_item_id}
             )
-        elif media_type == MediaType.Track:
+        elif media_type == MediaType.TRACK:
             result = await self._get_data(
                 "favorite/create", {"track_ids": prov_item_id}
             )
-        elif media_type == MediaType.Playlist:
+        elif media_type == MediaType.PLAYLIST:
             result = await self._get_data(
                 "playlist/subscribe", {"playlist_id": prov_item_id}
             )
@@ -319,19 +319,19 @@ class QobuzProvider(MusicProvider):
     async def library_remove(self, prov_item_id, media_type: MediaType):
         """Remove item from library."""
         result = None
-        if media_type == MediaType.Artist:
+        if media_type == MediaType.ARTIST:
             result = await self._get_data(
                 "favorite/delete", {"artist_ids": prov_item_id}
             )
-        elif media_type == MediaType.Album:
+        elif media_type == MediaType.ALBUM:
             result = await self._get_data(
                 "favorite/delete", {"album_ids": prov_item_id}
             )
-        elif media_type == MediaType.Track:
+        elif media_type == MediaType.TRACK:
             result = await self._get_data(
                 "favorite/delete", {"track_ids": prov_item_id}
             )
-        elif media_type == MediaType.Playlist:
+        elif media_type == MediaType.PLAYLIST:
             playlist = await self.get_playlist(prov_item_id)
             if playlist.is_editable:
                 result = await self._get_data(
@@ -460,6 +460,9 @@ class QobuzProvider(MusicProvider):
 
     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
@@ -493,17 +496,17 @@ class QobuzProvider(MusicProvider):
             album_obj.get("product_type", "") == "single"
             or album_obj.get("release_type", "") == "single"
         ):
-            album.album_type = AlbumType.Single
+            album.album_type = AlbumType.SINGLE
         elif (
             album_obj.get("product_type", "") == "compilation"
             or "Various" in album.artist.name
         ):
-            album.album_type = AlbumType.Compilation
+            album.album_type = AlbumType.COMPILATION
         elif (
             album_obj.get("product_type", "") == "album"
             or album_obj.get("release_type", "") == "album"
         ):
-            album.album_type = AlbumType.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)
index aae18158ee900422b8596c2dd7b091194ced7e8f..affc3afa55dd4521f533544583178dbac63fb8c7 100644 (file)
@@ -6,14 +6,9 @@ import time
 from typing import List
 
 import soco
-from music_assistant.helpers.util import run_periodic
+from music_assistant.helpers.util import create_task
 from music_assistant.models.config_entry import ConfigEntry
-from music_assistant.models.player import (
-    DeviceInfo,
-    PlaybackState,
-    Player,
-    PlayerFeature,
-)
+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
 
@@ -53,7 +48,7 @@ class SonosProvider(PlayerProvider):
 
     async def on_start(self) -> bool:
         """Handle initialization of the provider."""
-        self._tasks.append(self.mass.add_job(self._periodic_discovery()))
+        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."""
@@ -68,7 +63,7 @@ class SonosProvider(PlayerProvider):
         """
         player = self._players.get(player_id)
         if player:
-            self.mass.add_job(player.soco.play_uri, uri)
+            create_task(player.soco.play_uri, uri)
         else:
             LOGGER.warning("Received command for unavailable player: %s", player_id)
 
@@ -80,7 +75,7 @@ class SonosProvider(PlayerProvider):
         """
         player = self._players.get(player_id)
         if player:
-            self.mass.add_job(player.soco.stop)
+            create_task(player.soco.stop)
         else:
             LOGGER.warning("Received command for unavailable player: %s", player_id)
 
@@ -92,7 +87,7 @@ class SonosProvider(PlayerProvider):
         """
         player = self._players.get(player_id)
         if player:
-            self.mass.add_job(player.soco.play)
+            create_task(player.soco.play)
         else:
             LOGGER.warning("Received command for unavailable player: %s", player_id)
 
@@ -104,7 +99,7 @@ class SonosProvider(PlayerProvider):
         """
         player = self._players.get(player_id)
         if player:
-            self.mass.add_job(player.soco.pause)
+            create_task(player.soco.pause)
         else:
             LOGGER.warning("Received command for unavailable player: %s", player_id)
 
@@ -116,7 +111,7 @@ class SonosProvider(PlayerProvider):
         """
         player = self._players.get(player_id)
         if player:
-            self.mass.add_job(player.soco.next)
+            create_task(player.soco.next)
         else:
             LOGGER.warning("Received command for unavailable player: %s", player_id)
 
@@ -128,7 +123,7 @@ class SonosProvider(PlayerProvider):
         """
         player = self._players.get(player_id)
         if player:
-            self.mass.add_job(player.soco.previous)
+            create_task(player.soco.previous)
         else:
             LOGGER.warning("Received command for unavailable player: %s", player_id)
 
@@ -195,7 +190,7 @@ class SonosProvider(PlayerProvider):
         """
         player = self._players.get(player_id)
         if player:
-            self.mass.add_job(player.soco.play_from_queue, index)
+            create_task(player.soco.play_from_queue, index)
         else:
             LOGGER.warning("Received command for unavailable player: %s", player_id)
 
@@ -208,9 +203,9 @@ class SonosProvider(PlayerProvider):
         """
         player = self._players.get(player_id)
         if player:
-            self.mass.add_job(player.soco.clear_queue)
+            create_task(player.soco.clear_queue)
             for pos, item in enumerate(queue_items):
-                self.mass.add_job(player.soco.add_uri_to_queue, item.uri, pos)
+                create_task(player.soco.add_uri_to_queue, item.stream_url, pos)
         else:
             LOGGER.warning("Received command for unavailable player: %s", player_id)
 
@@ -228,8 +223,8 @@ class SonosProvider(PlayerProvider):
         player = self._players.get(player_id)
         if player:
             for pos, item in enumerate(queue_items):
-                self.mass.add_job(
-                    player.soco.add_uri_to_queue, item.uri, insert_at_index + pos
+                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)
@@ -256,15 +251,10 @@ class SonosProvider(PlayerProvider):
         """
         player = self._players.get(player_id)
         if player:
-            self.mass.add_job(player.soco.clear_queue)
+            create_task(player.soco.clear_queue)
         else:
             LOGGER.warning("Received command for unavailable player: %s", player_id)
 
-    @run_periodic(1800)
-    async def _periodic_discovery(self):
-        """Run Sonos discovery at interval."""
-        self._tasks.append(self.mass.add_job(None, self.__run_discovery))
-
     def __run_discovery(self):
         """Background Sonos discovery and handler, runs in executor thread."""
         if self._discovery_running:
@@ -279,7 +269,7 @@ class SonosProvider(PlayerProvider):
         # 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:
-                self.mass.add_job(self.mass.players.remove_player(player.player_id))
+                create_task(self.mass.players.remove_player(player.player_id))
                 for sub in player.subscriptions:
                     sub.unsubscribe()
                 self._players.pop(player, None)
@@ -322,7 +312,7 @@ class SonosProvider(PlayerProvider):
         subscribe(soco_device.avTransport, self.__player_event)
         subscribe(soco_device.renderingControl, self.__player_event)
         subscribe(soco_device.zoneGroupTopology, self.__topology_changed)
-        self.mass.run_task(self.mass.players.add_player(player))
+        create_task(self.mass.players.add_player(player))
         return player
 
     def __player_event(self, player_id: str, event):
@@ -353,8 +343,8 @@ class SonosProvider(PlayerProvider):
             )
             rel_time = __timespan_secs(position_info.get("RelTime"))
             player.elapsed_time = rel_time
-            if player.state == PlaybackState.Playing:
-                self.mass.add_job(self._report_progress(player_id))
+            if player.state == PlayerState.PLAYING:
+                create_task(self._report_progress(player_id))
         player.update_state()
 
     def __process_groups(self, sonos_groups):
@@ -371,13 +361,13 @@ class SonosProvider(PlayerProvider):
             group_player.is_group_player = True
             group_player.name = group.label
             group_player.group_childs = [item.uid for item in group.members]
-            self.mass.run_task(self.mass.players.update_player(group_player))
+            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.
-        self.mass.add_job(self.__run_discovery)
+        create_task(self.__run_discovery)
 
     async def _report_progress(self, player_id: str):
         """Report current progress while playing."""
@@ -387,7 +377,7 @@ class SonosProvider(PlayerProvider):
         # so we need to send it in periodically
         player = self._players[player_id]
         player.should_poll = True
-        while player and player.state == PlaybackState.Playing:
+        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
@@ -396,15 +386,15 @@ class SonosProvider(PlayerProvider):
         self._report_progress_tasks.pop(player_id, None)
 
 
-def __convert_state(sonos_state: str) -> PlaybackState:
-    """Convert Sonos state to PlaybackState."""
+def __convert_state(sonos_state: str) -> PlayerState:
+    """Convert Sonos state to PlayerState."""
     if sonos_state == "PLAYING":
-        return PlaybackState.Playing
+        return PlayerState.PLAYING
     if sonos_state == "TRANSITIONING":
-        return PlaybackState.Playing
+        return PlayerState.PLAYING
     if sonos_state == "PAUSED_PLAYBACK":
-        return PlaybackState.Paused
-    return PlaybackState.Stopped
+        return PlayerState.PAUSED
+    return PlayerState.IDLE
 
 
 def __timespan_secs(timespan):
index 3e835b725b8453e49fea5b39a68cc2a1b42c3941..e7cd92a9d5e6700915c0177749cb30d93cb7f83d 100644 (file)
@@ -84,11 +84,11 @@ class SpotifyProvider(MusicProvider):
     def supported_mediatypes(self) -> List[MediaType]:
         """Return MediaTypes the provider supports."""
         return [
-            MediaType.Album,
-            MediaType.Artist,
-            MediaType.Playlist,
-            # MediaType.Radio, # TODO!
-            MediaType.Track,
+            MediaType.ALBUM,
+            MediaType.ARTIST,
+            MediaType.PLAYLIST,
+            # MediaType.RADIO, # TODO!
+            MediaType.TRACK,
         ]
 
     async def on_start(self) -> bool:
@@ -120,13 +120,13 @@ class SpotifyProvider(MusicProvider):
         """
         result = SearchResult()
         searchtypes = []
-        if MediaType.Artist in media_types:
+        if MediaType.ARTIST in media_types:
             searchtypes.append("artist")
-        if MediaType.Album in media_types:
+        if MediaType.ALBUM in media_types:
             searchtypes.append("album")
-        if MediaType.Track in media_types:
+        if MediaType.TRACK in media_types:
             searchtypes.append("track")
-        if MediaType.Playlist in media_types:
+        if MediaType.PLAYLIST in media_types:
             searchtypes.append("playlist")
         searchtype = ",".join(searchtypes)
         params = {"q": search_query, "type": searchtype, "limit": limit}
@@ -257,15 +257,15 @@ class SpotifyProvider(MusicProvider):
     async def library_add(self, prov_item_id, media_type: MediaType):
         """Add item to library."""
         result = False
-        if media_type == MediaType.Artist:
+        if media_type == MediaType.ARTIST:
             result = await self._put_data(
                 "me/following", {"ids": prov_item_id, "type": "artist"}
             )
-        elif media_type == MediaType.Album:
+        elif media_type == MediaType.ALBUM:
             result = await self._put_data("me/albums", {"ids": prov_item_id})
-        elif media_type == MediaType.Track:
+        elif media_type == MediaType.TRACK:
             result = await self._put_data("me/tracks", {"ids": prov_item_id})
-        elif media_type == MediaType.Playlist:
+        elif media_type == MediaType.PLAYLIST:
             result = await self._put_data(
                 f"playlists/{prov_item_id}/followers", data={"public": False}
             )
@@ -274,15 +274,15 @@ class SpotifyProvider(MusicProvider):
     async def library_remove(self, prov_item_id, media_type: MediaType):
         """Remove item from library."""
         result = False
-        if media_type == MediaType.Artist:
+        if media_type == MediaType.ARTIST:
             result = await self._delete_data(
                 "me/following", {"ids": prov_item_id, "type": "artist"}
             )
-        elif media_type == MediaType.Album:
+        elif media_type == MediaType.ALBUM:
             result = await self._delete_data("me/albums", {"ids": prov_item_id})
-        elif media_type == MediaType.Track:
+        elif media_type == MediaType.TRACK:
             result = await self._delete_data("me/tracks", {"ids": prov_item_id})
-        elif media_type == MediaType.Playlist:
+        elif media_type == MediaType.PLAYLIST:
             result = await self._delete_data(f"playlists/{prov_item_id}/followers")
         return result
 
@@ -360,11 +360,11 @@ class SpotifyProvider(MusicProvider):
             if album.artist:
                 break
         if album_obj["album_type"] == "single":
-            album.album_type = AlbumType.Single
+            album.album_type = AlbumType.SINGLE
         elif album_obj["album_type"] == "compilation":
-            album.album_type = AlbumType.Compilation
+            album.album_type = AlbumType.COMPILATION
         elif album_obj["album_type"] == "album":
-            album.album_type = AlbumType.Album
+            album.album_type = AlbumType.ALBUM
         if "genres" in album_obj:
             album.metadata["genres"] = album_obj["genres"]
         if album_obj.get("images"):
index 00144cb45d4ad89f7c4ad09c4935c728e7adf03d..758125d6d8f4f17817aa390993502a8f061bec03 100644 (file)
@@ -6,14 +6,9 @@ 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
+from music_assistant.helpers.util import callback, create_task
 from music_assistant.models.config_entry import ConfigEntry
-from music_assistant.models.player import (
-    DeviceInfo,
-    PlaybackState,
-    Player,
-    PlayerFeature,
-)
+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
 
@@ -60,18 +55,10 @@ class PySqueezeProvider(PlayerProvider):
     async def on_start(self) -> bool:
         """Handle initialization of the provider. Called on startup."""
         # start slimproto server
-        self._tasks.append(
-            self.mass.add_job(
-                asyncio.start_server(self._client_connected, "0.0.0.0", 3483)
-            )
-        )
-        # setup discovery
-        self._tasks.append(self.mass.add_job(self.start_discovery()))
+        create_task(asyncio.start_server(self._client_connected, "0.0.0.0", 3483))
 
-    async def on_stop(self):
-        """Handle correct close/cleanup of the provider on exit."""
-        for task in self._tasks:
-            task.cancel()
+        # setup discovery
+        create_task(self.start_discovery())
 
     async def start_discovery(self):
         """Start discovery for players."""
@@ -171,7 +158,7 @@ class SqueezePlayer(Player):
     @property
     def state(self):
         """Return current state of player."""
-        return PlaybackState(self.socket_client.state)
+        return PlayerState(self.socket_client.state)
 
     @property
     def elapsed_time(self):
@@ -253,7 +240,7 @@ class SqueezePlayer(Player):
         if queue:
             new_track = queue.get_item(queue.cur_index + 1)
             if new_track:
-                return await self.cmd_play_uri(new_track.uri)
+                return await self.cmd_play_uri(new_track.stream_url)
 
     async def cmd_previous(self):
         """Send PREVIOUS TRACK command to player."""
@@ -261,7 +248,7 @@ class SqueezePlayer(Player):
         if queue:
             new_track = queue.get_item(queue.cur_index - 1)
             if new_track:
-                return await self.cmd_play_uri(new_track.uri)
+                return await self.cmd_play_uri(new_track.stream_url)
 
     async def cmd_queue_play_index(self, index: int):
         """
@@ -273,7 +260,7 @@ class SqueezePlayer(Player):
         if queue:
             new_track = queue.get_item(index)
             if new_track:
-                return await self.cmd_play_uri(new_track.uri)
+                return await self.cmd_play_uri(new_track.stream_url)
 
     async def cmd_queue_load(self, queue_items: List[QueueItem]):
         """
@@ -282,8 +269,8 @@ class SqueezePlayer(Player):
             :param queue_items: a list of QueueItems
         """
         if queue_items:
-            await self.cmd_play_uri(queue_items[0].uri)
-            return await self.cmd_play_uri(queue_items[0].uri)
+            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
@@ -335,7 +322,7 @@ class SqueezePlayer(Player):
         """Process incoming event from the socket client."""
         if event == SqueezeEvent.CONNECTED:
             # restore previous power/volume
-            self.mass.add_job(self.restore_states())
+            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)
@@ -345,9 +332,9 @@ class SqueezePlayer(Player):
                     crossfade = self.mass.config.player_settings[self.player_id][
                         CONF_CROSSFADE_DURATION
                     ]
-                    self.mass.add_job(
+                    create_task(
                         self.socket_client.play_uri(
-                            next_item.uri,
+                            next_item.stream_url,
                             send_flush=False,
                             crossfade_duration=crossfade,
                         )
index 5fe3b99a314098981c5aa701a8f0a91b01500867..f5be8ac43b451826dcbbeb682bac4b7490b348f3 100644 (file)
@@ -9,7 +9,7 @@ from enum import Enum
 from typing import Callable
 
 from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.helpers.util import run_periodic
+from music_assistant.helpers.util import create_task, run_periodic
 
 from .constants import PROV_ID
 
@@ -32,7 +32,7 @@ DEVICE_TYPE = {
 }
 
 STATE_PLAYING = "playing"
-STATE_STOPPED = "stopped"
+STATE_IDLE = "idle"
 STATE_PAUSED = "paused"
 
 
@@ -67,15 +67,15 @@ class SqueezeSocketClient:
         self._volume_control = PySqueezeVolume()
         self._powered = False
         self._muted = False
-        self._state = STATE_STOPPED
+        self._state = STATE_IDLE
         self._elapsed_seconds = 0
         self._elapsed_milliseconds = 0
         self._current_uri = ""
         self._connected = True
         self._event_callbacks = []
         self._tasks = [
-            asyncio.create_task(self._socket_reader()),
-            asyncio.create_task(self._send_heartbeat()),
+            create_task(self._socket_reader()),
+            create_task(self._send_heartbeat()),
         ]
 
     def disconnect(self) -> None:
@@ -172,18 +172,15 @@ class SqueezeSocketClient:
 
     async def cmd_stop(self):
         """Send stop command to player."""
-        data = self.__pack_stream(b"q", autostart=b"0", flags=0)
-        await self._send_frame(b"strm", data)
+        await self.send_strm(b"q")
 
     async def cmd_play(self):
         """Send play (unpause) command to player."""
-        data = self.__pack_stream(b"u", autostart=b"0", flags=0)
-        await self._send_frame(b"strm", data)
+        await self.send_strm(b"u")
 
     async def cmd_pause(self):
         """Send pause command to player."""
-        data = self.__pack_stream(b"p", autostart=b"0", flags=0)
-        await self._send_frame(b"strm", data)
+        await self.send_strm(b"p")
 
     async def cmd_power(self, powered: bool = True):
         """Send power command to player."""
@@ -215,25 +212,15 @@ class SqueezeSocketClient:
     ):
         """Request player to start playing a single uri."""
         if send_flush:
-            data = self.__pack_stream(b"f", autostart=b"0", flags=0)
-            await self._send_frame(b"strm", data)
+            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"
+        autostart = b"0"
         trans_type = b"1" if enable_crossfade else b"0"
-        formatbyte = b"f"  # fixed to flac
         uri = "/stream" + uri.split("/stream")[1]
-        data = self.__pack_stream(
-            command,
-            autostart=autostart,
-            flags=0x00,
-            formatbyte=formatbyte,
-            trans_type=trans_type,
-            trans_duration=crossfade_duration,
-        )
         # extract host and port from uri
         regex = "(?:http.*://)?(?P<host>[^:/ ]+).?(?P<port>[0-9]*).*"
         regex_result = re.search(regex, uri)
@@ -244,9 +231,14 @@ class SqueezeSocketClient:
         elif not port:
             port = 80
         headers = f"Connection: close\r\nAccept: */*\r\nHost: {host}:{port}\r\n"
-        request = "GET %s HTTP/1.1\r\n%s\r\n" % (uri, headers)
-        data = data + request.encode("utf-8")
-        await self._send_frame(b"strm", data)
+        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):
@@ -254,8 +246,7 @@ class SqueezeSocketClient:
         if not self._connected:
             return
         timestamp = int(time.time())
-        data = self.__pack_stream(b"t", replay_gain=timestamp, flags=0)
-        await self._send_frame(b"strm", data)
+        await self.send_strm(b"t", replay_gain=timestamp, flags=0)
 
     async def _send_frame(self, command, data):
         """Send command to Squeeze player."""
@@ -297,29 +288,36 @@ class SqueezeSocketClient:
         self._connected = False
         self.signal_event(SqueezeEvent.DISCONNECTED)
 
-    @staticmethod
-    def __pack_stream(
-        command,
-        autostart=b"1",
-        formatbyte=b"o",
-        pcmargs=(b"?", b"?", b"?", b"?"),
-        threshold=200,
+    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,
+        flags=0x00,
         output_threshold=0,
         replay_gain=0,
         server_port=8095,
         server_ip=0,
+        httpreq=b"",
     ):
         """Create stream request message based on given arguments."""
-        return struct.pack(
+        data = struct.pack(
             "!cccccccBcBcBBBLHL",
             command,
             autostart,
             formatbyte,
-            *pcmargs,
+            samplesize,
+            samplerate,
+            channels,
+            endian,
             threshold,
             spdif,
             trans_duration,
@@ -331,6 +329,7 @@ class SqueezeSocketClient:
             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)."""
@@ -341,7 +340,7 @@ class SqueezeSocketClient:
         self._player_id = str(device_mac).lower()
         self._device_type = DEVICE_TYPE.get(dev_id, "unknown device")
         LOGGER.debug("Player connected: %s", self.name)
-        asyncio.create_task(self._initialize_player())
+        create_task(self._initialize_player())
         self.signal_event(SqueezeEvent.CONNECTED)
 
     def _process_stat(self, data):
@@ -355,7 +354,7 @@ class SqueezeSocketClient:
         if event_handler is None:
             LOGGER.debug("Unhandled event: %s - event_data: %s", event, event_data)
         else:
-            self.mass.add_job(event_handler, data[4:])
+            create_task(event_handler, data[4:])
 
     def _process_stat_aude(self, data):
         """Process incoming stat AUDe message (power level and mute)."""
@@ -374,14 +373,14 @@ class SqueezeSocketClient:
     def _process_stat_stmd(self, data):
         """Process incoming stat STMd message (decoder ready)."""
         # pylint: disable=unused-argument
-        LOGGER.debug("STMu received - Decoder Ready for next track.")
+        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_STOPPED
+        self._state = STATE_IDLE
         self._elapsed_milliseconds = 0
         self._elapsed_seconds = 0
         self.signal_event(SqueezeEvent.STATE_UPDATED)
@@ -394,7 +393,7 @@ class SqueezeSocketClient:
         No more decoded (uncompressed) data to play; triggers rebuffering.
         """
         # pylint: disable=unused-argument
-        LOGGER.debug("STMo received - output underrun.")
+        LOGGER.warning("STMo received - output underrun.")
 
     def _process_stat_stmp(self, data):
         """Process incoming stat STMp message: Pause confirmed."""
@@ -436,7 +435,7 @@ class SqueezeSocketClient:
             elapsed_seconds,
             voltage,
             elapsed_milliseconds,
-            server_timestamp,
+            timestamp,
             error_code,
         ) = struct.unpack("!BBBLLLLHLLLLHLLH", data)
         if self.state == STATE_PLAYING:
@@ -451,14 +450,26 @@ class SqueezeSocketClient:
         """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_STOPPED
+        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.")
+        # 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."""
-        # pylint: disable=unused-argument
-        # send continue
-        asyncio.create_task(self._send_frame(b"cont", b"0"))
+        LOGGER.debug("RESP received - Response received at player.")
 
     def _process_setd(self, data):
         """Process incoming SETD message: Get/set player firmware settings."""
index dd3307312e4a35b23295cbf4f9d324ed95cec9a9..821d4b3edfddd6ae5644f1201b78f664236a8541 100644 (file)
@@ -66,7 +66,7 @@ class TuneInProvider(MusicProvider):
     @property
     def supported_mediatypes(self) -> List[MediaType]:
         """Return MediaTypes the provider supports."""
-        return [MediaType.Radio]
+        return [MediaType.RADIO]
 
     async def on_start(self) -> bool:
         """Handle initialization of the provider based on config."""
@@ -176,6 +176,7 @@ class TuneInProvider(MusicProvider):
                     content_type=ContentType(stream["media_type"]),
                     sample_rate=44100,
                     bit_depth=16,
+                    media_type=MediaType.RADIO,
                     details=stream,
                 )
         return None
index c8ce97ffd15c5b07acd0250d8c69503fd531f1a8..5c5f2dfb08d0934eceee6b648edcb95a663e766b 100644 (file)
@@ -5,8 +5,9 @@ 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
-from music_assistant.models.player import DeviceInfo, PlaybackState, Player
+from music_assistant.models.player import DeviceInfo, Player, PlayerState
 from music_assistant.models.provider import PlayerProvider
 
 PROV_ID = "universal_group"
@@ -57,7 +58,7 @@ class GroupPlayerProvider(PlayerProvider):
         conf = self.mass.config.player_providers[PROV_ID]
         for index in range(conf[CONF_PLAYER_COUNT]):
             player = GroupPlayer(self.mass, index)
-            self.mass.add_job(self.mass.players.add_player(player))
+            await self.mass.players.add_player(player)
         return True
 
     async def on_stop(self):
@@ -78,7 +79,7 @@ class GroupPlayer(Player):
         self._provider_id = PROV_ID
         self._name = f"{PROV_NAME} {player_index}"
         self._powered = False
-        self._state = PlaybackState.Stopped
+        self._state = PlayerState.IDLE
         self._available = True
         self._current_uri = ""
         self._volume_level = 0
@@ -110,8 +111,8 @@ class GroupPlayer(Player):
         return self._powered
 
     @property
-    def state(self) -> PlaybackState:
-        """Return current PlaybackState of player."""
+    def state(self) -> PlayerState:
+        """Return current PlayerState of player."""
         return self._state
 
     @property
@@ -137,7 +138,7 @@ class GroupPlayer(Player):
     @property
     def elapsed_time(self):
         """Return elapsed time for first child player."""
-        if self.state in [PlaybackState.Playing, PlaybackState.Paused]:
+        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:
@@ -234,7 +235,7 @@ class GroupPlayer(Player):
         """Play the specified uri/url on the player."""
         await self.cmd_stop()
         self._current_uri = uri
-        self._state = PlaybackState.Playing
+        self._state = PlayerState.PLAYING
         self._powered = True
         # forward this command to each child player
         # TODO: Only start playing on powered players ?
@@ -245,11 +246,11 @@ class GroupPlayer(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 = self.mass.add_job(self.queue_stream_task())
+        self.stream_task = create_task(self.queue_stream_task())
 
     async def cmd_stop(self) -> None:
         """Send STOP command to player."""
-        self._state = PlaybackState.Stopped
+        self._state = PlayerState.IDLE
         if self.stream_task:
             # cancel existing stream task if any
             self.stream_task.cancel()
@@ -267,14 +268,14 @@ class GroupPlayer(Player):
 
     async def cmd_play(self) -> None:
         """Send PLAY command to player."""
-        if not self.state == PlaybackState.Paused:
+        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 = PlaybackState.Playing
+        self._state = PlayerState.PLAYING
         self.update_state()
 
     async def cmd_pause(self):
@@ -284,7 +285,7 @@ class GroupPlayer(Player):
             child_player = self.mass.players.get_player(child_player_id)
             if child_player:
                 await child_player.cmd_pause()
-        self._state = PlaybackState.Paused
+        self._state = PlayerState.PAUSED
         self.update_state()
 
     async def cmd_power_on(self) -> None:
@@ -361,7 +362,7 @@ class GroupPlayer(Player):
         LOGGER.debug(
             "start queue stream with %s connected clients", len(self.connected_clients)
         )
-        self.sync_task = asyncio.create_task(self.__synchronize_players())
+        self.sync_task = create_task(self.__synchronize_players())
 
         async for audio_chunk in self.mass.streams.queue_stream_flac(self.player_id):
 
@@ -373,7 +374,7 @@ class GroupPlayer(Player):
             # send the audio chunk to all connected players
             tasks = []
             for _queue in self.connected_clients.values():
-                tasks.append(self.mass.add_job(_queue.put(audio_chunk)))
+                tasks.append(create_task(_queue.put(audio_chunk)))
             # wait for clients to consume the data
             await asyncio.wait(tasks)
 
@@ -397,7 +398,7 @@ class GroupPlayer(Player):
         )
 
         # wait until master is playing
-        while master_player.state != PlaybackState.Playing:
+        while master_player.state != PlayerState.PLAYING:
             await asyncio.sleep(0.1)
         await asyncio.sleep(0.5)
 
@@ -417,7 +418,7 @@ class GroupPlayer(Player):
 
                 if (
                     not child_player
-                    or child_player.state != PlaybackState.Playing
+                    or child_player.state != PlayerState.PLAYING
                     or child_player.elapsed_milliseconds is None
                 ):
                     continue
@@ -451,7 +452,7 @@ class GroupPlayer(Player):
                         if avg_lag > 20:
                             sleep_time = avg_lag - 20
                             await asyncio.sleep(sleep_time / 1000)
-                        asyncio.create_task(master_player.cmd_play())
+                        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)
old mode 100644 (file)
new mode 100755 (executable)
index e6e244e..006bdb5
@@ -1 +1,269 @@
-"""Webserver and API handlers/logic."""
+"""
+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 API connect address for this Music Assistant instance."""
+        return f"ws://{self.hostname}:{self.port}/ws"
+
+    @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
new file mode 100644 (file)
index 0000000..92196a7
--- /dev/null
@@ -0,0 +1,240 @@
+"""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.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("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."""
+    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/server.py b/music_assistant/web/server.py
deleted file mode 100755 (executable)
index ef02fa3..0000000
+++ /dev/null
@@ -1,398 +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 asyncio
-import datetime
-import logging
-import os
-import uuid
-from base64 import b64encode
-from typing import Any, Awaitable, Optional, Union
-
-import aiohttp_cors
-import jwt
-import ujson
-from aiohttp import WSMsgType, web
-from aiohttp.web import WebSocketResponse
-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 import repath
-from music_assistant.helpers.encryption import decrypt_string
-from music_assistant.helpers.images import get_image_url, 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 api_route, json_serializer, parse_arguments
-from music_assistant.models.media_types import ItemMapping, MediaItem
-
-from .json_rpc import json_rpc_endpoint
-from .streams import routes as stream_routes
-
-LOGGER = logging.getLogger("webserver")
-
-
-class WebServer:
-    """Webserver and json/websocket api."""
-
-    def __init__(self, mass: MusicAssistant, port: int):
-        """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 = {}
-
-    async def setup(self):
-        """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["clients"] = []
-        # add all routes
-        self.app.add_routes(stream_routes)
-        self.app.router.add_route("*", "/jsonrpc.js", json_rpc_endpoint)
-        self.app.router.add_get("/ws", self._websocket_handler)
-
-        # register all methods decorated as api_route
-        for cls in [
-            self,
-            self.mass.music,
-            self.mass.players,
-            self.mass.config,
-            self.mass.library,
-        ]:
-            self.register_api_routes(cls)
-
-        # 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))
-        # 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)
-        else:
-            self.app.router.add_get("/", self.info)
-
-        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()
-        LOGGER.info("Started Music Assistant server on port %s", self.port)
-        self.mass.add_event_listener(self._handle_mass_events)
-
-    async def stop(self):
-        """Stop the webserver."""
-        for ws_client in self.app["clients"]:
-            await ws_client.close(message=b"server shutdown")
-
-    def register_api_route(self, cmd: str, func: Awaitable):
-        """Register a command(handler) to the websocket api."""
-        pattern = repath.path_to_pattern(cmd)
-        self.api_routes[pattern] = func
-
-    def register_api_routes(self, cls: Any):
-        """Register all methods of a class (instance) that are decorated with api_route."""
-        for item in dir(cls):
-            func = getattr(cls, item)
-            if not hasattr(func, "ws_cmd_path"):
-                continue
-            # method is decorated with our api decorator
-            self.register_api_route(func.ws_cmd_path, func)
-
-    @property
-    def hostname(self):
-        """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):
-        """Return the local IP(v4) address for this Music Assistant instance."""
-        return self._ip_address
-
-    @property
-    def port(self):
-        """Return the port for this Music Assistant instance."""
-        return self._port
-
-    @property
-    def stream_url(self):
-        """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):
-        """Return the API connect address for this Music Assistant instance."""
-        return f"ws://{self.hostname}:{self.port}/ws"
-
-    @property
-    def server_id(self):
-        """Return the device ID for this Music Assistant Server."""
-        return self.mass.config.stored_config["server_id"]
-
-    @property
-    def discovery_info(self):
-        """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):
-        """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)
-
-    @api_route("info", False)
-    async def info(self, request: web.Request = None):
-        """Return discovery info on index page."""
-        if request:
-            return web.json_response(self.discovery_info)
-        return self.discovery_info
-
-    @api_route("revoke_token")
-    async def revoke_token(self, client_id: str):
-        """Revoke token for client."""
-        return self.mass.config.security.revoke_app_token(client_id)
-
-    @api_route("get_token", False)
-    async def get_token(self, username: str, password: str, app_id: str = "") -> dict:
-        """
-        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.
-        """
-        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"] = (
-                    datetime.datetime.utcnow() + datetime.timedelta(days=365 * 10)
-                ).timestamp()
-            else:
-                token_info["exp"] = (
-                    datetime.datetime.utcnow() + datetime.timedelta(hours=8)
-                ).timestamp()
-            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 token_info
-        raise AuthenticationError("Invalid credentials")
-
-    @api_route("setup", False)
-    async def create_user_setup(self, username: str, password: str):
-        """Handle first-time server setup through onboarding wizard."""
-        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 True
-
-    @api_route("images/thumb")
-    async def get_image_thumb(
-        self,
-        size: int,
-        url: Optional[str] = "",
-        item: Union[None, ItemMapping, MediaItem] = None,
-    ):
-        """Get (resized) thumb image for given URL or media item as base64 encoded string."""
-        if not url and item:
-            url = await get_image_url(
-                self.mass, item.item_id, item.provider, item.media_type
-            )
-        if url:
-            img_file = await get_thumb_file(self.mass, url, size)
-            if img_file:
-                with open(img_file, "rb") as _file:
-                    icon_data = _file.read()
-                    icon_data = b64encode(icon_data)
-                    return "data:image/png;base64," + icon_data.decode()
-        raise KeyError("Invalid item or url")
-
-    @api_route("images/provider-icons/:provider_id?")
-    async def get_provider_icon(self, provider_id: Optional[str]):
-        """Get Provider icon as base64 encoded string."""
-        if not provider_id:
-            return {
-                prov.id: await self.get_provider_icon(prov.id)
-                for prov in self.mass.get_providers(include_unavailable=True)
-            }
-        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):
-            with open(icon_path, "rb") as _file:
-                icon_data = _file.read()
-                icon_data = b64encode(icon_data)
-                return "data:image/png;base64," + icon_data.decode()
-        raise KeyError("Invalid provider: %s" % provider_id)
-
-    async def _websocket_handler(self, request: web.Request):
-        """Handle websocket client."""
-
-        ws_client = WebSocketResponse()
-        ws_client.authenticated = False
-        await ws_client.prepare(request)
-        request.app["clients"].append(ws_client)
-
-        # handle incoming messages
-        async for msg in ws_client:
-            try:
-                if msg.type == WSMsgType.error:
-                    LOGGER.warning(
-                        "ws connection closed with exception %s", ws_client.exception()
-                    )
-                if msg.type != WSMsgType.text:
-                    continue
-                if msg.data == "close":
-                    await ws_client.close()
-                    break
-                # regular message
-                json_msg = msg.json(loads=ujson.loads)
-                if "command" in json_msg and "data" in json_msg:
-                    # handle command
-                    await self._handle_command(
-                        ws_client,
-                        json_msg["command"],
-                        json_msg["data"],
-                        json_msg.get("id"),
-                    )
-                elif "event" in json_msg:
-                    # handle event
-                    await self._handle_event(
-                        ws_client, json_msg["event"], json_msg.get("data")
-                    )
-            except AuthenticationError as exc:  # pylint:disable=broad-except
-                # disconnect client on auth errors
-                await self._send_json(ws_client, error=str(exc), **json_msg)
-                await ws_client.close(message=str(exc).encode())
-            except Exception as exc:  # pylint:disable=broad-except
-                # log the error only
-                await self._send_json(ws_client, error=str(exc), **json_msg)
-                LOGGER.error("Error with WS client", exc_info=exc)
-
-        # websocket disconnected
-        request.app["clients"].remove(ws_client)
-        LOGGER.debug("websocket connection closed: %s", request.remote)
-
-        return ws_client
-
-    async def _handle_command(
-        self,
-        ws_client: WebSocketResponse,
-        command: str,
-        data: Optional[dict],
-        msg_id: Any = None,
-    ):
-        """Handle websocket command."""
-        res = None
-        if command == "auth":
-            return await self._handle_auth(ws_client, data)
-        # work out handler for the given path/command
-        for key in self.api_routes:
-            match = repath.match_pattern(key, command)
-            if match:
-                params = match.groupdict()
-                handler = self.api_routes[key]
-                # check authentication
-                if (
-                    getattr(handler, "ws_require_auth", True)
-                    and not ws_client.authenticated
-                ):
-                    raise AuthenticationError("Not authenticated")
-                if not data:
-                    data = {}
-                params = parse_arguments(handler, {**params, **data})
-                res = handler(**params)
-                if asyncio.iscoroutine(res):
-                    res = await res
-                # return result of command to client
-                return await self._send_json(
-                    ws_client, id=msg_id, result=command, data=res
-                )
-        raise KeyError("Unknown command")
-
-    async def _handle_event(self, ws_client: WebSocketResponse, event: str, data: Any):
-        """Handle event message from ws client."""
-        LOGGER.info("received event %s", event)
-        if ws_client.authenticated:
-            self.mass.signal_event(event, data)
-
-    async def _handle_auth(self, ws_client: WebSocketResponse, token: str):
-        """Handle authentication with JWT token."""
-        token_info = jwt.decode(token, self.mass.web.jwt_key, algorithms=["HS256"])
-        if self.mass.config.security.is_token_revoked(token_info):
-            raise AuthenticationError("Token is revoked")
-        ws_client.authenticated = True
-        self.mass.config.security.set_last_login(token_info["client_id"])
-        # TODO: store token/app_id on ws_client obj and periodiclaly check if token is expired or revoked
-        await self._send_json(ws_client, result="auth", data=token_info)
-
-    async def _send_json(self, ws_client: WebSocketResponse, **kwargs):
-        """Send message (back) to websocket client."""
-        await ws_client.send_str(json_serializer(kwargs))
-
-    async def _handle_mass_events(self, event: str, event_data: Any):
-        """Broadcast events to connected clients."""
-        for ws_client in self.app["clients"]:
-            if not ws_client.authenticated:
-                continue
-            try:
-                await self._send_json(ws_client, event=event, data=event_data)
-            except ConnectionResetError:
-                # client is already disconnected
-                self.app["clients"].remove(ws_client)
-            except Exception as exc:  # pylint: disable=broad-except
-                # log errors and continue sending to all other clients
-                LOGGER.debug("Error while sending message to api client", exc_info=exc)
-
-
-class AuthenticationError(Exception):
-    """Custom Exception for all authentication errors."""
diff --git a/music_assistant/web/stream.py b/music_assistant/web/stream.py
new file mode 100644 (file)
index 0000000..963eeac
--- /dev/null
@@ -0,0 +1,433 @@
+"""
+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_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, StreamType
+
+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")
+    LOGGER.info("Start Queue Stream for player %s ", player_queue.player.name)
+
+    # prepare request
+    resp = StreamResponse(
+        status=200, reason="OK", headers={"Content-Type": "audio/flac"}
+    )
+    await resp.prepare(request)
+
+    player_conf = player_queue.player.config
+    sample_rate = min(player_conf.get(CONF_MAX_SAMPLE_RATE, 96000), 96000)
+
+    args = [
+        "sox",
+        "-t",
+        "s32",
+        "-c",
+        "2",
+        "-r",
+        str(sample_rate),
+        "-",
+        "-t",
+        "flac",
+        "-",
+    ]
+    async with AsyncProcess(args, enable_write=True) as sox_proc:
+
+        # feed stdin with pcm samples
+        async def fill_buffer():
+            """Feed audio data into sox stdin for processing."""
+            async for audio_chunk in get_queue_stream(
+                mass, player_queue, sample_rate, 32
+            ):
+                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 and not request.query.get("alert"):
+        # 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):
+        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."""
+    input_format = streamdetails.content_type.value
+    stream_path = streamdetails.path
+    stream_type = StreamType(streamdetails.type)
+    if output_format is None:
+        output_format = ContentType.FLAC
+
+    # collect all args for sox/ffmpeg
+    if output_format in [
+        ContentType.S24,
+        ContentType.S32,
+        ContentType.S64,
+    ]:
+        output_args = ["-t", output_format.value, "-c", "2", "-"]
+    elif output_format == ContentType.FLAC:
+        output_args = ["-t", output_format.value] + ["-C", "0", "-"]
+    else:
+        output_args = ["-t", output_format.value, "-"]
+
+    # stream from URL or file
+    if stream_type in [StreamType.URL, StreamType.FILE]:
+        # input_args = ["sox", "-t", input_format, stream_path]
+        input_args = ["sox", stream_path]
+    # stream from executable
+    else:
+        input_args = [stream_path, "|", "sox", "-t", input_format, "-"]
+
+    filter_args = []
+    if streamdetails.gain_correct:
+        filter_args += ["vol", str(streamdetails.gain_correct), "dB"]
+    if resample:
+        filter_args += ["rate", "-v", str(resample)]
+
+    if streamdetails.content_type in [ContentType.AAC, ContentType.MPEG]:
+        # use ffmpeg for processing radio streams
+        args = [
+            "ffmpeg",
+            "-hide_banner",
+            "-loglevel",
+            "error",
+            "-i",
+            stream_path,
+            "-filter:a",
+            "volume=%sdB" % streamdetails.gain_correct,
+            "-f",
+            "flac",
+            "-",
+        ]
+    else:
+        # regular sox processing
+        args = input_args + output_args + filter_args
+
+    # signal start of stream event
+    mass.eventbus.signal(EVENT_STREAM_STARTED, streamdetails)
+    LOGGER.debug(
+        "start media stream for: %s/%s (%s)",
+        streamdetails.provider,
+        streamdetails.item_id,
+        streamdetails.type,
+    )
+
+    async with AsyncProcess(args) as sox_proc:
+
+        # 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_queue_stream(
+    mass: MusicAssistant, player_queue: PlayerQueue, sample_rate=96000, bit_depth=32
+) -> 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
+    pcm_args = ["s32", "-c", "2", "-r", str(sample_rate)]
+    sample_size = int(sample_rate * (bit_depth / 8) * 2)  # 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,
+            ContentType.S32,
+            resample=sample_rate,
+            chunk_size=buffer_size,
+        ):
+            cur_chunk += 1
+
+            # HANDLE FIRST PART OF TRACK
+            if not chunk and bytes_written == 0:
+                # stream error: got empy first chunk
+                LOGGER.error("Stream error on track %s", queue_track.item_id)
+                # prevent player queue get stuck by just skipping to the next track
+                queue_track.duration = 0
+                continue
+            if cur_chunk <= 2 and not last_fadeout_data:
+                # no fadeout_part available so just pass it to the output directly
+                yield chunk
+                bytes_written += len(chunk)
+                del chunk
+            elif cur_chunk == 1 and last_fadeout_data:
+                prev_chunk = chunk
+                del chunk
+            # HANDLE CROSSFADE OF PREVIOUS TRACK FADE_OUT AND THIS TRACK FADE_IN
+            elif cur_chunk == 2 and last_fadeout_data:
+                # combine the first 2 chunks and strip off silence
+                first_part = await strip_silence(prev_chunk + chunk, pcm_args)
+                if len(first_part) < buffer_size:
+                    # part is too short after the strip action?!
+                    # so we just use the full first part
+                    first_part = prev_chunk + chunk
+                fade_in_part = first_part[:buffer_size]
+                remaining_bytes = first_part[buffer_size:]
+                del first_part
+                # do crossfade
+                crossfade_part = await crossfade_pcm_parts(
+                    fade_in_part, last_fadeout_data, pcm_args, fade_length
+                )
+                # send crossfade_part
+                yield crossfade_part
+                bytes_written += len(crossfade_part)
+                del crossfade_part
+                del fade_in_part
+                last_fadeout_data = b""
+                # also write the leftover bytes from the strip action
+                yield remaining_bytes
+                bytes_written += len(remaining_bytes)
+                del remaining_bytes
+                del chunk
+                prev_chunk = None  # needed to prevent this chunk being sent again
+            # HANDLE LAST PART OF TRACK
+            elif prev_chunk and is_last_chunk:
+                # last chunk received so create the last_part
+                # with the previous chunk and this chunk
+                # and strip off silence
+                last_part = await strip_silence(prev_chunk + chunk, pcm_args, True)
+                if len(last_part) < buffer_size:
+                    # part is too short after the strip action
+                    # so we just use the entire original data
+                    last_part = prev_chunk + chunk
+                if not player_queue.crossfade_enabled or len(last_part) < buffer_size:
+                    # crossfading is not enabled or not enough data,
+                    # so just pass the (stripped) audio data
+                    if not player_queue.crossfade_enabled:
+                        LOGGER.warning(
+                            "Not enough data for crossfade: %s", len(last_part)
+                        )
+
+                    yield last_part
+                    bytes_written += len(last_part)
+                    del last_part
+                    del chunk
+                else:
+                    # handle crossfading support
+                    # store fade section to be picked up for next track
+                    last_fadeout_data = last_part[-buffer_size:]
+                    remaining_bytes = last_part[:-buffer_size]
+                    # write remaining bytes
+                    if remaining_bytes:
+                        yield remaining_bytes
+                        bytes_written += len(remaining_bytes)
+                    del last_part
+                    del remaining_bytes
+                    del chunk
+            # MIDDLE PARTS OF TRACK
+            else:
+                # middle part of the track
+                # keep previous chunk in memory so we have enough
+                # samples to perform the crossfade
+                if prev_chunk:
+                    yield prev_chunk
+                    bytes_written += len(prev_chunk)
+                    prev_chunk = chunk
+                else:
+                    prev_chunk = chunk
+                del chunk
+        # end of the track reached
+        # update actual duration to the queue for more accurate now playing info
+        accurate_duration = bytes_written / sample_size
+        queue_track.duration = accurate_duration
+        LOGGER.debug(
+            "Finished Streaming queue track: %s (%s) on queue %s",
+            queue_track.item_id,
+            queue_track.name,
+            player_queue.player.name,
+        )
+    # end of queue reached, pass last fadeout bits to final output
+    if last_fadeout_data:
+        yield last_fadeout_data
+    del last_fadeout_data
+    # END OF QUEUE STREAM
diff --git a/music_assistant/web/streams.py b/music_assistant/web/streams.py
deleted file mode 100644 (file)
index 76e6cb2..0000000
+++ /dev/null
@@ -1,95 +0,0 @@
-"""Audio streaming endpoints."""
-
-from aiohttp.web import Request, Response, RouteTableDef, StreamResponse
-from music_assistant.helpers.web import require_local_subnet
-from music_assistant.models.media_types import MediaType
-
-routes = RouteTableDef()
-
-
-@routes.get("/stream/media/{media_type}/{item_id}")
-async def stream_media(request: Request):
-    """Stream a single audio track."""
-    media_type = MediaType(request.match_info["media_type"])
-    if media_type not in [MediaType.Track, MediaType.Radio]:
-        return Response(status=404, reason="Media item is not playable!")
-    item_id = request.match_info["item_id"]
-    provider = request.rel_url.query.get("provider", "database")
-    media_item = await request.app["mass"].music.get_item(item_id, provider, media_type)
-    streamdetails = await request.app["mass"].music.get_stream_details(media_item)
-
-    # prepare request
-    content_type = streamdetails.content_type.value
-    resp = StreamResponse(
-        status=200, reason="OK", headers={"Content-Type": f"audio/{content_type}"}
-    )
-    await resp.prepare(request)
-
-    # stream track
-    async for audio_chunk in request.app["mass"].streams.get_media_stream(
-        streamdetails
-    ):
-        await resp.write(audio_chunk)
-    return resp
-
-
-@routes.get("/stream/queue/{player_id}")
-@require_local_subnet
-async def stream_queue(request: Request):
-    """Stream a player's queue."""
-    player_id = request.match_info["player_id"]
-    if not request.app["mass"].players.get_player_queue(player_id):
-        return Response(text="invalid queue", status=404)
-
-    # prepare request
-    resp = StreamResponse(
-        status=200, reason="OK", headers={"Content-Type": "audio/flac"}
-    )
-    await resp.prepare(request)
-
-    # stream queue
-    async for audio_chunk in request.app["mass"].streams.queue_stream_flac(player_id):
-        await resp.write(audio_chunk)
-    return resp
-
-
-@routes.get("/stream/queue/{player_id}/{queue_item_id}")
-@require_local_subnet
-async def stream_queue_item(request: Request):
-    """Stream a single queue item."""
-    player_id = request.match_info["player_id"]
-    queue_item_id = request.match_info["queue_item_id"]
-
-    # prepare request
-    resp = StreamResponse(
-        status=200, reason="OK", headers={"Content-Type": "audio/flac"}
-    )
-    await resp.prepare(request)
-
-    async for audio_chunk in request.app["mass"].streams.stream_queue_item(
-        player_id, queue_item_id
-    ):
-        await resp.write(audio_chunk)
-    return resp
-
-
-@routes.get("/stream/group/{group_player_id}")
-@require_local_subnet
-async def stream_group(request: 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