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") logger = logging.getLogger("streamrip")
BLOWFISH_SECRET = "g4el58wc0zvf9na1"
def generate_temp_path(url: str): def generate_temp_path(url: str):
return os.path.join( return os.path.join(
tempfile.gettempdir(), tempfile.gettempdir(),
@ -172,12 +175,11 @@ class DeezerDownloadable(Downloadable):
:param track_id: :param track_id:
:type track_id: str :type track_id: str
""" """
SECRET = "g4el58wc0zvf9na1"
md5_hash = hashlib.md5(track_id.encode()).hexdigest() md5_hash = hashlib.md5(track_id.encode()).hexdigest()
# good luck :) # good luck :)
return "".join( return "".join(
chr(functools.reduce(lambda x, y: x ^ y, map(ord, t))) 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() ).encode()
@ -186,29 +188,52 @@ class TidalDownloadable(Downloadable):
error messages. 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.session = session
self.url = url codec = codec.lower()
assert enc_key is None if codec == "flac":
if self.url is None: self.extension = "flac"
raise Exception else:
# if restrictions := info["restrictions"]: self.extension = "m4a"
# # 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}")
assert isinstance(url, str) if url is None:
self.downloadable = BasicDownloadable(session, url, "m4a") # 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): async def _download(self, path: str, callback):
await self.downloadable._download(path, 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 @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. """Decrypt an MQA file.
:param in_path: :param in_path:
@ -240,11 +265,9 @@ class TidalDownloadable(Downloadable):
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)
async with aiofiles.open(in_path, "rb") as enc_file, aiofiles.open( async with aiofiles.open(in_path, "rb") as enc_file:
out_path, "wb"
) as dec_file:
dec_bytes = decryptor.decrypt(await enc_file.read()) dec_bytes = decryptor.decrypt(await enc_file.read())
await dec_file.write(dec_bytes) return dec_bytes
class SoundcloudDownloadable(Downloadable): class SoundcloudDownloadable(Downloadable):

View File

@ -10,6 +10,10 @@ from .downloadable import SoundcloudDownloadable
BASE = "https://api-v2.soundcloud.com" BASE = "https://api-v2.soundcloud.com"
SOUNDCLOUD_USER_ID = "672320-86895-162383-801513" SOUNDCLOUD_USER_ID = "672320-86895-162383-801513"
STOCK_URL = "https://soundcloud.com/"
# for playlists
MAX_BATCH_SIZE = 50
logger = logging.getLogger("streamrip") logger = logging.getLogger("streamrip")
@ -83,8 +87,6 @@ class SoundcloudClient(Client):
if len(unresolved_tracks) == 0: if len(unresolved_tracks) == 0:
return original_resp return original_resp
MAX_BATCH_SIZE = 50
batches = batched(unresolved_tracks, MAX_BATCH_SIZE) batches = batched(unresolved_tracks, MAX_BATCH_SIZE)
requests = [ requests = [
self._api_request( self._api_request(
@ -237,7 +239,6 @@ class SoundcloudClient(Client):
async def _refresh_tokens(self) -> tuple[str, str]: async def _refresh_tokens(self) -> tuple[str, str]:
"""Return a valid client_id, app_version pair.""" """Return a valid client_id, app_version pair."""
STOCK_URL = "https://soundcloud.com/"
async with self.session.get(STOCK_URL) as resp: async with self.session.get(STOCK_URL) as resp:
page_text = await resp.text(encoding="utf-8") 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( CLIENT_SECRET = base64.b64decode(
"VkpLaERGcUpQcXZzUFZOQlY2dWtYVEptd2x2YnR0UDd3bE1scmM3MnNlND0=", "VkpLaERGcUpQcXZzUFZOQlY2dWtYVEptd2x2YnR0UDd3bE1scmM3MnNlND0=",
).decode("iso-8859-1") ).decode("iso-8859-1")
AUTH = aiohttp.BasicAuth(login=CLIENT_ID, password=CLIENT_SECRET)
STREAM_URL_REGEX = re.compile( STREAM_URL_REGEX = re.compile(
r"#EXT-X-STREAM-INF:BANDWIDTH=\d+,AVERAGE-BANDWIDTH=\d+,CODECS=\"(?!jpeg)[^\"]+\",RESOLUTION=\d+x\d+\n(.+)" 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") assert media_type in ("album", "track", "playlist", "video")
return await self._api_request(f"search/{media_type}s", params=params) 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 = { params = {
"audioquality": QUALITY_MAP[quality], "audioquality": QUALITY_MAP[quality],
"playbackmode": "STREAM", "playbackmode": "STREAM",
@ -127,17 +128,22 @@ class TidalClient(Client):
resp = await self._api_request( resp = await self._api_request(
f"tracks/{track_id}/playbackinfopostpaywall", params f"tracks/{track_id}/playbackinfopostpaywall", params
) )
logger.debug(resp)
try: try:
manifest = json.loads(base64.b64decode(resp["manifest"]).decode("utf-8")) manifest = json.loads(base64.b64decode(resp["manifest"]).decode("utf-8"))
except KeyError: except KeyError:
raise Exception(resp["userMessage"]) raise Exception(resp["userMessage"])
logger.debug(manifest) logger.debug(manifest)
enc_key = manifest.get("keyId")
if manifest.get("encryptionType") == "NONE":
enc_key = None
return TidalDownloadable( return TidalDownloadable(
self.session, self.session,
url=manifest["urls"][0], url=manifest["urls"][0],
enc_key=manifest.get("keyId"),
codec=manifest["codecs"], codec=manifest["codecs"],
encryption_key=enc_key,
restrictions=manifest.get("restrictions"),
) )
async def get_video_file_url(self, video_id: str) -> str: 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", "scope": "r_usr+w_usr+w_sub",
} }
logger.debug("Checking with %s", data) logger.debug("Checking with %s", data)
resp = await self._api_post( resp = await self._api_post(f"{AUTH_URL}/token", data, AUTH)
f"{AUTH_URL}/token",
data,
aiohttp.BasicAuth(login=CLIENT_ID, password=CLIENT_SECRET),
)
if "status" in resp and resp["status"] != 200: if "status" in resp and resp["status"] != 200:
if resp["status"] == 400 and resp["sub_status"] == 1002: if resp["status"] == 400 and resp["sub_status"] == 1002:
@ -258,11 +260,7 @@ class TidalClient(Client):
"grant_type": "refresh_token", "grant_type": "refresh_token",
"scope": "r_usr+w_usr+w_sub", "scope": "r_usr+w_usr+w_sub",
} }
resp = await self._api_post( resp = await self._api_post(f"{AUTH_URL}/token", data, AUTH)
f"{AUTH_URL}/token",
data,
aiohttp.BasicAuth(login=CLIENT_ID, password=CLIENT_SECRET),
)
if resp.get("status", 200) != 200: if resp.get("status", 200) != 200:
raise Exception("Refresh failed") raise Exception("Refresh failed")

View File

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

View File

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

View File

@ -24,8 +24,8 @@ class AlbumInfo:
container: str container: str
label: Optional[str] = None label: Optional[str] = None
explicit: bool = False explicit: bool = False
sampling_rate: Optional[int | float] = None sampling_rate: int | float | None = None
bit_depth: Optional[int] = None bit_depth: int | None = None
booklets: list[dict] | None = None booklets: list[dict] | None = None
@ -39,16 +39,16 @@ class AlbumMetadata:
covers: Covers covers: Covers
tracktotal: int tracktotal: int
disctotal: int = 1 disctotal: int = 1
albumcomposer: Optional[str] = None albumcomposer: str | None = None
comment: Optional[str] = None comment: str | None = None
compilation: Optional[str] = None compilation: str | None = None
copyright: Optional[str] = None copyright: str | None = None
date: Optional[str] = None date: str | None = None
description: Optional[str] = None description: str | None = None
encoder: Optional[str] = None encoder: str | None = None
grouping: Optional[str] = None grouping: str | None = None
lyrics: Optional[str] = None lyrics: str | None = None
purchase_date: Optional[str] = None purchase_date: str | None = None
def get_genres(self) -> str: def get_genres(self) -> str:
return ", ".join(self.genre) return ", ".join(self.genre)
@ -174,7 +174,6 @@ class AlbumMetadata:
albumcomposer = None albumcomposer = None
label = resp.get("label") label = resp.get("label")
booklets = None booklets = None
# url = resp.get("link")
explicit = typed( explicit = typed(
resp.get("parental_warning", False) or resp.get("explicit_lyrics", False), resp.get("parental_warning", False) or resp.get("explicit_lyrics", False),
bool, bool,
@ -187,7 +186,6 @@ class AlbumMetadata:
container = "FLAC" container = "FLAC"
cover_urls = Covers.from_deezer(resp) cover_urls = Covers.from_deezer(resp)
# streamable = True
item_id = str(resp["id"]) item_id = str(resp["id"])
info = AlbumInfo( info = AlbumInfo(
@ -282,15 +280,98 @@ class AlbumMetadata:
) )
@classmethod @classmethod
def from_tidal(cls, resp) -> AlbumMetadata: def from_tidal(cls, resp) -> AlbumMetadata | None:
raise NotImplementedError """
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 @classmethod
def from_track_resp(cls, resp: dict, source: str) -> AlbumMetadata | None: def from_track_resp(cls, resp: dict, source: str) -> AlbumMetadata | None:
if source == "qobuz": if source == "qobuz":
return cls.from_qobuz(resp["album"]) return cls.from_qobuz(resp["album"])
if source == "tidal": if source == "tidal":
return cls.from_tidal(resp["album"]) return cls.from_tidal(resp)
if source == "soundcloud": if source == "soundcloud":
return cls.from_soundcloud(resp) return cls.from_soundcloud(resp)
if source == "deezer": if source == "deezer":

View File

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

View File

@ -32,6 +32,7 @@ class TrackMetadata:
tracknumber: int tracknumber: int
discnumber: int discnumber: int
composer: str | None composer: str | None
isrc: str | None = None
@classmethod @classmethod
def from_qobuz(cls, album: AlbumMetadata, resp: dict) -> TrackMetadata | None: def from_qobuz(cls, album: AlbumMetadata, resp: dict) -> TrackMetadata | None:
@ -150,8 +151,67 @@ class TrackMetadata:
) )
@classmethod @classmethod
def from_tidal(cls, album: AlbumMetadata, resp) -> TrackMetadata: def from_tidal(cls, album: AlbumMetadata, track) -> TrackMetadata:
raise NotImplementedError 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 @classmethod
def from_resp(cls, album: AlbumMetadata, source, resp) -> TrackMetadata | None: def from_resp(cls, album: AlbumMetadata, source, resp) -> TrackMetadata | None:

View File

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

View File

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