MQA album working

This commit is contained in:
nathom 2021-03-28 16:34:10 -07:00
parent afb76e530c
commit 086262e8b7
5 changed files with 107 additions and 28 deletions

1
.gitignore vendored
View file

@ -6,3 +6,4 @@ build
test.py test.py
/urls.txt /urls.txt
*.flac *.flac
/Downloads

View file

@ -1,12 +1,13 @@
import base64 import base64
import json
import datetime import datetime
import hashlib import hashlib
import json
import logging import logging
import os import os
import sys
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pprint import pformat from pprint import pformat, pprint
from typing import Generator, Sequence, Tuple, Union from typing import Generator, Sequence, Tuple, Union
import click import click
@ -511,7 +512,9 @@ class TidalClient(ClientInterface):
return resp.json() return resp.json()
class TidalMQAClient: class TidalMQAClient(ClientInterface):
source = "tidal"
def __init__(self): def __init__(self):
self.device_code = None self.device_code = None
self.user_code = None self.user_code = None
@ -524,9 +527,32 @@ class TidalMQAClient:
self.refresh_token = None self.refresh_token = None
self.expiry = None self.expiry = None
def login(self): def login(
self.get_device_code() self,
print(f"{self.user_code=}") 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() start = time.time()
elapsed = 0 elapsed = 0
while elapsed < 600: # change later while elapsed < 600: # change later
@ -558,9 +584,9 @@ class TidalMQAClient:
logger.debug(pformat(resp)) logger.debug(pformat(resp))
self.device_code = resp["deviceCode"] self.device_code = resp["deviceCode"]
self.user_code = resp["userCode"] self.user_code = resp["userCode"]
self.verification_url = resp["verificationUri"]
self.user_code_expiry = resp["expiresIn"] self.user_code_expiry = resp["expiresIn"]
self.auth_interval = resp["interval"] self.auth_interval = resp["interval"]
return resp["verificationUriComplete"]
def check_auth_status(self): def check_auth_status(self):
data = { data = {
@ -587,7 +613,7 @@ class TidalMQAClient:
self.country_code = resp["user"]["countryCode"] self.country_code = resp["user"]["countryCode"]
self.access_token = resp["access_token"] self.access_token = resp["access_token"]
self.refresh_token = resp["refresh_token"] self.refresh_token = resp["refresh_token"]
self.access_token_expiry = resp["expires_in"] self.token_expiry = resp["expires_in"] + time.time()
return 0 return 0
def verify_access_token(self, token): def verify_access_token(self, token):
@ -619,24 +645,48 @@ class TidalMQAClient:
self.user_id = resp["user"]["userId"] self.user_id = resp["user"]["userId"]
self.country_code = resp["user"]["countryCode"] self.country_code = resp["user"]["countryCode"]
self.access_token = resp["access_token"] 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): def login_by_access_token(self, token, user_id=None):
headers = {"authorization": f"Bearer {token}"} headers = {"authorization": f"Bearer {token}"}
resp = requests.get("https://api.tidal.com/v1/sessions", headers=headers).json() resp = requests.get("https://api.tidal.com/v1/sessions", headers=headers).json()
if resp.get("status") != 200: if resp.get("status", 200) != 200:
raise Exception("Login failed") raise Exception(f"Login failed {resp=}")
if str(resp.get("userId")) != str(user_id): 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.user_id = resp["userId"]
self.country_code = resp["countryCode"] self.country_code = resp["countryCode"]
self.access_token = token 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}"} headers = {"authorization": f"Bearer {self.access_token}"}
params["countryCode"] = self.country_code params["countryCode"] = self.country_code
params["limit"] = 100
r = requests.get(f"{TIDAL_BASE}/{path}", headers=headers, params=params).json() r = requests.get(f"{TIDAL_BASE}/{path}", headers=headers, params=params).json()
return r return r
@ -652,7 +702,8 @@ class TidalMQAClient:
} }
resp = self._api_request(f"tracks/{track_id}/playbackinfopostpaywall", params) resp = self._api_request(f"tracks/{track_id}/playbackinfopostpaywall", params)
manifest = json.loads(base64.b64decode(resp["manifest"]).decode("utf-8")) manifest = json.loads(base64.b64decode(resp["manifest"]).decode("utf-8"))
codec = manifest["codecs"] return {
file_url = manifest["urls"][0] "url": manifest["urls"][0],
enc_key = manifest.get("keyId", "") "enc_key": manifest.get("keyId", ""),
return manifest "codec": manifest["codecs"],
}

View file

@ -42,7 +42,7 @@ class Config:
"country_code": None, "country_code": None,
"access_token": None, "access_token": None,
"refresh_token": None, "refresh_token": None,
"expires_after": 0, "token_expiry": 0,
}, },
"database": {"enabled": True, "path": None}, "database": {"enabled": True, "path": None},
"conversion": { "conversion": {
@ -124,10 +124,7 @@ class Config:
@property @property
def tidal_creds(self): def tidal_creds(self):
return { return self.file["tidal"]
"email": self.file["tidal"]["email"],
"pwd": self.file["tidal"]["password"],
}
@property @property
def qobuz_creds(self): def qobuz_creds(self):

View file

@ -2,6 +2,7 @@ import logging
import os import os
import re import re
import shutil import shutil
import sys
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pprint import pformat, pprint from pprint import pformat, pprint
from tempfile import gettempdir from tempfile import gettempdir
@ -36,6 +37,7 @@ from .utils import (
safe_get, safe_get,
tidal_cover_url, tidal_cover_url,
tqdm_download, tqdm_download,
decrypt_mqa_file,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -137,9 +139,9 @@ class Track:
@staticmethod @staticmethod
def _get_tracklist(resp, source): def _get_tracklist(resp, source):
if source in ("qobuz", "tidal"): if source == "qobuz":
return resp["tracks"]["items"] return resp["tracks"]["items"]
elif source == "deezer": elif source in ("tidal", "deezer"):
return resp["tracks"] return resp["tracks"]
raise NotImplementedError(source) raise NotImplementedError(source)
@ -226,7 +228,10 @@ class Track:
else: else:
raise InvalidSourceError(self.client.source) 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): if isinstance(database, MusicDB):
database.add(self.id) database.add(self.id)
@ -288,7 +293,10 @@ class Track:
:raises IndexError :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) meta = TrackMetadata(album=album, track=track, source=client.source)
return cls(client=client, meta=meta, id=track["id"]) 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 This uses a classmethod to convert an item into a Track object, which
stores the metadata inside a TrackMetadata object. 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): for i in range(self.tracktotal):
# append method inherited from superclass list # append method inherited from superclass list
self.append( self.append(

View file

@ -1,4 +1,5 @@
import logging import logging
import base64
import logging.handlers as handlers import logging.handlers as handlers
import os import os
from string import Formatter from string import Formatter
@ -159,7 +160,28 @@ def capitalize(s: str) -> str:
return s[0].upper() + s[1:] 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) counter = Counter.new(64, prefix=nonce, initial_value=0)
decryptor = AES.new(key, AES.MODE_CTR, counter=counter) decryptor = AES.new(key, AES.MODE_CTR, counter=counter)