qobuz-cli 0.0.2__tar.gz → 0.0.4__tar.gz

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.
Files changed (46) hide show
  1. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/PKG-INFO +10 -13
  2. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/pyproject.toml +24 -14
  3. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/api/auth.py +8 -6
  4. qobuz_cli-0.0.4/qobuz_cli/api/client.py +381 -0
  5. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/api/rate_limiter.py +16 -9
  6. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/cli/app.py +2 -1
  7. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/cli/progress_manager.py +22 -6
  8. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/core/download_manager.py +18 -10
  9. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/core/track_processor.py +9 -4
  10. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/media/downloader.py +14 -6
  11. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/media/tagger.py +9 -9
  12. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/models/config.py +27 -2
  13. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/models/stats.py +12 -7
  14. qobuz_cli-0.0.4/qobuz_cli/py.typed +0 -0
  15. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/storage/archive.py +3 -4
  16. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/storage/cache.py +1 -2
  17. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/storage/config_manager.py +8 -6
  18. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/utils/circuit_breaker.py +11 -1
  19. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/utils/path.py +7 -1
  20. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/utils/playlist.py +13 -6
  21. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/web/bundle_fetcher.py +27 -6
  22. qobuz_cli-0.0.4/uv.lock +962 -0
  23. qobuz_cli-0.0.2/qobuz_cli/api/client.py +0 -259
  24. qobuz_cli-0.0.2/qobuz_cli/utils/batch_fetcher.py +0 -112
  25. qobuz_cli-0.0.2/qobuz_cli/utils/config_validator.py +0 -210
  26. qobuz_cli-0.0.2/qobuz_cli/utils/structured_logger.py +0 -338
  27. qobuz_cli-0.0.2/uv.lock +0 -1205
  28. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/.github/workflows/publish.yml +0 -0
  29. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/.gitignore +0 -0
  30. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/LICENSE +0 -0
  31. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/README.md +0 -0
  32. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/__init__.py +0 -0
  33. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/__main__.py +0 -0
  34. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/api/__init__.py +0 -0
  35. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/cli/__init__.py +0 -0
  36. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/cli/formatters.py +0 -0
  37. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/core/__init__.py +0 -0
  38. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/exceptions.py +0 -0
  39. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/media/__init__.py +0 -0
  40. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/media/integrity.py +0 -0
  41. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/models/__init__.py +0 -0
  42. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/storage/__init__.py +0 -0
  43. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/utils/__init__.py +0 -0
  44. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/utils/discography.py +0 -0
  45. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/utils/formatting.py +0 -0
  46. {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/web/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qobuz-cli
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Blazing fast Qobuz CLI downloader with modern UI
5
5
  Project-URL: Homepage, https://github.com/HongYue1/qobuz-cli
6
6
  Project-URL: Repository, https://github.com/HongYue1/qobuz-cli
@@ -15,18 +15,15 @@ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (G
15
15
  Classifier: Operating System :: OS Independent
16
16
  Classifier: Programming Language :: Python
17
17
  Classifier: Topic :: Multimedia :: Sound/Audio
18
- Requires-Python: >=3.10
19
- Requires-Dist: aiofiles
20
- Requires-Dist: aiohttp
21
- Requires-Dist: beautifulsoup4
22
- Requires-Dist: mutagen
23
- Requires-Dist: pathvalidate
24
- Requires-Dist: pydantic
25
- Requires-Dist: rich
26
- Requires-Dist: typer
27
- Provides-Extra: dev
28
- Requires-Dist: jsonschema; extra == 'dev'
29
- Requires-Dist: ruff>=0.10.0; extra == 'dev'
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: aiofiles>=25.1.0
20
+ Requires-Dist: aiohttp>=3.14.1
21
+ Requires-Dist: beautifulsoup4>=4.15.0
22
+ Requires-Dist: mutagen>=1.48.1
23
+ Requires-Dist: pathvalidate>=3.3.1
24
+ Requires-Dist: pydantic>=2.13.4
25
+ Requires-Dist: rich>=15.0.0
26
+ Requires-Dist: typer>=0.26.8
30
27
  Description-Content-Type: text/markdown
31
28
 
32
29
  # qobuz-cli
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "qobuz-cli"
7
- version = "v0.0.2"
7
+ version = "0.0.4"
8
8
  authors = [{ name = "HongYue1", email = "moonlord369@gmail.com" }]
9
9
  description = " Blazing fast Qobuz CLI downloader with modern UI"
10
10
  readme = "README.md"
11
- requires-python = ">=3.10"
11
+ requires-python = ">=3.12"
12
12
  license = "GPL-3.0-or-later"
13
13
  license-files = ["LICEN[CS]E.*"]
14
14
  keywords = ["qobuz", "music", "downloader", "cli", "audio"]
@@ -24,19 +24,16 @@ classifiers = [
24
24
  ]
25
25
 
26
26
  dependencies = [
27
- "aiofiles",
28
- "aiohttp",
29
- "beautifulsoup4",
30
- "mutagen",
31
- "pathvalidate",
32
- "pydantic",
33
- "rich",
34
- "typer",
27
+ "aiofiles>=25.1.0",
28
+ "aiohttp>=3.14.1",
29
+ "beautifulsoup4>=4.15.0",
30
+ "mutagen>=1.48.1",
31
+ "pathvalidate>=3.3.1",
32
+ "pydantic>=2.13.4",
33
+ "rich>=15.0.0",
34
+ "typer>=0.26.8",
35
35
  ]
36
36
 
37
- [project.optional-dependencies]
38
- dev = ["ruff>=0.10.0", "jsonschema"]
39
-
40
37
  [project.urls]
41
38
  Homepage = "https://github.com/HongYue1/qobuz-cli"
42
39
  Repository = "https://github.com/HongYue1/qobuz-cli"
@@ -46,9 +43,12 @@ Repository = "https://github.com/HongYue1/qobuz-cli"
46
43
  qobuz-cli = "qobuz_cli.__main__:main"
47
44
  qcli = "qobuz_cli.__main__:main"
48
45
 
46
+ [dependency-groups]
47
+ dev = ["ruff>=0.10.0", "jsonschema"]
48
+
49
49
  [tool.ruff]
50
50
  line-length = 88
51
- target-version = "py310"
51
+ target-version = "py312"
52
52
 
53
53
  [tool.ruff.lint]
54
54
  preview = true
@@ -67,6 +67,9 @@ select = [
67
67
  "ASYNC", # flake8-async
68
68
  "S", # flake8-bandit
69
69
  "F541", # f-string-missing-placeholders
70
+ "PERF", # perflint (performance anti-patterns)
71
+ "RET", # flake8-return (cleaner returns)
72
+ "FURB", # refurb (modern idioms)
70
73
  ]
71
74
 
72
75
 
@@ -82,3 +85,10 @@ known-first-party = ["qobuz_cli"]
82
85
  "__init__.py" = ["F401"]
83
86
  # Relax security rules in tests
84
87
  # "tests/**" = ["S"]
88
+
89
+ [tool.mypy]
90
+ python_version = "3.12"
91
+ ignore_missing_imports = true
92
+ warn_redundant_casts = true
93
+ warn_unused_ignores = true
94
+ no_implicit_optional = true
@@ -26,7 +26,7 @@ class QobuzAuthenticator:
26
26
  Manages the authentication flow for the Qobuz API client.
27
27
  """
28
28
 
29
- def __init__(self, api_client: "QobuzAPIClient"):
29
+ def __init__(self, api_client: QobuzAPIClient):
30
30
  """
31
31
  Initializes the authenticator.
32
32
 
@@ -111,12 +111,14 @@ class QobuzAuthenticator:
111
111
 
112
112
  log.debug(f"Testing {len(self._api_client.secrets)} potential app secrets...")
113
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)
114
+ # Only test non-empty secrets, and keep the tested list aligned with the
115
+ # results so a falsy secret can never desynchronize the zip below.
116
+ valid_candidates = [s for s in self._api_client.secrets if s]
117
+ results = await asyncio.gather(
118
+ *(self._test_secret(secret) for secret in valid_candidates)
119
+ )
118
120
 
119
- for secret, is_valid in zip(self._api_client.secrets, results, strict=True):
121
+ for secret, is_valid in zip(valid_candidates, results, strict=True):
120
122
  if is_valid:
121
123
  self._api_client.app_secret = secret
122
124
  log.debug(f"Valid secret found: {secret[:8]}...")
@@ -0,0 +1,381 @@
1
+ """
2
+ Enhanced API client with compression for metadata and circuit breaker protection.
3
+ """
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import logging
8
+ import time
9
+ import unicodedata
10
+ from collections.abc import AsyncGenerator
11
+ from typing import Any
12
+
13
+ import aiohttp
14
+
15
+ from qobuz_cli.exceptions import (
16
+ AuthenticationError,
17
+ InvalidAppIdError,
18
+ InvalidAppSecretError,
19
+ InvalidQualityError,
20
+ )
21
+ from qobuz_cli.utils.circuit_breaker import CircuitBreaker, CircuitBreakerError
22
+
23
+ from .auth import QobuzAuthenticator
24
+ from .rate_limiter import AdaptiveRateLimiter
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+
29
+ def _is_expected_client_error(exc: BaseException) -> bool:
30
+ """
31
+ Returns True for errors that mean the API responded but rejected the
32
+ request for client-side reasons (auth/quality/4xx). These must not count as
33
+ circuit-breaker failures -- the service itself is healthy.
34
+ """
35
+ if isinstance(
36
+ exc,
37
+ (
38
+ AuthenticationError,
39
+ InvalidAppIdError,
40
+ InvalidAppSecretError,
41
+ InvalidQualityError,
42
+ ),
43
+ ):
44
+ return True
45
+ if isinstance(exc, aiohttp.ClientResponseError):
46
+ return 400 <= exc.status < 500 and exc.status != 429
47
+ return False
48
+
49
+
50
+ class QobuzAPIClient:
51
+ """
52
+ Optimized async client for the Qobuz JSON API (v0.2).
53
+
54
+ Features:
55
+ - Compression for JSON/metadata responses (not audio files)
56
+ - Circuit breaker for API resilience
57
+ - Adaptive rate limiting
58
+ - Connection pooling
59
+ """
60
+
61
+ BASE_URL = "https://www.qobuz.com/api.json/0.2/"
62
+
63
+ def __init__(self, app_id: str, secrets: list[str], max_workers: int = 8):
64
+ """
65
+ Initializes the API client.
66
+
67
+ Args:
68
+ app_id: 9-digit Qobuz application ID from the web player.
69
+ secrets: List of potential app secrets scraped from the web player.
70
+ max_workers: The number of concurrent workers, used to tune the
71
+ connection pool.
72
+ """
73
+ self.app_id: str = str(app_id)
74
+ self.secrets: list[str] = secrets
75
+ self.max_workers = max_workers
76
+
77
+ # State set by the authenticator
78
+ self.app_secret: str | None = None
79
+ self.user_auth_token: str | None = None
80
+
81
+ self._session: aiohttp.ClientSession | None = None
82
+ self._rate_limiter = AdaptiveRateLimiter()
83
+ self._authenticator = QobuzAuthenticator(self)
84
+
85
+ # Circuit breaker for API resilience
86
+ self._circuit_breaker = CircuitBreaker(
87
+ failure_threshold=5,
88
+ recovery_timeout=60,
89
+ success_threshold=2,
90
+ ignore_predicate=_is_expected_client_error,
91
+ )
92
+
93
+ @property
94
+ def authenticator(self) -> QobuzAuthenticator:
95
+ """Provides access to the authentication helper."""
96
+ return self._authenticator
97
+
98
+ async def _initialize_session(self) -> None:
99
+ """Ensures an active aiohttp session is available with compression enabled."""
100
+ if self._session is None or self._session.closed:
101
+ connector = aiohttp.TCPConnector(
102
+ limit=self.max_workers * 2,
103
+ limit_per_host=self.max_workers,
104
+ ttl_dns_cache=300,
105
+ enable_cleanup_closed=True,
106
+ )
107
+ self._session = aiohttp.ClientSession(
108
+ connector=connector,
109
+ headers=self._default_headers(),
110
+ timeout=aiohttp.ClientTimeout(total=60, connect=15, sock_read=30),
111
+ )
112
+
113
+ def _default_headers(self) -> dict[str, str]:
114
+ """
115
+ Browser-equivalent headers for the Qobuz web-player origin.
116
+
117
+ Sending the full Client Hints / Sec-Fetch set (matching a real Chrome
118
+ session) makes requests indistinguishable from the official web player,
119
+ which avoids the WAF 403s and app-id bans that a bare User-Agent
120
+ triggers. The User-Agent MUST stay consistent with the Sec-Ch-Ua brand
121
+ list or the mismatch itself becomes a fingerprinting signal.
122
+ """
123
+ return {
124
+ "User-Agent": (
125
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
126
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
127
+ ),
128
+ "Accept": "application/json, text/plain, */*",
129
+ "Accept-Language": "en-US,en;q=0.9",
130
+ "Accept-Encoding": "gzip, deflate, br",
131
+ "Origin": "https://play.qobuz.com",
132
+ "Referer": "https://play.qobuz.com/",
133
+ "Sec-Ch-Ua": (
134
+ '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"'
135
+ ),
136
+ "Sec-Ch-Ua-Mobile": "?0",
137
+ "Sec-Ch-Ua-Platform": '"Windows"',
138
+ "Sec-Fetch-Dest": "empty",
139
+ "Sec-Fetch-Mode": "cors",
140
+ "Sec-Fetch-Site": "same-site",
141
+ "X-App-Id": self.app_id,
142
+ }
143
+
144
+ async def close(self) -> None:
145
+ """Gracefully closes the aiohttp session."""
146
+ if self._session and not self._session.closed:
147
+ await self._session.close()
148
+
149
+ def _prepare_get_file_url_params(
150
+ self, track_id: str, format_id: int, secret_override: str | None = None
151
+ ) -> dict[str, Any]:
152
+ """
153
+ Builds the signed parameter dictionary for the 'track/getFileUrl' endpoint.
154
+ """
155
+ if format_id not in (5, 6, 7, 27):
156
+ raise InvalidQualityError(
157
+ f"Invalid format_id: {format_id}. Must be one of 5, 6, 7, or 27."
158
+ )
159
+
160
+ unix_ts = int(time.time())
161
+ secret = secret_override or self.app_secret
162
+ if not secret:
163
+ raise InvalidAppSecretError(
164
+ "App secret has not been configured. Cannot sign request."
165
+ )
166
+
167
+ sig_str = (
168
+ f"trackgetFileUrlformat_id{format_id}intentstreamtrack_id"
169
+ f"{track_id}{unix_ts}{secret}"
170
+ )
171
+ request_sig = hashlib.md5(sig_str.encode("utf-8")).hexdigest() # noqa: S324
172
+
173
+ return {
174
+ "request_ts": unix_ts,
175
+ "request_sig": request_sig,
176
+ "track_id": track_id,
177
+ "format_id": format_id,
178
+ "intent": "stream",
179
+ }
180
+
181
+ # Number of attempts for transient failures (429/5xx/network).
182
+ MAX_API_ATTEMPTS = 3
183
+
184
+ async def api_call(self, endpoint: str, **kwargs: Any) -> dict[str, Any]:
185
+ """
186
+ Makes an authenticated API call with rate limiting, retry logic, and
187
+ circuit breaker.
188
+
189
+ Compression is automatically applied to JSON responses by aiohttp when
190
+ the server sends Content-Encoding header.
191
+ """
192
+ await self._initialize_session()
193
+
194
+ # Check circuit breaker before making request
195
+ try:
196
+ async with self._circuit_breaker:
197
+ return await self._request_with_retry(endpoint, kwargs)
198
+ except CircuitBreakerError as e:
199
+ log.error(f"[red]Circuit breaker is open for API calls: {e}[/red]")
200
+ raise
201
+ except Exception as e:
202
+ log.debug(f"API call to {endpoint} failed: {e}")
203
+ raise
204
+
205
+ async def _request_with_retry(
206
+ self, endpoint: str, params: dict[str, Any]
207
+ ) -> dict[str, Any]:
208
+ """
209
+ Executes a single API request, retrying transient failures (HTTP 429,
210
+ 5xx, and network/timeout errors) with exponential backoff. Client-side
211
+ 4xx errors are raised immediately without retrying.
212
+ """
213
+ delay = 1.0
214
+ last_exc: Exception | None = None
215
+ for attempt in range(1, self.MAX_API_ATTEMPTS + 1):
216
+ try:
217
+ return await self._do_request(endpoint, params)
218
+ except aiohttp.ClientResponseError as e:
219
+ last_exc = e
220
+ is_retryable = e.status == 429 or 500 <= e.status < 600
221
+ if not is_retryable or attempt == self.MAX_API_ATTEMPTS:
222
+ raise
223
+ log.debug(
224
+ f"Transient HTTP {e.status} on {endpoint} "
225
+ f"(attempt {attempt}/{self.MAX_API_ATTEMPTS}); "
226
+ f"retrying in {delay:.1f}s."
227
+ )
228
+ except (aiohttp.ClientError, TimeoutError) as e:
229
+ last_exc = e
230
+ if attempt == self.MAX_API_ATTEMPTS:
231
+ raise
232
+ log.debug(
233
+ f"Network error on {endpoint} "
234
+ f"(attempt {attempt}/{self.MAX_API_ATTEMPTS}): {e}; "
235
+ f"retrying in {delay:.1f}s."
236
+ )
237
+ await asyncio.sleep(delay)
238
+ delay *= 2
239
+
240
+ # Unreachable in practice: the final attempt always raises above.
241
+ if last_exc is not None:
242
+ raise last_exc
243
+ raise RuntimeError(f"API call to {endpoint} exhausted all retries.")
244
+
245
+ async def _do_request(
246
+ self, endpoint: str, kwargs: dict[str, Any]
247
+ ) -> dict[str, Any]:
248
+ """Performs one rate-limited HTTP request and parses the JSON body."""
249
+ await self._rate_limiter.acquire()
250
+
251
+ params = kwargs.copy()
252
+
253
+ if endpoint == "track/getFileUrl":
254
+ params = self._prepare_get_file_url_params(
255
+ track_id=params.pop("id"),
256
+ format_id=params.pop("fmt_id"),
257
+ secret_override=params.pop("sec", None),
258
+ )
259
+
260
+ if self.user_auth_token:
261
+ params["user_auth_token"] = self.user_auth_token
262
+
263
+ async with self._session.get(self.BASE_URL + endpoint, params=params) as r:
264
+ # Check if response was compressed (for logging)
265
+ was_compressed = r.headers.get("Content-Encoding") in (
266
+ "gzip",
267
+ "deflate",
268
+ "br",
269
+ )
270
+ if was_compressed:
271
+ log.debug(
272
+ f"API response for {endpoint} was compressed "
273
+ f"({r.headers.get('Content-Encoding')})"
274
+ )
275
+
276
+ if r.status == 429:
277
+ await self._rate_limiter.on_429()
278
+ r.raise_for_status()
279
+
280
+ if endpoint == "user/login":
281
+ if r.status == 401:
282
+ raise AuthenticationError("Invalid email or password.")
283
+ if r.status == 400 and "Invalid application" in await r.text():
284
+ raise InvalidAppIdError("The provided App ID is invalid.")
285
+
286
+ if endpoint == "track/getFileUrl" and r.status == 400:
287
+ raise InvalidAppSecretError("The app secret is invalid or has expired.")
288
+
289
+ r.raise_for_status()
290
+ return self._normalize_json_strings(await r.json())
291
+
292
+ @staticmethod
293
+ def _normalize_json_strings(obj: Any) -> Any:
294
+ """
295
+ Recursively normalize Unicode strings in an API response to NFC form.
296
+
297
+ Qobuz returns some text in decomposed (NFD) form, which produces
298
+ inconsistent tags and mangled filenames on Windows/macOS. We also fold
299
+ an ASCII "..." into a real ellipsis (U+2026) since a literal "..." in a
300
+ title tends to collide with path-truncation logic. URLs are left
301
+ untouched.
302
+ """
303
+ if isinstance(obj, str):
304
+ if "..." in obj and "://" not in obj:
305
+ obj = obj.replace("...", "\u2026")
306
+ return unicodedata.normalize("NFC", obj)
307
+ if isinstance(obj, dict):
308
+ return {
309
+ key: QobuzAPIClient._normalize_json_strings(value)
310
+ for key, value in obj.items()
311
+ }
312
+ if isinstance(obj, list):
313
+ return [QobuzAPIClient._normalize_json_strings(item) for item in obj]
314
+ return obj
315
+
316
+ async def _yield_paginated(
317
+ self, endpoint: str, item_key: str, **kwargs: Any
318
+ ) -> AsyncGenerator[dict[str, Any], None]:
319
+ """
320
+ Generator for handling paginated API endpoints.
321
+ """
322
+ offset = 0
323
+ limit = 200
324
+ total_items = 0
325
+
326
+ while True:
327
+ response = await self.api_call(
328
+ endpoint, offset=offset, limit=limit, **kwargs
329
+ )
330
+
331
+ if offset == 0:
332
+ total_items = response.get(f"{item_key}_count", 0)
333
+
334
+ items_in_response = len(response.get(item_key, {}).get("items", []))
335
+ if not items_in_response:
336
+ break
337
+
338
+ yield response
339
+
340
+ offset += items_in_response
341
+
342
+ # A short page reliably signals the end, regardless of whether the
343
+ # API returned a usable "<item>_count" total (it sometimes doesn't).
344
+ if items_in_response < limit:
345
+ break
346
+ if total_items and offset >= total_items:
347
+ break
348
+
349
+ # Public API Methods
350
+ async def fetch_album_metadata(self, album_id: str) -> dict[str, Any]:
351
+ return await self.api_call("album/get", album_id=album_id)
352
+
353
+ async def fetch_track_metadata(self, track_id: str) -> dict[str, Any]:
354
+ return await self.api_call("track/get", track_id=track_id)
355
+
356
+ async def fetch_track_url(self, track_id: str, format_id: int) -> dict[str, Any]:
357
+ return await self.api_call("track/getFileUrl", id=track_id, fmt_id=format_id)
358
+
359
+ def fetch_artist_discography(
360
+ self, artist_id: str
361
+ ) -> AsyncGenerator[dict[str, Any], None]:
362
+ return self._yield_paginated(
363
+ "artist/get", item_key="albums", artist_id=artist_id, extra="albums"
364
+ )
365
+
366
+ def fetch_playlist_tracks(
367
+ self, playlist_id: str
368
+ ) -> AsyncGenerator[dict[str, Any], None]:
369
+ return self._yield_paginated(
370
+ "playlist/get", item_key="tracks", playlist_id=playlist_id, extra="tracks"
371
+ )
372
+
373
+ def fetch_label_discography(
374
+ self, label_id: str
375
+ ) -> AsyncGenerator[dict[str, Any], None]:
376
+ return self._yield_paginated(
377
+ "label/get", item_key="albums", label_id=label_id, extra="albums"
378
+ )
379
+
380
+ async def search_tracks(self, query: str, limit: int = 50) -> dict[str, Any]:
381
+ return await self.api_call("track/search", query=query, limit=limit)
@@ -47,19 +47,26 @@ class AdaptiveRateLimiter:
47
47
 
48
48
  async def acquire(self) -> None:
49
49
  """
50
- Waits if necessary to respect the current rate limit before allowing a call
51
- to proceed.
50
+ Waits if necessary to respect the current rate limit before allowing a
51
+ call to proceed.
52
+
53
+ The wait is computed while holding the lock but performed *after*
54
+ releasing it, so a slow caller's sleep does not serialize every other
55
+ concurrent caller. Each caller reserves its own slot by advancing
56
+ ``_last_call_time`` before sleeping.
52
57
  """
53
58
  async with self._lock:
59
+ now = time.monotonic()
60
+
54
61
  # Gradually recover the rate if no 429 errors have occurred recently
55
- if time.monotonic() - self._last_429_time > 300: # 5 minutes
62
+ if now - self._last_429_time > 300: # 5 minutes
56
63
  self._rate = min(self._max_rate, self._rate * 1.005) # Slow recovery
57
64
  self._min_interval = 1.0 / self._rate
58
65
 
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)
66
+ # Reserve the next slot: the earliest time this call may proceed.
67
+ scheduled_time = max(now, self._last_call_time + self._min_interval)
68
+ self._last_call_time = scheduled_time
69
+ wait_time = scheduled_time - now
64
70
 
