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] [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.

View file

@ -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

View file

@ -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="

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 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]

View file

@ -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})

View file

@ -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()

View 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

View file

@ -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.

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. """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.