commit aec27938edc23886ab46548b87ba837accbff440
parent b8e3b7a2c83241f3f5a87cc23d7e41eb4c4d4981
Author: Hunter
Date: Wed, 6 May 2026 13:25:47 -0400
drag and drop to upload tracks
Diffstat:
| M | readme.md | | | 6 | ++++-- |
| M | resources/dev.js | | | 167 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
| M | serve.py | | | 150 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
3 files changed, 320 insertions(+), 3 deletions(-)
diff --git a/readme.md b/readme.md
@@ -42,8 +42,10 @@ hits different, right?<br><br>
- `./buy.py` to search for tracks to purchase (opens in iTunes on MacOS, <a href="https://song.link/i/1651294855">song.link</a> otherwise)
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.
+ - 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:
+ - **add new tracks** by dragging audio files onto the page
+ - **reorder tracks** by dragging them up or down in the list
+ - **delete tracks** with `shift + click`
3. **build it**
- 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.
diff --git a/resources/dev.js b/resources/dev.js
@@ -613,7 +613,10 @@ function reconcileTracks(incoming, renames) {
}
}
- renderPlaylist();
+ if (batchUploadsInFlight === 0) {
+ renderPlaylist();
+ flushPendingDropAnimation();
+ }
// Pick up any tracks added since last preload pass.
if (typeof preloadNextTrack === 'function') {
@@ -635,3 +638,165 @@ function startLiveSync() {
}
startLiveSync();
+
+// Drag-and-drop file upload
+
+const SUPPORTED_DROP_EXTS = ['.mp3', '.m4a', '.ogg', '.flac', '.wav'];
+
+// Snapshots of row positions captured at drop time, awaiting the SSE round-trip
+// so we can FLIP-animate displaced rows once the new tracks land.
+let pendingDropAnimations = [];
+
+// While > 0, a drag-drop batch is uploading. SSE updates land in `tracks`
+// but renderPlaylist is deferred so the user only sees the final layout.
+// Avoids snap-flashing through each intermediate ordering.
+let batchUploadsInFlight = 0;
+
+function dragHasFiles(e) {
+ if (!e.dataTransfer) return false;
+ const types = e.dataTransfer.types;
+ if (!types) return false;
+ for (let i = 0; i < types.length; i++) {
+ if (types[i] === 'Files') return true;
+ }
+ return false;
+}
+
+document.addEventListener('dragover', (e) => {
+ if (!dragHasFiles(e)) return;
+ e.preventDefault();
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
+});
+
+document.addEventListener('drop', (e) => {
+ if (!dragHasFiles(e)) return;
+ e.preventDefault();
+
+ const files = Array.from(e.dataTransfer.files || []).filter(f => {
+ const lower = f.name.toLowerCase();
+ return SUPPORTED_DROP_EXTS.some(ext => lower.endsWith(ext));
+ });
+ if (files.length === 0) return;
+
+ const insertAfter = computeInsertAfterIndex(e.clientY);
+ uploadFiles(files, insertAfter);
+});
+
+function computeInsertAfterIndex(clientY) {
+ const items = Array.from(playlist.querySelectorAll('.playlist-item'));
+ if (items.length === 0) return -1;
+ let aboveCount = 0;
+ for (const item of items) {
+ const rect = item.getBoundingClientRect();
+ if (rect.top + rect.height / 2 < clientY) aboveCount++;
+ else break;
+ }
+ return aboveCount - 1;
+}
+
+function uploadFiles(files, insertAfter) {
+ const snapshot = { positions: snapshotRowPositions(), ready: false };
+ pendingDropAnimations.push(snapshot);
+ batchUploadsInFlight++;
+
+ const dropSnapshot = () => {
+ const idx = pendingDropAnimations.indexOf(snapshot);
+ if (idx !== -1) pendingDropAnimations.splice(idx, 1);
+ };
+
+ let cursor = insertAfter;
+ const results = [];
+ const chain = files.reduce((p, file) => p.then(() => {
+ return uploadOneFile(file, cursor).then(r => {
+ results.push(r);
+ if (r && typeof r.final_index === 'number' && r.final_index >= 0) {
+ cursor = r.final_index;
+ } else {
+ cursor++;
+ }
+ });
+ }), Promise.resolve());
+
+ chain.then(() => {
+ batchUploadsInFlight--;
+ const noBroadcast = results.every(r => r && r.duplicate && !r.moved);
+ if (noBroadcast) {
+ dropSnapshot();
+ return;
+ }
+ snapshot.ready = true;
+ // Render once against the final batch state, then FLIP-animate from
+ // the pre-drop snapshot.
+ if (batchUploadsInFlight === 0) {
+ renderPlaylist();
+ flushPendingDropAnimation();
+ }
+ }).catch(err => {
+ batchUploadsInFlight--;
+ console.error('Upload failed:', err);
+ dropSnapshot();
+ if (batchUploadsInFlight === 0) renderPlaylist();
+ });
+}
+
+function uploadOneFile(file, insertAfter) {
+ return fetch('/upload/' + encodeURIComponent(file.name), {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/octet-stream',
+ 'X-Insert-After': String(insertAfter),
+ },
+ body: file,
+ }).then(r => {
+ if (!r.ok) throw new Error('upload failed: ' + r.status);
+ return r.json().catch(() => ({}));
+ });
+}
+
+function snapshotRowPositions() {
+ const map = new Map();
+ const items = playlist.querySelectorAll('.playlist-item');
+ items.forEach((el, i) => {
+ const t = tracks[i];
+ if (t) map.set(t.filename, el.getBoundingClientRect().top);
+ });
+ return map;
+}
+
+function flushPendingDropAnimation() {
+ const ready = pendingDropAnimations.filter(s => s.ready);
+ if (ready.length === 0) return;
+ // Merge ready snapshots. For any filename, prefer the OLDEST recorded
+ // top so multi-file drops still animate from the original pre-drop
+ // position.
+ const merged = new Map();
+ for (const snap of ready) {
+ for (const [name, top] of snap.positions) {
+ if (!merged.has(name)) merged.set(name, top);
+ }
+ }
+ pendingDropAnimations = pendingDropAnimations.filter(s => !s.ready);
+
+ const items = playlist.querySelectorAll('.playlist-item');
+ items.forEach((el, i) => {
+ const t = tracks[i];
+ if (!t) return;
+ const oldTop = merged.get(t.filename);
+ if (oldTop === undefined) return;
+ const newTop = el.getBoundingClientRect().top;
+ const dy = oldTop - newTop;
+ if (!dy) return;
+ el.style.transition = 'none';
+ el.style.transform = `translateY(${dy}px)`;
+ void el.offsetHeight;
+ el.style.transition = 'transform 180ms ease';
+ el.style.transform = '';
+ const clear = (e) => {
+ if (e.propertyName !== 'transform') return;
+ el.style.transition = '';
+ el.style.transform = '';
+ el.removeEventListener('transitionend', clear);
+ };
+ el.addEventListener('transitionend', clear);
+ });
+}
diff --git a/serve.py b/serve.py
@@ -23,6 +23,9 @@ VENV_DIR = SCRIPT_DIR / "venv"
MIX_DIR = SCRIPT_DIR / "mix"
TRACKS_PATH = MIX_DIR / "tracks.json"
+SUPPORTED_UPLOAD_EXTENSIONS = ('.mp3', '.m4a', '.ogg', '.flac', '.wav')
+MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MB per request
+
# Serializes all reads/writes of tracks.json so rescans, reorders, and future
# uploads can't race each other.
tracks_lock = threading.Lock()
@@ -125,6 +128,53 @@ def read_tracks():
return []
+def _move_track_to(tracks, filename, insert_after):
+ """Reorder `tracks` in place so the entry matching `filename` lands at
+ insert_after + 1 (clamped). Persists tracks.json if the order actually
+ changed. Returns True iff a move happened. Caller must hold tracks_lock."""
+ cur_idx = next((i for i, t in enumerate(tracks) if t.get('filename') == filename), -1)
+ if cur_idx == -1:
+ return False
+ target_idx = max(0, min(insert_after + 1, len(tracks) - 1))
+ if target_idx == cur_idx:
+ return False
+ entry = tracks.pop(cur_idx)
+ if target_idx > cur_idx:
+ target_idx -= 1
+ tracks.insert(target_idx, entry)
+ TRACKS_PATH.write_text(
+ json.dumps(tracks, indent='\t', ensure_ascii=False) + '\n',
+ encoding='utf-8',
+ )
+ return True
+
+
+def _canonical_name_for(audio_path, fallback_name):
+ """Return the 'Artist – Title.ext' filename scan would canonicalize this
+ upload to, so we can dedup BEFORE moving the file into /mix. Returns None
+ if metadata can't be read."""
+ import scan
+ try:
+ from mutagen import File as MutagenFile # type: ignore
+ except ImportError:
+ return None
+ try:
+ audio = MutagenFile(audio_path, easy=True)
+ except Exception:
+ return None
+ title = None
+ artist = None
+ if audio and audio.tags:
+ title = audio.tags.get('title', [None])[0]
+ artist = audio.tags.get('artist', [None])[0]
+ if not title:
+ title = Path(fallback_name).stem
+ if not artist:
+ artist = "Unknown Artist"
+ ext = Path(fallback_name).suffix
+ return f"{scan._sanitize_filename(artist)} – {scan._sanitize_filename(title)}{ext}"
+
+
def broadcast_tracks(tracks, renames=None):
"""Push the current tracks list to every connected SSE client if its
content differs from the last broadcast. `renames`, when non-empty,
@@ -260,6 +310,14 @@ def start_server():
with sse_subscribers_lock:
sse_subscribers.discard(q)
+ def _reply_json(self, status, payload):
+ body = json.dumps(payload).encode('utf-8')
+ self.send_response(status)
+ self.send_header('Content-Type', 'application/json')
+ self.send_header('Content-Length', str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
def _send_sse_event(self, event, data):
payload = json.dumps(data, ensure_ascii=False)
message = f'event: {event}\ndata: {payload}\n\n'.encode('utf-8')
@@ -283,6 +341,98 @@ def start_server():
self.send_response(204)
self.end_headers()
+ def do_PUT(self):
+ # Local-only: drag-and-drop upload from a connected browser.
+ # Path is /upload/<url-encoded-filename>; headers carry the
+ # desired insertion index (X-Insert-After: -1 means prepend).
+ prefix = '/upload/'
+ if not self.path.startswith(prefix):
+ self.send_response(404)
+ self.end_headers()
+ return
+
+ raw_name = urllib.parse.unquote(self.path[len(prefix):])
+ # Strip any client-supplied path components.
+ filename = Path(raw_name).name
+ ext = Path(filename).suffix.lower()
+ if not filename or ext not in SUPPORTED_UPLOAD_EXTENSIONS:
+ self.send_response(415)
+ self.end_headers()
+ return
+
+ length = int(self.headers.get('Content-Length', '0'))
+ if length <= 0 or length > MAX_UPLOAD_BYTES:
+ self.send_response(413)
+ self.end_headers()
+ return
+
+ try:
+ insert_after = int(self.headers.get('X-Insert-After', '-1'))
+ except ValueError:
+ insert_after = -1
+
+ # Stream the body to a temp file in MIX_DIR, then move into place
+ # atomically so a partial write is never visible to the rescan.
+ MIX_DIR.mkdir(parents=True, exist_ok=True)
+ tmp_path = MIX_DIR / (filename + '.uploading')
+ try:
+ with open(tmp_path, 'wb') as out:
+ remaining = length
+ while remaining > 0:
+ chunk = self.rfile.read(min(64 * 1024, remaining))
+ if not chunk:
+ break
+ out.write(chunk)
+ remaining -= len(chunk)
+ if remaining != 0:
+ tmp_path.unlink(missing_ok=True)
+ self.send_response(400)
+ self.end_headers()
+ return
+
+ with tracks_lock:
+ import scan
+ # Dedup by canonical name: if a file with the canonical
+ # "Artist – Title.ext" name already lives in /mix, drop
+ # the upload and just reorder the existing entry to the
+ # requested slot so the client still gets the FLIP.
+ canonical = _canonical_name_for(tmp_path, filename)
+ if canonical and (MIX_DIR / canonical).exists():
+ tmp_path.unlink(missing_ok=True)
+ tracks = read_tracks()
+ moved = _move_track_to(tracks, canonical, insert_after)
+ if moved:
+ broadcast_tracks(tracks)
+ final_index = next((i for i, t in enumerate(tracks) if t.get('filename') == canonical), -1)
+ self._reply_json(200, {'filename': canonical, 'duplicate': True, 'moved': moved, 'final_index': final_index})
+ return
+
+ tmp_path.rename(MIX_DIR / filename)
+ placed_name = filename
+
+ tracks, _changed, renames = scan.rescan(silent=True)
+
+ # Find the canonical (post-canonicalize) filename for the
+ # upload by following the rename chain.
+ new_name = placed_name
+ for r in renames:
+ if r['from'] == new_name:
+ new_name = r['to']
+
+ _move_track_to(tracks, new_name, insert_after)
+ broadcast_tracks(tracks, renames=renames)
+ final_index = next((i for i, t in enumerate(tracks) if t.get('filename') == new_name), -1)
+
+ self._reply_json(200, {'filename': new_name, 'final_index': final_index})
+ except Exception as e:
+ tmp_path.unlink(missing_ok=True)
+ self.send_response(500)
+ self.end_headers()
+ try:
+ self.wfile.write(str(e).encode('utf-8'))
+ except Exception:
+ pass
+
def do_DELETE(self):
# Local-only: remove a track's audio file from /mix and prune
# tracks.json. Path is /tracks/<url-encoded-filename>.