qobuz-cli 0.0.2__tar.gz → 0.0.3__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 (44) hide show
  1. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/PKG-INFO +10 -10
  2. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/pyproject.toml +11 -11
  3. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/__main__.py +3 -3
  4. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/api/auth.py +8 -6
  5. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/api/client.py +124 -56
  6. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/api/rate_limiter.py +16 -9
  7. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/cli/app.py +2 -1
  8. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/cli/progress_manager.py +22 -6
  9. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/core/download_manager.py +18 -10
  10. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/core/track_processor.py +9 -4
  11. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/media/downloader.py +14 -6
  12. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/models/config.py +26 -2
  13. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/models/stats.py +12 -7
  14. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/storage/config_manager.py +8 -6
  15. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/utils/circuit_breaker.py +11 -1
  16. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/utils/path.py +7 -1
  17. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/web/bundle_fetcher.py +26 -6
  18. qobuz_cli-0.0.3/uv.lock +664 -0
  19. qobuz_cli-0.0.2/qobuz_cli/utils/batch_fetcher.py +0 -112
  20. qobuz_cli-0.0.2/qobuz_cli/utils/config_validator.py +0 -210
  21. qobuz_cli-0.0.2/qobuz_cli/utils/structured_logger.py +0 -338
  22. qobuz_cli-0.0.2/uv.lock +0 -1205
  23. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/.github/workflows/publish.yml +0 -0
  24. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/.gitignore +0 -0
  25. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/LICENSE +0 -0
  26. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/README.md +0 -0
  27. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/__init__.py +0 -0
  28. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/api/__init__.py +0 -0
  29. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/cli/__init__.py +0 -0
  30. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/cli/formatters.py +0 -0
  31. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/core/__init__.py +0 -0
  32. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/exceptions.py +0 -0
  33. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/media/__init__.py +0 -0
  34. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/media/integrity.py +0 -0
  35. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/media/tagger.py +0 -0
  36. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/models/__init__.py +0 -0
  37. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/storage/__init__.py +0 -0
  38. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/storage/archive.py +0 -0
  39. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/storage/cache.py +0 -0
  40. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/utils/__init__.py +0 -0
  41. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/utils/discography.py +0 -0
  42. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/utils/formatting.py +0 -0
  43. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/utils/playlist.py +0 -0
  44. {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/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.3
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,15 +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
18
+ Requires-Python: >=3.14
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
27
27
  Provides-Extra: dev
28
28
  Requires-Dist: jsonschema; extra == 'dev'
29
29
  Requires-Dist: ruff>=0.10.0; extra == 'dev'
@@ -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.3"
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.14"
12
12
  license = "GPL-3.0-or-later"
13
13
  license-files = ["LICEN[CS]E.*"]
14
14
  keywords = ["qobuz", "music", "downloader", "cli", "audio"]
@@ -24,14 +24,14 @@ 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
37
  [project.optional-dependencies]
@@ -48,7 +48,7 @@ qcli = "qobuz_cli.__main__:main"
48
48
 
49
49
  [tool.ruff]
50
50
  line-length = 88
51
- target-version = "py310"
51
+ target-version = "py314"
52
52
 
53
53
  [tool.ruff.lint]
54
54
  preview = true
@@ -22,7 +22,7 @@ def main() -> None:
22
22
  try:
23
23
  sys.stdout.reconfigure(encoding="utf-8")
24
24
  sys.stderr.reconfigure(encoding="utf-8")
25
- except (TypeError, AttributeError):
25
+ except TypeError, AttributeError:
26
26
  pass
27
27
 
28
28
  log = logging.getLogger("qobuz_cli")
@@ -30,9 +30,9 @@ def main() -> None:
30
30
 
31
31
  try:
32
32
  app()
33
- except (typer.Exit, typer.Abort):
33
+ except typer.Exit, typer.Abort:
34
34
  pass
35
- except (KeyboardInterrupt, asyncio.CancelledError):
35
+ except KeyboardInterrupt, asyncio.CancelledError:
36
36
  console.print("\n[yellow]⚠️ Operation cancelled by user.[/yellow]")
37
37
  sys.exit(0)
38
38
  except QobuzCliError as e:
@@ -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]}...")
@@ -2,6 +2,7 @@
2
2
  Enhanced API client with compression for metadata and circuit breaker protection.
