first pass at track reordering when running locally

Hunter
Monday, April 27th, 2026 at 2:47 PM

index.html+8 
readme.md+4-4
resources/reorder.jsnew
resources/script.js+38-5
resources/styles.css+31 
scan.py+54-46
serve.py+17 

index.html
40  	<audio id="audioPlayer"></audio>
41  
42  	<script src="resources/script.js"></script>
43 +	<script>
44 +		// Load reorder UI only on local networks.
45 +		if (typeof isLocal !== 'undefined' && isLocal) {
46 +			const s = document.createElement('script');
47 +			s.src = 'resources/reorder.js';
48 +			document.body.appendChild(s);
49 +		}
50 +	</script>
51  	<script src="mix/custom.js" onerror="this.remove()"></script>
52  </body>
53  </html>
readme.md
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  
44  2. **scan it**
   -	- run `./scan.py` to generate `tracks.json`, which defines the tracks available to the player.
   -		- after running `./scan.py` once, you can manually edit `tracks.json` to refine your mix.
45 +	- run `./scan.py` to create (or update) `tracks.json`, which defines the tracks available to the player.
46  
47  3. **serve it**
   -	- run `./serve.py` to start a local HTTP server for testing. you can scan the QR code printed to the terminal to test the app from any device on your local network.
   -
