mirror of
https://github.com/nathom/streamrip.git
synced 2025-05-19 01:35:24 -04:00
Finish most of skeleton
This commit is contained in:
parent
b5a442c042
commit
34277a3c67
26 changed files with 2357 additions and 1791 deletions
1317
poetry.lock
generated
1317
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "streamrip"
|
name = "streamrip"
|
||||||
version = "1.9.7"
|
version = "2.0"
|
||||||
description = "A fast, all-in-one music ripper for Qobuz, Deezer, Tidal, and SoundCloud"
|
description = "A fast, all-in-one music ripper for Qobuz, Deezer, Tidal, and SoundCloud"
|
||||||
authors = ["nathom <nathanthomas707@gmail.com>"]
|
authors = ["nathom <nathanthomas707@gmail.com>"]
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
|
@ -8,10 +8,6 @@ readme = "README.md"
|
||||||
homepage = "https://github.com/nathom/streamrip"
|
homepage = "https://github.com/nathom/streamrip"
|
||||||
repository = "https://github.com/nathom/streamrip"
|
repository = "https://github.com/nathom/streamrip"
|
||||||
include = ["streamrip/config.toml"]
|
include = ["streamrip/config.toml"]
|
||||||
packages = [
|
|
||||||
{ include = "streamrip" },
|
|
||||||
{ include = "rip" },
|
|
||||||
]
|
|
||||||
keywords = ["hi-res", "free", "music", "download"]
|
keywords = ["hi-res", "free", "music", "download"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"License :: OSI Approved :: GNU General Public License (GPL)",
|
"License :: OSI Approved :: GNU General Public License (GPL)",
|
||||||
|
@ -19,13 +15,11 @@ classifiers = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
rip = "rip.cli:main"
|
rip = "src.cli:main"
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = ">=3.8 <4.0"
|
python = ">=3.8 <4.0"
|
||||||
requests = "^2.25.1"
|
|
||||||
mutagen = "^1.45.1"
|
mutagen = "^1.45.1"
|
||||||
click = "^8.0.1"
|
|
||||||
tqdm = "^4.61.1"
|
tqdm = "^4.61.1"
|
||||||
tomlkit = "^0.7.2"
|
tomlkit = "^0.7.2"
|
||||||
pathvalidate = "^2.4.1"
|
pathvalidate = "^2.4.1"
|
||||||
|
@ -35,12 +29,13 @@ windows-curses = {version = "^2.2.0", platform = 'win32|cygwin'}
|
||||||
Pillow = "^9.0.0"
|
Pillow = "^9.0.0"
|
||||||
deezer-py = "1.3.6"
|
deezer-py = "1.3.6"
|
||||||
pycryptodomex = "^3.10.1"
|
pycryptodomex = "^3.10.1"
|
||||||
cleo = {version = "1.0.0a4", allow-prereleases = true}
|
cleo = "^2.0"
|
||||||
appdirs = "^1.4.4"
|
appdirs = "^1.4.4"
|
||||||
m3u8 = "^0.9.0"
|
m3u8 = "^0.9.0"
|
||||||
aiofiles = "^0.7.0"
|
aiofiles = "^0.7"
|
||||||
aiohttp = "^3.7.4"
|
aiohttp = "^3.7"
|
||||||
aiodns = "^3.0.0"
|
aiodns = "^3.0.0"
|
||||||
|
aiolimiter = "^1.1.0"
|
||||||
|
|
||||||
[tool.poetry.urls]
|
[tool.poetry.urls]
|
||||||
"Bug Reports" = "https://github.com/nathom/streamrip/issues"
|
"Bug Reports" = "https://github.com/nathom/streamrip/issues"
|
||||||
|
@ -50,7 +45,7 @@ Sphinx = "^4.1.1"
|
||||||
autodoc = "^0.5.0"
|
autodoc = "^0.5.0"
|
||||||
types-click = "^7.1.2"
|
types-click = "^7.1.2"
|
||||||
types-Pillow = "^8.3.1"
|
types-Pillow = "^8.3.1"
|
||||||
black = "^21.7b0"
|
black = "^22"
|
||||||
isort = "^5.9.3"
|
isort = "^5.9.3"
|
||||||
flake8 = "^3.9.2"
|
flake8 = "^3.9.2"
|
||||||
setuptools = "^67.4.0"
|
setuptools = "^67.4.0"
|
||||||
|
|
845
rip/cli.py
845
rip/cli.py
|
@ -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
49
src/album.py
Normal 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
9
src/artist.py
Normal 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
852
src/cli.py
Normal 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
29
src/client.py
Normal 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
|
|
@ -1,10 +1,14 @@
|
||||||
"""A config class that manages arguments between the config file and CLI."""
|
"""A config class that manages arguments between the config file and CLI."""
|
||||||
|
|
||||||
|
import copy
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass
|
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")
|
logger = logging.getLogger("streamrip")
|
||||||
|
|
||||||
|
@ -201,7 +205,8 @@ class ThemeConfig:
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class Config:
|
class ConfigData:
|
||||||
|
toml: TOMLDocument
|
||||||
downloads: DownloadsConfig
|
downloads: DownloadsConfig
|
||||||
|
|
||||||
qobuz: QobuzConfig
|
qobuz: QobuzConfig
|
||||||
|
@ -224,7 +229,7 @@ class Config:
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_toml(cls, toml_str: str):
|
def from_toml(cls, toml_str: str):
|
||||||
# TODO: handle the mistake where Windows people forget to escape backslash
|
# 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
|
if toml["misc"]["version"] != CURRENT_CONFIG_VERSION: # type: ignore
|
||||||
raise Exception("Need to update config")
|
raise Exception("Need to update config")
|
||||||
|
|
||||||
|
@ -243,6 +248,7 @@ class Config:
|
||||||
database = DatabaseConfig(**toml["database"]) # type: ignore
|
database = DatabaseConfig(**toml["database"]) # type: ignore
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
|
toml=toml,
|
||||||
downloads=downloads,
|
downloads=downloads,
|
||||||
qobuz=qobuz,
|
qobuz=qobuz,
|
||||||
tidal=tidal,
|
tidal=tidal,
|
||||||
|
@ -265,3 +271,27 @@ class Config:
|
||||||
|
|
||||||
def set_modified(self):
|
def set_modified(self):
|
||||||
self._modified = True
|
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))
|
|
@ -242,8 +242,8 @@ class OPUS(Converter):
|
||||||
container = "opus"
|
container = "opus"
|
||||||
default_ffmpeg_arg = "-b:a 128k" # Transparent
|
default_ffmpeg_arg = "-b:a 128k" # Transparent
|
||||||
|
|
||||||
def get_quality_arg(self, rate: int) -> str:
|
def get_quality_arg(self, _: int) -> str:
|
||||||
pass
|
return ""
|
||||||
|
|
||||||
|
|
||||||
class AAC(Converter):
|
class AAC(Converter):
|
||||||
|
@ -260,5 +260,5 @@ class AAC(Converter):
|
||||||
container = "m4a"
|
container = "m4a"
|
||||||
default_ffmpeg_arg = "-b:a 256k"
|
default_ffmpeg_arg = "-b:a 256k"
|
||||||
|
|
||||||
def get_quality_arg(self, rate: int) -> str:
|
def get_quality_arg(self, _: int) -> str:
|
||||||
pass
|
return ""
|
|
@ -15,14 +15,6 @@ import requests
|
||||||
from click import secho, style
|
from click import secho, style
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
|
||||||
from streamrip.clients import (
|
|
||||||
Client,
|
|
||||||
DeezerClient,
|
|
||||||
DeezloaderClient,
|
|
||||||
QobuzClient,
|
|
||||||
SoundCloudClient,
|
|
||||||
TidalClient,
|
|
||||||
)
|
|
||||||
from streamrip.constants import MEDIA_TYPES
|
from streamrip.constants import MEDIA_TYPES
|
||||||
from streamrip.exceptions import (
|
from streamrip.exceptions import (
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
|
@ -47,20 +39,26 @@ from streamrip.media import (
|
||||||
from streamrip.utils import TQDM_DEFAULT_THEME, set_progress_bar_theme
|
from streamrip.utils import TQDM_DEFAULT_THEME, set_progress_bar_theme
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
|
from .clients import (
|
||||||
|
Client,
|
||||||
|
DeezerClient,
|
||||||
|
DeezloaderClient,
|
||||||
|
QobuzClient,
|
||||||
|
SoundcloudClient,
|
||||||
|
TidalClient,
|
||||||
|
)
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .constants import (
|
from .exceptions import DeezloaderFallback
|
||||||
CONFIG_PATH,
|
from .user_paths import DB_PATH, FAILED_DB_PATH
|
||||||
DB_PATH,
|
from .utils import extract_deezer_dynamic_link, extract_interpreter_url
|
||||||
|
from .validation_regexps import (
|
||||||
DEEZER_DYNAMIC_LINK_REGEX,
|
DEEZER_DYNAMIC_LINK_REGEX,
|
||||||
FAILED_DB_PATH,
|
|
||||||
LASTFM_URL_REGEX,
|
LASTFM_URL_REGEX,
|
||||||
QOBUZ_INTERPRETER_URL_REGEX,
|
QOBUZ_INTERPRETER_URL_REGEX,
|
||||||
SOUNDCLOUD_URL_REGEX,
|
SOUNDCLOUD_URL_REGEX,
|
||||||
URL_REGEX,
|
URL_REGEX,
|
||||||
YOUTUBE_URL_REGEX,
|
YOUTUBE_URL_REGEX,
|
||||||
)
|
)
|
||||||
from .exceptions import DeezloaderFallback
|
|
||||||
from .utils import extract_deezer_dynamic_link, extract_interpreter_url
|
|
||||||
|
|
||||||
logger = logging.getLogger("streamrip")
|
logger = logging.getLogger("streamrip")
|
||||||
|
|
||||||
|
@ -87,57 +85,33 @@ DB_PATH_MAP = {"downloads": DB_PATH, "failed_downloads": FAILED_DB_PATH}
|
||||||
|
|
||||||
|
|
||||||
class RipCore(list):
|
class RipCore(list):
|
||||||
"""RipCore."""
|
def __init__(self, config: Config):
|
||||||
|
|
||||||
clients = {
|
|
||||||
"qobuz": QobuzClient(),
|
|
||||||
"tidal": TidalClient(),
|
|
||||||
"deezer": DeezerClient(),
|
|
||||||
"soundcloud": SoundCloudClient(),
|
|
||||||
"deezloader": DeezloaderClient(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
config: Optional[Config] = None,
|
|
||||||
):
|
|
||||||
"""Create a RipCore object.
|
"""Create a RipCore object.
|
||||||
|
|
||||||
:param config:
|
:param config:
|
||||||
:type config: Optional[Config]
|
:type config: Optional[Config]
|
||||||
"""
|
"""
|
||||||
self.config: Config
|
self.config = config
|
||||||
if config is None:
|
self.clients: dict[str, Client] = {
|
||||||
self.config = Config(CONFIG_PATH)
|
"qobuz": QobuzClient(config),
|
||||||
else:
|
"tidal": TidalClient(config),
|
||||||
self.config = config
|
"deezer": DeezerClient(config),
|
||||||
|
"soundcloud": SoundcloudClient(config),
|
||||||
|
"deezloader": DeezloaderClient(config),
|
||||||
|
}
|
||||||
|
|
||||||
if (theme := self.config.file["theme"]["progress_bar"]) != TQDM_DEFAULT_THEME:
|
c = self.config.session
|
||||||
set_progress_bar_theme(theme.lower())
|
|
||||||
|
|
||||||
def get_db(db_type: str) -> db.Database:
|
theme = c.theme.progress_bar
|
||||||
db_settings = self.config.session["database"]
|
set_progress_bar_theme(theme)
|
||||||
db_class = db.CLASS_MAP[db_type]
|
|
||||||
|
|
||||||
if db_settings[db_type]["enabled"] and db_settings.get("enabled", True):
|
self.db = db.Downloads(
|
||||||
default_db_path = DB_PATH_MAP[db_type]
|
c.database.downloads_path, dummy=not c.database.downloads_enabled
|
||||||
path = db_settings[db_type]["path"]
|
)
|
||||||
|
self.failed = db.FailedDownloads(
|
||||||
if path:
|
c.database.failed_downloads_path,
|
||||||
database = db_class(path)
|
dummy=not c.database.failed_downloads_enabled,
|
||||||
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")
|
|
||||||
|
|
||||||
def handle_urls(self, urls):
|
def handle_urls(self, urls):
|
||||||
"""Download a url.
|
"""Download a url.
|
||||||
|
@ -469,7 +443,7 @@ class RipCore(list):
|
||||||
|
|
||||||
if soundcloud_urls:
|
if soundcloud_urls:
|
||||||
soundcloud_client = self.get_client("soundcloud")
|
soundcloud_client = self.get_client("soundcloud")
|
||||||
assert isinstance(soundcloud_client, SoundCloudClient) # for typing
|
assert isinstance(soundcloud_client, SoundcloudClient) # for typing
|
||||||
|
|
||||||
# TODO: Make this async
|
# TODO: Make this async
|
||||||
soundcloud_items = (
|
soundcloud_items = (
|
|
@ -182,6 +182,3 @@ class FailedDownloads(Database):
|
||||||
"media_type": ["text"],
|
"media_type": ["text"],
|
||||||
"id": ["text", "unique"],
|
"id": ["text", "unique"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
CLASS_MAP = {db.name: db for db in (Downloads, FailedDownloads)}
|
|
28
src/deezer_client.py
Normal file
28
src/deezer_client.py
Normal 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
23
src/deezloader_client.py
Normal 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
79
src/downloadable.py
Normal 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
32
src/media.py
Normal 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
|
|
@ -5,7 +5,8 @@ from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from collections import OrderedDict
|
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 (
|
from .constants import (
|
||||||
ALBUM_KEYS,
|
ALBUM_KEYS,
|
||||||
|
@ -23,7 +24,148 @@ from .utils import get_cover_urls, get_quality_id, safe_get
|
||||||
logger = logging.getLogger("streamrip")
|
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:
|
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.
|
"""Contains all of the metadata needed to tag the file.
|
||||||
|
|
||||||
Tags contained:
|
Tags contained:
|
258
src/qobuz_client.py
Normal file
258
src/qobuz_client.py
Normal 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
23
src/soundcloud_client.py
Normal 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
32
src/track.py
Normal 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
16
src/user_paths.py
Normal 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")
|
|
@ -1,22 +1,4 @@
|
||||||
"""Various constant values that are used by RipCore."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
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(
|
URL_REGEX = re.compile(
|
||||||
r"https?://(?:www|open|play|listen)?\.?(qobuz|tidal|deezer)\.com(?:(?:/(album|artist|track|playlist|video|label))|(?:\/[-\w]+?))+\/([-\w]+)"
|
r"https?://(?:www|open|play|listen)?\.?(qobuz|tidal|deezer)\.com(?:(?:/(album|artist|track|playlist|video|label))|(?:\/[-\w]+?))+\/([-\w]+)"
|
|
@ -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
|
|
|
@ -75,33 +75,6 @@ def concat_audio_files(paths: List[str], out: str, ext: str):
|
||||||
concat_audio_files(outpaths, out, ext)
|
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:
|
def clean_filename(fn: str, restrict=False) -> str:
|
||||||
path = sanitize_filename(fn)
|
path = sanitize_filename(fn)
|
||||||
if restrict:
|
if restrict:
|
||||||
|
@ -373,7 +346,7 @@ def get_cover_urls(resp: dict, source: str) -> Optional[dict]:
|
||||||
|
|
||||||
if source == "qobuz":
|
if source == "qobuz":
|
||||||
cover_urls = resp["image"]
|
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
|
return cover_urls
|
||||||
|
|
||||||
if source == "tidal":
|
if source == "tidal":
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue