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