bugfixes
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sat, 28 Nov 2020 17:27:29 +0000 (18:27 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Sat, 28 Nov 2020 17:27:29 +0000 (18:27 +0100)
18 files changed:
music_assistant/constants.py
music_assistant/helpers/compare.py
music_assistant/helpers/images.py
music_assistant/helpers/migration.py
music_assistant/helpers/process.py
music_assistant/helpers/util.py
music_assistant/managers/database.py
music_assistant/managers/library.py
music_assistant/managers/metadata.py
music_assistant/managers/music.py
music_assistant/managers/streams.py
music_assistant/models/media_types.py
music_assistant/models/player_queue.py
music_assistant/models/streamdetails.py
music_assistant/providers/chromecast/player.py
music_assistant/providers/qobuz/__init__.py
music_assistant/providers/spotify/__init__.py
music_assistant/web/__init__.py

index c4f10c7fd566e2c0f9a4cb2dfdcc7e69fd9920ff..4691ef93f531d811d586e2712dd114d16addba72 100755 (executable)
@@ -1,6 +1,6 @@
 """All constants for Music Assistant."""
 
-__version__ = "0.0.69"
+__version__ = "0.0.70"
 REQUIRED_PYTHON_VER = "3.7"
 
 # configuration keys/attributes
@@ -51,6 +51,11 @@ EVENT_QUEUE_TIME_UPDATED = "queue time updated"
 EVENT_SHUTDOWN = "application shutdown"
 EVENT_PROVIDER_REGISTERED = "provider registered"
 EVENT_PROVIDER_UNREGISTERED = "provider unregistered"
+EVENT_ARTIST_ADDED = "artist added"
+EVENT_ALBUM_ADDED = "album added"
+EVENT_TRACK_ADDED = "track added"
+EVENT_PLAYLIST_ADDED = "playlist added"
+EVENT_RADIO_ADDED = "radio added"
 
 # player attributes
 ATTR_PLAYER_ID = "player_id"
index 579c76c3979f6fe07fd80bcc1a6740d8609b3f37..b2f0dcfcd1d5df0e0f4c593c3a2a6d74209dec8d 100644 (file)
@@ -20,6 +20,22 @@ def compare_strings(str1, str2, strict=False):
     return match
 
 
+def compare_version(left_version: str, right_version: str):
+    """Compare version string."""
+    if not left_version and not right_version:
+        return True
+    if not left_version and right_version:
+        return False
+    if left_version and not right_version:
+        return False
+    if " " not in left_version:
+        return compare_strings(left_version, right_version)
+    # do this the hard way as sometimes the version string is in the wrong order
+    left_versions = left_version.lower().split(" ").sort()
+    right_versions = right_version.lower().split(" ").sort()
+    return left_versions == right_versions
+
+
 def compare_artists(left_artists: List[Artist], right_artists: List[Artist]):
     """Compare two lists of artist and return True if a match was found."""
     for left_artist in left_artists:
@@ -40,22 +56,22 @@ def compare_albums(left_albums: List[Album], right_albums: List[Album]):
 
 def compare_album(left_album: Album, right_album: Album):
     """Compare two album items and return True if they match."""
+    # do not match on year and albumtype as this info is often inaccurate on providers
     if (
         left_album.provider == right_album.provider
         and left_album.item_id == right_album.item_id
     ):
         return True
-    if left_album.upc and left_album.upc == right_album.upc:
-        # UPC is always 100% accurate match
-        return True
+    if left_album.upc and right_album.upc:
+        if left_album.upc in right_album.upc or right_album.upc in left_album.upc:
+            # UPC is always 100% accurate match
+            return True
     if not compare_strings(left_album.name, right_album.name):
         return False
-    if not compare_strings(left_album.version, right_album.version):
+    if not compare_version(left_album.version, right_album.version):
         return False
     if not compare_strings(left_album.artist.name, right_album.artist.name):
         return False
-    if left_album.year != right_album.year:
-        return False
     # 100% match, all criteria passed
     return True
 
@@ -73,7 +89,7 @@ def compare_track(left_track: Track, right_track: Track):
     # track name and version must match
     if not compare_strings(left_track.name, right_track.name):
         return False
-    if not compare_strings(left_track.version, right_track.version):
+    if not compare_version(left_track.version, right_track.version):
         return False
     # track artist(s) must match
     if not compare_artists(left_track.artists, right_track.artists):
index 2e501fb27a58619e7a559b6b5166fd8640d47dd2..fda62d86677110a2769af93814383d81cccafc5e 100644 (file)
@@ -10,6 +10,7 @@ from PIL import Image
 
 async def async_get_thumb_file(mass: MusicAssistantType, url, size: int = 150):
     """Get path to (resized) thumbnail image for given image url."""
+    assert url
     cache_folder = os.path.join(mass.config.data_path, ".thumbs")
     cache_id = await mass.database.async_get_thumbnail_id(url, size)
     cache_file = os.path.join(cache_folder, f"{cache_id}.png")
index 1a8039f89c328b6e879ccddcf7e7ef3375e27758..e575e5d9bf26efc5601b22191c472869375934dc 100644 (file)
@@ -19,8 +19,8 @@ async def check_migrations(mass: MusicAssistantType):
     prev_version = packaging.version.parse(mass.config.stored_config.get("version", ""))
 
     # perform version specific migrations
-    if not is_fresh_setup and prev_version < packaging.version.parse("0.0.64"):
-        await run_migration_0064(mass)
+    if not is_fresh_setup and prev_version < packaging.version.parse("0.0.70"):
+        await run_migration_0070(mass)
 
     # store version in config
     mass.config.stored_config["version"] = app_version
@@ -37,9 +37,9 @@ async def check_migrations(mass: MusicAssistantType):
     await async_create_db_tables(mass.database.db_file)
 
 
-async def run_migration_0064(mass: MusicAssistantType):
-    """Run migration for version 0.0.64."""
-    # 0.0.64 introduced major changes to all data models and db structure
+async def run_migration_0070(mass: MusicAssistantType):
+    """Run migration for version 0.0.70."""
+    # 0.0.70 introduced major changes to all data models and db structure
     # a full refresh of data is unavoidable
     data_path = mass.config.data_path
     tracks_loudness = []
index 92af3c1bdfecc23b409d0bc390f8d27d387f0525..f50deb303ed5cc19977925a674a57e54dec1762d 100644 (file)
@@ -14,11 +14,9 @@ in uvloop is resolved.
 import asyncio
 import logging
 import subprocess
-import threading
-import time
 from typing import AsyncGenerator, List, Optional
 
-LOGGER = logging.getLogger("mass.helpers.process")
+LOGGER = logging.getLogger("AsyncProcess")
 
 
 class AsyncProcess:
@@ -27,149 +25,89 @@ class AsyncProcess:
     def __init__(
         self,
         process_args: List,
-        chunksize=512000,
         enable_write: bool = False,
         enable_shell=False,
     ):
         """Initialize."""
-        self._process_args = process_args
-        self._chunksize = chunksize
-        self._enable_write = enable_write
-        self._enable_shell = enable_shell
+        self._proc = subprocess.Popen(
+            process_args,
+            shell=enable_shell,
+            stdout=subprocess.PIPE,
+            stdin=subprocess.PIPE if enable_write else None,
+        )
         self.loop = asyncio.get_running_loop()
-        self.__queue_in = asyncio.Queue(4)
-        self.__queue_out = asyncio.Queue(8)
-        self.__proc_task = None
-        self._exit = False
-        self._id = int(time.time())  # some identifier for logging
+        self._cancelled = False
 
     async def __aenter__(self) -> "AsyncProcess":
-        """Enter context manager, start running the process in executor."""
-        self.__proc_task = self.loop.run_in_executor(None, self.__run_proc)
+        """Enter context manager."""
         return self
 
     async def __aexit__(self, exc_type, exc_value, traceback) -> bool:
         """Exit context manager."""
-        if exc_type:
-            LOGGER.debug(
-                "[%s] Context manager exit with exception %s (%s)",
-                self._id,
-                exc_type,
-                str(exc_value),
-            )
-
-        self._exit = True
-        # prevent a deadlock by clearing the queues
-        while self.__queue_in.qsize():
-            await self.__queue_in.get()
-            self.__queue_in.task_done()
-        self.__queue_in.put_nowait(b"")
-        while self.__queue_out.qsize():
-            await self.__queue_out.get()
-            self.__queue_out.task_done()
-        await self.__proc_task
-        return True
-
-    async def iterate_chunks(self) -> AsyncGenerator[bytes, None]:
-        """Yield chunks from the output Queue. Generator."""
+        self._cancelled = True
+        if await self.loop.run_in_executor(None, self._proc.poll) is None:
+            # prevent subprocess deadlocking, send terminate and read remaining bytes
+            await self.loop.run_in_executor(None, self._proc.kill)
+            self.loop.run_in_executor(None, self.__read)
+        del self._proc
+
+    async def iterate_chunks(
+        self, chunksize: int = 512000
+    ) -> AsyncGenerator[bytes, None]:
+        """Yield chunks from the process stdout. Generator."""
         while True:
-            chunk = await self.read()
-            yield chunk
-            if not chunk or len(chunk) < self._chunksize:
+            chunk = await self.read(chunksize)
+            if not chunk:
                 break
+            yield chunk
+
+    async def read(self, chunksize: int = -1) -> bytes:
+        """Read x bytes from the process stdout."""
+        if self._cancelled:
+            raise asyncio.CancelledError()
+        return await self.loop.run_in_executor(None, self.__read, chunksize)
 
-    async def read(self) -> bytes:
-        """Read single chunk from the output Queue."""
-        if self._exit:
-            raise RuntimeError("Already exited")
-        data = await self.__queue_out.get()
-        self.__queue_out.task_done()
-        return data
+    def __read(self, chunksize: int = -1):
+        """Try read chunk from process."""
+        try:
+            return self._proc.stdout.read(chunksize)
+        except (BrokenPipeError, ValueError, AttributeError):
+            # Process already exited
+            return b""
 
     async def write(self, data: bytes) -> None:
-        """Write data to process."""
-        if self._exit:
-            raise RuntimeError("Already exited")
-        await self.__queue_in.put(data)
+        """Write data to process stdin."""
+        if self._cancelled:
+            raise asyncio.CancelledError()
+
+        def __write():
+            try:
+                self._proc.stdin.write(data)
+            except (BrokenPipeError, ValueError, AttributeError):
+                # Process already exited
+                pass
+
+        await self.loop.run_in_executor(None, __write)
 
     async def write_eof(self) -> None:
         """Write eof to process."""
-        await self.__queue_in.put(b"")
+        if self._cancelled:
+            raise asyncio.CancelledError()
+
+        def __write_eof():
+            try:
+                self._proc.stdin.close()
+            except (BrokenPipeError, ValueError, AttributeError):
+                # Process already exited
+                pass
+
+        await self.loop.run_in_executor(None, __write_eof)
 
     async def communicate(self, input_data: Optional[bytes] = None) -> bytes:
         """Write bytes to process and read back results."""
-        if not self._enable_write and input_data:
-            raise RuntimeError("Write is disabled")
-        if input_data:
-            await self.write(input_data)
-            await self.write_eof()
-        output = b""
-        async for chunk in self.iterate_chunks():
-            output += chunk
-        return output
-
-    def __run_proc(self):
-        """Run process in executor."""
-        try:
-            proc = subprocess.Popen(
-                self._process_args,
-                shell=self._enable_shell,
-                stdout=subprocess.PIPE,
-                stdin=subprocess.PIPE if self._enable_write else None,
-            )
-            if self._enable_write:
-                threading.Thread(
-                    target=self.__write_stdin,
-                    args=(proc.stdin,),
-                    name=f"AsyncProcess_{self._id}_write_stdin",
-                    daemon=True,
-                ).start()
-            threading.Thread(
-                target=self.__read_stdout,
-                args=(proc.stdout,),
-                name=f"AsyncProcess_{self._id}_read_stdout",
-                daemon=True,
-            ).start()
-            proc.wait()
-
-        except Exception as exc:  # pylint: disable=broad-except
-            LOGGER.warning("[%s] process exiting abormally: %s", self._id, str(exc))
-            LOGGER.exception(exc)
-        finally:
-            if proc.poll() is None:
-                proc.terminate()
-                proc.communicate()
-
-    def __write_stdin(self, _stdin):
-        """Put chunks from queue to stdin."""
-        try:
-            while True:
-                chunk = asyncio.run_coroutine_threadsafe(
-                    self.__queue_in.get(), self.loop
-                ).result()
-                self.__queue_in.task_done()
-                if not chunk:
-                    _stdin.close()
-                    break
-                _stdin.write(chunk)
-        except Exception as exc:  # pylint: disable=broad-except
-            LOGGER.debug(
-                "[%s] write to stdin aborted with exception: %s", self._id, str(exc)
-            )
-
-    def __read_stdout(self, _stdout):
-        """Put chunks from stdout to queue."""
-        try:
-            while True:
-                chunk = _stdout.read(self._chunksize)
-                asyncio.run_coroutine_threadsafe(
-                    self.__queue_out.put(chunk), self.loop
-                ).result()
-                if not chunk or len(chunk) < self._chunksize:
-                    break
-            # write empty chunk just in case
-            asyncio.run_coroutine_threadsafe(self.__queue_out.put(b""), self.loop)
-        except Exception as exc:  # pylint: disable=broad-except
-            LOGGER.debug(
-                "[%s] read from stdout aborted with exception: %s", self._id, str(exc)
-            )
+        if self._cancelled:
+            raise asyncio.CancelledError()
+        stdout, _ = await self.loop.run_in_executor(
+            None, self._proc.communicate, input_data
+        )
+        return stdout
index dd47f7397beaf9fcdaa13b481d092bad666c1bd6..331f8b155d89a63217723a19992b13ffba5b8f1f 100755 (executable)
@@ -141,13 +141,15 @@ def parse_title_and_version(track_title, track_version=None):
                     "remix",
                     "mix",
                     "acoustic",
-                    " instrumental",
+                    "instrumental",
                     "karaoke",
                     "remaster",
                     "versie",
                     "radio",
                     "unplugged",
                     "disco",
+                    "akoestisch",
+                    "deluxe",
                 ]:
                     if version_str in title_part:
                         version = title_part
@@ -156,6 +158,8 @@ def parse_title_and_version(track_title, track_version=None):
     if not version and track_version:
         version = track_version
     version = get_version_substitute(version).title()
+    if version == title:
+        version = ""
     return title, version
 
 
index 3e4cc527286a99485c6f11adb2066c78a507824b..0fb1ac6461d1e325403369cff32f1dafec9c7f0b 100755 (executable)
@@ -10,6 +10,7 @@ from music_assistant.helpers.util import merge_dict, merge_list, try_parse_int
 from music_assistant.helpers.web import json_serializer
 from music_assistant.models.media_types import (
     Album,
+    AlbumType,
     Artist,
     FullAlbum,
     FullTrack,
@@ -637,11 +638,16 @@ 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:
+                album_type = album.album_type
+            else:
+                album_type = cur_item.album_type
             sql_query = """UPDATE albums
                 SET upc=?,
                     artist=?,
                     metadata=?,
-                    provider_ids=?
+                    provider_ids=?,
+                    album_type=?
                 WHERE item_id=?;"""
             await db_conn.execute(
                 sql_query,
@@ -650,6 +656,7 @@ class DatabaseManager:
                     json_serializer(album_artist),
                     json_serializer(metadata),
                     json_serializer(provider_ids),
+                    album_type.value,
                     item_id,
                 ),
             )
index 10399d6ca737d700dc4b35cca31836b3fb894f2d..1ac034682a0bf5284c8b8ed6ee05599929a74a0b 100755 (executable)
@@ -272,7 +272,9 @@ class LibraryManager:
         prev_db_ids = await self.mass.cache.async_get(cache_key, default=[])
         cur_db_ids = []
         for item in await music_provider.async_get_library_artists():
-            db_item = await self.mass.music.async_get_artist(item.item_id, provider_id)
+            db_item = await self.mass.music.async_get_artist(
+                item.item_id, provider_id, lazy=False
+            )
             cur_db_ids.append(db_item.item_id)
             if not db_item.in_library:
                 await self.mass.database.async_add_to_library(
@@ -295,8 +297,10 @@ class LibraryManager:
         prev_db_ids = await self.mass.cache.async_get(cache_key, default=[])
         cur_db_ids = []
         for item in await music_provider.async_get_library_albums():
-            db_album = await self.mass.music.async_get_album(item.item_id, provider_id)
-            if db_album.available != item.available:
+            db_album = await self.mass.music.async_get_album(
+                item.item_id, provider_id, lazy=False
+            )
+            if not db_album.available and not item.available:
                 # album availability changed, sort this out with auto matching magic
                 db_album = await self.mass.music.async_match_album(db_album)
             cur_db_ids.append(db_album.item_id)
@@ -305,15 +309,7 @@ class LibraryManager:
                     db_album.item_id, MediaType.Album, provider_id
                 )
             # precache album tracks
-            for album_track in await self.mass.music.async_get_album_tracks(
-                item.item_id, provider_id
-            ):
-                # try to find substitutes for unavailable tracks with matching technique
-                if not album_track.available:
-                    if album_track.provider == "database":
-                        await self.mass.music.async_match_track(album_track)
-                    else:
-                        await self.mass.music.async_add_track(album_track)
+            await self.mass.music.async_get_album_tracks(item.item_id, provider_id)
         # process album deletions
         for db_id in prev_db_ids:
             if db_id not in cur_db_ids:
@@ -331,8 +327,10 @@ class LibraryManager:
         prev_db_ids = await self.mass.cache.async_get(cache_key, default=[])
         cur_db_ids = []
         for item in await music_provider.async_get_library_tracks():
-            db_item = await self.mass.music.async_get_track(item.item_id, provider_id)
-            if db_item.available != item.available:
+            db_item = await self.mass.music.async_get_track(
+                item.item_id, provider_id, lazy=False
+            )
+            if not db_item.available and not item.available:
                 # track availability changed, sort this out with auto matching magic
                 db_item = await self.mass.music.async_add_track(item)
             cur_db_ids.append(db_item.item_id)
@@ -358,7 +356,7 @@ class LibraryManager:
         cur_db_ids = []
         for playlist in await music_provider.async_get_library_playlists():
             db_item = await self.mass.music.async_get_playlist(
-                playlist.item_id, provider_id
+                playlist.item_id, provider_id, lazy=False
             )
             if db_item.checksum != playlist.checksum:
                 db_item = await self.mass.database.async_add_playlist(playlist)
@@ -371,7 +369,7 @@ class LibraryManager:
                 playlist.item_id, provider_id
             ):
                 # try to find substitutes for unavailable tracks with matching technique
-                if not playlist_track.available:
+                if not db_item.available and not playlist_track.available:
                     if playlist_track.provider == "database":
                         await self.mass.music.async_match_track(playlist_track)
                     else:
@@ -393,7 +391,9 @@ class LibraryManager:
         prev_db_ids = await self.mass.cache.async_get(cache_key, default=[])
         cur_db_ids = []
         for item in await music_provider.async_get_library_radios():
-            db_radio = await self.mass.music.async_get_radio(item.item_id, provider_id)
+            db_radio = await self.mass.music.async_get_radio(
+                item.item_id, provider_id, lazy=False
+            )
             cur_db_ids.append(db_radio.item_id)
             await self.mass.database.async_add_to_library(
                 db_radio.item_id, MediaType.Radio, provider_id
index a5c6c9c8600a16b5fe76fa58b5f2ecaf0cd915d7..dac230f55c8562dd34a55bd599bb3b4f1330a42e 100755 (executable)
@@ -39,5 +39,5 @@ class MetaDataManager:
                 self.cache, cache_key, provider.async_get_artist_images, mb_artist_id
             )
             if res:
-                merge_dict(metadata, res)
+                metadata = merge_dict(metadata, res)
         return metadata
index 7ac446bb448b9d0631ec7df1736755a3b6c5f12b..62ec981fb741b33d2478df3dfd495f5d084350b7 100755 (executable)
@@ -4,21 +4,28 @@ import asyncio
 import logging
 from typing import List
 
+from music_assistant.constants import (
+    EVENT_ALBUM_ADDED,
+    EVENT_ARTIST_ADDED,
+    EVENT_PLAYLIST_ADDED,
+    EVENT_RADIO_ADDED,
+    EVENT_TRACK_ADDED,
+)
 from music_assistant.helpers.cache import async_cached
 from music_assistant.helpers.compare import (
     compare_album,
     compare_strings,
     compare_track,
 )
