qobuz-cli 0.0.1__py3-none-any.whl

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