From bbc08e45e4a73513afd7c0ea15174c7c756547d0 Mon Sep 17 00:00:00 2001 From: nathom Date: Sat, 27 Mar 2021 16:50:55 -0700 Subject: [PATCH 1/5] started TidalMQAClient class --- streamrip/clients.py | 109 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/streamrip/clients.py b/streamrip/clients.py index 9ef9c65..11b52b9 100644 --- a/streamrip/clients.py +++ b/streamrip/clients.py @@ -35,6 +35,13 @@ region = make_region().configure( arguments={"filename": os.path.join(CACHE_DIR, "clients.db")}, ) +TIDAL_BASE = 'https://api.tidalhifi.com/v1' +TIDAL_AUTH_URL = 'https://auth.tidal.com/v1/oauth2' +TIDAL_CLIENT_INFO = { + 'id': 'aR7gUaTK1ihpXOEP', + 'secret': 'eVWBEkuL2FCjxgjOkR3yK0RYZEbcrMXRc2l8fU3ZCdE=', +} + logger = logging.getLogger(__name__) TRACK_CACHE_TIME = datetime.timedelta(weeks=2).total_seconds() @@ -499,3 +506,105 @@ class TidalClient(ClientInterface): resp = self.session.request("GET", f"tracks/{track_id}/streamUrl", params) resp.raise_for_status() return resp.json() + + +class TidalMQAClient: + def __init__(self): + self.device_code = None + self.user_code = None + self.verification_url = None + self.auth_check_timeout = None + self.auth_check_interval = None + self.user_id = None + self.country_code = None + self.access_token = None + self.refresh_token = None + self.expiry = None + + def login(self): + self.get_device_code() + print(f"{self.user_code=}") + start = time.time() + elapsed = 0 + while elapsed < 600: # change later + elapsed = time.time() - start + status = self.check_auth_status() + if status == 2: + # pending + time.sleep(4) + continue + elif status == 1: + # error checking + raise Exception + elif status == 0: + # successful + break + else: + raise Exception + + def get_device_code(self): + data = { + 'client_id': TIDAL_CLIENT_INFO['id'], + 'scope': 'r_usr+w_usr+w_sub', + } + r = requests.post(f"{TIDAL_AUTH_URL}/device_authorization", data) + r.raise_for_status() + + resp = r.json() + self.device_code = resp['deviceCode'] + self.user_code = resp['userCode'] + self.verification_url = resp['verificationUri'] + self.expiry = resp['expiresIn'] + self.auth_interval = resp['interval'] + + def check_auth_status(self): + data = { + 'client_id': TIDAL_CLIENT_INFO['id'], + 'device_code': self.device_code, + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + 'scope': 'r_usr+w_usr+w_sub', + } + logger.debug(data) + r = requests.post(f"{TIDAL_AUTH_URL}/token", data=data, auth=(TIDAL_CLIENT_INFO['id'], TIDAL_CLIENT_INFO['secret']), verify=False).json() + logger.debug(r) + + if r.get("status"): + if r['status'] != 200: + if r['status'] == 400 and r['sub_status'] == 1002: + return 2 + else: + return 1 + + self.user_id = r['user']['userId'] + self.country_code = r['user']['countryCode'] + self.access_token = r['access_token'] + self.refresh_token = r['refresh_token'] + self.expires_in = r['expires_in'] + return 0 + + def verify_access_token(self, token): + headers = { + 'authorization': f"Bearer {token}", + } + r = requests.get('https://api.tidal.com/v1/sessions', headers=headers).json() + if r.status != 200: + raise Exception("Login failed") + + return True + + def _api_request(self, path, params): + headers = { + 'authorization': f"Bearer {self.access_token}" + } + params['countryCode'] = self.country_code + r = requests.get(f"{TIDAL_BASE}/{path}", headers=headers, params=params).json() + return r + + def get_file_url(self, track_id, quality: int = 7): + params = { + "audioquality": TIDAL_Q_IDS[quality], + "playbackmode": "STREAM", + "assetpresentation": "FULL", + } + resp = self._api_request(f"tracks/{track_id}/playbackinfopostpaywall", params) + return resp From ead14afbbe35ae24dcd82234f8c1de4e243caf6f Mon Sep 17 00:00:00 2001 From: nathom Date: Sat, 27 Mar 2021 21:44:38 -0700 Subject: [PATCH 2/5] Tidal MQA get_file_url working Also formatting --- setup.py | 8 +-- streamrip/cli.py | 34 +++++------ streamrip/clients.py | 127 +++++++++++++++++++++++++++------------- streamrip/converter.py | 1 + streamrip/core.py | 9 ++- streamrip/downloader.py | 4 +- streamrip/utils.py | 12 ++++ 7 files changed, 129 insertions(+), 66 deletions(-) diff --git a/setup.py b/setup.py index 5a1f1aa..df8d292 100644 --- a/setup.py +++ b/setup.py @@ -15,12 +15,12 @@ requirements = read_file("requirements.txt").strip().split() setup( name=pkg_name, version="0.2.2", - author='Nathan', - author_email='nathanthomas707@gmail.com', - keywords='lossless, hi-res, qobuz, tidal, deezer, audio, convert', + author="Nathan", + author_email="nathanthomas707@gmail.com", + keywords="lossless, hi-res, qobuz, tidal, deezer, audio, convert", description="A stream downloader for Qobuz, Tidal, and Deezer.", long_description=read_file("README.md"), - long_description_content_type='text/markdown', + long_description_content_type="text/markdown", install_requires=requirements, py_modules=["streamrip"], entry_points={ diff --git a/streamrip/cli.py b/streamrip/cli.py index 623df0c..393f280 100644 --- a/streamrip/cli.py +++ b/streamrip/cli.py @@ -1,6 +1,6 @@ import logging -from getpass import getpass import os +from getpass import getpass import click @@ -21,15 +21,13 @@ if not os.path.isdir(CACHE_DIR): @click.group(invoke_without_command=True) @click.option("-c", "--convert", metavar="CODEC") @click.option("-u", "--urls", metavar="URLS") -@click.option("-t", "--text", metavar='PATH') +@click.option("-t", "--text", metavar="PATH") @click.option("-nd", "--no-db", is_flag=True) @click.option("--debug", is_flag=True) @click.option("--reset-config", is_flag=True) @click.pass_context def cli(ctx, **kwargs): - """ - - """ + """""" global config global core @@ -53,10 +51,10 @@ def cli(ctx, **kwargs): logger.debug(f"handling {kwargs['urls']}") core.handle_urls(kwargs["urls"]) - if kwargs['text'] is not None: - if os.path.isfile(kwargs['text']): + if kwargs["text"] is not None: + if os.path.isfile(kwargs["text"]): logger.debug(f"Handling {kwargs['text']}") - core.handle_txt(kwargs['text']) + core.handle_txt(kwargs["text"]) else: click.secho(f"Text file {kwargs['text']} does not exist.") @@ -176,24 +174,24 @@ def discover(ctx, **kwargs): @cli.command() -@click.option("-o", "--open", is_flag=True, help='Open the config file') -@click.option("-q", "--qobuz", is_flag=True, help='Set Qobuz credentials') -@click.option("-t", "--tidal", is_flag=True, help='Set Tidal credentials') +@click.option("-o", "--open", is_flag=True, help="Open the config file") +@click.option("-q", "--qobuz", is_flag=True, help="Set Qobuz credentials") +@click.option("-t", "--tidal", is_flag=True, help="Set Tidal credentials") @click.pass_context def config(ctx, **kwargs): """Manage the streamrip configuration.""" - if kwargs['open']: + if kwargs["open"]: click.launch(CONFIG_PATH) - if kwargs['qobuz']: - config.file['qobuz']['email'] = input("Qobuz email: ") - config.file['qobuz']['password'] = getpass() + if kwargs["qobuz"]: + config.file["qobuz"]["email"] = input("Qobuz email: ") + config.file["qobuz"]["password"] = getpass() config.save() - if kwargs['tidal']: - config.file['tidal']['email'] = input("Tidal email: ") - config.file['tidal']['password'] = getpass() + if kwargs["tidal"]: + config.file["tidal"]["email"] = input("Tidal email: ") + config.file["tidal"]["password"] = getpass() config.save() diff --git a/streamrip/clients.py b/streamrip/clients.py index 11b52b9..36d5888 100644 --- a/streamrip/clients.py +++ b/streamrip/clients.py @@ -1,12 +1,14 @@ import datetime -import click +import base64 import hashlib import logging import os import time from abc import ABC, abstractmethod +from pprint import pformat from typing import Generator, Sequence, Tuple, Union +import click import requests import tidalapi from dogpile.cache import make_region @@ -35,11 +37,11 @@ region = make_region().configure( arguments={"filename": os.path.join(CACHE_DIR, "clients.db")}, ) -TIDAL_BASE = 'https://api.tidalhifi.com/v1' -TIDAL_AUTH_URL = 'https://auth.tidal.com/v1/oauth2' +TIDAL_BASE = "https://api.tidalhifi.com/v1" +TIDAL_AUTH_URL = "https://auth.tidal.com/v1/oauth2" TIDAL_CLIENT_INFO = { - 'id': 'aR7gUaTK1ihpXOEP', - 'secret': 'eVWBEkuL2FCjxgjOkR3yK0RYZEbcrMXRc2l8fU3ZCdE=', + "id": "aR7gUaTK1ihpXOEP", + "secret": "eVWBEkuL2FCjxgjOkR3yK0RYZEbcrMXRc2l8fU3ZCdE=", } logger = logging.getLogger(__name__) @@ -130,7 +132,7 @@ class QobuzClient(ClientInterface): :type pwd: str :param kwargs: app_id: str, secrets: list, return_secrets: bool """ - click.secho(f"Logging into {self.source}", fg='green') + click.secho(f"Logging into {self.source}", fg="green") if self.logged_in: logger.debug("Already logged in") return @@ -430,7 +432,7 @@ class TidalClient(ClientInterface): self.logged_in = False def login(self, email: str, pwd: str): - click.secho(f"Logging into {self.source}", fg='green') + click.secho(f"Logging into {self.source}", fg="green") if self.logged_in: return @@ -544,62 +546,103 @@ class TidalMQAClient: def get_device_code(self): data = { - 'client_id': TIDAL_CLIENT_INFO['id'], - 'scope': 'r_usr+w_usr+w_sub', + "client_id": TIDAL_CLIENT_INFO["id"], + "scope": "r_usr+w_usr+w_sub", } - r = requests.post(f"{TIDAL_AUTH_URL}/device_authorization", data) - r.raise_for_status() + resp = self._api_post(f"{TIDAL_AUTH_URL}/device_authorization", data) - resp = r.json() - self.device_code = resp['deviceCode'] - self.user_code = resp['userCode'] - self.verification_url = resp['verificationUri'] - self.expiry = resp['expiresIn'] - self.auth_interval = resp['interval'] + if 'status' in resp and resp['status'] != 200: + raise Exception(f"Device authorization failed {resp}") + + logger.debug(pformat(resp)) + self.device_code = resp["deviceCode"] + self.user_code = resp["userCode"] + self.verification_url = resp["verificationUri"] + self.user_code_expiry = resp["expiresIn"] + self.auth_interval = resp["interval"] def check_auth_status(self): data = { - 'client_id': TIDAL_CLIENT_INFO['id'], - 'device_code': self.device_code, - 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', - 'scope': 'r_usr+w_usr+w_sub', + "client_id": TIDAL_CLIENT_INFO["id"], + "device_code": self.device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "scope": "r_usr+w_usr+w_sub", } logger.debug(data) - r = requests.post(f"{TIDAL_AUTH_URL}/token", data=data, auth=(TIDAL_CLIENT_INFO['id'], TIDAL_CLIENT_INFO['secret']), verify=False).json() - logger.debug(r) + resp = self._api_post( + f"{TIDAL_AUTH_URL}/token", + data, + (TIDAL_CLIENT_INFO["id"], TIDAL_CLIENT_INFO["secret"]), + ) + logger.debug(resp) - if r.get("status"): - if r['status'] != 200: - if r['status'] == 400 and r['sub_status'] == 1002: - return 2 - else: - return 1 + if resp.get("status", 200) != 200: + if resp["status"] == 400 and resp["sub_status"] == 1002: + return 2 + else: + return 1 - self.user_id = r['user']['userId'] - self.country_code = r['user']['countryCode'] - self.access_token = r['access_token'] - self.refresh_token = r['refresh_token'] - self.expires_in = r['expires_in'] + self.user_id = resp["user"]["userId"] + self.country_code = resp["user"]["countryCode"] + self.access_token = resp["access_token"] + self.refresh_token = resp["refresh_token"] + self.access_token_expiry = resp["expires_in"] return 0 def verify_access_token(self, token): headers = { - 'authorization': f"Bearer {token}", + "authorization": f"Bearer {token}", } - r = requests.get('https://api.tidal.com/v1/sessions', headers=headers).json() + r = requests.get("https://api.tidal.com/v1/sessions", headers=headers).json() if r.status != 200: raise Exception("Login failed") return True - def _api_request(self, path, params): - headers = { - 'authorization': f"Bearer {self.access_token}" + def refresh_access_token(self): + data = { + "client_id": TIDAL_CLIENT_INFO["id"], + "refresh_token": self.refresh_token, + "grant_type": "refresh_token", + "scope": "r_usr+w_usr+w_sub", } - params['countryCode'] = self.country_code + resp = self._api_post( + f"{TIDAL_AUTH_URL}/token", + data, + (TIDAL_CLIENT_INFO["id"], TIDAL_CLIENT_INFO["secret"]), + ) + + if resp.get("status") != 200: + raise Exception("Refresh failed") + + self.user_id = resp["user"]["userId"] + self.country_code = resp["user"]["countryCode"] + self.access_token = resp["access_token"] + self.access_token_expiry = resp["expires_in"] + + def login_by_access_token(self, token, user_id=None): + headers = {"authorization": f"Bearer {token}"} + resp = requests.get("https://api.tidal.com/v1/sessions", headers=headers).json() + if resp.get("status") != 200: + raise Exception("Login failed") + + if str(resp.get("userId")) != str(user_id): + raise Exception(f"User id mismatch {locals()}") + + self.user_id = resp["userId"] + self.country_code = resp["countryCode"] + self.access_token = token + + def _api_request(self, path, params): + headers = {"authorization": f"Bearer {self.access_token}"} + params["countryCode"] = self.country_code r = requests.get(f"{TIDAL_BASE}/{path}", headers=headers, params=params).json() return r + def _api_post(self, url, data, auth=None): + r = requests.post(url, data=data, auth=auth, verify=False).json() + return r + def get_file_url(self, track_id, quality: int = 7): params = { "audioquality": TIDAL_Q_IDS[quality], @@ -607,4 +650,8 @@ class TidalMQAClient: "assetpresentation": "FULL", } resp = self._api_request(f"tracks/{track_id}/playbackinfopostpaywall", params) + manifest = json.loads(base64.b64decode(resp['manifest']).decode("utf-8")) + codec = manifest['codecs'] + file_url = manifest["urls"][0] + enc_key = manifest.get("keyId", "") return resp diff --git a/streamrip/converter.py b/streamrip/converter.py index 263c80b..95a81f6 100644 --- a/streamrip/converter.py +++ b/streamrip/converter.py @@ -4,6 +4,7 @@ import shutil import subprocess from tempfile import gettempdir from typing import Optional + from mutagen.flac import FLAC as FLAC_META from .exceptions import ConversionError diff --git a/streamrip/core.py b/streamrip/core.py index 0d49a52..ffd3327 100644 --- a/streamrip/core.py +++ b/streamrip/core.py @@ -98,8 +98,13 @@ class MusicDL(list): """ for source, url_type, item_id in self.parse_urls(url): if item_id in self.db: - logger.info(f"ID {item_id} already downloaded, use --no-db to override.") - click.secho(f"ID {item_id} already downloaded, use --no-db to override.", fg='magenta') + logger.info( + f"ID {item_id} already downloaded, use --no-db to override." + ) + click.secho( + f"ID {item_id} already downloaded, use --no-db to override.", + fg="magenta", + ) continue self.handle_item(source, url_type, item_id) diff --git a/streamrip/downloader.py b/streamrip/downloader.py index 5d8f7d1..7801a9b 100644 --- a/streamrip/downloader.py +++ b/streamrip/downloader.py @@ -431,7 +431,7 @@ class Track: sampling_rate=kwargs.get("sampling_rate"), remove_source=kwargs.get("remove_source", True), ) - click.secho(f"Converting {self!s}", fg='blue') + click.secho(f"Converting {self!s}", fg="blue") engine.convert() def get(self, *keys, default=None): @@ -1092,7 +1092,7 @@ class Artist(Tracklist): """Send an API call to get album info based on id.""" self.meta = self.client.get(self.id, media_type="artist") # TODO find better fix for this - self.name = self.meta['items'][0]['artist']['name'] + self.name = self.meta["items"][0]["artist"]["name"] self._load_albums() def _load_albums(self): diff --git a/streamrip/utils.py b/streamrip/utils.py index 8779f01..f8d47e6 100644 --- a/streamrip/utils.py +++ b/streamrip/utils.py @@ -7,6 +7,8 @@ from typing import Optional import requests from pathvalidate import sanitize_filename from tqdm import tqdm +from Crypto.Cipher import AES +from Crypto.Util import Counter from .constants import LOG_DIR, TIDAL_COVER_URL from .exceptions import NonStreamable @@ -155,3 +157,13 @@ def init_log( def capitalize(s: str) -> str: return s[0].upper() + s[1:] + + +def decrypt_mqa_file(in_path, out_path, key, nonce): + counter = Counter.new(64, prefix=nonce, initial_value=0) + decryptor = AES.new(key, AES.MODE_CTR, counter=counter) + + with open(in_path, "rb") as enc_file: + dec_bytes = decryptor.decrypt(enc_file.read()) + with open(out_path, "wb") as dec_file: + dec_file.write(dec_bytes) From afb76e530c0f506b4c62c2e53f06f559d67cadbd Mon Sep 17 00:00:00 2001 From: nathom Date: Sun, 28 Mar 2021 14:56:23 -0700 Subject: [PATCH 3/5] misc changes --- .gitignore | 1 + streamrip/clients.py | 11 ++++++----- streamrip/config.py | 10 +++++++--- streamrip/constants.py | 2 +- streamrip/utils.py | 4 ++-- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 03213b0..3fbdd36 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist build test.py /urls.txt +*.flac diff --git a/streamrip/clients.py b/streamrip/clients.py index 36d5888..2f9b16a 100644 --- a/streamrip/clients.py +++ b/streamrip/clients.py @@ -1,5 +1,6 @@ -import datetime import base64 +import json +import datetime import hashlib import logging import os @@ -551,7 +552,7 @@ class TidalMQAClient: } resp = self._api_post(f"{TIDAL_AUTH_URL}/device_authorization", data) - if 'status' in resp and resp['status'] != 200: + if "status" in resp and resp["status"] != 200: raise Exception(f"Device authorization failed {resp}") logger.debug(pformat(resp)) @@ -650,8 +651,8 @@ class TidalMQAClient: "assetpresentation": "FULL", } resp = self._api_request(f"tracks/{track_id}/playbackinfopostpaywall", params) - manifest = json.loads(base64.b64decode(resp['manifest']).decode("utf-8")) - codec = manifest['codecs'] + manifest = json.loads(base64.b64decode(resp["manifest"]).decode("utf-8")) + codec = manifest["codecs"] file_url = manifest["urls"][0] enc_key = manifest.get("keyId", "") - return resp + return manifest diff --git a/streamrip/config.py b/streamrip/config.py index 008ed02..80c4f17 100644 --- a/streamrip/config.py +++ b/streamrip/config.py @@ -32,14 +32,18 @@ class Config: defaults = { "qobuz": { - "enabled": True, "email": None, "password": None, "app_id": "", # Avoid NoneType error "secrets": [], }, - "tidal": {"enabled": True, "email": None, "password": None}, - "deezer": {"enabled": True}, + "tidal": { + "user_id": None, + "country_code": None, + "access_token": None, + "refresh_token": None, + "expires_after": 0, + }, "database": {"enabled": True, "path": None}, "conversion": { "enabled": False, diff --git a/streamrip/constants.py b/streamrip/constants.py index 7270890..db4960b 100644 --- a/streamrip/constants.py +++ b/streamrip/constants.py @@ -8,7 +8,7 @@ APPNAME = "streamrip" CACHE_DIR = click.get_app_dir(APPNAME) CONFIG_DIR = click.get_app_dir(APPNAME) -CONFIG_PATH = os.path.join(CONFIG_DIR, "config.yaml") +CONFIG_PATH = os.path.join(CONFIG_DIR, "configmqa.yaml") LOG_DIR = click.get_app_dir(APPNAME) DB_PATH = os.path.join(LOG_DIR, "downloads.db") diff --git a/streamrip/utils.py b/streamrip/utils.py index f8d47e6..450609b 100644 --- a/streamrip/utils.py +++ b/streamrip/utils.py @@ -5,10 +5,10 @@ from string import Formatter from typing import Optional import requests -from pathvalidate import sanitize_filename -from tqdm import tqdm from Crypto.Cipher import AES from Crypto.Util import Counter +from pathvalidate import sanitize_filename +from tqdm import tqdm from .constants import LOG_DIR, TIDAL_COVER_URL from .exceptions import NonStreamable From 086262e8b7c84e4aa837bce7eba5d5a4beff142e Mon Sep 17 00:00:00 2001 From: nathom Date: Sun, 28 Mar 2021 16:34:10 -0700 Subject: [PATCH 4/5] MQA album working --- .gitignore | 1 + streamrip/clients.py | 85 ++++++++++++++++++++++++++++++++--------- streamrip/config.py | 7 +--- streamrip/downloader.py | 18 ++++++--- streamrip/utils.py | 24 +++++++++++- 5 files changed, 107 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index 3fbdd36..a2f3ca7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ build test.py /urls.txt *.flac +/Downloads diff --git a/streamrip/clients.py b/streamrip/clients.py index 2f9b16a..f1c75f7 100644 --- a/streamrip/clients.py +++ b/streamrip/clients.py @@ -1,12 +1,13 @@ import base64 -import json import datetime import hashlib +import json import logging import os +import sys import time from abc import ABC, abstractmethod -from pprint import pformat +from pprint import pformat, pprint from typing import Generator, Sequence, Tuple, Union import click @@ -511,7 +512,9 @@ class TidalClient(ClientInterface): return resp.json() -class TidalMQAClient: +class TidalMQAClient(ClientInterface): + source = "tidal" + def __init__(self): self.device_code = None self.user_code = None @@ -524,9 +527,32 @@ class TidalMQAClient: self.refresh_token = None self.expiry = None - def login(self): - self.get_device_code() - print(f"{self.user_code=}") + def login( + self, + user_id=None, + country_code=None, + access_token=None, + token_expiry=None, + refresh_token=None, + ): + if access_token is not None: + self.token_expiry = token_expiry + self.refresh_token = refresh_token + self.login_by_access_token(access_token, user_id) + else: + self.login_new_user() + + def get(self, item_id, media_type): + return self._api_get(item_id, media_type) + + def search(self, query, media_type="album"): + raise NotImplementedError + + def login_new_user(self): + login_link = self.get_device_code() + click.secho( + f"Go to {login_link} to log into Tidal within 5 minutes.", fg="blue" + ) start = time.time() elapsed = 0 while elapsed < 600: # change later @@ -558,9 +584,9 @@ class TidalMQAClient: logger.debug(pformat(resp)) self.device_code = resp["deviceCode"] self.user_code = resp["userCode"] - self.verification_url = resp["verificationUri"] self.user_code_expiry = resp["expiresIn"] self.auth_interval = resp["interval"] + return resp["verificationUriComplete"] def check_auth_status(self): data = { @@ -587,7 +613,7 @@ class TidalMQAClient: self.country_code = resp["user"]["countryCode"] self.access_token = resp["access_token"] self.refresh_token = resp["refresh_token"] - self.access_token_expiry = resp["expires_in"] + self.token_expiry = resp["expires_in"] + time.time() return 0 def verify_access_token(self, token): @@ -619,24 +645,48 @@ class TidalMQAClient: self.user_id = resp["user"]["userId"] self.country_code = resp["user"]["countryCode"] self.access_token = resp["access_token"] - self.access_token_expiry = resp["expires_in"] + self.token_expiry = resp["expires_in"] + time.time() def login_by_access_token(self, token, user_id=None): headers = {"authorization": f"Bearer {token}"} resp = requests.get("https://api.tidal.com/v1/sessions", headers=headers).json() - if resp.get("status") != 200: - raise Exception("Login failed") + if resp.get("status", 200) != 200: + raise Exception(f"Login failed {resp=}") if str(resp.get("userId")) != str(user_id): - raise Exception(f"User id mismatch {locals()}") + raise Exception(f"User id mismatch {resp['userId']} v {user_id}") self.user_id = resp["userId"] self.country_code = resp["countryCode"] self.access_token = token - def _api_request(self, path, params): + def get_tokens(self): + return { + k: getattr(self, k) + for k in ( + "user_id", + "country_code", + "access_token", + "refresh_token", + "token_expiry", + ) + } + + def _api_get(self, item_id: str, media_type: str) -> dict: + item = self._api_request(f"{media_type}s/{item_id}") + if media_type in ("playlist", "album"): + resp = self._api_request(f"{media_type}s/{item_id}/items") + item["tracks"] = [item["item"] for item in resp["items"]] + + return item + + def _api_request(self, path, params=None) -> dict: + if params is None: + params = {} + headers = {"authorization": f"Bearer {self.access_token}"} params["countryCode"] = self.country_code + params["limit"] = 100 r = requests.get(f"{TIDAL_BASE}/{path}", headers=headers, params=params).json() return r @@ -652,7 +702,8 @@ class TidalMQAClient: } resp = self._api_request(f"tracks/{track_id}/playbackinfopostpaywall", params) manifest = json.loads(base64.b64decode(resp["manifest"]).decode("utf-8")) - codec = manifest["codecs"] - file_url = manifest["urls"][0] - enc_key = manifest.get("keyId", "") - return manifest + return { + "url": manifest["urls"][0], + "enc_key": manifest.get("keyId", ""), + "codec": manifest["codecs"], + } diff --git a/streamrip/config.py b/streamrip/config.py index 80c4f17..b5fd8f3 100644 --- a/streamrip/config.py +++ b/streamrip/config.py @@ -42,7 +42,7 @@ class Config: "country_code": None, "access_token": None, "refresh_token": None, - "expires_after": 0, + "token_expiry": 0, }, "database": {"enabled": True, "path": None}, "conversion": { @@ -124,10 +124,7 @@ class Config: @property def tidal_creds(self): - return { - "email": self.file["tidal"]["email"], - "pwd": self.file["tidal"]["password"], - } + return self.file["tidal"] @property def qobuz_creds(self): diff --git a/streamrip/downloader.py b/streamrip/downloader.py index 7801a9b..df22388 100644 --- a/streamrip/downloader.py +++ b/streamrip/downloader.py @@ -2,6 +2,7 @@ import logging import os import re import shutil +import sys from abc import ABC, abstractmethod from pprint import pformat, pprint from tempfile import gettempdir @@ -36,6 +37,7 @@ from .utils import ( safe_get, tidal_cover_url, tqdm_download, + decrypt_mqa_file, ) logger = logging.getLogger(__name__) @@ -137,9 +139,9 @@ class Track: @staticmethod def _get_tracklist(resp, source): - if source in ("qobuz", "tidal"): + if source == "qobuz": return resp["tracks"]["items"] - elif source == "deezer": + elif source in ("tidal", "deezer"): return resp["tracks"] raise NotImplementedError(source) @@ -226,7 +228,10 @@ class Track: else: raise InvalidSourceError(self.client.source) - shutil.move(temp_file, self.final_path) + if dl_info.get("enc_key"): + decrypt_mqa_file(temp_file, self.final_path, dl_info['enc_key']) + else: + shutil.move(temp_file, self.final_path) if isinstance(database, MusicDB): database.add(self.id) @@ -288,7 +293,10 @@ class Track: :raises IndexError """ - track = cls._get_tracklist(album, client.source)[pos] + logger.debug(pos) + tracklist = cls._get_tracklist(album, client.source) + logger.debug(len(tracklist)) + track = tracklist[pos] meta = TrackMetadata(album=album, track=track, source=client.source) return cls(client=client, meta=meta, id=track["id"]) @@ -743,7 +751,7 @@ class Album(Tracklist): This uses a classmethod to convert an item into a Track object, which stores the metadata inside a TrackMetadata object. """ - logging.debug("Loading tracks to album") + logging.debug(f"Loading {self.tracktotal} tracks to album") for i in range(self.tracktotal): # append method inherited from superclass list self.append( diff --git a/streamrip/utils.py b/streamrip/utils.py index 450609b..8750d82 100644 --- a/streamrip/utils.py +++ b/streamrip/utils.py @@ -1,4 +1,5 @@ import logging +import base64 import logging.handlers as handlers import os from string import Formatter @@ -159,7 +160,28 @@ def capitalize(s: str) -> str: return s[0].upper() + s[1:] -def decrypt_mqa_file(in_path, out_path, key, nonce): +def decrypt_mqa_file(in_path, out_path, encryption_key): + # Do not change this + master_key = "UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754=" + + # Decode the base64 strings to ascii strings + master_key = base64.b64decode(master_key) + security_token = base64.b64decode(encryption_key) + + # Get the IV from the first 16 bytes of the securityToken + iv = security_token[:16] + encrypted_st = security_token[16:] + + # Initialize decryptor + decryptor = AES.new(master_key, AES.MODE_CBC, iv) + + # Decrypt the security token + decrypted_st = decryptor.decrypt(encrypted_st) + + # Get the audio stream decryption key and nonce from the decrypted security token + key = decrypted_st[:16] + nonce = decrypted_st[16:24] + counter = Counter.new(64, prefix=nonce, initial_value=0) decryptor = AES.new(key, AES.MODE_CTR, counter=counter) From 66a22bf7c37c94cadb368a9b3e0b39e74df40fe7 Mon Sep 17 00:00:00 2001 From: nathom Date: Sun, 28 Mar 2021 20:02:46 -0700 Subject: [PATCH 5/5] Tidal MQA working --- streamrip/clients.py | 84 ++++++++++++++++++++++++++--------------- streamrip/constants.py | 2 +- streamrip/core.py | 30 +++++++++------ streamrip/downloader.py | 4 +- streamrip/utils.py | 2 +- 5 files changed, 75 insertions(+), 47 deletions(-) diff --git a/streamrip/clients.py b/streamrip/clients.py index f1c75f7..f7f6412 100644 --- a/streamrip/clients.py +++ b/streamrip/clients.py @@ -4,14 +4,15 @@ import hashlib import json import logging import os -import sys +# import sys import time from abc import ABC, abstractmethod -from pprint import pformat, pprint +from pprint import pformat # , pprint from typing import Generator, Sequence, Tuple, Union import click import requests +from requests.packages import urllib3 import tidalapi from dogpile.cache import make_region @@ -33,6 +34,9 @@ from .exceptions import ( ) from .spoofbuz import Spoofer +urllib3.disable_warnings() +requests.adapters.DEFAULT_RETRIES = 5 + os.makedirs(CACHE_DIR, exist_ok=True) region = make_region().configure( "dogpile.cache.dbm", @@ -427,6 +431,7 @@ class DeezerClient(ClientInterface): return url +''' class TidalClient(ClientInterface): source = "tidal" @@ -510,12 +515,15 @@ class TidalClient(ClientInterface): resp = self.session.request("GET", f"tracks/{track_id}/streamUrl", params) resp.raise_for_status() return resp.json() +''' -class TidalMQAClient(ClientInterface): +class TidalClient(ClientInterface): source = "tidal" def __init__(self): + self.logged_in = False + self.device_code = None self.user_code = None self.verification_url = None @@ -538,26 +546,54 @@ class TidalMQAClient(ClientInterface): if access_token is not None: self.token_expiry = token_expiry self.refresh_token = refresh_token - self.login_by_access_token(access_token, user_id) + if self.token_expiry - time.time() < 86400: # 1 day + self._refresh_access_token() + else: + self._login_by_access_token(access_token, user_id) else: - self.login_new_user() + self._login_new_user() + + self.logged_in = True + click.secho("Logged into Tidal", fg='green') def get(self, item_id, media_type): return self._api_get(item_id, media_type) - def search(self, query, media_type="album"): - raise NotImplementedError + def search(self, query, media_type="album", limit: int = 100): + params = { + "query": query, + "limit": limit, + } + return self._api_get(f"search/{media_type}s", params=params) + + def get_file_url(self, track_id, quality: int = 7): + params = { + "audioquality": TIDAL_Q_IDS[quality], + "playbackmode": "STREAM", + "assetpresentation": "FULL", + } + resp = self._api_request(f"tracks/{track_id}/playbackinfopostpaywall", params) + manifest = json.loads(base64.b64decode(resp["manifest"]).decode("utf-8")) + return { + "url": manifest["urls"][0], + "enc_key": manifest.get("keyId"), + "codec": manifest["codecs"], + } + + def _login_new_user(self, launch=True): + login_link = f"https://{self._get_device_code()}" - def login_new_user(self): - login_link = self.get_device_code() click.secho( f"Go to {login_link} to log into Tidal within 5 minutes.", fg="blue" ) + if launch: + click.launch(login_link) + start = time.time() elapsed = 0 - while elapsed < 600: # change later + while elapsed < 600: # 5 mins to login elapsed = time.time() - start - status = self.check_auth_status() + status = self._check_auth_status() if status == 2: # pending time.sleep(4) @@ -571,7 +607,7 @@ class TidalMQAClient(ClientInterface): else: raise Exception - def get_device_code(self): + def _get_device_code(self): data = { "client_id": TIDAL_CLIENT_INFO["id"], "scope": "r_usr+w_usr+w_sub", @@ -588,7 +624,7 @@ class TidalMQAClient(ClientInterface): self.auth_interval = resp["interval"] return resp["verificationUriComplete"] - def check_auth_status(self): + def _check_auth_status(self): data = { "client_id": TIDAL_CLIENT_INFO["id"], "device_code": self.device_code, @@ -616,7 +652,7 @@ class TidalMQAClient(ClientInterface): self.token_expiry = resp["expires_in"] + time.time() return 0 - def verify_access_token(self, token): + def _verify_access_token(self, token): headers = { "authorization": f"Bearer {token}", } @@ -626,7 +662,7 @@ class TidalMQAClient(ClientInterface): return True - def refresh_access_token(self): + def _refresh_access_token(self): data = { "client_id": TIDAL_CLIENT_INFO["id"], "refresh_token": self.refresh_token, @@ -639,7 +675,7 @@ class TidalMQAClient(ClientInterface): (TIDAL_CLIENT_INFO["id"], TIDAL_CLIENT_INFO["secret"]), ) - if resp.get("status") != 200: + if resp.get("status", 200) != 200: raise Exception("Refresh failed") self.user_id = resp["user"]["userId"] @@ -647,7 +683,7 @@ class TidalMQAClient(ClientInterface): self.access_token = resp["access_token"] self.token_expiry = resp["expires_in"] + time.time() - def login_by_access_token(self, token, user_id=None): + def _login_by_access_token(self, token, user_id=None): headers = {"authorization": f"Bearer {token}"} resp = requests.get("https://api.tidal.com/v1/sessions", headers=headers).json() if resp.get("status", 200) != 200: @@ -693,17 +729,3 @@ class TidalMQAClient(ClientInterface): def _api_post(self, url, data, auth=None): r = requests.post(url, data=data, auth=auth, verify=False).json() return r - - def get_file_url(self, track_id, quality: int = 7): - params = { - "audioquality": TIDAL_Q_IDS[quality], - "playbackmode": "STREAM", - "assetpresentation": "FULL", - } - resp = self._api_request(f"tracks/{track_id}/playbackinfopostpaywall", params) - manifest = json.loads(base64.b64decode(resp["manifest"]).decode("utf-8")) - return { - "url": manifest["urls"][0], - "enc_key": manifest.get("keyId", ""), - "codec": manifest["codecs"], - } diff --git a/streamrip/constants.py b/streamrip/constants.py index db4960b..7270890 100644 --- a/streamrip/constants.py +++ b/streamrip/constants.py @@ -8,7 +8,7 @@ APPNAME = "streamrip" CACHE_DIR = click.get_app_dir(APPNAME) CONFIG_DIR = click.get_app_dir(APPNAME) -CONFIG_PATH = os.path.join(CONFIG_DIR, "configmqa.yaml") +CONFIG_PATH = os.path.join(CONFIG_DIR, "config.yaml") LOG_DIR = click.get_app_dir(APPNAME) DB_PATH = os.path.join(LOG_DIR, "downloads.db") diff --git a/streamrip/core.py b/streamrip/core.py index ffd3327..e07f5ca 100644 --- a/streamrip/core.py +++ b/streamrip/core.py @@ -63,18 +63,21 @@ class MusicDL(list): :param source: :type source: str """ - click.secho(f"Enter {capitalize(source)} email:", fg="green") - self.config.file[source]["email"] = input() - click.secho( - f"Enter {capitalize(source)} password (will not show on screen):", - fg="green", - ) - self.config.file[source]["password"] = getpass( - prompt="" - ) # does hashing work for tidal? + if source == "qobuz": + click.secho(f"Enter {capitalize(source)} email:", fg="green") + self.config.file[source]["email"] = input() + click.secho( + f"Enter {capitalize(source)} password (will not show on screen):", + fg="green", + ) + self.config.file[source]["password"] = getpass( + prompt="" + ) # does hashing work for tidal? - self.config.save() - click.secho(f'Credentials saved to config file at "{self.config._path}"') + self.config.save() + click.secho(f'Credentials saved to config file at "{self.config._path}"') + else: + raise Exception def assert_creds(self, source: str): assert source in ("qobuz", "tidal", "deezer"), f"Invalid source {source}" @@ -82,7 +85,7 @@ class MusicDL(list): # no login for deezer return - if ( + if source == "qobuz" and ( self.config.file[source]["email"] is None or self.config.file[source]["password"] is None ): @@ -185,6 +188,9 @@ class MusicDL(list): self.config.file["qobuz"]["secrets"], ) = client.get_tokens() self.config.save() + elif client.source == 'tidal': + self.config.file['tidal'] = client.get_tokens() + self.config.save() def parse_urls(self, url: str) -> Tuple[str, str]: """Returns the type of the url and the id. diff --git a/streamrip/downloader.py b/streamrip/downloader.py index df22388..2d5f23b 100644 --- a/streamrip/downloader.py +++ b/streamrip/downloader.py @@ -33,11 +33,11 @@ from .exceptions import ( from .metadata import TrackMetadata from .utils import ( clean_format, + decrypt_mqa_file, quality_id, safe_get, tidal_cover_url, tqdm_download, - decrypt_mqa_file, ) logger = logging.getLogger(__name__) @@ -229,7 +229,7 @@ class Track: raise InvalidSourceError(self.client.source) if dl_info.get("enc_key"): - decrypt_mqa_file(temp_file, self.final_path, dl_info['enc_key']) + decrypt_mqa_file(temp_file, self.final_path, dl_info["enc_key"]) else: shutil.move(temp_file, self.final_path) diff --git a/streamrip/utils.py b/streamrip/utils.py index 8750d82..2d24cba 100644 --- a/streamrip/utils.py +++ b/streamrip/utils.py @@ -1,5 +1,5 @@ -import logging import base64 +import logging import logging.handlers as handlers import os from string import Formatter