Added new download function, fixed featured bugs

This commit is contained in:
nathom 2021-03-24 18:42:33 -07:00
parent a46b9867b2
commit 6e93cc796c
4 changed files with 121 additions and 71 deletions

View file

@ -1,13 +1,10 @@
# For tests
import logging import logging
import os import os
from pprint import pformat
import click import click
from .config import Config 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 .core import MusicDL
from .utils import init_log from .utils import init_log
@ -22,7 +19,10 @@ if not os.path.isdir(CACHE_DIR):
@click.group(invoke_without_command=True) @click.group(invoke_without_command=True)
@click.option("-c", "--convert", metavar="CODEC") @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 @click.pass_context
def cli(ctx, **kwargs): def cli(ctx, **kwargs):
""" """
@ -56,9 +56,29 @@ def cli(ctx, **kwargs):
global config global config
global core global core
if kwargs["debug"]:
init_log()
config = Config() 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) 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") @cli.command(name="filter")
@click.option("--repeats", is_flag=True) @click.option("--repeats", is_flag=True)
@ -79,28 +99,28 @@ def filter_discography(ctx, **kwargs):
For basic filtering, use the `--repeats` and `--features` filters. For basic filtering, use the `--repeats` and `--features` filters.
""" """
filters = [k for k, v in kwargs.items() if v] filters = kwargs.copy()
filters.remove('urls') filters.remove("urls")
print(f"loaded filters {filters}")
config.session["filters"] = filters 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() @cli.command()
@click.option("-t", "--type", default="album") @click.option("-t", "--type", default="album")
@click.option("-s", "--source", default="qobuz")
@click.option("-d", "--discover", is_flag=True) @click.option("-d", "--discover", is_flag=True)
@click.argument("QUERY", nargs=-1) @click.argument("QUERY", nargs=-1)
@click.pass_context @click.pass_context
def interactive(ctx, **kwargs): 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. from which you can choose an item to download.
If the source is Qobuz, you can use the `--discover` option with If the source is Qobuz, you can use the `--discover` option with
one of the following queries to fetch and interactively download one of the following queries to fetch and interactively download
the featured albums. the featured albums.
{pformat(QOBUZ_FEATURED_KEYS)}
* most-streamed * most-streamed
* recent-releases * recent-releases
@ -133,14 +153,18 @@ def interactive(ctx, **kwargs):
""" """
print(f"starting interactive mode for type {kwargs['type']}") logger.debug(f"starting interactive mode for type {kwargs['type']}")
if kwargs['discover']: if kwargs["discover"]:
if kwargs['query'] == (): if kwargs["query"] == ():
kwargs['query'] = 'ideal-discography' kwargs["query"] = ["ideal-discography"]
print(f"doing a discover search of type {kwargs['query']}") kwargs["type"] = "featured"
query = " ".join(kwargs["query"])
if core.interactive_search(query, kwargs["source"], kwargs["type"]):
core.download()
else: else:
query = ' '.join(kwargs['query']) click.secho("No items chosen, exiting.", fg="bright_red")
print(f"searching for query '{query}'")
def parse_urls(arg: str): def parse_urls(arg: str):

View file