65
- self._last_call_time = asyncio.get_event_loop().time()
71
+ if wait_time > 0:
72
+ await asyncio.sleep(wait_time)
@@ -178,7 +178,8 @@ def init(
178
178
  secrets = list(bundle.extract_secrets().values())
179
179
  console.print("[green]✓ Secrets fetched successfully.[/green]")
180
180
  except Exception as e:
181
- console.print(f"[red]✗ Failed to fetch secrets: {e}[/red]")
181
+ detail = str(e) or type(e).__name__
182
+ console.print(f"[red]✗ Failed to fetch secrets: {detail}[/red]")
182
183
  raise typer.Exit(code=1) from e
183
184
  settings = {"app_id": app_id, "secrets": secrets}
184
185
  if len(credentials) == 1:
@@ -6,6 +6,7 @@ statistics.
6
6
 
7
7
  import asyncio
8
8
  import logging
9
+ import time
9
10
  from datetime import datetime
10
11
  from typing import Any
11
12
 
@@ -85,6 +86,11 @@ class ProgressManager:
85
86
  self._overall_task_id: TaskID | None = None
86
87
  self._active_tasks: dict[TaskID, dict] = {}
87
88
 
89
+ # Throttle expensive full-layout rebuilds; the Live object still
90
+ # refreshes the last-built layout at its own refresh_per_second.
91
+ self._last_display_update = 0.0
92
+ self._display_min_interval = 0.1 # seconds (~10 rebuilds/sec)
93
+
88
94
  def log_message(self, message: str, level: str = "info"):
89
95
  """Unified logging respecting dry_run mode."""
90
96
  if self.dry_run:
@@ -293,13 +299,23 @@ class ProgressManager:
293
299
  border_style="green",
294
300
  )
