From 0376c421b5843dc078c2076385caa207ebb44695 Mon Sep 17 00:00:00 2001 From: nathom Date: Fri, 30 Jul 2021 17:33:26 -0700 Subject: [PATCH] Use cleo for the CLI --- rip/cli.py | 950 ++++++++++++++++++++----------------------- rip/cli_cleo.py | 214 ---------- rip/core.py | 4 +- streamrip/clients.py | 6 +- 4 files changed, 456 insertions(+), 718 deletions(-) delete mode 100644 rip/cli_cleo.py diff --git a/rip/cli.py b/rip/cli.py index 2eae9b9..03a1b60 100644 --- a/rip/cli.py +++ b/rip/cli.py @@ -1,528 +1,480 @@ -"""The streamrip command line interface.""" +import concurrent.futures import logging +import os +import threading -import click +import requests +from cleo.application import Application as BaseApplication +from cleo.commands.command import Command +from cleo.formatters.style import Style +from click import launch from streamrip import __version__ +from .config import Config +from .core import RipCore + logging.basicConfig(level="WARNING") logger = logging.getLogger("streamrip") - -class SkipArg(Group): - def parse_args(self, ctx, args): - if len(args) == 0: - echo(self.get_help(ctx)) - exit() - - if args[0] in self.commands: - print('command found') - args.insert(0, "") - # if args[0] in self.commands: - # if len(args) == 1 or args[1] not in self.commands: - # # This condition needs updating for multiple positional arguments - # args.insert(0, "") - super(SkipArg, self).parse_args(ctx, args) +outdated = False -# @option( -# "-u", -# "--urls", -# metavar="URLS", -# help="Url from Qobuz, Tidal, SoundCloud, or Deezer", -# multiple=True, -# ) -@group(cls=SkipArg, invoke_without_command=True) -@option("-c", "--convert", metavar="CODEC", help="alac, mp3, flac, or ogg") -@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", -) -@option("-t", "--text", metavar="PATH", help="Download urls from a text file.") -@option("-nd", "--no-db", is_flag=True, help="Ignore the database.") -@option("--debug", is_flag=True, help="Show debugging logs.") -@argument("URLS", nargs=1) -@version_option(prog_name="rip", version=__version__) -@pass_context -def cli(ctx, **kwargs): - """Streamrip: The all-in-one Qobuz, Tidal, SoundCloud, and Deezer music - downloader. - - 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 - - \b - Repository: https://github.com/nathom/streamrip - Bug Reports & Feature Requests: https://github.com/nathom/streamrip/issues - Release Notes: https://github.com/nathom/streamrip/releases +class DownloadCommand(Command): """ - import os + Download items using urls. - import requests - - from .config import Config - from .constants import CONFIG_DIR - from .core import RipCore - - print(kwargs) - if not os.path.isdir(CONFIG_DIR): - os.makedirs(CONFIG_DIR, exist_ok=True) - - global config - global core - - if kwargs["debug"]: - logger.setLevel("DEBUG") - logger.debug("Starting debug log") - - if ctx.invoked_subcommand is None and not ctx.params["urls"]: - print(dir(cli)) - echo(cli.get_help(ctx)) - - if ctx.invoked_subcommand not in { - None, - "lastfm", - "search", - "discover", - "config", - "repair", - }: - - return - - config = Config() - - if ctx.invoked_subcommand == "config": - return - - if config.session["misc"]["check_for_updates"]: - r = requests.get("https://pypi.org/pypi/streamrip/json").json() - newest = r["info"]["version"] - if __version__ != newest: - secho( - "A new version of streamrip is available! " - "Run `pip3 install streamrip --upgrade` to update.", - fg="yellow", - ) - else: - secho("streamrip is up-to-date!", fg="green") - - if kwargs["no_db"]: - config.session["database"]["enabled"] = False - - if kwargs["convert"]: - config.session["conversion"]["enabled"] = True - config.session["conversion"]["codec"] = kwargs["convert"] - - if kwargs["quality"] is not None: - quality = int(kwargs["quality"]) - if quality not in range(5): - secho("Invalid quality", fg="red") - return - - config.session["qobuz"]["quality"] = quality - config.session["tidal"]["quality"] = quality - config.session["deezer"]["quality"] = quality - - core = RipCore(config) - - 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: - secho(f"Text file {kwargs['text']} does not exist.") - - if ctx.invoked_subcommand is None: - core.download() - - -@cli.command(name="filter") -@option("--repeats", is_flag=True) -@option("--non-albums", is_flag=True) -@option("--extras", is_flag=True) -@option("--features", is_flag=True) -@option("--non-studio-albums", is_flag=True) -@option("--non-remasters", is_flag=True) -@argument("URLS", nargs=-1) -@pass_context -def filter_discography(ctx, **kwargs): - """Filter an artists discography (qobuz only). - - 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. + url + {--f|file=None : Path to a text file containing urls} + {--c|codec=None : Convert the downloaded files to ALAC, FLAC, MP3, AAC, or OGG} + {--m|max-quality=None : The maximum quality to download. Can be 0, 1, 2, 3 or 4} + {--i|ignore-db : Download items even if they have been logged in the database.} + {urls?* : One or more Qobuz, Tidal, Deezer, or SoundCloud urls} """ - raise Exception - filters = kwargs.copy() - filters.pop("urls") - config.session["filters"] = filters - logger.debug(f"downloading {kwargs['urls']} with filters {filters}") - core.handle_urls(" ".join(kwargs["urls"])) - core.download() + help = ( + "\nDownload Dreams by Fleetwood Mac:\n" + "$ rip url https://www.deezer.com/en/track/63480987\n\n" + "Batch download urls from a text file named urls.txt:\n" + "$ rip url --file urls.txt\n\n" + "For more information on Quality IDs, see\n" + "https://github.com/nathom/streamrip/wiki/Quality-IDs\n" + ) -@cli.command() -@option( - "-t", - "--type", - default="album", - help="album, playlist, track, or artist", - show_default=True, -) -@option( - "-s", - "--source", - default="qobuz", - help="qobuz, tidal, soundcloud, deezer, or deezloader", - show_default=True, -) -@argument("QUERY", nargs=-1) -@pass_context -def search(ctx, **kwargs): - """Search and download media in interactive mode. + def handle(self): + global outdated - The QUERY must be surrounded in quotes if it contains spaces. If your query - contains single quotes, use double quotes, and vice versa. + # Use a thread so that it doesn't slow down startup + update_check = threading.Thread(target=is_outdated, daemon=True) - 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: - secho("No items chosen, exiting.", fg="bright_red") - - -@cli.command() -@option("-l", "--list", default="ideal-discography", show_default=True) -@option( - "-s", "--scrape", is_flag=True, help="Download all of the items in a list." -) -@option( - "-n", "--num-items", default=50, help="Number of items to parse.", show_default=True -) -@pass_context -def discover(ctx, **kwargs): - """Search for albums in Qobuz's featured lists. - - Avaiable options for `--list`: - - \b - * most-streamed - * 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 - - """ - from streamrip.constants import QOBUZ_FEATURED_KEYS - - assert ( - kwargs["list"] in QOBUZ_FEATURED_KEYS - ), f"Invalid featured key {kwargs['list']}" - - if kwargs["scrape"]: - core.scrape(kwargs["list"]) - core.download() - return - - if core.interactive_search( - kwargs["list"], "qobuz", "featured", limit=int(kwargs["num_items"]) - ): - core.download() - else: - none_chosen() - - -@cli.command() -@option( - "-s", - "--source", - help="qobuz, tidal, deezer, deezloader, or soundcloud", -) -@argument("URL") -@pass_context -def lastfm(ctx, source, url): - """Search for tracks from a last.fm playlist on a given source. - - This can be used to download playlists from Spotify and Apple Music. - To import a playlist from one of those services, go to https://www.last.fm, - log in, and to to the playlists section to import a link. - - \b - Examples: - - \b - Download a playlist using Qobuz as the source - $ rip lastfm https://www.last.fm/user/nathan3895/playlists/12059037 - - \b - Download a playlist using Tidal as the source - $ rip lastfm -s tidal https://www.last.fm/user/nathan3895/playlists/12059037 - - """ - if source is not None: - config.session["lastfm"]["source"] = source - - core.handle_lastfm_urls(url) - core.download() - - -@cli.command() -@option("-o", "--open", is_flag=True, help="Open the config file") -@option("-d", "--directory", is_flag=True, help="Open the config directory") -@option("-q", "--qobuz", is_flag=True, help="Set Qobuz credentials") -@option("-t", "--tidal", is_flag=True, help="Re-login into Tidal") -@option("-dz", "--deezer", is_flag=True, help="Set the Deezer ARL") -@option("--reset", is_flag=True, help="RESET the config file") -@option( - "--update", - is_flag=True, - help="Reset the config file, keeping the credentials", -) -@option("-p", "--path", is_flag=True, help="Show the config file's path") -@option( - "-ov", - "--open-vim", - is_flag=True, - help="Open the config file in the nvim or vim text editor.", -) -@pass_context -def config(ctx, **kwargs): - """Manage the streamrip configuration file.""" - import os - import shutil - from getpass import getpass - from hashlib import md5 - - from streamrip.clients import TidalClient - - from .constants import CONFIG_PATH - - global config - if kwargs["reset"]: - config.reset() - - if kwargs["update"]: - config.update() - - if kwargs["path"]: - echo(CONFIG_PATH) - - if kwargs["open"]: - secho(f"Opening {CONFIG_PATH}", fg="green") - 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}'") - - if kwargs["directory"]: - config_dir = os.path.dirname(CONFIG_PATH) - secho(f"Opening {config_dir}", fg="green") - launch(config_dir) - - if kwargs["qobuz"]: - config.file["qobuz"]["email"] = input(style("Qobuz email: ", fg="blue")) - - secho("Qobuz password (will not show on screen):", fg="blue") - config.file["qobuz"]["password"] = md5( - getpass(prompt="").encode("utf-8") - ).hexdigest() - - config.save() - secho("Qobuz credentials hashed and saved to config.", fg="green") - - if kwargs["tidal"]: - client = TidalClient() - client.login() - config.file["tidal"].update(client.get_tokens()) - config.save() - secho("Credentials saved to config.", fg="green") - - if kwargs["deezer"]: - secho( - "If you're not sure how to find the ARL cookie, see the instructions at ", - italic=True, - nl=False, - dim=True, - ) - secho( - "https://github.com/nathom/streamrip/wiki/Finding-your-Deezer-ARL-Cookie", - underline=True, - italic=True, - fg="blue", - ) - config.file["deezer"]["arl"] = input(style("ARL: ", fg="green")) - config.save() - - -@cli.command() -@option( - "-sr", "--sampling-rate", help="Downsample the tracks to this rate, in Hz." -) -@option("-bd", "--bit-depth", help="Downsample the tracks to this bit depth.") -@option( - "-k", - "--keep-source", - is_flag=True, - help="Do not delete the old file after conversion.", -) -@argument("CODEC") -@argument("PATH") -@pass_context -def convert(ctx, **kwargs): - """Batch convert audio files. - - This is a tool that is included with the `rip` program that assists with - converting audio files. This is essentially a wrapper over ffmpeg - that is designed to be easy to use with sensible default options. - - Examples (assuming /my/music is filled with FLAC files): - - $ rip convert MP3 /my/music - - $ rip convert ALAC --sampling-rate 48000 /my/music - - """ - import concurrent.futures - import os - - from tqdm import tqdm - - from streamrip import converter - - 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"]): - import itertools - from pathlib import Path - - dirname = kwargs["path"] - audio_extensions = ("flac", "m4a", "aac", "opus", "mp3", "ogg") - path_obj = Path(dirname) - audio_files = ( - path.as_posix() - for path in itertools.chain.from_iterable( - (path_obj.rglob(f"*.{ext}") for ext in audio_extensions) - ) + config = Config() + path, codec, quality, no_db = clean_options( + self.option("file"), + self.option("codec"), + self.option("max-quality"), + self.option("ignore-db"), ) - with concurrent.futures.ThreadPoolExecutor() as executor: - futures = [] - for file in audio_files: - futures.append( - executor.submit( - codec_map[codec]( - filename=os.path.join(dirname, file), **converter_args - ).convert - ) + if no_db: + config.session["database"]["enabled"] = False + + if quality is not None: + for source in ("qobuz", "tidal", "deezer"): + config.session[source]["quality"] = quality + + core = RipCore(config) + + urls = self.argument("urls") + + if path is not None: + if os.path.isfile(path): + core.handle_txt(path) + else: + self.line( + f"File {path} does not exist." ) - for future in tqdm( - concurrent.futures.as_completed(futures), - total=len(futures), - desc="Converting", - ): - # Only show loading bar - future.result() + return 1 - elif os.path.isfile(kwargs["path"]): - codec_map[codec](filename=kwargs["path"], **converter_args).convert() - else: - secho(f"File {kwargs['path']} does not exist.", fg="red") + if urls: + core.handle_urls(";".join(urls)) + + if len(core) > 0: + core.download() + elif not urls and path is None: + self.line("Must pass arguments. See rip url -h.") + + try: + update_check.join() + if outdated: + self.line( + "A new version of streamrip is available! Run " + "pip3 install streamrip --upgrade to update" + ) + except RuntimeError as e: + logger.debug("Update check error: %s", e) + pass + + return 0 -@cli.command() -@option( - "-n", "--num-items", help="The number of items to atttempt downloads for." -) -@pass_context -def repair(ctx, **kwargs): - """Retry failed downloads. - - If the failed downloads database is enabled in the config file (it is by default), - when an item is not available for download, it is logged in the database. - - When this command is called, it tries to download those items again. This is useful - for times when a temporary server error may miss a few tracks in an album. +class SearchCommand(Command): """ - core.repair(max_items=kwargs.get("num_items")) + Search for and download items in interactive mode. + + search + {query : The name to search for} + {--s|source=qobuz : Qobuz, Tidal, Soundcloud, Deezer, or Deezloader} + {--t|type=album : Album, Playlist, Track, or Artist} + """ + + help = ( + "\nSearch for Rumours by Fleetwood Mac\n" + "$ rip search 'rumours fleetwood mac'\n\n" + "Search for 444 by Jay-Z on TIDAL\n" + "$ rip search --source tidal '444'\n\n" + "Search for Bob Dylan on Deezer\n" + "$ rip search --type artist --source deezer 'bob dylan'\n" + ) + + def handle(self): + query = self.argument("query") + source, type = clean_options(self.option("source"), self.option("type")) + + config = Config() + core = RipCore(config) + + if core.interactive_search(query, source, type): + core.download() + else: + self.line("No items chosen, exiting.") -def none_chosen(): - """Print message if nothing was chosen.""" - secho("No items chosen, exiting.", fg="bright_red") +class DiscoverCommand(Command): + """ + Browse and download items in interactive mode (Qobuz only). + + discover + {--s|scrape : Download all of the items in the list} + {--m|max-items=50 : The number of items to fetch} + {list=ideal-discography : The list to fetch} + """ + + help = ( + "\nBrowse the Qobuz ideal-discography list\n" + "$ rip discover\n\n" + "Browse the best-sellers list\n" + "$ rip discover best-sellers\n\n" + "Available options for list:\n\n" + " • most-streamed\n" + " • recent-releases\n" + " • best-sellers\n" + " • press-awards\n" + " • ideal-discography\n" + " • editor-picks\n" + " • most-featured\n" + " • qobuzissims\n" + " • new-releases\n" + " • new-releases-full\n" + " • harmonia-mundi\n" + " • universal-classic\n" + " • universal-jazz\n" + " • universal-jeunesse\n" + " • universal-chanson\n" + ) + + def handle(self): + from streamrip.constants import QOBUZ_FEATURED_KEYS + + chosen_list = self.argument("list") + scrape = self.option("scrape") + max_items = self.option("max-items") + + if chosen_list not in QOBUZ_FEATURED_KEYS: + self.line(f'Error: list "{chosen_list}" not available') + self.line(self.help) + return 1 + + config = Config() + core = RipCore(config) + + if scrape: + core.scrape(chosen_list, max_items) + core.download() + return 0 + + if core.interactive_search( + chosen_list, "qobuz", "featured", limit=int(max_items) + ): + core.download() + else: + self.line("No items chosen, exiting.") + + +class LastfmCommand(Command): + """ + Search for tracks from a list.fm playlist and download them. + + lastfm + {--s|source=qobuz : The source to search for items on} + {urls* : Last.fm playlist urls} + """ + + help = ( + "You can use this command to download Spotify, Apple Music, and YouTube " + "playlists.\nTo get started, create an account at " + "https://www.last.fm. Once you have\nreached the home page, " + "go to Profile IconView profile ⟶ " + "PlaylistsIMPORT\nand paste your url.\n\n" + "Download the young & free Apple Music playlist (already imported)\n" + "$ rip lastfm https://www.last.fm/user/nathan3895/playlists/12089888\n" + ) + + def handle(self): + source = self.option("source") + urls = self.argument("urls") + + config = Config() + core = RipCore(config) + config.session["lastfm"]["source"] = source + core.handle_lastfm_urls(";".join(urls)) + core.download() + + +class ConfigCommand(Command): + """ + Manage the configuration file. + + config + {--o|open : Open the config file in the default application} + {--O|open-vim : Open the config file in (neo)vim} + {--d|directory : Open the directory that the config file is located in} + {--p|path : Show the config file's path} + {--qobuz : Set the credentials for Qobuz} + {--tidal : Log into Tidal} + {--deezer : Set the Deezer ARL} + {--reset : Reset the config file} + {--update : Reset the config file, keeping the credentials} + """ + + def handle(self): + import shutil + + from .constants import CONFIG_DIR, CONFIG_PATH + + config = Config() + + if self.option("path"): + self.line(f"{CONFIG_PATH}") + + if self.option("open"): + self.line(f"Opening {CONFIG_PATH} in default application") + launch(CONFIG_PATH) + + if self.option("reset"): + config.reset() + + if self.option("update"): + config.update() + + if self.option("open-vim"): + if shutil.which("nvim") is not None: + os.system(f"nvim '{CONFIG_PATH}'") + else: + os.system(f"vim '{CONFIG_PATH}'") + + if self.option("directory"): + self.line(f"Opening {CONFIG_DIR}") + launch(CONFIG_DIR) + + if self.option("tidal"): + from streamrip.clients import TidalClient + + client = TidalClient() + client.login() + config.file["tidal"].update(client.get_tokens()) + config.save() + self.line("Credentials saved to config.") + + if self.option("deezer"): + self.line( + "Follow the instructions at https://github.com" + "/nathom/streamrip/wiki/Finding-your-Deezer-ARL-Cookie" + ) + + config.file["deezer"]["arl"] = self.ask("Paste your ARL here: ") + config.save() + + +class ConvertCommand(Command): + """ + A standalone tool that converts audio files to other codecs en masse. + + convert + {--s|sampling-rate=192000 : Downsample the tracks to this rate, in Hz.} + {--b|bit-depth=24 : Downsample the tracks to this bit depth.} + {--k|keep-source : Keep the original file after conversion.} + {codec : FLAC, ALAC, OPUS, MP3, or AAC.} + {path : The path to the audio file or a directory that contains audio files.} + """ + + help = ( + "\nConvert all of the audio files in /my/music to MP3s\n" + "$ rip convert MP3 /my/music\n\n" + "Downsample the audio to 48kHz after converting them to ALAC\n" + "$ rip convert --sampling-rate 48000 ALAC /my/music\n" + ) + + def handle(self): + from streamrip import converter + + CODEC_MAP = { + "FLAC": converter.FLAC, + "ALAC": converter.ALAC, + "OPUS": converter.OPUS, + "MP3": converter.LAME, + "AAC": converter.AAC, + } + + codec = self.argument("codec") + path = self.argument("path") + + ConverterCls = CODEC_MAP.get(codec.upper()) + if ConverterCls is None: + self.line( + f'Invalid codec "{codec}". See rip convert' + " -h." + ) + return 1 + + sampling_rate, bit_depth, keep_source = clean_options( + self.option("sampling-rate"), + self.option("bit-depth"), + self.option("keep-source"), + ) + + converter_args = { + "sampling_rate": sampling_rate, + "bit_depth": bit_depth, + "remove_source": not keep_source, + } + + if os.path.isdir(path): + import itertools + from pathlib import Path + + from tqdm import tqdm + + dirname = path + audio_extensions = ("flac", "m4a", "aac", "opus", "mp3", "ogg") + path_obj = Path(dirname) + audio_files = ( + path.as_posix() + for path in itertools.chain.from_iterable( + (path_obj.rglob(f"*.{ext}") for ext in audio_extensions) + ) + ) + + with concurrent.futures.ThreadPoolExecutor() as executor: + futures = [] + for file in audio_files: + futures.append( + executor.submit( + ConverterCls( + filename=os.path.join(dirname, file), **converter_args + ).convert + ) + ) + from streamrip.utils import TQDM_BAR_FORMAT + + for future in tqdm( + concurrent.futures.as_completed(futures), + total=len(futures), + desc="Converting", + bar_format=TQDM_BAR_FORMAT, + ): + # Only show loading bar + future.result() + + elif os.path.isfile(path): + ConverterCls(filename=path, **converter_args).convert() + else: + self.line( + f'Path "{path}" does not exist.', fg="red" + ) + + +class RepairCommand(Command): + """ + Retry failed downloads. + + repair + {--m|max-items=None : The maximum number of tracks to download} + """ + + help = "\nRetry up to 20 failed downloads\n$ rip repair --max-items 20\n" + + def handle(self): + max_items = clean_options(self.option("repair")) + config = Config() + RipCore(config).repair(max_items=max_items) + + +STRING_TO_PRIMITIVE = { + "None": None, + "True": True, + "False": False, +} + + +class Application(BaseApplication): + def __init__(self): + super().__init__("rip", __version__) + + def _run(self, io): + if io.is_debug(): + logger.setLevel(logging.DEBUG) + + super()._run(io) + + def create_io(self, input=None, output=None, error_output=None): + io = super().create_io(input, output, error_output) + # Set our own CLI styles + formatter = io.output.formatter + formatter.set_style("url", Style("blue", options=["underline"])) + formatter.set_style("path", Style("green", options=["bold"])) + formatter.set_style("cmd", Style("magenta")) + formatter.set_style("title", Style("yellow", options=["bold"])) + io.output.set_formatter(formatter) + io.error_output.set_formatter(formatter) + + self._io = io + + return io + + @property + def _default_definition(self): + default_globals = super()._default_definition + # as of 1.0.0a3, the descriptions don't wrap properly + # so I'm truncating the description for help as a hack + default_globals._options["help"]._description = ( + default_globals._options["help"]._description.split(".")[0] + "." + ) + + return default_globals + + +def clean_options(*opts): + for opt in opts: + if isinstance(opt, str): + if opt.startswith("="): + opt = opt[1:] + + opt = opt.strip() + if opt.isdigit(): + opt = int(opt) + else: + opt = STRING_TO_PRIMITIVE.get(opt, opt) + + yield opt + + +def is_outdated(): + global outdated + r = requests.get("https://pypi.org/pypi/streamrip/json").json() + outdated = r["info"]["version"] != __version__ def main(): - """Run the main program.""" - cli(obj={}) + application = Application() + application.add(DownloadCommand()) + application.add(SearchCommand()) + application.add(DiscoverCommand()) + application.add(LastfmCommand()) + application.add(ConfigCommand()) + application.add(ConvertCommand()) + application.add(RepairCommand()) + application.run() + + +if __name__ == "__main__": + main() diff --git a/rip/cli_cleo.py b/rip/cli_cleo.py deleted file mode 100644 index 0e15eda..0000000 --- a/rip/cli_cleo.py +++ /dev/null @@ -1,214 +0,0 @@ -import logging -import os - -from cleo.application import Application as BaseApplication -from cleo.commands.command import Command - -from streamrip import __version__ - -from .config import Config -from .core import RipCore - -logging.basicConfig(level="WARNING") -logger = logging.getLogger("streamrip") - - -class DownloadCommand(Command): - """ - Download items from a url - - url - {--f|file=None : Path to a text file containing urls} - {urls?* : One or more Qobuz, Tidal, Deezer, or SoundCloud urls} - """ - - help = ( - '\nDownload "Dreams" by Fleetwood Mac:\n' - "$ rip url https://www.deezer.com/en/track/63480987\n\n" - "Batch download urls from a text file named urls.txt:\n" - "$ rip --file urls.txt\n" - ) - - def handle(self): - config = Config() - core = RipCore(config) - - urls = self.argument("urls") - path = self.option("file") - if path != "None": - if os.path.isfile(path): - core.handle_txt(path) - else: - self.line( - f"File {path} does not exist." - ) - return 1 - - if urls: - core.handle_urls(";".join(urls)) - - if len(core) > 0: - core.download() - elif not urls and path == "None": - self.line("Must pass arguments. See rip url -h.") - - return 0 - - -class SearchCommand(Command): - """ - Search for and download items in interactive mode. - - search - {query : The name to search for} - {--s|source=qobuz : Qobuz, Tidal, Soundcloud, Deezer, or Deezloader} - {--t|type=album : Album, Playlist, Track, or Artist} - """ - - def handle(self): - query = self.argument("query") - source, type = clean_options(self.option("source"), self.option("type")) - - config = Config() - core = RipCore(config) - - if core.interactive_search(query, source, type): - core.download() - else: - self.line("No items chosen, exiting.") - - -class DiscoverCommand(Command): - """ - Browse and download items in interactive mode. - - discover - {--s|scrape : Download all of the items in the list} - {--m|max-items=50 : The number of items to fetch} - {list=ideal-discography : The list to fetch} - """ - - help = ( - "\nAvailable options for list:\n\n" - " • most-streamed\n" - " • recent-releases\n" - " • best-sellers\n" - " • press-awards\n" - " • ideal-discography\n" - " • editor-picks\n" - " • most-featured\n" - " • qobuzissims\n" - " • new-releases\n" - " • new-releases-full\n" - " • harmonia-mundi\n" - " • universal-classic\n" - " • universal-jazz\n" - " • universal-jeunesse\n" - " • universal-chanson\n" - ) - - def handle(self): - from streamrip.constants import QOBUZ_FEATURED_KEYS - - chosen_list = self.argument("list") - scrape = self.option("scrape") - max_items = self.option("max-items") - - if chosen_list not in QOBUZ_FEATURED_KEYS: - self.line(f'Error: list "{chosen_list}" not available') - self.line(self.help) - return 1 - - config = Config() - core = RipCore(config) - - if scrape: - core.scrape(chosen_list, max_items) - core.download() - return 0 - - if core.interactive_search( - chosen_list, "qobuz", "featured", limit=int(max_items) - ): - core.download() - else: - self.line("No items chosen, exiting.") - - -class LastfmCommand(Command): - """ - Search for tracks from a list.fm playlist and download them. - - lastfm - {--s|source=qobuz : The source to search for items on} - {urls* : Last.fm playlist urls} - """ - - def handle(self): - source = self.option("source") - urls = self.argument("urls") - - config = Config() - core = RipCore(config) - config.session["lastfm"]["source"] = source - core.handle_lastfm_urls(";".join(urls)) - core.download() - - -class ConfigCommand(Command): - """ - Manage the configuration file - - {--o|open : Open the config file in the default application} - {--ov|open-vim : Open the config file in (neo)vim} - {--d|directory : Open the directory that the config file is located in} - {--p|path : Show the config file's path} - {--q|qobuz : Set the credentials for Qobuz} - {--t|tidal : Log into Tidal} - {--dz|deezer : Set the Deezer ARL} - {--reset : Reset the config file} - {--update : Reset the config file, keeping the credentials} - """ - - -class Application(BaseApplication): - def __init__(self): - super().__init__("rip", __version__) - - def _run(self, io): - if io.is_debug(): - logger.setLevel(logging.DEBUG) - - super()._run(io) - - # @property - # def _default_definition(self): - # default_globals = super()._default_definition - # default_globals.add_option(Option("convert", shortcut="c", flag=False)) - # return default_globals - - -# class ConvertCommand(Command): -# pass - - -# class RepairCommand(Command): -# pass - - -def clean_options(*opts): - return tuple(o.replace("=", "").strip() for o in opts) - - -def main(): - application = Application() - application.add(DownloadCommand()) - application.add(SearchCommand()) - application.add(DiscoverCommand()) - application.add(LastfmCommand()) - # application.add(ConfigCommand()) - application.run() - - -if __name__ == "__main__": - main() diff --git a/rip/core.py b/rip/core.py index 1a9535c..9248bc4 100644 --- a/rip/core.py +++ b/rip/core.py @@ -173,10 +173,10 @@ class RipCore(list): for source, url_type, item_id in parsed: if item_id in self.db: logger.info( - f"ID {item_id} already downloaded, use --no-db to override." + f"ID {item_id} already downloaded, use --ignore-db to override." ) secho( - f"ID {item_id} already downloaded, use --no-db to override.", + f"ID {item_id} already downloaded, use --ignore-db to override.", fg="magenta", ) continue diff --git a/streamrip/clients.py b/streamrip/clients.py index ebfe11d..1aa549c 100644 --- a/streamrip/clients.py +++ b/streamrip/clients.py @@ -12,7 +12,7 @@ from pprint import pformat from typing import Any, Dict, Generator, Optional, Sequence, Tuple, Union import deezer -from click import secho +from click import launch, secho from Cryptodome.Cipher import AES from .constants import ( @@ -807,7 +807,7 @@ class TidalClient(Client): # ------------ Utilities to login ------------- - def _login_new_user(self, launch: bool = True): + def _login_new_user(self, launch_url: bool = True): """Create app url where the user can log in. :param launch: Launch the browser. @@ -819,7 +819,7 @@ class TidalClient(Client): f"Go to {login_link} to log into Tidal within 5 minutes.", fg="blue", ) - if launch: + if launch_url: launch(login_link) start = time.time()