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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discstation",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "DiscStation optical-media appliance host — one cross-OS installer",
5
5
  "bin": {
6
6
  "discstation-setup": "scripts/setup.mjs",
@@ -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-v6').then(c => c.addAll(['/','/static/style.css?v=6','/static/app.js?v=6']));
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-v6').then(c => c.put(e.request, copy));
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(caches.match(e.request).then(r => r || fetch(e.request)));
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
@@ -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)
@@ -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
- setInterval(pollStatus, 2000);
257
+ startEventStream();
258
258
 
259
- if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=4").catch(() => {});
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");
@@ -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=5").catch(() => {});</script>
109
- <script src="/static/app.js?v=5" defer></script>
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>