restructure: auto-scan, auto-rename, auto-reload

Hunter
Wednesday, May 6th, 2026 at 12:35 AM

.gitignore+3-4
buy.py+10-2
index.html+1-2
readme.md+7-10
resources/dev.jsnew
resources/reorder.jsdeleted
rip.py+12-3
scan.py+161-57
serve.py+143-54

.gitignore
23  manifest.json
24  service-worker.js
25  
   -# Personal deployment scripts
   -push.py
   -cleanup.py
26 +# Personal deployment script
27 +push.py
buy.py
  9  """
 10  
 11  import sys
 12 +import os
 13 +
 14 +# Hop into the shared venv (managed by scan.py) before doing anything else,
 15 +# so scan.rescan() has mutagen available when we call it after a purchase.
 16 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
 17 +import scan
 18 +scan.bootstrap(__file__)
 19 +
 20  import urllib.parse
 21  import urllib.request
 22  import json
 23  import platform
 24  import webbrowser
    -import os
 25  import shutil
 26  import time
 27  

158  	
159  	before = snapshot_audio_files(ITUNES_MUSIC_DIR)
160  	new_file = wait_for_new_file(ITUNES_MUSIC_DIR, before)
    -	
161 +
162  	filename = os.path.basename(new_file)
163  	dest = os.path.join(MIX_DIR, filename)
164  	shutil.copy2(new_file, dest)
165 +	scan.rescan(silent=True)
166  	print(f"Added to /mix: {filename}")
167  
168  
index.html
41  
42  	<script src="resources/script.js"></script>
43  	<script>
   -		// Load reorder UI only on local networks.
44  		if (typeof isLocal !== 'undefined' && isLocal) {
45  			const s = document.createElement('script');
   -			s.src = 'resources/reorder.js';
46 +			s.src = 'resources/dev.js';
47  			document.body.appendChild(s);
48  		}
49  	</script>
readme.md
41  		- `./rip.py` to rip tracks from a physical CD
42  		- `./buy.py` to search for tracks to purchase (opens in iTunes on MacOS, <a href="https://song.link/i/1651294855">song.link</a> otherwise)
43  
   -2. **scan it**
   -	- run `./scan.py` to create (or update) `tracks.json`, which defines the tracks available to the player.
   -
   -3. **serve it**
44 +2. **serve it**
45  	- run `./serve.py` to start a local HTTP server for testing. you can scan the QR code printed to your terminal to test the app from any device on your local network.
   -	- while the server is running, you can drag tracks up or down in the list to refine your mix (this automatically updates `tracks.json`).
   -	
   -4. **build it**
   -	- run `./build.py` and follow the interactive prompts to generate `manifest.json` and `service-worker.js`, which enable PWA installation and offline functionality.
46 +		- while the server is running, you can drag tracks up or down in the list to reorder them.
47 +
48 +3. **build it**
49 +	- 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.
50  
   -5. **ship it**
51 +4. **ship it**
52  	- upload the entire project directory to any static web host with HTTPS support (GitHub Pages, Neocities, AWS S3, etc.)
53  
   -6. **share it**
54 +5. **share it**
55  	- send the hosted URL to your recipient and walk them through the installation process:
56  		- **iOS (Safari)**: tap `···` → `Share` → `View More` → scroll down to reveal and tap `Add to Home Screen` → `Add`
57  		- **Android**:
resources/dev.js
  1 +/* Local-only reorder/rename code used when refining your mix via serve.py */
  2 +
  3 +// Drag to reorder
  4 +
  5 +const REORDER_DRAG_THRESHOLD = 5;
  6 +const LONG_PRESS_MS = 400;
  7 +const LONG_PRESS_MOVE_TOLERANCE = 10;
  8 +let activeReorder = null;
  9 +let longPressTimer = null;
 10 +
 11 +function attachReorderHandlers(item, index) {
 12 +	item.addEventListener('mousedown', (e) => {
 13 +		// Ignore non-primary buttons
 14 +		if (e.button !== 0) return;
 15 +		startReorder(item, index, e.clientX, e.clientY);
 16 +	});
 17 +
 18 +	item.addEventListener('touchstart', (e) => {
 19 +		if (e.touches.length !== 1) return;
 20 +		const t = e.touches[0];
 21 +		startLongPress(item, index, t.clientX, t.clientY);
 22 +	}, { passive: true });
 23 +}
 24 +
 25 +function startLongPress(item, index, startX, startY) {
 26 +	if (longPressTimer) clearTimeout(longPressTimer);
 27 +
 28 +	const candidate = { item, index, startX, startY };
 29 +	longPressTimer = setTimeout(() => {
 30 +		longPressTimer = null;
 31 +		cleanup();
 32 +		startReorder(candidate.item, candidate.index, candidate.startX, candidate.startY, /* isTouch */ true);
 33 +		beginDrag();
 34 +		if (navigator.vibrate) navigator.vibrate(10);
 35 +	}, LONG_PRESS_MS);
 36 +
 37 +	const onMove = (e) => {
 38 +		const t = e.touches[0];
 39 +		if (!t) return;
 40 +		const dx = t.clientX - startX;
 41 +		const dy = t.clientY - startY;
 42 +		if (Math.hypot(dx, dy) > LONG_PRESS_MOVE_TOLERANCE) cancel();
 43 +	};
 44 +	const onUp = () => cancel();
 45 +
 46 +	function cleanup() {
 47 +		document.removeEventListener('touchmove', onMove);
 48 +		document.removeEventListener('touchend', onUp);
 49 +	}
 50 +
 51 +	function cancel() {
 52 +		if (longPressTimer) {
 53 +			clearTimeout(longPressTimer);
 54 +			longPressTimer = null;
 55 +		}
 56 +		cleanup();
 57 +	}
 58 +
 59 +	document.addEventListener('touchmove', onMove, { passive: true });
 60 +	document.addEventListener('touchend', onUp);
 61 +}
 62 +
 63 +function startReorder(item, index, startX, startY, isTouch = false) {
 64 +	activeReorder = {
 65 +		item,
 66 +		index,
 67 +		startX,
 68 +		startY,
 69 +		started: false,
 70 +		placeholder: null,
 71 +		offsetY: 0,
 72 +		itemHeight: 0,
 73 +		autoScrollRaf: null,
 74 +		autoScrollSpeed: 0,
 75 +		lastClientY: startY,
 76 +		isTouch,
 77 +	};
 78 +	if (isTouch) {
 79 +		document.addEventListener('touchmove', onReorderTouchMove, { passive: false });
 80 +		document.addEventListener('touchend', onReorderEnd);
 81 +		document.addEventListener('touchcancel', onReorderEnd);
 82 +	} else {
 83 +		document.body.classList.add('reorder-pressed');
 84 +		document.addEventListener('mousemove', onReorderMove);
 85 +		document.addEventListener('mouseup', onReorderEnd);
 86 +	}
 87 +}
 88 +
 89 +function beginDrag() {
 90 +	const r = activeReorder;
 91 +	const rect = r.item.getBoundingClientRect();
 92 +	const playlistRect = playlist.getBoundingClientRect();
 93 +	r.itemHeight = rect.height;
 94 +	r.offsetY = r.startY - rect.top;
 95 +
 96 +	const styles = window.getComputedStyle(r.item);
 97 +	const placeholder = document.createElement('div');
 98 +	placeholder.classList.add('playlist-item-placeholder');
 99 +	placeholder.style.height = `${rect.height}px`;
100 +	placeholder.style.marginTop = styles.marginTop;
101 +	placeholder.style.marginBottom = styles.marginBottom;
102 +	placeholder.style.paddingLeft = styles.paddingLeft;
103 +	placeholder.style.paddingRight = styles.paddingRight;
104 +	placeholder.style.paddingBottom = styles.paddingBottom;
105 +	r.item.parentNode.insertBefore(placeholder, r.item);
106 +	r.placeholder = placeholder;
107 +
108 +	r.item.classList.add('dragging');
109 +	r.item.style.position = 'fixed';
110 +	r.item.style.left = `${playlistRect.left}px`;
111 +	r.item.style.top = `${rect.top}px`;
112 +	r.item.style.width = `${playlistRect.width}px`;
113 +	r.item.style.height = `${rect.height}px`;
114 +	r.item.style.zIndex = '10';
115 +	document.body.appendChild(r.item);
116 +	// Now we're actually dragging — disable pointer-events on the other items
117 +	document.body.classList.add('reordering');
118 +
119 +	r.started = true;
120 +	r.item._suppressClick = true;
121 +}
122 +
123 +function onReorderMove(e) {
124 +	const r = activeReorder;
125 +	if (!r) return;
126 +	r.lastClientY = e.clientY;
127 +
128 +	if (!r.started) {
129 +		const dx = e.clientX - r.startX;
130 +		const dy = e.clientY - r.startY;
131 +		if (Math.hypot(dx, dy) < REORDER_DRAG_THRESHOLD) return;
132 +		beginDrag();
133 +	}
134 +
135 +	positionDraggedItem();
136 +	updateAutoScroll();
137 +}
138 +
139 +function onReorderTouchMove(e) {
140 +	const r = activeReorder;
141 +	if (!r || !r.started) return;
142 +	e.preventDefault();
143 +	const t = e.touches[0];
144 +	if (!t) return;
145 +	r.lastClientY = t.clientY;
146 +	positionDraggedItem();
147 +	updateAutoScroll();
148 +}
149 +
150 +function positionDraggedItem() {
151 +	const r = activeReorder;
152 +	if (!r || !r.started) return;
153 +
154 +	// Clamp the dragged item between the bottom of the now-playing area
155 +	// (the wrapper's top) and the top of the navigation controls.
156 +	const wrapperRect = playlistWrapper.getBoundingClientRect();
157 +	const controlsRect = controls.getBoundingClientRect();
158 +	const desiredTop = r.lastClientY - r.offsetY;
159 +	const minTop = wrapperRect.top;
160 +	const maxTop = controlsRect.top - r.itemHeight;
161 +	const clampedTop = Math.max(minTop, Math.min(maxTop, desiredTop));
162 +	r.item.style.top = `${clampedTop}px`;
163 +
164 +	repositionPlaceholder();
165 +}
166 +
167 +function layoutMidY(el) {
168 +	const rect = el.getBoundingClientRect();
169 +	if (el._flipTargetTop !== undefined) {
170 +		return el._flipTargetTop + rect.height / 2;
171 +	}
172 +	return rect.top + rect.height / 2;
173 +}
174 +
175 +function repositionPlaceholder() {
176 +	const r = activeReorder;
177 +	if (!r || !r.started) return;
178 +
179 +	const draggedRect = r.item.getBoundingClientRect();
180 +	const draggedMid = draggedRect.top + draggedRect.height / 2;
181 +
182 +	const prev = r.placeholder.previousElementSibling;
183 +	if (prev && prev.classList.contains('playlist-item')) {
184 +		if (draggedMid < layoutMidY(prev)) {
185 +			flipItem(prev, () => playlist.insertBefore(r.placeholder, prev));
186 +			return;
187 +		}
188 +	}
189 +
190 +	const next = r.placeholder.nextElementSibling;
191 +	if (next && next.classList.contains('playlist-item')) {
192 +		if (draggedMid > layoutMidY(next)) {
193 +			flipItem(next, () => playlist.insertBefore(r.placeholder, next.nextSibling));
194 +			return;
195 +		}
196 +	}
197 +}
198 +
199 +const AUTO_SCROLL_EDGE = 60; // px from edge that triggers scrolling
200 +const AUTO_SCROLL_MAX_SPEED = 6; // px per frame
201 +
202 +function updateAutoScroll() {
203 +	const r = activeReorder;
204 +	if (!r || !r.started) return;
205 +
206 +	const wrapperRect = playlistWrapper.getBoundingClientRect();
207 +	const bottomBound = controls.getBoundingClientRect().top;
208 +	const y = r.lastClientY;
209 +
210 +	let speed = 0;
211 +	if (y < wrapperRect.top + AUTO_SCROLL_EDGE) {
212 +		const intensity = (wrapperRect.top + AUTO_SCROLL_EDGE - y) / AUTO_SCROLL_EDGE;
213 +		speed = -Math.min(1, intensity) * AUTO_SCROLL_MAX_SPEED;
214 +	} else if (y > bottomBound - AUTO_SCROLL_EDGE) {
215 +		const intensity = (y - (bottomBound - AUTO_SCROLL_EDGE)) / AUTO_SCROLL_EDGE;
216 +		speed = Math.min(1, intensity) * AUTO_SCROLL_MAX_SPEED;
217 +	}
218 +
219 +	r.autoScrollSpeed = speed;
220 +	if (speed !== 0 && !r.autoScrollRaf) {
221 +		const tick = () => {
222 +			const cur = activeReorder;
223 +			if (!cur || !cur.started || cur.autoScrollSpeed === 0) {
224 +				if (cur) cur.autoScrollRaf = null;
225 +				return;
226 +			}
227 +			const before = playlistWrapper.scrollTop;
228 +			playlistWrapper.scrollTop = Math.max(0, Math.min(playlistWrapper.scrollHeight - playlistWrapper.clientHeight, playlistWrapper.scrollTop + cur.autoScrollSpeed));
229 +			const delta = playlistWrapper.scrollTop - before;
230 +			if (delta !== 0) {
231 +				positionDraggedItem();
232 +			}
233 +			cur.autoScrollRaf = requestAnimationFrame(tick);
234 +		};
235 +		r.autoScrollRaf = requestAnimationFrame(tick);
236 +	}
237 +}
238 +
239 +function flipItem(el, mutate) {
240 +	el.style.transition = 'none';
241 +	el.style.transform = '';
242 +	const oldTop = el.getBoundingClientRect().top;
243 +
244 +	mutate();
245 +
246 +	const newTop = el.getBoundingClientRect().top;
247 +	const dy = oldTop - newTop;
248 +	if (!dy) return;
249 +	el.style.transform = `translateY(${dy}px)`;
250 +	void el.offsetHeight;
251 +	el.style.transition = 'transform 180ms ease';
252 +	el.style.transform = '';
253 +
254 +	el._flipTargetTop = newTop;
255 +	if (el._flipEndHandler) el.removeEventListener('transitionend', el._flipEndHandler);
256 +	el._flipEndHandler = (e) => {
257 +		if (e.propertyName !== 'transform') return;
258 +		delete el._flipTargetTop;
259 +		el.removeEventListener('transitionend', el._flipEndHandler);
260 +		el._flipEndHandler = null;
261 +	};
262 +	el.addEventListener('transitionend', el._flipEndHandler);
263 +}
264 +
265 +function onReorderEnd() {
266 +	const r = activeReorder;
267 +	document.removeEventListener('mousemove', onReorderMove);
268 +	document.removeEventListener('mouseup', onReorderEnd);
269 +	document.removeEventListener('touchmove', onReorderTouchMove);
270 +	document.removeEventListener('touchend', onReorderEnd);
271 +	document.removeEventListener('touchcancel', onReorderEnd);
272 +	activeReorder = null;
273 +	document.body.classList.remove('reorder-pressed');
274 +	document.body.classList.remove('reordering');
275 +	if (r && r.autoScrollRaf) cancelAnimationFrame(r.autoScrollRaf);
276 +	if (!r) return;
277 +
278 +	if (!r.started) {
279 +		// No real drag — let the click handler fire normally
280 +		return;
281 +	}
282 +
283 +	// Compute the new order from placeholder position and rebuild tracks array
284 +	const fromIndex = r.index;
285 +	const itemsInDom = Array.from(playlist.querySelectorAll('.playlist-item, .playlist-item-placeholder'));
286 +	const toIndex = itemsInDom.indexOf(r.placeholder);
287 +
288 +	const droppedTop = r.item.getBoundingClientRect().top;
289 +	r.placeholder.remove();
290 +
291 +	if (toIndex === -1 || toIndex === fromIndex) {
292 +		renderPlaylist();
293 +		animateDroppedTrack(r.item, fromIndex, droppedTop);
294 +		return;
295 +	}
296 +
297 +	// Build the new tracks array directly from the DOM ordering.
298 +	const moved = tracks[fromIndex];
299 +	const remaining = tracks.filter((_, i) => i !== fromIndex);
300 +	const newTracks = [];
301 +	let remainingIdx = 0;
302 +	for (let i = 0; i < itemsInDom.length; i++) {
303 +		if (itemsInDom[i] === r.placeholder) {
304 +			newTracks.push(moved);
305 +		} else {
306 +			newTracks.push(remaining[remainingIdx++]);
307 +		}
308 +	}
309 +
310 +	const playingTrack = tracks[currentTrackIndex];
311 +	tracks.length = 0;
312 +	tracks.push(...newTracks);
313 +	const newCurrentIndex = tracks.indexOf(playingTrack);
314 +	if (newCurrentIndex !== -1) setCurrentTrackIndex(newCurrentIndex);
315 +
316 +	renderPlaylist();
317 +	animateDroppedTrack(r.item, toIndex, droppedTop);
318 +	saveTrackOrder();
319 +}
320 +
321 +function animateDroppedTrack(draggedEl, newIndex, oldTop) {
322 +	const items = playlist.querySelectorAll('.playlist-item');
323 +	const target = items[newIndex];
324 +	if (!target) {
325 +		draggedEl.remove();
326 +		return;
327 +	}
328 +
329 +	// Hide the rebuilt item but keep it in layout so sizing/scroll are stable.
330 +	target.style.visibility = 'hidden';
331 +	const newTop = target.getBoundingClientRect().top;
332 +
333 +	const cleanup = () => {
334 +		draggedEl.remove();
335 +		target.style.visibility = '';
336 +	};
337 +
338 +	if (oldTop === newTop) {
339 +		cleanup();
340 +		return;
341 +	}
342 +
343 +	const playlistRect = playlist.getBoundingClientRect();
344 +	draggedEl.style.transition = 'none';
345 +	draggedEl.style.top = `${oldTop}px`;
346 +	draggedEl.style.left = `${playlistRect.left}px`;
347 +	draggedEl.style.width = `${playlistRect.width}px`;
348 +	void draggedEl.offsetHeight;
349 +	draggedEl.style.transition = 'top 180ms ease';
350 +	draggedEl.style.top = `${newTop}px`;
351 +
352 +	const finish = (e) => {
353 +		if (e.propertyName !== 'top') return;
354 +		draggedEl.removeEventListener('transitionend', finish);
355 +		cleanup();
356 +	};
357 +	draggedEl.addEventListener('transitionend', finish);
358 +}
359 +
360 +function saveTrackOrder() {
361 +	// Strip the runtime-only `looping` flag so we don't persist transient UI state
362 +	const persisted = tracks.map(({ looping, ...rest }) => rest);
363 +	fetch('/tracks', {
364 +		method: 'POST',
365 +		headers: { 'Content-Type': 'application/json' },
366 +		body: JSON.stringify(persisted),
367 +	}).catch(err => console.error('Failed to save order:', err));
368 +}
369 +
370 +// Live sync over SSE
371 +
372 +// Swap the now-playing text without disturbing an in-flight marquee
373 +function updateNowPlayingTextInPlace(text) {
374 +	const span = currentTrackDisplay.querySelector('span');
375 +	if (!span) {
376 +		updateCurrentTrackDisplay(text);
377 +		return;
378 +	}
379 +
380 +	// If the marquee isn't currently animating, decide whether the new text needs animation
381 +	if (!marqueeAnimating) {
382 +		span.textContent = text;
383 +		const containerWidth = currentTrackDisplay.offsetWidth - 30;
384 +		if (span.scrollWidth > containerWidth) {
385 +			marqueeOriginalText = text;
386 +			setupMarquee({ preserveOffset: true });
387 +		} else {
388 +			marqueeOriginalText = text;
389 +		}
390 +		return;
391 +	}
392 +
393 +	// Marquee is animating. Update the duplicated text in place, recompute
394 +	// the half-width (since the new text may differ in length), and wrap
395 +	// the current offset into the new range
396 +	marqueeOriginalText = text;
397 +	const spacing = '            ';
398 +	span.textContent = text + spacing + text + spacing;
399 +
400 +	const containerWidth = currentTrackDisplay.offsetWidth - 30;
401 +	if (span.scrollWidth / 2 <= containerWidth) {
402 +		// New text fits — stop the marquee cleanly.
403 +		setupMarquee({ preserveOffset: true });
404 +		return;
405 +	}
406 +
407 +	marqueeHalfWidth = span.scrollWidth / 2;
408 +	marqueeOffset = marqueeOffset % marqueeHalfWidth;
409 +	span.style.transform = `translateX(-${marqueeOffset}px)`;
410 +}
411 +
412 +// Skip the very first SSE message so we don't double-render before the player has finished its
413 +// initial setup.
414 +let sseSeenInitial = false;
415 +
416 +function tracksEqual(a, b) {
417 +	if (a.length !== b.length) return false;
418 +	for (let i = 0; i < a.length; i++) {
419 +		if (a[i].filename !== b[i].filename) return false;
420 +		if (a[i].title !== b[i].title) return false;
421 +		if (a[i].artist !== b[i].artist) return false;
422 +	}
423 +	return true;
424 +}
425 +
426 +function applyRenames(renames) {
427 +	for (const { from, to } of renames) {
428 +		if (from === to) continue;
429 +
430 +		if (preloadedAudio[from]) {
431 +			preloadedAudio[to] = preloadedAudio[from];
432 +			delete preloadedAudio[from];
433 +		}
434 +
435 +		if (cachedTracks.has(from)) {
436 +			cachedTracks.delete(from);
437 +			cachedTracks.add(to);
438 +		}
439 +
440 +		const oldUrl = `mix/${from}`;
441 +		if (audio.src && audio.src.endsWith(oldUrl)) {
442 +			const wasPlaying = !audio.paused;
443 +			const t = audio.currentTime;
444 +			audio.src = `mix/${to}`;
445 +			audio.load();
446 +			audio.addEventListener('loadedmetadata', function resume() {
447 +				audio.removeEventListener('loadedmetadata', resume);
448 +				try { audio.currentTime = t; } catch (e) {}
449 +				if (wasPlaying) audio.play().catch(() => {});
450 +			}, { once: true });
451 +		}
452 +
453 +		const idx = tracks.findIndex(t => t.filename === from);
454 +		if (idx !== -1) tracks[idx].filename = to;
455 +	}
456 +}
457 +
458 +function reconcileTracks(incoming, renames) {
459 +	if (renames && renames.length) applyRenames(renames);
460 +
461 +	const incomingByName = new Map(incoming.map(t => [t.filename, t]));
462 +	const currentNames = new Set(tracks.map(t => t.filename));
463 +	const incomingNames = new Set(incomingByName.keys());
464 +
465 +	if (tracks.length > 0 && tracksEqual(tracks, incoming)) return;
466 +
467 +	const playingFilename = tracks[currentTrackIndex] && tracks[currentTrackIndex].filename;
468 +	const playingRemoved = playingFilename && !incomingNames.has(playingFilename);
469 +
470 +	// Free blob URLs for tracks that disappeared.
471 +	for (const name of currentNames) {
472 +		if (!incomingNames.has(name) && preloadedAudio[name]) {
473 +			try { URL.revokeObjectURL(preloadedAudio[name].blobUrl); } catch (e) {}
474 +			delete preloadedAudio[name];
475 +		}
476 +	}
477 +
478 +	const loopingByName = new Map(tracks.map(t => [t.filename, t.looping || false]));
479 +	const newTracks = incoming.map(t => ({ ...t, looping: loopingByName.get(t.filename) || false }));
480 +
481 +	tracks.length = 0;
482 +	tracks.push(...newTracks);
483 +
484 +	if (tracks.length === 0) {
485 +		playerReady = false;
486 +		try { audio.pause(); } catch (e) {}
487 +		audio.removeAttribute('src');
488 +		audio.load();
489 +		isPlaying = false;
490 +		resetProgressBar();
491 +		updatePlayPauseButton();
492 +		updateCurrentTrackDisplay('No tracks found');
493 +		playlist.innerHTML = '';
494 +		return;
495 +	}
496 +
497 +	playerReady = true;
498 +
499 +	if (playingRemoved) {
500 +		// Playing track is gone: stop, advance to the next-best slot, and
501 +		// drop into "Ready to play".
502 +		try { audio.pause(); } catch (e) {}
503 +		audio.removeAttribute('src');
504 +		audio.load();
505 +		isPlaying = false;
506 +		resetProgressBar();
507 +		const nextIndex = Math.min(currentTrackIndex, tracks.length - 1);
508 +		setCurrentTrackIndex(nextIndex);
509 +		const next = tracks[nextIndex];
510 +		updateCurrentTrackDisplay(`Ready to play: ${next.artist} – ${next.title}`);
511 +		updatePlayPauseButton();
512 +	} else if (playingFilename) {
513 +		// Playing track survived (possibly via a rename applied above): its
514 +		// index may have shifted because of reorders or adds/removes
515 +		// elsewhere in the list.
516 +		const newIndex = tracks.findIndex(t => t.filename === playingFilename);
517 +		if (newIndex !== -1) setCurrentTrackIndex(newIndex);
518 +
519 +		const cur = tracks[currentTrackIndex];
520 +		if (cur) {
521 +			const isReady = currentTrackDisplay.textContent.includes('Ready to play:');
522 +			const text = isReady
523 +				? `Ready to play: ${cur.artist} – ${cur.title}`
524 +				: `${cur.artist} – ${cur.title}`;
525 +			updateNowPlayingTextInPlace(text);
526 +		}
527 +	}
528 +
529 +	renderPlaylist();
530 +
531 +	// Pick up any tracks added since last preload pass.
532 +	if (typeof preloadNextTrack === 'function') {
533 +		currentPreloadIndex = 0;
534 +		preloadNextTrack();
535 +	}
536 +}
537 +
538 +function startLiveSync() {
539 +	const source = new EventSource('/events');
540 +	source.addEventListener('tracks', (e) => {
541 +		const payload = JSON.parse(e.data);
542 +		if (!sseSeenInitial) {
543 +			sseSeenInitial = true;
544 +			return;
545 +		}
546 +		reconcileTracks(payload.tracks, payload.renames);
547 +	});
548 +}
549 +
550 +startLiveSync();
resources/reorder.js
  -const REORDER_DRAG_THRESHOLD = 5;
  -const LONG_PRESS_MS = 400;
  -const LONG_PRESS_MOVE_TOLERANCE = 10;
  -let activeReorder = null;
  -let longPressTimer = null;
  -
  -function attachReorderHandlers(item, index) {
  -	item.addEventListener('mousedown', (e) => {
  -		// Ignore non-primary buttons
  -		if (e.button !== 0) return;
  -		startReorder(item, index, e.clientX, e.clientY);
  -	});
  -
  -	item.addEventListener('touchstart', (e) => {
  -		if (e.touches.length !== 1) return;
  -		const t = e.touches[0];
  -		startLongPress(item, index, t.clientX, t.clientY);
  -	}, { passive: true });
  -}
  -
  -function startLongPress(item, index, startX, startY) {
  -	if (longPressTimer) clearTimeout(longPressTimer);
  -
  -	const candidate = { item, index, startX, startY };
  -	longPressTimer = setTimeout(() => {
  -		longPressTimer = null;
  -		cleanup();
  -		startReorder(candidate.item, candidate.index, candidate.startX, candidate.startY, /* isTouch */ true);
  -		beginDrag();
  -		if (navigator.vibrate) navigator.vibrate(10);
  -	}, LONG_PRESS_MS);
  -
  -	const onMove = (e) => {
  -		const t = e.touches[0];
  -		if (!t) return;
  -		const dx = t.clientX - startX;
  -		const dy = t.clientY - startY;
  -		if (Math.hypot(dx, dy) > LONG_PRESS_MOVE_TOLERANCE) cancel();
  -	};
  -	const onUp = () => cancel();
  -
  -	function cleanup() {
  -		document.removeEventListener('touchmove', onMove);
  -		document.removeEventListener('touchend', onUp);
  -	}
  -
  -	function cancel() {
  -		if (longPressTimer) {
  -			clearTimeout(longPressTimer);
  -			longPressTimer = null;
  -		}
  -		cleanup();
  -	}
  -
  -	document.addEventListener('touchmove', onMove, { passive: true });
  -	document.addEventListener('touchend', onUp);
  -}
  -
  -function startReorder(item, index, startX, startY, isTouch = false) {
  -	activeReorder = {
  -		item,
  -		index,
  -		startX,
  -		startY,
  -		started: false,
  -		placeholder: null,
  -		offsetY: 0,
  -		itemHeight: 0,
  -		autoScrollRaf: null,
  -		autoScrollSpeed: 0,
  -		lastClientY: startY,
  -		isTouch,
  -	};
  -	if (isTouch) {
  -		document.addEventListener('touchmove', onReorderTouchMove, { passive: false });
  -		document.addEventListener('touchend', onReorderEnd);
  -		document.addEventListener('touchcancel', onReorderEnd);
  -	} else {
  -		document.body.classList.add('reorder-pressed');
  -		document.addEventListener('mousemove', onReorderMove);
  -		document.addEventListener('mouseup', onReorderEnd);
  -	}
  -}
  -
  -function beginDrag() {
  -	const r = activeReorder;
  -	const rect = r.item.getBoundingClientRect();
  -	const playlistRect = playlist.getBoundingClientRect();
  -	r.itemHeight = rect.height;
  -	r.offsetY = r.startY - rect.top;
  -
  -	const styles = window.getComputedStyle(r.item);
  -	const placeholder = document.createElement('div');
  -	placeholder.classList.add('playlist-item-placeholder');
  -	placeholder.style.height = `${rect.height}px`;
  -	placeholder.style.marginTop = styles.marginTop;
  -	placeholder.style.marginBottom = styles.marginBottom;
  -	placeholder.style.paddingLeft = styles.paddingLeft;
  -	placeholder.style.paddingRight = styles.paddingRight;
  -	placeholder.style.paddingBottom = styles.paddingBottom;
  -	r.item.parentNode.insertBefore(placeholder, r.item);
  -	r.placeholder = placeholder;
  -
  -	r.item.classList.add('dragging');
  -	r.item.style.position = 'fixed';
  -	r.item.style.left = `${playlistRect.left}px`;
  -	r.item.style.top = `${rect.top}px`;
  -	r.item.style.width = `${playlistRect.width}px`;
  -	r.item.style.height = `${rect.height}px`;
  -	r.item.style.zIndex = '10';
  -	document.body.appendChild(r.item);
  -	// Now we're actually dragging — disable pointer-events on the other items
  -	document.body.classList.add('reordering');
  -
  -	r.started = true;
  -	r.item._suppressClick = true;
  -}
  -
  -function onReorderMove(e) {
  -	const r = activeReorder;
  -	if (!r) return;
  -	r.lastClientY = e.clientY;
  -
  -	if (!r.started) {
  -		const dx = e.clientX - r.startX;
  -		const dy = e.clientY - r.startY;
  -		if (Math.hypot(dx, dy) < REORDER_DRAG_THRESHOLD) return;
  -		beginDrag();
  -	}
  -
  -	positionDraggedItem();
  -	updateAutoScroll();
  -}
  -
  -function onReorderTouchMove(e) {
  -	const r = activeReorder;
  -	if (!r || !r.started) return;
  -	e.preventDefault();
  -	const t = e.touches[0];
  -	if (!t) return;
  -	r.lastClientY = t.clientY;
  -	positionDraggedItem();
  -	updateAutoScroll();
  -}
  -
  -function positionDraggedItem() {
  -	const r = activeReorder;
  -	if (!r || !r.started) return;
  -
  -	// Clamp the dragged item between the bottom of the now-playing area
  -	// (the wrapper's top) and the top of the navigation controls.
  -	const wrapperRect = playlistWrapper.getBoundingClientRect();
  -	const controlsRect = controls.getBoundingClientRect();
  -	const desiredTop = r.lastClientY - r.offsetY;
  -	const minTop = wrapperRect.top;
  -	const maxTop = controlsRect.top - r.itemHeight;
  -	const clampedTop = Math.max(minTop, Math.min(maxTop, desiredTop));
  -	r.item.style.top = `${clampedTop}px`;
  -
  -	repositionPlaceholder();
  -}
  -
  -function layoutMidY(el) {
  -	const rect = el.getBoundingClientRect();
  -	if (el._flipTargetTop !== undefined) {
  -		return el._flipTargetTop + rect.height / 2;
  -	}
  -	return rect.top + rect.height / 2;
  -}
  -
  -function repositionPlaceholder() {
  -	const r = activeReorder;
  -	if (!r || !r.started) return;
  -
  -	const draggedRect = r.item.getBoundingClientRect();
  -	const draggedMid = draggedRect.top + draggedRect.height / 2;
  -
  -	const prev = r.placeholder.previousElementSibling;
  -	if (prev && prev.classList.contains('playlist-item')) {
  -		if (draggedMid < layoutMidY(prev)) {
  -			flipItem(prev, () => playlist.insertBefore(r.placeholder, prev));
  -			return;
  -		}
  -	}
  -
  -	const next = r.placeholder.nextElementSibling;
  -	if (next && next.classList.contains('playlist-item')) {
  -		if (draggedMid > layoutMidY(next)) {
  -			flipItem(next, () => playlist.insertBefore(r.placeholder, next.nextSibling));
  -			return;
  -		}
  -	}
  -}
  -
  -const AUTO_SCROLL_EDGE = 60; // px from edge that triggers scrolling
  -const AUTO_SCROLL_MAX_SPEED = 6; // px per frame
  -
  -function updateAutoScroll() {
  -	const r = activeReorder;
  -	if (!r || !r.started) return;
  -
  -	const wrapperRect = playlistWrapper.getBoundingClientRect();
  -	const bottomBound = controls.getBoundingClientRect().top;
  -	const y = r.lastClientY;
  -
  -	let speed = 0;
  -	if (y < wrapperRect.top + AUTO_SCROLL_EDGE) {
  -		const intensity = (wrapperRect.top + AUTO_SCROLL_EDGE - y) / AUTO_SCROLL_EDGE;
  -		speed = -Math.min(1, intensity) * AUTO_SCROLL_MAX_SPEED;
  -	} else if (y > bottomBound - AUTO_SCROLL_EDGE) {
  -		const intensity = (y - (bottomBound - AUTO_SCROLL_EDGE)) / AUTO_SCROLL_EDGE;
  -		speed = Math.min(1, intensity) * AUTO_SCROLL_MAX_SPEED;
  -	}
  -
  -	r.autoScrollSpeed = speed;
  -	if (speed !== 0 && !r.autoScrollRaf) {
  -		const tick = () => {
  -			const cur = activeReorder;
  -			if (!cur || !cur.started || cur.autoScrollSpeed === 0) {
  -				if (cur) cur.autoScrollRaf = null;
  -				return;
  -			}
  -			const before = playlistWrapper.scrollTop;
  -			playlistWrapper.scrollTop = Math.max(0, Math.min(playlistWrapper.scrollHeight - playlistWrapper.clientHeight, playlistWrapper.scrollTop + cur.autoScrollSpeed));
  -			const delta = playlistWrapper.scrollTop - before;
  -			if (delta !== 0) {
  -				positionDraggedItem();
  -			}
  -			cur.autoScrollRaf = requestAnimationFrame(tick);
  -		};
  -		r.autoScrollRaf = requestAnimationFrame(tick);
  -	}
  -}
  -
  -function flipItem(el, mutate) {
  -	el.style.transition = 'none';
  -	el.style.transform = '';
  -	const oldTop = el.getBoundingClientRect().top;
  -
  -	mutate();
  -
  -	const newTop = el.getBoundingClientRect().top;
  -	const dy = oldTop - newTop;
  -	if (!dy) return;
  -	el.style.transform = `translateY(${dy}px)`;
  -	void el.offsetHeight;
  -	el.style.transition = 'transform 180ms ease';
  -	el.style.transform = '';
  -
  -	el._flipTargetTop = newTop;
  -	if (el._flipEndHandler) el.removeEventListener('transitionend', el._flipEndHandler);
  -	el._flipEndHandler = (e) => {
  -		if (e.propertyName !== 'transform') return;
  -		delete el._flipTargetTop;
  -		el.removeEventListener('transitionend', el._flipEndHandler);
  -		el._flipEndHandler = null;
  -	};
  -	el.addEventListener('transitionend', el._flipEndHandler);
  -}
  -
  -function onReorderEnd() {
  -	const r = activeReorder;
  -	document.removeEventListener('mousemove', onReorderMove);
  -	document.removeEventListener('mouseup', onReorderEnd);
  -	document.removeEventListener('touchmove', onReorderTouchMove);
  -	document.removeEventListener('touchend', onReorderEnd);
  -	document.removeEventListener('touchcancel', onReorderEnd);
  -	activeReorder = null;
  -	document.body.classList.remove('reorder-pressed');
  -	document.body.classList.remove('reordering');
  -	if (r && r.autoScrollRaf) cancelAnimationFrame(r.autoScrollRaf);
  -	if (!r) return;
  -
  -	if (!r.started) {
  -		// No real drag — let the click handler fire normally
  -		return;
  -	}
  -
  -	// Compute the new order from placeholder position and rebuild tracks array
  -	const fromIndex = r.index;
  -	const itemsInDom = Array.from(playlist.querySelectorAll('.playlist-item, .playlist-item-placeholder'));
  -	const toIndex = itemsInDom.indexOf(r.placeholder);
  -
  -	const droppedTop = r.item.getBoundingClientRect().top;
  -	r.placeholder.remove();
  -
  -	if (toIndex === -1 || toIndex === fromIndex) {
  -		renderPlaylist();
  -		animateDroppedTrack(r.item, fromIndex, droppedTop);
  -		return;
  -	}
  -
  -	// Build the new tracks array directly from the DOM ordering.
  -	const moved = tracks[fromIndex];
  -	const remaining = tracks.filter((_, i) => i !== fromIndex);
  -	const newTracks = [];
  -	let remainingIdx = 0;
  -	for (let i = 0; i < itemsInDom.length; i++) {
  -		if (itemsInDom[i] === r.placeholder) {
  -			newTracks.push(moved);
  -		} else {
  -			newTracks.push(remaining[remainingIdx++]);
  -		}
  -	}
  -
  -	const playingTrack = tracks[currentTrackIndex];
  -	tracks.length = 0;
  -	tracks.push(...newTracks);
  -	const newCurrentIndex = tracks.indexOf(playingTrack);
  -	if (newCurrentIndex !== -1) setCurrentTrackIndex(newCurrentIndex);
  -
  -	renderPlaylist();
  -	animateDroppedTrack(r.item, toIndex, droppedTop);
  -	saveTrackOrder();
  -}
  -
  -function animateDroppedTrack(draggedEl, newIndex, oldTop) {
  -	const items = playlist.querySelectorAll('.playlist-item');
  -	const target = items[newIndex];
  -	if (!target) {
  -		draggedEl.remove();
  -		return;
  -	}
  -
  -	// Hide the rebuilt item but keep it in layout so sizing/scroll are stable.
  -	target.style.visibility = 'hidden';
  -	const newTop = target.getBoundingClientRect().top;
  -
  -	const cleanup = () => {
  -		draggedEl.remove();
  -		target.style.visibility = '';
  -	};
  -
  -	if (oldTop === newTop) {
  -		cleanup();
  -		return;
  -	}
  -
  -	const playlistRect = playlist.getBoundingClientRect();
  -	draggedEl.style.transition = 'none';
  -	draggedEl.style.top = `${oldTop}px`;
  -	draggedEl.style.left = `${playlistRect.left}px`;
  -	draggedEl.style.width = `${playlistRect.width}px`;
  -	void draggedEl.offsetHeight;
  -	draggedEl.style.transition = 'top 180ms ease';
  -	draggedEl.style.top = `${newTop}px`;
  -
  -	const finish = (e) => {
  -		if (e.propertyName !== 'top') return;
  -		draggedEl.removeEventListener('transitionend', finish);
  -		cleanup();
  -	};
  -	draggedEl.addEventListener('transitionend', finish);
  -}
  -
  -function saveTrackOrder() {
  -	// Strip the runtime-only `looping` flag so we don't persist transient UI state
  -	const persisted = tracks.map(({ looping, ...rest }) => rest);
  -	fetch('/tracks', {
  -		method: 'POST',
  -		headers: { 'Content-Type': 'application/json' },
  -		body: JSON.stringify(persisted),
  -	}).catch(err => console.error('Failed to save order:', err));
  -}
rip.py
  6  
  7  import os
  8  import sys
  9 +
 10 +# Hop into the shared venv (managed by scan.py) before doing anything else,
 11 +# so scan.rescan() has mutagen available when we call it after ripping.
 12 +sys.path.insert(0, str(os.path.dirname(os.path.abspath(__file__))))
 13 +import scan
 14 +scan.bootstrap(__file__)
 15 +
 16  import subprocess
 17  import shutil
 18  import time

368  	print(f"\n✓ Successfully ripped {success_count}/{len(audio_files)} tracks in {total_minutes}m {total_secs}s.")
369  
370  	if success_count > 0:
371 +		# Pick up the newly-ripped tracks (with the artist tag we just wrote
372 +		# via ffmpeg) into tracks.json in disk order.
373 +		scan.rescan(silent=True)
374 +
375  		print(f"\nTracks saved to: {MIX_DIR}")
    -		print("\nNext steps:")
    -		print("  1. Run scan.py to generate tracks.json with metadata")
    -		print("  2. Run serve.py to test your mixtape locally")
376 +		print("\nNext step: run serve.py to test your mixtape locally.")
377  
378  		# Eject the CD
379  		print("\nEjecting CD...")
scan.py
 17  OUTPUT_FILE = MIX_DIR / "tracks.json"
 18  
 19  
    -def setup_venv():
    -	"""Create and set up virtual environment if it doesn't exist"""
    -	# Determine the path to pip and python in the venv
 20 +def setup_venv(packages=("mutagen",)):
 21 +	"""Create the shared venv if missing and ensure each package is installed.
 22 +	Returns the path to the venv Python interpreter."""
 23  	if sys.platform == "win32":
 24  		pip_path = VENV_DIR / "Scripts" / "pip"
 25  		python_path = VENV_DIR / "Scripts" / "python"

 27  		pip_path = VENV_DIR / "bin" / "pip"
 28  		python_path = VENV_DIR / "bin" / "python3"
 29  
    -	# Check if venv needs to be created or recreated
 30  	if not VENV_DIR.exists() or not python_path.exists():
 31  		if VENV_DIR.exists():
 32  			print("Virtual environment incomplete, recreating...")

 42  			print(f"Error creating virtual environment: {e}")
 43  			sys.exit(1)
 44  
    -	# Ensure pip is available (sometimes venv doesn't include it)
 45  	if not pip_path.exists():
 46  		print("Installing pip in virtual environment...")
 47  		try:

 50  			print(f"Error ensuring pip: {e}")
 51  			sys.exit(1)
 52  
    -	check = subprocess.run(
    -		[str(python_path), "-c", "import mutagen"],
    -		capture_output=True
    -	)
    -	if check.returncode != 0:
    -		try:
    -			subprocess.check_call([str(python_path), "-m", "pip", "install", "-q", "mutagen"])
    -		except subprocess.CalledProcessError:
    -			print("Note: Could not install mutagen (offline?). Metadata will be derived from filenames.\n")
 53 +	for pkg in packages:
 54 +		check = subprocess.run(
 55 +			[str(python_path), "-c", f"import {pkg}"],
 56 +			capture_output=True
 57 +		)
 58 +		if check.returncode != 0:
 59 +			try:
 60 +				subprocess.check_call([str(python_path), "-m", "pip", "install", "-q", pkg])
 61 +			except subprocess.CalledProcessError:
 62 +				print(f"Note: Could not install {pkg} (offline?).\n")
 63  
 64  	return python_path
 65  
 66  
 67 +def bootstrap(script_path, packages=("mutagen",), sentinel="--in-venv"):
 68 +	"""Ensure the calling script is running inside the shared venv
 69 +	with `packages` available. Re-execs through the venv Python on first call;
 70 +	returns immediately on the second pass once the sentinel is in argv.
 71 +
 72 +	Each script that uses this should pass its own __file__ as script_path."""
 73 +	if sentinel in sys.argv:
 74 +		sys.argv.remove(sentinel)
 75 +		return
 76 +	python_path = setup_venv(packages)
 77 +	try:
 78 +		result = subprocess.run([str(python_path), script_path, sentinel, *sys.argv[1:]])
 79 +		sys.exit(result.returncode)
 80 +	except KeyboardInterrupt:
 81 +		sys.exit(130)
 82 +
 83 +
 84  def run_in_venv():
    -	"""Re-run this script in the virtual environment"""
 85 +	"""Re-run this script in the virtual environment (CLI use of scan.py)."""
 86  	python_path = setup_venv()
    -
    -	# Re-run this script with the venv Python
 87  	print("Running scanner in virtual environment...\n")
 88  	subprocess.check_call([str(python_path), __file__, "--in-venv"])
 89  	sys.exit(0)
 90  
 91  
    -def scan_tracks():
    -	"""Main function to scan audio files and generate tracks.json"""
    -	# Import mutagen here (only after venv is active)
 92 +SUPPORTED_EXTENSIONS = ('.mp3', '.m4a', '.ogg', '.flac', '.wav')
 93 +
 94 +_INVALID_FILENAME_CHARS = '<>:"/\\|?*'
 95 +
 96 +
 97 +def _sanitize_filename(name):
 98 +	for char in _INVALID_FILENAME_CHARS:
 99 +		name = name.replace(char, '')
