From 5517f38ef7e3320c62ddc5b151ac74175d96f5c8 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 9 Jul 2025 22:31:50 +0200 Subject: [PATCH 1/4] Switch to v2 endpoint for playlist get/add/delete. Add support for album, track, artist batch additions. Add support for add/delete mixes and radios to favorites. (Fixes #336, #337, #339) --- tidalapi/session.py | 3 +- tidalapi/user.py | 187 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 156 insertions(+), 34 deletions(-) diff --git a/tidalapi/session.py b/tidalapi/session.py index b83b4dd4..d10c4229 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -358,7 +358,8 @@ def parse_v2_mix(self, obj: JsonObj) -> mix.Mix: def parse_playlist(self, obj: JsonObj) -> playlist.Playlist: """Parse a playlist from the given response.""" - return self.playlist().parse(obj) + # Note: When parsing playlists from v2 response, "data" field must be parsed + return self.playlist().parse(obj["data"]) def parse_folder(self, obj: JsonObj) -> playlist.Folder: """Parse an album from the given response.""" diff --git a/tidalapi/user.py b/tidalapi/user.py index 9d09fac5..5db57c20 100644 --- a/tidalapi/user.py +++ b/tidalapi/user.py @@ -313,44 +313,88 @@ def __init__(self, session: "Session", user_id: int): self.base_url = f"users/{user_id}/favorites" self.v2_base_url = "favorites" - def add_album(self, album_id: str) -> bool: - """Adds an album to the users favorites. + def add_album(self, album_id: list[str] | str) -> bool: + """Adds one or more albums to the users favorites. :param album_id: TIDAL's identifier of the album. :return: A boolean indicating whether the request was successful or not. """ - return self.requests.request( - "POST", f"{self.base_url}/albums", data={"albumId": album_id} - ).ok + playlist_id = list_validate(album_id) + + response = self.requests.request( + "POST", f"{self.base_url}/albums", data={"albumId": ",".join(playlist_id)} + ) + + return response.ok - def add_artist(self, artist_id: str) -> bool: - """Adds an artist to the users favorites. + def add_artist(self, artist_id: list[str] | str) -> bool: + """Adds one or more artists to the users favorites. :param artist_id: TIDAL's identifier of the artist :return: A boolean indicating whether the request was successful or not. """ + artist_id = list_validate(artist_id) + return self.requests.request( - "POST", f"{self.base_url}/artists", data={"artistId": artist_id} + "POST", f"{self.base_url}/artists", data={"artistId": ",".join(artist_id)} ).ok - def add_playlist(self, playlist_id: str) -> bool: - """Adds a playlist to the users favorites. + def add_playlist( + self, + playlist_id: list[str] | str, + parent_folder_id: str = "root", + validate: bool = False, + ) -> bool: + """Add one or more playlists to the users favorites (v2 endpoint) - :param playlist_id: TIDAL's identifier of the playlist. - :return: A boolean indicating whether the request was successful or not. + :param playlist_id: One or more playlists + :param parent_folder_id: Parent folder ID. Default: 'root' playlist folder + :param validate: Validate if the request was completed successfully + :return: True if request was successful, False otherwise. If 'validate', added mixes will be checked. """ - return self.requests.request( - "POST", f"{self.base_url}/playlists", data={"uuids": playlist_id} - ).ok + playlist_id = list_validate(playlist_id) + + params = {"folderId": parent_folder_id, "uuids": ",".join(playlist_id)} + endpoint = "my-collection/playlists/folders/add-favorites" + + response = self.requests.request( + method="PUT", + path=endpoint, + base_url=self.session.config.api_v2_location, + params=params, + ) - def add_track(self, track_id: str) -> bool: - """Adds a track to the users favorites. + if validate: + # Check if the expected playlists has been added + json_obj = response.json() + added_items = json_obj.get("addedItems", []) + + # No playlists added? Return early + if not added_items: + return False + + try: + # Extract playlist IDs by stripping the 'trn:playlist:' prefix + added_ids = {item["trn"].split(":")[2] for item in added_items} + except (KeyError, IndexError): + # Malformed response; fail gracefully + return False + + # Check if all requested playlist IDs were successfully added + return set(playlist_id).issubset(added_ids) + else: + return response.ok + + def add_track(self, track_id: list[str] | str) -> bool: + """Add one or more tracks to the users favorites. :param track_id: TIDAL's identifier of the track. :return: A boolean indicating whether the request was successful or not. """ + track_id = list_validate(track_id) + return self.requests.request( - "POST", f"{self.base_url}/tracks", data={"trackId": track_id} + "POST", f"{self.base_url}/tracks", data={"trackId": ",".join(track_id)} ).ok def add_track_by_isrc(self, isrc: str) -> bool: @@ -386,12 +430,44 @@ def add_video(self, video_id: str) -> bool: params=params, ).ok + def add_mixes(self, mix_ids: list[str] | str, validate: bool = False) -> bool: + """Add one or more mixes (eg. artist, track mixes) to the users favorites (v2 endpoint) + Note: Default behaviour on missing IDs is FAIL + + :param mix_ids: One or more mix_ids, usually associated to an artist radio or mix + :param validate: Validate if the request was completed successfully + :return: True if request was successful, False otherwise. If 'validate', added mixes will be checked. + """ + mix_ids = list_validate(mix_ids) + + # Prepare request parameters + params = {"mixIds": ",".join(mix_ids), "onArtifactNotFound": "FAIL"} + endpoint = "favorites/mixes/add" + + # Send request + response = self.requests.request( + method="PUT", + path=endpoint, + base_url=self.session.config.api_v2_location, + params=params, + ) + + if validate: + # Check if all requested mix IDs are in the added items + json_obj = response.json() + added_items = set(json_obj.get("addedItems", [])) + return set(mix_ids).issubset(added_items) + else: + return response.ok + def remove_artist(self, artist_id: str) -> bool: """Removes a track from the users favorites. :param artist_id: TIDAL's identifier of the artist. :return: A boolean indicating whether the request was successful or not. """ + if isinstance(artist_id, list): + return False return self.requests.request( "DELETE", f"{self.base_url}/artists/{artist_id}" ).ok @@ -402,17 +478,17 @@ def remove_album(self, album_id: str) -> bool: :param album_id: TIDAL's identifier of the album :return: A boolean indicating whether the request was successful or not. """ + if isinstance(album_id, list): + return False return self.requests.request("DELETE", f"{self.base_url}/albums/{album_id}").ok - def remove_playlist(self, playlist_id: str) -> bool: - """Removes a playlist from the users favorites. + def remove_playlist(self, playlist_id: list[str] | str) -> bool: + """Removes one or more playlists from the users favorites. :param playlist_id: TIDAL's identifier of the playlist. :return: A boolean indicating whether the request was successful or not. """ - return self.requests.request( - "DELETE", f"{self.base_url}/playlists/{playlist_id}" - ).ok + return self.remove_folders_playlists(playlist_id, type="playlist") def remove_track(self, track_id: str) -> bool: """Removes a track from the users favorites. @@ -420,6 +496,8 @@ def remove_track(self, track_id: str) -> bool: :param track_id: TIDAL's identifier of the track. :return: A boolean indicating whether the request was successful or not. """ + if isinstance(track_id, list): + return False return self.requests.request("DELETE", f"{self.base_url}/tracks/{track_id}").ok def remove_video(self, video_id: str) -> bool: @@ -428,11 +506,43 @@ def remove_video(self, video_id: str) -> bool: :param video_id: TIDAL's identifier of the video. :return: A boolean indicating whether the request was successful or not. """ + if isinstance(video_id, list): + return False return self.requests.request("DELETE", f"{self.base_url}/videos/{video_id}").ok - def remove_folders_playlists(self, trns: [str], type: str = "folder") -> bool: - """Removes one or more folders or playlists from the users favourites, using the - v2 endpoint. + def remove_mixes(self, mix_ids: list[str] | str, validate: bool = False) -> bool: + """Remove one or more mixes (e.g. artist or track mixes) from the user's favorites (v2 endpoint). + + :param mix_ids: One or more mix IDs (typically artist or track radios) + :param validate: Validate if the request was completed successfull + :return: True if request was successful, False otherwise. If 'validate', deleted mixes will be checked. + """ + mix_ids = list_validate(mix_ids) + + # Prepare request parameters + params = {"mixIds": ",".join(mix_ids), "onArtifactNotFound": "FAIL"} + endpoint = "favorites/mixes/remove" + + # Send request + response = self.requests.request( + method="PUT", + path=endpoint, + base_url=self.session.config.api_v2_location, + params=params, + ) + + if validate: + # Check if all requested mix IDs are in the deleted items + json_obj = response.json() + deleted_items = set(json_obj.get("deletedItems", [])) + return set(mix_ids).issubset(deleted_items) + else: + return response.ok + + def remove_folders_playlists( + self, trns: list[str] | str, type: str = "folder" + ) -> bool: + """Removes one or more folders or playlists from the users favourites (v2 endpoint) :param trns: List of folder (or playlist) trns to be deleted :param type: Type of trn: as string, either `folder` or `playlist`. Default `folder` @@ -450,12 +560,14 @@ def remove_folders_playlists(self, trns: [str], type: str = "folder") -> bool: trns_full.append(f"trn:{type}:{trn}") params = {"trns": ",".join(trns_full)} endpoint = "my-collection/playlists/folders/remove" - return self.requests.request( + + response = self.requests.request( method="PUT", path=endpoint, base_url=self.session.config.api_v2_location, params=params, - ).ok + ) + return response.ok def artists( self, @@ -517,12 +629,12 @@ def albums( def playlists( self, - limit: Optional[int] = None, + limit: Optional[int] = 50, offset: int = 0, order: Optional[PlaylistOrder] = None, order_direction: Optional[OrderDirection] = None, ) -> List["Playlist"]: - """Get the users favorite playlists. + """Get the users favorite playlists (v2 endpoint) :param limit: Optional; The amount of playlists you want returned. :param offset: The index of the first playlist you want included. @@ -530,16 +642,25 @@ def playlists( :param order_direction: Optional; A :class:`OrderDirection` describing the ordering direction when sorting by `order`. eg.: "ASC", "DESC" :return: A :class:`list` :class:`~tidalapi.playlist.Playlist` objects containing the favorite playlists. """ - params = {"limit": limit, "offset": offset} + params = { + "folderId": "root", + "offset": offset, + "limit": limit, + "includeOnly": "", + } if order: params["order"] = order.value if order_direction: params["orderDirection"] = order_direction.value + endpoint = "my-collection/playlists/folders" return cast( List["Playlist"], - self.requests.map_request( - f"{self.base_url}/playlists", + self.session.request.map_request( + url=urljoin( + self.session.config.api_v2_location, + endpoint, + ), params=params, parse=self.session.parse_playlist, ), From 62117041092ad605f0ec56ac1ec2ca7b2ef40f66 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 9 Jul 2025 22:33:40 +0200 Subject: [PATCH 2/4] Test: List ordering when getting tracks, items, playlist, mixes --- tests/test_playlist.py | 145 ++++++++++++++++++++++++++++ tests/test_user.py | 208 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 353 insertions(+) diff --git a/tests/test_playlist.py b/tests/test_playlist.py index d02fca26..1972b6d7 100644 --- a/tests/test_playlist.py +++ b/tests/test_playlist.py @@ -22,6 +22,7 @@ import tidalapi from tidalapi.exceptions import ObjectNotFound +from tidalapi.types import ItemOrder, OrderDirection from .cover import verify_image_cover, verify_image_resolution @@ -321,6 +322,150 @@ def test_get_tracks(session): assert items[5287].id == 209284860 +def test_get_tracks_order(session): + pl = session.user.create_playlist("TestingOrder", "TestingOrder") + # Add four tracks, one after the other (Thus user_date_added is NOT identical) + assert pl.add("246567991") + assert pl.add("23749237") + assert pl.add("185042773") + assert pl.add("180688234") + + # Default order + tracks = pl.tracks() + tr = tracks[0] + # Default order: First track should correspond to first track added + assert tr.id == 246567991 + + # Index, ascending/descending + trs = pl.tracks(order=ItemOrder.Index, order_direction=OrderDirection.Ascending) + # Ascending: First track should be "23749237" (First track added) + assert trs[0].id == 246567991 + trs = pl.tracks(order=ItemOrder.Index, order_direction=OrderDirection.Descending) + # Descending: First track should be "180688234" (Last track added) + assert trs[0].id == 180688234 + + # Title Name, ascending/descending + trs = pl.tracks(order=ItemOrder.Name, order_direction=OrderDirection.Ascending) + # Ascending: First track should be "Beam Me Up", "180688234" + assert trs[0].id == 180688234 + trs = pl.tracks(order=ItemOrder.Name, order_direction=OrderDirection.Descending) + # Descending: First track should be "Joakim,Nothing Gold", "185042773" + assert trs[0].id == 185042773 + + # Artist Name, ascending/descending + trs = pl.tracks(order=ItemOrder.Artist, order_direction=OrderDirection.Ascending) + # Ascending: First track should be by Artist:"Hubbabubbaklubb"; "23749237" + assert trs[0].id == 23749237 + trs = pl.tracks(order=ItemOrder.Artist, order_direction=OrderDirection.Descending) + # Descending: First track should be by Artist:"Todd Terje"; "23749237" + assert trs[0].id == 246567991 + + # Album Name, ascending/descending + trs = pl.tracks(order=ItemOrder.Album, order_direction=OrderDirection.Ascending) + # Ascending: First track should be from Album: "It's Album Time"; "246567991" + assert trs[0].id == 246567991 + trs = pl.tracks(order=ItemOrder.Album, order_direction=OrderDirection.Descending) + # Descending: First track should be from Album: "Walking the Midnight Streets"; "23749237" + assert trs[0].id == 180688234 + + # Date, ascending/descending + trs = pl.tracks(order=ItemOrder.Album, order_direction=OrderDirection.Ascending) + # Ascending: First track should be "246567991" + assert trs[0].id == 246567991 + added_first = trs[0].user_date_added + trs = pl.tracks(order=ItemOrder.Album, order_direction=OrderDirection.Descending) + # Descending: First track should be "23749237" + assert trs[0].id == 180688234 + added_last = trs[0].user_date_added + # Tracks are added one after the other so first track should be added before last + assert added_first < added_last + + # Track Length, ascending/descending + trs = pl.tracks(order=ItemOrder.Length, order_direction=OrderDirection.Ascending) + # Ascending: First track should be "23749237" + assert trs[0].id == 23749237 + length_first = trs[0].duration + trs = pl.tracks(order=ItemOrder.Length, order_direction=OrderDirection.Descending) + # Descending: First track should be "185042773" + assert trs[0].id == 185042773 + length_last = trs[0].duration + assert length_first < length_last + # Cleanup + pl.delete() + + +def test_get_items_order(session): + pl = session.user.create_playlist("TestingOrder", "TestingOrder") + # Add four tracks, one after the other (Thus user_date_added is NOT identical) + assert pl.add("246567991") + assert pl.add("23749237") + assert pl.add("185042773") + assert pl.add("180688234") + + # Default order + tracks = pl.items() + tr = tracks[0] + # Default order: First track should correspond to first track added + assert tr.id == 246567991 + + # Index, ascending/descending + trs = pl.items(order=ItemOrder.Index, order_direction=OrderDirection.Ascending) + # Ascending: First track should be "23749237" (First track added) + assert trs[0].id == 246567991 + trs = pl.items(order=ItemOrder.Index, order_direction=OrderDirection.Descending) + # Descending: First track should be "180688234" (Last track added) + assert trs[0].id == 180688234 + + # Title Name, ascending/descending + trs = pl.items(order=ItemOrder.Name, order_direction=OrderDirection.Ascending) + # Ascending: First track should be "Beam Me Up", "180688234" + assert trs[0].id == 180688234 + trs = pl.items(order=ItemOrder.Name, order_direction=OrderDirection.Descending) + # Descending: First track should be "Joakim,Nothing Gold", "185042773" + assert trs[0].id == 185042773 + + # Artist Name, ascending/descending + trs = pl.items(order=ItemOrder.Artist, order_direction=OrderDirection.Ascending) + # Ascending: First track should be by Artist:"Hubbabubbaklubb"; "23749237" + assert trs[0].id == 23749237 + trs = pl.items(order=ItemOrder.Artist, order_direction=OrderDirection.Descending) + # Descending: First track should be by Artist:"Todd Terje"; "23749237" + assert trs[0].id == 246567991 + + # Album Name, ascending/descending + trs = pl.items(order=ItemOrder.Album, order_direction=OrderDirection.Ascending) + # Ascending: First track should be from Album: "It's Album Time"; "246567991" + assert trs[0].id == 246567991 + trs = pl.items(order=ItemOrder.Album, order_direction=OrderDirection.Descending) + # Descending: First track should be from Album: "Walking the Midnight Streets"; "23749237" + assert trs[0].id == 180688234 + + # Date, ascending/descending + trs = pl.items(order=ItemOrder.Album, order_direction=OrderDirection.Ascending) + # Ascending: First track should be "246567991" + assert trs[0].id == 246567991 + added_first = trs[0].user_date_added + trs = pl.items(order=ItemOrder.Album, order_direction=OrderDirection.Descending) + # Descending: First track should be "23749237" + assert trs[0].id == 180688234 + added_last = trs[0].user_date_added + # Tracks are added one after the other so first track should be added before last + assert added_first < added_last + + # Track Length, ascending/descending + trs = pl.items(order=ItemOrder.Length, order_direction=OrderDirection.Ascending) + # Ascending: First track should be "23749237" + assert trs[0].id == 23749237 + length_first = trs[0].duration + trs = pl.items(order=ItemOrder.Length, order_direction=OrderDirection.Descending) + # Descending: First track should be "185042773" + assert trs[0].id == 185042773 + length_last = trs[0].duration + assert length_first < length_last + # Cleanup + pl.delete() + + def test_get_videos(session): playlist = session.playlist("aa3611ff-5b25-4bbe-8ce4-36c678c3438f") items = playlist.items() diff --git a/tests/test_user.py b/tests/test_user.py index db464e87..a9de29fb 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -283,6 +283,214 @@ def test_get_favorite_mixes(session): assert isinstance(mixes[0], tidalapi.MixV2) +def test_get_favorite_playlists_order(session): + # Add 5 favourite playlists to ensure enough playlists exist for the tests + playlist_ids = [ + "285d6293-8f77-4dc1-8dab-a262f3d0cb43", + "6bd2a3a8-a84e-4540-9077-f99858c230d5", + "e89f8af0-cf8c-4f5d-81fc-7b5955c558f1", + "13aacb6d-aa07-4186-8fb1-41b6a617d1c8", + "ca372375-7d98-4970-a7b0-04db88b68c6d", + ] + # Add playlist one at a time (will ensure non-identical DateCreated) + for playlist_id in playlist_ids: + assert session.user.favorites.add_playlist(playlist_id) + + def get_playlist_ids(**kwargs) -> list[str]: + return [str(pl.id) for pl in session.user.favorites.playlists(**kwargs)] + + # Default sort should equal DateCreated ascending + ids_default = get_playlist_ids() + ids_date_created_asc = get_playlist_ids( + order=PlaylistOrder.DateCreated, + order_direction=OrderDirection.Ascending, + ) + assert ids_default == ids_date_created_asc + + # DateCreated descending is reverse of ascending + ids_date_created_desc = get_playlist_ids( + order=PlaylistOrder.DateCreated, + order_direction=OrderDirection.Descending, + ) + assert ids_date_created_desc == ids_date_created_asc[::-1] + + # Name ascending vs. descending + ids_name_asc = get_playlist_ids( + order=PlaylistOrder.Name, + order_direction=OrderDirection.Ascending, + ) + ids_name_desc = get_playlist_ids( + order=PlaylistOrder.Name, + order_direction=OrderDirection.Descending, + ) + assert ids_name_desc == ids_name_asc[::-1] + + # Cleanup + assert session.user.favorites.remove_playlist(playlist_ids) + + +def test_get_favorite_albums_order(session): + album_ids = [ + "446470480", + "436252631", + "426730499", + "437654760", + "206012740", + ] + + # Add playlist one at a time (will ensure non-identical DateAdded) + for album_id in album_ids: + assert session.user.favorites.add_album(album_id) + + def get_album_ids(**kwargs) -> list[str]: + return [str(album.id) for album in session.user.favorites.albums(**kwargs)] + + # Default sort should equal name ascending + ids_default = get_album_ids() + ids_name_asc = get_album_ids( + order=AlbumOrder.Name, + order_direction=OrderDirection.Ascending, + ) + assert ids_default == ids_name_asc + + # Name descending is reverse of ascending + ids_name_desc = get_album_ids( + order=AlbumOrder.Name, + order_direction=OrderDirection.Descending, + ) + assert ids_name_desc == ids_name_asc[::-1] + + # Date added ascending vs. descending + ids_date_created_asc = get_album_ids( + order=AlbumOrder.DateAdded, + order_direction=OrderDirection.Ascending, + ) + ids_date_created_desc = get_album_ids( + order=AlbumOrder.DateAdded, + order_direction=OrderDirection.Descending, + ) + assert ids_date_created_asc == ids_date_created_desc[::-1] + + # Release date ascending vs. descending + ids_rel_date_created_asc = get_album_ids( + order=AlbumOrder.ReleaseDate, + order_direction=OrderDirection.Ascending, + ) + ids_rel_date_created_desc = get_album_ids( + order=AlbumOrder.ReleaseDate, + order_direction=OrderDirection.Descending, + ) + # TODO Somehow these two are not 100% equal. Why? + # assert ids_rel_date_created_asc == ids_rel_date_created_desc[::-1] + + # Cleanup + for album_id in album_ids: + assert session.user.favorites.remove_album(album_id) + + +def test_get_favorite_mixes_order(session): + mix_ids = [ + "0007646f7c64d03d56846ed25dae3d", + "0000fc7cda952f508279ad2f66222a", + "0002411cdd08aceba45671ba1f41a2", + "00026ca3141ec4758599dda8801d84", + "00031d3da7d212ac54e2b5d6a42849", + ] + + # Add mix one at a time (will ensure non-identical DateAdded) + for mix_id in mix_ids: + assert session.user.favorites.add_mixes(mix_id) + + def get_mix_ids(**kwargs) -> list[str]: + return [str(mix.id) for mix in session.user.favorites.mixes(**kwargs)] + + # Default sort should equal DateAdded ascending + ids_default = get_mix_ids() + ids_date_added_asc = get_mix_ids( + order=MixOrder.DateAdded, + order_direction=OrderDirection.Ascending, + ) + assert ids_default == ids_date_added_asc + + # DateAdded descending is reverse of ascending + ids_date_added_desc = get_mix_ids( + order=MixOrder.DateAdded, + order_direction=OrderDirection.Descending, + ) + assert ids_date_added_desc == ids_date_added_asc[::-1] + + # Name ascending vs. descending + ids_name_asc = get_mix_ids( + order=MixOrder.Name, + order_direction=OrderDirection.Ascending, + ) + ids_name_desc = get_mix_ids( + order=MixOrder.Name, + order_direction=OrderDirection.Descending, + ) + assert ids_name_desc == ids_name_asc[::-1] + + # MixType ascending vs. descending + ids_type_asc = get_mix_ids( + order=MixOrder.MixType, + order_direction=OrderDirection.Ascending, + ) + ids_type_desc = get_mix_ids( + order=MixOrder.MixType, + order_direction=OrderDirection.Descending, + ) + assert ids_type_desc == ids_type_asc[::-1] + + # Cleanup + assert session.user.favorites.remove_mixes(mix_ids, validate=True) + + +def test_get_favorite_artists_order(session): + artist_ids = [ + "4836523", + "3642059", + "5652094", + "9762896", + "6777457", + ] + + for artist_id in artist_ids: + assert session.user.favorites.add_artist(artist_id) + + def get_artist_ids(**kwargs) -> list[str]: + return [str(artist.id) for artist in session.user.favorites.artists(**kwargs)] + + # Default sort should equal Name ascending + ids_default = get_artist_ids() + ids_name_asc = get_artist_ids( + order=ArtistOrder.Name, + order_direction=OrderDirection.Ascending, + ) + assert ids_default == ids_name_asc + + # Name descending is reverse of ascending + ids_name_desc = get_artist_ids( + order=ArtistOrder.Name, + order_direction=OrderDirection.Descending, + ) + assert ids_name_desc == ids_name_asc[::-1] + + # DateAdded ascending vs. descending + ids_date_added_asc = get_artist_ids( + order=ArtistOrder.DateAdded, + order_direction=OrderDirection.Ascending, + ) + ids_date_added_desc = get_artist_ids( + order=ArtistOrder.DateAdded, + order_direction=OrderDirection.Descending, + ) + assert ids_date_added_desc == ids_date_added_asc[::-1] + + # Cleanup + for artist_id in artist_ids: + assert session.user.favorites.remove_artist(artist_id) + + def add_remove(object_id, add, remove, objects): """Add and remove an item from favorites. Skips the test if the item was already in your favorites. From 1da15de55fd3ad2965aa575399efd8cf88263c8e Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 9 Jul 2025 22:34:14 +0200 Subject: [PATCH 3/4] Test: Add, remove favorite mix. Add remove artist, album, playlist, track (multiple) --- tests/test_user.py | 204 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 196 insertions(+), 8 deletions(-) diff --git a/tests/test_user.py b/tests/test_user.py index a9de29fb..4c43f631 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -22,6 +22,13 @@ import tidalapi from tidalapi.exceptions import ObjectNotFound +from tidalapi.types import ( + ArtistOrder, + PlaylistOrder, + AlbumOrder, + MixOrder, + OrderDirection, +) def test_user(session): @@ -222,14 +229,52 @@ def test_folder_moves(session): folder_b.remove() -def test_add_remove_folder(session): - folder = session.user.create_folder(title="testfolderA") - folder_id = folder.id - # remove folder from favourites - session.user.favorites.remove_folders_playlists([folder.id]) - # check if folder has been removed - with pytest.raises(ObjectNotFound): - session.folder(folder_id) +def test_add_remove_favorite_mix(session): + mix_ids_single = ["0007646f7c64d03d56846ed25dae3d"] + mix_ids_multiple = [ + "0000fc7cda952f508279ad2f66222a", + "0002411cdd08aceba45671ba1f41a2", + ] + + def assert_mixes_present(expected_ids: list[str], should_exist: bool): + current_ids = [mix.id for mix in session.user.favorites.mixes()] + for mix_id in expected_ids: + if should_exist: + assert mix_id in current_ids + else: + assert mix_id not in current_ids + + # Add single and verify + assert session.user.favorites.add_mixes(mix_ids_single) + assert_mixes_present(mix_ids_single, should_exist=True) + + # Add multiple and verify + assert session.user.favorites.add_mixes(mix_ids_multiple) + assert_mixes_present(mix_ids_multiple, should_exist=True) + + # Remove single and verify + assert session.user.favorites.remove_mixes(mix_ids_single) + assert_mixes_present(mix_ids_single, should_exist=False) + + # Remove multiple and verify + assert session.user.favorites.remove_mixes(mix_ids_multiple) + assert_mixes_present(mix_ids_multiple, should_exist=False) + + +def test_add_remove_favorite_mix_validate(session): + # Add the same mix twice (Second time will fail, if validate is enabled) + # Add a single artist mix + assert session.user.favorites.add_mixes("0000343aa1769e75f54f900febba7e") + # Add it again. No validate: Success. Validate: Failure + assert session.user.favorites.add_mixes("0000343aa1769e75f54f900febba7e") + assert not session.user.favorites.add_mixes( + "0000343aa1769e75f54f900febba7e", validate=True + ) + # Cleanup after tests & validate + assert session.user.favorites.remove_mixes("0000343aa1769e75f54f900febba7e") + assert not session.user.favorites.remove_mixes( + "0000343aa1769e75f54f900febba7e", validate=True + ) def test_add_remove_favorite_artist(session): @@ -240,12 +285,84 @@ def test_add_remove_favorite_artist(session): ) +def test_add_remove_favorite_artist_multiple(session): + artist_single = ["1566"] + artists_multiple = [ + "33236", + "30395", + "24996", + "16928", + "1728", + ] + + def assert_artists_present(expected_ids: list[str], should_exist: bool): + current_ids = [str(artist.id) for artist in session.user.favorites.artists()] + for artist_id in expected_ids: + if should_exist: + assert artist_id in current_ids + else: + assert artist_id not in current_ids + + # Add single and verify + assert session.user.favorites.add_artist(artist_single) + assert_artists_present(artist_single, should_exist=True) + + # Add multiple and verify + assert session.user.favorites.add_artist(artists_multiple) + assert_artists_present(artists_multiple, should_exist=True) + + # Remove single and verify + assert session.user.favorites.remove_artist(artist_single[0]) + assert_artists_present(artist_single, should_exist=False) + + # Remove multiple (one by one) and verify + for artist_id in artists_multiple: + assert session.user.favorites.remove_artist(artist_id) + assert_artists_present(artists_multiple, should_exist=False) + + def test_add_remove_favorite_album(session): favorites = session.user.favorites album_id = 32961852 add_remove(album_id, favorites.add_album, favorites.remove_album, favorites.albums) +def test_add_remove_favorite_album_multiple(session): + album_single = ["32961852"] + albums_multiple = [ + "446470480", + "436252631", + "426730499", + "437654760", + "206012740", + ] + + def assert_albums_present(expected_ids: list[str], should_exist: bool): + current_ids = [str(album.id) for album in session.user.favorites.albums()] + for album_id in expected_ids: + if should_exist: + assert album_id in current_ids + else: + assert album_id not in current_ids + + # Add single and verify + assert session.user.favorites.add_album(album_single) + assert_albums_present(album_single, should_exist=True) + + # Add multiple and verify + assert session.user.favorites.add_album(albums_multiple) + assert_albums_present(albums_multiple, should_exist=True) + + # Remove single and verify + assert session.user.favorites.remove_album(album_single[0]) + assert_albums_present(album_single, should_exist=False) + + # Remove multiple and verify + for album in albums_multiple: + assert session.user.favorites.remove_album(album) + assert_albums_present(albums_multiple, should_exist=False) + + def test_add_remove_favorite_playlist(session): favorites = session.user.favorites playlists_and_favorite_playlists = session.user.playlist_and_favorite_playlists @@ -264,12 +381,83 @@ def test_add_remove_favorite_playlist(session): ) +def test_add_remove_favorite_playlists(session): + playlist_single = ["94fe2b9b-096d-4b39-8129-d5b8e774e9b3"] + playlists_multiple = [ + "285d6293-8f77-4dc1-8dab-a262f3d0cb43", + "6bd2a3a8-a84e-4540-9077-f99858c230d5", + "e89f8af0-cf8c-4f5d-81fc-7b5955c558f1", + "13aacb6d-aa07-4186-8fb1-41b6a617d1c8", + "ca372375-7d98-4970-a7b0-04db88b68c6d", + ] + + def assert_playlists_present(expected_ids: list[str], should_exist: bool): + current_ids = [pl.id for pl in session.user.favorites.playlists()] + for pl_id in expected_ids: + if should_exist: + assert pl_id in current_ids + else: + assert pl_id not in current_ids + + # Add single and verify + assert session.user.favorites.add_playlist(playlist_single) + assert_playlists_present(playlist_single, should_exist=True) + + # Add multiple and verify + assert session.user.favorites.add_playlist(playlists_multiple) + assert_playlists_present(playlists_multiple, should_exist=True) + + # Remove single and verify + assert session.user.favorites.remove_playlist(playlist_single[0]) + assert_playlists_present(playlist_single, should_exist=False) + + # Remove multiple and verify + for playlist in playlists_multiple: + assert session.user.favorites.remove_playlist(playlist) + assert_playlists_present(playlists_multiple, should_exist=False) + + def test_add_remove_favorite_track(session): favorites = session.user.favorites track_id = 32961853 add_remove(track_id, favorites.add_track, favorites.remove_track, favorites.tracks) +def test_add_remove_favorite_track_multiple(session): + track_single = ["444306564"] + tracks_multiple = [ + "439159646", + "445292352", + "444053782", + "426730500", + ] + + def assert_tracks_present(expected_ids: list[str], should_exist: bool): + current_ids = [str(track.id) for track in session.user.favorites.tracks()] + for track_id in expected_ids: + if should_exist: + assert track_id in current_ids + else: + assert track_id not in current_ids + + # Add single and verify + assert session.user.favorites.add_track(track_single) + assert_tracks_present(track_single, should_exist=True) + + # Add multiple and verify + assert session.user.favorites.add_track(tracks_multiple) + assert_tracks_present(tracks_multiple, should_exist=True) + + # Remove single and verify + assert session.user.favorites.remove_track(track_single[0]) + assert_tracks_present(track_single, should_exist=False) + + # Remove multiple (one by one) and verify + for track_id in tracks_multiple: + assert session.user.favorites.remove_track(track_id) + assert_tracks_present(tracks_multiple, should_exist=False) + + def test_add_remove_favorite_video(session): favorites = session.user.favorites video_id = 160850422 From 920f3f02f7e99bac78a7f3c7241e4f79fd7fe018 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 9 Jul 2025 22:34:40 +0200 Subject: [PATCH 4/4] Formatting --- tests/test_user.py | 4 ++-- tidalapi/user.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/test_user.py b/tests/test_user.py index 4c43f631..8cd33636 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -23,11 +23,11 @@ import tidalapi from tidalapi.exceptions import ObjectNotFound from tidalapi.types import ( - ArtistOrder, - PlaylistOrder, AlbumOrder, + ArtistOrder, MixOrder, OrderDirection, + PlaylistOrder, ) diff --git a/tidalapi/user.py b/tidalapi/user.py index 5db57c20..1f6c79ae 100644 --- a/tidalapi/user.py +++ b/tidalapi/user.py @@ -350,7 +350,8 @@ def add_playlist( :param playlist_id: One or more playlists :param parent_folder_id: Parent folder ID. Default: 'root' playlist folder :param validate: Validate if the request was completed successfully - :return: True if request was successful, False otherwise. If 'validate', added mixes will be checked. + :return: True if request was successful, False otherwise. If 'validate', added + mixes will be checked. """ playlist_id = list_validate(playlist_id) @@ -511,11 +512,13 @@ def remove_video(self, video_id: str) -> bool: return self.requests.request("DELETE", f"{self.base_url}/videos/{video_id}").ok def remove_mixes(self, mix_ids: list[str] | str, validate: bool = False) -> bool: - """Remove one or more mixes (e.g. artist or track mixes) from the user's favorites (v2 endpoint). + """Remove one or more mixes (e.g. artist or track mixes) from the user's + favorites (v2 endpoint). :param mix_ids: One or more mix IDs (typically artist or track radios) :param validate: Validate if the request was completed successfull - :return: True if request was successful, False otherwise. If 'validate', deleted mixes will be checked. + :return: True if request was successful, False otherwise. If 'validate', deleted + mixes will be checked. """ mix_ids = list_validate(mix_ids) @@ -542,7 +545,8 @@ def remove_mixes(self, mix_ids: list[str] | str, validate: bool = False) -> bool def remove_folders_playlists( self, trns: list[str] | str, type: str = "folder" ) -> bool: - """Removes one or more folders or playlists from the users favourites (v2 endpoint) + """Removes one or more folders or playlists from the users favourites (v2 + endpoint) :param trns: List of folder (or playlist) trns to be deleted :param type: Type of trn: as string, either `folder` or `playlist`. Default `folder`