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.
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/PKG-INFO +10 -10
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/pyproject.toml +11 -11
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/__main__.py +3 -3
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/api/auth.py +8 -6
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/api/client.py +124 -56
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/api/rate_limiter.py +16 -9
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/cli/app.py +2 -1
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/cli/progress_manager.py +22 -6
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/core/download_manager.py +18 -10
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/core/track_processor.py +9 -4
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/media/downloader.py +14 -6
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/models/config.py +26 -2
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/models/stats.py +12 -7
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/storage/config_manager.py +8 -6
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/utils/circuit_breaker.py +11 -1
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/utils/path.py +7 -1
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/web/bundle_fetcher.py +26 -6
- qobuz_cli-0.0.3/uv.lock +664 -0
- qobuz_cli-0.0.2/qobuz_cli/utils/batch_fetcher.py +0 -112
- qobuz_cli-0.0.2/qobuz_cli/utils/config_validator.py +0 -210
- qobuz_cli-0.0.2/qobuz_cli/utils/structured_logger.py +0 -338
- qobuz_cli-0.0.2/uv.lock +0 -1205
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/.github/workflows/publish.yml +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/.gitignore +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/LICENSE +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/README.md +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/__init__.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/api/__init__.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/cli/__init__.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/cli/formatters.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/core/__init__.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/exceptions.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/media/__init__.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/media/integrity.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/media/tagger.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/models/__init__.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/storage/__init__.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/storage/archive.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/storage/cache.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/utils/__init__.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/utils/discography.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/utils/formatting.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.3}/qobuz_cli/utils/playlist.py +0 -0
- {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.
|
|
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.
|
|
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 = "
|
|
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.
|
|
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 = "
|
|
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
|
|
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
|
|
33
|
+
except typer.Exit, typer.Abort:
|
|
34
34
|
pass
|
|
35
|
-
except
|
|
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:
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
]
|
|
117
|
-
results = await asyncio.gather(
|
|
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(
|
|
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.
|
|
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]
|
|
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
|
-
|
|
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]
|
|
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
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
track,
|
|
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
|
|
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
|
|
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=
|
|
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
|
-
|
|
129
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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 "
|