100 +	return name.strip()
101 +
102 +
103 +def _write_tags(audio_file, artist, title, MutagenFile):
104 +	"""Write artist/title tags to disk if they don't already match. Returns
105 +	True if anything was written."""
106 +	try:
107 +		audio = MutagenFile(audio_file, easy=True)
108 +		if audio is None:
109 +			return False
110 +		if audio.tags is None:
111 +			audio.add_tags()
112 +		current_title = audio.tags.get('title', [None])[0]
113 +		current_artist = audio.tags.get('artist', [None])[0]
114 +		if current_title == title and current_artist == artist:
115 +			return False
116 +		audio.tags['title'] = title
117 +		audio.tags['artist'] = artist
118 +		audio.save()
119 +		return True
120 +	except Exception:
121 +		return False
122 +
123 +
124 +def _canonicalize(tracks, has_mutagen, MutagenFile, silent):
125 +	"""Rename files to 'Artist – Title.ext' and sync ID3 tags so disk state
126 +	matches tracks.json metadata. Mutates `tracks` in place; returns a list
127 +	of {from, to} renames so callers can migrate any external state (eg.
128 +	preloaded blob URLs in connected browser tabs)."""
129 +	renames = []
130 +	for entry in tracks:
131 +		old_filename = entry['filename']
132 +		ext = Path(old_filename).suffix
133 +		artist = _sanitize_filename(entry['artist'])
134 +		title = _sanitize_filename(entry['title'])
135 +		new_filename = f"{artist} – {title}{ext}"
136 +
137 +		old_path = MIX_DIR / old_filename
138 +		new_path = MIX_DIR / new_filename
139 +
140 +		# Keep tags aligned with the (possibly hand-edited) tracks.json
141 +		# entry, regardless of whether a rename is needed.
142 +		if has_mutagen and old_path.exists():
143 +			_write_tags(old_path, entry['artist'], entry['title'], MutagenFile)
144 +
145 +		if old_filename == new_filename:
146 +			continue
147 +		if not old_path.exists():
148 +			continue
149 +		if new_path.exists():
150 +			# Don't clobber an unrelated file that already occupies the
151 +			# canonical name. Leave the entry pointing at its current file.
152 +			if not silent:
153 +				print(f"  Skipping rename {old_filename} -> {new_filename} (target exists)")
154 +			continue
155 +		try:
156 +			old_path.rename(new_path)
157 +			entry['filename'] = new_filename
158 +			renames.append({'from': old_filename, 'to': new_filename})
159 +			if not silent:
160 +				print(f"  Renamed: {old_filename} -> {new_filename}")
161 +		except Exception as e:
162 +			if not silent:
163 +				print(f"  Error renaming {old_filename}: {e}")
164 +	return renames
165 +
166 +
167 +def rescan(silent=False):
168 +	"""Reconcile tracks.json with /mix. Renames files to canonical
169 +	'Artist – Title.ext' form when they drift, syncing ID3 tags at the same
170 +	time. Returns (tracks, changed, renames) where renames is a list of
171 +	{from, to} dicts for any files that moved this pass."""
172  	try:
173  		from mutagen import File as MutagenFile  # type: ignore
174  		has_mutagen = True
175  	except ImportError:
176  		MutagenFile = None
177  		has_mutagen = False
    -		print("Mutagen not available. Metadata will be derived from filenames.\n")
    -
    -	# Supported audio formats
    -	SUPPORTED_EXTENSIONS = ('.mp3', '.m4a', '.ogg', '.flac', '.wav')
