From: Marcel van der Veldt Date: Mon, 10 Jun 2024 00:23:14 +0000 (+0200) Subject: Several small bugfixes (#1336) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=892a6f61feda3d7e44065d8c13556055a8269437;p=music-assistant-server.git Several small bugfixes (#1336) * Fix enum in config entry * Fix server-side paging for unknown length lists Dont assume list length from limit * Fix smb mount with spaces * Fix paged_items for playlist tracks as well * add some comments * More fixes for paged listings * fix tunein listing * Update __init__.py --- diff --git a/music_assistant/common/models/config_entries.py b/music_assistant/common/models/config_entries.py index 818ee838..c080f5e5 100644 --- a/music_assistant/common/models/config_entries.py +++ b/music_assistant/common/models/config_entries.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging from collections.abc import Iterable from dataclasses import dataclass +from enum import Enum from types import NoneType from typing import Any @@ -194,6 +195,9 @@ class Config(DataClassDictMixin): """Parse Config from the raw values (as stored in persistent storage).""" conf = cls.from_dict({**raw, "values": {}}) for entry in config_entries: + # unpack Enum value in default_value + if isinstance(entry.default_value, Enum): + entry.default_value = entry.default_value.value # create a copy of the entry conf.values[entry.key] = ConfigEntry.from_dict(entry.to_dict()) conf.values[entry.key].parse_value( diff --git a/music_assistant/common/models/media_items.py b/music_assistant/common/models/media_items.py index 4fa8dd0a..5af397d7 100644 --- a/music_assistant/common/models/media_items.py +++ b/music_assistant/common/models/media_items.py @@ -568,8 +568,8 @@ class PagedItems(Generic[_T]): items: list[_T], limit: int, offset: int, + total: int | None, count: int | None = None, - total: int | None = None, ): """Initialize PagedItems.""" self.items = items @@ -577,9 +577,6 @@ class PagedItems(Generic[_T]): self.limit = limit self.offset = offset self.total = total - if total is None and count != limit: - # total is important so always calculate it from count if omitted - self.total = offset + count def to_dict(self, *args, **kwargs) -> dict[str, Any]: """Return PagedItems as serializable dict.""" diff --git a/music_assistant/server/controllers/media/artists.py b/music_assistant/server/controllers/media/artists.py index a0396182..551d7933 100644 --- a/music_assistant/server/controllers/media/artists.py +++ b/music_assistant/server/controllers/media/artists.py @@ -9,7 +9,11 @@ from typing import TYPE_CHECKING, Any from music_assistant.common.helpers.json import serialize_to_json from music_assistant.common.models.enums import ProviderFeature -from music_assistant.common.models.errors import MediaNotFoundError, UnsupportedFeaturedException +from music_assistant.common.models.errors import ( + MediaNotFoundError, + ProviderUnavailableError, + UnsupportedFeaturedException, +) from music_assistant.common.models.media_items import ( Album, AlbumType, @@ -441,9 +445,10 @@ class ArtistsController(MediaControllerBase[Artist]): if len(ref_tracks) < 10: # fetch reference tracks from provider(s) attached to the artist for provider_mapping in db_artist.provider_mappings: - ref_tracks += await self.mass.music.artists.tracks( - provider_mapping.item_id, provider_mapping.provider_instance - ) + with contextlib.suppress(ProviderUnavailableError, MediaNotFoundError): + ref_tracks += await self.mass.music.artists.tracks( + provider_mapping.item_id, provider_mapping.provider_instance + ) for ref_track in ref_tracks: for search_str in ( f"{db_artist.name} - {ref_track.name}", @@ -476,9 +481,10 @@ class ArtistsController(MediaControllerBase[Artist]): if len(ref_albums) < 10: # fetch reference albums from provider(s) attached to the artist for provider_mapping in db_artist.provider_mappings: - ref_albums += await self.mass.music.artists.albums( - provider_mapping.item_id, provider_mapping.provider_instance - ) + with contextlib.suppress(ProviderUnavailableError, MediaNotFoundError): + ref_albums += await self.mass.music.artists.albums( + provider_mapping.item_id, provider_mapping.provider_instance + ) for ref_album in ref_albums: if ref_album.album_type == AlbumType.COMPILATION: continue diff --git a/music_assistant/server/controllers/media/playlists.py b/music_assistant/server/controllers/media/playlists.py index b4d3261d..728d4a55 100644 --- a/music_assistant/server/controllers/media/playlists.py +++ b/music_assistant/server/controllers/media/playlists.py @@ -58,11 +58,12 @@ class PlaylistController(MediaControllerBase[Playlist]): force_refresh=force_refresh, lazy=not force_refresh, ) - prov = next(x for x in playlist.provider_mappings) + prov_map = next(x for x in playlist.provider_mappings) + cache_checksum = playlist.metadata.cache_checksum tracks = await self._get_provider_playlist_tracks( - prov.item_id, - prov.provider_instance, - cache_checksum=playlist.metadata.cache_checksum, + prov_map.item_id, + prov_map.provider_instance, + cache_checksum=cache_checksum, offset=offset, limit=limit, ) @@ -84,7 +85,9 @@ class PlaylistController(MediaControllerBase[Playlist]): final_tracks.append(track) else: final_tracks = tracks - return PagedItems(items=final_tracks, limit=limit, offset=offset) + # we set total to None as we have no idea how many tracks there are + # the frontend can figure this out and stop paging when it gets an empty list + return PagedItems(items=final_tracks, limit=limit, offset=offset, total=None) async def create_playlist( self, name: str, provider_instance_or_domain: str | None = None @@ -290,7 +293,7 @@ class PlaylistController(MediaControllerBase[Playlist]): playlist.name, ) while True: - paged_items = await self.mass.music.playlists.tracks( + paged_items = await self.tracks( item_id=playlist.item_id, provider_instance_id_or_domain=playlist.provider, offset=offset, @@ -298,7 +301,9 @@ class PlaylistController(MediaControllerBase[Playlist]): prefer_library_items=prefer_library_items, ) result += paged_items.items - if paged_items.count != limit: + if paged_items.total is not None and len(result) >= paged_items.total: + break + if paged_items.count == 0: break offset += paged_items.count return result diff --git a/music_assistant/server/controllers/music.py b/music_assistant/server/controllers/music.py index a236c9d1..41b0acf4 100644 --- a/music_assistant/server/controllers/music.py +++ b/music_assistant/server/controllers/music.py @@ -335,7 +335,7 @@ class MusicController(CoreController): name=prov.name, ) ) - return PagedItems(items=root_items, limit=limit, offset=offset) + return PagedItems(items=root_items, limit=limit, offset=offset, total=len(root_items)) # provider level prepend_items: list[MediaItemType] = [] @@ -347,7 +347,9 @@ class MusicController(CoreController): BrowseFolder(item_id="root", provider="library", path="root", name="..") ) if not prov: - return PagedItems(items=prepend_items, limit=limit, offset=offset) + return PagedItems( + items=prepend_items, limit=limit, offset=offset, total=len(prepend_items) + ) elif offset == 0: back_path = f"{provider_instance}://" + "/".join(sub_path.split("/")[:-1]) prepend_items.append( @@ -356,6 +358,8 @@ class MusicController(CoreController): # limit -1 to account for the prepended items prov_items = await prov.browse(path, offset=offset, limit=limit) prov_items.items = prepend_items + prov_items.items + if prov_items.total is not None: + prov_items.total += len(prepend_items) return prov_items @api_command("music/recently_played_items") diff --git a/music_assistant/server/controllers/player_queues.py b/music_assistant/server/controllers/player_queues.py index c93323c8..aa832384 100644 --- a/music_assistant/server/controllers/player_queues.py +++ b/music_assistant/server/controllers/player_queues.py @@ -209,7 +209,7 @@ class PlayerQueuesController(CoreController): def items(self, queue_id: str, limit: int = 500, offset: int = 0) -> PagedItems[QueueItem]: """Return all QueueItems for given PlayerQueue.""" if queue_id not in self._queue_items: - return PagedItems(items=[], limit=limit, offset=offset) + return PagedItems(items=[], limit=limit, offset=offset, total=0) return PagedItems( items=self._queue_items[queue_id][offset : offset + limit], diff --git a/music_assistant/server/models/music_provider.py b/music_assistant/server/models/music_provider.py index 09c3e395..33d99bdd 100644 --- a/music_assistant/server/models/music_provider.py +++ b/music_assistant/server/models/music_provider.py @@ -320,6 +320,8 @@ class MusicProvider(Provider): items.append(item) if len(items) >= limit: break + # explicitly set total to None as we don't know the total count + total = None else: # no subpath: return main listing if ProviderFeature.LIBRARY_ARTISTS in self.supported_features: @@ -372,7 +374,8 @@ class MusicProvider(Provider): label="radios", ) ) - return PagedItems(items=items, limit=limit, offset=offset) + total = len(items) + return PagedItems(items=items, limit=limit, offset=offset, total=total) async def recommendations(self) -> list[MediaItemType]: """Get this provider's recommendations. diff --git a/music_assistant/server/providers/builtin/__init__.py b/music_assistant/server/providers/builtin/__init__.py index 13f719dd..5c0d6fa2 100644 --- a/music_assistant/server/providers/builtin/__init__.py +++ b/music_assistant/server/providers/builtin/__init__.py @@ -352,7 +352,8 @@ class BuiltinProvider(MusicProvider): ) -> list[Track]: """Get playlist tracks.""" if prov_playlist_id in BUILTIN_PLAYLISTS: - return await self._get_builtin_playlist_tracks(prov_playlist_id) + result = await self._get_builtin_playlist_tracks(prov_playlist_id) + return result[offset : offset + limit] # user created universal playlist result: list[Track] = [] playlist_items = await self._read_playlist_file_items(prov_playlist_id, offset, limit) diff --git a/music_assistant/server/providers/deezer/__init__.py b/music_assistant/server/providers/deezer/__init__.py index 15377095..0e87992f 100644 --- a/music_assistant/server/providers/deezer/__init__.py +++ b/music_assistant/server/providers/deezer/__init__.py @@ -327,7 +327,7 @@ class DeezerProvider(MusicProvider): # pylint: disable=W0223 result: list[Track] = [] # TODO: implement pagination! playlist = await self.client.get_playlist(int(prov_playlist_id)) - for index, deezer_track in enumerate(await playlist.get_tracks()): + for index, deezer_track in enumerate(await playlist.get_tracks(offset=offset, limit=limit)): result.append( self.parse_track( track=deezer_track, diff --git a/music_assistant/server/providers/filesystem_local/base.py b/music_assistant/server/providers/filesystem_local/base.py index e0b49894..7e563e43 100644 --- a/music_assistant/server/providers/filesystem_local/base.py +++ b/music_assistant/server/providers/filesystem_local/base.py @@ -336,7 +336,8 @@ class FileSystemProviderBase(MusicProvider): index += 1 if len(items) >= limit: break - return PagedItems(items=items, limit=limit, offset=offset) + total = len(items) if len(items) < limit else None + return PagedItems(items=items, limit=limit, offset=offset, total=total) async def sync_library(self, media_types: tuple[MediaType, ...]) -> None: """Run library sync for this provider.""" diff --git a/music_assistant/server/providers/filesystem_smb/__init__.py b/music_assistant/server/providers/filesystem_smb/__init__.py index 268beeca..da94ee9e 100644 --- a/music_assistant/server/providers/filesystem_smb/__init__.py +++ b/music_assistant/server/providers/filesystem_smb/__init__.py @@ -186,7 +186,7 @@ class SMBFileSystemProvider(LocalFileSystemProvider): if platform.system() == "Darwin": password_str = f":{password}" if password else "" - mount_cmd = f"mount -t smbfs //{username}{password_str}@{server}/{share}{subfolder} {self.base_path}" # noqa: E501 + mount_cmd = f'mount -t smbfs "//{username}{password_str}@{server}/{share}{subfolder}" "{self.base_path}"' # noqa: E501 elif platform.system() == "Linux": options = [ @@ -197,7 +197,12 @@ class SMBFileSystemProvider(LocalFileSystemProvider): options.append(f'password="{password}"') if mount_options := self.config.get_value(CONF_MOUNT_OPTIONS): options += mount_options.split(",") - mount_cmd = f"mount -t cifs -o {','.join(options)} //{server}/{share}{subfolder} {self.base_path}" # noqa: E501 + + options_str = ",".join(options) + mount_cmd = ( + f"mount -t cifs -o {options_str} " + f'"//{server}/{share}{subfolder}" "{self.base_path}"' + ) else: msg = f"SMB provider is not supported on {platform.system()}" diff --git a/music_assistant/server/providers/jellyfin/__init__.py b/music_assistant/server/providers/jellyfin/__init__.py index e67f58ec..9004bce4 100644 --- a/music_assistant/server/providers/jellyfin/__init__.py +++ b/music_assistant/server/providers/jellyfin/__init__.py @@ -691,7 +691,7 @@ class JellyfinProvider(MusicProvider): ) if not playlist_items: return result - for index, jellyfin_track in enumerate(playlist_items): + for index, jellyfin_track in enumerate(playlist_items[offset : offset + limit]): try: if track := await self._parse_track(jellyfin_track): if not track.position: diff --git a/music_assistant/server/providers/opensubsonic/sonic_provider.py b/music_assistant/server/providers/opensubsonic/sonic_provider.py index d77394e0..0c35318e 100644 --- a/music_assistant/server/providers/opensubsonic/sonic_provider.py +++ b/music_assistant/server/providers/opensubsonic/sonic_provider.py @@ -665,7 +665,7 @@ class OpenSonicProvider(MusicProvider): except (ParameterError, DataNotFoundError) as e: msg = f"Playlist {prov_playlist_id} not found" raise MediaNotFoundError(msg) from e - for index, sonic_song in enumerate(sonic_playlist.songs): + for index, sonic_song in enumerate(sonic_playlist.songs[offset : offset + limit]): track = self._parse_track(sonic_song) track.position = index result.append(track) diff --git a/music_assistant/server/providers/plex/__init__.py b/music_assistant/server/providers/plex/__init__.py index 32f5fc8b..3ef3a304 100644 --- a/music_assistant/server/providers/plex/__init__.py +++ b/music_assistant/server/providers/plex/__init__.py @@ -814,7 +814,7 @@ class PlexProvider(MusicProvider): plex_playlist: PlexPlaylist = await self._get_data(prov_playlist_id, PlexPlaylist) if not (playlist_items := await self._run_async(plex_playlist.items)): return result - for index, plex_track in enumerate(playlist_items): + for index, plex_track in enumerate(playlist_items[offset : offset + limit]): if track := await self._parse_track(plex_track): track.position = index result.append(track) diff --git a/music_assistant/server/providers/radiobrowser/__init__.py b/music_assistant/server/providers/radiobrowser/__init__.py index a4f5d9da..eb8e6c07 100644 --- a/music_assistant/server/providers/radiobrowser/__init__.py +++ b/music_assistant/server/providers/radiobrowser/__init__.py @@ -138,12 +138,13 @@ class RadioBrowserProvider(MusicProvider): ] if subpath == "popular": - items = await self.get_by_popularity() + items = await self.get_by_popularity(limit=limit, offset=offset) if subpath == "tag": tags = await self.radios.tags( hide_broken=True, - limit=100, + limit=limit, + offset=offset, order=Order.STATION_COUNT, reverse=True, ) @@ -159,7 +160,9 @@ class RadioBrowserProvider(MusicProvider): ] if subpath == "country": - for country in await self.radios.countries(order=Order.NAME): + for country in await self.radios.countries( + order=Order.NAME, hide_broken=True, limit=limit, offset=offset + ): folder = BrowseFolder( item_id=country.code.lower(), provider=self.domain, @@ -176,18 +179,20 @@ class RadioBrowserProvider(MusicProvider): ] items.append(folder) - if subsubpath in await self.get_tag_names(): + if subsubpath in await self.get_tag_names(limit=limit, offset=offset): items = await self.get_by_tag(subsubpath) - if subsubpath in await self.get_country_codes(): + if subsubpath in await self.get_country_codes(limit=limit, offset=offset): items = await self.get_by_country(subsubpath) - return PagedItems(items=items, limit=limit, offset=offset) + total = len(items) if len(items) < limit else None + return PagedItems(items=items, limit=limit, offset=offset, total=total) - async def get_tag_names(self): + async def get_tag_names(self, limit: int, offset: int): """Get a list of tag names.""" tags = await self.radios.tags( hide_broken=True, - limit=100, + limit=limit, + offset=offset, order=Order.STATION_COUNT, reverse=True, ) @@ -197,19 +202,22 @@ class RadioBrowserProvider(MusicProvider): tag_names.append(tag.name.lower()) return tag_names - async def get_country_codes(self): + async def get_country_codes(self, limit: int, offset: int): """Get a list of country names.""" - countries = await self.radios.countries(order=Order.NAME) + countries = await self.radios.countries( + order=Order.NAME, hide_broken=True, limit=limit, offset=offset + ) country_codes = [] for country in countries: country_codes.append(country.code.lower()) return country_codes - async def get_by_popularity(self): + async def get_by_popularity(self, limit: int, offset: int): """Get radio stations by popularity.""" stations = await self.radios.stations( hide_broken=True, - limit=250, + limit=limit, + offset=offset, order=Order.CLICK_COUNT, reverse=True, ) diff --git a/music_assistant/server/providers/soundcloud/__init__.py b/music_assistant/server/providers/soundcloud/__init__.py index cb94eca3..7148fc47 100644 --- a/music_assistant/server/providers/soundcloud/__init__.py +++ b/music_assistant/server/providers/soundcloud/__init__.py @@ -264,7 +264,7 @@ class SoundcloudMusicProvider(MusicProvider): playlist_obj = await self._soundcloud.get_playlist_details(playlist_id=prov_playlist_id) if "tracks" not in playlist_obj: return result - for index, item in enumerate(playlist_obj["tracks"]): + for index, item in enumerate(playlist_obj["tracks"][offset : offset + limit]): song = await self._soundcloud.get_track_details(item["id"]) try: # TODO: is it really needed to grab the entire track with an api call ? diff --git a/music_assistant/server/providers/tunein/__init__.py b/music_assistant/server/providers/tunein/__init__.py index a489de9f..0090073b 100644 --- a/music_assistant/server/providers/tunein/__init__.py +++ b/music_assistant/server/providers/tunein/__init__.py @@ -20,7 +20,6 @@ from music_assistant.common.models.media_items import ( ) from music_assistant.common.models.streamdetails import StreamDetails from music_assistant.constants import CONF_USERNAME -from music_assistant.server.helpers.tags import parse_tags from music_assistant.server.models.music_provider import MusicProvider SUPPORTED_FEATURES = ( @@ -109,13 +108,12 @@ class TuneInProvider(MusicProvider): if "preset_id" not in item: continue # each radio station can have multiple streams add each one as different quality - stream_info = await self.__get_data("Tune.ashx", id=item["preset_id"]) - for stream in stream_info["body"]: - yield await self._parse_radio(item, stream, folder) + stream_info = await self._get_stream_info(item["preset_id"]) + yield self._parse_radio(item, stream_info, folder) elif item_type == "link" and item.get("item") == "url": # custom url try: - yield await self._parse_radio(item) + yield self._parse_radio(item) except InvalidDataError as err: # there may be invalid custom urls, ignore those self.logger.warning(str(err)) @@ -137,16 +135,19 @@ class TuneInProvider(MusicProvider): async def get_radio(self, prov_radio_id: str) -> Radio: """Get radio station details.""" if not prov_radio_id.startswith("http"): - prov_radio_id, media_type = prov_radio_id.split("--", 1) + if "--" in prov_radio_id: + prov_radio_id, media_type = prov_radio_id.split("--", 1) + else: + media_type = None params = {"c": "composite", "detail": "listing", "id": prov_radio_id} result = await self.__get_data("Describe.ashx", **params) if result and result.get("body") and result["body"][0].get("children"): item = result["body"][0]["children"][0] - stream_info = await self.__get_data("Tune.ashx", id=prov_radio_id) - for stream in stream_info["body"]: - if stream["media_type"] != media_type: + stream_info = await self._get_stream_info(prov_radio_id) + for stream in stream_info: + if media_type and stream["media_type"] != media_type: continue - return await self._parse_radio(item, stream) + return self._parse_radio(item, [stream]) # fallback - e.g. for handle custom urls ... async for radio in self.get_library_radios(): if radio.item_id == prov_radio_id: @@ -154,8 +155,8 @@ class TuneInProvider(MusicProvider): msg = f"Item {prov_radio_id} not found" raise MediaNotFoundError(msg) - async def _parse_radio( - self, details: dict, stream: dict | None = None, folder: str | None = None + def _parse_radio( + self, details: dict, stream_info: list[dict] | None = None, folder: str | None = None ) -> Radio: """Parse Radio object from json obj returned from api.""" if "name" in details: @@ -167,37 +168,47 @@ class TuneInProvider(MusicProvider): name = name.split(" | ")[1] name = name.split(" (")[0] - if stream is None: - # custom url (no stream object present) - url = details["URL"] - item_id = url - media_info = await parse_tags(url) - content_type = ContentType.try_parse(media_info.format) - bit_rate = media_info.bit_rate + if stream_info is not None: + # stream info is provided: parse stream objects into provider mappings + radio = Radio( + item_id=details["preset_id"], + provider=self.lookup_key, + name=name, + provider_mappings={ + ProviderMapping( + item_id=f'{details["preset_id"]}--{stream["media_type"]}', + provider_domain=self.domain, + provider_instance=self.instance_id, + audio_format=AudioFormat( + content_type=ContentType.try_parse(stream["media_type"]), + bit_rate=stream.get("bitrate", 128), + ), + details=stream["url"], + available=details.get("is_available", True), + ) + for stream in stream_info + }, + ) else: - url = stream["url"] - item_id = f'{details["preset_id"]}--{stream["media_type"]}' - content_type = ContentType.try_parse(stream["media_type"]) - bit_rate = stream.get("bitrate", 128) # TODO ! + # custom url (no stream object present) + radio = Radio( + item_id=details["URL"], + provider=self.lookup_key, + name=name, + provider_mappings={ + ProviderMapping( + item_id=details["URL"], + provider_domain=self.domain, + provider_instance=self.instance_id, + audio_format=AudioFormat( + content_type=ContentType.UNKNOWN, + ), + details=details["URL"], + available=details.get("is_available", True), + ) + }, + ) - radio = Radio( - item_id=item_id, - provider=self.domain, - name=name, - provider_mappings={ - ProviderMapping( - item_id=item_id, - provider_domain=self.domain, - provider_instance=self.instance_id, - audio_format=AudioFormat( - content_type=content_type, - bit_rate=bit_rate, - ), - details=url, - available=details.get("is_available", True), - ) - }, - ) # preset number is used for sorting (not present at stream time) preset_number = details.get("preset_number", 0) radio.position = preset_number @@ -215,6 +226,15 @@ class TuneInProvider(MusicProvider): ] return radio + async def _get_stream_info(self, preset_id: str) -> list[dict]: + """Get stream info for a radio station.""" + cache_key = f"tunein_stream_{preset_id}" + if cache := await self.mass.cache.get(cache_key): + return cache + result = (await self.__get_data("Tune.ashx", id=preset_id))["body"] + await self.mass.cache.set(cache_key, result) + return result + async def get_stream_details(self, item_id: str) -> StreamDetails: """Get streamdetails for a radio station.""" if item_id.startswith("http"): @@ -230,10 +250,13 @@ class TuneInProvider(MusicProvider): path=item_id, can_seek=False, ) - stream_item_id, media_type = item_id.split("--", 1) - stream_info = await self.__get_data("Tune.ashx", id=stream_item_id) - for stream in stream_info["body"]: - if stream["media_type"] != media_type: + if "--" in item_id: + stream_item_id, media_type = item_id.split("--", 1) + else: + media_type = None + stream_item_id = item_id + for stream in await self._get_stream_info(stream_item_id): + if media_type and stream["media_type"] != media_type: continue return StreamDetails( provider=self.domain,