Finish most of skeleton

This commit is contained in:
Nathan Thomas 2023-09-21 19:19:30 -07:00
parent b5a442c042
commit 34277a3c67
26 changed files with 2357 additions and 1791 deletions

1317
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "streamrip"
version = "1.9.7"
version = "2.0"
description = "A fast, all-in-one music ripper for Qobuz, Deezer, Tidal, and SoundCloud"
authors = ["nathom <nathanthomas707@gmail.com>"]
license = "GPL-3.0-only"
@ -8,10 +8,6 @@ readme = "README.md"
homepage = "https://github.com/nathom/streamrip"
repository = "https://github.com/nathom/streamrip"
include = ["streamrip/config.toml"]
packages = [
{ include = "streamrip" },
{ include = "rip" },
]
keywords = ["hi-res", "free", "music", "download"]
classifiers = [
"License :: OSI Approved :: GNU General Public License (GPL)",
@ -19,13 +15,11 @@ classifiers = [
]
[tool.poetry.scripts]
rip = "rip.cli:main"
rip = "src.cli:main"
[tool.poetry.dependencies]
python = ">=3.8 <4.0"
requests = "^2.25.1"
mutagen = "^1.45.1"
click = "^8.0.1"
tqdm = "^4.61.1"
tomlkit = "^0.7.2"
pathvalidate = "^2.4.1"
@ -35,12 +29,13 @@ windows-curses = {version = "^2.2.0", platform = 'win32|cygwin'}
Pillow = "^9.0.0"
deezer-py = "1.3.6"
pycryptodomex = "^3.10.1"
cleo = {version = "1.0.0a4", allow-prereleases = true}
cleo = "^2.0"
appdirs = "^1.4.4"
m3u8 = "^0.9.0"
aiofiles = "^0.7.0"
aiohttp = "^3.7.4"
aiofiles = "^0.7"
aiohttp = "^3.7"
aiodns = "^3.0.0"
aiolimiter = "^1.1.0"
[tool.poetry.urls]
"Bug Reports" = "https://github.com/nathom/streamrip/issues"
@ -50,7 +45,7 @@ Sphinx = "^4.1.1"
autodoc = "^0.5.0"
types-click = "^7.1.2"
types-Pillow = "^8.3.1"
black = "^21.7b0"
black = "^22"
isort = "^5.9.3"
flake8 = "^3.9.2"
setuptools = "^67.4.0"

View file

@ -1,845 +0,0 @@
import concurrent.futures
import logging
import os
import threading
from typing import Optional
import requests
from cleo.application import Application as BaseApplication
from cleo.commands.command import Command
from cleo.formatters.style import Style
from cleo.helpers import argument, option
from click import launch
from streamrip import __version__
from .config import Config
from .core import RipCore
logging.basicConfig(level="WARNING")
logger = logging.getLogger("streamrip")
outdated = False
newest_version: Optional[str] = None
class DownloadCommand(Command):
name = "url"
description = "Download items using urls."
arguments = [
argument(
"urls",
"One or more Qobuz, Tidal, Deezer, or SoundCloud urls",
optional=True,
multiple=True,
)
]
options = [
option(
"file",
"-f",
"Path to a text file containing urls",
flag=False,
default="None",
),
option(
"codec",
"-c",
"Convert the downloaded files to <cmd>ALAC</cmd>, <cmd>FLAC</cmd>, <cmd>MP3</cmd>, <cmd>AAC</cmd>, or <cmd>OGG</cmd>",
flag=False,
default="None",
),
option(
"max-quality",
"m",
"The maximum quality to download. Can be <cmd>0</cmd>, <cmd>1</cmd>, <cmd>2</cmd>, <cmd>3 </cmd>or <cmd>4</cmd>",
flag=False,
default="None",
),
option(
"ignore-db",
"-i",
description="Download items even if they have been logged in the database.",
),
option("config", description="Path to config file.", flag=False),
option("directory", "-d", "Directory to download items into.", flag=False),
]
help = (
"\nDownload <title>Dreams</title> by <title>Fleetwood Mac</title>:\n"
"$ <cmd>rip url https://www.deezer.com/us/track/67549262</cmd>\n\n"
"Batch download urls from a text file named <path>urls.txt</path>:\n"
"$ <cmd>rip url --file urls.txt</cmd>\n\n"
"For more information on Quality IDs, see\n"
"<url>https://github.com/nathom/streamrip/wiki/Quality-IDs</url>\n"
)
def handle(self):
global outdated
global newest_version
# Use a thread so that it doesn't slow down startup
update_check = threading.Thread(target=is_outdated, daemon=True)
update_check.start()
path, quality, no_db, directory, config = clean_options(
self.option("file"),
self.option("max-quality"),
self.option("ignore-db"),
self.option("directory"),
self.option("config"),
)
assert isinstance(config, str) or config is None
config = Config(config)
if directory is not None:
config.session["downloads"]["folder"] = directory
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:
assert isinstance(path, str)
if os.path.isfile(path):
core.handle_txt(path)
else:
self.line(
f"<error>File <comment>{path}</comment> does not exist.</error>"
)
return 1
if urls:
core.handle_urls(";".join(urls))
if len(core) > 0:
core.download()
elif not urls and path is None:
self.line("<error>Must pass arguments. See </><cmd>rip url -h</cmd>.")
update_check.join()
if outdated:
import re
self.line(
f"\n<info>A new version of streamrip <title>v{newest_version}</title>"
" is available! Run <cmd>pip3 install streamrip --upgrade</cmd>"
" to update.</info>\n"
)
md_header = re.compile(r"#\s+(.+)")
bullet_point = re.compile(r"-\s+(.+)")
code = re.compile(r"`([^`]+)`")
issue_reference = re.compile(r"(#\d+)")
release_notes = requests.get(
"https://api.github.com/repos/nathom/streamrip/releases/latest"
).json()["body"]
release_notes = md_header.sub(r"<header>\1</header>", release_notes)
release_notes = bullet_point.sub(r"<options=bold>•</> \1", release_notes)
release_notes = code.sub(r"<cmd>\1</cmd>", release_notes)
release_notes = issue_reference.sub(r"<options=bold>\1</>", release_notes)
self.line(release_notes)
return 0
class SearchCommand(Command):
name = "search"
description = "Search for an item"
arguments = [
argument(
"query",
"The name to search for",
optional=False,
multiple=False,
)
]
options = [
option(
"source",
"-s",
"Qobuz, Tidal, Soundcloud, Deezer, or Deezloader",
flag=False,
default="qobuz",
),
option(
"type",
"-t",
"Album, Playlist, Track, or Artist",
flag=False,
default="album",
),
]
help = (
"\nSearch for <title>Rumours</title> by <title>Fleetwood Mac</title>\n"
"$ <cmd>rip search 'rumours fleetwood mac'</cmd>\n\n"
"Search for <title>444</title> by <title>Jay-Z</title> on TIDAL\n"
"$ <cmd>rip search --source tidal '444'</cmd>\n\n"
"Search for <title>Bob Dylan</title> on Deezer\n"
"$ <cmd>rip search --type artist --source deezer 'bob dylan'</cmd>\n"
)
def handle(self):
query = self.argument("query")
source, type = clean_options(self.option("source"), self.option("type"))
assert isinstance(source, str)
assert isinstance(type, str)
config = Config()
core = RipCore(config)
if core.interactive_search(query, source, type):
core.download()
else:
self.line("<error>No items chosen, exiting.</error>")
class DiscoverCommand(Command):
name = "discover"
description = "Download items from the charts or a curated playlist"
arguments = [
argument(
"list",
"The list to fetch",
optional=True,
multiple=False,
default="ideal-discography",
)
]
options = [
option(
"scrape",
description="Download all of the items in the list",
),
option(
"max-items",
"-m",
description="The number of items to fetch",
flag=False,
default=50,
),
option(
"source",
"-s",
description="The source to download from (<cmd>qobuz</cmd> or <cmd>deezer</cmd>)",
flag=False,
default="qobuz",
),
]
help = (
"\nBrowse the Qobuz ideal-discography list\n"
"$ <cmd>rip discover</cmd>\n\n"
"Browse the best-sellers list\n"
"$ <cmd>rip discover best-sellers</cmd>\n\n"
"Available options for Qobuz <cmd>list</cmd>:\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\n"
"Browse the Deezer editorial releases list\n"
"$ <cmd>rip discover --source deezer</cmd>\n\n"
"Browse the Deezer charts\n"
"$ <cmd>rip discover --source deezer charts</cmd>\n\n"
"Available options for Deezer <cmd>list</cmd>:\n\n"
" • releases\n"
" • charts\n"
" • selection\n"
)
def handle(self):
source = self.option("source")
scrape = self.option("scrape")
chosen_list = self.argument("list")
max_items = self.option("max-items")
if source == "qobuz":
from streamrip.constants import QOBUZ_FEATURED_KEYS
if chosen_list not in QOBUZ_FEATURED_KEYS:
self.line(f'<error>Error: list "{chosen_list}" not available</error>')
self.line(self.help)
return 1
elif source == "deezer":
from streamrip.constants import DEEZER_FEATURED_KEYS
if chosen_list not in DEEZER_FEATURED_KEYS:
self.line(f'<error>Error: list "{chosen_list}" not available</error>')
self.line(self.help)
return 1
else:
self.line(
"<error>Invalid source. Choose either <cmd>qobuz</cmd> or <cmd>deezer</cmd></error>"
)
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, source, "featured", limit=int(max_items)
):
core.download()
else:
self.line("<error>No items chosen, exiting.</error>")
return 0
class LastfmCommand(Command):
name = "lastfm"
description = "Search for tracks from a last.fm playlist and download them."
arguments = [
argument(
"urls",
"Last.fm playlist urls",
optional=False,
multiple=True,
)
]
options = [
option(
"source",
"-s",
description="The source to search for items on",
flag=False,
default="qobuz",
),
]
help = (
"You can use this command to download Spotify, Apple Music, and YouTube "
"playlists.\nTo get started, create an account at "
"<url>https://www.last.fm</url>. Once you have\nreached the home page, "
"go to <path>Profile Icon</path> => <path>View profile</path> => "
"<path>Playlists</path> => <path>IMPORT</path>\nand paste your url.\n\n"
"Download the <info>young & free</info> Apple Music playlist (already imported)\n"
"$ <cmd>rip lastfm https://www.last.fm/user/nathan3895/playlists/12089888</cmd>\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):
name = "config"
description = "Manage the configuration file."
options = [
option(
"open",
"-o",
description="Open the config file in the default application",
flag=True,
),
option(
"open-vim",
"-O",
description="Open the config file in (neo)vim",
flag=True,
),
option(
"directory",
"-d",
description="Open the directory that the config file is located in",
flag=True,
),
option("path", "-p", description="Show the config file's path", flag=True),
option("qobuz", description="Set the credentials for Qobuz", flag=True),
option("tidal", description="Log into Tidal", flag=True),
option("deezer", description="Set the Deezer ARL", flag=True),
option(
"music-app",
description="Configure the config file for usage with the macOS Music App",
flag=True,
),
option("reset", description="Reset the config file", flag=True),
option(
"--update",
description="Reset the config file, keeping the credentials",
flag=True,
),
]
"""
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}
{--music-app : Configure the config file for usage with the macOS Music App}
{--reset : Reset the config file}
{--update : Reset the config file, keeping the credentials}
"""
_config: Config
def handle(self):
import shutil
from .constants import CONFIG_DIR, CONFIG_PATH
self._config = Config()
if self.option("path"):
self.line(f"<info>{CONFIG_PATH}</info>")
if self.option("open"):
self.line(f"Opening <url>{CONFIG_PATH}</url> in default application")
launch(CONFIG_PATH)
if self.option("reset"):
self._config.reset()
if self.option("update"):
self._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 <url>{CONFIG_DIR}</url>")
launch(CONFIG_DIR)
if self.option("tidal"):
from streamrip.clients import TidalClient
client = TidalClient()
client.login()
self._config.file["tidal"].update(client.get_tokens())
self._config.save()
self.line("<info>Credentials saved to config.</info>")
if self.option("deezer"):
from streamrip.clients import DeezerClient
from streamrip.exceptions import AuthenticationError
self.line(
"Follow the instructions at <url>https://github.com"
"/nathom/streamrip/wiki/Finding-your-Deezer-ARL-Cookie</url>"
)
given_arl = self.ask("Paste your ARL here: ").strip()
self.line("<comment>Validating arl...</comment>")
try:
DeezerClient().login(arl=given_arl)
self._config.file["deezer"]["arl"] = given_arl
self._config.save()
self.line("<b>Sucessfully logged in!</b>")
except AuthenticationError:
self.line("<error>Could not log in. Double check your ARL</error>")
if self.option("qobuz"):
import getpass
import hashlib
self._config.file["qobuz"]["email"] = self.ask("Qobuz email:")
self._config.file["qobuz"]["password"] = hashlib.md5(
getpass.getpass("Qobuz password (won't show on screen): ").encode()
).hexdigest()
self._config.save()
if self.option("music-app"):
self._conf_music_app()
def _conf_music_app(self):
import subprocess
import xml.etree.ElementTree as ET
from pathlib import Path
from tempfile import mktemp
# Find the Music library folder
temp_file = mktemp()
music_pref_plist = Path(Path.home()) / Path(
"Library/Preferences/com.apple.Music.plist"
)
# copy preferences to tempdir
subprocess.run(["cp", music_pref_plist, temp_file])
# convert binary to xml for parsing
subprocess.run(["plutil", "-convert", "xml1", temp_file])
items = iter(ET.parse(temp_file).getroot()[0])
for item in items:
if item.text == "NSNavLastRootDirectory":
break
library_folder = Path(next(items).text)
os.remove(temp_file)
# cp ~/library/preferences/com.apple.music.plist music.plist
# plutil -convert xml1 music.plist
# cat music.plist | pbcopy
self._config.file["downloads"]["folder"] = os.path.join(
library_folder, "Automatically Add to Music.localized"
)
conversion_config = self._config.file["conversion"]
conversion_config["enabled"] = True
conversion_config["codec"] = "ALAC"
conversion_config["sampling_rate"] = 48000
conversion_config["bit_depth"] = 24
self._config.file["filepaths"]["folder_format"] = ""
self._config.file["artwork"]["keep_hires_cover"] = False
self._config.save()
class ConvertCommand(Command):
name = "convert"
description = (
"A standalone tool that converts audio files to other codecs en masse."
)
arguments = [
argument(
"codec",
description="<cmd>FLAC</cmd>, <cmd>ALAC</cmd>, <cmd>OPUS</cmd>, <cmd>MP3</cmd>, or <cmd>AAC</cmd>.",
),
argument(
"path",
description="The path to the audio file or a directory that contains audio files.",
),
]
options = [
option(
"sampling-rate",
"-s",
description="Downsample the tracks to this rate, in Hz.",
default=192000,
flag=False,
),
option(
"bit-depth",
"-b",
description="Downsample the tracks to this bit depth.",
default=24,
flag=False,
),
option(
"keep-source", "-k", description="Keep the original file after conversion."
),
]
help = (
"\nConvert all of the audio files in <path>/my/music</path> to MP3s\n"
"$ <cmd>rip convert MP3 /my/music</cmd>\n\n"
"Downsample the audio to 48kHz after converting them to ALAC\n"
"$ <cmd>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'<error>Invalid codec "{codec}". See </error><cmd>rip convert'
" -h</cmd>."
)
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",
unit="track",
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'<error>Path <path>"{path}"</path> does not exist.</error>',
)
class RepairCommand(Command):
name = "repair"
description = "Retry failed downloads."
options = [
option(
"max-items",
"-m",
flag=False,
description="The maximum number of tracks to download}",
default="None",
)
]
help = "\nRetry up to 20 failed downloads\n$ <cmd>rip repair --max-items 20</cmd>\n"
def handle(self):
max_items = next(clean_options(self.option("max-items")))
config = Config()
RipCore(config).repair(max_items=max_items)
class DatabaseCommand(Command):
name = "db"
description = "View and manage rip's databases."
arguments = [
argument(
"name", description="<cmd>downloads</cmd> or <cmd>failed-downloads</cmd>."
)
]
options = [
option("list", "-l", description="Display the contents of the database."),
option("reset", description="Reset the database."),
]
_table_style = "box-double"
def handle(self) -> None:
from . import db
from .config import Config
config = Config()
db_name = self.argument("name").replace("-", "_")
self._path = config.file["database"][db_name]["path"]
self._db = db.CLASS_MAP[db_name](self._path)
if self.option("list"):
getattr(self, f"_render_{db_name}")()
if self.option("reset"):
os.remove(self._path)
def _render_downloads(self):
from cleo.ui.table import Table
id_table = Table(self._io)
id_table.set_style(self._table_style)
id_table.set_header_title("IDs")
id_table.set_headers(list(self._db.structure.keys()))
id_table.add_rows(id for id in iter(self._db) if id[0].isalnum())
if id_table._rows:
id_table.render()
url_table = Table(self._io)
url_table.set_style(self._table_style)
url_table.set_header_title("URLs")
url_table.set_headers(list(self._db.structure.keys()))
url_table.add_rows(id for id in iter(self._db) if not id[0].isalnum())
# prevent wierd formatting
if url_table._rows:
url_table.render()
def _render_failed_downloads(self):
from cleo.ui.table import Table
id_table = Table(self._io)
id_table.set_style(self._table_style)
id_table.set_header_title("Failed Downloads")
id_table.set_headers(["Source", "Media Type", "ID"])
id_table.add_rows(iter(self._db))
id_table.render()
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():
from .constants import CONFIG_DIR
logger.setLevel(logging.DEBUG)
fh = logging.FileHandler(os.path.join(CONFIG_DIR, "streamrip.log"))
fh.setLevel(logging.DEBUG)
logger.addHandler(fh)
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"]))
formatter.set_style("header", Style("yellow", options=["bold", "underline"]))
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 render_error(self, error, io):
super().render_error(error, io)
io.write_line(
"\n<error>If this was unexpected, please open a <path>Bug Report</path> at </error>"
"<url>https://github.com/nathom/streamrip/issues/new/choose</url>"
)
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
global newest_version
r = requests.get("https://pypi.org/pypi/streamrip/json").json()
newest_version = r["info"]["version"]
# Compare versions
curr_version_parsed = map(int, __version__.split("."))
assert isinstance(newest_version, str)
newest_version_parsed = map(int, newest_version.split("."))
outdated = False
for c, n in zip(curr_version_parsed, newest_version_parsed):
outdated = c < n
if c != n:
break
def main():
application = Application()
application.add(DownloadCommand())
application.add(SearchCommand())
application.add(DiscoverCommand())
application.add(LastfmCommand())
application.add(ConfigCommand())
application.add(ConvertCommand())
application.add(RepairCommand())
application.add(DatabaseCommand())
application.run()
if __name__ == "__main__":
main()

