qobuz-cli 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qobuz_cli/__init__.py +5 -0
- qobuz_cli/__main__.py +48 -0
- qobuz_cli/api/__init__.py +11 -0
- qobuz_cli/api/auth.py +147 -0
- qobuz_cli/api/client.py +259 -0
- qobuz_cli/api/rate_limiter.py +65 -0
- qobuz_cli/cli/__init__.py +6 -0
- qobuz_cli/cli/app.py +558 -0
- qobuz_cli/cli/formatters.py +405 -0
- qobuz_cli/cli/progress_manager.py +442 -0
- qobuz_cli/core/__init__.py +7 -0
- qobuz_cli/core/download_manager.py +608 -0
- qobuz_cli/core/track_processor.py +238 -0
- qobuz_cli/exceptions.py +41 -0
- qobuz_cli/media/__init__.py +12 -0
- qobuz_cli/media/downloader.py +186 -0
- qobuz_cli/media/integrity.py +86 -0
- qobuz_cli/media/tagger.py +262 -0
- qobuz_cli/models/__init__.py +11 -0
- qobuz_cli/models/config.py +181 -0
- qobuz_cli/models/stats.py +76 -0
- qobuz_cli/storage/__init__.py +12 -0
- qobuz_cli/storage/archive.py +246 -0
- qobuz_cli/storage/cache.py +162 -0
- qobuz_cli/storage/config_manager.py +186 -0
- qobuz_cli/utils/__init__.py +6 -0
- qobuz_cli/utils/batch_fetcher.py +112 -0
- qobuz_cli/utils/circuit_breaker.py +136 -0
- qobuz_cli/utils/config_validator.py +210 -0
- qobuz_cli/utils/discography.py +131 -0
- qobuz_cli/utils/formatting.py +68 -0
- qobuz_cli/utils/path.py +117 -0
- qobuz_cli/utils/playlist.py +56 -0
- qobuz_cli/utils/structured_logger.py +338 -0
- qobuz_cli/web/__init__.py +10 -0
- qobuz_cli/web/bundle_fetcher.py +154 -0
- qobuz_cli-0.0.1.dist-info/METADATA +229 -0
- qobuz_cli-0.0.1.dist-info/RECORD +40 -0
- qobuz_cli-0.0.1.dist-info/WHEEL +4 -0
- qobuz_cli-0.0.1.dist-info/entry_points.txt +3 -0
qobuz_cli/__init__.py
ADDED
qobuz_cli/__main__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main entry point for the qobuz-cli application.
|
|
3
|
+
This module handles top-level setup, exception handling, and CLI invocation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from qobuz_cli.cli.app import app
|
|
15
|
+
from qobuz_cli.cli.formatters import format_error_with_suggestions
|
|
16
|
+
from qobuz_cli.exceptions import QobuzCliError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> None:
|
|
20
|
+
"""Main entry point function."""
|
|
21
|
+
if os.name == "nt":
|
|
22
|
+
try:
|
|
23
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
24
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
25
|
+
except (TypeError, AttributeError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger("qobuz_cli")
|
|
29
|
+
console = Console()
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
app()
|
|
33
|
+
except (typer.Exit, typer.Abort):
|
|
34
|
+
pass
|
|
35
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
36
|
+
console.print("\n[yellow]⚠️ Operation cancelled by user.[/yellow]")
|
|
37
|
+
sys.exit(0)
|
|
38
|
+
except QobuzCliError as e:
|
|
39
|
+
console.print(f"\n{format_error_with_suggestions(e)}")
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
console.print(f"\n{format_error_with_suggestions(e, {'type': 'Unexpected'})}")
|
|
43
|
+
log.debug("Full traceback:", exc_info=True)
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
main()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Qobuz API Layer.
|
|
3
|
+
|
|
4
|
+
This package handles all communication with the official Qobuz API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .auth import QobuzAuthenticator
|
|
8
|
+
from .client import QobuzAPIClient
|
|
9
|
+
from .rate_limiter import AdaptiveRateLimiter
|
|
10
|
+
|
|
11
|
+
__all__ = ["AdaptiveRateLimiter", "QobuzAPIClient", "QobuzAuthenticator"]
|
qobuz_cli/api/auth.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Handles authentication with the Qobuz API, including credential login
|
|
3
|
+
and app secret validation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
|
|
12
|
+
from qobuz_cli.exceptions import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
IneligibleAccountError,
|
|
15
|
+
InvalidAppSecretError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .client import QobuzAPIClient
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class QobuzAuthenticator:
|
|
25
|
+
"""
|
|
26
|
+
Manages the authentication flow for the Qobuz API client.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, api_client: "QobuzAPIClient"):
|
|
30
|
+
"""
|
|
31
|
+
Initializes the authenticator.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
api_client: A reference to the main QobuzAPIClient instance.
|
|
35
|
+
"""
|
|
36
|
+
self._api_client = api_client
|
|
37
|
+
self._secrets_tested = False
|
|
38
|
+
|
|
39
|
+
async def authenticate_with_token(self, token: str) -> dict[str, Any]:
|
|
40
|
+
"""
|
|
41
|
+
Authenticates the client using a pre-existing user token.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
token: The user authentication token.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The user information dictionary from the API.
|
|
48
|
+
"""
|
|
49
|
+
log.info("Authenticating with token...")
|
|
50
|
+
self._api_client.user_auth_token = token
|
|
51
|
+
await self.configure_authentication()
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
user_info = await self._api_client.api_call("user/get")
|
|
55
|
+
log.info(
|
|
56
|
+
"Successfully authenticated as: "
|
|
57
|
+
f"{user_info.get('email', 'Unknown User')}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if not user_info.get("credential", {}).get("parameters"):
|
|
61
|
+
raise IneligibleAccountError(
|
|
62
|
+
"This account is not eligible for streaming."
|
|
63
|
+
)
|
|
64
|
+
return user_info
|
|
65
|
+
except aiohttp.ClientResponseError as e:
|
|
66
|
+
if e.status == 401:
|
|
67
|
+
raise AuthenticationError(
|
|
68
|
+
"The provided token is invalid or has expired."
|
|
69
|
+
) from e
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
async def authenticate_with_credentials(
|
|
73
|
+
self, email: str, password_md5: str
|
|
74
|
+
) -> dict[str, Any]:
|
|
75
|
+
"""
|
|
76
|
+
Authenticates using an email and an MD5-hashed password.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
email: The user's email address.
|
|
80
|
+
password_md5: The user's password, hashed with MD5.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The user information dictionary from the API.
|
|
84
|
+
"""
|
|
85
|
+
log.info(f"Authenticating as: {email}")
|
|
86
|
+
await self.configure_authentication()
|
|
87
|
+
|
|
88
|
+
login_payload = {
|
|
89
|
+
"email": email,
|
|
90
|
+
"password": password_md5,
|
|
91
|
+
"app_id": self._api_client.app_id,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
user_info = await self._api_client.api_call("user/login", **login_payload)
|
|
95
|
+
|
|
96
|
+
if not user_info.get("user", {}).get("credential", {}).get("parameters"):
|
|
97
|
+
raise IneligibleAccountError("This account is not eligible for streaming.")
|
|
98
|
+
|
|
99
|
+
self._api_client.user_auth_token = user_info["user_auth_token"]
|
|
100
|
+
return user_info
|
|
101
|
+
|
|
102
|
+
async def configure_authentication(self) -> None:
|
|
103
|
+
"""
|
|
104
|
+
Finds and sets a valid app secret from the provided list.
|
|
105
|
+
|
|
106
|
+
Tests each secret against a known valid endpoint until one succeeds.
|
|
107
|
+
This is a crucial step before making authenticated API calls.
|
|
108
|
+
"""
|
|
109
|
+
if self._api_client.app_secret:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
log.debug(f"Testing {len(self._api_client.secrets)} potential app secrets...")
|
|
113
|
+
|
|
114
|
+
test_tasks = [
|
|
115
|
+
self._test_secret(secret) for secret in self._api_client.secrets if secret
|
|
116
|
+
]
|
|
117
|
+
results = await asyncio.gather(*test_tasks)
|
|
118
|
+
|
|
119
|
+
for secret, is_valid in zip(self._api_client.secrets, results, strict=True):
|
|
120
|
+
if is_valid:
|
|
121
|
+
self._api_client.app_secret = secret
|
|
122
|
+
log.debug(f"Valid secret found: {secret[:8]}...")
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
raise InvalidAppSecretError(
|
|
126
|
+
"No valid app secrets found."
|
|
127
|
+
" Please run 'qobuz-cli init' to fetch new secrets."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def _test_secret(self, secret: str) -> bool:
|
|
131
|
+
"""
|
|
132
|
+
Tests if a single app secret is valid.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
secret: The app secret to test.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
True if the secret is valid, False otherwise.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
# Use a known public and valid track ID for testing
|
|
142
|
+
await self._api_client.api_call(
|
|
143
|
+
"track/getFileUrl", id=5966783, fmt_id=5, sec=secret
|
|
144
|
+
)
|
|
145
|
+
return True
|
|
146
|
+
except (InvalidAppSecretError, aiohttp.ClientError):
|
|
147
|
+
return False
|
qobuz_cli/api/client.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enhanced API client with compression for metadata and circuit breaker protection.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import AsyncGenerator
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
|
|
13
|
+
from qobuz_cli.exceptions import (
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
InvalidAppIdError,
|
|
16
|
+
InvalidAppSecretError,
|
|
17
|
+
InvalidQualityError,
|
|
18
|
+
)
|
|
19
|
+
from qobuz_cli.utils.circuit_breaker import CircuitBreaker, CircuitBreakerError
|
|
20
|
+
|
|
21
|
+
from .auth import QobuzAuthenticator
|
|
22
|
+
from .rate_limiter import AdaptiveRateLimiter
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class QobuzAPIClient:
|
|
28
|
+
"""
|
|
29
|
+
Optimized async client for the Qobuz JSON API (v0.2).
|
|
30
|
+
|
|
31
|
+
Features:
|
|
32
|
+
- Compression for JSON/metadata responses (not audio files)
|
|
33
|
+
- Circuit breaker for API resilience
|
|
34
|
+
- Adaptive rate limiting
|
|
35
|
+
- Connection pooling
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
BASE_URL = "https://www.qobuz.com/api.json/0.2/"
|
|
39
|
+
|
|
40
|
+
def __init__(self, app_id: str, secrets: list[str], max_workers: int = 8):
|
|
41
|
+
"""
|
|
42
|
+
Initializes the API client.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
app_id: 9-digit Qobuz application ID from the web player.
|
|
46
|
+
secrets: List of potential app secrets scraped from the web player.
|
|
47
|
+
max_workers: The number of concurrent workers, used to tune the
|
|
48
|
+
connection pool.
|
|
49
|
+
"""
|
|
50
|
+
self.app_id: str = str(app_id)
|
|
51
|
+
self.secrets: list[str] = secrets
|
|
52
|
+
self.max_workers = max_workers
|
|
53
|
+
|
|
54
|
+
# State set by the authenticator
|
|
55
|
+
self.app_secret: str | None = None
|
|
56
|
+
self.user_auth_token: str | None = None
|
|
57
|
+
|
|
58
|
+
self._session: aiohttp.ClientSession | None = None
|
|
59
|
+
self._rate_limiter = AdaptiveRateLimiter()
|
|
60
|
+
self._authenticator = QobuzAuthenticator(self)
|
|
61
|
+
|
|
62
|
+
# Circuit breaker for API resilience
|
|
63
|
+
self._circuit_breaker = CircuitBreaker(
|
|
64
|
+
failure_threshold=5,
|
|
65
|
+
recovery_timeout=60,
|
|
66
|
+
success_threshold=2,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def authenticator(self) -> QobuzAuthenticator:
|
|
71
|
+
"""Provides access to the authentication helper."""
|
|
72
|
+
return self._authenticator
|
|
73
|
+
|
|
74
|
+
async def _initialize_session(self) -> None:
|
|
75
|
+
"""Ensures an active aiohttp session is available with compression enabled."""
|
|
76
|
+
if self._session is None or self._session.closed:
|
|
77
|
+
connector = aiohttp.TCPConnector(
|
|
78
|
+
limit=self.max_workers * 2,
|
|
79
|
+
limit_per_host=self.max_workers,
|
|
80
|
+
ttl_dns_cache=300,
|
|
81
|
+
enable_cleanup_closed=True,
|
|
82
|
+
)
|
|
83
|
+
self._session = aiohttp.ClientSession(
|
|
84
|
+
connector=connector,
|
|
85
|
+
headers={
|
|
86
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0)"
|
|
87
|
+
" Gecko/20100101 Firefox/121.0",
|
|
88
|
+
"X-App-Id": self.app_id,
|
|
89
|
+
# Enable compression for JSON metadata responses
|
|
90
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
91
|
+
},
|
|
92
|
+
timeout=aiohttp.ClientTimeout(total=60, connect=15, sock_read=30),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async def close(self) -> None:
|
|
96
|
+
"""Gracefully closes the aiohttp session."""
|
|
97
|
+
if self._session and not self._session.closed:
|
|
98
|
+
await self._session.close()
|
|
99
|
+
|
|
100
|
+
def _prepare_get_file_url_params(
|
|
101
|
+
self, track_id: str, format_id: int, secret_override: str | None = None
|
|
102
|
+
) -> dict[str, Any]:
|
|
103
|
+
"""
|
|
104
|
+
Builds the signed parameter dictionary for the 'track/getFileUrl' endpoint.
|
|
105
|
+
"""
|
|
106
|
+
if format_id not in (5, 6, 7, 27):
|
|
107
|
+
raise InvalidQualityError(
|
|
108
|
+
f"Invalid format_id: {format_id}. Must be one of 5, 6, 7, or 27."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
unix_ts = int(time.time())
|
|
112
|
+
secret = secret_override or self.app_secret
|
|
113
|
+
if not secret:
|
|
114
|
+
raise InvalidAppSecretError(
|
|
115
|
+
"App secret has not been configured. Cannot sign request."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
sig_str = (
|
|
119
|
+
f"trackgetFileUrlformat_id{format_id}intentstreamtrack_id"
|
|
120
|
+
f"{track_id}{unix_ts}{secret}"
|
|
121
|
+
)
|
|
122
|
+
request_sig = hashlib.md5(sig_str.encode("utf-8")).hexdigest() # noqa: S324
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"request_ts": unix_ts,
|
|
126
|
+
"request_sig": request_sig,
|
|
127
|
+
"track_id": track_id,
|
|
128
|
+
"format_id": format_id,
|
|
129
|
+
"intent": "stream",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async def api_call(self, endpoint: str, **kwargs: Any) -> dict[str, Any]:
|
|
133
|
+
"""
|
|
134
|
+
Makes an authenticated API call with rate limiting, retry logic, and
|
|
135
|
+
circuit breaker.
|
|
136
|
+
|
|
137
|
+
Compression is automatically applied to JSON responses by aiohttp when
|
|
138
|
+
the server sends Content-Encoding header.
|
|
139
|
+
"""
|
|
140
|
+
await self._initialize_session()
|
|
141
|
+
|
|
142
|
+
# Check circuit breaker before making request
|
|
143
|
+
try:
|
|
144
|
+
async with self._circuit_breaker:
|
|
145
|
+
await self._rate_limiter.acquire()
|
|
146
|
+
|
|
147
|
+
params = kwargs.copy()
|
|
148
|
+
|
|
149
|
+
if endpoint == "track/getFileUrl":
|
|
150
|
+
params = self._prepare_get_file_url_params(
|
|
151
|
+
track_id=params.pop("id"),
|
|
152
|
+
format_id=params.pop("fmt_id"),
|
|
153
|
+
secret_override=params.pop("sec", None),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if self.user_auth_token:
|
|
157
|
+
params["user_auth_token"] = self.user_auth_token
|
|
158
|
+
|
|
159
|
+
async with self._session.get(
|
|
160
|
+
self.BASE_URL + endpoint, params=params
|
|
161
|
+
) as r:
|
|
162
|
+
# Check if response was compressed (for logging)
|
|
163
|
+
was_compressed = r.headers.get("Content-Encoding") in (
|
|
164
|
+
"gzip",
|
|
165
|
+
"deflate",
|
|
166
|
+
"br",
|
|
167
|
+
)
|
|
168
|
+
if was_compressed:
|
|
169
|
+
log.debug(
|
|
170
|
+
f"API response for {endpoint} was compressed "
|
|
171
|
+
f"({r.headers.get('Content-Encoding')})"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if r.status == 429:
|
|
175
|
+
await self._rate_limiter.on_429()
|
|
176
|
+
r.raise_for_status()
|
|
177
|
+
|
|
178
|
+
if endpoint == "user/login":
|
|
179
|
+
if r.status == 401:
|
|
180
|
+
raise AuthenticationError("Invalid email or password.")
|
|
181
|
+
if r.status == 400 and "Invalid application" in await r.text():
|
|
182
|
+
raise InvalidAppIdError("The provided App ID is invalid.")
|
|
183
|
+
|
|
184
|
+
if endpoint == "track/getFileUrl" and r.status == 400:
|
|
185
|
+
raise InvalidAppSecretError(
|
|
186
|
+
"The app secret is invalid or has expired."
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
r.raise_for_status()
|
|
190
|
+
return await r.json()
|
|
191
|
+
|
|
192
|
+
except CircuitBreakerError as e:
|
|
193
|
+
log.error(f"[red]Circuit breaker is open for API calls: {e}[/red]")
|
|
194
|
+
raise
|
|
195
|
+
except Exception as e:
|
|
196
|
+
log.debug(f"API call to {endpoint} failed: {e}")
|
|
197
|
+
raise
|
|
198
|
+
|
|
199
|
+
async def _yield_paginated(
|
|
200
|
+
self, endpoint: str, item_key: str, **kwargs: Any
|
|
201
|
+
) -> AsyncGenerator[dict[str, Any], None]:
|
|
202
|
+
"""
|
|
203
|
+
Generator for handling paginated API endpoints.
|
|
204
|
+
"""
|
|
205
|
+
offset = 0
|
|
206
|
+
limit = 200
|
|
207
|
+
total_items = 0
|
|
208
|
+
|
|
209
|
+
while True:
|
|
210
|
+
response = await self.api_call(
|
|
211
|
+
endpoint, offset=offset, limit=limit, **kwargs
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if offset == 0:
|
|
215
|
+
total_items = response.get(f"{item_key}_count", 0)
|
|
216
|
+
|
|
217
|
+
items_in_response = len(response.get(item_key, {}).get("items", []))
|
|
218
|
+
if not items_in_response:
|
|
219
|
+
break
|
|
220
|
+
|
|
221
|
+
yield response
|
|
222
|
+
|
|
223
|
+
offset += items_in_response
|
|
224
|
+
if offset >= total_items:
|
|
225
|
+
break
|
|
226
|
+
|
|
227
|
+
# Public API Methods
|
|
228
|
+
async def fetch_album_metadata(self, album_id: str) -> dict[str, Any]:
|
|
229
|
+
return await self.api_call("album/get", album_id=album_id)
|
|
230
|
+
|
|
231
|
+
async def fetch_track_metadata(self, track_id: str) -> dict[str, Any]:
|
|
232
|
+
return await self.api_call("track/get", track_id=track_id)
|
|
233
|
+
|
|
234
|
+
async def fetch_track_url(self, track_id: str, format_id: int) -> dict[str, Any]:
|
|
235
|
+
return await self.api_call("track/getFileUrl", id=track_id, fmt_id=format_id)
|
|
236
|
+
|
|
237
|
+
def fetch_artist_discography(
|
|
238
|
+
self, artist_id: str
|
|
239
|
+
) -> AsyncGenerator[dict[str, Any], None]:
|
|
240
|
+
return self._yield_paginated(
|
|
241
|
+
"artist/get", item_key="albums", artist_id=artist_id, extra="albums"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def fetch_playlist_tracks(
|
|
245
|
+
self, playlist_id: str
|
|
246
|
+
) -> AsyncGenerator[dict[str, Any], None]:
|
|
247
|
+
return self._yield_paginated(
|
|
248
|
+
"playlist/get", item_key="tracks", playlist_id=playlist_id, extra="tracks"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def fetch_label_discography(
|
|
252
|
+
self, label_id: str
|
|
253
|
+
) -> AsyncGenerator[dict[str, Any], None]:
|
|
254
|
+
return self._yield_paginated(
|
|
255
|
+
"label/get", item_key="albums", label_id=label_id, extra="albums"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
async def search_tracks(self, query: str, limit: int = 50) -> dict[str, Any]:
|
|
259
|
+
return await self.api_call("track/search", query=query, limit=limit)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provides an adaptive rate limiter to avoid 429 "Too Many Requests" errors from the API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AdaptiveRateLimiter:
|
|
13
|
+
"""
|
|
14
|
+
Dynamically adjusts call rate based on API feedback (429 errors).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self, initial_calls_per_second: float = 8.0, max_calls_per_second: float = 12.0
|
|
19
|
+
):
|
|
20
|
+
"""
|
|
21
|
+
Initializes the rate limiter.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
initial_calls_per_second: The starting rate of calls per second.
|
|
25
|
+
max_calls_per_second: The maximum rate to recover to.
|
|
26
|
+
"""
|
|
27
|
+
self._rate = initial_calls_per_second
|
|
28
|
+
self._max_rate = max_calls_per_second
|
|
29
|
+
self._min_interval = 1.0 / self._rate
|
|
30
|
+
self._last_call_time = 0.0
|
|
31
|
+
self._last_429_time = 0.0
|
|
32
|
+
self._lock = asyncio.Lock()
|
|
33
|
+
|
|
34
|
+
async def on_429(self) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Called when a 429 error is received. Halves the current request rate.
|
|
37
|
+
"""
|
|
38
|
+
async with self._lock:
|
|
39
|
+
self._rate = max(
|
|
40
|
+
1.0, self._rate * 0.5
|
|
41
|
+
) # Halve the rate, minimum 1 call/sec
|
|
42
|
+
self._min_interval = 1.0 / self._rate
|
|
43
|
+
self._last_429_time = time.monotonic()
|
|
44
|
+
log.warning(
|
|
45
|
+
f"[yellow]Rate limit hit. New rate: {self._rate:.1f} calls/s[/yellow]"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async def acquire(self) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Waits if necessary to respect the current rate limit before allowing a call
|
|
51
|
+
to proceed.
|
|
52
|
+
"""
|
|
53
|
+
async with self._lock:
|
|
54
|
+
# Gradually recover the rate if no 429 errors have occurred recently
|
|
55
|
+
if time.monotonic() - self._last_429_time > 300: # 5 minutes
|
|
56
|
+
self._rate = min(self._max_rate, self._rate * 1.005) # Slow recovery
|
|
57
|
+
self._min_interval = 1.0 / self._rate
|
|
58
|
+
|
|
59
|
+
now = asyncio.get_event_loop().time()
|
|
60
|
+
time_since_last = now - self._last_call_time
|
|
61
|
+
|
|
62
|
+
if time_since_last < self._min_interval:
|
|
63
|
+
await asyncio.sleep(self._min_interval - time_since_last)
|
|
64
|
+
|
|
65
|
+
self._last_call_time = asyncio.get_event_loop().time()
|