diff --git a/HISTORY.rst b/HISTORY.rst index 7ea13ab..ed42822 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -4,6 +4,22 @@ History ======= v0.7.7 ------ +* Tests: Added additional playlist, folder tests. - tehkillerbee_ +* Feature: Add support for playlist merging. - tehkillerbee_ +* Added trn to playlist object for convenience. - tehkillerbee_ +* Set limits from argument in all relevant methods. - tehkillerbee_ +* Feature: Use v2 endpoint for playlist creation. - tehkillerbee_ +* Feature: Add support for playlist folders (#181) - tehkillerbee_ +* Feature: Add track to user playlist, user tracks from ISRC (#96) - tehkillerbee_ +* Feature: Add optional fn_print to Session::login_session_file - GioF71_ +* Feature: Add support for moving playlist items (#116) - tehkillerbee_ +* Feature: Allow adding items multiple times to the same playlist - tehkillerbee_ +* Feature: Add support for adding items to a playlists at a specific position (#116) - tehkillerbee_ +* Feature: Set UserPlaylist public/private. Add method for getting public user playlists. - tehkillerbee_ +* Feature: Remove multiple items from UserPlaylist. (Fixes #259) - tehkillerbee_ +* Remove deprecated username/pass login method (Fixes #279) - tehkillerbee_ +* Populate the track/items.album attributes from the parent Album object. Updated tests (Fixes #281) - tehkillerbee_ +* Added clarifications to video_url method. Check video URLs for all available video qualities (Fixes #257) - tehkillerbee_ * Tests: Fix all tests that previously failed. - tehkillerbee_ * Use enum to specify default audio / video quality - tehkillerbee_ * Bugfix: Recent TIDAL changes resulted in missing Mix not causing a ObjectNotFound exception. - tehkillerbee_ @@ -198,6 +214,7 @@ v0.6.2 .. _Jimmyscene: https://github.com/Jimmyscene .. _quodrum-glas: https://github.com/quodrum-glas .. _M4TH1EU: https://github.com/M4TH1EU +.. _GioF71: https://github.com/GioF71 diff --git a/tests/test_album.py b/tests/test_album.py index ba8c8e0..b4302c0 100644 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -23,8 +23,7 @@ from dateutil import tz import tidalapi -from tidalapi.album import Album -from tidalapi.exceptions import MetadataNotAvailable, ObjectNotFound +from tidalapi.exceptions import ObjectNotFound from tidalapi.media import AudioMode, Quality from .cover import verify_image_cover, verify_video_cover @@ -72,11 +71,20 @@ def test_get_tracks(session): assert tracks[0].id == 17927864 assert tracks[0].volume_num == 1 assert tracks[0].track_num == 1 + assert tracks[0].album == album assert tracks[-1].name == "Pray" assert tracks[-1].id == 17927885 assert tracks[-1].volume_num == 2 assert tracks[-1].track_num == 8 + assert tracks[-1].album == album + + # Getting album.tracks with sparse_album=True will result in a track.album containing only essential fields + tracks_sparse = album.tracks(sparse_album=True) + assert tracks_sparse[0].album.audio_quality is None + assert tracks_sparse[0].album.id == 17927863 + assert tracks_sparse[-1].album.audio_quality is None + assert tracks_sparse[-1].album.id == 17927863 def test_get_items(session): @@ -87,11 +95,20 @@ def test_get_items(session): assert items[0].id == 108043415 assert items[0].volume_num == 1 assert items[0].track_num == 1 + assert items[0].album == album assert items[-1].name == "Lemonade Film" assert items[-1].id == 108043437 assert items[-1].volume_num == 1 assert items[-1].track_num == 15 + assert items[-1].album == album + + # Getting album.items with sparse_album=True will result in a track.album containing only essential fields + items_sparse = album.items(sparse_album=True) + assert items_sparse[0].album.id == 108043414 + assert items_sparse[0].album.audio_quality is None + assert items_sparse[-1].album.id == 108043414 + assert items_sparse[-1].album.audio_quality is None def test_image_cover(session): diff --git a/tests/test_media.py b/tests/test_media.py index 2d73958..5e0a61e 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -23,6 +23,7 @@ from dateutil import tz import tidalapi +from tidalapi import VideoQuality from tidalapi.exceptions import MetadataNotAvailable, ObjectNotFound from tidalapi.media import ( AudioExtensions, @@ -341,8 +342,23 @@ def test_video_no_release_date(session): ] +def test_video_not_found(session): + with pytest.raises(ObjectNotFound): + session.video(12345678) + + def test_video_url(session): + # Test video URLs at all available qualities video = session.video(125506698) + session.video_quality = VideoQuality.low + url = video.get_url() + assert "m3u8" in url + verify_video_resolution(url, 640, 360) + session.video_quality = VideoQuality.medium + url = video.get_url() + assert "m3u8" in url + verify_video_resolution(url, 1280, 720) + session.video_quality = VideoQuality.high url = video.get_url() assert "m3u8" in url verify_video_resolution(url, 1920, 1080) diff --git a/tests/test_page.py b/tests/test_page.py index 2521c24..98ec827 100644 --- a/tests/test_page.py +++ b/tests/test_page.py @@ -15,7 +15,6 @@ # # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -import pytest import tidalapi @@ -48,6 +47,7 @@ def test_get_explore_items(session): assert first_item.categories[1].title == "Milestone Year Albums" assert first_item.categories[2].title == "Albums Of The Decade" playlist = first_item.categories[0].items[0] + assert isinstance(playlist, tidalapi.Playlist) assert playlist.name # == 'Remember...the 1950s' assert playlist.num_tracks > 1 assert playlist.num_videos == 0 @@ -55,6 +55,7 @@ def test_get_explore_items(session): genre_genres = explore.categories[0].items[1] genre_genres_page_items = iter(genre_genres.get()) playlist = next(genre_genres_page_items) # Usually a playlist + assert isinstance(playlist, tidalapi.Playlist) assert playlist.name # == 'Remember...the 1950s' assert playlist.num_tracks > 1 assert playlist.num_videos == 0 @@ -66,9 +67,25 @@ def test_get_explore_items(session): assert next(iterator).title == "Country" +def test_hires_page(session): + hires = session.hires_page() + first = next(iter(hires)) + assert first.name == "Electronic: Headphone Classics" + assert isinstance(first, tidalapi.Playlist) + + def test_for_you(session): for_you = session.for_you() - assert for_you + first = next(iter(for_you)) + assert first.title == "My Daily Discovery" + assert isinstance(first, tidalapi.Mix) + + +def test_videos(session): + videos = session.videos() + first = next(iter(videos)) + assert first.type == "VIDEO" + assert isinstance(first.get(), tidalapi.Video) def test_show_more(session): diff --git a/tests/test_playlist.py b/tests/test_playlist.py index ef1cb19..271a5c8 100644 --- a/tests/test_playlist.py +++ b/tests/test_playlist.py @@ -93,6 +93,193 @@ def test_playlist_not_found(session): session.playlist("12345678") +def test_playlist_categories(session): + playlist = session.user.create_playlist("TestingA", "Testing1234") + playlist_id = playlist.id + assert playlist.add("125169484", allow_duplicates=True) + # Playlist should be found in (user) playlists + user_playlists = session.user.playlists() + playlist_ids = [playlist.id for playlist in user_playlists] + assert playlist_id in playlist_ids + + # Playlist not found in user favourite playlists + # playlists_favs = session.user.favorites.playlists() + # playlist_ids = [playlist.id for playlist in playlists_favs] + # assert playlist_id in playlist_ids + + # Playlist is found in user (created) playlists and favourite playlists + playlists_and_favs = session.user.playlist_and_favorite_playlists() + playlist_ids = [playlist.id for playlist in playlists_and_favs] + assert playlist_id in playlist_ids + + # Check if playlist is found in list of public playlists + public_playlists = session.user.public_playlists() + playlist_ids = [playlist.id for playlist in public_playlists] + assert not playlist_id in playlist_ids + playlist.set_playlist_public() + + # Check if playlist is found in list of public playlists + public_playlists = session.user.public_playlists() + playlist_ids = [playlist.id for playlist in public_playlists] + assert playlist_id in playlist_ids + playlist.delete() + + +def test_playlist_add_duplicate(session): + playlist = session.user.create_playlist("TestingA", "Testing1234") + # track id 125169484 + assert 125169484 in playlist.add("125169484", allow_duplicates=True) + assert 125169484 in playlist.add("125169484", allow_duplicates=True) + assert 125169484 not in playlist.add("125169484", allow_duplicates=False) + playlist.add(["125169484", "125169484", "125169484"], allow_duplicates=False) + # Check if track has been added more than 2 times + item_ids = [item.id for item in playlist.items()] + assert item_ids.count(125169484) == 2 + # Add again, this time allowing duplicates + assert playlist.add(["125169484", "125169484", "125169484"], allow_duplicates=True) + item_ids = [item.id for item in playlist.items()] + assert item_ids.count(125169484) == 5 + playlist.delete() + + +def test_playlist_add_at_position(session): + playlist = session.user.create_playlist("TestingA", "Testing1234") + playlist_a = session.playlist("7eafb342-141a-4092-91eb-da0012da3a19") + # Add 10 tracks to the new playlist + track_ids = [track.id for track in playlist_a.tracks()] + playlist.add(track_ids[0:10]) + # Add a track to the end of the playlist (default) + assert playlist.add("125169484") + item_ids = [item.id for item in playlist.items()] + assert str(item_ids[-1]) == "125169484" + # Add a new track to a specific position + assert playlist.add("77692131", position=2) + # Verify that track matches the expected position + item_ids = [item.id for item in playlist.items()] + assert str(item_ids[2]) == "77692131" + # Add last four tracks to position 2 in the playlist and verify they are stored at the expected location + playlist.add(track_ids[-4:], position=2) + tracks = [item.id for item in playlist.items()][2:6] + for idx, track_id in enumerate(track_ids[-4:]): + assert tracks[idx] == track_id + playlist.delete() + + +def test_playlist_remove_by_indices(session): + playlist = session.user.create_playlist("TestingA", "Testing1234") + playlist_a = session.playlist("7eafb342-141a-4092-91eb-da0012da3a19") + track_ids = [track.id for track in playlist_a.tracks()][0:9] + playlist.add(track_ids) + # Remove odd tracks + playlist.remove_by_indices([1, 3, 5, 7]) + # Verify remaining tracks + tracks = [item.id for item in playlist.items()] + for idx, track_id in enumerate(tracks): + assert track_id == track_ids[idx * 2] + # Remove last track in playlist and check that track has been removed + last_track = tracks[-1] + playlist.remove_by_index(playlist.num_tracks - 1) + tracks = [item.id for item in playlist.items()] + assert last_track not in tracks + playlist.delete() + + +def test_playlist_remove_by_id(session): + playlist = session.user.create_playlist("TestingA", "Testing1234") + playlist_a = session.playlist("7eafb342-141a-4092-91eb-da0012da3a19") + track_ids = [track.id for track in playlist_a.tracks()][0:9] + playlist.add(track_ids) + # Remove track with specific ID + playlist.remove_by_id(str(track_ids[2])) + tracks = [item.id for item in playlist.items()] + assert track_ids[2] not in tracks + playlist.delete() + + +def test_playlist_add_isrc(session): + playlist = session.user.create_playlist("TestingA", "Testing1234") + # track id 125169484 + assert playlist.add_by_isrc("NOG841907010", allow_duplicates=True) + assert playlist.add_by_isrc("NOG841907010", allow_duplicates=True) + assert not playlist.add_by_isrc("NOG841907010", allow_duplicates=False) + assert not playlist.add_by_isrc( + "NOG841907123", allow_duplicates=True, position=0 + ) # Does not exist, returns false + # Check if track has been added more than 2 times + item_ids = [item.id for item in playlist.items()] + assert item_ids.count(125169484) == 2 + playlist.delete() + + +def test_playlist_move_by_id(session): + playlist = session.user.create_playlist("TestingA", "Testing1234") + # Add tracks from existing playlist + playlist_a = session.playlist("7eafb342-141a-4092-91eb-da0012da3a19") + track_ids = [track.id for track in playlist_a.tracks()] + playlist.add(track_ids[0:9]) + # Move first track to the end + first_track_id = track_ids[0] + playlist.move_by_id(media_id=str(first_track_id), position=playlist.num_tracks - 2) + # Last track(-2) should now match the previous first track + tracks = playlist.tracks() + assert first_track_id == tracks[playlist.num_tracks - 2].id + playlist.delete() + + +def test_playlist_move_by_index(session): + playlist = session.user.create_playlist("TestingA", "Testing1234") + # Add tracks from existing playlist + playlist_a = session.playlist("7eafb342-141a-4092-91eb-da0012da3a19") + track_ids = [track.id for track in playlist_a.tracks()] + playlist.add(track_ids[0:9]) + # Move first track to the end + first_track_id = track_ids[0] + playlist.move_by_index(index=0, position=playlist.num_tracks - 2) + # Last track(-2) should now match the previous first track + tracks = playlist.tracks() + track_ids = [track.id for track in playlist.tracks()] + assert track_ids.index(first_track_id) == playlist.num_tracks - 2 + playlist.delete() + + +def test_playlist_move_by_indices(session): + playlist = session.user.create_playlist("TestingA", "Testing1234") + # Add tracks from existing playlist + playlist_a = session.playlist("7eafb342-141a-4092-91eb-da0012da3a19") + track_ids = [track.id for track in playlist_a.tracks()] + playlist.add(track_ids[0:9]) + # Move first 4 tracks to the end + playlist.move_by_indices(indices=[0, 1, 2, 3], position=playlist.num_tracks) + # First four tracks should now be moved to the end + last_tracks = [track.id for track in playlist.tracks()][-4:] + for idx, track_id in enumerate(last_tracks): + assert track_ids[idx] == track_id + playlist.delete() + + +def test_playlist_merge(session): + playlist = session.user.create_playlist("TestingA", "Testing1234") + # Add tracks from existing playlist + added_items = playlist.merge("7eafb342-141a-4092-91eb-da0012da3a19") + # Check if tracks were added + # Note: Certain tracks might be skipped for unknown reasons. (Why?) + # Therefore, we will compare the list of added items with the actual playlist content. + tracks = [track.id for track in playlist.tracks()] + for track in tracks: + assert track in added_items + + +def test_playlist_public_private(session): + playlist = session.user.create_playlist("TestingA", "Testing1234") + # Default: UserPlaylist is not public + assert not playlist.public + playlist.set_playlist_public() + assert playlist.public + playlist.set_playlist_private() + assert not playlist.public + playlist.delete() + + def test_video_playlist(session): playlist = session.playlist("aa3611ff-5b25-4bbe-8ce4-36c678c3438f") assert playlist.id == "aa3611ff-5b25-4bbe-8ce4-36c678c3438f" diff --git a/tests/test_session.py b/tests/test_session.py index 990419d..b123af0 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -37,13 +37,6 @@ def test_load_oauth_session(session): assert isinstance(session.user, tidalapi.LoggedInUser) -def test_failed_login(): - session = tidalapi.Session() - with pytest.raises(requests.HTTPError): - session.login("", "") - assert session.check_login() is False - - @pytest.mark.interactive def test_oauth_login(capsys): config = tidalapi.Config(item_limit=20000) diff --git a/tests/test_user.py b/tests/test_user.py index 53ce25f..66950a2 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -23,6 +23,7 @@ import pytest import tidalapi +from tidalapi.exceptions import ObjectNotFound def test_user(session): @@ -59,6 +60,15 @@ def test_get_user_playlists(session): assert playlist_ids | favourite_ids == both_ids +def test_get_playlist_folders(session): + folder = session.user.create_folder(title="testfolder") + assert folder + folder_ids = [folder.id for folder in session.user.playlist_folders()] + assert folder.id in folder_ids + folder.remove() + assert folder.id not in folder_ids + + def test_get_user_playlist_creator(session): playlist = session.playlist("944dd087-f65c-4954-a9a3-042a574e86e3") creator = playlist.creator @@ -77,13 +87,13 @@ def test_get_editorial_playlist_creator(session): def test_create_playlist(session): playlist = session.user.create_playlist("Testing", "Testing1234") - playlist.add([125169484]) + playlist.add(["125169484"]) assert playlist.tracks()[0].name == "Alone, Pt. II" assert playlist.description == "Testing1234" assert playlist.name == "Testing" - playlist.remove_by_id(125169484) + playlist.remove_by_id("125169484") assert len(playlist.tracks()) == 0 - playlist.add([64728757, 125169484]) + playlist.add(["64728757", "125169484"]) for index, item in enumerate(playlist.tracks()): if item.name == "Alone, Pt. II": playlist.remove_by_index(index) @@ -114,16 +124,115 @@ def test_create_playlist(session): long_playlist = session.playlist("944dd087-f65c-4954-a9a3-042a574e86e3") playlist_tracks = long_playlist.tracks(limit=250) - playlist.add(playlist.id for playlist in playlist_tracks) + playlist.add([track.id for track in playlist_tracks]) playlist._reparse() - playlist.remove_by_id(199477058) + playlist.remove_by_id("199477058") playlist._reparse() - assert all(playlist.id != 199477058 for playlist in playlist.tracks(limit=250)) + track_ids = [track.id for track in playlist.tracks(limit=250)] + assert 199477058 not in track_ids playlist.delete() +def test_create_folder(session): + folder = session.user.create_folder(title="testfolder") + assert folder.name == "testfolder" + assert folder.parent is None + assert folder.parent_folder_id == "root" + assert folder.listen_url == f"https://listen.tidal.com/folder/{folder.id}" + assert folder.total_number_of_items == 0 + assert folder.trn == f"trn:folder:{folder.id}" + folder_id = folder.id + + # update name + folder.rename(name="testfolder1") + assert folder.name == "testfolder1" + + # cleanup + folder.remove() + + # check if folder has been removed + with pytest.raises(ObjectNotFound): + session.folder(folder_id) + + +def test_folder_add_items(session): + folder = session.user.create_folder(title="testfolder") + folder_a = session.folder(folder.id) + assert isinstance(folder_a, tidalapi.playlist.Folder) + assert folder_a.id == folder.id + + # create a playlist and add it to the folder + playlist_a = session.user.create_playlist("TestingA", "Testing1234") + playlist_a.add(["125169484"]) + playlist_b = session.user.create_playlist("TestingB", "Testing1234") + playlist_b.add(["125169484"]) + folder.add_items([playlist_a.id, playlist_b.id]) + + # verify items + assert folder.total_number_of_items == 2 + items = folder.items() + assert len(items) == 2 + item_ids = [item.id for item in items] + assert playlist_a.id in item_ids + assert playlist_b.id in item_ids + + # cleanup (This will also delete playlists inside the folder!) + folder.remove() + + +def test_folder_moves(session): + folder_a = session.user.create_folder(title="testfolderA") + folder_b = session.user.create_folder(title="testfolderB") + + # create a playlist and add it to the folder + playlist_a = session.user.create_playlist("TestingA", "Testing1234") + playlist_a.add(["125169484"]) + playlist_b = session.user.create_playlist("TestingB", "Testing1234") + playlist_b.add(["125169484"]) + folder_a.add_items([playlist_a.id, playlist_b.id]) + + # verify items + assert folder_a.total_number_of_items == 2 + assert folder_b.total_number_of_items == 0 + items = folder_a.items() + item_ids = [item.id for item in items] + + # move items to folder B + folder_a.move_items_to_folder(trns=item_ids, folder=folder_b.id) + folder_b._reparse() # Manually refresh, as src folder contents will have changed + assert folder_a.total_number_of_items == 0 + assert folder_b.total_number_of_items == 2 + item_a_ids = [item.id for item in folder_a.items()] + item_b_ids = [item.id for item in folder_b.items()] + assert playlist_a.id not in item_a_ids + assert playlist_b.id not in item_a_ids + assert playlist_a.id in item_b_ids + assert playlist_b.id in item_b_ids + + # move items to the root folder + folder_b.move_items_to_root(trns=item_ids) + assert folder_a.total_number_of_items == 0 + assert folder_b.total_number_of_items == 0 + folder_b.move_items_to_folder(trns=item_ids) + assert folder_b.total_number_of_items == 2 + + # cleanup (This will also delete playlists inside the folders, if they are still there + folder_a.remove() + 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_artist(session): favorites = session.user.favorites artist_id = 5247488 diff --git a/tidalapi/album.py b/tidalapi/album.py index d25b41f..cfee372 100644 --- a/tidalapi/album.py +++ b/tidalapi/album.py @@ -18,6 +18,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import copy +import functools from datetime import datetime from typing import TYPE_CHECKING, List, Optional, Union, cast @@ -180,31 +181,59 @@ def available_release_date(self) -> Optional[datetime]: return self.tidal_release_date return None - def tracks(self, limit: Optional[int] = None, offset: int = 0) -> List["Track"]: + def tracks( + self, + limit: Optional[int] = None, + offset: int = 0, + sparse_album: bool = False, + ) -> List["Track"]: """Returns the tracks in classes album. :param limit: The amount of items you want returned. :param offset: The position of the first item you want to include. + :param sparse_album: Provide a sparse track.album, containing only essential attributes from track JSON + False: Populate the track.album attributes from the parent Album object (self) :return: A list of the :class:`Tracks <.Track>` in the album. """ params = {"limit": limit, "offset": offset} + if sparse_album: + parse_track_callable = self.session.parse_track + else: + # Parse tracks attributes but provide the Album object directly from self + parse_track_callable = functools.partial( + self.session.parse_track, album=self + ) + tracks = self.request.map_request( - "albums/%s/tracks" % self.id, params, parse=self.session.parse_track + "albums/%s/tracks" % self.id, params, parse=parse_track_callable ) assert isinstance(tracks, list) return cast(List["Track"], tracks) - def items(self, limit: int = 100, offset: int = 0) -> List[Union["Track", "Video"]]: + def items( + self, limit: int = 100, offset: int = 0, sparse_album: bool = False + ) -> List[Union["Track", "Video"]]: """Gets the first 'limit' tracks and videos in the album from TIDAL. :param limit: The number of items you want to retrieve :param offset: The index you want to start retrieving items from + :param sparse_album: Provide a sparse track.album, containing only essential attributes from track JSON + False: Populate the track.album attributes from the parent Album object (self) :return: A list of :class:`Tracks<.Track>` and :class:`Videos`<.Video>` """ params = {"offset": offset, "limit": limit} + + if sparse_album: + parse_media_callable = self.session.parse_media + else: + # Parse tracks attributes but provide the Album object directly from self + parse_media_callable = functools.partial( + self.session.parse_media, album=self + ) + items = self.request.map_request( - "albums/%s/items" % self.id, params=params, parse=self.session.parse_media + "albums/%s/items" % self.id, params=params, parse=parse_media_callable ) assert isinstance(items, list) return cast(List[Union["Track", "Video"]], items) @@ -289,7 +318,6 @@ def review(self) -> str: :return: A :class:`str` containing the album review, with wimp links :raises: :class:`requests.HTTPError` if there isn't a review yet """ - # morguldir: TODO: Add parsing of wimplinks? review = self.request.request("GET", "albums/%s/review" % self.id).json()[ "text" ] diff --git a/tidalapi/artist.py b/tidalapi/artist.py index 57d69c0..cbde3f4 100644 --- a/tidalapi/artist.py +++ b/tidalapi/artist.py @@ -212,7 +212,6 @@ def get_bio(self) -> str: :return: A string containing the bio, as well as identifiers to other TIDAL objects inside the bio. """ - # morguldir: TODO: Add parsing of wimplinks? return cast( str, self.request.request("GET", f"artists/{self.id}/bio").json()["text"] ) diff --git a/tidalapi/media.py b/tidalapi/media.py index 46d3942..8b42c82 100644 --- a/tidalapi/media.py +++ b/tidalapi/media.py @@ -40,6 +40,7 @@ from isodate import parse_duration from mpegdash.parser import MPEGDASHParser +from tidalapi.album import Album from tidalapi.exceptions import ( ManifestDecodeError, MetadataNotAvailable, @@ -223,9 +224,11 @@ def _get(self, media_id: str) -> Media: "You are not supposed to use the media class directly." ) - def parse(self, json_obj: JsonObj) -> None: + def parse(self, json_obj: JsonObj, album: Optional[Album] = None) -> None: """Assigns all :param json_obj: + :param json_obj: The JSON object to parse + :param album: The (optional) album to use, instead of parsing the JSON object :return: """ artists = self.session.parse_artists(json_obj["artists"]) @@ -236,9 +239,9 @@ def parse(self, json_obj: JsonObj) -> None: else: artist = artists[0] - album = None - if json_obj["album"]: + if album is None and json_obj["album"]: album = self.session.album().parse(json_obj["album"], artist, artists) + self.album = album self.id = json_obj["id"] self.name = json_obj["title"] @@ -265,21 +268,23 @@ def parse(self, json_obj: JsonObj) -> None: self.popularity = json_obj["popularity"] self.artist = artist self.artists = artists - self.album = album self.type = json_obj.get("type") self.artist_roles = json_obj.get("artistRoles") - def parse_media(self, json_obj: JsonObj) -> Union["Track", "Video"]: + def parse_media( + self, json_obj: JsonObj, album: Optional[Album] = None + ) -> Union["Track", "Video"]: """Selects the media type when checking lists that can contain both. :param json_obj: The json containing the media + :param album: The (optional) album to use, instead of parsing the JSON object :return: Returns a new Video or Track object. """ if json_obj.get("type") is None or json_obj["type"] == "Track": - return Track(self.session).parse_track(json_obj) - # There are other types like Event, Live, and Video witch match the video class - return Video(self.session).parse_video(json_obj) + return Track(self.session).parse_track(json_obj, album) + # There are other types like Event, Live, and Video which match the video class + return Video(self.session).parse_video(json_obj, album) class Track(Media): @@ -295,8 +300,8 @@ class Track(Media): copyright = None media_metadata_tags = None - def parse_track(self, json_obj: JsonObj) -> Track: - Media.parse(self, json_obj) + def parse_track(self, json_obj: JsonObj, album: Optional[Album] = None) -> Track: + Media.parse(self, json_obj, album) self.replay_gain = json_obj["replayGain"] # Tracks from the pages endpoints might not actually exist if "peak" in json_obj and "isrc" in json_obj: @@ -846,8 +851,8 @@ class Video(Media): video_quality: Optional[str] = None cover: Optional[str] = None - def parse_video(self, json_obj: JsonObj) -> Video: - Media.parse(self, json_obj) + def parse_video(self, json_obj: JsonObj, album: Optional[Album] = None) -> Video: + Media.parse(self, json_obj, album) release_date = json_obj.get("releaseDate") self.release_date = ( dateutil.parser.isoparse(release_date) if release_date else None @@ -871,6 +876,7 @@ def _get(self, media_id: str) -> Video: :param media_id: TIDAL's identifier of the video :return: A :class:`Video` object containing all the information about the video. + :raises: A :class:`exceptions.ObjectNotFound` if video is not found or unavailable """ try: @@ -886,7 +892,7 @@ def _get(self, media_id: str) -> Video: return cast("Video", video) def get_url(self) -> str: - """Retrieves the URL for a video. + """Retrieves the URL to the m3u8 video playlist. :return: A `str` object containing the direct video URL :raises: A :class:`exceptions.URLNotAvailable` if no URL is available for this video diff --git a/tidalapi/mix.py b/tidalapi/mix.py index 9605e4c..3b3125c 100644 --- a/tidalapi/mix.py +++ b/tidalapi/mix.py @@ -176,10 +176,7 @@ class TextInfo: class MixV2: - """A mix from TIDALs v2 api endpoint, weirdly, it is used in only one place - currently.""" - - # tehkillerbee: TODO Doesn't look like this is using the v2 endpoint anyways!? + """A mix from TIDALs v2 api endpoint.""" date_added: Optional[datetime] = None title: Optional[str] = None diff --git a/tidalapi/playlist.py b/tidalapi/playlist.py index 84548c5..f026fae 100644 --- a/tidalapi/playlist.py +++ b/tidalapi/playlist.py @@ -37,11 +37,20 @@ import dateutil.parser +def list_validate(lst): + if isinstance(lst, str): + lst = [lst] + if len(lst) == 0: + raise ValueError("An empty list was provided.") + return lst + + class Playlist: """An object containing various data about a playlist and methods to work with them.""" id: Optional[str] = None + trn: Optional[str] = None name: Optional[str] = None num_tracks: int = -1 num_videos: int = -1 @@ -88,6 +97,7 @@ def parse(self, json_obj: JsonObj) -> "Playlist": :return: Returns a copy of the original :exc: 'Playlist': object """ self.id = json_obj["uuid"] + self.trn = f"trn:playlist:{self.id}" self.name = json_obj["title"] self.num_tracks = int(json_obj["numberOfTracks"]) self.num_videos = int(json_obj["numberOfVideos"]) @@ -136,7 +146,7 @@ def parse(self, json_obj: JsonObj) -> "Playlist": return copy.copy(self) - def factory(self) -> "Playlist": + def factory(self) -> Union["Playlist", "UserPlaylist"]: if ( self.id and self.creator @@ -231,35 +241,276 @@ def wide_image(self, width: int = 1080, height: int = 720) -> str: ) +class Folder: + """An object containing various data about a folder and methods to work with + them.""" + + trn: Optional[str] = None + id: Optional[str] = None + parent_folder_id: Optional[str] = None + name: Optional[str] = None + parent: Optional[str] = None # TODO Determine the correct type of the parent + added: Optional[datetime] = None + created: Optional[datetime] = None + last_modified: Optional[datetime] = None + total_number_of_items: int = 0 + + # Direct URL to https://listen.tidal.com/folder/<folder_id> + listen_url: str = "" + + def __init__( + self, + session: "Session", + folder_id: Optional[str], + parent_folder_id: str = "root", + ): + self.id = folder_id + self.parent_folder_id = parent_folder_id + self.session = session + self.request = session.request + self.playlist = session.playlist() + self._endpoint = "my-collection/playlists/folders" + if folder_id: + # Go through all available folders and see if the requested folder exists + try: + params = { + "folderId": parent_folder_id, + "offset": 0, + "limit": 50, + "order": "NAME", + "includeOnly": "FOLDER", + } + request = self.request.request( + "GET", + self._endpoint, + base_url=self.session.config.api_v2_location, + params=params, + ) + for item in request.json().get("items"): + if item["data"].get("id") == folder_id: + self.parse(item) + return + raise ObjectNotFound + except ObjectNotFound: + raise ObjectNotFound(f"Folder not found") + except TooManyRequests: + raise TooManyRequests("Folder unavailable") + + def _reparse(self) -> None: + params = { + "folderId": self.parent_folder_id, + "offset": 0, + "limit": 50, + "order": "NAME", + "includeOnly": "FOLDER", + } + request = self.request.request( + "GET", + self._endpoint, + base_url=self.session.config.api_v2_location, + params=params, + ) + for item in request.json().get("items"): + if item["data"].get("id") == self.id: + self.parse(item) + return + + def parse(self, json_obj: JsonObj) -> "Folder": + """Parses a folder from tidal, replaces the current folder object. + + :param json_obj: Json data returned from api.tidal.com containing a folder + :return: Returns a copy of the original :class:`Folder` object + """ + self.trn = json_obj.get("trn") + self.id = json_obj["data"].get("id") + self.name = json_obj.get("name") + self.parent = json_obj.get("parent") + added = json_obj.get("addedAt") + created = json_obj["data"].get("createdAt") + last_modified = json_obj["data"].get("lastModifiedAt") + self.added = dateutil.parser.isoparse(added) if added else None + self.created = dateutil.parser.isoparse(created) if added else None + self.last_modified = dateutil.parser.isoparse(last_modified) if added else None + self.total_number_of_items = json_obj["data"].get("totalNumberOfItems") + + self.listen_url = f"{self.session.config.listen_base_url}/folder/{self.id}" + + return copy.copy(self) + + def rename(self, name: str) -> bool: + """Rename the selected folder. + + :param name: The name to be used for the folder + :return: True, if operation was successful. + """ + params = {"trn": self.trn, "name": name} + endpoint = "my-collection/playlists/folders/rename" + res = self.request.request( + "PUT", + endpoint, + base_url=self.session.config.api_v2_location, + params=params, + ) + self._reparse() + return res.ok + + def remove(self) -> bool: + """Remove the selected folder. + + :return: True, if operation was successful. + """ + params = {"trns": self.trn} + endpoint = "my-collection/playlists/folders/remove" + return self.request.request( + "PUT", + endpoint, + base_url=self.session.config.api_v2_location, + params=params, + ).ok + + def items( + self, offset: int = 0, limit: int = 50 + ) -> List[Union["Playlist", "UserPlaylist"]]: + """Return the items in the folder. + + :param offset: Optional; The index of the first item to be returned. Default: 0 + :param limit: Optional; The amount of items you want returned. Default: 50 + :return: Returns a list of :class:`Playlist` or :class:`UserPlaylist` objects + """ + params = { + "folderId": self.id, + "offset": offset, + "limit": limit, + "order": "NAME", + "includeOnly": "PLAYLIST", + } + endpoint = "my-collection/playlists/folders" + json_obj = self.request.request( + "GET", + endpoint, + base_url=self.session.config.api_v2_location, + params=params, + ).json() + # Generate a dict of Playlist items from the response data + if json_obj.get("items"): + playlists = {"items": [item["data"] for item in json_obj.get("items")]} + return cast( + List[Union["Playlist", "UserPlaylist"]], + self.request.map_json(playlists, parse=self.playlist.parse_factory), + ) + else: + return [] + + def add_items(self, trns: [str]): + """Convenience method to add items to the current folder. + + :param trns: List of playlist trns to be added to the current folder + :return: True, if operation was successful. + """ + self.move_items_to_folder(trns, self.id) + + def move_items_to_root(self, trns: [str]): + """Convenience method to move items from the current folder to the root folder. + + :param trns: List of playlist trns to be moved from the current folder + :return: True, if operation was successful. + """ + self.move_items_to_folder(trns, folder="root") + + def move_items_to_folder(self, trns: [str], folder: str = None): + """Move item(s) in one folder to another folder. + + :param trns: List of playlist trns to be moved. + :param folder: Destination folder. Default: Use the current folder + :return: True, if operation was successful. + """ + trns = list_validate(trns) + # Make sure all trns has the correct type prepended to it + trns_full = [] + for trn in trns: + if "trn:" in trn: + trns_full.append(trn) + else: + trns_full.append(f"trn:playlist:{trn}") + if not folder: + folder = self.id + params = {"folderId": folder, "trns": ",".join(trns_full)} + endpoint = "my-collection/playlists/folders/move" + res = self.request.request( + "PUT", + endpoint, + base_url=self.session.config.api_v2_location, + params=params, + ) + self._reparse() + return res.ok + + class UserPlaylist(Playlist): def _reparse(self) -> None: + """Re-Read Playlist to get ETag.""" request = self.request.request("GET", self._base_url % self.id) self._etag = request.headers["etag"] self.request.map_json(request.json(), parse=self.parse) def edit( self, title: Optional[str] = None, description: Optional[str] = None - ) -> None: + ) -> bool: + """Edit UserPlaylist title & description. + + :param title: Playlist title + :param description: Playlist title. + :return: True, if successful. + """ if not title: title = self.name if not description: description = self.description data = {"title": title, "description": description} - self.request.request("POST", self._base_url % self.id, data=data) + return self.request.request("POST", self._base_url % self.id, data=data).ok - def delete(self) -> None: - self.request.request("DELETE", self._base_url % self.id) + def delete_by_id(self, media_ids: List[str]) -> bool: + """Delete one or more items from the UserPlaylist. - def add(self, media_ids: List[str]) -> None: + :param media_ids: Lists of Media IDs to remove. + :return: True, if successful. + """ + media_ids = list_validate(media_ids) + # Generate list of track indices of tracks found in the list of media_ids. + track_ids = [str(track.id) for track in self.tracks()] + matching_indices = [i for i, item in enumerate(track_ids) if item in media_ids] + return self.remove_by_indices(matching_indices) + + def add( + self, + media_ids: List[str], + allow_duplicates: bool = False, + position: int = -1, + limit: int = 100, + ) -> List[int]: + """Add one or more items to the UserPlaylist. + + :param media_ids: List of Media IDs to add. + :param allow_duplicates: Allow adding duplicate items + :param position: Insert items at a specific position. + Default: insert at the end of the playlist + :param limit: Maximum number of items to add + :return: List of media IDs that has been added + """ + media_ids = list_validate(media_ids) + # Insert items at a specific index + if position < 0 or position > self.num_tracks: + position = self.num_tracks data = { "onArtifactNotFound": "SKIP", - "onDupes": "SKIP", "trackIds": ",".join(map(str, media_ids)), + "toIndex": position, + "onDupes": "ADD" if allow_duplicates else "SKIP", } - params = {"limit": 100} + params = {"limit": limit} headers = {"If-None-Match": self._etag} if self._etag else None - self.request.request( + res = self.request.request( "POST", self._base_url % self.id + "/items", params=params, @@ -267,35 +518,215 @@ def add(self, media_ids: List[str]) -> None: headers=headers, ) self._reparse() + # Respond with the added item IDs: + added_items = res.json().get("addedItemIds") + if added_items: + return added_items + else: + return [] + + def merge( + self, playlist: str, allow_duplicates: bool = False, allow_missing: bool = True + ) -> List[int]: + """Add (merge) items from a playlist with the current playlist. + + :param playlist: Playlist UUID to be merged in the current playlist + :param allow_duplicates: If true, duplicate tracks are allowed. Otherwise, + tracks will be skipped. + :param allow_missing: If true, missing tracks are allowed. Otherwise, exception + will be thrown + :return: List of items that has been added from the playlist + """ + data = { + "fromPlaylistUuid": str(playlist), + "onArtifactNotFound": "SKIP" if allow_missing else "FAIL", + "onDupes": "ADD" if allow_duplicates else "SKIP", + } + headers = {"If-None-Match": self._etag} if self._etag else None + res = self.request.request( + "POST", + self._base_url % self.id + "/items", + data=data, + headers=headers, + ) + self._reparse() + # Respond with the added item IDs: + added_items = res.json().get("addedItemIds") + if added_items: + return added_items + else: + return [] + + def add_by_isrc( + self, + isrc: str, + allow_duplicates: bool = False, + position: int = -1, + ) -> bool: + """Add an item to a playlist, using the track ISRC. + + :param isrc: The ISRC of the track to be added + :param allow_duplicates: Allow adding duplicate items + :param position: Insert items at a specific position. + Default: insert at the end of the playlist + :return: True, if successful. + """ + if not isinstance(isrc, str): + isrc = str(isrc) + try: + track = self.session.get_tracks_by_isrc(isrc) + if track: + # Add the first track in the list + track_id = track[0].id + added = self.add( + [str(track_id)], + allow_duplicates=allow_duplicates, + position=position, + ) + if track_id in added: + return True + else: + return False + else: + return False + except ObjectNotFound: + return False + + def move_by_id(self, media_id: str, position: int) -> bool: + """Move an item to a new position, by media ID. - def remove_by_index(self, index: int) -> None: + :param media_id: The index of the item to be moved + :param position: The new position of the item + :return: True, if successful. + """ + if not isinstance(media_id, str): + media_id = str(media_id) + track_ids = [str(track.id) for track in self.tracks()] + try: + index = track_ids.index(media_id) + if index is not None and index < self.num_tracks: + return self.move_by_indices([index], position) + except ValueError: + return False + + def move_by_index(self, index: int, position: int) -> bool: + """Move a single item to a new position. + + :param index: The index of the item to be moved + :param position: The new position/offset of the item + :return: True, if successful. + """ + if not isinstance(index, int): + raise ValueError + return self.move_by_indices([index], position) + + def move_by_indices(self, indices: Sequence[int], position: int) -> bool: + """Move one or more items to a new position. + + :param indices: List containing indices to move. + :param position: The new position/offset of the item(s) + :return: True, if successful. + """ + # Move item to a new position + if position < 0 or position >= self.num_tracks: + position = self.num_tracks + data = { + "toIndex": position, + } headers = {"If-None-Match": self._etag} if self._etag else None - self.request.request( - "DELETE", (self._base_url + "/items/%i") % (self.id, index), headers=headers + track_index_string = ",".join([str(x) for x in indices]) + res = self.request.request( + "POST", + (self._base_url + "/items/%s") % (self.id, track_index_string), + data=data, + headers=headers, ) + self._reparse() + return res.ok + + def remove_by_id(self, media_id: str) -> bool: + """Remove a single item from the playlist, using the media ID. + + :param media_id: Media ID to remove. + :return: True, if successful. + """ + if not isinstance(media_id, str): + media_id = str(media_id) + track_ids = [str(track.id) for track in self.tracks()] + try: + index = track_ids.index(media_id) + if index is not None and index < self.num_tracks: + return self.remove_by_index(index) + except ValueError: + return False + + def remove_by_index(self, index: int) -> bool: + """Remove a single item from the UserPlaylist, using item index. + + :param index: Media index to remove + :return: True, if successful. + """ + return self.remove_by_indices([index]) - def remove_by_indices(self, indices: Sequence[int]) -> None: + def remove_by_indices(self, indices: Sequence[int]) -> bool: + """Remove one or more items from the UserPlaylist, using list of indices. + + :param indices: List containing indices to remove. + :return: True, if successful. + """ headers = {"If-None-Match": self._etag} if self._etag else None track_index_string = ",".join([str(x) for x in indices]) - self.request.request( + res = self.request.request( "DELETE", - (self._base_url + "/tracks/%s") % (self.id, track_index_string), + (self._base_url + "/items/%s") % (self.id, track_index_string), headers=headers, ) + self._reparse() + return res.ok + + def clear(self, chunk_size: int = 50) -> bool: + """Clear UserPlaylist. - def _calculate_id(self, media_id: str) -> Optional[int]: - i = 0 - while i < self.num_tracks: - items = self.items(100, i) - for index, item in enumerate(items): - if item.id == media_id: - # Return the amount of items we have gone through plus the index in the last list. - return index + i - - i += len(items) - return None - - def remove_by_id(self, media_id: str) -> None: - index = self._calculate_id(media_id) - if index is not None: - self.remove_by_index(index) + :param chunk_size: Number of items to remove per request + :return: True, if successful. + """ + while self.num_tracks: + indices = range(min(self.num_tracks, chunk_size)) + if not self.remove_by_indices(indices): + return False + return True + + def set_playlist_public(self): + """Set UserPlaylist as Public. + + :return: True, if successful. + """ + res = self.request.request( + "PUT", + base_url=self.session.config.api_v2_location, + path=(self._base_url + "/set-public") % self.id, + ) + self.public = True + self._reparse() + return res.ok + + def set_playlist_private(self): + """Set UserPlaylist as Private. + + :return: True, if successful. + """ + res = self.request.request( + "PUT", + base_url=self.session.config.api_v2_location, + path=(self._base_url + "/set-private") % self.id, + ) + self.public = False + self._reparse() + return res.ok + + def delete(self) -> bool: + """Delete UserPlaylist. + + :return: True, if successful. + """ + return self.request.request("DELETE", path="playlists/%s" % self.id).ok diff --git a/tidalapi/session.py b/tidalapi/session.py index 3b323a1..91e3cb2 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -24,7 +24,6 @@ import datetime import hashlib import json -import locale import logging import os import random @@ -46,7 +45,7 @@ cast, no_type_check, ) -from urllib.parse import parse_qs, urlencode, urljoin, urlsplit +from urllib.parse import parse_qs, urlencode, urlsplit import requests from requests.exceptions import HTTPError @@ -274,14 +273,14 @@ def __init__(self, config: Config = Config()): self.request = request.Requests(session=self) self.genre = genre.Genre(session=self) - self.parse_artists = self.artist().parse_artists - self.parse_playlist = self.playlist().parse + # self.parse_artists = self.artist().parse_artists + # self.parse_playlist = self.playlist().parse - self.parse_track = self.track().parse_track - self.parse_video = self.video().parse_video - self.parse_media = self.track().parse_media - self.parse_mix = self.mix().parse - self.parse_v2_mix = self.mixv2().parse + # self.parse_track = self.track().parse_track + # self.parse_video = self.video().parse_video + # self.parse_media = self.track().parse_media + # self.parse_mix = self.mix().parse + # self.parse_v2_mix = self.mixv2().parse self.parse_user = user.User(self, None).parse self.page = page.Page(self, "") @@ -318,14 +317,46 @@ def parse_album(self, obj: JsonObj) -> album.Album: """Parse an album from the given response.""" return self.album().parse(obj) + def parse_track( + self, obj: JsonObj, album: Optional[album.Album] = None + ) -> media.Track: + """Parse an album from the given response.""" + return self.track().parse_track(obj, album) + + def parse_video(self, obj: JsonObj) -> media.Video: + """Parse an album from the given response.""" + return self.video().parse_video(obj) + + def parse_media( + self, obj: JsonObj, album: Optional[album.Album] = None + ) -> Union[media.Track, media.Video]: + """Parse a media type (track, video) from the given response.""" + return self.track().parse_media(obj, album) + def parse_artist(self, obj: JsonObj) -> artist.Artist: """Parse an artist from the given response.""" return self.artist().parse_artist(obj) + def parse_artists(self, obj: List[JsonObj]) -> List[artist.Artist]: + """Parse an artist from the given response.""" + return self.artist().parse_artists(obj) + def parse_mix(self, obj: JsonObj) -> mix.Mix: """Parse a mix from the given response.""" return self.mix().parse(obj) + def parse_v2_mix(self, obj: JsonObj) -> mix.Mix: + """Parse a mixV2 from the given response.""" + return self.mixv2().parse(obj) + + def parse_playlist(self, obj: JsonObj) -> playlist.Playlist: + """Parse a playlist from the given response.""" + return self.playlist().parse(obj) + + def parse_folder(self, obj: JsonObj) -> playlist.Folder: + """Parse an album from the given response.""" + return self.folder().parse(obj) + def convert_type( self, search: Any, @@ -416,32 +447,6 @@ def load_oauth_session( return True - def login(self, username: str, password: str) -> bool: - """Logs in to the TIDAL api. - - :param username: The TIDAL username - :param password: The password to your TIDAL account - :return: Returns true if we think the login was successful. - """ - url = urljoin(self.config.api_v1_location, "login/username") - headers: dict[str, str] = {"X-Tidal-Token": self.config.api_token} - payload = { - "username": username, - "password": password, - "clientUniqueKey": format(random.getrandbits(64), "02x"), - } - request = self.request_session.post(url, data=payload, headers=headers) - - if not request.ok: - log.error("Login failed: %s", request.text) - request.raise_for_status() - - body = request.json() - self.session_id = str(body["sessionId"]) - self.country_code = str(body["countryCode"]) - self.user = user.User(self, user_id=body["userId"]).factory() - return True - def login_session_file( self, session_file: Path, @@ -843,6 +848,20 @@ def playlist( log.warning("Playlist '%s' is unavailable", playlist_id) raise + def folder(self, folder_id: Optional[str] = None) -> playlist.Folder: + """Function to create a Folder object with access to the session instance in a + smoother way. Calls :class:`tidalapi.Folder(session=session, folder_id=track_id) + <.Folder>` internally. + + :param folder_id: + :return: Returns a :class:`.Folder` object that has access to the session instance used. + """ + try: + return playlist.Folder(session=self, folder_id=folder_id) + except ObjectNotFound: + log.warning("Folder '%s' is unavailable", folder_id) + raise + def track( self, track_id: Optional[str] = None, with_album: bool = False ) -> media.Track: diff --git a/tidalapi/user.py b/tidalapi/user.py index 8512c24..eea2f29 100644 --- a/tidalapi/user.py +++ b/tidalapi/user.py @@ -25,9 +25,10 @@ from __future__ import annotations from copy import copy -from typing import TYPE_CHECKING, Dict, List, Optional, Union, cast +from typing import TYPE_CHECKING, List, Optional, Union, cast from urllib.parse import urljoin +from tidalapi.exceptions import ObjectNotFound from tidalapi.types import JsonObj if TYPE_CHECKING: @@ -35,10 +36,18 @@ from tidalapi.artist import Artist from tidalapi.media import Track, Video from tidalapi.mix import MixV2 - from tidalapi.playlist import Playlist, UserPlaylist + from tidalapi.playlist import Folder, Playlist, UserPlaylist from tidalapi.session import Session +def list_validate(lst): + if isinstance(lst, str): + lst = [lst] + if len(lst) == 0: + raise ValueError("An empty list was provided.") + return lst + + class User: """A class containing various information about a TIDAL user. @@ -57,6 +66,7 @@ def __init__(self, session: "Session", user_id: Optional[int]): self.session = session self.request = session.request self.playlist = session.playlist() + self.folder = session.folder() def factory(self) -> Union["LoggedInUser", "FetchedUser", "PlaylistCreator"]: return cast( @@ -131,7 +141,7 @@ def parse(self, json_obj: JsonObj) -> "LoggedInUser": return copy(self) def playlists(self) -> List[Union["Playlist", "UserPlaylist"]]: - """Get the playlists created by the user. + """Get the (personal) playlists created by the user. :return: Returns a list of :class:`~tidalapi.playlist.Playlist` objects containing the playlists. """ @@ -142,15 +152,71 @@ def playlists(self) -> List[Union["Playlist", "UserPlaylist"]]: ), ) + def playlist_folders( + self, offset: int = 0, limit: int = 50, parent_folder_id: str = "root" + ) -> List["Folder"]: + """Get a list of folders created by the user. + + :param offset: The amount of items you want returned. + :param limit: The index of the first item you want included. + :param parent_folder_id: Parent folder ID. Default: 'root' playlist folder + :return: Returns a list of :class:`~tidalapi.playlist.Folder` objects containing the Folders. + """ + params = { + "folderId": parent_folder_id, + "offset": offset, + "limit": limit, + "order": "NAME", + "includeOnly": "FOLDER", + } + endpoint = "my-collection/playlists/folders" + return cast( + List["Folder"], + self.session.request.map_request( + url=urljoin( + self.session.config.api_v2_location, + endpoint, + ), + params=params, + parse=self.session.parse_folder, + ), + ) + + def public_playlists( + self, offset: int = 0, limit: int = 50 + ) -> List[Union["Playlist", "UserPlaylist"]]: + """Get the (public) playlists created by the user. + + :param offset: The amount of items you want returned. + :param limit: The index of the first item you want included. + :return: List of public playlists. + """ + params = {"limit": limit, "offset": offset} + endpoint = "user-playlists/%s/public" % self.id + json_obj = self.request.request( + "GET", endpoint, base_url=self.session.config.api_v2_location, params=params + ).json() + + # The response contains both playlists and user details (followInfo, profile) but we will discard the latter. + playlists = {"items": []} + for index, item in enumerate(json_obj["items"]): + if item["playlist"]: + playlists["items"].append(item["playlist"]) + + return cast( + List[Union["Playlist", "UserPlaylist"]], + self.request.map_json(playlists, parse=self.playlist.parse_factory), + ) + def playlist_and_favorite_playlists( - self, offset: int = 0 + self, limit: Optional[int] = None, offset: int = 0 ) -> List[Union["Playlist", "UserPlaylist"]]: """Get the playlists created by the user, and the playlists favorited by the user. This function is limited to 50 by TIDAL, requiring pagination. :return: Returns a list of :class:`~tidalapi.playlist.Playlist` objects containing the playlists. """ - params = {"limit": 50, "offset": offset} + params = {"limit": limit, "offset": offset} endpoint = "users/%s/playlistsAndFavoritePlaylists" % self.id json_obj = self.request.request("GET", endpoint, params=params).json() @@ -164,13 +230,52 @@ def playlist_and_favorite_playlists( self.request.map_json(json_obj, parse=self.playlist.parse_factory), ) - def create_playlist(self, title: str, description: str) -> "Playlist": - data = {"title": title, "description": description} - json = self.request.request( - "POST", "users/%s/playlists" % self.id, data=data + def create_playlist( + self, title: str, description: str, parent_id: str = "root" + ) -> "UserPlaylist": + """Create a playlist in the specified parent folder. + + :param title: Playlist title + :param description: Playlist description + :param parent_id: Parent folder ID. Default: 'root' playlist folder + :return: Returns an object of :class:`~tidalapi.playlist.UserPlaylist` containing the newly created playlist + """ + params = {"name": title, "description": description, "folderId": parent_id} + endpoint = "my-collection/playlists/folders/create-playlist" + + json_obj = self.request.request( + method="PUT", + path=endpoint, + base_url=self.session.config.api_v2_location, + params=params, ).json() - playlist = self.session.playlist().parse(json) - return playlist.factory() + json = json_obj.get("data") + if json and json.get("uuid"): + playlist = self.session.playlist().parse(json) + return playlist.factory() + else: + raise ObjectNotFound("Playlist not found after creation") + + def create_folder(self, title: str, parent_id: str = "root") -> "Folder": + """Create folder in the specified parent folder. + + :param title: Folder title + :param parent_id: Folder parent ID. Default: 'root' playlist folder + :return: Returns an object of :class:`~tidalapi.playlist.Folder` containing the newly created object + """ + params = {"name": title, "folderId": parent_id} + endpoint = "my-collection/playlists/folders/create-folder" + + json_obj = self.request.request( + method="PUT", + path=endpoint, + base_url=self.session.config.api_v2_location, + params=params, + ).json() + if json_obj and json_obj.get("data"): + return self.request.map_json(json_obj, parse=self.folder.parse) + else: + raise ObjectNotFound("Folder not found after creation") class PlaylistCreator(User): @@ -241,6 +346,25 @@ def add_track(self, track_id: str) -> bool: "POST", f"{self.base_url}/tracks", data={"trackId": track_id} ).ok + def add_track_by_isrc(self, isrc: str) -> bool: + """Adds a track to the users favorites, using isrc. + + :param isrc: The ISRC of the track to be added + :return: True, if successful. + """ + try: + track = self.session.get_tracks_by_isrc(isrc) + if track: + # Add the first track in the list + track_id = str(track[0].id) + return self.requests.request( + "POST", f"{self.base_url}/tracks", data={"trackId": track_id} + ).ok + else: + return False + except ObjectNotFound: + return False + def add_video(self, video_id: str) -> bool: """Adds a video to the users favorites. @@ -299,6 +423,33 @@ def remove_video(self, video_id: str) -> bool: """ 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. + + :param trns: List of folder (or playlist) trns to be deleted + :param type: Type of trn: as string, either `folder` or `playlist`. Default `folder` + :return: A boolean indicating whether theÅ› request was successful or not. + """ + if type not in ("playlist", "folder"): + raise ValueError("Invalid trn value used for playlist/folder endpoint") + trns = list_validate(trns) + # Make sure all trns has the correct type prepended to it + trns_full = [] + for trn in trns: + if "trn:" in trn: + trns_full.append(trn) + else: + trns_full.append(f"trn:{type}:{trn}") + params = {"trns": ",".join(trns_full)} + endpoint = "my-collection/playlists/folders/remove" + return self.requests.request( + method="PUT", + path=endpoint, + base_url=self.session.config.api_v2_location, + params=params, + ).ok + def artists(self, limit: Optional[int] = None, offset: int = 0) -> List["Artist"]: """Get the users favorite artists.