restructure; add https_serve.py

Hunter
Thursday, May 28th, 2026 at 12:26 AM

.gitignore+3 
build.py+28-2
https_serve.pynew
index.html+1-1
readme.md+76-9
resources/dev.js+52-65
resources/script.js+38-24
resources/styles.css+8 
serve.py+216-217

.gitignore
23  manifest.json
24  service-worker.js
25  
26 +# Local HTTPS certs/keys minted by https_serve.py
27 +.https_certs/
28 +
29  # Personal deployment script
30  push.py
build.py
206  // Audio files will be cached by the main app's blob preloading system.
207  self.addEventListener('install', (event) => {{
208  	console.log('Service Worker installing...', 'Base path:', basePath);
209 +	self.skipWaiting();
210  	event.waitUntil(
211  		caches.open(CACHE_NAME).then(cache => {{
212  			const absoluteUrls = STATIC_FILES.map(url => {{

245  	);
246  }});
247  
248 +self.addEventListener('activate', (event) => {{
249 +	event.waitUntil(self.clients.claim());
250 +}});
251 +
252 +// Network fetch capped with a timeout. A mixapp installed off the LAN points
253 +// at a private-IP origin that's usually gone by launch time; without a cap an
254 +// uncached request hangs on a TCP timeout and stalls startup (black→white
255 +// screen). Aborting fast turns that hang into an ordinary cache-miss failure.
256 +const fetchWithTimeout = (request, ms = 2000) => {{
257 +	const controller = new AbortController();
258 +	const timer = setTimeout(() => controller.abort(), ms);
259 +	return fetch(request, {{ signal: controller.signal }})
260 +		.finally(() => clearTimeout(timer));
261 +}};
262 +
263  // Fetch event - cache first, network fallback
264  self.addEventListener('fetch', (event) => {{
265  	// Ignore non-http(s) requests like blob: URLs, data: URLs, chrome-extension:, etc.

275  					return cachedResponse;
276  				}}
277  
    -				// Not in cache - try network
278 +				// Navigation that missed the exact cache key: serve the cached
279 +				// app shell so the document always renders from cache, online
280 +				// or off, even if the launch URL doesn't byte-match a key.
281 +				if (event.request.mode === 'navigate') {{
282 +					const shellUrl = new URL(basePath, self.location.href).href;
283 +					return caches.match(shellUrl)
284 +						.then(shell => shell || caches.match('index.html'))
285 +						.then(shell => shell || fetchWithTimeout(event.request));
286 +				}}
287 +
288 +				// Not in cache - try network (time-boxed; see fetchWithTimeout)
289  				console.log('⟳ Fetching from network:', event.request.url);
    -				return fetch(event.request)
290 +				return fetchWithTimeout(event.request)
291  					.then((networkResponse) => {{
292  						// Check if valid response
293  						if (!networkResponse || networkResponse.status !== 200 || networkResponse.type === 'error') {{
https_serve.py
  1 +#!/usr/bin/env python3
  2 +"""
  3 +Serve a mixapp over HTTPS on your local network so you can install it as a PWA
  4 +without a public web host or tunnel.
  5 +
  6 +Uses mkcert to mint a locally-trusted certificate for this machine's LAN IP. A
  7 +device won't trust that cert until it trusts mkcert's root CA, so we print
  8 +two QR codes:
  9 +
 10 +	1. an HTTP link that hands the device the mkcert root CA (the public root
 11 +	   cert, no secret; served to any device until you stop the script)
 12 +	2. an HTTPS link to the mixapp itself, for PWA installation
 13 +
 14 +Both servers shut down together on Ctrl+C.
 15 +
 16 +After scanning the first QR code, install + trust the CA on the device:
 17 +	iOS:     Settings → General → VPN & Device Management → install the profile,
 18 +	         then Settings → General → About → Certificate Trust Settings →
 19 +	         enable full trust
 20 +	Android: open the downloaded file and follow the prompt to install it as a
 21 +	         CA certificate
 22 +
 23 +Run with --reset to revoke the CA and delete the local certificates.
 24 +"""
 25 +
 26 +import http.server
 27 +import socketserver
 28 +import socket
 29 +import ssl
 30 +import sys
 31 +import os
 32 +import json
 33 +import subprocess
 34 +import threading
 35 +from pathlib import Path
 36 +
 37 +import serve  # reuse serve.py's track-management backend (TrackRequestHandler, SSE, etc.)
 38 +
 39 +CERT_HTTP_PORT = 8001  # preferred; falls back to the next free port
 40 +HTTPS_PORT = 8443      # preferred; falls back to the next free port
 41 +SCRIPT_DIR = Path(__file__).parent.absolute()
 42 +CERT_DIR = SCRIPT_DIR / ".https_certs"
 43 +CERT_FILE = CERT_DIR / "cert.pem"
 44 +KEY_FILE = CERT_DIR / "key.pem"
 45 +MANIFEST_FILE = SCRIPT_DIR / "manifest.json"
 46 +
 47 +# Path the phone fetches the root CA from. Anything else over HTTP 404s.
 48 +CA_DOWNLOAD_PATH = "/rootCA.pem"
 49 +
 50 +
 51 +def manifest_base_path():
 52 +	"""The base path the PWA was built for (manifest scope/start_url).
 53 +	The installed app launches here, so we must serve the project under
 54 +	it or the standalone window 404s. Defaults to '/'."""
 55 +	try:
 56 +		manifest = json.loads(MANIFEST_FILE.read_text(encoding="utf-8"))
 57 +	except (OSError, json.JSONDecodeError):
 58 +		return "/"
 59 +	base = manifest.get("scope") or manifest.get("start_url") or "/"
 60 +	if not base.startswith("/"):
 61 +		base = "/" + base
 62 +	if not base.endswith("/"):
 63 +		base = base + "/"
 64 +	return base
 65 +
 66 +
 67 +def run_in_venv():
 68 +	"""Re-run this script in the shared venv with qrcode available,
 69 +	forwarding any user flags (eg. --reset) to the re-exec."""
 70 +	import scan
 71 +	# mutagen: serve.py's upload path reads tags to canonicalize filenames.
 72 +	python_path = scan.setup_venv(packages=("qrcode", "mutagen"))
 73 +	try:
 74 +		subprocess.check_call([str(python_path), __file__, "--in-venv", *sys.argv[1:]])
 75 +	except (KeyboardInterrupt, subprocess.CalledProcessError):
 76 +		pass
 77 +	sys.exit(0)
 78 +
 79 +
 80 +def get_local_ip():
 81 +	"""Get the LAN IP other devices on the network can reach this machine at."""
 82 +	try:
 83 +		s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
 84 +		s.connect(("8.8.8.8", 80))
 85 +		local_ip = s.getsockname()[0]
 86 +		s.close()
 87 +		return local_ip
 88 +	except Exception:
 89 +		return None
 90 +
 91 +
 92 +def find_available_port(start_port, max_attempts=20):
 93 +	"""Return the first bindable port at or after start_port. Uses SO_REUSEADDR
 94 +	so a socket lingering in TIME_WAIT from a prior run doesn't block us."""
 95 +	for port in range(start_port, start_port + max_attempts):
 96 +		s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
 97 +		s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
 98 +		try:
 99 +			s.bind(("", port))
100 +			return port
101 +		except OSError:
102 +			continue
103 +		finally:
104 +			s.close()
105 +	return None
106 +
107 +
108 +def print_qr_code(url, caption):
109 +	"""Generate and print a QR code using block characters."""
110 +	try:
111 +		import qrcode
112 +
113 +		qr = qrcode.QRCode(
114 +			version=1,
115 +			error_correction=qrcode.constants.ERROR_CORRECT_L,
116 +			box_size=1,
117 +			border=1,
118 +		)
119 +		qr.add_data(url)
120 +		qr.make(fit=True)
121 +
122 +		matrix = qr.get_matrix()
123 +
124 +		print(f"\n{caption}\n")
125 +		for y in range(0, len(matrix), 2):
126 +			line = " "
127 +			for x in range(len(matrix[y])):
128 +				top = matrix[y][x]
129 +				bottom = matrix[y + 1][x] if y + 1 < len(matrix) else False
130 +				if top and bottom:
131 +					line += "█"
132 +				elif top:
133 +					line += "▀"
134 +				elif bottom:
135 +					line += "▄"
136 +				else:
137 +					line += " "
138 +			print(line)
139 +	except ImportError:
140 +		print(f"\n{caption}\n  (QR unavailable; open this URL manually) {url}")
141 +	except Exception as e:
142 +		print(f"\nCould not generate QR code: {e}\n  open manually: {url}")
143 +
144 +
145 +def mkcert_install_command():
146 +	"""Best-guess (command, package-manager-label) to install mkcert on this
147 +	platform, or (None, None) if no known manager is on PATH."""
148 +	from shutil import which
149 +	if sys.platform == "darwin":
150 +		if which("brew"):
151 +			return (["brew", "install", "mkcert"], "Homebrew")
152 +	elif sys.platform == "win32":
153 +		if which("choco"):
154 +			return (["choco", "install", "mkcert", "-y"], "Chocolatey")
155 +		if which("scoop"):
156 +			return (["scoop", "install", "mkcert"], "Scoop")
157 +	else:  # linux / other unix
158 +		# Most distros package mkcert; pick whichever manager is present.
159 +		if which("apt"):
160 +			return (["sudo", "apt", "install", "-y", "mkcert"], "apt")
161 +		if which("dnf"):
162 +			return (["sudo", "dnf", "install", "-y", "mkcert"], "dnf")
163 +		if which("pacman"):
164 +			return (["sudo", "pacman", "-S", "--noconfirm", "mkcert"], "pacman")
165 +		if which("brew"):
166 +			return (["brew", "install", "mkcert"], "Homebrew")
167 +	return (None, None)
168 +
169 +
170 +def manual_install_hint():
171 +	"""Platform-appropriate manual install instructions for mkcert."""
172 +	if sys.platform == "darwin":
173 +		return "brew install mkcert  (https://github.com/FiloSottile/mkcert)"
174 +	if sys.platform == "win32":
175 +		return "choco install mkcert  OR  scoop install mkcert  (https://github.com/FiloSottile/mkcert)"
176 +	return "install 'mkcert' via your package manager (https://github.com/FiloSottile/mkcert)"
177 +
178 +
179 +def require_mkcert():
180 +	"""Ensure mkcert is on PATH, offering to install it if it isn't."""
181 +	from shutil import which
182 +	if which("mkcert") is not None:
183 +		return
184 +
185 +	print("mkcert is required but was not found on PATH.")
186 +	cmd, label = mkcert_install_command()
187 +	if cmd is None:
188 +		print(f"  Install it manually:  {manual_install_hint()}")
189 +		sys.exit(1)
190 +
191 +	answer = input(f"Install it now with {label} ({' '.join(cmd)})? [y/N] ").strip().lower()
192 +	if answer not in ("y", "yes"):
193 +		print(f"  Install it manually:  {manual_install_hint()}")
194 +		sys.exit(1)
195 +
196 +	try:
197 +		subprocess.check_call(cmd)
198 +	except (subprocess.CalledProcessError, KeyboardInterrupt):
199 +		print(f"\nInstall failed. Install it manually:  {manual_install_hint()}")
200 +		sys.exit(1)
201 +
202 +	if which("mkcert") is None:
203 +		print("mkcert still not on PATH after install. Open a new terminal and retry.")
204 +		sys.exit(1)
205 +
206 +
207 +def ca_root_file():
208 +	"""Path to mkcert's root CA cert (rootCA.pem)."""
209 +	caroot = subprocess.run(
210 +		["mkcert", "-CAROOT"], capture_output=True, text=True
211 +	).stdout.strip()
212 +	return Path(caroot) / "rootCA.pem"
213 +
214 +
215 +def ensure_ca_installed():
216 +	"""Ensure mkcert's local CA exists and is trusted by this machine.
217 +	May prompt for your password to write to the system trust store."""
218 +	if not ca_root_file().exists():
219 +		print("Setting up mkcert local CA (you may be prompted for your password)...")
220 +		subprocess.check_call(["mkcert", "-install"])
221 +
222 +
223 +def ensure_leaf_cert(local_ip):
224 +	"""Mint a cert/key for this machine's LAN IP (and localhost) if missing."""
225 +	CERT_DIR.mkdir(parents=True, exist_ok=True)
226 +	if CERT_FILE.exists() and KEY_FILE.exists():
227 +		return
228 +	print(f"Minting certificate for {local_ip}...")
229 +	subprocess.check_call([
230 +		"mkcert",
231 +		"-cert-file", str(CERT_FILE),
232 +		"-key-file", str(KEY_FILE),
233 +		local_ip, "localhost", "127.0.0.1",
234 +	])
235 +
236 +
237 +def start_ca_server(local_ip):
238 +	"""Start a plain-HTTP server that hands out the mkcert root CA, and return
239 +	it running. The CA is the *public* root cert (no secret), so there's no
240 +	reason to limit how many times or to how many devices it's served. leave
241 +	it up alongside the HTTPS server so you can set up multiple devices, and
242 +	tear both down together on Ctrl+C. Caller owns shutdown."""
243 +	ca_file = ca_root_file()
244 +	if not ca_file.exists():
245 +		print(f"Error: root CA not found at {ca_file}")
246 +		sys.exit(1)
247 +
248 +	ca_bytes = ca_file.read_bytes()
249 +
250 +	class CAHandler(http.server.BaseHTTPRequestHandler):
251 +		def log_message(self, *args):
252 +			pass
253 +
254 +		def do_GET(self):
255 +			# Redirect bare visits to the download path so a typo'd / still works.
256 +			if self.path in ("/", "/index.html"):
257 +				self.send_response(302)
258 +				self.send_header("Location", CA_DOWNLOAD_PATH)
259 +				self.end_headers()
260 +				return
261 +			if self.path != CA_DOWNLOAD_PATH:
262 +				self.send_error(404)
263 +				return
264 +			# x-x509-ca-cert makes iOS offer to install it as a CA profile.
265 +			self.send_response(200)
266 +			self.send_header("Content-Type", "application/x-x509-ca-cert")
267 +			self.send_header("Content-Length", str(len(ca_bytes)))
268 +			self.send_header(
269 +				"Content-Disposition", 'attachment; filename="rootCA.pem"'
270 +			)
271 +			self.end_headers()
272 +			try:
273 +				self.wfile.write(ca_bytes)
274 +			except (BrokenPipeError, ConnectionResetError):
275 +				return
276 +
277 +	port = find_available_port(CERT_HTTP_PORT)
278 +	if port is None:
279 +		print(f"Error: no free port near {CERT_HTTP_PORT} for the CA server.")
280 +		sys.exit(1)
281 +	http.server.HTTPServer.allow_reuse_address = True
282 +	httpd = http.server.HTTPServer(("", port), CAHandler)
283 +	threading.Thread(target=httpd.serve_forever, daemon=True).start()
284 +
285 +	cert_url = f"http://{local_ip}:{port}{CA_DOWNLOAD_PATH}"
286 +	print("=" * 69)
287 +	print("STEP 1: install + trust the local CA on each device (first time only)")
288 +	print("=" * 69)
289 +	print_qr_code(cert_url, "Scan to download the root certificate:")
290 +	print(f"  {cert_url}")
291 +	print("\nOn iOS, after downloading:")
292 +	print("  • Settings → General → VPN & Device Management → install the profile")
293 +	print("  • Settings → General → About → Certificate Trust Settings →")
294 +	print("    toggle ON full trust for the mkcert CA")
295 +	print("\nOn Android, after downloading:")
296 +	print("  • open the downloaded file, or Settings → Security → Encryption &")
297 +	print("    credentials → Install a certificate → CA certificate, then confirm")
298 +	print("  • installing the CA is what makes it trusted; there's no separate")
299 +	print("    trust toggle (exact menu names vary by version/manufacturer)")
300 +	print("\n(skip this step on a device that already trusts the CA from a previous run)")
301 +	return httpd
302 +
303 +
304 +def serve_https(local_ip, base_path, ca_httpd=None):
305 +	"""Serve SCRIPT_DIR over HTTPS for PWA installation, mounted under
306 +	base_path so the installed app's start_url resolves. Runs until Ctrl+C,
307 +	then also shuts down the CA HTTP server (if given)."""
308 +	os.chdir(SCRIPT_DIR)
309 +
310 +	# Rescan /mix periodically so edits made on disk reach connected browser
311 +	# tabs over the SSE stream, same as serve.py.
312 +	serve.start_rescan_ticker()
313 +
314 +	# Subclass serve.py's handler so editing works over HTTPS too: it adds the
315 +	# SSE change stream (/events), reorder save (POST /tracks), drag-drop
316 +	# upload (PUT /upload/<name>), and delete (DELETE /tracks/<name>). Those
317 +	# endpoints are root-absolute, so base_path stripping (GET-only) doesn't
318 +	# touch them.
319 +	class QuietHandler(serve.TrackRequestHandler):
320 +		def end_headers(self):
321 +			self.send_header("Cache-Control", "no-cache")
322 +			super().end_headers()
323 +
324 +		def log_message(self, *args):
325 +			pass
326 +
327 +		def handle(self):
328 +			try:
329 +				super().handle()
330 +			except (BrokenPipeError, ConnectionResetError):
331 +				pass
332 +
333 +		def translate_path(self, path):
334 +			# Strip the manifest base prefix so /mixapp_name/foo maps to ./foo.
335 +			if base_path != "/" and path.startswith(base_path):
336 +				path = "/" + path[len(base_path):]
337 +			return super().translate_path(path)
338 +
339 +		def do_GET(self):
340 +			# Bounce a bare root visit into the app so manual navigation works.
341 +			if base_path != "/" and self.path in ("/", "/index.html"):
342 +				self.send_response(302)
343 +				self.send_header("Location", base_path)
344 +				self.end_headers()
345 +				return
346 +			# super() is serve.TrackRequestHandler.do_GET, which handles
347 +			# /events (SSE) and rescans on /mix/tracks.json before serving.
348 +			return super().do_GET()
349 +
350 +	ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
351 +	ctx.load_cert_chain(certfile=str(CERT_FILE), keyfile=str(KEY_FILE))
352 +
353 +	port = find_available_port(HTTPS_PORT)
354 +	if port is None:
355 +		print(f"Error: no free port near {HTTPS_PORT} for the HTTPS server.")
356 +		if ca_httpd is not None:
357 +			ca_httpd.shutdown()
358 +			ca_httpd.server_close()
359 +		sys.exit(1)
360 +
361 +	with socketserver.ThreadingTCPServer(("", port), QuietHandler) as httpd:
362 +		httpd.daemon_threads = True
363 +		httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
364 +		https_url = f"https://{local_ip}:{port}{base_path}"
365 +
366 +		print("\n" + "=" * 59)
367 +		print("STEP 2: install your mixapp (once the device trusts the CA)")
368 +		print("=" * 59)
369 +		print_qr_code(https_url, "Scan to open your mixapp over HTTPS:")
370 +		print(f"  {https_url}")
371 +		print("\nThen install it as a PWA: https://hunterirving.github.io/web_workshop/pages/pwa")
372 +		print("\nPress Ctrl+C to stop the server(s)")
373 +
374 +		try:
375 +			httpd.serve_forever()
376 +		except KeyboardInterrupt:
377 +			print("\n\nShutting down...")
378 +		finally:
379 +			if ca_httpd is not None:
380 +				ca_httpd.shutdown()
381 +				ca_httpd.server_close()
382 +
383 +
384 +def caroot_dir():
385 +	"""Path to mkcert's CAROOT folder (holds rootCA.pem and rootCA-key.pem)."""
386 +	caroot = subprocess.run(
387 +		["mkcert", "-CAROOT"], capture_output=True, text=True
388 +	).stdout.strip()
389 +	return Path(caroot) if caroot else None
390 +
391 +
392 +def reset_certs():
393 +	"""Opt-in teardown, in two stages so it's safe alongside other mkcert use.
394 +
395 +	mkcert keeps ONE shared CA per machine, so anything else you run with
396 +	mkcert trusts the same CA. Stage 1 removes only THIS tool's own leaf certs
397 +	(always safe). Stage 2 removes the shared mkcert CA itself (untrust + optional
398 +	key deletion). Guarded, because it affects every project that uses
399 +	mkcert, not just this one."""
400 +	from shutil import rmtree, which
401 +
402 +	# Stage 1: our own leaf certs.
403 +	if CERT_DIR.exists():
404 +		rmtree(CERT_DIR)
405 +		print(f"Removed this tool's local certificates ({CERT_DIR}).")
406 +	else:
407 +		print("No local certificates to remove.")
408 +
409 +	# Stage 2: the shared mkcert CA. Skip entirely unless asked, since other
410 +	# projects on this machine may rely on it and we can't detect them.
411 +	print(
412 +		"\nThe mkcert CA itself is SHARED across everything you use mkcert for"
413 +		" on this computer, not just this tool. Leaving it installed is normal."
414 +	)
415 +	answer = input(
416 +		"Also remove the shared mkcert CA (untrust it system-wide)? [y/N] "
417 +	).strip().lower()
418 +	if answer not in ("y", "yes"):
419 +		print("Left the mkcert CA in place.")
420 +		return
421 +
422 +	if which("mkcert"):
423 +		try:
424 +			subprocess.check_call(["mkcert", "-uninstall"])
425 +			print("Stopped this computer from trusting the mkcert CA.")
426 +		except subprocess.CalledProcessError as e:
427 +			print(f"mkcert -uninstall failed: {e}")
428 +
429 +	# mkcert -uninstall removes trust but does NOT delete the CA. The private key
430 +	# (rootCA-key.pem) is the sensitive material, so offer to delete it too, with
431 +	# a separate confirmation since it's irreversible and shared.
432 +	caroot = caroot_dir()
433 +	if caroot and (caroot / "rootCA-key.pem").exists():
434 +		print(
435 +			"\nThe CA's PRIVATE KEY is still on disk at:\n"
436 +			f"  {caroot}\n"
437 +			"Per mkcert: \"the rootCA-key.pem file ... gives complete power to\n"
438 +			"intercept secure requests from your machine. Do not share it.\"\n"
439 +			"Deleting it removes the shared CA entirely; future mkcert use\n"
440 +			"(here or in any project) will generate a brand-new one."
441 +		)
442 +		ans2 = input("Delete the CA key + folder now? [y/N] ").strip().lower()
443 +		if ans2 in ("y", "yes"):
444 +			rmtree(caroot)
445 +			print(f"Removed {caroot}")
446 +		else:
447 +			print("Left the CA key in place.")
448 +
449 +	print("\nNote: this does NOT touch any device; remove or untrust the CA\n"
450 +		"there separately (see the instructions printed during install).")
451 +
452 +
453 +def start():
454 +	require_mkcert()
455 +
456 +	local_ip = get_local_ip()
457 +	if not local_ip:
458 +		print("Error: could not determine this machine's LAN IP.")
459 +		print("Make sure you're connected to a network and try again.")
460 +		sys.exit(1)
461 +
462 +	base_path = manifest_base_path()
463 +	if not MANIFEST_FILE.exists():
464 +		print("Warning: manifest.json not found. Run ./build.py first so the\n"
465 +			"PWA is installable. Serving at root for now.\n")
466 +
467 +	ensure_ca_installed()
468 +	ensure_leaf_cert(local_ip)
469 +	ca_httpd = start_ca_server(local_ip)
470 +	serve_https(local_ip, base_path, ca_httpd=ca_httpd)
471 +
472 +
473 +def main():
474 +	if "--in-venv" not in sys.argv:
475 +		run_in_venv()
476 +	else:
477 +		try:
478 +			if "--reset" in sys.argv:
479 +				reset_certs()
480 +			else:
481 +				start()
482 +		except KeyboardInterrupt:
483 +			print("\nCancelled.")
484 +			sys.exit(130)
485 +
486 +
487 +if __name__ == "__main__":
488 +	main()
index.html
41  
42  	<script src="resources/script.js"></script>
43  	<script>
   -		if (typeof isLocal !== 'undefined' && isLocal) {
44 +		if (typeof isLocal !== 'undefined' && isLocal && typeof isInstalled !== 'undefined' && !isInstalled) {
45  			const s = document.createElement('script');
46  			s.src = 'resources/dev.js';
47  			document.body.appendChild(s);
readme.md
  9  ## demo
 10  <p align="center">
 11  <a href="https://hunterirving.com/worn_grooves/">worn grooves ↗</a>
    -<br>(public domain recordings)
 12 +<br>(public-domain recordings)
 13  </p>
 14  
 15  ## key features

 33  
 34  <hr><br>
 35  
    -mixapps are digital artifacts. immutable objects that can persist on-device, independent of platforms, contracts, and corporate whim.<br><br>
 36 +mixapps are immutable artifacts that can persist on-device, independent of platforms, contracts, and corporate whim.<br><br>
 37  
 38  once you install one, it's yours.<br><br>
 39  
 40  > [!IMPORTANT]
    -> mixapps make it easy to host audio on the public internet. hosting content you don't have the right to distribute is copyright infringement, even when shared privately with a friend or framed as a gift. fair use and backup exceptions cover personal copies, not public hosting.
 41 +> mixapps make it easy to host audio on the public internet, but publishing files you don't have the right to distribute may constitute copyright infringement.
 42  >
    -> before uploading, ensure you have the right to distribute the files you include in `/mix`. this can include your own recordings, public-domain works, creative commons releases, or material you've licensed from the rights holder.
 43 +> before uploading, ensure you have the right to distribute the files you include in `mix/`. this can include your own original recordings, public-domain works, Creative Commons releases, or material you've licensed from rights holders.
 44  
 45  ## quickstart
 46  1. **serve it**
 47  	- run `./serve.py` to start a development server. while the server is running, you can:
    -		- **add new tracks** by moving audio files into the `/mix` directory, dragging them into the browser window, running `./rip.py` to rip tracks from a physical CD, or running `./buy.py` to buy tracks on iTunes (both are fine to use locally; check distribution rights before hosting publicly)
 48 +		- **add new tracks** by...
 49 +			- dragging them into the browser window
 50 +			- moving them into the `mix/` directory
 51 +			- running `./rip.py` to rip tracks from a physical CD
 52 +			- running `./buy.py` to buy tracks on iTunes
 53  		- **reorder tracks** by dragging them up or down in the list
 54  		- **delete tracks** with `shift + click`
 55  

 57  	- once you're happy with your mix, run `./build.py` and follow the interactive prompts to generate `manifest.json` and `service-worker.js`, which enable PWA installation and offline functionality.
 58  
 59  3. **ship it**
    -	- upload the entire project directory to any static web host with HTTPS support (GitHub Pages, Neocities, AWS S3, etc.)
 60  
    -4. **share it**
    -	- once it's hosted, anyone can install your mixapp by opening the URL and following their browser's PWA installation steps:
 61 +	two options here:
 62 +
 63 +	- **public host:** when using media you have distribution rights for, you can upload the entire project directory to any static web host with HTTPS support (GitHub Pages, Neocities, AWS S3, etc.)
 64 +
 65 +		or...
 66 +
 67 +	- **your own wifi:** install onto your own devices using a local HTTPS server (see <a href="#local-network-installation">local network installation</a>)
 68 +
 69 +4. **save it**
 70 +	- once your mixapp is hosted, anyone can install it by opening the URL and following their browser's PWA installation steps:
 71  		- **iOS (Safari)**: tap `···` → `Share` → `View More` → scroll down to reveal and tap `Add to Home Screen` → `Add`
 72  		- **Android**:
 73  			- **Firefox**: tap `⋮` → `··· More` → Add to Home screen → Add to home screen

 78  	(pictured: integration with iOS lockscreen controls)
 79  
 80  ## customization
    -add your own `custom.css`, `custom.js`, and/or `album_art.jpg` to `/mix` to customize your mixapp's appearance and behavior.
 81 +add your own `custom.css`, `custom.js`, and/or `album_art.jpg` to `mix/` to customize your mixapp's appearance and behavior.
 82 +
 83 +## local network installation
 84 +
 85 +<details>
 86 +<summary><strong>install on your own devices without a public host</strong></summary>
 87 +
 88 +<br>
 89 +
 90 +PWA installation requires a secure (HTTPS) connection. the easiest way to get one is to upload your files to a public host, but publishing files this way is distribution, which means it's only appropriate for content you have the right to distribute.
 91 +
 92 +however, if you'd like to install mixapps without pushing to a public host, you can use `https_serve.py` to serve the necessary files over HTTPS on your local network. because nothing is published, this path keeps your files on your own network and off the public internet.
 93 +
 94 +<h3>how it works</h3>
 95 +
 96 +normally, an HTTPS site proves its identity with a certificate signed by a **certificate authority (CA)** that browsers already trust. a server on your own network has no such signature, so browsers refuse connections by default.
 97 +
 98 +<a href="https://github.com/FiloSottile/mkcert">mkcert</a> bridges this gap with two pieces:
 99 +
100 +- a **CA**, created once on your computer, that acts as a trusted issuer.
101 +- a **server certificate**, signed by your local CA, which your local server presents to prove its identity.
102 +
103 +on each device you want to install a mixapp to, you add your local CA to that device's list of trusted Certificate Authorities. after that, its browser will automatically accept the certificate presented by your local server, letting you connect and install your mixapp as a PWA.
104 +
105 +> [!NOTE]
106 +> trusting a CA tells a device to trust **any** HTTPS certificate the CA issues (for any website, not just your mixapp server). in effect, you're asking the installing device to trust your computer to vouch for the whole web.
107 +>
108 +> in practice the risk is small, because an attack needs **three** things to line up at once: your CA's private key has leaked off your computer, the attacker is on a network where they can intercept the installing device's traffic, *and* that device still trusts the CA. deny any one and there's nothing to exploit, so a few habits keep you safe:
109 +> - **guard your local CA's private key**: as long as you don't move or share this file, it stays safely on your computer (see <a href="#cleaning-up">cleaning up</a>).
110 +> - **install over a network you control**: your own wifi, not public/shared.
111 +> - **leave trust disabled when it isn't needed**: iOS lets you toggle trust on/off per custom CA; on Android, you can remove the CA when you're done and re-add it next time you want to install (see <a href="#cleaning-up">cleaning up</a>).
112 +
113 +<h3>usage</h3>
114 +
115 +1. run `./https_serve.py`. it will offer to install `mkcert` if needed, then create a local certificate authority (CA) on your computer.
116 +2. **first time on a new device:** scan the first QR code to download the CA certificate, then install and trust it.
117 +	- **iOS:**
118 +		1. scanning the QR code prompts you to allow the download. tap **Allow**.<br>
119 +		<img src="readme_images/ios_ca_download.png" width="275" alt="iOS prompt: this website is trying to download a configuration profile"><br>
120 +		2. **install it:** open `Settings` → `General` → `VPN & Device Management`, tap the downloaded profile, and tap **Install**.<br>
121 +		<img src="readme_images/ios_ca_install.png" width="275" alt="iOS profile install screen for the mkcert CA"><br>
122 +		3. **trust it:** open `Settings` → `General` → `About` → `Certificate Trust Settings` and toggle the CA **on**.<br>
123 +		<img src="readme_images/ios_ca_trust.png" width="275" alt="iOS Certificate Trust Settings with the mkcert CA toggled on"><br>
124 +	- **Android:** open the downloaded file, or go to `Settings` → `Security` → `Encryption & credentials` → `Install a certificate` → `CA certificate`, and confirm the install. (on Android, installing a user CA is what makes it trusted; there's no separate trust toggle. exact menu names vary by version/manufacturer.)
125 +
126 +	a device that already trusts the CA from a previous run can skip this step.
127 +
128 +3. scan the second QR code to open your mixapp over HTTPS, then follow the <a href="https://hunterirving.github.io/web_workshop/pages/pwa">PWA installation instructions</a> for your device/OS to add it to your homescreen. after the initial installation and cache, mixapps work completely offline; they no longer need the network, server, or certificate.
129 +
130 +<h3 id="cleaning-up">cleaning up</h3>
131 +
132 +- **on the installing device (recommended):** it's good practice to untrust the CA once you're done installing, and only re-enable it when you want to install another mixapp.
133 +	- **iOS, disable (keep installed):** `Settings` → `General` → `About` → `Certificate Trust Settings` → toggle the CA **off**; you can re-enable this in the future if you'd like to install another mixapp.
134 +	- **iOS, remove entirely:** `Settings` → `General` → `VPN & Device Management` → tap the profile → **Remove Profile**.
135 +	- **Android, remove:** `Settings` → `Security` → `Encryption & credentials` → `User credentials` (or `Trusted credentials` → `User`) → tap the mkcert entry → **Remove**.
136 +- **on your computer:** keeping mkcert's CA installed long-term is normal for a development machine, as long as you protect your CA's private key. you'd only need to remove the CA if you were done with it for good or thought your private key may have been exposed. in those cases, `./https_serve.py --reset` removes this tool's own certificates, then asks separately before touching the shared mkcert CA: first to untrust it on your development machine, then to delete its private key. mkcert uses one CA for everything, so if you also use mkcert for other projects, decline those steps to leave your CA intact.
137 +</details>
138  
139  ## licenses
140  this project is licensed under the <a href="LICENSE">GNU General Public License v3.0</a>.
resources/dev.js
383  	const removed = tracks[index];
384  	if (!removed) return;
385  
    -	// Snapshot old positions of every playlist row keyed by filename, so we
    -	// can FLIP-animate the survivors after re-rendering.
    -	const beforeItems = Array.from(playlist.querySelectorAll('.playlist-item'));
    -	const oldTopByFilename = new Map();
    -	beforeItems.forEach((el, i) => {
    -		const t = tracks[i];
    -		if (t) oldTopByFilename.set(t.filename, el.getBoundingClientRect().top);
    -	});
386 +	// Snapshot row positions so survivors FLIP-animate after re-rendering.
387 +	const oldTops = snapshotRowTops();
388  
389  	const wasPlaying = currentTrackIndex === index;
390  

418  	}
419  
420  	renderPlaylist();
    -
    -	// FLIP: translate each surviving row from its old top to its new top.
    -	const afterItems = playlist.querySelectorAll('.playlist-item');
    -	afterItems.forEach((el, i) => {
    -		const t = tracks[i];
    -		if (!t) return;
    -		const oldTop = oldTopByFilename.get(t.filename);
    -		if (oldTop === undefined) return;
    -		const newTop = el.getBoundingClientRect().top;
    -		const dy = oldTop - newTop;
    -		if (!dy) return;
    -		el.style.transition = 'none';
    -		el.style.transform = `translateY(${dy}px)`;
    -		void el.offsetHeight;
    -		el.style.transition = 'transform 180ms ease';
    -		el.style.transform = '';
    -		const clear = (e) => {
    -			if (e.propertyName !== 'transform') return;
    -			el.style.transition = '';
    -			el.style.transform = '';
    -			el.removeEventListener('transitionend', clear);
    -		};
    -		el.addEventListener('transitionend', clear);
    -	});
421 +	flipRowsFrom(oldTops);
422  
423  	fetch('/tracks/' + encodeURIComponent(removed.filename), { method: 'DELETE' })
424  		.then(r => { if (!r.ok) throw new Error('delete failed: ' + r.status); })

513  	}
514  }
515  
516 +function snapshotRowTops() {
517 +	const tops = new Map();
518 +	const items = playlist.querySelectorAll('.playlist-item');
519 +	items.forEach((el, i) => {
520 +		const t = tracks[i];
521 +		if (t) tops.set(t.filename, el.getBoundingClientRect().top);
522 +	});
523 +	return tops;
524 +}
525 +
526 +function flipRowsFrom(oldTops) {
527 +	const items = playlist.querySelectorAll('.playlist-item');
528 +	items.forEach((el, i) => {
529 +		const t = tracks[i];
530 +		if (!t) return;
531 +		const oldTop = oldTops.get(t.filename);
532 +		if (oldTop === undefined) return;
533 +		const newTop = el.getBoundingClientRect().top;
534 +		const dy = oldTop - newTop;
535 +		if (!dy) return;
536 +		el.style.transition = 'none';
537 +		el.style.transform = `translateY(${dy}px)`;
538 +		void el.offsetHeight;
539 +		el.style.transition = 'transform 180ms ease';
540 +		el.style.transform = '';
541 +		const clear = (e) => {
542 +			if (e.propertyName !== 'transform') return;
543 +			el.style.transition = '';
544 +			el.style.transform = '';
545 +			el.removeEventListener('transitionend', clear);
546 +		};
547 +		el.addEventListener('transitionend', clear);
548 +	});
549 +}
550 +
551  function reconcileTracks(incoming, renames) {
552  	if (renames && renames.length) applyRenames(renames);
553  

557  
558  	if (tracks.length > 0 && tracksEqual(tracks, incoming)) return;
559  
560 +	// Snapshot row positions before mutating `tracks` so a remote reorder
561 +	// arriving over SSE animates (FLIP) instead of jumping.
562 +	const oldTops = snapshotRowTops();
563 +
564  	const playingFilename = tracks[currentTrackIndex] && tracks[currentTrackIndex].filename;
565  	const playingRemoved = playingFilename && !incomingNames.has(playingFilename);
566  

625  
626  	if (batchUploadsInFlight === 0) {
627  		renderPlaylist();
628 +		flipRowsFrom(oldTops);
629  		flushPendingDropAnimation();
630  	}
631  

650  
651  startLiveSync();
652  
653 +// script.js may have already rendered the playlist before this file loaded
654 +// (the all-cached fast path renders almost immediately), in which case those
655 +// rows have no reorder handlers. Re-render now that they're defined.
656 +if (canReorder && typeof renderPlaylist === 'function') {
657 +	renderPlaylist();
658 +}
659 +
660  // Drag-and-drop file upload
661  
662  const SUPPORTED_DROP_EXTS = ['.mp3', '.m4a', '.ogg', '.flac', '.wav'];

713  }
714  
715  function uploadFiles(files, insertAfter) {
    -	const snapshot = { positions: snapshotRowPositions(), ready: false };
716 +	const snapshot = { positions: snapshotRowTops(), ready: false };
717  	pendingDropAnimations.push(snapshot);
718  	batchUploadsInFlight++;
719  

771  	});
772  }
773  
    -function snapshotRowPositions() {
    -	const map = new Map();
    -	const items = playlist.querySelectorAll('.playlist-item');
    -	items.forEach((el, i) => {
    -		const t = tracks[i];
    -		if (t) map.set(t.filename, el.getBoundingClientRect().top);
    -	});
    -	return map;
    -}
    -
774  function flushPendingDropAnimation() {
775  	const ready = pendingDropAnimations.filter(s => s.ready);
776  	if (ready.length === 0) return;

785  	}
786  	pendingDropAnimations = pendingDropAnimations.filter(s => !s.ready);
787  
    -	const items = playlist.querySelectorAll('.playlist-item');
    -	items.forEach((el, i) => {
    -		const t = tracks[i];
    -		if (!t) return;
    -		const oldTop = merged.get(t.filename);
    -		if (oldTop === undefined) return;
    -		const newTop = el.getBoundingClientRect().top;
    -		const dy = oldTop - newTop;
    -		if (!dy) return;
    -		el.style.transition = 'none';
    -		el.style.transform = `translateY(${dy}px)`;
    -		void el.offsetHeight;
    -		el.style.transition = 'transform 180ms ease';
    -		el.style.transform = '';
    -		const clear = (e) => {
    -			if (e.propertyName !== 'transform') return;
    -			el.style.transition = '';
    -			el.style.transform = '';
    -			el.removeEventListener('transitionend', clear);
    -		};
    -		el.addEventListener('transitionend', clear);
    -	});
788 +	flipRowsFrom(merged);
789  }
resources/script.js
 16  	window.matchMedia('(display-mode: minimal-ui)').matches ||
 17  	window.navigator.standalone === true;
 18  
    -if ('serviceWorker' in navigator && isInstalled && !isLocal) {
 19 +// Register when installed as a PWA regardless of hostname so a mixapp
 20 +// installed over LAN (via https_serve.py) caches for offline use too;
 21 +// a plain browser tab still skips the SW so dev always sees latest files.
 22 +if ('serviceWorker' in navigator && isInstalled) {
 23  	window.addEventListener('load', () => {
 24  		navigator.serviceWorker.register('service-worker.js')
 25  			.then(registration => {

 29  				console.log('Service Worker registration failed:', error);
 30  			});
 31  	});
    -} else if ('serviceWorker' in navigator) {
    -	// Browser tab or local dev: tear down any existing SW and caches so
    -	// the page always reflects the latest deploy.
 32 +} else if ('serviceWorker' in navigator && !isLocal) {
 33  	navigator.serviceWorker.getRegistrations().then(registrations => {
 34  		registrations.forEach(r => r.unregister());
 35  	});

110  	}
111  }
112  
    -// Enable reordering UI only when served locally
    -if (isLocal) {
113 +// Enable reordering UI only in a local browser tab (serve.py), never in an
114 +// installed PWA — there's no backend to persist reorders there.
115 +if (isLocal && !isInstalled) {
116  	canReorder = true;
117  	document.body.classList.add('reorderable');
118  }

140  		}
141  		staticFiles.push(albumArtPath);
142  
    -		// Probe for optional files and add them to staticFiles
143  		const optionalFiles = ['mix/custom.css', 'mix/custom.js'];
    -		return Promise.all([
    -			...optionalFiles.map(f =>
144 +		const detectOptional = isInstalled
145 +			? caches.open(CACHE_NAME).then(cache =>
146 +				Promise.all(optionalFiles.map(f =>
147 +					cache.match(new URL(f, window.location.href).href)
148 +						.then(hit => { if (hit) staticFiles.push(f); })
149 +						.catch(() => {})
150 +				))
151 +			)
152 +			: Promise.all(optionalFiles.map(f =>
153  				fetch(f, { method: 'HEAD' })
    -					.then(r => {
    -						if (r.ok) {
    -							staticFiles.push(f);
    -						}
    -					})
154 +					.then(r => { if (r.ok) staticFiles.push(f); })
155  					.catch(() => {})
    -			),
    -			fetch('mix/tracks.json')
    -				.then(r => {
156 +			));
157 +
158 +		// Load tracks.json cache-first when installed (instant, no network
159 +		// wait); network-first in a dev tab so reorders/edits show live.
160 +		const loadTracksFromCache = () =>
161 +			caches.open(CACHE_NAME)
162 +				.then(cache => cache.match(new URL('mix/tracks.json', window.location.href).href))
163 +				.then(r => r ? r.json() : Promise.reject('tracks.json not in cache'));
164 +		const loadTracks = isInstalled
165 +			? loadTracksFromCache().catch(() =>
166 +				fetch('mix/tracks.json').then(r => {
167  					if (!r.ok) throw new Error('tracks.json not found');
168  					return r.json();
169  				})
    -				.catch(() => {
    -					// Offline fallback: try loading from cache directly
    -					return caches.open(CACHE_NAME)
    -						.then(cache => cache.match('mix/tracks.json'))
    -						.then(r => r ? r.json() : Promise.reject('tracks.json not in cache'));
170 +			)
171 +			: fetch('mix/tracks.json')
172 +				.then(r => {
173 +					if (!r.ok) throw new Error('tracks.json not found');
174 +					return r.json();
175  				})
    -		]);
176 +				.catch(loadTracksFromCache);
177 +
178 +		return Promise.all([detectOptional, loadTracks]);
179  	})
180  	.then((results) => {
181  		const data = results[results.length - 1];

361  			}
362  			toggleLooping(index);
363  		});
    -		if (canReorder) {
364 +		if (canReorder && typeof attachReorderHandlers === 'function') {
365  			attachReorderHandlers(item, index);
366  		}
367  		playlist.appendChild(item);
resources/styles.css
390  	mask-position: center;
391  }
392  
393 +body::after {
394 +	content: '';
395 +	position: absolute;
396 +	width: 0;
397 +	height: 0;
398 +	mask-image: url('pause.svg');
399 +}
400 +
401  #prev::before {
402  	mask-image: url('prev.svg');
403  	mask-position: 45% center;
serve.py
 94  		# Get the QR code matrix
 95  		matrix = qr.get_matrix()
 96  
    -		print("\nScan to connect:")
 97  		for y in range(0, len(matrix), 2):
 98  			line = ""
 99  			for x in range(len(matrix[y])):

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

454  	local_ip = get_local_ip()
455  
456  	# Create server
    -	Handler = http.server.SimpleHTTPRequestHandler
457  
458  	# Suppress default logging and broken pipe errors
    -	class QuietHandler(Handler):
459 +	class QuietHandler(TrackRequestHandler):
460  		def end_headers(self):
461  			self.send_header('Cache-Control', 'no-cache')
462  			super().end_headers()

472  				# Browser cancelled the request (normal for media streaming/preloading)
473  				pass
474  
    -		def do_GET(self):
    -			# Rescan /mix on every tracks.json fetch so the page always sees
    -			# what's actually on disk (added via rip.py/buy.py or by hand).
    -			if self.path == '/mix/tracks.json':
    -				with tracks_lock:
    -					rescan_and_maybe_broadcast()
    -				return super().do_GET()
    -
    -			if self.path == '/events':
    -				return self._serve_sse()
    -
    -			return super().do_GET()
    -
    -		def _serve_sse(self):
    -			"""Server-Sent Events stream of tracks.json changes."""
    -			self.send_response(200)
    -			self.send_header('Content-Type', 'text/event-stream')
    -			self.send_header('Cache-Control', 'no-cache')
    -			self.send_header('Connection', 'keep-alive')
    -			self.send_header('X-Accel-Buffering', 'no')
    -			self.end_headers()
    -
    -			q = queue.Queue(maxsize=16)
    -			with sse_subscribers_lock:
    -				sse_subscribers.add(q)
    -
    -			try:
    -				# Initial sync so a fresh tab gets the current state without
    -				# waiting for the next change. No renames context: a fresh
    -				# client has no prior state to migrate from.
    -				with tracks_lock:
    -					initial = read_tracks()
    -				self._send_sse_event('tracks', {'tracks': initial, 'renames': []})
    -
    -				while True:
    -					try:
    -						payload = q.get(timeout=15)
    -						self._send_sse_event('tracks', payload)
    -					except queue.Empty:
    -						# Heartbeat keeps proxies and idle connections from
    -						# closing the stream.
    -						self.wfile.write(b': keepalive\n\n')
    -						self.wfile.flush()
    -			except (BrokenPipeError, ConnectionResetError, OSError):
    -				pass
    -			finally:
    -				with sse_subscribers_lock:
    -					sse_subscribers.discard(q)
    -
    -		def _reply_json(self, status, payload):
    -			body = json.dumps(payload).encode('utf-8')
    -			self.send_response(status)
    -			self.send_header('Content-Type', 'application/json')
    -			self.send_header('Content-Length', str(len(body)))
    -			self.end_headers()
    -			self.wfile.write(body)
    -
    -		def _send_sse_event(self, event, data):
    -			payload = json.dumps(data, ensure_ascii=False)
    -			message = f'event: {event}\ndata: {payload}\n\n'.encode('utf-8')
    -			self.wfile.write(message)
    -			self.wfile.flush()
    -
    -		def do_POST(self):
    -			# Local-only: receive a reordered tracks array and overwrite tracks.json
    -			if self.path != '/tracks':
    -				self.send_response(404)
    -				self.end_headers()
    -				return
    -			length = int(self.headers.get('Content-Length', '0'))
    -			payload = json.loads(self.rfile.read(length))
    -			with tracks_lock:
    -				TRACKS_PATH.write_text(
    -					json.dumps(payload, indent='\t', ensure_ascii=False) + '\n',
    -					encoding='utf-8',
    -				)
    -				broadcast_tracks(payload)
    -			self.send_response(204)
    -			self.end_headers()
    -
    -		def do_PUT(self):
    -			# Local-only: drag-and-drop upload from a connected browser.
    -			# Path is /upload/<url-encoded-filename>; headers carry the
    -			# desired insertion index (X-Insert-After: -1 means prepend).
    -			prefix = '/upload/'
    -			if not self.path.startswith(prefix):
    -				self.send_response(404)
    -				self.end_headers()
    -				return
    -
    -			raw_name = urllib.parse.unquote(self.path[len(prefix):])
    -			# Strip any client-supplied path components.
    -			filename = Path(raw_name).name
    -			ext = Path(filename).suffix.lower()
    -			if not filename or ext not in SUPPORTED_UPLOAD_EXTENSIONS:
    -				self.send_response(415)
    -				self.end_headers()
    -				return
    -
    -			length = int(self.headers.get('Content-Length', '0'))
    -			if length <= 0 or length > MAX_UPLOAD_BYTES:
    -				self.send_response(413)
    -				self.end_headers()
    -				return
    -
    -			try:
    -				insert_after = int(self.headers.get('X-Insert-After', '-1'))
    -			except ValueError:
    -				insert_after = -1
    -
    -			# Stream the body to a temp file in MIX_DIR, then move into place
    -			# atomically so a partial write is never visible to the rescan.
    -			MIX_DIR.mkdir(parents=True, exist_ok=True)
    -			tmp_path = MIX_DIR / (filename + '.uploading')
    -			try:
    -				with open(tmp_path, 'wb') as out:
    -					remaining = length
    -					while remaining > 0:
    -						chunk = self.rfile.read(min(64 * 1024, remaining))
    -						if not chunk:
    -							break
    -						out.write(chunk)
    -						remaining -= len(chunk)
    -				if remaining != 0:
    -					tmp_path.unlink(missing_ok=True)
    -					self.send_response(400)
    -					self.end_headers()
    -					return
    -
    -				with tracks_lock:
    -					import scan
    -					# Dedup by canonical name: if a file with the canonical
    -					# "Artist – Title.ext" name already lives in /mix, drop
    -					# the upload and just reorder the existing entry to the
    -					# requested slot so the client still gets the FLIP.
    -					canonical = _canonical_name_for(tmp_path, filename)
    -					if canonical and (MIX_DIR / canonical).exists():
    -						tmp_path.unlink(missing_ok=True)
    -						tracks = read_tracks()
    -						moved = _move_track_to(tracks, canonical, insert_after)
    -						if moved:
    -							broadcast_tracks(tracks)
    -						final_index = next((i for i, t in enumerate(tracks) if t.get('filename') == canonical), -1)
    -						self._reply_json(200, {'filename': canonical, 'duplicate': True, 'moved': moved, 'final_index': final_index})
    -						return
    -
    -					tmp_path.rename(MIX_DIR / filename)
    -					placed_name = filename
    -
    -					tracks, _changed, renames = scan.rescan(silent=True)
    -
    -					# Find the canonical (post-canonicalize) filename for the
    -					# upload by following the rename chain.
    -					new_name = placed_name
    -					for r in renames:
    -						if r['from'] == new_name:
    -							new_name = r['to']
    -
    -					_move_track_to(tracks, new_name, insert_after)
    -					broadcast_tracks(tracks, renames=renames)
    -					final_index = next((i for i, t in enumerate(tracks) if t.get('filename') == new_name), -1)
    -
    -				self._reply_json(200, {'filename': new_name, 'final_index': final_index})
    -			except Exception as e:
    -				tmp_path.unlink(missing_ok=True)
    -				self.send_response(500)
    -				self.end_headers()
    -				try:
    -					self.wfile.write(str(e).encode('utf-8'))
    -				except Exception:
    -					pass
    -
    -		def do_DELETE(self):
    -			# Local-only: remove a track's audio file from /mix and prune
    -			# tracks.json. Path is /tracks/<url-encoded-filename>.
    -			prefix = '/tracks/'
    -			if not self.path.startswith(prefix):
    -				self.send_response(404)
    -				self.end_headers()
    -				return
    -
    -			filename = urllib.parse.unquote(self.path[len(prefix):])
    -			target = (MIX_DIR / filename).resolve()
    -			try:
    -				target.relative_to(MIX_DIR.resolve())
    -			except ValueError:
    -				self.send_response(400)
    -				self.end_headers()
    -				return
    -			if target == TRACKS_PATH.resolve() or target.name != filename:
    -				self.send_response(400)
    -				self.end_headers()
    -				return
    -			with tracks_lock:
    -				try:
    -					target.unlink()
    -				except FileNotFoundError:
    -					pass
    -				except OSError as e:
    -					self.send_response(500)
    -					self.end_headers()
    -					self.wfile.write(str(e).encode('utf-8'))
    -					return
    -				tracks = [t for t in read_tracks() if t.get('filename') != filename]
    -				TRACKS_PATH.write_text(
    -					json.dumps(tracks, indent='\t', ensure_ascii=False) + '\n',
    -					encoding='utf-8',
    -				)
    -				broadcast_tracks(tracks)
    -			self.send_response(204)
    -			self.end_headers()
475  
476  	try:
477  		with socketserver.ThreadingTCPServer(("", port), QuietHandler) as httpd:

480  			network_url = f"http://{local_ip}:{port}"
481  
482  			print("=" * 60)
    -			print("💿 mixapps")
    -			print("=" * 60)
    -			print(f"\nServer running on port {port}")
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)