restructure: auto-scan, auto-rename, auto-reload
Hunter
Wednesday, May 6th, 2026 at 12:35 AM
23 manifest.json
24 service-worker.js
25
-# Personal deployment scripts
-push.py
-cleanup.py
26 +# Personal deployment script
27 +push.py
9 """
10
11 import sys
12 +import os
13 +
14 +# Hop into the shared venv (managed by scan.py) before doing anything else,
15 +# so scan.rescan() has mutagen available when we call it after a purchase.
16 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
17 +import scan
18 +scan.bootstrap(__file__)
19 +
20 import urllib.parse
21 import urllib.request
22 import json
23 import platform
24 import webbrowser
-import os
25 import shutil
26 import time
27
158
159 before = snapshot_audio_files(ITUNES_MUSIC_DIR)
160 new_file = wait_for_new_file(ITUNES_MUSIC_DIR, before)
-
161 +
162 filename = os.path.basename(new_file)
163 dest = os.path.join(MIX_DIR, filename)
164 shutil.copy2(new_file, dest)
165 + scan.rescan(silent=True)
166 print(f"Added to /mix: {filename}")
167
168
41
42 <script src="resources/script.js"></script>
43 <script>
- // Load reorder UI only on local networks.
44 if (typeof isLocal !== 'undefined' && isLocal) {
45 const s = document.createElement('script');
- s.src = 'resources/reorder.js';
46 + s.src = 'resources/dev.js';
47 document.body.appendChild(s);
48 }
49 </script>
41 - `./rip.py` to rip tracks from a physical CD
42 - `./buy.py` to search for tracks to purchase (opens in iTunes on MacOS, <a href="https://song.link/i/1651294855">song.link</a> otherwise)
43
-2. **scan it**
- - run `./scan.py` to create (or update) `tracks.json`, which defines the tracks available to the player.
-
-3. **serve it**
44 +2. **serve it**
45 - 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.
46 + - while the server is running, you can drag tracks up or down in the list to reorder them.
47 +
48 +3. **build it**
49 + - 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.
50
-5. **ship it**
51 +4. **ship it**
52 - upload the entire project directory to any static web host with HTTPS support (GitHub Pages, Neocities, AWS S3, etc.)
53
-6. **share it**
54 +5. **share it**
55 - send the hosted URL to your recipient and walk them through the installation process:
56 - **iOS (Safari)**: tap `···` → `Share` → `View More` → scroll down to reveal and tap `Add to Home Screen` → `Add`
57 - **Android**:
1 +/* Local-only reorder/rename code used when refining your mix via serve.py */
2 +
3 +// Drag to reorder
4 +
5 +const REORDER_DRAG_THRESHOLD = 5;
6 +const LONG_PRESS_MS = 400;
7 +const LONG_PRESS_MOVE_TOLERANCE = 10;
8 +let activeReorder = null;
9 +let longPressTimer = null;
10 +
11 +function attachReorderHandlers(item, index) {
12 + item.addEventListener('mousedown', (e) => {
13 + // Ignore non-primary buttons
14 + if (e.button !== 0) return;
15 + startReorder(item, index, e.clientX, e.clientY);
16 + });
17 +
18 + item.addEventListener('touchstart', (e) => {
19 + if (e.touches.length !== 1) return;
20 + const t = e.touches[0];
21 + startLongPress(item, index, t.clientX, t.clientY);
22 + }, { passive: true });
23 +}
24 +
25 +function startLongPress(item, index, startX, startY) {
26 + if (longPressTimer) clearTimeout(longPressTimer);
27 +
28 + const candidate = { item, index, startX, startY };
29 + longPressTimer = setTimeout(() => {
30 + longPressTimer = null;
31 + cleanup();
32 + startReorder(candidate.item, candidate.index, candidate.startX, candidate.startY, /* isTouch */ true);
33 + beginDrag();
34 + if (navigator.vibrate) navigator.vibrate(10);
35 + }, LONG_PRESS_MS);
36 +
37 + const onMove = (e) => {
38 + const t = e.touches[0];
39 + if (!t) return;
40 + const dx = t.clientX - startX;
41 + const dy = t.clientY - startY;
42 + if (Math.hypot(dx, dy) > LONG_PRESS_MOVE_TOLERANCE) cancel();
43 + };
44 + const onUp = () => cancel();
45 +
46 + function cleanup() {
47 + document.removeEventListener('touchmove', onMove);
48 + document.removeEventListener('touchend', onUp);
49 + }
50 +
51 + function cancel() {
52 + if (longPressTimer) {
53 + clearTimeout(longPressTimer);
54 + longPressTimer = null;
55 + }
56 + cleanup();
57 + }
58 +
59 + document.addEventListener('touchmove', onMove, { passive: true });
60 + document.addEventListener('touchend', onUp);
61 +}
62 +
63 +function startReorder(item, index, startX, startY, isTouch = false) {
64 + activeReorder = {
65 + item,
66 + index,
67 + startX,
68 + startY,
69 + started: false,
70 + placeholder: null,
71 + offsetY: 0,
72 + itemHeight: 0,
73 + autoScrollRaf: null,
74 + autoScrollSpeed: 0,
75 + lastClientY: startY,
76 + isTouch,
77 + };
78 + if (isTouch) {
79 + document.addEventListener('touchmove', onReorderTouchMove, { passive: false });
80 + document.addEventListener('touchend', onReorderEnd);
81 + document.addEventListener('touchcancel', onReorderEnd);
82 + } else {
83 + document.body.classList.add('reorder-pressed');
84 + document.addEventListener('mousemove', onReorderMove);
85 + document.addEventListener('mouseup', onReorderEnd);
86 + }
87 +}
88 +
89 +function beginDrag() {
90 + const r = activeReorder;
91 + const rect = r.item.getBoundingClientRect();
92 + const playlistRect = playlist.getBoundingClientRect();
93 + r.itemHeight = rect.height;
94 + r.offsetY = r.startY - rect.top;
95 +
96 + const styles = window.getComputedStyle(r.item);
97 + const placeholder = document.createElement('div');
98 + placeholder.classList.add('playlist-item-placeholder');
99 + placeholder.style.height = `${rect.height}px`;
100 + placeholder.style.marginTop = styles.marginTop;
101 + placeholder.style.marginBottom = styles.marginBottom;
102 + placeholder.style.paddingLeft = styles.paddingLeft;
103 + placeholder.style.paddingRight = styles.paddingRight;
104 + placeholder.style.paddingBottom = styles.paddingBottom;
105 + r.item.parentNode.insertBefore(placeholder, r.item);
106 + r.placeholder = placeholder;
107 +
108 + r.item.classList.add('dragging');
109 + r.item.style.position = 'fixed';
110 + r.item.style.left = `${playlistRect.left}px`;
111 + r.item.style.top = `${rect.top}px`;
112 + r.item.style.width = `${playlistRect.width}px`;
113 + r.item.style.height = `${rect.height}px`;
114 + r.item.style.zIndex = '10';
115 + document.body.appendChild(r.item);
116 + // Now we're actually dragging — disable pointer-events on the other items
117 + document.body.classList.add('reordering');
118 +
119 + r.started = true;
120 + r.item._suppressClick = true;
121 +}
122 +
123 +function onReorderMove(e) {
124 + const r = activeReorder;
125 + if (!r) return;
126 + r.lastClientY = e.clientY;
127 +
128 + if (!r.started) {
129 + const dx = e.clientX - r.startX;
130 + const dy = e.clientY - r.startY;
131 + if (Math.hypot(dx, dy) < REORDER_DRAG_THRESHOLD) return;
132 + beginDrag();
133 + }
134 +
135 + positionDraggedItem();
136 + updateAutoScroll();
137 +}
138 +
139 +function onReorderTouchMove(e) {
140 + const r = activeReorder;
141 + if (!r || !r.started) return;
142 + e.preventDefault();
143 + const t = e.touches[0];
144 + if (!t) return;
145 + r.lastClientY = t.clientY;
146 + positionDraggedItem();
147 + updateAutoScroll();
148 +}
149 +
150 +function positionDraggedItem() {
151 + const r = activeReorder;
152 + if (!r || !r.started) return;
153 +
154 + // Clamp the dragged item between the bottom of the now-playing area
155 + // (the wrapper's top) and the top of the navigation controls.
156 + const wrapperRect = playlistWrapper.getBoundingClientRect();
157 + const controlsRect = controls.getBoundingClientRect();
158 + const desiredTop = r.lastClientY - r.offsetY;
159 + const minTop = wrapperRect.top;
160 + const maxTop = controlsRect.top - r.itemHeight;
161 + const clampedTop = Math.max(minTop, Math.min(maxTop, desiredTop));
162 + r.item.style.top = `${clampedTop}px`;
163 +
164 + repositionPlaceholder();
165 +}
166 +
167 +function layoutMidY(el) {
168 + const rect = el.getBoundingClientRect();
169 + if (el._flipTargetTop !== undefined) {
170 + return el._flipTargetTop + rect.height / 2;
171 + }
172 + return rect.top + rect.height / 2;
173 +}
174 +
175 +function repositionPlaceholder() {
176 + const r = activeReorder;
177 + if (!r || !r.started) return;
178 +
179 + const draggedRect = r.item.getBoundingClientRect();
180 + const draggedMid = draggedRect.top + draggedRect.height / 2;
181 +
182 + const prev = r.placeholder.previousElementSibling;
183 + if (prev && prev.classList.contains('playlist-item')) {
184 + if (draggedMid < layoutMidY(prev)) {
185 + flipItem(prev, () => playlist.insertBefore(r.placeholder, prev));
186 + return;
187 + }
188 + }
189 +
190 + const next = r.placeholder.nextElementSibling;
191 + if (next && next.classList.contains('playlist-item')) {
192 + if (draggedMid > layoutMidY(next)) {
193 + flipItem(next, () => playlist.insertBefore(r.placeholder, next.nextSibling));
194 + return;
195 + }
196 + }
197 +}
198 +
199 +const AUTO_SCROLL_EDGE = 60; // px from edge that triggers scrolling
200 +const AUTO_SCROLL_MAX_SPEED = 6; // px per frame
201 +
202 +function updateAutoScroll() {
203 + const r = activeReorder;
204 + if (!r || !r.started) return;
205 +
206 + const wrapperRect = playlistWrapper.getBoundingClientRect();
207 + const bottomBound = controls.getBoundingClientRect().top;
208 + const y = r.lastClientY;
209 +
210 + let speed = 0;
211 + if (y < wrapperRect.top + AUTO_SCROLL_EDGE) {
212 + const intensity = (wrapperRect.top + AUTO_SCROLL_EDGE - y) / AUTO_SCROLL_EDGE;
213 + speed = -Math.min(1, intensity) * AUTO_SCROLL_MAX_SPEED;
214 + } else if (y > bottomBound - AUTO_SCROLL_EDGE) {
215 + const intensity = (y - (bottomBound - AUTO_SCROLL_EDGE)) / AUTO_SCROLL_EDGE;
216 + speed = Math.min(1, intensity) * AUTO_SCROLL_MAX_SPEED;
217 + }
218 +
219 + r.autoScrollSpeed = speed;
220 + if (speed !== 0 && !r.autoScrollRaf) {
221 + const tick = () => {
222 + const cur = activeReorder;
223 + if (!cur || !cur.started || cur.autoScrollSpeed === 0) {
224 + if (cur) cur.autoScrollRaf = null;
225 + return;
226 + }
227 + const before = playlistWrapper.scrollTop;
228 + playlistWrapper.scrollTop = Math.max(0, Math.min(playlistWrapper.scrollHeight - playlistWrapper.clientHeight, playlistWrapper.scrollTop + cur.autoScrollSpeed));
229 + const delta = playlistWrapper.scrollTop - before;
230 + if (delta !== 0) {
231 + positionDraggedItem();
232 + }
233 + cur.autoScrollRaf = requestAnimationFrame(tick);
234 + };
235 + r.autoScrollRaf = requestAnimationFrame(tick);
236 + }
237 +}
238 +
239 +function flipItem(el, mutate) {
240 + el.style.transition = 'none';
241 + el.style.transform = '';
242 + const oldTop = el.getBoundingClientRect().top;
243 +
244 + mutate();
245 +
246 + const newTop = el.getBoundingClientRect().top;
247 + const dy = oldTop - newTop;
248 + if (!dy) return;
249 + el.style.transform = `translateY(${dy}px)`;
250 + void el.offsetHeight;
251 + el.style.transition = 'transform 180ms ease';
252 + el.style.transform = '';
253 +
254 + el._flipTargetTop = newTop;
255 + if (el._flipEndHandler) el.removeEventListener('transitionend', el._flipEndHandler);
256 + el._flipEndHandler = (e) => {
257 + if (e.propertyName !== 'transform') return;
258 + delete el._flipTargetTop;
259 + el.removeEventListener('transitionend', el._flipEndHandler);
260 + el._flipEndHandler = null;
261 + };
262 + el.addEventListener('transitionend', el._flipEndHandler);
263 +}
264 +
265 +function onReorderEnd() {
266 + const r = activeReorder;
267 + document.removeEventListener('mousemove', onReorderMove);
268 + document.removeEventListener('mouseup', onReorderEnd);
269 + document.removeEventListener('touchmove', onReorderTouchMove);
270 + document.removeEventListener('touchend', onReorderEnd);
271 + document.removeEventListener('touchcancel', onReorderEnd);
272 + activeReorder = null;
273 + document.body.classList.remove('reorder-pressed');
274 + document.body.classList.remove('reordering');
275 + if (r && r.autoScrollRaf) cancelAnimationFrame(r.autoScrollRaf);
276 + if (!r) return;
277 +
278 + if (!r.started) {
279 + // No real drag — let the click handler fire normally
280 + return;
281 + }
282 +
283 + // Compute the new order from placeholder position and rebuild tracks array
284 + const fromIndex = r.index;
285 + const itemsInDom = Array.from(playlist.querySelectorAll('.playlist-item, .playlist-item-placeholder'));
286 + const toIndex = itemsInDom.indexOf(r.placeholder);
287 +
288 + const droppedTop = r.item.getBoundingClientRect().top;
289 + r.placeholder.remove();
290 +
291 + if (toIndex === -1 || toIndex === fromIndex) {
292 + renderPlaylist();
293 + animateDroppedTrack(r.item, fromIndex, droppedTop);
294 + return;
295 + }
296 +
297 + // Build the new tracks array directly from the DOM ordering.
298 + const moved = tracks[fromIndex];
299 + const remaining = tracks.filter((_, i) => i !== fromIndex);
300 + const newTracks = [];
301 + let remainingIdx = 0;
302 + for (let i = 0; i < itemsInDom.length; i++) {
303 + if (itemsInDom[i] === r.placeholder) {
304 + newTracks.push(moved);
305 + } else {
306 + newTracks.push(remaining[remainingIdx++]);
307 + }
308 + }
309 +
310 + const playingTrack = tracks[currentTrackIndex];
311 + tracks.length = 0;
312 + tracks.push(...newTracks);
313 + const newCurrentIndex = tracks.indexOf(playingTrack);
314 + if (newCurrentIndex !== -1) setCurrentTrackIndex(newCurrentIndex);
315 +
316 + renderPlaylist();
317 + animateDroppedTrack(r.item, toIndex, droppedTop);
318 + saveTrackOrder();
319 +}
320 +
321 +function animateDroppedTrack(draggedEl, newIndex, oldTop) {
322 + const items = playlist.querySelectorAll('.playlist-item');
323 + const target = items[newIndex];
324 + if (!target) {
325 + draggedEl.remove();
326 + return;
327 + }
328 +
329 + // Hide the rebuilt item but keep it in layout so sizing/scroll are stable.
330 + target.style.visibility = 'hidden';
331 + const newTop = target.getBoundingClientRect().top;
332 +
333 + const cleanup = () => {
334 + draggedEl.remove();
335 + target.style.visibility = '';
336 + };
337 +
338 + if (oldTop === newTop) {
339 + cleanup();
340 + return;
341 + }
342 +
343 + const playlistRect = playlist.getBoundingClientRect();
344 + draggedEl.style.transition = 'none';
345 + draggedEl.style.top = `${oldTop}px`;
346 + draggedEl.style.left = `${playlistRect.left}px`;
347 + draggedEl.style.width = `${playlistRect.width}px`;
348 + void draggedEl.offsetHeight;
349 + draggedEl.style.transition = 'top 180ms ease';
350 + draggedEl.style.top = `${newTop}px`;
351 +
352 + const finish = (e) => {
353 + if (e.propertyName !== 'top') return;
354 + draggedEl.removeEventListener('transitionend', finish);
355 + cleanup();
356 + };
357 + draggedEl.addEventListener('transitionend', finish);
358 +}
359 +
360 +function saveTrackOrder() {
361 + // Strip the runtime-only `looping` flag so we don't persist transient UI state
362 + const persisted = tracks.map(({ looping, ...rest }) => rest);
363 + fetch('/tracks', {
364 + method: 'POST',
365 + headers: { 'Content-Type': 'application/json' },
366 + body: JSON.stringify(persisted),
367 + }).catch(err => console.error('Failed to save order:', err));
368 +}
369 +
370 +// Live sync over SSE
371 +
372 +// Swap the now-playing text without disturbing an in-flight marquee
373 +function updateNowPlayingTextInPlace(text) {
374 + const span = currentTrackDisplay.querySelector('span');
375 + if (!span) {
376 + updateCurrentTrackDisplay(text);
377 + return;
378 + }
379 +
380 + // If the marquee isn't currently animating, decide whether the new text needs animation
381 + if (!marqueeAnimating) {
382 + span.textContent = text;
383 + const containerWidth = currentTrackDisplay.offsetWidth - 30;
384 + if (span.scrollWidth > containerWidth) {
385 + marqueeOriginalText = text;
386 + setupMarquee({ preserveOffset: true });
387 + } else {
388 + marqueeOriginalText = text;
389 + }
390 + return;
391 + }
392 +
393 + // Marquee is animating. Update the duplicated text in place, recompute
394 + // the half-width (since the new text may differ in length), and wrap
395 + // the current offset into the new range
396 + marqueeOriginalText = text;
397 + const spacing = ' ';
398 + span.textContent = text + spacing + text + spacing;
399 +
400 + const containerWidth = currentTrackDisplay.offsetWidth - 30;
401 + if (span.scrollWidth / 2 <= containerWidth) {
402 + // New text fits — stop the marquee cleanly.
403 + setupMarquee({ preserveOffset: true });
404 + return;
405 + }
406 +
407 + marqueeHalfWidth = span.scrollWidth / 2;
408 + marqueeOffset = marqueeOffset % marqueeHalfWidth;
409 + span.style.transform = `translateX(-${marqueeOffset}px)`;
410 +}
411 +
412 +// Skip the very first SSE message so we don't double-render before the player has finished its
413 +// initial setup.
414 +let sseSeenInitial = false;
415 +
416 +function tracksEqual(a, b) {
417 + if (a.length !== b.length) return false;
418 + for (let i = 0; i < a.length; i++) {
419 + if (a[i].filename !== b[i].filename) return false;
420 + if (a[i].title !== b[i].title) return false;
421 + if (a[i].artist !== b[i].artist) return false;
422 + }
423 + return true;
424 +}
425 +
426 +function applyRenames(renames) {
427 + for (const { from, to } of renames) {
428 + if (from === to) continue;
429 +
430 + if (preloadedAudio[from]) {
431 + preloadedAudio[to] = preloadedAudio[from];
432 + delete preloadedAudio[from];
433 + }
434 +
435 + if (cachedTracks.has(from)) {
436 + cachedTracks.delete(from);
437 + cachedTracks.add(to);
438 + }
439 +
440 + const oldUrl = `mix/${from}`;
441 + if (audio.src && audio.src.endsWith(oldUrl)) {
442 + const wasPlaying = !audio.paused;
443 + const t = audio.currentTime;
444 + audio.src = `mix/${to}`;
445 + audio.load();
446 + audio.addEventListener('loadedmetadata', function resume() {
447 + audio.removeEventListener('loadedmetadata', resume);
448 + try { audio.currentTime = t; } catch (e) {}
449 + if (wasPlaying) audio.play().catch(() => {});
450 + }, { once: true });
451 + }
452 +
453 + const idx = tracks.findIndex(t => t.filename === from);
454 + if (idx !== -1) tracks[idx].filename = to;
455 + }
456 +}
457 +
458 +function reconcileTracks(incoming, renames) {
459 + if (renames && renames.length) applyRenames(renames);
460 +
461 + const incomingByName = new Map(incoming.map(t => [t.filename, t]));
462 + const currentNames = new Set(tracks.map(t => t.filename));
463 + const incomingNames = new Set(incomingByName.keys());
464 +
465 + if (tracks.length > 0 && tracksEqual(tracks, incoming)) return;
466 +
467 + const playingFilename = tracks[currentTrackIndex] && tracks[currentTrackIndex].filename;
468 + const playingRemoved = playingFilename && !incomingNames.has(playingFilename);
469 +
470 + // Free blob URLs for tracks that disappeared.
471 + for (const name of currentNames) {
472 + if (!incomingNames.has(name) && preloadedAudio[name]) {
473 + try { URL.revokeObjectURL(preloadedAudio[name].blobUrl); } catch (e) {}
474 + delete preloadedAudio[name];
475 + }
476 + }
477 +
478 + const loopingByName = new Map(tracks.map(t => [t.filename, t.looping || false]));
479 + const newTracks = incoming.map(t => ({ ...t, looping: loopingByName.get(t.filename) || false }));
480 +
481 + tracks.length = 0;
482 + tracks.push(...newTracks);
483 +
484 + if (tracks.length === 0) {
485 + playerReady = false;
486 + try { audio.pause(); } catch (e) {}
487 + audio.removeAttribute('src');
488 + audio.load();
489 + isPlaying = false;
490 + resetProgressBar();
491 + updatePlayPauseButton();
492 + updateCurrentTrackDisplay('No tracks found');
493 + playlist.innerHTML = '';
494 + return;
495 + }
496 +
497 + playerReady = true;
498 +
499 + if (playingRemoved) {
500 + // Playing track is gone: stop, advance to the next-best slot, and
501 + // drop into "Ready to play".
502 + try { audio.pause(); } catch (e) {}
503 + audio.removeAttribute('src');
504 + audio.load();
505 + isPlaying = false;
506 + resetProgressBar();
507 + const nextIndex = Math.min(currentTrackIndex, tracks.length - 1);
508 + setCurrentTrackIndex(nextIndex);
509 + const next = tracks[nextIndex];
510 + updateCurrentTrackDisplay(`Ready to play: ${next.artist} – ${next.title}`);
511 + updatePlayPauseButton();
512 + } else if (playingFilename) {
513 + // Playing track survived (possibly via a rename applied above): its
514 + // index may have shifted because of reorders or adds/removes
515 + // elsewhere in the list.
516 + const newIndex = tracks.findIndex(t => t.filename === playingFilename);
517 + if (newIndex !== -1) setCurrentTrackIndex(newIndex);
518 +
519 + const cur = tracks[currentTrackIndex];
520 + if (cur) {
521 + const isReady = currentTrackDisplay.textContent.includes('Ready to play:');
522 + const text = isReady
523 + ? `Ready to play: ${cur.artist} – ${cur.title}`
524 + : `${cur.artist} – ${cur.title}`;
525 + updateNowPlayingTextInPlace(text);
526 + }
527 + }
528 +
529 + renderPlaylist();
530 +
531 + // Pick up any tracks added since last preload pass.
532 + if (typeof preloadNextTrack === 'function') {
533 + currentPreloadIndex = 0;
534 + preloadNextTrack();
535 + }
536 +}
537 +
538 +function startLiveSync() {
539 + const source = new EventSource('/events');
540 + source.addEventListener('tracks', (e) => {
541 + const payload = JSON.parse(e.data);
542 + if (!sseSeenInitial) {
543 + sseSeenInitial = true;
544 + return;
545 + }
546 + reconcileTracks(payload.tracks, payload.renames);
547 + });
548 +}
549 +
550 +startLiveSync();
-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));
-}
6
7 import os
8 import sys
9 +
10 +# Hop into the shared venv (managed by scan.py) before doing anything else,
11 +# so scan.rescan() has mutagen available when we call it after ripping.
12 +sys.path.insert(0, str(os.path.dirname(os.path.abspath(__file__))))
13 +import scan
14 +scan.bootstrap(__file__)
15 +
16 import subprocess
17 import shutil
18 import time
368 print(f"\n✓ Successfully ripped {success_count}/{len(audio_files)} tracks in {total_minutes}m {total_secs}s.")
369
370 if success_count > 0:
371 + # Pick up the newly-ripped tracks (with the artist tag we just wrote
372 + # via ffmpeg) into tracks.json in disk order.
373 + scan.rescan(silent=True)
374 +
375 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")
376 + print("\nNext step: run serve.py to test your mixtape locally.")
377
378 # Eject the CD
379 print("\nEjecting CD...")
17 OUTPUT_FILE = MIX_DIR / "tracks.json"
18
19
-def setup_venv():
- """Create and set up virtual environment if it doesn't exist"""
- # Determine the path to pip and python in the venv
20 +def setup_venv(packages=("mutagen",)):
21 + """Create the shared venv if missing and ensure each package is installed.
22 + Returns the path to the venv Python interpreter."""
23 if sys.platform == "win32":
24 pip_path = VENV_DIR / "Scripts" / "pip"
25 python_path = VENV_DIR / "Scripts" / "python"
27 pip_path = VENV_DIR / "bin" / "pip"
28 python_path = VENV_DIR / "bin" / "python3"
29
- # Check if venv needs to be created or recreated
30 if not VENV_DIR.exists() or not python_path.exists():
31 if VENV_DIR.exists():
32 print("Virtual environment incomplete, recreating...")
42 print(f"Error creating virtual environment: {e}")
43 sys.exit(1)
44
- # Ensure pip is available (sometimes venv doesn't include it)
45 if not pip_path.exists():
46 print("Installing pip in virtual environment...")
47 try:
50 print(f"Error ensuring pip: {e}")
51 sys.exit(1)
52
- 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")
53 + for pkg in packages:
54 + check = subprocess.run(
55 + [str(python_path), "-c", f"import {pkg}"],
56 + capture_output=True
57 + )
58 + if check.returncode != 0:
59 + try:
60 + subprocess.check_call([str(python_path), "-m", "pip", "install", "-q", pkg])
61 + except subprocess.CalledProcessError:
62 + print(f"Note: Could not install {pkg} (offline?).\n")
63
64 return python_path
65
66
67 +def bootstrap(script_path, packages=("mutagen",), sentinel="--in-venv"):
68 + """Ensure the calling script is running inside the shared venv
69 + with `packages` available. Re-execs through the venv Python on first call;
70 + returns immediately on the second pass once the sentinel is in argv.
71 +
72 + Each script that uses this should pass its own __file__ as script_path."""
73 + if sentinel in sys.argv:
74 + sys.argv.remove(sentinel)
75 + return
76 + python_path = setup_venv(packages)
77 + try:
78 + result = subprocess.run([str(python_path), script_path, sentinel, *sys.argv[1:]])
79 + sys.exit(result.returncode)
80 + except KeyboardInterrupt:
81 + sys.exit(130)
82 +
83 +
84 def run_in_venv():
- """Re-run this script in the virtual environment"""
85 + """Re-run this script in the virtual environment (CLI use of scan.py)."""
86 python_path = setup_venv()
-
- # Re-run this script with the venv Python
87 print("Running scanner in virtual environment...\n")
88 subprocess.check_call([str(python_path), __file__, "--in-venv"])
89 sys.exit(0)
90
91
-def scan_tracks():
- """Main function to scan audio files and generate tracks.json"""
- # Import mutagen here (only after venv is active)
92 +SUPPORTED_EXTENSIONS = ('.mp3', '.m4a', '.ogg', '.flac', '.wav')
93 +
94 +_INVALID_FILENAME_CHARS = '<>:"/\\|?*'
95 +
96 +
97 +def _sanitize_filename(name):
98 + for char in _INVALID_FILENAME_CHARS:
99 + name = name.replace(char, '')
100 + return name.strip()
101 +
102 +
103 +def _write_tags(audio_file, artist, title, MutagenFile):
104 + """Write artist/title tags to disk if they don't already match. Returns
105 + True if anything was written."""
106 + try:
107 + audio = MutagenFile(audio_file, easy=True)
108 + if audio is None:
109 + return False
110 + if audio.tags is None:
111 + audio.add_tags()
112 + current_title = audio.tags.get('title', [None])[0]
113 + current_artist = audio.tags.get('artist', [None])[0]
114 + if current_title == title and current_artist == artist:
115 + return False
116 + audio.tags['title'] = title
117 + audio.tags['artist'] = artist
118 + audio.save()
119 + return True
120 + except Exception:
121 + return False
122 +
123 +
124 +def _canonicalize(tracks, has_mutagen, MutagenFile, silent):
125 + """Rename files to 'Artist – Title.ext' and sync ID3 tags so disk state
126 + matches tracks.json metadata. Mutates `tracks` in place; returns a list
127 + of {from, to} renames so callers can migrate any external state (eg.
128 + preloaded blob URLs in connected browser tabs)."""
129 + renames = []
130 + for entry in tracks:
131 + old_filename = entry['filename']
132 + ext = Path(old_filename).suffix
133 + artist = _sanitize_filename(entry['artist'])
134 + title = _sanitize_filename(entry['title'])
135 + new_filename = f"{artist} – {title}{ext}"
136 +
137 + old_path = MIX_DIR / old_filename
138 + new_path = MIX_DIR / new_filename
139 +
140 + # Keep tags aligned with the (possibly hand-edited) tracks.json
141 + # entry, regardless of whether a rename is needed.
142 + if has_mutagen and old_path.exists():
143 + _write_tags(old_path, entry['artist'], entry['title'], MutagenFile)
144 +
145 + if old_filename == new_filename:
146 + continue
147 + if not old_path.exists():
148 + continue
149 + if new_path.exists():
150 + # Don't clobber an unrelated file that already occupies the
151 + # canonical name. Leave the entry pointing at its current file.
152 + if not silent:
153 + print(f" Skipping rename {old_filename} -> {new_filename} (target exists)")
154 + continue
155 + try:
156 + old_path.rename(new_path)
157 + entry['filename'] = new_filename
158 + renames.append({'from': old_filename, 'to': new_filename})
159 + if not silent:
160 + print(f" Renamed: {old_filename} -> {new_filename}")
161 + except Exception as e:
162 + if not silent:
163 + print(f" Error renaming {old_filename}: {e}")
164 + return renames
165 +
166 +
167 +def rescan(silent=False):
168 + """Reconcile tracks.json with /mix. Renames files to canonical
169 + 'Artist – Title.ext' form when they drift, syncing ID3 tags at the same
170 + time. Returns (tracks, changed, renames) where renames is a list of
171 + {from, to} dicts for any files that moved this pass."""
172 try:
173 from mutagen import File as MutagenFile # type: ignore
174 has_mutagen = True
175 except ImportError:
176 MutagenFile = None
177 has_mutagen = False
- print("Mutagen not available. Metadata will be derived from filenames.\n")
-
- # Supported audio formats
- SUPPORTED_EXTENSIONS = ('.mp3', '.m4a', '.ogg', '.flac', '.wav')
178 + if not silent:
179 + print("Mutagen not available. Metadata will be derived from filenames.\n")
180
- # Check if tracks directory exists, create if it doesn't
181 if not MIX_DIR.exists():
- print(f"Creating {MIX_DIR.name} directory...")
182 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)
183
- # Find all supported audio files
184 audio_files = [f for f in MIX_DIR.iterdir() if f.suffix.lower() in SUPPORTED_EXTENSIONS]
185
- 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")
-
186 existing_tracks = []
187 if OUTPUT_FILE.exists():
188 try:
191 if isinstance(loaded, list):
192 existing_tracks = loaded
193 except (json.JSONDecodeError, OSError) as e:
- print(f"Warning: could not read existing {OUTPUT_FILE.name} ({e}). Starting fresh.\n")
194 + if not silent:
195 + print(f"Warning: could not read existing {OUTPUT_FILE.name} ({e}). Starting fresh.\n")
196
197 existing_by_filename = {
198 t['filename']: t for t in existing_tracks
234 try:
235 new_tracks.append(read_metadata(audio_file))
236 except Exception as e:
- print(f"✗ Error reading {audio_file.name}: {e}")
237 + if not silent:
238 + print(f"✗ Error reading {audio_file.name}: {e}")
239
- 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.")
240 + if not silent:
241 + for track in new_tracks:
242 + print(f"+ {track['artist']} - {track['title']}")
243 + for filename in removed:
244 + print(f"- {filename} (removed; no longer on disk)")
245 + if not new_tracks and not removed:
246 + print("No changes — tracks.json already matches /mix.")
247
248 tracks.extend(new_tracks)
249
- if not tracks:
- print("\nNo valid audio files could be processed.")
- sys.exit(1)
250 + renames = _canonicalize(tracks, has_mutagen, MutagenFile, silent)
251
- # Write to tracks.json
- try:
- with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
- json.dump(tracks, f, indent="\t", ensure_ascii=False)
252 + changed = bool(new_tracks) or bool(removed) or bool(renames)
253 + if changed or not OUTPUT_FILE.exists():
254 + try:
255 + OUTPUT_FILE.write_text(
256 + json.dumps(tracks, indent='\t', ensure_ascii=False) + '\n',
257 + encoding='utf-8',
258 + )
259 + except Exception as e:
260 + if not silent:
261 + print(f"\nError writing {OUTPUT_FILE.name}: {e}")
262 + raise
263 +
264 + return tracks, changed, renames
265 +
266 +
267 +def scan_tracks():
268 + """CLI entry point: rescan and print a summary."""
269 + if not MIX_DIR.exists():
270 + print(f"Creating {MIX_DIR.name} directory...")
271 + MIX_DIR.mkdir(parents=True, exist_ok=True)
272 + print(f"✓ {MIX_DIR.name} directory created.")
273 + print(f"\nAdd audio files to the {MIX_DIR.name} directory and run this script again.")
274 + print(f"Supported formats: {', '.join(SUPPORTED_EXTENSIONS)}")
275 + sys.exit(0)
276
- print(f"\n✓ Successfully generated {OUTPUT_FILE.name} with {len(tracks)} track(s).")
277 + audio_files = [f for f in MIX_DIR.iterdir() if f.suffix.lower() in SUPPORTED_EXTENSIONS]
278 + if not audio_files:
279 + print(f"No audio files found in {MIX_DIR}")
280 + print(f"\nPlease add audio files to the {MIX_DIR.name} directory and run this script again.")
281 + print(f"Supported formats: {', '.join(SUPPORTED_EXTENSIONS)}")
282 + sys.exit(0)
283
- except Exception as e:
- print(f"\nError writing {OUTPUT_FILE.name}: {e}")
284 + print(f"Found {len(audio_files)} audio file(s). Extracting metadata...\n")
285 + tracks, changed, _renames = rescan(silent=False)
286 + if not tracks:
287 + print("\nNo valid audio files could be processed.")
288 sys.exit(1)
289 + if changed:
290 + print(f"\n✓ Updated {OUTPUT_FILE.name} ({len(tracks)} track(s)).")
291
292
293 def main():
10 import sys
11 import os
12 import json
13 +import queue
14 +import threading
15 import subprocess
16 +import time
17 from pathlib import Path
18
19 DEFAULT_PORT = 8000
20 SCRIPT_DIR = Path(__file__).parent.absolute()
21 VENV_DIR = SCRIPT_DIR / "venv"
22 +TRACKS_PATH = SCRIPT_DIR / "mix" / "tracks.json"
23
24 +# Serializes all reads/writes of tracks.json so rescans, reorders, and future
25 +# uploads can't race each other.
26 +tracks_lock = threading.Lock()
27
-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")
28 +# Set of per-client SSE queues. Each connected /events client gets a queue;
29 +# broadcast_tracks() pushes the latest tracks list onto every queue.
30 +sse_subscribers = set()
31 +sse_subscribers_lock = threading.Lock()
32
- return python_path
33 +# Canonical serialization of the most recently broadcast tracks list, so we
34 +# can fire on any content change (file add/remove/reorder or a hand-edit to
35 +# tracks.json's metadata) instead of only when /mix's filenames change.
36 +last_broadcast_serialized = None
37
38
39 def run_in_venv():
- """Re-run this script in the virtual environment"""
- python_path = setup_venv()
-
- # Re-run this script with the venv Python
40 + """Re-run this script in the shared venv with serve.py's deps available"""
41 + import scan
42 + python_path = scan.setup_venv(packages=("qrcode", "mutagen"))
43 try:
44 subprocess.check_call([str(python_path), __file__, "--in-venv"])
45 except (KeyboardInterrupt, subprocess.CalledProcessError):
110 except Exception as e:
111 print(f"\nCould not generate QR code: {e}")
112
113 +
114 +def read_tracks():
115 + """Read tracks.json from disk; return [] if missing or malformed."""
116 + if not TRACKS_PATH.exists():
117 + return []
118 + try:
119 + with open(TRACKS_PATH, 'r', encoding='utf-8') as f:
120 + loaded = json.load(f)
121 + return loaded if isinstance(loaded, list) else []
122 + except (json.JSONDecodeError, OSError):
123 + return []
124 +
125 +
126 +def broadcast_tracks(tracks, renames=None):
127 + """Push the current tracks list to every connected SSE client if its
128 + content differs from the last broadcast. `renames`, when non-empty,
129 + carries [{from, to}, ...] so clients can migrate per-track state (eg.
130 + preloaded blob URLs) without losing it across a canonicalizing rename.
131 + Caller must hold tracks_lock."""
132 + global last_broadcast_serialized
133 + serialized = json.dumps(tracks, ensure_ascii=False, sort_keys=True)
134 + if serialized == last_broadcast_serialized and not renames:
135 + return
136 + last_broadcast_serialized = serialized
137 + payload = {'tracks': tracks, 'renames': renames or []}
138 + with sse_subscribers_lock:
139 + subscribers = list(sse_subscribers)
140 + for q in subscribers:
141 + try:
142 + q.put_nowait(payload)
143 + except queue.Full:
144 + pass
145 +
146 +
147 +def rescan_and_maybe_broadcast():
148 + """Rescan /mix, then broadcast if the resulting tracks list differs from
149 + what we last sent."""
150 + import scan
151 + tracks, _changed, renames = scan.rescan(silent=True)
152 + broadcast_tracks(tracks, renames=renames)
153 + return tracks
154 +
155 +
156 +def start_rescan_ticker(interval=1.0):
157 + """Background thread that periodically rescans /mix so changes made
158 + directly on disk (eg. user dragging a file in or `rm`-ing one from the
159 + terminal) reach connected clients without anyone hitting the server."""
160 + def tick():
161 + while True:
162 + time.sleep(interval)
163 + try:
164 + with tracks_lock:
165 + rescan_and_maybe_broadcast()
166 + except Exception as e:
167 + # Don't let a bad scan kill the ticker.
168 + print(f"rescan tick error: {e}", file=sys.stderr)
169 + t = threading.Thread(target=tick, daemon=True)
170 + t.start()
171 +
172 +
173 def start_server():
174 """Start the HTTP server (runs after venv is set up)"""
175 # Change to script directory
176 os.chdir(SCRIPT_DIR)
177
178 + # Periodically rescan /mix so on-disk changes propagate to the UI even
179 + # when nothing is hitting an HTTP endpoint.
180 + start_rescan_ticker()
181 +
182 # Find an available port
183 port = find_available_port(DEFAULT_PORT)
184
191
192 # Create server
193 Handler = http.server.SimpleHTTPRequestHandler
- tracks_path = SCRIPT_DIR / "mix" / "tracks.json"
194
195 # Suppress default logging and broken pipe errors
196 class QuietHandler(Handler):
209 # Browser cancelled the request (normal for media streaming/preloading)
210 pass
211
212 + def do_GET(self):
213 + # Rescan /mix on every tracks.json fetch so the page always sees
214 + # what's actually on disk (added via rip.py/buy.py or by hand).
215 + if self.path == '/mix/tracks.json':
216 + with tracks_lock:
217 + rescan_and_maybe_broadcast()
218 + return super().do_GET()
219 +
220 + if self.path == '/events':
221 + return self._serve_sse()
222 +
223 + return super().do_GET()
224 +
225 + def _serve_sse(self):
226 + """Server-Sent Events stream of tracks.json changes."""
227 + self.send_response(200)
228 + self.send_header('Content-Type', 'text/event-stream')
229 + self.send_header('Cache-Control', 'no-cache')
230 + self.send_header('Connection', 'keep-alive')
231 + self.send_header('X-Accel-Buffering', 'no')
232 + self.end_headers()
233 +
234 + q = queue.Queue(maxsize=16)
235 + with sse_subscribers_lock:
236 + sse_subscribers.add(q)
237 +
238 + try:
239 + # Initial sync so a fresh tab gets the current state without
240 + # waiting for the next change. No renames context: a fresh
241 + # client has no prior state to migrate from.
242 + with tracks_lock:
243 + initial = read_tracks()
244 + self._send_sse_event('tracks', {'tracks': initial, 'renames': []})
245 +
246 + while True:
247 + try:
248 + payload = q.get(timeout=15)
249 + self._send_sse_event('tracks', payload)
250 + except queue.Empty:
251 + # Heartbeat keeps proxies and idle connections from
252 + # closing the stream.
253 + self.wfile.write(b': keepalive\n\n')
254 + self.wfile.flush()
255 + except (BrokenPipeError, ConnectionResetError, OSError):
256 + pass
257 + finally:
258 + with sse_subscribers_lock:
259 + sse_subscribers.discard(q)
260 +
261 + def _send_sse_event(self, event, data):
262 + payload = json.dumps(data, ensure_ascii=False)
263 + message = f'event: {event}\ndata: {payload}\n\n'.encode('utf-8')
264 + self.wfile.write(message)
265 + self.wfile.flush()
266 +
267 def do_POST(self):
268 # Local-only: receive a reordered tracks array and overwrite tracks.json
269 if self.path != '/tracks':
272 return
273 length = int(self.headers.get('Content-Length', '0'))
274 payload = json.loads(self.rfile.read(length))
- tracks_path.write_text(
- json.dumps(payload, indent='\t', ensure_ascii=False) + '\n',
- encoding='utf-8',
- )
275 + with tracks_lock:
276 + TRACKS_PATH.write_text(
277 + json.dumps(payload, indent='\t', ensure_ascii=False) + '\n',
278 + encoding='utf-8',
279 + )
280 + broadcast_tracks(payload)
281 self.send_response(204)
282 self.end_headers()
283