3
3
  """
4
4
 
5
+ import asyncio
5
6
  import hashlib
6
7
  import logging
7
8
  import time
@@ -24,6 +25,27 @@ from .rate_limiter import AdaptiveRateLimiter
24
25
  log = logging.getLogger(__name__)
25
26
 
26
27
 
28
+ def _is_expected_client_error(exc: BaseException) -> bool:
29
+ """
30
+ Returns True for errors that mean the API responded but rejected the
31
+ request for client-side reasons (auth/quality/4xx). These must not count as
32
+ circuit-breaker failures -- the service itself is healthy.
33
+ """
34
+ if isinstance(
35
+ exc,
36
+ (
37
+ AuthenticationError,
38
+ InvalidAppIdError,
39
+ InvalidAppSecretError,
40
+ InvalidQualityError,
41
+ ),
42
+ ):
43
+ return True
44
+ if isinstance(exc, aiohttp.ClientResponseError):
45
+ return 400 <= exc.status < 500 and exc.status != 429
46
+ return False
47
+
48
+
27
49
  class QobuzAPIClient:
28
50
  """
29
51
  Optimized async client for the Qobuz JSON API (v0.2).
@@ -64,6 +86,7 @@ class QobuzAPIClient:
64
86
  failure_threshold=5,
65
87
  recovery_timeout=60,
66
88
  success_threshold=2,
89
+ ignore_predicate=_is_expected_client_error,
67
90
  )
68
91
 
69
92
  @property
@@ -129,6 +152,9 @@ class QobuzAPIClient:
129
152
  "intent": "stream",
130
153
  }
131
154
 
155
+ # Number of attempts for transient failures (429/5xx/network).
156
+ MAX_API_ATTEMPTS = 3
157
+
132
158
  async def api_call(self, endpoint: str, **kwargs: Any) -> dict[str, Any]:
