From: Jozef Kruszynski <60214390+jozefKruszynski@users.noreply.github.com> Date: Wed, 9 Apr 2025 08:28:42 +0000 (+0100) Subject: Tidal: Various playlist and mix changes (#2118) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=c6f0d911d08984cc6195357e89aeb7770719bd20;p=music-assistant-server.git Tidal: Various playlist and mix changes (#2118) --- diff --git a/music_assistant/providers/tidal/__init__.py b/music_assistant/providers/tidal/__init__.py index 4d409467..368ea1aa 100644 --- a/music_assistant/providers/tidal/__init__.py +++ b/music_assistant/providers/tidal/__init__.py @@ -473,8 +473,9 @@ class TidalProvider(MusicProvider): as_form: bool = False, **kwargs: Any, ) -> dict[str, Any]: - """Post data to Tidal API using mass.http_session.""" - url = f"{self.BASE_URL}/{endpoint}" + """Send POST data to Tidal API.""" + base_url = kwargs.pop("base_url", self.BASE_URL) + url = f"{base_url}/{endpoint}" if as_form: # Set content type for form data @@ -487,13 +488,45 @@ class TidalProvider(MusicProvider): "dict[str, Any]", await self._handle_response(response, return_etag=False), ) - else: - # Use json parameter for JSON data (default) - async with self.mass.http_session.post(url, json=data, **kwargs) as response: + # Use json parameter for JSON data (default) + async with self.mass.http_session.post(url, json=data, **kwargs) as response: + return cast( + "dict[str, Any]", + await self._handle_response(response, return_etag=False), + ) + + @prepare_api_request + async def _put_data( + self, + endpoint: str, + data: dict[str, Any] | None = None, + as_form: bool = False, + **kwargs: Any, + ) -> dict[str, Any]: + """Send PUT data to Tidal API.""" + # Use BASE_URL_V2 for PUT requests to mixes endpoints + base_url = kwargs.pop( + "base_url", self.BASE_URL_V2 if "mixes" in endpoint else self.BASE_URL + ) + url = f"{base_url}/{endpoint}" + + if as_form: + # Set content type for form data + headers = kwargs.get("headers", {}) + headers["Content-Type"] = "application/x-www-form-urlencoded" + kwargs["headers"] = headers + # Use data parameter for form-encoded data + async with self.mass.http_session.put(url, data=data, **kwargs) as response: return cast( "dict[str, Any]", await self._handle_response(response, return_etag=False), ) + # Use json parameter for JSON data (default) + async with self.mass.http_session.put(url, json=data, **kwargs) as response: + return cast( + "dict[str, Any]", + await self._handle_response(response, return_etag=False), + ) @prepare_api_request async def _delete_data( @@ -1090,7 +1123,7 @@ class TidalProvider(MusicProvider): results, category=CACHE_CATEGORY_RECOMMENDATIONS, base_key=self.lookup_key, - expiration=12 * 3600, + expiration=3600, ) except (ClientError, ResourceTemporarilyUnavailable) as err: @@ -1291,11 +1324,12 @@ class TidalProvider(MusicProvider): try: # Get the page structure self.logger.debug("Fetching fresh page content for '%s'", page_path) + locale = self.mass.metadata.locale.replace("_", "-") api_result = await self._get_data( page_path, base_url="https://listen.tidal.com/v1", params={ - "locale": "en_US", + "locale": locale, "deviceType": "BROWSER", "countryCode": self.auth.country_code or "US", }, @@ -1366,6 +1400,14 @@ class TidalProvider(MusicProvider): async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: """Retrieve all library playlists from the provider.""" user_id = self.auth.user_id + mix_path = "favorites/mixes" + + async for mix_item in self._paginate_api( + mix_path, item_key="items", base_url=self.BASE_URL_V2 + ): + if mix_item and mix_item.get("id"): + yield self._parse_playlist(mix_item, is_mix=True) + playlist_path = f"users/{user_id}/playlistsAndFavoritePlaylists" async for playlist_item in self._paginate_api(playlist_path, nested_key="playlist"): @@ -1374,48 +1416,39 @@ class TidalProvider(MusicProvider): async def library_add(self, item: MediaItemType) -> bool: """Add item to library.""" - endpoint = None - data = {} + endpoint, data, is_mix = self._get_library_endpoint_data( + item.item_id, item.media_type, "add" + ) - if item.media_type == MediaType.ARTIST: - endpoint = "favorites/artists" - data = {"artistId": item.item_id} - elif item.media_type == MediaType.ALBUM: - endpoint = "favorites/albums" - data = {"albumId": item.item_id} - elif item.media_type == MediaType.TRACK: - endpoint = "favorites/tracks" - data = {"trackId": item.item_id} - elif item.media_type == MediaType.PLAYLIST: - endpoint = "favorites/playlists" - data = {"playlistId": item.item_id} - else: + if not endpoint: return False - endpoint = f"users/{self.auth.user_id}/{endpoint}" - - await self._post_data(endpoint, data=data, as_form=True) - return True + try: + if is_mix: + await self._put_data(endpoint, data=data, as_form=True) + else: + endpoint = f"users/{self.auth.user_id}/{endpoint}" + await self._post_data(endpoint, data=data, as_form=True) + return True + except (ClientError, MediaNotFoundError, ResourceTemporarilyUnavailable) as err: + self.logger.warning( + "Failed to add %s:%s to library: %s", item.media_type, item.item_id, err + ) + return False async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool: """Remove item from library.""" - endpoint = None + endpoint, data, is_mix = self._get_library_endpoint_data(prov_item_id, media_type, "remove") - if media_type == MediaType.ARTIST: - endpoint = f"favorites/artists/{prov_item_id}" - elif media_type == MediaType.ALBUM: - endpoint = f"favorites/albums/{prov_item_id}" - elif media_type == MediaType.TRACK: - endpoint = f"favorites/tracks/{prov_item_id}" - elif media_type == MediaType.PLAYLIST: - endpoint = f"favorites/playlists/{prov_item_id}" - else: + if not endpoint: return False - endpoint = f"users/{self.auth.user_id}/{endpoint}" - try: - await self._delete_data(endpoint) + if is_mix: + await self._put_data(endpoint, data=data, as_form=True) + else: + endpoint = f"users/{self.auth.user_id}/{endpoint}" + await self._delete_data(endpoint) return True except (ClientError, MediaNotFoundError, ResourceTemporarilyUnavailable) as err: self.logger.warning( @@ -1423,6 +1456,57 @@ class TidalProvider(MusicProvider): ) return False + def _get_library_endpoint_data( + self, item_id: str, media_type: MediaType, operation: str + ) -> tuple[str | None, dict[str, Any], bool]: + """Get the endpoint, data, and mix flag for library operations.""" + is_mix = False + data = {} + + # Check if this is a mix by ID prefix + if media_type == MediaType.PLAYLIST and item_id.startswith("mix_"): + is_mix = True + # Strip prefix for API calls + mix_id = item_id[4:] # Remove "mix_" prefix + + if operation == "add": + endpoint = "favorites/mixes/add" + data = {"mixIds": mix_id, "onArtifactNotFound": "FAIL", "deviceType": "BROWSER"} + else: # remove + endpoint = "favorites/mixes/remove" + data = {"mixIds": mix_id, "deviceType": "BROWSER"} + return endpoint, data, is_mix + + # Regular items + if media_type == MediaType.ARTIST: + if operation == "add": + endpoint = "favorites/artists" + data = {"artistId": item_id} + else: + endpoint = f"favorites/artists/{item_id}" + elif media_type == MediaType.ALBUM: + if operation == "add": + endpoint = "favorites/albums" + data = {"albumId": item_id} + else: + endpoint = f"favorites/albums/{item_id}" + elif media_type == MediaType.TRACK: + if operation == "add": + endpoint = "favorites/tracks" + data = {"trackId": item_id} + else: + endpoint = f"favorites/tracks/{item_id}" + elif media_type == MediaType.PLAYLIST: + if operation == "add": + endpoint = "favorites/playlists" + data = {"uuids": item_id} + else: + endpoint = f"favorites/playlists/{item_id}" + else: + return None, {}, False + + return endpoint, data, is_mix + # # PLAYLIST MANAGEMENT #