mirror of
https://github.com/nathom/streamrip.git
synced 2025-05-18 00:54:50 -04:00
Added new download function, fixed featured bugs
This commit is contained in:
parent
a46b9867b2
commit
6e93cc796c
4 changed files with 121 additions and 71 deletions
|
@ -1,13 +1,10 @@
|
||||||
# For tests
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pprint import pformat
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .constants import CACHE_DIR, CONFIG_DIR, CONFIG_PATH, QOBUZ_FEATURED_KEYS
|
from .constants import CACHE_DIR, CONFIG_DIR, CONFIG_PATH
|
||||||
from .core import MusicDL
|
from .core import MusicDL
|
||||||
from .utils import init_log
|
from .utils import init_log
|
||||||
|
|
||||||
|
@ -22,7 +19,10 @@ if not os.path.isdir(CACHE_DIR):
|
||||||
|
|
||||||
@click.group(invoke_without_command=True)
|
@click.group(invoke_without_command=True)
|
||||||
@click.option("-c", "--convert", metavar="CODEC")
|
@click.option("-c", "--convert", metavar="CODEC")
|
||||||
@click.option("-u", '--urls', metavar='URLS')
|
@click.option("-u", "--urls", metavar="URLS")
|
||||||
|
@click.option("-nd", "--no-db", is_flag=True)
|
||||||
|
@click.option("--debug", is_flag=True)
|
||||||
|
@click.option("--reset-config", is_flag=True)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def cli(ctx, **kwargs):
|
def cli(ctx, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -56,9 +56,29 @@ def cli(ctx, **kwargs):
|
||||||
global config
|
global config
|
||||||
global core
|
global core
|
||||||
|
|
||||||
|
if kwargs["debug"]:
|
||||||
|
init_log()
|
||||||
|
|
||||||
config = Config()
|
config = Config()
|
||||||
|
if kwargs["reset_config"]:
|
||||||
|
config.reset()
|
||||||
|
return
|
||||||
|
|
||||||
|
if kwargs["no_db"]:
|
||||||
|
config.session["database"]["enabled"] = False
|
||||||
|
if kwargs["convert"]:
|
||||||
|
config.session["conversion"]["enabled"] = True
|
||||||
|
config.session["conversion"]["codec"] = kwargs["convert"]
|
||||||
|
|
||||||
core = MusicDL(config)
|
core = MusicDL(config)
|
||||||
|
|
||||||
|
if kwargs["urls"]:
|
||||||
|
logger.debug(f"handling {kwargs['urls']}")
|
||||||
|
core.handle_urls(kwargs["urls"])
|
||||||
|
|
||||||
|
if ctx.invoked_subcommand is None:
|
||||||
|
core.download()
|
||||||
|
|
||||||
|
|
||||||
@cli.command(name="filter")
|
@cli.command(name="filter")
|
||||||
@click.option("--repeats", is_flag=True)
|
@click.option("--repeats", is_flag=True)
|
||||||
|
@ -79,28 +99,28 @@ def filter_discography(ctx, **kwargs):
|
||||||
For basic filtering, use the `--repeats` and `--features` filters.
|
For basic filtering, use the `--repeats` and `--features` filters.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
filters = [k for k, v in kwargs.items() if v]
|
filters = kwargs.copy()
|
||||||
filters.remove('urls')
|
filters.remove("urls")
|
||||||
print(f"loaded filters {filters}")
|
|
||||||
config.session["filters"] = filters
|
config.session["filters"] = filters
|
||||||
print(f"downloading {kwargs['urls']} with filters")
|
logger.debug(f"downloading {kwargs['urls']} with filters {filters}")
|
||||||
|
core.handle_urls(" ".join(kwargs["urls"]))
|
||||||
|
core.download()
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.option("-t", "--type", default="album")
|
@click.option("-t", "--type", default="album")
|
||||||
|
@click.option("-s", "--source", default="qobuz")
|
||||||
@click.option("-d", "--discover", is_flag=True)
|
@click.option("-d", "--discover", is_flag=True)
|
||||||
@click.argument("QUERY", nargs=-1)
|
@click.argument("QUERY", nargs=-1)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def interactive(ctx, **kwargs):
|
def interactive(ctx, **kwargs):
|
||||||
f"""Interactive search for a query. This will display a menu
|
"""Interactive search for a query. This will display a menu
|
||||||
from which you can choose an item to download.
|
from which you can choose an item to download.
|
||||||
|
|
||||||
If the source is Qobuz, you can use the `--discover` option with
|
If the source is Qobuz, you can use the `--discover` option with
|
||||||
one of the following queries to fetch and interactively download
|
one of the following queries to fetch and interactively download
|
||||||
the featured albums.
|
the featured albums.
|
||||||
|
|
||||||
{pformat(QOBUZ_FEATURED_KEYS)}
|
|
||||||
|
|
||||||
* most-streamed
|
* most-streamed
|
||||||
|
|
||||||
* recent-releases
|
* recent-releases
|
||||||
|
@ -133,14 +153,18 @@ def interactive(ctx, **kwargs):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
print(f"starting interactive mode for type {kwargs['type']}")
|
logger.debug(f"starting interactive mode for type {kwargs['type']}")
|
||||||
if kwargs['discover']:
|
if kwargs["discover"]:
|
||||||
if kwargs['query'] == ():
|
if kwargs["query"] == ():
|
||||||
kwargs['query'] = 'ideal-discography'
|
kwargs["query"] = ["ideal-discography"]
|
||||||
print(f"doing a discover search of type {kwargs['query']}")
|
kwargs["type"] = "featured"
|
||||||
|
|
||||||
|
query = " ".join(kwargs["query"])
|
||||||
|
|
||||||
|
if core.interactive_search(query, kwargs["source"], kwargs["type"]):
|
||||||
|
core.download()
|
||||||
else:
|
else:
|
||||||
query = ' '.join(kwargs['query'])
|
click.secho("No items chosen, exiting.", fg="bright_red")
|
||||||
print(f"searching for query '{query}'")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_urls(arg: str):
|
def parse_urls(arg: str):
|
||||||
|
|
|
@ -40,8 +40,13 @@ class Config:
|
||||||
},
|
},
|
||||||
"tidal": {"enabled": True, "email": None, "password": None},
|
"tidal": {"enabled": True, "email": None, "password": None},
|
||||||
"deezer": {"enabled": True},
|
"deezer": {"enabled": True},
|
||||||
"downloads_database": None,
|
"database": {"enabled": True, "path": None},
|
||||||
"conversion": {"codec": None, "sampling_rate": None, "bit_depth": None},
|
"conversion": {
|
||||||
|
"enabled": False,
|
||||||
|
"codec": None,
|
||||||
|
"sampling_rate": None,
|
||||||
|
"bit_depth": None,
|
||||||
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"extras": False,
|
"extras": False,
|
||||||
"repeats": False,
|
"repeats": False,
|
||||||
|
@ -74,15 +79,15 @@ class Config:
|
||||||
logger.debug(f"Creating yaml config file at '{self._path}'")
|
logger.debug(f"Creating yaml config file at '{self._path}'")
|
||||||
self.dump(self.defaults)
|
self.dump(self.defaults)
|
||||||
else:
|
else:
|
||||||
self.load_file()
|
self.load()
|
||||||
|
|
||||||
def save_file(self):
|
def save(self):
|
||||||
self.dump(self.file)
|
self.dump(self.file)
|
||||||
|
|
||||||
def reset_file(self):
|
def reset(self):
|
||||||
self.dump(self.defaults)
|
self.dump(self.defaults)
|
||||||
|
|
||||||
def load_file(self):
|
def load(self):
|
||||||
with open(self._path) as cfg:
|
with open(self._path) as cfg:
|
||||||
for k, v in yaml.load(cfg).items():
|
for k, v in yaml.load(cfg).items():
|
||||||
self.file[k] = v
|
self.file[k] = v
|
||||||
|
|
|
@ -2,12 +2,12 @@ import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from getpass import getpass
|
from getpass import getpass
|
||||||
|
from pprint import pprint
|
||||||
from string import Formatter
|
from string import Formatter
|
||||||
from typing import Generator, Optional, Tuple, Union
|
from typing import Generator, Optional, Tuple, Union
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from simple_term_menu import TerminalMenu
|
from simple_term_menu import TerminalMenu
|
||||||
from tqdm import tqdm
|
|
||||||
|
|
||||||
from .clients import DeezerClient, QobuzClient, TidalClient
|
from .clients import DeezerClient, QobuzClient, TidalClient
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
@ -30,16 +30,12 @@ MEDIA_CLASS = {
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class MusicDL(list):
|
class MusicDL(list):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: Optional[Config] = None,
|
config: Optional[Config] = None,
|
||||||
database: Optional[str] = None,
|
|
||||||
):
|
):
|
||||||
logger.debug(locals())
|
|
||||||
|
|
||||||
self.url_parse = re.compile(URL_REGEX)
|
self.url_parse = re.compile(URL_REGEX)
|
||||||
self.config = config
|
self.config = config
|
||||||
|
@ -52,10 +48,15 @@ class MusicDL(list):
|
||||||
"deezer": DeezerClient(),
|
"deezer": DeezerClient(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if isinstance(database, (MusicDB, list)):
|
if config.session["database"]["enabled"]:
|
||||||
self.db = database
|
if config.session["database"]["path"] is not None:
|
||||||
elif database is None:
|
self.db = MusicDB(config.session["database"]["path"])
|
||||||
|
else:
|
||||||
self.db = MusicDB(DB_PATH)
|
self.db = MusicDB(DB_PATH)
|
||||||
|
config.file["database"]["path"] = DB_PATH
|
||||||
|
config.save()
|
||||||
|
else:
|
||||||
|
self.db = []
|
||||||
|
|
||||||
def prompt_creds(self, source: str):
|
def prompt_creds(self, source: str):
|
||||||
"""Prompt the user for credentials.
|
"""Prompt the user for credentials.
|
||||||
|
@ -88,7 +89,7 @@ class MusicDL(list):
|
||||||
):
|
):
|
||||||
self.prompt_creds(source)
|
self.prompt_creds(source)
|
||||||
|
|
||||||
def handle_url(self, url: str):
|
def handle_urls(self, url: str):
|
||||||
"""Download an url
|
"""Download an url
|
||||||
|
|
||||||
:param url:
|
:param url:
|
||||||
|
@ -100,34 +101,43 @@ class MusicDL(list):
|
||||||
if item_id in self.db:
|
if item_id in self.db:
|
||||||
logger.info(f"{url} already downloaded, use --no-db to override.")
|
logger.info(f"{url} already downloaded, use --no-db to override.")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.handle_item(source, url_type, item_id)
|
self.handle_item(source, url_type, item_id)
|
||||||
|
|
||||||
def handle_item(self, source: str, media_type: str, item_id: str):
|
def handle_item(self, source: str, media_type: str, item_id: str):
|
||||||
self.assert_creds(source)
|
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.get_client(source)
|
client = self.get_client(source)
|
||||||
|
|
||||||
item = MEDIA_CLASS[media_type](client=client, id=item_id)
|
item = MEDIA_CLASS[media_type](client=client, id=item_id)
|
||||||
self.append(item)
|
self.append(item)
|
||||||
|
|
||||||
|
def download(self):
|
||||||
|
arguments = {
|
||||||
|
"database": self.db,
|
||||||
|
"parent_folder": self.config.session["downloads"]["folder"],
|
||||||
|
"quality": self.config.session["downloads"]["quality"],
|
||||||
|
"embed_cover": self.config.session["metadata"]["embed_cover"],
|
||||||
|
}
|
||||||
|
logger.debug("Arguments from config: %s", arguments)
|
||||||
|
for item in self:
|
||||||
if isinstance(item, Artist):
|
if isinstance(item, Artist):
|
||||||
keys = self.config.filters.keys()
|
filters_ = tuple(
|
||||||
# TODO: move this to config.py
|
k for k, v in self.config.session["filters"].items() if v
|
||||||
filters_ = tuple(key for key in keys if self.config.filters[key])
|
)
|
||||||
arguments["filters"] = filters_
|
arguments["filters"] = filters_
|
||||||
logger.debug("Added filter argument for artist/label: %s", filters_)
|
logger.debug("Added filter argument for artist/label: %s", filters_)
|
||||||
|
|
||||||
logger.debug("Arguments from config: %s", arguments)
|
|
||||||
|
|
||||||
item.load_meta()
|
item.load_meta()
|
||||||
click.secho(f"Downloading {item!s}", fg="bright_green")
|
click.secho(f"Downloading {item!s}", fg="bright_green")
|
||||||
item.download(**arguments)
|
item.download(**arguments)
|
||||||
|
pprint(self.config.session["conversion"])
|
||||||
|
if self.config.session["conversion"]["enabled"]:
|
||||||
|
click.secho(
|
||||||
|
f"Converting {item!s} to {self.config.session['conversion']['codec']}",
|
||||||
|
fg="cyan",
|
||||||
|
)
|
||||||
|
item.convert(**self.config.session["conversion"])
|
||||||
|
|
||||||
def get_client(self, source: str):
|
def get_client(self, source: str):
|
||||||
client = self.clients[source]
|
client = self.clients[source]
|
||||||
|
@ -157,8 +167,8 @@ class MusicDL(list):
|
||||||
and not creds.get("app_id")
|
and not creds.get("app_id")
|
||||||
):
|
):
|
||||||
(
|
(
|
||||||
self.config["qobuz"]["app_id"],
|
self.config.file["qobuz"]["app_id"],
|
||||||
self.config["qobuz"]["secrets"],
|
self.config.file["qobuz"]["secrets"],
|
||||||
) = client.get_tokens()
|
) = client.get_tokens()
|
||||||
self.config.save()
|
self.config.save()
|
||||||
|
|
||||||
|
@ -208,8 +218,15 @@ class MusicDL(list):
|
||||||
i = 0
|
i = 0
|
||||||
if isinstance(results, Generator): # QobuzClient
|
if isinstance(results, Generator): # QobuzClient
|
||||||
for page in results:
|
for page in results:
|
||||||
for item in page[f"{media_type}s"]["items"]:
|
tracklist = (
|
||||||
yield MEDIA_CLASS[media_type].from_api(item, client)
|
page[f"{media_type}s"]["items"]
|
||||||
|
if media_type != "featured"
|
||||||
|
else page["albums"]["items"]
|
||||||
|
)
|
||||||
|
for item in tracklist:
|
||||||
|
yield MEDIA_CLASS[
|
||||||
|
media_type if media_type != "featured" else "album"
|
||||||
|
].from_api(item, client)
|
||||||
i += 1
|
i += 1
|
||||||
if i > limit:
|
if i > limit:
|
||||||
return
|
return
|
||||||
|
@ -238,7 +255,7 @@ class MusicDL(list):
|
||||||
def interactive_search(
|
def interactive_search(
|
||||||
self, query: str, source: str = "qobuz", media_type: str = "album"
|
self, query: str, source: str = "qobuz", media_type: str = "album"
|
||||||
):
|
):
|
||||||
results = tuple(self.search(source, query, media_type, limit=30))
|
results = tuple(self.search(source, query, media_type, limit=50))
|
||||||
|
|
||||||
def title(res):
|
def title(res):
|
||||||
return f"{res[0]+1}. {res[1].title}"
|
return f"{res[0]+1}. {res[1].title}"
|
||||||
|
@ -261,4 +278,8 @@ class MusicDL(list):
|
||||||
clear_screen=True,
|
clear_screen=True,
|
||||||
)
|
)
|
||||||
choice = menu.show()
|
choice = menu.show()
|
||||||
return results[choice]
|
if choice is None:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
self.append(results[choice])
|
||||||
|
return True
|
||||||
|
|
|
@ -99,8 +99,8 @@ class Track:
|
||||||
self.sampling_rate = 44100
|
self.sampling_rate = 44100
|
||||||
self.bit_depth = 16
|
self.bit_depth = 16
|
||||||
|
|
||||||
self.__is_downloaded = False
|
self._is_downloaded = False
|
||||||
self.__is_tagged = False
|
self._is_tagged = False
|
||||||
for attr in ("quality", "folder", "meta"):
|
for attr in ("quality", "folder", "meta"):
|
||||||
setattr(self, attr, None)
|
setattr(self, attr, None)
|
||||||
|
|
||||||
|
@ -155,14 +155,14 @@ class Track:
|
||||||
quality or self.quality,
|
quality or self.quality,
|
||||||
parent_folder or self.folder,
|
parent_folder or self.folder,
|
||||||
)
|
)
|
||||||
self.folder = sanitize_filepath(parent_folder)
|
self.folder = sanitize_filepath(parent_folder, platform="auto")
|
||||||
|
|
||||||
os.makedirs(self.folder, exist_ok=True)
|
os.makedirs(self.folder, exist_ok=True)
|
||||||
|
|
||||||
if database is not None:
|
if database is not None:
|
||||||
if self.id in database:
|
if self.id in database:
|
||||||
self.__is_downloaded = True
|
self._is_downloaded = True
|
||||||
self.__is_tagged = True
|
self._is_tagged = True
|
||||||
click.secho(
|
click.secho(
|
||||||
f"{self['title']} already logged in database, skipping.",
|
f"{self['title']} already logged in database, skipping.",
|
||||||
fg="magenta",
|
fg="magenta",
|
||||||
|
@ -170,8 +170,8 @@ class Track:
|
||||||
return
|
return
|
||||||
|
|
||||||
if os.path.isfile(self.format_final_path()):
|
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="magenta")
|
click.secho(f"Track already downloaded: {self.final_path}", fg="magenta")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -195,8 +195,8 @@ class Track:
|
||||||
|
|
||||||
if os.path.isfile(temp_file):
|
if os.path.isfile(temp_file):
|
||||||
logger.debug("Temporary file found: %s", temp_file)
|
logger.debug("Temporary file found: %s", temp_file)
|
||||||
self.__is_downloaded = True
|
self._is_downloaded = True
|
||||||
self.__is_tagged = False
|
self._is_tagged = False
|
||||||
|
|
||||||
click.secho(f"\nDownloading {self!s}", fg="blue")
|
click.secho(f"\nDownloading {self!s}", fg="blue")
|
||||||
|
|
||||||
|
@ -217,7 +217,7 @@ class Track:
|
||||||
|
|
||||||
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
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def download_cover(self):
|
def download_cover(self):
|
||||||
|
@ -310,13 +310,13 @@ class Track:
|
||||||
:type cover: Union[Picture, APIC]
|
:type cover: Union[Picture, APIC]
|
||||||
"""
|
"""
|
||||||
assert isinstance(self.meta, TrackMetadata), "meta must be TrackMetadata"
|
assert isinstance(self.meta, TrackMetadata), "meta must be TrackMetadata"
|
||||||
if not self.__is_downloaded:
|
if not self._is_downloaded:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Track %s not tagged because it was not downloaded", self["title"]
|
"Track %s not tagged because it was not downloaded", self["title"]
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.__is_tagged:
|
if self._is_tagged:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Track %s not tagged because it is already tagged", self["title"]
|
"Track %s not tagged because it is already tagged", self["title"]
|
||||||
)
|
)
|
||||||
|
@ -361,7 +361,7 @@ class Track:
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown container type: {audio}")
|
raise ValueError(f"Unknown container type: {audio}")
|
||||||
|
|
||||||
self.__is_tagged = True
|
self._is_tagged = True
|
||||||
|
|
||||||
def convert(self, codec: str = "ALAC", **kwargs):
|
def convert(self, codec: str = "ALAC", **kwargs):
|
||||||
"""Converts the track to another codec.
|
"""Converts the track to another codec.
|
||||||
|
@ -380,7 +380,7 @@ class Track:
|
||||||
:type codec: str
|
:type codec: str
|
||||||
:param kwargs:
|
:param kwargs:
|
||||||
"""
|
"""
|
||||||
assert self.__is_downloaded, "Track must be downloaded before conversion"
|
assert self._is_downloaded, "Track must be downloaded before conversion"
|
||||||
|
|
||||||
CONV_CLASS = {
|
CONV_CLASS = {
|
||||||
"FLAC": converter.FLAC,
|
"FLAC": converter.FLAC,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue