diff --git a/qobuz_dl_rewrite/clients.py b/qobuz_dl_rewrite/clients.py index a6931ea..92a9359 100644 --- a/qobuz_dl_rewrite/clients.py +++ b/qobuz_dl_rewrite/clients.py @@ -193,7 +193,7 @@ class QobuzClient(SecureClientInterface): @property def source(self): - return 'qobuz' + return "qobuz" # ---------- Private Methods --------------- @@ -387,7 +387,7 @@ class DeezerClient(ClientInterface): @staticmethod def get_file_url(meta_id: Union[str, int], quality: int = 6): quality = min(DEEZER_MAX_Q, quality) - url = f'{DEEZER_DL}/{DEEZER_Q_IDS[quality]}/{DEEZER_BASE}/track/{meta_id}' + url = f"{DEEZER_DL}/{DEEZER_Q_IDS[quality]}/{DEEZER_BASE}/track/{meta_id}" logger.debug(f"Download url {url}") return url diff --git a/qobuz_dl_rewrite/core.py b/qobuz_dl_rewrite/core.py index aed6a2f..ee0fbab 100644 --- a/qobuz_dl_rewrite/core.py +++ b/qobuz_dl_rewrite/core.py @@ -31,7 +31,7 @@ class QobuzDL: ): self.client = QobuzClient() self.client.login(creds[0], creds[1]) - self.qobuz_url_parse = re.compile(QOBUZ_URL_REGEX) + self.url_parse = re.compile(QOBUZ_URL_REGEX) def handle_url(self, url: str): url_type, item_id = self.parse_url(url) @@ -53,7 +53,7 @@ class QobuzDL: :raises exceptions.ParsingError """ - parsed = self.qobuz_url_parse.search(url) + parsed = self.url_parse.search(url) if parsed is not None: parsed = parsed.groups() diff --git a/qobuz_dl_rewrite/downloader.py b/qobuz_dl_rewrite/downloader.py index 5da5c8c..69fb05c 100644 --- a/qobuz_dl_rewrite/downloader.py +++ b/qobuz_dl_rewrite/downloader.py @@ -1,8 +1,9 @@ import logging import os +import re import shutil from abc import ABC, abstractmethod -from pprint import pformat, pprint +from pprint import pprint from tempfile import gettempdir from typing import Any, Optional, Union @@ -33,6 +34,11 @@ TIDAL_Q_MAP = { COVER_SIZES = ("thumbnail", "small", "large") +TYPE_REGEXES = { + "remaster": re.compile(r"(?i)(re)?master(ed)?"), + "extra": re.compile(r"(?i)(anniversary|deluxe|live|collector|demo|expanded)"), +} + class Track: """Represents a downloadable track. @@ -130,17 +136,17 @@ class Track: self.temp_file = os.path.join(gettempdir(), "~qdl_track.tmp") logger.debug("Temporary file path: %s", self.temp_file) - if self.client.source == 'qobuz': + if self.client.source == "qobuz": if not (dl_info.get("url") or dl_info.get("sampling_rate")) or dl_info.get( "sample" ): logger.debug("Track is a sample: %s", dl_info) return - if self.client.source in ('qobuz', 'tidal'): + if self.client.source in ("qobuz", "tidal"): logger.debug("Downloadable URL found: %s", dl_info.get("url")) tqdm_download(dl_info["url"], self.temp_file) # downloads file - elif self.client.source == 'deezer': + elif self.client.source == "deezer": logger.debug("Downloadable URL found: %s", dl_info) tqdm_download(dl_info, self.temp_file) # downloads file else: @@ -419,6 +425,15 @@ class Tracklist(list, ABC): def download(self, **kwargs): pass + @staticmethod + def essence(album: dict) -> str: + """Ignore text in parens/brackets, return all lowercase. + Used to group two albums that may be named similarly, but not exactly + the same. + """ + r = re.match(r"([^\(]+)(?:\s*[\(\[][^\)][\)\]])*", album) + return r.group(1).strip().lower() + class Album(Tracklist): """Represents a downloadable Qobuz album.""" @@ -466,13 +481,15 @@ class Album(Tracklist): "id": resp.get("id"), "title": resp.get("title"), "_artist": resp.get("artist") or resp.get("performer"), - "albumartist": resp.get("name"), + "albumartist": resp.get("artist", {}).get("name"), "version": resp.get("version"), "cover_urls": resp.get("image"), "streamable": resp.get("streamable"), "quality": quality_id( resp.get("maximum_bit_depth"), resp.get("maximum_sampling_rate") ), + "bit_depth": resp.get("maximum_bit_depth"), + "sampling_rate": resp.get("maximum_sampling_rate") * 1000, "tracktotal": resp.get("tracks_count"), } elif client.source == "tidal": @@ -490,6 +507,8 @@ class Album(Tracklist): }, "streamable": resp.get("allowStreaming"), "quality": TIDAL_Q_MAP[resp.get("audioQuality")], + "bit_depth": 16, + "sampling_rate": 44100, "tracktotal": resp.get("numberOfTracks"), } elif client.source == "deezer": @@ -508,6 +527,8 @@ class Album(Tracklist): "url": resp.get("link"), "streamable": True, # api only returns streamables "quality": 6, # all tracks are 16/44.1 streamable + "bit_depth": 16, + "sampling_rate": 44100, "tracktotal": resp.get("track_total"), } else: @@ -523,7 +544,6 @@ class Album(Tracklist): 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( @@ -594,13 +614,13 @@ class Album(Tracklist): # create a single cover object and use them for all tracks # TODO: avoid this method if embeded covers are not requested - if self.client.source != 'deezer': + if self.client.source != "deezer": cover = self.get_cover_obj(cover_path, quality) for track in self: logger.debug("Downloading track to %s with quality %s", folder, quality) track.download(quality, folder, progress_bar) - if tag_tracks and self.client.source != 'deezer': + if tag_tracks and self.client.source != "deezer": logger.debug("Tagging track") track.tag(cover=cover) @@ -699,17 +719,47 @@ class Artist(Tracklist): self.load_meta() def load_meta(self): - response = self.client.get(self.id, media_type="artist") - for page in response: # hacky solution, fix later - self.meta = page - self._load_albums() + self.meta = self.client.get(self.id, media_type="artist") + self._load_albums() self.name = self.meta.get("name") def _load_albums(self): for album in self.meta.get("albums", {}).get("items", []): logger.debug("Appending album: %s", album.get("title")) - self.append(Album(self.client, **album)) + self.append(Album(self.client, **Album._parse_get_resp(album, self.client))) + + def download(self, filters=None, no_repeats=False, quality=7): + logger.debug(f"Length of tracklist {len(self)}") + if no_repeats: + final = self._remove_repeats(bit_depth=max, sampling_rate=min) + else: + final = self + + if filters is not None and self.client.source == "qobuz": + if type(filters) in (tuple, list): + for f in filters: + + def inter(album): + """Intermediate function to pass self into f""" + return f(self, album) + + final = filter(inter, final) + else: + + def inter(album): + return filters(self, album) + + final = filter(inter, final) + + i = 0 + for album in final: + i += 1 + logger.debug(f"Downloading album {album}") + album.load_meta() + album.download(quality=quality) + + logger.debug(f"{i} albums downloaded") @classmethod def from_api(cls, item: dict, client: ClientInterface, source: str = "qobuz"): @@ -739,6 +789,33 @@ class Artist(Tracklist): # equivalent to Artist(client=client, **info) return cls(client=client, **info) + def _remove_repeats(self, bit_depth=max, sampling_rate=max): + groups = dict() + for album in self: + if (t := self.essence(album.title)) not in groups: + groups[t] = [] + groups[t].append(album) + + for group in groups.values(): + assert bit_depth in (min, max) and sampling_rate in (min, max) + best_bd = bit_depth(a["bit_depth"] for a in group) + best_sr = sampling_rate(a["sampling_rate"] for a in group) + for album in group: + if album["bit_depth"] == best_bd and album["sampling_rate"] == best_sr: + yield album + break + + @staticmethod + def studio_albums(artist, album): + return ( + album["albumartist"] != "Various Artists" + and TYPE_REGEXES['extra'].search(album.title) is None + ) + + @staticmethod + def no_features(artist, album): + return artist["name"] == album["albumartist"] + def __repr__(self) -> str: """Return a string representation of this Artist object. Useful for pprint and json.dumps. diff --git a/qobuz_dl_rewrite/util.py b/qobuz_dl_rewrite/util.py index b5b04f2..fd8fe92 100644 --- a/qobuz_dl_rewrite/util.py +++ b/qobuz_dl_rewrite/util.py @@ -1,7 +1,7 @@ -import sys import logging import logging.handlers as handlers import os +import sys from typing import Optional, Union import requests