mirror of
https://github.com/nathom/streamrip.git
synced 2025-05-09 14:11:55 -04:00
184 lines
5.8 KiB
Python
184 lines
5.8 KiB
Python
import logging
|
|
import os
|
|
import re
|
|
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, URL_REGEX
|
|
from .db import QobuzDB
|
|
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, "label": Label}
|
|
CLIENTS = {"qobuz": QobuzClient, "tidal": TidalClient, "deezer": DeezerClient}
|
|
Media = Union[Album, Playlist, Artist, Track] # type hint
|
|
|
|
# TODO: add support for database
|
|
|
|
|
|
class QobuzDL:
|
|
def __init__(
|
|
self,
|
|
config: Optional[Config] = None,
|
|
database: Optional[str] = None,
|
|
):
|
|
logger.debug(locals())
|
|
|
|
self.url_parse = re.compile(URL_REGEX)
|
|
self.config = config
|
|
if self.config is None:
|
|
self.config = Config(CONFIG_PATH)
|
|
|
|
self.clients = {
|
|
"qobuz": QobuzClient(),
|
|
"tidal": TidalClient(),
|
|
"deezer": DeezerClient(),
|
|
}
|
|
|
|
if database is None:
|
|
self.db = QobuzDB(DB_PATH)
|
|
else:
|
|
assert isinstance(database, QobuzDB)
|
|
self.db = database
|
|
|
|
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
|
|
|
|
:param url:
|
|
:type url: str
|
|
:raises InvalidSourceError
|
|
:raises ParsingError
|
|
"""
|
|
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(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.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_)
|
|
|
|
logger.debug("Arguments from config: %s", arguments)
|
|
|
|
item.load_meta()
|
|
item.download(**arguments)
|
|
|
|
def parse_url(self, url: str) -> Tuple[str, str]:
|
|
"""Returns the type of the url and the id.
|
|
|
|
Compatible with urls of the form:
|
|
https://www.qobuz.com/us-en/{type}/{name}/{id}
|
|
https://open.qobuz.com/{type}/{id}
|
|
https://play.qobuz.com/{type}/{id}
|
|
/us-en/{type}/-/{id}
|
|
|
|
https://www.deezer.com/us/{type}/{id}
|
|
https://tidal.com/browse/{type}/{id}
|
|
|
|
:raises exceptions.ParsingError
|
|
"""
|
|
parsed = self.url_parse.search(url)
|
|
|
|
if parsed is not None:
|
|
parsed = parsed.groups()
|
|
|
|
if len(parsed) == 3:
|
|
return tuple(parsed) # Convert from Seq for the sake of typing
|
|
|
|
raise ParsingError(f"Error parsing URL: `{url}`")
|
|
|
|
def from_txt(self, filepath: Union[str, os.PathLike]):
|
|
"""
|
|
Handle a text file containing URLs. Lines starting with `#` are ignored.
|
|
|
|
:param filepath:
|
|
:type filepath: Union[str, os.PathLike]
|
|
:raises OSError
|
|
:raises exceptions.ParsingError
|
|
"""
|
|
with open(filepath) as txt:
|
|
lines = (
|
|
line for line in txt.readlines() if not line.strip().startswith("#")
|
|
)
|
|
|
|
click.secho(f"URLs found in text file: {len(lines)}")
|
|
|
|
for line in lines:
|
|
self.handle_url(line)
|
|
|
|
def search(
|
|
self, query: str, media_type: str = "album", limit: int = 200
|
|
) -> Generator:
|
|
results = self.client.search(query, media_type, limit)
|
|
|
|
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, self.client)
|
|
else:
|
|
for item in results.get("data") or results.get("items"):
|
|
yield MEDIA_CLASS[media_type].from_api(item, self.client)
|