diff --git a/music_dl/cli.py b/music_dl/cli.py index c0bf1b4..33a7ce2 100644 --- a/music_dl/cli.py +++ b/music_dl/cli.py @@ -1,8 +1,8 @@ # For tests import logging -from getpass import getpass import os +from getpass import getpass import click @@ -13,23 +13,25 @@ from .core import MusicDL logger = logging.getLogger(__name__) config = Config(CONFIG_PATH) +if not os.path.isdir(CONFIG_DIR): + os.makedirs(CONFIG_DIR) +if not os.path.isdir(CACHE_DIR): + os.makedirs(CONFIG_DIR) + +config = Config(CONFIG_PATH) + def _get_config(ctx): - print(f"{ctx.obj=}") - if not os.path.isdir(CONFIG_DIR): - os.makedirs(CONFIG_DIR) - if not os.path.isdir(CACHE_DIR): - os.makedirs(CONFIG_DIR) - - config = Config(CONFIG_PATH) - config.update_from_cli(**ctx.obj) - return config + config.update_from_cli(**ctx.params) @click.group() @click.option("--debug", default=False, is_flag=True, help="Enable debug logging") -@click.option("--flush-cache", metavar="PATH", help="Flush the cache before running (only for extreme cases)") -@click.option("-c", '--convert', metavar='CODEC') +@click.option( + "--flush-cache", + metavar="PATH", + help="Flush the cache before running (only for extreme cases)", +) @click.pass_context def cli(ctx, **kwargs): """cli. @@ -46,18 +48,20 @@ def cli(ctx, **kwargs): > download discography with given filters """ - print(f"{ctx=}") - print(f"{kwargs=}") + pass @click.command(name="dl") @click.option("-q", "--quality", metavar="INT", help="Quality integer ID (5, 6, 7, 27)") @click.option("-f", "--folder", metavar="PATH", help="Custom download folder") -@click.option("-s", "--search", metavar='QUERY') +@click.option("-s", "--search", metavar="QUERY") @click.option("-nd", "--no-db", is_flag=True) +@click.option("-c", "--convert", metavar="CODEC") +@click.option("-sr", "--sampling-rate", metavar="INT") +@click.option("-bd", "--bit-depth", metavar="INT") @click.argument("items", nargs=-1) @click.pass_context -def download(ctx, quality, folder, search, items): +def download(ctx, **kwargs): """ Download an URL, space separated URLs or a text file with URLs. Mixed arguments are also supported. @@ -78,10 +82,9 @@ def download(ctx, quality, folder, search, items): * Tidal (album, artist, track, playlist) """ - ctx.ensure_object(dict) config = _get_config(ctx) - core = MusicDL(config) - for item in items: + core = MusicDL(config, database=list() if kwargs["no_db"] else None) + for item in kwargs["items"]: try: if os.path.isfile(item): core.from_txt(item) @@ -94,11 +97,18 @@ def download(ctx, quality, folder, search, items): f"{type(error).__name__} raised processing {item}: {error}", fg="red" ) + if ctx.params["convert"] is not None: + core.convert_all( + ctx.params["convert"], + sampling_rate=ctx.params["sampling_rate"], + bit_depth=ctx.params["bit_depth"], + ) -@click.command(name='config') -@click.option('-o', "--open", is_flag=True) -@click.option("-q", '--qobuz', is_flag=True) -@click.option("-t", '--tidal', is_flag=True) + +@click.command(name="config") +@click.option("-o", "--open", is_flag=True) +@click.option("-q", "--qobuz", is_flag=True) +@click.option("-t", "--tidal", is_flag=True) def edit_config(open, qobuz, tidal): if open: # open in text editor @@ -106,34 +116,30 @@ def edit_config(open, qobuz, tidal): return if qobuz: - config['qobuz']['email'] = input("Qobuz email: ") - config['qobuz']['password'] = getpass("Qobuz password: ") + config["qobuz"]["email"] = input("Qobuz email: ") + config["qobuz"]["password"] = getpass("Qobuz password: ") config.save() - click.secho(f"Config saved at {CONFIG_PATH}", fg='green') + click.secho(f"Config saved at {CONFIG_PATH}", fg="green") if tidal: - config['tidal']['email'] = input("Tidal email: ") - config['tidal']['password'] = getpass("Tidal password: ") + config["tidal"]["email"] = input("Tidal email: ") + config["tidal"]["password"] = getpass("Tidal password: ") config.save() - click.secho(f"Config saved at {CONFIG_PATH}", fg='green') + click.secho(f"Config saved at {CONFIG_PATH}", fg="green") @click.command() -@click.option("-t", '--type', default='album', - help='Type to search for. Can be album, artist, playlist, track') +@click.option( + "-t", + "--type", + default="album", + help="Type to search for. Can be album, artist, playlist, track", +) @click.argument("QUERY") def search(media_type, query): print(f"searching for {media_type} with {query=}") -@click.command() -@click.option("-sr", '--sampling-rate') -@click.option("-bd", "--bit-depth") -@click.argument("codec") -def convert(sampling_rate, bit_depth, codec): - print(codec, sampling_rate, bit_depth) - - @click.command() def interactive(): pass @@ -150,8 +156,10 @@ def filter(*args): @click.command() -@click.option("--default-comment", metavar="COMMENT", help="Custom comment tag for audio files") -@click.option("--no-cover", help='Do not embed cover into audio file.') +@click.option( + "--default-comment", metavar="COMMENT", help="Custom comment tag for audio files" +) +@click.option("--no-cover", help="Do not embed cover into audio file.") def tags(default_comment, no_cover): print(f"{default_comment=}, {no_cover=}") @@ -161,8 +169,7 @@ def main(): cli.add_command(filter) cli.add_command(tags) cli.add_command(edit_config) - cli.add_command(convert) - cli(obj={}) + cli() if __name__ == "__main__": diff --git a/music_dl/clients.py b/music_dl/clients.py index 91790b0..16e61ec 100644 --- a/music_dl/clients.py +++ b/music_dl/clients.py @@ -103,6 +103,8 @@ class ClientInterface(ABC): class QobuzClient(ClientInterface): + source = "qobuz" + # ------- Public Methods ------------- def __init__(self): self.logged_in = False @@ -193,10 +195,6 @@ class QobuzClient(ClientInterface): def get_file_url(self, item_id, quality=6) -> dict: return self._api_get_file_url(item_id, quality=quality) - @property - def source(self): - return "qobuz" - # ---------- Private Methods --------------- # Credit to Sorrow446 for the original methods @@ -355,6 +353,8 @@ class QobuzClient(ClientInterface): class DeezerClient(ClientInterface): + source = "deezer" + def __init__(self): self.session = requests.Session() self.logged_in = True @@ -412,12 +412,10 @@ class DeezerClient(ClientInterface): logger.debug(f"Download url {url}") return url - @property - def source(self): - return "deezer" - class TidalClient(ClientInterface): + source = "tidal" + def __init__(self): self.logged_in = False @@ -468,10 +466,6 @@ class TidalClient(ClientInterface): 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 = { "query": query, diff --git a/music_dl/config.py b/music_dl/config.py index d28ec1e..43123d0 100644 --- a/music_dl/config.py +++ b/music_dl/config.py @@ -46,6 +46,7 @@ class Config: self.tidal = {"enabled": True, "email": None, "password": None} self.deezer = {"enabled": True} self.downloads_database = None + self.conversion = {"codec": None, "sampling_rate": None, "bit_depth": None} self.filters = { "no_extras": False, "albums_only": False, @@ -55,7 +56,7 @@ class Config: } self.downloads = {"folder": folder, "quality": quality} self.metadata = { - "embed_cover": False, + "embed_cover": True, "large_cover": False, "default_comment": None, "remove_extra_tags": False, diff --git a/music_dl/core.py b/music_dl/core.py index f5cde19..586dcd1 100644 --- a/music_dl/core.py +++ b/music_dl/core.py @@ -5,26 +5,33 @@ from getpass import getpass from typing import Generator, Optional, Tuple, Union import click +from tqdm import tqdm from .clients import DeezerClient, QobuzClient, TidalClient from .config import Config from .constants import CONFIG_PATH, DB_PATH, URL_REGEX from .db import MusicDB -from .downloader import Album, Artist, Playlist, Track, Label +from .downloader import Album, Artist, Label, Playlist, Track from .exceptions import AuthenticationError, ParsingError from .utils import capitalize logger = logging.getLogger(__name__) -MEDIA_CLASS = {"album": Album, "playlist": Playlist, "artist": Artist, "track": Track, "label": Label} +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 # TODO: add support for database -class MusicDL: +class MusicDL(list): def __init__( self, config: Optional[Config] = None, @@ -43,11 +50,10 @@ class MusicDL: "deezer": DeezerClient(), } - if database is None: - self.db = MusicDB(DB_PATH) - else: - assert isinstance(database, MusicDB) + if isinstance(database, (MusicDB, list)): self.db = database + elif database is None: + self.db = MusicDB(DB_PATH) def prompt_creds(self, source: str): """Prompt the user for credentials. @@ -105,16 +111,10 @@ class MusicDL: } 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) + self.login(client) 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 @@ -125,8 +125,35 @@ class MusicDL: logger.debug("Arguments from config: %s", arguments) item.load_meta() + click.secho(f"Downloading {item!s}") item.download(**arguments) + def convert_all(self, codec, **kwargs): + click.secho("Converting the downloaded tracks...", fg="cyan") + for item in self: + item.convert(codec, **kwargs) + + def login(self, client): + creds = self.config.creds(client.source) + if not client.logged_in: + while True: + try: + client.login(**creds) + break + except AuthenticationError: + click.secho("Invalid credentials, try again.") + self.prompt_creds(client.source) + if ( + client.source == "qobuz" + and not creds.get("secrets") + and not creds.get("app_id") + ): + ( + self.config["qobuz"]["app_id"], + self.config["qobuz"]["secrets"], + ) = client.get_tokens() + self.config.save() + def parse_url(self, url: str) -> Tuple[str, str]: """Returns the type of the url and the id. diff --git a/music_dl/downloader.py b/music_dl/downloader.py index 6a50bb7..06f96fd 100644 --- a/music_dl/downloader.py +++ b/music_dl/downloader.py @@ -159,8 +159,16 @@ class Track: os.makedirs(self.folder, exist_ok=True) - assert database is not None # remove this later - if os.path.isfile(self.format_final_path()) or self.id in database: + if database is not None: + if self.id in database: + self.__is_downloaded = True + self.__is_tagged = True + click.secho( + f"{self['title']} already logged in database, skipping.", fg="green" + ) + return + + if os.path.isfile(self.format_final_path()): self.__is_downloaded = True self.__is_tagged = True click.secho(f"Track already downloaded: {self.final_path}", fg="green") @@ -189,6 +197,8 @@ class Track: self.__is_downloaded = True self.__is_tagged = False + click.secho(f"\nDownloading {self!s}", fg="blue") + if self.client.source in ("qobuz", "tidal"): logger.debug("Downloadable URL found: %s", dl_info.get("url")) tqdm_download(dl_info["url"], temp_file) # downloads file @@ -199,7 +209,11 @@ class Track: raise InvalidSourceError(self.client.source) shutil.move(temp_file, self.final_path) - database.add(self.id) + + if isinstance(database, MusicDB): + database.add(self.id) + logger.debug(f"{self.id} added to database") + logger.debug("Downloaded: %s -> %s", temp_file, self.final_path) self.__is_downloaded = True @@ -379,6 +393,8 @@ class Track: } self.container = codec.upper() + if not hasattr(self, "final_path"): + self.format_final_path() engine = CONV_CLASS[codec.upper()]( filename=self.final_path, @@ -426,6 +442,14 @@ class Track: """ return f"" + def __str__(self) -> str: + """Return a readable string representation of + this track. + + :rtype: str + """ + return f"{self['artist']} - {self['title']}" + class Tracklist(list, ABC): """A base class for tracklist-like objects. @@ -818,6 +842,14 @@ class Album(Tracklist): return f"" + def __str__(self) -> str: + """Return a readable string representation of + this album. + + :rtype: str + """ + return f"{self['albumartist']} - {self['title']}" + class Playlist(Tracklist): """Represents a downloadable Qobuz playlist. @@ -990,6 +1022,14 @@ class Playlist(Tracklist): """ return f"" + def __str__(self) -> str: + """Return a readable string representation of + this track. + + :rtype: str + """ + return f"{self.name} ({len(self)} tracks)" + class Artist(Tracklist): """Represents a downloadable artist. @@ -1255,6 +1295,14 @@ class Artist(Tracklist): """ return f"" + def __str__(self) -> str: + """Return a readable string representation of + this Artist. + + :rtype: str + """ + return self.name + class Label(Artist): def load_meta(self): @@ -1268,3 +1316,11 @@ class Label(Artist): def __repr__(self): return f"