From 874d11753ab8759277a503d65344087119e0da27 Mon Sep 17 00:00:00 2001 From: Jozef Kruszynski Date: Sun, 22 Oct 2023 12:33:00 +0200 Subject: [PATCH] feat: Issue number 197 add v2 api favorite mixes --- tests/test_user.py | 5 ++ tidalapi/__init__.py | 2 +- tidalapi/mix.py | 114 +++++++++++++++++++++++++++++++++++++++++++ tidalapi/request.py | 10 ++-- tidalapi/session.py | 15 +++++- tidalapi/user.py | 15 ++++++ 6 files changed, 154 insertions(+), 7 deletions(-) diff --git a/tests/test_user.py b/tests/test_user.py index 33a99a5..1ac9126 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -166,6 +166,11 @@ def test_add_remove_favorite_video(session): video_id = 160850422 add_remove(video_id, favorites.add_video, favorites.remove_video, favorites.videos) +def test_get_favorite_mixes(session): + favorites = session.user.favorites + mixes = favorites.mixes() + assert len(mixes) > 0 + assert isinstance(mixes[0], tidalapi.MixV2) def add_remove(object_id, add, remove, objects): """Add and remove an item from favorites. Skips the test if the item was already in diff --git a/tidalapi/__init__.py b/tidalapi/__init__.py index 3acadda..a3c7cd7 100644 --- a/tidalapi/__init__.py +++ b/tidalapi/__init__.py @@ -2,7 +2,7 @@ from .artist import Artist, Role # noqa: F401 from .genre import Genre # noqa: F401 from .media import Quality, Track, Video, VideoQuality # noqa: F401 -from .mix import Mix # noqa: F401 +from .mix import Mix, MixV2 # noqa: F401 from .page import Page # noqa: F401 from .playlist import Playlist, UserPlaylist # noqa: F401 from .request import Requests # noqa: F401 diff --git a/tidalapi/mix.py b/tidalapi/mix.py index 96d16a0..cb829ce 100644 --- a/tidalapi/mix.py +++ b/tidalapi/mix.py @@ -20,11 +20,14 @@ import copy from dataclasses import dataclass +from datetime import datetime from enum import Enum from typing import TYPE_CHECKING, List, Optional, Union from tidalapi.types import JsonObj +import dateutil.parser + if TYPE_CHECKING: from tidalapi.media import Track, Video from tidalapi.session import Session @@ -151,3 +154,114 @@ def image(self, dimensions: int = 320) -> str: return self.images.large raise ValueError(f"Invalid resolution {dimensions} x {dimensions}") + +@dataclass +class TextInfo: + text: str + color: str + +class MixV2: + """A mix from TIDALs v2 api endpoint, weirdly, it is used in only one place currently. + """ + date_added: Optional[datetime] = None + title: str = "" + id: str = "" + mix_type: Optional[MixType] = None + images: Optional[ImageResponse] = None + detail_images: Optional[ImageResponse] = None + master = False + title_text_info: Optional[TextInfo] = None + sub_title_text_info: Optional[TextInfo] = None + sub_title: str = "" + updated: Optional[datetime] = None + + def __init__(self, session: Session, mix_id: str): + self.session = session + self.request = session.request + if mix_id is not None: + self.get(mix_id) + + def get(self, mix_id: Optional[str] = None) -> "Mix": + """Returns information about a mix, and also replaces the mix object used to + call this function. + + :param mix_id: TIDAL's identifier of the mix + :return: A :class:`Mix` object containing all the information about the mix + """ + if mix_id is None: + mix_id = self.id + + params = {"mixId": mix_id, "deviceType": "BROWSER"} + parse = self.session.parse_page + result = self.request.map_request("pages/mix", parse=parse, params=params) + assert not isinstance(result, list) + self._retrieved = True + self.__dict__.update(result.categories[0].__dict__) + self._items = result.categories[1].items + return self + + def parse(self, json_obj: JsonObj) -> "MixV2": + """Parse a mix into a :class:`MixV2`, replaces the calling object. + + :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 + ) + + return copy.copy(self) + + def image(self, dimensions: int = 320) -> str: + """A URL to a Mix picture. + + :param dimensions: The width and height the requested image should be + :type dimensions: int + :return: A url to the image + + Original sizes: 320x320, 640x640, 1500x1500 + """ + if not self.images: + raise ValueError("No images present.") + + if dimensions == 320: + return self.images.small + elif dimensions == 640: + return self.images.medium + elif dimensions == 1500: + return self.images.large + + raise ValueError(f"Invalid resolution {dimensions} x {dimensions}") \ No newline at end of file diff --git a/tidalapi/request.py b/tidalapi/request.py index ec5c637..68e4fb2 100644 --- a/tidalapi/request.py +++ b/tidalapi/request.py @@ -36,7 +36,7 @@ def __init__(self, session): self.session = session self.config = session.config - def basic_request(self, method, path, params=None, data=None, headers=None): + def basic_request(self, method, path, api_version="v1/", params=None, data=None, headers=None): request_params = { "sessionId": self.session.session_id, "countryCode": self.session.country_code, @@ -56,7 +56,7 @@ def basic_request(self, method, path, params=None, data=None, headers=None): self.session.token_type + " " + self.session.access_token ) - url = urljoin(self.session.config.api_location, path) + url = urljoin(f"{self.session.config.api_location}{api_version}", path) request = self.session.request_session.request( method, url, params=request_params, data=data, headers=headers ) @@ -86,6 +86,7 @@ def request( self, method: Literal["GET", "POST", "PUT", "DELETE"], path: str, + api_version: str = "v1/", params: Optional[Params] = None, data: Optional[JsonObj] = None, headers: Optional[Mapping[str, str]] = None, @@ -102,7 +103,7 @@ def request( :return: The json data at specified api endpoint. """ - request = self.basic_request(method, path, params, data, headers) + request = self.basic_request(method, path, api_version, params, data, headers) log.debug("request: %s", request.request.url) request.raise_for_status() if request.content: @@ -112,6 +113,7 @@ def request( def map_request( self, url: str, + api_version: str = "v1/", params: Optional[Params] = None, parse: Optional[Callable] = None, ): @@ -126,7 +128,7 @@ def map_request( :return: The object(s) at the url, with the same type as the class of the parse method. """ - json_obj = self.request("GET", url, params).json() + json_obj = self.request("GET", url, api_version, params).json() return self.map_json(json_obj, parse=parse) diff --git a/tidalapi/session.py b/tidalapi/session.py index b725997..6e9035d 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -98,7 +98,7 @@ def __init__( ): self.quality = quality.value self.video_quality = video_quality.value - self.api_location = "https://api.tidal.com/v1/" + self.api_location = "https://api.tidal.com/" self.image_url = "https://resources.tidal.com/images/%s/%ix%i.jpg" self.video_url = "https://resources.tidal.com/videos/%s/%ix%i.mp4" @@ -228,6 +228,7 @@ def __init__(self, config=Config()): 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, None) @@ -343,7 +344,7 @@ def login(self, username, password): :param password: The password to your TIDAL account :return: Returns true if we think the login was successful. """ - url = urljoin(self.config.api_location, "login/username") + url = urljoin(self.config.api_location, "v1/login/username") headers: dict[str, str] = {"X-Tidal-Token": self.config.api_token} payload = { "username": username, @@ -618,6 +619,16 @@ def mix(self, mix_id=None) -> tidalapi.Mix: """ return mix.Mix(session=self, mix_id=mix_id) + + def mixv2(self, mix_id=None) -> tidalapi.MixV2: + """Function to create a mix object with access to the session instance smoothly + Calls :class:`tidalapi.MixV2(session=session, mix_id=mix_id) <.Album>` internally. + + :param mix_id: (Optional) The TIDAL id of the Mix. You may want access to the mix methods without an id. + :return: Returns a :class:`.Mix` object that has access to the session instance used. + """ + + return mix.MixV2(session=self, mix_id=mix_id) def get_user( self, user_id=None diff --git a/tidalapi/user.py b/tidalapi/user.py index 566b25c..fdb1c12 100644 --- a/tidalapi/user.py +++ b/tidalapi/user.py @@ -34,6 +34,7 @@ from tidalapi.media import Track, Video from tidalapi.playlist import Playlist, UserPlaylist from tidalapi.session import Session + from tidalapi.mix import Mix, MixV2 class User: @@ -188,6 +189,7 @@ def __init__(self, session: "Session", user_id: int): self.session = session self.requests = session.request self.base_url = f"users/{user_id}/favorites" + self.v2_base_url = "favorites" def add_album(self, album_id: str) -> bool: """Adds an album to the users favorites. @@ -356,3 +358,16 @@ def videos(self) -> List["Video"]: f"{self.base_url}/videos", parse=self.session.parse_media ), ) + + def mixes(self, limit: Optional[int] = 50, offset: int = 0) -> List["MixV2"]: + """Get the users favorite tracks. + + :return: A :class:`list` of :class:`~tidalapi.media.Track` objects containing all of the favorite tracks. + """ + params = {"limit": limit, "offset": offset} + return cast( + List["MixV2"], + self.requests.map_request( + f"{self.v2_base_url}/mixes", api_version="v2/", params=params, parse=self.session.parse_v2_mix + ), + ) \ No newline at end of file