49
src/album.py Normal file
View file

@ -0,0 +1,49 @@
import asyncio
from dataclasses import dataclass
from .client import Client
from .config import Config
from .media import Media, Pending
from .metadata import AlbumMetadata, get_album_track_ids
from .track import PendingTrack, Track
@dataclass(slots=True)
class Album(Media):
meta: AlbumMetadata
tracks: list[Track]
config: Config
directory: str
@dataclass(slots=True)
class PendingAlbum(Pending):
id: str
client: Client
config: Config
folder: str
async def resolve(self):
resp = self.client.get_metadata(id, "album")
meta = AlbumMetadata.from_resp(self.client.source, resp)
tracklist = get_album_track_ids(self.client.source, resp)
album_folder = self._album_folder(self.folder, meta.album)
pending_tracks = [
PendingTrack(
id=id,
album=meta,
client=self.client,
config=self.config,
folder=album_folder,
)
for id in tracklist
]
tracks: list[Track] = await asyncio.gather(
*(track.resolve() for track in pending_tracks)
)
return Album(meta, tracks, self.config)
def _album_folder(self, parent: str, album_name: str) -> str:
# find name of album folder
# create album folder if it doesnt exist
pass

9
src/artist.py Normal file
View file

@ -0,0 +1,9 @@
class Artist(Media):
name: str
albums: list[Album]
config: Config
class PendingArtist(Pending):
id: str
client: Client