178 +		if not silent:
179 +			print("Mutagen not available. Metadata will be derived from filenames.\n")
180  
    -	# Check if tracks directory exists, create if it doesn't
181  	if not MIX_DIR.exists():
    -		print(f"Creating {MIX_DIR.name} directory...")
182  		MIX_DIR.mkdir(parents=True, exist_ok=True)
    -		print(f"✓ {MIX_DIR.name} directory created.")
    -		print(f"\nAdd audio files to the {MIX_DIR.name} directory and run this script again.")
    -		print(f"Supported formats: {', '.join(SUPPORTED_EXTENSIONS)}")
    -		sys.exit(0)
183  
    -	# Find all supported audio files
184  	audio_files = [f for f in MIX_DIR.iterdir() if f.suffix.lower() in SUPPORTED_EXTENSIONS]
185  
    -	if not audio_files:
    -		print(f"No audio files found in {MIX_DIR}")
    -		print(f"\nPlease add audio files to the {MIX_DIR.name} directory and run this script again.")
    -		print(f"Supported formats: {', '.join(SUPPORTED_EXTENSIONS)}")
    -		sys.exit(0)
    -
    -	print(f"Found {len(audio_files)} audio file(s). Extracting metadata...\n")
    -
186  	existing_tracks = []
187  	if OUTPUT_FILE.exists():
188  		try:

