Added support for urls from multiple sources

Also added database integration
This commit is contained in:
nathannathant 2021-03-21 20:25:33 -07:00
parent fd1fead8f8
commit 27695a96e9
8 changed files with 101 additions and 48 deletions

View File

@ -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):

View File

@ -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()

View File

@ -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):

View File

@ -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+)"
)

View File

@ -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}`")

View File

@ -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`"""

View File

@ -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")

View File

@ -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:]