mirror of
https://github.com/nathom/streamrip.git
synced 2025-05-20 02:05:41 -04:00
Switch config to TOML
Signed-off-by: nathom <nathanthomas707@gmail.com>
This commit is contained in:
parent
7698ad7a2e
commit
5a5a199be2
9 changed files with 188 additions and 368 deletions
|
@ -1,6 +1,6 @@
|
||||||
click
|
click
|
||||||
ruamel.yaml
|
|
||||||
pathvalidate
|
pathvalidate
|
||||||
requests
|
requests
|
||||||
mutagen>=1.45.1
|
mutagen>=1.45.1
|
||||||
tqdm
|
tqdm
|
||||||
|
tomlkit
|
||||||
|
|
|
@ -67,7 +67,7 @@ def cli(ctx, **kwargs):
|
||||||
if ctx.invoked_subcommand == "config":
|
if ctx.invoked_subcommand == "config":
|
||||||
return
|
return
|
||||||
|
|
||||||
if config.session["check_for_updates"]:
|
if config.session["misc"]["check_for_updates"]:
|
||||||
r = requests.get("https://pypi.org/pypi/streamrip/json").json()
|
r = requests.get("https://pypi.org/pypi/streamrip/json").json()
|
||||||
newest = r["info"]["version"]
|
newest = r["info"]["version"]
|
||||||
if __version__ != newest:
|
if __version__ != newest:
|
||||||
|
@ -285,7 +285,7 @@ def config(ctx, **kwargs):
|
||||||
config.update()
|
config.update()
|
||||||
|
|
||||||
if kwargs["path"]:
|
if kwargs["path"]:
|
||||||
print(CONFIG_PATH)
|
click.echo(CONFIG_PATH)
|
||||||
|
|
||||||
if kwargs["open"]:
|
if kwargs["open"]:
|
||||||
click.secho(f"Opening {CONFIG_PATH}", fg="green")
|
click.secho(f"Opening {CONFIG_PATH}", fg="green")
|
||||||
|
|
|
@ -31,6 +31,7 @@ from .exceptions import (
|
||||||
IneligibleError,
|
IneligibleError,
|
||||||
InvalidAppIdError,
|
InvalidAppIdError,
|
||||||
InvalidAppSecretError,
|
InvalidAppSecretError,
|
||||||
|
MissingCredentials,
|
||||||
InvalidQuality,
|
InvalidQuality,
|
||||||
)
|
)
|
||||||
from .spoofbuz import Spoofer
|
from .spoofbuz import Spoofer
|
||||||
|
@ -113,6 +114,9 @@ class QobuzClient(Client):
|
||||||
click.secho(f"Logging into {self.source}", fg="green")
|
click.secho(f"Logging into {self.source}", fg="green")
|
||||||
email: str = kwargs["email"]
|
email: str = kwargs["email"]
|
||||||
pwd: str = kwargs["pwd"]
|
pwd: str = kwargs["pwd"]
|
||||||
|
if not email or not pwd:
|
||||||
|
raise MissingCredentials
|
||||||
|
|
||||||
if self.logged_in:
|
if self.logged_in:
|
||||||
logger.debug("Already logged in")
|
logger.debug("Already logged in")
|
||||||
return
|
return
|
||||||
|
@ -542,7 +546,7 @@ class TidalClient(Client):
|
||||||
:param refresh_token:
|
:param refresh_token:
|
||||||
"""
|
"""
|
||||||
if access_token is not None:
|
if access_token is not None:
|
||||||
self.token_expiry = token_expiry
|
self.token_expiry = float(token_expiry)
|
||||||
self.refresh_token = refresh_token
|
self.refresh_token = refresh_token
|
||||||
|
|
||||||
if self.token_expiry - time.time() < 86400: # 1 day
|
if self.token_expiry - time.time() < 86400: # 1 day
|
||||||
|
|
|
@ -2,26 +2,18 @@
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
|
import click
|
||||||
import os
|
import os
|
||||||
import re
|
import shutil
|
||||||
from collections import OrderedDict
|
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict
|
||||||
|
|
||||||
from ruamel.yaml import YAML
|
import tomlkit
|
||||||
|
|
||||||
from .constants import (
|
from .constants import CONFIG_DIR, CONFIG_PATH
|
||||||
CONFIG_DIR,
|
from . import __version__
|
||||||
CONFIG_PATH,
|
|
||||||
DOWNLOADS_DIR,
|
|
||||||
FOLDER_FORMAT,
|
|
||||||
TRACK_FORMAT,
|
|
||||||
)
|
|
||||||
from .exceptions import InvalidSourceError
|
from .exceptions import InvalidSourceError
|
||||||
|
|
||||||
yaml = YAML()
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("streamrip")
|
logger = logging.getLogger("streamrip")
|
||||||
|
|
||||||
|
|
||||||
|
@ -30,7 +22,7 @@ class Config:
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
>>> config = Config('test_config.yaml')
|
>>> config = Config('test_config.toml')
|
||||||
>>> config.defaults['qobuz']['quality']
|
>>> config.defaults['qobuz']['quality']
|
||||||
3
|
3
|
||||||
|
|
||||||
|
@ -39,75 +31,20 @@ class Config:
|
||||||
values.
|
values.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
defaults: Dict[str, Any] = {
|
default_config_path = os.path.join(os.path.dirname(__file__), "config.toml")
|
||||||
"qobuz": {
|
|
||||||
"quality": 3,
|
with open(default_config_path) as cfg:
|
||||||
"download_booklets": True,
|
defaults: Dict[str, Any] = tomlkit.parse(cfg.read())
|
||||||
"email": None,
|
|
||||||
"password": None,
|
|
||||||
"app_id": "",
|
|
||||||
"secrets": [],
|
|
||||||
},
|
|
||||||
"tidal": {
|
|
||||||
"quality": 3,
|
|
||||||
"download_videos": True,
|
|
||||||
"user_id": None,
|
|
||||||
"country_code": None,
|
|
||||||
"access_token": None,
|
|
||||||
"refresh_token": None,
|
|
||||||
"token_expiry": 0,
|
|
||||||
},
|
|
||||||
"deezer": {
|
|
||||||
"quality": 2,
|
|
||||||
},
|
|
||||||
"soundcloud": {
|
|
||||||
"quality": 0,
|
|
||||||
},
|
|
||||||
"youtube": {
|
|
||||||
"quality": 0,
|
|
||||||
"download_videos": False,
|
|
||||||
"video_downloads_folder": DOWNLOADS_DIR,
|
|
||||||
},
|
|
||||||
"database": {"enabled": True, "path": None},
|
|
||||||
"conversion": {
|
|
||||||
"enabled": False,
|
|
||||||
"codec": None,
|
|
||||||
"sampling_rate": None,
|
|
||||||
"bit_depth": None,
|
|
||||||
},
|
|
||||||
"filters": {
|
|
||||||
"extras": False,
|
|
||||||
"repeats": False,
|
|
||||||
"non_albums": False,
|
|
||||||
"features": False,
|
|
||||||
"non_studio_albums": False,
|
|
||||||
"non_remaster": False,
|
|
||||||
},
|
|
||||||
"downloads": {"folder": DOWNLOADS_DIR, "source_subdirectories": False},
|
|
||||||
"artwork": {
|
|
||||||
"embed": True,
|
|
||||||
"size": "large",
|
|
||||||
"keep_hires_cover": True,
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"set_playlist_to_album": False,
|
|
||||||
"new_playlist_tracknumbers": True,
|
|
||||||
},
|
|
||||||
"path_format": {"folder": FOLDER_FORMAT, "track": TRACK_FORMAT},
|
|
||||||
"check_for_updates": True,
|
|
||||||
"lastfm": {"source": "qobuz", "fallback_source": "deezer"},
|
|
||||||
"concurrent_downloads": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, path: str = None):
|
def __init__(self, path: str = None):
|
||||||
"""Create a Config object with state.
|
"""Create a Config object with state.
|
||||||
|
|
||||||
A YAML file is created at `path` if there is none.
|
A TOML file is created at `path` if there is none.
|
||||||
|
|
||||||
:param path:
|
:param path:
|
||||||
:type path: str
|
:type path: str
|
||||||
"""
|
"""
|
||||||
# to access settings loaded from yaml file
|
# to access settings loaded from toml file
|
||||||
self.file: Dict[str, Any] = copy.deepcopy(self.defaults)
|
self.file: Dict[str, Any] = copy.deepcopy(self.defaults)
|
||||||
self.session: Dict[str, Any] = copy.deepcopy(self.defaults)
|
self.session: Dict[str, Any] = copy.deepcopy(self.defaults)
|
||||||
|
|
||||||
|
@ -117,10 +54,14 @@ class Config:
|
||||||
self._path = path
|
self._path = path
|
||||||
|
|
||||||
if not os.path.isfile(self._path):
|
if not os.path.isfile(self._path):
|
||||||
logger.debug("Creating yaml config file at '%s'", self._path)
|
logger.debug("Creating toml config file at '%s'", self._path)
|
||||||
self.dump(self.defaults)
|
shutil.copy(self.default_config_path, CONFIG_PATH)
|
||||||
else:
|
else:
|
||||||
self.load()
|
self.load()
|
||||||
|
if self.file["misc"]["version"] != __version__:
|
||||||
|
click.secho("Updating config file to new version...", fg="green")
|
||||||
|
self.reset()
|
||||||
|
self.load()
|
||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
"""Reset the config file except for credentials."""
|
"""Reset the config file except for credentials."""
|
||||||
|
@ -129,6 +70,7 @@ class Config:
|
||||||
temp["qobuz"].update(self.file["qobuz"])
|
temp["qobuz"].update(self.file["qobuz"])
|
||||||
temp["tidal"].update(self.file["tidal"])
|
temp["tidal"].update(self.file["tidal"])
|
||||||
self.dump(temp)
|
self.dump(temp)
|
||||||
|
del temp
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
"""Save the config state to file."""
|
"""Save the config state to file."""
|
||||||
|
@ -139,12 +81,12 @@ class Config:
|
||||||
if not os.path.isdir(CONFIG_DIR):
|
if not os.path.isdir(CONFIG_DIR):
|
||||||
os.makedirs(CONFIG_DIR, exist_ok=True)
|
os.makedirs(CONFIG_DIR, exist_ok=True)
|
||||||
|
|
||||||
self.dump(self.defaults)
|
shutil.copy(self.default_config_path, self._path)
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
"""Load infomation from the config files, making a deepcopy."""
|
"""Load infomation from the config files, making a deepcopy."""
|
||||||
with open(self._path) as cfg:
|
with open(self._path) as cfg:
|
||||||
for k, v in yaml.load(cfg).items():
|
for k, v in tomlkit.loads(cfg.read()).items():
|
||||||
self.file[k] = v
|
self.file[k] = v
|
||||||
if hasattr(v, "copy"):
|
if hasattr(v, "copy"):
|
||||||
self.session[k] = v.copy()
|
self.session[k] = v.copy()
|
||||||
|
@ -161,10 +103,7 @@ class Config:
|
||||||
"""
|
"""
|
||||||
with open(self._path, "w") as cfg:
|
with open(self._path, "w") as cfg:
|
||||||
logger.debug("Config saved: %s", self._path)
|
logger.debug("Config saved: %s", self._path)
|
||||||
yaml.dump(info, cfg)
|
cfg.write(tomlkit.dumps(info))
|
||||||
|
|
||||||
docs = ConfigDocumentation()
|
|
||||||
docs.dump(self._path)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tidal_creds(self):
|
def tidal_creds(self):
|
||||||
|
@ -203,251 +142,3 @@ class Config:
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
"""Return a string representation of the config."""
|
"""Return a string representation of the config."""
|
||||||
return f"Config({pformat(self.session)})"
|
return f"Config({pformat(self.session)})"
|
||||||
|
|
||||||
|
|
||||||
class ConfigDocumentation:
|
|
||||||
"""Documentation is stored in this docstring.
|
|
||||||
|
|
||||||
qobuz:
|
|
||||||
quality: 1: 320kbps MP3, 2: 16/44.1, 3: 24/<=96, 4: 24/>=96
|
|
||||||
download_booklets: This will download booklet pdfs that are included with some albums
|
|
||||||
password: This is an md5 hash of the plaintext password
|
|
||||||
app_id: Do not change
|
|
||||||
secrets: Do not change
|
|
||||||
tidal:
|
|
||||||
quality: 0: 256kbps AAC, 1: 320kbps AAC, 2: 16/44.1 "HiFi" FLAC, 3: 24/44.1 "MQA" FLAC
|
|
||||||
download_videos: This will download videos included in Video Albums.
|
|
||||||
user_id: Do not change any of the fields below
|
|
||||||
token_expiry: Tokens last 1 week after refresh. This is the Unix timestamp of the expiration time.
|
|
||||||
deezer: Deezer doesn't require login
|
|
||||||
quality: 0, 1, or 2
|
|
||||||
soundcloud:
|
|
||||||
quality: Only 0 is available
|
|
||||||
youtube:
|
|
||||||
quality: Only 0 is available for now
|
|
||||||
download_videos: Download the video along with the audio
|
|
||||||
video_downloads_folder: The path to download the videos to
|
|
||||||
database: This stores a list of item IDs so that repeats are not downloaded.
|
|
||||||
conversion: Convert tracks to a codec after downloading them.
|
|
||||||
codec: FLAC, ALAC, OPUS, MP3, VORBIS, or AAC
|
|
||||||
sampling_rate: In Hz. Tracks are downsampled if their sampling rate is greater than this. Values greater than 48000 are only recommended if the audio will be processed. It is otherwise a waste of space as the human ear cannot discern higher frequencies.
|
|
||||||
bit_depth: Only 16 and 24 are available. It is only applied when the bit depth is higher than this value.
|
|
||||||
filters: Filter a Qobuz artist's discography. Set to 'true' to turn on a filter.
|
|
||||||
extras: Remove Collectors Editions, live recordings, etc.
|
|
||||||
repeats: Picks the highest quality out of albums with identical titles.
|
|
||||||
non_albums: Remove EPs and Singles
|
|
||||||
features: Remove albums whose artist is not the one requested
|
|
||||||
non_remaster: Only download remastered albums
|
|
||||||
downloads:
|
|
||||||
folder: Folder where tracks are downloaded to
|
|
||||||
source_subdirectories: Put Qobuz albums in a 'Qobuz' folder, Tidal albums in 'Tidal' etc.
|
|
||||||
artwork:
|
|
||||||
embed: Write the image to the audio file
|
|
||||||
size: The size of the artwork to embed. Options: thumbnail, small, large, original. 'original' images can be up to 30MB, and may fail embedding. Using 'large' is recommended.
|
|
||||||
keep_hires_cover: Save the cover image at the highest quality as a seperate jpg file
|
|
||||||
metadata: Only applicable for playlist downloads.
|
|
||||||
set_playlist_to_album: Sets the value of the 'ALBUM' field in the metadata to the playlist's name. This is useful if your music library software organizes tracks based on album name.
|
|
||||||
new_playlist_tracknumbers: Replaces the original track's tracknumber with it's position in the playlist
|
|
||||||
path_format: Changes the folder and file names generated by streamrip.
|
|
||||||
folder: Available keys: "albumartist", "title", "year", "bit_depth", "sampling_rate", and "container"
|
|
||||||
track: Available keys: "tracknumber", "artist", "albumartist", "composer", and "title"
|
|
||||||
lastfm: Last.fm playlists are downloaded by searching for the titles of the tracks
|
|
||||||
source: The source on which to search for the tracks.
|
|
||||||
fallback_source: If no results were found with the primary source, the item is searched for on this one.
|
|
||||||
concurrent_downloads: Download (and convert) tracks all at once, instead of sequentially. If you are converting the tracks, and/or have fast internet, this will substantially improve processing speed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Create a new ConfigDocumentation object."""
|
|
||||||
# not using ruamel because its super slow
|
|
||||||
self.docs = []
|
|
||||||
doctext = self.__doc__
|
|
||||||
# get indent level, key, and documentation
|
|
||||||
keyval = re.compile(r"( *)([\w_]+):\s*(.*)")
|
|
||||||
lines = (line[4:] for line in doctext.split("\n")[2:-1])
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
info = list(keyval.match(line).groups())
|
|
||||||
if len(info) == 3:
|
|
||||||
info[0] = len(info[0]) // 4 # here use standard 4 spaces/tab
|
|
||||||
else: # line doesn't start with spaces
|
|
||||||
info.insert(0, 0)
|
|
||||||
|
|
||||||
self.docs.append(info)
|
|
||||||
|
|
||||||
def dump(self, path: str):
|
|
||||||
"""Write comments to an uncommented YAML file.
|
|
||||||
|
|
||||||
:param path:
|
|
||||||
:type path: str
|
|
||||||
"""
|
|
||||||
is_comment = re.compile(r"^\s*#.*")
|
|
||||||
with open(path) as f:
|
|
||||||
# includes newline at the end
|
|
||||||
lines = f.readlines()
|
|
||||||
|
|
||||||
with open(path, "w") as f:
|
|
||||||
while lines != []:
|
|
||||||
line = lines.pop(0)
|
|
||||||
found = False
|
|
||||||
to_remove = None
|
|
||||||
for level, key, doc in self.docs:
|
|
||||||
# using 1 indent = 2 spaces like ruamel.yaml
|
|
||||||
spaces = level * " "
|
|
||||||
comment = f"{spaces}# {doc}"
|
|
||||||
|
|
||||||
if is_comment.match(line):
|
|
||||||
# update comment
|
|
||||||
found = True
|
|
||||||
break
|
|
||||||
|
|
||||||
re_obj = self._get_key_regex(spaces, key)
|
|
||||||
match = re_obj.match(line)
|
|
||||||
if match is not None: # line contains the key
|
|
||||||
if doc != "":
|
|
||||||
f.write(f"{comment}\n{line}")
|
|
||||||
found = True
|
|
||||||
to_remove = [level, key, doc]
|
|
||||||
break
|
|
||||||
|
|
||||||
if not found: # field with no comment
|
|
||||||
f.write(line)
|
|
||||||
|
|
||||||
if to_remove is not None:
|
|
||||||
# key, doc pairs are unique
|
|
||||||
self.docs.remove(to_remove)
|
|
||||||
|
|
||||||
def _get_key_regex(self, spaces: str, key: str) -> re.Pattern:
|
|
||||||
"""Get a regex that matches a key in YAML.
|
|
||||||
|
|
||||||
:param spaces: a string spaces that represent the indent level.
|
|
||||||
:type spaces: str
|
|
||||||
:param key: the key to match.
|
|
||||||
:type key: str
|
|
||||||
:rtype: re.Pattern
|
|
||||||
"""
|
|
||||||
regex = rf"{spaces}{key}:(?:$|\s+?(.+))"
|
|
||||||
return re.compile(regex)
|
|
||||||
|
|
||||||
def strip_comments(self, path: str):
|
|
||||||
"""Remove single-line comments from a file.
|
|
||||||
|
|
||||||
:param path:
|
|
||||||
:type path: str
|
|
||||||
"""
|
|
||||||
with open(path, "r") as f:
|
|
||||||
lines = [
|
|
||||||
line
|
|
||||||
for line in f.readlines()
|
|
||||||
if not line.strip().startswith("#")
|
|
||||||
]
|
|
||||||
|
|
||||||
with open(path, "w") as f:
|
|
||||||
f.write("".join(lines))
|
|
||||||
|
|
||||||
|
|
||||||
# ------------- ~~ Experimental ~~ ----------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def load_yaml(path: str):
|
|
||||||
"""Load a streamrip config YAML file.
|
|
||||||
|
|
||||||
Warning: this is not fully compliant with YAML. It was made for use
|
|
||||||
with streamrip.
|
|
||||||
|
|
||||||
:param path:
|
|
||||||
:type path: str
|
|
||||||
"""
|
|
||||||
with open(path) as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
|
|
||||||
settings = OrderedDict()
|
|
||||||
type_dict = {t.__name__: t for t in (list, dict, str)}
|
|
||||||
for line in lines:
|
|
||||||
key_l: List[str] = []
|
|
||||||
val_l: List[str] = []
|
|
||||||
|
|
||||||
chars = StringWalker(line)
|
|
||||||
level = 0
|
|
||||||
|
|
||||||
# get indent level of line
|
|
||||||
while next(chars).isspace():
|
|
||||||
level += 1
|
|
||||||
|
|
||||||
chars.prev()
|
|
||||||
if (c := next(chars)) == "#":
|
|
||||||
# is a comment
|
|
||||||
continue
|
|
||||||
|
|
||||||
elif c == "-":
|
|
||||||
# is an item in a list
|
|
||||||
next(chars)
|
|
||||||
val_l = list(chars)
|
|
||||||
level += 2 # it is a child of the previous key
|
|
||||||
item_type = "list"
|
|
||||||
else:
|
|
||||||
# undo char read
|
|
||||||
chars.prev()
|
|
||||||
|
|
||||||
if not val_l:
|
|
||||||
while (c := next(chars)) != ":":
|
|
||||||
key_l.append(c)
|
|
||||||
val_l = list("".join(chars).strip())
|
|
||||||
|
|
||||||
if val_l:
|
|
||||||
val = "".join(val_l)
|
|
||||||
else:
|
|
||||||
# start of a section
|
|
||||||
item_type = "dict"
|
|
||||||
val = type_dict[item_type]()
|
|
||||||
|
|
||||||
key = "".join(key_l)
|
|
||||||
if level == 0:
|
|
||||||
settings[key] = val
|
|
||||||
elif level == 2:
|
|
||||||
parent = settings[tuple(settings.keys())[-1]]
|
|
||||||
if isinstance(parent, dict):
|
|
||||||
parent[key] = val
|
|
||||||
elif isinstance(parent, list):
|
|
||||||
parent.append(val)
|
|
||||||
else:
|
|
||||||
raise Exception(f"level too high: {level}")
|
|
||||||
|
|
||||||
return settings
|
|
||||||
|
|
||||||
|
|
||||||
class StringWalker:
|
|
||||||
"""A fancier str iterator."""
|
|
||||||
|
|
||||||
def __init__(self, s: str):
|
|
||||||
"""Create a StringWalker object.
|
|
||||||
|
|
||||||
:param s:
|
|
||||||
:type s: str
|
|
||||||
"""
|
|
||||||
self.__val = s.replace("\n", "")
|
|
||||||
self.__pos = 0
|
|
||||||
|
|
||||||
def __next__(self) -> str:
|
|
||||||
"""Get the next char.
|
|
||||||
|
|
||||||
:rtype: str
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
c = self.__val[self.__pos]
|
|
||||||
self.__pos += 1
|
|
||||||
return c
|
|
||||||
except IndexError:
|
|
||||||
raise StopIteration
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
"""Get an iterator."""
|
|
||||||
return self
|
|
||||||
|
|
||||||
def prev(self, step: int = 1):
|
|
||||||
"""Un-read a character.
|
|
||||||
|
|
||||||
:param step: The number of steps backward to take.
|
|
||||||
:type step: int
|
|
||||||
"""
|
|
||||||
self.__pos -= step
|
|
||||||
|
|
128
streamrip/config.toml
Normal file
128
streamrip/config.toml
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
[downloads]
|
||||||
|
# Folder where tracks are downloaded to
|
||||||
|
folder = "/Users/nathan/StreamripDownloads"
|
||||||
|
# Put Qobuz albums in a 'Qobuz' folder, Tidal albums in 'Tidal' etc.
|
||||||
|
source_subdirectories = false
|
||||||
|
# Download (and convert) tracks all at once, instead of sequentially.
|
||||||
|
# If you are converting the tracks, or have fast internet, this will
|
||||||
|
# substantially improve processing speed.
|
||||||
|
concurrent = false
|
||||||
|
|
||||||
|
[qobuz]
|
||||||
|
# 1: 320kbps MP3, 2: 16/44.1, 3: 24/<=96, 4: 24/>=96
|
||||||
|
quality = 3
|
||||||
|
# This will download booklet pdfs that are included with some albums
|
||||||
|
download_booklets = true
|
||||||
|
|
||||||
|
email = ""
|
||||||
|
# This is an md5 hash of the plaintext password
|
||||||
|
password = ""
|
||||||
|
# Do not change
|
||||||
|
app_id = ""
|
||||||
|
# Do not change
|
||||||
|
secrets = []
|
||||||
|
|
||||||
|
[tidal]
|
||||||
|
# 0: 256kbps AAC, 1: 320kbps AAC, 2: 16/44.1 "HiFi" FLAC, 3: 24/44.1 "MQA" FLAC
|
||||||
|
quality = 3
|
||||||
|
# This will download videos included in Video Albums.
|
||||||
|
download_videos = true
|
||||||
|
|
||||||
|
# Do not change any of the fields below
|
||||||
|
user_id = ""
|
||||||
|
country_code = ""
|
||||||
|
access_token = ""
|
||||||
|
refresh_token = ""
|
||||||
|
# Tokens last 1 week after refresh. This is the Unix timestamp of the expiration
|
||||||
|
# time. If you haven't used streamrip in more than a week, you may have to log
|
||||||
|
# in again using `rip config --tidal`
|
||||||
|
token_expiry = ""
|
||||||
|
|
||||||
|
# Doesn't require login
|
||||||
|
[deezer]
|
||||||
|
# 0, 1, or 2
|
||||||
|
quality = 2
|
||||||
|
|
||||||
|
[soundcloud]
|
||||||
|
# Only 0 is available for now
|
||||||
|
quality = 0
|
||||||
|
|
||||||
|
[youtube]
|
||||||
|
# Only 0 is available for now
|
||||||
|
quality = 0
|
||||||
|
# Download the video along with the audio
|
||||||
|
download_videos = false
|
||||||
|
# The path to download the videos to
|
||||||
|
video_downloads_folder = ""
|
||||||
|
|
||||||
|
# This stores a list of item IDs so that repeats are not downloaded.
|
||||||
|
[database]
|
||||||
|
enabled = true
|
||||||
|
path = ""
|
||||||
|
|
||||||
|
# Convert tracks to a codec after downloading them.
|
||||||
|
[conversion]
|
||||||
|
enabled = false
|
||||||
|
# FLAC, ALAC, OPUS, MP3, VORBIS, or AAC
|
||||||
|
codec = "ALAC"
|
||||||
|
# In Hz. Tracks are downsampled if their sampling rate is greater than this.
|
||||||
|
# Value of 48000 is recommended to maximize quality and minimize space
|
||||||
|
sampling_rate = 48000
|
||||||
|
# Only 16 and 24 are available. It is only applied when the bit depth is higher
|
||||||
|
# than this value.
|
||||||
|
bit_depth = 24
|
||||||
|
|
||||||
|
# Filter a Qobuz artist's discography. Set to 'true' to turn on a filter.
|
||||||
|
[filters]
|
||||||
|
# Remove Collectors Editions, live recordings, etc.
|
||||||
|
extras = false
|
||||||
|
# Picks the highest quality out of albums with identical titles.
|
||||||
|
repeats = false
|
||||||
|
# Remove EPs and Singles
|
||||||
|
non_albums = false
|
||||||
|
# Remove albums whose artist is not the one requested
|
||||||
|
features = false
|
||||||
|
# Skip non studio albums
|
||||||
|
non_studio_albums = false
|
||||||
|
# Only download remastered albums
|
||||||
|
non_remaster = false
|
||||||
|
|
||||||
|
[artwork]
|
||||||
|
# Write the image to the audio file
|
||||||
|
embed = true
|
||||||
|
# The size of the artwork to embed. Options: thumbnail, small, large, original.
|
||||||
|
# "original" images can be up to 30MB, and may fail embedding.
|
||||||
|
# Using "large" is recommended.
|
||||||
|
size = "large"
|
||||||
|
# Save the cover image at the highest quality as a seperate jpg file
|
||||||
|
keep_hires_cover = true
|
||||||
|
|
||||||
|
[metadata]
|
||||||
|
# Sets the value of the 'ALBUM' field in the metadata to the playlist's name.
|
||||||
|
# This is useful if your music library software organizes tracks based on album name.
|
||||||
|
set_playlist_to_album = true
|
||||||
|
# Replaces the original track's tracknumber with it's position in the playlist
|
||||||
|
new_playlist_tracknumbers = true
|
||||||
|
|
||||||
|
# Changes the folder and file names generated by streamrip.
|
||||||
|
[path_format]
|
||||||
|
# Available keys: "albumartist", "title", "year", "bit_depth", "sampling_rate",
|
||||||
|
# and "container"
|
||||||
|
folder = "{albumartist} - {title} ({year}) [{container}] [{bit_depth}B-{sampling_rate}kHz]"
|
||||||
|
# Available keys: "tracknumber", "artist", "albumartist", "composer", and "title"
|
||||||
|
track = "{tracknumber}. {artist} - {title}"
|
||||||
|
|
||||||
|
# Last.fm playlists are downloaded by searching for the titles of the tracks
|
||||||
|
[lastfm]
|
||||||
|
# The source on which to search for the tracks.
|
||||||
|
source = "qobuz"
|
||||||
|
# If no results were found with the primary source, the item is searched for
|
||||||
|
# on this one.
|
||||||
|
fallback_source = "deezer"
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
# Check whether a newer version of streamrip is available when starting up
|
||||||
|
check_for_updates = true
|
||||||
|
|
||||||
|
# Metadata to identify this config file. Do not change.
|
||||||
|
version = "0.5.5"
|
|
@ -10,7 +10,7 @@ APPNAME = "streamrip"
|
||||||
|
|
||||||
CACHE_DIR = click.get_app_dir(APPNAME)
|
CACHE_DIR = click.get_app_dir(APPNAME)
|
||||||
CONFIG_DIR = click.get_app_dir(APPNAME)
|
CONFIG_DIR = click.get_app_dir(APPNAME)
|
||||||
CONFIG_PATH = os.path.join(CONFIG_DIR, "config.yaml")
|
CONFIG_PATH = os.path.join(CONFIG_DIR, "config.toml")
|
||||||
LOG_DIR = click.get_app_dir(APPNAME)
|
LOG_DIR = click.get_app_dir(APPNAME)
|
||||||
DB_PATH = os.path.join(LOG_DIR, "downloads.db")
|
DB_PATH = os.path.join(LOG_DIR, "downloads.db")
|
||||||
|
|
||||||
|
@ -19,9 +19,7 @@ DOWNLOADS_DIR = os.path.join(HOME, "StreamripDownloads")
|
||||||
|
|
||||||
AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0"
|
AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0"
|
||||||
|
|
||||||
TIDAL_COVER_URL = (
|
TIDAL_COVER_URL = "https://resources.tidal.com/images/{uuid}/{width}x{height}.jpg"
|
||||||
"https://resources.tidal.com/images/{uuid}/{width}x{height}.jpg"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
QUALITY_DESC = {
|
QUALITY_DESC = {
|
||||||
|
@ -32,7 +30,6 @@ QUALITY_DESC = {
|
||||||
4: "24bit/192kHz",
|
4: "24bit/192kHz",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
QOBUZ_FEATURED_KEYS = (
|
QOBUZ_FEATURED_KEYS = (
|
||||||
"most-streamed",
|
"most-streamed",
|
||||||
"recent-releases",
|
"recent-releases",
|
||||||
|
|
|
@ -36,6 +36,7 @@ from .constants import (
|
||||||
from .db import MusicDB
|
from .db import MusicDB
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
|
MissingCredentials,
|
||||||
NonStreamable,
|
NonStreamable,
|
||||||
NoResultsFound,
|
NoResultsFound,
|
||||||
ParsingError,
|
ParsingError,
|
||||||
|
@ -96,9 +97,11 @@ class MusicDL(list):
|
||||||
}
|
}
|
||||||
|
|
||||||
self.db: Union[MusicDB, list]
|
self.db: Union[MusicDB, list]
|
||||||
if self.config.session["database"]["enabled"]:
|
db_settings = self.config.session["database"]
|
||||||
if self.config.session["database"]["path"] is not None:
|
if db_settings["enabled"]:
|
||||||
self.db = MusicDB(self.config.session["database"]["path"])
|
path = db_settings["path"]
|
||||||
|
if path:
|
||||||
|
self.db = MusicDB(path)
|
||||||
else:
|
else:
|
||||||
self.db = MusicDB(DB_PATH)
|
self.db = MusicDB(DB_PATH)
|
||||||
self.config.file["database"]["path"] = DB_PATH
|
self.config.file["database"]["path"] = DB_PATH
|
||||||
|
@ -172,6 +175,7 @@ class MusicDL(list):
|
||||||
|
|
||||||
:rtype: dict
|
:rtype: dict
|
||||||
"""
|
"""
|
||||||
|
logger.debug(self.config.session)
|
||||||
return {
|
return {
|
||||||
"database": self.db,
|
"database": self.db,
|
||||||
"parent_folder": self.config.session["downloads"]["folder"],
|
"parent_folder": self.config.session["downloads"]["folder"],
|
||||||
|
@ -179,24 +183,18 @@ class MusicDL(list):
|
||||||
"track_format": self.config.session["path_format"]["track"],
|
"track_format": self.config.session["path_format"]["track"],
|
||||||
"embed_cover": self.config.session["artwork"]["embed"],
|
"embed_cover": self.config.session["artwork"]["embed"],
|
||||||
"embed_cover_size": self.config.session["artwork"]["size"],
|
"embed_cover_size": self.config.session["artwork"]["size"],
|
||||||
"keep_hires_cover": self.config.session["artwork"][
|
"keep_hires_cover": self.config.session["artwork"]["keep_hires_cover"],
|
||||||
"keep_hires_cover"
|
|
||||||
],
|
|
||||||
"set_playlist_to_album": self.config.session["metadata"][
|
"set_playlist_to_album": self.config.session["metadata"][
|
||||||
"set_playlist_to_album"
|
"set_playlist_to_album"
|
||||||
],
|
],
|
||||||
"stay_temp": self.config.session["conversion"]["enabled"],
|
"stay_temp": self.config.session["conversion"]["enabled"],
|
||||||
"conversion": self.config.session["conversion"],
|
"conversion": self.config.session["conversion"],
|
||||||
"concurrent_downloads": self.config.session[
|
"concurrent_downloads": self.config.session["downloads"]["concurrent"],
|
||||||
"concurrent_downloads"
|
|
||||||
],
|
|
||||||
"new_tracknumbers": self.config.session["metadata"][
|
"new_tracknumbers": self.config.session["metadata"][
|
||||||
"new_playlist_tracknumbers"
|
"new_playlist_tracknumbers"
|
||||||
],
|
],
|
||||||
"download_videos": self.config.session["tidal"]["download_videos"],
|
"download_videos": self.config.session["tidal"]["download_videos"],
|
||||||
"download_booklets": self.config.session["qobuz"][
|
"download_booklets": self.config.session["qobuz"]["download_booklets"],
|
||||||
"download_booklets"
|
|
||||||
],
|
|
||||||
"download_youtube_videos": self.config.session["youtube"][
|
"download_youtube_videos": self.config.session["youtube"][
|
||||||
"download_videos"
|
"download_videos"
|
||||||
],
|
],
|
||||||
|
@ -209,9 +207,10 @@ class MusicDL(list):
|
||||||
"""Download all the items in self."""
|
"""Download all the items in self."""
|
||||||
try:
|
try:
|
||||||
arguments = self._get_download_args()
|
arguments = self._get_download_args()
|
||||||
except KeyError:
|
except KeyError as e:
|
||||||
self._config_updating_message()
|
self._config_updating_message()
|
||||||
self.config.update()
|
self.config.update()
|
||||||
|
logger.debug("Config update error: %s", e)
|
||||||
exit()
|
exit()
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
self._config_corrupted_message(err)
|
self._config_corrupted_message(err)
|
||||||
|
@ -219,9 +218,7 @@ class MusicDL(list):
|
||||||
|
|
||||||
logger.debug("Arguments from config: %s", arguments)
|
logger.debug("Arguments from config: %s", arguments)
|
||||||
|
|
||||||
source_subdirs = self.config.session["downloads"][
|
source_subdirs = self.config.session["downloads"]["source_subdirectories"]
|
||||||
"source_subdirectories"
|
|
||||||
]
|
|
||||||
for item in self:
|
for item in self:
|
||||||
if source_subdirs:
|
if source_subdirs:
|
||||||
arguments["parent_folder"] = self.__get_source_subdir(
|
arguments["parent_folder"] = self.__get_source_subdir(
|
||||||
|
@ -232,26 +229,20 @@ class MusicDL(list):
|
||||||
item.download(**arguments)
|
item.download(**arguments)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
arguments["quality"] = self.config.session[item.client.source][
|
arguments["quality"] = self.config.session[item.client.source]["quality"]
|
||||||
"quality"
|
|
||||||
]
|
|
||||||
if isinstance(item, Artist):
|
if isinstance(item, Artist):
|
||||||
filters_ = tuple(
|
filters_ = tuple(
|
||||||
k for k, v in self.config.session["filters"].items() if v
|
k for k, v in self.config.session["filters"].items() if v
|
||||||
)
|
)
|
||||||
arguments["filters"] = filters_
|
arguments["filters"] = filters_
|
||||||
logger.debug(
|
logger.debug("Added filter argument for artist/label: %s", filters_)
|
||||||
"Added filter argument for artist/label: %s", filters_
|
|
||||||
)
|
|
||||||
|
|
||||||
if not (isinstance(item, Tracklist) and item.loaded):
|
if not (isinstance(item, Tracklist) and item.loaded):
|
||||||
logger.debug("Loading metadata")
|
logger.debug("Loading metadata")
|
||||||
try:
|
try:
|
||||||
item.load_meta()
|
item.load_meta()
|
||||||
except NonStreamable:
|
except NonStreamable:
|
||||||
click.secho(
|
click.secho(f"{item!s} is not available, skipping.", fg="red")
|
||||||
f"{item!s} is not available, skipping.", fg="red"
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
item.download(**arguments)
|
item.download(**arguments)
|
||||||
|
@ -290,6 +281,12 @@ class MusicDL(list):
|
||||||
except AuthenticationError:
|
except AuthenticationError:
|
||||||
click.secho("Invalid credentials, try again.")
|
click.secho("Invalid credentials, try again.")
|
||||||
self.prompt_creds(client.source)
|
self.prompt_creds(client.source)
|
||||||
|
creds = self.config.creds(client.source)
|
||||||
|
except MissingCredentials:
|
||||||
|
logger.debug("Credentials are missing. Prompting..")
|
||||||
|
self.prompt_creds(client.source)
|
||||||
|
creds = self.config.creds(client.source)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
client.source == "qobuz"
|
client.source == "qobuz"
|
||||||
and not creds.get("secrets")
|
and not creds.get("secrets")
|
||||||
|
@ -311,7 +308,6 @@ class MusicDL(list):
|
||||||
https://www.qobuz.com/us-en/{type}/{name}/{id}
|
https://www.qobuz.com/us-en/{type}/{name}/{id}
|
||||||
https://open.qobuz.com/{type}/{id}
|
https://open.qobuz.com/{type}/{id}
|
||||||
https://play.qobuz.com/{type}/{id}
|
https://play.qobuz.com/{type}/{id}
|
||||||
/us-en/{type}/-/{id}
|
|
||||||
|
|
||||||
https://www.deezer.com/us/{type}/{id}
|
https://www.deezer.com/us/{type}/{id}
|
||||||
https://tidal.com/browse/{type}/{id}
|
https://tidal.com/browse/{type}/{id}
|
||||||
|
|
|
@ -2,6 +2,10 @@ class AuthenticationError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MissingCredentials(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class IneligibleError(Exception):
|
class IneligibleError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
@ -613,7 +613,7 @@ class Artist(Tracklist):
|
||||||
albums = self.meta["albums"]
|
albums = self.meta["albums"]
|
||||||
|
|
||||||
elif self.client.source == "deezer":
|
elif self.client.source == "deezer":
|
||||||
# TODO: load artist name
|
self.name = self.meta["name"]
|
||||||
albums = self.meta["albums"]
|
albums = self.meta["albums"]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue