discstation 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/discstation.py +88 -10
- package/src/discstation_burn.py +15 -0
- package/src/discstation_host.py +5 -0
- package/src/static/app.js +27 -2
- package/src/static/index.html +2 -2
package/package.json
CHANGED
package/src/discstation.py
CHANGED
|
@@ -23,7 +23,7 @@ import tempfile
|
|
|
23
23
|
import time
|
|
24
24
|
import datetime
|
|
25
25
|
from pathlib import Path
|
|
26
|
-
from queue import Queue, Empty
|
|
26
|
+
from queue import Queue, Empty, Full
|
|
27
27
|
import http.server
|
|
28
28
|
import socketserver
|
|
29
29
|
try:
|
|
@@ -73,6 +73,8 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
73
73
|
}), "application/json")
|
|
74
74
|
elif path == '/disc-info':
|
|
75
75
|
self._serve_disc_info()
|
|
76
|
+
elif path == '/events':
|
|
77
|
+
self._serve_sse()
|
|
76
78
|
elif path == '/sw.js':
|
|
77
79
|
self._serve_sw()
|
|
78
80
|
elif path == '/manifest.json':
|
|
@@ -163,6 +165,38 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
163
165
|
print(f"Disc info error: {e}")
|
|
164
166
|
self._respond(200, json.dumps(info), "application/json")
|
|
165
167
|
|
|
168
|
+
def _serve_sse(self):
|
|
169
|
+
"""Server-Sent Events stream: pushes status/progress snapshots and a
|
|
170
|
+
'disc-changed' nudge the moment anything changes. Keepalive comment every
|
|
171
|
+
15s so proxies don't drop the idle connection."""
|
|
172
|
+
q = Queue(maxsize=64)
|
|
173
|
+
with _sse_lock:
|
|
174
|
+
if len(_sse_subs) >= 32:
|
|
175
|
+
self.send_error(503)
|
|
176
|
+
return
|
|
177
|
+
_sse_subs.add(q)
|
|
178
|
+
try:
|
|
179
|
+
self.send_response(200)
|
|
180
|
+
self.send_header('Content-Type', 'text/event-stream')
|
|
181
|
+
self.send_header('Cache-Control', 'no-store')
|
|
182
|
+
self.send_header('X-Accel-Buffering', 'no')
|
|
183
|
+
self.end_headers()
|
|
184
|
+
self.wfile.write(b"retry: 3000\n\n")
|
|
185
|
+
self.wfile.write(("data: " + json.dumps(_status_snapshot()) + "\n\n").encode())
|
|
186
|
+
self.wfile.flush()
|
|
187
|
+
while True:
|
|
188
|
+
try:
|
|
189
|
+
payload = q.get(timeout=15)
|
|
190
|
+
self.wfile.write(("data: " + payload + "\n\n").encode())
|
|
191
|
+
except Empty:
|
|
192
|
+
self.wfile.write(b": ping\n\n")
|
|
193
|
+
self.wfile.flush()
|
|
194
|
+
except (BrokenPipeError, ConnectionResetError, OSError, ValueError):
|
|
195
|
+
pass
|
|
196
|
+
finally:
|
|
197
|
+
with _sse_lock:
|
|
198
|
+
_sse_subs.discard(q)
|
|
199
|
+
|
|
166
200
|
def _handle_set_label(self):
|
|
167
201
|
global _last_upload_label
|
|
168
202
|
length = int(self.headers.get('Content-Length', 0))
|
|
@@ -178,19 +212,20 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
178
212
|
def _serve_sw(self):
|
|
179
213
|
sw = '''self.addEventListener('install', e => {
|
|
180
214
|
self.skipWaiting();
|
|
181
|
-
caches.open('discstation-
|
|
215
|
+
caches.open('discstation-v7').then(c => c.addAll(['/','/static/style.css?v=7','/static/app.js?v=7']));
|
|
182
216
|
});
|
|
183
217
|
self.addEventListener('activate', e => e.waitUntil(clients.claim()));
|
|
184
218
|
self.addEventListener('fetch', e => {
|
|
185
219
|
const path = new URL(e.request.url).pathname;
|
|
220
|
+
if (path === '/events') return; // never intercept the SSE stream
|
|
186
221
|
if (path === '/' || path.startsWith('/static/')) {
|
|
187
222
|
e.respondWith(fetch(e.request).then(r => {
|
|
188
223
|
const copy = r.clone();
|
|
189
|
-
caches.open('discstation-
|
|
224
|
+
caches.open('discstation-v7').then(c => c.put(e.request, copy));
|
|
190
225
|
return r;
|
|
191
226
|
}).catch(() => caches.match(e.request)));
|
|
192
227
|
} else {
|
|
193
|
-
e.respondWith(
|
|
228
|
+
e.respondWith(fetch(e.request).catch(() => caches.match(e.request)));
|
|
194
229
|
}
|
|
195
230
|
});'''
|
|
196
231
|
self._respond(200, sw, 'application/javascript')
|
|
@@ -300,6 +335,7 @@ def start_web_server(port=8080):
|
|
|
300
335
|
_web_port = port
|
|
301
336
|
server = socketserver.ThreadingTCPServer(('', port), _WebHandler, bind_and_activate=False)
|
|
302
337
|
server.allow_reuse_address = True
|
|
338
|
+
server.daemon_threads = True # don't let an open SSE connection wedge shutdown
|
|
303
339
|
server.server_bind()
|
|
304
340
|
server.server_activate()
|
|
305
341
|
|
|
@@ -326,8 +362,11 @@ def start_web_server(port=8080):
|
|
|
326
362
|
http_port = 8081
|
|
327
363
|
if http_port and http_port != port:
|
|
328
364
|
try:
|
|
329
|
-
plain = socketserver.ThreadingTCPServer(('', http_port), _WebHandler)
|
|
330
|
-
plain.allow_reuse_address = True
|
|
365
|
+
plain = socketserver.ThreadingTCPServer(('', http_port), _WebHandler, bind_and_activate=False)
|
|
366
|
+
plain.allow_reuse_address = True # must be set before bind, or a restart hits TIME_WAIT
|
|
367
|
+
plain.daemon_threads = True
|
|
368
|
+
plain.server_bind()
|
|
369
|
+
plain.server_activate()
|
|
331
370
|
threading.Thread(target=plain.serve_forever, daemon=True).start()
|
|
332
371
|
print(f"Plain HTTP (mobile app) on http://0.0.0.0:{http_port}")
|
|
333
372
|
except OSError as e:
|
|
@@ -476,6 +515,25 @@ def ensure_text(value):
|
|
|
476
515
|
return str(value)
|
|
477
516
|
|
|
478
517
|
|
|
518
|
+
# --- Server-Sent Events: push status/progress to browsers the instant it changes
|
|
519
|
+
_sse_subs = set() # of Queue
|
|
520
|
+
_sse_lock = threading.Lock()
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _status_snapshot():
|
|
524
|
+
return {"status": _web_status or "READY", "progress": _web_progress, "active": _web_progress_active}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _sse_publish(event):
|
|
528
|
+
payload = json.dumps(event)
|
|
529
|
+
with _sse_lock:
|
|
530
|
+
for q in list(_sse_subs):
|
|
531
|
+
try:
|
|
532
|
+
q.put_nowait(payload)
|
|
533
|
+
except Full:
|
|
534
|
+
_sse_subs.discard(q)
|
|
535
|
+
|
|
536
|
+
|
|
479
537
|
def _set_web_progress(phase, percent=-1):
|
|
480
538
|
global _web_status, _web_progress, _web_progress_active
|
|
481
539
|
_web_status = phase
|
|
@@ -485,10 +543,14 @@ def _set_web_progress(phase, percent=-1):
|
|
|
485
543
|
discstation_burn.safe_send(_active_ser, f"STATUS:{phase}")
|
|
486
544
|
if _web_progress >= 0:
|
|
487
545
|
discstation_burn.safe_send(_active_ser, f"PROGRESS:{_web_progress}%")
|
|
546
|
+
_sse_publish(_status_snapshot())
|
|
488
547
|
|
|
489
548
|
|
|
490
549
|
def _record_web_status(msg):
|
|
491
550
|
global _web_status, _web_progress, _web_progress_active
|
|
551
|
+
if msg.startswith("DISC:"):
|
|
552
|
+
_sse_publish({"type": "disc-changed"})
|
|
553
|
+
return
|
|
492
554
|
if msg.startswith("STATUS:"):
|
|
493
555
|
_web_status = msg[7:].strip() or "READY"
|
|
494
556
|
_web_progress_active = True
|
|
@@ -509,18 +571,30 @@ def _record_web_status(msg):
|
|
|
509
571
|
elif msg.startswith("CANCELLED:"):
|
|
510
572
|
_web_status = msg[10:].strip() or "CANCELLED"
|
|
511
573
|
_web_progress_active = False
|
|
574
|
+
elif msg.startswith(("STANDBY:", "HOME:")):
|
|
575
|
+
# idle again (tray open, insert disc, back to the menu) — clear any
|
|
576
|
+
# lingering "Ejecting..." / progress state on the web UI.
|
|
577
|
+
text = msg.split(":", 1)[1].strip()
|
|
578
|
+
_web_status = "READY" if text in ("", "DiscStation", "Select mode", "Starting...") else text
|
|
579
|
+
_web_progress = -1
|
|
580
|
+
_web_progress_active = False
|
|
581
|
+
else:
|
|
582
|
+
return
|
|
583
|
+
_sse_publish(_status_snapshot())
|
|
512
584
|
|
|
513
585
|
|
|
514
586
|
def send(ser, msg):
|
|
515
|
-
_record_web_status(msg)
|
|
516
587
|
discstation_burn.send(ser, msg)
|
|
517
588
|
|
|
518
589
|
|
|
519
590
|
def safe_send(ser, msg):
|
|
520
|
-
_record_web_status(msg)
|
|
521
591
|
discstation_burn.safe_send(ser, msg)
|
|
522
592
|
|
|
523
593
|
|
|
594
|
+
# Route every serial line the burn/rip pipeline emits into the web/SSE status.
|
|
595
|
+
discstation_burn.status_sink = _record_web_status
|
|
596
|
+
|
|
597
|
+
|
|
524
598
|
def run_as_desktop_user(cmd):
|
|
525
599
|
if os.name != "posix" or pwd is None:
|
|
526
600
|
return cmd
|
|
@@ -3762,9 +3836,13 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
|
|
|
3762
3836
|
apply_disc_line("Disc: reading...")
|
|
3763
3837
|
elif st == "disc":
|
|
3764
3838
|
_tray_open = False
|
|
3839
|
+
transient_words = ["none", "checking", "reading", "tray open"]
|
|
3840
|
+
if discstation_host.system_name() == "darwin":
|
|
3841
|
+
# macOS slot drives take a few seconds to mount; "unknown"
|
|
3842
|
+
# is a not-ready read, not a settled answer — keep re-polling.
|
|
3843
|
+
transient_words.append("unknown")
|
|
3765
3844
|
have_line = last_disc_line and not any(
|
|
3766
|
-
w in last_disc_line.lower()
|
|
3767
|
-
for w in ("none", "checking", "reading", "tray open"))
|
|
3845
|
+
w in last_disc_line.lower() for w in transient_words)
|
|
3768
3846
|
if not have_line and _disc_poll_future is None:
|
|
3769
3847
|
_disc_poll_start = now
|
|
3770
3848
|
last_disc_poll = now
|
package/src/discstation_burn.py
CHANGED
|
@@ -513,8 +513,18 @@ def detect_disc_type(device):
|
|
|
513
513
|
"capacity": capacity,
|
|
514
514
|
}
|
|
515
515
|
|
|
516
|
+
# discstation.py sets this to _record_web_status so every serial line the burn
|
|
517
|
+
# pipeline emits also updates the web/SSE status in real time.
|
|
518
|
+
status_sink = None
|
|
519
|
+
|
|
520
|
+
|
|
516
521
|
def send(ser, msg):
|
|
517
522
|
global _serial_write_failed
|
|
523
|
+
if status_sink is not None:
|
|
524
|
+
try:
|
|
525
|
+
status_sink(msg)
|
|
526
|
+
except Exception:
|
|
527
|
+
pass
|
|
518
528
|
if not ser:
|
|
519
529
|
return False
|
|
520
530
|
try:
|
|
@@ -532,6 +542,11 @@ def send(ser, msg):
|
|
|
532
542
|
|
|
533
543
|
def safe_send(ser, msg):
|
|
534
544
|
if not ser:
|
|
545
|
+
if status_sink is not None:
|
|
546
|
+
try:
|
|
547
|
+
status_sink(msg)
|
|
548
|
+
except Exception:
|
|
549
|
+
pass
|
|
535
550
|
return False
|
|
536
551
|
try:
|
|
537
552
|
return send(ser, msg)
|
package/src/discstation_host.py
CHANGED
|
@@ -424,6 +424,7 @@ def eject_device(device, close=False):
|
|
|
424
424
|
command.append("-t")
|
|
425
425
|
command.append(device)
|
|
426
426
|
elif system == "darwin":
|
|
427
|
+
global _last_disc_device
|
|
427
428
|
# `drutil tray open` is a no-op on slot-load drives; `drutil eject`
|
|
428
429
|
# works on both. `diskutil eject` is the fallback for a mounted disc.
|
|
429
430
|
if close:
|
|
@@ -435,6 +436,10 @@ def eject_device(device, close=False):
|
|
|
435
436
|
for command in commands:
|
|
436
437
|
try:
|
|
437
438
|
if subprocess.run(command, capture_output=True, text=True, timeout=10).returncode == 0:
|
|
439
|
+
if not close:
|
|
440
|
+
# the /dev/diskN node is gone now — don't let disc_device()
|
|
441
|
+
# hand back this stale node when the next disc goes in.
|
|
442
|
+
_last_disc_device = None
|
|
438
443
|
return True
|
|
439
444
|
except (OSError, subprocess.TimeoutExpired):
|
|
440
445
|
pass
|
package/src/static/app.js
CHANGED
|
@@ -254,9 +254,34 @@
|
|
|
254
254
|
setupTabs();
|
|
255
255
|
loadDiscInfo();
|
|
256
256
|
pollStatus();
|
|
257
|
-
|
|
257
|
+
startEventStream();
|
|
258
258
|
|
|
259
|
-
|
|
259
|
+
function startEventStream() {
|
|
260
|
+
if (typeof EventSource === "undefined") { setInterval(pollStatus, 2000); return; }
|
|
261
|
+
let es;
|
|
262
|
+
try { es = new EventSource("/events"); }
|
|
263
|
+
catch (_) { setInterval(pollStatus, 2000); return; }
|
|
264
|
+
es.addEventListener("message", (ev) => {
|
|
265
|
+
let d;
|
|
266
|
+
try { d = JSON.parse(ev.data); } catch (_) { return; }
|
|
267
|
+
if (d.type === "disc-changed") { loadDiscInfo(); return; }
|
|
268
|
+
setConnection(true);
|
|
269
|
+
setLiveStatus(d.status);
|
|
270
|
+
setProgress(d.status, Number(d.progress), d.active);
|
|
271
|
+
});
|
|
272
|
+
es.addEventListener("open", () => { setConnection(true); loadDiscInfo(); });
|
|
273
|
+
es.addEventListener("error", () => {
|
|
274
|
+
// EventSource reconnects on its own; reflect the gap meanwhile.
|
|
275
|
+
setConnection(false);
|
|
276
|
+
setLiveStatus("OFFLINE");
|
|
277
|
+
setProgress("OFFLINE", -1, false);
|
|
278
|
+
});
|
|
279
|
+
// Backstops: catch a zombie SSE connection, and refresh disc state slowly.
|
|
280
|
+
setInterval(() => { if (!es || es.readyState !== 1) pollStatus(); }, 8000);
|
|
281
|
+
setInterval(loadDiscInfo, 15000);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=7").catch(() => {});
|
|
260
285
|
window.addEventListener("beforeinstallprompt", (event) => {
|
|
261
286
|
event.preventDefault();
|
|
262
287
|
const button = document.createElement("button");
|
package/src/static/index.html
CHANGED
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
</footer>
|
|
106
106
|
</div>
|
|
107
107
|
<div id="install-slot"></div>
|
|
108
|
-
<script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=
|
|
109
|
-
<script src="/static/app.js?v=
|
|
108
|
+
<script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=7").catch(() => {});</script>
|
|
109
|
+
<script src="/static/app.js?v=7" defer></script>
|
|
110
110
|
</body>
|
|
111
111
|
</html>
|