Skip to content

Commit 6211a6c

Browse files
authored
Merge pull request #351 from EbbLabs/fix/issue-345
Feature: Fix playlist v2 endpoint usage. Add pagination workers from mopidy-tidal
2 parents b60f15a + 07cedb7 commit 6211a6c

File tree

5 files changed

+174
-40
lines changed

5 files changed

+174
-40
lines changed

tests/test_user.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,10 @@ def test_get_user_playlists(session):
6868
def test_get_playlist_folders(session):
6969
folder = session.user.create_folder(title="testfolder")
7070
assert folder
71-
folder_ids = [folder.id for folder in session.user.playlist_folders()]
71+
folder_ids = [folder.id for folder in session.user.favorites.playlist_folders()]
7272
assert folder.id in folder_ids
7373
folder.remove()
74-
folder_ids = [folder.id for folder in session.user.playlist_folders()]
74+
folder_ids = [folder.id for folder in session.user.favorites.playlist_folders()]
7575
assert folder.id not in folder_ids
7676

7777

tidalapi/playlist.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,14 @@ def __init__(self, session: "Session", playlist_id: Optional[str]):
8888
self._etag = request.headers["etag"]
8989
self.parse(request.json())
9090

91-
def parse(self, json_obj: JsonObj) -> "Playlist":
91+
def parse(self, obj: JsonObj) -> "Playlist":
9292
"""Parses a playlist from tidal, replaces the current playlist object.
9393
94-
:param json_obj: Json data returned from api.tidal.com containing a playlist
94+
:param obj: Json data returned from api.tidal.com containing a playlist
9595
:return: Returns a copy of the original :exc: 'Playlist': object
9696
"""
97+
json_obj = obj.get("data", obj)
98+
9799
self.id = json_obj["uuid"]
98100
self.trn = f"trn:playlist:{self.id}"
99101
self.name = json_obj["title"]

tidalapi/session.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ def parse_v2_mix(self, obj: JsonObj) -> mix.Mix:
361361
def parse_playlist(self, obj: JsonObj) -> playlist.Playlist:
362362
"""Parse a playlist from the given response."""
363363
# Note: When parsing playlists from v2 response, "data" field must be parsed
364-
return self.playlist().parse(obj.get("data", obj))
364+
return self.playlist().parse(obj)
365365

366366
def parse_folder(self, obj: JsonObj) -> playlist.Folder:
367367
"""Parse an album from the given response."""

tidalapi/user.py

Lines changed: 103 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
PlaylistOrder,
3838
VideoOrder,
3939
)
40+
from tidalapi.workers import get_items
4041

4142
if TYPE_CHECKING:
4243
from tidalapi.album import Album
@@ -159,36 +160,6 @@ def playlists(self) -> List[Union["Playlist", "UserPlaylist"]]:
159160
),
160161
)
161162

162-
def playlist_folders(
163-
self, offset: int = 0, limit: int = 50, parent_folder_id: str = "root"
164-
) -> List["Folder"]:
165-
"""Get a list of folders created by the user.
166-
167-
:param offset: The amount of items you want returned.
168-
:param limit: The index of the first item you want included.
169-
:param parent_folder_id: Parent folder ID. Default: 'root' playlist folder
170-
:return: Returns a list of :class:`~tidalapi.playlist.Folder` objects containing the Folders.
171-
"""
172-
params = {
173-
"folderId": parent_folder_id,
174-
"offset": offset,
175-
"limit": limit,
176-
"order": "NAME",
177-
"includeOnly": "FOLDER",
178-
}
179-
endpoint = "my-collection/playlists/folders"
180-
return cast(
181-
List["Folder"],
182-
self.session.request.map_request(
183-
url=urljoin(
184-
self.session.config.api_v2_location,
185-
endpoint,
186-
),
187-
params=params,
188-
parse=self.session.parse_folder,
189-
),
190-
)
191-
192163
def public_playlists(
193164
self, offset: int = 0, limit: int = 50
194165
) -> List[Union["Playlist", "UserPlaylist"]]:
@@ -573,6 +544,19 @@ def remove_folders_playlists(
573544
)
574545
return response.ok
575546