133
159
  """
134
160
  Makes an authenticated API call with rate limiting, retry logic, and
@@ -142,53 +168,7 @@ class QobuzAPIClient:
142
168
  # Check circuit breaker before making request
143
169
  try:
144
170
  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
-
171
+ return await self._request_with_retry(endpoint, kwargs)
192
172
  except CircuitBreakerError as e:
193
173
  log.error(f"[red]Circuit breaker is open for API calls: {e}[/red]")
194
174
  raise
@@ -196,9 +176,96 @@ class QobuzAPIClient:
196
176
  log.debug(f"API call to {endpoint} failed: {e}")
197
177
  raise
198
178
 
179
+ async def _request_with_retry(
180
+ self, endpoint: str, params: dict[str, Any]
181
+ ) -> dict[str, Any]:
182
+ """
183
+ Executes a single API request, retrying transient failures (HTTP 429,
184
+ 5xx, and network/timeout errors) with exponential backoff. Client-side
185
+ 4xx errors are raised immediately without retrying.
186
+ """
187
+ delay = 1.0
188
+ last_exc: Exception | None = None
189
+ for attempt in range(1, self.MAX_API_ATTEMPTS + 1):
190
+ try:
191
+ return await self._do_request(endpoint, params)
192
+ except aiohttp.ClientResponseError as e:
193
+ last_exc = e
194
+ is_retryable = e.status == 429 or 500 <= e.status < 600
195
+ if not is_retryable or attempt == self.MAX_API_ATTEMPTS:
196
+ raise
197
+ log.debug(
198
+ f"Transient HTTP {e.status} on {endpoint} "
199
+ f"(attempt {attempt}/{self.MAX_API_ATTEMPTS}); "
200
+ f"retrying in {delay:.1f}s."
201
+ )
202
+ except (aiohttp.ClientError, TimeoutError) as e:
203
+ last_exc = e
204
+ if attempt == self.MAX_API_ATTEMPTS:
205
+ raise
206
+ log.debug(
207
+ f"Network error on {endpoint} "
208
+ f"(attempt {attempt}/{self.MAX_API_ATTEMPTS}): {e}; "
209
+ f"retrying in {delay:.1f}s."
210
+ )
211
+ await asyncio.sleep(delay)
212
+ delay *= 2
213
+
214
+ # Unreachable in practice: the final attempt always raises above.
215
+ if last_exc is not None:
216
+ raise last_exc
217
+ raise RuntimeError(f"API call to {endpoint} exhausted all retries.")
218
+
219
+ async def _do_request(
220
+ self, endpoint: str, kwargs: dict[str, Any]
221
+ ) -> dict[str, Any]:
222
+ """Performs one rate-limited HTTP request and parses the JSON body."""
223
+ await self._rate_limiter.acquire()
224
+
225
+ params = kwargs.copy()
226
+
227
+ if endpoint == "track/getFileUrl":
228
+ params = self._prepare_get_file_url_params(
229
+ track_id=params.pop("id"),
230
+ format_id=params.pop("fmt_id"),
231
+ secret_override=params.pop("sec", None),
232
+ )
233
+
234
+ if self.user_auth_token:
235
+ params["user_auth_token"] = self.user_auth_token
236
+
237
+ async with self._session.get(self.BASE_URL + endpoint, params=params) as r:
238
+ # Check if response was compressed (for logging)
239
+ was_compressed = r.headers.get("Content-Encoding") in (
240
+ "gzip",
241
+ "deflate",
242
+ "br",
243
+ )
244
+ if was_compressed:
245
+ log.debug(
246
+ f"API response for {endpoint} was compressed "
247
+ f"({r.headers.get('Content-Encoding')})"
248
+ )
249
+
250
+ if r.status == 429:
251
+ await self._rate_limiter.on_429()
252
+ r.raise_for_status()
253
+
254
+ if endpoint == "user/login":
255
+ if r.status == 401:
256
+ raise AuthenticationError("Invalid email or password.")
257
+ if r.status == 400 and "Invalid application" in await r.text():
258
+ raise InvalidAppIdError("The provided App ID is invalid.")
259
+
260
+ if endpoint == "track/getFileUrl" and r.status == 400:
261
+ raise InvalidAppSecretError("The app secret is invalid or has expired.")
262
+
263
+ r.raise_for_status()
264
+ return await r.json()
265
+
199
266
  async def _yield_paginated(
200
267
  self, endpoint: str, item_key: str, **kwargs: Any
201
- ) -> AsyncGenerator[dict[str, Any], None]:
268
+ ) -> AsyncGenerator[dict[str, Any]]:
202
269
  """
203
270
  Generator for handling paginated API endpoints.
