Various small bugfixes and improvements (#640)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 20 Apr 2023 14:02:36 +0000 (16:02 +0200)
committerGitHub <noreply@github.com>
Thu, 20 Apr 2023 14:02:36 +0000 (16:02 +0200)
* adjust comment

* fix encoding of playlists

* add hide option to manifest

* fix icy metadata for radiobrowser

* fix error on player settings of disabled provider

* filter out players from providers that are not available

* fix local images display

* prevent duplicate items by better locking

* some more locking to prevent duplicates due to race conditions

* fix create playlist

* bump frontend to 20230420.0

16 files changed:
music_assistant/common/models/provider.py
music_assistant/server/controllers/config.py
music_assistant/server/controllers/media/albums.py
music_assistant/server/controllers/media/artists.py
music_assistant/server/controllers/media/base.py
music_assistant/server/controllers/media/playlists.py
music_assistant/server/controllers/media/radio.py
music_assistant/server/controllers/media/tracks.py
music_assistant/server/controllers/metadata.py
music_assistant/server/providers/filesystem_local/base.py
music_assistant/server/providers/radiobrowser/__init__.py
music_assistant/server/providers/url/manifest.json
music_assistant/server/providers/websocket_api/manifest.json
pyproject.toml
requirements_all.txt
script/gen_requirements_all.py

index 59fb538e68be882c0ffba271a02fa2d568e22022..5e6a66f78bc3b7421dcce365f737ddbc9b7e7331 100644 (file)
@@ -31,6 +31,8 @@ class ProviderManifest(DataClassORJSONMixin):
     multi_instance: bool = False
     # builtin: whether this provider is a system/builtin and can not disabled/removed
     builtin: bool = False
+    # hidden: hide entry in the UI
+    hidden: bool = False
     # load_by_default: load this provider by default (mostly used together with `builtin`)
     load_by_default: bool = False
     # depends_on: depends on another provider to function
index 288c4e819ba39d75d6a349e4b9cf6280d139756a..a78e00bd4121ba90ae3897fdd1ca3f7870bac24f 100644 (file)
@@ -263,10 +263,14 @@ class ConfigController:
     @api_command("config/players")
     def get_player_configs(self, provider: str | None = None) -> list[PlayerConfig]:
         """Return all known player configurations, optionally filtered by provider domain."""
+        available_providers = {x.domain for x in self.mass.providers}
         return [
             self.get_player_config(player_id)
             for player_id, raw_conf in self.get(CONF_PLAYERS).items()
-            if (provider in (None, raw_conf["provider"]))
+            # filter out unavailable providers
+            if raw_conf["provider"] in available_providers
+            # optional provider filter
+            and (provider in (None, raw_conf["provider"]))
         ]
 
     @api_command("config/players/get")
@@ -282,7 +286,6 @@ class ConfigController:
                 raw_conf["available"] = False
                 raw_conf["name"] = raw_conf.get("name")
                 raw_conf["default_name"] = raw_conf.get("default_name") or raw_conf["player_id"]
-            prov_entries = prov.get_player_config_entries(player_id)
             prov_entries_keys = {x.key for x in prov_entries}
             # combine provider defined entries with default player config entries
             entries = prov_entries + tuple(
index e2c23b0a1f4824d4f9583c1b1731846c388b4266..b4223322c875854cf055839f967e090f64b43452 100644 (file)
@@ -36,6 +36,7 @@ class AlbumsController(MediaControllerBase[Album]):
     db_table = DB_TABLE_ALBUMS
     media_type = MediaType.ALBUM
     item_cls = DbAlbum
+    _db_add_lock = asyncio.Lock()
 
     def __init__(self, *args, **kwargs):
         """Initialize class."""
@@ -95,7 +96,9 @@ class AlbumsController(MediaControllerBase[Album]):
         # grab additional metadata
         if not skip_metadata_lookup:
             await self.mass.metadata.get_album_metadata(item)
-        existing = await self.get_db_item_by_prov_id(item.item_id, item.provider)
+        async with self._db_add_lock:
+            # use the lock to prevent a race condition of the same item being added twice
+            existing = await self.get_db_item_by_prov_id(item.item_id, item.provider)
         if existing:
             db_item = await self._update_db_item(existing.item_id, item)
         else:
@@ -200,25 +203,28 @@ class AlbumsController(MediaControllerBase[Album]):
         assert item.provider_mappings, "Item is missing provider mapping(s)"
         assert item.artists, f"Album {item.name} is missing artists"
         cur_item = None
-        # always try to grab existing item by musicbrainz_id
-        if item.musicbrainz_id:
-            match = {"musicbrainz_id": item.musicbrainz_id}
-            cur_item = await self.mass.music.database.get_row(self.db_table, match)
-        # try barcode/upc
-        if not cur_item and item.barcode:
-            for barcode in item.barcode:
-                if search_result := await self.mass.music.database.search(
-                    self.db_table, barcode, "barcode"
-                ):
-                    cur_item = Album.from_db_row(search_result[0])
-                    break
-        if not cur_item:
-            # fallback to search and match
-            for row in await self.mass.music.database.search(self.db_table, item.name):
-                row_album = Album.from_db_row(row)
-                if compare_album(row_album, item):
-                    cur_item = row_album
-                    break
+        # safety guard: check for existing item first
+        # use the lock to prevent a race condition of the same item being added twice
+        async with self._db_add_lock:
+            # always try to grab existing item by musicbrainz_id
+            if item.musicbrainz_id:
+                match = {"musicbrainz_id": item.musicbrainz_id}
+                cur_item = await self.mass.music.database.get_row(self.db_table, match)
+            # try barcode/upc
+            if not cur_item and item.barcode:
+                for barcode in item.barcode:
+                    if search_result := await self.mass.music.database.search(
+                        self.db_table, barcode, "barcode"
+                    ):
+                        cur_item = Album.from_db_row(search_result[0])
+                        break
+            if not cur_item:
+                # fallback to search and match
+                for row in await self.mass.music.database.search(self.db_table, item.name):
+                    row_album = Album.from_db_row(row)
+                    if compare_album(row_album, item):
+                        cur_item = row_album
+                        break
         if cur_item:
             # update existing
             return await self._update_db_item(cur_item.item_id, item)
@@ -237,10 +243,10 @@ class AlbumsController(MediaControllerBase[Album]):
                     "timestamp_modified": int(utc_timestamp()),
                 },
             )
-            item_id = new_item["item_id"]
-            # update/set provider_mappings table
-            await self._set_provider_mappings(item_id, item.provider_mappings)
-            self.logger.debug("added %s to database", item.name)
+        item_id = new_item["item_id"]
+        # update/set provider_mappings table
+        await self._set_provider_mappings(item_id, item.provider_mappings)
+        self.logger.debug("added %s to database", item.name)
         # return created object
         return await self.get_db_item(item_id)
 
@@ -260,28 +266,27 @@ class AlbumsController(MediaControllerBase[Album]):
         else:
             album_type = cur_item.album_type
         sort_artist = album_artists[0].sort_name if album_artists else ""
-        async with self._db_add_lock:
-            await self.mass.music.database.update(
-                self.db_table,
-                {"item_id": db_id},
-                {
-                    "name": item.name if overwrite else cur_item.name,
-                    "sort_name": item.sort_name if overwrite else cur_item.sort_name,
-                    "sort_artist": sort_artist,
-                    "version": item.version if overwrite else cur_item.version,
-                    "year": item.year if overwrite else cur_item.year or item.year,
-                    "barcode": ";".join(cur_item.barcode),
-                    "album_type": album_type.value,
-                    "artists": serialize_to_json(album_artists) or None,
-                    "metadata": serialize_to_json(metadata),
-                    "provider_mappings": serialize_to_json(provider_mappings),
-                    "musicbrainz_id": item.musicbrainz_id or cur_item.musicbrainz_id,
-                    "timestamp_modified": int(utc_timestamp()),
-                },
-            )
-            # update/set provider_mappings table
-            await self._set_provider_mappings(db_id, provider_mappings)
-            self.logger.debug("updated %s in database: %s", item.name, db_id)
+        await self.mass.music.database.update(
+            self.db_table,
+            {"item_id": db_id},
+            {
+                "name": item.name if overwrite else cur_item.name,
+                "sort_name": item.sort_name if overwrite else cur_item.sort_name,
+                "sort_artist": sort_artist,
+                "version": item.version if overwrite else cur_item.version,
+                "year": item.year if overwrite else cur_item.year or item.year,
+                "barcode": ";".join(cur_item.barcode),
+                "album_type": album_type.value,
+                "artists": serialize_to_json(album_artists) or None,
+                "metadata": serialize_to_json(metadata),
+                "provider_mappings": serialize_to_json(provider_mappings),
+                "musicbrainz_id": item.musicbrainz_id or cur_item.musicbrainz_id,
+                "timestamp_modified": int(utc_timestamp()),
+            },
+        )
+        # update/set provider_mappings table
+        await self._set_provider_mappings(db_id, provider_mappings)
+        self.logger.debug("updated %s in database: %s", item.name, db_id)
         return await self.get_db_item(db_id)
 
     async def _get_provider_album_tracks(
index e8458636159f570b6b187edd6eab80ebb9c1bf31..aaf68c5c18c3ecf86d6cc3d5b4bc518533cd7b8a 100644 (file)
@@ -39,6 +39,7 @@ class ArtistsController(MediaControllerBase[Artist]):
     db_table = DB_TABLE_ARTISTS
     media_type = MediaType.ARTIST
     item_cls = Artist
+    _db_add_lock = asyncio.Lock()
 
     def __init__(self, *args, **kwargs):
         """Initialize class."""
@@ -59,7 +60,9 @@ class ArtistsController(MediaControllerBase[Artist]):
         # grab musicbrainz id and additional metadata
         if not skip_metadata_lookup:
             await self.mass.metadata.get_artist_metadata(item)
-        existing = await self.get_db_item_by_prov_id(item.item_id, item.provider)
+        async with self._db_add_lock:
+            # use the lock to prevent a race condition of the same item being added twice
+            existing = await self.get_db_item_by_prov_id(item.item_id, item.provider)
         if existing:
             db_item = await self._update_db_item(existing.item_id, item)
         else:
@@ -288,23 +291,25 @@ class ArtistsController(MediaControllerBase[Artist]):
                 item.musicbrainz_id = VARIOUS_ARTISTS_ID
             if item.musicbrainz_id == VARIOUS_ARTISTS_ID:
                 item.name = VARIOUS_ARTISTS
-
-        # always try to grab existing item by musicbrainz_id
-        cur_item = None
-        if musicbrainz_id := getattr(item, "musicbrainz_id", None):
-            match = {"musicbrainz_id": musicbrainz_id}
-            cur_item = await self.mass.music.database.get_row(self.db_table, match)
-        if not cur_item:
-            # fallback to exact name match
-            # NOTE: we match an artist by name which could theoretically lead to collisions
-            # but the chance is so small it is not worth the additional overhead of grabbing
-            # the musicbrainz id upfront
-            match = {"sort_name": item.sort_name}
-            for row in await self.mass.music.database.get_rows(self.db_table, match):
-                row_artist = Artist.from_db_row(row)
-                if row_artist.sort_name == item.sort_name:
-                    cur_item = row_artist
-                    break
+        # safety guard: check for existing item first
+        # use the lock to prevent a race condition of the same item being added twice
+        async with self._db_add_lock:
+            # always try to grab existing item by musicbrainz_id
+            cur_item = None
+            if musicbrainz_id := getattr(item, "musicbrainz_id", None):
+                match = {"musicbrainz_id": musicbrainz_id}
+                cur_item = await self.mass.music.database.get_row(self.db_table, match)
+            if not cur_item:
+                # fallback to exact name match
+                # NOTE: we match an artist by name which could theoretically lead to collisions
+                # but the chance is so small it is not worth the additional overhead of grabbing
+                # the musicbrainz id upfront
+                match = {"sort_name": item.sort_name}
+                for row in await self.mass.music.database.get_rows(self.db_table, match):
+                    row_artist = Artist.from_db_row(row)
+                    if row_artist.sort_name == item.sort_name:
+                        cur_item = row_artist
+                        break
         if cur_item:
             # update existing
             return await self._update_db_item(cur_item.item_id, item)
@@ -318,10 +323,10 @@ class ArtistsController(MediaControllerBase[Artist]):
             item = Artist.from_dict(item.to_dict())
         async with self._db_add_lock:
             new_item = await self.mass.music.database.insert(self.db_table, item.to_db_row())
-            item_id = new_item["item_id"]
-            # update/set provider_mappings table
-            await self._set_provider_mappings(item_id, item.provider_mappings)
-            self.logger.debug("added %s to database", item.name)
+        item_id = new_item["item_id"]
+        # update/set provider_mappings table
+        await self._set_provider_mappings(item_id, item.provider_mappings)
+        self.logger.debug("added %s to database", item.name)
         # return created object
         return await self.get_db_item(item_id)
 
@@ -341,22 +346,21 @@ class ArtistsController(MediaControllerBase[Artist]):
                 item.musicbrainz_id = VARIOUS_ARTISTS_ID
             if item.musicbrainz_id == VARIOUS_ARTISTS_ID:
                 item.name = VARIOUS_ARTISTS
-        async with self._db_add_lock:
-            await self.mass.music.database.update(
-                self.db_table,
-                {"item_id": db_id},
-                {
-                    "name": item.name if overwrite else cur_item.name,
-                    "sort_name": item.sort_name if overwrite else cur_item.sort_name,
-                    "musicbrainz_id": musicbrainz_id,
-                    "metadata": serialize_to_json(metadata),
-                    "provider_mappings": serialize_to_json(provider_mappings),
-                    "timestamp_modified": int(utc_timestamp()),
-                },
-            )
-            # update/set provider_mappings table
-            await self._set_provider_mappings(db_id, provider_mappings)
-            self.logger.debug("updated %s in database: %s", item.name, db_id)
+        await self.mass.music.database.update(
+            self.db_table,
+            {"item_id": db_id},
+            {
+                "name": item.name if overwrite else cur_item.name,
+                "sort_name": item.sort_name if overwrite else cur_item.sort_name,
+                "musicbrainz_id": musicbrainz_id,
+                "metadata": serialize_to_json(metadata),
+                "provider_mappings": serialize_to_json(provider_mappings),
+                "timestamp_modified": int(utc_timestamp()),
+            },
+        )
+        # update/set provider_mappings table
+        await self._set_provider_mappings(db_id, provider_mappings)
+        self.logger.debug("updated %s in database: %s", item.name, db_id)
         return await self.get_db_item(db_id)
 
     async def _get_provider_dynamic_tracks(
index 12d16cdacaa881bb9c229bc8a2706ada7b33f3fa..ed03125c30547951538ff59421c5c1677963231b 100644 (file)
@@ -36,12 +36,12 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
     media_type: MediaType
     item_cls: MediaItemType
     db_table: str
+    _db_add_lock = asyncio.Lock()
 
     def __init__(self, mass: MusicAssistant):
         """Initialize class."""
         self.mass = mass
         self.logger = logging.getLogger(f"{ROOT_LOGGER_NAME}.music.{self.media_type.value}")
-        self._db_add_lock = asyncio.Lock()
 
     @abstractmethod
     async def add(self, item: ItemCls, skip_metadata_lookup: bool = False) -> ItemCls:
@@ -392,7 +392,8 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
         """Set the in-library bool on a database item."""
         db_id = int(item_id)  # ensure integer
         match = {"item_id": db_id}
-        await self.mass.music.database.update(self.db_table, match, {"in_library": in_library})
+        async with self._db_add_lock:
+            await self.mass.music.database.update(self.db_table, match, {"in_library": in_library})
         db_item = await self.get_db_item(db_id)
         self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, db_item.uri, db_item)
 
@@ -441,14 +442,15 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
             return
 
         # update provider_mappings table
-        await self.mass.music.database.delete(
-            DB_TABLE_PROVIDER_MAPPINGS,
-            {
-                "media_type": self.media_type.value,
-                "item_id": db_id,
-                "provider_instance": provider_instance_id,
-            },
-        )
+        async with self._db_add_lock:
+            await self.mass.music.database.delete(
+                DB_TABLE_PROVIDER_MAPPINGS,
+                {
+                    "media_type": self.media_type.value,
+                    "item_id": db_id,
+                    "provider_instance": provider_instance_id,
+                },
+            )
 
         # update the item in db (provider_mappings column only)
         db_item.provider_mappings = {
@@ -456,11 +458,12 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
         }
         match = {"item_id": db_id}
         if db_item.provider_mappings:
-            await self.mass.music.database.update(
-                self.db_table,
-                match,
-                {"provider_mappings": serialize_to_json(db_item.provider_mappings)},
-            )
+            async with self._db_add_lock:
+                await self.mass.music.database.update(
+                    self.db_table,
+                    match,
+                    {"provider_mappings": serialize_to_json(db_item.provider_mappings)},
+                )
             self.logger.debug("removed provider %s from item id %s", provider_instance_id, db_id)
             self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, db_item.uri, db_item)
         else:
