Basic album downloading working

This commit is contained in:
nathom 2021-03-22 18:00:04 -07:00
parent b7ace1d8d0
commit 129f3d8fe6
6 changed files with 164 additions and 79 deletions

View file

@ -1,8 +1,8 @@
# For tests # For tests
import logging import logging
from getpass import getpass
import os import os
from getpass import getpass
import click import click
@ -13,23 +13,25 @@ from .core import MusicDL
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
config = Config(CONFIG_PATH) 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): def _get_config(ctx):
print(f"{ctx.obj=}") config.update_from_cli(**ctx.params)
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
@click.group() @click.group()
@click.option("--debug", default=False, is_flag=True, help="Enable debug logging") @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(
@click.option("-c", '--convert', metavar='CODEC') "--flush-cache",
metavar="PATH",
help="Flush the cache before running (only for extreme cases)",
)
@click.pass_context @click.pass_context
def cli(ctx, **kwargs): def cli(ctx, **kwargs):
"""cli. """cli.
@ -46,18 +48,20 @@ def cli(ctx, **kwargs):
> download discography with given filters > download discography with given filters
""" """
print(f"{ctx=}") pass
print(f"{kwargs=}")
@click.command(name="dl") @click.command(name="dl")
@click.option("-q", "--quality", metavar="INT", help="Quality integer ID (5, 6, 7, 27)") @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("-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("-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.argument("items", nargs=-1)
@click.pass_context @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. Download an URL, space separated URLs or a text file with URLs.
Mixed arguments are also supported. Mixed arguments are also supported.
@ -78,10 +82,9 @@ def download(ctx, quality, folder, search, items):
* Tidal (album, artist, track, playlist) * Tidal (album, artist, track, playlist)
""" """
ctx.ensure_object(dict)
config = _get_config(ctx) config = _get_config(ctx)
core = MusicDL(config) core = MusicDL(config, database=list() if kwargs["no_db"] else None)
for item in items: for item in kwargs["items"]:
try: try:
if os.path.isfile(item): if os.path.isfile(item):
core.from_txt(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" 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.command(name="config")
@click.option("-q", '--qobuz', is_flag=True) @click.option("-o", "--open", is_flag=True)
@click.option("-t", '--tidal', is_flag=True) @click.option("-q", "--qobuz", is_flag=True)
@click.option("-t", "--tidal", is_flag=True)
def edit_config(open, qobuz, tidal): def edit_config(open, qobuz, tidal):
if open: if open:
# open in text editor # open in text editor
@ -106,34 +116,30 @@ def edit_config(open, qobuz, tidal):
return return
if qobuz: if qobuz:
config['qobuz']['email'] = input("Qobuz email: ") config["qobuz"]["email"] = input("Qobuz email: ")
config['qobuz']['password'] = getpass("Qobuz password: ") config["qobuz"]["password"] = getpass("Qobuz password: ")
config.save() config.save()
click.secho(f"Config saved at {CONFIG_PATH}", fg='green') click.secho(f"Config saved at {CONFIG_PATH}", fg="green")
if tidal: if tidal:
config['tidal']['email'] = input("Tidal email: ") config["tidal"]["email"] = input("Tidal email: ")
config['tidal']['password'] = getpass("Tidal password: ") config["tidal"]["password"] = getpass("Tidal password: ")
config.save() 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.command()
@click.option("-t", '--type', default='album', @click.option(
help='Type to search for. Can be album, artist, playlist, track') "-t",
"--type",
default="album",
help="Type to search for. Can be album, artist, playlist, track",
)
@click.argument("QUERY") @click.argument("QUERY")
def search(media_type, query): def search(media_type, query):
print(f"searching for {media_type} with {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() @click.command()
def interactive(): def interactive():
pass pass
@ -150,8 +156,10 @@ def filter(*args):
@click.command() @click.command()
@click.option("--default-comment", metavar="COMMENT", help="Custom comment tag for audio files") @click.option(
@click.option("--no-cover", help='Do not embed cover into audio file.') "--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): def tags(default_comment, no_cover):
print(f"{default_comment=}, {no_cover=}") print(f"{default_comment=}, {no_cover=}")
@ -161,8 +169,7 @@ def main():
cli.add_command(filter) cli.add_command(filter)
cli.add_command(tags) cli.add_command(tags)
cli.add_command(edit_config) cli.add_command(edit_config)
cli.add_command(convert) cli()
cli(obj={})
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -103,6 +103,8 @@ class ClientInterface(ABC):
class QobuzClient(ClientInterface): class QobuzClient(ClientInterface):
source = "qobuz"
# ------- Public Methods ------------- # ------- Public Methods -------------
def __init__(self): def __init__(self):
self.logged_in = False self.logged_in = False
@ -193,10 +195,6 @@ class QobuzClient(ClientInterface):
def get_file_url(self, item_id, quality=6) -> dict: def get_file_url(self, item_id, quality=6) -> dict:
return self._api_get_file_url(item_id, quality=quality) return self._api_get_file_url(item_id, quality=quality)
@property
def source(self):
return "qobuz"
# ---------- Private Methods --------------- # ---------- Private Methods ---------------
# Credit to Sorrow446 for the original methods # Credit to Sorrow446 for the original methods
@ -355,6 +353,8 @@ class QobuzClient(ClientInterface):
class DeezerClient(ClientInterface): class DeezerClient(ClientInterface):
source = "deezer"
def __init__(self): def __init__(self):
self.session = requests.Session() self.session = requests.Session()
self.logged_in = True self.logged_in = True
@ -412,12 +412,10 @@ class DeezerClient(ClientInterface):
logger.debug(f"Download url {url}") logger.debug(f"Download url {url}")
return url return url
@property
def source(self):
return "deezer"
class TidalClient(ClientInterface): class TidalClient(ClientInterface):
source = "tidal"
def __init__(self): def __init__(self):
self.logged_in = False self.logged_in = False
@ -468,10 +466,6 @@ class TidalClient(ClientInterface):
logger.debug(f"Fetching file url with quality {quality}") logger.debug(f"Fetching file url with quality {quality}")
return self._get_file_url(meta_id, quality=min(TIDAL_MAX_Q, 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): def _search(self, query, media_type="album", **kwargs):
params = { params = {
"query": query, "query": query,

View file

@ -46,6 +46,7 @@ class Config:
self.tidal = {"enabled": True, "email": None, "password": None} self.tidal = {"enabled": True, "email": None, "password": None}
self.deezer = {"enabled": True} self.deezer = {"enabled": True}
self.downloads_database = None self.downloads_database = None
self.conversion = {"codec": None, "sampling_rate": None, "bit_depth": None}
self.filters = { self.filters = {
"no_extras": False, "no_extras": False,
"albums_only": False, "albums_only": False,
@ -55,7 +56,7 @@ class Config:
} }
self.downloads = {"folder": folder, "quality": quality} self.downloads = {"folder": folder, "quality": quality}
self.metadata = { self.metadata = {
"embed_cover": False, "embed_cover": True,
"large_cover": False, "large_cover": False,
"default_comment": None, "default_comment": None,
"remove_extra_tags": False, "remove_extra_tags": False,

View file

@ -5,26 +5,33 @@ from getpass import getpass
from typing import Generator, Optional, Tuple, Union from typing import Generator, Optional, Tuple, Union
import click import click
from tqdm import tqdm
from .clients import DeezerClient, QobuzClient, TidalClient from .clients import DeezerClient, QobuzClient, TidalClient
from .config import Config from .config import Config
from .constants import CONFIG_PATH, DB_PATH, URL_REGEX from .constants import CONFIG_PATH, DB_PATH, URL_REGEX
from .db import MusicDB 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 .exceptions import AuthenticationError, ParsingError
from .utils import capitalize from .utils import capitalize
logger = logging.getLogger(__name__) 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} 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 # TODO: add support for database
class MusicDL: class MusicDL(list):
def __init__( def __init__(
self, self,
config: Optional[Config] = None, config: Optional[Config] = None,
@ -43,11 +50,10 @@ class MusicDL:
"deezer": DeezerClient(), "deezer": DeezerClient(),
} }
if database is None: if isinstance(database, (MusicDB, list)):
self.db = MusicDB(DB_PATH)
else:
assert isinstance(database, MusicDB)
self.db = database self.db = database
elif database is None:
self.db = MusicDB(DB_PATH)
def prompt_creds(self, source: str): def prompt_creds(self, source: str):
"""Prompt the user for credentials. """Prompt the user for credentials.
@ -105,16 +111,10 @@ class MusicDL:
} }
client = self.clients[source] client = self.clients[source]
if not client.logged_in: self.login(client)
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) item = MEDIA_CLASS[media_type](client=client, id=item_id)
self.append(item)
if isinstance(item, Artist): if isinstance(item, Artist):
keys = self.config.filters.keys() keys = self.config.filters.keys()
# TODO: move this to config.py # TODO: move this to config.py
@ -125,8 +125,35 @@ class MusicDL:
logger.debug("Arguments from config: %s", arguments) logger.debug("Arguments from config: %s", arguments)
item.load_meta() item.load_meta()
click.secho(f"Downloading {item!s}")
item.download(**arguments) 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]: def parse_url(self, url: str) -> Tuple[str, str]:
"""Returns the type of the url and the id. """Returns the type of the url and the id.

View file

@ -159,8 +159,16 @@ class Track:
os.makedirs(self.folder, exist_ok=True) os.makedirs(self.folder, exist_ok=True)
assert database is not None # remove this later if database is not None:
if os.path.isfile(self.format_final_path()) or self.id in database: 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_downloaded = True
self.__is_tagged = True self.__is_tagged = True
click.secho(f"Track already downloaded: {self.final_path}", fg="green") click.secho(f"Track already downloaded: {self.final_path}", fg="green")
@ -189,6 +197,8 @@ class Track:
self.__is_downloaded = True self.__is_downloaded = True
self.__is_tagged = False self.__is_tagged = False
click.secho(f"\nDownloading {self!s}", fg="blue")
if self.client.source in ("qobuz", "tidal"): if self.client.source in ("qobuz", "tidal"):
logger.debug("Downloadable URL found: %s", dl_info.get("url")) logger.debug("Downloadable URL found: %s", dl_info.get("url"))
tqdm_download(dl_info["url"], temp_file) # downloads file tqdm_download(dl_info["url"], temp_file) # downloads file
@ -199,7 +209,11 @@ class Track:
raise InvalidSourceError(self.client.source) raise InvalidSourceError(self.client.source)
shutil.move(temp_file, self.final_path) 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) logger.debug("Downloaded: %s -> %s", temp_file, self.final_path)
self.__is_downloaded = True self.__is_downloaded = True
@ -379,6 +393,8 @@ class Track:
} }
self.container = codec.upper() self.container = codec.upper()
if not hasattr(self, "final_path"):
self.format_final_path()
engine = CONV_CLASS[codec.upper()]( engine = CONV_CLASS[codec.upper()](
filename=self.final_path, filename=self.final_path,
@ -426,6 +442,14 @@ class Track:
""" """
return f"<Track - {self['title']}>" return f"<Track - {self['title']}>"
def __str__(self) -> str:
"""Return a readable string representation of
this track.
:rtype: str
"""
return f"{self['artist']} - {self['title']}"
class Tracklist(list, ABC): class Tracklist(list, ABC):
"""A base class for tracklist-like objects. """A base class for tracklist-like objects.
@ -818,6 +842,14 @@ class Album(Tracklist):
return f"<Album: V/A - {self.title}>" return f"<Album: V/A - {self.title}>"
def __str__(self) -> str:
"""Return a readable string representation of
this album.
:rtype: str
"""
return f"{self['albumartist']} - {self['title']}"
class Playlist(Tracklist): class Playlist(Tracklist):
"""Represents a downloadable Qobuz playlist. """Represents a downloadable Qobuz playlist.
@ -990,6 +1022,14 @@ class Playlist(Tracklist):
""" """
return f"<Playlist: {self.name}>" return f"<Playlist: {self.name}>"
def __str__(self) -> str:
"""Return a readable string representation of
this track.
:rtype: str
"""
return f"{self.name} ({len(self)} tracks)"
class Artist(Tracklist): class Artist(Tracklist):
"""Represents a downloadable artist. """Represents a downloadable artist.
@ -1255,6 +1295,14 @@ class Artist(Tracklist):
""" """
return f"<Artist: {self.name}>" return f"<Artist: {self.name}>"
def __str__(self) -> str:
"""Return a readable string representation of
this Artist.
:rtype: str
"""
return self.name
class Label(Artist): class Label(Artist):
def load_meta(self): def load_meta(self):
@ -1268,3 +1316,11 @@ class Label(Artist):
def __repr__(self): def __repr__(self):
return f"<Label - {self.name}>" return f"<Label - {self.name}>"
def __str__(self) -> str:
"""Return a readable string representation of
this track.
:rtype: str
"""
return self.name

View file

@ -32,13 +32,13 @@ setup(
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
], ],
package_dir={'', 'music-dl'}, package_dir={"", "music-dl"},
packages=find_packages(where='music-dl'), packages=find_packages(where="music-dl"),
python_requires=">=3.9", python_requires=">=3.9",
project_urls={ project_urls={
'Bug Reports': 'https://github.com/nathom/music-dl/issues', "Bug Reports": "https://github.com/nathom/music-dl/issues",
'Source': 'https://github.com/nathom/music-dl', "Source": "https://github.com/nathom/music-dl",
} },
) )
# rm -f dist/* # rm -f dist/*