streamrip/rip/cli.py

416 lines
11 KiB
Python
Raw Normal View History

2021-05-04 15:57:00 -04:00
"""The streamrip command line interface."""
2021-03-22 12:21:27 -04:00
import logging
import os
import shutil
from getpass import getpass
from hashlib import md5
2021-03-22 12:21:27 -04:00
import click
import requests
2021-03-22 12:21:27 -04:00
from streamrip import __version__
from streamrip.clients import TidalClient
2021-03-22 17:53:28 -04:00
from .config import Config
from streamrip.constants import CACHE_DIR, CONFIG_DIR, CONFIG_PATH, QOBUZ_FEATURED_KEYS
2021-03-22 17:53:28 -04:00
from .core import MusicDL
2021-05-13 21:55:03 -04:00
2021-05-13 20:22:21 -04:00
logging.basicConfig(level="WARNING")
2021-05-12 18:19:51 -04:00
logger = logging.getLogger("streamrip")
2021-03-22 12:21:27 -04:00
2021-03-22 21:00:04 -04:00
if not os.path.isdir(CONFIG_DIR):
os.makedirs(CONFIG_DIR, exist_ok=True)
2021-03-22 21:00:04 -04:00
if not os.path.isdir(CACHE_DIR):
os.makedirs(CONFIG_DIR, exist_ok=True)
2021-03-22 12:21:27 -04:00
2021-03-24 13:39:37 -04:00
@click.group(invoke_without_command=True)
@click.option("-c", "--convert", metavar="CODEC", help="alac, mp3, flac, or ogg")
2021-05-13 21:46:41 -04:00
@click.option(
"-u",
"--urls",
metavar="URLS",
help="Url from Qobuz, Tidal, SoundCloud, or Deezer",
2021-06-22 06:18:23 -04:00
multiple=True,
2021-04-06 19:46:47 -04:00
)
2021-04-01 15:54:36 -04:00
@click.option(
"-q",
"--quality",
metavar="INT",
help="0: < 320kbps, 1: 320 kbps, 2: 16 bit/44.1 kHz, 3: 24 bit/<=96 kHz, 4: 24 bit/<=192 kHz",
)
@click.option("-t", "--text", metavar="PATH")
@click.option("-nd", "--no-db", is_flag=True)
@click.option("--debug", is_flag=True)
@click.version_option(prog_name="streamrip")
2021-03-22 12:21:27 -04:00
@click.pass_context
def cli(ctx, **kwargs):
2021-04-06 18:18:39 -04:00
"""Streamrip: The all-in-one Qobuz, Tidal, SoundCloud, and Deezer music downloader.
2021-03-30 21:36:51 -04:00
To get started, try:
$ rip -u https://www.deezer.com/en/album/6612814
For customization down to the details, see the config file:
$ rip config --open
"""
2021-03-24 13:39:37 -04:00
global config
global core
2021-03-22 12:21:27 -04:00
if kwargs["debug"]:
2021-05-12 18:19:51 -04:00
logger.setLevel("DEBUG")
logger.debug("Starting debug log")
if ctx.invoked_subcommand not in {None, "lastfm", "search", "disover", "config"}:
return
2021-03-24 13:39:37 -04:00
config = Config()
2021-04-12 20:20:48 -04:00
if ctx.invoked_subcommand == "config":
return
if config.session["misc"]["check_for_updates"]:
2021-04-08 15:41:45 -04:00
r = requests.get("https://pypi.org/pypi/streamrip/json").json()
2021-04-09 14:17:48 -04:00
newest = r["info"]["version"]
2021-05-13 21:55:03 -04:00
if __version__ != newest:
2021-04-08 15:41:45 -04:00
click.secho(
"A new version of streamrip is available! "
"Run `pip3 install streamrip --upgrade` to update.",
fg="yellow",
)
else:
2021-04-09 14:17:48 -04:00
click.secho("streamrip is up-to-date!", fg="green")
2021-04-08 15:41:45 -04:00
if kwargs["no_db"]:
config.session["database"]["enabled"] = False
if kwargs["convert"]:
config.session["conversion"]["enabled"] = True
config.session["conversion"]["codec"] = kwargs["convert"]
2021-04-01 15:54:36 -04:00
if kwargs["quality"] is not None:
2021-04-05 21:24:26 -04:00
quality = int(kwargs["quality"])
if quality not in range(5):
2021-04-01 15:54:36 -04:00
click.secho("Invalid quality", fg="red")
2021-03-30 21:36:51 -04:00
return
config.session["qobuz"]["quality"] = quality
config.session["tidal"]["quality"] = quality
config.session["deezer"]["quality"] = quality
2021-03-24 13:39:37 -04:00
core = MusicDL(config)
2021-03-22 12:21:27 -04:00
if kwargs["urls"]:
logger.debug(f"handling {kwargs['urls']}")
core.handle_urls(kwargs["urls"])
if kwargs["text"] is not None:
if os.path.isfile(kwargs["text"]):
logger.debug(f"Handling {kwargs['text']}")
core.handle_txt(kwargs["text"])
else:
click.secho(f"Text file {kwargs['text']} does not exist.")
2021-03-26 15:26:50 -04:00
if ctx.invoked_subcommand is None:
core.download()
2021-03-24 13:39:37 -04:00
@cli.command(name="filter")
@click.option("--repeats", is_flag=True)
@click.option("--non-albums", is_flag=True)
@click.option("--extras", is_flag=True)
@click.option("--features", is_flag=True)
@click.option("--non-studio-albums", is_flag=True)
@click.option("--non-remasters", is_flag=True)
@click.argument("URLS", nargs=-1)
2021-03-22 12:21:27 -04:00
@click.pass_context
2021-03-24 13:39:37 -04:00
def filter_discography(ctx, **kwargs):
"""Filter an artists discography (qobuz only).
2021-03-24 13:39:37 -04:00
The Qobuz API returns a massive number of tangentially related
albums when requesting an artist's discography. This command
can filter out most of the junk.
For basic filtering, use the `--repeats` and `--features` filters.
2021-03-22 12:21:27 -04:00
"""
filters = kwargs.copy()
2021-04-06 00:45:51 -04:00
filters.pop("urls")
2021-03-24 13:39:37 -04:00
config.session["filters"] = filters
logger.debug(f"downloading {kwargs['urls']} with filters {filters}")
core.handle_urls(" ".join(kwargs["urls"]))
core.download()
2021-03-22 12:21:27 -04:00
2021-03-24 13:39:37 -04:00
@cli.command()
@click.option("-t", "--type", default="album", help="album, playlist, track, or artist")
2021-05-13 21:46:41 -04:00
@click.option(
"-s",
"--source",
default="qobuz",
help="qobuz, tidal, soundcloud, or deezer",
2021-04-06 19:46:47 -04:00
)
2021-03-24 13:39:37 -04:00
@click.argument("QUERY", nargs=-1)
@click.pass_context
def search(ctx, **kwargs):
"""Search and download media in interactive mode.
2021-03-22 12:21:27 -04:00
The QUERY must be surrounded in quotes if it contains spaces. If your query
contains single quotes, use double quotes, and vice versa.
Example usage:
$ rip search 'fleetwood mac rumours'
Search for a Qobuz album that matches 'fleetwood mac rumours'
$ rip search -t track 'back in the ussr'
Search for a Qobuz track with the given query
$ rip search -s tidal 'jay z 444'
Search for a Tidal album that matches 'jay z 444'
"""
if isinstance(kwargs["query"], (list, tuple)):
query = " ".join(kwargs["query"])
elif isinstance(kwargs["query"], str):
query = kwargs["query"]
else:
raise ValueError("Invalid query type" + type(kwargs["query"]))
if core.interactive_search(query, kwargs["source"], kwargs["type"]):
core.download()
else:
click.secho("No items chosen, exiting.", fg="bright_red")
@cli.command()
@click.option("-l", "--list", default="ideal-discography")
@click.pass_context
def discover(ctx, **kwargs):
2021-05-04 15:57:00 -04:00
"""Search for albums in Qobuz's featured lists.
Avaiable options for `--list`:
2021-03-22 12:21:27 -04:00
2021-03-24 13:39:37 -04:00
* most-streamed
2021-03-22 12:21:27 -04:00
2021-03-24 13:39:37 -04:00
* recent-releases
* best-sellers
* press-awards
* ideal-discography
* editor-picks
* most-featured
* qobuzissims
* new-releases
* new-releases-full
* harmonia-mundi
* universal-classic
* universal-jazz
* universal-jeunesse
* universal-chanson
2021-03-22 12:21:27 -04:00
"""
assert (
kwargs["list"] in QOBUZ_FEATURED_KEYS
), f"Invalid featured key {kwargs['list']}"
2021-03-24 13:39:37 -04:00
if core.interactive_search(kwargs["list"], "qobuz", "featured"):
core.download()
2021-03-24 13:39:37 -04:00
else:
none_chosen()
2021-04-09 19:20:03 -04:00
@cli.command()
@click.option(
2021-05-13 21:46:41 -04:00
"-s",
"--source",
help="Qobuz, Tidal, Deezer, or SoundCloud. Default: Qobuz.",
2021-04-09 19:20:03 -04:00
)
@click.argument("URL")
@click.pass_context
def lastfm(ctx, source, url):
2021-05-04 15:57:00 -04:00
"""Search for tracks from a last.fm playlist on a given source.
2021-04-09 19:20:03 -04:00
Examples:
$ rip lastfm https://www.last.fm/user/nathan3895/playlists/12059037
Download a playlist using Qobuz as the source
$ rip lastfm -s tidal https://www.last.fm/user/nathan3895/playlists/12059037
Download a playlist using Tidal as the source
"""
if source is not None:
config.session["lastfm"]["source"] = source
core.handle_lastfm_urls(url)
core.download()
2021-03-25 22:43:18 -04:00
@cli.command()
@click.option("-o", "--open", is_flag=True, help="Open the config file")
@click.option("-d", "--directory", is_flag=True, help="Open the config directory")
@click.option("-q", "--qobuz", is_flag=True, help="Set Qobuz credentials")
@click.option("-t", "--tidal", is_flag=True, help="Re-login into Tidal")
2021-03-29 18:46:26 -04:00
@click.option("--reset", is_flag=True, help="RESET the config file")
@click.option(
"--update",
is_flag=True,
help="Reset the config file, keeping the credentials",
)
@click.option("-p", "--path", is_flag=True, help="Show the config file's path")
@click.option(
"-ov",
"--open-vim",
is_flag=True,
help="Open the config file in the nvim or vim text editor.",
)
2021-03-25 22:43:18 -04:00
@click.pass_context
def config(ctx, **kwargs):
"""Manage the streamrip configuration file."""
global config
2021-03-29 18:46:26 -04:00
if kwargs["reset"]:
config.reset()
2021-03-25 22:43:18 -04:00
if kwargs["update"]:
config.update()
if kwargs["path"]:
click.echo(CONFIG_PATH)
if kwargs["open"]:
click.secho(f"Opening {CONFIG_PATH}", fg="green")
2021-03-25 22:43:18 -04:00
click.launch(CONFIG_PATH)
if kwargs["open_vim"]:
if shutil.which("nvim") is not None:
os.system(f"nvim '{CONFIG_PATH}'")
else:
os.system(f"vim '{CONFIG_PATH}'")
2021-04-19 16:41:40 -04:00
if kwargs["directory"]:
config_dir = os.path.dirname(CONFIG_PATH)
click.secho(f"Opening {config_dir}", fg="green")
click.launch(config_dir)
if kwargs["qobuz"]:
config.file["qobuz"]["email"] = input(click.style("Qobuz email: ", fg="blue"))
2021-03-25 22:43:18 -04:00
click.secho("Qobuz password (will not show on screen):", fg="blue")
config.file["qobuz"]["password"] = md5(
getpass(prompt="").encode("utf-8")
).hexdigest()
2021-03-25 22:43:18 -04:00
config.save()
click.secho("Qobuz credentials hashed and saved to config.", fg="green")
2021-03-24 13:39:37 -04:00
if kwargs["tidal"]:
client = TidalClient()
client.login()
config.file["tidal"].update(client.get_tokens())
config.save()
click.secho("Credentials saved to config.", fg="green")
2021-03-24 13:39:37 -04:00
@cli.command()
@click.option(
"-sr", "--sampling-rate", help="Downsample the tracks to this rate, in Hz."
)
@click.option("-bd", "--bit-depth", help="Downsample the tracks to this bit depth.")
@click.option(
"-k",
"--keep-source",
is_flag=True,
help="Do not delete the old file after conversion.",
)
@click.argument("CODEC")
@click.argument("PATH")
@click.pass_context
def convert(ctx, **kwargs):
from . import converter
import concurrent.futures
from tqdm import tqdm
codec_map = {
"FLAC": converter.FLAC,
"ALAC": converter.ALAC,
"OPUS": converter.OPUS,
"MP3": converter.LAME,
"AAC": converter.AAC,
}
codec = kwargs.get("codec").upper()
assert codec in codec_map.keys(), f"Invalid codec {codec}"
if s := kwargs.get("sampling_rate"):
sampling_rate = int(s)
else:
sampling_rate = None
if s := kwargs.get("bit_depth"):
bit_depth = int(s)
else:
bit_depth = None
converter_args = {
"sampling_rate": sampling_rate,
"bit_depth": bit_depth,
"remove_source": not kwargs.get("keep_source", False),
}
if os.path.isdir(kwargs["path"]):
dirname = kwargs["path"]
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = []
audio_extensions = ("flac", "m4a", "aac", "opus", "mp3", "ogg")
audio_files = (
f
for f in os.listdir(kwargs["path"])
if any(f.endswith(ext) for ext in audio_extensions)
)
for file in audio_files:
futures.append(
executor.submit(
codec_map[codec](
filename=os.path.join(dirname, file), **converter_args
).convert
)
)
for future in tqdm(
concurrent.futures.as_completed(futures),
total=len(futures),
desc="Converting",
):
# Only show loading bar
pass
elif os.path.isfile(kwargs["path"]):
codec_map[codec](filename=kwargs["path"], **converter_args).convert()
else:
click.secho(f"File {kwargs['path']} does not exist.", fg="red")
def none_chosen():
2021-05-04 15:57:00 -04:00
"""Print message if nothing was chosen."""
click.secho("No items chosen, exiting.", fg="bright_red")
2021-03-22 16:40:29 -04:00
2021-03-22 12:21:27 -04:00
def main():
2021-05-04 15:57:00 -04:00
"""Run the main program."""
2021-03-24 13:39:37 -04:00
cli(obj={})