first pass at track reordering when running locally
Hunter
Monday, April 27th, 2026 at 2:47 PM
40 <audio id="audioPlayer"></audio>
41
42 <script src="resources/script.js"></script>
43 + <script>
44 + // Load reorder UI only on local networks.
45 + if (typeof isLocal !== 'undefined' && isLocal) {
46 + const s = document.createElement('script');
47 + s.src = 'resources/reorder.js';
48 + document.body.appendChild(s);
49 + }
50 + </script>
51 <script src="mix/custom.js" onerror="this.remove()"></script>
52 </body>
53 </html>
42 - `./buy.py` to search for tracks to purchase (opens in iTunes on MacOS, <a href="https://song.link/i/1651294855">song.link</a> otherwise)
43
44 2. **scan it**
- - run `./scan.py` to generate `tracks.json`, which defines the tracks available to the player.
- - after running `./scan.py` once, you can manually edit `tracks.json` to refine your mix.
45 + - run `./scan.py` to create (or update) `tracks.json`, which defines the tracks available to the player.
46
47 3. **serve it**
- - run `./serve.py` to start a local HTTP server for testing. you can scan the QR code printed to the terminal to test the app from any device on your local network.
-
48 + - 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.
49 + - while the server is running, you can drag tracks up or down in the list to refine your mix (this automatically updates `tracks.json`).
50 +
51 4. **build it**
52 - run `./build.py` and follow the interactive prompts to generate `manifest.json` and `service-worker.js`, which enable PWA installation and offline functionality.
53
1 +const REORDER_DRAG_THRESHOLD = 5;
2 +const LONG_PRESS_MS = 400;
3 +const LONG_PRESS_MOVE_TOLERANCE = 10;
4 +let activeReorder = null;
5 +let longPressTimer = null;
6 +
7 +function attachReorderHandlers(item, index) {
8 + item.addEventListener('mousedown', (e) => {
9 + // Ignore non-primary buttons
10 + if (e.button !== 0) return;
11 + startReorder(item, index, e.clientX, e.clientY);
12 + });
13 +
14 + item.addEventListener('touchstart', (e) => {
15 + if (e.touches.length !== 1) return;
16 + const t = e.touches[0];
17 + startLongPress(item, index, t.clientX, t.clientY);
18 + }, { passive: true });
19 +}
20 +
21 +function startLongPress(item, index, startX, startY) {
22 + if (longPressTimer) clearTimeout(longPressTimer);
23 +
24 + const candidate = { item, index, startX, startY };
25 + longPressTimer = setTimeout(() => {
26 + longPressTimer = null;
27 + cleanup();
28 + startReorder(candidate.item, candidate.index, candidate.startX, candidate.startY, /* isTouch */ true);
29 + beginDrag();
30 + if (navigator.vibrate) navigator.vibrate(10);
31 + }, LONG_PRESS_MS);
32 +
33 + const onMove = (e) => {
34 + const t = e.touches[0];
35 + if (!t) return;
36 + const dx = t.clientX - startX;
37 + const dy = t.clientY - startY;
38 + if (Math.hypot(dx, dy) > LONG_PRESS_MOVE_TOLERANCE) cancel();
39 + };
40 + const onUp = () => cancel();
41 +
42 + function cleanup() {
43 + document.removeEventListener('touchmove', onMove);
44 + document.removeEventListener('touchend', onUp);
45 + }
46 +
47 + function cancel() {
48 + if (longPressTimer) {
49 + clearTimeout(longPressTimer);
50 + longPressTimer = null;
51 + }
52 + cleanup();
53 + }
54 +
55 + document.addEventListener('touchmove', onMove, { passive: true });
56 + document.addEventListener('touchend', onUp);
57 +}
58 +
59 +function startReorder(item, index, startX, startY, isTouch = false) {
60 + activeReorder = {
61 + item,
62 + index,
63 + startX,
64 + startY,
65 + started: false,
66 + placeholder: null,
67 + offsetY: 0,
68 + itemHeight: 0,
69 + autoScrollRaf: null,
70 + autoScrollSpeed: 0,
71 + lastClientY: startY,
72 + isTouch,
73 + };
74 + if (isTouch) {
75 + document.addEventListener('touchmove', onReorderTouchMove, { passive: false });
76 + document.addEventListener('touchend', onReorderEnd);
77 + document.addEventListener('touchcancel', onReorderEnd);
78 + } else {
79 + document.body.classList.add('reorder-pressed');
80 + document.addEventListener('mousemove', onReorderMove);
81 + document.addEventListener('mouseup', onReorderEnd);
82 + }
83 +}
84 +
85 +function beginDrag() {
86 + const r = activeReorder;
87 + const rect = r.item.getBoundingClientRect();
88 + r.itemHeight = rect.height;
89 + r.offsetY = r.startY - rect.top;
90 +
91 + const styles = window.getComputedStyle(r.item);
92 + const placeholder = document.createElement('div');
93 + placeholder.classList.add('playlist-item-placeholder');
94 + placeholder.style.height = `${rect.height}px`;
95 + placeholder.style.marginTop = styles.marginTop;
96 + placeholder.style.marginBottom = styles.marginBottom;
97 + placeholder.style.paddingLeft = styles.paddingLeft;
98 + placeholder.style.paddingRight = styles.paddingRight;
99 + placeholder.style.paddingBottom = styles.paddingBottom;
100 + r.item.parentNode.insertBefore(placeholder, r.item);
101 + r.placeholder = placeholder;
102 +
103 + const playlistRect = playlist.getBoundingClientRect();
104 + r.item.classList.add('dragging');
105 + r.item.style.position = 'fixed';
106 + r.item.style.left = `${playlistRect.left}px`;
107 + r.item.style.top = `${rect.top}px`;
108 + r.item.style.width = `${playlistRect.width}px`;
109 + r.item.style.height = `${rect.height}px`;
110 + r.item.style.zIndex = '10';
111 + document.body.appendChild(r.item);
112 + // Now we're actually dragging — disable pointer-events on the other items
113 + document.body.classList.add('reordering');
114 +
115 + r.started = true;
116 + r.item._suppressClick = true;
117 +}
118 +
119 +function onReorderMove(e) {
120 + const r = activeReorder;
121 + if (!r) return;
122 + r.lastClientY = e.clientY;
123 +
124 + if (!r.started) {
125 + const dx = e.clientX - r.startX;
126 + const dy = e.clientY - r.startY;
127 + if (Math.hypot(dx, dy) < REORDER_DRAG_THRESHOLD) return;
128 + beginDrag();
129 + }
130 +
131 + positionDraggedItem();
132 + updateAutoScroll();
133 +}
134 +
135 +function onReorderTouchMove(e) {
136 + const r = activeReorder;
137 + if (!r || !r.started) return;
138 + e.preventDefault();
139 + const t = e.touches[0];
140 + if (!t) return;
141 + r.lastClientY = t.clientY;
142 + positionDraggedItem();
143 + updateAutoScroll();
144 +}
145 +
146 +function positionDraggedItem() {
147 + const r = activeReorder;
148 + if (!r || !r.started) return;
149 +
150 + // Clamp the dragged item between the bottom of the now-playing area
151 + // (the wrapper's top) and the top of the navigation controls.
152 + const wrapperRect = playlistWrapper.getBoundingClientRect();
153 + const controlsRect = controls.getBoundingClientRect();
154 + const desiredTop = r.lastClientY - r.offsetY;
155 + const minTop = wrapperRect.top;
156 + const maxTop = controlsRect.top - r.itemHeight;
157 + const clampedTop = Math.max(minTop, Math.min(maxTop, desiredTop));
158 + r.item.style.top = `${clampedTop}px`;
159 +
160 + repositionPlaceholder();
161 +}
162 +
163 +function layoutMidY(el) {
164 + const rect = el.getBoundingClientRect();
165 + if (el._flipTargetTop !== undefined) {
166 + return el._flipTargetTop + rect.height / 2;
167 + }
168 + return rect.top + rect.height / 2;
169 +}
170 +
171 +function repositionPlaceholder() {
172 + const r = activeReorder;
173 + if (!r || !r.started) return;
174 +
175 + const draggedRect = r.item.getBoundingClientRect();
176 + const draggedMid = draggedRect.top + draggedRect.height / 2;
177 +
178 + const prev = r.placeholder.previousElementSibling;
179 + if (prev && prev.classList.contains('playlist-item')) {
180 + if (draggedMid < layoutMidY(prev)) {
181 + flipItem(prev, () => playlist.insertBefore(r.placeholder, prev));
182 + return;
183 + }
184 + }
185 +
186 + const next = r.placeholder.nextElementSibling;
187 + if (next && next.classList.contains('playlist-item')) {
188 + if (draggedMid > layoutMidY(next)) {
189 + flipItem(next, () => playlist.insertBefore(r.placeholder, next.nextSibling));
190 + return;
191 + }
192 + }
193 +}
194 +
195 +const AUTO_SCROLL_EDGE = 60; // px from edge that triggers scrolling
196 +const AUTO_SCROLL_MAX_SPEED = 6; // px per frame
197 +
198 +function updateAutoScroll() {
199 + const r = activeReorder;
200 + if (!r || !r.started) return;
201 +
202 + const wrapperRect = playlistWrapper.getBoundingClientRect();
203 + const bottomBound = controls.getBoundingClientRect().top;
204 + const y = r.lastClientY;
205 +
206 + let speed = 0;
207 + if (y < wrapperRect.top + AUTO_SCROLL_EDGE) {
208 + const intensity = (wrapperRect.top + AUTO_SCROLL_EDGE - y) / AUTO_SCROLL_EDGE;
209 + speed = -Math.min(1, intensity) * AUTO_SCROLL_MAX_SPEED;
210 + } else if (y > bottomBound - AUTO_SCROLL_EDGE) {
211 + const intensity = (y - (bottomBound - AUTO_SCROLL_EDGE)) / AUTO_SCROLL_EDGE;
212 + speed = Math.min(1, intensity) * AUTO_SCROLL_MAX_SPEED;
213 + }
214 +
215 + r.autoScrollSpeed = speed;
216 + if (speed !== 0 && !r.autoScrollRaf) {
217 + const tick = () => {
218 + const cur = activeReorder;
219 + if (!cur || !cur.started || cur.autoScrollSpeed === 0) {
220 + if (cur) cur.autoScrollRaf = null;
221 + return;
222 + }
223 + const before = playlistWrapper.scrollTop;
224 + playlistWrapper.scrollTop = Math.max(0, Math.min(playlistWrapper.scrollHeight - playlistWrapper.clientHeight, playlistWrapper.scrollTop + cur.autoScrollSpeed));
225 + const delta = playlistWrapper.scrollTop - before;
226 + if (delta !== 0) {
227 + positionDraggedItem();
228 + }
229 + cur.autoScrollRaf = requestAnimationFrame(tick);
230 + };
231 + r.autoScrollRaf = requestAnimationFrame(tick);
232 + }
233 +}
234 +
235 +function flipItem(el, mutate) {
236 + el.style.transition = 'none';
237 + el.style.transform = '';
238 + const oldTop = el.getBoundingClientRect().top;
239 +
240 + mutate();
241 +
242 + const newTop = el.getBoundingClientRect().top;
243 + const dy = oldTop - newTop;
244 + if (!dy) return;
245 + el.style.transform = `translateY(${dy}px)`;
246 + void el.offsetHeight;
247 + el.style.transition = 'transform 180ms ease';
248 + el.style.transform = '';
249 +
250 + el._flipTargetTop = newTop;
251 + if (el._flipEndHandler) el.removeEventListener('transitionend', el._flipEndHandler);
252 + el._flipEndHandler = (e) => {
253 + if (e.propertyName !== 'transform') return;
254 + delete el._flipTargetTop;
255 + el.removeEventListener('transitionend', el._flipEndHandler);
256 + el._flipEndHandler = null;
257 + };
258 + el.addEventListener('transitionend', el._flipEndHandler);
259 +}
260 +
261 +function onReorderEnd() {
262 + const r = activeReorder;
263 + document.removeEventListener('mousemove', onReorderMove);
264 + document.removeEventListener('mouseup', onReorderEnd);
265 + document.removeEventListener('touchmove', onReorderTouchMove);
266 + document.removeEventListener('touchend', onReorderEnd);
267 + document.removeEventListener('touchcancel', onReorderEnd);
268 + activeReorder = null;
269 + document.body.classList.remove('reorder-pressed');
270 + document.body.classList.remove('reordering');
271 + if (r && r.autoScrollRaf) cancelAnimationFrame(r.autoScrollRaf);
272 + if (!r) return;
273 +
274 + if (!r.started) {
275 + // No real drag — let the click handler fire normally
276 + return;
277 + }
278 +
279 + // Compute the new order from placeholder position and rebuild tracks array
280 + const fromIndex = r.index;
281 + const itemsInDom = Array.from(playlist.querySelectorAll('.playlist-item, .playlist-item-placeholder'));
282 + const toIndex = itemsInDom.indexOf(r.placeholder);
283 +
284 + const droppedTop = r.item.getBoundingClientRect().top;
285 + r.placeholder.remove();
286 +
287 + if (toIndex === -1 || toIndex === fromIndex) {
288 + renderPlaylist();
289 + animateDroppedTrack(r.item, fromIndex, droppedTop);
290 + return;
291 + }
292 +
293 + // Build the new tracks array directly from the DOM ordering.
294 + const moved = tracks[fromIndex];
295 + const remaining = tracks.filter((_, i) => i !== fromIndex);
296 + const newTracks = [];
297 + let remainingIdx = 0;
298 + for (let i = 0; i < itemsInDom.length; i++) {
299 + if (itemsInDom[i] === r.placeholder) {
300 + newTracks.push(moved);
301 + } else {
302 + newTracks.push(remaining[remainingIdx++]);
303 + }
304 + }
305 +
306 + const playingTrack = tracks[currentTrackIndex];
307 + tracks.length = 0;
308 + tracks.push(...newTracks);
309 + const newCurrentIndex = tracks.indexOf(playingTrack);
310 + if (newCurrentIndex !== -1) setCurrentTrackIndex(newCurrentIndex);
311 +
312 + renderPlaylist();
313 + animateDroppedTrack(r.item, toIndex, droppedTop);
314 + saveTrackOrder();
315 +}
316 +
317 +function animateDroppedTrack(draggedEl, newIndex, oldTop) {
318 + const items = playlist.querySelectorAll('.playlist-item');
319 + const target = items[newIndex];
320 + if (!target) {
321 + draggedEl.remove();
322 + return;
323 + }
324 +
325 + // Hide the rebuilt item but keep it in layout so sizing/scroll are stable.
326 + target.style.visibility = 'hidden';
327 + const newTop = target.getBoundingClientRect().top;
328 +
329 + const cleanup = () => {
330 + draggedEl.remove();
331 + target.style.visibility = '';
332 + };
333 +
334 + if (oldTop === newTop) {
335 + cleanup();
336 + return;
337 + }
338 +
339 + const playlistRect = playlist.getBoundingClientRect();
340 + draggedEl.style.transition = 'none';
341 + draggedEl.style.top = `${oldTop}px`;
342 + draggedEl.style.left = `${playlistRect.left}px`;
343 + draggedEl.style.width = `${playlistRect.width}px`;
344 + void draggedEl.offsetHeight;
345 + draggedEl.style.transition = 'top 180ms ease';
346 + draggedEl.style.top = `${newTop}px`;
347 +
348 + const finish = (e) => {
349 + if (e.propertyName !== 'top') return;
350 + draggedEl.removeEventListener('transitionend', finish);
351 + cleanup();
352 + };
353 + draggedEl.addEventListener('transitionend', finish);
354 +}
355 +
356 +function saveTrackOrder() {
357 + // Strip the runtime-only `looping` flag so we don't persist transient UI state
358 + const persisted = tracks.map(({ looping, ...rest }) => rest);
359 + fetch('/tracks', {
360 + method: 'POST',
361 + headers: { 'Content-Type': 'application/json' },
362 + body: JSON.stringify(persisted),
363 + }).catch(err => console.error('Failed to save order:', err));
364 +}
-// Register service worker for PWA functionality (skip on localhost to avoid caching during development)
-if ('serviceWorker' in navigator && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1') {
1 +const hostname = location.hostname;
2 +const isLocal =
3 + hostname === 'localhost' ||
4 + hostname === '127.0.0.1' ||
5 + hostname === '::1' ||
6 + /^10\./.test(hostname) ||
7 + /^192\.168\./.test(hostname) ||
8 + /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
9 + /^169\.254\./.test(hostname);
10 +
11 +// Register service worker for PWA functionality (skip locally to avoid caching during development)
12 +if ('serviceWorker' in navigator && !isLocal) {
13 window.addEventListener('load', () => {
14 navigator.serviceWorker.register('service-worker.js')
15 .then(registration => {
19 console.log('Service Worker registration failed:', error);
20 });
21 });
-} else if ('serviceWorker' in navigator && (location.hostname === 'localhost' || location.hostname === '127.0.0.1')) {
- // On localhost, unregister any existing service worker and clear caches
22 +} else if ('serviceWorker' in navigator && isLocal) {
23 + // Locally, unregister any existing service worker and clear caches
24 navigator.serviceWorker.getRegistrations().then(registrations => {
25 registrations.forEach(r => r.unregister());
26 });
33 const prevBtn = document.getElementById('prev');
34 const nextBtn = document.getElementById('next');
35 const playlist = document.getElementById('playlist');
36 +const playlistWrapper = document.querySelector('.playlist-wrapper');
37 +const controls = document.querySelector('.controls');
38 const currentTrackDisplay = document.getElementById('currentTrack');
39 const progressBar = document.getElementById('progressBar');
40 const progressContainer = document.getElementById('progressContainer');
54
55 let currentTrackIndex = 0;
56 let isPlaying = false;
57 +let canReorder = false;
58 let progressInterval;
59 let playerReady = false;
60 let tracks = [];
99 }
100 }
101
102 +// Enable reordering UI only when served locally
103 +if (isLocal) {
104 + canReorder = true;
105 + document.body.classList.add('reorderable');
106 +}
107 +
108 // Load cache name from manifest.json first, then load tracks
109 fetch('manifest.json')
110 .then(response => {
317
318 item.appendChild(contentDiv);
319 item.appendChild(loopIcon);
- item.addEventListener('click', () => toggleLooping(index));
320 + item.addEventListener('click', (e) => {
321 + if (item._suppressClick) {
322 + item._suppressClick = false;
323 + e.stopPropagation();
324 + return;
325 + }
326 + toggleLooping(index);
327 + });
328 + if (canReorder) {
329 + attachReorderHandlers(item, index);
330 + }
331 playlist.appendChild(item);
332 });
333 }
334
335 +function setCurrentTrackIndex(i) {
336 + currentTrackIndex = i;
337 +}
338 +
339 function toggleLooping(index) {
340 if (!playerReady) return;
341 if (index === currentTrackIndex) {
130 padding-bottom: 70px;
131 overscroll-behavior: none;
132 margin-top: -1px;
133 + overflow-anchor: none;
134 }
135
136 .playlist {
197 .playlist-item:hover .playlist-item-artist.current {
198 color: var(--accent);
199 }
200 +
201 + body.reorderable .playlist,
202 + body.reorderable .playlist-item:hover {
203 + cursor: grab;
204 + }
205 +}
206 +
207 +body.reorder-pressed,
208 +body.reorder-pressed * {
209 + cursor: grabbing !important;
210 +}
211 +
212 +body.reordering .playlist-item {
213 + pointer-events: none;
214 + will-change: transform;
215 +}
216 +
217 +.playlist-item.dragging {
218 + background-color: var(--background);
219 + box-sizing: border-box;
220 +}
221 +
222 +.playlist-item.dragging,
223 +.playlist-item.dragging .playlist-item-title,
224 +.playlist-item.dragging .playlist-item-artist {
225 + color: var(--accent);
226 +}
227 +
228 +.playlist-item-placeholder {
229 + box-sizing: border-box;
230 }
231
232 .current-track {
79 """Main function to scan audio files and generate tracks.json"""
80 # Import mutagen here (only after venv is active)
81 try:
- from mutagen import File as MutagenFile
82 + from mutagen import File as MutagenFile # type: ignore
83 has_mutagen = True
84 except ImportError:
85 MutagenFile = None
109
110 print(f"Found {len(audio_files)} audio file(s). Extracting metadata...\n")
111
- tracks = []
-
- for audio_file in sorted(audio_files):
112 + existing_tracks = []
113 + if OUTPUT_FILE.exists():
114 try:
- title = None
- artist = None
-
- if has_mutagen:
- audio = MutagenFile(audio_file, easy=True)
- if audio and audio.tags:
- title = audio.tags.get('title', [None])[0]
- artist = audio.tags.get('artist', [None])[0]
-
- # Fallback to filename for title if not found
- if not title:
- title = audio_file.stem # filename without extension
115 + with open(OUTPUT_FILE, 'r', encoding='utf-8') as f:
116 + loaded = json.load(f)
117 + if isinstance(loaded, list):
118 + existing_tracks = loaded
119 + except (json.JSONDecodeError, OSError) as e:
120 + print(f"Warning: could not read existing {OUTPUT_FILE.name} ({e}). Starting fresh.\n")
121 +
122 + existing_by_filename = {
123 + t['filename']: t for t in existing_tracks
124 + if isinstance(t, dict) and 'filename' in t
125 + }
126 + on_disk_filenames = {f.name for f in audio_files}
127 + files_by_name = {f.name: f for f in audio_files}
128 +
129 + def read_metadata(audio_file):
130 + title = None
131 + artist = None
132 + if has_mutagen:
133 + audio = MutagenFile(audio_file, easy=True)
134 + if audio and audio.tags:
135 + title = audio.tags.get('title', [None])[0]
136 + artist = audio.tags.get('artist', [None])[0]
137 + if not title:
138 + title = audio_file.stem
139 + if not artist:
140 + artist = "Unknown Artist"
141 + return {"title": title, "artist": artist, "filename": audio_file.name}
142
- # Fallback to "Unknown Artist" if not found
- if not artist:
- artist = "Unknown Artist"
-
- track_info = {
- "title": title,
- "artist": artist,
- "filename": audio_file.name
- }
-
- tracks.append(track_info)
- print(f"✓ {track_info['artist']} - {track_info['title']}")
143 + tracks = []
144 + removed = []
145 + for entry in existing_tracks:
146 + if not isinstance(entry, dict) or 'filename' not in entry:
147 + continue
148 + if entry['filename'] in on_disk_filenames:
149 + tracks.append(entry)
150 + else:
151 + removed.append(entry['filename'])
152
153 + new_files = sorted(
154 + (files_by_name[name] for name in on_disk_filenames if name not in existing_by_filename),
155 + key=lambda f: f.name,
156 + )
157 + new_tracks = []
158 + for audio_file in new_files:
159 + try:
160 + new_tracks.append(read_metadata(audio_file))
161 except Exception as e:
162 print(f"✗ Error reading {audio_file.name}: {e}")
- continue
163
- # Check if ALL titles start with numbers
- # If so, strip the leading numbers from all titles
- import re
- all_have_leading_numbers = all(
- re.match(r'^\d+\s*[-.]?\s*', track['title'])
- for track in tracks
- )
164 + for track in new_tracks:
165 + print(f"+ {track['artist']} - {track['title']}")
166 + for filename in removed:
167 + print(f"- {filename} (removed; no longer on disk)")
168 + if not new_tracks and not removed:
169 + print("No changes — tracks.json already matches /mix.")
170
- if all_have_leading_numbers and tracks:
- print("\nDetected track numbers in all titles. Stripping them...")
- for track in tracks:
- original_title = track['title']
- # Remove leading number pattern
- cleaned_title = re.sub(r'^\d+\s*[-.]?\s*', '', original_title)
- if cleaned_title: # Only update if something remains
- track['title'] = cleaned_title
- if cleaned_title != original_title:
- print(f" {original_title} → {cleaned_title}")
171 + tracks.extend(new_tracks)
172
173 if not tracks:
174 print("\nNo valid audio files could be processed.")
9 import socket
10 import sys
11 import os
12 +import json
13 import subprocess
14 from pathlib import Path
15
151
152 # Create server
153 Handler = http.server.SimpleHTTPRequestHandler
154 + tracks_path = SCRIPT_DIR / "mix" / "tracks.json"
155
156 # Suppress default logging and broken pipe errors
157 class QuietHandler(Handler):
170 # Browser cancelled the request (normal for media streaming/preloading)
171 pass
172
173 + def do_POST(self):
174 + # Local-only: receive a reordered tracks array and overwrite tracks.json
175 + if self.path != '/tracks':
176 + self.send_response(404)
177 + self.end_headers()
178 + return
179 + length = int(self.headers.get('Content-Length', '0'))
180 + payload = json.loads(self.rfile.read(length))
181 + tracks_path.write_text(
182 + json.dumps(payload, indent='\t', ensure_ascii=False) + '\n',
183 + encoding='utf-8',
184 + )
185 + self.send_response(204)
186 + self.end_headers()
187 +
188 try:
189 with socketserver.TCPServer(("", port), QuietHandler) as httpd:
190 local_url = f"http://localhost:{port}"