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.
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/PKG-INFO +10 -13
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/pyproject.toml +24 -14
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/api/auth.py +8 -6
- qobuz_cli-0.0.4/qobuz_cli/api/client.py +381 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/api/rate_limiter.py +16 -9
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/cli/app.py +2 -1
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/cli/progress_manager.py +22 -6
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/core/download_manager.py +18 -10
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/core/track_processor.py +9 -4
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/media/downloader.py +14 -6
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/media/tagger.py +9 -9
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/models/config.py +27 -2
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/models/stats.py +12 -7
- qobuz_cli-0.0.4/qobuz_cli/py.typed +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/storage/archive.py +3 -4
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/storage/cache.py +1 -2
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/storage/config_manager.py +8 -6
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/utils/circuit_breaker.py +11 -1
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/utils/path.py +7 -1
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/utils/playlist.py +13 -6
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/web/bundle_fetcher.py +27 -6
- qobuz_cli-0.0.4/uv.lock +962 -0
- qobuz_cli-0.0.2/qobuz_cli/api/client.py +0 -259
- 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.4}/.github/workflows/publish.yml +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/.gitignore +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/LICENSE +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/README.md +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/__init__.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/__main__.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/api/__init__.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/cli/__init__.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/cli/formatters.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/core/__init__.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/exceptions.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/media/__init__.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/media/integrity.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/models/__init__.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/storage/__init__.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/utils/__init__.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/utils/discography.py +0 -0
- {qobuz_cli-0.0.2 → qobuz_cli-0.0.4}/qobuz_cli/utils/formatting.py +0 -0
- {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.
|
|
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.
|
|
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 = "
|
|
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.
|
|
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 = "
|
|
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:
|
|
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]}...")
|
|
@@ -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
|
|
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,
|