-from music_assistant.helpers.encryption import async_encrypt_string
 from music_assistant.helpers.musicbrainz import MusicBrainz
 from music_assistant.helpers.util import unique_item_ids
 from music_assistant.helpers.web import api_route
 from music_assistant.models.media_types import (
     Album,
+    AlbumType,
     Artist,
     FullAlbum,
-    FullTrack,
+    ItemMapping,
     MediaItem,
     MediaType,
     Playlist,
@@ -53,24 +60,39 @@ class MusicManager:
 
     @api_route("items/:media_type/:provider_id/:item_id")
     async def async_get_item(
-        self, item_id: str, provider_id: str, media_type: MediaType
+        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.async_get_artist(item_id, provider_id)
+            return await self.async_get_artist(
+                item_id, provider_id, refresh=refresh, lazy=lazy
+            )
         if media_type == MediaType.Album:
-            return await self.async_get_album(item_id, provider_id)
+            return await self.async_get_album(
+                item_id, provider_id, refresh=refresh, lazy=lazy
+            )
         if media_type == MediaType.Track:
-            return await self.async_get_track(item_id, provider_id)
+            return await self.async_get_track(
+                item_id, provider_id, refresh=refresh, lazy=lazy
+            )
         if media_type == MediaType.Playlist:
-            return await self.async_get_playlist(item_id, provider_id)
+            return await self.async_get_playlist(
+                item_id, provider_id, refresh=refresh, lazy=lazy
+            )
         if media_type == MediaType.Radio:
-            return await self.async_get_radio(item_id, provider_id)
+            return await self.async_get_radio(
+                item_id, provider_id, refresh=refresh, lazy=lazy
+            )
         return None
 
     @api_route("artists/:provider_id/:item_id")
     async def async_get_artist(
-        self, item_id: str, provider_id: str, refresh=False
+        self, item_id: str, provider_id: str, refresh: bool = False, lazy: bool = True
     ) -> Artist:
         """Return artist details for the given provider artist id."""
         if provider_id == "database" and not refresh:
@@ -83,10 +105,10 @@ class MusicManager:
         elif db_item:
             return db_item
         artist = await self.__async_get_provider_artist(item_id, provider_id)
-        # fetching an artist is slow because of musicbrainz and metadata lookup
-        # so we return the provider object
-        self.mass.add_job(self.async_add_artist(artist))
-        return artist
+        if not lazy:
+            return await self.async_add_artist(artist)
+        self.mass.add_background_task(self.async_add_artist(artist))
+        return db_item if db_item else artist
 
     async def __async_get_provider_artist(
         self, item_id: str, provider_id: str
@@ -107,7 +129,7 @@ class MusicManager:
 
     @api_route("albums/:provider_id/:item_id")
     async def async_get_album(
-        self, item_id: str, provider_id: str, refresh=False
+        self, item_id: str, provider_id: str, refresh: bool = False, lazy: bool = True
     ) -> Album:
         """Return album details for the given provider album id."""
         if provider_id == "database" and not refresh:
@@ -120,7 +142,10 @@ class MusicManager:
         elif db_item:
             return db_item
         album = await self.__async_get_provider_album(item_id, provider_id)
-        return await self.async_add_album(album)
+        if not lazy:
+            return await self.async_add_album(album)
+        self.mass.add_background_task(self.async_add_album(album))
+        return db_item if db_item else album
 
     async def __async_get_provider_album(self, item_id: str, provider_id: str) -> Album:
         """Return album details for the given provider album id."""
@@ -145,6 +170,7 @@ class MusicManager:
         track_details: Track = None,
         album_details: Album = None,
         refresh: bool = False,
+        lazy: bool = True,
     ) -> Track:
         """Return track details for the given provider track id."""
         if provider_id == "database" and not refresh:
@@ -153,10 +179,6 @@ class MusicManager:
             provider_id, item_id
         )
         if db_item and refresh:
-            # in some cases (e.g. at playback time or requesting full track info)
-            # it's useful to have the track refreshed from the provider instead of
-            # the database cache to make sure that the track is available and perhaps
-            # another or a higher quality version is available.
             provider_id, item_id = await self.__get_provider_id(db_item)
         elif db_item:
             return db_item
@@ -164,9 +186,12 @@ class MusicManager:
             track_details = await self.__async_get_provider_track(item_id, provider_id)
         if album_details:
             track_details.album = album_details
-        return await self.async_add_track(track_details)
+        if not lazy:
+            return await self.async_add_track(track_details)
+        self.mass.add_background_task(self.async_add_track(track_details))
+        return db_item if db_item else track_details
 
-    async def __async_get_provider_track(self, item_id: str, provider_id: str) -> Album:
+    async def __async_get_provider_track(self, item_id: str, provider_id: str) -> Track:
         """Return track details for the given provider track id."""
         provider = self.mass.get_provider(provider_id)
         if not provider or not provider.available:
@@ -182,36 +207,78 @@ class MusicManager:
         return track
 
     @api_route("playlists/:provider_id/:item_id")
-    async def async_get_playlist(self, item_id: str, provider_id: str) -> Playlist:
+    async def async_get_playlist(
+        self, item_id: str, provider_id: str, refresh: bool = False, lazy: bool = True
+    ) -> Playlist:
         """Return playlist details for the given provider playlist id."""
         assert item_id and provider_id
         db_item = await self.mass.database.async_get_playlist_by_prov_id(
             provider_id, item_id
         )
-        if not db_item:
-            # item not yet in local database so fetch and store details
-            provider = self.mass.get_provider(provider_id)
-            if not provider.available:
-                return None
-            item_details = await provider.async_get_playlist(item_id)
-            db_item = await self.mass.database.async_add_playlist(item_details)
-        return db_item
+        if db_item and refresh:
+            provider_id, item_id = await self.__get_provider_id(db_item)
+        elif db_item:
+            return db_item
+        playlist = await self.__async_get_provider_playlist(item_id, provider_id)
+        if not lazy:
+            return await self.async_add_playlist(playlist)
+        self.mass.add_background_task(self.async_add_playlist(playlist))
+        return db_item if db_item else playlist
+
+    async def __async_get_provider_playlist(
+        self, item_id: str, provider_id: str
+    ) -> Playlist:
+        """Return playlist details for the given provider playlist id."""
+        provider = self.mass.get_provider(provider_id)
+        if not provider or not provider.available:
+            raise Exception("Provider %s is not available!" % provider_id)
+        cache_key = f"{provider_id}.get_playlist.{item_id}"
+        playlist = await async_cached(
+            self.cache,
+            cache_key,
+            provider.async_get_playlist,
+            item_id,
+            expires=86400 * 2,
+        )
+        if not playlist:
+            raise Exception(
+                "Playlist %s not found on provider %s" % (item_id, provider_id)
+            )
+        return playlist
 
     @api_route("radios/:provider_id/:item_id")
-    async def async_get_radio(self, item_id: str, provider_id: str) -> Radio:
-        """Return radio details for the given provider playlist id."""
+    async def async_get_radio(
+        self, item_id: str, provider_id: str, refresh: bool = False, lazy: bool = True
+    ) -> Radio:
+        """Return radio details for the given provider radio id."""
         assert item_id and provider_id
         db_item = await self.mass.database.async_get_radio_by_prov_id(
             provider_id, item_id
         )
-        if not db_item:
-            # item not yet in local database so fetch and store details
-            provider = self.mass.get_provider(provider_id)
-            if not provider.available:
-                return None
-            item_details = await provider.async_get_radio(item_id)
-            db_item = await self.mass.database.async_add_radio(item_details)
-        return db_item
+        if db_item and refresh:
+            provider_id, item_id = await self.__get_provider_id(db_item)
+        elif db_item:
+            return db_item
+        radio = await self.__async_get_provider_radio(item_id, provider_id)
+        if not lazy:
+            return await self.async_add_radio(radio)
+        self.mass.add_background_task(self.async_add_radio(radio))
+        return db_item if db_item else radio
+
+    async def __async_get_provider_radio(self, item_id: str, provider_id: str) -> Radio:
+        """Return radio details for the given provider playlist id."""
+        provider = self.mass.get_provider(provider_id)
+        if not provider or not provider.available:
+            raise Exception("Provider %s is not available!" % provider_id)
+        cache_key = f"{provider_id}.get_radio.{item_id}"
+        radio = await async_cached(
+            self.cache, cache_key, provider.async_get_radio, item_id
+        )
+        if not radio:
+            raise Exception(
+                "Radio %s not found on provider %s" % (item_id, provider_id)
+            )
+        return radio
 
     @api_route("albums/:provider_id/:item_id/tracks")
     async def async_get_album_tracks(
@@ -555,8 +622,6 @@ class MusicManager:
         if streamdetails:
             # set player_id on the streamdetails so we know what players stream
             streamdetails.player_id = player_id
-            # store the path encrypted as we do not want it to be visible in the api
-            streamdetails.path = await async_encrypt_string(streamdetails.path)
             # set streamdetails as attribute on the media_item
             # this way the app knows what content is playing
             media_item.streamdetails = streamdetails
@@ -565,7 +630,7 @@ class MusicManager:
 
     ################ ADD MediaItem(s) to database helpers ################
 
-    async def async_add_artist(self, artist: Artist) -> int:
+    async def async_add_artist(self, artist: Artist) -> Artist:
         """Add artist to local db and return the database item."""
         if not artist.musicbrainz_id:
             artist.musicbrainz_id = await self.__async_get_artist_musicbrainz_id(artist)
@@ -575,29 +640,42 @@ class MusicManager:
         )
         db_item = await self.mass.database.async_add_artist(artist)
         # also fetch same artist on all providers
-        self.mass.add_background_task(self.async_match_artist(db_item))
-        self.mass.signal_event("artist added", db_item)
+        await self.async_match_artist(db_item)
+        self.mass.signal_event(EVENT_ARTIST_ADDED, db_item)
         return db_item
 
-    async def async_add_album(self, album: Album) -> int:
+    async def async_add_album(self, album: Album) -> Album:
         """Add album to local db and return the database item."""
         # make sure we have an artist
         assert album.artist
         db_item = await self.mass.database.async_add_album(album)
         # also fetch same album on all providers
-        self.mass.add_background_task(self.async_match_album(db_item))
-        self.mass.signal_event("album added", db_item)
+        await self.async_match_album(db_item)
+        self.mass.signal_event(EVENT_ALBUM_ADDED, db_item)
         return db_item
 
-    async def async_add_track(self, track: Track) -> int:
-        """Add track to local db and return the new database id."""
+    async def async_add_track(self, track: Track) -> Track:
+        """Add track to local db and return the new database item."""
         # make sure we have artists
         assert track.artists
         # make sure we have an album
         assert track.album or track.albums
         db_item = await self.mass.database.async_add_track(track)
         # also fetch same track on all providers (will also get other quality versions)
-        self.mass.add_background_task(self.async_match_track(db_item))
+        await self.async_match_track(db_item)
+        self.mass.signal_event(EVENT_TRACK_ADDED, db_item)
+        return db_item
+
+    async def async_add_playlist(self, playlist: Playlist) -> Playlist:
+        """Add playlist to local db and return the new database item."""
+        db_item = await self.mass.database.async_add_playlist(playlist)
+        self.mass.signal_event(EVENT_PLAYLIST_ADDED, db_item)
+        return db_item
+
+    async def async_add_radio(self, radio: Radio) -> Radio:
+        """Add radio to local db and return the new database item."""
+        db_item = await self.mass.database.async_add_radio(radio)
+        self.mass.signal_event(EVENT_RADIO_ADDED, db_item)
         return db_item
 
     async def __async_get_artist_musicbrainz_id(self, artist: Artist):
@@ -608,6 +686,8 @@ class MusicManager:
         ):
             if not lookup_album:
                 continue
