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 +8 -1
- package/package.json +3 -2
- package/scripts/open.mjs +38 -0
- package/scripts/setup.mjs +12 -0
- package/src/discstation.py +75 -8
- package/src/discstation_burn.py +15 -0
- package/src/static/app.js +27 -2
- package/src/static/index.html +2 -2
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.
|
|
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"
|
package/scripts/open.mjs
ADDED
|
@@ -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
|
|
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,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
|
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/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>
|