More docs

This commit is contained in:
Nathan Thomas 2024-01-24 13:20:20 -08:00
parent 1c2bd2545c
commit 963881ca27
10 changed files with 270 additions and 31 deletions

View File

@ -69,7 +69,7 @@ build-backend = "poetry.core.masonry.api"
[tool.ruff.lint]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
select = ["E4", "E7", "E9", "F", "I", "ASYNC", "N", "RUF", "ERA001"]
select = ["E4", "E7", "E9", "F", "I", "ASYNC", "N", "RUF", "ERA001", "D"]
ignore = []
# Allow fix for all enabled rules (when `--fix`) is provided.

View File

@ -22,7 +22,8 @@ logging.captureWarnings(True)
class DeezerClient(Client):
"""Client to handle deezer API. Does not do rate limiting.
Attributes:
Attributes
----------
global_config: Entire config object
client: client from deezer py used for API requests
logged_in: True if logged in

View File

@ -242,7 +242,6 @@ class TidalDownloadable(Downloadable):
:param out_path:
:param encryption_key:
"""
# Do not change this
master_key = "UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754="

View File

@ -1,3 +1,37 @@
"""Module providing a QobuzClient class for interacting with the Qobuz API.
The QobuzClient class extends the Client class and includes methods for
authentication, metadata retrieval, search, and downloading content from Qobuz.
Classes:
- QobuzClient: Main class for interacting with the Qobuz API.
Usage:
Example usage of the QobuzClient class:
```python
from qobuz_client import QobuzClient
# Initialize the QobuzClient with a configuration object
qobuz_client = QobuzClient(config)
# Log in to the Qobuz API
await qobuz_client.login()
# Retrieve metadata for a track
metadata = await qobuz_client.get_metadata("123456", "track")
# Search for albums by an artist
search_results = await qobuz_client.search("artist", "John Doe", limit=5)
# Get user favorites for tracks
user_favorites = await qobuz_client.get_user_favorites("track", limit=10)
# Download a track
downloadable = await qobuz_client.get_downloadable("789012", quality=3)
await downloadable.download("output_path")
```
"""
import asyncio
import base64
import hashlib
@ -64,6 +98,7 @@ class QobuzSpoofer:
self.session = None
async def get_app_id_and_secrets(self) -> tuple[str, list[str]]:
"""Request the relevant pages and return app ID and secrets."""
assert self.session is not None
async with self.session.get("https://play.qobuz.com/login") as req:
login_page = await req.text()
@ -124,20 +159,57 @@ class QobuzSpoofer:
return app_id, secrets_list
async def __aenter__(self):
"""Enter context manager and create async client session."""
self.session = aiohttp.ClientSession()
return self
async def __aexit__(self, *_):
"""Close client session on context manager exit."""
if self.session is not None:
await self.session.close()
self.session = None
class QobuzClient(Client):
"""QobuzClient class for interacting with the Qobuz API.
Attributes
----------
source (str): The source identifier for Qobuz.
max_quality (int): The maximum quality level supported by Qobuz.
Methods
-------
__init__(self, config: Config): Initialize the QobuzClient instance.
login(self): Log in to the Qobuz API.
get_metadata(self, item_id: str, media_type: str): Get metadata for a specified item.
get_label(self, label_id: str) -> dict: Get details for a label.
search(self, media_type: str, query: str, limit: int = 500) -> list[dict]: Search for items on Qobuz.
get_featured(self, query, limit: int = 500) -> list[dict]: Get featured items on Qobuz.
get_user_favorites(self, media_type: str, limit: int = 500) -> list[dict]: Get user favorites for a media type.
get_user_playlists(self, limit: int = 500) -> list[dict]: Get user playlists on Qobuz.
get_downloadable(self, item_id: str, quality: int) -> Downloadable: Get downloadable content details.
Private Methods
_paginate(self, epoint: str, params: dict, limit: int = 500) -> list[dict]: Paginate search results.
_get_app_id_and_secrets(self) -> tuple[str, list[str]]: Get Qobuz app ID and secrets.
_get_valid_secret(self, secrets: list[str]) -> str: Get a valid secret for authentication.
_test_secret(self, secret: str) -> Optional[str]: Test the validity of a secret.
_request_file_url(self, track_id: str, quality: int, secret: str) -> tuple[int, dict]: Request file URL for downloading.
_api_request(self, epoint: str, params: dict) -> tuple[int, dict]: Make a request to the Qobuz API.
get_quality(quality: int): Map the quality level to Qobuz format.
"""
source = "qobuz"
max_quality = 4
def __init__(self, config: Config):
"""Initialize a new QobuzClient instance.
Args:
----
config (Config): Configuration object containing session details.
"""
self.logged_in = False
self.config = config
self.rate_limiter = self.get_rate_limiter(
@ -146,6 +218,15 @@ class QobuzClient(Client):
self.secret: Optional[str] = None
async def login(self):
"""Log in to the Qobuz API.
Raises
------
MissingCredentialsError: If email/user ID or password/token is missing.
AuthenticationError: If invalid credentials are provided.
InvalidAppIdError: If the app ID is invalid.
IneligibleError: If the user has a free account that is not eligible for downloading tracks.
"""
self.session = await self.get_session()
c = self.config.session.qobuz
if not c.email_or_userid or not c.password_or_token:
@ -198,6 +279,17 @@ class QobuzClient(Client):
self.logged_in = True
async def get_metadata(self, item_id: str, media_type: str):
"""Get metadata for a specified item.
Args:
----
item_id (str): The ID of the item.
media_type (str): The type of media (e.g., artist, album, track).
Raises:
------
NonStreamableError: If there is an error fetching metadata.
"""
if media_type == "label":
return await self.get_label(item_id)
@ -233,6 +325,16 @@ class QobuzClient(Client):
return resp
async def get_label(self, label_id: str) -> dict:
"""Get details for a label.
Args:
----
label_id (str): The ID of the label.
Returns:
-------
dict: Details of the label.
"""
c = self.config.session.qobuz
page_limit = 500
params = {
@ -273,6 +375,18 @@ class QobuzClient(Client):
return label_resp
async def search(self, media_type: str, query: str, limit: int = 500) -> list[dict]:
"""Search for items on Qobuz.
Args:
----
media_type (str): The type of media to search for (e.g., artist, album, track, playlist).
query (str): The search query.
limit (int): The maximum number of results to retrieve.
Returns:
-------
list[dict]: List of search results.
"""
if media_type not in ("artist", "album", "track", "playlist"):
raise Exception(f"{media_type} not available for search on qobuz")
@ -284,6 +398,21 @@ class QobuzClient(Client):
return await self._paginate(epoint, params, limit=limit)
async def get_featured(self, query, limit: int = 500) -> list[dict]:
"""Get featured items on Qobuz.
Args:
----
query: The type of featured items to retrieve.
limit (int): The maximum number of results to retrieve.
Raises:
------
AssertionError: If the provided query is invalid.
Returns:
-------
list[dict]: List of featured items.
"""
params = {
"type": query,
}
@ -292,6 +421,21 @@ class QobuzClient(Client):
return await self._paginate(epoint, params, limit=limit)
async def get_user_favorites(self, media_type: str, limit: int = 500) -> list[dict]:
"""Get user favorites for a specific media type on Qobuz.
Args:
----
media_type (str): The type of media (e.g., track, artist, album).
limit (int): The maximum number of results to retrieve.
Raises:
------
AssertionError: If the provided media type is invalid.
Returns:
-------
list[dict]: List of user favorites for the specified media type.
"""
assert media_type in ("track", "artist", "album")
params = {"type": f"{media_type}s"}
epoint = "favorite/getUserFavorites"
@ -299,10 +443,36 @@ class QobuzClient(Client):
return await self._paginate(epoint, params, limit=limit)
async def get_user_playlists(self, limit: int = 500) -> list[dict]:
"""Get user playlists on Qobuz.
Args:
----
limit (int): The maximum number of playlists to retrieve.
Returns:
-------
list[dict]: List of user playlists.
"""
epoint = "playlist/getUserPlaylists"
return await self._paginate(epoint, {}, limit=limit)
async def get_downloadable(self, item_id: str, quality: int) -> Downloadable:
"""Get details of a downloadable item on Qobuz.
Args:
----
item_id (str): The ID of the item to download.
quality (int): The quality level of the download.
Raises:
------
AssertionError: If the secret is not valid, not logged in, or quality level is out of bounds.
NonStreamableError: If the item is not streamable or there is an error.
Returns:
-------
Downloadable: Downloadable item details.
"""
assert self.secret is not None and self.logged_in and 1 <= quality <= 4
status, resp_json = await self._request_file_url(item_id, quality, self.secret)
assert status == 200
@ -332,13 +502,15 @@ class QobuzClient(Client):
) -> list[dict]:
"""Paginate search results.
params:
limit: If None, all the results are yielded. Otherwise a maximum
of `limit` results are yielded.
Args:
----
epoint (str): The API endpoint.
params (dict): Parameters for the API request.
limit (int): The maximum number of results to retrieve.
Returns
Returns:
-------
Generator that yields (status code, response) tuples
list[dict]: List of paginated search results.
"""
params.update({"limit": limit})
status, page = await self._api_request(epoint, params)
@ -408,7 +580,7 @@ class QobuzClient(Client):
quality: int,
secret: str,
) -> tuple[int, dict]:
quality = self.get_quality(quality)
quality = self._get_quality(quality)
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)
@ -425,6 +597,7 @@ class QobuzClient(Client):
async def _api_request(self, epoint: str, params: dict) -> tuple[int, dict]:
"""Make a request to the API.
returns: status code, json parsed response
"""
url = f"{QOBUZ_BASE_URL}/{epoint}"
@ -434,6 +607,6 @@ class QobuzClient(Client):
return response.status, await response.json()
@staticmethod
def get_quality(quality: int):
def _get_quality(quality: int):
quality_map = (5, 6, 7, 27)
return quality_map[quality - 1]

View File

@ -56,12 +56,12 @@ class SoundcloudClient(Client):
"""Fetch metadata for an item in Soundcloud API.
Args:
----
item_id (str): Plain soundcloud item ID (e.g 1633786176)
media_type (str): track or playlist
Returns:
-------
API response. The item IDs for the tracks in the playlist are modified to
include resolution status.
"""
@ -141,9 +141,11 @@ class SoundcloudClient(Client):
usage.
Args:
----
url (str): Url to resolve.
Returns:
-------
API response for item.
"""
resp, status = await self._api_request("resolve", params={"url": url})

View File

@ -20,6 +20,8 @@ CURRENT_CONFIG_VERSION = "2.0.3"
@dataclass(slots=True)
class QobuzConfig:
"""Stores configuration related to Qobuz."""
use_auth_token: bool
email_or_userid: str
# This is an md5 hash of the plaintext password
@ -35,6 +37,8 @@ class QobuzConfig:
@dataclass(slots=True)
class TidalConfig:
"""Stores configuration related to Tidal."""
# Do not change any of the fields below
user_id: str
country_code: str
@ -52,6 +56,8 @@ class TidalConfig:
@dataclass(slots=True)
class DeezerConfig:
"""Stores configuration related to Deezer."""
# An authentication cookie that allows streamrip to use your Deezer account
# See https://github.com/nathom/streamrip/wiki/Finding-Your-Deezer-ARL-Cookie
# for instructions on how to find this
@ -70,6 +76,8 @@ class DeezerConfig:
@dataclass(slots=True)
class SoundcloudConfig:
"""Stores configuration related to Soundcloud."""
# This changes periodically, so it needs to be updated
client_id: str
app_version: str
@ -79,6 +87,8 @@ class SoundcloudConfig:
@dataclass(slots=True)
class YoutubeConfig:
"""Stores configuration related to Youtube."""
# The path to download the videos to
video_downloads_folder: str
# Only 0 is available for now
@ -89,6 +99,8 @@ class YoutubeConfig:
@dataclass(slots=True)
class DatabaseConfig:
"""Stores configuration related to databases."""
downloads_enabled: bool
downloads_path: str
failed_downloads_enabled: bool
@ -97,6 +109,8 @@ class DatabaseConfig:
@dataclass(slots=True)
class ConversionConfig:
"""Stores configuration related to audio coversion."""
enabled: bool
# FLAC, ALAC, OPUS, MP3, VORBIS, or AAC
codec: str
@ -112,6 +126,8 @@ class ConversionConfig:
@dataclass(slots=True)
class QobuzDiscographyFilterConfig:
"""Stores configuration related to qobuz discography filters."""
# Remove Collectors Editions, live recordings, etc.
extras: bool
# Picks the highest quality out of albums with identical titles.
@ -128,6 +144,8 @@ class QobuzDiscographyFilterConfig:
@dataclass(slots=True)
class ArtworkConfig:
"""Stores configuration related to Album artwork."""
# Write the image to the audio file
embed: bool
# The size of the artwork to embed. Options: thumbnail, small, large, original.
@ -146,6 +164,8 @@ class ArtworkConfig:
@dataclass(slots=True)
class MetadataConfig:
"""Stores configuration related to Metadata."""
# Sets the value of the 'ALBUM' field in the metadata to the playlist's name.
# This is useful if your music library software organizes tracks based on album name.
set_playlist_to_album: bool
@ -159,6 +179,8 @@ class MetadataConfig:
@dataclass(slots=True)
class FilepathsConfig:
"""Stores configuration related to Filepaths."""
# Create folders for single tracks within the downloads directory using the folder_format
# template
add_singles_to_folder: bool
@ -177,6 +199,8 @@ class FilepathsConfig:
@dataclass(slots=True)
class DownloadsConfig:
"""Stores configuration related to downloads."""
# Folder where tracks are downloaded to
folder: str
# Put Qobuz albums in a 'Qobuz' folder, Tidal albums in 'Tidal' etc.
@ -194,6 +218,8 @@ class DownloadsConfig:
@dataclass(slots=True)
class LastFmConfig:
"""Stores configuration related to last.fm."""
# The source on which to search for the tracks.
source: str
# If no results were found with the primary source, the item is searched for
@ -203,6 +229,8 @@ class LastFmConfig:
@dataclass(slots=True)
class CliConfig:
"""Stores configuration related to the command line interface."""
# Print "Downloading {Album name}" etc. to screen
text_output: bool
# Show resolve, download progress bars
@ -213,6 +241,8 @@ class CliConfig:
@dataclass(slots=True)
class MiscConfig:
"""Stores miscellaneous configuration."""
version: str
check_for_updates: bool
@ -231,6 +261,8 @@ assert os.path.isfile(BLANK_CONFIG_PATH), "Template config not found"
@dataclass(slots=True)
class ConfigData:
"""Stores all the configuration data."""
toml: TOMLDocument
downloads: DownloadsConfig
@ -256,6 +288,7 @@ class ConfigData:
@classmethod
def from_toml(cls, toml_str: str):
"""Create a ConfigData instance from valid TOML."""
# TODO: handle the mistake where Windows people forget to escape backslash
toml = parse(toml_str)
if (v := toml["misc"]["version"]) != CURRENT_CONFIG_VERSION: # type: ignore
@ -300,36 +333,55 @@ class ConfigData:
@classmethod
def defaults(cls):
"""Return a ConfigData object filled with default values."""
with open(BLANK_CONFIG_PATH) as f:
return cls.from_toml(f.read())
def set_modified(self):
"""Set the config data as modified for saving to disk."""
self._modified = True
@property
def modified(self):
"""Get whether the config was modified for saving to disk."""
return self._modified
def update_toml(self):
update_toml_section_from_config(self.toml["downloads"], self.downloads)
update_toml_section_from_config(self.toml["qobuz"], self.qobuz)
update_toml_section_from_config(self.toml["tidal"], self.tidal)
update_toml_section_from_config(self.toml["deezer"], self.deezer)
update_toml_section_from_config(self.toml["soundcloud"], self.soundcloud)
update_toml_section_from_config(self.toml["youtube"], self.youtube)
update_toml_section_from_config(self.toml["lastfm"], self.lastfm)
update_toml_section_from_config(self.toml["artwork"], self.artwork)
update_toml_section_from_config(self.toml["filepaths"], self.filepaths)
update_toml_section_from_config(self.toml["metadata"], self.metadata)
update_toml_section_from_config(self.toml["qobuz_filters"], self.qobuz_filters)
update_toml_section_from_config(self.toml["cli"], self.cli)
update_toml_section_from_config(self.toml["database"], self.database)
update_toml_section_from_config(self.toml["conversion"], self.conversion)
"""Write the current state to the TOML object, which will be synced with disk."""
_update_toml_section_from_config(self.toml["downloads"], self.downloads)
_update_toml_section_from_config(self.toml["qobuz"], self.qobuz)
_update_toml_section_from_config(self.toml["tidal"], self.tidal)
_update_toml_section_from_config(self.toml["deezer"], self.deezer)
_update_toml_section_from_config(self.toml["soundcloud"], self.soundcloud)
_update_toml_section_from_config(self.toml["youtube"], self.youtube)
_update_toml_section_from_config(self.toml["lastfm"], self.lastfm)
_update_toml_section_from_config(self.toml["artwork"], self.artwork)
_update_toml_section_from_config(self.toml["filepaths"], self.filepaths)
_update_toml_section_from_config(self.toml["metadata"], self.metadata)
_update_toml_section_from_config(self.toml["qobuz_filters"], self.qobuz_filters)
_update_toml_section_from_config(self.toml["cli"], self.cli)
_update_toml_section_from_config(self.toml["database"], self.database)
_update_toml_section_from_config(self.toml["conversion"], self.conversion)
def get_source(
self,
source: str,
) -> QobuzConfig | DeezerConfig | SoundcloudConfig | TidalConfig:
"""Return the configuration for the source.
Args:
----
source (str): One of the available sources
Returns:
-------
A Config dataclass.
Raises:
------
Exception: If the source is invalid
"""
d = {
"qobuz": self.qobuz,
"deezer": self.deezer,
@ -342,13 +394,21 @@ class ConfigData:
return res
def update_toml_section_from_config(toml_section, config):
def _update_toml_section_from_config(toml_section, config):
for field in fields(config):
toml_section[field.name] = getattr(config, field.name)
class Config:
"""Manages the synchronization between the config data and the file stored on disk.
It contains 2 copies of the data: one that will be synced with disk (self.file),
and another that will be read during program execution, but not synced with
disk (self.session).
"""
def __init__(self, path: str, /):
"""Create Config object."""
self.path = path
with open(path) as toml_file:
@ -357,6 +417,7 @@ class Config:
self.session: ConfigData = copy.deepcopy(self.file)
def save_file(self):
"""Save the file config copy to disk."""
if not self.file.modified:
return
@ -366,12 +427,15 @@ class Config:
@classmethod
def defaults(cls):
"""Return a Config object with default values."""
return cls(BLANK_CONFIG_PATH)
def __enter__(self):
"""Enter context manager."""
return self
def __exit__(self, *_):
"""Save to disk when context manager exits."""
self.save_file()

View File

@ -261,6 +261,7 @@ class PendingLastfmPlaylist(Pending):
if that fails.
Args:
----
query (str): Query to search
s (Status):
callback: function to call after each query completes

View File

@ -281,9 +281,8 @@ class AlbumMetadata:
@classmethod
def from_tidal(cls, resp) -> AlbumMetadata | None:
"""
Args:
"""Args:
----
resp: API response containing album metadata.
Returns: AlbumMetadata instance if the album is streamable, otherwise None.

View File

@ -353,7 +353,7 @@ async def search(ctx, first, output_file, num_results, source, media_type, query
"""Search for content using a specific source.
Example:
-------
rip search qobuz album 'rumours'
"""
if first and output_file:

Binary file not shown.