commit fa6978f95e36756bba728e7571fd2a9075cff212
parent 38cfa30adc0c5c7f439a883d05dbd1ee7106d80e
Author: Hunter
Date: Wed, 6 May 2026 00:35:11 -0400
restructure: auto-scan, auto-rename, auto-reload
Diffstat:
| M | .gitignore | | | 7 | +++---- |
| M | buy.py | | | 12 | ++++++++++-- |
| M | index.html | | | 3 | +-- |
| M | readme.md | | | 17 | +++++++---------- |
| A | resources/dev.js | | | 550 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| D | resources/reorder.js | | | 364 | ------------------------------------------------------------------------------- |
| M | rip.py | | | 15 | ++++++++++++--- |
| M | scan.py | | | 218 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------- |
| M | serve.py | | | 197 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------- |
9 files changed, 887 insertions(+), 496 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -23,6 +23,5 @@ mix/*
manifest.json
service-worker.js
-# Personal deployment scripts
-push.py
-cleanup.py
-\ No newline at end of file
+# Personal deployment script
+push.py
+\ No newline at end of file
diff --git a/buy.py b/buy.py
@@ -9,12 +9,19 @@ Usage:
"""
import sys
+import os
+
+# Hop into the shared venv (managed by scan.py) before doing anything else,
+# so scan.rescan() has mutagen available when we call it after a purchase.
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import scan
+scan.bootstrap(__file__)
+
import urllib.parse
import urllib.request
import json
import platform
import webbrowser
-import os
import shutil
import time
@@ -151,10 +158,11 @@ def main():
before = snapshot_audio_files(ITUNES_MUSIC_DIR)
new_file = wait_for_new_file(ITUNES_MUSIC_DIR, before)
-
+
filename = os.path.basename(new_file)
dest = os.path.join(MIX_DIR, filename)
shutil.copy2(new_file, dest)
+ scan.rescan(silent=True)
print(f"Added to /mix: {filename}")
diff --git a/index.html b/index.html
@@ -41,10 +41,9 @@
<script src="resources/script.js"></script>
<script>
- // Load reorder UI only on local networks.
if (typeof isLocal !== 'undefined' && isLocal) {
const s = document.createElement('script');
- s.src = 'resources/reorder.js';
+ s.src = 'resources/dev.js';
document.body.appendChild(s);
}
</script>
diff --git a/readme.md b/readme.md
@@ -41,20 +41,17 @@ hits different, right?<br><br>
- `./rip.py` to rip tracks from a physical CD
- `./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. **scan it**
- - run `./scan.py` to create (or update) `tracks.json`, which defines the tracks available to the player.
-
-3. **serve it**
+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 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.
+ - while the server is running, you can drag tracks up or down in the list to reorder them.
+
+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.
-5. **ship it**
+4. **ship it**
- upload the entire project directory to any static web host with HTTPS support (GitHub Pages, Neocities, AWS S3, etc.)
-6. **share it**
+5. **share it**
- send the hosted URL to your recipient and walk them through the installation process:
- **iOS (Safari)**: tap `···` → `Share` → `View More` → scroll down to reveal and tap `Add to Home Screen` → `Add`
- **Android**:
diff --git a/resources/dev.js b/resources/dev.js
@@ -0,0 +1,550 @@
+/* Local-only reorder/rename code used when refining your mix via serve.py */
+
+// Drag to reorder
+
+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));
+}
+
+// Live sync over SSE
+
+// Swap the now-playing text without disturbing an in-flight marquee
+function updateNowPlayingTextInPlace(text) {
+ const span = currentTrackDisplay.querySelector('span');
+ if (!span) {
+ updateCurrentTrackDisplay(text);
+ return;
+ }
+
+ // If the marquee isn't currently animating, decide whether the new text needs animation
+ if (!marqueeAnimating) {
+ span.textContent = text;
+ const containerWidth = currentTrackDisplay.offsetWidth - 30;
+ if (span.scrollWidth > containerWidth) {
+ marqueeOriginalText = text;
+ setupMarquee({ preserveOffset: true });
+ } else {
+ marqueeOriginalText = text;
+ }
+ return;
+ }
+
+ // Marquee is animating. Update the duplicated text in place, recompute
+ // the half-width (since the new text may differ in length), and wrap
+ // the current offset into the new range
+ marqueeOriginalText = text;
+ const spacing = ' ';
+ span.textContent = text + spacing + text + spacing;
+
+ const containerWidth = currentTrackDisplay.offsetWidth - 30;
+ if (span.scrollWidth / 2 <= containerWidth) {
+ // New text fits — stop the marquee cleanly.
+ setupMarquee({ preserveOffset: true });
+ return;
+ }
+
+ marqueeHalfWidth = span.scrollWidth / 2;
+ marqueeOffset = marqueeOffset % marqueeHalfWidth;
+ span.style.transform = `translateX(-${marqueeOffset}px)`;
+}
+
+// Skip the very first SSE message so we don't double-render before the player has finished its
+// initial setup.
+let sseSeenInitial = false;
+
+function tracksEqual(a, b) {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) {
+ if (a[i].filename !== b[i].filename) return false;
+ if (a[i].title !== b[i].title) return false;
+ if (a[i].artist !== b[i].artist) return false;
+ }
+ return true;
+}
+
+function applyRenames(renames) {
+ for (const { from, to } of renames) {
+ if (from === to) continue;
+
+ if (preloadedAudio[from]) {
+ preloadedAudio[to] = preloadedAudio[from];
+ delete preloadedAudio[from];
+ }
+
+ if (cachedTracks.has(from)) {
+ cachedTracks.delete(from);
+ cachedTracks.add(to);
+ }
+
+ const oldUrl = `mix/${from}`;
+ if (audio.src && audio.src.endsWith(oldUrl)) {
+ const wasPlaying = !audio.paused;
+ const t = audio.currentTime;
+ audio.src = `mix/${to}`;
+ audio.load();
+ audio.addEventListener('loadedmetadata', function resume() {
+ audio.removeEventListener('loadedmetadata', resume);
+ try { audio.currentTime = t; } catch (e) {}
+ if (wasPlaying) audio.play().catch(() => {});
+ }, { once: true });
+ }
+
+ const idx = tracks.findIndex(t => t.filename === from);
+ if (idx !== -1) tracks[idx].filename = to;
+ }
+}
+
+function reconcileTracks(incoming, renames) {
+ if (renames && renames.length) applyRenames(renames);
+
+ const incomingByName = new Map(incoming.map(t => [t.filename, t]));
+ const currentNames = new Set(tracks.map(t => t.filename));
+ const incomingNames = new Set(incomingByName.keys());
+
+ if (tracks.length > 0 && tracksEqual(tracks, incoming)) return;
+
+ const playingFilename = tracks[currentTrackIndex] && tracks[currentTrackIndex].filename;
+ const playingRemoved = playingFilename && !incomingNames.has(playingFilename);
+
+ // Free blob URLs for tracks that disappeared.
+ for (const name of currentNames) {
+ if (!incomingNames.has(name) && preloadedAudio[name]) {
+ try { URL.revokeObjectURL(preloadedAudio[name].blobUrl); } catch (e) {}
+ delete preloadedAudio[name];
+ }
+ }
+
+ const loopingByName = new Map(tracks.map(t => [t.filename, t.looping || false]));
+ const newTracks = incoming.map(t => ({ ...t, looping: loopingByName.get(t.filename) || false }));
+
+ tracks.length = 0;
+ tracks.push(...newTracks);
+
+ if (tracks.length === 0) {
+ playerReady = false;
+ try { audio.pause(); } catch (e) {}
+ audio.removeAttribute('src');
+ audio.load();
+ isPlaying = false;
+ resetProgressBar();
+ updatePlayPauseButton();
+ updateCurrentTrackDisplay('No tracks found');
+ playlist.innerHTML = '';
+ return;
+ }
+
+ playerReady = true;
+
+ if (playingRemoved) {
+ // Playing track is gone: stop, advance to the next-best slot, and
+ // drop into "Ready to play".
+ try { audio.pause(); } catch (e) {}
+ audio.removeAttribute('src');
+ audio.load();
+ isPlaying = false;
+ resetProgressBar();
+ const nextIndex = Math.min(currentTrackIndex, tracks.length - 1);
+ setCurrentTrackIndex(nextIndex);
+ const next = tracks[nextIndex];
+ updateCurrentTrackDisplay(`Ready to play: ${next.artist} – ${next.title}`);
+ updatePlayPauseButton();
+ } else if (playingFilename) {
+ // Playing track survived (possibly via a rename applied above): its
+ // index may have shifted because of reorders or adds/removes
+ // elsewhere in the list.
+ const newIndex = tracks.findIndex(t => t.filename === playingFilename);
+ if (newIndex !== -1) setCurrentTrackIndex(newIndex);
+
+ const cur = tracks[currentTrackIndex];
+ if (cur) {
+ const isReady = currentTrackDisplay.textContent.includes('Ready to play:');
+ const text = isReady
+ ? `Ready to play: ${cur.artist} – ${cur.title}`
+ : `${cur.artist} – ${cur.title}`;
+ updateNowPlayingTextInPlace(text);
+ }
+ }
+
+ renderPlaylist();
+
+ // Pick up any tracks added since last preload pass.
+ if (typeof preloadNextTrack === 'function') {
+ currentPreloadIndex = 0;
+ preloadNextTrack();
+ }
+}
+
+function startLiveSync() {
+ const source = new EventSource('/events');
+ source.addEventListener('tracks', (e) => {
+ const payload = JSON.parse(e.data);
+ if (!sseSeenInitial) {
+ sseSeenInitial = true;
+ return;
+ }
+ reconcileTracks(payload.tracks, payload.renames);
+ });
+}
+
+startLiveSync();
diff --git a/resources/reorder.js b/resources/reorder.js
@@ -1,364 +0,0 @@
-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));
-}
diff --git a/rip.py b/rip.py
@@ -6,6 +6,13 @@ Uses system tools: ffmpeg/ffprobe (no Python dependencies needed)
import os
import sys
+
+# Hop into the shared venv (managed by scan.py) before doing anything else,
+# so scan.rescan() has mutagen available when we call it after ripping.
+sys.path.insert(0, str(os.path.dirname(os.path.abspath(__file__))))
+import scan
+scan.bootstrap(__file__)
+
import subprocess
import shutil
import time
@@ -361,10 +368,12 @@ def rip_cd():
print(f"\n✓ Successfully ripped {success_count}/{len(audio_files)} tracks in {total_minutes}m {total_secs}s.")
if success_count > 0:
+ # Pick up the newly-ripped tracks (with the artist tag we just wrote
+ # via ffmpeg) into tracks.json in disk order.
+ scan.rescan(silent=True)
+
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")
+ print("\nNext step: run serve.py to test your mixtape locally.")
# Eject the CD
print("\nEjecting CD...")
diff --git a/scan.py b/scan.py
@@ -17,9 +17,9 @@ MIX_DIR = SCRIPT_DIR / "mix"
OUTPUT_FILE = MIX_DIR / "tracks.json"
-def setup_venv():
- """Create and set up virtual environment if it doesn't exist"""
- # Determine the path to pip and python in the venv
+def setup_venv(packages=("mutagen",)):
+ """Create the shared venv if missing and ensure each package is installed.
+ Returns the path to the venv Python interpreter."""
if sys.platform == "win32":
pip_path = VENV_DIR / "Scripts" / "pip"
python_path = VENV_DIR / "Scripts" / "python"
@@ -27,7 +27,6 @@ def setup_venv():
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...")
@@ -43,7 +42,6 @@ def setup_venv():
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:
@@ -52,63 +50,139 @@ def setup_venv():
print(f"Error ensuring pip: {e}")
sys.exit(1)
- 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")
+ for pkg in packages:
+ check = subprocess.run(
+ [str(python_path), "-c", f"import {pkg}"],
+ capture_output=True
+ )
+ if check.returncode != 0:
+ try:
+ subprocess.check_call([str(python_path), "-m", "pip", "install", "-q", pkg])
+ except subprocess.CalledProcessError:
+ print(f"Note: Could not install {pkg} (offline?).\n")
return python_path
+def bootstrap(script_path, packages=("mutagen",), sentinel="--in-venv"):
+ """Ensure the calling script is running inside the shared venv
+ with `packages` available. Re-execs through the venv Python on first call;
+ returns immediately on the second pass once the sentinel is in argv.
+
+ Each script that uses this should pass its own __file__ as script_path."""
+ if sentinel in sys.argv:
+ sys.argv.remove(sentinel)
+ return
+ python_path = setup_venv(packages)
+ try:
+ result = subprocess.run([str(python_path), script_path, sentinel, *sys.argv[1:]])
+ sys.exit(result.returncode)
+ except KeyboardInterrupt:
+ sys.exit(130)
+
+
def run_in_venv():
- """Re-run this script in the virtual environment"""
+ """Re-run this script in the virtual environment (CLI use of scan.py)."""
python_path = setup_venv()
-
- # Re-run this script with the venv Python
print("Running scanner in virtual environment...\n")
subprocess.check_call([str(python_path), __file__, "--in-venv"])
sys.exit(0)
-def scan_tracks():
- """Main function to scan audio files and generate tracks.json"""
- # Import mutagen here (only after venv is active)
+SUPPORTED_EXTENSIONS = ('.mp3', '.m4a', '.ogg', '.flac', '.wav')
+
+_INVALID_FILENAME_CHARS = '<>:"/\\|?*'
+
+
+def _sanitize_filename(name):
+ for char in _INVALID_FILENAME_CHARS:
+ name = name.replace(char, '')
+ return name.strip()
+
+
+def _write_tags(audio_file, artist, title, MutagenFile):
+ """Write artist/title tags to disk if they don't already match. Returns
+ True if anything was written."""
+ try:
+ audio = MutagenFile(audio_file, easy=True)
+ if audio is None:
+ return False
+ if audio.tags is None:
+ audio.add_tags()
+ current_title = audio.tags.get('title', [None])[0]
+ current_artist = audio.tags.get('artist', [None])[0]
+ if current_title == title and current_artist == artist:
+ return False
+ audio.tags['title'] = title
+ audio.tags['artist'] = artist
+ audio.save()
+ return True
+ except Exception:
+ return False
+
+
+def _canonicalize(tracks, has_mutagen, MutagenFile, silent):
+ """Rename files to 'Artist – Title.ext' and sync ID3 tags so disk state
+ matches tracks.json metadata. Mutates `tracks` in place; returns a list
+ of {from, to} renames so callers can migrate any external state (eg.
+ preloaded blob URLs in connected browser tabs)."""
+ renames = []
+ for entry in tracks:
+ old_filename = entry['filename']
+ ext = Path(old_filename).suffix
+ artist = _sanitize_filename(entry['artist'])
+ title = _sanitize_filename(entry['title'])
+ new_filename = f"{artist} – {title}{ext}"
+
+ old_path = MIX_DIR / old_filename
+ new_path = MIX_DIR / new_filename
+
+ # Keep tags aligned with the (possibly hand-edited) tracks.json
+ # entry, regardless of whether a rename is needed.
+ if has_mutagen and old_path.exists():
+ _write_tags(old_path, entry['artist'], entry['title'], MutagenFile)
+
+ if old_filename == new_filename:
+ continue
+ if not old_path.exists():
+ continue
+ if new_path.exists():
+ # Don't clobber an unrelated file that already occupies the
+ # canonical name. Leave the entry pointing at its current file.
+ if not silent:
+ print(f" Skipping rename {old_filename} -> {new_filename} (target exists)")
+ continue
+ try:
+ old_path.rename(new_path)
+ entry['filename'] = new_filename
+ renames.append({'from': old_filename, 'to': new_filename})
+ if not silent:
+ print(f" Renamed: {old_filename} -> {new_filename}")
+ except Exception as e:
+ if not silent:
+ print(f" Error renaming {old_filename}: {e}")
+ return renames
+
+
+def rescan(silent=False):
+ """Reconcile tracks.json with /mix. Renames files to canonical
+ 'Artist – Title.ext' form when they drift, syncing ID3 tags at the same
+ time. Returns (tracks, changed, renames) where renames is a list of
+ {from, to} dicts for any files that moved this pass."""
try:
from mutagen import File as MutagenFile # type: ignore
has_mutagen = True
except ImportError:
MutagenFile = None
has_mutagen = False
- print("Mutagen not available. Metadata will be derived from filenames.\n")
-
- # Supported audio formats
- SUPPORTED_EXTENSIONS = ('.mp3', '.m4a', '.ogg', '.flac', '.wav')
+ if not silent:
+ print("Mutagen not available. Metadata will be derived from filenames.\n")
- # Check if tracks directory exists, create if it doesn't
if not MIX_DIR.exists():
- print(f"Creating {MIX_DIR.name} directory...")
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)
- # Find all supported audio files
audio_files = [f for f in MIX_DIR.iterdir() if f.suffix.lower() in SUPPORTED_EXTENSIONS]
- 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")
-
existing_tracks = []
if OUTPUT_FILE.exists():
try:
@@ -117,7 +191,8 @@ def scan_tracks():
if isinstance(loaded, list):
existing_tracks = loaded
except (json.JSONDecodeError, OSError) as e:
- print(f"Warning: could not read existing {OUTPUT_FILE.name} ({e}). Starting fresh.\n")
+ if not silent:
+ print(f"Warning: could not read existing {OUTPUT_FILE.name} ({e}). Starting fresh.\n")
existing_by_filename = {
t['filename']: t for t in existing_tracks
@@ -159,31 +234,60 @@ def scan_tracks():
try:
new_tracks.append(read_metadata(audio_file))
except Exception as e:
- print(f"✗ Error reading {audio_file.name}: {e}")
+ if not silent:
+ print(f"✗ Error reading {audio_file.name}: {e}")
- 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.")
+ if not silent:
+ 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.")
tracks.extend(new_tracks)
- if not tracks:
- print("\nNo valid audio files could be processed.")
- sys.exit(1)
+ renames = _canonicalize(tracks, has_mutagen, MutagenFile, silent)
- # Write to tracks.json
- try:
- with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
- json.dump(tracks, f, indent="\t", ensure_ascii=False)
+ changed = bool(new_tracks) or bool(removed) or bool(renames)
+ if changed or not OUTPUT_FILE.exists():
+ try:
+ OUTPUT_FILE.write_text(
+ json.dumps(tracks, indent='\t', ensure_ascii=False) + '\n',
+ encoding='utf-8',
+ )
+ except Exception as e:
+ if not silent:
+ print(f"\nError writing {OUTPUT_FILE.name}: {e}")
+ raise
+
+ return tracks, changed, renames
+
+
+def scan_tracks():
+ """CLI entry point: rescan and print a summary."""
+ if not MIX_DIR.exists():
+ print(f"Creating {MIX_DIR.name} directory...")
+ 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)
- print(f"\n✓ Successfully generated {OUTPUT_FILE.name} with {len(tracks)} track(s).")
+ audio_files = [f for f in MIX_DIR.iterdir() if f.suffix.lower() in SUPPORTED_EXTENSIONS]
+ 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)
- except Exception as e:
- print(f"\nError writing {OUTPUT_FILE.name}: {e}")
+ print(f"Found {len(audio_files)} audio file(s). Extracting metadata...\n")
+ tracks, changed, _renames = rescan(silent=False)
+ if not tracks:
+ print("\nNo valid audio files could be processed.")
sys.exit(1)
+ if changed:
+ print(f"\n✓ Updated {OUTPUT_FILE.name} ({len(tracks)} track(s)).")
def main():
diff --git a/serve.py b/serve.py
@@ -10,67 +10,36 @@ import socket
import sys
import os
import json
+import queue
+import threading
import subprocess
+import time
from pathlib import Path
DEFAULT_PORT = 8000
SCRIPT_DIR = Path(__file__).parent.absolute()
VENV_DIR = SCRIPT_DIR / "venv"
+TRACKS_PATH = SCRIPT_DIR / "mix" / "tracks.json"
+# Serializes all reads/writes of tracks.json so rescans, reorders, and future
+# uploads can't race each other.
+tracks_lock = threading.Lock()
-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")
+# Set of per-client SSE queues. Each connected /events client gets a queue;
+# broadcast_tracks() pushes the latest tracks list onto every queue.
+sse_subscribers = set()
+sse_subscribers_lock = threading.Lock()
- return python_path
+# Canonical serialization of the most recently broadcast tracks list, so we
+# can fire on any content change (file add/remove/reorder or a hand-edit to
+# tracks.json's metadata) instead of only when /mix's filenames change.
+last_broadcast_serialized = None
def run_in_venv():
- """Re-run this script in the virtual environment"""
- python_path = setup_venv()
-
- # Re-run this script with the venv Python
+ """Re-run this script in the shared venv with serve.py's deps available"""
+ import scan
+ python_path = scan.setup_venv(packages=("qrcode", "mutagen"))
try:
subprocess.check_call([str(python_path), __file__, "--in-venv"])
except (KeyboardInterrupt, subprocess.CalledProcessError):
@@ -141,11 +110,75 @@ def print_qr_code(url):
except Exception as e:
print(f"\nCould not generate QR code: {e}")
+
+def read_tracks():
+ """Read tracks.json from disk; return [] if missing or malformed."""
+ if not TRACKS_PATH.exists():
+ return []
+ try:
+ with open(TRACKS_PATH, 'r', encoding='utf-8') as f:
+ loaded = json.load(f)
+ return loaded if isinstance(loaded, list) else []
+ except (json.JSONDecodeError, OSError):
+ return []
+
+
+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,
+ carries [{from, to}, ...] so clients can migrate per-track state (eg.
+ preloaded blob URLs) without losing it across a canonicalizing rename.
+ Caller must hold tracks_lock."""
+ global last_broadcast_serialized
+ serialized = json.dumps(tracks, ensure_ascii=False, sort_keys=True)
+ if serialized == last_broadcast_serialized and not renames:
+ return
+ last_broadcast_serialized = serialized
+ payload = {'tracks': tracks, 'renames': renames or []}
+ with sse_subscribers_lock:
+ subscribers = list(sse_subscribers)
+ for q in subscribers:
+ try:
+ q.put_nowait(payload)
+ except queue.Full:
+ pass
+
+
+def rescan_and_maybe_broadcast():
+ """Rescan /mix, then broadcast if the resulting tracks list differs from
+ what we last sent."""
+ import scan
+ tracks, _changed, renames = scan.rescan(silent=True)
+ broadcast_tracks(tracks, renames=renames)
+ return tracks
+
+
+def start_rescan_ticker(interval=1.0):
+ """Background thread that periodically rescans /mix so changes made
+ directly on disk (eg. user dragging a file in or `rm`-ing one from the
+ terminal) reach connected clients without anyone hitting the server."""
+ def tick():
+ while True:
+ time.sleep(interval)
+ try:
+ with tracks_lock:
+ rescan_and_maybe_broadcast()
+ except Exception as e:
+ # Don't let a bad scan kill the ticker.
+ print(f"rescan tick error: {e}", file=sys.stderr)
+ t = threading.Thread(target=tick, daemon=True)
+ t.start()
+
+
def start_server():
"""Start the HTTP server (runs after venv is set up)"""
# Change to script directory
os.chdir(SCRIPT_DIR)
+ # Periodically rescan /mix so on-disk changes propagate to the UI even
+ # when nothing is hitting an HTTP endpoint.
+ start_rescan_ticker()
+
# Find an available port
port = find_available_port(DEFAULT_PORT)
@@ -158,7 +191,6 @@ def start_server():
# Create server
Handler = http.server.SimpleHTTPRequestHandler
- tracks_path = SCRIPT_DIR / "mix" / "tracks.json"
# Suppress default logging and broken pipe errors
class QuietHandler(Handler):
@@ -177,6 +209,61 @@ def start_server():
# Browser cancelled the request (normal for media streaming/preloading)
pass
+ def do_GET(self):
+ # Rescan /mix on every tracks.json fetch so the page always sees
+ # what's actually on disk (added via rip.py/buy.py or by hand).
+ if self.path == '/mix/tracks.json':
+ with tracks_lock:
+ rescan_and_maybe_broadcast()
+ return super().do_GET()
+
+ if self.path == '/events':
+ return self._serve_sse()
+
+ return super().do_GET()
+
+ def _serve_sse(self):
+ """Server-Sent Events stream of tracks.json changes."""
+ self.send_response(200)
+ self.send_header('Content-Type', 'text/event-stream')
+ self.send_header('Cache-Control', 'no-cache')
+ self.send_header('Connection', 'keep-alive')
+ self.send_header('X-Accel-Buffering', 'no')
+ self.end_headers()
+
+ q = queue.Queue(maxsize=16)
+ with sse_subscribers_lock:
+ sse_subscribers.add(q)
+
+ try:
+ # Initial sync so a fresh tab gets the current state without
+ # waiting for the next change. No renames context: a fresh
+ # client has no prior state to migrate from.
+ with tracks_lock:
+ initial = read_tracks()
+ self._send_sse_event('tracks', {'tracks': initial, 'renames': []})
+
+ while True:
+ try:
+ payload = q.get(timeout=15)
+ self._send_sse_event('tracks', payload)
+ except queue.Empty:
+ # Heartbeat keeps proxies and idle connections from
+ # closing the stream.
+ self.wfile.write(b': keepalive\n\n')
+ self.wfile.flush()
+ except (BrokenPipeError, ConnectionResetError, OSError):
+ pass
+ finally:
+ with sse_subscribers_lock:
+ sse_subscribers.discard(q)
+
+ def _send_sse_event(self, event, data):
+ payload = json.dumps(data, ensure_ascii=False)
+ message = f'event: {event}\ndata: {payload}\n\n'.encode('utf-8')
+ self.wfile.write(message)
+ self.wfile.flush()
+
def do_POST(self):
# Local-only: receive a reordered tracks array and overwrite tracks.json
if self.path != '/tracks':
@@ -185,10 +272,12 @@ def start_server():
return
length = int(self.headers.get('Content-Length', '0'))
payload = json.loads(self.rfile.read(length))
- tracks_path.write_text(
- json.dumps(payload, indent='\t', ensure_ascii=False) + '\n',
- encoding='utf-8',
- )
+ with tracks_lock:
+ TRACKS_PATH.write_text(
+ json.dumps(payload, indent='\t', ensure_ascii=False) + '\n',
+ encoding='utf-8',
+ )
+ broadcast_tracks(payload)
self.send_response(204)
self.end_headers()