48 +	- 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.
49 +	- while the server is running, you can drag tracks up or down in the list to refine your mix (this automatically updates `tracks.json`).
50 +	
51  4. **build it**
52  	- run `./build.py` and follow the interactive prompts to generate `manifest.json` and `service-worker.js`, which enable PWA installation and offline functionality.
53  
resources/reorder.js
  1 +const REORDER_DRAG_THRESHOLD = 5;
  2 +const LONG_PRESS_MS = 400;
  3 +const LONG_PRESS_MOVE_TOLERANCE = 10;
  4 +let activeReorder = null;
  5 +let longPressTimer = null;
  6 +
  7 +function attachReorderHandlers(item, index) {
  8 +	item.addEventListener('mousedown', (e) => {
  9 +		// Ignore non-primary buttons
 10 +		if (e.button !== 0) return;
 11 +		startReorder(item, index, e.clientX, e.clientY);
 12 +	});
 13 +
 14 +	item.addEventListener('touchstart', (e) => {
 15 +		if (e.touches.length !== 1) return;
 16 +		const t = e.touches[0];
 17 +		startLongPress(item, index, t.clientX, t.clientY);
 18 +	}, { passive: true });
 19 +}
 20 +
 21 +function startLongPress(item, index, startX, startY) {
 22 +	if (longPressTimer) clearTimeout(longPressTimer);
 23 +
 24 +	const candidate = { item, index, startX, startY };
 25 +	longPressTimer = setTimeout(() => {
 26 +		longPressTimer = null;
 27 +		cleanup();
 28 +		startReorder(candidate.item, candidate.index, candidate.startX, candidate.startY, /* isTouch */ true);
 29 +		beginDrag();
 30 +		if (navigator.vibrate) navigator.vibrate(10);
 31 +	}, LONG_PRESS_MS);
 32 +
 33 +	const onMove = (e) => {
 34 +		const t = e.touches[0];
 35 +		if (!t) return;
 36 +		const dx = t.clientX - startX;
 37 +		const dy = t.clientY - startY;
 38 +		if (Math.hypot(dx, dy) > LONG_PRESS_MOVE_TOLERANCE) cancel();
 39 +	};
 40 +	const onUp = () => cancel();
 41 +
 42 +	function cleanup() {
 43 +		document.removeEventListener('touchmove', onMove);
 44 +		document.removeEventListener('touchend', onUp);
 45 +	}
 46 +
 47 +	function cancel() {
 48 +		if (longPressTimer) {
 49 +			clearTimeout(longPressTimer);
 50 +			longPressTimer = null;
 51 +		}
 52 +		cleanup();
 53 +	}
 54 +
 55 +	document.addEventListener('touchmove', onMove, { passive: true });
 56 +	document.addEventListener('touchend', onUp);
 57 +}
 58 +
 59 +function startReorder(item, index, startX, startY, isTouch = false) {
 60 +	activeReorder = {
 61 +		item,
 62 +		index,
 63 +		startX,
 64 +		startY,
 65 +		started: false,
 66 +		placeholder: null,
 67 +		offsetY: 0,
 68 +		itemHeight: 0,
 69 +		autoScrollRaf: null,
 70 +		autoScrollSpeed: 0,
 71 +		lastClientY: startY,
 72 +		isTouch,
 73 +	};
 74 +	if (isTouch) {
 75 +		document.addEventListener('touchmove', onReorderTouchMove, { passive: false });
 76 +		document.addEventListener('touchend', onReorderEnd);
 77 +		document.addEventListener('touchcancel', onReorderEnd);
 78 +	} else {
 79 +		document.body.classList.add('reorder-pressed');
 80 +		document.addEventListener('mousemove', onReorderMove);
 81 +		document.addEventListener('mouseup', onReorderEnd);
 82 +	}
 83 +}
 84 +
 85 +function beginDrag() {
 86 +	const r = activeReorder;
 87 +	const rect = r.item.getBoundingClientRect();
 88 +	r.itemHeight = rect.height;
 89 +	r.offsetY = r.startY - rect.top;
 90 +
 91 +	const styles = window.getComputedStyle(r.item);
 92 +	const placeholder = document.createElement('div');
 93 +	placeholder.classList.add('playlist-item-placeholder');
 94 +	placeholder.style.height = `${rect.height}px`;
 95 +	placeholder.style.marginTop = styles.marginTop;
 96 +	placeholder.style.marginBottom = styles.marginBottom;
 97 +	placeholder.style.paddingLeft = styles.paddingLeft;
 98 +	placeholder.style.paddingRight = styles.paddingRight;
 99 +	placeholder.style.paddingBottom = styles.paddingBottom;
100 +	r.item.parentNode.insertBefore(placeholder, r.item);
101 +	r.placeholder = placeholder;
102 +
103 +	const playlistRect = playlist.getBoundingClientRect();
104 +	r.item.classList.add('dragging');
105 +	r.item.style.position = 'fixed';
106 +	r.item.style.left = `${playlistRect.left}px`;
107 +	r.item.style.top = `${rect.top}px`;
108 +	r.item.style.width = `${playlistRect.width}px`;
109 +	r.item.style.height = `${rect.height}px`;
110 +	r.item.style.zIndex = '10';
111 +	document.body.appendChild(r.item);
112 +	// Now we're actually dragging — disable pointer-events on the other items
113 +	document.body.classList.add('reordering');
114 +
115 +	r.started = true;
116 +	r.item._suppressClick = true;
117 +}
118 +
119 +function onReorderMove(e) {
120 +	const r = activeReorder;
121 +	if (!r) return;
122 +	r.lastClientY = e.clientY;
123 +
124 +	if (!r.started) {
125 +		const dx = e.clientX - r.startX;
126 +		const dy = e.clientY - r.startY;
127 +		if (Math.hypot(dx, dy) < REORDER_DRAG_THRESHOLD) return;
128 +		beginDrag();
129 +	}
130 +
131 +	positionDraggedItem();
132 +	updateAutoScroll();
133 +}
134 +
135 +function onReorderTouchMove(e) {
136 +	const r = activeReorder;
137 +	if (!r || !r.started) return;
138 +	e.preventDefault();
139 +	const t = e.touches[0];
140 +	if (!t) return;
141 +	r.lastClientY = t.clientY;
142 +	positionDraggedItem();
143 +	updateAutoScroll();
144 +}
145 +
146 +function positionDraggedItem() {
147 +	const r = activeReorder;
148 +	if (!r || !r.started) return;
149 +
150 +	// Clamp the dragged item between the bottom of the now-playing area
151 +	// (the wrapper's top) and the top of the navigation controls.
152 +	const wrapperRect = playlistWrapper.getBoundingClientRect();
153 +	const controlsRect = controls.getBoundingClientRect();
154 +	const desiredTop = r.lastClientY - r.offsetY;
155 +	const minTop = wrapperRect.top;
156 +	const maxTop = controlsRect.top - r.itemHeight;
157 +	const clampedTop = Math.max(minTop, Math.min(maxTop, desiredTop));
158 +	r.item.style.top = `${clampedTop}px`;
159 +
160 +	repositionPlaceholder();
161 +}
162 +
163 +function layoutMidY(el) {
164 +	const rect = el.getBoundingClientRect();
165 +	if (el._flipTargetTop !== undefined) {
166 +		return el._flipTargetTop + rect.height / 2;
167 +	}
168 +	return rect.top + rect.height / 2;
169 +}
170 +
171 +function repositionPlaceholder() {
172 +	const r = activeReorder;
173 +	if (!r || !r.started) return;
174 +
175 +	const draggedRect = r.item.getBoundingClientRect();
176 +	const draggedMid = draggedRect.top + draggedRect.height / 2;
177 +
178 +	const prev = r.placeholder.previousElementSibling;
179 +	if (prev && prev.classList.contains('playlist-item')) {
180 +		if (draggedMid < layoutMidY(prev)) {
181 +			flipItem(prev, () => playlist.insertBefore(r.placeholder, prev));
182 +			return;
183 +		}
184 +	}
185 +
186 +	const next = r.placeholder.nextElementSibling;
187 +	if (next && next.classList.contains('playlist-item')) {
188 +		if (draggedMid > layoutMidY(next)) {
189 +			flipItem(next, () => playlist.insertBefore(r.placeholder, next.nextSibling));
190 +			return;
191 +		}
192 +	}
193 +}
194 +
195 +const AUTO_SCROLL_EDGE = 60; // px from edge that triggers scrolling
196 +const AUTO_SCROLL_MAX_SPEED = 6; // px per frame
197 +
198 +function updateAutoScroll() {
199 +	const r = activeReorder;
200 +	if (!r || !r.started) return;
201 +
202 +	const wrapperRect = playlistWrapper.getBoundingClientRect();
203 +	const bottomBound = controls.getBoundingClientRect().top;
204 +	const y = r.lastClientY;
205 +
206 +	let speed = 0;
207 +	if (y < wrapperRect.top + AUTO_SCROLL_EDGE) {
208 +		const intensity = (wrapperRect.top + AUTO_SCROLL_EDGE - y) / AUTO_SCROLL_EDGE;
209 +		speed = -Math.min(1, intensity) * AUTO_SCROLL_MAX_SPEED;
210 +	} else if (y > bottomBound - AUTO_SCROLL_EDGE) {
211 +		const intensity = (y - (bottomBound - AUTO_SCROLL_EDGE)) / AUTO_SCROLL_EDGE;
212 +		speed = Math.min(1, intensity) * AUTO_SCROLL_MAX_SPEED;
213 +	}
214 +
215 +	r.autoScrollSpeed = speed;
216 +	if (speed !== 0 && !r.autoScrollRaf) {
217 +		const tick = () => {
218 +			const cur = activeReorder;
219 +			if (!cur || !cur.started || cur.autoScrollSpeed === 0) {
220 +				if (cur) cur.autoScrollRaf = null;
221 +				return;
222 +			}
223 +			const before = playlistWrapper.scrollTop;
224 +			playlistWrapper.scrollTop = Math.max(0, Math.min(playlistWrapper.scrollHeight - playlistWrapper.clientHeight, playlistWrapper.scrollTop + cur.autoScrollSpeed));
225 +			const delta = playlistWrapper.scrollTop - before;
226 +			if (delta !== 0) {
227 +				positionDraggedItem();
228 +			}
229 +			cur.autoScrollRaf = requestAnimationFrame(tick);
230 +		};
231 +		r.autoScrollRaf = requestAnimationFrame(tick);
232 +	}
233 +}
234 +
235 +function flipItem(el, mutate) {
236 +	el.style.transition = 'none';
237 +	el.style.transform = '';
238 +	const oldTop = el.getBoundingClientRect().top;
239 +
240 +	mutate();
241 +
242 +	const newTop = el.getBoundingClientRect().top;
243 +	const dy = oldTop - newTop;
244 +	if (!dy) return;
245 +	el.style.transform = `translateY(${dy}px)`;
246 +	void el.offsetHeight;
247 +	el.style.transition = 'transform 180ms ease';
248 +	el.style.transform = '';
249 +
250 +	el._flipTargetTop = newTop;
251 +	if (el._flipEndHandler) el.removeEventListener('transitionend', el._flipEndHandler);
252 +	el._flipEndHandler = (e) => {
253 +		if (e.propertyName !== 'transform') return;
254 +		delete el._flipTargetTop;
255 +		el.removeEventListener('transitionend', el._flipEndHandler);
256 +		el._flipEndHandler = null;
257 +	};
258 +	el.addEventListener('transitionend', el._flipEndHandler);
259 +}
260 +
261 +function onReorderEnd() {
262 +	const r = activeReorder;
263 +	document.removeEventListener('mousemove', onReorderMove);
264 +	document.removeEventListener('mouseup', onReorderEnd);
265 +	document.removeEventListener('touchmove', onReorderTouchMove);
266 +	document.removeEventListener('touchend', onReorderEnd);
267 +	document.removeEventListener('touchcancel', onReorderEnd);
268 +	activeReorder = null;
269 +	document.body.classList.remove('reorder-pressed');
270 +	document.body.classList.remove('reordering');
271 +	if (r && r.autoScrollRaf) cancelAnimationFrame(r.autoScrollRaf);
272 +	if (!r) return;
273 +
274 +	if (!r.started) {
275 +		// No real drag — let the click handler fire normally
276 +		return;
277 +	}
278 +
279 +	// Compute the new order from placeholder position and rebuild tracks array
280 +	const fromIndex = r.index;
281 +	const itemsInDom = Array.from(playlist.querySelectorAll('.playlist-item, .playlist-item-placeholder'));
282 +	const toIndex = itemsInDom.indexOf(r.placeholder);
283 +
284 +	const droppedTop = r.item.getBoundingClientRect().top;
285 +	r.placeholder.remove();
286 +
287 +	if (toIndex === -1 || toIndex === fromIndex) {
288 +		renderPlaylist();
289 +		animateDroppedTrack(r.item, fromIndex, droppedTop);
290 +		return;
291 +	}
292 +
293 +	// Build the new tracks array directly from the DOM ordering.
294 +	const moved = tracks[fromIndex];
295 +	const remaining = tracks.filter((_, i) => i !== fromIndex);
296 +	const newTracks = [];
297 +	let remainingIdx = 0;
298 +	for (let i = 0; i < itemsInDom.length; i++) {
299 +		if (itemsInDom[i] === r.placeholder) {
300 +			newTracks.push(moved);
301 +		} else {
302 +			newTracks.push(remaining[remainingIdx++]);
303 +		}
304 +	}
305 +
306 +	const playingTrack = tracks[currentTrackIndex];
307 +	tracks.length = 0;
308 +	tracks.push(...newTracks);
309 +	const newCurrentIndex = tracks.indexOf(playingTrack);
310 +	if (newCurrentIndex !== -1) setCurrentTrackIndex(newCurrentIndex);
311 +
312 +	renderPlaylist();
313 +	animateDroppedTrack(r.item, toIndex, droppedTop);
314 +	saveTrackOrder();
315 +}
316 +
317 +function animateDroppedTrack(draggedEl, newIndex, oldTop) {
318 +	const items = playlist.querySelectorAll('.playlist-item');
319 +	const target = items[newIndex];
320 +	if (!target) {
321 +		draggedEl.remove();
322 +		return;
323 +	}
324 +
325 +	// Hide the rebuilt item but keep it in layout so sizing/scroll are stable.
326 +	target.style.visibility = 'hidden';
327 +	const newTop = target.getBoundingClientRect().top;
328 +
329 +	const cleanup = () => {
330 +		draggedEl.remove();
331 +		target.style.visibility = '';
332 +	};
333 +
334 +	if (oldTop === newTop) {
335 +		cleanup();
336 +		return;
337 +	}
338 +
339 +	const playlistRect = playlist.getBoundingClientRect();
340 +	draggedEl.style.transition = 'none';
341 +	draggedEl.style.top = `${oldTop}px`;
342 +	draggedEl.style.left = `${playlistRect.left}px`;
343 +	draggedEl.style.width = `${playlistRect.width}px`;
344 +	void draggedEl.offsetHeight;
345 +	draggedEl.style.transition = 'top 180ms ease';
346 +	draggedEl.style.top = `${newTop}px`;
347 +
348 +	const finish = (e) => {
349 +		if (e.propertyName !== 'top') return;
350 +		draggedEl.removeEventListener('transitionend', finish);
351 +		cleanup();
352 +	};
353 +	draggedEl.addEventListener('transitionend', finish);
354 +}
355 +
356 +function saveTrackOrder() {
357 +	// Strip the runtime-only `looping` flag so we don't persist transient UI state
358 +	const persisted = tracks.map(({ looping, ...rest }) => rest);
359 +	fetch('/tracks', {
360 +		method: 'POST',
361 +		headers: { 'Content-Type': 'application/json' },
362 +		body: JSON.stringify(persisted),
363 +	}).catch(err => console.error('Failed to save order:', err));
364 +}
resources/script.js
    -// Register service worker for PWA functionality (skip on localhost to avoid caching during development)
    -if ('serviceWorker' in navigator && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1') {
  1 +const hostname = location.hostname;
  2 +const isLocal =
  3 +	hostname === 'localhost' ||
  4 +	hostname === '127.0.0.1' ||
  5 +	hostname === '::1' ||
  6 +	/^10\./.test(hostname) ||
  7 +	/^192\.168\./.test(hostname) ||
  8 +	/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
  9 +	/^169\.254\./.test(hostname);
 10 +
 11 +// Register service worker for PWA functionality (skip locally to avoid caching during development)
 12 +if ('serviceWorker' in navigator && !isLocal) {
 13  	window.addEventListener('load', () => {
 14  		navigator.serviceWorker.register('service-worker.js')
 15  			.then(registration => {

 19  				console.log('Service Worker registration failed:', error);
 20  			});
 21  	});
    -} else if ('serviceWorker' in navigator && (location.hostname === 'localhost' || location.hostname === '127.0.0.1')) {
    -	// On localhost, unregister any existing service worker and clear caches
 22 +} else if ('serviceWorker' in navigator && isLocal) {
 23 +	// Locally, unregister any existing service worker and clear caches
 24  	navigator.serviceWorker.getRegistrations().then(registrations => {
 25  		registrations.forEach(r => r.unregister());
 26  	});

 33  const prevBtn = document.getElementById('prev');
 34  const nextBtn = document.getElementById('next');
 35  const playlist = document.getElementById('playlist');
 36 +const playlistWrapper = document.querySelector('.playlist-wrapper');
 37 +const controls = document.querySelector('.controls');
 38  const currentTrackDisplay = document.getElementById('currentTrack');
 39  const progressBar = document.getElementById('progressBar');
 40  const progressContainer = document.getElementById('progressContainer');

 54  
 55  let currentTrackIndex = 0;
 56  let isPlaying = false;
 57 +let canReorder = false;
 58  let progressInterval;
 59  let playerReady = false;
 60  let tracks = [];

 99  	}
100  }
101  
102 +// Enable reordering UI only when served locally
103 +if (isLocal) {
104 +	canReorder = true;
105 +	document.body.classList.add('reorderable');
106 +}
107 +
108  // Load cache name from manifest.json first, then load tracks
109  fetch('manifest.json')
110  	.then(response => {

317  
318  		item.appendChild(contentDiv);
319  		item.appendChild(loopIcon);
    -		item.addEventListener('click', () => toggleLooping(index));
320 +		item.addEventListener('click', (e) => {
321 +			if (item._suppressClick) {
322 +				item._suppressClick = false;
323 +				e.stopPropagation();
324 +				return;
325 +			}
326 +			toggleLooping(index);
327 +		});
328 +		if (canReorder) {
329 +			attachReorderHandlers(item, index);
330 +		}
331  		playlist.appendChild(item);
332  	});
333  }
334  
335 +function setCurrentTrackIndex(i) {
336 +	currentTrackIndex = i;
337 +}
338 +
339  function toggleLooping(index) {
340  	if (!playerReady) return;
341  	if (index === currentTrackIndex) {
resources/styles.css
130  	padding-bottom: 70px;
131  	overscroll-behavior: none;
132  	margin-top: -1px;
133 +	overflow-anchor: none;
134  }
135  
136  .playlist {

197  	.playlist-item:hover .playlist-item-artist.current {
198  		color: var(--accent);
199  	}
200 +
201 +	body.reorderable .playlist,
202 +	body.reorderable .playlist-item:hover {
203 +		cursor: grab;
204 +	}
205 +}
206 +
207 +body.reorder-pressed,
208 +body.reorder-pressed * {
209 +	cursor: grabbing !important;
210 +}
211 +
212 +body.reordering .playlist-item {
213 +	pointer-events: none;
214 +	will-change: transform;
215 +}
216 +
217 +.playlist-item.dragging {
218 +	background-color: var(--background);
219 +	box-sizing: border-box;
220 +}
221 +
222 +.playlist-item.dragging,
223 +.playlist-item.dragging .playlist-item-title,
224 +.playlist-item.dragging .playlist-item-artist {
225 +	color: var(--accent);
226 +}
227 +
228 +.playlist-item-placeholder {
229 +	box-sizing: border-box;
230  }
231  
232  .current-track {
scan.py
 79  	"""Main function to scan audio files and generate tracks.json"""
 80  	# Import mutagen here (only after venv is active)
 81  	try:
    -		from mutagen import File as MutagenFile
 82 +		from mutagen import File as MutagenFile  # type: ignore
 83  		has_mutagen = True
 84  	except ImportError:
 85  		MutagenFile = None

109  
110  	print(f"Found {len(audio_files)} audio file(s). Extracting metadata...\n")
111  
    -	tracks = []
    -
    -	for audio_file in sorted(audio_files):
112 +	existing_tracks = []
113 +	if OUTPUT_FILE.exists():
114  		try:
    -			title = None
    -			artist = None
    -
    -			if has_mutagen:
    -				audio = MutagenFile(audio_file, easy=True)
    -				if audio and audio.tags:
    -					title = audio.tags.get('title', [None])[0]
    -					artist = audio.tags.get('artist', [None])[0]
    -
    -			# Fallback to filename for title if not found
    -			if not title:
    -				title = audio_file.stem  # filename without extension
115 +			with open(OUTPUT_FILE, 'r', encoding='utf-8') as f:
116 +				loaded = json.load(f)
117 +			if isinstance(loaded, list):
118 +				existing_tracks = loaded
119 +		except (json.JSONDecodeError, OSError) as e:
120 +			print(f"Warning: could not read existing {OUTPUT_FILE.name} ({e}). Starting fresh.\n")
121 +
122 +	existing_by_filename = {
123 +		t['filename']: t for t in existing_tracks
124 +		if isinstance(t, dict) and 'filename' in t
125 +	}
126 +	on_disk_filenames = {f.name for f in audio_files}
127 +	files_by_name = {f.name: f for f in audio_files}
128 +
129 +	def read_metadata(audio_file):
130 +		title = None
131 +		artist = None
132 +		if has_mutagen:
133 +			audio = MutagenFile(audio_file, easy=True)
134 +			if audio and audio.tags:
135 +				title = audio.tags.get('title', [None])[0]
136 +				artist = audio.tags.get('artist', [None])[0]
137 +		if not title:
138 +			title = audio_file.stem
139 +		if not artist:
140 +			artist = "Unknown Artist"
141 +		return {"title": title, "artist": artist, "filename": audio_file.name}
142  
    -			# Fallback to "Unknown Artist" if not found
    -			if not artist:
    -				artist = "Unknown Artist"
    -
    -			track_info = {
    -				"title": title,
    -				"artist": artist,
    -				"filename": audio_file.name
    -			}
    -
    -			tracks.append(track_info)
    -			print(f"✓ {track_info['artist']} - {track_info['title']}")
143 +	tracks = []
144 +	removed = []
145 +	for entry in existing_tracks:
146 +		if not isinstance(entry, dict) or 'filename' not in entry:
147 +			continue
148 +		if entry['filename'] in on_disk_filenames:
149 +			tracks.append(entry)
150 +		else:
151 +			removed.append(entry['filename'])
152  
153 +	new_files = sorted(
154 +		(files_by_name[name] for name in on_disk_filenames if name not in existing_by_filename),
155 +		key=lambda f: f.name,
156 +	)
157 +	new_tracks = []
158 +	for audio_file in new_files:
159 +		try:
160 +			new_tracks.append(read_metadata(audio_file))
161  		except Exception as e:
162  			print(f"✗ Error reading {audio_file.name}: {e}")
    -			continue
163  
    -	# Check if ALL titles start with numbers
    -	# If so, strip the leading numbers from all titles
    -	import re
    -	all_have_leading_numbers = all(
    -		re.match(r'^\d+\s*[-.]?\s*', track['title'])
    -		for track in tracks
    -	)
164 +	for track in new_tracks:
165 +		print(f"+ {track['artist']} - {track['title']}")
166 +	for filename in removed:
167 +		print(f"- {filename} (removed; no longer on disk)")
168 +	if not new_tracks and not removed:
169 +		print("No changes — tracks.json already matches /mix.")
170  
    -	if all_have_leading_numbers and tracks:
    -		print("\nDetected track numbers in all titles. Stripping them...")
    -		for track in tracks:
    -			original_title = track['title']
    -			# Remove leading number pattern
    -			cleaned_title = re.sub(r'^\d+\s*[-.]?\s*', '', original_title)
    -			if cleaned_title:  # Only update if something remains
    -				track['title'] = cleaned_title
    -				if cleaned_title != original_title:
    -					print(f"  {original_title} → {cleaned_title}")
171 +	tracks.extend(new_tracks)
172  
173  	if not tracks:
174  		print("\nNo valid audio files could be processed.")
serve.py
  9  import socket
 10  import sys
 11  import os
 12 +import json
 13  import subprocess
 14  from pathlib import Path
 15  

151  
152  	# Create server
153  	Handler = http.server.SimpleHTTPRequestHandler
154 +	tracks_path = SCRIPT_DIR / "mix" / "tracks.json"
155  
156  	# Suppress default logging and broken pipe errors
157  	class QuietHandler(Handler):

170  				# Browser cancelled the request (normal for media streaming/preloading)
171  				pass
172  
173 +		def do_POST(self):
174 +			# Local-only: receive a reordered tracks array and overwrite tracks.json
175 +			if self.path != '/tracks':
176 +				self.send_response(404)
177 +				self.end_headers()
178 +				return
179 +			length = int(self.headers.get('Content-Length', '0'))
180 +			payload = json.loads(self.rfile.read(length))
181 +			tracks_path.write_text(
182 +				json.dumps(payload, indent='\t', ensure_ascii=False) + '\n',
183 +				encoding='utf-8',
184 +			)
185 +			self.send_response(204)
186 +			self.end_headers()
187 +
188  	try:
189  		with socketserver.TCPServer(("", port), QuietHandler) as httpd:
190  			local_url = f"http://localhost:{port}"