From 0be52bdb0f334681d5f80ed4d5978111237ae1e1 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 11 Sep 2024 16:06:33 +0200 Subject: [PATCH 01/24] Added clarifications to video_url. Check video URLs for all available video qualities (Fixes #257) --- tests/test_media.py | 16 ++++++++++++++++ tidalapi/media.py | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) 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/tidalapi/media.py b/tidalapi/media.py index 46d3942..ee4a912 100644 --- a/tidalapi/media.py +++ b/tidalapi/media.py @@ -871,6 +871,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 +887,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 From 24152e4e6b0ac80eaabb1ae173fd0c80cb817153 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 11 Sep 2024 17:00:07 +0200 Subject: [PATCH 02/24] Populate the track/items.album attributes from the parent Album object. Updated tests (Fixes #281) --- tests/test_album.py | 21 +++++++++++++++++++-- tidalapi/album.py | 37 +++++++++++++++++++++++++++++++++---- tidalapi/media.py | 29 +++++++++++++++++------------ 3 files changed, 69 insertions(+), 18 deletions(-) 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/tidalapi/album.py b/tidalapi/album.py index d25b41f..b2bdc30 100644 --- a/tidalapi/album.py +++ b/tidalapi/album.py @@ -18,6 +18,7 @@ # along with this program. If not, see . 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) diff --git a/tidalapi/media.py b/tidalapi/media.py index ee4a912..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 From 0c0f332a3e1d9b9becf760cc4c5dee13a7a4698a Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 11 Sep 2024 17:23:29 +0200 Subject: [PATCH 03/24] Remove deprecated username/pass login method (Fixes #279) Formatting --- tests/test_session.py | 7 ------- tidalapi/session.py | 29 +---------------------------- 2 files changed, 1 insertion(+), 35 deletions(-) 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/tidalapi/session.py b/tidalapi/session.py index 3b323a1..ce9a402 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 @@ -416,32 +415,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, From 1d94898ba5f01ce5950b29adc0a97764a80fdc80 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Mon, 30 Sep 2024 22:48:54 +0200 Subject: [PATCH 04/24] Remove multiple items from UserPlaylist. Set UserPlaylist public/private. Updated doxygen (Fixes #259) Renamed delete method Delete UserPlaylist. Reparse after changing public/private state --- tidalapi/playlist.py | 99 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 81 insertions(+), 18 deletions(-) diff --git a/tidalapi/playlist.py b/tidalapi/playlist.py index 84548c5..5bc1395 100644 --- a/tidalapi/playlist.py +++ b/tidalapi/playlist.py @@ -22,7 +22,7 @@ import copy from datetime import datetime -from typing import TYPE_CHECKING, List, Optional, Sequence, Union, cast +from typing import TYPE_CHECKING, List, Optional, Sequence, Union from tidalapi.exceptions import ObjectNotFound, TooManyRequests from tidalapi.types import JsonObj @@ -233,6 +233,7 @@ def wide_image(self, width: int = 1080, height: int = 720) -> str: 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) @@ -240,6 +241,11 @@ def _reparse(self) -> None: def edit( self, title: Optional[str] = None, description: Optional[str] = None ) -> None: + """ + Edit UserPlaylist title & description + :param title: Playlist title + :param description: Playlist title + """ if not title: title = self.name if not description: @@ -248,10 +254,21 @@ def edit( data = {"title": title, "description": description} self.request.request("POST", self._base_url % self.id, data=data) - def delete(self) -> None: - self.request.request("DELETE", self._base_url % self.id) + def delete(self, media_ids: List[str]) -> None: + """ + Delete one or more items from the UserPlaylist + :param media_ids: Lists of Media IDs to remove + """ + # 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] + self.remove_by_indices(matching_indices) def add(self, media_ids: List[str]) -> None: + """ + Add one or more items to the UserPlaylist + :param media_ids: List of Media IDs to add + """ data = { "onArtifactNotFound": "SKIP", "onDupes": "SKIP", @@ -268,34 +285,80 @@ def add(self, media_ids: List[str]) -> None: ) self._reparse() + def remove_by_id(self, media_id: str) -> None: + """ + Remove a single item from the playlist, using the media ID + :param media_id: Media ID to remove + """ + 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: + self.remove_by_index(index) + except ValueError: + pass + def remove_by_index(self, index: int) -> None: + """ + Remove a single item from the UserPlaylist, using item index. + :param index: Media index to remove + """ 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 ) def remove_by_indices(self, indices: Sequence[int]) -> None: + """ + Remove one or more items from the UserPlaylist, using list of indices + :param indices: List containing indices to remove + """ headers = {"If-None-Match": self._etag} if self._etag else None track_index_string = ",".join([str(x) for x in indices]) 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() - 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 + def clear(self, chunk_size: int = 50): + """ + Clear UserPlaylist + :param chunk_size: Number of items to remove per request + :return: + """ + while self.num_tracks: + indices = range(min(self.num_tracks, chunk_size)) + self.remove_by_indices(indices) - i += len(items) - return None + def set_playlist_public(self): + """ + Set UserPlaylist as Public + """ + 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() - 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) + def set_playlist_private(self): + """ + Set UserPlaylist as Private + """ + 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() + + def delete_playlist(self): + """ + Delete UserPlaylist + :return: True, if successful + """ + return self.request.request("DELETE", path="playlists/%s" % self.id).ok From e9fcd6cfc1230b48fcf3c582c5765359152ba76e Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Mon, 30 Sep 2024 23:16:36 +0200 Subject: [PATCH 05/24] Add method for getting public user playlists --- tidalapi/user.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/tidalapi/user.py b/tidalapi/user.py index 8512c24..650a85e 100644 --- a/tidalapi/user.py +++ b/tidalapi/user.py @@ -25,7 +25,7 @@ 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.types import JsonObj @@ -142,6 +142,28 @@ def playlists(self) -> List[Union["Playlist", "UserPlaylist"]]: ), ) + def get_public_playlists(self, offset=0) -> List[Union["Playlist", "UserPlaylist"]]: + """ + Get the (public) playlists created by the user + :return: List of public playlists + """ + params = {"limit": 50, "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 ) -> List[Union["Playlist", "UserPlaylist"]]: From 89cc63b7984f9f9d00dc8d09453501934bd813a3 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Mon, 30 Sep 2024 23:23:03 +0200 Subject: [PATCH 06/24] Formatting --- tidalapi/playlist.py | 54 ++++++++++++++++++++------------------------ tidalapi/user.py | 6 ++--- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/tidalapi/playlist.py b/tidalapi/playlist.py index 5bc1395..45d9c57 100644 --- a/tidalapi/playlist.py +++ b/tidalapi/playlist.py @@ -233,7 +233,7 @@ def wide_image(self, width: int = 1080, height: int = 720) -> str: class UserPlaylist(Playlist): def _reparse(self) -> None: - # Re-Read Playlist to get ETag + """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) @@ -241,10 +241,10 @@ def _reparse(self) -> None: def edit( self, title: Optional[str] = None, description: Optional[str] = None ) -> None: - """ - Edit UserPlaylist title & description + """Edit UserPlaylist title & description. + :param title: Playlist title - :param description: Playlist title + :param description: Playlist title. """ if not title: title = self.name @@ -255,9 +255,9 @@ def edit( self.request.request("POST", self._base_url % self.id, data=data) def delete(self, media_ids: List[str]) -> None: - """ - Delete one or more items from the UserPlaylist - :param media_ids: Lists of Media IDs to remove + """Delete one or more items from the UserPlaylist. + + :param media_ids: Lists of Media IDs to remove. """ # Generate list of track indices of tracks found in the list of media_ids. track_ids = [str(track.id) for track in self.tracks()] @@ -265,9 +265,9 @@ def delete(self, media_ids: List[str]) -> None: self.remove_by_indices(matching_indices) def add(self, media_ids: List[str]) -> None: - """ - Add one or more items to the UserPlaylist - :param media_ids: List of Media IDs to add + """Add one or more items to the UserPlaylist. + + :param media_ids: List of Media IDs to add. """ data = { "onArtifactNotFound": "SKIP", @@ -286,9 +286,9 @@ def add(self, media_ids: List[str]) -> None: self._reparse() def remove_by_id(self, media_id: str) -> None: - """ - Remove a single item from the playlist, using the media ID - :param media_id: Media ID to remove + """Remove a single item from the playlist, using the media ID :param media_id: + + Media ID to remove. """ track_ids = [str(track.id) for track in self.tracks()] try: @@ -299,8 +299,8 @@ def remove_by_id(self, media_id: str) -> None: pass def remove_by_index(self, index: int) -> None: - """ - Remove a single item from the UserPlaylist, using item index. + """Remove a single item from the UserPlaylist, using item index. + :param index: Media index to remove """ headers = {"If-None-Match": self._etag} if self._etag else None @@ -309,9 +309,9 @@ def remove_by_index(self, index: int) -> None: ) def remove_by_indices(self, indices: Sequence[int]) -> None: - """ - Remove one or more items from the UserPlaylist, using list of indices - :param indices: List containing indices to remove + """Remove one or more items from the UserPlaylist, using list of indices. + + :param indices: List containing indices to remove. """ headers = {"If-None-Match": self._etag} if self._etag else None track_index_string = ",".join([str(x) for x in indices]) @@ -323,8 +323,8 @@ def remove_by_indices(self, indices: Sequence[int]) -> None: self._reparse() def clear(self, chunk_size: int = 50): - """ - Clear UserPlaylist + """Clear UserPlaylist. + :param chunk_size: Number of items to remove per request :return: """ @@ -333,9 +333,7 @@ def clear(self, chunk_size: int = 50): self.remove_by_indices(indices) def set_playlist_public(self): - """ - Set UserPlaylist as Public - """ + """Set UserPlaylist as Public.""" self.request.request( "PUT", base_url=self.session.config.api_v2_location, @@ -345,9 +343,7 @@ def set_playlist_public(self): self._reparse() def set_playlist_private(self): - """ - Set UserPlaylist as Private - """ + """Set UserPlaylist as Private.""" self.request.request( "PUT", base_url=self.session.config.api_v2_location, @@ -357,8 +353,8 @@ def set_playlist_private(self): self._reparse() def delete_playlist(self): - """ - Delete UserPlaylist - :return: True, if successful + """Delete UserPlaylist. + + :return: True, if successful. """ return self.request.request("DELETE", path="playlists/%s" % self.id).ok diff --git a/tidalapi/user.py b/tidalapi/user.py index 650a85e..46165ea 100644 --- a/tidalapi/user.py +++ b/tidalapi/user.py @@ -143,9 +143,9 @@ def playlists(self) -> List[Union["Playlist", "UserPlaylist"]]: ) def get_public_playlists(self, offset=0) -> List[Union["Playlist", "UserPlaylist"]]: - """ - Get the (public) playlists created by the user - :return: List of public playlists + """Get the (public) playlists created by the user. + + :return: List of public playlists. """ params = {"limit": 50, "offset": offset} endpoint = "user-playlists/%s/public" % self.id From 90daa30ce2b8ac5179d7dfad6730227d82c67c97 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Mon, 30 Sep 2024 23:23:14 +0200 Subject: [PATCH 07/24] Updated changelog --- HISTORY.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/HISTORY.rst b/HISTORY.rst index 7ea13ab..6ad0053 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -4,6 +4,11 @@ History ======= v0.7.7 ------ +* 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_ From 15191b4a47398fbe5c4f43370d0597092504d7bf Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Tue, 1 Oct 2024 21:01:31 +0200 Subject: [PATCH 08/24] Add support for adding tracks to playlists at an offset. Allow duplicates argument (Partially fixes #116) --- tidalapi/playlist.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tidalapi/playlist.py b/tidalapi/playlist.py index 45d9c57..ded1bb1 100644 --- a/tidalapi/playlist.py +++ b/tidalapi/playlist.py @@ -264,16 +264,28 @@ def delete(self, media_ids: List[str]) -> None: matching_indices = [i for i, item in enumerate(track_ids) if item in media_ids] self.remove_by_indices(matching_indices) - def add(self, media_ids: List[str]) -> None: + def add( + self, + media_ids: List[str], + allow_duplicates: bool = False, + position: int = -1, + ) -> None: """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 """ + # 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, } + if not allow_duplicates: + data["onDupes"] = "SKIP" params = {"limit": 100} headers = {"If-None-Match": self._etag} if self._etag else None self.request.request( From 7c2b777afd827045e18b0a3de043bf2a9d54e4ac Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Tue, 1 Oct 2024 21:54:48 +0200 Subject: [PATCH 09/24] Add support for moving playlist items (Fixes #116) --- tidalapi/playlist.py | 50 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tidalapi/playlist.py b/tidalapi/playlist.py index ded1bb1..ccdcd88 100644 --- a/tidalapi/playlist.py +++ b/tidalapi/playlist.py @@ -296,6 +296,56 @@ def add( headers=headers, ) self._reparse() + def move_by_id(self, media_id: str, position: int) -> bool: + """ + Move an item to a new position, by media ID + + :param media_id: The index of the item to be moved + :param position: The new position of the item + :return: True, if success + """ + 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 success + """ + 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 success + """ + # 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 + 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) -> None: """Remove a single item from the playlist, using the media ID :param media_id: From 2c5e42bc8b5e5dd539b0d9ac2ff536f5a0650b5d Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Tue, 1 Oct 2024 21:56:29 +0200 Subject: [PATCH 10/24] Add method return value on success. --- tidalapi/playlist.py | 78 +++++++++++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 30 deletions(-) diff --git a/tidalapi/playlist.py b/tidalapi/playlist.py index ccdcd88..4181933 100644 --- a/tidalapi/playlist.py +++ b/tidalapi/playlist.py @@ -240,11 +240,12 @@ def _reparse(self) -> None: 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 @@ -252,29 +253,31 @@ def edit( 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, media_ids: List[str]) -> None: + def delete(self, media_ids: List[str]) -> bool: """Delete one or more items from the UserPlaylist. :param media_ids: Lists of Media IDs to remove. + :return: True, if successful. """ # 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] - self.remove_by_indices(matching_indices) + return self.remove_by_indices(matching_indices) def add( self, media_ids: List[str], allow_duplicates: bool = False, position: int = -1, - ) -> None: + ) -> bool: """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 + :return: True, if successful. """ # Insert items at a specific index if position < 0 or position > self.num_tracks: @@ -288,7 +291,7 @@ def add( data["onDupes"] = "SKIP" params = {"limit": 100} 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, @@ -296,13 +299,15 @@ def add( headers=headers, ) self._reparse() + return res.ok + def move_by_id(self, media_id: str, position: int) -> bool: """ Move an item to a new position, by media ID :param media_id: The index of the item to be moved :param position: The new position of the item - :return: True, if success + :return: True, if successful. """ track_ids = [str(track.id) for track in self.tracks()] try: @@ -318,7 +323,7 @@ def move_by_index(self, index: int, position: int) -> bool: :param index: The index of the item to be moved :param position: The new position/offset of the item - :return: True, if success + :return: True, if successful. """ return self.move_by_indices([index], position) @@ -328,7 +333,7 @@ def move_by_indices(self, indices: Sequence[int], position: int) -> bool: :param indices: List containing indices to move. :param position: The new position/offset of the item(s) - :return: True, if success + :return: True, if successful. """ # Move item to a new position if position < 0 or position >= self.num_tracks: @@ -347,76 +352,89 @@ def move_by_indices(self, indices: Sequence[int], position: int) -> bool: self._reparse() return res.ok - def remove_by_id(self, media_id: str) -> None: - """Remove a single item from the playlist, using the media ID :param media_id: + def remove_by_id(self, media_id: str) -> bool: + """Remove a single item from the playlist, using the media ID - Media ID to remove. + :param media_id: Media ID to remove. + :return: True, if successful. """ 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: - self.remove_by_index(index) + return self.remove_by_index(index) except ValueError: - pass + return False - def remove_by_index(self, index: int) -> None: + 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. """ - 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 - ) + 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 + "/items/%s") % (self.id, track_index_string), headers=headers, ) self._reparse() + return res.ok - def clear(self, chunk_size: int = 50): + def clear(self, chunk_size: int = 50) -> bool: """Clear UserPlaylist. :param chunk_size: Number of items to remove per request - :return: + :return: True, if successful. """ while self.num_tracks: indices = range(min(self.num_tracks, chunk_size)) - self.remove_by_indices(indices) + if not self.remove_by_indices(indices): + return False + return True def set_playlist_public(self): - """Set UserPlaylist as Public.""" - self.request.request( + """ + 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.""" - self.request.request( + """ + 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_playlist(self): + def delete_playlist(self) -> bool: """Delete UserPlaylist. - :return: True, if successful. + :return: True, if successful.. """ return self.request.request("DELETE", path="playlists/%s" % self.id).ok From 0a867a66d556be4956cff001a667674a96888c5e Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Tue, 1 Oct 2024 21:56:42 +0200 Subject: [PATCH 11/24] Updated changelog --- HISTORY.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/HISTORY.rst b/HISTORY.rst index 6ad0053..2afd26a 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -4,6 +4,9 @@ History ======= v0.7.7 ------ +* 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_ From 8c736767b11f8741a617cbb8304fa9a14d4a2e9a Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Tue, 1 Oct 2024 21:58:28 +0200 Subject: [PATCH 12/24] Formatting --- tidalapi/playlist.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/tidalapi/playlist.py b/tidalapi/playlist.py index 4181933..d18ad7a 100644 --- a/tidalapi/playlist.py +++ b/tidalapi/playlist.py @@ -276,7 +276,8 @@ def add( :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 position: Insert items at a specific position. Default: insert at the end + of the playlist :return: True, if successful. """ # Insert items at a specific index @@ -302,8 +303,7 @@ def add( return res.ok def move_by_id(self, media_id: str, position: int) -> bool: - """ - Move an item to a new position, by media ID + """Move an item to a new position, by media ID. :param media_id: The index of the item to be moved :param position: The new position of the item @@ -318,8 +318,7 @@ def move_by_id(self, media_id: str, position: int) -> bool: return False def move_by_index(self, index: int, position: int) -> bool: - """ - Move a single item to a new position + """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 @@ -328,8 +327,7 @@ def move_by_index(self, index: int, position: int) -> bool: 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 + """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) @@ -353,7 +351,7 @@ def move_by_indices(self, indices: Sequence[int], position: int) -> bool: return res.ok def remove_by_id(self, media_id: str) -> bool: - """Remove a single item from the playlist, using the media ID + """Remove a single item from the playlist, using the media ID. :param media_id: Media ID to remove. :return: True, if successful. @@ -403,10 +401,9 @@ def clear(self, chunk_size: int = 50) -> bool: return True def set_playlist_public(self): - """ - Set UserPlaylist as Public. + """Set UserPlaylist as Public. - :return: True, if successful.. + :return: True, if successful. """ res = self.request.request( "PUT", @@ -418,10 +415,9 @@ def set_playlist_public(self): return res.ok def set_playlist_private(self): - """ - Set UserPlaylist as Private. + """Set UserPlaylist as Private. - :return: True, if successful.. + :return: True, if successful. """ res = self.request.request( "PUT", @@ -435,6 +431,6 @@ def set_playlist_private(self): def delete_playlist(self) -> bool: """Delete UserPlaylist. - :return: True, if successful.. + :return: True, if successful. """ return self.request.request("DELETE", path="playlists/%s" % self.id).ok From 076fc814885a0f0d2a01937d372fa4f1fdb3e2a0 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Tue, 1 Oct 2024 22:29:57 +0200 Subject: [PATCH 13/24] Add track to user playlist, user tracks from ISRC (#96). Updated changelog. --- HISTORY.rst | 1 + tidalapi/playlist.py | 33 +++++++++++++++++++++++++++++++-- tidalapi/user.py | 20 ++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 2afd26a..241401c 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -4,6 +4,7 @@ History ======= v0.7.7 ------ +* Feature: Add track to user playlist, user tracks from ISRC (#96) - tehkillerbee_ * 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_ diff --git a/tidalapi/playlist.py b/tidalapi/playlist.py index d18ad7a..2e6deee 100644 --- a/tidalapi/playlist.py +++ b/tidalapi/playlist.py @@ -276,8 +276,8 @@ def add( :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 position: Insert items at a specific position. + Default: insert at the end of the playlist :return: True, if successful. """ # Insert items at a specific index @@ -302,6 +302,35 @@ def add( self._reparse() return res.ok + 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. + """ + 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.add( + [track_id], + allow_duplicates=allow_duplicates, + position=position, + ) + 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. diff --git a/tidalapi/user.py b/tidalapi/user.py index 46165ea..f3d2506 100644 --- a/tidalapi/user.py +++ b/tidalapi/user.py @@ -28,6 +28,7 @@ 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: @@ -263,6 +264,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. From 00fecb605aad006aa69a1e0e9f40b58c149fbe50 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Tue, 8 Oct 2024 22:38:35 +0200 Subject: [PATCH 14/24] Add support for playlist folders (#181) --- tests/test_user.py | 101 ++++++++++++++++++++ tidalapi/playlist.py | 216 +++++++++++++++++++++++++++++++++++++++++++ tidalapi/session.py | 20 ++++ tidalapi/user.py | 87 ++++++++++++++++- 4 files changed, 421 insertions(+), 3 deletions(-) diff --git a/tests/test_user.py b/tests/test_user.py index 53ce25f..9ad0f7e 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -124,6 +124,107 @@ def test_create_playlist(session): 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 + # Throw error if non-list is provided + with pytest.raises(ValueError): + session.user.favorites.remove_folders_playlists(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/playlist.py b/tidalapi/playlist.py index 2e6deee..b0b560b 100644 --- a/tidalapi/playlist.py +++ b/tidalapi/playlist.py @@ -231,6 +231,222 @@ 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/ + 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. + """ + if len(trns) == 0: + raise ValueError("An empty list of trns were provided. Cannot continue.") + if not isinstance(trns, List): + raise ValueError( + "A single item was provided but a list was expected. Cannot continue." + ) + # 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.""" diff --git a/tidalapi/session.py b/tidalapi/session.py index ce9a402..5539922 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -325,6 +325,11 @@ def parse_mix(self, obj: JsonObj) -> mix.Mix: """Parse a mix from the given response.""" return self.mix().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, @@ -816,6 +821,21 @@ 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 f3d2506..db038e6 100644 --- a/tidalapi/user.py +++ b/tidalapi/user.py @@ -36,7 +36,7 @@ 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 Playlist, UserPlaylist, Folder from tidalapi.session import Session @@ -58,6 +58,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( @@ -143,12 +144,41 @@ def playlists(self) -> List[Union["Playlist", "UserPlaylist"]]: ), ) - def get_public_playlists(self, offset=0) -> List[Union["Playlist", "UserPlaylist"]]: + def playlist_folders( + self, offset: int = 0, limit: int = 50, parent_folder_id: str = "root" + ) -> List["Folder"]: + """Get the playlists created by the user. + + :return: Returns a list of :class:`~tidalapi.playlist.Playlist` objects containing the playlists. + """ + 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. :return: List of public playlists. """ - params = {"limit": 50, "offset": offset} + 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 @@ -195,6 +225,21 @@ def create_playlist(self, title: str, description: str) -> "Playlist": playlist = self.session.playlist().parse(json) return playlist.factory() + def create_folder(self, title: str, parent_id: str = "root") -> "Folder": + 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): name: Optional[str] = None @@ -341,6 +386,42 @@ 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") + if len(trns) == 0: + raise ValueError("An empty list of trns were provided. Cannot continue.") + # 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 remove_folder_playlist(self, trn: str, type: str = "folder") -> bool: + """Removes a folder (or playlist) from the users favourites, using the v2 endpoint + + :param trn: Folder or playlist id (trn) of the item to be deleted + :param type: Type of trn: as string, either `folder` or `playlist` + :return: A boolean indicating whether the request was successful or not. + """ + return self.remove_folders_playlists([trn], type) + def artists(self, limit: Optional[int] = None, offset: int = 0) -> List["Artist"]: """Get the users favorite artists. From 4375a0c77799af591e72fec32b755c8d21e3a64a Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 9 Oct 2024 21:07:58 +0200 Subject: [PATCH 15/24] Use v2 endpoint for playlist creation. Fix playlist delete naming. Add missing type. Set limits from argument. Fix create_playlist test. --- tests/test_user.py | 14 ++++++++------ tidalapi/playlist.py | 8 ++++---- tidalapi/session.py | 1 - tidalapi/user.py | 29 ++++++++++++++++++++--------- 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/tests/test_user.py b/tests/test_user.py index 9ad0f7e..3001d0d 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): @@ -77,13 +78,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,12 +115,13 @@ 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() diff --git a/tidalapi/playlist.py b/tidalapi/playlist.py index b0b560b..3669717 100644 --- a/tidalapi/playlist.py +++ b/tidalapi/playlist.py @@ -22,7 +22,7 @@ import copy from datetime import datetime -from typing import TYPE_CHECKING, List, Optional, Sequence, Union +from typing import TYPE_CHECKING, List, Optional, Sequence, Union, cast from tidalapi.exceptions import ObjectNotFound, TooManyRequests from tidalapi.types import JsonObj @@ -136,7 +136,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 @@ -471,7 +471,7 @@ def edit( data = {"title": title, "description": description} return self.request.request("POST", self._base_url % self.id, data=data).ok - def delete(self, media_ids: List[str]) -> bool: + def delete_by_id(self, media_ids: List[str]) -> bool: """Delete one or more items from the UserPlaylist. :param media_ids: Lists of Media IDs to remove. @@ -673,7 +673,7 @@ def set_playlist_private(self): self._reparse() return res.ok - def delete_playlist(self) -> bool: + def delete(self) -> bool: """Delete UserPlaylist. :return: True, if successful. diff --git a/tidalapi/session.py b/tidalapi/session.py index 5539922..9b9ee46 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -52,7 +52,6 @@ from tidalapi.exceptions import * from tidalapi.types import JsonObj - from . import album, artist, genre, media, mix, page, playlist, request, user if TYPE_CHECKING: diff --git a/tidalapi/user.py b/tidalapi/user.py index db038e6..679858c 100644 --- a/tidalapi/user.py +++ b/tidalapi/user.py @@ -133,7 +133,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. """ @@ -196,14 +196,14 @@ def public_playlists( ) def playlist_and_favorite_playlists( - self, offset: int = 0 + self, offset: int = 0, limit: int = 50 ) -> 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() @@ -217,13 +217,24 @@ 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": + 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": params = {"name": title, "folderId": parent_id} From 0280c0fba9ff99c4db9cb301f973b3db56e439da Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Thu, 10 Oct 2024 22:53:01 +0200 Subject: [PATCH 16/24] Validate method input --- tidalapi/playlist.py | 25 +++++++++++++++++++------ tidalapi/user.py | 11 +++++++++-- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/tidalapi/playlist.py b/tidalapi/playlist.py index 3669717..f3a10a9 100644 --- a/tidalapi/playlist.py +++ b/tidalapi/playlist.py @@ -37,6 +37,14 @@ 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.""" @@ -420,12 +428,7 @@ def move_items_to_folder(self, trns: [str], folder: str = None): :param folder: Destination folder. Default: Use the current folder :return: True, if operation was successful. """ - if len(trns) == 0: - raise ValueError("An empty list of trns were provided. Cannot continue.") - if not isinstance(trns, List): - raise ValueError( - "A single item was provided but a list was expected. Cannot continue." - ) + trns = list_validate(trns) # Make sure all trns has the correct type prepended to it trns_full = [] for trn in trns: @@ -477,6 +480,7 @@ def delete_by_id(self, media_ids: List[str]) -> bool: :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] @@ -496,6 +500,7 @@ def add( Default: insert at the end of the playlist :return: True, if successful. """ + media_ids = list_validate(media_ids) # Insert items at a specific index if position < 0 or position > self.num_tracks: position = self.num_tracks @@ -532,6 +537,8 @@ def add_by_isrc( 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: @@ -554,6 +561,8 @@ def move_by_id(self, media_id: str, position: int) -> bool: :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) @@ -569,6 +578,8 @@ def move_by_index(self, index: int, position: int) -> bool: :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: @@ -601,6 +612,8 @@ def remove_by_id(self, media_id: str) -> bool: :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) diff --git a/tidalapi/user.py b/tidalapi/user.py index 679858c..f95512d 100644 --- a/tidalapi/user.py +++ b/tidalapi/user.py @@ -40,6 +40,14 @@ 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. @@ -406,8 +414,7 @@ def remove_folders_playlists(self, trns: [str], type: str = "folder") -> bool: """ if type not in ("playlist", "folder"): raise ValueError("Invalid trn value used for playlist/folder endpoint") - if len(trns) == 0: - raise ValueError("An empty list of trns were provided. Cannot continue.") + trns = list_validate(trns) # Make sure all trns has the correct type prepended to it trns_full = [] for trn in trns: From 21c432e925814ca033727757df37a5cf751657ac Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Thu, 10 Oct 2024 22:53:30 +0200 Subject: [PATCH 17/24] Added trn to playlist object --- tidalapi/playlist.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tidalapi/playlist.py b/tidalapi/playlist.py index f3a10a9..ba8dfd6 100644 --- a/tidalapi/playlist.py +++ b/tidalapi/playlist.py @@ -50,6 +50,7 @@ class Playlist: them.""" id: Optional[str] = None + trn: Optional[str] = None name: Optional[str] = None num_tracks: int = -1 num_videos: int = -1 @@ -96,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"]) From f8013aed513ee753133cbf5391fe54c9a9548545 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Thu, 10 Oct 2024 22:53:49 +0200 Subject: [PATCH 18/24] Add support for playlist merge. Return list of added tracks. --- tidalapi/playlist.py | 57 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/tidalapi/playlist.py b/tidalapi/playlist.py index ba8dfd6..3d68617 100644 --- a/tidalapi/playlist.py +++ b/tidalapi/playlist.py @@ -493,13 +493,15 @@ def add( media_ids: List[str], allow_duplicates: bool = False, position: int = -1, - ) -> bool: + 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 position: Maximum number of items to add :return: True, if successful. """ media_ids = list_validate(media_ids) @@ -510,10 +512,9 @@ def add( "onArtifactNotFound": "SKIP", "trackIds": ",".join(map(str, media_ids)), "toIndex": position, + "onDupes": "ADD" if allow_duplicates else "SKIP", } - if not allow_duplicates: - data["onDupes"] = "SKIP" - params = {"limit": 100} + params = {"limit": limit} headers = {"If-None-Match": self._etag} if self._etag else None res = self.request.request( "POST", @@ -523,7 +524,43 @@ def add( headers=headers, ) self._reparse() - return res.ok + # 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: + """ + 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, @@ -545,12 +582,16 @@ def add_by_isrc( 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.add( - [track_id], + 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: From 9d8aa6bbc5531c7a33f676b16006781752d1923a Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Thu, 10 Oct 2024 22:54:31 +0200 Subject: [PATCH 19/24] Add additional playlist tests. Fix broken tests --- tests/test_playlist.py | 188 ++++++++++++++++++++++++++++++++++++++++- tests/test_user.py | 5 +- 2 files changed, 188 insertions(+), 5 deletions(-) diff --git a/tests/test_playlist.py b/tests/test_playlist.py index ef1cb19..d32a958 100644 --- a/tests/test_playlist.py +++ b/tests/test_playlist.py @@ -24,7 +24,6 @@ import tidalapi from tidalapi.exceptions import ObjectNotFound - from .cover import verify_image_cover, verify_image_resolution @@ -93,6 +92,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_user.py b/tests/test_user.py index 3001d0d..9ba03ce 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -115,7 +115,7 @@ def test_create_playlist(session): long_playlist = session.playlist("944dd087-f65c-4954-a9a3-042a574e86e3") playlist_tracks = long_playlist.tracks(limit=250) - playlist.add(track.id for track in playlist_tracks) + playlist.add([track.id for track in playlist_tracks]) playlist._reparse() playlist.remove_by_id("199477058") playlist._reparse() @@ -217,9 +217,6 @@ def test_folder_moves(session): def test_add_remove_folder(session): folder = session.user.create_folder(title="testfolderA") folder_id = folder.id - # Throw error if non-list is provided - with pytest.raises(ValueError): - session.user.favorites.remove_folders_playlists(folder.id) # remove folder from favourites session.user.favorites.remove_folders_playlists([folder.id]) # check if folder has been removed From 64386f3ce716609fc7ceca755a8368d0eb7a99c1 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Thu, 10 Oct 2024 22:55:34 +0200 Subject: [PATCH 20/24] Cleanup parser methods. Fix misc. comments. --- tidalapi/album.py | 1 - tidalapi/artist.py | 1 - tidalapi/mix.py | 5 +---- tidalapi/session.py | 41 ++++++++++++++++++++++++++++++++++------- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/tidalapi/album.py b/tidalapi/album.py index b2bdc30..cfee372 100644 --- a/tidalapi/album.py +++ b/tidalapi/album.py @@ -318,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/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/session.py b/tidalapi/session.py index 9b9ee46..98fca2c 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -272,14 +272,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, "") @@ -316,14 +316,41 @@ 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.""" From 523dec129e6e4b5637ac671091eaf25a3dd71f0f Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Thu, 10 Oct 2024 23:02:56 +0200 Subject: [PATCH 21/24] Update changelog Updated changelog --- HISTORY.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/HISTORY.rst b/HISTORY.rst index 241401c..ed42822 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -4,7 +4,14 @@ 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_ @@ -207,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 From 952abc4b64b5ab01e69e73a501110a813a5dd529 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Thu, 10 Oct 2024 23:04:29 +0200 Subject: [PATCH 22/24] Fix formatting. Removed redundant method --- tests/test_playlist.py | 1 + tidalapi/playlist.py | 27 +++++++++++---------------- tidalapi/session.py | 4 ++-- tidalapi/user.py | 14 +++----------- 4 files changed, 17 insertions(+), 29 deletions(-) diff --git a/tests/test_playlist.py b/tests/test_playlist.py index d32a958..271a5c8 100644 --- a/tests/test_playlist.py +++ b/tests/test_playlist.py @@ -24,6 +24,7 @@ import tidalapi from tidalapi.exceptions import ObjectNotFound + from .cover import verify_image_cover, verify_image_resolution diff --git a/tidalapi/playlist.py b/tidalapi/playlist.py index 3d68617..ce3ce3d 100644 --- a/tidalapi/playlist.py +++ b/tidalapi/playlist.py @@ -338,8 +338,7 @@ def parse(self, json_obj: JsonObj) -> "Folder": return copy.copy(self) def rename(self, name: str) -> bool: - """ - Rename the selected folder + """Rename the selected folder. :param name: The name to be used for the folder :return: True, if operation was successful. @@ -356,8 +355,7 @@ def rename(self, name: str) -> bool: return res.ok def remove(self) -> bool: - """ - Remove the selected folder + """Remove the selected folder. :return: True, if operation was successful. """ @@ -373,8 +371,7 @@ def remove(self) -> bool: def items( self, offset: int = 0, limit: int = 50 ) -> List[Union["Playlist", "UserPlaylist"]]: - """ - Return the items in the folder + """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 @@ -405,8 +402,7 @@ def items( return [] def add_items(self, trns: [str]): - """ - Convenience method to add items to the current folder + """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. @@ -414,8 +410,7 @@ def add_items(self, trns: [str]): 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 + """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. @@ -423,8 +418,7 @@ def move_items_to_root(self, trns: [str]): 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. + """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 @@ -534,12 +528,13 @@ def add( 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 + """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 + :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: """ data = { diff --git a/tidalapi/session.py b/tidalapi/session.py index 98fca2c..91e3cb2 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -52,6 +52,7 @@ from tidalapi.exceptions import * from tidalapi.types import JsonObj + from . import album, artist, genre, media, mix, page, playlist, request, user if TYPE_CHECKING: @@ -848,8 +849,7 @@ def playlist( 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 + """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. diff --git a/tidalapi/user.py b/tidalapi/user.py index f95512d..850dfc7 100644 --- a/tidalapi/user.py +++ b/tidalapi/user.py @@ -36,7 +36,7 @@ from tidalapi.artist import Artist from tidalapi.media import Track, Video from tidalapi.mix import MixV2 - from tidalapi.playlist import Playlist, UserPlaylist, Folder + from tidalapi.playlist import Folder, Playlist, UserPlaylist from tidalapi.session import Session @@ -406,7 +406,8 @@ 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 + """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` @@ -431,15 +432,6 @@ def remove_folders_playlists(self, trns: [str], type: str = "folder") -> bool: params=params, ).ok - def remove_folder_playlist(self, trn: str, type: str = "folder") -> bool: - """Removes a folder (or playlist) from the users favourites, using the v2 endpoint - - :param trn: Folder or playlist id (trn) of the item to be deleted - :param type: Type of trn: as string, either `folder` or `playlist` - :return: A boolean indicating whether the request was successful or not. - """ - return self.remove_folders_playlists([trn], type) - def artists(self, limit: Optional[int] = None, offset: int = 0) -> List["Artist"]: """Get the users favorite artists. From 6338a2b511da218a7f6f639e12488ad7b92211e2 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Fri, 11 Oct 2024 22:56:45 +0200 Subject: [PATCH 23/24] Added misc. page tests. Fixed misc. comments. --- tests/test_page.py | 21 +++++++++++++++++++-- tidalapi/playlist.py | 6 +++--- 2 files changed, 22 insertions(+), 5 deletions(-) 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 . -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/tidalapi/playlist.py b/tidalapi/playlist.py index ce3ce3d..f026fae 100644 --- a/tidalapi/playlist.py +++ b/tidalapi/playlist.py @@ -495,8 +495,8 @@ def 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 position: Maximum number of items to add - :return: True, if successful. + :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 @@ -535,7 +535,7 @@ def merge( tracks will be skipped. :param allow_missing: If true, missing tracks are allowed. Otherwise, exception will be thrown - :return: + :return: List of items that has been added from the playlist """ data = { "fromPlaylistUuid": str(playlist), From 9ad1be5308e48a49d8ed968b25abbd12e4de1393 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Mon, 14 Oct 2024 21:16:31 +0200 Subject: [PATCH 24/24] Fix doxygen. Added playlist folders test Fix formatting, updated doxygen --- tests/test_user.py | 9 +++++++++ tidalapi/user.py | 24 +++++++++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/tests/test_user.py b/tests/test_user.py index 9ba03ce..66950a2 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -60,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 diff --git a/tidalapi/user.py b/tidalapi/user.py index 850dfc7..eea2f29 100644 --- a/tidalapi/user.py +++ b/tidalapi/user.py @@ -155,9 +155,12 @@ 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 the playlists created by the user. + """Get a list of folders created by the user. - :return: Returns a list of :class:`~tidalapi.playlist.Playlist` objects containing the playlists. + :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, @@ -184,6 +187,8 @@ def public_playlists( ) -> 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} @@ -204,7 +209,7 @@ def public_playlists( ) def playlist_and_favorite_playlists( - self, offset: int = 0, limit: int = 50 + 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. @@ -228,6 +233,13 @@ def playlist_and_favorite_playlists( 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" @@ -245,6 +257,12 @@ def create_playlist( 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"