547+
def artists_paginated(
548+
self,
549+
order: Optional[ArtistOrder] = None,
550+
order_direction: Optional[OrderDirection] = None,
551+
) -> List["Artist"]:
552+
"""Get the users favorite artists, using pagination.
553+
554+
:param order: Optional; A :class:`ArtistOrder` describing the ordering type when returning the user favorite artists. eg.: "NAME, "DATE"
555+
:param order_direction: Optional; A :class:`OrderDirection` describing the ordering direction when sorting by `order`. eg.: "ASC", "DESC"
556+
:return: A :class:`list` :class:`~tidalapi.artist.Artist` objects containing the favorite artists.
557+
"""
558+
return get_items(self.session.user.favorites.artists, order, order_direction)
559+
576560
def artists(
577561
self,
578562
limit: Optional[int] = None,
@@ -603,6 +587,19 @@ def artists(
603587
),
604588
)
605589

590+
def albums_paginated(
591+
self,
592+
order: Optional[AlbumOrder] = None,
593+
order_direction: Optional[OrderDirection] = None,
594+
) -> List["Album"]:
595+
"""Get the users favorite albums, using pagination.
596+
597+
:param order: Optional; A :class:`AlbumOrder` describing the ordering type when returning the user favorite albums. eg.: "NAME, "DATE"
598+
:param order_direction: Optional; A :class:`OrderDirection` describing the ordering direction when sorting by `order`. eg.: "ASC", "DESC"
599+
:return: A :class:`list` :class:`~tidalapi.album.Album` objects containing the favorite albums.
600+
"""
601+
return get_items(self.session.user.favorites.albums, order, order_direction)
602+
606603
def albums(
607604
self,
608605
limit: Optional[int] = None,
@@ -631,17 +628,32 @@ def albums(
631628
),
632629
)
633630

631+
def playlists_paginated(
632+
self,
633+
order: Optional[PlaylistOrder] = None,
634+
order_direction: Optional[OrderDirection] = None,
635+
) -> List["Playlist"]:
636+
"""Get the users favorite playlists relative to the root folder, using
637+
pagination.
638+
639+
:param order: Optional; A :class:`PlaylistOrder` describing the ordering type when returning the user favorite playlists. eg.: "NAME, "DATE"
640+
:param order_direction: Optional; A :class:`OrderDirection` describing the ordering direction when sorting by `order`. eg.: "ASC", "DESC"
641+
:return: A :class:`list` :class:`~tidalapi.playlist.Playlist` objects containing the favorite playlists.
642+
"""
643+
return get_items(self.session.user.favorites.playlists, order, order_direction)
644+
634645
def playlists(
635646
self,
636647
limit: Optional[int] = 50,
637648
offset: int = 0,
638649
order: Optional[PlaylistOrder] = None,
639650
order_direction: Optional[OrderDirection] = None,
640651
) -> List["Playlist"]:
641-
"""Get the users favorite playlists (v2 endpoint)
652+
"""Get the users favorite playlists (v2 endpoint), relative to the root folder
653+
This function is limited to 50 by TIDAL, requiring pagination.
642654
643-
:param limit: Optional; The amount of playlists you want returned.
644-
:param offset: The index of the first playlist you want included.
655+
:param limit: Optional; The number of playlists you want returned (Note: Cannot exceed 50)
656+
:param offset: The index of the first playlist to fetch
645657
:param order: Optional; A :class:`PlaylistOrder` describing the ordering type when returning the user favorite playlists. eg.: "NAME, "DATE"
646658
:param order_direction: Optional; A :class:`OrderDirection` describing the ordering direction when sorting by `order`. eg.: "ASC", "DESC"
647659
:return: A :class:`list` :class:`~tidalapi.playlist.Playlist` objects containing the favorite playlists.
@@ -650,14 +662,14 @@ def playlists(
650662
"folderId": "root",
651663
"offset": offset,
652664
"limit": limit,
653-
"includeOnly": "",
665+
"includeOnly": "PLAYLIST", # Include only PLAYLIST types, FOLDER will be ignored
654666
}
655667
if order:
656668
params["order"] = order.value
657669
if order_direction:
658670
params["orderDirection"] = order_direction.value
659671