295
301
 
296
- def _update_display(self):
302
+ def _update_display(self, force: bool = False):
297
303
  """
298
- Updates all panels in the layout, letting the Live object handle refresh rate.
304
+ Rebuilds all panels in the layout, letting the Live object handle the
305
+ actual on-screen refresh rate.
306
+
307
+ Rebuilds are throttled to a few times per second because they are
308
+ expensive; callers that change structure (task added/removed) pass
309
+ ``force=True`` so those changes appear immediately.
299
310
  """
300
311
  if self.dry_run or not self._layout:
301
312
  return
302
313
 
314
+ now = time.monotonic()
315
+ if not force and (now - self._last_display_update) < self._display_min_interval:
316
+ return
317
+ self._last_display_update = now
318
+
303
319
  self._layout["header"].update(self._generate_header())
304
320
  self._layout["stats"].update(self._generate_stats_panel())
305
321
  self._layout["album_context"].update(self._generate_album_context_panel())
@@ -369,7 +385,7 @@ class ProgressManager:
369
385
  self._stats["peak_concurrent"] = max(
370
386
  self._stats["peak_concurrent"], self._stats["active_downloads"]
371
387
  )
372
- self._update_display()
388
+ self._update_display(force=True)
373
389
  return task_id
374
390
 
375
391
  def update_task_progress(self, task_id: TaskID, completed: int):
@@ -402,7 +418,7 @@ class ProgressManager:
402
418
  + self._stats["skipped"]
403
419
  ),
404
420
  )
405
- self._update_display()
421
+ self._update_display(force=True)
406
422
  except KeyError:
407
423
  pass
408
424
 
@@ -417,7 +433,7 @@ class ProgressManager:
417
433
  + self._stats["skipped"]
418
434
  ),
419
435
  )
420
- self._update_display()
436
+ self._update_display(force=True)
421
437
 
422
438
  def get_statistics(self) -> dict[str, Any]:
423
439
  return self._stats.copy()
@@ -426,7 +442,7 @@ class ProgressManager:
426
442
  if self.dry_run:
427
443
  return self
428
444
  self._layout = self._create_layout()
429
- self._update_display()
445
+ self._update_display(force=True)
430
446
  self._live = Live(
431
447
  self._layout,
432
448
  console=self.console,