From 6f031a0cdb2c8293f2b2b26da68497b809611d87 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Fri, 19 Jan 2024 19:07:07 +0100 Subject: [PATCH 01/24] =?UTF-8?q?=E2=9C=A8=20First=20working=20PKCE=20logi?= =?UTF-8?q?n=20implement.=20HiRes=20downloads=20are=20possible=20now.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tidalapi/session.py | 98 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 3 deletions(-) diff --git a/tidalapi/session.py b/tidalapi/session.py index 1af116b..d509494 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -21,7 +21,9 @@ import base64 import concurrent.futures import datetime +import hashlib import logging +import os import random import time import uuid @@ -40,7 +42,7 @@ cast, no_type_check, ) -from urllib.parse import urljoin +from urllib.parse import urljoin, urlencode, parse_qs, urljoin, urlsplit import requests @@ -178,8 +180,9 @@ def __init__( for word in token2: self.client_id.remove(word) self.client_id = "".join(self.client_id) - self.client_secret = self.client_id - self.client_id = self.api_token + self.client_secret = "xeuPmY7nbpZ9IIbLAcQ93shka1VNheUAqN6IcszjTG8=" + self.client_id = "6BDSRdpK9hqEBTgU" + self.api_token = self.client_id class Case(Enum): @@ -399,6 +402,30 @@ def login(self, username: str, password: str) -> bool: self.user = user.User(self, user_id=body["userId"]).factory() return True + def login_pkce(self): + pkce: Pkce = Pkce() + url_login: str = pkce.get_login_url() + + print(url_login) + + url_redirect: str = input('Paste URL:') + + json: dict = pkce.get_auth_token(url_redirect) + + self.access_token = json["access_token"] + self.expiry_time = datetime.datetime.utcnow() + datetime.timedelta( + seconds=json["expires_in"] + ) + self.refresh_token = json["refresh_token"] + self.token_type = json["token_type"] + session = self.request.request("GET", "sessions") + json = session.json() + self.session_id = json["sessionId"] + self.country_code = json["countryCode"] + self.user = user.User(self, user_id=json["userId"]).factory() + + print(json) + def login_oauth_simple(self, function: Callable[[str], None] = print) -> None: """Login to TIDAL using a remote link. You can select what function you want to use to display the link. @@ -750,3 +777,68 @@ def mixes(self) -> page.Page: :return: A list of :class:`.Mix` """ return self.page.get("pages/my_collection_my_mixes") + +class Pkce(object): + def __init__(self): + self.client_unique_key = format(random.getrandbits(64), '02x') + self.code_verifier = base64.urlsafe_b64encode(os.urandom(32))[:-1].decode("utf-8") + self.code_challenge = base64.urlsafe_b64encode(hashlib.sha256(self.code_verifier.encode('utf-8')).digest())[:-1].decode("utf-8") + # TODO: Move to constant file and use it everywhere. + 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.params = { 'response_type': 'code', + 'redirect_uri': 'https://tidal.com/android/login/auth', + 'client_id': '6BDSRdpK9hqEBTgU', + # TODO: Use from config + 'lang': 'DE', + 'appMode': 'android', + 'client_unique_key': self.client_unique_key, + 'code_challenge': self.code_challenge, + 'code_challenge_method': 'S256', + 'restrict_signup': 'true' + } + self.code = None + + def check_response(self, r): + log.info('%s %s' % (r.request.method, r.request.url)) + if not r.ok: + log.error(repr(r)) + try: + log.error('response: %s' % json.dumps(r.json(), indent=4)) + except: + pass + return r + + def get_login_url(self): + """ Returns the Login-URL to login via web browser """ + # TODO: Refactor login url + return urljoin('https://login.tidal.com/', 'authorize') + '?' + urlencode(self.params) + + def get_auth_token(self, url_redirect: str): + """ Using one-time authorization code to get the access and refresh tokens (last step of the login sequence) """ + # TODO: Refactor scopes + DEFAULT_SCOPE = 'r_usr+w_usr+w_sub' # w_usr=WRITE_USR, r_usr=READ_USR_DATA, w_sub=WRITE_SUBSCRIPTION + REFRESH_SCOPE = 'r_usr+w_usr' + + if url_redirect and 'https://' in url_redirect: + self.code = parse_qs(urlsplit(url_redirect).query)["code"][0] + + data = { 'code': self.code, + 'client_id': self.params['client_id'], + 'grant_type': 'authorization_code', + 'redirect_uri': self.params['redirect_uri'], + 'scope': DEFAULT_SCOPE, + 'code_verifier': self.code_verifier, + 'client_unique_key': self.client_unique_key + } + r = requests.post(urljoin('https://auth.tidal.com/v1/oauth2/', 'token'), data=data, headers={'User-Agent': self.user_agent}) + r = self.check_response(r) + + try: + self.token = {} + self.token = r.json() + except: + log.error('Wrong one-time authorization code') + + raise Exception('Wrong one-time authorization code', r) + + return self.token From 60a3badc7fe064f3c92eeb4fe86d9e63562b2f17 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Mon, 22 Jan 2024 21:42:26 +0100 Subject: [PATCH 02/24] =?UTF-8?q?=E2=9C=A8=20Set=20appropriate=20User-Agen?= =?UTF-8?q?t.=20Fixes=20tamland/python-tidal#217?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tidalapi/request.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tidalapi/request.py b/tidalapi/request.py index 90548d1..ebe3fea 100644 --- a/tidalapi/request.py +++ b/tidalapi/request.py @@ -49,8 +49,11 @@ class Requests(object): """A class for handling api requests to TIDAL.""" + user_agent: str 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.session = session self.config = session.config @@ -76,6 +79,10 @@ def basic_request( if not headers: headers = {} + + if 'User-Agent' not in headers: + headers['User-Agent'] = self.user_agent + if self.session.token_type and self.session.access_token is not None: headers["authorization"] = ( self.session.token_type + " " + self.session.access_token From d5572866b50a7b2c60f9aaae9b9cf5a8446350f6 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 23 Jan 2024 09:08:42 +0100 Subject: [PATCH 03/24] =?UTF-8?q?=E2=9C=A8=20Implemented=20PKCE=20authoriz?= =?UTF-8?q?ation=20to=20enable=20HiRes=20FLAC=20downloads.=20Fixes=20tamla?= =?UTF-8?q?nd/python-tidal#188?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tidalapi/session.py | 199 +++++++++++++++++++++++++------------------- 1 file changed, 114 insertions(+), 85 deletions(-) diff --git a/tidalapi/session.py b/tidalapi/session.py index d509494..5319eda 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -42,7 +42,7 @@ cast, no_type_check, ) -from urllib.parse import urljoin, urlencode, parse_qs, urljoin, urlsplit +from urllib.parse import urlencode, parse_qs, urljoin, urlsplit import requests @@ -95,6 +95,8 @@ class Config: """ api_location: str = "https://api.tidal.com/v1/" + api_oauth2_token: str = "https://auth.tidal.com/v1/oauth2/token" + api_pkce_auth: str = "https://login.tidal.com/authorize" api_token: str client_id: str client_secret: str @@ -103,6 +105,12 @@ class Config: quality: str video_quality: str video_url: str = "https://resources.tidal.com/videos/%s/%ix%i.mp4" + # Necessary for PKCE authorization only + client_unique_key: str + code_verifier: str + code_challenge: str + pkce_uri_redirect: str = "https://tidal.com/android/login/auth" + client_id_pkce: str @no_type_check def __init__( @@ -183,6 +191,16 @@ def __init__( self.client_secret = "xeuPmY7nbpZ9IIbLAcQ93shka1VNheUAqN6IcszjTG8=" self.client_id = "6BDSRdpK9hqEBTgU" self.api_token = self.client_id + # PKCE Authorization. We will keep the former `client_id` as a fallback / will only be used for non PCKE + # authorizations. + self.client_unique_key = format(random.getrandbits(64), '02x') + self.code_verifier = base64.urlsafe_b64encode(os.urandom(32))[:-1].decode("utf-8") + self.code_challenge = base64.urlsafe_b64encode( + hashlib.sha256(self.code_verifier.encode('utf-8')).digest() + )[:-1].decode("utf-8") + self.client_id_pkce = base64.b64decode( + base64.b64decode(b'TmtKRVUxSmtjRXM=') + base64.b64decode(b'NWFIRkZRbFJuVlE9PQ==') + ).decode('utf-8') class Case(Enum): @@ -358,7 +376,7 @@ def load_oauth_session( :param refresh_token: (Optional) A refresh token that lets you get a new access token after it has expired :param expiry_time: (Optional) The datetime the access token will expire - :return: True if we believe the log in was successful, otherwise false. + :return: True if we believe the login was successful, otherwise false. """ self.token_type = token_type self.access_token = access_token @@ -402,29 +420,96 @@ def login(self, username: str, password: str) -> bool: self.user = user.User(self, user_id=body["userId"]).factory() return True - def login_pkce(self): - pkce: Pkce = Pkce() - url_login: str = pkce.get_login_url() + def login_pkce(self, fn_print: Callable[[str], None] = print) -> None: + """Login handler for PKCE based authentication. This is the only way how to get access to HiRes + (Up to 24-bit, 192 kHz) FLAC files. - print(url_login) + This handler will ask you to follow a URL, process with the login in the browser and copy & paste the + URL of the redirected browser page. - url_redirect: str = input('Paste URL:') + :param fn_print: A function which will be called to print the instructions, defaults to `print()`. + :type fn_print: Callable, optional + :return: + """ + # Get login url + url_login: str = self._pkce_login_url() - json: dict = pkce.get_auth_token(url_redirect) + fn_print("READ CAREFULLY!") + fn_print("---------------") + fn_print("You need to open this link and login with your username and password. " + "Afterwards you will be redirected to an 'Oops' page. " + "To complete the login you must copy the URL from this 'Oops' page and paste it to the input field.") + fn_print(url_login) - self.access_token = json["access_token"] - self.expiry_time = datetime.datetime.utcnow() + datetime.timedelta( - seconds=json["expires_in"] - ) - self.refresh_token = json["refresh_token"] - self.token_type = json["token_type"] - session = self.request.request("GET", "sessions") - json = session.json() - self.session_id = json["sessionId"] - self.country_code = json["countryCode"] - self.user = user.User(self, user_id=json["userId"]).factory() + # Get redirect URL from user input. + url_redirect: str = input("Paste 'Ooops' page URL here and press :") + # Query for auth tokens + json: dict[str, Union[str, int]] = self._pkce_get_auth_token(url_redirect) + + # Parse and set tokens. + self._process_auth_token(json) - print(json) + def _pkce_login_url(self) -> str: + """ Returns the Login-URL to login via web browser. + + :return: The URL the user has to use for login. + :rtype: str + """ + params: request.Params = { + 'response_type': 'code', + 'redirect_uri': self.config.pkce_uri_redirect, + 'client_id': self.config.client_id_pkce, + 'lang': 'EN', + 'appMode': 'android', + 'client_unique_key': self.config.client_unique_key, + 'code_challenge': self.config.code_challenge, + 'code_challenge_method': 'S256', + 'restrict_signup': 'true' + } + + return self.config.api_pkce_auth + '?' + urlencode(params) + + def _pkce_get_auth_token(self, url_redirect: str) -> dict[str, Union[str, int]]: + """Parses the redirect url to extract access and refresh tokens. + + :param url_redirect: URL of the 'Ooops' page, where the user was redirected to after login. + :type url_redirect: str + :return: A parsed JSON object with access and refresh tokens and other information. + :rtype: dict[str, str | int] + """ + # w_usr=WRITE_USR, r_usr=READ_USR_DATA, w_sub=WRITE_SUBSCRIPTION + scope_default: str = 'r_usr+w_usr+w_sub' + + # Extract the code parameter from query string + if url_redirect and 'https://' in url_redirect: + code: str = parse_qs(urlsplit(url_redirect).query)["code"][0] + else: + raise Exception('The provided redirect url looks wrong: ' + url_redirect) + + # Set post data and call the API + data: request.Params = { + 'code': code, + 'client_id': self.config.client_id_pkce, + 'grant_type': 'authorization_code', + 'redirect_uri': self.config.pkce_uri_redirect, + 'scope': scope_default, + 'code_verifier': self.config.code_verifier, + 'client_unique_key': self.config.client_unique_key + } + response = self.request_session.post(self.config.api_oauth2_token, data) + + # Check response + if not response.ok: + log.error("Login failed: %s", response.text) + response.raise_for_status() + + # Parse the JSON response. + try: + token: dict[str, Union[str, int]] = response.json() + except: + raise Exception('Wrong one-time authorization code', response) + + return token def login_oauth_simple(self, function: Callable[[str], None] = print) -> None: """Login to TIDAL using a remote link. You can select what function you want to @@ -466,6 +551,15 @@ def _login_with_link(self) -> Tuple[LinkLogin, concurrent.futures.Future[Any]]: def _process_link_login(self, json: JsonObj) -> None: json = self._wait_for_link_login(json) + self._process_auth_token(json) + + def _process_auth_token(self, json: dict[str, Union[str, int]]) -> None: + """Parses the authorization response and sets the token values to the specific variables for further usage. + + :param json: Parsed JSON response after login / authorization. + :type json: dict[str, str | int] + :return: None + """ self.access_token = json["access_token"] self.expiry_time = datetime.datetime.utcnow() + datetime.timedelta( seconds=json["expires_in"] @@ -777,68 +871,3 @@ def mixes(self) -> page.Page: :return: A list of :class:`.Mix` """ return self.page.get("pages/my_collection_my_mixes") - -class Pkce(object): - def __init__(self): - self.client_unique_key = format(random.getrandbits(64), '02x') - self.code_verifier = base64.urlsafe_b64encode(os.urandom(32))[:-1].decode("utf-8") - self.code_challenge = base64.urlsafe_b64encode(hashlib.sha256(self.code_verifier.encode('utf-8')).digest())[:-1].decode("utf-8") - # TODO: Move to constant file and use it everywhere. - 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.params = { 'response_type': 'code', - 'redirect_uri': 'https://tidal.com/android/login/auth', - 'client_id': '6BDSRdpK9hqEBTgU', - # TODO: Use from config - 'lang': 'DE', - 'appMode': 'android', - 'client_unique_key': self.client_unique_key, - 'code_challenge': self.code_challenge, - 'code_challenge_method': 'S256', - 'restrict_signup': 'true' - } - self.code = None - - def check_response(self, r): - log.info('%s %s' % (r.request.method, r.request.url)) - if not r.ok: - log.error(repr(r)) - try: - log.error('response: %s' % json.dumps(r.json(), indent=4)) - except: - pass - return r - - def get_login_url(self): - """ Returns the Login-URL to login via web browser """ - # TODO: Refactor login url - return urljoin('https://login.tidal.com/', 'authorize') + '?' + urlencode(self.params) - - def get_auth_token(self, url_redirect: str): - """ Using one-time authorization code to get the access and refresh tokens (last step of the login sequence) """ - # TODO: Refactor scopes - DEFAULT_SCOPE = 'r_usr+w_usr+w_sub' # w_usr=WRITE_USR, r_usr=READ_USR_DATA, w_sub=WRITE_SUBSCRIPTION - REFRESH_SCOPE = 'r_usr+w_usr' - - if url_redirect and 'https://' in url_redirect: - self.code = parse_qs(urlsplit(url_redirect).query)["code"][0] - - data = { 'code': self.code, - 'client_id': self.params['client_id'], - 'grant_type': 'authorization_code', - 'redirect_uri': self.params['redirect_uri'], - 'scope': DEFAULT_SCOPE, - 'code_verifier': self.code_verifier, - 'client_unique_key': self.client_unique_key - } - r = requests.post(urljoin('https://auth.tidal.com/v1/oauth2/', 'token'), data=data, headers={'User-Agent': self.user_agent}) - r = self.check_response(r) - - try: - self.token = {} - self.token = r.json() - except: - log.error('Wrong one-time authorization code') - - raise Exception('Wrong one-time authorization code', r) - - return self.token From 69bf50c0bac34dd16771693f3d6ec5f1f158dfd8 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 23 Jan 2024 09:13:54 +0100 Subject: [PATCH 04/24] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Minor=20refactoring?= =?UTF-8?q?=20of=20urls.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tidalapi/session.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tidalapi/session.py b/tidalapi/session.py index 5319eda..4301704 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -576,7 +576,7 @@ def _wait_for_link_login(self, json: JsonObj) -> Any: expiry = float(json["expiresIn"]) interval = float(json["interval"]) device_code = json["deviceCode"] - url = "https://auth.tidal.com/v1/oauth2/token" + url = self.config.api_oauth2_token params = { "client_id": self.config.client_id, "client_secret": self.config.client_secret, @@ -605,7 +605,7 @@ def token_refresh(self, refresh_token: str) -> bool: :return: True if we believe the token was successfully refreshed, otherwise False """ - url = "https://auth.tidal.com/v1/oauth2/token" + url = self.config.api_oauth2_token params = { "grant_type": "refresh_token", "refresh_token": refresh_token, From 3b1d285a6e4f055ec793b1f6586c58c87ed81281 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 23 Jan 2024 09:16:36 +0100 Subject: [PATCH 05/24] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Code=20format.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tidalapi/request.py | 7 ++-- tidalapi/session.py | 87 +++++++++++++++++++++++++-------------------- 2 files changed, 52 insertions(+), 42 deletions(-) diff --git a/tidalapi/request.py b/tidalapi/request.py index ebe3fea..611a2e0 100644 --- a/tidalapi/request.py +++ b/tidalapi/request.py @@ -49,11 +49,12 @@ class Requests(object): """A class for handling api requests to TIDAL.""" + user_agent: str 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.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.session = session self.config = session.config @@ -80,8 +81,8 @@ def basic_request( if not headers: headers = {} - if 'User-Agent' not in headers: - headers['User-Agent'] = self.user_agent + if "User-Agent" not in headers: + headers["User-Agent"] = self.user_agent if self.session.token_type and self.session.access_token is not None: headers["authorization"] = ( diff --git a/tidalapi/session.py b/tidalapi/session.py index 4301704..88b2684 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -42,7 +42,7 @@ cast, no_type_check, ) -from urllib.parse import urlencode, parse_qs, urljoin, urlsplit +from urllib.parse import parse_qs, urlencode, urljoin, urlsplit import requests @@ -193,14 +193,17 @@ def __init__( self.api_token = self.client_id # PKCE Authorization. We will keep the former `client_id` as a fallback / will only be used for non PCKE # authorizations. - self.client_unique_key = format(random.getrandbits(64), '02x') - self.code_verifier = base64.urlsafe_b64encode(os.urandom(32))[:-1].decode("utf-8") + self.client_unique_key = format(random.getrandbits(64), "02x") + self.code_verifier = base64.urlsafe_b64encode(os.urandom(32))[:-1].decode( + "utf-8" + ) self.code_challenge = base64.urlsafe_b64encode( - hashlib.sha256(self.code_verifier.encode('utf-8')).digest() + hashlib.sha256(self.code_verifier.encode("utf-8")).digest() )[:-1].decode("utf-8") self.client_id_pkce = base64.b64decode( - base64.b64decode(b'TmtKRVUxSmtjRXM=') + base64.b64decode(b'NWFIRkZRbFJuVlE9PQ==') - ).decode('utf-8') + base64.b64decode(b"TmtKRVUxSmtjRXM=") + + base64.b64decode(b"NWFIRkZRbFJuVlE9PQ==") + ).decode("utf-8") class Case(Enum): @@ -421,13 +424,14 @@ def login(self, username: str, password: str) -> bool: return True def login_pkce(self, fn_print: Callable[[str], None] = print) -> None: - """Login handler for PKCE based authentication. This is the only way how to get access to HiRes - (Up to 24-bit, 192 kHz) FLAC files. + """Login handler for PKCE based authentication. This is the only way how to get + access to HiRes (Up to 24-bit, 192 kHz) FLAC files. - This handler will ask you to follow a URL, process with the login in the browser and copy & paste the - URL of the redirected browser page. + This handler will ask you to follow a URL, process with the login in the browser + and copy & paste the URL of the redirected browser page. - :param fn_print: A function which will be called to print the instructions, defaults to `print()`. + :param fn_print: A function which will be called to print the instructions, + defaults to `print()`. :type fn_print: Callable, optional :return: """ @@ -436,9 +440,11 @@ def login_pkce(self, fn_print: Callable[[str], None] = print) -> None: fn_print("READ CAREFULLY!") fn_print("---------------") - fn_print("You need to open this link and login with your username and password. " - "Afterwards you will be redirected to an 'Oops' page. " - "To complete the login you must copy the URL from this 'Oops' page and paste it to the input field.") + fn_print( + "You need to open this link and login with your username and password. " + "Afterwards you will be redirected to an 'Oops' page. " + "To complete the login you must copy the URL from this 'Oops' page and paste it to the input field." + ) fn_print(url_login) # Get redirect URL from user input. @@ -450,51 +456,53 @@ def login_pkce(self, fn_print: Callable[[str], None] = print) -> None: self._process_auth_token(json) def _pkce_login_url(self) -> str: - """ Returns the Login-URL to login via web browser. + """Returns the Login-URL to login via web browser. :return: The URL the user has to use for login. :rtype: str """ params: request.Params = { - 'response_type': 'code', - 'redirect_uri': self.config.pkce_uri_redirect, - 'client_id': self.config.client_id_pkce, - 'lang': 'EN', - 'appMode': 'android', - 'client_unique_key': self.config.client_unique_key, - 'code_challenge': self.config.code_challenge, - 'code_challenge_method': 'S256', - 'restrict_signup': 'true' + "response_type": "code", + "redirect_uri": self.config.pkce_uri_redirect, + "client_id": self.config.client_id_pkce, + "lang": "EN", + "appMode": "android", + "client_unique_key": self.config.client_unique_key, + "code_challenge": self.config.code_challenge, + "code_challenge_method": "S256", + "restrict_signup": "true", } - return self.config.api_pkce_auth + '?' + urlencode(params) + return self.config.api_pkce_auth + "?" + urlencode(params) def _pkce_get_auth_token(self, url_redirect: str) -> dict[str, Union[str, int]]: """Parses the redirect url to extract access and refresh tokens. - :param url_redirect: URL of the 'Ooops' page, where the user was redirected to after login. + :param url_redirect: URL of the 'Ooops' page, where the user was redirected to + after login. :type url_redirect: str - :return: A parsed JSON object with access and refresh tokens and other information. + :return: A parsed JSON object with access and refresh tokens and other + information. :rtype: dict[str, str | int] """ # w_usr=WRITE_USR, r_usr=READ_USR_DATA, w_sub=WRITE_SUBSCRIPTION - scope_default: str = 'r_usr+w_usr+w_sub' + scope_default: str = "r_usr+w_usr+w_sub" # Extract the code parameter from query string - if url_redirect and 'https://' in url_redirect: + if url_redirect and "https://" in url_redirect: code: str = parse_qs(urlsplit(url_redirect).query)["code"][0] else: - raise Exception('The provided redirect url looks wrong: ' + url_redirect) + raise Exception("The provided redirect url looks wrong: " + url_redirect) # Set post data and call the API data: request.Params = { - 'code': code, - 'client_id': self.config.client_id_pkce, - 'grant_type': 'authorization_code', - 'redirect_uri': self.config.pkce_uri_redirect, - 'scope': scope_default, - 'code_verifier': self.config.code_verifier, - 'client_unique_key': self.config.client_unique_key + "code": code, + "client_id": self.config.client_id_pkce, + "grant_type": "authorization_code", + "redirect_uri": self.config.pkce_uri_redirect, + "scope": scope_default, + "code_verifier": self.config.code_verifier, + "client_unique_key": self.config.client_unique_key, } response = self.request_session.post(self.config.api_oauth2_token, data) @@ -507,7 +515,7 @@ def _pkce_get_auth_token(self, url_redirect: str) -> dict[str, Union[str, int]]: try: token: dict[str, Union[str, int]] = response.json() except: - raise Exception('Wrong one-time authorization code', response) + raise Exception("Wrong one-time authorization code", response) return token @@ -554,7 +562,8 @@ def _process_link_login(self, json: JsonObj) -> None: self._process_auth_token(json) def _process_auth_token(self, json: dict[str, Union[str, int]]) -> None: - """Parses the authorization response and sets the token values to the specific variables for further usage. + """Parses the authorization response and sets the token values to the specific + variables for further usage. :param json: Parsed JSON response after login / authorization. :type json: dict[str, str | int] From abf164307b1863eff53ff9ee772757d2e915a050 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 23 Jan 2024 12:00:28 +0100 Subject: [PATCH 06/24] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20Reverted=20to=20a?= =?UTF-8?q?ctual=20login=20credentials=20for=20device=20linking.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tidalapi/session.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tidalapi/session.py b/tidalapi/session.py index 88b2684..7279bf9 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -188,9 +188,8 @@ def __init__( for word in token2: self.client_id.remove(word) self.client_id = "".join(self.client_id) - self.client_secret = "xeuPmY7nbpZ9IIbLAcQ93shka1VNheUAqN6IcszjTG8=" - self.client_id = "6BDSRdpK9hqEBTgU" - self.api_token = self.client_id + self.client_secret = self.client_id + self.client_id = self.api_token # PKCE Authorization. We will keep the former `client_id` as a fallback / will only be used for non PCKE # authorizations. self.client_unique_key = format(random.getrandbits(64), "02x") From 2a43d9a125fad236a7526a49381b0292dff7d608 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Fri, 26 Jan 2024 18:34:23 +0100 Subject: [PATCH 07/24] =?UTF-8?q?=E2=9C=A8=20Changed=20from=20private=20to?= =?UTF-8?q?=20public=20methods.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tidalapi/session.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tidalapi/session.py b/tidalapi/session.py index 7279bf9..09f0fb1 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -435,7 +435,7 @@ def login_pkce(self, fn_print: Callable[[str], None] = print) -> None: :return: """ # Get login url - url_login: str = self._pkce_login_url() + url_login: str = self.pkce_login_url() fn_print("READ CAREFULLY!") fn_print("---------------") @@ -449,12 +449,12 @@ def login_pkce(self, fn_print: Callable[[str], None] = print) -> None: # Get redirect URL from user input. url_redirect: str = input("Paste 'Ooops' page URL here and press :") # Query for auth tokens - json: dict[str, Union[str, int]] = self._pkce_get_auth_token(url_redirect) + json: dict[str, Union[str, int]] = self.pkce_get_auth_token(url_redirect) # Parse and set tokens. - self._process_auth_token(json) + self.process_auth_token(json) - def _pkce_login_url(self) -> str: + def pkce_login_url(self) -> str: """Returns the Login-URL to login via web browser. :return: The URL the user has to use for login. @@ -474,7 +474,7 @@ def _pkce_login_url(self) -> str: return self.config.api_pkce_auth + "?" + urlencode(params) - def _pkce_get_auth_token(self, url_redirect: str) -> dict[str, Union[str, int]]: + def pkce_get_auth_token(self, url_redirect: str) -> dict[str, Union[str, int]]: """Parses the redirect url to extract access and refresh tokens. :param url_redirect: URL of the 'Ooops' page, where the user was redirected to @@ -558,9 +558,9 @@ def _login_with_link(self) -> Tuple[LinkLogin, concurrent.futures.Future[Any]]: def _process_link_login(self, json: JsonObj) -> None: json = self._wait_for_link_login(json) - self._process_auth_token(json) + self.process_auth_token(json) - def _process_auth_token(self, json: dict[str, Union[str, int]]) -> None: + def process_auth_token(self, json: dict[str, Union[str, int]]) -> None: """Parses the authorization response and sets the token values to the specific variables for further usage. From 261f2377f688233fda8ebca5078992d630791740 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jan 2024 21:59:12 +0000 Subject: [PATCH 08/24] chore(deps-dev): bump pillow from 10.0.1 to 10.2.0 Bumps [pillow](https://github.com/python-pillow/Pillow) from 10.0.1 to 10.2.0. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/10.0.1...10.2.0) --- updated-dependencies: - dependency-name: pillow dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- poetry.lock | 128 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 73 insertions(+), 55 deletions(-) diff --git a/poetry.lock b/poetry.lock index 876943f..a4c6036 100644 --- a/poetry.lock +++ b/poetry.lock @@ -620,70 +620,88 @@ files = [ [[package]] name = "pillow" -version = "10.0.1" +version = "10.2.0" description = "Python Imaging Library (Fork)" optional = false python-versions = ">=3.8" files = [ - {file = "Pillow-10.0.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:8f06be50669087250f319b706decf69ca71fdecd829091a37cc89398ca4dc17a"}, - {file = "Pillow-10.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:50bd5f1ebafe9362ad622072a1d2f5850ecfa44303531ff14353a4059113b12d"}, - {file = "Pillow-10.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6a90167bcca1216606223a05e2cf991bb25b14695c518bc65639463d7db722d"}, - {file = "Pillow-10.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f11c9102c56ffb9ca87134bd025a43d2aba3f1155f508eff88f694b33a9c6d19"}, - {file = "Pillow-10.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:186f7e04248103482ea6354af6d5bcedb62941ee08f7f788a1c7707bc720c66f"}, - {file = "Pillow-10.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:0462b1496505a3462d0f35dc1c4d7b54069747d65d00ef48e736acda2c8cbdff"}, - {file = "Pillow-10.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d889b53ae2f030f756e61a7bff13684dcd77e9af8b10c6048fb2c559d6ed6eaf"}, - {file = "Pillow-10.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:552912dbca585b74d75279a7570dd29fa43b6d93594abb494ebb31ac19ace6bd"}, - {file = "Pillow-10.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:787bb0169d2385a798888e1122c980c6eff26bf941a8ea79747d35d8f9210ca0"}, - {file = "Pillow-10.0.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:fd2a5403a75b54661182b75ec6132437a181209b901446ee5724b589af8edef1"}, - {file = "Pillow-10.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2d7e91b4379f7a76b31c2dda84ab9e20c6220488e50f7822e59dac36b0cd92b1"}, - {file = "Pillow-10.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19e9adb3f22d4c416e7cd79b01375b17159d6990003633ff1d8377e21b7f1b21"}, - {file = "Pillow-10.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93139acd8109edcdeffd85e3af8ae7d88b258b3a1e13a038f542b79b6d255c54"}, - {file = "Pillow-10.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:92a23b0431941a33242b1f0ce6c88a952e09feeea9af4e8be48236a68ffe2205"}, - {file = "Pillow-10.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:cbe68deb8580462ca0d9eb56a81912f59eb4542e1ef8f987405e35a0179f4ea2"}, - {file = "Pillow-10.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:522ff4ac3aaf839242c6f4e5b406634bfea002469656ae8358644fc6c4856a3b"}, - {file = "Pillow-10.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:84efb46e8d881bb06b35d1d541aa87f574b58e87f781cbba8d200daa835b42e1"}, - {file = "Pillow-10.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:898f1d306298ff40dc1b9ca24824f0488f6f039bc0e25cfb549d3195ffa17088"}, - {file = "Pillow-10.0.1-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:bcf1207e2f2385a576832af02702de104be71301c2696d0012b1b93fe34aaa5b"}, - {file = "Pillow-10.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5d6c9049c6274c1bb565021367431ad04481ebb54872edecfcd6088d27edd6ed"}, - {file = "Pillow-10.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28444cb6ad49726127d6b340217f0627abc8732f1194fd5352dec5e6a0105635"}, - {file = "Pillow-10.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de596695a75496deb3b499c8c4f8e60376e0516e1a774e7bc046f0f48cd620ad"}, - {file = "Pillow-10.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:2872f2d7846cf39b3dbff64bc1104cc48c76145854256451d33c5faa55c04d1a"}, - {file = "Pillow-10.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4ce90f8a24e1c15465048959f1e94309dfef93af272633e8f37361b824532e91"}, - {file = "Pillow-10.0.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ee7810cf7c83fa227ba9125de6084e5e8b08c59038a7b2c9045ef4dde61663b4"}, - {file = "Pillow-10.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b1be1c872b9b5fcc229adeadbeb51422a9633abd847c0ff87dc4ef9bb184ae08"}, - {file = "Pillow-10.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:98533fd7fa764e5f85eebe56c8e4094db912ccbe6fbf3a58778d543cadd0db08"}, - {file = "Pillow-10.0.1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:764d2c0daf9c4d40ad12fbc0abd5da3af7f8aa11daf87e4fa1b834000f4b6b0a"}, - {file = "Pillow-10.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fcb59711009b0168d6ee0bd8fb5eb259c4ab1717b2f538bbf36bacf207ef7a68"}, - {file = "Pillow-10.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:697a06bdcedd473b35e50a7e7506b1d8ceb832dc238a336bd6f4f5aa91a4b500"}, - {file = "Pillow-10.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f665d1e6474af9f9da5e86c2a3a2d2d6204e04d5af9c06b9d42afa6ebde3f21"}, - {file = "Pillow-10.0.1-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:2fa6dd2661838c66f1a5473f3b49ab610c98a128fc08afbe81b91a1f0bf8c51d"}, - {file = "Pillow-10.0.1-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:3a04359f308ebee571a3127fdb1bd01f88ba6f6fb6d087f8dd2e0d9bff43f2a7"}, - {file = "Pillow-10.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:723bd25051454cea9990203405fa6b74e043ea76d4968166dfd2569b0210886a"}, - {file = "Pillow-10.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:71671503e3015da1b50bd18951e2f9daf5b6ffe36d16f1eb2c45711a301521a7"}, - {file = "Pillow-10.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:44e7e4587392953e5e251190a964675f61e4dae88d1e6edbe9f36d6243547ff3"}, - {file = "Pillow-10.0.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:3855447d98cced8670aaa63683808df905e956f00348732448b5a6df67ee5849"}, - {file = "Pillow-10.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ed2d9c0704f2dc4fa980b99d565c0c9a543fe5101c25b3d60488b8ba80f0cce1"}, - {file = "Pillow-10.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5bb289bb835f9fe1a1e9300d011eef4d69661bb9b34d5e196e5e82c4cb09b37"}, - {file = "Pillow-10.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a0d3e54ab1df9df51b914b2233cf779a5a10dfd1ce339d0421748232cea9876"}, - {file = "Pillow-10.0.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:2cc6b86ece42a11f16f55fe8903595eff2b25e0358dec635d0a701ac9586588f"}, - {file = "Pillow-10.0.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:ca26ba5767888c84bf5a0c1a32f069e8204ce8c21d00a49c90dabeba00ce0145"}, - {file = "Pillow-10.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f0b4b06da13275bc02adfeb82643c4a6385bd08d26f03068c2796f60d125f6f2"}, - {file = "Pillow-10.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bc2e3069569ea9dbe88d6b8ea38f439a6aad8f6e7a6283a38edf61ddefb3a9bf"}, - {file = "Pillow-10.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:8b451d6ead6e3500b6ce5c7916a43d8d8d25ad74b9102a629baccc0808c54971"}, - {file = "Pillow-10.0.1-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:32bec7423cdf25c9038fef614a853c9d25c07590e1a870ed471f47fb80b244db"}, - {file = "Pillow-10.0.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7cf63d2c6928b51d35dfdbda6f2c1fddbe51a6bc4a9d4ee6ea0e11670dd981e"}, - {file = "Pillow-10.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f6d3d4c905e26354e8f9d82548475c46d8e0889538cb0657aa9c6f0872a37aa4"}, - {file = "Pillow-10.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:847e8d1017c741c735d3cd1883fa7b03ded4f825a6e5fcb9378fd813edee995f"}, - {file = "Pillow-10.0.1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:7f771e7219ff04b79e231d099c0a28ed83aa82af91fd5fa9fdb28f5b8d5addaf"}, - {file = "Pillow-10.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:459307cacdd4138edee3875bbe22a2492519e060660eaf378ba3b405d1c66317"}, - {file = "Pillow-10.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b059ac2c4c7a97daafa7dc850b43b2d3667def858a4f112d1aa082e5c3d6cf7d"}, - {file = "Pillow-10.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d6caf3cd38449ec3cd8a68b375e0c6fe4b6fd04edb6c9766b55ef84a6e8ddf2d"}, - {file = "Pillow-10.0.1.tar.gz", hash = "sha256:d72967b06be9300fed5cfbc8b5bafceec48bf7cdc7dab66b1d2549035287191d"}, + {file = "pillow-10.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:7823bdd049099efa16e4246bdf15e5a13dbb18a51b68fa06d6c1d4d8b99a796e"}, + {file = "pillow-10.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:83b2021f2ade7d1ed556bc50a399127d7fb245e725aa0113ebd05cfe88aaf588"}, + {file = "pillow-10.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fad5ff2f13d69b7e74ce5b4ecd12cc0ec530fcee76356cac6742785ff71c452"}, + {file = "pillow-10.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da2b52b37dad6d9ec64e653637a096905b258d2fc2b984c41ae7d08b938a67e4"}, + {file = "pillow-10.2.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:47c0995fc4e7f79b5cfcab1fc437ff2890b770440f7696a3ba065ee0fd496563"}, + {file = "pillow-10.2.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:322bdf3c9b556e9ffb18f93462e5f749d3444ce081290352c6070d014c93feb2"}, + {file = "pillow-10.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:51f1a1bffc50e2e9492e87d8e09a17c5eea8409cda8d3f277eb6edc82813c17c"}, + {file = "pillow-10.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69ffdd6120a4737710a9eee73e1d2e37db89b620f702754b8f6e62594471dee0"}, + {file = "pillow-10.2.0-cp310-cp310-win32.whl", hash = "sha256:c6dafac9e0f2b3c78df97e79af707cdc5ef8e88208d686a4847bab8266870023"}, + {file = "pillow-10.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:aebb6044806f2e16ecc07b2a2637ee1ef67a11840a66752751714a0d924adf72"}, + {file = "pillow-10.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:7049e301399273a0136ff39b84c3678e314f2158f50f517bc50285fb5ec847ad"}, + {file = "pillow-10.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35bb52c37f256f662abdfa49d2dfa6ce5d93281d323a9af377a120e89a9eafb5"}, + {file = "pillow-10.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c23f307202661071d94b5e384e1e1dc7dfb972a28a2310e4ee16103e66ddb67"}, + {file = "pillow-10.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:773efe0603db30c281521a7c0214cad7836c03b8ccff897beae9b47c0b657d61"}, + {file = "pillow-10.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11fa2e5984b949b0dd6d7a94d967743d87c577ff0b83392f17cb3990d0d2fd6e"}, + {file = "pillow-10.2.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:716d30ed977be8b37d3ef185fecb9e5a1d62d110dfbdcd1e2a122ab46fddb03f"}, + {file = "pillow-10.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a086c2af425c5f62a65e12fbf385f7c9fcb8f107d0849dba5839461a129cf311"}, + {file = "pillow-10.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c8de2789052ed501dd829e9cae8d3dcce7acb4777ea4a479c14521c942d395b1"}, + {file = "pillow-10.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:609448742444d9290fd687940ac0b57fb35e6fd92bdb65386e08e99af60bf757"}, + {file = "pillow-10.2.0-cp311-cp311-win32.whl", hash = "sha256:823ef7a27cf86df6597fa0671066c1b596f69eba53efa3d1e1cb8b30f3533068"}, + {file = "pillow-10.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:1da3b2703afd040cf65ec97efea81cfba59cdbed9c11d8efc5ab09df9509fc56"}, + {file = "pillow-10.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:edca80cbfb2b68d7b56930b84a0e45ae1694aeba0541f798e908a49d66b837f1"}, + {file = "pillow-10.2.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:1b5e1b74d1bd1b78bc3477528919414874748dd363e6272efd5abf7654e68bef"}, + {file = "pillow-10.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0eae2073305f451d8ecacb5474997c08569fb4eb4ac231ffa4ad7d342fdc25ac"}, + {file = "pillow-10.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7c2286c23cd350b80d2fc9d424fc797575fb16f854b831d16fd47ceec078f2c"}, + {file = "pillow-10.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e23412b5c41e58cec602f1135c57dfcf15482013ce6e5f093a86db69646a5aa"}, + {file = "pillow-10.2.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:52a50aa3fb3acb9cf7213573ef55d31d6eca37f5709c69e6858fe3bc04a5c2a2"}, + {file = "pillow-10.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:127cee571038f252a552760076407f9cff79761c3d436a12af6000cd182a9d04"}, + {file = "pillow-10.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:8d12251f02d69d8310b046e82572ed486685c38f02176bd08baf216746eb947f"}, + {file = "pillow-10.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:54f1852cd531aa981bc0965b7d609f5f6cc8ce8c41b1139f6ed6b3c54ab82bfb"}, + {file = "pillow-10.2.0-cp312-cp312-win32.whl", hash = "sha256:257d8788df5ca62c980314053197f4d46eefedf4e6175bc9412f14412ec4ea2f"}, + {file = "pillow-10.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:154e939c5f0053a383de4fd3d3da48d9427a7e985f58af8e94d0b3c9fcfcf4f9"}, + {file = "pillow-10.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:f379abd2f1e3dddb2b61bc67977a6b5a0a3f7485538bcc6f39ec76163891ee48"}, + {file = "pillow-10.2.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8373c6c251f7ef8bda6675dd6d2b3a0fcc31edf1201266b5cf608b62a37407f9"}, + {file = "pillow-10.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:870ea1ada0899fd0b79643990809323b389d4d1d46c192f97342eeb6ee0b8483"}, + {file = "pillow-10.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4b6b1e20608493548b1f32bce8cca185bf0480983890403d3b8753e44077129"}, + {file = "pillow-10.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3031709084b6e7852d00479fd1d310b07d0ba82765f973b543c8af5061cf990e"}, + {file = "pillow-10.2.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:3ff074fc97dd4e80543a3e91f69d58889baf2002b6be64347ea8cf5533188213"}, + {file = "pillow-10.2.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:cb4c38abeef13c61d6916f264d4845fab99d7b711be96c326b84df9e3e0ff62d"}, + {file = "pillow-10.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b1b3020d90c2d8e1dae29cf3ce54f8094f7938460fb5ce8bc5c01450b01fbaf6"}, + {file = "pillow-10.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:170aeb00224ab3dc54230c797f8404507240dd868cf52066f66a41b33169bdbe"}, + {file = "pillow-10.2.0-cp38-cp38-win32.whl", hash = "sha256:c4225f5220f46b2fde568c74fca27ae9771536c2e29d7c04f4fb62c83275ac4e"}, + {file = "pillow-10.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:0689b5a8c5288bc0504d9fcee48f61a6a586b9b98514d7d29b840143d6734f39"}, + {file = "pillow-10.2.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:b792a349405fbc0163190fde0dc7b3fef3c9268292586cf5645598b48e63dc67"}, + {file = "pillow-10.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c570f24be1e468e3f0ce7ef56a89a60f0e05b30a3669a459e419c6eac2c35364"}, + {file = "pillow-10.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8ecd059fdaf60c1963c58ceb8997b32e9dc1b911f5da5307aab614f1ce5c2fb"}, + {file = "pillow-10.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c365fd1703040de1ec284b176d6af5abe21b427cb3a5ff68e0759e1e313a5e7e"}, + {file = "pillow-10.2.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:70c61d4c475835a19b3a5aa42492409878bbca7438554a1f89d20d58a7c75c01"}, + {file = "pillow-10.2.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b6f491cdf80ae540738859d9766783e3b3c8e5bd37f5dfa0b76abdecc5081f13"}, + {file = "pillow-10.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d189550615b4948f45252d7f005e53c2040cea1af5b60d6f79491a6e147eef7"}, + {file = "pillow-10.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:49d9ba1ed0ef3e061088cd1e7538a0759aab559e2e0a80a36f9fd9d8c0c21591"}, + {file = "pillow-10.2.0-cp39-cp39-win32.whl", hash = "sha256:babf5acfede515f176833ed6028754cbcd0d206f7f614ea3447d67c33be12516"}, + {file = "pillow-10.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:0304004f8067386b477d20a518b50f3fa658a28d44e4116970abfcd94fac34a8"}, + {file = "pillow-10.2.0-cp39-cp39-win_arm64.whl", hash = "sha256:0fb3e7fc88a14eacd303e90481ad983fd5b69c761e9e6ef94c983f91025da869"}, + {file = "pillow-10.2.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:322209c642aabdd6207517e9739c704dc9f9db943015535783239022002f054a"}, + {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eedd52442c0a5ff4f887fab0c1c0bb164d8635b32c894bc1faf4c618dd89df2"}, + {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb28c753fd5eb3dd859b4ee95de66cc62af91bcff5db5f2571d32a520baf1f04"}, + {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:33870dc4653c5017bf4c8873e5488d8f8d5f8935e2f1fb9a2208c47cdd66efd2"}, + {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3c31822339516fb3c82d03f30e22b1d038da87ef27b6a78c9549888f8ceda39a"}, + {file = "pillow-10.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a2b56ba36e05f973d450582fb015594aaa78834fefe8dfb8fcd79b93e64ba4c6"}, + {file = "pillow-10.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d8e6aeb9201e655354b3ad049cb77d19813ad4ece0df1249d3c793de3774f8c7"}, + {file = "pillow-10.2.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:2247178effb34a77c11c0e8ac355c7a741ceca0a732b27bf11e747bbc950722f"}, + {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15587643b9e5eb26c48e49a7b33659790d28f190fc514a322d55da2fb5c2950e"}, + {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753cd8f2086b2b80180d9b3010dd4ed147efc167c90d3bf593fe2af21265e5a5"}, + {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7c8f97e8e7a9009bcacbe3766a36175056c12f9a44e6e6f2d5caad06dcfbf03b"}, + {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d1b35bcd6c5543b9cb547dee3150c93008f8dd0f1fef78fc0cd2b141c5baf58a"}, + {file = "pillow-10.2.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fe4c15f6c9285dc54ce6553a3ce908ed37c8f3825b5a51a15c91442bb955b868"}, + {file = "pillow-10.2.0.tar.gz", hash = "sha256:e87f0b2c78157e12d7686b27d63c070fd65d994e8ddae6f328e0dcf4a0cd007e"}, ] [package.extras] docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-removed-in", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] +typing = ["typing-extensions"] +xmp = ["defusedxml"] [[package]] name = "platformdirs" From 3d7d7b721fa462d5380d04fe9dde6062f3f47abe Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Wed, 29 Nov 2023 21:50:03 +0100 Subject: [PATCH 09/24] Use correct parse object --- tidalapi/artist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tidalapi/artist.py b/tidalapi/artist.py index 1b4ed47..6c1660d 100644 --- a/tidalapi/artist.py +++ b/tidalapi/artist.py @@ -177,7 +177,7 @@ def get_similar(self) -> List["Artist"]: return cast( List["Artist"], self.request.map_request( - f"artists/{self.id}/similar", parse=self.parse_artist + f"artists/{self.id}/similar", parse=self.session.parse_artist ), ) From e058766c5c6d5edfa16fcec636bcead1c55987f3 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Fri, 26 Jan 2024 16:02:26 +0100 Subject: [PATCH 10/24] Add OAuth file login load/save functionality. --- tidalapi/session.py | 182 +++++++++++++------------------------------- 1 file changed, 53 insertions(+), 129 deletions(-) diff --git a/tidalapi/session.py b/tidalapi/session.py index 09f0fb1..415bf64 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -21,12 +21,12 @@ import base64 import concurrent.futures import datetime -import hashlib import logging -import os +import json import random import time import uuid +from pathlib import Path from dataclasses import dataclass from enum import Enum from typing import ( @@ -42,7 +42,7 @@ cast, no_type_check, ) -from urllib.parse import parse_qs, urlencode, urljoin, urlsplit +from urllib.parse import urljoin import requests @@ -95,8 +95,6 @@ class Config: """ api_location: str = "https://api.tidal.com/v1/" - api_oauth2_token: str = "https://auth.tidal.com/v1/oauth2/token" - api_pkce_auth: str = "https://login.tidal.com/authorize" api_token: str client_id: str client_secret: str @@ -105,12 +103,6 @@ class Config: quality: str video_quality: str video_url: str = "https://resources.tidal.com/videos/%s/%ix%i.mp4" - # Necessary for PKCE authorization only - client_unique_key: str - code_verifier: str - code_challenge: str - pkce_uri_redirect: str = "https://tidal.com/android/login/auth" - client_id_pkce: str @no_type_check def __init__( @@ -190,19 +182,6 @@ def __init__( self.client_id = "".join(self.client_id) self.client_secret = self.client_id self.client_id = self.api_token - # PKCE Authorization. We will keep the former `client_id` as a fallback / will only be used for non PCKE - # authorizations. - self.client_unique_key = format(random.getrandbits(64), "02x") - self.code_verifier = base64.urlsafe_b64encode(os.urandom(32))[:-1].decode( - "utf-8" - ) - self.code_challenge = base64.urlsafe_b64encode( - hashlib.sha256(self.code_verifier.encode("utf-8")).digest() - )[:-1].decode("utf-8") - self.client_id_pkce = base64.b64decode( - base64.b64decode(b"TmtKRVUxSmtjRXM=") - + base64.b64decode(b"NWFIRkZRbFJuVlE9PQ==") - ).decode("utf-8") class Case(Enum): @@ -378,7 +357,7 @@ def load_oauth_session( :param refresh_token: (Optional) A refresh token that lets you get a new access token after it has expired :param expiry_time: (Optional) The datetime the access token will expire - :return: True if we believe the login was successful, otherwise false. + :return: True if we believe the log in was successful, otherwise false. """ self.token_type = token_type self.access_token = access_token @@ -422,101 +401,33 @@ def login(self, username: str, password: str) -> bool: self.user = user.User(self, user_id=body["userId"]).factory() return True - def login_pkce(self, fn_print: Callable[[str], None] = print) -> None: - """Login handler for PKCE based authentication. This is the only way how to get - access to HiRes (Up to 24-bit, 192 kHz) FLAC files. - - This handler will ask you to follow a URL, process with the login in the browser - and copy & paste the URL of the redirected browser page. - - :param fn_print: A function which will be called to print the instructions, - defaults to `print()`. - :type fn_print: Callable, optional - :return: - """ - # Get login url - url_login: str = self.pkce_login_url() - - fn_print("READ CAREFULLY!") - fn_print("---------------") - fn_print( - "You need to open this link and login with your username and password. " - "Afterwards you will be redirected to an 'Oops' page. " - "To complete the login you must copy the URL from this 'Oops' page and paste it to the input field." - ) - fn_print(url_login) - - # Get redirect URL from user input. - url_redirect: str = input("Paste 'Ooops' page URL here and press :") - # Query for auth tokens - json: dict[str, Union[str, int]] = self.pkce_get_auth_token(url_redirect) - - # Parse and set tokens. - self.process_auth_token(json) + def login_oauth_file(self, oauth_file: Path) -> bool: + """Logs in to the TIDAL api using an existing OAuth session file. + If no OAuth session json file exists, a new one will be created after successful login - def pkce_login_url(self) -> str: - """Returns the Login-URL to login via web browser. - - :return: The URL the user has to use for login. - :rtype: str - """ - params: request.Params = { - "response_type": "code", - "redirect_uri": self.config.pkce_uri_redirect, - "client_id": self.config.client_id_pkce, - "lang": "EN", - "appMode": "android", - "client_unique_key": self.config.client_unique_key, - "code_challenge": self.config.code_challenge, - "code_challenge_method": "S256", - "restrict_signup": "true", - } - - return self.config.api_pkce_auth + "?" + urlencode(params) - - def pkce_get_auth_token(self, url_redirect: str) -> dict[str, Union[str, int]]: - """Parses the redirect url to extract access and refresh tokens. - - :param url_redirect: URL of the 'Ooops' page, where the user was redirected to - after login. - :type url_redirect: str - :return: A parsed JSON object with access and refresh tokens and other - information. - :rtype: dict[str, str | int] + :param oauth_file: The OAuth session json file + :return: Returns true if we think the login was successful. """ - # w_usr=WRITE_USR, r_usr=READ_USR_DATA, w_sub=WRITE_SUBSCRIPTION - scope_default: str = "r_usr+w_usr+w_sub" - - # Extract the code parameter from query string - if url_redirect and "https://" in url_redirect: - code: str = parse_qs(urlsplit(url_redirect).query)["code"][0] - else: - raise Exception("The provided redirect url looks wrong: " + url_redirect) - - # Set post data and call the API - data: request.Params = { - "code": code, - "client_id": self.config.client_id_pkce, - "grant_type": "authorization_code", - "redirect_uri": self.config.pkce_uri_redirect, - "scope": scope_default, - "code_verifier": self.config.code_verifier, - "client_unique_key": self.config.client_unique_key, - } - response = self.request_session.post(self.config.api_oauth2_token, data) - - # Check response - if not response.ok: - log.error("Login failed: %s", response.text) - response.raise_for_status() - - # Parse the JSON response. try: - token: dict[str, Union[str, int]] = response.json() - except: - raise Exception("Wrong one-time authorization code", response) - - return token + # attempt to reload existing session from file + with open(oauth_file) as f: + log.info("Loading OAuth session from %s...", oauth_file) + data = json.load(f) + self._load_oauth_session_from_file(**data) + except Exception as e: + log.info("Could not load OAuth session from %s: %s", oauth_file, e) + + if not self.check_login(): + log.info("Creating new OAuth session...") + self.login_oauth_simple() + + if self.check_login(): + log.info("TIDAL Login OK") + self._save_oauth_session_to_file(oauth_file) + return True + else: + log.info("TIDAL Login KO") + return False def login_oauth_simple(self, function: Callable[[str], None] = print) -> None: """Login to TIDAL using a remote link. You can select what function you want to @@ -525,6 +436,7 @@ def login_oauth_simple(self, function: Callable[[str], None] = print) -> None: :param function: The function you want to display the link with :raises: TimeoutError: If the login takes too long """ + login, future = self.login_oauth() text = "Visit https://{0} to log in, the code will expire in {1} seconds" function(text.format(login.verification_uri_complete, login.expires_in)) @@ -542,6 +454,28 @@ def login_oauth(self) -> Tuple[LinkLogin, concurrent.futures.Future[Any]]: login, future = self._login_with_link() return login, future + def _save_oauth_session_to_file(self, oauth_file: Path): + # create a new session + if self.check_login(): + # store current OAuth session + data = {"token_type": {"data": self.token_type}, + "session_id": {"data": self.session_id}, + "access_token": {"data": self.access_token}, + "refresh_token": {"data": self.refresh_token}} + with oauth_file.open("w") as outfile: + json.dump(data, outfile) + self._oauth_saved = True + + def _load_oauth_session_from_file(self, **data): + assert self, "No session loaded" + args = { + "token_type": data.get("token_type", {}).get("data"), + "access_token": data.get("access_token", {}).get("data"), + "refresh_token": data.get("refresh_token", {}).get("data"), + } + + self.load_oauth_session(**args) + def _login_with_link(self) -> Tuple[LinkLogin, concurrent.futures.Future[Any]]: url = "https://auth.tidal.com/v1/oauth2/device_authorization" params = {"client_id": self.config.client_id, "scope": "r_usr w_usr w_sub"} @@ -558,16 +492,6 @@ def _login_with_link(self) -> Tuple[LinkLogin, concurrent.futures.Future[Any]]: def _process_link_login(self, json: JsonObj) -> None: json = self._wait_for_link_login(json) - self.process_auth_token(json) - - def process_auth_token(self, json: dict[str, Union[str, int]]) -> None: - """Parses the authorization response and sets the token values to the specific - variables for further usage. - - :param json: Parsed JSON response after login / authorization. - :type json: dict[str, str | int] - :return: None - """ self.access_token = json["access_token"] self.expiry_time = datetime.datetime.utcnow() + datetime.timedelta( seconds=json["expires_in"] @@ -584,7 +508,7 @@ def _wait_for_link_login(self, json: JsonObj) -> Any: expiry = float(json["expiresIn"]) interval = float(json["interval"]) device_code = json["deviceCode"] - url = self.config.api_oauth2_token + url = "https://auth.tidal.com/v1/oauth2/token" params = { "client_id": self.config.client_id, "client_secret": self.config.client_secret, @@ -613,7 +537,7 @@ def token_refresh(self, refresh_token: str) -> bool: :return: True if we believe the token was successfully refreshed, otherwise False """ - url = self.config.api_oauth2_token + url = "https://auth.tidal.com/v1/oauth2/token" params = { "grant_type": "refresh_token", "refresh_token": refresh_token, From d227800ab79f5db974ddc1f022a177a7e54dde9f Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Fri, 26 Jan 2024 16:02:44 +0100 Subject: [PATCH 11/24] Add misc gitignores (json, csv) --- .gitignore | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c73ccdd..d3de888 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,10 @@ prof/ .venv # MacOS -.DS_Store \ No newline at end of file +.DS_Store + +# OAuth json session files +*tidal-oauth* + +# Misc. csv. files that might be generated when executing examples +*.csv \ No newline at end of file From 33ab53d862b14274dc50bd60589e24a41fcecb84 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Fri, 26 Jan 2024 16:03:05 +0100 Subject: [PATCH 12/24] Added example script for transferring user favourites --- examples/transfer_favorites.py | 148 +++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 examples/transfer_favorites.py diff --git a/examples/transfer_favorites.py b/examples/transfer_favorites.py new file mode 100644 index 0000000..ac35c70 --- /dev/null +++ b/examples/transfer_favorites.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2023- The Tidalapi Developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# +####################### +# transfer_favorites.py +# Use this script to transfer your Tidal favourites from Tidal user A to Tidal user B +####################### +import logging +from pathlib import Path +import csv +import time +import sys + +import tidalapi + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) +logger.addHandler(logging.StreamHandler(sys.stdout)) + +oauth_file1 = Path("tidal-oauth-userA.json") +oauth_file2 = Path("tidal-oauth-userB.json") + + +class TidalSession: + def __init__(self): + self._active_session = tidalapi.Session() + + def get_uid(self): + return self._active_session.user.id + + def get_session(self): + return self._active_session + + +class TidalTransfer: + def __init__(self): + self.session_src = TidalSession() + self.session_dst = TidalSession() + + def export_csv(self, my_tracks, my_albums, my_artists, my_playlists): + logger.info("Exporting user A favorites to csv...") + # save to csv file + with open("fav_tracks.csv", "w") as file: + wr = csv.writer(file, quoting=csv.QUOTE_ALL) + for track in my_tracks: + wr.writerow( + [ + track.id, + track.user_date_added, + track.artist.name, + track.album.name, + ] + ) + with open("fav_albums.csv", "w") as file: + wr = csv.writer(file, quoting=csv.QUOTE_ALL) + for album in my_albums: + wr.writerow( + [album.id, album.user_date_added, album.artist.name, album.name] + ) + with open("fav_artists.csv", "w") as file: + wr = csv.writer(file, quoting=csv.QUOTE_ALL) + for artist in my_artists: + wr.writerow([artist.id, artist.user_date_added, artist.name]) + with open("fav_playlists.csv", "w") as file: + wr = csv.writer(file, quoting=csv.QUOTE_ALL) + for playlist in my_playlists: + wr.writerow( + [playlist.id, playlist.created, playlist.type, playlist.name] + ) + + def do_transfer(self): + # do login for src and dst Tidal account + session_src = self.session_src.get_session() + session_dst = self.session_dst.get_session() + logger.info("Login to user A (source)...") + if not session_src.login_oauth_file(oauth_file1): + logger.error("Login to Tidal user...FAILED!") + exit(1) + logger.info("Login to user B (destination)...") + if not session_dst.login_oauth_file(oauth_file2): + logger.error("Login to Tidal user...FAILED!") + exit(1) + + # get current user favourites (source) + my_tracks = session_src.user.favorites.tracks() + my_albums = session_src.user.favorites.albums() + my_artists = session_src.user.favorites.artists() + my_playlists = session_src.user.playlist_and_favorite_playlists() + # my_mixes = self._active_session.user.mixes() + + # export to csv + self.export_csv(my_tracks, my_albums, my_artists, my_playlists) + + # add favourites to new user + logger.info("Adding favourites to Tidal user B...") + for idx, track in enumerate(my_tracks): + logger.info("Adding track {}/{}".format(idx, len(my_tracks))) + try: + session_dst.user.favorites.add_track(track.id) + time.sleep(0.1) + except: + logger.error("error while adding track {} {}".format(track.id, track.name)) + + for idx, album in enumerate(my_albums): + logger.info("Adding album {}/{}".format(idx, len(my_albums))) + try: + session_dst.user.favorites.add_album(album.id) + time.sleep(0.1) + except: + logger.error("error while adding album {} {}".format(album.id, album.name)) + + for idx, artist in enumerate(my_artists): + logger.info("Adding artist {}/{}".format(idx, len(my_artists))) + try: + session_dst.user.favorites.add_artist(artist.id) + time.sleep(0.1) + except: + logger.error("error while adding artist {} {}".format(artist.id, artist.name)) + + for idx, playlist in enumerate(my_playlists): + logger.info("Adding playlist {}/{}".format(idx, len(my_playlists))) + try: + session_dst.user.favorites.add_playlist(playlist.id) + time.sleep(0.1) + except: + logger.error( + "error while adding playlist {} {}".format( + playlist.id, playlist.name + ) + ) + + +if __name__ == "__main__": + TidalTransfer().do_transfer() From b09f396557e8f894fd9feebbec6bd69e548394dd Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Fri, 26 Jan 2024 16:05:03 +0100 Subject: [PATCH 13/24] Fixed header --- examples/transfer_favorites.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/examples/transfer_favorites.py b/examples/transfer_favorites.py index ac35c70..971171e 100644 --- a/examples/transfer_favorites.py +++ b/examples/transfer_favorites.py @@ -15,10 +15,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . # -####################### -# transfer_favorites.py -# Use this script to transfer your Tidal favourites from Tidal user A to Tidal user B -####################### +"""transfer_favorites.py: Use this script to transfer your Tidal favourites from Tidal user A to Tidal user B""" import logging from pathlib import Path import csv From e211b5e521c2db0e4b19972ae79d10fb094d2f5b Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Fri, 26 Jan 2024 16:09:50 +0100 Subject: [PATCH 14/24] Update/cleanup readme, move example to subdir --- README.rst | 26 +------------------------- examples/simple.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 25 deletions(-) create mode 100644 examples/simple.py diff --git a/README.rst b/README.rst index 257aa5a..9270a31 100644 --- a/README.rst +++ b/README.rst @@ -11,10 +11,6 @@ Unofficial Python API for TIDAL music streaming service. Requires Python 3.9 or higher. -0.7.x Migration guide ---------------------- -The 0.7.x rewrite is now complete, see the `migration guide `_ for dealing with it - Installation ------------ @@ -29,27 +25,7 @@ Install from `PyPI `_ using ``pip``: Example usage ------------- -.. code-block:: python - - import tidalapi - - session = tidalapi.Session() - # Will run until you visit the printed url and link your account - session.login_oauth_simple() - # Override the required playback quality, if necessary - # Note: Set the quality according to your subscription. - # Normal: Quality.low_320k - # HiFi: Quality.high_lossless - # HiFi+ Quality.hi_res_lossless - session.audio_quality = Quality.low_320k - - album = session.album(66236918) - tracks = album.tracks() - for track in tracks: - print(track.name) - for artist in track.artists: - print(' by: ', artist.name) - +For examples on how to use the api, see the `examples` directory. Documentation ------------- diff --git a/examples/simple.py b/examples/simple.py new file mode 100644 index 0000000..5f724c9 --- /dev/null +++ b/examples/simple.py @@ -0,0 +1,19 @@ +import tidalapi +from tidalapi import Quality + +session = tidalapi.Session() +# Will run until you visit the printed url and link your account +session.login_oauth_simple() +# Override the required playback quality, if necessary +# Note: Set the quality according to your subscription. +# Normal: Quality.low_320k +# HiFi: Quality.high_lossless +# HiFi+ Quality.hi_res_lossless +session.audio_quality = Quality.low_320k + +album = session.album(66236918) +tracks = album.tracks() +for track in tracks: + print(track.name) + for artist in track.artists: + print(' by: ', artist.name) \ No newline at end of file From 6173d1247242365640f07b872398dc3fff990bdd Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Fri, 26 Jan 2024 16:13:18 +0100 Subject: [PATCH 15/24] Use oauth file login --- examples/simple.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/examples/simple.py b/examples/simple.py index 5f724c9..4d5e34f 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -1,9 +1,31 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2023- The Tidalapi Developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# +"""simple.py: A simple example script that describes how to get started using tidalapi""" + import tidalapi from tidalapi import Quality +from pathlib import Path + +oauth_file1 = Path("tidal-oauth-user.json") session = tidalapi.Session() # Will run until you visit the printed url and link your account -session.login_oauth_simple() +session.login_oauth_file(oauth_file1) # Override the required playback quality, if necessary # Note: Set the quality according to your subscription. # Normal: Quality.low_320k @@ -11,8 +33,10 @@ # HiFi+ Quality.hi_res_lossless session.audio_quality = Quality.low_320k -album = session.album(66236918) +album = session.album(66236918) # Electric For Life Episode 099 tracks = album.tracks() +print(album.name) +# list album tracks for track in tracks: print(track.name) for artist in track.artists: From dab095b0073c7495f23e0decdd7c1b16d7437147 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Fri, 26 Jan 2024 16:13:42 +0100 Subject: [PATCH 16/24] Use same oauth file path as other examples --- examples/transfer_favorites.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/transfer_favorites.py b/examples/transfer_favorites.py index 971171e..74f75c2 100644 --- a/examples/transfer_favorites.py +++ b/examples/transfer_favorites.py @@ -28,7 +28,7 @@ logger.setLevel(logging.INFO) logger.addHandler(logging.StreamHandler(sys.stdout)) -oauth_file1 = Path("tidal-oauth-userA.json") +oauth_file1 = Path("tidal-oauth-user.json") oauth_file2 = Path("tidal-oauth-userB.json") From 34c05a37c90cc970b1576ad24163c55ccc1f712e Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Fri, 26 Jan 2024 16:15:29 +0100 Subject: [PATCH 17/24] Fix formatting --- tidalapi/session.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tidalapi/session.py b/tidalapi/session.py index 415bf64..e5ba1ed 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -21,14 +21,14 @@ import base64 import concurrent.futures import datetime -import logging import json +import logging import random import time import uuid -from pathlib import Path from dataclasses import dataclass from enum import Enum +from pathlib import Path from typing import ( TYPE_CHECKING, Any, @@ -402,8 +402,8 @@ def login(self, username: str, password: str) -> bool: return True def login_oauth_file(self, oauth_file: Path) -> bool: - """Logs in to the TIDAL api using an existing OAuth session file. - If no OAuth session json file exists, a new one will be created after successful login + """Logs in to the TIDAL api using an existing OAuth session file. If no OAuth + session json file exists, a new one will be created after successful login. :param oauth_file: The OAuth session json file :return: Returns true if we think the login was successful. @@ -458,10 +458,12 @@ def _save_oauth_session_to_file(self, oauth_file: Path): # create a new session if self.check_login(): # store current OAuth session - data = {"token_type": {"data": self.token_type}, - "session_id": {"data": self.session_id}, - "access_token": {"data": self.access_token}, - "refresh_token": {"data": self.refresh_token}} + data = { + "token_type": {"data": self.token_type}, + "session_id": {"data": self.session_id}, + "access_token": {"data": self.access_token}, + "refresh_token": {"data": self.refresh_token}, + } with oauth_file.open("w") as outfile: json.dump(data, outfile) self._oauth_saved = True From b6c2aa2dc38e7dea985645a1fa1dd77124587d4a Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Fri, 26 Jan 2024 18:42:20 +0100 Subject: [PATCH 18/24] Include request response on error. Print as warning. --- tidalapi/request.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tidalapi/request.py b/tidalapi/request.py index 611a2e0..d8c4b4f 100644 --- a/tidalapi/request.py +++ b/tidalapi/request.py @@ -136,7 +136,12 @@ def request( request = self.basic_request(method, path, params, data, headers) log.debug("request: %s", request.request.url) - request.raise_for_status() + try: + request.raise_for_status() + except Exception as e: + print("Got exception", e) + print("Response was", e.response) + print("Response json was", e.response.json()) if request.content: log.debug("response: %s", json.dumps(request.json(), indent=4)) return request From 91226cba85276363386c3a722beb202b6bd782ce Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Fri, 26 Jan 2024 18:43:06 +0100 Subject: [PATCH 19/24] Add path to both v1 and v2 api's --- tidalapi/request.py | 2 +- tidalapi/session.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tidalapi/request.py b/tidalapi/request.py index d8c4b4f..4c6268a 100644 --- a/tidalapi/request.py +++ b/tidalapi/request.py @@ -88,7 +88,7 @@ def basic_request( headers["authorization"] = ( self.session.token_type + " " + self.session.access_token ) - url = urljoin(self.session.config.api_location, path) + url = urljoin(self.session.config.api_v1_location, path) request = self.session.request_session.request( method, url, params=request_params, data=data, headers=headers ) diff --git a/tidalapi/session.py b/tidalapi/session.py index e5ba1ed..765512d 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -26,6 +26,7 @@ import random import time import uuid +import locale from dataclasses import dataclass from enum import Enum from pathlib import Path @@ -94,7 +95,8 @@ class Config: Additionally, num_videos will turn into num_tracks in playlists. """ - api_location: str = "https://api.tidal.com/v1/" + api_v1_location: str = "https://api.tidal.com/v1/" + api_v2_location: str = "https://api.tidal.com/v2/" api_token: str client_id: str client_secret: str @@ -382,7 +384,7 @@ def login(self, username: str, password: str) -> bool: :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_v1_location, "login/username") headers: dict[str, str] = {"X-Tidal-Token": self.config.api_token} payload = { "username": username, From 1858fc1f5eb76d247d2d505fd174bcd6bb7bda06 Mon Sep 17 00:00:00 2001 From: tehkillerbee Date: Fri, 26 Jan 2024 18:45:28 +0100 Subject: [PATCH 20/24] Fix formatting --- tidalapi/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tidalapi/session.py b/tidalapi/session.py index 765512d..c55b590 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -22,11 +22,11 @@ import concurrent.futures import datetime import json +import locale import logging import random import time import uuid -import locale from dataclasses import dataclass from enum import Enum from pathlib import Path From 472a01875c01c584252e8a41ff97a30510cbab61 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Fri, 19 Jan 2024 19:07:07 +0100 Subject: [PATCH 21/24] =?UTF-8?q?=E2=9C=A8=20First=20working=20PKCE=20logi?= =?UTF-8?q?n=20implement.=20HiRes=20downloads=20are=20possible=20now.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tidalapi/session.py | 93 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/tidalapi/session.py b/tidalapi/session.py index c55b590..d5e2bd0 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -21,9 +21,11 @@ import base64 import concurrent.futures import datetime +import hashlib import json import locale import logging +import os import random import time import uuid @@ -43,7 +45,7 @@ cast, no_type_check, ) -from urllib.parse import urljoin +from urllib.parse import urljoin, urlencode, parse_qs, urljoin, urlsplit import requests @@ -431,6 +433,30 @@ def login_oauth_file(self, oauth_file: Path) -> bool: log.info("TIDAL Login KO") return False + def login_pkce(self): + pkce: Pkce = Pkce() + url_login: str = pkce.get_login_url() + + print(url_login) + + url_redirect: str = input('Paste URL:') + + json: dict = pkce.get_auth_token(url_redirect) + + self.access_token = json["access_token"] + self.expiry_time = datetime.datetime.utcnow() + datetime.timedelta( + seconds=json["expires_in"] + ) + self.refresh_token = json["refresh_token"] + self.token_type = json["token_type"] + session = self.request.request("GET", "sessions") + json = session.json() + self.session_id = json["sessionId"] + self.country_code = json["countryCode"] + self.user = user.User(self, user_id=json["userId"]).factory() + + print(json) + def login_oauth_simple(self, function: Callable[[str], None] = print) -> None: """Login to TIDAL using a remote link. You can select what function you want to use to display the link. @@ -807,3 +833,68 @@ def mixes(self) -> page.Page: :return: A list of :class:`.Mix` """ return self.page.get("pages/my_collection_my_mixes") + +class Pkce(object): + def __init__(self): + self.client_unique_key = format(random.getrandbits(64), '02x') + self.code_verifier = base64.urlsafe_b64encode(os.urandom(32))[:-1].decode("utf-8") + self.code_challenge = base64.urlsafe_b64encode(hashlib.sha256(self.code_verifier.encode('utf-8')).digest())[:-1].decode("utf-8") + # TODO: Move to constant file and use it everywhere. + 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.params = { 'response_type': 'code', + 'redirect_uri': 'https://tidal.com/android/login/auth', + 'client_id': '6BDSRdpK9hqEBTgU', + # TODO: Use from config + 'lang': 'DE', + 'appMode': 'android', + 'client_unique_key': self.client_unique_key, + 'code_challenge': self.code_challenge, + 'code_challenge_method': 'S256', + 'restrict_signup': 'true' + } + self.code = None + + def check_response(self, r): + log.info('%s %s' % (r.request.method, r.request.url)) + if not r.ok: + log.error(repr(r)) + try: + log.error('response: %s' % json.dumps(r.json(), indent=4)) + except: + pass + return r + + def get_login_url(self): + """ Returns the Login-URL to login via web browser """ + # TODO: Refactor login url + return urljoin('https://login.tidal.com/', 'authorize') + '?' + urlencode(self.params) + + def get_auth_token(self, url_redirect: str): + """ Using one-time authorization code to get the access and refresh tokens (last step of the login sequence) """ + # TODO: Refactor scopes + DEFAULT_SCOPE = 'r_usr+w_usr+w_sub' # w_usr=WRITE_USR, r_usr=READ_USR_DATA, w_sub=WRITE_SUBSCRIPTION + REFRESH_SCOPE = 'r_usr+w_usr' + + if url_redirect and 'https://' in url_redirect: + self.code = parse_qs(urlsplit(url_redirect).query)["code"][0] + + data = { 'code': self.code, + 'client_id': self.params['client_id'], + 'grant_type': 'authorization_code', + 'redirect_uri': self.params['redirect_uri'], + 'scope': DEFAULT_SCOPE, + 'code_verifier': self.code_verifier, + 'client_unique_key': self.client_unique_key + } + r = requests.post(urljoin('https://auth.tidal.com/v1/oauth2/', 'token'), data=data, headers={'User-Agent': self.user_agent}) + r = self.check_response(r) + + try: + self.token = {} + self.token = r.json() + except: + log.error('Wrong one-time authorization code') + + raise Exception('Wrong one-time authorization code', r) + + return self.token From 7b07328c2032b6353d849a7ec70211a3dbcbda1d Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 23 Jan 2024 09:08:42 +0100 Subject: [PATCH 22/24] =?UTF-8?q?=E2=9C=A8=20Implemented=20PKCE=20authoriz?= =?UTF-8?q?ation=20to=20enable=20HiRes=20FLAC=20downloads.=20Fixes=20tamla?= =?UTF-8?q?nd/python-tidal#188?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tidalapi/session.py | 199 +++++++++++++++++++++++++------------------- 1 file changed, 114 insertions(+), 85 deletions(-) diff --git a/tidalapi/session.py b/tidalapi/session.py index d5e2bd0..c42c498 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -45,7 +45,7 @@ cast, no_type_check, ) -from urllib.parse import urljoin, urlencode, parse_qs, urljoin, urlsplit +from urllib.parse import urlencode, parse_qs, urljoin, urlsplit import requests @@ -97,6 +97,8 @@ class Config: Additionally, num_videos will turn into num_tracks in playlists. """ + api_oauth2_token: str = "https://auth.tidal.com/v1/oauth2/token" + api_pkce_auth: str = "https://login.tidal.com/authorize" api_v1_location: str = "https://api.tidal.com/v1/" api_v2_location: str = "https://api.tidal.com/v2/" api_token: str @@ -107,6 +109,12 @@ class Config: quality: str video_quality: str video_url: str = "https://resources.tidal.com/videos/%s/%ix%i.mp4" + # Necessary for PKCE authorization only + client_unique_key: str + code_verifier: str + code_challenge: str + pkce_uri_redirect: str = "https://tidal.com/android/login/auth" + client_id_pkce: str @no_type_check def __init__( @@ -186,6 +194,16 @@ def __init__( self.client_id = "".join(self.client_id) self.client_secret = self.client_id self.client_id = self.api_token + # PKCE Authorization. We will keep the former `client_id` as a fallback / will only be used for non PCKE + # authorizations. + self.client_unique_key = format(random.getrandbits(64), '02x') + self.code_verifier = base64.urlsafe_b64encode(os.urandom(32))[:-1].decode("utf-8") + self.code_challenge = base64.urlsafe_b64encode( + hashlib.sha256(self.code_verifier.encode('utf-8')).digest() + )[:-1].decode("utf-8") + self.client_id_pkce = base64.b64decode( + base64.b64decode(b'TmtKRVUxSmtjRXM=') + base64.b64decode(b'NWFIRkZRbFJuVlE9PQ==') + ).decode('utf-8') class Case(Enum): @@ -361,7 +379,7 @@ def load_oauth_session( :param refresh_token: (Optional) A refresh token that lets you get a new access token after it has expired :param expiry_time: (Optional) The datetime the access token will expire - :return: True if we believe the log in was successful, otherwise false. + :return: True if we believe the login was successful, otherwise false. """ self.token_type = token_type self.access_token = access_token @@ -433,29 +451,96 @@ def login_oauth_file(self, oauth_file: Path) -> bool: log.info("TIDAL Login KO") return False - def login_pkce(self): - pkce: Pkce = Pkce() - url_login: str = pkce.get_login_url() + def login_pkce(self, fn_print: Callable[[str], None] = print) -> None: + """Login handler for PKCE based authentication. This is the only way how to get access to HiRes + (Up to 24-bit, 192 kHz) FLAC files. - print(url_login) + This handler will ask you to follow a URL, process with the login in the browser and copy & paste the + URL of the redirected browser page. - url_redirect: str = input('Paste URL:') + :param fn_print: A function which will be called to print the instructions, defaults to `print()`. + :type fn_print: Callable, optional + :return: + """ + # Get login url + url_login: str = self._pkce_login_url() - json: dict = pkce.get_auth_token(url_redirect) + fn_print("READ CAREFULLY!") + fn_print("---------------") + fn_print("You need to open this link and login with your username and password. " + "Afterwards you will be redirected to an 'Oops' page. " + "To complete the login you must copy the URL from this 'Oops' page and paste it to the input field.") + fn_print(url_login) - self.access_token = json["access_token"] - self.expiry_time = datetime.datetime.utcnow() + datetime.timedelta( - seconds=json["expires_in"] - ) - self.refresh_token = json["refresh_token"] - self.token_type = json["token_type"] - session = self.request.request("GET", "sessions") - json = session.json() - self.session_id = json["sessionId"] - self.country_code = json["countryCode"] - self.user = user.User(self, user_id=json["userId"]).factory() + # Get redirect URL from user input. + url_redirect: str = input("Paste 'Ooops' page URL here and press :") + # Query for auth tokens + json: dict[str, Union[str, int]] = self._pkce_get_auth_token(url_redirect) + + # Parse and set tokens. + self._process_auth_token(json) - print(json) + def _pkce_login_url(self) -> str: + """ Returns the Login-URL to login via web browser. + + :return: The URL the user has to use for login. + :rtype: str + """ + params: request.Params = { + 'response_type': 'code', + 'redirect_uri': self.config.pkce_uri_redirect, + 'client_id': self.config.client_id_pkce, + 'lang': 'EN', + 'appMode': 'android', + 'client_unique_key': self.config.client_unique_key, + 'code_challenge': self.config.code_challenge, + 'code_challenge_method': 'S256', + 'restrict_signup': 'true' + } + + return self.config.api_pkce_auth + '?' + urlencode(params) + + def _pkce_get_auth_token(self, url_redirect: str) -> dict[str, Union[str, int]]: + """Parses the redirect url to extract access and refresh tokens. + + :param url_redirect: URL of the 'Ooops' page, where the user was redirected to after login. + :type url_redirect: str + :return: A parsed JSON object with access and refresh tokens and other information. + :rtype: dict[str, str | int] + """ + # w_usr=WRITE_USR, r_usr=READ_USR_DATA, w_sub=WRITE_SUBSCRIPTION + scope_default: str = 'r_usr+w_usr+w_sub' + + # Extract the code parameter from query string + if url_redirect and 'https://' in url_redirect: + code: str = parse_qs(urlsplit(url_redirect).query)["code"][0] + else: + raise Exception('The provided redirect url looks wrong: ' + url_redirect) + + # Set post data and call the API + data: request.Params = { + 'code': code, + 'client_id': self.config.client_id_pkce, + 'grant_type': 'authorization_code', + 'redirect_uri': self.config.pkce_uri_redirect, + 'scope': scope_default, + 'code_verifier': self.config.code_verifier, + 'client_unique_key': self.config.client_unique_key + } + response = self.request_session.post(self.config.api_oauth2_token, data) + + # Check response + if not response.ok: + log.error("Login failed: %s", response.text) + response.raise_for_status() + + # Parse the JSON response. + try: + token: dict[str, Union[str, int]] = response.json() + except: + raise Exception('Wrong one-time authorization code', response) + + return token def login_oauth_simple(self, function: Callable[[str], None] = print) -> None: """Login to TIDAL using a remote link. You can select what function you want to @@ -522,6 +607,15 @@ def _login_with_link(self) -> Tuple[LinkLogin, concurrent.futures.Future[Any]]: def _process_link_login(self, json: JsonObj) -> None: json = self._wait_for_link_login(json) + self._process_auth_token(json) + + def _process_auth_token(self, json: dict[str, Union[str, int]]) -> None: + """Parses the authorization response and sets the token values to the specific variables for further usage. + + :param json: Parsed JSON response after login / authorization. + :type json: dict[str, str | int] + :return: None + """ self.access_token = json["access_token"] self.expiry_time = datetime.datetime.utcnow() + datetime.timedelta( seconds=json["expires_in"] @@ -833,68 +927,3 @@ def mixes(self) -> page.Page: :return: A list of :class:`.Mix` """ return self.page.get("pages/my_collection_my_mixes") - -class Pkce(object): - def __init__(self): - self.client_unique_key = format(random.getrandbits(64), '02x') - self.code_verifier = base64.urlsafe_b64encode(os.urandom(32))[:-1].decode("utf-8") - self.code_challenge = base64.urlsafe_b64encode(hashlib.sha256(self.code_verifier.encode('utf-8')).digest())[:-1].decode("utf-8") - # TODO: Move to constant file and use it everywhere. - 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.params = { 'response_type': 'code', - 'redirect_uri': 'https://tidal.com/android/login/auth', - 'client_id': '6BDSRdpK9hqEBTgU', - # TODO: Use from config - 'lang': 'DE', - 'appMode': 'android', - 'client_unique_key': self.client_unique_key, - 'code_challenge': self.code_challenge, - 'code_challenge_method': 'S256', - 'restrict_signup': 'true' - } - self.code = None - - def check_response(self, r): - log.info('%s %s' % (r.request.method, r.request.url)) - if not r.ok: - log.error(repr(r)) - try: - log.error('response: %s' % json.dumps(r.json(), indent=4)) - except: - pass - return r - - def get_login_url(self): - """ Returns the Login-URL to login via web browser """ - # TODO: Refactor login url - return urljoin('https://login.tidal.com/', 'authorize') + '?' + urlencode(self.params) - - def get_auth_token(self, url_redirect: str): - """ Using one-time authorization code to get the access and refresh tokens (last step of the login sequence) """ - # TODO: Refactor scopes - DEFAULT_SCOPE = 'r_usr+w_usr+w_sub' # w_usr=WRITE_USR, r_usr=READ_USR_DATA, w_sub=WRITE_SUBSCRIPTION - REFRESH_SCOPE = 'r_usr+w_usr' - - if url_redirect and 'https://' in url_redirect: - self.code = parse_qs(urlsplit(url_redirect).query)["code"][0] - - data = { 'code': self.code, - 'client_id': self.params['client_id'], - 'grant_type': 'authorization_code', - 'redirect_uri': self.params['redirect_uri'], - 'scope': DEFAULT_SCOPE, - 'code_verifier': self.code_verifier, - 'client_unique_key': self.client_unique_key - } - r = requests.post(urljoin('https://auth.tidal.com/v1/oauth2/', 'token'), data=data, headers={'User-Agent': self.user_agent}) - r = self.check_response(r) - - try: - self.token = {} - self.token = r.json() - except: - log.error('Wrong one-time authorization code') - - raise Exception('Wrong one-time authorization code', r) - - return self.token From c41d1f3c60263f2a147a6cd8a5b27e6ee2c78755 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 23 Jan 2024 09:16:36 +0100 Subject: [PATCH 23/24] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Code=20format.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tidalapi/session.py | 87 +++++++++++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 39 deletions(-) diff --git a/tidalapi/session.py b/tidalapi/session.py index c42c498..646cc94 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -45,7 +45,7 @@ cast, no_type_check, ) -from urllib.parse import urlencode, parse_qs, urljoin, urlsplit +from urllib.parse import parse_qs, urlencode, urljoin, urlsplit import requests @@ -196,14 +196,17 @@ def __init__( self.client_id = self.api_token # PKCE Authorization. We will keep the former `client_id` as a fallback / will only be used for non PCKE # authorizations. - self.client_unique_key = format(random.getrandbits(64), '02x') - self.code_verifier = base64.urlsafe_b64encode(os.urandom(32))[:-1].decode("utf-8") + self.client_unique_key = format(random.getrandbits(64), "02x") + self.code_verifier = base64.urlsafe_b64encode(os.urandom(32))[:-1].decode( + "utf-8" + ) self.code_challenge = base64.urlsafe_b64encode( - hashlib.sha256(self.code_verifier.encode('utf-8')).digest() + hashlib.sha256(self.code_verifier.encode("utf-8")).digest() )[:-1].decode("utf-8") self.client_id_pkce = base64.b64decode( - base64.b64decode(b'TmtKRVUxSmtjRXM=') + base64.b64decode(b'NWFIRkZRbFJuVlE9PQ==') - ).decode('utf-8') + base64.b64decode(b"TmtKRVUxSmtjRXM=") + + base64.b64decode(b"NWFIRkZRbFJuVlE9PQ==") + ).decode("utf-8") class Case(Enum): @@ -452,13 +455,14 @@ def login_oauth_file(self, oauth_file: Path) -> bool: return False def login_pkce(self, fn_print: Callable[[str], None] = print) -> None: - """Login handler for PKCE based authentication. This is the only way how to get access to HiRes - (Up to 24-bit, 192 kHz) FLAC files. + """Login handler for PKCE based authentication. This is the only way how to get + access to HiRes (Up to 24-bit, 192 kHz) FLAC files. - This handler will ask you to follow a URL, process with the login in the browser and copy & paste the - URL of the redirected browser page. + This handler will ask you to follow a URL, process with the login in the browser + and copy & paste the URL of the redirected browser page. - :param fn_print: A function which will be called to print the instructions, defaults to `print()`. + :param fn_print: A function which will be called to print the instructions, + defaults to `print()`. :type fn_print: Callable, optional :return: """ @@ -467,9 +471,11 @@ def login_pkce(self, fn_print: Callable[[str], None] = print) -> None: fn_print("READ CAREFULLY!") fn_print("---------------") - fn_print("You need to open this link and login with your username and password. " - "Afterwards you will be redirected to an 'Oops' page. " - "To complete the login you must copy the URL from this 'Oops' page and paste it to the input field.") + fn_print( + "You need to open this link and login with your username and password. " + "Afterwards you will be redirected to an 'Oops' page. " + "To complete the login you must copy the URL from this 'Oops' page and paste it to the input field." + ) fn_print(url_login) # Get redirect URL from user input. @@ -481,51 +487,53 @@ def login_pkce(self, fn_print: Callable[[str], None] = print) -> None: self._process_auth_token(json) def _pkce_login_url(self) -> str: - """ Returns the Login-URL to login via web browser. + """Returns the Login-URL to login via web browser. :return: The URL the user has to use for login. :rtype: str """ params: request.Params = { - 'response_type': 'code', - 'redirect_uri': self.config.pkce_uri_redirect, - 'client_id': self.config.client_id_pkce, - 'lang': 'EN', - 'appMode': 'android', - 'client_unique_key': self.config.client_unique_key, - 'code_challenge': self.config.code_challenge, - 'code_challenge_method': 'S256', - 'restrict_signup': 'true' + "response_type": "code", + "redirect_uri": self.config.pkce_uri_redirect, + "client_id": self.config.client_id_pkce, + "lang": "EN", + "appMode": "android", + "client_unique_key": self.config.client_unique_key, + "code_challenge": self.config.code_challenge, + "code_challenge_method": "S256", + "restrict_signup": "true", } - return self.config.api_pkce_auth + '?' + urlencode(params) + return self.config.api_pkce_auth + "?" + urlencode(params) def _pkce_get_auth_token(self, url_redirect: str) -> dict[str, Union[str, int]]: """Parses the redirect url to extract access and refresh tokens. - :param url_redirect: URL of the 'Ooops' page, where the user was redirected to after login. + :param url_redirect: URL of the 'Ooops' page, where the user was redirected to + after login. :type url_redirect: str - :return: A parsed JSON object with access and refresh tokens and other information. + :return: A parsed JSON object with access and refresh tokens and other + information. :rtype: dict[str, str | int] """ # w_usr=WRITE_USR, r_usr=READ_USR_DATA, w_sub=WRITE_SUBSCRIPTION - scope_default: str = 'r_usr+w_usr+w_sub' + scope_default: str = "r_usr+w_usr+w_sub" # Extract the code parameter from query string - if url_redirect and 'https://' in url_redirect: + if url_redirect and "https://" in url_redirect: code: str = parse_qs(urlsplit(url_redirect).query)["code"][0] else: - raise Exception('The provided redirect url looks wrong: ' + url_redirect) + raise Exception("The provided redirect url looks wrong: " + url_redirect) # Set post data and call the API data: request.Params = { - 'code': code, - 'client_id': self.config.client_id_pkce, - 'grant_type': 'authorization_code', - 'redirect_uri': self.config.pkce_uri_redirect, - 'scope': scope_default, - 'code_verifier': self.config.code_verifier, - 'client_unique_key': self.config.client_unique_key + "code": code, + "client_id": self.config.client_id_pkce, + "grant_type": "authorization_code", + "redirect_uri": self.config.pkce_uri_redirect, + "scope": scope_default, + "code_verifier": self.config.code_verifier, + "client_unique_key": self.config.client_unique_key, } response = self.request_session.post(self.config.api_oauth2_token, data) @@ -538,7 +546,7 @@ def _pkce_get_auth_token(self, url_redirect: str) -> dict[str, Union[str, int]]: try: token: dict[str, Union[str, int]] = response.json() except: - raise Exception('Wrong one-time authorization code', response) + raise Exception("Wrong one-time authorization code", response) return token @@ -610,7 +618,8 @@ def _process_link_login(self, json: JsonObj) -> None: self._process_auth_token(json) def _process_auth_token(self, json: dict[str, Union[str, int]]) -> None: - """Parses the authorization response and sets the token values to the specific variables for further usage. + """Parses the authorization response and sets the token values to the specific + variables for further usage. :param json: Parsed JSON response after login / authorization. :type json: dict[str, str | int] From 656833c520f258583af2372a52bc33b927ae0092 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Sat, 27 Jan 2024 16:42:26 +0100 Subject: [PATCH 24/24] =?UTF-8?q?=E2=9C=A8=20Added=20PKCE=20secret=20and?= =?UTF-8?q?=20client=20id=20swap.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tidalapi/session.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/tidalapi/session.py b/tidalapi/session.py index 646cc94..ed3eb41 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -207,6 +207,10 @@ def __init__( base64.b64decode(b"TmtKRVUxSmtjRXM=") + base64.b64decode(b"NWFIRkZRbFJuVlE9PQ==") ).decode("utf-8") + self.client_secret_pkce = base64.b64decode( + base64.b64decode(b"ZUdWMVVHMVpOMjVpY0ZvNVNVbGlURUZqVVQ=") + + base64.b64decode(b"a3pjMmhyWVRGV1RtaGxWVUZ4VGpaSlkzTjZhbFJIT0QwPQ==") + ).decode("utf-8") class Case(Enum): @@ -467,7 +471,7 @@ def login_pkce(self, fn_print: Callable[[str], None] = print) -> None: :return: """ # Get login url - url_login: str = self._pkce_login_url() + url_login: str = self.pkce_login_url() fn_print("READ CAREFULLY!") fn_print("---------------") @@ -481,12 +485,19 @@ def login_pkce(self, fn_print: Callable[[str], None] = print) -> None: # Get redirect URL from user input. url_redirect: str = input("Paste 'Ooops' page URL here and press :") # Query for auth tokens - json: dict[str, Union[str, int]] = self._pkce_get_auth_token(url_redirect) + json: dict[str, Union[str, int]] = self.pkce_get_auth_token(url_redirect) # Parse and set tokens. - self._process_auth_token(json) + self.process_auth_token(json) + + # Swap the client_id and secret + #self.client_enable_hires() + + def client_enable_hires(self): + self.config.client_id = self.config.client_id_pkce + self.config.client_secret = self.config.client_secret_pkce - def _pkce_login_url(self) -> str: + def pkce_login_url(self) -> str: """Returns the Login-URL to login via web browser. :return: The URL the user has to use for login. @@ -506,7 +517,7 @@ def _pkce_login_url(self) -> str: return self.config.api_pkce_auth + "?" + urlencode(params) - def _pkce_get_auth_token(self, url_redirect: str) -> dict[str, Union[str, int]]: + def pkce_get_auth_token(self, url_redirect: str) -> dict[str, Union[str, int]]: """Parses the redirect url to extract access and refresh tokens. :param url_redirect: URL of the 'Ooops' page, where the user was redirected to @@ -615,9 +626,9 @@ def _login_with_link(self) -> Tuple[LinkLogin, concurrent.futures.Future[Any]]: def _process_link_login(self, json: JsonObj) -> None: json = self._wait_for_link_login(json) - self._process_auth_token(json) + self.process_auth_token(json) - def _process_auth_token(self, json: dict[str, Union[str, int]]) -> None: + def process_auth_token(self, json: dict[str, Union[str, int]]) -> None: """Parses the authorization response and sets the token values to the specific variables for further usage. @@ -641,7 +652,7 @@ def _wait_for_link_login(self, json: JsonObj) -> Any: expiry = float(json["expiresIn"]) interval = float(json["interval"]) device_code = json["deviceCode"] - url = "https://auth.tidal.com/v1/oauth2/token" + url = self.config.api_oauth2_token params = { "client_id": self.config.client_id, "client_secret": self.config.client_secret, @@ -670,7 +681,7 @@ def token_refresh(self, refresh_token: str) -> bool: :return: True if we believe the token was successfully refreshed, otherwise False """ - url = "https://auth.tidal.com/v1/oauth2/token" + url = self.config.api_oauth2_token params = { "grant_type": "refresh_token", "refresh_token": refresh_token,