660-
endpoint = "my-collection/playlists/folders"
672+
endpoint = "my-collection/playlists"
661673
return cast(
662674
List["Playlist"],
663675
self.session.request.map_request(
@@ -670,6 +682,62 @@ def playlists(
670682
),
671683
)
672684

685+
def playlist_folders(
686+
self,
687+
limit: Optional[int] = 50,
688+
offset: int = 0,
689+
order: Optional[PlaylistOrder] = None,
690+
order_direction: Optional[OrderDirection] = None,
691+
parent_folder_id: str = "root",
692+
) -> List["Folder"]:
693+
"""Get a list of folders created by the user.
694+
695+
:param limit: Optional; The number of playlists you want returned (Note: Cannot exceed 50)
696+
:param offset: The index of the first playlist folder to fetch
697+
:param order: Optional; A :class:`PlaylistOrder` describing the ordering type when returning the user favorite playlists. eg.: "NAME, "DATE"
698+
:param order_direction: Optional; A :class:`OrderDirection` describing the ordering direction when sorting by `order`. eg.: "ASC", "DESC"
699+
:param parent_folder_id: Parent folder ID. Default: 'root' playlist folder
700+
:return: Returns a list of :class:`~tidalapi.playlist.Folder` objects containing the Folders.
701+
"""
702+
params = {
703+
"folderId": parent_folder_id,
704+
"offset": offset,
705+
"limit": limit,
706+
"order": "NAME",
707+
"includeOnly": "FOLDER",
708+
}
709+
if order:
710+
params["order"] = order.value
711+
if order_direction:
712+
params["orderDirection"] = order_direction.value
713+
714+
endpoint = "my-collection/playlists/folders"
715+
return cast(
716+
List["Folder"],
717+
self.session.request.map_request(
718+
url=urljoin(
719+
self.session.config.api_v2_location,
720+
endpoint,
721+
),
722+
params=params,
723+
parse=self.session.parse_folder,
724+
),
725+
)
726+
727+
def tracks_paginated(
728+
self,
729+
order: Optional[ItemOrder] = None,
730+
order_direction: Optional[OrderDirection] = None,
731+
) -> List["Playlist"]:
732+
"""Get the users favorite playlists relative to the root folder, using
733+
pagination.
734+
735+
:param order: Optional; A :class:`ItemOrder` describing the ordering type when returning the user favorite tracks. eg.: "NAME, "DATE"
736+
:param order_direction: Optional; A :class:`OrderDirection` describing the ordering direction when sorting by `order`. eg.: "ASC", "DESC"
737+
:return: A :class:`list` :class:`~tidalapi.playlist.Playlist` objects containing the favorite tracks.
738+
"""
739+
return get_items(self.session.user.favorites.tracks, order, order_direction)
740+
673741
def tracks(
674742
self,
675743
limit: Optional[int] = None,

tidalapi/workers.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import logging
2+
from concurrent.futures import ThreadPoolExecutor
3+
from typing import Callable
4+
5+
log = logging.getLogger(__name__)
6+
7+
8+
def func_wrapper(args):
9+
(f, offset, *args) = args
10+
try:
11+
items = f(*args)
12+
except Exception as e:
13+
log.error("Failed to run %s(offset=%d, args=%s)", f, offset, args)
14+
log.exception(e)
15+
items = []
16+
return list((i + offset, item) for i, item in enumerate(items))
17+
18+
19+
def get_items(
20+
func: Callable,
21+
*args,
22+
parse: Callable = lambda _: _,
23+
chunk_size: int = 50,
24+
processes: int = 2,
25+
):
26+
"""This function performs pagination on a function that supports `limit`/`offset`
27+
parameters and it runs API requests in parallel to speed things up."""
28+
items = []
29+
offsets = [-chunk_size]
30+
remaining = chunk_size * processes
31+
32+
with ThreadPoolExecutor(
33+
processes, thread_name_prefix=f"mopidy-tidal-{func.__name__}-"
34+
) as pool:
35+
while remaining == chunk_size * processes:
36+
offsets = [offsets[-1] + chunk_size * (i + 1) for i in range(processes)]
37+
38+
pool_results = pool.map(
39+
func_wrapper,
40+
[
41+
(
42+
func,
43+
offset,
44+
chunk_size, # limit
45+
offset, # offset
46+
*args, # extra args (e.g. order, order_direction)
47+
)
48+
for offset in offsets
49+
],
50+
)
51+
52+
new_items = []
53+
for results in pool_results:
54+
new_items.extend(results)
55+
56+
remaining = len(new_items)
57+
items.extend(new_items)
58+
59+
items = [_ for _ in items if _]
60+
sorted_items = list(
61+
map(lambda item: item[1], sorted(items, key=lambda item: item[0]))
62+
)
63+
64+
return list(map(parse, sorted_items))

0 commit comments

Comments
 (0)