852
src/cli.py Normal file
View file

@ -0,0 +1,852 @@
import concurrent.futures
import logging
import os
import threading
from typing import Optional
import requests
from cleo.application import Application as BaseApplication
from cleo.commands.command import Command
from cleo.formatters.style import Style
from cleo.helpers import argument, option
from click import launch
from .config import Config
from .user_paths import DEFAULT_CONFIG_PATH
# from . import __version__
# from .core import RipCore
logging.basicConfig(level="WARNING")
logger = logging.getLogger("streamrip")
outdated = False
newest_version: Optional[str] = None
class DownloadCommand(Command):
name = "url"
description = "Download items using urls."
arguments = [
argument(
"urls",
"One or more Qobuz, Tidal, Deezer, or SoundCloud urls",
optional=True,
multiple=True,
)
]
options = [
option(
"file",
"-f",
"Path to a text file containing urls",
flag=False,
default="None",
),
option(
"codec",
"-c",
"Convert the downloaded files to <cmd>ALAC</cmd>, <cmd>FLAC</cmd>, <cmd>MP3</cmd>, <cmd>AAC</cmd>, or <cmd>OGG</cmd>",
flag=False,
default="None",
),
option(
"max-quality",
"m",
"The maximum quality to download. Can be <cmd>0</cmd>, <cmd>1</cmd>, <cmd>2</cmd>, <cmd>3 </cmd>or <cmd>4</cmd>",
flag=False,
default="None",
),
option(
"ignore-db",
"-i",
description="Download items even if they have been logged in the database.",
),
option("config", description="Path to config file.", flag=False),
option("directory", "-d", "Directory to download items into.", flag=False),
]
help = (
"\nDownload <title>Dreams</title> by <title>Fleetwood Mac</title>:\n"
"$ <cmd>rip url https://www.deezer.com/us/track/67549262</cmd>\n\n"
"Batch download urls from a text file named <path>urls.txt</path>:\n"
"$ <cmd>rip url --file urls.txt</cmd>\n\n"
"For more information on Quality IDs, see\n"
"<url>https://github.com/nathom/streamrip/wiki/Quality-IDs</url>\n"
)
def handle(self):
global outdated
global newest_version
# Use a thread so that it doesn't slow down startup
update_check = threading.Thread(target=is_outdated, daemon=True)
update_check.start()
path, quality, no_db, directory, config_path_arg = clean_options(
self.option("file"),
self.option("max-quality"),
self.option("ignore-db"),
self.option("directory"),
self.option("config"),
)
config_path = config_path_arg or DEFAULT_CONFIG_PATH
assert isinstance(config_path, str)
config = Config(config_path)
if directory is not None:
assert isinstance(directory, str)
config.session.downloads.folder = directory
if no_db:
config.session.database.downloads_enabled = False
if quality is not None:
assert isinstance(quality, int)
config.session.qobuz.quality = quality
config.session.tidal.quality = quality
config.session.deezer.quality = quality
core = RipCore(config)
urls = self.argument("urls")
if path is not None:
assert isinstance(path, str)
if os.path.isfile(path):
core.handle_txt(path)
else:
self.line(
f"<error>File <comment>{path}</comment> does not exist.</error>"
)
return 1
if urls:
core.handle_urls(";".join(urls))
if len(core) > 0:
core.download()
elif not urls and path is None:
self.line("<error>Must pass arguments. See </><cmd>rip url -h</cmd>.")
update_check.join()
if outdated:
import re
self.line(
f"\n<info>A new version of streamrip <title>v{newest_version}</title>"
" is available! Run <cmd>pip3 install streamrip --upgrade</cmd>"
" to update.</info>\n"
)
md_header = re.compile(r"#\s+(.+)")
bullet_point = re.compile(r"-\s+(.+)")
code = re.compile(r"`([^`]+)`")
issue_reference = re.compile(r"(#\d+)")
release_notes = requests.get(
"https://api.github.com/repos/nathom/streamrip/releases/latest"
).json()["body"]
release_notes = md_header.sub(r"<header>\1</header>", release_notes)
release_notes = bullet_point.sub(r"<options=bold>•</> \1", release_notes)
release_notes = code.sub(r"<cmd>\1</cmd>", release_notes)
release_notes = issue_reference.sub(r"<options=bold>\1</>", release_notes)
self.line(release_notes)
return 0
# class SearchCommand(Command):
# name = "search"
# description = "Search for an item"
# arguments = [
# argument(
# "query",
# "The name to search for",
# optional=False,
# multiple=False,
# )
# ]
# options = [
# option(
# "source",
# "-s",
# "Qobuz, Tidal, Soundcloud, Deezer, or Deezloader",
# flag=False,
# default="qobuz",
# ),
# option(
# "type",
# "-t",
# "Album, Playlist, Track, or Artist",
# flag=False,
# default="album",
# ),
# ]
#
# help = (
# "\nSearch for <title>Rumours</title> by <title>Fleetwood Mac</title>\n"
# "$ <cmd>rip search 'rumours fleetwood mac'</cmd>\n\n"
# "Search for <title>444</title> by <title>Jay-Z</title> on TIDAL\n"
# "$ <cmd>rip search --source tidal '444'</cmd>\n\n"
# "Search for <title>Bob Dylan</title> on Deezer\n"
# "$ <cmd>rip search --type artist --source deezer 'bob dylan'</cmd>\n"
# )
#
# def handle(self):
# query = self.argument("query")
# source, type = clean_options(self.option("source"), self.option("type"))
# assert isinstance(source, str)
# assert isinstance(type, str)
#
# config = Config()
# core = RipCore(config)
#
# if core.interactive_search(query, source, type):
# core.download()
# else:
# self.line("<error>No items chosen, exiting.</error>")
#
#
# class DiscoverCommand(Command):
# name = "discover"
# description = "Download items from the charts or a curated playlist"
# arguments = [
# argument(
# "list",
# "The list to fetch",
# optional=True,
# multiple=False,
# default="ideal-discography",
# )
# ]
# options = [
# option(
# "scrape",
# description="Download all of the items in the list",
# ),
# option(
# "max-items",
# "-m",
# description="The number of items to fetch",
# flag=False,
# default=50,
# ),
# option(
# "source",
# "-s",
# description="The source to download from (<cmd>qobuz</cmd> or <cmd>deezer</cmd>)",
# flag=False,
# default="qobuz",
# ),
# ]
# help = (
# "\nBrowse the Qobuz ideal-discography list\n"
# "$ <cmd>rip discover</cmd>\n\n"
# "Browse the best-sellers list\n"
# "$ <cmd>rip discover best-sellers</cmd>\n\n"
# "Available options for Qobuz <cmd>list</cmd>:\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\n"
# "Browse the Deezer editorial releases list\n"
# "$ <cmd>rip discover --source deezer</cmd>\n\n"
# "Browse the Deezer charts\n"
# "$ <cmd>rip discover --source deezer charts</cmd>\n\n"
# "Available options for Deezer <cmd>list</cmd>:\n\n"
# " • releases\n"
# " • charts\n"
# " • selection\n"
# )
#
# def handle(self):
# source = self.option("source")
# scrape = self.option("scrape")
# chosen_list = self.argument("list")
# max_items = self.option("max-items")
#
# if source == "qobuz":
# from streamrip.constants import QOBUZ_FEATURED_KEYS
#
# if chosen_list not in QOBUZ_FEATURED_KEYS:
# self.line(f'<error>Error: list "{chosen_list}" not available</error>')
# self.line(self.help)
# return 1
# elif source == "deezer":
# from streamrip.constants import DEEZER_FEATURED_KEYS
#
# if chosen_list not in DEEZER_FEATURED_KEYS:
# self.line(f'<error>Error: list "{chosen_list}" not available</error>')
# self.line(self.help)
# return 1
#
# else:
# self.line(
# "<error>Invalid source. Choose either <cmd>qobuz</cmd> or <cmd>deezer</cmd></error>"
# )
# 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, source, "featured", limit=int(max_items)
# ):
# core.download()
# else:
# self.line("<error>No items chosen, exiting.</error>")
#
# return 0
#
#
# class LastfmCommand(Command):
# name = "lastfm"
# description = "Search for tracks from a last.fm playlist and download them."
#
# arguments = [
# argument(
# "urls",
# "Last.fm playlist urls",
# optional=False,
# multiple=True,
# )
# ]
# options = [
# option(
# "source",
# "-s",
# description="The source to search for items on",
# flag=False,
# default="qobuz",
# ),
# ]
# help = (
# "You can use this command to download Spotify, Apple Music, and YouTube "
# "playlists.\nTo get started, create an account at "
# "<url>https://www.last.fm</url>. Once you have\nreached the home page, "
# "go to <path>Profile Icon</path> => <path>View profile</path> => "
# "<path>Playlists</path> => <path>IMPORT</path>\nand paste your url.\n\n"
# "Download the <info>young & free</info> Apple Music playlist (already imported)\n"
# "$ <cmd>rip lastfm https://www.last.fm/user/nathan3895/playlists/12089888</cmd>\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):
# name = "config"
# description = "Manage the configuration file."
#
# options = [
# option(
# "open",
# "-o",
# description="Open the config file in the default application",
# flag=True,
# ),
# option(
# "open-vim",
# "-O",
# description="Open the config file in (neo)vim",
# flag=True,
# ),
# option(
# "directory",
# "-d",
# description="Open the directory that the config file is located in",
# flag=True,
# ),
# option("path", "-p", description="Show the config file's path", flag=True),
# option("qobuz", description="Set the credentials for Qobuz", flag=True),
# option("tidal", description="Log into Tidal", flag=True),
# option("deezer", description="Set the Deezer ARL", flag=True),
# option(
# "music-app",
# description="Configure the config file for usage with the macOS Music App",
# flag=True,
# ),
# option("reset", description="Reset the config file", flag=True),
# option(
# "--update",
# description="Reset the config file, keeping the credentials",
# flag=True,
# ),
# ]
# """
# 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}
# {--music-app : Configure the config file for usage with the macOS Music App}
# {--reset : Reset the config file}
# {--update : Reset the config file, keeping the credentials}
# """
#
# _config: Config
#
# def handle(self):
# import shutil
#
# from .constants import CONFIG_DIR, CONFIG_PATH
#
# self._config = Config()
#
# if self.option("path"):
# self.line(f"<info>{CONFIG_PATH}</info>")
#
# if self.option("open"):
# self.line(f"Opening <url>{CONFIG_PATH}</url> in default application")
# launch(CONFIG_PATH)
#
# if self.option("reset"):
# self._config.reset()
#
# if self.option("update"):
# self._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 <url>{CONFIG_DIR}</url>")
# launch(CONFIG_DIR)
#
# if self.option("tidal"):
# from streamrip.clients import TidalClient
#
# client = TidalClient()
# client.login()
# self._config.file["tidal"].update(client.get_tokens())
# self._config.save()
# self.line("<info>Credentials saved to config.</info>")
#
# if self.option("deezer"):
# from streamrip.clients import DeezerClient
# from streamrip.exceptions import AuthenticationError
#
# self.line(
# "Follow the instructions at <url>https://github.com"
# "/nathom/streamrip/wiki/Finding-your-Deezer-ARL-Cookie</url>"
# )
#
# given_arl = self.ask("Paste your ARL here: ").strip()
# self.line("<comment>Validating arl...</comment>")
#
# try:
# DeezerClient().login(arl=given_arl)
# self._config.file["deezer"]["arl"] = given_arl
# self._config.save()
# self.line("<b>Sucessfully logged in!</b>")
#
# except AuthenticationError:
# self.line("<error>Could not log in. Double check your ARL</error>")
#
# if self.option("qobuz"):
# import getpass
# import hashlib
#
# self._config.file["qobuz"]["email"] = self.ask("Qobuz email:")
# self._config.file["qobuz"]["password"] = hashlib.md5(
# getpass.getpass("Qobuz password (won't show on screen): ").encode()
# ).hexdigest()
# self._config.save()
#
# if self.option("music-app"):
# self._conf_music_app()
#
# def _conf_music_app(self):
# import subprocess
# import xml.etree.ElementTree as ET
# from pathlib import Path
# from tempfile import mktemp
#
# # Find the Music library folder
# temp_file = mktemp()
# music_pref_plist = Path(Path.home()) / Path(
# "Library/Preferences/com.apple.Music.plist"
# )
# # copy preferences to tempdir
# subprocess.run(["cp", music_pref_plist, temp_file])
# # convert binary to xml for parsing
# subprocess.run(["plutil", "-convert", "xml1", temp_file])
# items = iter(ET.parse(temp_file).getroot()[0])
#
# for item in items:
# if item.text == "NSNavLastRootDirectory":
# break
#
# library_folder = Path(next(items).text)
# os.remove(temp_file)
#
# # cp ~/library/preferences/com.apple.music.plist music.plist
# # plutil -convert xml1 music.plist
# # cat music.plist | pbcopy
#
# self._config.file["downloads"]["folder"] = os.path.join(
# library_folder, "Automatically Add to Music.localized"
# )
#
# conversion_config = self._config.file["conversion"]
# conversion_config["enabled"] = True
# conversion_config["codec"] = "ALAC"
# conversion_config["sampling_rate"] = 48000
# conversion_config["bit_depth"] = 24
#
# self._config.file["filepaths"]["folder_format"] = ""
# self._config.file["artwork"]["keep_hires_cover"] = False
# self._config.save()
#
#
# class ConvertCommand(Command):
# name = "convert"
# description = (
# "A standalone tool that converts audio files to other codecs en masse."
# )
# arguments = [
# argument(
# "codec",
# description="<cmd>FLAC</cmd>, <cmd>ALAC</cmd>, <cmd>OPUS</cmd>, <cmd>MP3</cmd>, or <cmd>AAC</cmd>.",
# ),
# argument(
# "path",
# description="The path to the audio file or a directory that contains audio files.",
# ),
# ]
# options = [
# option(
# "sampling-rate",
# "-s",
# description="Downsample the tracks to this rate, in Hz.",
# default=192000,
# flag=False,
# ),
# option(
# "bit-depth",
# "-b",
# description="Downsample the tracks to this bit depth.",
# default=24,
# flag=False,
# ),
# option(
# "keep-source", "-k", description="Keep the original file after conversion."
# ),
# ]
#
# help = (
# "\nConvert all of the audio files in <path>/my/music</path> to MP3s\n"
# "$ <cmd>rip convert MP3 /my/music</cmd>\n\n"
# "Downsample the audio to 48kHz after converting them to ALAC\n"
# "$ <cmd>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'<error>Invalid codec "{codec}". See </error><cmd>rip convert'
# " -h</cmd>."
# )
# 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",
# unit="track",
# 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'<error>Path <path>"{path}"</path> does not exist.</error>',
# )
#
#
# class RepairCommand(Command):
# name = "repair"
# description = "Retry failed downloads."
#
# options = [
# option(
# "max-items",
# "-m",
# flag=False,
# description="The maximum number of tracks to download}",
# default="None",
# )
# ]
#
# help = "\nRetry up to 20 failed downloads\n$ <cmd>rip repair --max-items 20</cmd>\n"
#
# def handle(self):
# max_items = next(clean_options(self.option("max-items")))
# config = Config()
# RipCore(config).repair(max_items=max_items)
#
#
# class DatabaseCommand(Command):
# name = "db"
# description = "View and manage rip's databases."
#
# arguments = [
# argument(
# "name", description="<cmd>downloads</cmd> or <cmd>failed-downloads</cmd>."
# )
# ]
# options = [
# option("list", "-l", description="Display the contents of the database."),
# option("reset", description="Reset the database."),
# ]
#
# _table_style = "box-double"
#
# def handle(self) -> None:
# from . import db
# from .config import Config
#
# config = Config()
# db_name = self.argument("name").replace("-", "_")
#
# self._path = config.file["database"][db_name]["path"]
# self._db = db.CLASS_MAP[db_name](self._path)
#
# if self.option("list"):
# getattr(self, f"_render_{db_name}")()
#
# if self.option("reset"):
# os.remove(self._path)
#
# def _render_downloads(self):
# from cleo.ui.table import Table
#
# id_table = Table(self._io)
# id_table.set_style(self._table_style)
# id_table.set_header_title("IDs")
# id_table.set_headers(list(self._db.structure.keys()))
# id_table.add_rows(id for id in iter(self._db) if id[0].isalnum())
# if id_table._rows:
# id_table.render()
#
# url_table = Table(self._io)
# url_table.set_style(self._table_style)
# url_table.set_header_title("URLs")
# url_table.set_headers(list(self._db.structure.keys()))
# url_table.add_rows(id for id in iter(self._db) if not id[0].isalnum())
# # prevent wierd formatting
# if url_table._rows:
# url_table.render()
#
# def _render_failed_downloads(self):
# from cleo.ui.table import Table
#
# id_table = Table(self._io)
# id_table.set_style(self._table_style)
# id_table.set_header_title("Failed Downloads")
# id_table.set_headers(["Source", "Media Type", "ID"])
# id_table.add_rows(iter(self._db))
# id_table.render()
#
#
STRING_TO_PRIMITIVE = {
"None": None,
"True": True,
"False": False,
}
class Application(BaseApplication):
def __init__(self):
# TODO: fix version
super().__init__("rip", "2.0")
def _run(self, io):
# if io.is_debug():
# from .constants import CONFIG_DIR
#
# logger.setLevel(logging.DEBUG)
# fh = logging.FileHandler(os.path.join(CONFIG_DIR, "streamrip.log"))
# fh.setLevel(logging.DEBUG)
# logger.addHandler(fh)
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"]))
formatter.set_style("header", Style("yellow", options=["bold", "underline"]))
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 render_error(self, error, io):
super().render_error(error, io)
io.write_line(
"\n<error>If this was unexpected, please open a <path>Bug Report</path> at </error>"
"<url>https://github.com/nathom/streamrip/issues/new/choose</url>"
)
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
global newest_version
r = requests.get("https://pypi.org/pypi/streamrip/json").json()
newest_version = r["info"]["version"]
# Compare versions
curr_version_parsed = map(int, __version__.split("."))
assert isinstance(newest_version, str)
newest_version_parsed = map(int, newest_version.split("."))
outdated = False
for c, n in zip(curr_version_parsed, newest_version_parsed):
outdated = c < n
if c != n:
break
def main():
application = Application()
application.add(DownloadCommand())
# application.add(SearchCommand())
# application.add(DiscoverCommand())
# application.add(LastfmCommand())
# application.add(ConfigCommand())
# application.add(ConvertCommand())
# application.add(RepairCommand())
# application.add(DatabaseCommand())
application.run()
if __name__ == "__main__":
main()

