From: Marcel van der Veldt Date: Thu, 20 Apr 2023 14:02:36 +0000 (+0200) Subject: Various small bugfixes and improvements (#640) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=a302867954ab22a12408d039deef9a345e73d1a6;p=music-assistant-server.git Various small bugfixes and improvements (#640) * 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 --- diff --git a/music_assistant/common/models/provider.py b/music_assistant/common/models/provider.py index 59fb538e..5e6a66f7 100644 --- a/music_assistant/common/models/provider.py +++ b/music_assistant/common/models/provider.py @@ -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 diff --git a/music_assistant/server/controllers/config.py b/music_assistant/server/controllers/config.py index 288c4e81..a78e00bd 100644 --- a/music_assistant/server/controllers/config.py +++ b/music_assistant/server/controllers/config.py @@ -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( diff --git a/music_assistant/server/controllers/media/albums.py b/music_assistant/server/controllers/media/albums.py index e2c23b0a..b4223322 100644 --- a/music_assistant/server/controllers/media/albums.py +++ b/music_assistant/server/controllers/media/albums.py @@ -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( diff --git a/music_assistant/server/controllers/media/artists.py b/music_assistant/server/controllers/media/artists.py index e8458636..aaf68c5c 100644 --- a/music_assistant/server/controllers/media/artists.py +++ b/music_assistant/server/controllers/media/artists.py @@ -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( diff --git a/music_assistant/server/controllers/media/base.py b/music_assistant/server/controllers/media/base.py index 12d16cda..ed03125c 100644 --- a/music_assistant/server/controllers/media/base.py +++ b/music_assistant/server/controllers/media/base.py @@ -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, diff --git a/music_assistant/server/controllers/media/playlists.py b/music_assistant/server/controllers/media/playlists.py index e1bee203..21b97e3c 100644 --- a/music_assistant/server/controllers/media/playlists.py +++ b/music_assistant/server/controllers/media/playlists.py @@ -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( diff --git a/music_assistant/server/controllers/media/radio.py b/music_assistant/server/controllers/media/radio.py index e8a174f9..ef85561b 100644 --- a/music_assistant/server/controllers/media/radio.py +++ b/music_assistant/server/controllers/media/radio.py @@ -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( diff --git a/music_assistant/server/controllers/media/tracks.py b/music_assistant/server/controllers/media/tracks.py index fdbb1fdd..619842cc 100644 --- a/music_assistant/server/controllers/media/tracks.py +++ b/music_assistant/server/controllers/media/tracks.py @@ -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( diff --git a/music_assistant/server/controllers/metadata.py b/music_assistant/server/controllers/metadata.py index 9aaff994..5dee5669 100755 --- a/music_assistant/server/controllers/metadata.py +++ b/music_assistant/server/controllers/metadata.py @@ -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( diff --git a/music_assistant/server/providers/filesystem_local/base.py b/music_assistant/server/providers/filesystem_local/base.py index 9c126af6..fe41107a 100644 --- a/music_assistant/server/providers/filesystem_local/base.py +++ b/music_assistant/server/providers/filesystem_local/base.py @@ -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.""" diff --git a/music_assistant/server/providers/radiobrowser/__init__.py b/music_assistant/server/providers/radiobrowser/__init__.py index 8c33fe0c..b4084664 100644 --- a/music_assistant/server/providers/radiobrowser/__init__.py +++ b/music_assistant/server/providers/radiobrowser/__init__.py @@ -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( diff --git a/music_assistant/server/providers/url/manifest.json b/music_assistant/server/providers/url/manifest.json index 4c3ab249..4a68a459 100644 --- a/music_assistant/server/providers/url/manifest.json +++ b/music_assistant/server/providers/url/manifest.json @@ -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" } diff --git a/music_assistant/server/providers/websocket_api/manifest.json b/music_assistant/server/providers/websocket_api/manifest.json index 96830fe1..9ef085c2 100644 --- a/music_assistant/server/providers/websocket_api/manifest.json +++ b/music_assistant/server/providers/websocket_api/manifest.json @@ -8,6 +8,7 @@ "documentation": "", "multi_instance": false, "builtin": true, + "hidden": true, "load_by_default": true, "icon": "md:webhook" } diff --git a/pyproject.toml b/pyproject.toml index 48bf6701..ec1c7b96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/requirements_all.txt b/requirements_all.txt index f189426a..fa53c401 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index c6111932..b0da3f4f 100644 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -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)