@ -40,8 +40,13 @@ class Config:
}, },
"tidal": {"enabled": True, "email": None, "password": None}, "tidal": {"enabled": True, "email": None, "password": None},
"deezer": {"enabled": True}, "deezer": {"enabled": True},
"downloads_database": None, "database": {"enabled": True, "path": None},
"conversion": {"codec": None, "sampling_rate": None, "bit_depth": None}, "conversion": {
"enabled": False,
"codec": None,
"sampling_rate": None,
"bit_depth": None,
},
"filters": { "filters": {
"extras": False, "extras": False,
"repeats": False, "repeats": False,
@ -74,15 +79,15 @@ class Config:
logger.debug(f"Creating yaml config file at '{self._path}'") logger.debug(f"Creating yaml config file at '{self._path}'")
self.dump(self.defaults) self.dump(self.defaults)
else: else:
self.load_file() self.load()
def save_file(self): def save(self):
self.dump(self.file) self.dump(self.file)
def reset_file(self): def reset(self):
self.dump(self.defaults) self.dump(self.defaults)
def load_file(self): def load(self):
with open(self._path) as cfg: with open(self._path) as cfg:
for k, v in yaml.load(cfg).items(): for k, v in yaml.load(cfg).items():
self.file[k] = v self.file[k] = v

View file

@ -2,12 +2,12 @@ import logging
import os import os
import re import re
from getpass import getpass from getpass import getpass
from pprint import pprint
from string import Formatter from string import Formatter
from typing import Generator, Optional, Tuple, Union from typing import Generator, Optional, Tuple, Union
import click import click
from simple_term_menu import TerminalMenu from simple_term_menu import TerminalMenu
from tqdm import tqdm
from .clients import DeezerClient, QobuzClient, TidalClient from .clients import DeezerClient, QobuzClient, TidalClient
from .config import Config from .config import Config
@ -30,16 +30,12 @@ MEDIA_CLASS = {
CLIENTS = {"qobuz": QobuzClient, "tidal": TidalClient, "deezer": DeezerClient} CLIENTS = {"qobuz": QobuzClient, "tidal": TidalClient, "deezer": DeezerClient}
Media = Union[Album, Playlist, Artist, Track] # type hint Media = Union[Album, Playlist, Artist, Track] # type hint
# TODO: add support for database
class MusicDL(list): class MusicDL(list):
def __init__( def __init__(
self, self,
config: Optional[Config] = None, config: Optional[Config] = None,
database: Optional[str] = None,
): ):
logger.debug(locals())
self.url_parse = re.compile(URL_REGEX) self.url_parse = re.compile(URL_REGEX)
self.config = config self.config = config
@ -52,10 +48,15 @@ class MusicDL(list):
"deezer": DeezerClient(), "deezer": DeezerClient(),
} }
if isinstance(database, (MusicDB, list)): if config.session["database"]["enabled"]:
self.db = database if config.session["database"]["path"] is not None:
elif database is None: self.db = MusicDB(config.session["database"]["path"])
self.db = MusicDB(DB_PATH) else:
self.db = MusicDB(DB_PATH)
config.file["database"]["path"] = DB_PATH
config.save()
else:
self.db = []
def prompt_creds(self, source: str): def prompt_creds(self, source: str):
"""Prompt the user for credentials. """Prompt the user for credentials.
@ -88,7 +89,7 @@ class MusicDL(list):
): ):
self.prompt_creds(source) self.prompt_creds(source)
def handle_url(self, url: str): def handle_urls(self, url: str):
"""Download an url """Download an url
:param url: :param url:
@ -100,34 +101,43 @@ class MusicDL(list):
if item_id in self.db: if item_id in self.db:
logger.info(f"{url} already downloaded, use --no-db to override.") logger.info(f"{url} already downloaded, use --no-db to override.")
return return
self.handle_item(source, url_type, item_id) self.handle_item(source, url_type, item_id)
def handle_item(self, source: str, media_type: str, item_id: str): def handle_item(self, source: str, media_type: str, item_id: str):
self.assert_creds(source) 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) client = self.get_client(source)
item = MEDIA_CLASS[media_type](client=client, id=item_id) item = MEDIA_CLASS[media_type](client=client, id=item_id)
self.append(item) 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) 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() item.load_meta()
click.secho(f"Downloading {item!s}", fg="bright_green") click.secho(f"Downloading {item!s}", fg="bright_green")
item.download(**arguments) 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): def get_client(self, source: str):
client = self.clients[source] client = self.clients[source]
@ -157,8 +167,8 @@ class MusicDL(list):
and not creds.get("app_id") and not creds.get("app_id")
): ):
( (
self.config["qobuz"]["app_id"], self.config.file["qobuz"]["app_id"],
self.config["qobuz"]["secrets"], self.config.file["qobuz"]["secrets"],
) = client.get_tokens() ) = client.get_tokens()
self.config.save() self.config.save()
@ -208,8 +218,15 @@ class MusicDL(list):
i = 0 i = 0
if isinstance(results, Generator): # QobuzClient if isinstance(results, Generator): # QobuzClient
for page in results: for page in results:
for item in page[f"{media_type}s"]["items"]: tracklist = (
yield MEDIA_CLASS[media_type].from_api(item, client) 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 i += 1
if i > limit: if i > limit:
return return
@ -238,7 +255,7 @@ class MusicDL(list):
def interactive_search( def interactive_search(
self, query: str, source: str = "qobuz", media_type: str = "album" 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): def title(res):
return f"{res[0]+1}. {res[1].title}" return f"{res[0]+1}. {res[1].title}"
@ -261,4 +278,8 @@ class MusicDL(list):
clear_screen=True, clear_screen=True,
) )
choice = menu.show() choice = menu.show()
return results[choice] if choice is None:
return False
else:
self.append(results[choice])
return True

