diff --git a/music_dl/cli.py b/music_dl/cli.py index 28c596c..c2b4bc2 100644 --- a/music_dl/cli.py +++ b/music_dl/cli.py @@ -1,13 +1,10 @@ -# For tests - import logging import os -from pprint import pformat import click from .config import Config -from .constants import CACHE_DIR, CONFIG_DIR, CONFIG_PATH, QOBUZ_FEATURED_KEYS +from .constants import CACHE_DIR, CONFIG_DIR, CONFIG_PATH from .core import MusicDL from .utils import init_log @@ -22,7 +19,10 @@ if not os.path.isdir(CACHE_DIR): @click.group(invoke_without_command=True) @click.option("-c", "--convert", metavar="CODEC") -@click.option("-u", '--urls', metavar='URLS') +@click.option("-u", "--urls", metavar="URLS") +@click.option("-nd", "--no-db", is_flag=True) +@click.option("--debug", is_flag=True) +@click.option("--reset-config", is_flag=True) @click.pass_context def cli(ctx, **kwargs): """ @@ -56,9 +56,29 @@ def cli(ctx, **kwargs): global config global core + if kwargs["debug"]: + init_log() + config = Config() + if kwargs["reset_config"]: + config.reset() + return + + if kwargs["no_db"]: + config.session["database"]["enabled"] = False + if kwargs["convert"]: + config.session["conversion"]["enabled"] = True + config.session["conversion"]["codec"] = kwargs["convert"] + core = MusicDL(config) + if kwargs["urls"]: + logger.debug(f"handling {kwargs['urls']}") + core.handle_urls(kwargs["urls"]) + + if ctx.invoked_subcommand is None: + core.download() + @cli.command(name="filter") @click.option("--repeats", is_flag=True) @@ -79,28 +99,28 @@ def filter_discography(ctx, **kwargs): For basic filtering, use the `--repeats` and `--features` filters. """ - filters = [k for k, v in kwargs.items() if v] - filters.remove('urls') - print(f"loaded filters {filters}") + filters = kwargs.copy() + filters.remove("urls") config.session["filters"] = filters - print(f"downloading {kwargs['urls']} with filters") + logger.debug(f"downloading {kwargs['urls']} with filters {filters}") + core.handle_urls(" ".join(kwargs["urls"])) + core.download() @cli.command() @click.option("-t", "--type", default="album") +@click.option("-s", "--source", default="qobuz") @click.option("-d", "--discover", is_flag=True) @click.argument("QUERY", nargs=-1) @click.pass_context def interactive(ctx, **kwargs): - f"""Interactive search for a query. This will display a menu + """Interactive search for a query. This will display a menu from which you can choose an item to download. If the source is Qobuz, you can use the `--discover` option with one of the following queries to fetch and interactively download the featured albums. - {pformat(QOBUZ_FEATURED_KEYS)} - * most-streamed * recent-releases @@ -133,14 +153,18 @@ def interactive(ctx, **kwargs): """ - print(f"starting interactive mode for type {kwargs['type']}") - if kwargs['discover']: - if kwargs['query'] == (): - kwargs['query'] = 'ideal-discography' - print(f"doing a discover search of type {kwargs['query']}") + logger.debug(f"starting interactive mode for type {kwargs['type']}") + if kwargs["discover"]: + if kwargs["query"] == (): + kwargs["query"] = ["ideal-discography"] + kwargs["type"] = "featured" + + query = " ".join(kwargs["query"]) + + if core.interactive_search(query, kwargs["source"], kwargs["type"]): + core.download() else: - query = ' '.join(kwargs['query']) - print(f"searching for query '{query}'") + click.secho("No items chosen, exiting.", fg="bright_red") def parse_urls(arg: str): diff --git a/music_dl/config.py b/music_dl/config.py index e6deed2..008ed02 100644 --- a/music_dl/config.py +++ b/music_dl/config.py @@ -40,8 +40,13 @@ class Config: }, "tidal": {"enabled": True, "email": None, "password": None}, "deezer": {"enabled": True}, - "downloads_database": None, - "conversion": {"codec": None, "sampling_rate": None, "bit_depth": None}, + "database": {"enabled": True, "path": None}, + "conversion": { + "enabled": False, + "codec": None, + "sampling_rate": None, + "bit_depth": None, + }, "filters": { "extras": False, "repeats": False, @@ -74,15 +79,15 @@ class Config: logger.debug(f"Creating yaml config file at '{self._path}'") self.dump(self.defaults) else: - self.load_file() + self.load() - def save_file(self): + def save(self): self.dump(self.file) - def reset_file(self): + def reset(self): self.dump(self.defaults) - def load_file(self): + def load(self): with open(self._path) as cfg: for k, v in yaml.load(cfg).items(): self.file[k] = v diff --git a/music_dl/core.py b/music_dl/core.py index 9783bb2..b0af287 100644 --- a/music_dl/core.py +++ b/music_dl/core.py @@ -2,12 +2,12 @@ import logging import os import re from getpass import getpass +from pprint import pprint from string import Formatter from typing import Generator, Optional, Tuple, Union import click from simple_term_menu import TerminalMenu -from tqdm import tqdm from .clients import DeezerClient, QobuzClient, TidalClient from .config import Config @@ -30,16 +30,12 @@ MEDIA_CLASS = { CLIENTS = {"qobuz": QobuzClient, "tidal": TidalClient, "deezer": DeezerClient} Media = Union[Album, Playlist, Artist, Track] # type hint -# TODO: add support for database - class MusicDL(list): def __init__( self, config: Optional[Config] = None, - database: Optional[str] = None, ): - logger.debug(locals()) self.url_parse = re.compile(URL_REGEX) self.config = config @@ -52,10 +48,15 @@ class MusicDL(list): "deezer": DeezerClient(), } - if isinstance(database, (MusicDB, list)): - self.db = database - elif database is None: - self.db = MusicDB(DB_PATH) + if config.session["database"]["enabled"]: + if config.session["database"]["path"] is not None: + self.db = MusicDB(config.session["database"]["path"]) + else: + self.db = MusicDB(DB_PATH) + config.file["database"]["path"] = DB_PATH + config.save() + else: + self.db = [] def prompt_creds(self, source: str): """Prompt the user for credentials. @@ -88,7 +89,7 @@ class MusicDL(list): ): self.prompt_creds(source) - def handle_url(self, url: str): + def handle_urls(self, url: str): """Download an url :param url: @@ -100,34 +101,43 @@ class MusicDL(list): if item_id in self.db: logger.info(f"{url} already downloaded, use --no-db to override.") return + self.handle_item(source, url_type, item_id) def handle_item(self, source: str, media_type: str, item_id: str): self.assert_creds(source) - arguments = { - "database": self.db, - "parent_folder": self.config.downloads["folder"], - "quality": self.config.downloads["quality"], - "embed_cover": self.config.metadata["embed_cover"], - } - client = self.get_client(source) item = MEDIA_CLASS[media_type](client=client, id=item_id) self.append(item) - 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_) + def download(self): + arguments = { + "database": self.db, + "parent_folder": self.config.session["downloads"]["folder"], + "quality": self.config.session["downloads"]["quality"], + "embed_cover": self.config.session["metadata"]["embed_cover"], + } logger.debug("Arguments from config: %s", arguments) + for item in self: + if isinstance(item, Artist): + filters_ = tuple( + k for k, v in self.config.session["filters"].items() if v + ) + arguments["filters"] = filters_ + logger.debug("Added filter argument for artist/label: %s", filters_) - item.load_meta() - click.secho(f"Downloading {item!s}", fg="bright_green") - item.download(**arguments) + item.load_meta() + click.secho(f"Downloading {item!s}", fg="bright_green") + item.download(**arguments) + pprint(self.config.session["conversion"]) + if self.config.session["conversion"]["enabled"]: + click.secho( + f"Converting {item!s} to {self.config.session['conversion']['codec']}", + fg="cyan", + ) + item.convert(**self.config.session["conversion"]) def get_client(self, source: str): client = self.clients[source] @@ -157,8 +167,8 @@ class MusicDL(list): and not creds.get("app_id") ): ( - self.config["qobuz"]["app_id"], - self.config["qobuz"]["secrets"], + self.config.file["qobuz"]["app_id"], + self.config.file["qobuz"]["secrets"], ) = client.get_tokens() self.config.save() @@ -208,8 +218,15 @@ class MusicDL(list): i = 0 if isinstance(results, Generator): # QobuzClient for page in results: - for item in page[f"{media_type}s"]["items"]: - yield MEDIA_CLASS[media_type].from_api(item, client) + tracklist = ( + page[f"{media_type}s"]["items"] + if media_type != "featured" + else page["albums"]["items"] + ) + for item in tracklist: + yield MEDIA_CLASS[ + media_type if media_type != "featured" else "album" + ].from_api(item, client) i += 1 if i > limit: return @@ -238,7 +255,7 @@ class MusicDL(list): def interactive_search( self, query: str, source: str = "qobuz", media_type: str = "album" ): - results = tuple(self.search(source, query, media_type, limit=30)) + results = tuple(self.search(source, query, media_type, limit=50)) def title(res): return f"{res[0]+1}. {res[1].title}" @@ -261,4 +278,8 @@ class MusicDL(list): clear_screen=True, ) choice = menu.show() - return results[choice] + if choice is None: + return False + else: + self.append(results[choice]) + return True diff --git a/music_dl/downloader.py b/music_dl/downloader.py index d71deea..7a80a5c 100644 --- a/music_dl/downloader.py +++ b/music_dl/downloader.py @@ -99,8 +99,8 @@ class Track: self.sampling_rate = 44100 self.bit_depth = 16 - self.__is_downloaded = False - self.__is_tagged = False + self._is_downloaded = False + self._is_tagged = False for attr in ("quality", "folder", "meta"): setattr(self, attr, None) @@ -155,14 +155,14 @@ class Track: quality or self.quality, parent_folder or self.folder, ) - self.folder = sanitize_filepath(parent_folder) + self.folder = sanitize_filepath(parent_folder, platform="auto") os.makedirs(self.folder, exist_ok=True) if database is not None: if self.id in database: - self.__is_downloaded = True - self.__is_tagged = True + self._is_downloaded = True + self._is_tagged = True click.secho( f"{self['title']} already logged in database, skipping.", fg="magenta", @@ -170,8 +170,8 @@ class Track: return if os.path.isfile(self.format_final_path()): - self.__is_downloaded = True - self.__is_tagged = True + self._is_downloaded = True + self._is_tagged = True click.secho(f"Track already downloaded: {self.final_path}", fg="magenta") return False @@ -195,8 +195,8 @@ class Track: if os.path.isfile(temp_file): logger.debug("Temporary file found: %s", temp_file) - self.__is_downloaded = True - self.__is_tagged = False + self._is_downloaded = True + self._is_tagged = False click.secho(f"\nDownloading {self!s}", fg="blue") @@ -217,7 +217,7 @@ class Track: logger.debug("Downloaded: %s -> %s", temp_file, self.final_path) - self.__is_downloaded = True + self._is_downloaded = True return True def download_cover(self): @@ -310,13 +310,13 @@ class Track: :type cover: Union[Picture, APIC] """ assert isinstance(self.meta, TrackMetadata), "meta must be TrackMetadata" - if not self.__is_downloaded: + if not self._is_downloaded: logger.info( "Track %s not tagged because it was not downloaded", self["title"] ) return - if self.__is_tagged: + if self._is_tagged: logger.info( "Track %s not tagged because it is already tagged", self["title"] ) @@ -361,7 +361,7 @@ class Track: else: raise ValueError(f"Unknown container type: {audio}") - self.__is_tagged = True + self._is_tagged = True def convert(self, codec: str = "ALAC", **kwargs): """Converts the track to another codec. @@ -380,7 +380,7 @@ class Track: :type codec: str :param kwargs: """ - assert self.__is_downloaded, "Track must be downloaded before conversion" + assert self._is_downloaded, "Track must be downloaded before conversion" CONV_CLASS = { "FLAC": converter.FLAC,