From a57c84e2fe1d834a27ee862744f30121a6550ff7 Mon Sep 17 00:00:00 2001 From: nathannathant <74019033+pynathanthomas@users.noreply.github.com> Date: Mon, 15 Mar 2021 22:58:24 -0700 Subject: [PATCH] Tidal album download working Bug fixes relate to: - cover art - quality keys Also updated gitignore to ignore audio files. --- .gitignore | 4 ++++ qobuz_dl_rewrite/clients.py | 18 +++++++++++---- qobuz_dl_rewrite/constants.py | 2 ++ qobuz_dl_rewrite/downloader.py | 38 +++++++++++++++++++++---------- qobuz_dl_rewrite/metadata.py | 41 ++++++++++++++++------------------ 5 files changed, 65 insertions(+), 38 deletions(-) diff --git a/.gitignore b/.gitignore index cb400db..23e6070 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,7 @@ dmypy.json # Cython debug symbols cython_debug/ *.yaml +*.flac +*.m4a +*.ogg +*.mp3 diff --git a/qobuz_dl_rewrite/clients.py b/qobuz_dl_rewrite/clients.py index aa43bbf..972b49a 100644 --- a/qobuz_dl_rewrite/clients.py +++ b/qobuz_dl_rewrite/clients.py @@ -32,9 +32,10 @@ QOBUZ_BASE = "https://www.qobuz.com/api.json/0.2" # Tidal TIDAL_Q_IDS = { 4: "LOW", # AAC - 5: "MEDIUM", # AAC - 6: "HIGH", # Lossless, but it also could be MQA + 5: "HIGH", # AAC + 6: "LOSSLESS", # Lossless, but it also could be MQA } +TIDAL_MAX_Q = max(TIDAL_Q_IDS.keys()) # Deezer @@ -80,6 +81,11 @@ class ClientInterface(ABC): """ pass + @property + @abstractmethod + def source(self): + pass + class SecureClientInterface(ClientInterface): """Identical to a ClientInterface except for a login @@ -412,8 +418,12 @@ class TidalClient(SecureClientInterface): :param quality: :type quality: int """ - # Not tested - return self._get_file_url(meta_id, quality=quality) + logger.debug(f"Fetching file url with quality {quality}") + return self._get_file_url(meta_id, quality=min(TIDAL_MAX_Q, quality)) + + @property + def source(self): + return "tidal" def _search(self, query, media_type="album", **kwargs): params = { diff --git a/qobuz_dl_rewrite/constants.py b/qobuz_dl_rewrite/constants.py index 7d8fe2c..4200341 100644 --- a/qobuz_dl_rewrite/constants.py +++ b/qobuz_dl_rewrite/constants.py @@ -10,6 +10,8 @@ LOG_DIR = appdirs.user_config_dir(APPNAME) AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0" +TIDAL_COVER_URL = "https://resources.tidal.com/images/{uuid}/{width}x{height}.jpg" + EXT = { 5: ".mp3", 6: ".flac", diff --git a/qobuz_dl_rewrite/downloader.py b/qobuz_dl_rewrite/downloader.py index 382ef46..dd8c3f1 100644 --- a/qobuz_dl_rewrite/downloader.py +++ b/qobuz_dl_rewrite/downloader.py @@ -1,7 +1,7 @@ import logging import os import shutil -import subprocess +from pprint import pformat from tempfile import gettempdir from typing import Any, Optional, Union @@ -12,7 +12,7 @@ from pathvalidate import sanitize_filename from . import converter from .clients import ClientInterface -from .constants import EXT, FLAC_MAX_BLOCKSIZE +from .constants import EXT, FLAC_MAX_BLOCKSIZE, TIDAL_COVER_URL from .exceptions import ( InvalidQuality, InvalidSourceError, @@ -32,7 +32,7 @@ TIDAL_Q_MAP = { class Track: - """Represents a downloadable track returned by the qobuz api. + """Represents a downloadable track. Loading metadata as a single track: >>> t = Track(client, id='20252078') @@ -96,8 +96,8 @@ class Track: ) # meta dict -> TrackMetadata object @staticmethod - def _get_tracklist(resp, client): - if client.source in ("qobuz", "tidal"): + def _get_tracklist(resp, source): + if source in ("qobuz", "tidal"): return resp["tracks"]["items"] else: # TODO: implement deezer @@ -176,9 +176,8 @@ class Track: :raises IndexError """ - track = cls._get_tracklist(album)[pos] + track = cls._get_tracklist(album, client.source)[pos] meta = TrackMetadata(album=album, track=track, source=client.source) - meta.add_track_meta(album["tracks"]["items"][pos]) return cls(client=client, meta=meta, id=track["id"]) def tag(self, album_meta: dict = None, cover: Union[Picture, APIC] = None): @@ -295,6 +294,9 @@ class Track: """ setattr(self.meta, key, val) + def __repr__(self): + return f"" + class Tracklist(list): """A base class for tracklist-like objects. @@ -367,7 +369,7 @@ class Tracklist(list): :param source: in ('qobuz', 'deezer', 'tidal') :type source: str """ - info = cls._load_get_response(item) + info = cls._parse_get_resp(item, client=client) # equivalent to Album(client=client, **info) return cls(client=client, **info) @@ -429,9 +431,10 @@ class Album(Tracklist): self.load_meta() def load_meta(self): + assert hasattr(self, "id"), "id must be set to load metadata" self.meta = self.client.get(self.id, media_type="album") # update attributes based on response - self.__dict__.update(self._parse_get_resp(self.meta)) + self.__dict__.update(self._parse_get_resp(self.meta, self.client)) if not self.get("streamable", False): raise NonStreamable(f"This album is not streamable ({self.id} ID)") @@ -448,6 +451,7 @@ class Album(Tracklist): """ if client.source == "qobuz": info = { + "id": resp.get("id"), "title": resp.get("title"), "_artist": resp.get("artist") or resp.get("performer"), "albumartist": resp.get("name"), @@ -461,11 +465,19 @@ class Album(Tracklist): } elif client.source == "tidal": info = { + "id": resp.get("id"), "title": resp.get("title"), "_artist": safe_get(resp, "artist", "name"), "albumartist": safe_get(resp, "artist", "name"), "version": resp.get("version"), - "cover_urls": [resp.get("cover")], + "cover_urls": { + size: TIDAL_COVER_URL.format( + uuid=resp.get("cover").replace("-", "/"), height=x, width=x + ) + for size, x in zip( + ("thumbnail", "small", "large"), (160, 320, 1280) + ) + }, "streamable": resp.get("allowStreaming"), "quality": TIDAL_Q_MAP[resp.get("audioQuality")], "tracktotal": resp.get("numberOfTracks"), @@ -491,6 +503,8 @@ class Album(Tracklist): This uses a classmethod to convert an item into a Track object, which stores the metadata inside a TrackMetadata object. """ + logging.debug("Loading tracks to album") + logging.debug(pformat(self.meta)) for i in range(self.tracktotal): # append method inherited from superclass list self.append( @@ -639,7 +653,7 @@ class Playlist(Tracklist): "id": item.id, } else: - raise ValueError(f"invalid source '{source}'") + raise InvalidSourceError(source) # equivalent to Playlist(client=client, **info) return cls(client=client, **info) @@ -708,7 +722,7 @@ class Artist(Tracklist): "id": item.id, } else: - raise ValueError(f"invalid source '{source}'") + raise InvalidSourceError(source) logging.debug(f"Loaded info {info}") # equivalent to Artist(client=client, **info) diff --git a/qobuz_dl_rewrite/metadata.py b/qobuz_dl_rewrite/metadata.py index a3d5c4e..8a1a3a9 100644 --- a/qobuz_dl_rewrite/metadata.py +++ b/qobuz_dl_rewrite/metadata.py @@ -73,37 +73,34 @@ class TrackMetadata: if track is not None: self.add_track_meta(track) - # prefer track['album'] over album - if track.get("album"): - album = track.get("album") if album is not None: self.add_album_meta(album) - def add_album_meta(self, album: dict): - """Parse the metadata from an album dict returned by the + def add_album_meta(self, resp: dict): + """Parse the metadata from an resp dict returned by the Qobuz API. - :param dict album: from the Qobuz API + :param dict resp: from the Qobuz API """ if self.__source == "qobuz": - self.album = album.get("title") - self.tracktotal = str(album.get("tracks_count", 1)) - self.genre = album.get("genres_list", []) - self.date = album.get("release_date_original") or album.get("release_date") - self.copyright = album.get("copyright") - self.albumartist = album.get("artist", {}).get("name") + self.album = resp.get("title") + self.tracktotal = str(resp.get("tracks_count", 1)) + self.genre = resp.get("genres_list", []) + self.date = resp.get("release_date_original") or resp.get("release_date") + self.copyright = resp.get("copyright") + self.albumartist = resp.get("artist", {}).get("name") - self.label = album.get("label") + self.label = resp.get("label") if isinstance(self.label, dict): self.label = self.label.get("name") elif self.__source == "tidal": - self.album = album.get("title") - self.tracktotal = album.get("numberOfTracks") - self.date = album.get("releaseDate") - self.copyright = album.get("copyright") - self.albumartist = album.get("artist", {}).get("name") + self.album = resp.get("title") + self.tracktotal = resp.get("numberOfTracks") + self.date = resp.get("releaseDate") + self.copyright = resp.get("copyright") + self.albumartist = resp.get("artist", {}).get("name") elif self.__source == "deezer": raise NotImplementedError else: @@ -130,8 +127,8 @@ class TrackMetadata: elif self.__source == "tidal": self.title = track.get("title").strip() self._mod_title(track.get("version"), None) - self.tracknumber = track.get("trackNumber") - self.discnumber = track.get("volumeNumber") + self.tracknumber = str(track.get("trackNumber")) + self.discnumber = str(track.get("volumeNumber")) self.artist = track.get("artist", {}).get("name") elif self.__source == "deezer": raise NotImplementedError @@ -284,7 +281,7 @@ class TrackMetadata: """ for k, v in FLAC_KEY.items(): tag = getattr(self, k) - if tag is not None and not k.startswith("_"): + if tag is not None: yield (v, tag) def __gen_mp3_tags(self) -> Tuple[str, str]: @@ -300,7 +297,7 @@ class TrackMetadata: else: text = getattr(self, k) - if text is not None and not k.startswith("_"): + if text is not None: yield (v.__name__, v(encoding=3, text=text)) def __mp4_tags(self) -> Tuple[str, str]: