serve.py (14.8 KB)
1 #!/usr/bin/env python3 2 """ 3 Starts HTTP server for local testing 4 Automatically manages a virtual environment for dependencies 5 """ 6 7 import http.server 8 import socketserver 9 import socket 10 import sys 11 import os 12 import json 13 import queue 14 import threading 15 import subprocess 16 import time 17 import urllib.parse 18 from pathlib import Path 19 20 DEFAULT_PORT = 8000 21 SCRIPT_DIR = Path(__file__).parent.absolute() 22 VENV_DIR = SCRIPT_DIR / "venv" 23 MIX_DIR = SCRIPT_DIR / "mix" 24 TRACKS_PATH = MIX_DIR / "tracks.json" 25 26 SUPPORTED_UPLOAD_EXTENSIONS = ('.mp3', '.m4a', '.ogg', '.flac', '.wav') 27 MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MB per request 28 29 # Serializes all reads/writes of tracks.json so rescans, reorders, and future 30 # uploads can't race each other. 31 tracks_lock = threading.Lock() 32 33 # Set of per-client SSE queues. Each connected /events client gets a queue; 34 # broadcast_tracks() pushes the latest tracks list onto every queue. 35 sse_subscribers = set() 36 sse_subscribers_lock = threading.Lock() 37 38 # Canonical serialization of the most recently broadcast tracks list, so we 39 # can fire on any content change (file add/remove/reorder or a hand-edit to 40 # tracks.json's metadata) instead of only when /mix's filenames change. 41 last_broadcast_serialized = None 42 43 44 def run_in_venv(): 45 """Re-run this script in the shared venv with serve.py's deps available""" 46 import scan 47 python_path = scan.setup_venv(packages=("qrcode", "mutagen")) 48 try: 49 subprocess.check_call([str(python_path), __file__, "--in-venv"]) 50 except (KeyboardInterrupt, subprocess.CalledProcessError): 51 pass 52 sys.exit(0) 53 54 55 def get_local_ip(): 56 """Get the local IP address for network access""" 57 try: 58 # Create a socket to determine the local IP 59 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 60 # Connect to a public DNS server (doesn't actually send data) 61 s.connect(("8.8.8.8", 80)) 62 local_ip = s.getsockname()[0] 63 s.close() 64 return local_ip 65 except Exception: 66 return "Unable to determine" 67 68 69 def find_available_port(start_port=DEFAULT_PORT, max_attempts=10): 70 """Find an available port starting from start_port""" 71 for port in range(start_port, start_port + max_attempts): 72 try: 73 with socketserver.TCPServer(("", port), None) as s: 74 return port 75 except OSError: 76 continue 77 return None 78 79 80 def print_qr_code(url): 81 """Generate and print a QR code using block characters""" 82 try: 83 import qrcode 84 85 qr = qrcode.QRCode( 86 version=1, 87 error_correction=qrcode.constants.ERROR_CORRECT_L, 88 box_size=1, 89 border=1, 90 ) 91 qr.add_data(url) 92 qr.make(fit=True) 93 94 # Get the QR code matrix 95 matrix = qr.get_matrix() 96 97 for y in range(0, len(matrix), 2): 98 line = "" 99 for x in range(len(matrix[y])): 100 top = matrix[y][x] 101 bottom = matrix[y + 1][x] if y + 1 < len(matrix) else False 102 if top and bottom: 103 line += "█" 104 elif top: 105 line += "▀" 106 elif bottom: 107 line += "▄" 108 else: 109 line += " " 110 print(line) 111 print() 112 except ImportError: 113 print("\nQR code generation unavailable (qrcode library not installed)") 114 except Exception as e: 115 print(f"\nCould not generate QR code: {e}") 116 117 118 def read_tracks(): 119 """Read tracks.json from disk; return [] if missing or malformed.""" 120 if not TRACKS_PATH.exists(): 121 return [] 122 try: 123 with open(TRACKS_PATH, 'r', encoding='utf-8') as f: 124 loaded = json.load(f) 125 return loaded if isinstance(loaded, list) else [] 126 except (json.JSONDecodeError, OSError): 127 return [] 128 129 130 def _move_track_to(tracks, filename, insert_after): 131 """Reorder `tracks` in place so the entry matching `filename` lands at 132 insert_after + 1 (clamped). Persists tracks.json if the order actually 133 changed. Returns True iff a move happened. Caller must hold tracks_lock.""" 134 cur_idx = next((i for i, t in enumerate(tracks) if t.get('filename') == filename), -1) 135 if cur_idx == -1: 136 return False 137 target_idx = max(0, min(insert_after + 1, len(tracks) - 1)) 138 if target_idx == cur_idx: 139 return False 140 entry = tracks.pop(cur_idx) 141 if target_idx > cur_idx: 142 target_idx -= 1 143 tracks.insert(target_idx, entry) 144 TRACKS_PATH.write_text( 145 json.dumps(tracks, indent='\t', ensure_ascii=False) + '\n', 146 encoding='utf-8', 147 ) 148 return True 149 150 151 def _canonical_name_for(audio_path, fallback_name): 152 """Return the 'Artist – Title.ext' filename scan would canonicalize this 153 upload to, so we can dedup BEFORE moving the file into /mix. Returns None 154 if metadata can't be read.""" 155 import scan 156 try: 157 from mutagen import File as MutagenFile # type: ignore 158 except ImportError: 159 return None 160 try: 161 audio = MutagenFile(audio_path, easy=True) 162 except Exception: 163 return None 164 title = None 165 artist = None 166 if audio and audio.tags: 167 title = audio.tags.get('title', [None])[0] 168 artist = audio.tags.get('artist', [None])[0] 169 if not title: 170 title = Path(fallback_name).stem 171 if not artist: 172 artist = "Unknown Artist" 173 ext = Path(fallback_name).suffix 174 return f"{scan._sanitize_filename(artist)} – {scan._sanitize_filename(title)}{ext}" 175 176 177 def broadcast_tracks(tracks, renames=None): 178 """Push the current tracks list to every connected SSE client if its 179 content differs from the last broadcast. `renames`, when non-empty, 180 carries [{from, to}, ...] so clients can migrate per-track state (eg. 181 preloaded blob URLs) without losing it across a canonicalizing rename. 182 Caller must hold tracks_lock.""" 183 global last_broadcast_serialized 184 serialized = json.dumps(tracks, ensure_ascii=False, sort_keys=True) 185 if serialized == last_broadcast_serialized and not renames: 186 return 187 last_broadcast_serialized = serialized 188 payload = {'tracks': tracks, 'renames': renames or []} 189 with sse_subscribers_lock: 190 subscribers = list(sse_subscribers) 191 for q in subscribers: 192 try: 193 q.put_nowait(payload) 194 except queue.Full: 195 pass 196 197 198 def rescan_and_maybe_broadcast(): 199 """Rescan /mix, then broadcast if the resulting tracks list differs from 200 what we last sent.""" 201 import scan 202 tracks, _changed, renames = scan.rescan(silent=True) 203 broadcast_tracks(tracks, renames=renames) 204 return tracks 205 206 207 def start_rescan_ticker(interval=1.0): 208 """Background thread that periodically rescans /mix so changes made 209 directly on disk (eg. user dragging a file in or `rm`-ing one from the 210 terminal) reach connected clients without anyone hitting the server.""" 211 def tick(): 212 while True: 213 time.sleep(interval) 214 try: 215 with tracks_lock: 216 rescan_and_maybe_broadcast() 217 except Exception as e: 218 # Don't let a bad scan kill the ticker. 219 print(f"rescan tick error: {e}", file=sys.stderr) 220 t = threading.Thread(target=tick, daemon=True) 221 t.start() 222 223 224 class TrackRequestHandler(http.server.SimpleHTTPRequestHandler): 225 def do_GET(self): 226 # Rescan /mix on every tracks.json fetch so the page always sees 227 # what's actually on disk (added via rip.py/buy.py or by hand). 228 if self.path == '/mix/tracks.json': 229 with tracks_lock: 230 rescan_and_maybe_broadcast() 231 return super().do_GET() 232 233 if self.path == '/events': 234 return self._serve_sse() 235 236 return super().do_GET() 237 238 def _serve_sse(self): 239 """Server-Sent Events stream of tracks.json changes.""" 240 self.send_response(200) 241 self.send_header('Content-Type', 'text/event-stream') 242 self.send_header('Cache-Control', 'no-cache') 243 self.send_header('Connection', 'keep-alive') 244 self.send_header('X-Accel-Buffering', 'no') 245 self.end_headers() 246 247 q = queue.Queue(maxsize=16) 248 with sse_subscribers_lock: 249 sse_subscribers.add(q) 250 251 try: 252 # Initial sync so a fresh tab gets the current state without 253 # waiting for the next change. No renames context: a fresh 254 # client has no prior state to migrate from. 255 with tracks_lock: 256 initial = read_tracks() 257 self._send_sse_event('tracks', {'tracks': initial, 'renames': []}) 258 259 while True: 260 try: 261 payload = q.get(timeout=15) 262 self._send_sse_event('tracks', payload) 263 except queue.Empty: 264 # Heartbeat keeps proxies and idle connections from 265 # closing the stream. 266 self.wfile.write(b': keepalive\n\n') 267 self.wfile.flush() 268 except (BrokenPipeError, ConnectionResetError, OSError): 269 pass 270 finally: 271 with sse_subscribers_lock: 272 sse_subscribers.discard(q) 273 274 def _reply_json(self, status, payload): 275 body = json.dumps(payload).encode('utf-8') 276 self.send_response(status) 277 self.send_header('Content-Type', 'application/json') 278 self.send_header('Content-Length', str(len(body))) 279 self.end_headers() 280 self.wfile.write(body) 281 282 def _send_sse_event(self, event, data): 283 payload = json.dumps(data, ensure_ascii=False) 284 message = f'event: {event}\ndata: {payload}\n\n'.encode('utf-8') 285 self.wfile.write(message) 286 self.wfile.flush() 287 288 def do_POST(self): 289 # Local-only: receive a reordered tracks array and overwrite tracks.json 290 if self.path != '/tracks': 291 self.send_response(404) 292 self.end_headers() 293 return 294 length = int(self.headers.get('Content-Length', '0')) 295 payload = json.loads(self.rfile.read(length)) 296 with tracks_lock: 297 TRACKS_PATH.write_text( 298 json.dumps(payload, indent='\t', ensure_ascii=False) + '\n', 299 encoding='utf-8', 300 ) 301 broadcast_tracks(payload) 302 self.send_response(204) 303 self.end_headers() 304 305 def do_PUT(self): 306 # Local-only: drag-and-drop upload from a connected browser. 307 # Path is /upload/<url-encoded-filename>; headers carry the 308 # desired insertion index (X-Insert-After: -1 means prepend). 309 prefix = '/upload/' 310 if not self.path.startswith(prefix): 311 self.send_response(404) 312 self.end_headers() 313 return 314 315 raw_name = urllib.parse.unquote(self.path[len(prefix):]) 316 # Strip any client-supplied path components. 317 filename = Path(raw_name).name 318 ext = Path(filename).suffix.lower() 319 if not filename or ext not in SUPPORTED_UPLOAD_EXTENSIONS: 320 self.send_response(415) 321 self.end_headers() 322 return 323 324 length = int(self.headers.get('Content-Length', '0')) 325 if length <= 0 or length > MAX_UPLOAD_BYTES: 326 self.send_response(413) 327 self.end_headers() 328 return 329 330 try: 331 insert_after = int(self.headers.get('X-Insert-After', '-1')) 332 except ValueError: 333 insert_after = -1 334 335 # Stream the body to a temp file in MIX_DIR, then move into place 336 # atomically so a partial write is never visible to the rescan. 337 MIX_DIR.mkdir(parents=True, exist_ok=True) 338 tmp_path = MIX_DIR / (filename + '.uploading') 339 try: 340 with open(tmp_path, 'wb') as out: 341 remaining = length 342 while remaining > 0: 343 chunk = self.rfile.read(min(64 * 1024, remaining)) 344 if not chunk: 345 break 346 out.write(chunk) 347 remaining -= len(chunk) 348 if remaining != 0: 349 tmp_path.unlink(missing_ok=True) 350 self.send_response(400) 351 self.end_headers() 352 return 353 354 with tracks_lock: 355 import scan 356 # Dedup by canonical name: if a file with the canonical 357 # "Artist – Title.ext" name already lives in /mix, drop 358 # the upload and just reorder the existing entry to the 359 # requested slot so the client still gets the FLIP. 360 canonical = _canonical_name_for(tmp_path, filename) 361 if canonical and (MIX_DIR / canonical).exists(): 362 tmp_path.unlink(missing_ok=True) 363 tracks = read_tracks() 364 moved = _move_track_to(tracks, canonical, insert_after) 365 if moved: 366 broadcast_tracks(tracks) 367 final_index = next((i for i, t in enumerate(tracks) if t.get('filename') == canonical), -1) 368 self._reply_json(200, {'filename': canonical, 'duplicate': True, 'moved': moved, 'final_index': final_index}) 369 return 370 371 tmp_path.rename(MIX_DIR / filename) 372 placed_name = filename 373 374 tracks, _changed, renames = scan.rescan(silent=True) 375 376 # Find the canonical (post-canonicalize) filename for the 377 # upload by following the rename chain. 378 new_name = placed_name 379 for r in renames: 380 if r['from'] == new_name: 381 new_name = r['to'] 382 383 _move_track_to(tracks, new_name, insert_after) 384 broadcast_tracks(tracks, renames=renames) 385 final_index = next((i for i, t in enumerate(tracks) if t.get('filename') == new_name), -1) 386 387 self._reply_json(200, {'filename': new_name, 'final_index': final_index}) 388 except Exception as e: 389 tmp_path.unlink(missing_ok=True) 390 self.send_response(500) 391 self.end_headers() 392 try: 393 self.wfile.write(str(e).encode('utf-8')) 394 except Exception: 395 pass 396 397 def do_DELETE(self): 398 # Local-only: remove a track's audio file from /mix and prune 399 # tracks.json. Path is /tracks/<url-encoded-filename>. 400 prefix = '/tracks/' 401 if not self.path.startswith(prefix): 402 self.send_response(404) 403 self.end_headers() 404 return 405 406 filename = urllib.parse.unquote(self.path[len(prefix):]) 407 target = (MIX_DIR / filename).resolve() 408 try: 409 target.relative_to(MIX_DIR.resolve()) 410 except ValueError: 411 self.send_response(400) 412 self.end_headers() 413 return 414 if target == TRACKS_PATH.resolve() or target.name != filename: 415 self.send_response(400) 416 self.end_headers() 417 return 418 with tracks_lock: 419 try: 420 target.unlink() 421 except FileNotFoundError: 422 pass 423 except OSError as e: 424 self.send_response(500) 425 self.end_headers() 426 self.wfile.write(str(e).encode('utf-8')) 427 return 428 tracks = [t for t in read_tracks() if t.get('filename') != filename] 429 TRACKS_PATH.write_text( 430 json.dumps(tracks, indent='\t', ensure_ascii=False) + '\n', 431 encoding='utf-8', 432 ) 433 broadcast_tracks(tracks) 434 self.send_response(204) 435 self.end_headers() 436 437 def start_server(): 438 """Start the HTTP server (runs after venv is set up)""" 439 # Change to script directory 440 os.chdir(SCRIPT_DIR) 441 442 # Periodically rescan /mix so on-disk changes propagate to the UI even 443 # when nothing is hitting an HTTP endpoint. 444 start_rescan_ticker() 445 446 # Find an available port 447 port = find_available_port(DEFAULT_PORT) 448 449 if port is None: 450 print(f"Error: Could not find an available port (tried {DEFAULT_PORT}-{DEFAULT_PORT + 9})") 451 sys.exit(1) 452 453 # Get local IP for network access 454 local_ip = get_local_ip() 455 456 # Create server 457 458 # Suppress default logging and broken pipe errors 459 class QuietHandler(TrackRequestHandler): 460 def end_headers(self): 461 self.send_header('Cache-Control', 'no-cache') 462 super().end_headers() 463 464 def log_message(self, format, *args): 465 pass 466 467 def handle(self): 468 """Handle requests and suppress broken pipe errors""" 469 try: 470 super().handle() 471 except (BrokenPipeError, ConnectionResetError): 472 # Browser cancelled the request (normal for media streaming/preloading) 473 pass 474 475 476 try: 477 with socketserver.ThreadingTCPServer(("", port), QuietHandler) as httpd: 478 httpd.daemon_threads = True 479 local_url = f"http://localhost:{port}" 480 network_url = f"http://{local_ip}:{port}" 481 482 print("=" * 60) 483 print("💿 mixapps · local test server") 484 print("=" * 60 + "\n") 485 486 # Print QR code for easy mobile access 487 print_qr_code(network_url) 488 489 print(f"Local access: {local_url}") 490 print(f"Network access: {network_url}") 491 print("\nPress Ctrl+C to stop the server") 492 493 # Serve forever 494 httpd.serve_forever() 495 496 except KeyboardInterrupt: 497 print("\n\nShutting down server...") 498 sys.exit(0) 499 except Exception as e: 500 print(f"\nError starting server: {e}") 501 sys.exit(1) 502 503 504 def main(): 505 """Main entry point""" 506 # Check if we're already running in venv 507 if "--in-venv" not in sys.argv: 508 run_in_venv() 509 else: 510 start_server() 511 512 513 if __name__ == "__main__": 514 main()