+            if artist.name != lookup_album.artist.name:
+                continue
             musicbrainz_id = await self.musicbrainz.async_get_mb_artist_id(
                 artist.name,
                 albumname=lookup_album.name,
@@ -645,7 +725,7 @@ class MusicManager:
         for provider in self.mass.get_providers(ProviderType.MUSIC_PROVIDER):
             if provider.id in cur_providers:
                 continue
-            if Artist not in provider.supported_mediatypes:
+            if MediaType.Artist not in provider.supported_mediatypes:
                 continue
             if not await self.__async_match_prov_artist(db_artist, provider):
                 LOGGER.debug(
@@ -661,40 +741,59 @@ class MusicManager:
         LOGGER.debug(
             "Trying to match artist %s on provider %s", db_artist.name, provider.name
         )
-        # try to get a match with some reference albums of this artist
-        for ref_album in await self.async_get_artist_albums(
-            db_artist.item_id, db_artist.provider
-        ):
-            searchstr = "%s - %s" % (db_artist.name, ref_album.name)
-            search_result = await self.async_search_provider(
-                searchstr, provider.id, [MediaType.Album], limit=10
-            )
-            for search_result_item in search_result.albums:
-                if compare_album(search_result_item, ref_album):
-                    # 100% album match, we can simply update the db with the provider id
-                    await self.mass.database.async_update_artist(
-                        db_artist.item_id, search_result_item.artist
-                    )
-                    return True
-
         # try to get a match with some reference tracks of this artist
         for ref_track in await self.async_get_artist_toptracks(
             db_artist.item_id, db_artist.provider
         ):
-            searchstr = "%s - %s" % (db_artist.name, ref_track.name)
+            # make sure we have a full track
+            if isinstance(ref_track.album, ItemMapping):
+                ref_track = await self.async_get_track(
+                    ref_track.item_id, ref_track.provider
+                )
+            searchstr = "%s %s" % (db_artist.name, ref_track.name)
             search_results = await self.async_search_provider(
-                searchstr, provider.id, [MediaType.Track], limit=10
+                searchstr, provider.id, [MediaType.Track], limit=25
             )
             for search_result_item in search_results.tracks:
                 if compare_track(search_result_item, ref_track):
                     # get matching artist from track
                     for search_item_artist in search_result_item.artists:
                         if compare_strings(db_artist.name, search_item_artist.name):
-                            # 100% match, we can simply update the db with additional provider ids
+                            # 100% album match
+                            # get full artist details so we have all metadata
+                            prov_artist = await self.__async_get_provider_artist(
+                                search_item_artist.item_id, search_item_artist.provider
+                            )
                             await self.mass.database.async_update_artist(
-                                db_artist.item_id, search_item_artist
+                                db_artist.item_id, prov_artist
                             )
                             return True
+        # try to get a match with some reference albums of this artist
+        artist_albums = await self.async_get_artist_albums(
+            db_artist.item_id, db_artist.provider
+        )
+        for ref_album in artist_albums[:50]:
+            if ref_album.album_type == AlbumType.Compilation:
+                continue
+            searchstr = "%s %s" % (db_artist.name, ref_album.name)
+            search_result = await self.async_search_provider(
+                searchstr, provider.id, [MediaType.Album], limit=25
+            )
+            for search_result_item in search_result.albums:
+                # artist must match 100%
+                if not compare_strings(db_artist.name, search_result_item.artist.name):
+                    continue
+                if compare_album(search_result_item, ref_album):
+                    # 100% album match
+                    # get full artist details so we have all metadata
+                    prov_artist = await self.__async_get_provider_artist(
+                        search_result_item.artist.item_id,
+                        search_result_item.artist.provider,
+                    )
+                    await self.mass.database.async_update_artist(
+                        db_artist.item_id, prov_artist
+                    )
+                    return True
         return False
 
     async def async_match_album(self, db_album: Album):
@@ -715,21 +814,36 @@ class MusicManager:
                 "Trying to match album %s on provider %s", db_album.name, provider.name
             )
             match_found = False
-            searchstr = "%s %s" % (db_album.artist.name, db_album.name)
+            searchstr = "%s %s" % (db_album.artist.name, db_album.name)
             if db_album.version:
                 searchstr += " " + db_album.version
             search_result = await self.async_search_provider(
-                searchstr, provider.id, [MediaType.Album], limit=5
+                searchstr, provider.id, [MediaType.Album], limit=25
             )
             for search_result_item in search_result.albums:
                 if not search_result_item.available:
                     continue
-                if compare_album(search_result_item, db_album):
+                if not compare_album(search_result_item, db_album):
+                    continue
+                # we must fetch the full album version, search results are simplified objects
+                prov_album = await self.__async_get_provider_album(
+                    search_result_item.item_id, search_result_item.provider
+                )
+                if compare_album(prov_album, db_album):
                     # 100% match, we can simply update the db with additional provider ids
                     await self.mass.database.async_update_album(
-                        db_album.item_id, search_result_item
+                        db_album.item_id, prov_album
                     )
                     match_found = True
+                    # while we're here, also match the artist
+                    if db_album.artist.provider == "database":
+                        prov_artist = await self.__async_get_provider_artist(
+                            prov_album.artist.item_id, prov_album.artist.provider
+                        )
+                        await self.mass.database.async_update_artist(
+                            db_album.artist.item_id, prov_artist
+                        )
+
             # no match found
             if not match_found:
                 LOGGER.debug(
@@ -741,7 +855,7 @@ class MusicManager:
         # try to find match on all providers
         providers = self.mass.get_providers(ProviderType.MUSIC_PROVIDER)
         for provider in providers:
-            if Album in provider.supported_mediatypes:
+            if MediaType.Album in provider.supported_mediatypes:
                 await find_prov_match(provider)
 
     async def async_match_track(self, db_track: Track):
@@ -753,11 +867,11 @@ class MusicManager:
         assert (
             db_track.provider == "database"
         ), "Matching only supported for database items!"
-        if not isinstance(db_track, FullTrack):
+        if isinstance(db_track.album, ItemMapping):
             # matching only works if we have a full track object
             db_track = await self.mass.database.async_get_track(db_track.item_id)
         for provider in self.mass.get_providers(ProviderType.MUSIC_PROVIDER):
-            if 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
@@ -766,11 +880,11 @@ class MusicManager:
             for db_track_artist in db_track.artists:
                 if match_found:
                     break
-                searchstr = "%s %s" % (db_track_artist.name, db_track.name)
+                searchstr = "%s %s" % (db_track_artist.name, db_track.name)
                 if db_track.version:
                     searchstr += " " + db_track.version
                 search_result = await self.async_search_provider(
-                    searchstr, provider.id, [MediaType.Track], limit=10
+                    searchstr, provider.id, [MediaType.Track], limit=25
                 )
                 for search_result_item in search_result.tracks:
                     if not search_result_item.available:
@@ -781,6 +895,19 @@ class MusicManager:
                         await self.mass.database.async_update_track(
                             db_track.item_id, search_result_item
                         )
+                        # while we're here, also match the artist
+                        if db_track_artist.provider == "database":
+                            for artist in search_result_item.artists:
+                                if not compare_strings(
+                                    db_track_artist.name, artist.name
+                                ):
+                                    continue
+                                prov_artist = await self.__async_get_provider_artist(
+                                    artist.item_id, artist.provider
+                                )
+                                await self.mass.database.async_update_artist(
+                                    db_track_artist.item_id, prov_artist
+                                )
 
             if not match_found:
                 LOGGER.debug(
index 3b300d8d2b16ab3dc2af2029f4fac82e407930fb..821e75ee6dac5141d063f1597b116f1ca5a3efd3 100755 (executable)
@@ -20,7 +20,6 @@ from music_assistant.constants import (
     EVENT_STREAM_ENDED,
     EVENT_STREAM_STARTED,
 )
-from music_assistant.helpers.encryption import async_decrypt_string
 from music_assistant.helpers.process import AsyncProcess
 from music_assistant.helpers.typing import MusicAssistantType
 from music_assistant.helpers.util import create_tempfile, get_ip, try_parse_int
@@ -77,15 +76,17 @@ class StreamManager:
         if resample:
             args += ["rate", "-v", str(resample)]
 
-        async with AsyncProcess(args, chunk_size, enable_write=True) as sox_proc:
+        LOGGER.debug(
+            "start sox stream for: %s/%s", streamdetails.provider, streamdetails.item_id
+        )
 
-            cancelled = False
+        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.async_get_media_stream(streamdetails):
-                    if self.mass.exit or cancelled or not chunk:
+                    if not chunk:
                         break
                     await sox_proc.write(chunk)
                 await sox_proc.write_eof()
@@ -93,33 +94,21 @@ class StreamManager:
             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():
-                    if len(chunk) < chunk_size:
-                        # last chunk
-                        yield (True, prev_chunk + chunk)
-                        break
-                    if prev_chunk:
-                        yield (False, prev_chunk)
-                    prev_chunk = chunk
-
-                await asyncio.wait([fill_buffer_task])
-
-            # pylint: disable=broad-except
-            except (
-                GeneratorExit,
-                asyncio.CancelledError,
-                Exception,
-            ) as exc:
-                cancelled = True
-                fill_buffer_task.cancel()
-                LOGGER.debug(
-                    "[async_get_sox_stream] [%s/%s] cancelled: %s",
-                    streamdetails.provider,
-                    streamdetails.item_id,
-                    str(exc),
-                )
+            prev_chunk = b""
+            async for chunk in sox_proc.iterate_chunks(chunk_size):
+                if len(chunk) < chunk_size:
+                    # last chunk
+                    yield (True, prev_chunk + chunk)
+                    break
+                if prev_chunk:
+                    yield (False, prev_chunk)
+                prev_chunk = chunk
+            await asyncio.wait([fill_buffer_task])
+            LOGGER.debug(
+                "finished sox stream for: %s/%s",
+                streamdetails.provider,
+                streamdetails.item_id,
+            )
 
     async def async_queue_stream_flac(self, player_id) -> AsyncGenerator[bytes, None]:
         """Stream the PlayerQueue's tracks as constant feed in flac format."""
@@ -141,39 +130,24 @@ class StreamManager:
             "flac",
             "-",
         ]
-        async with AsyncProcess(args, chunk_size, enable_write=True) as sox_proc:
+        async with AsyncProcess(args, enable_write=True) as sox_proc:
 
             # feed stdin with pcm samples
-            cancelled = False
-
             async def fill_buffer():
                 """Feed audio data into sox stdin for processing."""
                 async for chunk in self.async_queue_stream_pcm(
                     player_id, sample_rate, 32
                 ):
-                    if self.mass.exit or cancelled or not chunk:
+                    if not chunk:
                         break
                     await sox_proc.write(chunk)
-                # write eof when no more data
-                await sox_proc.write_eof()
 
             fill_buffer_task = self.mass.loop.create_task(fill_buffer())
-            try:
-                # start yielding audio chunks
-                async for chunk in sox_proc.iterate_chunks():
-                    yield chunk
-                await asyncio.wait([fill_buffer_task])
-            # pylint: disable=broad-except
-            except (
-                GeneratorExit,
-                asyncio.CancelledError,
-                Exception,
-            ) as exc:
-                cancelled = True
-                fill_buffer_task.cancel()
-                LOGGER.debug(
-                    "[async_queue_stream_flac] [%s] cancelled: %s", player_id, str(exc)
-                )
+
+            # start yielding audio chunks
+            async for chunk in sox_proc.iterate_chunks(chunk_size):
+                yield chunk
+            await asyncio.wait([fill_buffer_task])
 
     async def async_queue_stream_pcm(
         self, player_id, sample_rate=96000, bit_depth=32
@@ -233,8 +207,12 @@ class StreamManager:
                 cur_chunk += 1
 
                 # HANDLE FIRST PART OF TRACK
-                if not chunk and cur_chunk == 1 and is_last_chunk:
-                    raise RuntimeError("Stream error on track %s" % queue_track.item_id)
+                if not chunk and bytes_written == 0:
+                    # stream error: got empy first chunk
+                    # prevent player queue get stuck by sending next track command
+                    self.mass.add_job(player_queue.async_next())
+                    LOGGER.error("Stream error on track %s", queue_track.item_id)
+                    return
                 if cur_chunk <= 2 and not last_fadeout_data:
                     # no fadeout_part available so just pass it to the output directly
                     yield chunk
@@ -358,19 +336,25 @@ class StreamManager:
             player_id, streamdetails.item_id, streamdetails.provider
         )
         # start streaming
+        LOGGER.debug("Start streaming %s (%s)", queue_item_id, queue_item.name)
         async for _, audio_chunk in self.async_get_sox_stream(
             streamdetails, gain_db_adjust=gain_correct
         ):
             yield audio_chunk
+        LOGGER.debug("Finished streaming %s (%s)", queue_item_id, queue_item.name)
 
     async def async_get_media_stream(
         self, streamdetails: StreamDetails
     ) -> AsyncGenerator[bytes, None]:
         """Get the (original/untouched) audio data for the given streamdetails. Generator."""
-        stream_path = await async_decrypt_string(streamdetails.path)
+        stream_path = streamdetails.path
         stream_type = StreamType(streamdetails.type)
         audio_data = b""
         chunk_size = 512000
+        track_loudness = await self.mass.database.async_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]:
@@ -380,6 +364,12 @@ class StreamManager:
 
         # 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,
+        )
 
         if stream_type == StreamType.URL:
             async with self.mass.http_session.get(stream_path) as response:
@@ -388,27 +378,37 @@ class StreamManager:
                     if not chunk:
                         break
                     yield chunk
-                    if len(audio_data) < 100000000:
+                    if needs_analyze and len(audio_data) < 100000000:
                         audio_data += chunk
         elif stream_type == StreamType.FILE:
             async with AIOFile(stream_path) as afp:
                 async for chunk in Reader(afp, chunk_size=chunk_size):
+                    if not chunk:
+                        break
                     yield chunk
-                    if len(audio_data) < 100000000:
+                    if needs_analyze and len(audio_data) < 100000000:
                         audio_data += chunk
         elif stream_type == StreamType.EXECUTABLE:
             args = shlex.split(stream_path)
-            async with AsyncProcess(args, chunk_size, False) as process:
-                async for chunk in process.iterate_chunks():
+            async with AsyncProcess(args) as process:
+                async for chunk in process.iterate_chunks(chunk_size):
+                    if not chunk:
+                        break
                     yield chunk
-                    if len(audio_data) < 100000000:
+                    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,
+        )
 
         # send analyze job to background worker
-        self.mass.add_job(self.__analyze_audio, streamdetails, audio_data)
+        if needs_analyze and audio_data:
+            self.mass.add_job(self.__analyze_audio, streamdetails, audio_data)
 
     def __get_player_sox_options(
         self, player_id: str, streamdetails: StreamDetails
index ff02abf2bbaa4c3d3107d2d81541c448ddd20209..9add64232049763e8a48610daa233cc1e523032c 100755 (executable)
@@ -5,7 +5,6 @@ from enum import Enum, IntEnum
 from typing import Any, List, Mapping
 
 import ujson
-import unidecode
 from mashumaro import DataClassDictMixin
 
 
@@ -33,6 +32,7 @@ class AlbumType(Enum):
     Album = "album"
     Single = "single"
     Compilation = "compilation"
+    Unknown = "unknown"
 
 
 class TrackQuality(IntEnum):
@@ -110,7 +110,7 @@ class MediaItem(DataClassDictMixin):
         for item in ["The ", "De ", "de ", "Les "]:
             if self.name.startswith(item):
                 sort_name = "".join(self.name.split(item)[1:])
-        return unidecode.unidecode(sort_name).lower()
+        return sort_name.lower()
 
     @property
     def available(self):
@@ -151,7 +151,7 @@ class Album(MediaItem):
     version: str = ""
     year: int = 0
     artist: ItemMapping = None
-    album_type: AlbumType = AlbumType.Album
+    album_type: AlbumType = AlbumType.Unknown
     upc: str = ""
 
 
index 21ccc3807cfe1ec47ae9b8109f3af77fb1743bf3..9e94e855dee77517aff80d7057850aa4f69053e4 100755 (executable)
@@ -55,7 +55,7 @@ class QueueItem(Track):
 
     @classmethod
     def from_track(cls, track: Union[Track, Radio]):
-        """Construct QueueItem from track/raio item."""
+        """Construct QueueItem from track/radio item."""
         return cls.from_dict(track.to_dict())
 
 
@@ -302,8 +302,8 @@ class PlayerQueue:
             else:
                 # at this point we don't know if the queue is synced with the player
                 # so just to be safe we send the queue_items to the player
-                await self.player.async_cmd_queue_load(self.items)
-                await self.async_play_index(prev_index)
+                self._items = self._items[prev_index:]
+                return await self.player.async_cmd_queue_load(self._items)
         else:
             LOGGER.warning(
                 "resume queue requested for %s but queue is empty", self.queue_id
index 5a5e3cbf5d045478b7eaa4655451e54daefbed22..b3bda898ffbb5b9bd4aa6289345490783ba06dd5 100644 (file)
@@ -4,8 +4,6 @@ from dataclasses import dataclass
 from enum import Enum
 from typing import Any
 
-from mashumaro import DataClassDictMixin
-
 
 class StreamType(Enum):
     """Enum with stream types."""
@@ -28,7 +26,7 @@ class ContentType(Enum):
 
 
 @dataclass
-class StreamDetails(DataClassDictMixin):
+class StreamDetails:
     """Model for streamdetails."""
 
     type: StreamType
@@ -42,3 +40,20 @@ class StreamDetails(DataClassDictMixin):
     details: Any = None
     seconds_played: int = 0
     sox_options: str = None
+
+    def to_dict(
+        self,
+        use_bytes: bool = False,
+        use_enum: bool = False,
+        use_datetime: bool = False,
+    ):
+        """Handle conversion to dict."""
+        return {
+            "provider": self.provider,
+            "item_id": self.item_id,
+            "content_type": self.content_type.value,
+            "sample_rate": self.sample_rate,
+            "bit_depth": self.bit_depth,
+            "sox_options": self.sox_options,
+            "seconds_played": self.seconds_played,
+        }
index e3be4d72004542a960c95cdf6d027c11268b0f89..c85529b8777cfaeec23982df8f46b44260504cfc 100644 (file)
@@ -319,7 +319,7 @@ class ChromecastPlayer(Player):
     async def async_cmd_stop(self) -> None:
         """Send stop command to player."""
         if self._chromecast and self._chromecast.media_controller:
-            await self.async_chromecast_command(self._chromecast.media_controller.stop)
+            await self.async_chromecast_command(self._chromecast.quit_app)
 
     async def async_cmd_play(self) -> None:
         """Send play command to player."""
@@ -351,12 +351,7 @@ class ChromecastPlayer(Player):
 
     async def async_cmd_power_off(self) -> None:
         """Send power OFF command to player."""
-        if self.media_status and (
-            self.media_status.player_is_playing
-            or self.media_status.player_is_paused
-            or self.media_status.player_is_idle
-        ):
-            await self.async_chromecast_command(self._chromecast.media_controller.stop)
+        await self.async_cmd_stop()
         # chromecast has no real poweroff so we send mute instead
         await self.async_chromecast_command(self._chromecast.set_volume_muted, True)
 
index 3f43b23c4f3469cd302f6241472ff70f33e46857..7557310d09dcca260439e8c8e23d8abd0fc66407 100644 (file)
@@ -267,8 +267,20 @@ class QobuzProvider(MusicProvider):
 
     async def async_get_artist_toptracks(self, prov_artist_id) -> List[Track]:
         """Get a list of most popular tracks for the given artist."""
-        # artist toptracks not supported on Qobuz, so use search instead
-        # assuming qobuz returns results sorted by popularity
+        params = {
+            "artist_id": prov_artist_id,
+            "extra": "playlists",
+            "offset": 0,
+            "limit": 25,
+        }
+        result = await self.__async_get_data("artist/get", params)
+        if result and result["playlists"]:
+            return [
+                await self.__async_parse_track(item)
+                for item in result["playlists"][0]["tracks"]["items"]
+                if (item and item["id"])
+            ]
+        # fallback to search
         artist = await self.async_get_artist(prov_artist_id)
         params = {"query": artist.name, "limit": 25, "type": "tracks"}
         searchresult = await self.__async_get_data("catalog/search", params)
@@ -283,6 +295,10 @@ class QobuzProvider(MusicProvider):
             )
         ]
 
+    async def async_get_similar_artists(self, prov_artist_id):
+        """Get similar artists for given artist."""
+        # https://www.qobuz.com/api.json/0.2/artist/getSimilarArtists?artist_id=220020&offset=0&limit=3
+
     async def async_library_add(self, prov_item_id, media_type: MediaType):
         """Add item to library."""
         result = None
@@ -397,6 +413,8 @@ class QobuzProvider(MusicProvider):
         if not self.__user_auth_info:
             return
         # TODO: need to figure out if the streamed track is purchased by user
+        # https://www.qobuz.com/api.json/0.2/purchase/getUserPurchasesIds?limit=5000&user_id=xxxxxxx
+        # {"albums":{"total":0,"items":[]},"tracks":{"total":0,"items":[]},"user":{"id":xxxx,"login":"xxxxx"}}
         if msg == EVENT_STREAM_STARTED and msg_details.provider == PROV_ID:
             # report streaming started to qobuz
             device_id = self.__user_auth_info["user"]["device"]["id"]
@@ -440,15 +458,7 @@ class QobuzProvider(MusicProvider):
         artist.provider_ids.append(
             MediaItemProviderId(provider=PROV_ID, item_id=str(artist_obj["id"]))
         )
-        if artist_obj.get("image"):
-            for key in ["extralarge", "large", "medium", "small"]:
-                if artist_obj["image"].get(key):
-                    if (
-                        "2a96cbd8b46e442fc41c2b86b821562f"
-                        not in artist_obj["image"][key]
-                    ):
-                        artist.metadata["image"] = artist_obj["image"][key]
-                        break
+        artist.metadata["image"] = self.__get_image(artist_obj)
         if artist_obj.get("biography"):
             artist.metadata["biography"] = artist_obj["biography"].get("content", "")
         if artist_obj.get("url"):