204
271
  """
@@ -221,7 +288,12 @@ class QobuzAPIClient:
221
288
  yield response
222
289
 
223
290
  offset += items_in_response
224
- if offset >= total_items:
291
+
292
+ # A short page reliably signals the end, regardless of whether the
293
+ # API returned a usable "<item>_count" total (it sometimes doesn't).
294
+ if items_in_response < limit:
295
+ break
296
+ if total_items and offset >= total_items:
225
297
  break
226
298
 
227
299
  # Public API Methods
@@ -236,21 +308,17 @@ class QobuzAPIClient:
236
308
 
237
309
  def fetch_artist_discography(
238
310
  self, artist_id: str
239
- ) -> AsyncGenerator[dict[str, Any], None]:
311
+ ) -> AsyncGenerator[dict[str, Any]]:
240
312
  return self._yield_paginated(
241
313
  "artist/get", item_key="albums", artist_id=artist_id, extra="albums"
242
314
  )
243
315
 
244
- def fetch_playlist_tracks(
245
- self, playlist_id: str
246
- ) -> AsyncGenerator[dict[str, Any], None]:
316
+ def fetch_playlist_tracks(self, playlist_id: str) -> AsyncGenerator[dict[str, Any]]:
247
317
  return self._yield_paginated(
248
318
  "playlist/get", item_key="tracks", playlist_id=playlist_id, extra="tracks"
249
319
  )
250
320
 
251
- def fetch_label_discography(
252
- self, label_id: str
253
- ) -> AsyncGenerator[dict[str, Any], None]:
321
+ def fetch_label_discography(self, label_id: str) -> AsyncGenerator[dict[str, Any]]:
254
322
  return self._yield_paginated(
255
323
  "label/get", item_key="albums", label_id=label_id, extra="albums"
256
324
  )
@@ -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,
@@ -23,7 +23,6 @@ from qobuz_cli.models.config import DownloadConfig
23
23
  from qobuz_cli.models.stats import DownloadStats
24
24
  from qobuz_cli.storage.archive import TrackArchive
25
25
  from qobuz_cli.storage.cache import CacheManager
26
- from qobuz_cli.utils.batch_fetcher import BatchMetadataFetcher
27
26
  from qobuz_cli.utils.discography import smart_discography_filter
28
27
  from qobuz_cli.utils.formatting import extract_artist_name
29
28
  from qobuz_cli.utils.path import create_dir, parse_qobuz_url
@@ -58,7 +57,6 @@ class DownloadManager:
58
57
  Tagger(config.embed_art),
59
58
  progress_manager,
60
59
  )
61
- self.batch_fetcher = BatchMetadataFetcher(api_client, max_concurrent=10)
62
60
 
63
61
  def cache_stats_callback(is_hit: bool):
64
62
  if is_hit:
@@ -303,7 +301,7 @@ class DownloadManager:
303
301
  self.cache.set(cache_key, track_meta)
304
302
 
305
303
  archive_status = {}
306
- if self.config.download_archive:
304
+ if self.config.download_archive and not self.config.dry_run:
307
305
  archive_status = await self.archive.check_if_tracks_exist([str(track_id)])
308
306
 
309
307
  if archive_status.get(str(track_id)):
@@ -421,11 +419,15 @@ class DownloadManager:
421
419
  )
422
420
  self.stats.albums_processed.add(f"playlist_{playlist_id}")
423
421
 
424
- for track in all_tracks:
425
- album_meta = track.get("album", {})
426
- await self._get_and_process_track(
427
- track, album_meta, output_dir_override=playlist_dir
422
+ playlist_tasks = [
423
+ self._get_and_process_track(
424
+ track,
425
+ track.get("album", {}),
426
+ output_dir_override=playlist_dir,
428
427
  )
428
+ for track in all_tracks
429
+ ]
430
+ await asyncio.gather(*playlist_tasks)
429
431
 
430
432
  if not self.config.no_m3u and not self.config.dry_run:
431
433
  generate_m3u(playlist_dir)
@@ -514,7 +516,7 @@ class DownloadManager:
514
516
  if self.config.download_archive:
515
517
  await self.archive.add_tracks([processed_meta])
516
518
  return processed_meta
517
- except (aiohttp.ClientError, asyncio.TimeoutError) as e:
519
+ except (TimeoutError, aiohttp.ClientError) as e:
518
520
  self.stats.tracks_failed += 1
519
521
  log.error(
520
522
  "[red] ✗ Network error for track "
@@ -567,7 +569,7 @@ class DownloadManager:
567
569
  ):
568
570
  response.raise_for_status()
569
571
  html = await response.text()
570
- except (aiohttp.ClientError, asyncio.TimeoutError) as e:
572
+ except (TimeoutError, aiohttp.ClientError) as e:
571
573
  log.error(f"[red]Failed to fetch Last.fm playlist: {e}[/red]")
572
574
  return
573
575
 
@@ -583,8 +585,14 @@ class DownloadManager:
583
585
  log.warning("[yellow]No tracks found on Last.fm page.[/yellow]")
584
586
  return
585
587
 
588
+ if len(artists) != len(titles):
589
+ log.warning(
590
+ "[yellow]Last.fm page returned mismatched artist/title counts "
591
+ f"({len(artists)} artists, {len(titles)} titles); pairing up to "
592
+ "the shorter list.[/yellow]"
593
+ )
586
594
  search_queries = [
587
- f"{artist} {title}" for artist, title in zip(artists, titles, strict=True)
595
+ f"{artist} {title}" for artist, title in zip(artists, titles, strict=False)
588
596
  ]
589
597
  log.info(f"Found {len(search_queries)} tracks. Searching for them on Qobuz...")
590
598
 
@@ -13,7 +13,7 @@ from typing import Any
13
13
  from rich.markup import escape
14
14
 
15
15
  from qobuz_cli.cli.progress_manager import ProgressManager
16
- from qobuz_cli.exceptions import FileIntegrityError
16
+ from qobuz_cli.exceptions import FileIntegrityError, QobuzCliError
17
17
  from qobuz_cli.media import Downloader, FileIntegrityChecker, Tagger
18
18
  from qobuz_cli.models.config import DownloadConfig, get_quality_info
19
19
  from qobuz_cli.models.stats import DownloadStats
@@ -125,8 +125,9 @@ class TrackProcessor:
125
125
 
126
126
  # Download cover art if needed (optimized double-checked locking)
127
127
  if not self.config.no_cover:
128
- album_id_str = str(album_meta.get("id"))
129
- if album_id_str:
128
+ album_id_val = album_meta.get("id")
129
+ if album_id_val:
130
+ album_id_str = str(album_id_val)
130
131
  cover_path = final_dir / "cover.jpg"
131
132
  # First check (outside lock) for performance
132
133
  path_exists = await asyncio.to_thread(cover_path.exists)
@@ -195,7 +196,7 @@ class TrackProcessor:
195
196
  max_workers=self.config.max_workers,
196
197
  )
197
198
 
198
- await asyncio.to_thread(
199
+ tag_success = await asyncio.to_thread(
199
200
  self.tagger.tag_file,
200
201
  str(temp_path),
201
202
  str(final_path),
@@ -203,6 +204,10 @@ class TrackProcessor:
203
204
  album_meta,
204
205
  is_mp3,
205
206
  )
207
+ if not tag_success:
208
+ raise QobuzCliError(
209
+ "Failed to write metadata tags to the downloaded file."
210
+ )
206
211
 
207
212
  is_valid = await (
208
213
  FileIntegrityChecker.check_mp3_async(str(final_path))
@@ -125,15 +125,23 @@ class Downloader:
125
125
  bytes_downloaded = 0
126
126
  last_speed_check = asyncio.get_event_loop().time()
127
127
  chunk_size = self._shared_chunk_size
128
-
129
- async for chunk in response.content.iter_chunked(chunk_size):
128
+ stream = response.content
129
+
130
+ # Manual read loop (instead of iter_chunked) so an
131
+ # adapted chunk size actually takes effect mid-download.
132
+ while True:
133
+ chunk = await stream.read(chunk_size)
134
+ if not chunk:
135
+ break
130
136
  await f.write(chunk)
131
137
  bytes_downloaded += len(chunk)
132
138
 
133
139
  if stats:
134
- await stats.update_speed_stats(
135
- stats.total_size_downloaded + bytes_downloaded,
136
- progress_manager,
140
+ # Report only this chunk's bytes; DownloadStats
141
+ # aggregates them into a session-wide counter for
142
+ # accurate concurrent speed measurement.
143
+ await stats.record_progress(
144
+ len(chunk), progress_manager
137
145
  )
138
146
  now = asyncio.get_event_loop().time()
139
147
  if now - last_speed_check > 2.0:
@@ -147,7 +155,7 @@ class Downloader:
147
155
  task_id, completed=bytes_downloaded
148
156
  )
149
157
  return
150
- except (aiohttp.ClientError, asyncio.TimeoutError) as e:
158
+ except (TimeoutError, aiohttp.ClientError) as e:
151
159
  last_exception = e
152
160
  log.debug(
153
161
  f"Download attempt {attempt}/{self.max_attempts} for "