Add prompter and soundcloud client

This commit is contained in:
Nathan Thomas 2023-10-04 10:52:07 -07:00
parent 34277a3c67
commit 4e2709468b
6 changed files with 282 additions and 47 deletions

View file

@ -2,11 +2,19 @@
import logging import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Optional, Union
import aiohttp
import aiolimiter
from .downloadable import Downloadable from .downloadable import Downloadable
logger = logging.getLogger("streamrip") logger = logging.getLogger("streamrip")
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0"
)
class Client(ABC): class Client(ABC):
source: str source: str
@ -17,7 +25,7 @@ class Client(ABC):
raise NotImplemented raise NotImplemented
@abstractmethod @abstractmethod
async def get_metadata(self, item_id, media_type): async def get_metadata(self, item: dict[str, Union[str, int, float]], media_type):
raise NotImplemented raise NotImplemented
@abstractmethod @abstractmethod
@ -27,3 +35,25 @@ class Client(ABC):
@abstractmethod @abstractmethod
async def get_downloadable(self, item_id: str, quality: int) -> Downloadable: async def get_downloadable(self, item_id: str, quality: int) -> Downloadable:
raise NotImplemented raise NotImplemented
@staticmethod
def get_rate_limiter(
requests_per_min: int,
) -> Optional[aiolimiter.AsyncLimiter]:
return (
aiolimiter.AsyncLimiter(requests_per_min, 60)
if requests_per_min > 0
else None
)
@staticmethod
def get_session(headers: Optional[dict] = None) -> aiohttp.ClientSession:
if headers is None:
headers = {}
return aiohttp.ClientSession(
headers={"User-Agent": DEFAULT_USER_AGENT}, **headers
)
class NonStreamable(Exception):
pass

View file

@ -3,9 +3,7 @@
import copy 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
from tomlkit.api import dumps, parse from tomlkit.api import dumps, parse
from tomlkit.toml_document import TOMLDocument from tomlkit.toml_document import TOMLDocument
@ -272,6 +270,7 @@ class ConfigData:
def set_modified(self): def set_modified(self):
self._modified = True self._modified = True
@property
def modified(self): def modified(self):
return self._modified return self._modified
@ -289,7 +288,7 @@ class Config:
self.session: ConfigData = copy.deepcopy(self.file) self.session: ConfigData = copy.deepcopy(self.file)
def save_file(self): def save_file(self):
if not self.file.modified(): if not self.file.modified:
return return
with open(self._path, "w") as toml_file: with open(self._path, "w") as toml_file:

View file