@@ -486,22 +496,24 @@ class QobuzProvider(MusicProvider):
             album.artist = artist_obj
         else:
             album.artist = await self.__async_parse_artist(album_obj["artist"])
-        if album_obj.get("product_type", "") == "single":
+        if (
+            album_obj.get("product_type", "") == "single"
+            or album_obj.get("release_type", "") == "single"
+        ):
             album.album_type = AlbumType.Single
         elif (
             album_obj.get("product_type", "") == "compilation"
             or "Various" in album.artist.name
         ):
             album.album_type = AlbumType.Compilation
-        else:
+        elif (
+            album_obj.get("product_type", "") == "album"
+            or album_obj.get("release_type", "") == "album"
+        ):
             album.album_type = AlbumType.Album
         if "genre" in album_obj:
             album.metadata["genre"] = album_obj["genre"]["name"]
-        if album_obj.get("image"):
-            for key in ["extralarge", "large", "medium", "small"]:
-                if album_obj["image"].get(key):
-                    album.metadata["image"] = album_obj["image"][key]
-                    break
+        album.metadata["image"] = self.__get_image(album_obj)
         if len(album_obj["upc"]) == 13:
             # qobuz writes ean as upc ?!
             album.metadata["ean"] = album_obj["upc"]
@@ -573,6 +585,13 @@ class QobuzProvider(MusicProvider):
             track.metadata["performers"] = track_obj["performers"]
         if track_obj.get("copyright"):
             track.metadata["copyright"] = track_obj["copyright"]
+        if track_obj.get("audio_info"):
+            track.metadata["replaygain"] = track_obj["audio_info"][
+                "replaygain_track_gain"
+            ]
+        if track_obj.get("parental_warning"):
+            track.metadata["explicit"] = True
+        track.metadata["image"] = self.__get_image(track_obj)
         # get track quality
         if track_obj["maximum_sampling_rate"] > 192:
             quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4
@@ -612,8 +631,7 @@ class QobuzProvider(MusicProvider):
             playlist_obj["owner"]["id"] == self.__user_auth_info["user"]["id"]
             or playlist_obj["is_collaborative"]
         )
-        if playlist_obj.get("images300"):
-            playlist.metadata["image"] = playlist_obj["images300"][0]
+        playlist.metadata["image"] = self.__get_image(playlist_obj)
         if playlist_obj.get("url"):
             playlist.metadata["qobuz_url"] = playlist_obj["url"]
         playlist.checksum = playlist_obj["updated_at"]
@@ -713,3 +731,20 @@ class QobuzProvider(MusicProvider):
                 LOGGER.error("%s - %s", endpoint, result)
                 return None
             return result
+
+    def __get_image(self, obj: dict) -> Optional[str]:
+        """Try to parse image from Qobuz media object."""
+        if obj.get("image"):
+            for key in ["extralarge", "large", "medium", "small"]:
+                if obj["image"].get(key):
+                    if "2a96cbd8b46e442fc41c2b86b821562f" in obj["image"][key]:
+                        continue
+                    return obj["image"][key]
+        if obj.get("images300"):
+            # playlists seem to use this strange format
+            return obj["images300"][0]
+        if obj.get("album"):
+            return self.__get_image(obj["album"])
+        if obj.get("artist"):
+            return self.__get_image(obj["artist"])
+        return None
index 9c02374c5456314d4c26e57d151c11a174069d3a..d6eab7674ceb428d7e0f2de07857f81ae98f0a82 100644 (file)
@@ -371,7 +371,7 @@ class SpotifyProvider(MusicProvider):
             album.album_type = AlbumType.Single
         elif album_obj["album_type"] == "compilation":
             album.album_type = AlbumType.Compilation
-        else:
+        elif album_obj["album_type"] == "album":
             album.album_type = AlbumType.Album
         if "genres" in album_obj:
             album.metadata["genres"] = album_obj["genres"]
@@ -419,6 +419,8 @@ class SpotifyProvider(MusicProvider):
             track.isrc = track_obj["external_ids"]["isrc"]
         if "album" in track_obj:
             track.album = await self.__async_parse_album(track_obj["album"])
+            if track_obj["album"].get("images"):
+                track.metadata["image"] = track_obj["album"]["images"][0]["url"]
         if track_obj.get("copyright"):
             track.metadata["copyright"] = track_obj["copyright"]
         if track_obj.get("explicit"):
index 12762e0e64b762a41a775c56ab182d1bd349b410..b5dd226f0623ce130b6a1cb5c5e52a6cf04218ff 100755 (executable)
@@ -327,12 +327,13 @@ class WebServer:
             url = await async_get_image_url(
                 self.mass, item.item_id, item.provider, item.media_type
             )
-        img_file = await async_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()
+        if url:
+            img_file = await async_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?")