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 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()