commit ac95e36713a044de7f39a6c05757132dca567b3d
parent b0ef17110a765000e158aaedc09b0dceeec38318
Author: Hunter
Date: Thu, 28 May 2026 00:26:36 -0400
restructure; add https_serve.py
Diffstat:
| M | .gitignore | | | 3 | +++ |
| M | build.py | | | 30 | ++++++++++++++++++++++++++++-- |
| A | https_serve.py | | | 488 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | index.html | | | 2 | +- |
| M | readme.md | | | 85 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------- |
| M | resources/dev.js | | | 117 | +++++++++++++++++++++++++++++++++++-------------------------------------------- |
| M | resources/script.js | | | 62 | ++++++++++++++++++++++++++++++++++++++------------------------ |
| M | resources/styles.css | | | 8 | ++++++++ |
| M | serve.py | | | 433 | +++++++++++++++++++++++++++++++++++++++---------------------------------------- |
9 files changed, 910 insertions(+), 318 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -23,5 +23,8 @@ mix/*
manifest.json
service-worker.js
+# Local HTTPS certs/keys minted by https_serve.py
+.https_certs/
+
# Personal deployment script
push.py
\ No newline at end of file
diff --git a/build.py b/build.py
@@ -206,6 +206,7 @@ const STATIC_FILES = {static_files_js};
// Audio files will be cached by the main app's blob preloading system.
self.addEventListener('install', (event) => {{
console.log('Service Worker installing...', 'Base path:', basePath);
+ self.skipWaiting();
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {{
const absoluteUrls = STATIC_FILES.map(url => {{
@@ -244,6 +245,21 @@ self.addEventListener('install', (event) => {{
);
}});
+self.addEventListener('activate', (event) => {{
+ event.waitUntil(self.clients.claim());
+}});
+
+// Network fetch capped with a timeout. A mixapp installed off the LAN points
+// at a private-IP origin that's usually gone by launch time; without a cap an
+// uncached request hangs on a TCP timeout and stalls startup (black→white
+// screen). Aborting fast turns that hang into an ordinary cache-miss failure.
+const fetchWithTimeout = (request, ms = 2000) => {{
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), ms);
+ return fetch(request, {{ signal: controller.signal }})
+ .finally(() => clearTimeout(timer));
+}};
+
// Fetch event - cache first, network fallback
self.addEventListener('fetch', (event) => {{
// Ignore non-http(s) requests like blob: URLs, data: URLs, chrome-extension:, etc.
@@ -259,9 +275,19 @@ self.addEventListener('fetch', (event) => {{
return cachedResponse;
}}
- // Not in cache - try network
+ // Navigation that missed the exact cache key: serve the cached
+ // app shell so the document always renders from cache, online
+ // or off, even if the launch URL doesn't byte-match a key.
+ if (event.request.mode === 'navigate') {{
+ const shellUrl = new URL(basePath, self.location.href).href;
+ return caches.match(shellUrl)
+ .then(shell => shell || caches.match('index.html'))
+ .then(shell => shell || fetchWithTimeout(event.request));
+ }}
+
+ // Not in cache - try network (time-boxed; see fetchWithTimeout)
console.log('⟳ Fetching from network:', event.request.url);
- return fetch(event.request)
+ return fetchWithTimeout(event.request)
.then((networkResponse) => {{
// Check if valid response
if (!networkResponse || networkResponse.status !== 200 || networkResponse.type === 'error') {{
diff --git a/https_serve.py b/https_serve.py
@@ -0,0 +1,488 @@
+#!/usr/bin/env python3
+"""
+Serve a mixapp over HTTPS on your local network so you can install it as a PWA
+without a public web host or tunnel.
+
+Uses mkcert to mint a locally-trusted certificate for this machine's LAN IP. A
+device won't trust that cert until it trusts mkcert's root CA, so we print
+two QR codes:
+
+ 1. an HTTP link that hands the device the mkcert root CA (the public root
+ cert, no secret; served to any device until you stop the script)
+ 2. an HTTPS link to the mixapp itself, for PWA installation
+
+Both servers shut down together on Ctrl+C.
+
+After scanning the first QR code, install + trust the CA on the device:
+ iOS: Settings → General → VPN & Device Management → install the profile,
+ then Settings → General → About → Certificate Trust Settings →
+ enable full trust
+ Android: open the downloaded file and follow the prompt to install it as a
+ CA certificate
+
+Run with --reset to revoke the CA and delete the local certificates.
+"""
+
+import http.server
+import socketserver
+import socket
+import ssl
+import sys
+import os
+import json
+import subprocess
+import threading
+from pathlib import Path
+
+import serve # reuse serve.py's track-management backend (TrackRequestHandler, SSE, etc.)
+
+CERT_HTTP_PORT = 8001 # preferred; falls back to the next free port
+HTTPS_PORT = 8443 # preferred; falls back to the next free port
+SCRIPT_DIR = Path(__file__).parent.absolute()
+CERT_DIR = SCRIPT_DIR / ".https_certs"
+CERT_FILE = CERT_DIR / "cert.pem"
+KEY_FILE = CERT_DIR / "key.pem"
+MANIFEST_FILE = SCRIPT_DIR / "manifest.json"
+
+# Path the phone fetches the root CA from. Anything else over HTTP 404s.
+CA_DOWNLOAD_PATH = "/rootCA.pem"
+
+
+def manifest_base_path():
+ """The base path the PWA was built for (manifest scope/start_url).
+ The installed app launches here, so we must serve the project under
+ it or the standalone window 404s. Defaults to '/'."""
+ try:
+ manifest = json.loads(MANIFEST_FILE.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return "/"
+ base = manifest.get("scope") or manifest.get("start_url") or "/"
+ if not base.startswith("/"):
+ base = "/" + base
+ if not base.endswith("/"):
+ base = base + "/"
+ return base
+
+
+def run_in_venv():
+ """Re-run this script in the shared venv with qrcode available,
+ forwarding any user flags (eg. --reset) to the re-exec."""
+ import scan
+ # mutagen: serve.py's upload path reads tags to canonicalize filenames.
+ python_path = scan.setup_venv(packages=("qrcode", "mutagen"))
+ try:
+ subprocess.check_call([str(python_path), __file__, "--in-venv", *sys.argv[1:]])
+ except (KeyboardInterrupt, subprocess.CalledProcessError):
+ pass
+ sys.exit(0)
+
+
+def get_local_ip():
+ """Get the LAN IP other devices on the network can reach this machine at."""
+ try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ s.connect(("8.8.8.8", 80))
+ local_ip = s.getsockname()[0]
+ s.close()
+ return local_ip
+ except Exception:
+ return None
+
+
+def find_available_port(start_port, max_attempts=20):
+ """Return the first bindable port at or after start_port. Uses SO_REUSEADDR
+ so a socket lingering in TIME_WAIT from a prior run doesn't block us."""
+ for port in range(start_port, start_port + max_attempts):
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ s.bind(("", port))
+ return port
+ except OSError:
+ continue
+ finally:
+ s.close()
+ return None
+
+
+def print_qr_code(url, caption):
+ """Generate and print a QR code using block characters."""
+ try:
+ import qrcode
+
+ qr = qrcode.QRCode(
+ version=1,
+ error_correction=qrcode.constants.ERROR_CORRECT_L,
+ box_size=1,
+ border=1,
+ )
+ qr.add_data(url)
+ qr.make(fit=True)
+
+ matrix = qr.get_matrix()
+
+ print(f"\n{caption}\n")
+ for y in range(0, len(matrix), 2):
+ line = " "
+ for x in range(len(matrix[y])):
+ top = matrix[y][x]
+ bottom = matrix[y + 1][x] if y + 1 < len(matrix) else False
+ if top and bottom:
+ line += "█"
+ elif top:
+ line += "▀"
+ elif bottom:
+ line += "▄"
+ else:
+ line += " "
+ print(line)
+ except ImportError:
+ print(f"\n{caption}\n (QR unavailable; open this URL manually) {url}")
+ except Exception as e:
+ print(f"\nCould not generate QR code: {e}\n open manually: {url}")
+
+
+def mkcert_install_command():
+ """Best-guess (command, package-manager-label) to install mkcert on this
+ platform, or (None, None) if no known manager is on PATH."""
+ from shutil import which
+ if sys.platform == "darwin":
+ if which("brew"):
+ return (["brew", "install", "mkcert"], "Homebrew")
+ elif sys.platform == "win32":
+ if which("choco"):
+ return (["choco", "install", "mkcert", "-y"], "Chocolatey")
+ if which("scoop"):
+ return (["scoop", "install", "mkcert"], "Scoop")
+ else: # linux / other unix
+ # Most distros package mkcert; pick whichever manager is present.
+ if which("apt"):
+ return (["sudo", "apt", "install", "-y", "mkcert"], "apt")
+ if which("dnf"):
+ return (["sudo", "dnf", "install", "-y", "mkcert"], "dnf")
+ if which("pacman"):
+ return (["sudo", "pacman", "-S", "--noconfirm", "mkcert"], "pacman")
+ if which("brew"):
+ return (["brew", "install", "mkcert"], "Homebrew")
+ return (None, None)
+
+
+def manual_install_hint():
+ """Platform-appropriate manual install instructions for mkcert."""
+ if sys.platform == "darwin":
+ return "brew install mkcert (https://github.com/FiloSottile/mkcert)"
+ if sys.platform == "win32":
+ return "choco install mkcert OR scoop install mkcert (https://github.com/FiloSottile/mkcert)"
+ return "install 'mkcert' via your package manager (https://github.com/FiloSottile/mkcert)"
+
+
+def require_mkcert():
+ """Ensure mkcert is on PATH, offering to install it if it isn't."""
+ from shutil import which
+ if which("mkcert") is not None:
+ return
+
+ print("mkcert is required but was not found on PATH.")
+ cmd, label = mkcert_install_command()
+ if cmd is None:
+ print(f" Install it manually: {manual_install_hint()}")
+ sys.exit(1)
+
+ answer = input(f"Install it now with {label} ({' '.join(cmd)})? [y/N] ").strip().lower()
+ if answer not in ("y", "yes"):
+ print(f" Install it manually: {manual_install_hint()}")
+ sys.exit(1)
+
+ try:
+ subprocess.check_call(cmd)
+ except (subprocess.CalledProcessError, KeyboardInterrupt):
+ print(f"\nInstall failed. Install it manually: {manual_install_hint()}")
+ sys.exit(1)
+
+ if which("mkcert") is None:
+ print("mkcert still not on PATH after install. Open a new terminal and retry.")
+ sys.exit(1)
+
+
+def ca_root_file():
+ """Path to mkcert's root CA cert (rootCA.pem)."""
+ caroot = subprocess.run(
+ ["mkcert", "-CAROOT"], capture_output=True, text=True
+ ).stdout.strip()
+ return Path(caroot) / "rootCA.pem"
+
+
+def ensure_ca_installed():
+ """Ensure mkcert's local CA exists and is trusted by this machine.
+ May prompt for your password to write to the system trust store."""
+ if not ca_root_file().exists():
+ print("Setting up mkcert local CA (you may be prompted for your password)...")
+ subprocess.check_call(["mkcert", "-install"])
+
+
+def ensure_leaf_cert(local_ip):
+ """Mint a cert/key for this machine's LAN IP (and localhost) if missing."""
+ CERT_DIR.mkdir(parents=True, exist_ok=True)
+ if CERT_FILE.exists() and KEY_FILE.exists():
+ return
+ print(f"Minting certificate for {local_ip}...")
+ subprocess.check_call([
+ "mkcert",
+ "-cert-file", str(CERT_FILE),
+ "-key-file", str(KEY_FILE),
+ local_ip, "localhost", "127.0.0.1",
+ ])
+
+
+def start_ca_server(local_ip):
+ """Start a plain-HTTP server that hands out the mkcert root CA, and return
+ it running. The CA is the *public* root cert (no secret), so there's no
+ reason to limit how many times or to how many devices it's served. leave
+ it up alongside the HTTPS server so you can set up multiple devices, and
+ tear both down together on Ctrl+C. Caller owns shutdown."""
+ ca_file = ca_root_file()
+ if not ca_file.exists():
+ print(f"Error: root CA not found at {ca_file}")
+ sys.exit(1)
+
+ ca_bytes = ca_file.read_bytes()
+
+ class CAHandler(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *args):
+ pass
+
+ def do_GET(self):
+ # Redirect bare visits to the download path so a typo'd / still works.
+ if self.path in ("/", "/index.html"):
+ self.send_response(302)
+ self.send_header("Location", CA_DOWNLOAD_PATH)
+ self.end_headers()
+ return
+ if self.path != CA_DOWNLOAD_PATH:
+ self.send_error(404)
+ return
+ # x-x509-ca-cert makes iOS offer to install it as a CA profile.
+ self.send_response(200)
+ self.send_header("Content-Type", "application/x-x509-ca-cert")
+ self.send_header("Content-Length", str(len(ca_bytes)))
+ self.send_header(
+ "Content-Disposition", 'attachment; filename="rootCA.pem"'
+ )
+ self.end_headers()
+ try:
+ self.wfile.write(ca_bytes)
+ except (BrokenPipeError, ConnectionResetError):
+ return
+
+ port = find_available_port(CERT_HTTP_PORT)
+ if port is None:
+ print(f"Error: no free port near {CERT_HTTP_PORT} for the CA server.")
+ sys.exit(1)
+ http.server.HTTPServer.allow_reuse_address = True
+ httpd = http.server.HTTPServer(("", port), CAHandler)
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
+
+ cert_url = f"http://{local_ip}:{port}{CA_DOWNLOAD_PATH}"
+ print("=" * 69)
+ print("STEP 1: install + trust the local CA on each device (first time only)")
+ print("=" * 69)
+ print_qr_code(cert_url, "Scan to download the root certificate:")
+ print(f" {cert_url}")
+ print("\nOn iOS, after downloading:")
+ print(" • Settings → General → VPN & Device Management → install the profile")
+ print(" • Settings → General → About → Certificate Trust Settings →")
+ print(" toggle ON full trust for the mkcert CA")
+ print("\nOn Android, after downloading:")
+ print(" • open the downloaded file, or Settings → Security → Encryption &")
+ print(" credentials → Install a certificate → CA certificate, then confirm")
+ print(" • installing the CA is what makes it trusted; there's no separate")
+ print(" trust toggle (exact menu names vary by version/manufacturer)")
+ print("\n(skip this step on a device that already trusts the CA from a previous run)")
+ return httpd
+
+
+def serve_https(local_ip, base_path, ca_httpd=None):
+ """Serve SCRIPT_DIR over HTTPS for PWA installation, mounted under
+ base_path so the installed app's start_url resolves. Runs until Ctrl+C,
+ then also shuts down the CA HTTP server (if given)."""
+ os.chdir(SCRIPT_DIR)
+
+ # Rescan /mix periodically so edits made on disk reach connected browser
+ # tabs over the SSE stream, same as serve.py.
+ serve.start_rescan_ticker()
+
+ # Subclass serve.py's handler so editing works over HTTPS too: it adds the
+ # SSE change stream (/events), reorder save (POST /tracks), drag-drop
+ # upload (PUT /upload/<name>), and delete (DELETE /tracks/<name>). Those
+ # endpoints are root-absolute, so base_path stripping (GET-only) doesn't
+ # touch them.
+ class QuietHandler(serve.TrackRequestHandler):
+ def end_headers(self):
+ self.send_header("Cache-Control", "no-cache")
+ super().end_headers()
+
+ def log_message(self, *args):
+ pass
+
+ def handle(self):
+ try:
+ super().handle()
+ except (BrokenPipeError, ConnectionResetError):
+ pass
+
+ def translate_path(self, path):
+ # Strip the manifest base prefix so /mixapp_name/foo maps to ./foo.
+ if base_path != "/" and path.startswith(base_path):
+ path = "/" + path[len(base_path):]
+ return super().translate_path(path)
+
+ def do_GET(self):
+ # Bounce a bare root visit into the app so manual navigation works.
+ if base_path != "/" and self.path in ("/", "/index.html"):
+ self.send_response(302)
+ self.send_header("Location", base_path)
+ self.end_headers()
+ return
+ # super() is serve.TrackRequestHandler.do_GET, which handles
+ # /events (SSE) and rescans on /mix/tracks.json before serving.
+ return super().do_GET()
+
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ ctx.load_cert_chain(certfile=str(CERT_FILE), keyfile=str(KEY_FILE))
+
+ port = find_available_port(HTTPS_PORT)
+ if port is None:
+ print(f"Error: no free port near {HTTPS_PORT} for the HTTPS server.")
+ if ca_httpd is not None:
+ ca_httpd.shutdown()
+ ca_httpd.server_close()
+ sys.exit(1)
+
+ with socketserver.ThreadingTCPServer(("", port), QuietHandler) as httpd:
+ httpd.daemon_threads = True
+ httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
+ https_url = f"https://{local_ip}:{port}{base_path}"
+
+ print("\n" + "=" * 59)
+ print("STEP 2: install your mixapp (once the device trusts the CA)")
+ print("=" * 59)
+ print_qr_code(https_url, "Scan to open your mixapp over HTTPS:")
+ print(f" {https_url}")
+ print("\nThen install it as a PWA: https://hunterirving.github.io/web_workshop/pages/pwa")
+ print("\nPress Ctrl+C to stop the server(s)")
+
+ try:
+ httpd.serve_forever()
+ except KeyboardInterrupt:
+ print("\n\nShutting down...")
+ finally:
+ if ca_httpd is not None:
+ ca_httpd.shutdown()
+ ca_httpd.server_close()
+
+
+def caroot_dir():
+ """Path to mkcert's CAROOT folder (holds rootCA.pem and rootCA-key.pem)."""
+ caroot = subprocess.run(
+ ["mkcert", "-CAROOT"], capture_output=True, text=True
+ ).stdout.strip()
+ return Path(caroot) if caroot else None
+
+
+def reset_certs():
+ """Opt-in teardown, in two stages so it's safe alongside other mkcert use.
+
+ mkcert keeps ONE shared CA per machine, so anything else you run with
+ mkcert trusts the same CA. Stage 1 removes only THIS tool's own leaf certs
+ (always safe). Stage 2 removes the shared mkcert CA itself (untrust + optional
+ key deletion). Guarded, because it affects every project that uses
+ mkcert, not just this one."""
+ from shutil import rmtree, which
+
+ # Stage 1: our own leaf certs.
+ if CERT_DIR.exists():
+ rmtree(CERT_DIR)
+ print(f"Removed this tool's local certificates ({CERT_DIR}).")
+ else:
+ print("No local certificates to remove.")
+
+ # Stage 2: the shared mkcert CA. Skip entirely unless asked, since other
+ # projects on this machine may rely on it and we can't detect them.
+ print(
+ "\nThe mkcert CA itself is SHARED across everything you use mkcert for"
+ " on this computer, not just this tool. Leaving it installed is normal."
+ )
+ answer = input(
+ "Also remove the shared mkcert CA (untrust it system-wide)? [y/N] "
+ ).strip().lower()
+ if answer not in ("y", "yes"):
+ print("Left the mkcert CA in place.")
+ return
+
+ if which("mkcert"):
+ try:
+ subprocess.check_call(["mkcert", "-uninstall"])
+ print("Stopped this computer from trusting the mkcert CA.")
+ except subprocess.CalledProcessError as e:
+ print(f"mkcert -uninstall failed: {e}")
+
+ # mkcert -uninstall removes trust but does NOT delete the CA. The private key
+ # (rootCA-key.pem) is the sensitive material, so offer to delete it too, with
+ # a separate confirmation since it's irreversible and shared.
+ caroot = caroot_dir()
+ if caroot and (caroot / "rootCA-key.pem").exists():
+ print(
+ "\nThe CA's PRIVATE KEY is still on disk at:\n"
+ f" {caroot}\n"
+ "Per mkcert: \"the rootCA-key.pem file ... gives complete power to\n"
+ "intercept secure requests from your machine. Do not share it.\"\n"
+ "Deleting it removes the shared CA entirely; future mkcert use\n"
+ "(here or in any project) will generate a brand-new one."
+ )
+ ans2 = input("Delete the CA key + folder now? [y/N] ").strip().lower()
+ if ans2 in ("y", "yes"):
+ rmtree(caroot)
+ print(f"Removed {caroot}")
+ else:
+ print("Left the CA key in place.")
+
+ print("\nNote: this does NOT touch any device; remove or untrust the CA\n"
+ "there separately (see the instructions printed during install).")
+
+
+def start():
+ require_mkcert()
+
+ local_ip = get_local_ip()
+ if not local_ip:
+ print("Error: could not determine this machine's LAN IP.")
+ print("Make sure you're connected to a network and try again.")
+ sys.exit(1)
+
+ base_path = manifest_base_path()
+ if not MANIFEST_FILE.exists():
+ print("Warning: manifest.json not found. Run ./build.py first so the\n"
+ "PWA is installable. Serving at root for now.\n")
+
+ ensure_ca_installed()
+ ensure_leaf_cert(local_ip)
+ ca_httpd = start_ca_server(local_ip)
+ serve_https(local_ip, base_path, ca_httpd=ca_httpd)
+
+
+def main():
+ if "--in-venv" not in sys.argv:
+ run_in_venv()
+ else:
+ try:
+ if "--reset" in sys.argv:
+ reset_certs()
+ else:
+ start()
+ except KeyboardInterrupt:
+ print("\nCancelled.")
+ sys.exit(130)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/index.html b/index.html
@@ -41,7 +41,7 @@
<script src="resources/script.js"></script>
<script>
- if (typeof isLocal !== 'undefined' && isLocal) {
+ if (typeof isLocal !== 'undefined' && isLocal && typeof isInstalled !== 'undefined' && !isInstalled) {
const s = document.createElement('script');
s.src = 'resources/dev.js';
document.body.appendChild(s);
diff --git a/readme.md b/readme.md
@@ -9,7 +9,7 @@ mixapps are like mixtapes or mix CDs, but packaged as <a href="https://hunterirv
## demo
<p align="center">
<a href="https://hunterirving.com/worn_grooves/">worn grooves ↗</a>
-<br>(public domain recordings)
+<br>(public-domain recordings)
</p>
## key features
@@ -33,19 +33,23 @@ these days, we mostly point to things that we don't control.
<hr><br>
-mixapps are digital artifacts. immutable objects that can persist on-device, independent of platforms, contracts, and corporate whim.<br><br>
+mixapps are immutable artifacts that can persist on-device, independent of platforms, contracts, and corporate whim.<br><br>
once you install one, it's yours.<br><br>
> [!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.
+> 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.
>
-> 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.
+> 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.
## quickstart
1. **serve it**
- 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)
+ - **add new tracks** by...
+ - dragging them into the browser window
+ - moving them into the `mix/` directory
+ - running `./rip.py` to rip tracks from a physical CD
+ - running `./buy.py` to buy tracks on iTunes
- **reorder tracks** by dragging them up or down in the list
- **delete tracks** with `shift + click`
@@ -53,10 +57,17 @@ once you install one, it's yours.<br><br>
- 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.
3. **ship it**
- - upload the entire project directory to any static web host with HTTPS support (GitHub Pages, Neocities, AWS S3, etc.)
-4. **share it**
- - once it's hosted, anyone can install your mixapp by opening the URL and following their browser's PWA installation steps:
+ two options here:
+
+ - **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.)
+
+ or...
+
+ - **your own wifi:** install onto your own devices using a local HTTPS server (see <a href="#local-network-installation">local network installation</a>)
+
+4. **save it**
+ - once your mixapp is hosted, anyone can install it by opening the URL and following their browser's PWA installation steps:
- **iOS (Safari)**: tap `···` → `Share` → `View More` → scroll down to reveal and tap `Add to Home Screen` → `Add`
- **Android**:
- **Firefox**: tap `⋮` → `··· More` → Add to Home screen → Add to home screen
@@ -67,7 +78,63 @@ once you install one, it's yours.<br><br>
(pictured: integration with iOS lockscreen controls)
## customization
-add your own `custom.css`, `custom.js`, and/or `album_art.jpg` to `/mix` to customize your mixapp's appearance and behavior.
+add your own `custom.css`, `custom.js`, and/or `album_art.jpg` to `mix/` to customize your mixapp's appearance and behavior.
+
+## local network installation
+
+<details>
+<summary><strong>install on your own devices without a public host</strong></summary>
+
+<br>
+
+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.
+
+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.
+
+<h3>how it works</h3>
+
+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.
+
+<a href="https://github.com/FiloSottile/mkcert">mkcert</a> bridges this gap with two pieces:
+
+- a **CA**, created once on your computer, that acts as a trusted issuer.
+- a **server certificate**, signed by your local CA, which your local server presents to prove its identity.
+
+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.
+
+> [!NOTE]
+> 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.
+>
+> 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:
+> - **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>).
+> - **install over a network you control**: your own wifi, not public/shared.
+> - **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>).
+
+<h3>usage</h3>
+
+1. run `./https_serve.py`. it will offer to install `mkcert` if needed, then create a local certificate authority (CA) on your computer.
+2. **first time on a new device:** scan the first QR code to download the CA certificate, then install and trust it.
+ - **iOS:**
+ 1. scanning the QR code prompts you to allow the download. tap **Allow**.<br>
+ <img src="readme_images/ios_ca_download.png" width="275" alt="iOS prompt: this website is trying to download a configuration profile"><br>
+ 2. **install it:** open `Settings` → `General` → `VPN & Device Management`, tap the downloaded profile, and tap **Install**.<br>
+ <img src="readme_images/ios_ca_install.png" width="275" alt="iOS profile install screen for the mkcert CA"><br>
+ 3. **trust it:** open `Settings` → `General` → `About` → `Certificate Trust Settings` and toggle the CA **on**.<br>
+ <img src="readme_images/ios_ca_trust.png" width="275" alt="iOS Certificate Trust Settings with the mkcert CA toggled on"><br>
+ - **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.)
+
+ a device that already trusts the CA from a previous run can skip this step.
+
+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.
+
+<h3 id="cleaning-up">cleaning up</h3>
+
+- **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.
+ - **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.
+ - **iOS, remove entirely:** `Settings` → `General` → `VPN & Device Management` → tap the profile → **Remove Profile**.
+ - **Android, remove:** `Settings` → `Security` → `Encryption & credentials` → `User credentials` (or `Trusted credentials` → `User`) → tap the mkcert entry → **Remove**.
+- **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.
+</details>
## licenses
this project is licensed under the <a href="LICENSE">GNU General Public License v3.0</a>.
diff --git a/resources/dev.js b/resources/dev.js
@@ -383,14 +383,8 @@ function deleteTrack(index) {
const removed = tracks[index];
if (!removed) return;
- // 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);
- });
+ // Snapshot row positions so survivors FLIP-animate after re-rendering.
+ const oldTops = snapshotRowTops();
const wasPlaying = currentTrackIndex === index;
@@ -424,30 +418,7 @@ function deleteTrack(index) {
}
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);
- });
+ flipRowsFrom(oldTops);
fetch('/tracks/' + encodeURIComponent(removed.filename), { method: 'DELETE' })
.then(r => { if (!r.ok) throw new Error('delete failed: ' + r.status); })
@@ -542,6 +513,41 @@ function applyRenames(renames) {
}
}
+function snapshotRowTops() {
+ const tops = new Map();
+ const items = playlist.querySelectorAll('.playlist-item');
+ items.forEach((el, i) => {
+ const t = tracks[i];
+ if (t) tops.set(t.filename, el.getBoundingClientRect().top);
+ });
+ return tops;
+}
+
+function flipRowsFrom(oldTops) {
+ const items = playlist.querySelectorAll('.playlist-item');
+ items.forEach((el, i) => {
+ const t = tracks[i];
+ if (!t) return;
+ const oldTop = oldTops.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);
+ });
+}
+
function reconcileTracks(incoming, renames) {
if (renames && renames.length) applyRenames(renames);
@@ -551,6 +557,10 @@ function reconcileTracks(incoming, renames) {
if (tracks.length > 0 && tracksEqual(tracks, incoming)) return;
+ // Snapshot row positions before mutating `tracks` so a remote reorder
+ // arriving over SSE animates (FLIP) instead of jumping.
+ const oldTops = snapshotRowTops();
+
const playingFilename = tracks[currentTrackIndex] && tracks[currentTrackIndex].filename;
const playingRemoved = playingFilename && !incomingNames.has(playingFilename);
@@ -615,6 +625,7 @@ function reconcileTracks(incoming, renames) {
if (batchUploadsInFlight === 0) {
renderPlaylist();
+ flipRowsFrom(oldTops);
flushPendingDropAnimation();
}
@@ -639,6 +650,13 @@ function startLiveSync() {
startLiveSync();
+// script.js may have already rendered the playlist before this file loaded
+// (the all-cached fast path renders almost immediately), in which case those
+// rows have no reorder handlers. Re-render now that they're defined.
+if (canReorder && typeof renderPlaylist === 'function') {
+ renderPlaylist();
+}
+
// Drag-and-drop file upload
const SUPPORTED_DROP_EXTS = ['.mp3', '.m4a', '.ogg', '.flac', '.wav'];
@@ -695,7 +713,7 @@ function computeInsertAfterIndex(clientY) {
}
function uploadFiles(files, insertAfter) {
- const snapshot = { positions: snapshotRowPositions(), ready: false };
+ const snapshot = { positions: snapshotRowTops(), ready: false };
pendingDropAnimations.push(snapshot);
batchUploadsInFlight++;
@@ -753,16 +771,6 @@ function uploadOneFile(file, insertAfter) {
});
}
-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;
-}
-
function flushPendingDropAnimation() {
const ready = pendingDropAnimations.filter(s => s.ready);
if (ready.length === 0) return;
@@ -777,26 +785,5 @@ function flushPendingDropAnimation() {
}
pendingDropAnimations = pendingDropAnimations.filter(s => !s.ready);
- 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);
- });
+ flipRowsFrom(merged);
}
diff --git a/resources/script.js b/resources/script.js
@@ -16,7 +16,10 @@ const isInstalled =
window.matchMedia('(display-mode: minimal-ui)').matches ||
window.navigator.standalone === true;
-if ('serviceWorker' in navigator && isInstalled && !isLocal) {
+// Register when installed as a PWA regardless of hostname so a mixapp
+// installed over LAN (via https_serve.py) caches for offline use too;
+// a plain browser tab still skips the SW so dev always sees latest files.
+if ('serviceWorker' in navigator && isInstalled) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('service-worker.js')
.then(registration => {
@@ -26,9 +29,7 @@ if ('serviceWorker' in navigator && isInstalled && !isLocal) {
console.log('Service Worker registration failed:', error);
});
});
-} 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.
+} else if ('serviceWorker' in navigator && !isLocal) {
navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(r => r.unregister());
});
@@ -109,8 +110,9 @@ async function fetchWithRetry(url, maxRetries = 4, baseDelay = 2000) {
}
}
-// Enable reordering UI only when served locally
-if (isLocal) {
+// Enable reordering UI only in a local browser tab (serve.py), never in an
+// installed PWA — there's no backend to persist reorders there.
+if (isLocal && !isInstalled) {
canReorder = true;
document.body.classList.add('reorderable');
}
@@ -138,30 +140,42 @@ fetch('manifest.json')
}
staticFiles.push(albumArtPath);
- // Probe for optional files and add them to staticFiles
const optionalFiles = ['mix/custom.css', 'mix/custom.js'];
- return Promise.all([
- ...optionalFiles.map(f =>
+ const detectOptional = isInstalled
+ ? caches.open(CACHE_NAME).then(cache =>
+ Promise.all(optionalFiles.map(f =>
+ cache.match(new URL(f, window.location.href).href)
+ .then(hit => { if (hit) staticFiles.push(f); })
+ .catch(() => {})
+ ))
+ )
+ : Promise.all(optionalFiles.map(f =>
fetch(f, { method: 'HEAD' })
- .then(r => {
- if (r.ok) {
- staticFiles.push(f);
- }
- })
+ .then(r => { if (r.ok) staticFiles.push(f); })
.catch(() => {})
- ),
- fetch('mix/tracks.json')
- .then(r => {
+ ));
+
+ // Load tracks.json cache-first when installed (instant, no network
+ // wait); network-first in a dev tab so reorders/edits show live.
+ const loadTracksFromCache = () =>
+ caches.open(CACHE_NAME)
+ .then(cache => cache.match(new URL('mix/tracks.json', window.location.href).href))
+ .then(r => r ? r.json() : Promise.reject('tracks.json not in cache'));
+ const loadTracks = isInstalled
+ ? loadTracksFromCache().catch(() =>
+ fetch('mix/tracks.json').then(r => {
if (!r.ok) throw new Error('tracks.json not found');
return r.json();
})
- .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'));
+ )
+ : fetch('mix/tracks.json')
+ .then(r => {
+ if (!r.ok) throw new Error('tracks.json not found');
+ return r.json();
})
- ]);
+ .catch(loadTracksFromCache);
+
+ return Promise.all([detectOptional, loadTracks]);
})
.then((results) => {
const data = results[results.length - 1];
@@ -347,7 +361,7 @@ function renderPlaylist() {
}
toggleLooping(index);
});
- if (canReorder) {
+ if (canReorder && typeof attachReorderHandlers === 'function') {
attachReorderHandlers(item, index);
}
playlist.appendChild(item);
diff --git a/resources/styles.css b/resources/styles.css
@@ -390,6 +390,14 @@ body.reordering .playlist-item {
mask-position: center;
}
+body::after {
+ content: '';
+ position: absolute;
+ width: 0;
+ height: 0;
+ mask-image: url('pause.svg');
+}
+
#prev::before {
mask-image: url('prev.svg');
mask-position: 45% center;
diff --git a/serve.py b/serve.py
@@ -94,7 +94,6 @@ def print_qr_code(url):
# Get the QR code matrix
matrix = qr.get_matrix()
- print("\nScan to connect:")
for y in range(0, len(matrix), 2):
line = ""
for x in range(len(matrix[y])):
@@ -222,6 +221,219 @@ def start_rescan_ticker(interval=1.0):
t.start()
+class TrackRequestHandler(http.server.SimpleHTTPRequestHandler):
+ 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()
+
def start_server():
"""Start the HTTP server (runs after venv is set up)"""
# Change to script directory
@@ -242,10 +454,9 @@ def start_server():
local_ip = get_local_ip()
# Create server
- Handler = http.server.SimpleHTTPRequestHandler
# Suppress default logging and broken pipe errors
- class QuietHandler(Handler):
+ class QuietHandler(TrackRequestHandler):
def end_headers(self):
self.send_header('Cache-Control', 'no-cache')
super().end_headers()
@@ -261,217 +472,6 @@ def start_server():
# Browser cancelled the request (normal for media streaming/preloading)
pass
- 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()
try:
with socketserver.ThreadingTCPServer(("", port), QuietHandler) as httpd:
@@ -480,9 +480,8 @@ def start_server():
network_url = f"http://{local_ip}:{port}"
print("=" * 60)
- print("💿 mixapps")
- print("=" * 60)
- print(f"\nServer running on port {port}")
+ print("💿 mixapps · local test server")
+ print("=" * 60 + "\n")
# Print QR code for easy mobile access
print_qr_code(network_url)