mirror of
https://github.com/nathom/streamrip.git
synced 2025-05-09 14:11:55 -04:00
Tidal album downloads working
This commit is contained in:
parent
abb37f17fd
commit
d14fb608d3
10 changed files with 237 additions and 64 deletions
|
@ -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):
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()]
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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":
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue