discstation 0.1.11 → 0.1.13

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/README.md CHANGED
@@ -47,9 +47,16 @@ playback, ripping, phone uploads, and ESP32 remote control.
47
47
 
48
48
  ```bash
49
49
  npm install -g discstation
50
- discstation-setup # dispatches to the installer for your OS
50
+ discstation-setup # dispatches to the installer for your OS, then opens the UI
51
51
  ```
52
52
 
53
+ The host runs as a background service; the UI is the built-in web app at
54
+ `http://localhost:8081`. `discstation-setup` opens it when it finishes, and the
55
+ `discstation` command re-opens it any time. It's a PWA — use your browser's
56
+ **Install DiscStation** (or the in-page **INSTALL APP** button on Chromium) to
57
+ get a standalone app window with a dock/taskbar icon on macOS, Linux, and
58
+ Windows.
59
+
53
60
  `discstation-setup --help` lists the forwarded env vars. From a git clone,
54
61
  `npm run setup` does the same thing. Support by OS:
55
62
 
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "discstation",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "DiscStation optical-media appliance host — one cross-OS installer",
5
5
  "bin": {
6
- "discstation-setup": "scripts/setup.mjs"
6
+ "discstation-setup": "scripts/setup.mjs",
7
+ "discstation": "scripts/open.mjs"
7
8
  },
8
9
  "scripts": {
9
10
  "setup": "node scripts/setup.mjs"
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ // `discstation` — open the local web UI in the default browser.
3
+ // The UI is a PWA: once open, use the browser's "Install DiscStation" (or the
4
+ // in-page INSTALL APP button on Chromium) to get a standalone app window.
5
+
6
+ import { spawn } from 'node:child_process';
7
+ import { get } from 'node:http';
8
+
9
+ const port = process.env.DISCSTATION_HTTP_PORT || '8081';
10
+ const url = process.argv[2] || `http://localhost:${port}/`;
11
+
12
+ function open(target) {
13
+ const [cmd, args] =
14
+ process.platform === 'darwin' ? ['open', [target]]
15
+ : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', target]]
16
+ : ['xdg-open', [target]];
17
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
18
+ child.on('error', () => {
19
+ console.log(`Open this in your browser:\n ${target}`);
20
+ });
21
+ child.unref();
22
+ }
23
+
24
+ // Best-effort liveness check so a stopped host gives a clear hint.
25
+ const probe = get(url, { timeout: 1500 }, (res) => {
26
+ res.resume();
27
+ open(url);
28
+ });
29
+ probe.on('timeout', () => probe.destroy());
30
+ probe.on('error', () => {
31
+ console.log(
32
+ `DiscStation host isn't answering on ${url}\n` +
33
+ `Start it with "discstation-setup" (first run) or check the service:\n` +
34
+ ` linux: systemctl --user status discstation.service\n` +
35
+ ` macOS: launchctl print gui/$(id -u)/com.discstation.agent`,
36
+ );
37
+ open(url);
38
+ });
package/scripts/setup.mjs CHANGED
@@ -28,6 +28,9 @@ Linux script uses sudo for apt + group membership.
28
28
  Env vars (forwarded): DISCSTATION_APP_DIR, DISCSTATION_VENV_DIR,
29
29
  DISCSTATION_CONFIG_DIR, DISCSTATION_HTTP_PORT, DISC_DEVICE, DISC_PORT.
30
30
 
31
+ On success it opens the web UI (a PWA — install it from the browser for an app
32
+ window). Re-open it any time with the "discstation" command.
33
+
31
34
  Any extra arguments are passed straight to the platform script.
32
35
  `
33
36
  );
@@ -40,6 +43,15 @@ function run(cmd, cmdArgs) {
40
43
  console.error(`\n${cmd}: ${r.error.message}`);
41
44
  process.exit(1);
42
45
  }
46
+ if ((r.status ?? 1) === 0) {
47
+ const port = process.env.DISCSTATION_HTTP_PORT || '8081';
48
+ console.log(
49
+ `\nDiscStation is installed and running.\n` +
50
+ ` Web UI: http://localhost:${port}/ (run "discstation" to open it any time)\n` +
51
+ ` Install it as an app from the browser menu, or the in-page INSTALL APP button.\n`,
52
+ );
53
+ spawnSync(process.execPath, [join(ROOT, 'scripts', 'open.mjs')], { stdio: 'ignore' });
54
+ }
43
55
  process.exit(r.status ?? 1);
44
56
  }
45
57
 
@@ -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,23 @@ 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
+ else:
575
+ return
576
+ _sse_publish(_status_snapshot())
512
577
 
513
578
 
514
579
  def send(ser, msg):
515
- _record_web_status(msg)
516
580
  discstation_burn.send(ser, msg)
517
581
 
518
582
 
519
583
  def safe_send(ser, msg):
520
- _record_web_status(msg)
521
584
  discstation_burn.safe_send(ser, msg)
522
585
 
523
586
 
587
+ # Route every serial line the burn/rip pipeline emits into the web/SSE status.
588
+ discstation_burn.status_sink = _record_web_status
589
+
590
+
524
591
  def run_as_desktop_user(cmd):
525
592
  if os.name != "posix" or pwd is None:
526
593
  return cmd
@@ -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/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>