@ -1,11 +1,12 @@
"""The stuff that ties everything together for the CLI to use.""" """The stuff that ties everything together for the CLI to use."""
import asyncio
import concurrent.futures import concurrent.futures
import html import html
import logging import logging
import os import os
import re import re
import threading from abc import ABC, abstractmethod
from getpass import getpass from getpass import getpass
from hashlib import md5 from hashlib import md5
from string import Formatter from string import Formatter
@ -84,6 +85,101 @@ DB_PATH_MAP = {"downloads": DB_PATH, "failed_downloads": FAILED_DB_PATH}
# ---------------------------------------------- # # ---------------------------------------------- #
class CredentialPrompter(ABC):
def __init__(self, config: Config):
self.config = config
@abstractmethod
def has_creds(self) -> bool:
raise NotImplemented
@abstractmethod
def prompt(self):
"""Prompt for credentials in the appropriate way,
and save them to the configuration."""
raise NotImplemented
@abstractmethod
def save(self):
"""Save current config to file"""
raise NotImplemented
class QobuzPrompter(CredentialPrompter):
def has_creds(self) -> bool:
c = self.config.session.qobuz
return c.email_or_userid != "" and c.password_or_token != ""
def prompt(self):
secho("Enter Qobuz email:", fg="green")
email = input()
secho(
"Enter Qobuz password (will not show on screen):",
fg="green",
)
pwd = md5(getpass(prompt="").encode("utf-8")).hexdigest()
secho(
f'Credentials saved to config file at "{self.config._path}"',
fg="green",
)
c = self.config.session.qobuz
c.use_auth_token = False
c.email_or_userid = email
c.password_or_token = pwd
def save(self):
c = self.config.session.qobuz
cf = self.config.file.qobuz
cf.use_auth_token = False
cf.email_or_userid = c.email_or_userid
cf.password_or_token = c.password_or_token
self.config.file.set_modified()
class TidalPrompter(CredentialPrompter):
def prompt(self):
# TODO: needs to be moved from TidalClient to here
raise NotImplemented
class DeezerPrompter(CredentialPrompter):
def has_creds(self):
c = self.config.session.deezer
return c.arl != ""
def prompt(self):
secho(
"If you're not sure how to find the ARL cookie, see the instructions at ",
nl=False,
dim=True,
)
secho(
"https://github.com/nathom/streamrip/wiki/Finding-your-Deezer-ARL-Cookie",
underline=True,
fg="blue",
)
c = self.config.session.deezer
c.arl = input(style("ARL: ", fg="green"))
def save(self):
c = self.config.session.deezer
cf = self.config.file.deezer
cf.arl = c.arl
self.config.file.set_modified()
secho(
f'Credentials saved to config file at "{self.config._path}"',
fg="green",
)
PROMPTERS = {
"qobuz": QobuzPrompter,
"deezer": DeezerPrompter,
"tidal": TidalPrompter,
}
class RipCore(list): class RipCore(list):
def __init__(self, config: Config): def __init__(self, config: Config):
"""Create a RipCore object. """Create a RipCore object.
@ -166,7 +262,7 @@ class RipCore(list):
:param item_id: :param item_id:
:type item_id: str :type item_id: str
""" """
client = self.get_client(source) client = self.get_client_and_log_in(source)
if media_type not in MEDIA_TYPES: if media_type not in MEDIA_TYPES:
if "playlist" in media_type: # for SoundCloud if "playlist" in media_type: # for SoundCloud
@ -320,7 +416,7 @@ class RipCore(list):
""" """
self.extend(self.search("qobuz", featured_list, "featured", limit=max_items)) self.extend(self.search("qobuz", featured_list, "featured", limit=max_items))
def get_client(self, source: str) -> Client: def get_client_and_log_in(self, source: str) -> Client:
"""Get a client given the source and log in. """Get a client given the source and log in.
:param source: :param source:
@ -336,14 +432,14 @@ class RipCore(list):
return client return client
def login(self, client): async def login(self, client):
"""Log into a client, if applicable. """Log into a client, if applicable.
:param client: :param client:
""" """
creds = self.config.creds(client.source) c = self.config.session
if client.source == "deezer" and creds["arl"] == "": if client.source == "deezer" and c.deezer.arl == "":
if self.config.session["deezer"]["deezloader_warnings"]: if c.deezer.deezloader_warnings:
secho( secho(
"Falling back to Deezloader (unstable). If you have a subscription, run ", "Falling back to Deezloader (unstable). If you have a subscription, run ",
nl=False, nl=False,
@ -355,23 +451,18 @@ class RipCore(list):
while True: while True:
try: try:
client.login(**creds) await client.login()
break break
except AuthenticationError: except AuthenticationError:
secho("Invalid credentials, try again.", fg="yellow") secho("Invalid credentials, try again.", fg="yellow")
self.prompt_creds(client.source) self.prompt_and_set_credentials(client.source)
creds = self.config.creds(client.source)
except MissingCredentials: except MissingCredentials:
logger.debug("Credentials are missing. Prompting..") if client.source == "qobuz":
get_tokens = threading.Thread( get_tokens = asyncio.create_task(client._get_app_id_and_secrets())
target=client._get_app_id_and_secrets, daemon=True self.prompt_and_set_credentials(client.source)
) await get_tokens
get_tokens.start() else:
self.prompt_and_set_credentials(client.source)
self.prompt_creds(client.source)
creds = self.config.creds(client.source)
get_tokens.join()
if ( if (
client.source == "qobuz" client.source == "qobuz"
@ -442,7 +533,7 @@ class RipCore(list):
soundcloud_urls = SOUNDCLOUD_URL_REGEX.findall(url) soundcloud_urls = SOUNDCLOUD_URL_REGEX.findall(url)
if soundcloud_urls: if soundcloud_urls:
soundcloud_client = self.get_client("soundcloud") soundcloud_client = self.get_client_and_log_in("soundcloud")
assert isinstance(soundcloud_client, SoundcloudClient) # for typing assert isinstance(soundcloud_client, SoundcloudClient) # for typing
# TODO: Make this async # TODO: Make this async
@ -550,7 +641,7 @@ class RipCore(list):
secho(f"Fetching playlist at {purl}", fg="blue") secho(f"Fetching playlist at {purl}", fg="blue")
title, queries = self.get_lastfm_playlist(purl) title, queries = self.get_lastfm_playlist(purl)
pl = Playlist(client=self.get_client(lastfm_source), name=title) pl = Playlist(client=self.get_client_and_log_in(lastfm_source), name=title)
creator_match = user_regex.search(purl) creator_match = user_regex.search(purl)
if creator_match is not None: if creator_match is not None:
pl.creator = creator_match.group(1) pl.creator = creator_match.group(1)
@ -614,7 +705,7 @@ class RipCore(list):
""" """
logger.debug("searching for %s", query) logger.debug("searching for %s", query)
client = self.get_client(source) client = self.get_client_and_log_in(source)
if isinstance(client, DeezloaderClient) and media_type == "featured": if isinstance(client, DeezloaderClient) and media_type == "featured":
raise IneligibleError( raise IneligibleError(
@ -845,12 +936,25 @@ class RipCore(list):
path = self.config.session["downloads"]["folder"] path = self.config.session["downloads"]["folder"]
return os.path.join(path, source.capitalize()) return os.path.join(path, source.capitalize())
def prompt_creds(self, source: str): async def prompt_and_set_credentials(self, source: str):
"""Prompt the user for credentials. """Prompt the user for credentials.
:param source: :param source:
:type source: str :type source: str
""" """
prompter = PROMPTERS[source]
client = self.clients[source]
while True:
prompter.prompt()
try:
await client.login()
break
except AuthenticationError:
secho("Invalid credentials, try again.", fg="yellow")
except MissingCredentials:
secho("Credentials not found, try again.", fg="yellow")
self.prompt_and_set_credentials(client.source)
if source == "qobuz": if source == "qobuz":
secho("Enter Qobuz email:", fg="green") secho("Enter Qobuz email:", fg="green")
self.config.file[source]["email"] = input() self.config.file[source]["email"] = input()

View file

@ -41,7 +41,7 @@ class Downloadable(ABC):
class BasicDownloadable(Downloadable): class BasicDownloadable(Downloadable):
"""Just downloads a URL.""" """Just downloads a URL."""
def __init__(self, session, url: str): def __init__(self, session: aiohttp.ClientSession, url: str):
self.session = session self.session = session
self.url = url self.url = url
@ -59,7 +59,7 @@ class DeezerDownloadable(Downloadable):
def __init__(self, resp: dict): def __init__(self, resp: dict):
self.resp = resp self.resp = resp
async def _download(self, path: str) -> bool: async def _download(self, path: str):
raise NotImplemented raise NotImplemented
@ -67,7 +67,7 @@ class TidalDownloadable(Downloadable):
def __init__(self, info: dict): def __init__(self, info: dict):
self.info = info self.info = info
async def _download(self, path: str) -> bool: async def _download(self, path: str):
raise NotImplemented raise NotImplemented
@ -75,5 +75,5 @@ class SoundcloudDownloadable(Downloadable):
def __init__(self, info: dict): def __init__(self, info: dict):
self.info = info self.info = info
async def _download(self, path: str) -> bool: async def _download(self, path: str):
raise NotImplemented raise NotImplemented

View file

@ -8,7 +8,7 @@ from typing import AsyncGenerator, Optional
import aiohttp import aiohttp
from aiolimiter import AsyncLimiter from aiolimiter import AsyncLimiter
from .client import Client from .client import DEFAULT_USER_AGENT, Client
from .config import Config from .config import Config
from .downloadable import BasicDownloadable, Downloadable from .downloadable import BasicDownloadable, Downloadable
from .exceptions import ( from .exceptions import (
@ -23,9 +23,6 @@ from .qobuz_spoofer import QobuzSpoofer
logger = logging.getLogger("streamrip") 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_BASE_URL = "https://www.qobuz.com/api.json/0.2"
QOBUZ_FEATURED_KEYS = { QOBUZ_FEATURED_KEYS = {
@ -54,9 +51,10 @@ class QobuzClient(Client):
def __init__(self, config: Config): def __init__(self, config: Config):
self.logged_in = False self.logged_in = False
self.config = config self.config = config
self.session = aiohttp.ClientSession(headers={"User-Agent": DEFAULT_USER_AGENT}) self.session = self.get_session()
rate_limit = config.session.downloads.requests_per_minute self.rate_limiter = self.get_rate_limiter(
self.rate_limiter = AsyncLimiter(rate_limit, 60) if rate_limit > 0 else None config.session.downloads.requests_per_minute
)
self.secret: Optional[str] = None self.secret: Optional[str] = None
async def login(self): async def login(self):
@ -185,7 +183,7 @@ class QobuzClient(Client):
) )
raise NonStreamable raise NonStreamable
return BasicDownloadable(stream_url) return BasicDownloadable(self.session, stream_url)
async def _paginate(self, epoint: str, params: dict) -> AsyncGenerator[dict, None]: async def _paginate(self, epoint: str, params: dict) -> AsyncGenerator[dict, None]:
response = await self._api_request(epoint, params) response = await self._api_request(epoint, params)

View file

@ -1,6 +1,11 @@
from .client import Client import re
from .client import Client, NonStreamable
from .config import Config from .config import Config
from .downloadable import Downloadable from .downloadable import SoundcloudDownloadable
BASE = "https://api-v2.soundcloud.com"
SOUNDCLOUD_USER_ID = "672320-86895-162383-801513"
class SoundcloudClient(Client): class SoundcloudClient(Client):
@ -8,16 +13,115 @@ class SoundcloudClient(Client):
logged_in = False logged_in = False
def __init__(self, config: Config): def __init__(self, config: Config):
self.config = config.soundcloud self.global_config = config
self.config = config.session.soundcloud
self.session = self.get_session()
self.rate_limiter = self.get_rate_limiter(
config.session.downloads.requests_per_minute
)
async def login(self): async def login(self):
client_id, app_version = self.config.client_id, self.config.app_version client_id, app_version = self.config.client_id, self.config.app_version
pass if not client_id or not app_version or not self._announce():
client_id, app_version = await self._refresh_tokens()
async def get_downloadable(self, track: dict, _) -> Downloadable: # update file and session configs and save to disk
pass c = self.global_config.file.soundcloud
self.config.client_id = c.client_id = client_id
self.config.client_id = c.app_version = app_version
self.global_config.file.set_modified()
async def _announce(self):
resp = await self._api_request("announcements")
return resp.status == 200
async def _refresh_tokens(self) -> tuple[str, str]:
"""Return a valid client_id, app_version pair."""
STOCK_URL = "https://soundcloud.com/"
async with self.session.get(STOCK_URL) as resp:
page_text = await resp.text(encoding="utf-8")
*_, client_id_url_match = re.finditer(
r"<script\s+crossorigin\s+src=\"([^\"]+)\"", page_text
)
if client_id_url_match is None:
raise Exception("Could not find client ID in %s" % STOCK_URL)
client_id_url = client_id_url_match.group(1)
app_version_match = re.search(
r'<script>window\.__sc_version="(\d+)"</script>', page_text
)
if app_version_match is None:
raise Exception("Could not find app version in %s" % client_id_url_match)
app_version = app_version_match.group(1)
async with self.session.get(client_id_url) as resp:
page_text2 = await resp.text(encoding="utf-8")
client_id_match = re.search(r'client_id:\s*"(\w+)"', page_text2)
assert client_id_match is not None
client_id = client_id_match.group(1)
return client_id, app_version
async def get_downloadable(self, item: dict, _) -> SoundcloudDownloadable:
if not item["streamable"] or item["policy"] == "BLOCK":
raise NonStreamable(item)
if item["downloadable"] and item["has_downloads_left"]:
resp = await self._api_request(f"tracks/{item['id']}/download")
resp_json = await resp.json()
return SoundcloudDownloadable(
{"url": resp_json["redirectUri"], "type": "original"}
)
else:
url = None
for tc in item["media"]["transcodings"]:
fmt = tc["format"]
if fmt["protocol"] == "hls" and fmt["mime_type"] == "audio/mpeg":
url = tc["url"]
break
assert url is not None
resp = await self._request(url)
resp_json = await resp.json()
return SoundcloudDownloadable({"url": resp_json["url"], "type": "mp3"})
async def search( async def search(
self, query: str, media_type: str, limit: int = 50, offset: int = 0 self, query: str, media_type: str, limit: int = 50, offset: int = 0
): ):
pass params = {
"q": query,
"facet": "genre",
"user_id": SOUNDCLOUD_USER_ID,
"limit": limit,
"offset": offset,
"linked_partitioning": "1",
}
resp = await self._api_request(f"search/{media_type}s", params=params)
return await resp.json()
async def _api_request(self, path, params=None, headers=None):
url = f"{BASE}/{path}"
return await self._request(url, params=params, headers=headers)
async def _request(self, url, params=None, headers=None):
c = self.config
_params = {
"client_id": c.client_id,
"app_version": c.app_version,
"app_locale": "en",
}
if params is not None:
_params.update(params)
async with self.session.get(url, params=_params, headers=headers) as resp:
return resp
async def _resolve_url(self, url: str) -> dict:
resp = await self._api_request(f"resolve?url={url}")
return await resp.json()