mirror of
https://github.com/nathom/streamrip.git
synced 2025-05-19 01:35:24 -04:00
More docs
This commit is contained in:
parent
1c2bd2545c
commit
963881ca27
10 changed files with 270 additions and 31 deletions
|
@ -69,7 +69,7 @@ build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
|
# 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 = []
|
ignore = []
|
||||||
|
|
||||||
# Allow fix for all enabled rules (when `--fix`) is provided.
|
# Allow fix for all enabled rules (when `--fix`) is provided.
|
||||||
|
|
|
@ -22,7 +22,8 @@ logging.captureWarnings(True)
|
||||||
class DeezerClient(Client):
|
class DeezerClient(Client):
|
||||||
"""Client to handle deezer API. Does not do rate limiting.
|
"""Client to handle deezer API. Does not do rate limiting.
|
||||||
|
|
||||||
Attributes:
|
Attributes
|
||||||
|
----------
|
||||||
global_config: Entire config object
|
global_config: Entire config object
|
||||||
client: client from deezer py used for API requests
|
client: client from deezer py used for API requests
|
||||||
logged_in: True if logged in
|
logged_in: True if logged in
|
||||||
|
|
|
@ -242,7 +242,6 @@ class TidalDownloadable(Downloadable):
|
||||||
:param out_path:
|
:param out_path:
|
||||||
:param encryption_key:
|
:param encryption_key:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Do not change this
|
# Do not change this
|
||||||
master_key = "UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754="
|
master_key = "UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754="
|
||||||
|
|
||||||
|
|
|
@ -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 asyncio
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
@ -64,6 +98,7 @@ class QobuzSpoofer:
|
||||||
self.session = None
|
self.session = None
|
||||||
|
|
||||||
async def get_app_id_and_secrets(self) -> tuple[str, list[str]]:
|
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
|
assert self.session is not None
|
||||||
async with self.session.get("https://play.qobuz.com/login") as req:
|
async with self.session.get("https://play.qobuz.com/login") as req:
|
||||||
login_page = await req.text()
|
login_page = await req.text()
|
||||||
|
@ -124,20 +159,57 @@ class QobuzSpoofer:
|
||||||
return app_id, secrets_list
|
return app_id, secrets_list
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
|
"""Enter context manager and create async client session."""
|
||||||
self.session = aiohttp.ClientSession()
|
self.session = aiohttp.ClientSession()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(self, *_):
|
async def __aexit__(self, *_):
|
||||||
|
"""Close client session on context manager exit."""
|
||||||
if self.session is not None:
|
if self.session is not None:
|
||||||
await self.session.close()
|
await self.session.close()
|
||||||
self.session = None
|
self.session = None
|
||||||
|
|
||||||
|
|
||||||
class QobuzClient(Client):
|
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"
|
source = "qobuz"
|
||||||
max_quality = 4
|
max_quality = 4
|
||||||
|
|
||||||
def __init__(self, config: Config):
|
def __init__(self, config: Config):
|
||||||
|
"""Initialize a new QobuzClient instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
----
|
||||||
|
config (Config): Configuration object containing session details.
|
||||||
|
"""
|
||||||
self.logged_in = False
|
self.logged_in = False
|
||||||
self.config = config
|
self.config = config
|
||||||
self.rate_limiter = self.get_rate_limiter(
|
self.rate_limiter = self.get_rate_limiter(
|
||||||
|
@ -146,6 +218,15 @@ class QobuzClient(Client):
|
||||||
self.secret: Optional[str] = None
|
self.secret: Optional[str] = None
|
||||||
|
|
||||||
async def login(self):
|
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()
|
self.session = await self.get_session()
|
||||||
c = self.config.session.qobuz
|
c = self.config.session.qobuz
|
||||||
if not c.email_or_userid or not c.password_or_token:
|
if not c.email_or_userid or not c.password_or_token:
|
||||||
|
@ -198,6 +279,17 @@ class QobuzClient(Client):
|
||||||
self.logged_in = True
|
self.logged_in = True
|
||||||
|
|
||||||
async def get_metadata(self, item_id: str, media_type: str):
|
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":
|
if media_type == "label":
|
||||||
return await self.get_label(item_id)
|
return await self.get_label(item_id)
|
||||||
|
|
||||||
|
@ -233,6 +325,16 @@ class QobuzClient(Client):
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
async def get_label(self, label_id: str) -> dict:
|
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
|
c = self.config.session.qobuz
|
||||||
page_limit = 500
|
page_limit = 500
|
||||||
params = {
|
params = {
|
||||||
|
@ -273,6 +375,18 @@ class QobuzClient(Client):
|
||||||
return label_resp
|
return label_resp
|
||||||
|
|
||||||
async def search(self, media_type: str, query: str, limit: int = 500) -> list[dict]:
|
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"):
|
if media_type not in ("artist", "album", "track", "playlist"):
|
||||||
raise Exception(f"{media_type} not available for search on qobuz")
|
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)
|
return await self._paginate(epoint, params, limit=limit)
|
||||||
|
|
||||||
async def get_featured(self, query, limit: int = 500) -> list[dict]:
|
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 = {
|
params = {
|
||||||
"type": query,
|
"type": query,
|
||||||
}
|
}
|
||||||
|
@ -292,6 +421,21 @@ class QobuzClient(Client):
|
||||||
return await self._paginate(epoint, params, limit=limit)
|
return await self._paginate(epoint, params, limit=limit)
|
||||||
|
|
||||||
async def get_user_favorites(self, media_type: str, limit: int = 500) -> list[dict]:
|
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")
|
assert media_type in ("track", "artist", "album")
|
||||||
params = {"type": f"{media_type}s"}
|
params = {"type": f"{media_type}s"}
|
||||||
epoint = "favorite/getUserFavorites"
|
epoint = "favorite/getUserFavorites"
|
||||||
|
@ -299,10 +443,36 @@ class QobuzClient(Client):
|
||||||
return await self._paginate(epoint, params, limit=limit)
|
return await self._paginate(epoint, params, limit=limit)
|
||||||
|
|
||||||
async def get_user_playlists(self, limit: int = 500) -> list[dict]:
|
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"
|
epoint = "playlist/getUserPlaylists"
|
||||||
return await self._paginate(epoint, {}, limit=limit)
|
return await self._paginate(epoint, {}, limit=limit)
|
||||||
|
|
||||||
async def get_downloadable(self, item_id: str, quality: int) -> Downloadable:
|
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
|
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)
|
status, resp_json = await self._request_file_url(item_id, quality, self.secret)
|
||||||
assert status == 200
|
assert status == 200
|
||||||
|
@ -332,13 +502,15 @@ class QobuzClient(Client):
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Paginate search results.
|
"""Paginate search results.
|
||||||
|
|
||||||
params:
|
Args:
|
||||||
limit: If None, all the results are yielded. Otherwise a maximum
|
----
|
||||||
of `limit` results are yielded.
|
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})
|
params.update({"limit": limit})
|
||||||
status, page = await self._api_request(epoint, params)
|
status, page = await self._api_request(epoint, params)
|
||||||
|
@ -408,7 +580,7 @@ class QobuzClient(Client):
|
||||||
quality: int,
|
quality: int,
|
||||||
secret: str,
|
secret: str,
|
||||||
) -> tuple[int, dict]:
|
) -> tuple[int, dict]:
|
||||||
quality = self.get_quality(quality)
|
quality = self._get_quality(quality)
|
||||||
unix_ts = time.time()
|
unix_ts = time.time()
|
||||||
r_sig = f"trackgetFileUrlformat_id{quality}intentstreamtrack_id{track_id}{unix_ts}{secret}"
|
r_sig = f"trackgetFileUrlformat_id{quality}intentstreamtrack_id{track_id}{unix_ts}{secret}"
|
||||||
logger.debug("Raw request signature: %s", r_sig)
|
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]:
|
async def _api_request(self, epoint: str, params: dict) -> tuple[int, dict]:
|
||||||
"""Make a request to the API.
|
"""Make a request to the API.
|
||||||
|
|
||||||
returns: status code, json parsed response
|
returns: status code, json parsed response
|
||||||
"""
|
"""
|
||||||
url = f"{QOBUZ_BASE_URL}/{epoint}"
|
url = f"{QOBUZ_BASE_URL}/{epoint}"
|
||||||
|
@ -434,6 +607,6 @@ class QobuzClient(Client):
|
||||||
return response.status, await response.json()
|
return response.status, await response.json()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_quality(quality: int):
|
def _get_quality(quality: int):
|
||||||
quality_map = (5, 6, 7, 27)
|
quality_map = (5, 6, 7, 27)
|
||||||
return quality_map[quality - 1]
|
return quality_map[quality - 1]
|
||||||
|
|
|
@ -56,12 +56,12 @@ class SoundcloudClient(Client):
|
||||||
"""Fetch metadata for an item in Soundcloud API.
|
"""Fetch metadata for an item in Soundcloud API.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
item_id (str): Plain soundcloud item ID (e.g 1633786176)
|
item_id (str): Plain soundcloud item ID (e.g 1633786176)
|
||||||
media_type (str): track or playlist
|
media_type (str): track or playlist
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
-------
|
||||||
API response. The item IDs for the tracks in the playlist are modified to
|
API response. The item IDs for the tracks in the playlist are modified to
|
||||||
include resolution status.
|
include resolution status.
|
||||||
"""
|
"""
|
||||||
|
@ -141,9 +141,11 @@ class SoundcloudClient(Client):
|
||||||
usage.
|
usage.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
url (str): Url to resolve.
|
url (str): Url to resolve.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
-------
|
||||||
API response for item.
|
API response for item.
|
||||||
"""
|
"""
|
||||||
resp, status = await self._api_request("resolve", params={"url": url})
|
resp, status = await self._api_request("resolve", params={"url": url})
|
||||||
|
|
|
@ -20,6 +20,8 @@ CURRENT_CONFIG_VERSION = "2.0.3"
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class QobuzConfig:
|
class QobuzConfig:
|
||||||
|
"""Stores configuration related to Qobuz."""
|
||||||
|
|
||||||
use_auth_token: bool
|
use_auth_token: bool
|
||||||
email_or_userid: str
|
email_or_userid: str
|
||||||
# This is an md5 hash of the plaintext password
|
# This is an md5 hash of the plaintext password
|
||||||
|
@ -35,6 +37,8 @@ class QobuzConfig:
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class TidalConfig:
|
class TidalConfig:
|
||||||
|
"""Stores configuration related to Tidal."""
|
||||||
|
|
||||||
# Do not change any of the fields below
|
# Do not change any of the fields below
|
||||||
user_id: str
|
user_id: str
|
||||||
country_code: str
|
country_code: str
|
||||||
|
@ -52,6 +56,8 @@ class TidalConfig:
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class DeezerConfig:
|
class DeezerConfig:
|
||||||
|
"""Stores configuration related to Deezer."""
|
||||||
|
|
||||||
# An authentication cookie that allows streamrip to use your Deezer account
|
# An authentication cookie that allows streamrip to use your Deezer account
|
||||||
# See https://github.com/nathom/streamrip/wiki/Finding-Your-Deezer-ARL-Cookie
|
# See https://github.com/nathom/streamrip/wiki/Finding-Your-Deezer-ARL-Cookie
|
||||||
# for instructions on how to find this
|
# for instructions on how to find this
|
||||||
|
@ -70,6 +76,8 @@ class DeezerConfig:
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class SoundcloudConfig:
|
class SoundcloudConfig:
|
||||||
|
"""Stores configuration related to Soundcloud."""
|
||||||
|
|
||||||
# This changes periodically, so it needs to be updated
|
# This changes periodically, so it needs to be updated
|
||||||
client_id: str
|
client_id: str
|
||||||
app_version: str
|
app_version: str
|
||||||
|
@ -79,6 +87,8 @@ class SoundcloudConfig:
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class YoutubeConfig:
|
class YoutubeConfig:
|
||||||
|
"""Stores configuration related to Youtube."""
|
||||||
|
|
||||||
# The path to download the videos to
|
# The path to download the videos to
|
||||||
video_downloads_folder: str
|
video_downloads_folder: str
|
||||||
# Only 0 is available for now
|
# Only 0 is available for now
|
||||||
|
@ -89,6 +99,8 @@ class YoutubeConfig:
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class DatabaseConfig:
|
class DatabaseConfig:
|
||||||
|
"""Stores configuration related to databases."""
|
||||||
|
|
||||||
downloads_enabled: bool
|
downloads_enabled: bool
|
||||||
downloads_path: str
|
downloads_path: str
|
||||||
failed_downloads_enabled: bool
|
failed_downloads_enabled: bool
|
||||||
|
@ -97,6 +109,8 @@ class DatabaseConfig:
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class ConversionConfig:
|
class ConversionConfig:
|
||||||
|
"""Stores configuration related to audio coversion."""
|
||||||
|
|
||||||
enabled: bool
|
enabled: bool
|
||||||
# FLAC, ALAC, OPUS, MP3, VORBIS, or AAC
|
# FLAC, ALAC, OPUS, MP3, VORBIS, or AAC
|
||||||
codec: str
|
codec: str
|
||||||
|
@ -112,6 +126,8 @@ class ConversionConfig:
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class QobuzDiscographyFilterConfig:
|
class QobuzDiscographyFilterConfig:
|
||||||
|
"""Stores configuration related to qobuz discography filters."""
|
||||||
|
|
||||||
# Remove Collectors Editions, live recordings, etc.
|
# Remove Collectors Editions, live recordings, etc.
|
||||||
extras: bool
|
extras: bool
|
||||||
# Picks the highest quality out of albums with identical titles.
|
# Picks the highest quality out of albums with identical titles.
|
||||||
|
@ -128,6 +144,8 @@ class QobuzDiscographyFilterConfig:
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class ArtworkConfig:
|
class ArtworkConfig:
|
||||||
|
"""Stores configuration related to Album artwork."""
|
||||||
|
|
||||||
# Write the image to the audio file
|
# Write the image to the audio file
|
||||||
embed: bool
|
embed: bool
|
||||||
# The size of the artwork to embed. Options: thumbnail, small, large, original.
|
# The size of the artwork to embed. Options: thumbnail, small, large, original.
|
||||||
|
@ -146,6 +164,8 @@ class ArtworkConfig:
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class MetadataConfig:
|
class MetadataConfig:
|
||||||
|
"""Stores configuration related to Metadata."""
|
||||||
|
|
||||||
# Sets the value of the 'ALBUM' field in the metadata to the playlist's name.
|
# 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.
|
# This is useful if your music library software organizes tracks based on album name.
|
||||||
set_playlist_to_album: bool
|
set_playlist_to_album: bool
|
||||||
|
@ -159,6 +179,8 @@ class MetadataConfig:
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class FilepathsConfig:
|
class FilepathsConfig:
|
||||||
|
"""Stores configuration related to Filepaths."""
|
||||||
|
|
||||||
# Create folders for single tracks within the downloads directory using the folder_format
|
# Create folders for single tracks within the downloads directory using the folder_format
|
||||||
# template
|
# template
|
||||||
add_singles_to_folder: bool
|
add_singles_to_folder: bool
|
||||||
|
@ -177,6 +199,8 @@ class FilepathsConfig:
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class DownloadsConfig:
|
class DownloadsConfig:
|
||||||
|
"""Stores configuration related to downloads."""
|
||||||
|
|
||||||
# Folder where tracks are downloaded to
|
# Folder where tracks are downloaded to
|
||||||
folder: str
|
folder: str
|
||||||
# Put Qobuz albums in a 'Qobuz' folder, Tidal albums in 'Tidal' etc.
|
# Put Qobuz albums in a 'Qobuz' folder, Tidal albums in 'Tidal' etc.
|
||||||
|
@ -194,6 +218,8 @@ class DownloadsConfig:
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class LastFmConfig:
|
class LastFmConfig:
|
||||||
|
"""Stores configuration related to last.fm."""
|
||||||
|
|
||||||
# The source on which to search for the tracks.
|
# The source on which to search for the tracks.
|
||||||
source: str
|
source: str
|
||||||
# If no results were found with the primary source, the item is searched for
|
# If no results were found with the primary source, the item is searched for
|
||||||
|
@ -203,6 +229,8 @@ class LastFmConfig:
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class CliConfig:
|
class CliConfig:
|
||||||
|
"""Stores configuration related to the command line interface."""
|
||||||
|
|
||||||
# Print "Downloading {Album name}" etc. to screen
|
# Print "Downloading {Album name}" etc. to screen
|
||||||
text_output: bool
|
text_output: bool
|
||||||
# Show resolve, download progress bars
|
# Show resolve, download progress bars
|
||||||
|
@ -213,6 +241,8 @@ class CliConfig:
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class MiscConfig:
|
class MiscConfig:
|
||||||
|
"""Stores miscellaneous configuration."""
|
||||||
|
|
||||||
version: str
|
version: str
|
||||||
check_for_updates: bool
|
check_for_updates: bool
|
||||||
|
|
||||||
|
@ -231,6 +261,8 @@ assert os.path.isfile(BLANK_CONFIG_PATH), "Template config not found"
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class ConfigData:
|
class ConfigData:
|
||||||
|
"""Stores all the configuration data."""
|
||||||
|
|
||||||
toml: TOMLDocument
|
toml: TOMLDocument
|
||||||
downloads: DownloadsConfig
|
downloads: DownloadsConfig
|
||||||
|
|
||||||
|
@ -256,6 +288,7 @@ class ConfigData:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_toml(cls, toml_str: str):
|
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
|
# TODO: handle the mistake where Windows people forget to escape backslash
|
||||||
toml = parse(toml_str)
|
toml = parse(toml_str)
|
||||||
if (v := toml["misc"]["version"]) != CURRENT_CONFIG_VERSION: # type: ignore
|
if (v := toml["misc"]["version"]) != CURRENT_CONFIG_VERSION: # type: ignore
|
||||||
|
@ -300,36 +333,55 @@ class ConfigData:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def defaults(cls):
|
def defaults(cls):
|
||||||
|
"""Return a ConfigData object filled with default values."""
|
||||||
with open(BLANK_CONFIG_PATH) as f:
|
with open(BLANK_CONFIG_PATH) as f:
|
||||||
return cls.from_toml(f.read())
|
return cls.from_toml(f.read())
|
||||||
|
|
||||||
def set_modified(self):
|
def set_modified(self):
|
||||||
|
"""Set the config data as modified for saving to disk."""
|
||||||
self._modified = True
|
self._modified = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def modified(self):
|
def modified(self):
|
||||||
|
"""Get whether the config was modified for saving to disk."""
|
||||||
return self._modified
|
return self._modified
|
||||||
|
|
||||||
def update_toml(self):
|
def update_toml(self):
|
||||||
update_toml_section_from_config(self.toml["downloads"], self.downloads)
|
"""Write the current state to the TOML object, which will be synced with disk."""
|
||||||
update_toml_section_from_config(self.toml["qobuz"], self.qobuz)
|
_update_toml_section_from_config(self.toml["downloads"], self.downloads)
|
||||||
update_toml_section_from_config(self.toml["tidal"], self.tidal)
|
_update_toml_section_from_config(self.toml["qobuz"], self.qobuz)
|
||||||
update_toml_section_from_config(self.toml["deezer"], self.deezer)
|
_update_toml_section_from_config(self.toml["tidal"], self.tidal)
|
||||||
update_toml_section_from_config(self.toml["soundcloud"], self.soundcloud)
|
_update_toml_section_from_config(self.toml["deezer"], self.deezer)
|
||||||
update_toml_section_from_config(self.toml["youtube"], self.youtube)
|
_update_toml_section_from_config(self.toml["soundcloud"], self.soundcloud)
|
||||||
update_toml_section_from_config(self.toml["lastfm"], self.lastfm)
|
_update_toml_section_from_config(self.toml["youtube"], self.youtube)
|
||||||
update_toml_section_from_config(self.toml["artwork"], self.artwork)
|
_update_toml_section_from_config(self.toml["lastfm"], self.lastfm)
|
||||||
update_toml_section_from_config(self.toml["filepaths"], self.filepaths)
|
_update_toml_section_from_config(self.toml["artwork"], self.artwork)
|
||||||
update_toml_section_from_config(self.toml["metadata"], self.metadata)
|
_update_toml_section_from_config(self.toml["filepaths"], self.filepaths)
|
||||||
update_toml_section_from_config(self.toml["qobuz_filters"], self.qobuz_filters)
|
_update_toml_section_from_config(self.toml["metadata"], self.metadata)
|
||||||
update_toml_section_from_config(self.toml["cli"], self.cli)
|
_update_toml_section_from_config(self.toml["qobuz_filters"], self.qobuz_filters)
|
||||||
update_toml_section_from_config(self.toml["database"], self.database)
|
_update_toml_section_from_config(self.toml["cli"], self.cli)
|
||||||
update_toml_section_from_config(self.toml["conversion"], self.conversion)
|
_update_toml_section_from_config(self.toml["database"], self.database)
|
||||||
|
_update_toml_section_from_config(self.toml["conversion"], self.conversion)
|
||||||
|
|
||||||
def get_source(
|
def get_source(
|
||||||
self,
|
self,
|
||||||
source: str,
|
source: str,
|
||||||
) -> QobuzConfig | DeezerConfig | SoundcloudConfig | TidalConfig:
|
) -> 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 = {
|
d = {
|
||||||
"qobuz": self.qobuz,
|
"qobuz": self.qobuz,
|
||||||
"deezer": self.deezer,
|
"deezer": self.deezer,
|
||||||
|
@ -342,13 +394,21 @@ class ConfigData:
|
||||||
return res
|
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):
|
for field in fields(config):
|
||||||
toml_section[field.name] = getattr(config, field.name)
|
toml_section[field.name] = getattr(config, field.name)
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
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, /):
|
def __init__(self, path: str, /):
|
||||||
|
"""Create Config object."""
|
||||||
self.path = path
|
self.path = path
|
||||||
|
|
||||||
with open(path) as toml_file:
|
with open(path) as toml_file:
|
||||||
|
@ -357,6 +417,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):
|
||||||
|
"""Save the file config copy to disk."""
|
||||||
if not self.file.modified:
|
if not self.file.modified:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -366,12 +427,15 @@ class Config:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def defaults(cls):
|
def defaults(cls):
|
||||||
|
"""Return a Config object with default values."""
|
||||||
return cls(BLANK_CONFIG_PATH)
|
return cls(BLANK_CONFIG_PATH)
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
|
"""Enter context manager."""
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, *_):
|
def __exit__(self, *_):
|
||||||
|
"""Save to disk when context manager exits."""
|
||||||
self.save_file()
|
self.save_file()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -261,6 +261,7 @@ class PendingLastfmPlaylist(Pending):
|
||||||
if that fails.
|
if that fails.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
----
|
||||||
query (str): Query to search
|
query (str): Query to search
|
||||||
s (Status):
|
s (Status):
|
||||||
callback: function to call after each query completes
|
callback: function to call after each query completes
|
||||||
|
|
|
@ -281,9 +281,8 @@ class AlbumMetadata:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_tidal(cls, resp) -> AlbumMetadata | None:
|
def from_tidal(cls, resp) -> AlbumMetadata | None:
|
||||||
"""
|
"""Args:
|
||||||
|
----
|
||||||
Args:
|
|
||||||
resp: API response containing album metadata.
|
resp: API response containing album metadata.
|
||||||
|
|
||||||
Returns: AlbumMetadata instance if the album is streamable, otherwise None.
|
Returns: AlbumMetadata instance if the album is streamable, otherwise None.
|
||||||
|
|
|
@ -353,7 +353,7 @@ async def search(ctx, first, output_file, num_results, source, media_type, query
|
||||||
"""Search for content using a specific source.
|
"""Search for content using a specific source.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
-------
|
||||||
rip search qobuz album 'rumours'
|
rip search qobuz album 'rumours'
|
||||||
"""
|
"""
|
||||||
if first and output_file:
|
if first and output_file:
|
||||||
|
|
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue