Tidal album downloads working

This commit is contained in:
Nathan Thomas 2023-12-21 12:48:19 -08:00
parent abb37f17fd
commit d14fb608d3
10 changed files with 237 additions and 64 deletions

View File

@ -26,6 +26,9 @@ from ..exceptions import NonStreamable
logger = logging.getLogger("streamrip")
BLOWFISH_SECRET = "g4el58wc0zvf9na1"
def generate_temp_path(url: str):
return os.path.join(
tempfile.gettempdir(),
@ -172,12 +175,11 @@ class DeezerDownloadable(Downloadable):
:param track_id:
:type track_id: str
"""
SECRET = "g4el58wc0zvf9na1"
md5_hash = hashlib.md5(track_id.encode()).hexdigest()
# good luck :)
return "".join(
chr(functools.reduce(lambda x, y: x ^ y, map(ord, t)))
for t in zip(md5_hash[:16], md5_hash[16:], SECRET)
for t in zip(md5_hash[:16], md5_hash[16:], BLOWFISH_SECRET)
).encode()
@ -186,29 +188,52 @@ class TidalDownloadable(Downloadable):
error messages.
"""
def __init__(self, session: aiohttp.ClientSession, url: str, enc_key, codec):
def __init__(
self,
session: aiohttp.ClientSession,
url: str | None,
codec: str,
encryption_key: str | None,
restrictions,
):
self.session = session
self.url = url
assert enc_key is None
if self.url is None:
raise Exception
# if restrictions := info["restrictions"]:
# # Turn CamelCase code into a readable sentence
# words = re.findall(r"([A-Z][a-z]+)", restrictions[0]["code"])
# raise NonStreamable(
# words[0] + " " + " ".join(map(str.lower, words[1:])),
# )
#
# raise NonStreamable(f"Tidal download: dl_info = {info}")
codec = codec.lower()
if codec == "flac":
self.extension = "flac"
else:
self.extension = "m4a"
assert isinstance(url, str)
self.downloadable = BasicDownloadable(session, url, "m4a")
if url is None:
# Turn CamelCase code into a readable sentence
if restrictions:
words = re.findall(r"([A-Z][a-z]+)", restrictions[0]["code"])
raise NonStreamable(
words[0] + " " + " ".join(map(str.lower, words[1:])),
)
raise NonStreamable(
f"Tidal download: dl_info = {url, codec, encryption_key}"
)
self.url = url
self.enc_key = encryption_key
self.downloadable = BasicDownloadable(session, url, self.extension)
async def _download(self, path: str, callback):
await self.downloadable._download(path, callback)
if self.enc_key is not None:
dec_bytes = await self._decrypt_mqa_file(path, self.enc_key)
async with aiofiles.open(path, "wb") as audio:
await audio.write(dec_bytes)
@property
def _size(self):
return self.downloadable._size
@_size.setter
def _size(self, v):
self.downloadable._size = v
@staticmethod
async def _decrypt_mqa_file(in_path, out_path, encryption_key):
async def _decrypt_mqa_file(in_path, encryption_key):
"""Decrypt an MQA file.
:param in_path:
@ -240,11 +265,9 @@ class TidalDownloadable(Downloadable):
counter = Counter.new(64, prefix=nonce, initial_value=0)
decryptor = AES.new(key, AES.MODE_CTR, counter=counter)
async with aiofiles.open(in_path, "rb") as enc_file, aiofiles.open(
out_path, "wb"
) as dec_file:
async with aiofiles.open(in_path, "rb") as enc_file:
dec_bytes = decryptor.decrypt(await enc_file.read())
await dec_file.write(dec_bytes)
return dec_bytes
class SoundcloudDownloadable(Downloadable):

View File

@ -10,6 +10,10 @@ from .downloadable import SoundcloudDownloadable
BASE = "https://api-v2.soundcloud.com"
SOUNDCLOUD_USER_ID = "672320-86895-162383-801513"
STOCK_URL = "https://soundcloud.com/"
# for playlists
MAX_BATCH_SIZE = 50
logger = logging.getLogger("streamrip")
@ -83,8 +87,6 @@ class SoundcloudClient(Client):
if len(unresolved_tracks) == 0:
return original_resp
MAX_BATCH_SIZE = 50
batches = batched(unresolved_tracks, MAX_BATCH_SIZE)
requests = [
self._api_request(
@ -237,7 +239,6 @@ class SoundcloudClient(Client):
async def _refresh_tokens(self) -> tuple[str, str]:
"""Return a valid client_id, app_version pair."""
STOCK_URL = "https://soundcloud.com/"
async with self.session.get(STOCK_URL) as resp:
page_text = await resp.text(encoding="utf-8")

View File

@ -19,6 +19,7 @@ CLIENT_ID = base64.b64decode("elU0WEhWVmtjMnREUG80dA==").decode("iso-8859-1")
CLIENT_SECRET = base64.b64decode(
"VkpLaERGcUpQcXZzUFZOQlY2dWtYVEptd2x2YnR0UDd3bE1scmM3MnNlND0=",
).decode("iso-8859-1")
AUTH = aiohttp.BasicAuth(login=CLIENT_ID, password=CLIENT_SECRET)
STREAM_URL_REGEX = re.compile(
r"#EXT-X-STREAM-INF:BANDWIDTH=\d+,AVERAGE-BANDWIDTH=\d+,CODECS=\"(?!jpeg)[^\"]+\",RESOLUTION=\d+x\d+\n(.+)"
)
@ -118,7 +119,7 @@ class TidalClient(Client):
assert media_type in ("album", "track", "playlist", "video")
return await self._api_request(f"search/{media_type}s", params=params)
async def get_downloadable(self, track_id, quality: int = 3):
async def get_downloadable(self, track_id: str, quality: int):
params = {
"audioquality": QUALITY_MAP[quality],
"playbackmode": "STREAM",
@ -127,17 +128,22 @@ class TidalClient(Client):
resp = await self._api_request(
f"tracks/{track_id}/playbackinfopostpaywall", params
)
logger.debug(resp)
try:
manifest = json.loads(base64.b64decode(resp["manifest"]).decode("utf-8"))
except KeyError:
raise Exception(resp["userMessage"])
logger.debug(manifest)
enc_key = manifest.get("keyId")
if manifest.get("encryptionType") == "NONE":
enc_key = None
return TidalDownloadable(
self.session,
url=manifest["urls"][0],
enc_key=manifest.get("keyId"),
codec=manifest["codecs"],
encryption_key=enc_key,
restrictions=manifest.get("restrictions"),
)
async def get_video_file_url(self, video_id: str) -> str:
@ -226,11 +232,7 @@ class TidalClient(Client):
"scope": "r_usr+w_usr+w_sub",
}
logger.debug("Checking with %s", data)
resp = await self._api_post(
f"{AUTH_URL}/token",
data,
aiohttp.BasicAuth(login=CLIENT_ID, password=CLIENT_SECRET),
)
resp = await self._api_post(f"{AUTH_URL}/token", data, AUTH)
if "status" in resp and resp["status"] != 200:
if resp["status"] == 400 and resp["sub_status"] == 1002:
@ -258,11 +260,7 @@ class TidalClient(Client):
"grant_type": "refresh_token",
"scope": "r_usr+w_usr+w_sub",
}
resp = await self._api_post(
f"{AUTH_URL}/token",
data,
aiohttp.BasicAuth(login=CLIENT_ID, password=CLIENT_SECRET),
)
resp = await self._api_post(f"{AUTH_URL}/token", data, AUTH)
if resp.get("status", 200) != 200:
raise Exception("Refresh failed")

View File

@ -5,7 +5,7 @@ import logging
import os
import shutil
from tempfile import gettempdir
from typing import Optional
from typing import Final, Optional
from .exceptions import ConversionError
@ -178,7 +178,7 @@ class LAME(Converter):
https://trac.ffmpeg.org/wiki/Encode/MP3
"""
_bitrate_map = {
_bitrate_map: Final[dict[int, str]] = {
320: "-b:a 320k",
245: "-q:a 0",
225: "-q:a 1",
@ -271,7 +271,7 @@ class AAC(Converter):
def get(codec: str) -> type[Converter]:
CONV_CLASS = {
converter_classes = {
"FLAC": FLAC,
"ALAC": ALAC,
"MP3": LAME,
@ -281,4 +281,4 @@ def get(codec: str) -> type[Converter]:
"AAC": AAC,
"M4A": AAC,
}
return CONV_CLASS[codec.upper()]
return converter_classes[codec.upper()]

View File

@ -5,6 +5,7 @@ import os
import sqlite3
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Final
logger = logging.getLogger("streamrip")
@ -161,7 +162,7 @@ class Downloads(DatabaseBase):
"""A table that stores the downloaded IDs."""
name = "downloads"
structure = {
structure: Final[dict] = {
"id": ["text", "unique"],
}
@ -170,7 +171,7 @@ class Failed(DatabaseBase):
"""A table that stores information about failed downloads."""
name = "failed_downloads"
structure = {
structure: Final[dict] = {
"source": ["text"],
"media_type": ["text"],
"id": ["text", "unique"],

View File

@ -24,8 +24,8 @@ class AlbumInfo:
container: str
label: Optional[str] = None
explicit: bool = False
sampling_rate: Optional[int | float] = None
bit_depth: Optional[int] = None
sampling_rate: int | float | None = None
bit_depth: int | None = None
booklets: list[dict] | None = None
@ -39,16 +39,16 @@ class AlbumMetadata:
covers: Covers
tracktotal: int
disctotal: int = 1
albumcomposer: Optional[str] = None
comment: Optional[str] = None
compilation: Optional[str] = None
copyright: Optional[str] = None
date: Optional[str] = None
description: Optional[str] = None
encoder: Optional[str] = None
grouping: Optional[str] = None
lyrics: Optional[str] = None
purchase_date: Optional[str] = None
albumcomposer: str | None = None
comment: str | None = None
compilation: str | None = None
copyright: str | None = None
date: str | None = None
description: str | None = None
encoder: str | None = None
grouping: str | None = None
lyrics: str | None = None
purchase_date: str | None = None
def get_genres(self) -> str:
return ", ".join(self.genre)
@ -174,7 +174,6 @@ class AlbumMetadata:
albumcomposer = None
label = resp.get("label")
booklets = None
# url = resp.get("link")
explicit = typed(
resp.get("parental_warning", False) or resp.get("explicit_lyrics", False),
bool,
@ -187,7 +186,6 @@ class AlbumMetadata:
container = "FLAC"
cover_urls = Covers.from_deezer(resp)
# streamable = True
item_id = str(resp["id"])
info = AlbumInfo(
@ -282,15 +280,98 @@ class AlbumMetadata:
)
@classmethod
def from_tidal(cls, resp) -> AlbumMetadata:
raise NotImplementedError
def from_tidal(cls, resp) -> AlbumMetadata | None:
"""
Args:
resp: API response containing album metadata.
Returns: AlbumMetadata instance if the album is streamable, otherwise None.
"""
streamable = resp.get("allowStreaming", False)
if not streamable:
return None
item_id = str(resp["id"])
album = typed(resp.get("title", "Unknown Album"), str)
tracktotal = typed(resp.get("numberOfTracks", 1), int)
# genre not returned by API
date = typed(resp.get("releaseDate"), str)
year = date[:4]
_copyright = typed(resp.get("copyright"), str)
artists = typed(resp.get("artists", []), list)
albumartist = ", ".join(a["name"] for a in artists)
if not albumartist:
albumartist = typed(safe_get(resp, "artist", "name"), str)
disctotal = typed(resp.get("numberOfVolumes", 1), int)
# label not returned by API
# non-embedded
explicit = typed(resp.get("explicit", False), bool)
covers = Covers.from_tidal(resp)
if covers is None:
covers = Covers()
quality_map: dict[str, int] = {
"LOW": 0,
"HIGH": 1,
"LOSSLESS": 2,
"HI_RES": 3,
}
tidal_quality = resp.get("audioQuality", "LOW")
quality = quality_map[tidal_quality]
if quality >= 2:
sampling_rate = 44100
if quality == 3:
bit_depth = 24
else:
bit_depth = 16
else:
sampling_rate = None
bit_depth = None
info = AlbumInfo(
id=item_id,
quality=quality,
container="MP4",
label=None,
explicit=explicit,
sampling_rate=sampling_rate,
bit_depth=bit_depth,
booklets=None,
)
return AlbumMetadata(
info,
album,
albumartist,
year,
genre=[],
covers=covers,
albumcomposer=None,
comment=None,
compilation=None,
copyright=_copyright,
date=date,
description=None,
disctotal=disctotal,
encoder=None,
grouping=None,
lyrics=None,
purchase_date=None,
tracktotal=tracktotal,
)
@classmethod
def from_track_resp(cls, resp: dict, source: str) -> AlbumMetadata | None:
if source == "qobuz":
return cls.from_qobuz(resp["album"])
if source == "tidal":
return cls.from_tidal(resp["album"])
return cls.from_tidal(resp)
if source == "soundcloud":
return cls.from_soundcloud(resp)
if source == "deezer":

View File

@ -92,6 +92,12 @@ class PlaylistMetadata:
tracks = [str(track["id"]) for track in resp["tracks"]]
return cls(name, tracks)
@classmethod
def from_tidal(cls, resp: dict):
name = typed(resp["title"], str)
tracks = [str(track["id"]) for track in resp["tracks"]]
return cls(name, tracks)
def ids(self) -> list[str]:
if len(self.tracks) == 0:
return []
@ -108,5 +114,7 @@ class PlaylistMetadata:
return cls.from_soundcloud(resp)
elif source == "deezer":
return cls.from_deezer(resp)
elif source == "tidal":
return cls.from_tidal(resp)
else:
raise NotImplementedError(source)

View File

@ -32,6 +32,7 @@ class TrackMetadata:
tracknumber: int
discnumber: int
composer: str | None
isrc: str | None = None
@classmethod
def from_qobuz(cls, album: AlbumMetadata, resp: dict) -> TrackMetadata | None:
@ -150,8 +151,67 @@ class TrackMetadata:
)
@classmethod
def from_tidal(cls, album: AlbumMetadata, resp) -> TrackMetadata:
raise NotImplementedError
def from_tidal(cls, album: AlbumMetadata, track) -> TrackMetadata:
with open("tidal_track.json", "w") as f:
json.dump(track, f)
title = typed(track["title"], str).strip()
item_id = str(track["id"])
version = track.get("version")
explicit = track.get("explicit", False)
isrc = track.get("isrc")
if version:
title = f"{title} ({version})"
tracknumber = typed(track.get("trackNumber", 1), int)
discnumber = typed(track.get("volumeNumber", 1), int)
artists = track.get("artists")
if len(artists) > 0:
artist = ", ".join(a["name"] for a in artists)
else:
artist = track["artist"]["name"]
quality_map: dict[str, int] = {
"LOW": 0,
"HIGH": 1,
"LOSSLESS": 2,
"HI_RES": 3,
}
tidal_quality = track.get("audioQuality")
if tidal_quality is not None:
quality = quality_map[tidal_quality]
else:
quality = 0
if quality >= 2:
sampling_rate = 44100
if quality == 3:
bit_depth = 24
else:
bit_depth = 16
else:
sampling_rate = bit_depth = None
info = TrackInfo(
id=item_id,
quality=quality,
bit_depth=bit_depth,
explicit=explicit,
sampling_rate=sampling_rate,
work=None,
)
return cls(
info=info,
title=title,
album=album,
artist=artist,
tracknumber=tracknumber,
discnumber=discnumber,
composer=None,
isrc=isrc,
)
@classmethod
def from_resp(cls, album: AlbumMetadata, source, resp) -> TrackMetadata | None:

View File

@ -29,6 +29,7 @@ class URL(ABC):
self.match = match
self.source = source
@classmethod
@abstractmethod
def from_str(cls, url: str) -> URL | None:
raise NotImplementedError

View File

@ -1,3 +1,4 @@
import asyncio
import hashlib
import logging
import time
@ -111,10 +112,9 @@ class TidalPrompter(CredentialPrompter):
while elapsed < self.timeout_s:
elapsed = time.time() - start
status, info = await self.client._get_auth_status(device_code)
print(status, info)
if status == 2:
# pending
time.sleep(4)
await asyncio.sleep(4)
continue
elif status == 0:
# successful