From 3168a55b93a0cb9665cae979874c3a81181c071d Mon Sep 17 00:00:00 2001 From: Nokse Date: Sat, 2 Aug 2025 11:29:21 +0200 Subject: [PATCH 01/11] Add x-tidal-client-version header --- tidalapi/request.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tidalapi/request.py b/tidalapi/request.py index e6bee540..26ecd6aa 100644 --- a/tidalapi/request.py +++ b/tidalapi/request.py @@ -85,6 +85,8 @@ def basic_request( if not headers: headers = {} + headers["x-tidal-client-version"] = "2025.7.16" + if "User-Agent" not in headers: headers["User-Agent"] = self.user_agent From 209865de46f69ed7a822d71baa1903b23286f568 Mon Sep 17 00:00:00 2001 From: Nokse Date: Sat, 2 Aug 2025 12:33:41 +0200 Subject: [PATCH 02/11] Support new home page endpoint --- tidalapi/page.py | 62 +++++++++++++++++++++++++++++++++++++++++++++ tidalapi/session.py | 14 +++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/tidalapi/page.py b/tidalapi/page.py index 3696cf4b..b8b01974 100644 --- a/tidalapi/page.py +++ b/tidalapi/page.py @@ -42,6 +42,8 @@ from tidalapi.request import Requests from tidalapi.session import Session +from . import album, artist, media, mix, playlist + PageCategories = Union[ "Album", "PageLinks", @@ -114,6 +116,17 @@ def parse(self, json_obj: JsonObj) -> "Page": return copy.copy(self) + def parseV2(self, json_obj: JsonObj) -> "Page": + """Goes through everything in the page, and gets the title and adds all the rows + to the categories field :param json_obj: The json to be parsed :return: A copy + of the Page that you can use to browse all the items.""" + self.categories = [] + for item in json_obj["items"]: + page_item = self.page_category.parse(item) + self.categories.append(page_item) + + return copy.copy(self) + def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> "Page": """Retrieve a page from the specified endpoint, overwrites the calling page. @@ -196,6 +209,10 @@ def parse(self, json_obj: JsonObj) -> AllCategories: elif category_type == "SOCIAL": json_obj["items"] = json_obj["socialProfiles"] category = LinkList(self.session) + elif category_type == "SHORTCUT_LIST": + category = ShortcutList(self.session) + elif category_type == "HORIZONTAL_LIST": + category = HorizontalList(self.session) else: raise NotImplementedError(f"PageType {category_type} not implemented") @@ -215,6 +232,51 @@ def show_more(self) -> Optional[Page]: ) +class SimpleList(PageCategory): + """A simple list of different items for the home page V2""" + + items: Optional[List[Any]] = None + + def __init__(self, session: "Session"): + super().__init__(session) + self.session = session + + def parse(self, json_obj: JsonObj) -> "SimpleList": + self.items = [] + self.title = json_obj["title"] + + for item in json_obj["items"]: + self.items.append(self.get_item(item)) + + return self + + def get_item(self, json_obj): + item_type = json_obj["type"] + item_data = json_obj["data"] + + if item_type == "PLAYLIST": + return self.session.parse_playlist(item_data) + elif item_type == "VIDEO": + return self.session.parse_video(item_data) + elif item_type == "TRACK": + return self.session.parse_track(item_data) + elif item_type == "ARTIST": + return self.session.parse_artist(item_data) + elif item_type == "ALBUM": + return self.session.parse_album(item_data) + elif item_type == "MIX": + return self.session.parse_mix(item_data) + raise NotImplementedError + + +class HorizontalList(SimpleList): + ... + + +class ShortcutList(SimpleList): + ... + + class FeaturedItems(PageCategory): """Items that have been featured by TIDAL.""" diff --git a/tidalapi/session.py b/tidalapi/session.py index 910b1104..59bb482c 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -1094,7 +1094,19 @@ def home(self) -> page.Page: :return: A :class:`.Page` object with the :class:`.PageCategory` list from the home page """ - return self.page.get("pages/home") + params = {} + params["deviceType"] = "BROWSER" + params["countryCode"] = "IT" + params["locale"] = "en_US" + params["platform"] = "WEB" + + json_obj = self.request.request( + "GET", + "home/feed/static", + base_url=self.config.api_v2_location, + params=params, + ).json() + return self.page.parseV2(json_obj) def explore(self) -> page.Page: """ From dec4a38f2353e1eec7065534ba5b99ed03e8c701 Mon Sep 17 00:00:00 2001 From: Nokse Date: Sat, 2 Aug 2025 13:58:28 +0200 Subject: [PATCH 03/11] Hack it to make it work --- tidalapi/page.py | 101 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 21 deletions(-) diff --git a/tidalapi/page.py b/tidalapi/page.py index b8b01974..bf52e3bd 100644 --- a/tidalapi/page.py +++ b/tidalapi/page.py @@ -70,6 +70,7 @@ class Page: _categories_iter: Optional[Iterator["AllCategories"]] = None _items_iter: Optional[Iterator[Callable[..., Any]]] = None page_category: "PageCategory" + page_category_v2: "PageCategoryV2" request: "Requests" def __init__(self, session: "Session", title: str): @@ -77,6 +78,7 @@ def __init__(self, session: "Session", title: str): self.categories = None self.title = title self.page_category = PageCategory(session) + self.page_category_v2 = PageCategoryV2(session) def __iter__(self) -> "Page": if self.categories is None: @@ -117,12 +119,9 @@ def parse(self, json_obj: JsonObj) -> "Page": return copy.copy(self) def parseV2(self, json_obj: JsonObj) -> "Page": - """Goes through everything in the page, and gets the title and adds all the rows - to the categories field :param json_obj: The json to be parsed :return: A copy - of the Page that you can use to browse all the items.""" self.categories = [] for item in json_obj["items"]: - page_item = self.page_category.parse(item) + page_item = self.page_category_v2.parse(item) self.categories.append(page_item) return copy.copy(self) @@ -181,6 +180,7 @@ def __init__(self, session: "Session"): def parse(self, json_obj: JsonObj) -> AllCategories: result = None category_type = json_obj["type"] + print(category_type) if category_type in ("PAGE_LINKS_CLOUD", "PAGE_LINKS"): category: PageCategories = PageLinks(self.session) elif category_type in ("FEATURED_PROMOTIONS", "MULTIPLE_TOP_PROMOTIONS"): @@ -209,10 +209,6 @@ def parse(self, json_obj: JsonObj) -> AllCategories: elif category_type == "SOCIAL": json_obj["items"] = json_obj["socialProfiles"] category = LinkList(self.session) - elif category_type == "SHORTCUT_LIST": - category = ShortcutList(self.session) - elif category_type == "HORIZONTAL_LIST": - category = HorizontalList(self.session) else: raise NotImplementedError(f"PageType {category_type} not implemented") @@ -232,6 +228,41 @@ def show_more(self) -> Optional[Page]: ) +class PageCategoryV2: + type = None + title: Optional[str] = None + description: Optional[str] = "" + request: "Requests" + + def __init__(self, session: "Session"): + self.session = session + self.request = session.request + self.item_types: Dict[str, Callable[..., Any]] = { + "ALBUM_LIST": self.session.parse_album, + "ARTIST_LIST": self.session.parse_artist, + "TRACK_LIST": self.session.parse_track, + "PLAYLIST_LIST": self.session.parse_playlist, + "VIDEO_LIST": self.session.parse_video, + "MIX_LIST": self.session.parse_mix, + } + + def parse(self, json_obj: JsonObj) -> AllCategories: + category_type = json_obj["type"] + print(category_type) + # if category_type in self.item_types.keys(): + # category = ItemListV2(self.session) + # el + if category_type == "SHORTCUT_LIST": + category = ShortcutList(self.session) + elif category_type == "HORIZONTAL_LIST": + category = HorizontalList(self.session) + else: + return None + # raise NotImplementedError(f"PageType {category_type} not implemented") + + return category.parse(json_obj) + + class SimpleList(PageCategory): """A simple list of different items for the home page V2""" @@ -252,21 +283,22 @@ def parse(self, json_obj: JsonObj) -> "SimpleList": def get_item(self, json_obj): item_type = json_obj["type"] - item_data = json_obj["data"] + # item_data = json_obj["data"] if item_type == "PLAYLIST": - return self.session.parse_playlist(item_data) - elif item_type == "VIDEO": - return self.session.parse_video(item_data) - elif item_type == "TRACK": - return self.session.parse_track(item_data) - elif item_type == "ARTIST": - return self.session.parse_artist(item_data) - elif item_type == "ALBUM": - return self.session.parse_album(item_data) - elif item_type == "MIX": - return self.session.parse_mix(item_data) - raise NotImplementedError + return self.session.parse_playlist(json_obj) + # elif item_type == "VIDEO": + # return self.session.parse_video(item_data) + # elif item_type == "TRACK": + # return self.session.parse_track(item_data) + # elif item_type == "ARTIST": + # return self.session.parse_artist(item_data) + # elif item_type == "ALBUM": + # return self.session.parse_album(item_data) + # elif item_type == "MIX": + # return self.session.parse_v2_mix(json_obj) + # raise NotImplementedError + return None class HorizontalList(SimpleList): @@ -355,6 +387,33 @@ def parse(self, json_obj: JsonObj) -> "ItemList": return copy.copy(self) +class ItemListV2(PageCategory): + """A list of items from TIDAL, can be a list of mixes, for example, or a list of + playlists and mixes in some cases.""" + + items: Optional[List[Any]] = None + + def parse(self, json_obj: JsonObj) -> "ItemListV2": + """Parse a list of items on TIDAL from the pages endpoints. + + :param json_obj: The json from TIDAL to be parsed + :return: A copy of the ItemListV2 with a list of items + """ + self.title = json_obj["title"] + item_type = json_obj["type"] + session: Optional["Session"] = None + parse: Optional[Callable[..., Any]] = None + + if item_type in self.item_types.keys(): + parse = self.item_types[item_type] + else: + raise NotImplementedError("PageType {} not implemented".format(item_type)) + + self.items = self.request.map_json(json_obj["items"], parse, session) + + return copy.copy(self) + + class PageLink: """A Link to another :class:`.Page` on TIDAL, Call get() to retrieve the Page.""" From 7b318c9cfed476bd90b84100259b28ea6b591834 Mon Sep 17 00:00:00 2001 From: Nokse Date: Sat, 2 Aug 2025 16:07:10 +0200 Subject: [PATCH 04/11] Support other item types --- tidalapi/page.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/tidalapi/page.py b/tidalapi/page.py index bf52e3bd..a578f71a 100644 --- a/tidalapi/page.py +++ b/tidalapi/page.py @@ -285,18 +285,23 @@ def get_item(self, json_obj): item_type = json_obj["type"] # item_data = json_obj["data"] - if item_type == "PLAYLIST": - return self.session.parse_playlist(json_obj) - # elif item_type == "VIDEO": - # return self.session.parse_video(item_data) - # elif item_type == "TRACK": - # return self.session.parse_track(item_data) - # elif item_type == "ARTIST": - # return self.session.parse_artist(item_data) - # elif item_type == "ALBUM": - # return self.session.parse_album(item_data) - # elif item_type == "MIX": - # return self.session.parse_v2_mix(json_obj) + print(item_type) + + try: + if item_type == "PLAYLIST": + return self.session.parse_playlist(json_obj) + elif item_type == "VIDEO": + return self.session.parse_video(json_obj["data"]) + elif item_type == "TRACK": + return self.session.parse_track(json_obj["data"]) + elif item_type == "ARTIST": + return self.session.parse_artist(json_obj["data"]) + elif item_type == "ALBUM": + return self.session.parse_album(json_obj["data"]) + elif item_type == "MIX": + return self.session.parse_mix(json_obj["data"]) + except Exception as e: + print(e) # raise NotImplementedError return None From 0edfeb8e02211d6e879e6432b4cec4d8420ae482 Mon Sep 17 00:00:00 2001 From: Nokse Date: Sat, 2 Aug 2025 16:27:58 +0200 Subject: [PATCH 05/11] Support TRACK_LIST --- tidalapi/page.py | 44 ++++++++++++++------------------------------ 1 file changed, 14 insertions(+), 30 deletions(-) diff --git a/tidalapi/page.py b/tidalapi/page.py index a578f71a..4efb71fa 100644 --- a/tidalapi/page.py +++ b/tidalapi/page.py @@ -237,28 +237,18 @@ class PageCategoryV2: def __init__(self, session: "Session"): self.session = session self.request = session.request - self.item_types: Dict[str, Callable[..., Any]] = { - "ALBUM_LIST": self.session.parse_album, - "ARTIST_LIST": self.session.parse_artist, - "TRACK_LIST": self.session.parse_track, - "PLAYLIST_LIST": self.session.parse_playlist, - "VIDEO_LIST": self.session.parse_video, - "MIX_LIST": self.session.parse_mix, - } def parse(self, json_obj: JsonObj) -> AllCategories: category_type = json_obj["type"] print(category_type) - # if category_type in self.item_types.keys(): - # category = ItemListV2(self.session) - # el - if category_type == "SHORTCUT_LIST": + if category_type == "TRACK_LIST": + category = TrackList(self.session) + elif category_type == "SHORTCUT_LIST": category = ShortcutList(self.session) elif category_type == "HORIZONTAL_LIST": category = HorizontalList(self.session) else: - return None - # raise NotImplementedError(f"PageType {category_type} not implemented") + raise NotImplementedError(f"PageType {category_type} not implemented") return category.parse(json_obj) @@ -298,8 +288,8 @@ def get_item(self, json_obj): return self.session.parse_artist(json_obj["data"]) elif item_type == "ALBUM": return self.session.parse_album(json_obj["data"]) - elif item_type == "MIX": - return self.session.parse_mix(json_obj["data"]) + # elif item_type == "MIX": + # return self.session.mix(json_obj["data"]["id"]) except Exception as e: print(e) # raise NotImplementedError @@ -392,29 +382,23 @@ def parse(self, json_obj: JsonObj) -> "ItemList": return copy.copy(self) -class ItemListV2(PageCategory): - """A list of items from TIDAL, can be a list of mixes, for example, or a list of - playlists and mixes in some cases.""" +class TrackList(PageCategory): + """A list of track from TIDAL.""" items: Optional[List[Any]] = None - def parse(self, json_obj: JsonObj) -> "ItemListV2": - """Parse a list of items on TIDAL from the pages endpoints. + def parse(self, json_obj: JsonObj) -> "TrackList": + """Parse a list of tracks on TIDAL from the pages endpoints. :param json_obj: The json from TIDAL to be parsed - :return: A copy of the ItemListV2 with a list of items + :return: A copy of the TrackList with a list of items """ self.title = json_obj["title"] - item_type = json_obj["type"] - session: Optional["Session"] = None - parse: Optional[Callable[..., Any]] = None - if item_type in self.item_types.keys(): - parse = self.item_types[item_type] - else: - raise NotImplementedError("PageType {} not implemented".format(item_type)) + self.items = [] - self.items = self.request.map_json(json_obj["items"], parse, session) + for item in json_obj["items"]: + self.items.append(self.session.parse_track(item["data"])) return copy.copy(self) From 57843ca4a649e2c1dc0a9a4b5aa38b13321bb1a0 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Sat, 2 Aug 2025 22:17:33 +0200 Subject: [PATCH 06/11] Updated Mix tests --- tests/test_mix.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_mix.py b/tests/test_mix.py index 8ce6a616..8e04831b 100644 --- a/tests/test_mix.py +++ b/tests/test_mix.py @@ -45,11 +45,9 @@ def test_mixv2_unavailable(session): mix = session.mixv2("12345678") -@pytest.mark.skip(reason="Cannot test against user specific mixes") def test_mix_available(session): - mix = session.mix("016edb91bc504e618de6918b11b25b") + mix = session.mix("001cb879e927219fc3322cb03aed01") -@pytest.mark.skip(reason="Cannot test against user specific mixes") def test_mixv2_available(session): - mix = session.mixv2("016edb91bc504e618de6918b11b25b") + mix = session.mixv2("001cb879e927219fc3322cb03aed01") From 3222541e3716282495ea63fd8dc90f1725a34cd1 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Sat, 2 Aug 2025 22:18:16 +0200 Subject: [PATCH 07/11] MixV2: Add support for parsing mixes originating from PageV2. --- tidalapi/mix.py | 120 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 86 insertions(+), 34 deletions(-) diff --git a/tidalapi/mix.py b/tidalapi/mix.py index 3d521862..c6718c3b 100644 --- a/tidalapi/mix.py +++ b/tidalapi/mix.py @@ -176,16 +176,22 @@ class TextInfo: class MixV2: """A mix from TIDALs v2 api endpoint.""" + mix_type: Optional[MixType] = None + country_code: Optional[str] = None date_added: Optional[datetime] = None - title: Optional[str] = None id: Optional[str] = None - mix_type: Optional[MixType] = None + artifact_id_type: Optional[str] = None + content_behavior: Optional[str] = None images: Optional[ImageResponse] = None detail_images: Optional[ImageResponse] = None master = False + is_stable_id = False + title: Optional[str] = None + sub_title: Optional[str] = None + short_subtitle: Optional[str] = None title_text_info: Optional[TextInfo] = None sub_title_text_info: Optional[TextInfo] = None - sub_title: Optional[str] = None + short_subtitle_text_info: Optional[TextInfo] = None updated: Optional[datetime] = None _retrieved = False _items: Optional[List[Union["Video", "Track"]]] = None @@ -232,38 +238,84 @@ def parse(self, json_obj: JsonObj) -> "MixV2": :param json_obj: The json of a mix to be parsed :return: A copy of the parsed mix """ - date_added = json_obj.get("dateAdded") - self.date_added = dateutil.parser.isoparse(date_added) if date_added else None - self.title = json_obj["title"] + self.id = json_obj["id"] - self.title = json_obj["title"] - self.mix_type = MixType(json_obj["mixType"]) - images = json_obj["images"] - self.images = ImageResponse( - small=images["SMALL"]["url"], - medium=images["MEDIUM"]["url"], - large=images["LARGE"]["url"], - ) - detail_images = json_obj["detailImages"] - self.detail_images = ImageResponse( - small=detail_images["SMALL"]["url"], - medium=detail_images["MEDIUM"]["url"], - large=detail_images["LARGE"]["url"], - ) - self.master = json_obj["master"] - title_text_info = json_obj["titleTextInfo"] - self.title_text_info = TextInfo( - text=title_text_info["text"], - color=title_text_info["color"], - ) - sub_title_text_info = json_obj["subTitleTextInfo"] - self.sub_title_text_info = TextInfo( - text=sub_title_text_info["text"], - color=sub_title_text_info["color"], - ) - self.sub_title = json_obj["subTitle"] - updated = json_obj.get("updated") - self.date_added = dateutil.parser.isoparse(updated) if date_added else None + if json_obj.get("mixType"): + date_added = json_obj.get("dateAdded") + self.date_added = ( + dateutil.parser.isoparse(date_added) if date_added else None + ) + self.title = json_obj["title"] + self.sub_title = json_obj["subTitle"] + images = json_obj["images"] + self.images = ImageResponse( + small=images["SMALL"]["url"], + medium=images["MEDIUM"]["url"], + large=images["LARGE"]["url"], + ) + detail_images = json_obj["detailImages"] + self.detail_images = ImageResponse( + small=detail_images["SMALL"]["url"], + medium=detail_images["MEDIUM"]["url"], + large=detail_images["LARGE"]["url"], + ) + self.master = json_obj["master"] + title_text_info = json_obj["titleTextInfo"] + self.title_text_info = TextInfo( + text=title_text_info["text"], + color=title_text_info["color"], + ) + sub_title_text_info = json_obj["subTitleTextInfo"] + self.sub_title_text_info = TextInfo( + text=sub_title_text_info["text"], + color=sub_title_text_info["color"], + ) + updated = json_obj.get("updated") + self.updated = dateutil.parser.isoparse(updated) if updated else None + elif json_obj.get("type"): + # Certain mix types (e.g. when returned from Page) must be parsed differently. Why, TIDAL? + self.country_code = json_obj.get("countryCode", None) + self.is_stable_id = json_obj.get("isStableId", False) + self.artifact_id_type = json_obj.get("trackGroupId", None) + self.content_behavior = json_obj.get("contentBehavior", None) + + images = json_obj["mixImages"] + self.images = ImageResponse( + small=images[0]["url"], + medium=images[1]["url"], + large=images[0]["url"], + ) + + detail_images = json_obj["detailMixImages"] + self.detail_images = ImageResponse( + small=detail_images[0]["url"], + medium=detail_images[1]["url"], + large=detail_images[2]["url"], + ) + + title_text_info = json_obj["titleTextInfo"] + self.title_text_info = TextInfo( + text=title_text_info["text"], + color=title_text_info["color"], + ) + self.title = title_text_info["text"] + + sub_title_text_info = json_obj["subtitleTextInfo"] + self.sub_title_text_info = TextInfo( + text=sub_title_text_info["text"], + color=sub_title_text_info["color"], + ) + self.sub_title = sub_title_text_info["text"] + + short_subtitle_text_info = json_obj["shortSubtitleTextInfo"] + self.short_subtitle_text_info = TextInfo( + text=sub_title_text_info["text"], + color=sub_title_text_info["color"], + ) + self.short_subtitle = short_subtitle_text_info["text"] + + if json_obj.get("updated"): + self.updated = datetime.fromtimestamp(json_obj["updated"] / 1000) return copy.copy(self) From 6728663c365781abaad33f0a07eb7594261d320b Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Sat, 2 Aug 2025 22:19:39 +0200 Subject: [PATCH 08/11] Requests: Cleanup comments. Move request variables to their appropriate location. Hardcode locale. --- tidalapi/request.py | 20 +++++++++++++------- tidalapi/session.py | 20 +++++--------------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/tidalapi/request.py b/tidalapi/request.py index 26ecd6aa..d584830b 100644 --- a/tidalapi/request.py +++ b/tidalapi/request.py @@ -57,6 +57,7 @@ class Requests(object): def __init__(self, session: "Session"): # More Android User-Agents here: https://user-agents.net/browsers/android self.user_agent = "Mozilla/5.0 (Linux; Android 12; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/91.0.4472.114 Safari/537.36" + self.client_version = "2025.7.16" self.session = session self.config = session.config self.latest_err_response = requests.Response() @@ -85,7 +86,8 @@ def basic_request( if not headers: headers = {} - headers["x-tidal-client-version"] = "2025.7.16" + if "x-tidal-client-version" not in headers: + headers["x-tidal-client-version"] = self.client_version if "User-Agent" not in headers: headers["User-Agent"] = self.user_agent @@ -172,18 +174,22 @@ def request( return request def get_latest_err_response(self) -> dict: - """Get the latest request Response that resulted in an Exception :return: The - request Response that resulted in the Exception, returned as a dict An empty - dict will be returned, if no response was returned.""" + """Get the latest request Response that resulted in an Exception. + + :return: The request Response that resulted in the Exception, returned as a dict + An empty dict will be returned, if no response was returned. + """ if self.latest_err_response.content: return self.latest_err_response.json() else: return {} def get_latest_err_response_str(self) -> str: - """Get the latest request response message as a string :return: The contents of - the (detailed) error response Response, returned as a string An empty str will - be returned, if no response was returned.""" + """Get the latest request response message as a string. + + :return: The contents of the (detailed) error response, returned as a string An + empty str will be returned, if no response was returned. + """ if self.latest_err_response.content: resp = self.latest_err_response.json() return resp["errors"][0]["detail"] diff --git a/tidalapi/session.py b/tidalapi/session.py index 59bb482c..9ee54011 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -268,9 +268,10 @@ class Session: refresh_token: Optional[str] = None #: The type of access token, e.g. Bearer token_type: Optional[str] = None - #: The id for a TIDAL session, you also need this to use load_oauth_session + #: The session id for a TIDAL session, you also need this to use load_oauth_session session_id: Optional[str] = None country_code: Optional[str] = None + locale: Optional[str] = None #: A :class:`.User` object containing the currently logged in user. user: Optional[Union["FetchedUser", "LoggedInUser", "PlaylistCreator"]] = None @@ -282,15 +283,6 @@ 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_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, "") self.parse_page = self.page.parse @@ -453,6 +445,7 @@ def load_oauth_session( self.session_id = json["sessionId"] self.country_code = json["countryCode"] + self.locale = "en_US" # TODO Get locale from system configuration self.user = user.User(self, user_id=json["userId"]).factory() return True @@ -719,6 +712,7 @@ def process_auth_token( json = session.json() self.session_id = json["sessionId"] self.country_code = json["countryCode"] + self.locale = "en_US" # TODO Set locale from system configuration self.user = user.User(self, user_id=json["userId"]).factory() self.is_pkce = is_pkce_token @@ -1094,11 +1088,7 @@ def home(self) -> page.Page: :return: A :class:`.Page` object with the :class:`.PageCategory` list from the home page """ - params = {} - params["deviceType"] = "BROWSER" - params["countryCode"] = "IT" - params["locale"] = "en_US" - params["platform"] = "WEB" + params = {"deviceType": "BROWSER", "locale": self.locale, "platform": "WEB"} json_obj = self.request.request( "GET", From 51dd2f74d2e05c31cffb33fc9240448cdac2b22f Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Sat, 2 Aug 2025 22:36:41 +0200 Subject: [PATCH 09/11] PageCategoryV2: Minor cleanup. Fix missing Category type(s) --- tidalapi/page.py | 68 ++++++++++++++++++++++++++---------------------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/tidalapi/page.py b/tidalapi/page.py index 4efb71fa..14ba02ad 100644 --- a/tidalapi/page.py +++ b/tidalapi/page.py @@ -42,7 +42,6 @@ from tidalapi.request import Requests from tidalapi.session import Session -from . import album, artist, media, mix, playlist PageCategories = Union[ "Album", @@ -56,6 +55,15 @@ AllCategories = Union["Artist", PageCategories] +PageCategoriesV2 = Union[ + "TrackList", + "ShortcutList", + "HorizontalList", + "HorizontalListWithContext", +] + +AllCategoriesV2 = Union[PageCategoriesV2] + class Page: """ @@ -180,7 +188,6 @@ def __init__(self, session: "Session"): def parse(self, json_obj: JsonObj) -> AllCategories: result = None category_type = json_obj["type"] - print(category_type) if category_type in ("PAGE_LINKS_CLOUD", "PAGE_LINKS"): category: PageCategories = PageLinks(self.session) elif category_type in ("FEATURED_PROMOTIONS", "MULTIPLE_TOP_PROMOTIONS"): @@ -237,24 +244,33 @@ class PageCategoryV2: def __init__(self, session: "Session"): self.session = session self.request = session.request + self.item_type_parser: Dict[str, Callable[..., Any]] = { + "PLAYLIST": self.session.parse_playlist, + "VIDEO": self.session.parse_video, + "TRACK": self.session.parse_track, + "ARTIST": self.session.parse_artist, + "ALBUM": self.session.parse_album, + "MIX": self.session.parse_v2_mix, + } - def parse(self, json_obj: JsonObj) -> AllCategories: + def parse(self, json_obj: JsonObj) -> AllCategoriesV2: category_type = json_obj["type"] - print(category_type) if category_type == "TRACK_LIST": category = TrackList(self.session) elif category_type == "SHORTCUT_LIST": category = ShortcutList(self.session) elif category_type == "HORIZONTAL_LIST": category = HorizontalList(self.session) + elif category_type == "HORIZONTAL_LIST_WITH_CONTEXT": + category = HorizontalListWithContext(self.session) else: raise NotImplementedError(f"PageType {category_type} not implemented") return category.parse(json_obj) -class SimpleList(PageCategory): - """A simple list of different items for the home page V2""" +class SimpleList(PageCategoryV2): + """A simple list of different items for the home page V2.""" items: Optional[List[Any]] = None @@ -273,35 +289,23 @@ def parse(self, json_obj: JsonObj) -> "SimpleList": def get_item(self, json_obj): item_type = json_obj["type"] - # item_data = json_obj["data"] - - print(item_type) try: - if item_type == "PLAYLIST": - return self.session.parse_playlist(json_obj) - elif item_type == "VIDEO": - return self.session.parse_video(json_obj["data"]) - elif item_type == "TRACK": - return self.session.parse_track(json_obj["data"]) - elif item_type == "ARTIST": - return self.session.parse_artist(json_obj["data"]) - elif item_type == "ALBUM": - return self.session.parse_album(json_obj["data"]) - # elif item_type == "MIX": - # return self.session.mix(json_obj["data"]["id"]) - except Exception as e: - print(e) - # raise NotImplementedError - return None + if item_type in self.item_type_parser.keys(): + return self.item_type_parser[item_type](json_obj["data"]) + else: + raise NotImplementedError(f"PageItemType {item_type} not implemented") + except TypeError as e: + print(f"Exception {e} while parsing SimpleList object.") + + +class HorizontalList(SimpleList): ... -class HorizontalList(SimpleList): - ... +class HorizontalListWithContext(HorizontalList): ... -class ShortcutList(SimpleList): - ... +class ShortcutList(SimpleList): ... class FeaturedItems(PageCategory): @@ -383,7 +387,7 @@ def parse(self, json_obj: JsonObj) -> "ItemList": class TrackList(PageCategory): - """A list of track from TIDAL.""" + """A list of tracks from TIDAL.""" items: Optional[List[Any]] = None @@ -457,7 +461,9 @@ def __init__(self, session: "Session", json_obj: JsonObj): self.text = json_obj["text"] self.featured = bool(json_obj["featured"]) - def get(self) -> Union["Artist", "Playlist", "Track", "UserPlaylist", "Video"]: + def get( + self, + ) -> Union["Artist", "Playlist", "Track", "UserPlaylist", "Video", "Album"]: """Retrieve the PageItem with the artifact_id matching the type. :return: The fully parsed item, e.g. :class:`.Playlist`, :class:`.Video`, :class:`.Track` From e1b6aaa2a8fa834afe04313b47edbe0260e04fc4 Mon Sep 17 00:00:00 2001 From: Nokse Date: Sun, 3 Aug 2025 12:54:30 +0200 Subject: [PATCH 10/11] Remove parseV2 from Page --- tidalapi/page.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/tidalapi/page.py b/tidalapi/page.py index 14ba02ad..ce5a06d8 100644 --- a/tidalapi/page.py +++ b/tidalapi/page.py @@ -118,19 +118,17 @@ def parse(self, json_obj: JsonObj) -> "Page": """Goes through everything in the page, and gets the title and adds all the rows to the categories field :param json_obj: The json to be parsed :return: A copy of the Page that you can use to browse all the items.""" - self.title = json_obj["title"] self.categories = [] - for row in json_obj["rows"]: - page_item = self.page_category.parse(row["modules"][0]) - self.categories.append(page_item) - - return copy.copy(self) - def parseV2(self, json_obj: JsonObj) -> "Page": - self.categories = [] - for item in json_obj["items"]: - page_item = self.page_category_v2.parse(item) - self.categories.append(page_item) + if json_obj.get("rows"): + self.title = json_obj["title"] + for row in json_obj["rows"]: + page_item = self.page_category.parse(row["modules"][0]) + self.categories.append(page_item) + else: + for item in json_obj["items"]: + page_item = self.page_category_v2.parse(item) + self.categories.append(page_item) return copy.copy(self) From 091897014363481403ede3624880166993f31f2e Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Mon, 4 Aug 2025 23:05:12 +0200 Subject: [PATCH 11/11] Ensure all fields are parsed (_base_parse). Use a factory method for creating subclass instance, based on the type field. Formatting --- tidalapi/page.py | 182 ++++++++++++++++++++++++++++++----------------- 1 file changed, 117 insertions(+), 65 deletions(-) diff --git a/tidalapi/page.py b/tidalapi/page.py index ce5a06d8..a5c3bc9e 100644 --- a/tidalapi/page.py +++ b/tidalapi/page.py @@ -74,8 +74,10 @@ class Page: """ title: str = "" - categories: Optional[List["AllCategories"]] = None - _categories_iter: Optional[Iterator["AllCategories"]] = None + categories: Optional[List[Union["AllCategories", "AllCategoriesV2"]]] = None + _categories_iter: Optional[Iterator[Union["AllCategories", "AllCategoriesV2"]]] = ( + None + ) _items_iter: Optional[Iterator[Callable[..., Any]]] = None page_category: "PageCategory" page_category_v2: "PageCategoryV2" @@ -127,7 +129,7 @@ def parse(self, json_obj: JsonObj) -> "Page": self.categories.append(page_item) else: for item in json_obj["items"]: - page_item = self.page_category_v2.parse(item) + page_item = self.page_category_v2.parse_item(item) self.categories.append(page_item) return copy.copy(self) @@ -158,10 +160,13 @@ class More: @classmethod def parse(cls, json_obj: JsonObj) -> Optional["More"]: show_more = json_obj.get("showMore") - if show_more is None: - return None - else: + view_all = json_obj.get("viewAll") + if show_more is not None: return cls(api_path=show_more["apiPath"], title=show_more["title"]) + elif view_all is not None: + return cls(api_path=view_all, title=json_obj.get("title")) + else: + return None class PageCategory: @@ -234,15 +239,34 @@ def show_more(self) -> Optional[Page]: class PageCategoryV2: - type = None + """Base class for all V2 homepage page categories (e.g., TRACK_LIST, SHORTCUT_LIST). + + Handles shared fields and parsing logic, and automatically dispatches to the correct + subclass based on the 'type' field in the JSON object. + """ + + # Registry mapping 'type' strings to subclass types + _type_map: Dict[str, Type["PageCategoryV2"]] = {} + + # Common metadata fields for all category types + type: Optional[str] = None + module_id: Optional[str] = None title: Optional[str] = None + subtitle: Optional[str] = None description: Optional[str] = "" - request: "Requests" + _more: Optional["More"] = None def __init__(self, session: "Session"): + """Store the shared session object and initialize common fields. + + Subclasses should implement their own `parse()` method but not override + __init__. + """ self.session = session self.request = session.request - self.item_type_parser: Dict[str, Callable[..., Any]] = { + + # Common item parsers by type (can be used by subclasses like SimpleList) + self.item_types: Dict[str, Callable[..., Any]] = { "PLAYLIST": self.session.parse_playlist, "VIDEO": self.session.parse_video, "TRACK": self.session.parse_track, @@ -251,59 +275,108 @@ def __init__(self, session: "Session"): "MIX": self.session.parse_v2_mix, } - def parse(self, json_obj: JsonObj) -> AllCategoriesV2: - category_type = json_obj["type"] - if category_type == "TRACK_LIST": - category = TrackList(self.session) - elif category_type == "SHORTCUT_LIST": - category = ShortcutList(self.session) - elif category_type == "HORIZONTAL_LIST": - category = HorizontalList(self.session) - elif category_type == "HORIZONTAL_LIST_WITH_CONTEXT": - category = HorizontalListWithContext(self.session) - else: - raise NotImplementedError(f"PageType {category_type} not implemented") + @classmethod + def register_subclass(cls, category_type: str): + """Decorator to register subclasses in the _type_map. - return category.parse(json_obj) + Usage: + @PageCategoryV2.register_subclass("TRACK_LIST") + class TrackList(PageCategoryV2): + ... + """ + def decorator(subclass): + cls._type_map[category_type] = subclass + subclass.category_type = category_type + return subclass + + return decorator + + def parse_item(self, list_item: Dict) -> "PageCategoryV2": + """Factory method that creates the correct subclass instance based on the 'type' + field in item Dict, parses base fields, and then calls subclass parse().""" + category_type = list_item.get("type") + cls = self._type_map.get(category_type) + if cls is None: + raise NotImplementedError(f"Category {category_type} not implemented") + instance = cls(self.session) + instance._parse_base(list_item) + instance.parse(list_item) + return instance + + def _parse_base(self, list_item: Dict): + """Parse fields common to all categories.""" + self.type = list_item.get("type") + self.module_id = list_item.get("moduleId") + self.title = list_item.get("title") + self.subtitle = list_item.get("subtitle") + self.description = list_item.get("description") + self._more = More.parse(list_item) + + def parse(self, json_obj: JsonObj): + """Subclasses implement this method to parse category-specific data.""" + raise NotImplementedError("Subclasses must implement parse()") + + def view_all(self) -> Optional[Page]: + """View all items in a Get the full list of items on their own :class:`.Page` + from a :class:`.PageCategory` -class SimpleList(PageCategoryV2): - """A simple list of different items for the home page V2.""" + :return: A :class:`.Page` more of the items in the category, None if there aren't any + """ + api_path = self._more.api_path if self._more else None + return self.session.view_all(api_path) if api_path and self._more else None - items: Optional[List[Any]] = None + +class SimpleList(PageCategoryV2): + """A generic list of items (tracks, albums, playlists, etc.) using the shared + self.item_types parser dictionary.""" def __init__(self, session: "Session"): super().__init__(session) - self.session = session + self.items: List[Any] = [] - def parse(self, json_obj: JsonObj) -> "SimpleList": - self.items = [] - self.title = json_obj["title"] + def parse(self, json_obj: "JsonObj"): + self.items = [self.get_item(item) for item in json_obj["items"]] + return self - for item in json_obj["items"]: - self.items.append(self.get_item(item)) + def get_item(self, json_obj: "JsonObj") -> Any: + item_type = json_obj.get("type") + if item_type not in self.item_types: + raise NotImplementedError(f"Item type '{item_type}' not implemented") - return self + return self.item_types[item_type](json_obj["data"]) - def get_item(self, json_obj): - item_type = json_obj["type"] - try: - if item_type in self.item_type_parser.keys(): - return self.item_type_parser[item_type](json_obj["data"]) - else: - raise NotImplementedError(f"PageItemType {item_type} not implemented") - except TypeError as e: - print(f"Exception {e} while parsing SimpleList object.") +@PageCategoryV2.register_subclass("SHORTCUT_LIST") +class ShortcutList(SimpleList): + """A list of "shortcut" links (typically small horizontally scrollable rows).""" -class HorizontalList(SimpleList): ... +@PageCategoryV2.register_subclass("HORIZONTAL_LIST") +class HorizontalList(SimpleList): + """A horizontal scrollable row of items.""" -class HorizontalListWithContext(HorizontalList): ... +@PageCategoryV2.register_subclass("HORIZONTAL_LIST_WITH_CONTEXT") +class HorizontalListWithContext(HorizontalList): + """A horizontal list of items with additional context.""" -class ShortcutList(SimpleList): ... +@PageCategoryV2.register_subclass("TRACK_LIST") +class TrackList(PageCategoryV2): + """A category that represents a list of tracks, each one parsed with + parse_track().""" + + def __init__(self, session: "Session"): + super().__init__(session) + self.items: List[Any] = [] + + def parse(self, json_obj: "JsonObj"): + self.items = [ + self.session.parse_track(item["data"]) for item in json_obj["items"] + ] + + return self class FeaturedItems(PageCategory): @@ -384,27 +457,6 @@ def parse(self, json_obj: JsonObj) -> "ItemList": return copy.copy(self) -class TrackList(PageCategory): - """A list of tracks from TIDAL.""" - - items: Optional[List[Any]] = None - - def parse(self, json_obj: JsonObj) -> "TrackList": - """Parse a list of tracks on TIDAL from the pages endpoints. - - :param json_obj: The json from TIDAL to be parsed - :return: A copy of the TrackList with a list of items - """ - self.title = json_obj["title"] - - self.items = [] - - for item in json_obj["items"]: - self.items.append(self.session.parse_track(item["data"])) - - return copy.copy(self) - - class PageLink: """A Link to another :class:`.Page` on TIDAL, Call get() to retrieve the Page."""