@@ -509,22 +512,23 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
         """Update the provider_items table for the media item."""
         db_id = int(item_id)  # ensure integer
         # clear all records first
-        await self.mass.music.database.delete(
-            DB_TABLE_PROVIDER_MAPPINGS,
-            {"media_type": self.media_type.value, "item_id": db_id},
-        )
-        # add entries
-        for provider_mapping in provider_mappings:
-            await self.mass.music.database.insert_or_replace(
+        async with self._db_add_lock:
+            await self.mass.music.database.delete(
                 DB_TABLE_PROVIDER_MAPPINGS,
-                {
-                    "media_type": self.media_type.value,
-                    "item_id": db_id,
-                    "provider_domain": provider_mapping.provider_domain,
-                    "provider_instance": provider_mapping.provider_instance,
-                    "provider_item_id": provider_mapping.item_id,
-                },
+                {"media_type": self.media_type.value, "item_id": db_id},
             )
+            # add entries
+            for provider_mapping in provider_mappings:
+                await self.mass.music.database.insert_or_replace(
+                    DB_TABLE_PROVIDER_MAPPINGS,
+                    {
+                        "media_type": self.media_type.value,
+                        "item_id": db_id,
+                        "provider_domain": provider_mapping.provider_domain,
+                        "provider_instance": provider_mapping.provider_instance,
+                        "provider_item_id": provider_mapping.item_id,
+                    },
+                )
 
     def _get_provider_mappings(
         self,
index e1bee2034901113ac8c846bc83433b3dd8c7a5f2..21b97e3c14c1bc1958803af22fb7f551ff24055b 100644 (file)
@@ -1,6 +1,7 @@
 """Manage MediaItems of type Playlist."""
 from __future__ import annotations
 
+import asyncio
 import random
 from collections.abc import AsyncGenerator
 from typing import Any
@@ -27,6 +28,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
     db_table = DB_TABLE_PLAYLISTS
     media_type = MediaType.PLAYLIST
     item_cls = Playlist
+    _db_add_lock = asyncio.Lock()
 
     def __init__(self, *args, **kwargs):
         """Initialize class."""
@@ -48,7 +50,9 @@ class PlaylistController(MediaControllerBase[Playlist]):
         # preload playlist tracks listing (do not load them in the db)
         async for track in self.tracks(item.item_id, item.provider):
             pass
-        existing = await self.get_db_item_by_prov_id(item.item_id, item.provider)
+        async with self._db_add_lock:
+            # use the lock to prevent a race condition of the same item being added twice
+            existing = await self.get_db_item_by_prov_id(item.item_id, item.provider)
         if existing:
             db_item = await self._update_db_item(existing.item_id, item)
         else:
@@ -96,7 +100,11 @@ class PlaylistController(MediaControllerBase[Playlist]):
         if provider is None:
             raise ProviderUnavailableError("No provider available which allows playlists creation.")
 
-        return await provider.create_playlist(name)
+        # create playlist on the provider
+        prov_playlist = await provider.create_playlist(name)
+        prov_playlist.in_library = True
+        # return db playlist
+        return await self.add(prov_playlist, True)
 
     async def add_playlist_tracks(self, db_playlist_id: str | int, uris: list[str]) -> None:
         """Add multiple tracks to playlist. Creates background tasks to process the action."""
@@ -196,8 +204,13 @@ class PlaylistController(MediaControllerBase[Playlist]):
     async def _add_db_item(self, item: Playlist) -> Playlist:
         """Add a new record to the database."""
         assert item.provider_mappings, "Item is missing provider mapping(s)"
-        match = {"name": item.name, "owner": item.owner}
-        if cur_item := await self.mass.music.database.get_row(self.db_table, match):
+        cur_item = None
+        # safety guard: check for existing item first
+        # use the lock to prevent a race condition of the same item being added twice
+        async with self._db_add_lock:
+            match = {"sort_name": item.sort_name, "owner": item.owner}
+            cur_item = await self.mass.music.database.get_row(self.db_table, match)
+        if cur_item:
             # update existing
             return await self._update_db_item(cur_item["item_id"], item)
         # insert new item
@@ -205,10 +218,10 @@ class PlaylistController(MediaControllerBase[Playlist]):
         item.timestamp_modified = int(utc_timestamp())
         async with self._db_add_lock:
             new_item = await self.mass.music.database.insert(self.db_table, item.to_db_row())
-            item_id = new_item["item_id"]
-            # update/set provider_mappings table
-            await self._set_provider_mappings(item_id, item.provider_mappings)
-            self.logger.debug("added %s to database", item.name)
+        item_id = new_item["item_id"]
+        # update/set provider_mappings table
+        await self._set_provider_mappings(item_id, item.provider_mappings)
+        self.logger.debug("added %s to database", item.name)
         # return created object
         return await self.get_db_item(item_id)
 
@@ -220,24 +233,23 @@ class PlaylistController(MediaControllerBase[Playlist]):
         cur_item = await self.get_db_item(db_id)
         metadata = cur_item.metadata.update(getattr(item, "metadata", None), overwrite)
         provider_mappings = self._get_provider_mappings(cur_item, item, overwrite)
-        async with self._db_add_lock:
-            await self.mass.music.database.update(
-                self.db_table,
-                {"item_id": db_id},
-                {
-                    # always prefer name/owner from updated item here
-                    "name": item.name or cur_item.name,
-                    "sort_name": item.sort_name or cur_item.sort_name,
-                    "owner": item.owner or cur_item.sort_name,
-                    "is_editable": item.is_editable,
-                    "metadata": serialize_to_json(metadata),
-                    "provider_mappings": serialize_to_json(provider_mappings),
-                    "timestamp_modified": int(utc_timestamp()),
-                },
-            )
-            # update/set provider_mappings table
-            await self._set_provider_mappings(db_id, provider_mappings)
-            self.logger.debug("updated %s in database: %s", item.name, db_id)
+        await self.mass.music.database.update(
+            self.db_table,
+            {"item_id": db_id},
+            {
+                # always prefer name/owner from updated item here
+                "name": item.name or cur_item.name,
+                "sort_name": item.sort_name or cur_item.sort_name,
+                "owner": item.owner or cur_item.sort_name,
+                "is_editable": item.is_editable,
+                "metadata": serialize_to_json(metadata),
+                "provider_mappings": serialize_to_json(provider_mappings),
+                "timestamp_modified": int(utc_timestamp()),
+            },
+        )
+        # update/set provider_mappings table
+        await self._set_provider_mappings(db_id, provider_mappings)
+        self.logger.debug("updated %s in database: %s", item.name, db_id)
         return await self.get_db_item(db_id)
 
     async def _get_provider_playlist_tracks(
index e8a174f9bfbf6b9a24026afb425eaf861b38bb52..ef85561ba10bbb1975218efb34b843af17713335 100644 (file)
@@ -19,6 +19,7 @@ class RadioController(MediaControllerBase[Radio]):
     db_table = DB_TABLE_RADIOS
     media_type = MediaType.RADIO
     item_cls = Radio
+    _db_add_lock = asyncio.Lock()
 
     def __init__(self, *args, **kwargs):
         """Initialize class."""
@@ -79,8 +80,13 @@ class RadioController(MediaControllerBase[Radio]):
     async def _add_db_item(self, item: Radio) -> Radio:
         """Add a new item record to the database."""
         assert item.provider_mappings, "Item is missing provider mapping(s)"
-        match = {"name": item.name}
-        if cur_item := await self.mass.music.database.get_row(self.db_table, match):
+        cur_item = None
+        # safety guard: check for existing item first
+        # use the lock to prevent a race condition of the same item being added twice
+        async with self._db_add_lock:
+            match = {"name": item.name}
+            cur_item = await self.mass.music.database.get_row(self.db_table, match)
+        if cur_item:
             # update existing
             return await self._update_db_item(cur_item["item_id"], item)
         # insert new item
@@ -88,10 +94,10 @@ class RadioController(MediaControllerBase[Radio]):
         item.timestamp_modified = int(utc_timestamp())
         async with self._db_add_lock:
             new_item = await self.mass.music.database.insert(self.db_table, item.to_db_row())
-            item_id = new_item["item_id"]
-            # update/set provider_mappings table
-            await self._set_provider_mappings(item_id, item.provider_mappings)
-            self.logger.debug("added %s to database", item.name)
+        item_id = new_item["item_id"]
+        # update/set provider_mappings table
+        await self._set_provider_mappings(item_id, item.provider_mappings)
+        self.logger.debug("added %s to database", item.name)
         # return created object
         return await self.get_db_item(item_id)
 
@@ -104,22 +110,21 @@ class RadioController(MediaControllerBase[Radio]):
         metadata = cur_item.metadata.update(getattr(item, "metadata", None), overwrite)
         provider_mappings = self._get_provider_mappings(cur_item, item, overwrite)
         match = {"item_id": db_id}
-        async with self._db_add_lock:
-            await self.mass.music.database.update(
-                self.db_table,
-                match,
-                {
-                    # always prefer name from updated item here
-                    "name": item.name or cur_item.name,
-                    "sort_name": item.sort_name or cur_item.sort_name,
-                    "metadata": serialize_to_json(metadata),
-                    "provider_mappings": serialize_to_json(provider_mappings),
-                    "timestamp_modified": int(utc_timestamp()),
-                },
-            )
-            # update/set provider_mappings table
-            await self._set_provider_mappings(db_id, provider_mappings)
-            self.logger.debug("updated %s in database: %s", item.name, db_id)
+        await self.mass.music.database.update(
+            self.db_table,
+            match,
+            {
+                # always prefer name from updated item here
+                "name": item.name or cur_item.name,
+                "sort_name": item.sort_name or cur_item.sort_name,
+                "metadata": serialize_to_json(metadata),
+                "provider_mappings": serialize_to_json(provider_mappings),
+                "timestamp_modified": int(utc_timestamp()),
+            },
+        )
+        # update/set provider_mappings table
+        await self._set_provider_mappings(db_id, provider_mappings)
+        self.logger.debug("updated %s in database: %s", item.name, db_id)
         return await self.get_db_item(db_id)
 
     async def _get_provider_dynamic_tracks(
index fdbb1fdd8777ff015651b90236a5be48db621d8d..619842ccc501be2fd333b9acd4dc98e3c21542f0 100644 (file)
@@ -35,6 +35,7 @@ class TracksController(MediaControllerBase[Track]):
     db_table = DB_TABLE_TRACKS
     media_type = MediaType.TRACK
     item_cls = DbTrack
+    _db_add_lock = asyncio.Lock()
 
     def __init__(self, *args, **kwargs):
         """Initialize class."""
@@ -130,7 +131,9 @@ class TracksController(MediaControllerBase[Track]):
         # grab additional metadata
         if not skip_metadata_lookup:
             await self.mass.metadata.get_track_metadata(item)
-        existing = await self.get_db_item_by_prov_id(item.item_id, item.provider)
+        async with self._db_add_lock:
+            # use the lock to prevent a race condition of the same item being added twice
+            existing = await self.get_db_item_by_prov_id(item.item_id, item.provider)
         if existing:
             db_item = await self._update_db_item(existing.item_id, item)
         else:
@@ -284,22 +287,27 @@ class TracksController(MediaControllerBase[Track]):
         assert item.provider_mappings, "Track is missing provider mapping(s)"
         cur_item = None
 
-        # always try to grab existing item by external_id
-        if item.musicbrainz_id:
-            match = {"musicbrainz_id": item.musicbrainz_id}
-            cur_item = await self.mass.music.database.get_row(self.db_table, match)
-        for isrc in item.isrc:
-            if search_result := await self.mass.music.database.search(self.db_table, isrc, "isrc"):
-                cur_item = Track.from_db_row(search_result[0])
-                break
-        if not cur_item:
-            # fallback to matching
-            match = {"sort_name": item.sort_name}
-            for row in await self.mass.music.database.get_rows(self.db_table, match):
-                row_track = Track.from_db_row(row)
-                if compare_track(row_track, item):
-                    cur_item = row_track
+        # safety guard: check for existing item first
+        # use the lock to prevent a race condition of the same item being added twice
+        async with self._db_add_lock:
+            # always try to grab existing item by external_id
+            if item.musicbrainz_id:
+                match = {"musicbrainz_id": item.musicbrainz_id}
+                cur_item = await self.mass.music.database.get_row(self.db_table, match)
+            for isrc in item.isrc:
+                if search_result := await self.mass.music.database.search(
+                    self.db_table, isrc, "isrc"
+                ):
+                    cur_item = Track.from_db_row(search_result[0])
                     break
+            if not cur_item:
+                # fallback to matching
+                match = {"sort_name": item.sort_name}
+                for row in await self.mass.music.database.get_rows(self.db_table, match):
+                    row_track = Track.from_db_row(row)
+                    if compare_track(row_track, item):
+                        cur_item = row_track
+                        break
         if cur_item:
             # update existing
             return await self._update_db_item(cur_item.item_id, item)
@@ -322,11 +330,11 @@ class TracksController(MediaControllerBase[Track]):
                     "timestamp_modified": int(utc_timestamp()),
                 },
             )
-            item_id = new_item["item_id"]
-            # update/set provider_mappings table
-            await self._set_provider_mappings(item_id, item.provider_mappings)
-            # return created object
-            self.logger.debug("added %s to database: %s", item.name, item_id)
+        item_id = new_item["item_id"]
+        # update/set provider_mappings table
+        await self._set_provider_mappings(item_id, item.provider_mappings)
+        # return created object
+        self.logger.debug("added %s to database: %s", item.name, item_id)
         return await self.get_db_item(item_id)
 
     async def _update_db_item(
@@ -341,26 +349,25 @@ class TracksController(MediaControllerBase[Track]):
             cur_item.isrc.update(item.isrc)
         track_artists = await self._get_artist_mappings(cur_item, item, overwrite=overwrite)
         track_albums = await self._get_track_albums(cur_item, item, overwrite=overwrite)
-        async with self._db_add_lock:
-            await self.mass.music.database.update(
-                self.db_table,
-                {"item_id": db_id},
-                {
-                    "name": item.name or cur_item.name,
-                    "sort_name": item.sort_name or cur_item.sort_name,
-                    "version": item.version or cur_item.version,
-                    "duration": getattr(item, "duration", None) or cur_item.duration,
-                    "artists": serialize_to_json(track_artists),
-                    "albums": serialize_to_json(track_albums),
-                    "metadata": serialize_to_json(metadata),
-                    "provider_mappings": serialize_to_json(provider_mappings),
-                    "isrc": ";".join(cur_item.isrc),
-                    "timestamp_modified": int(utc_timestamp()),
-                },
-            )
-            # update/set provider_mappings table
-            await self._set_provider_mappings(db_id, provider_mappings)
-            self.logger.debug("updated %s in database: %s", item.name, db_id)
+        await self.mass.music.database.update(
+            self.db_table,
+            {"item_id": db_id},
+            {
+                "name": item.name or cur_item.name,
+                "sort_name": item.sort_name or cur_item.sort_name,
+                "version": item.version or cur_item.version,
+                "duration": getattr(item, "duration", None) or cur_item.duration,
+                "artists": serialize_to_json(track_artists),
+                "albums": serialize_to_json(track_albums),
+                "metadata": serialize_to_json(metadata),
+                "provider_mappings": serialize_to_json(provider_mappings),
+                "isrc": ";".join(cur_item.isrc),
+                "timestamp_modified": int(utc_timestamp()),
+            },
+        )
+        # update/set provider_mappings table
+        await self._set_provider_mappings(db_id, provider_mappings)
+        self.logger.debug("updated %s in database: %s", item.name, db_id)
         return await self.get_db_item(db_id)
 
     async def _get_track_albums(
index 9aaff99455975bd02a12f49a1d5a23bc49b58695..5dee566993bbe2d68bb28b99aa22d2493f156b05 100755 (executable)
@@ -312,7 +312,7 @@ class MetaDataController:
             # return imageproxy url for images that need to be resolved
             # the original path is double encoded
             encoded_url = urllib.parse.quote(urllib.parse.quote(image.path))
-            return f"{self.mass.webserver.base_url}/imageproxy?path={encoded_url}"
+            return f"{self.mass.webserver.base_url}/imageproxy?path={encoded_url}&provider={image.provider}"  # noqa: E501
         return image.path
 
     async def get_thumbnail(
index 9c126af6f98c5fd69bb95937c76b970bc309865d..fe41107ab28623a90a0f7a2c5cc6f222c5fa6054 100644 (file)
@@ -8,6 +8,7 @@ from abc import abstractmethod
 from collections.abc import AsyncGenerator
 from dataclasses import dataclass
 
+import cchardet
 import xmltodict
 
 from music_assistant.common.helpers.util import parse_title_and_version
@@ -449,7 +450,8 @@ class FileSystemProviderBase(MusicProvider):
             playlist_data = b""
             async for chunk in self.read_file_content(prov_playlist_id):
                 playlist_data += chunk
-            playlist_data = playlist_data.decode("utf-8")
+            encoding_details = await asyncio.to_thread(cchardet.detect, playlist_data)
+            playlist_data = playlist_data.decode(encoding_details["encoding"])
 
             if ext in ("m3u", "m3u8"):
                 playlist_lines = await parse_m3u(playlist_data)
@@ -489,11 +491,12 @@ class FileSystemProviderBase(MusicProvider):
         playlist_data = b""
         async for chunk in self.read_file_content(prov_playlist_id):
             playlist_data += chunk
-        playlist_data = playlist_data.decode("utf-8")
+        encoding_details = await asyncio.to_thread(cchardet.detect, playlist_data)
+        playlist_data = playlist_data.decode(encoding_details["encoding"])
         for uri in prov_track_ids:
             playlist_data += f"\n{uri}"
 
-        # write playlist file
+        # write playlist file (always in utf-8)
         await self.write_file_content(prov_playlist_id, playlist_data.encode("utf-8"))
 
     async def remove_playlist_tracks(
@@ -509,7 +512,8 @@ class FileSystemProviderBase(MusicProvider):
         playlist_data = b""
         async for chunk in self.read_file_content(prov_playlist_id):
             playlist_data += chunk
-        playlist_data.decode("utf-8")
+        encoding_details = await asyncio.to_thread(cchardet.detect, playlist_data)
+        playlist_data = playlist_data.decode(encoding_details["encoding"])
 
         if ext in ("m3u", "m3u8"):
             playlist_lines = await parse_m3u(playlist_data)
@@ -521,7 +525,7 @@ class FileSystemProviderBase(MusicProvider):
                 cur_lines.append(playlist_line)
 
         new_playlist_data = "\n".join(cur_lines)
-        # write playlist file
+        # write playlist file (always in utf-8)
         await self.write_file_content(prov_playlist_id, new_playlist_data.encode("utf-8"))
 
     async def create_playlist(self, name: str) -> Playlist:
@@ -531,9 +535,7 @@ class FileSystemProviderBase(MusicProvider):
         # filename = await self.resolve(f"{name}.m3u")
         filename = f"{name}.m3u"
         await self.write_file_content(filename, b"")
-        playlist = await self.get_playlist(filename)
-        db_playlist = await self.mass.music.playlists.add(playlist, skip_metadata_lookup=True)
-        return db_playlist
+        return await self.get_playlist(filename)
 
     async def get_stream_details(self, item_id: str) -> StreamDetails:
         """Return the content details for the given track when it will be streamed."""
index 8c33fe0cad129262a717bfbc6f3b8f7be0365dc6..b4084664ba51c958f98c34ba1caec9abad60c458 100644 (file)
@@ -130,7 +130,6 @@ class RadioBrowserProvider(MusicProvider):
     async def get_stream_details(self, item_id: str) -> StreamDetails:
         """Get streamdetails for a radio station."""
         stream = await self.radios.station(uuid=item_id)
-        url = stream.url
         url_resolved = stream.url_resolved
         await self.radios.station_click(uuid=item_id)
         return StreamDetails(
@@ -138,9 +137,8 @@ class RadioBrowserProvider(MusicProvider):
             item_id=item_id,
             content_type=ContentType.try_parse(stream.codec),
             media_type=MediaType.RADIO,
-            data=url,
+            data=url_resolved,
             expires=time() + 24 * 3600,
-            direct=url_resolved,
         )
 
     async def get_audio_stream(
index 4c3ab2497059dd4ce416ec9c7d527df6668ac9a4..4a68a459d469922b94a1c0fe44e8c3e84b7747e0 100644 (file)
@@ -8,6 +8,7 @@
   "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/categories/music-providers",
   "multi_instance": false,
   "builtin": true,
+  "hidden": true,
   "load_by_default": true,
   "icon": "mdi:mdi-web"
 }
index 96830fe1c24330b79c28caa08e4199dd87c6db70..9ef085c2f1e324ab8657c7c83263df2686f60644 100644 (file)
@@ -8,6 +8,7 @@
   "documentation": "",
   "multi_instance": false,
   "builtin": true,
+  "hidden": true,
   "load_by_default": true,
   "icon": "md:webhook"
 }
index 48bf6701a6eaf5e0c9247fb9f9ed3100de090ab1..ec1c7b96af9ac95f87c23b94a796c0aa059bc5c1 100644 (file)
@@ -37,7 +37,7 @@ server = [
   "python-slugify==8.0.1",
   "mashumaro==3.7",
   "memory-tempfile==2.2.3",
-  "music-assistant-frontend==20230419.0",
+  "music-assistant-frontend==20230420.0",
   "pillow==9.5.0",
   "unidecode==1.3.6",
   "xmltodict==0.13.0",
index f189426a4eda87a69dcf8c21c73cc5f634e5473a..fa53c4016bf48be8e7a3196c1855374161e0a3e7 100644 (file)
@@ -18,7 +18,7 @@ git+https://github.com/jozefKruszynski/python-tidal.git@v0.7.1
 git+https://github.com/pytube/pytube.git@refs/pull/1501/head
 mashumaro==3.7
 memory-tempfile==2.2.3
-music-assistant-frontend==20230419.0
+music-assistant-frontend==20230420.0
 orjson==3.8.9
 pillow==9.5.0
 plexapi==4.13.4
index c6111932c0eaab29bd006e505ed46a2ea1f2e100..b0da3f4ffb9f3fa73ae7c23941b9f4d1882c1492 100644 (file)
@@ -68,7 +68,7 @@ def main() -> int:
             # duplicate package without version is safe to ignore
             continue
         else:
-            print("Found requirement without version specifier: %s" % req_str)
+            print("Found requirement without (exact) version specifier: %s" % req_str)
             package_name = req_str
 
         existing = final_requirements.get(package_name)