191  			if isinstance(loaded, list):
192  				existing_tracks = loaded
193  		except (json.JSONDecodeError, OSError) as e:
    -			print(f"Warning: could not read existing {OUTPUT_FILE.name} ({e}). Starting fresh.\n")
194 +			if not silent:
195 +				print(f"Warning: could not read existing {OUTPUT_FILE.name} ({e}). Starting fresh.\n")
196  
197  	existing_by_filename = {
198  		t['filename']: t for t in existing_tracks

234  		try:
235  			new_tracks.append(read_metadata(audio_file))
236  		except Exception as e:
    -			print(f"✗ Error reading {audio_file.name}: {e}")
237 +			if not silent:
238 +				print(f"✗ Error reading {audio_file.name}: {e}")
239  
    -	for track in new_tracks:
    -		print(f"+ {track['artist']} - {track['title']}")
    -	for filename in removed:
    -		print(f"- {filename} (removed; no longer on disk)")
    -	if not new_tracks and not removed:
    -		print("No changes — tracks.json already matches /mix.")
240 +	if not silent:
241 +		for track in new_tracks:
242 +			print(f"+ {track['artist']} - {track['title']}")
243 +		for filename in removed:
244 +			print(f"- {filename} (removed; no longer on disk)")
245 +		if not new_tracks and not removed:
246 +			print("No changes — tracks.json already matches /mix.")
247  
248  	tracks.extend(new_tracks)
249  
    -	if not tracks:
    -		print("\nNo valid audio files could be processed.")
    -		sys.exit(1)
250 +	renames = _canonicalize(tracks, has_mutagen, MutagenFile, silent)
251  
    -	# Write to tracks.json
    -	try:
    -		with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
    -			json.dump(tracks, f, indent="\t", ensure_ascii=False)
252 +	changed = bool(new_tracks) or bool(removed) or bool(renames)
253 +	if changed or not OUTPUT_FILE.exists():
254 +		try:
255 +			OUTPUT_FILE.write_text(
256 +				json.dumps(tracks, indent='\t', ensure_ascii=False) + '\n',
257 +				encoding='utf-8',
258 +			)
259 +		except Exception as e:
260 +			if not silent:
261 +				print(f"\nError writing {OUTPUT_FILE.name}: {e}")
262 +			raise
263 +
264 +	return tracks, changed, renames
265 +
266 +
267 +def scan_tracks():
268 +	"""CLI entry point: rescan and print a summary."""
269 +	if not MIX_DIR.exists():
270 +		print(f"Creating {MIX_DIR.name} directory...")
271 +		MIX_DIR.mkdir(parents=True, exist_ok=True)
272 +		print(f"✓ {MIX_DIR.name} directory created.")
273 +		print(f"\nAdd audio files to the {MIX_DIR.name} directory and run this script again.")
274 +		print(f"Supported formats: {', '.join(SUPPORTED_EXTENSIONS)}")
275 +		sys.exit(0)
276  
    -		print(f"\n✓ Successfully generated {OUTPUT_FILE.name} with {len(tracks)} track(s).")
277 +	audio_files = [f for f in MIX_DIR.iterdir() if f.suffix.lower() in SUPPORTED_EXTENSIONS]
278 +	if not audio_files:
279 +		print(f"No audio files found in {MIX_DIR}")
280 +		print(f"\nPlease add audio files to the {MIX_DIR.name} directory and run this script again.")
281 +		print(f"Supported formats: {', '.join(SUPPORTED_EXTENSIONS)}")
282 +		sys.exit(0)
283  
    -	except Exception as e:
    -		print(f"\nError writing {OUTPUT_FILE.name}: {e}")
284 +	print(f"Found {len(audio_files)} audio file(s). Extracting metadata...\n")
285 +	tracks, changed, _renames = rescan(silent=False)
286 +	if not tracks:
287 +		print("\nNo valid audio files could be processed.")
288  		sys.exit(1)
289 +	if changed:
290 +		print(f"\n✓ Updated {OUTPUT_FILE.name} ({len(tracks)} track(s)).")
291  
292  
293  def main():
serve.py
 10  import sys
 11  import os
 12  import json
 13 +import queue
 14 +import threading
 15  import subprocess
 16 +import time
 17  from pathlib import Path
 18  
 19  DEFAULT_PORT = 8000
 20  SCRIPT_DIR = Path(__file__).parent.absolute()
 21  VENV_DIR = SCRIPT_DIR / "venv"
 22 +TRACKS_PATH = SCRIPT_DIR / "mix" / "tracks.json"
 23  
 24 +# Serializes all reads/writes of tracks.json so rescans, reorders, and future
 25 +# uploads can't race each other.
 26 +tracks_lock = threading.Lock()
 27  
    -def setup_venv():
    -	"""Create and setup virtual environment if it doesn't exist"""
    -	# Determine the path to pip and python in the venv
    -	if sys.platform == "win32":
    -		pip_path = VENV_DIR / "Scripts" / "pip"
    -		python_path = VENV_DIR / "Scripts" / "python"
    -	else:
    -		pip_path = VENV_DIR / "bin" / "pip"
    -		python_path = VENV_DIR / "bin" / "python3"
    -
    -	# Check if venv needs to be created or recreated
    -	if not VENV_DIR.exists() or not python_path.exists():
    -		if VENV_DIR.exists():
    -			print("Virtual environment incomplete, recreating...")
    -			import shutil
    -			shutil.rmtree(VENV_DIR)
    -		else:
    -			print("Creating virtual environment...")
    -
    -		try:
    -			subprocess.check_call([sys.executable, "-m", "venv", str(VENV_DIR)])
    -			print("Virtual environment created successfully.")
    -		except subprocess.CalledProcessError as e:
    -			print(f"Error creating virtual environment: {e}")
    -			sys.exit(1)
    -
    -	# Ensure pip is available (sometimes venv doesn't include it)
    -	if not pip_path.exists():
    -		print("Installing pip in virtual environment...")
    -		try:
    -			subprocess.check_call([str(python_path), "-m", "ensurepip", "--upgrade"])
    -		except subprocess.CalledProcessError as e:
    -			print(f"Error ensuring pip: {e}")
    -			sys.exit(1)
    -
    -	check = subprocess.run(
    -		[str(python_path), "-c", "import qrcode"],
    -		capture_output=True
    -	)
    -	if check.returncode != 0:
    -		try:
    -			subprocess.check_call([str(python_path), "-m", "pip", "install", "-q", "qrcode"])
    -		except subprocess.CalledProcessError:
    -			print("Note: Could not install qrcode (offline?). QR codes will be unavailable.\n")
 28 +# Set of per-client SSE queues. Each connected /events client gets a queue;
 29 +# broadcast_tracks() pushes the latest tracks list onto every queue.
 30 +sse_subscribers = set()
 31 +sse_subscribers_lock = threading.Lock()
 32  
    -	return python_path
 33 +# Canonical serialization of the most recently broadcast tracks list, so we
 34 +# can fire on any content change (file add/remove/reorder or a hand-edit to
 35 +# tracks.json's metadata) instead of only when /mix's filenames change.
 36 +last_broadcast_serialized = None
 37  
 38  
 39  def run_in_venv():
    -	"""Re-run this script in the virtual environment"""
    -	python_path = setup_venv()
    -
    -	# Re-run this script with the venv Python
 40 +	"""Re-run this script in the shared venv with serve.py's deps available"""
 41 +	import scan
 42 +	python_path = scan.setup_venv(packages=("qrcode", "mutagen"))
 43  	try:
 44  		subprocess.check_call([str(python_path), __file__, "--in-venv"])
 45  	except (KeyboardInterrupt, subprocess.CalledProcessError):

110  	except Exception as e:
111  		print(f"\nCould not generate QR code: {e}")
112  
113 +
114 +def read_tracks():
115 +	"""Read tracks.json from disk; return [] if missing or malformed."""
116 +	if not TRACKS_PATH.exists():
117 +		return []
118 +	try:
119 +		with open(TRACKS_PATH, 'r', encoding='utf-8') as f:
120 +			loaded = json.load(f)
121 +		return loaded if isinstance(loaded, list) else []
122 +	except (json.JSONDecodeError, OSError):
123 +		return []
124 +
125 +
126 +def broadcast_tracks(tracks, renames=None):
127 +	"""Push the current tracks list to every connected SSE client if its
128 +	content differs from the last broadcast. `renames`, when non-empty,
129 +	carries [{from, to}, ...] so clients can migrate per-track state (eg.
130 +	preloaded blob URLs) without losing it across a canonicalizing rename.
131 +	Caller must hold tracks_lock."""
132 +	global last_broadcast_serialized
133 +	serialized = json.dumps(tracks, ensure_ascii=False, sort_keys=True)
134 +	if serialized == last_broadcast_serialized and not renames:
135 +		return
136 +	last_broadcast_serialized = serialized
137 +	payload = {'tracks': tracks, 'renames': renames or []}
138 +	with sse_subscribers_lock:
139 +		subscribers = list(sse_subscribers)
140 +	for q in subscribers:
141 +		try:
142 +			q.put_nowait(payload)
143 +		except queue.Full:
144 +			pass
145 +
146 +
147 +def rescan_and_maybe_broadcast():
148 +	"""Rescan /mix, then broadcast if the resulting tracks list differs from
149 +	what we last sent."""
150 +	import scan
151 +	tracks, _changed, renames = scan.rescan(silent=True)
152 +	broadcast_tracks(tracks, renames=renames)
153 +	return tracks
154 +
155 +
156 +def start_rescan_ticker(interval=1.0):
157 +	"""Background thread that periodically rescans /mix so changes made
158 +	directly on disk (eg. user dragging a file in or `rm`-ing one from the
159 +	terminal) reach connected clients without anyone hitting the server."""
160 +	def tick():
161 +		while True:
162 +			time.sleep(interval)
163 +			try:
164 +				with tracks_lock:
165 +					rescan_and_maybe_broadcast()
166 +			except Exception as e:
167 +				# Don't let a bad scan kill the ticker.
168 +				print(f"rescan tick error: {e}", file=sys.stderr)
169 +	t = threading.Thread(target=tick, daemon=True)
170 +	t.start()
171 +
172 +
173  def start_server():
174  	"""Start the HTTP server (runs after venv is set up)"""
175  	# Change to script directory
176  	os.chdir(SCRIPT_DIR)
177  
178 +	# Periodically rescan /mix so on-disk changes propagate to the UI even
179 +	# when nothing is hitting an HTTP endpoint.
180 +	start_rescan_ticker()
181 +
182  	# Find an available port
183  	port = find_available_port(DEFAULT_PORT)
184  

191  
192  	# Create server
193  	Handler = http.server.SimpleHTTPRequestHandler
    -	tracks_path = SCRIPT_DIR / "mix" / "tracks.json"
194  
195  	# Suppress default logging and broken pipe errors
196  	class QuietHandler(Handler):

209  				# Browser cancelled the request (normal for media streaming/preloading)
210  				pass
211  
212 +		def do_GET(self):
213 +			# Rescan /mix on every tracks.json fetch so the page always sees
214 +			# what's actually on disk (added via rip.py/buy.py or by hand).
215 +			if self.path == '/mix/tracks.json':
216 +				with tracks_lock:
217 +					rescan_and_maybe_broadcast()
218 +				return super().do_GET()
219 +
220 +			if self.path == '/events':
221 +				return self._serve_sse()
222 +
223 +			return super().do_GET()
224 +
225 +		def _serve_sse(self):
226 +			"""Server-Sent Events stream of tracks.json changes."""
227 +			self.send_response(200)
228 +			self.send_header('Content-Type', 'text/event-stream')
229 +			self.send_header('Cache-Control', 'no-cache')
230 +			self.send_header('Connection', 'keep-alive')
231 +			self.send_header('X-Accel-Buffering', 'no')
232 +			self.end_headers()
233 +
234 +			q = queue.Queue(maxsize=16)
235 +			with sse_subscribers_lock:
236 +				sse_subscribers.add(q)
237 +
238 +			try:
239 +				# Initial sync so a fresh tab gets the current state without
240 +				# waiting for the next change. No renames context: a fresh
241 +				# client has no prior state to migrate from.
242 +				with tracks_lock:
243 +					initial = read_tracks()
244 +				self._send_sse_event('tracks', {'tracks': initial, 'renames': []})
245 +
246 +				while True:
247 +					try:
248 +						payload = q.get(timeout=15)
249 +						self._send_sse_event('tracks', payload)
250 +					except queue.Empty:
251 +						# Heartbeat keeps proxies and idle connections from
252 +						# closing the stream.
253 +						self.wfile.write(b': keepalive\n\n')
254 +						self.wfile.flush()
255 +			except (BrokenPipeError, ConnectionResetError, OSError):
256 +				pass
257 +			finally:
258 +				with sse_subscribers_lock:
259 +					sse_subscribers.discard(q)
260 +
261 +		def _send_sse_event(self, event, data):
262 +			payload = json.dumps(data, ensure_ascii=False)
263 +			message = f'event: {event}\ndata: {payload}\n\n'.encode('utf-8')
264 +			self.wfile.write(message)
265 +			self.wfile.flush()
266 +
267  		def do_POST(self):
268  			# Local-only: receive a reordered tracks array and overwrite tracks.json
269  			if self.path != '/tracks':

272  				return
273  			length = int(self.headers.get('Content-Length', '0'))
274  			payload = json.loads(self.rfile.read(length))
    -			tracks_path.write_text(
    -				json.dumps(payload, indent='\t', ensure_ascii=False) + '\n',
    -				encoding='utf-8',
    -			)
275 +			with tracks_lock:
276 +				TRACKS_PATH.write_text(
277 +					json.dumps(payload, indent='\t', ensure_ascii=False) + '\n',
278 +					encoding='utf-8',
279 +				)
280 +				broadcast_tracks(payload)
281  			self.send_response(204)
282  			self.end_headers()
283