diff --git a/qobuz_dl_rewrite/cli.py b/qobuz_dl_rewrite/cli.py index 2324b1e..7a69500 100644 --- a/qobuz_dl_rewrite/cli.py +++ b/qobuz_dl_rewrite/cli.py @@ -84,7 +84,6 @@ def download(ctx, items): """ config = _get_config(ctx) core = QobuzDL(config) - core.load_creds() for item in items: try: if os.path.isfile(item): diff --git a/qobuz_dl_rewrite/clients.py b/qobuz_dl_rewrite/clients.py index ac857a7..91790b0 100644 --- a/qobuz_dl_rewrite/clients.py +++ b/qobuz_dl_rewrite/clients.py @@ -124,7 +124,7 @@ class QobuzClient(ClientInterface): logger.debug("Already logged in") return - if (kwargs.get("app_id") or kwargs.get("secrets")) in (None, [""]): + if (kwargs.get("app_id") or kwargs.get("secrets")) in (None, [], ""): logger.info("Fetching tokens from Qobuz") spoofer = Spoofer() kwargs["app_id"] = spoofer.get_app_id() diff --git a/qobuz_dl_rewrite/config.py b/qobuz_dl_rewrite/config.py index e1a1b83..e5ae086 100644 --- a/qobuz_dl_rewrite/config.py +++ b/qobuz_dl_rewrite/config.py @@ -4,7 +4,7 @@ from pprint import pformat from ruamel.yaml import YAML -from .constants import FOLDER_FORMAT, TRACK_FORMAT +from .constants import CONFIG_PATH, FOLDER_FORMAT, TRACK_FORMAT from .exceptions import InvalidSourceError yaml = YAML() @@ -62,7 +62,10 @@ class Config: } self.path_format = {"folder": folder_format, "track": track_format} - self._path = path + if path is None: + self._path = CONFIG_PATH + else: + self._path = path if not os.path.exists(self._path): logger.debug(f"Creating yaml config file at {self._path}") @@ -70,7 +73,7 @@ class Config: else: # sometimes the file gets erased, this will reset it with open(self._path) as f: - if f.read().strip() == '': + if f.read().strip() == "": logger.debug(f"Config file {self._path} corrupted, resetting.") self.dump(self.info) else: @@ -139,7 +142,7 @@ class Config: @property def info(self): - return {k: v for k, v in self.__dict__.items() if not k.startswith('_')} + return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} @info.setter def info(self, val): diff --git a/qobuz_dl_rewrite/constants.py b/qobuz_dl_rewrite/constants.py index 915b46c..fdcd996 100644 --- a/qobuz_dl_rewrite/constants.py +++ b/qobuz_dl_rewrite/constants.py @@ -128,10 +128,9 @@ FOLDER_FORMAT = ( ) TRACK_FORMAT = "{tracknumber}. {artist} - {title}" -QOBUZ_URL_REGEX = ( - r"(?:https:\/\/(?:w{3}|open|play)\.qobuz\.com)?" - r"(?:\/[a-z]{2}-[a-z]{2})?\/(album|artist|track|playlist|label)(?:" - r"\/[-\w\d]+)?\/([\w\d]+)" +URL_REGEX = ( + r"https:\/\/(?:www|open|play)?\.?(\w+)\.com(?:(?:\/(track|playlist|album|" + r"artist|label))|(?:\/[-\w]+?))+\/(\w+)" ) diff --git a/qobuz_dl_rewrite/core.py b/qobuz_dl_rewrite/core.py index 9919a8a..3ab2e40 100644 --- a/qobuz_dl_rewrite/core.py +++ b/qobuz_dl_rewrite/core.py @@ -1,21 +1,25 @@ +import hashlib +import inspect import logging import os import re -from typing import Generator, Optional, Sequence, Tuple, Union +from getpass import getpass +from typing import Generator, Optional, Tuple, Union import click from .clients import DeezerClient, QobuzClient, TidalClient from .config import Config -from .constants import CONFIG_PATH, DB_PATH, QOBUZ_URL_REGEX +from .constants import CONFIG_PATH, DB_PATH, URL_REGEX from .db import QobuzDB -from .downloader import Album, Artist, Playlist, Track -from .exceptions import InvalidSourceError, ParsingError +from .downloader import Album, Artist, Playlist, Track, Label +from .exceptions import AuthenticationError, ParsingError +from .utils import capitalize logger = logging.getLogger(__name__) -MEDIA_CLASS = {"album": Album, "playlist": Playlist, "artist": Artist, "track": Track} +MEDIA_CLASS = {"album": Album, "playlist": Playlist, "artist": Artist, "track": Track, "label": Label} CLIENTS = {"qobuz": QobuzClient, "tidal": TidalClient, "deezer": DeezerClient} Media = Union[Album, Playlist, Artist, Track] # type hint @@ -26,20 +30,20 @@ class QobuzDL: def __init__( self, config: Optional[Config] = None, - source: str = "qobuz", database: Optional[str] = None, ): logger.debug(locals()) - self.source = source - self.url_parse = re.compile(QOBUZ_URL_REGEX) + self.url_parse = re.compile(URL_REGEX) self.config = config if self.config is None: self.config = Config(CONFIG_PATH) - self.client = CLIENTS[source]() - - logger.debug("Using client: %s", self.client) + self.clients = { + "qobuz": QobuzClient(), + "tidal": TidalClient(), + "deezer": DeezerClient(), + } if database is None: self.db = QobuzDB(DB_PATH) @@ -47,17 +51,36 @@ class QobuzDL: assert isinstance(database, QobuzDB) self.db = database - def load_creds(self): - if isinstance(self.client, (QobuzClient, TidalClient)): - creds = self.config.creds(self.source) - if not creds.get("app_id") and isinstance(self.client, QobuzClient): - self.client.login(**creds) - app_id, secrets = self.client.get_tokens() - self.config["qobuz"]["app_id"] = app_id - self.config["qobuz"]["secrets"] = secrets - self.config.save() - else: - self.client.login(**creds) + def prompt_creds(self, source: str): + """Prompt the user for credentials. + + :param source: + :type source: str + """ + click.secho(f"Enter {capitalize(source)} email:", fg="green") + self.config[source]["email"] = input() + click.secho( + f"Enter {capitalize(source)} password (will not show on screen):", + fg="green", + ) + self.config[source]["password"] = getpass( + prompt="" + ) # does hashing work for tidal? + + self.config.save() + click.secho(f'Credentials saved to config file at "{self.config._path}"') + + def assert_creds(self, source: str): + assert source in ("qobuz", "tidal", "deezer"), f"Invalid source {source}" + if source == "deezer": + # no login for deezer + return + + if ( + self.config[source]["email"] is None + or self.config[source]["password"] is None + ): + self.prompt_creds(source) def handle_url(self, url: str): """Download an url @@ -67,23 +90,36 @@ class QobuzDL: :raises InvalidSourceError :raises ParsingError """ - assert self.source in url, f"{url} is not a {self.source} source" - url_type, item_id = self.parse_url(url) + source, url_type, item_id = self.parse_url(url) if item_id in self.db: logger.info(f"{url} already downloaded, use --no-db to override.") return - self.handle_item(url_type, item_id) + self.handle_item(source, url_type, item_id) + + def handle_item(self, source: str, media_type: str, item_id: str): + self.assert_creds(source) - def handle_item(self, media_type, item_id): arguments = { + "database": self.db, "parent_folder": self.config.downloads["folder"], "quality": self.config.downloads["quality"], "embed_cover": self.config.metadata["embed_cover"], } - item = MEDIA_CLASS[media_type](client=self.client, id=item_id) + client = self.clients[source] + if not client.logged_in: + while True: + try: + client.login(**self.config.creds(source)) + break + except AuthenticationError: + click.secho("Invalid credentials, try again.") + self.prompt_creds(source) + + item = MEDIA_CLASS[media_type](client=client, id=item_id) if isinstance(item, Artist): keys = self.config.filters.keys() + # TODO: move this to config.py filters_ = tuple(key for key in keys if self.config.filters[key]) arguments["filters"] = filters_ logger.debug("Added filter argument for artist/label: %s", filters_) @@ -112,7 +148,7 @@ class QobuzDL: if parsed is not None: parsed = parsed.groups() - if len(parsed) == 2: + if len(parsed) == 3: return tuple(parsed) # Convert from Seq for the sake of typing raise ParsingError(f"Error parsing URL: `{url}`") diff --git a/qobuz_dl_rewrite/db.py b/qobuz_dl_rewrite/db.py index 7b1c69b..f7392c4 100644 --- a/qobuz_dl_rewrite/db.py +++ b/qobuz_dl_rewrite/db.py @@ -16,6 +16,8 @@ class QobuzDB: :type db_path: Union[str, os.PathLike] """ self.path = db_path + if not os.path.exists(self.path): + self.create() def create(self): """Create a database at `self.path`""" diff --git a/qobuz_dl_rewrite/downloader.py b/qobuz_dl_rewrite/downloader.py index ae1e4ed..4daeb9a 100644 --- a/qobuz_dl_rewrite/downloader.py +++ b/qobuz_dl_rewrite/downloader.py @@ -1,8 +1,8 @@ -import sys import logging import os import re import shutil +import sys from abc import ABC, abstractmethod from pprint import pprint from tempfile import gettempdir @@ -23,6 +23,7 @@ from .constants import ( FOLDER_FORMAT, TRACK_FORMAT, ) +from .db import QobuzDB from .exceptions import ( InvalidQuality, InvalidSourceError, @@ -138,6 +139,7 @@ class Track: quality: int = 7, parent_folder: str = "Downloads", progress_bar: bool = True, + database: QobuzDB = None, ): """ Download the track. @@ -157,13 +159,14 @@ class Track: os.makedirs(self.folder, exist_ok=True) - if os.path.isfile(self.format_final_path()): + assert database is not None # remove this later + if os.path.isfile(self.format_final_path()) or self.id in database: self.__is_downloaded = True self.__is_tagged = True click.secho(f"Track already downloaded: {self.final_path}", fg="green") return False - if hasattr(self, "cover_url") and self.embed_cover: + if hasattr(self, "cover_url"): self.download_cover() dl_info = self.client.get_file_url(self.id, quality) # dict @@ -196,6 +199,7 @@ class Track: raise InvalidSourceError(self.client.source) shutil.move(temp_file, self.final_path) + database.add(self.id) logger.debug("Downloaded: %s -> %s", temp_file, self.final_path) self.__is_downloaded = True @@ -206,7 +210,7 @@ class Track: assert hasattr(self, "cover_url"), "must pass cover_url parameter" - self.cover_path = os.path.join(self.folder, f"cover{hash(self.meta.title)}.jpg") + self.cover_path = os.path.join(self.folder, f"cover{hash(self.cover_url)}.jpg") logger.debug(f"Downloading cover from {self.cover_url}") if not os.path.exists(self.cover_path): tqdm_download(self.cover_url, self.cover_path) @@ -721,6 +725,7 @@ class Album(Tracklist): tag_tracks: bool = True, cover_key: str = "large", embed_cover: bool = False, + database: QobuzDB = None, ): """Download all of the tracks in the album. @@ -764,7 +769,7 @@ class Album(Tracklist): for track in self: logger.debug("Downloading track to %s", folder) - track.download(quality, folder, progress_bar) + track.download(quality, folder, progress_bar, database=database) if tag_tracks and self.client.source != "deezer": track.tag(cover=cover, embed_cover=embed_cover) @@ -780,10 +785,10 @@ class Album(Tracklist): else: dict_[key] = None - dict_['sampling_rate'] /= 1000 + dict_["sampling_rate"] /= 1000 # 48.0kHz -> 48kHz, 44.1kHz -> 44.1kHz - if dict_['sampling_rate'] % 1 == 0.0: - dict_['sampling_rate'] = int(dict_['sampling_rate']) + if dict_["sampling_rate"] % 1 == 0.0: + dict_["sampling_rate"] = int(dict_["sampling_rate"]) return dict_ @@ -930,6 +935,7 @@ class Playlist(Tracklist): quality: int = 6, filters: Callable = None, embed_cover: bool = False, + database: QobuzDB = None, ): """Download and tag all of the tracks. @@ -944,7 +950,7 @@ class Playlist(Tracklist): folder = os.path.join(parent_folder, folder) for track in self: - track.download(parent_folder=folder, quality=quality) + track.download(parent_folder=folder, quality=quality, database=database) if self.client.source != "deezer": track.tag(embed_cover=embed_cover) @@ -1046,6 +1052,7 @@ class Artist(Tracklist): no_repeats: bool = False, quality: int = 6, embed_cover: bool = False, + database: QobuzDB = None, ): """Download all albums in the discography. @@ -1088,7 +1095,10 @@ class Artist(Tracklist): except NonStreamable: logger.info("Skipping album, not available to stream.") album.download( - parent_folder=folder, quality=quality, embed_cover=embed_cover + parent_folder=folder, + quality=quality, + embed_cover=embed_cover, + database=database, ) logger.debug(f"{i} albums downloaded") diff --git a/qobuz_dl_rewrite/utils.py b/qobuz_dl_rewrite/utils.py index 4a87432..9d814cd 100644 --- a/qobuz_dl_rewrite/utils.py +++ b/qobuz_dl_rewrite/utils.py @@ -148,3 +148,7 @@ def init_log( logging.getLogger("urllib3").setLevel(logging.WARNING) logging.getLogger("tidal_api").setLevel(logging.WARNING) + + +def capitalize(s: str) -> str: + return s[0].upper() + s[1:]