View file

@ -99,8 +99,8 @@ class Track:
self.sampling_rate = 44100 self.sampling_rate = 44100
self.bit_depth = 16 self.bit_depth = 16
self.__is_downloaded = False self._is_downloaded = False
self.__is_tagged = False self._is_tagged = False
for attr in ("quality", "folder", "meta"): for attr in ("quality", "folder", "meta"):
setattr(self, attr, None) setattr(self, attr, None)
@ -155,14 +155,14 @@ class Track:
quality or self.quality, quality or self.quality,
parent_folder or self.folder, 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) os.makedirs(self.folder, exist_ok=True)
if database is not None: if database is not None:
if self.id in database: if self.id in database:
self.__is_downloaded = True self._is_downloaded = True
self.__is_tagged = True self._is_tagged = True
click.secho( click.secho(
f"{self['title']} already logged in database, skipping.", f"{self['title']} already logged in database, skipping.",
fg="magenta", fg="magenta",
@ -170,8 +170,8 @@ class Track:
return return
if os.path.isfile(self.format_final_path()): if os.path.isfile(self.format_final_path()):
self.__is_downloaded = True self._is_downloaded = True
self.__is_tagged = True self._is_tagged = True
click.secho(f"Track already downloaded: {self.final_path}", fg="magenta") click.secho(f"Track already downloaded: {self.final_path}", fg="magenta")
return False return False
@ -195,8 +195,8 @@ class Track:
if os.path.isfile(temp_file): if os.path.isfile(temp_file):
logger.debug("Temporary file found: %s", temp_file) logger.debug("Temporary file found: %s", temp_file)
self.__is_downloaded = True self._is_downloaded = True
self.__is_tagged = False self._is_tagged = False
click.secho(f"\nDownloading {self!s}", fg="blue") click.secho(f"\nDownloading {self!s}", fg="blue")
@ -217,7 +217,7 @@ class Track:
logger.debug("Downloaded: %s -> %s", temp_file, self.final_path) logger.debug("Downloaded: %s -> %s", temp_file, self.final_path)
self.__is_downloaded = True self._is_downloaded = True
return True return True
def download_cover(self): def download_cover(self):
@ -310,13 +310,13 @@ class Track:
:type cover: Union[Picture, APIC] :type cover: Union[Picture, APIC]
""" """
assert isinstance(self.meta, TrackMetadata), "meta must be TrackMetadata" assert isinstance(self.meta, TrackMetadata), "meta must be TrackMetadata"
if not self.__is_downloaded: if not self._is_downloaded:
logger.info( logger.info(
"Track %s not tagged because it was not downloaded", self["title"] "Track %s not tagged because it was not downloaded", self["title"]
) )
return return
if self.__is_tagged: if self._is_tagged:
logger.info( logger.info(
"Track %s not tagged because it is already tagged", self["title"] "Track %s not tagged because it is already tagged", self["title"]
) )
@ -361,7 +361,7 @@ class Track:
else: else:
raise ValueError(f"Unknown container type: {audio}") raise ValueError(f"Unknown container type: {audio}")
self.__is_tagged = True self._is_tagged = True
def convert(self, codec: str = "ALAC", **kwargs): def convert(self, codec: str = "ALAC", **kwargs):
"""Converts the track to another codec. """Converts the track to another codec.
@ -380,7 +380,7 @@ class Track:
:type codec: str :type codec: str
:param kwargs: :param kwargs:
""" """
assert self.__is_downloaded, "Track must be downloaded before conversion" assert self._is_downloaded, "Track must be downloaded before conversion"
CONV_CLASS = { CONV_CLASS = {
"FLAC": converter.FLAC, "FLAC": converter.FLAC,