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+++----
Mbuy.py | 12++++++++++--
Mindex.html | 3+--
Mreadme.md | 17+++++++----------
Aresources/dev.js | 550+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dresources/reorder.js | 364-------------------------------------------------------------------------------
Mrip.py | 15++++++++++++---
Mscan.py | 218++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mserve.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()