diff --git a/streamrip/client/downloadable.py b/streamrip/client/downloadable.py index 25f2b17..6075033 100644 --- a/streamrip/client/downloadable.py +++ b/streamrip/client/downloadable.py @@ -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): diff --git a/streamrip/client/soundcloud.py b/streamrip/client/soundcloud.py index ceb25cd..76d1889 100644 --- a/streamrip/client/soundcloud.py +++ b/streamrip/client/soundcloud.py @@ -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") diff --git a/streamrip/client/tidal.py b/streamrip/client/tidal.py index 62039c4..da09319 100644 --- a/streamrip/client/tidal.py +++ b/streamrip/client/tidal.py @@ -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") diff --git a/streamrip/converter.py b/streamrip/converter.py index e041ae3..6668cda 100644 --- a/streamrip/converter.py +++ b/streamrip/converter.py @@ -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()] diff --git a/streamrip/db.py b/streamrip/db.py index a09cab9..94085e1 100644 --- a/streamrip/db.py +++ b/streamrip/db.py @@ -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"], diff --git a/streamrip/metadata/album_metadata.py b/streamrip/metadata/album_metadata.py index 371fe1e..402f360 100644 --- a/streamrip/metadata/album_metadata.py +++ b/streamrip/metadata/album_metadata.py @@ -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": diff --git a/streamrip/metadata/playlist_metadata.py b/streamrip/metadata/playlist_metadata.py index 3ea2e7e..45a612f 100644 --- a/streamrip/metadata/playlist_metadata.py +++ b/streamrip/metadata/playlist_metadata.py @@ -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) diff --git a/streamrip/metadata/track_metadata.py b/streamrip/metadata/track_metadata.py index ad6d3ed..822fe70 100644 --- a/streamrip/metadata/track_metadata.py +++ b/streamrip/metadata/track_metadata.py @@ -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: diff --git a/streamrip/rip/parse_url.py b/streamrip/rip/parse_url.py index 293810f..ce8c16e 100644 --- a/streamrip/rip/parse_url.py +++ b/streamrip/rip/parse_url.py @@ -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 diff --git a/streamrip/rip/prompter.py b/streamrip/rip/prompter.py index 4cadcf2..0b039ff 100644 --- a/streamrip/rip/prompter.py +++ b/streamrip/rip/prompter.py @@ -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