ttydal 1.0.0__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.
- ttydal/__init__.py +47 -0
- ttydal/api_logger.py +331 -0
- ttydal/app.py +604 -0
- ttydal/components/__init__.py +1 -0
- ttydal/components/albums_list.py +366 -0
- ttydal/components/cache_modal.py +178 -0
- ttydal/components/login_modal.py +251 -0
- ttydal/components/player_bar.py +165 -0
- ttydal/components/search_modal.py +358 -0
- ttydal/components/tracks_list.py +531 -0
- ttydal/config.py +126 -0
- ttydal/credentials.py +68 -0
- ttydal/exceptions.py +302 -0
- ttydal/logger.py +129 -0
- ttydal/pages/__init__.py +1 -0
- ttydal/pages/config_page.py +241 -0
- ttydal/pages/player_page.py +176 -0
- ttydal/player.py +244 -0
- ttydal/services/__init__.py +188 -0
- ttydal/services/playback_service.py +92 -0
- ttydal/services/tracks_cache.py +175 -0
- ttydal/tidal_client.py +427 -0
- ttydal-1.0.0.dist-info/METADATA +261 -0
- ttydal-1.0.0.dist-info/RECORD +26 -0
- ttydal-1.0.0.dist-info/WHEEL +4 -0
- ttydal-1.0.0.dist-info/entry_points.txt +3 -0
ttydal/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""ttydal - Tidal in your terminal!"""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import traceback
|
|
5
|
+
from ttydal.logger import log
|
|
6
|
+
from ttydal.app import TtydalApp
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
"""Launch the ttydal TUI application."""
|
|
11
|
+
log("="*80)
|
|
12
|
+
log("Starting ttydal application")
|
|
13
|
+
log("="*80)
|
|
14
|
+
|
|
15
|
+
app = None
|
|
16
|
+
try:
|
|
17
|
+
log("Creating TtydalApp instance...")
|
|
18
|
+
app = TtydalApp()
|
|
19
|
+
log("TtydalApp instance created successfully")
|
|
20
|
+
|
|
21
|
+
log("Starting app.run()...")
|
|
22
|
+
app.run()
|
|
23
|
+
log("App.run() completed normally")
|
|
24
|
+
except KeyboardInterrupt:
|
|
25
|
+
log("Received KeyboardInterrupt (Ctrl+C)")
|
|
26
|
+
except Exception as e:
|
|
27
|
+
log(f"ERROR: Exception caught in main: {e}")
|
|
28
|
+
log(f"Exception type: {type(e).__name__}")
|
|
29
|
+
log("Full traceback:")
|
|
30
|
+
log(traceback.format_exc())
|
|
31
|
+
|
|
32
|
+
print(f"Error starting ttydal: {e}", file=sys.stderr)
|
|
33
|
+
traceback.print_exc()
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
finally:
|
|
36
|
+
log("Performing final cleanup...")
|
|
37
|
+
if app is not None:
|
|
38
|
+
try:
|
|
39
|
+
# Ensure player is shutdown
|
|
40
|
+
if hasattr(app, 'player'):
|
|
41
|
+
log(" - Final player shutdown check...")
|
|
42
|
+
app.player.shutdown()
|
|
43
|
+
except Exception as e:
|
|
44
|
+
log(f" - Error during final cleanup: {e}")
|
|
45
|
+
log("Application exited")
|
|
46
|
+
log("="*80)
|
|
47
|
+
|
ttydal/api_logger.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""HTTP API request/response logger for ttydal.
|
|
2
|
+
|
|
3
|
+
This module intercepts all HTTP requests made by the application and logs
|
|
4
|
+
them to a dedicated debug-api.log file with full details.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class APILogger:
|
|
14
|
+
"""Singleton HTTP request/response logger."""
|
|
15
|
+
|
|
16
|
+
_instance = None
|
|
17
|
+
_original_request = None
|
|
18
|
+
|
|
19
|
+
def __new__(cls):
|
|
20
|
+
"""Ensure only one instance exists."""
|
|
21
|
+
if cls._instance is None:
|
|
22
|
+
cls._instance = super().__new__(cls)
|
|
23
|
+
cls._instance._initialized = False
|
|
24
|
+
return cls._instance
|
|
25
|
+
|
|
26
|
+
def __init__(self):
|
|
27
|
+
"""Initialize the API logger (lazy - no files created here)."""
|
|
28
|
+
if self._initialized:
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
self.log_dir = Path.home() / ".ttydal"
|
|
32
|
+
self.log_file = self.log_dir / "debug-api.log"
|
|
33
|
+
self._file_setup_done = False
|
|
34
|
+
self._initialized = True
|
|
35
|
+
|
|
36
|
+
def _is_logging_enabled(self) -> bool:
|
|
37
|
+
"""Check if API logging is enabled in config."""
|
|
38
|
+
try:
|
|
39
|
+
from ttydal.config import ConfigManager
|
|
40
|
+
|
|
41
|
+
config = ConfigManager()
|
|
42
|
+
return config.api_logging_enabled
|
|
43
|
+
except Exception:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
def _setup_log_file(self) -> None:
|
|
47
|
+
"""Setup the API log file (called lazily on first log)."""
|
|
48
|
+
if self._file_setup_done:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
# ASCII art header
|
|
54
|
+
ascii_art = """
|
|
55
|
+
______ ______ __ __ _____ ______ __ ______ ______ __
|
|
56
|
+
/\\__ _\\ /\\__ _\\ /\\ \\_\\ \\ /\\ __-. /\\ __ \\ /\\ \\ /\\ __ \\ /\\ == \\ /\\ \\
|
|
57
|
+
\\/_/\\ \\/ \\/_/\\ \\/ \\ \\____ \\ \\ \\ \\/\\ \\ \\ \\ __ \\ \\ \\ \\____ \\ \\ __ \\ \\ \\ _-/ \\ \\ \\
|
|
58
|
+
\\ \\_\\ \\ \\_\\ \\/\\_____\\ \\ \\____- \\ \\_\\ \\_\\ \\ \\_____\\ \\ \\_\\ \\_\\ \\ \\_\\ \\ \\_\\
|
|
59
|
+
\\/_/ \\/_/ \\/_____/ \\/____/ \\/_/\\/_/ \\/_____/ \\/_/\\/_/ \\/_/ \\/_/
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
# Write session start marker
|
|
63
|
+
with open(self.log_file, "a", encoding="utf-8") as f:
|
|
64
|
+
f.write(f"\n{'=' * 100}\n")
|
|
65
|
+
f.write(ascii_art)
|
|
66
|
+
f.write(f"API Logging Session started at {datetime.now().isoformat()}\n")
|
|
67
|
+
f.write(f"{'=' * 100}\n\n")
|
|
68
|
+
|
|
69
|
+
self._file_setup_done = True
|
|
70
|
+
|
|
71
|
+
def _format_headers(self, headers: dict) -> str:
|
|
72
|
+
"""Format headers for logging.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
headers: Headers dictionary
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Formatted headers string
|
|
79
|
+
"""
|
|
80
|
+
if not headers:
|
|
81
|
+
return " (no headers)"
|
|
82
|
+
|
|
83
|
+
lines = []
|
|
84
|
+
for key, value in headers.items():
|
|
85
|
+
lines.append(f" {key}: {value}")
|
|
86
|
+
return "\n".join(lines)
|
|
87
|
+
|
|
88
|
+
def _format_cookies(self, cookies: Any) -> str:
|
|
89
|
+
"""Format cookies for logging.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
cookies: Cookies object or dict
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Formatted cookies string
|
|
96
|
+
"""
|
|
97
|
+
if not cookies:
|
|
98
|
+
return " (no cookies)"
|
|
99
|
+
|
|
100
|
+
# Handle different cookie types
|
|
101
|
+
if hasattr(cookies, "items"):
|
|
102
|
+
cookie_dict = (
|
|
103
|
+
dict(cookies.items()) if hasattr(cookies, "items") else cookies
|
|
104
|
+
)
|
|
105
|
+
elif hasattr(cookies, "get_dict"):
|
|
106
|
+
cookie_dict = cookies.get_dict()
|
|
107
|
+
else:
|
|
108
|
+
return f" {cookies}"
|
|
109
|
+
|
|
110
|
+
if not cookie_dict:
|
|
111
|
+
return " (no cookies)"
|
|
112
|
+
|
|
113
|
+
lines = []
|
|
114
|
+
for key, value in cookie_dict.items():
|
|
115
|
+
lines.append(f" {key}: {value}")
|
|
116
|
+
return "\n".join(lines)
|
|
117
|
+
|
|
118
|
+
def _format_body(self, body: Any, content_type: str = "") -> str:
|
|
119
|
+
"""Format request/response body for logging.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
body: Body content (str, bytes, dict, etc.)
|
|
123
|
+
content_type: Content-Type header value
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Formatted body string
|
|
127
|
+
"""
|
|
128
|
+
if body is None or body == "":
|
|
129
|
+
return " (empty body)"
|
|
130
|
+
|
|
131
|
+
# Handle bytes
|
|
132
|
+
if isinstance(body, bytes):
|
|
133
|
+
# Try to decode as text
|
|
134
|
+
try:
|
|
135
|
+
body = body.decode("utf-8")
|
|
136
|
+
except UnicodeDecodeError:
|
|
137
|
+
return f" (binary data, {len(body)} bytes)"
|
|
138
|
+
|
|
139
|
+
# Handle dict/list (JSON)
|
|
140
|
+
if isinstance(body, (dict, list)):
|
|
141
|
+
try:
|
|
142
|
+
return " " + json.dumps(body, indent=2).replace("\n", "\n ")
|
|
143
|
+
except Exception:
|
|
144
|
+
return f" {body}"
|
|
145
|
+
|
|
146
|
+
# Handle string body
|
|
147
|
+
body_str = str(body)
|
|
148
|
+
|
|
149
|
+
# Try to parse as JSON for pretty printing
|
|
150
|
+
if "json" in content_type.lower() or body_str.strip().startswith(("{", "[")):
|
|
151
|
+
try:
|
|
152
|
+
parsed = json.loads(body_str)
|
|
153
|
+
return " " + json.dumps(parsed, indent=2).replace("\n", "\n ")
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
# Return as-is with indentation, NO TRUNCATION
|
|
158
|
+
return " " + body_str.replace("\n", "\n ")
|
|
159
|
+
|
|
160
|
+
def log_request_response(
|
|
161
|
+
self,
|
|
162
|
+
method: str,
|
|
163
|
+
url: str,
|
|
164
|
+
request_headers: dict,
|
|
165
|
+
request_cookies: Any,
|
|
166
|
+
request_body: Any,
|
|
167
|
+
response_status: int,
|
|
168
|
+
response_headers: dict,
|
|
169
|
+
response_body: Any,
|
|
170
|
+
elapsed_time: float,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Log a complete HTTP request/response pair.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
method: HTTP method (GET, POST, etc.)
|
|
176
|
+
url: Request URL
|
|
177
|
+
request_headers: Request headers dict
|
|
178
|
+
request_cookies: Request cookies
|
|
179
|
+
request_body: Request body content
|
|
180
|
+
response_status: HTTP response status code
|
|
181
|
+
response_headers: Response headers dict
|
|
182
|
+
response_body: Response body content (NOT TRUNCATED)
|
|
183
|
+
elapsed_time: Request duration in seconds
|
|
184
|
+
"""
|
|
185
|
+
# Check if API logging is enabled FIRST
|
|
186
|
+
if not self._is_logging_enabled():
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
# Setup log file lazily (only if logging is enabled)
|
|
190
|
+
self._setup_log_file()
|
|
191
|
+
|
|
192
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
|
193
|
+
|
|
194
|
+
log_entry = []
|
|
195
|
+
log_entry.append(f"\n{'=' * 100}")
|
|
196
|
+
log_entry.append(f"[{timestamp}] HTTP REQUEST/RESPONSE")
|
|
197
|
+
log_entry.append(f"{'=' * 100}")
|
|
198
|
+
|
|
199
|
+
# Request section
|
|
200
|
+
log_entry.append("\n>>> REQUEST:")
|
|
201
|
+
log_entry.append(f"Method: {method}")
|
|
202
|
+
log_entry.append(f"URL: {url}")
|
|
203
|
+
log_entry.append("\nHeaders:")
|
|
204
|
+
log_entry.append(self._format_headers(request_headers))
|
|
205
|
+
log_entry.append("\nCookies:")
|
|
206
|
+
log_entry.append(self._format_cookies(request_cookies))
|
|
207
|
+
log_entry.append("\nBody:")
|
|
208
|
+
|
|
209
|
+
content_type = (
|
|
210
|
+
request_headers.get("Content-Type", "") if request_headers else ""
|
|
211
|
+
)
|
|
212
|
+
log_entry.append(self._format_body(request_body, content_type))
|
|
213
|
+
|
|
214
|
+
# Response section
|
|
215
|
+
log_entry.append("\n<<< RESPONSE:")
|
|
216
|
+
log_entry.append(f"Status: {response_status}")
|
|
217
|
+
log_entry.append(f"Elapsed: {elapsed_time:.3f}s")
|
|
218
|
+
log_entry.append("\nHeaders:")
|
|
219
|
+
log_entry.append(self._format_headers(response_headers))
|
|
220
|
+
log_entry.append("\nBody (FULL CONTENT, NO TRUNCATION):")
|
|
221
|
+
|
|
222
|
+
response_content_type = (
|
|
223
|
+
response_headers.get("Content-Type", "") if response_headers else ""
|
|
224
|
+
)
|
|
225
|
+
log_entry.append(self._format_body(response_body, response_content_type))
|
|
226
|
+
|
|
227
|
+
log_entry.append(f"\n{'=' * 100}\n")
|
|
228
|
+
|
|
229
|
+
# Write to file
|
|
230
|
+
try:
|
|
231
|
+
with open(self.log_file, "a", encoding="utf-8") as f:
|
|
232
|
+
f.write("\n".join(log_entry))
|
|
233
|
+
except Exception as e:
|
|
234
|
+
# Silently fail - don't disrupt the application
|
|
235
|
+
print(f"[API Logger] Failed to write log: {e}")
|
|
236
|
+
|
|
237
|
+
def install(self) -> None:
|
|
238
|
+
"""Install HTTP request interceptor.
|
|
239
|
+
|
|
240
|
+
This monkey-patches the requests library to intercept all HTTP calls.
|
|
241
|
+
"""
|
|
242
|
+
try:
|
|
243
|
+
import requests
|
|
244
|
+
|
|
245
|
+
# Store original request method if not already stored
|
|
246
|
+
if APILogger._original_request is None:
|
|
247
|
+
APILogger._original_request = requests.Session.request
|
|
248
|
+
|
|
249
|
+
# Create wrapper function
|
|
250
|
+
def logged_request(
|
|
251
|
+
session_self: Any, method: str, url: str, **kwargs: Any
|
|
252
|
+
) -> Any:
|
|
253
|
+
"""Wrapped request method that logs all calls."""
|
|
254
|
+
import time
|
|
255
|
+
|
|
256
|
+
# Extract request details
|
|
257
|
+
request_headers = kwargs.get("headers", {})
|
|
258
|
+
request_cookies = kwargs.get("cookies", session_self.cookies)
|
|
259
|
+
request_body = kwargs.get("data") or kwargs.get("json")
|
|
260
|
+
|
|
261
|
+
# Make the actual request
|
|
262
|
+
start_time = time.time()
|
|
263
|
+
response = APILogger._original_request(
|
|
264
|
+
session_self, method, url, **kwargs
|
|
265
|
+
)
|
|
266
|
+
elapsed_time = time.time() - start_time
|
|
267
|
+
|
|
268
|
+
# Extract response details
|
|
269
|
+
response_headers = dict(response.headers)
|
|
270
|
+
response_status = response.status_code
|
|
271
|
+
|
|
272
|
+
# Get response body - be careful not to consume the stream
|
|
273
|
+
try:
|
|
274
|
+
# For streaming responses, we can't easily log the full body
|
|
275
|
+
# without consuming it. Try to get it safely.
|
|
276
|
+
if hasattr(response, "_content") and response._content is not None:
|
|
277
|
+
response_body = response._content
|
|
278
|
+
elif hasattr(response, "text"):
|
|
279
|
+
response_body = response.text
|
|
280
|
+
else:
|
|
281
|
+
response_body = "(streaming response, body not captured)"
|
|
282
|
+
except Exception:
|
|
283
|
+
response_body = "(could not capture response body)"
|
|
284
|
+
|
|
285
|
+
# Log the request/response
|
|
286
|
+
self.log_request_response(
|
|
287
|
+
method=method.upper(),
|
|
288
|
+
url=url,
|
|
289
|
+
request_headers=request_headers,
|
|
290
|
+
request_cookies=request_cookies,
|
|
291
|
+
request_body=request_body,
|
|
292
|
+
response_status=response_status,
|
|
293
|
+
response_headers=response_headers,
|
|
294
|
+
response_body=response_body,
|
|
295
|
+
elapsed_time=elapsed_time,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
return response
|
|
299
|
+
|
|
300
|
+
# Patch the Session.request method
|
|
301
|
+
requests.Session.request = logged_request
|
|
302
|
+
|
|
303
|
+
except ImportError:
|
|
304
|
+
# requests library not available, skip patching
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# Global logger instance
|
|
309
|
+
_api_logger = None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def get_api_logger() -> APILogger:
|
|
313
|
+
"""Get the global API logger instance.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
APILogger instance
|
|
317
|
+
"""
|
|
318
|
+
global _api_logger
|
|
319
|
+
if _api_logger is None:
|
|
320
|
+
_api_logger = APILogger()
|
|
321
|
+
return _api_logger
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def install_api_logger() -> None:
|
|
325
|
+
"""Install the API logger to intercept all HTTP requests.
|
|
326
|
+
|
|
327
|
+
Call this early in your application startup to ensure all
|
|
328
|
+
HTTP requests are logged.
|
|
329
|
+
"""
|
|
330
|
+
logger = get_api_logger()
|
|
331
|
+
logger.install()
|