drag and drop to upload tracks

Hunter
Wednesday, May 6th, 2026 at 1:25 PM

readme.md+4-2
resources/dev.js+166-1
serve.py+150 

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. **serve it**
   -	- 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 reorder them, or `shift + click` to delete them.
45 +	- run `./serve.py` to start a local 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:
46 +		- **add new tracks** by dragging audio files onto the page
47 +		- **reorder tracks** by dragging them up or down in the list
48 +		- **delete tracks** with `shift + click`
49  
50  3. **build it**
51  	- 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.
resources/dev.js
613  		}
614  	}
615  
    -	renderPlaylist();
616 +	if (batchUploadsInFlight === 0) {
617 +		renderPlaylist();
618 +		flushPendingDropAnimation();
619 +	}
620  
621  	// Pick up any tracks added since last preload pass.
622  	if (typeof preloadNextTrack === 'function') {

638  }
639  
640  startLiveSync();
641 +
642 +// Drag-and-drop file upload
643 +
644 +const SUPPORTED_DROP_EXTS = ['.mp3', '.m4a', '.ogg', '.flac', '.wav'];
645 +
646 +// Snapshots of row positions captured at drop time, awaiting the SSE round-trip
647 +// so we can FLIP-animate displaced rows once the new tracks land.
648 +let pendingDropAnimations = [];
649 +
650 +// While > 0, a drag-drop batch is uploading. SSE updates land in `tracks`
651 +// but renderPlaylist is deferred so the user only sees the final layout.
652 +// Avoids snap-flashing through each intermediate ordering.
653 +let batchUploadsInFlight = 0;
654 +
655 +function dragHasFiles(e) {
656 +	if (!e.dataTransfer) return false;
657 +	const types = e.dataTransfer.types;
658 +	if (!types) return false;
659 +	for (let i = 0; i < types.length; i++) {
660 +		if (types[i] === 'Files') return true;
661 +	}
662 +	return false;
663 +}
664 +
665 +document.addEventListener('dragover', (e) => {
666 +	if (!dragHasFiles(e)) return;
667 +	e.preventDefault();
668 +	if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
669 +});
670 +
671 +document.addEventListener('drop', (e) => {
672 +	if (!dragHasFiles(e)) return;
673 +	e.preventDefault();
674 +
675 +	const files = Array.from(e.dataTransfer.files || []).filter(f => {
676 +		const lower = f.name.toLowerCase();
677 +		return SUPPORTED_DROP_EXTS.some(ext => lower.endsWith(ext));
678 +	});
679 +	if (files.length === 0) return;
680 +
681 +	const insertAfter = computeInsertAfterIndex(e.clientY);
682 +	uploadFiles(files, insertAfter);
683 +});
684 +
685 +function computeInsertAfterIndex(clientY) {
686 +	const items = Array.from(playlist.querySelectorAll('.playlist-item'));
687 +	if (items.length === 0) return -1;
688 +	let aboveCount = 0;
689 +	for (const item of items) {
690 +		const rect = item.getBoundingClientRect();
691 +		if (rect.top + rect.height / 2 < clientY) aboveCount++;
692 +		else break;
693 +	}
694 +	return aboveCount - 1;
695 +}
696 +
697 +function uploadFiles(files, insertAfter) {
698 +	const snapshot = { positions: snapshotRowPositions(), ready: false };
699 +	pendingDropAnimations.push(snapshot);
700 +	batchUploadsInFlight++;
701 +
702 +	const dropSnapshot = () => {
703 +		const idx = pendingDropAnimations.indexOf(snapshot);
704 +		if (idx !== -1) pendingDropAnimations.splice(idx, 1);
705 +	};
706 +
707 +	let cursor = insertAfter;
708 +	const results = [];
709 +	const chain = files.reduce((p, file) => p.then(() => {
710 +		return uploadOneFile(file, cursor).then(r => {
711 +			results.push(r);
712 +			if (r && typeof r.final_index === 'number' && r.final_index >= 0) {
713 +				cursor = r.final_index;
714 +			} else {
715 +				cursor++;
716 +			}
717 +		});
718 +	}), Promise.resolve());
719 +
720 +	chain.then(() => {
721 +		batchUploadsInFlight--;
722 +		const noBroadcast = results.every(r => r && r.duplicate && !r.moved);
723 +		if (noBroadcast) {
724 +			dropSnapshot();
725 +			return;
726 +		}
727 +		snapshot.ready = true;
728 +		// Render once against the final batch state, then FLIP-animate from
729 +		// the pre-drop snapshot.
730 +		if (batchUploadsInFlight === 0) {
731 +			renderPlaylist();
732 +			flushPendingDropAnimation();
733 +		}
734 +	}).catch(err => {
735 +		batchUploadsInFlight--;
736 +		console.error('Upload failed:', err);
737 +		dropSnapshot();
738 +		if (batchUploadsInFlight === 0) renderPlaylist();
739 +	});
740 +}
741 +
742 +function uploadOneFile(file, insertAfter) {
743 +	return fetch('/upload/' + encodeURIComponent(file.name), {
744 +		method: 'PUT',
745 +		headers: {
746 +			'Content-Type': 'application/octet-stream',
747 +			'X-Insert-After': String(insertAfter),
748 +		},
749 +		body: file,
750 +	}).then(r => {
751 +		if (!r.ok) throw new Error('upload failed: ' + r.status);
752 +		return r.json().catch(() => ({}));
753 +	});
754 +}
755 +
756 +function snapshotRowPositions() {
757 +	const map = new Map();
758 +	const items = playlist.querySelectorAll('.playlist-item');
759 +	items.forEach((el, i) => {
760 +		const t = tracks[i];
761 +		if (t) map.set(t.filename, el.getBoundingClientRect().top);
762 +	});
763 +	return map;
764 +}
765 +
766 +function flushPendingDropAnimation() {
767 +	const ready = pendingDropAnimations.filter(s => s.ready);
768 +	if (ready.length === 0) return;
769 +	// Merge ready snapshots. For any filename, prefer the OLDEST recorded
770 +	// top so multi-file drops still animate from the original pre-drop
771 +	// position.
772 +	const merged = new Map();
773 +	for (const snap of ready) {
774 +		for (const [name, top] of snap.positions) {
775 +			if (!merged.has(name)) merged.set(name, top);
776 +		}
777 +	}
778 +	pendingDropAnimations = pendingDropAnimations.filter(s => !s.ready);
779 +
780 +	const items = playlist.querySelectorAll('.playlist-item');
781 +	items.forEach((el, i) => {
782 +		const t = tracks[i];
783 +		if (!t) return;
784 +		const oldTop = merged.get(t.filename);
785 +		if (oldTop === undefined) return;
786 +		const newTop = el.getBoundingClientRect().top;
787 +		const dy = oldTop - newTop;
788 +		if (!dy) return;
789 +		el.style.transition = 'none';
790 +		el.style.transform = `translateY(${dy}px)`;
791 +		void el.offsetHeight;
792 +		el.style.transition = 'transform 180ms ease';
793 +		el.style.transform = '';
794 +		const clear = (e) => {
795 +			if (e.propertyName !== 'transform') return;
796 +			el.style.transition = '';
797 +			el.style.transform = '';
798 +			el.removeEventListener('transitionend', clear);
799 +		};
800 +		el.addEventListener('transitionend', clear);
801 +	});
802 +}
serve.py
 23  MIX_DIR = SCRIPT_DIR / "mix"
 24  TRACKS_PATH = MIX_DIR / "tracks.json"
 25  
 26 +SUPPORTED_UPLOAD_EXTENSIONS = ('.mp3', '.m4a', '.ogg', '.flac', '.wav')
 27 +MAX_UPLOAD_BYTES = 200 * 1024 * 1024  # 200 MB per request
 28 +
 29  # Serializes all reads/writes of tracks.json so rescans, reorders, and future
 30  # uploads can't race each other.
 31  tracks_lock = threading.Lock()

128  		return []
129  
130  
131 +def _move_track_to(tracks, filename, insert_after):
132 +	"""Reorder `tracks` in place so the entry matching `filename` lands at
133 +	insert_after + 1 (clamped). Persists tracks.json if the order actually
134 +	changed. Returns True iff a move happened. Caller must hold tracks_lock."""
135 +	cur_idx = next((i for i, t in enumerate(tracks) if t.get('filename') == filename), -1)
136 +	if cur_idx == -1:
137 +		return False
138 +	target_idx = max(0, min(insert_after + 1, len(tracks) - 1))
139 +	if target_idx == cur_idx:
140 +		return False
141 +	entry = tracks.pop(cur_idx)
142 +	if target_idx > cur_idx:
143 +		target_idx -= 1
144 +	tracks.insert(target_idx, entry)
145 +	TRACKS_PATH.write_text(
146 +		json.dumps(tracks, indent='\t', ensure_ascii=False) + '\n',
147 +		encoding='utf-8',
148 +	)
149 +	return True
150 +
151 +
152 +def _canonical_name_for(audio_path, fallback_name):
153 +	"""Return the 'Artist – Title.ext' filename scan would canonicalize this
154 +	upload to, so we can dedup BEFORE moving the file into /mix. Returns None
155 +	if metadata can't be read."""
156 +	import scan
157 +	try:
158 +		from mutagen import File as MutagenFile  # type: ignore
159 +	except ImportError:
160 +		return None
161 +	try:
162 +		audio = MutagenFile(audio_path, easy=True)
163 +	except Exception:
164 +		return None
165 +	title = None
166 +	artist = None
167 +	if audio and audio.tags:
168 +		title = audio.tags.get('title', [None])[0]
169 +		artist = audio.tags.get('artist', [None])[0]
170 +	if not title:
171 +		title = Path(fallback_name).stem
172 +	if not artist:
173 +		artist = "Unknown Artist"
174 +	ext = Path(fallback_name).suffix
175 +	return f"{scan._sanitize_filename(artist)} – {scan._sanitize_filename(title)}{ext}"
176 +
177 +
178  def broadcast_tracks(tracks, renames=None):
179  	"""Push the current tracks list to every connected SSE client if its
180  	content differs from the last broadcast. `renames`, when non-empty,

310  				with sse_subscribers_lock:
311  					sse_subscribers.discard(q)
312  
313 +		def _reply_json(self, status, payload):
314 +			body = json.dumps(payload).encode('utf-8')
315 +			self.send_response(status)
316 +			self.send_header('Content-Type', 'application/json')
317 +			self.send_header('Content-Length', str(len(body)))
318 +			self.end_headers()
319 +			self.wfile.write(body)
320 +
321  		def _send_sse_event(self, event, data):
322  			payload = json.dumps(data, ensure_ascii=False)
323  			message = f'event: {event}\ndata: {payload}\n\n'.encode('utf-8')

341  			self.send_response(204)
342  			self.end_headers()
343  
344 +		def do_PUT(self):
345 +			# Local-only: drag-and-drop upload from a connected browser.
346 +			# Path is /upload/<url-encoded-filename>; headers carry the
347 +			# desired insertion index (X-Insert-After: -1 means prepend).
348 +			prefix = '/upload/'
349 +			if not self.path.startswith(prefix):
350 +				self.send_response(404)
351 +				self.end_headers()
352 +				return
353 +
354 +			raw_name = urllib.parse.unquote(self.path[len(prefix):])
355 +			# Strip any client-supplied path components.
356 +			filename = Path(raw_name).name
357 +			ext = Path(filename).suffix.lower()
358 +			if not filename or ext not in SUPPORTED_UPLOAD_EXTENSIONS:
359 +				self.send_response(415)
360 +				self.end_headers()
361 +				return
362 +
363 +			length = int(self.headers.get('Content-Length', '0'))
364 +			if length <= 0 or length > MAX_UPLOAD_BYTES:
365 +				self.send_response(413)
366 +				self.end_headers()
367 +				return
368 +
369 +			try:
370 +				insert_after = int(self.headers.get('X-Insert-After', '-1'))
371 +			except ValueError:
372 +				insert_after = -1
373 +
374 +			# Stream the body to a temp file in MIX_DIR, then move into place
375 +			# atomically so a partial write is never visible to the rescan.
376 +			MIX_DIR.mkdir(parents=True, exist_ok=True)
377 +			tmp_path = MIX_DIR / (filename + '.uploading')
378 +			try:
379 +				with open(tmp_path, 'wb') as out:
380 +					remaining = length
381 +					while remaining > 0:
382 +						chunk = self.rfile.read(min(64 * 1024, remaining))
383 +						if not chunk:
384 +							break
385 +						out.write(chunk)
386 +						remaining -= len(chunk)
387 +				if remaining != 0:
388 +					tmp_path.unlink(missing_ok=True)
389 +					self.send_response(400)
390 +					self.end_headers()
391 +					return
392 +
393 +				with tracks_lock:
394 +					import scan
395 +					# Dedup by canonical name: if a file with the canonical
396 +					# "Artist – Title.ext" name already lives in /mix, drop
397 +					# the upload and just reorder the existing entry to the
398 +					# requested slot so the client still gets the FLIP.
399 +					canonical = _canonical_name_for(tmp_path, filename)
400 +					if canonical and (MIX_DIR / canonical).exists():
401 +						tmp_path.unlink(missing_ok=True)
402 +						tracks = read_tracks()
403 +						moved = _move_track_to(tracks, canonical, insert_after)
404 +						if moved:
405 +							broadcast_tracks(tracks)
406 +						final_index = next((i for i, t in enumerate(tracks) if t.get('filename') == canonical), -1)
407 +						self._reply_json(200, {'filename': canonical, 'duplicate': True, 'moved': moved, 'final_index': final_index})
408 +						return
409 +
410 +					tmp_path.rename(MIX_DIR / filename)
411 +					placed_name = filename
412 +
413 +					tracks, _changed, renames = scan.rescan(silent=True)
414 +
415 +					# Find the canonical (post-canonicalize) filename for the
416 +					# upload by following the rename chain.
417 +					new_name = placed_name
418 +					for r in renames:
419 +						if r['from'] == new_name:
420 +							new_name = r['to']
421 +
422 +					_move_track_to(tracks, new_name, insert_after)
423 +					broadcast_tracks(tracks, renames=renames)
424 +					final_index = next((i for i, t in enumerate(tracks) if t.get('filename') == new_name), -1)
425 +
426 +				self._reply_json(200, {'filename': new_name, 'final_index': final_index})
427 +			except Exception as e:
428 +				tmp_path.unlink(missing_ok=True)
429 +				self.send_response(500)
430 +				self.end_headers()
431 +				try:
432 +					self.wfile.write(str(e).encode('utf-8'))
433 +				except Exception:
434 +					pass
435 +
436  		def do_DELETE(self):
437  			# Local-only: remove a track's audio file from /mix and prune
438  			# tracks.json. Path is /tracks/<url-encoded-filename>.