29
src/client.py Normal file
View file

@ -0,0 +1,29 @@
"""The clients that interact with the streaming service APIs."""
import logging
from abc import ABC, abstractmethod
from .downloadable import Downloadable
logger = logging.getLogger("streamrip")
class Client(ABC):
source: str
max_quality: int
@abstractmethod
async def login(self):
raise NotImplemented
@abstractmethod
async def get_metadata(self, item_id, media_type):
raise NotImplemented
@abstractmethod
async def search(self, query: str, media_type: str, limit: int = 500):
raise NotImplemented
@abstractmethod
async def get_downloadable(self, item_id: str, quality: int) -> Downloadable:
raise NotImplemented

View file

@ -1,10 +1,14 @@
"""A config class that manages arguments between the config file and CLI."""
import copy
import logging
import os
from collections import defaultdict
from dataclasses import dataclass
from typing import Any
import tomlkit
from tomlkit.api import dumps, parse
from tomlkit.toml_document import TOMLDocument
logger = logging.getLogger("streamrip")
@ -201,7 +205,8 @@ class ThemeConfig:
@dataclass(slots=True)
class Config:
class ConfigData:
toml: TOMLDocument
downloads: DownloadsConfig
qobuz: QobuzConfig
@ -224,7 +229,7 @@ class Config:
@classmethod
def from_toml(cls, toml_str: str):
# TODO: handle the mistake where Windows people forget to escape backslash
toml = tomlkit.parse(toml_str) # type: ignore
toml = parse(toml_str)
if toml["misc"]["version"] != CURRENT_CONFIG_VERSION: # type: ignore
raise Exception("Need to update config")
@ -243,6 +248,7 @@ class Config:
database = DatabaseConfig(**toml["database"]) # type: ignore
return cls(
toml=toml,
downloads=downloads,
qobuz=qobuz,
tidal=tidal,
@ -265,3 +271,27 @@ class Config:
def set_modified(self):
self._modified = True
def modified(self):
return self._modified
def update_toml(self):
pass
class Config:
def __init__(self, path: str):
self._path = path
with open(path) as toml_file:
self.file: ConfigData = ConfigData.from_toml(toml_file.read())
self.session: ConfigData = copy.deepcopy(self.file)
def save_file(self):
if not self.file.modified():
return
with open(self._path, "w") as toml_file:
self.file.update_toml()
toml_file.write(dumps(self.file.toml))

View file

@ -242,8 +242,8 @@ class OPUS(Converter):
container = "opus"
default_ffmpeg_arg = "-b:a 128k" # Transparent
def get_quality_arg(self, rate: int) -> str:
pass
def get_quality_arg(self, _: int) -> str:
return ""
class AAC(Converter):
@ -260,5 +260,5 @@ class AAC(Converter):
container = "m4a"
default_ffmpeg_arg = "-b:a 256k"
def get_quality_arg(self, rate: int) -> str:
pass
def get_quality_arg(self, _: int) -> str:
return ""

View file

@ -15,14 +15,6 @@ import requests
from click import secho, style
from tqdm import tqdm
from streamrip.clients import (
Client,
DeezerClient,
DeezloaderClient,
QobuzClient,
SoundCloudClient,
TidalClient,
)
from streamrip.constants import MEDIA_TYPES
from streamrip.exceptions import (
AuthenticationError,
@ -47,20 +39,26 @@ from streamrip.media import (
from streamrip.utils import TQDM_DEFAULT_THEME, set_progress_bar_theme
from . import db
from .clients import (
Client,
DeezerClient,
DeezloaderClient,
QobuzClient,
SoundcloudClient,
TidalClient,
)
from .config import Config
from .constants import (
CONFIG_PATH,
DB_PATH,
from .exceptions import DeezloaderFallback
from .user_paths import DB_PATH, FAILED_DB_PATH
from .utils import extract_deezer_dynamic_link, extract_interpreter_url
from .validation_regexps import (
DEEZER_DYNAMIC_LINK_REGEX,
FAILED_DB_PATH,
LASTFM_URL_REGEX,
QOBUZ_INTERPRETER_URL_REGEX,
SOUNDCLOUD_URL_REGEX,
URL_REGEX,
YOUTUBE_URL_REGEX,
)
from .exceptions import DeezloaderFallback
from .utils import extract_deezer_dynamic_link, extract_interpreter_url
logger = logging.getLogger("streamrip")
@ -87,57 +85,33 @@ DB_PATH_MAP = {"downloads": DB_PATH, "failed_downloads": FAILED_DB_PATH}
class RipCore(list):
"""RipCore."""
clients = {
"qobuz": QobuzClient(),
"tidal": TidalClient(),
"deezer": DeezerClient(),
"soundcloud": SoundCloudClient(),
"deezloader": DeezloaderClient(),
}
def __init__(
self,
config: Optional[Config] = None,
):
def __init__(self, config: Config):
"""Create a RipCore object.
:param config:
:type config: Optional[Config]
"""
self.config: Config
if config is None:
self.config = Config(CONFIG_PATH)
else:
self.config = config
self.config = config
self.clients: dict[str, Client] = {
"qobuz": QobuzClient(config),
"tidal": TidalClient(config),
"deezer": DeezerClient(config),
"soundcloud": SoundcloudClient(config),
"deezloader": DeezloaderClient(config),
}
if (theme := self.config.file["theme"]["progress_bar"]) != TQDM_DEFAULT_THEME:
set_progress_bar_theme(theme.lower())
c = self.config.session
def get_db(db_type: str) -> db.Database:
db_settings = self.config.session["database"]
db_class = db.CLASS_MAP[db_type]
theme = c.theme.progress_bar
set_progress_bar_theme(theme)
if db_settings[db_type]["enabled"] and db_settings.get("enabled", True):
default_db_path = DB_PATH_MAP[db_type]
path = db_settings[db_type]["path"]
if path:
database = db_class(path)
else:
database = db_class(default_db_path)
assert config is not None
config.file["database"][db_type]["path"] = default_db_path
config.save()
else:
database = db_class("", dummy=True)
return database
self.db = get_db("downloads")
self.failed_db = get_db("failed_downloads")
self.db = db.Downloads(
c.database.downloads_path, dummy=not c.database.downloads_enabled
)
self.failed = db.FailedDownloads(
c.database.failed_downloads_path,
dummy=not c.database.failed_downloads_enabled,
)
def handle_urls(self, urls):
"""Download a url.
@ -469,7 +443,7 @@ class RipCore(list):
if soundcloud_urls:
soundcloud_client = self.get_client("soundcloud")
assert isinstance(soundcloud_client, SoundCloudClient) # for typing
assert isinstance(soundcloud_client, SoundcloudClient) # for typing
# TODO: Make this async
soundcloud_items = (

View file

@ -182,6 +182,3 @@ class FailedDownloads(Database):
"media_type": ["text"],
"id": ["text", "unique"],
}
CLASS_MAP = {db.name: db for db in (Downloads, FailedDownloads)}

28
src/deezer_client.py Normal file
View file

@ -0,0 +1,28 @@
class DeezerClient(Client):
source = "deezer"
max_quality = 2
def __init__(self, config: Config):
self.client = deezer.Deezer()
self.logged_in = False
self.config = config.deezer
async def login(self):
arl = self.config.arl
if not arl:
raise MissingCredentials
success = self.client.login_via_arl(arl)
if not success:
raise AuthenticationError
self.logged_in = True
async def get_metadata(self, item_id: str, media_type: str) -> dict:
pass
async def search(
self, query: str, media_type: str, limit: int = 200
) -> SearchResult:
pass
async def get_downloadable(self, item_id: str, quality: int = 2) -> Downloadable:
pass

23
src/deezloader_client.py Normal file
View file

@ -0,0 +1,23 @@
from .client import Client
class DeezloaderClient(Client):
source = "deezer"
max_quality = 2
def __init__(self, config):
self.session = SRSession()
self.global_config = config
self.logged_in = True
async def search(self, query: str, media_type: str, limit: int = 200):
pass
async def login(self):
pass
async def get(self, item_id: str, media_type: str):
pass
async def get_downloadable(self, item_id: str, quality: int):
pass

79
src/downloadable.py Normal file
View file

@ -0,0 +1,79 @@
import os
import shutil
import time
from abc import ABC, abstractmethod
from tempfile import gettempdir
from typing import Callable, Optional
import aiofiles
import aiohttp
def generate_temp_path(url: str):
return os.path.join(gettempdir(), f"__streamrip_{hash(url)}_{time.time()}.download")
class Downloadable(ABC):
session: aiohttp.ClientSession
url: str
chunk_size = 1024
_size: Optional[int] = None
async def download(self, path: str, callback: Callable[[], None]):
tmp = generate_temp_path(self.url)
await self._download(tmp, callback)
shutil.move(tmp, path)
async def size(self) -> int:
if self._size is not None:
return self._size
async with self.session.head(self.url) as response:
response.raise_for_status()
content_length = response.headers["Content-Length"]
self._size = int(content_length)
return self._size
@abstractmethod
async def _download(self, path: str, callback: Callable[[], None]):
raise NotImplemented
class BasicDownloadable(Downloadable):
"""Just downloads a URL."""
def __init__(self, session, url: str):
self.session = session
self.url = url
async def _download(self, path: str, callback: Callable[[int], None]):
async with self.session.get(self.url) as response:
response.raise_for_status()
async with aiofiles.open(path, "wb") as file:
async for chunk in response.content.iter_chunked(self.chunk_size):
await file.write(chunk)
# typically a bar.update()
callback(self.chunk_size)
class DeezerDownloadable(Downloadable):
def __init__(self, resp: dict):
self.resp = resp
async def _download(self, path: str) -> bool:
raise NotImplemented
class TidalDownloadable(Downloadable):
def __init__(self, info: dict):
self.info = info
async def _download(self, path: str) -> bool:
raise NotImplemented
class SoundcloudDownloadable(Downloadable):
def __init__(self, info: dict):
self.info = info
async def _download(self, path: str) -> bool:
raise NotImplemented

32
src/media.py Normal file
View file

@ -0,0 +1,32 @@
from abc import ABC, abstractmethod
class Media(ABC):
async def rip(self):
await self.preprocess()
await self.download()
await self.postprocess()
@abstractmethod
async def preprocess(self):
"""Create directories, download cover art, etc."""
raise NotImplemented
@abstractmethod
async def download(self):
"""Download and tag the actual audio files in the correct directories."""
raise NotImplemented
@abstractmethod
async def postprocess(self):
"""Update database, run conversion, delete garbage files etc."""
raise NotImplemented
class Pending(ABC):
"""A request to download a `Media` whose metadata has not been fetched."""
@abstractmethod
async def resolve(self) -> Media:
"""Fetch metadata and resolve into a downloadable `Media` object."""
raise NotImplemented

View file

@ -5,7 +5,8 @@ from __future__ import annotations
import logging
import re
from collections import OrderedDict
from typing import Generator, Hashable, Iterable, Optional, Union
from dataclasses import dataclass
from typing import Generator, Hashable, Iterable, Optional, Type, Union
from .constants import (
ALBUM_KEYS,
@ -23,7 +24,148 @@ from .utils import get_cover_urls, get_quality_id, safe_get
logger = logging.getLogger("streamrip")
def get_album_track_ids(source: str, resp) -> list[str]:
tracklist = resp["tracks"]
if source == "qobuz":
tracklist = tracklist["items"]
return [track["id"] for track in tracklist]
@dataclass(slots=True)
class CoverUrls:
thumbnail: Optional[str]
small: Optional[str]
large: Optional[str]
original: Optional[str]
def largest(self):
if self.original is not None:
return self.original
if self.large is not None:
return self.large
if self.small is not None:
return self.small
if self.thumbnail is not None:
return self.thumbnail
@dataclass(slots=True)
class TrackMetadata:
info: TrackInfo
title: str
album: AlbumMetadata
artist: str
tracknumber: int
discnumber: int
composer: Optional[str]
@classmethod
def from_qobuz(cls, album: AlbumMetadata, resp) -> TrackMetadata:
raise NotImplemented
@classmethod
def from_deezer(cls, album: AlbumMetadata, resp) -> TrackMetadata:
raise NotImplemented
@classmethod
def from_soundcloud(cls, album: AlbumMetadata, resp) -> TrackMetadata:
raise NotImplemented
@classmethod
def from_tidal(cls, album: AlbumMetadata, resp) -> TrackMetadata:
raise NotImplemented
@classmethod
def from_resp(cls, album: AlbumMetadata, source, resp) -> TrackMetadata:
if source == "qobuz":
return cls.from_qobuz(album, resp)
if source == "tidal":
return cls.from_tidal(album, resp)
if source == "soundcloud":
return cls.from_soundcloud(album, resp)
if source == "deezer":
return cls.from_deezer(album, resp)
raise Exception
@dataclass(slots=True)
class TrackInfo:
id: str
quality: int
bit_depth: Optional[int] = None
booklets = None
explicit: bool = False
sampling_rate: Optional[int] = None
work: Optional[str] = None
@dataclass(slots=True)
class AlbumMetadata:
info: AlbumInfo
album: str
albumartist: str
year: str
genre: list[str]
covers: list[CoverUrls]
albumcomposer: Optional[str] = None
comment: Optional[str] = None
compilation: Optional[str] = None
copyright: Optional[str] = None
cover: Optional[str] = None
date: Optional[str] = None
description: Optional[str] = None
disctotal: Optional[int] = None
encoder: Optional[str] = None
grouping: Optional[str] = None
lyrics: Optional[str] = None
purchase_date: Optional[str] = None
tracktotal: Optional[int] = None
@classmethod
def from_qobuz(cls, resp) -> AlbumMetadata:
raise NotImplemented
@classmethod
def from_deezer(cls, resp) -> AlbumMetadata:
raise NotImplemented
@classmethod
def from_soundcloud(cls, resp) -> AlbumMetadata:
raise NotImplemented
@classmethod
def from_tidal(cls, resp) -> AlbumMetadata:
raise NotImplemented
@classmethod
def from_resp(cls, source, resp) -> AlbumMetadata:
if source == "qobuz":
return cls.from_qobuz(resp)
if source == "tidal":
return cls.from_tidal(resp)
if source == "soundcloud":
return cls.from_soundcloud(resp)
if source == "deezer":
return cls.from_deezer(resp)
raise Exception
@dataclass(slots=True)
class AlbumInfo:
id: str
quality: int
explicit: bool = False
sampling_rate: Optional[int] = None
bit_depth: Optional[int] = None
booklets = None
work: Optional[str] = None
class TrackMetadata1:
"""Contains all of the metadata needed to tag the file.
Tags contained:

258
src/qobuz_client.py Normal file
View file

@ -0,0 +1,258 @@
import asyncio
import hashlib
import logging
import re
import time
from typing import AsyncGenerator, Optional
import aiohttp
from aiolimiter import AsyncLimiter
from .client import Client
from .config import Config
from .downloadable import BasicDownloadable, Downloadable
from .exceptions import (
AuthenticationError,
IneligibleError,
InvalidAppIdError,
InvalidAppSecretError,
MissingCredentials,
NonStreamable,
)
from .qobuz_spoofer import QobuzSpoofer
logger = logging.getLogger("streamrip")
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0"
)
QOBUZ_BASE_URL = "https://www.qobuz.com/api.json/0.2"
QOBUZ_FEATURED_KEYS = {
"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",
}
class QobuzClient(Client):
source = "qobuz"
max_quality = 4
def __init__(self, config: Config):
self.logged_in = False
self.config = config
self.session = aiohttp.ClientSession(headers={"User-Agent": DEFAULT_USER_AGENT})
rate_limit = config.session.downloads.requests_per_minute
self.rate_limiter = AsyncLimiter(rate_limit, 60) if rate_limit > 0 else None
self.secret: Optional[str] = None
async def login(self):
c = self.config.session.qobuz
if not c.email_or_userid or not c.password_or_token:
raise MissingCredentials
assert not self.logged_in, "Already logged in"
if not c.app_id or not c.secrets:
c.app_id, c.secrets = await self._get_app_id_and_secrets()
# write to file
self.config.file.qobuz.app_id = c.app_id
self.config.file.qobuz.secrets = c.secrets
self.config.file.set_modified()
self.session.headers.update({"X-App-Id": c.app_id})
self.secret = await self._get_valid_secret(c.secrets)
if c.use_auth_token:
params = {
"user_id": c.email_or_userid,
"user_auth_token": c.password_or_token,
"app_id": c.app_id,
}
else:
params = {
"email": c.email_or_userid,
"password": c.password_or_token,
"app_id": c.app_id,
}
resp = await self._api_request("user/login", params)
if resp.status == 401:
raise AuthenticationError(f"Invalid credentials from params {params}")
elif resp.status == 400:
logger.debug(resp)
raise InvalidAppIdError(f"Invalid app id from params {params}")
logger.info("Logged in to Qobuz")
resp_json = await resp.json()
if not resp_json["user"]["credential"]["parameters"]:
raise IneligibleError("Free accounts are not eligible to download tracks.")
uat = resp_json["user_auth_token"]
self.session.headers.update({"X-User-Auth-Token": uat})
# label = resp_json["user"]["credential"]["parameters"]["short_label"]
self.logged_in = True
async def get_metadata(self, item_id: str, media_type: str):
c = self.config.session.qobuz
params = {
"app_id": c.app_id,
f"{media_type}_id": item_id,
# Do these matter?
"limit": 500,
"offset": 0,
}
extras = {
"artist": "albums",
"playlist": "tracks",
"label": "albums",
}
if media_type in extras:
params.update({"extra": extras[media_type]})
logger.debug("request params: %s", params)
epoint = f"{media_type}/get"
response = await self._api_request(epoint, params)
resp_json = await response.json()
if response.status != 200:
raise Exception(f'Error fetching metadata. "{resp_json["message"]}"')
return resp_json
async def search(
self, query: str, media_type: str, limit: int = 500
) -> AsyncGenerator:
params = {
"query": query,
"limit": limit,
}
# TODO: move featured, favorites, and playlists into _api_get later
if media_type == "featured":
assert query in QOBUZ_FEATURED_KEYS, f'query "{query}" is invalid.'
params.update({"type": query})
del params["query"]
epoint = "album/getFeatured"
elif query == "user-favorites":
assert query in ("track", "artist", "album")
params.update({"type": f"{media_type}s"})
epoint = "favorite/getUserFavorites"
elif query == "user-playlists":
epoint = "playlist/getUserPlaylists"
else:
epoint = f"{media_type}/search"
return self._paginate(epoint, params)
async def get_downloadable(self, item_id: str, quality: int) -> Downloadable:
assert self.secret is not None and self.logged_in and 1 <= quality <= 4
resp = await self._request_file_url(item_id, quality, self.secret)
resp_json = await resp.json()
stream_url = resp_json.get("url")
if stream_url is None:
restrictions = resp_json["restrictions"]
if restrictions:
# Turn CamelCase code into a readable sentence
words = re.findall(r"([A-Z][a-z]+)", restrictions[0]["code"])
raise NonStreamable(
words[0] + " " + " ".join(map(str.lower, words[1:])) + "."
)
raise NonStreamable
return BasicDownloadable(stream_url)
async def _paginate(self, epoint: str, params: dict) -> AsyncGenerator[dict, None]:
response = await self._api_request(epoint, params)
page = await response.json()
logger.debug("Keys returned from _gen_pages: %s", ", ".join(page.keys()))
key = epoint.split("/")[0] + "s"
total = page.get(key, {})
total = total.get("total") or total.get("items")
if not total:
logger.debug("Nothing found from %s epoint", epoint)
return
limit = page.get(key, {}).get("limit", 500)
offset = page.get(key, {}).get("offset", 0)
params.update({"limit": limit})
yield page
while (offset + limit) < total:
offset += limit
params.update({"offset": offset})
response = await self._api_request(epoint, params)
yield await response.json()
async def _get_app_id_and_secrets(self) -> tuple[str, list[str]]:
spoofer = QobuzSpoofer()
return await spoofer.get_app_id_and_secrets()
async def _get_valid_secret(self, secrets: list[str]) -> str:
results = await asyncio.gather(
*[self._test_secret(secret) for secret in secrets]
)
working_secrets = [r for r in results if r is not None]
if len(working_secrets) == 0:
raise InvalidAppSecretError(secrets)
return working_secrets[0]
async def _test_secret(self, secret: str) -> Optional[str]:
resp = await self._request_file_url("19512574", 1, secret)
if resp.status == 400:
return None
resp.raise_for_status()
return secret
async def _request_file_url(
self, track_id: str, quality: int, secret: str
) -> aiohttp.ClientResponse:
unix_ts = time.time()
r_sig = f"trackgetFileUrlformat_id{quality}intentstreamtrack_id{track_id}{unix_ts}{secret}"
logger.debug("Raw request signature: %s", r_sig)
r_sig_hashed = hashlib.md5(r_sig.encode("utf-8")).hexdigest()
logger.debug("Hashed request signature: %s", r_sig_hashed)
params = {
"request_ts": unix_ts,
"request_sig": r_sig_hashed,
"track_id": track_id,
"format_id": quality,
"intent": "stream",
}
return await self._api_request("track/getFileUrl", params)
async def _api_request(self, epoint: str, params: dict) -> aiohttp.ClientResponse:
url = f"{QOBUZ_BASE_URL}/{epoint}"
if self.rate_limiter is not None:
async with self.rate_limiter:
async with self.session.get(url, params=params) as response:
return response
async with self.session.get(url, params=params) as response:
return response

23
src/soundcloud_client.py Normal file
View file

@ -0,0 +1,23 @@
from .client import Client
from .config import Config
from .downloadable import Downloadable
class SoundcloudClient(Client):
source = "soundcloud"
logged_in = False
def __init__(self, config: Config):
self.config = config.soundcloud
async def login(self):
client_id, app_version = self.config.client_id, self.config.app_version
pass
async def get_downloadable(self, track: dict, _) -> Downloadable:
pass
async def search(
self, query: str, media_type: str, limit: int = 50, offset: int = 0
):
pass

32
src/track.py Normal file
View file

@ -0,0 +1,32 @@
from dataclasses import dataclass
from .client import Client
from .config import Config
from .downloadable import Downloadable
from .media import Media, Pending
from .metadata import AlbumMetadata, TrackMetadata
@dataclass(slots=True)
class Track(Media):
meta: TrackMetadata
downloadable: Downloadable
config: Config
folder: str
@dataclass(slots=True)
class PendingTrack(Pending):
id: str
album: AlbumMetadata
client: Client
config: Config
folder: str
async def resolve(self) -> Track:
resp = await self.client.get_metadata(id, "track")
meta = TrackMetadata.from_resp(self.album, self.client.source, resp)
quality = getattr(self.config.session, self.client.source).quality
assert isinstance(quality, int)
downloadable = await self.client.get_downloadable(self.id, quality)
return Track(meta, downloadable, self.config, self.directory)

16
src/user_paths.py Normal file
View file

@ -0,0 +1,16 @@
import os
from pathlib import Path
from appdirs import user_config_dir
APPNAME = "streamrip"
APP_DIR = user_config_dir(APPNAME)
HOME = Path.home()
LOG_DIR = CACHE_DIR = CONFIG_DIR = APP_DIR
DEFAULT_CONFIG_PATH = os.path.join(CONFIG_DIR, "config.toml")
DB_PATH = os.path.join(LOG_DIR, "downloads.db")
FAILED_DB_PATH = os.path.join(LOG_DIR, "failed_downloads.db")
DOWNLOADS_DIR = os.path.join(HOME, "StreamripDownloads")

View file

@ -1,22 +1,4 @@
"""Various constant values that are used by RipCore."""
import os
import re
from pathlib import Path
from appdirs import user_config_dir
APPNAME = "streamrip"
APP_DIR = user_config_dir(APPNAME)
HOME = Path.home()
LOG_DIR = CACHE_DIR = CONFIG_DIR = APP_DIR
CONFIG_PATH = os.path.join(CONFIG_DIR, "config.toml")
DB_PATH = os.path.join(LOG_DIR, "downloads.db")
FAILED_DB_PATH = os.path.join(LOG_DIR, "failed_downloads.db")
DOWNLOADS_DIR = os.path.join(HOME, "StreamripDownloads")
URL_REGEX = re.compile(
r"https?://(?:www|open|play|listen)?\.?(qobuz|tidal|deezer)\.com(?:(?:/(album|artist|track|playlist|video|label))|(?:\/[-\w]+?))+\/([-\w]+)"

View file

@ -1,209 +0,0 @@
"""The clients that interact with the streaming service APIs."""
import base64
import binascii
import concurrent.futures
import hashlib
import json
import logging
import re
import time
from abc import ABC, abstractmethod
from typing import Any, Dict, Generator, Optional, Sequence, Tuple, Union
import deezer
from click import launch, secho
from Cryptodome.Cipher import AES
from rip.config import Config, QobuzConfig
from .constants import (
AGENT,
AVAILABLE_QUALITY_IDS,
DEEZER_BASE,
DEEZER_DL,
DEEZER_FORMATS,
QOBUZ_BASE,
QOBUZ_FEATURED_KEYS,
SOUNDCLOUD_BASE,
SOUNDCLOUD_USER_ID,
TIDAL_AUTH_URL,
TIDAL_BASE,
TIDAL_CLIENT_INFO,
TIDAL_MAX_Q,
)
from .exceptions import (
AuthenticationError,
IneligibleError,
InvalidAppIdError,
InvalidAppSecretError,
InvalidQuality,
MissingCredentials,
NonStreamable,
)
from .spoofbuz import Spoofer
from .utils import gen_threadsafe_session, get_quality, safe_get
logger = logging.getLogger("streamrip")
class Downloadable(ABC):
@abstractmethod
async def download(self, path: str):
raise NotImplemented
class BasicDownloadable(Downloadable):
"""Just downloads a URL."""
def __init__(self, url: str):
self.url = url
async def download(self, path: str) -> bool:
raise NotImplemented
class DeezerDownloadable(Downloadable):
def __init__(self, resp: dict):
self.resp = resp
async def download(self, path: str) -> bool:
raise NotImplemented
class TidalDownloadable(Downloadable):
def __init__(self, info: dict):
self.info = info
async def download(self, path: str) -> bool:
raise NotImplemented
class SoundcloudDownloadable(Downloadable):
def __init__(self, info: dict):
self.info = info
async def download(self, path: str) -> bool:
raise NotImplemented
class SearchResult(ABC):
pass
class QobuzClient:
source = "qobuz"
max_quality = 4
def __init__(self, config: Config):
self.logged_in = False
self.global_config = config
self.config: QobuzConfig = config.qobuz
self.session = None
async def login(self):
c = self.config
if not c.email_or_userid or not c.password_or_token:
raise MissingCredentials
assert not self.logged_in
if not c.app_id or not c.secrets:
c.app_id, c.secrets = await self._fetch_app_id_and_secrets()
self.global_config.set_modified()
self.session = SRSession(
headers={"User-Agent": AGENT, "X-App-Id": c.app_id},
requests_per_min=self.global_config.downloads.requests_per_minute,
)
await self._validate_secrets(c.secrets)
await self._api_login(c.use_auth_token, c.email_or_userid, c.password_or_token)
self.logged_in = True
async def get_metadata(self, item_id: str, media_type: str) -> Metadata:
pass
async def search(
self, query: str, media_type: str, limit: int = 500
) -> SearchResult:
pass
async def get_downloadable(self, item_id: str, quality: int) -> Downloadable:
pass
async def _fetch_app_id_and_secrets(self) -> tuple[str, list[str]]:
pass
class DeezerClient:
source = "deezer"
max_quality = 2
def __init__(self, config: Config):
self.client = deezer.Deezer()
self.logged_in = False
self.config = config.deezer
async def login(self):
arl = self.config.arl
if not arl:
raise MissingCredentials
success = self.client.login_via_arl(arl)
if not success:
raise AuthenticationError
self.logged_in = True
async def get_metadata(self, item_id: str, media_type: str) -> dict:
pass
async def search(
self, query: str, media_type: str, limit: int = 200
) -> SearchResult:
pass
async def get_downloadable(self, item_id: str, quality: int = 2) -> Downloadable:
pass
class SoundcloudClient:
source = "soundcloud"
logged_in = False
def __init__(self, config: Config):
self.config = config.soundcloud
async def login(self):
client_id, app_version = self.config.client_id, self.config.app_version
pass
async def get_downloadable(self, track: dict, _) -> Downloadable:
pass
async def search(
self, query: str, media_type: str, limit: int = 50, offset: int = 0
) -> SearchResult:
pass
class DeezloaderClient:
source = "deezer"
max_quality = 2
def __init__(self, config):
self.session = SRSession()
self.global_config = config
self.logged_in = True
async def search(
self, query: str, media_type: str, limit: int = 200
) -> SearchResult:
pass
async def login(self):
raise NotImplemented
async def get(self, item_id: str, media_type: str):
pass
async def get_downloadable(self, item_id: str, quality: int):
pass

View file

@ -75,33 +75,6 @@ def concat_audio_files(paths: List[str], out: str, ext: str):
concat_audio_files(outpaths, out, ext)
def safe_get(d: dict, *keys: Hashable, default=None):
"""Traverse dict layers safely.
Usage:
>>> d = {'foo': {'bar': 'baz'}}
>>> safe_get(d, 'baz')
None
>>> safe_get(d, 'foo', 'bar')
'baz'
:param d:
:type d: dict
:param keys:
:type keys: Hashable
:param default: the default value to use if a key isn't found
"""
curr = d
res = default
for key in keys:
res = curr.get(key, default)
if res == default or not hasattr(res, "__getitem__"):
return res
else:
curr = res
return res
def clean_filename(fn: str, restrict=False) -> str:
path = sanitize_filename(fn)
if restrict:
@ -373,7 +346,7 @@ def get_cover_urls(resp: dict, source: str) -> Optional[dict]:
if source == "qobuz":
cover_urls = resp["image"]
cover_urls["original"] = "org".join(cover_urls["large"].rsplit('600', 1))
cover_urls["original"] = "org".join(cover_urls["large"].rsplit("600", 1))
return cover_urls
if source == "tidal":