Various small bugfixes and optimizations (#1494)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sat, 13 Jul 2024 14:47:08 +0000 (16:47 +0200)
committerGitHub <noreply@github.com>
Sat, 13 Jul 2024 14:47:08 +0000 (16:47 +0200)
* Fix pause support in HA players

* Revert browse to non-paged lists

paging on browse causes more headache than benefit

* Optimize playlist tracks handling

* precache playlist tracks

* Match tracks in playlist

* Fix albumtype on Tidal

* ensure full item on library add

23 files changed:
music_assistant/client/music.py
music_assistant/server/controllers/media/playlists.py
music_assistant/server/controllers/media/tracks.py
music_assistant/server/controllers/music.py
music_assistant/server/controllers/player_queues.py
music_assistant/server/controllers/webserver.py
music_assistant/server/models/music_provider.py
music_assistant/server/providers/airplay/__init__.py
music_assistant/server/providers/apple_music/__init__.py
music_assistant/server/providers/builtin/__init__.py
music_assistant/server/providers/deezer/__init__.py
music_assistant/server/providers/filesystem_local/base.py
music_assistant/server/providers/hass_players/__init__.py
music_assistant/server/providers/jellyfin/__init__.py
music_assistant/server/providers/opensubsonic/sonic_provider.py
music_assistant/server/providers/plex/__init__.py
music_assistant/server/providers/qobuz/__init__.py
music_assistant/server/providers/radiobrowser/__init__.py
music_assistant/server/providers/sonos/player.py
music_assistant/server/providers/soundcloud/__init__.py
music_assistant/server/providers/spotify/__init__.py
music_assistant/server/providers/tidal/__init__.py
music_assistant/server/providers/ytmusic/__init__.py

index 28aa100e39737a30074ceaee217c15bc1624744b..9cdadb3a21425bafb39cf51d498a0615e911009e 100644 (file)
@@ -298,8 +298,7 @@ class Music:
         self,
         item_id: str,
         provider_instance_id_or_domain: str,
-        limit: int | None = None,
-        offset: int | None = None,
+        page: int = 0,
     ) -> list[PlaylistTrack]:
         """Get tracks for given playlist."""
         return [
@@ -308,8 +307,7 @@ class Music:
                 "music/playlists/playlist_tracks",
                 item_id=item_id,
                 provider_instance_id_or_domain=provider_instance_id_or_domain,
-                limit=limit,
-                offset=offset,
+                page=page,
             )
         ]
 
@@ -437,18 +435,11 @@ class Music:
     async def browse(
         self,
         path: str | None = None,
-        limit: int | None = None,
-        offset: int | None = None,
     ) -> list[MediaItemType | ItemMapping]:
         """Browse Music providers."""
         return [
             media_from_dict(obj)
-            for obj in await self.client.send_command(
-                "music/browse",
-                path=path,
-                limit=limit,
-                offset=offset,
-            )
+            for obj in await self.client.send_command("music/browse", path=path)
         ]
 
     async def recently_played(
index b3798e0c8febe26fb486cf644ae1df5fb6784672..370b5764d42947e778156f2035006b4808deb0d8 100644 (file)
@@ -4,6 +4,7 @@ from __future__ import annotations
 
 import random
 import time
+from collections.abc import AsyncGenerator
 from typing import Any
 
 from music_assistant.common.helpers.json import serialize_to_json
@@ -48,10 +49,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
         item_id: str,
         provider_instance_id_or_domain: str,
         force_refresh: bool = False,
-        offset: int = 0,
-        limit: int = 50,
-        prefer_library_items: bool = True,
-    ) -> list[PlaylistTrack]:
+    ) -> AsyncGenerator[PlaylistTrack, None]:
         """Return playlist tracks for the given provider playlist id."""
         playlist = await self.get(
             item_id,
@@ -60,35 +58,22 @@ class PlaylistController(MediaControllerBase[Playlist]):
         # a playlist can only have one provider so simply pick the first one
         prov_map = next(x for x in playlist.provider_mappings)
         cache_checksum = playlist.cache_checksum
-        # playlist tracks ar enot stored in the db,
+        # playlist tracks arnot stored in the db,
         # we always fetched them (cached) from the provider
-        tracks = await self._get_provider_playlist_tracks(
-            prov_map.item_id,
-            prov_map.provider_instance,
-            cache_checksum=cache_checksum,
-            offset=offset,
-            limit=limit,
-            force_refresh=force_refresh,
-        )
-        if prefer_library_items:
-            final_tracks = []
+        page = 0
+        while True:
+            tracks = await self._get_provider_playlist_tracks(
+                prov_map.item_id,
+                prov_map.provider_instance,
+                cache_checksum=cache_checksum,
+                page=page,
+                force_refresh=force_refresh,
+            )
+            if not tracks:
+                break
             for track in tracks:
-                # prefer library_item
-                # TODO: we could speedup this call by requesting all tracks at once
-                # but so far this doesn't seem to be that slow due to the paging
-                if track.provider == "library":
-                    final_tracks.append(track)
-                elif db_item := await self.mass.music.tracks.get_library_item_by_prov_id(
-                    track.item_id, track.provider
-                ):
-                    db_item.position = track.position
-                    final_tracks.append(db_item)
-                else:
-                    # fall back to the original playlist item if we do not know it in the db
-                    final_tracks.append(track)
-        else:
-            final_tracks = tracks
-        return final_tracks
+                yield track
+            page += 1
 
     async def create_playlist(
         self, name: str, provider_instance_or_domain: str | None = None
@@ -126,7 +111,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
             raise ProviderUnavailableError(msg)
         cur_playlist_track_ids = set()
         cur_playlist_track_uris = set()
-        for item in await self.get_all_playlist_tracks(playlist):
+        async for item in self.tracks(playlist.item_id, playlist.provider):
             cur_playlist_track_uris.add(item.item_id)
             cur_playlist_track_uris.add(item.uri)
 
@@ -185,20 +170,27 @@ class PlaylistController(MediaControllerBase[Playlist]):
                     ids_to_add.add(item_id)
                     continue
 
-            # ensure we have a full library track
+            # ensure we have a full (library) track (including all provider mappings)
             full_track = await self.mass.music.tracks.get(
                 item_id,
                 provider_instance_id_or_domain,
                 recursive=provider_instance_id_or_domain != "library",
             )
-            if full_track.provider == "library":
-                db_track = full_track
-            else:
-                db_track = await self.mass.music.tracks.add_item_to_library(full_track)
+            track_prov_domains = {x.provider_domain for x in full_track.provider_mappings}
+            if (
+                playlist_prov.domain != "builtin"
+                and playlist_prov.is_streaming_provider
+                and playlist_prov.domain not in track_prov_domains
+            ):
+                # try to match the track to the playlist provider
+                full_track.provider_mappings.update(
+                    await self.mass.music.tracks.match_provider(playlist_prov, full_track, False)
+                )
+
             # a track can contain multiple versions on the same provider
             # simply sort by quality and just add the first available version
             for track_version in sorted(
-                db_track.provider_mappings, key=lambda x: x.quality, reverse=True
+                full_track.provider_mappings, key=lambda x: x.quality, reverse=True
             ):
                 if not track_version.available:
                     continue
@@ -215,7 +207,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
                 if track_version_uri in cur_playlist_track_uris:
                     self.logger.warning(
                         "Not adding %s to playlist %s - it already exists",
-                        db_track.name,
+                        full_track.name,
                         playlist.name,
                     )
                     break  # already existing in the playlist
@@ -224,7 +216,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
                     ids_to_add.add(track_version_uri)
                     self.logger.info(
                         "Adding %s to playlist %s",
-                        db_track.name,
+                        full_track.name,
                         playlist.name,
                     )
                     break
@@ -232,14 +224,14 @@ class PlaylistController(MediaControllerBase[Playlist]):
                     ids_to_add.add(track_version.item_id)
                     self.logger.info(
                         "Adding %s to playlist %s",
-                        db_track.name,
+                        full_track.name,
                         playlist.name,
                     )
                     break
             else:
                 self.logger.warning(
                     "Can't add %s to playlist %s - it is not available provider %s",
-                    db_track.name,
+                    full_track.name,
                     playlist.name,
                     playlist_prov.name,
                 )
@@ -282,41 +274,6 @@ class PlaylistController(MediaControllerBase[Playlist]):
         playlist.cache_checksum = str(time.time())
         await self.update_item_in_library(db_playlist_id, playlist)
 
-    async def get_all_playlist_tracks(
-        self, playlist: Playlist, prefer_library_items: bool = False
-    ) -> list[PlaylistTrack]:
-        """Return all tracks for given playlist (by unwrapping the paged listing)."""
-        result: list[PlaylistTrack] = []
-        offset = 0
-        limit = 50
-        self.logger.debug(
-            "Fetching all tracks for playlist %s",
-            playlist.name,
-        )
-        while True:
-            paged_items = await self.tracks(
-                item_id=playlist.item_id,
-                provider_instance_id_or_domain=playlist.provider,
-                offset=offset,
-                limit=limit,
-                prefer_library_items=prefer_library_items,
-            )
-            result += paged_items
-            if len(paged_items) > limit:
-                # this happens if the provider doesn't support paging
-                # and it does simply return all items in one call
-                break
-            if len(paged_items) == 0:
-                break
-            if len(paged_items) < (limit - 20):
-                # if get get less than 30 items, we assume this is the end
-                # note that we account for the fact that the provider might
-                # return less than the limit (e.g. 20 items) due to track unavailability
-                break
-
-            offset += limit
-        return result
-
     async def _add_library_item(self, item: Playlist) -> int:
         """Add a new record to the database."""
         new_item = await self.mass.music.database.insert(
@@ -376,8 +333,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
         item_id: str,
         provider_instance_id_or_domain: str,
         cache_checksum: Any = None,
-        offset: int = 0,
-        limit: int = 50,
+        page: int = 0,
         force_refresh: bool = False,
     ) -> list[PlaylistTrack]:
         """Return playlist tracks for the given provider playlist id."""
@@ -386,7 +342,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
         if not provider:
             return []
         # prefer cache items (if any)
-        cache_key = f"{provider.lookup_key}.playlist.{item_id}.tracks.{offset}.{limit}"
+        cache_key = f"{provider.lookup_key}.playlist.{item_id}.tracks.{page}"
         if (
             not force_refresh
             and (cache := await self.mass.cache.get(cache_key, checksum=cache_checksum)) is not None
@@ -394,7 +350,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
             return [PlaylistTrack.from_dict(x) for x in cache]
         # no items in cache (or force_refresh) - get listing from provider
         result: list[Track] = []
-        for item in await provider.get_playlist_tracks(item_id, offset=offset, limit=limit):
+        for item in await provider.get_playlist_tracks(item_id, page=page):
             # double check if position set
             assert item.position is not None, "Playlist items require position to be set"
             result.append(item)
@@ -424,7 +380,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
         playlist = await self.get(item_id, provider_instance_id_or_domain)
         playlist_tracks = [
             x
-            for x in await self.get_all_playlist_tracks(playlist)
+            async for x in self.tracks(playlist.item_id, playlist.provider)
             # filter out unavailable tracks
             if x.available
         ]
@@ -470,9 +426,17 @@ class PlaylistController(MediaControllerBase[Playlist]):
 
         radio_items: list[Track] = []
         radio_item_titles: set[str] = set()
-        playlist_tracks = await self.get_all_playlist_tracks(media_item, prefer_library_items=True)
+        playlist_tracks = [x async for x in self.tracks(media_item.item_id, media_item.provider)]
         random.shuffle(playlist_tracks)
         for playlist_track in playlist_tracks:
+            # prefer library item if available so we can use all providers
+            if playlist_track.provider != "library" and (
+                db_item := await self.mass.music.tracks.get_library_item_by_prov_id(
+                    playlist_track.item_id, playlist_track.provider
+                )
+            ):
+                playlist_track = db_item  # noqa: PLW2901
+
             if not playlist_track.available:
                 continue
             # include base item in the list
index 25a013725f6be3676255efc65311e02d600c8dbf..f9f724ac6927228664a1975dba45d0a2c95597e2 100644 (file)
@@ -14,7 +14,14 @@ from music_assistant.common.models.errors import (
     MusicAssistantError,
     UnsupportedFeaturedException,
 )
-from music_assistant.common.models.media_items import Album, Artist, ItemMapping, Track, UniqueList
+from music_assistant.common.models.media_items import (
+    Album,
+    Artist,
+    ItemMapping,
+    ProviderMapping,
+    Track,
+    UniqueList,
+)
 from music_assistant.constants import (
     DB_TABLE_ALBUM_TRACKS,
     DB_TABLE_ALBUMS,
@@ -29,6 +36,7 @@ from music_assistant.server.helpers.compare import (
     compare_track,
     loose_compare_strings,
 )
+from music_assistant.server.models.music_provider import MusicProvider
 
 from .base import MediaControllerBase
 
@@ -247,23 +255,46 @@ class TracksController(MediaControllerBase[Track]):
                 continue
             if not provider.library_supported(MediaType.TRACK):
                 continue
-            self.logger.debug(
-                "Trying to match track %s on provider %s", db_track.name, provider.name
+            provider_matches = await self.match_provider(
+                provider, db_track, strict=True, ref_albums=track_albums
             )
-            match_found = False
+            for provider_mapping in provider_matches:
+                # 100% match, we update the db with the additional provider mapping(s)
+                await self.add_provider_mapping(db_track.item_id, provider_mapping)
+                db_track.provider_mappings.add(provider_mapping)
+
+    async def match_provider(
+        self,
+        provider: MusicProvider,
+        ref_track: Track,
+        strict: bool = True,
+        ref_albums: list[Album] | None = None,
+    ) -> set[ProviderMapping]:
+        """Try to find matching track on given provider."""
+        if ref_albums is None:
+            ref_albums = await self.albums(ref_track.item_id, ref_track.provider)
+        if ProviderFeature.SEARCH not in provider.supported_features:
+            raise UnsupportedFeaturedException("Provider does not support search")
+        if not provider.is_streaming_provider:
+            raise UnsupportedFeaturedException("Matching only possible for streaming providers")
+        self.logger.debug("Trying to match track %s on provider %s", ref_track.name, provider.name)
+        matches: set[ProviderMapping] = set()
+        for artist in ref_track.artists:
+            if matches:
+                break
             for search_str in (
-                db_track.name,
-                f"{db_track.artists[0].name} - {db_track.name}",
-                f"{db_track.artists[0].name} {db_track.name}",
+                ref_track.name,
+                f"{artist.name} - {ref_track.name}",
+                f"{artist.name} {ref_track.name}",
             ):
-                if match_found:
+                if matches:
                     break
                 search_result = await self.search(search_str, provider.domain)
                 for search_result_item in search_result:
                     if not search_result_item.available:
                         continue
                     # do a basic compare first
-                    if not compare_media_item(db_track, search_result_item, strict=False):
+                    if not compare_media_item(ref_track, search_result_item, strict=False):
                         continue
                     # we must fetch the full version, search results can be simplified objects
                     prov_track = await self.get_provider_item(
@@ -271,19 +302,16 @@ class TracksController(MediaControllerBase[Track]):
                         search_result_item.provider,
                         fallback=search_result_item,
                     )
-                    if compare_track(db_track, prov_track, strict=True, track_albums=track_albums):
-                        # 100% match, we update the db with the additional provider mapping(s)
-                        match_found = True
-                        for provider_mapping in search_result_item.provider_mappings:
-                            await self.add_provider_mapping(db_track.item_id, provider_mapping)
-                            db_track.provider_mappings.add(provider_mapping)
+                    if compare_track(ref_track, prov_track, strict=strict, track_albums=ref_albums):
+                        matches.update(search_result_item.provider_mappings)
 
-            if not match_found:
-                self.logger.debug(
-                    "Could not find match for Track %s on provider %s",
-                    db_track.name,
-                    provider.name,
-                )
+        if not matches:
+            self.logger.debug(
+                "Could not find match for Track %s on provider %s",
+                ref_track.name,
+                provider.name,
+            )
+        return matches
 
     async def _get_provider_dynamic_tracks(
         self,
index aec36a8df745a81334b7486c6b7d201801a68a5d..4580717f2e2b0ef0fbdd3d883ee92e8638bec4c5 100644 (file)
@@ -312,7 +312,7 @@ class MusicController(CoreController):
         return result
 
     @api_command("music/browse")
-    async def browse(self, offset: int, limit: int, path: str | None = None) -> list[MediaItemType]:
+    async def browse(self, path: str | None = None) -> list[MediaItemType]:
         """Browse Music providers."""
         if not path or path == "root":
             # root level; folder per provider
@@ -342,13 +342,13 @@ class MusicController(CoreController):
             )
             if not prov:
                 return prepend_items
-        elif offset == 0:
+        else:
             back_path = f"{provider_instance}://" + "/".join(sub_path.split("/")[:-1])
             prepend_items.append(
                 BrowseFolder(item_id="back", provider=provider_instance, path=back_path, name="..")
             )
         # limit -1 to account for the prepended items
-        prov_items = await prov.browse(path=path, offset=offset, limit=limit)
+        prov_items = await prov.browse(path=path)
         return prepend_items + prov_items
 
     @api_command("music/recently_played_items")
@@ -480,6 +480,8 @@ class MusicController(CoreController):
         provider = self.mass.get_provider(item.provider)
         if provider.library_edit_supported(item.media_type):
             await provider.library_add(item)
+        # ensure a full item
+        item = await ctrl.get(item.item_id, item.provider)
         library_item = await ctrl.add_item_to_library(item)
         # perform full metadata scan (and provider match)
         await self.mass.metadata.update_metadata(library_item)
@@ -697,6 +699,11 @@ class MusicController(CoreController):
             # race conditions when multiple providers are syncing at the same time.
             async with self._sync_lock:
                 await provider.sync_library(media_types)
+            # precache playlist tracks
+            if MediaType.PLAYLIST in media_types:
+                for playlist in await self.playlists.library_items(provider=provider_instance):
+                    async for _ in self.playlists.tracks(playlist.item_id, playlist.provider):
+                        pass
 
         # we keep track of running sync tasks
         task = self.mass.create_task(run_sync())
index 1b3b1b003c10ef133498088b74015786d323959c..e4643910e183da1e2493666685e7037bd6d6faf6 100644 (file)
@@ -337,7 +337,32 @@ class PlayerQueuesController(CoreController):
                 if radio_mode:
                     radio_source.append(media_item)
                 elif media_item.media_type == MediaType.PLAYLIST:
-                    tracks += await self.mass.music.playlists.get_all_playlist_tracks(media_item)
+                    first_track_seen: bool = False
+                    async for playlist_track in self.mass.music.playlists.tracks(
+                        media_item.item_id, media_item.provider
+                    ):
+                        if not playlist_track.available:
+                            continue
+                        # allow first track to start playing immediately while we still
+                        # work out the rest of the queue
+                        if (
+                            not queue.shuffle_enabled
+                            and not first_track_seen
+                            and option == QueueOption.REPLACE
+                            and not start_item
+                        ):
+                            first_track_seen = True
+                            self.load(
+                                queue_id,
+                                queue_items=[QueueItem.from_media_item(queue_id, playlist_track)],
+                                keep_remaining=False,
+                                keep_played=False,
+                            )
+                            await self.play_index(queue_id, 0)
+                            # add the remaining items
+                            option = QueueOption.ADD
+                        else:
+                            tracks.append(playlist_track)
                     self.mass.create_task(
                         self.mass.music.mark_item_played(
                             media_item.media_type, media_item.item_id, media_item.provider
index 0b7394d20f28bbc5e369b4f5ce0744715e6fade5..39869898e12dd457d94f8816788b44d587f571ea 100644 (file)
@@ -347,7 +347,10 @@ class WebsocketClientHandler:
         try:
             args = parse_arguments(handler.signature, handler.type_hints, msg.args)
             result = handler.target(**args)
-            if asyncio.iscoroutine(result):
+            if hasattr(result, "__anext__"):
+                # handle async generator
+                result = [x async for x in result]
+            elif asyncio.iscoroutine(result):
                 result = await result
             self._send_message(SuccessResultMessage(msg.message_id, result))
         except Exception as err:  # pylint: disable=broad-except
index af9c51e12d9e73f9a9ff169652e92b6859ec9889..8c42d9c95c3081ce582aa949f8e914e25472672c 100644 (file)
@@ -148,7 +148,9 @@ class MusicProvider(Provider):
             raise NotImplementedError
 
     async def get_playlist_tracks(
-        self, prov_playlist_id: str, offset: int, limit: int
+        self,
+        prov_playlist_id: str,
+        page: int = 0,
     ) -> list[Track]:
         """Get all playlist tracks for given playlist id."""
         if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features:
@@ -286,9 +288,7 @@ class MusicProvider(Provider):
             return await self.get_radio(prov_item_id)
         return await self.get_track(prov_item_id)
 
-    async def browse(
-        self, path: str, offset: int, limit: int
-    ) -> Sequence[MediaItemType | ItemMapping]:
+    async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping]:
         """Browse this provider's items.
 
         :param path: The path to browse, (e.g. provider_id://artists).
@@ -300,25 +300,15 @@ class MusicProvider(Provider):
         subpath = path.split("://", 1)[1]
         # this reference implementation can be overridden with a provider specific approach
         if subpath == "artists":
-            return await self.mass.music.artists.library_items(
-                limit=limit, offset=offset, provider=self.instance_id
-            )
+            return await self.mass.music.artists.library_items(provider=self.instance_id)
         if subpath == "albums":
-            return await self.mass.music.albums.library_items(
-                limit=limit, offset=offset, provider=self.instance_id
-            )
+            return await self.mass.music.albums.library_items(provider=self.instance_id)
         if subpath == "tracks":
-            return await self.mass.music.tracks.library_items(
-                limit=limit, offset=offset, provider=self.instance_id
-            )
+            return await self.mass.music.tracks.library_items(provider=self.instance_id)
         if subpath == "radios":
-            return await self.mass.music.radio.library_items(
-                limit=limit, offset=offset, provider=self.instance_id
-            )
+            return await self.mass.music.radio.library_items(provider=self.instance_id)
         if subpath == "playlists":
-            return await self.mass.music.playlists.library_items(
-                limit=limit, offset=offset, provider=self.instance_id
-            )
+            return await self.mass.music.playlists.library_items(provider=self.instance_id)
         if subpath:
             # unknown path
             msg = "Invalid subpath"
index c0f9e8a3fc275fd83421da2932632c02b8333f6d..347b79ef1fdf012d333279dade88402a094a73a4 100644 (file)
@@ -908,6 +908,8 @@ class AirplayProvider(PlayerProvider):
         )
         self.mass.players.register_or_update(mass_player)
         # update can_sync_with field of all other players
+        # this ensure that the field always contains all player ids,
+        # even when a player joins later on
         for player in self.players:
             if player.player_id == player_id:
                 continue
index 46d2748146541286ef1b43afee46e44723d0797c..50a2fc952d52ed393327779b3af59cb2dcb7a067 100644 (file)
@@ -273,15 +273,17 @@ class AppleMusicProvider(MusicProvider):
             tracks.append(track)
         return tracks
 
-    async def get_playlist_tracks(self, prov_playlist_id, offset, limit) -> list[Track]:
+    async def get_playlist_tracks(self, prov_playlist_id, page: int = 0) -> list[Track]:
         """Get all playlist tracks for given playlist id."""
         if self._is_catalog_id(prov_playlist_id):
             endpoint = f"catalog/{self._storefront}/playlists/{prov_playlist_id}/tracks"
         else:
             endpoint = f"me/library/playlists/{prov_playlist_id}/tracks"
         result = []
+        page_size = 200
+        offset = page * page_size
         response = await self._get_data(
-            endpoint, include="artists,catalog", limit=limit, offset=offset
+            endpoint, include="artists,catalog", limit=page_size, offset=offset
         )
         if not response or "data" not in response:
             return result
index cde2e4a9612a64e8cb988e849f80dd99c54e158b..02517fbd86a93d300f5b25f35b54eef896bb2039 100644 (file)
@@ -356,19 +356,17 @@ class BuiltinProvider(MusicProvider):
         self.mass.config.set(key, stored_items)
         return True
 
-    async def get_playlist_tracks(
-        self, prov_playlist_id: str, offset: int, limit: int
-    ) -> list[Track]:
+    async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
         """Get playlist tracks."""
+        if page > 0:
+            # paging not supported, we always return the whole list at once
+            return []
         if prov_playlist_id in BUILTIN_PLAYLISTS:
-            if offset:
-                # paging not supported, we always return the whole list at once
-                return []
             return await self._get_builtin_playlist_tracks(prov_playlist_id)
         # user created universal playlist
         result: list[Track] = []
-        playlist_items = await self._read_playlist_file_items(prov_playlist_id, offset, limit)
-        for index, uri in enumerate(playlist_items):
+        playlist_items = await self._read_playlist_file_items(prov_playlist_id)
+        for index, uri in enumerate(playlist_items, 1):
             try:
                 media_type, provider_instance_id_or_domain, item_id = await parse_uri(uri)
                 media_controller = self.mass.music.get_controller(media_type)
@@ -383,7 +381,7 @@ class BuiltinProvider(MusicProvider):
                         item_id, provider_instance_id_or_domain
                     )
                 assert isinstance(track, Track)
-                track.position = offset + index
+                track.position = index
                 result.append(track)
             except (MediaNotFoundError, InvalidDataError, ProviderUnavailableError) as err:
                 self.logger.warning(
@@ -590,9 +588,7 @@ class BuiltinProvider(MusicProvider):
         except KeyError:
             raise MediaNotFoundError(f"No built in playlist: {builtin_playlist_id}")
 
-    async def _read_playlist_file_items(
-        self, playlist_id: str, offset: int = 0, limit: int = 100000
-    ) -> list[str]:
+    async def _read_playlist_file_items(self, playlist_id: str) -> list[str]:
         """Return lines of a playlist file."""
         playlist_file = os.path.join(self._playlists_dir, playlist_id)
         if not await asyncio.to_thread(os.path.isfile, playlist_file):
@@ -602,7 +598,7 @@ class BuiltinProvider(MusicProvider):
             aiofiles.open(playlist_file, "r", encoding="utf-8") as _file,
         ):
             lines = await _file.readlines()
-            return [x.strip() for x in lines[offset : offset + limit]]
+            return [x.strip() for x in lines]
 
     async def _write_playlist_file_items(self, playlist_id: str, lines: list[str]) -> None:
         """Return lines of a playlist file."""
index 168e955b2ed101fd562a89535501f7956fbebc5f..530be676835e79dbe57a853bef0bb4b36149f913 100644 (file)
@@ -320,12 +320,10 @@ class DeezerProvider(MusicProvider):  # pylint: disable=W0223
             for deezer_track in await album.get_tracks()
         ]
 
-    async def get_playlist_tracks(
-        self, prov_playlist_id: str, offset: int, limit: int
-    ) -> list[Track]:
+    async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
         """Get playlist tracks."""
         result: list[Track] = []
-        if offset:
+        if page > 0:
             # paging not supported, we always return the whole list at once
             return []
         # TODO: access the underlying paging on the deezer api (if possible))
@@ -336,7 +334,7 @@ class DeezerProvider(MusicProvider):  # pylint: disable=W0223
                 self.parse_track(
                     track=deezer_track,
                     user_country=self.gw_client.user_country,
-                    position=offset + index,
+                    position=index,
                 )
             )
         return result
index 221c30bf305894a44aec56dc5fc76c8f832e4583..aaa64c5eda93fa2434ac5b2c07895430581bb4e5 100644 (file)
@@ -258,14 +258,11 @@ class FileSystemProviderBase(MusicProvider):
             )
         return result
 
-    async def browse(self, path: str, offset: int, limit: int) -> list[MediaItemType | ItemMapping]:
+    async def browse(self, path: str) -> list[MediaItemType | ItemMapping]:
         """Browse this provider's items.
 
         :param path: The path to browse, (e.g. provid://artists).
         """
-        if offset:
-            # we do not support pagination
-            return []
         items: list[MediaItemType | ItemMapping] = []
         item_path = path.split("://", 1)[1]
         if not item_path:
@@ -524,11 +521,12 @@ class FileSystemProviderBase(MusicProvider):
             if any(x.provider_instance == self.instance_id for x in track.provider_mappings)
         ]
 
-    async def get_playlist_tracks(
-        self, prov_playlist_id: str, offset: int, limit: int
-    ) -> list[Track]:
+    async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
         """Get playlist tracks."""
         result: list[Track] = []
+        if page > 0:
+            # paging not (yet) supported
+            return result
         if not await self.exists(prov_playlist_id):
             msg = f"Playlist path does not exist: {prov_playlist_id}"
             raise MediaNotFoundError(msg)
@@ -547,13 +545,11 @@ class FileSystemProviderBase(MusicProvider):
             else:
                 playlist_lines = parse_pls(playlist_data)
 
-            playlist_lines = playlist_lines[offset : offset + limit]
-
-            for line_no, playlist_line in enumerate(playlist_lines):
+            for idx, playlist_line in enumerate(playlist_lines, 1):
                 if track := await self._parse_playlist_line(
                     playlist_line.path, os.path.dirname(prov_playlist_id)
                 ):
-                    track.position = offset + line_no
+                    track.position = idx
                     result.append(track)
 
         except Exception as err:  # pylint: disable=broad-except
@@ -626,7 +622,8 @@ class FileSystemProviderBase(MusicProvider):
             playlist_items = parse_pls(playlist_data)
         # remove items by index
         for i in sorted(positions_to_remove, reverse=True):
-            del playlist_items[i]
+            # position = index + 1
+            del playlist_items[i - 1]
         # build new playlist data
         new_playlist_data = "#EXTM3U\n"
         for item in playlist_items:
index d24780a60b522175abeb8abfdabf49ab8536b2a8..59028c6e108f09bf6bf1a497c3f776f496a2f724 100644 (file)
@@ -365,6 +365,8 @@ class HomeAssistantPlayers(PlayerProvider):
         supported_features: list[PlayerFeature] = []
         if MediaPlayerEntityFeature.GROUPING in hass_supported_features:
             supported_features.append(PlayerFeature.SYNC)
+        if MediaPlayerEntityFeature.PAUSE in hass_supported_features:
+            supported_features.append(PlayerFeature.PAUSE)
         if MediaPlayerEntityFeature.MEDIA_ENQUEUE in hass_supported_features:
             supported_features.append(PlayerFeature.ENQUEUE_NEXT)
         if MediaPlayerEntityFeature.VOLUME_SET in hass_supported_features:
index 854cc9e7f3619ce6538f2c018455affd9700268f..b1c32b0fa1e61a82275ac686181319124b377711 100644 (file)
@@ -437,12 +437,10 @@ class JellyfinProvider(MusicProvider):
             raise MediaNotFoundError(f"Item {prov_playlist_id} not found")
         return parse_playlist(self.instance_id, self._client, playlist)
 
-    async def get_playlist_tracks(
-        self, prov_playlist_id: str, offset: int, limit: int
-    ) -> list[Track]:
+    async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
         """Get playlist tracks."""
         result: list[Track] = []
-        if offset:
+        if page > 0:
             # paging not supported, we always return the whole list at once
             return []
         # TODO: Does Jellyfin support paging here?
@@ -456,7 +454,7 @@ class JellyfinProvider(MusicProvider):
                     self.logger, self.instance_id, self._client, jellyfin_track
                 ):
                     if not track.position:
-                        track.position = offset + index
+                        track.position = index
                     result.append(track)
             except (KeyError, ValueError) as err:
                 self.logger.error(
index 48a92401f3d89ac811f43f56b624cb88a4213af0..cff871c2028d0fabc31b2e88989319129bc31fa5 100644 (file)
@@ -703,11 +703,12 @@ class OpenSonicProvider(MusicProvider):
             raise MediaNotFoundError(msg) from e
         return self._parse_playlist(sonic_playlist)
 
-    async def get_playlist_tracks(
-        self, prov_playlist_id: str, offset: int, limit: int
-    ) -> list[Track]:
+    async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
         """Get playlist tracks."""
         result: list[Track] = []
+        if page > 0:
+            # paging not supported, we always return the whole list at once
+            return result
         try:
             sonic_playlist: SonicPlaylist = await self._run_async(
                 self._conn.getPlaylist, prov_playlist_id
@@ -715,13 +716,11 @@ class OpenSonicProvider(MusicProvider):
         except (ParameterError, DataNotFoundError) as e:
             msg = f"Playlist {prov_playlist_id} not found"
             raise MediaNotFoundError(msg) from e
-        if offset:
-            # paging not supported, we always return the whole list at once
-            return []
+
         # TODO: figure out if subsonic supports paging here
         for index, sonic_song in enumerate(sonic_playlist.songs, 1):
             track = self._parse_track(sonic_song)
-            track.position = offset + index
+            track.position = index
             result.append(track)
         return result
 
@@ -768,11 +767,12 @@ class OpenSonicProvider(MusicProvider):
         self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
     ) -> None:
         """Remove selected positions from the playlist."""
+        idx_to_remove = [pos - 1 for pos in positions_to_remove]
         try:
             await self._run_async(
                 self._conn.updatePlaylist,
                 lid=prov_playlist_id,
-                songIndexesToRemove=list(positions_to_remove),
+                songIndexesToRemove=idx_to_remove,
             )
         except SonicError:
             msg = f"Failed to remove songs from {prov_playlist_id}, check your permissions."
index b9a0ad51a16cc2441cbef4bc52b121df2188a250..29f68496942fb7a8e906045f000bbc798d2c9d2f 100644 (file)
@@ -348,14 +348,14 @@ class PlexProvider(MusicProvider):
                 if token == AUTH_TOKEN_UNAUTH:
                     # Doing local connection, not via plex.tv.
                     plex_server = PlexServer(plex_url)
-                    # I don't think PlexAPI intends for this to be accessible, but we need it.
-                    self._baseurl = plex_server._baseurl
                 else:
                     plex_server = PlexServer(
                         plex_url,
                         token,
                         session=session,
                     )
+                # I don't think PlexAPI intends for this to be accessible, but we need it.
+                self._baseurl = plex_server._baseurl
 
             except plexapi.exceptions.BadRequest as err:
                 if "Invalid token" in str(err):
@@ -882,12 +882,10 @@ class PlexProvider(MusicProvider):
         msg = f"Item {prov_playlist_id} not found"
         raise MediaNotFoundError(msg)
 
-    async def get_playlist_tracks(
-        self, prov_playlist_id: str, offset: int, limit: int
-    ) -> list[Track]:
+    async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
         """Get playlist tracks."""
         result: list[Track] = []
-        if offset:
+        if page > 0:
             # paging not supported, we always return the whole list at once
             return []
         plex_playlist: PlexPlaylist = await self._get_data(prov_playlist_id, PlexPlaylist)
index 2c69eac96c131b6fc72581b680a7d89783688959..5d2459760fc77670e3b9b01d2a54f95ee85c2c45 100644 (file)
@@ -270,20 +270,20 @@ class QobuzProvider(MusicProvider):
             if (item and item["id"])
         ]
 
-    async def get_playlist_tracks(
-        self, prov_playlist_id: str, offset: int, limit: int
-    ) -> list[Track]:
+    async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
         """Get playlist tracks."""
         result: list[Track] = []
+        page_size = 100
+        offset = page * page_size
         qobuz_result = await self._get_data(
             "playlist/get",
             key="tracks",
             playlist_id=prov_playlist_id,
             extra="tracks",
             offset=offset,
-            limit=limit,
+            limit=page_size,
         )
-        for index, track_obj in enumerate(qobuz_result["tracks"]["items"]):
+        for index, track_obj in enumerate(qobuz_result["tracks"]["items"], 1):
             if not (track_obj and track_obj["id"]):
                 continue
             track = await self._parse_track(track_obj)
@@ -386,8 +386,20 @@ class QobuzProvider(MusicProvider):
         """Remove track(s) from playlist."""
         playlist_track_ids = set()
         for pos in positions_to_remove:
-            for track in await self.get_playlist_tracks(prov_playlist_id, pos, pos):
-                playlist_track_ids.add(str(track["playlist_track_id"]))
+            idx = pos - 1
+            qobuz_result = await self._get_data(
+                "playlist/get",
+                key="tracks",
+                playlist_id=prov_playlist_id,
+                extra="tracks",
+                offset=idx,
+                limit=1,
+            )
+            if not qobuz_result:
+                continue
+            playlist_track_id = qobuz_result["tracks"]["items"][0]["playlist_track_id"]
+            playlist_track_ids.add(str(playlist_track_id))
+
         return await self._get_data(
             "playlist/deleteTracks",
             playlist_id=prov_playlist_id,
index 673d6c324c3aa24d85db90f5056b7a69982c324c..d873407819eae16cdb83e030bcabcaa026d24c8d 100644 (file)
@@ -103,14 +103,11 @@ class RadioBrowserProvider(MusicProvider):
 
         return result
 
-    async def browse(self, path: str, offset: int, limit: int) -> Sequence[MediaItemType]:
+    async def browse(self, path: str) -> Sequence[MediaItemType]:
         """Browse this provider's items.
 
         :param path: The path to browse, (e.g. provid://artists).
         """
-        if offset != 0:
-            # paging is broken on RadioBrowser, we just return some big lists
-            return []
         subpath = path.split("://", 1)[1]
         subsubpath = "" if "/" not in subpath else subpath.split("/")[-1]
 
index 8ac9d2efc96c546fb4d10a6b3a530e5111c54ca0..41e8ff1c2a250a66c9b7f791da758701db4c8aab 100644 (file)
@@ -711,7 +711,7 @@ class SonosPlayer:
         self.mass_player.can_sync_with = tuple(
             x.player_id
             for x in self.sonos_prov.sonosplayers.values()
-            if x.sync_coordinator is None and x.player_id != self.player_id
+            if x.player_id != self.player_id
         )
         if self.sync_coordinator:
             # player is syned to another player
index 98f6ee35f6a29d4363e2cf82497df7dcdc10372e..d988e160a54d67e9c12f95e528ad4a4dc2178da4 100644 (file)
@@ -255,23 +255,20 @@ class SoundcloudMusicProvider(MusicProvider):
             self.logger.debug("Parse playlist failed: %s", playlist_obj, exc_info=error)
         return playlist
 
-    async def get_playlist_tracks(
-        self, prov_playlist_id: str, offset: int, limit: int
-    ) -> list[Track]:
+    async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
         """Get playlist tracks."""
         result: list[Track] = []
-        # TODO: soundcloud doesn't seem to support paging for playlist tracks ?!
+        if page > 0:
+            # TODO: soundcloud doesn't seem to support paging for playlist tracks ?!
+            return result
         playlist_obj = await self._soundcloud.get_playlist_details(playlist_id=prov_playlist_id)
         if "tracks" not in playlist_obj:
             return result
-        if offset:
-            # paging not supported, we always return the whole list at once
-            return []
         for index, item in enumerate(playlist_obj["tracks"], 1):
+            # TODO: is it really needed to grab the entire track with an api call ?
             song = await self._soundcloud.get_track_details(item["id"])
             try:
-                # TODO: is it really needed to grab the entire track with an api call ?
-                if track := await self._parse_track(song[0], index + offset):
+                if track := await self._parse_track(song[0], index):
                     result.append(track)
             except (KeyError, TypeError, InvalidDataError, IndexError) as error:
                 self.logger.debug("Parse track failed: %s", song, exc_info=error)
index 0d8b61fe08208a63f5b8d33a775b90e3f850d435..b78dfbaaf35ae7b8691483ff49bc68e673959837 100644 (file)
@@ -313,9 +313,7 @@ class SpotifyProvider(MusicProvider):
             if item["id"]
         ]
 
-    async def get_playlist_tracks(
-        self, prov_playlist_id: str, offset: int, limit: int
-    ) -> list[Track]:
+    async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
         """Get playlist tracks."""
         result: list[Track] = []
         uri = (
@@ -323,7 +321,9 @@ class SpotifyProvider(MusicProvider):
             if prov_playlist_id == self._get_liked_songs_playlist_id()
             else f"playlists/{prov_playlist_id}/tracks"
         )
-        spotify_result = await self._get_data(uri, limit=limit, offset=offset)
+        page_size = 50
+        offset = page * page_size
+        spotify_result = await self._get_data(uri, limit=page_size, offset=offset)
         for index, item in enumerate(spotify_result["items"], 1):
             if not (item and item["track"] and item["track"]["id"]):
                 continue
@@ -390,8 +390,12 @@ class SpotifyProvider(MusicProvider):
         """Remove track(s) from playlist."""
         track_uris = []
         for pos in positions_to_remove:
-            for track in await self.get_playlist_tracks(prov_playlist_id, pos, pos):
-                track_uris.append({"uri": f"spotify:track:{track.item_id}"})
+            uri = f"playlists/{prov_playlist_id}/tracks"
+            spotify_result = await self._get_data(uri, limit=1, offset=pos - 1)
+            for item in spotify_result["items"]:
+                if not (item and item["track"] and item["track"]["id"]):
+                    continue
+                track_uris.append({"uri": f'spotify:track:{item["track"]["id"]}'})
         data = {"tracks": track_uris}
         await self._delete_data(f"playlists/{prov_playlist_id}/tracks", data=data)
 
index 6fab63ff0fac0c91f3c676b299cdd2c831f0ac94..e9018d2aa31d47a7bd1bc5f376c56ea183852de2 100644 (file)
@@ -353,15 +353,15 @@ class TidalProvider(MusicProvider):
             self.logger.warning(f"Failed to get toptracks for artist {prov_artist_id}: {err}")
             return []
 
-    async def get_playlist_tracks(
-        self, prov_playlist_id: str, offset: int, limit: int
-    ) -> list[Track]:
+    async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
         """Get playlist tracks."""
         tidal_session = await self._get_tidal_session()
         result: list[Track] = []
+        page_size = 200
+        offset = page * page_size
         track_obj: TidalTrack  # satisfy the type checker
         tidal_tracks = await get_playlist_tracks(
-            tidal_session, prov_playlist_id, limit=limit, offset=offset
+            tidal_session, prov_playlist_id, limit=page_size, offset=offset
         )
         for index, track_obj in enumerate(tidal_tracks, 1):
             track = self._parse_track(track_obj=track_obj)
@@ -409,11 +409,11 @@ class TidalProvider(MusicProvider):
         """Remove track(s) from playlist."""
         prov_track_ids = []
         tidal_session = await self._get_tidal_session()
-        for track in await self.get_playlist_tracks(prov_playlist_id, 0, 10000):
-            if track.position in positions_to_remove:
-                prov_track_ids.append(track.item_id)
-            if len(prov_track_ids) == len(positions_to_remove):
-                break
+        for pos in positions_to_remove:
+            for tidal_track in await get_playlist_tracks(
+                tidal_session, prov_playlist_id, limit=1, offset=pos - 1
+            ):
+                prov_track_ids.append(tidal_track.id)
         return await remove_playlist_tracks(tidal_session, prov_playlist_id, prov_track_ids)
 
     async def create_playlist(self, name: str) -> Playlist:
@@ -609,12 +609,16 @@ class TidalProvider(MusicProvider):
                 )
             },
         )
+        various_artist_album: bool = False
         for artist_obj in album_obj.artists:
+            if artist_obj.name == "Various Artists":
+                various_artist_album = True
             album.artists.append(self._parse_artist(artist_obj))
-        if album_obj.type == "ALBUM":
-            album.album_type = AlbumType.ALBUM
-        elif album_obj.type == "COMPILATION":
+
+        if album_obj.type == "COMPILATION" or various_artist_album:
             album.album_type = AlbumType.COMPILATION
+        elif album_obj.type == "ALBUM":
+            album.album_type = AlbumType.ALBUM
         elif album_obj.type == "EP":
             album.album_type = AlbumType.EP
         elif album_obj.type == "SINGLE":
index af450cf9b26d8c860e5c351a60bb9125138feb11..b5da3c5e552644ace59fc79d4175330eec42d29d 100644 (file)
@@ -353,10 +353,11 @@ class YoutubeMusicProvider(MusicProvider):
         msg = f"Item {prov_playlist_id} not found"
         raise MediaNotFoundError(msg)
 
-    async def get_playlist_tracks(
-        self, prov_playlist_id: str, offset: int, limit: int
-    ) -> list[Track]:
+    async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
         """Return playlist tracks for the given provider playlist id."""
+        if page > 0:
+            # paging not supported, we always return the whole list at once
+            return []
         await self._check_oauth_token()
         # Grab the playlist id from the full url in case of personal playlists
         if YT_PLAYLIST_ID_DELIMITER in prov_playlist_id:
@@ -373,20 +374,17 @@ class YoutubeMusicProvider(MusicProvider):
             return None
         result = []
         # TODO: figure out how to handle paging in YTM
-        if offset:
-            # paging not supported, we always return the whole list at once
-            return []
         for index, track_obj in enumerate(playlist_obj["tracks"], 1):
             if track_obj["isAvailable"]:
                 # Playlist tracks sometimes do not have a valid artist id
                 # In that case, call the API for track details based on track id
                 try:
                     if track := self._parse_track(track_obj):
-                        track.position = index + 1
+                        track.position = index
                         result.append(track)
                 except InvalidDataError:
                     if track := await self.get_track(track_obj["videoId"]):
-                        track.position = index + 1
+                        track.position = index
                         result.append(track)
         # YTM doesn't seem to support paging so we ignore offset and limit
         return result
@@ -412,7 +410,8 @@ class YoutubeMusicProvider(MusicProvider):
         artist_obj = await get_artist(prov_artist_id=prov_artist_id, headers=self._headers)
         if artist_obj.get("songs") and artist_obj["songs"].get("browseId"):
             prov_playlist_id = artist_obj["songs"]["browseId"]
-            return await self.get_playlist_tracks(prov_playlist_id, 0, 25)
+            playlist_tracks = await self.get_playlist_tracks(prov_playlist_id)
+            return playlist_tracks[:25]
         return []
 
     async def library_add(self, item: MediaItemType) -> bool: