discstation 0.1.20 → 0.1.22
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 +26 -4
- package/arduino/c6/DiscStation_C6.ino +7 -1
- package/arduino/v1/DiscStation.ino +4 -1
- package/install-windows.ps1 +29 -6
- package/package.json +3 -2
- package/scripts/postinstall.mjs +82 -0
- package/src/discstation.py +409 -83
- package/src/discstation_burn.py +96 -18
- package/src/discstation_host.py +47 -16
- package/src/static/app.js +72 -0
- package/src/static/index.html +37 -3
- package/src/static/style.css +38 -0
- package/src/win/burn-audio.ps1 +18 -3
- package/src/win/burn-data.ps1 +21 -9
- package/src/win/burn-image.ps1 +17 -13
- package/src/win/disc-info.ps1 +13 -8
- package/src/win/play-audio-cd.ps1 +94 -0
package/src/discstation.py
CHANGED
|
@@ -54,17 +54,71 @@ _web_port = 8080
|
|
|
54
54
|
_web_server = None
|
|
55
55
|
_last_burn_result = None
|
|
56
56
|
_last_burn_result_time = 0
|
|
57
|
-
_last_upload_dir = None
|
|
58
57
|
_last_upload_label = None
|
|
59
58
|
_web_status = "READY"
|
|
60
59
|
_web_progress = -1
|
|
61
60
|
_web_progress_active = False
|
|
61
|
+
_web_playing = False # a play_flow is currently active (transport controls apply)
|
|
62
62
|
_operation_active = False # a burn/rip/play flow is holding the drive
|
|
63
63
|
_last_disc_info = {"disc_present": False, "capacity_bytes": 0, "capacity_gb": 0, "type": "none"}
|
|
64
64
|
_active_ser = None
|
|
65
|
+
_appliance_mode = "hardware" # "hardware" (real ESP32) or "software" (web remote only)
|
|
65
66
|
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
|
66
67
|
|
|
67
68
|
|
|
69
|
+
class VirtualSerial:
|
|
70
|
+
"""Drop-in stand-in for serial.Serial when no ESP32 is attached, so
|
|
71
|
+
station_loop and every flow function (burn/rip/play) run completely
|
|
72
|
+
unchanged - fed by lines POSTed to /remote/button instead of real
|
|
73
|
+
serial bytes. send()/safe_send() already publish every status update
|
|
74
|
+
through status_sink (-> the web SSE stream) regardless of ser, and
|
|
75
|
+
already tolerate ser=None, so only the "commands in" direction needs
|
|
76
|
+
shimming here - the exact same text protocol the ESP32 already speaks
|
|
77
|
+
(SELECT:..., PLAY_BUTTON, FF:BIG, CANCEL, POT:<n>, ...) is the contract,
|
|
78
|
+
so the on-screen remote just POSTs the same strings the ESP32 sends."""
|
|
79
|
+
|
|
80
|
+
def __init__(self):
|
|
81
|
+
self._buf = b""
|
|
82
|
+
self._lock = threading.Lock()
|
|
83
|
+
|
|
84
|
+
def push_line(self, text):
|
|
85
|
+
with self._lock:
|
|
86
|
+
self._buf += text.encode(errors="ignore").strip() + b"\n"
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def in_waiting(self):
|
|
90
|
+
with self._lock:
|
|
91
|
+
return len(self._buf)
|
|
92
|
+
|
|
93
|
+
def read(self, n=1):
|
|
94
|
+
with self._lock:
|
|
95
|
+
data, self._buf = self._buf[:n], self._buf[n:]
|
|
96
|
+
return data
|
|
97
|
+
|
|
98
|
+
def readline(self):
|
|
99
|
+
with self._lock:
|
|
100
|
+
idx = self._buf.find(b"\n")
|
|
101
|
+
if idx < 0:
|
|
102
|
+
data, self._buf = self._buf, b""
|
|
103
|
+
return data
|
|
104
|
+
line, self._buf = self._buf[:idx + 1], self._buf[idx + 1:]
|
|
105
|
+
return line
|
|
106
|
+
|
|
107
|
+
def write(self, data):
|
|
108
|
+
return len(data) if data else 0
|
|
109
|
+
|
|
110
|
+
def close(self):
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
def setDTR(self, value):
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _HardwareAttached(Exception):
|
|
118
|
+
"""Raised out of station_loop when a real ESP32 appears while running on
|
|
119
|
+
a VirtualSerial, so main() can hand control over to it."""
|
|
120
|
+
|
|
121
|
+
|
|
68
122
|
class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
69
123
|
def do_GET(self):
|
|
70
124
|
path = urllib.parse.urlsplit(self.path).path
|
|
@@ -74,11 +128,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
74
128
|
result = _last_burn_result
|
|
75
129
|
self._respond(200, _web_status or result or 'Idle')
|
|
76
130
|
elif path == '/progress':
|
|
77
|
-
self._respond(200, json.dumps(
|
|
78
|
-
"status": _web_status or "READY",
|
|
79
|
-
"progress": _web_progress,
|
|
80
|
-
"active": _web_progress_active,
|
|
81
|
-
}), "application/json")
|
|
131
|
+
self._respond(200, json.dumps(_status_snapshot()), "application/json")
|
|
82
132
|
elif path == '/disc-info':
|
|
83
133
|
self._serve_disc_info()
|
|
84
134
|
elif path == '/events':
|
|
@@ -102,6 +152,8 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
102
152
|
self._handle_url()
|
|
103
153
|
elif path == '/set-label':
|
|
104
154
|
self._handle_set_label()
|
|
155
|
+
elif path == '/remote/button':
|
|
156
|
+
self._handle_remote_button()
|
|
105
157
|
else:
|
|
106
158
|
self.send_error(404)
|
|
107
159
|
|
|
@@ -117,7 +169,6 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
117
169
|
self._respond(400, 'Missing URL')
|
|
118
170
|
|
|
119
171
|
def _handle_upload(self):
|
|
120
|
-
global _last_upload_dir
|
|
121
172
|
_set_web_progress("UPLOADING", 0)
|
|
122
173
|
files = self._parse_multipart(lambda percent: _set_web_progress("UPLOADING", percent))
|
|
123
174
|
if not files:
|
|
@@ -148,15 +199,42 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
148
199
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
149
200
|
dest.write_bytes(data)
|
|
150
201
|
total += len(data)
|
|
151
|
-
|
|
202
|
+
# wait_for_web_url() (already running on the OLED side once a burn
|
|
203
|
+
# mode is selected first - the normal flow: scan the QR code, then
|
|
204
|
+
# upload) only ever reads from _burn_url_queue, same as a pasted
|
|
205
|
+
# URL - this is the one and only signal an upload sends. (A second,
|
|
206
|
+
# separate _last_upload_dir global used to exist alongside this;
|
|
207
|
+
# removed - having two mechanisms for the same event meant one
|
|
208
|
+
# could consume its half while the other's copy sat undrained in
|
|
209
|
+
# the queue forever, waiting to wrongly satisfy a later, unrelated
|
|
210
|
+
# burn attempt.)
|
|
211
|
+
_burn_url_queue.put(str(upload_dir))
|
|
152
212
|
size_str = f"{total / 1e6:.1f}MB" if total > 1e6 else f"{total / 1e3:.0f}KB"
|
|
153
213
|
_set_web_progress("UPLOAD READY", 100)
|
|
154
214
|
self._respond(200, f'{len(files)} file(s) uploaded ({size_str}). Select BURN DATA on remote.')
|
|
155
215
|
|
|
216
|
+
def _handle_remote_button(self):
|
|
217
|
+
"""Web on-screen remote -> the exact same text-line protocol the
|
|
218
|
+
ESP32 already speaks (SELECT:BURN DATA, PLAY_BUTTON, FF:BIG, CANCEL,
|
|
219
|
+
POT:<n>, ...), fed straight into the active VirtualSerial. Only
|
|
220
|
+
works in software mode - a real ESP32 already owns the input side."""
|
|
221
|
+
length = int(self.headers.get('Content-Length', 0))
|
|
222
|
+
body = self.rfile.read(length).decode()
|
|
223
|
+
params = urllib.parse.parse_qs(body)
|
|
224
|
+
cmd = params.get('cmd', [''])[0].strip()
|
|
225
|
+
if not cmd:
|
|
226
|
+
self._respond(400, 'Missing cmd')
|
|
227
|
+
return
|
|
228
|
+
if isinstance(_active_ser, VirtualSerial):
|
|
229
|
+
_active_ser.push_line(cmd)
|
|
230
|
+
self._respond(200, 'OK')
|
|
231
|
+
else:
|
|
232
|
+
self._respond(409, 'A hardware remote is attached')
|
|
233
|
+
|
|
156
234
|
def _serve_disc_info(self):
|
|
157
235
|
if _operation_active:
|
|
158
236
|
# a burn/rip/play holds the drive — don't probe it, serve last-known.
|
|
159
|
-
self._respond(200, json.dumps({**_last_disc_info, "busy": True}), "application/json")
|
|
237
|
+
self._respond(200, json.dumps({**_last_disc_info, "busy": True, "appliance": _appliance_mode}), "application/json")
|
|
160
238
|
return
|
|
161
239
|
info = {"disc_present": False, "capacity_bytes": 0, "capacity_gb": 0, "type": "none"}
|
|
162
240
|
try:
|
|
@@ -175,6 +253,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
175
253
|
info["label"] = di.label
|
|
176
254
|
except Exception as e:
|
|
177
255
|
print(f"Disc info error: {e}")
|
|
256
|
+
info["appliance"] = _appliance_mode
|
|
178
257
|
_last_disc_info.update(info)
|
|
179
258
|
self._respond(200, json.dumps(info), "application/json")
|
|
180
259
|
|
|
@@ -225,7 +304,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
225
304
|
def _serve_sw(self):
|
|
226
305
|
sw = '''self.addEventListener('install', e => {
|
|
227
306
|
self.skipWaiting();
|
|
228
|
-
caches.open('discstation-
|
|
307
|
+
caches.open('discstation-v17').then(c => c.addAll(['/','/static/style.css?v=17','/static/app.js?v=17']));
|
|
229
308
|
});
|
|
230
309
|
self.addEventListener('activate', e => e.waitUntil(clients.claim()));
|
|
231
310
|
self.addEventListener('fetch', e => {
|
|
@@ -234,7 +313,7 @@ self.addEventListener('fetch', e => {
|
|
|
234
313
|
if (path === '/' || path.startsWith('/static/')) {
|
|
235
314
|
e.respondWith(fetch(e.request).then(r => {
|
|
236
315
|
const copy = r.clone();
|
|
237
|
-
caches.open('discstation-
|
|
316
|
+
caches.open('discstation-v17').then(c => c.put(e.request, copy));
|
|
238
317
|
return r;
|
|
239
318
|
}).catch(() => caches.match(e.request)));
|
|
240
319
|
} else {
|
|
@@ -377,6 +456,7 @@ def start_web_server(port=8080):
|
|
|
377
456
|
http_port = int(os.environ.get("DISCSTATION_HTTP_PORT", "8081"))
|
|
378
457
|
except ValueError:
|
|
379
458
|
http_port = 8081
|
|
459
|
+
plain_http_up = False
|
|
380
460
|
if http_port and http_port != port:
|
|
381
461
|
try:
|
|
382
462
|
plain = socketserver.ThreadingTCPServer(('', http_port), _WebHandler, bind_and_activate=False)
|
|
@@ -386,9 +466,15 @@ def start_web_server(port=8080):
|
|
|
386
466
|
plain.server_activate()
|
|
387
467
|
threading.Thread(target=plain.serve_forever, daemon=True).start()
|
|
388
468
|
print(f"Plain HTTP (mobile app) on http://0.0.0.0:{http_port}")
|
|
469
|
+
plain_http_up = True
|
|
389
470
|
except OSError as e:
|
|
390
471
|
print(f"Plain HTTP listener not started on {http_port}: {e}")
|
|
391
472
|
|
|
473
|
+
ip = local_ip()
|
|
474
|
+
https_up = ssl is not None and cert.exists() and key.exists()
|
|
475
|
+
shown_port, shown_protocol = (http_port, "http") if plain_http_up else (port, "https" if https_up else "http")
|
|
476
|
+
print(f"\n>>> Open {shown_protocol}://{ip}:{shown_port} in your browser or the DiscStation app <<<\n")
|
|
477
|
+
|
|
392
478
|
return server
|
|
393
479
|
|
|
394
480
|
|
|
@@ -539,7 +625,9 @@ _sse_lock = threading.Lock()
|
|
|
539
625
|
|
|
540
626
|
|
|
541
627
|
def _status_snapshot():
|
|
542
|
-
return {"status": _web_status or "READY", "progress": _web_progress,
|
|
628
|
+
return {"status": _web_status or "READY", "progress": _web_progress,
|
|
629
|
+
"active": _web_progress_active, "appliance": _appliance_mode,
|
|
630
|
+
"playing": _web_playing, "tray_open": _tray_open}
|
|
543
631
|
|
|
544
632
|
|
|
545
633
|
def _sse_publish(event):
|
|
@@ -565,11 +653,17 @@ def _set_web_progress(phase, percent=-1):
|
|
|
565
653
|
|
|
566
654
|
|
|
567
655
|
def _record_web_status(msg):
|
|
568
|
-
global _web_status, _web_progress, _web_progress_active
|
|
656
|
+
global _web_status, _web_progress, _web_progress_active, _web_playing
|
|
569
657
|
if msg.startswith("DISC:"):
|
|
570
658
|
_sse_publish({"type": "disc-changed"})
|
|
571
659
|
return
|
|
572
|
-
if msg.startswith("
|
|
660
|
+
if msg.startswith("PLAY_MODE:"):
|
|
661
|
+
_web_playing = True
|
|
662
|
+
_web_status = "PLAYING"
|
|
663
|
+
_web_progress_active = True
|
|
664
|
+
elif msg.startswith("PLAY_STATUS:") or msg.startswith("PLAY:"):
|
|
665
|
+
_web_status = msg.split(":", 1)[1].strip() or "PLAYING"
|
|
666
|
+
elif msg.startswith("STATUS:"):
|
|
573
667
|
_web_status = msg[7:].strip() or "READY"
|
|
574
668
|
_web_progress_active = True
|
|
575
669
|
elif msg.startswith("PROGRESS:"):
|
|
@@ -583,12 +677,15 @@ def _record_web_status(msg):
|
|
|
583
677
|
_web_status = msg[5:].strip() or "DONE"
|
|
584
678
|
_web_progress = 100
|
|
585
679
|
_web_progress_active = False
|
|
680
|
+
_web_playing = False
|
|
586
681
|
elif msg.startswith("ERROR:"):
|
|
587
682
|
_web_status = msg[6:].strip() or "ERROR"
|
|
588
683
|
_web_progress_active = False
|
|
684
|
+
_web_playing = False
|
|
589
685
|
elif msg.startswith("CANCELLED:"):
|
|
590
686
|
_web_status = msg[10:].strip() or "CANCELLED"
|
|
591
687
|
_web_progress_active = False
|
|
688
|
+
_web_playing = False
|
|
592
689
|
elif msg.startswith(("STANDBY:", "HOME:")):
|
|
593
690
|
# idle again (tray open, insert disc, back to the menu) — clear any
|
|
594
691
|
# lingering "Ejecting..." / progress state on the web UI.
|
|
@@ -596,6 +693,7 @@ def _record_web_status(msg):
|
|
|
596
693
|
_web_status = "READY" if text in ("", "DiscStation", "Select mode", "Starting...") else text
|
|
597
694
|
_web_progress = -1
|
|
598
695
|
_web_progress_active = False
|
|
696
|
+
_web_playing = False
|
|
599
697
|
else:
|
|
600
698
|
return
|
|
601
699
|
_sse_publish(_status_snapshot())
|
|
@@ -905,6 +1003,24 @@ def eject_disc(ser, device):
|
|
|
905
1003
|
return ok
|
|
906
1004
|
|
|
907
1005
|
|
|
1006
|
+
def close_tray(ser, device):
|
|
1007
|
+
"""Close the tray on demand (e.g. the web remote's EJECT/CLOSE toggle,
|
|
1008
|
+
clicked any time after an EJECT - unlike eject_disc's own Linux wait-
|
|
1009
|
+
loop, this isn't limited to a ~60s window). eject_device(close=True) is
|
|
1010
|
+
already cross-platform (eject -t / drutil tray close / eject.ps1
|
|
1011
|
+
-Close), so no per-OS branching is needed here."""
|
|
1012
|
+
global _tray_open
|
|
1013
|
+
safe_send(ser, "STATUS:Closing tray...")
|
|
1014
|
+
try:
|
|
1015
|
+
ok = discstation_host.eject_device(device, close=True)
|
|
1016
|
+
except Exception as e:
|
|
1017
|
+
print(f"Close tray error: {e}")
|
|
1018
|
+
ok = False
|
|
1019
|
+
_tray_open = False
|
|
1020
|
+
safe_send(ser, "STANDBY:Insert disc" if ok else "ERROR:Close failed")
|
|
1021
|
+
return ok
|
|
1022
|
+
|
|
1023
|
+
|
|
908
1024
|
HISTORY_FILE = discstation_burn.WORK / "burn_history.jsonl"
|
|
909
1025
|
|
|
910
1026
|
|
|
@@ -2519,7 +2635,7 @@ def burn_flow(ser, url):
|
|
|
2519
2635
|
job_dir.mkdir()
|
|
2520
2636
|
|
|
2521
2637
|
send(ser, "STATUS:Preflight...")
|
|
2522
|
-
info = discstation_burn.get_video_info(url)
|
|
2638
|
+
info = discstation_burn.get_video_info(url, ser)
|
|
2523
2639
|
title = info["title"]
|
|
2524
2640
|
duration = info["duration"]
|
|
2525
2641
|
duration_line, fit_line, can_fit = discstation_burn.preflight_lines(duration, disc_bytes)
|
|
@@ -2738,12 +2854,9 @@ def _copy_to_job(ser, src, dst_dir):
|
|
|
2738
2854
|
|
|
2739
2855
|
|
|
2740
2856
|
def burn_data_flow(ser):
|
|
2741
|
-
global
|
|
2857
|
+
global _last_upload_label
|
|
2742
2858
|
|
|
2743
|
-
if
|
|
2744
|
-
url = _last_upload_dir
|
|
2745
|
-
_last_upload_dir = None
|
|
2746
|
-
elif _stdin_is_tty():
|
|
2859
|
+
if _stdin_is_tty():
|
|
2747
2860
|
safe_send(ser, "STATUS:Enter URL or file path in terminal")
|
|
2748
2861
|
print("=== Enter URL or file path below, then press Enter ===")
|
|
2749
2862
|
try:
|
|
@@ -2776,7 +2889,7 @@ def burn_data_flow(ser):
|
|
|
2776
2889
|
title = local_path.name if local_path.is_dir() else local_path.stem
|
|
2777
2890
|
else:
|
|
2778
2891
|
send(ser, "STATUS:Probing source...")
|
|
2779
|
-
info = discstation_burn.get_video_info(url)
|
|
2892
|
+
info = discstation_burn.get_video_info(url, ser)
|
|
2780
2893
|
title = info["title"]
|
|
2781
2894
|
|
|
2782
2895
|
if _last_upload_label:
|
|
@@ -2936,8 +3049,12 @@ def burn_audio_flow(ser):
|
|
|
2936
3049
|
audio_files = []
|
|
2937
3050
|
audio_exts = {".wav", ".flac", ".mp3", ".aac", ".ogg", ".wma", ".m4a", ".opus"}
|
|
2938
3051
|
if src_path.is_dir():
|
|
2939
|
-
|
|
2940
|
-
|
|
3052
|
+
# rglob, not iterdir: a browser folder upload preserves the
|
|
3053
|
+
# original relative paths (e.g. "Album Name/track.flac"), so the
|
|
3054
|
+
# audio files usually sit one level below src_path, not at its
|
|
3055
|
+
# top - a plain iterdir() only ever saw the subfolder itself.
|
|
3056
|
+
for f in sorted(src_path.rglob("*")):
|
|
3057
|
+
if f.is_file() and f.suffix.lower() in audio_exts:
|
|
2941
3058
|
audio_files.append(f)
|
|
2942
3059
|
elif src_path.is_file():
|
|
2943
3060
|
audio_files = [src_path]
|
|
@@ -3233,19 +3350,151 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
|
|
|
3233
3350
|
pass
|
|
3234
3351
|
|
|
3235
3352
|
|
|
3353
|
+
def _play_audio_cd_windows(ser, device, track_titles):
|
|
3354
|
+
"""Play an audio CD via src/win/play-audio-cd.ps1 (Windows Media
|
|
3355
|
+
Player's COM control) - mpv on Windows has no libcdio, so cdda:// is
|
|
3356
|
+
unavailable there. Controlled through a small command file instead of
|
|
3357
|
+
mpv's JSON-IPC socket (a different player, a different protocol);
|
|
3358
|
+
OLED button presses translate to PAUSE/STOP/NEXT/PREV/VOL: writes."""
|
|
3359
|
+
cmd_file = Path(tempfile.gettempdir()) / "discstation_wmp_cmd.txt"
|
|
3360
|
+
cmd_file.unlink(missing_ok=True)
|
|
3361
|
+
cmd, kwargs = discstation_host.ps_cmd("play-audio-cd.ps1", device, str(cmd_file))
|
|
3362
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **kwargs)
|
|
3363
|
+
|
|
3364
|
+
lines = Queue()
|
|
3365
|
+
|
|
3366
|
+
def read_output():
|
|
3367
|
+
try:
|
|
3368
|
+
for line in proc.stdout:
|
|
3369
|
+
lines.put(line.rstrip("\r\n"))
|
|
3370
|
+
finally:
|
|
3371
|
+
lines.put(None)
|
|
3372
|
+
|
|
3373
|
+
threading.Thread(target=read_output, daemon=True).start()
|
|
3374
|
+
|
|
3375
|
+
def send_cmd(text):
|
|
3376
|
+
cmd_file.write_text(text + "\n")
|
|
3377
|
+
|
|
3378
|
+
paused = False
|
|
3379
|
+
current_track = -1
|
|
3380
|
+
send(ser, "PLAY_MODE:AUDIO_CD")
|
|
3381
|
+
send(ser, "PLAY:PLAYING")
|
|
3382
|
+
print("Playing audio CD (Windows Media Player). Short press toggles pause; long press stops.")
|
|
3383
|
+
|
|
3384
|
+
last_ping = time.time()
|
|
3385
|
+
try:
|
|
3386
|
+
while proc.poll() is None:
|
|
3387
|
+
now = time.time()
|
|
3388
|
+
if now - last_ping >= 5:
|
|
3389
|
+
last_ping = now
|
|
3390
|
+
safe_send(ser, "PING")
|
|
3391
|
+
|
|
3392
|
+
try:
|
|
3393
|
+
line = lines.get(timeout=0.1)
|
|
3394
|
+
except Empty:
|
|
3395
|
+
line = None
|
|
3396
|
+
if line:
|
|
3397
|
+
if line.startswith("TRACK:"):
|
|
3398
|
+
try:
|
|
3399
|
+
track = int(line.split(":", 1)[1])
|
|
3400
|
+
except ValueError:
|
|
3401
|
+
track = None
|
|
3402
|
+
if track is not None and track != current_track:
|
|
3403
|
+
current_track = track
|
|
3404
|
+
title = track_titles[track] if 0 <= track < len(track_titles) else ""
|
|
3405
|
+
status = f"TRACK {track + 1:02d}"
|
|
3406
|
+
if title:
|
|
3407
|
+
status += f" // {title}"
|
|
3408
|
+
send(ser, f"PLAY_STATUS:{status}")
|
|
3409
|
+
elif line == "DONE":
|
|
3410
|
+
break
|
|
3411
|
+
elif line.startswith("ERROR:"):
|
|
3412
|
+
raise RuntimeError(line[6:])
|
|
3413
|
+
|
|
3414
|
+
if ser.in_waiting:
|
|
3415
|
+
btn = ser.readline().decode(errors="ignore").strip()
|
|
3416
|
+
discstation_burn.note_serial_activity()
|
|
3417
|
+
|
|
3418
|
+
if btn == "PLAY_BUTTON":
|
|
3419
|
+
paused = not paused
|
|
3420
|
+
send_cmd("PAUSE")
|
|
3421
|
+
send(ser, "PLAY_STATUS:PAUSED" if paused else "PLAY_STATUS:PLAYING")
|
|
3422
|
+
|
|
3423
|
+
elif btn == "PLAY_STOP":
|
|
3424
|
+
send(ser, "STATUS:Stopping play")
|
|
3425
|
+
send_cmd("STOP")
|
|
3426
|
+
break
|
|
3427
|
+
|
|
3428
|
+
elif btn == "FF:BIG" or btn.startswith("FF:"):
|
|
3429
|
+
send_cmd("NEXT")
|
|
3430
|
+
send(ser, "PLAY_STATUS:Next track")
|
|
3431
|
+
|
|
3432
|
+
elif btn == "REW:BIG" or btn.startswith("REW:"):
|
|
3433
|
+
send_cmd("PREV")
|
|
3434
|
+
send(ser, "PLAY_STATUS:Prev track")
|
|
3435
|
+
|
|
3436
|
+
elif btn.startswith("POT:"):
|
|
3437
|
+
try:
|
|
3438
|
+
volume = int(btn.split(":", 1)[1])
|
|
3439
|
+
send_cmd(f"VOL:{volume}")
|
|
3440
|
+
except (ValueError, OSError):
|
|
3441
|
+
pass
|
|
3442
|
+
|
|
3443
|
+
time.sleep(0.02)
|
|
3444
|
+
finally:
|
|
3445
|
+
try:
|
|
3446
|
+
send_cmd("STOP")
|
|
3447
|
+
except OSError:
|
|
3448
|
+
pass
|
|
3449
|
+
time.sleep(0.3)
|
|
3450
|
+
if proc.poll() is None:
|
|
3451
|
+
discstation_burn.stop_process(proc)
|
|
3452
|
+
cmd_file.unlink(missing_ok=True)
|
|
3453
|
+
safe_send(ser, "DONE:Playback stopped")
|
|
3454
|
+
time.sleep(3)
|
|
3455
|
+
|
|
3456
|
+
|
|
3236
3457
|
def play_flow(ser):
|
|
3237
3458
|
device = discstation_burn.disc_device()
|
|
3238
3459
|
kind = disc_kind(device)
|
|
3239
3460
|
print(f"Disc type: {kind}")
|
|
3240
3461
|
|
|
3241
|
-
|
|
3242
|
-
|
|
3462
|
+
mpv = None
|
|
3463
|
+
if not (discstation_host.system_name() == "windows" and kind == "audio_cd"):
|
|
3464
|
+
# The Windows audio_cd path plays via Windows Media Player's own COM
|
|
3465
|
+
# control, not mpv - no reason to require mpv just for that.
|
|
3466
|
+
try:
|
|
3467
|
+
mpv = discstation_burn.tool("mpv")
|
|
3468
|
+
except FileNotFoundError:
|
|
3469
|
+
raise RuntimeError("mpv not found")
|
|
3470
|
+
|
|
3471
|
+
def play_vob_fallback():
|
|
3472
|
+
# No DVD-menu engine available (libdvdnav missing, or on Windows
|
|
3473
|
+
# where the plain mpv build never has it) - play the main title's
|
|
3474
|
+
# VOBs directly off the mounted volume instead (no menus).
|
|
3475
|
+
with mounted_disc(device) as mount_dir:
|
|
3476
|
+
video_ts = mount_dir / "VIDEO_TS"
|
|
3477
|
+
files = sorted(
|
|
3478
|
+
path for path in video_ts.glob("VTS_01_*.VOB")
|
|
3479
|
+
if re.search(r"_\d+\.VOB$", path.name, re.IGNORECASE)
|
|
3480
|
+
and not path.name.upper().endswith("_0.VOB")
|
|
3481
|
+
)
|
|
3482
|
+
if not files:
|
|
3483
|
+
raise RuntimeError("No playable DVD title found")
|
|
3484
|
+
cmd = [
|
|
3485
|
+
mpv,
|
|
3486
|
+
"--input-ipc-server=" + MPV_SOCKET,
|
|
3487
|
+
"--force-window=yes",
|
|
3488
|
+
"--idle=no",
|
|
3489
|
+
*[str(path) for path in files],
|
|
3490
|
+
]
|
|
3491
|
+
_run_mpv(ser, cmd, "Playing DVD", kind)
|
|
3243
3492
|
|
|
3244
3493
|
if kind == "dvd_video":
|
|
3245
3494
|
if discstation_host.system_name() == "darwin":
|
|
3246
3495
|
try:
|
|
3247
3496
|
cmd = [
|
|
3248
|
-
|
|
3497
|
+
mpv,
|
|
3249
3498
|
"--input-ipc-server=" + MPV_SOCKET,
|
|
3250
3499
|
"--force-window=yes",
|
|
3251
3500
|
"--idle=no",
|
|
@@ -3254,51 +3503,37 @@ def play_flow(ser):
|
|
|
3254
3503
|
]
|
|
3255
3504
|
_run_mpv(ser, cmd, "Playing DVD", kind)
|
|
3256
3505
|
except RuntimeError:
|
|
3257
|
-
# libdvdnav couldn't open the disc
|
|
3258
|
-
|
|
3259
|
-
with mounted_disc(device) as mount_dir:
|
|
3260
|
-
video_ts = mount_dir / "VIDEO_TS"
|
|
3261
|
-
files = sorted(
|
|
3262
|
-
path for path in video_ts.glob("VTS_01_*.VOB")
|
|
3263
|
-
if re.search(r"_\d+\.VOB$", path.name, re.IGNORECASE)
|
|
3264
|
-
and not path.name.upper().endswith("_0.VOB")
|
|
3265
|
-
)
|
|
3266
|
-
if not files:
|
|
3267
|
-
raise RuntimeError("No playable DVD title found")
|
|
3268
|
-
cmd = [
|
|
3269
|
-
"mpv",
|
|
3270
|
-
"--input-ipc-server=" + MPV_SOCKET,
|
|
3271
|
-
"--force-window=yes",
|
|
3272
|
-
"--idle=no",
|
|
3273
|
-
*[str(path) for path in files],
|
|
3274
|
-
]
|
|
3275
|
-
_run_mpv(ser, cmd, "Playing DVD", kind)
|
|
3506
|
+
# libdvdnav couldn't open the disc.
|
|
3507
|
+
play_vob_fallback()
|
|
3276
3508
|
else:
|
|
3509
|
+
# Windows' only reliably-fetchable mpv build (a plain .zip, no
|
|
3510
|
+
# 7z/rar tooling needed) has no libdvdnav - --dvd-device isn't
|
|
3511
|
+
# even a recognized option in it, so don't waste a doomed
|
|
3512
|
+
# attempt; go straight to the VOB fallback.
|
|
3513
|
+
play_vob_fallback()
|
|
3514
|
+
|
|
3515
|
+
elif kind == "audio_cd":
|
|
3516
|
+
_, track_titles, track_starts = audio_track_metadata(device)
|
|
3517
|
+
if discstation_host.system_name() == "windows":
|
|
3518
|
+
# mpv on Windows has no libcdio - cdda:// is unavailable there
|
|
3519
|
+
# ("disabled at compile-time"). Windows Media Player's own COM
|
|
3520
|
+
# control plays it fine via Windows' native CD-audio support.
|
|
3521
|
+
_play_audio_cd_windows(ser, device, track_titles)
|
|
3522
|
+
else:
|
|
3523
|
+
audio_device = discstation_host.audio_output_device()
|
|
3277
3524
|
cmd = [
|
|
3278
|
-
|
|
3525
|
+
mpv,
|
|
3279
3526
|
"--input-ipc-server=" + MPV_SOCKET,
|
|
3280
|
-
"--force-window=
|
|
3527
|
+
"--force-window=no",
|
|
3281
3528
|
"--idle=no",
|
|
3282
|
-
device,
|
|
3529
|
+
"--cdrom-device=" + rip_device(device),
|
|
3530
|
+
"--cdda-cdtext=yes",
|
|
3531
|
+
"cdda://",
|
|
3283
3532
|
]
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
audio_device = discstation_host.audio_output_device()
|
|
3289
|
-
cmd = [
|
|
3290
|
-
"mpv",
|
|
3291
|
-
"--input-ipc-server=" + MPV_SOCKET,
|
|
3292
|
-
"--force-window=no",
|
|
3293
|
-
"--idle=no",
|
|
3294
|
-
"--cdrom-device=" + rip_device(device),
|
|
3295
|
-
"--cdda-cdtext=yes",
|
|
3296
|
-
"cdda://",
|
|
3297
|
-
]
|
|
3298
|
-
if audio_device:
|
|
3299
|
-
cmd.insert(1, "--audio-device=" + audio_device)
|
|
3300
|
-
print(f"Audio CD output: {audio_device}")
|
|
3301
|
-
_run_mpv(ser, cmd, "Playing audio CD", kind, track_titles, track_starts)
|
|
3533
|
+
if audio_device:
|
|
3534
|
+
cmd.insert(1, "--audio-device=" + audio_device)
|
|
3535
|
+
print(f"Audio CD output: {audio_device}")
|
|
3536
|
+
_run_mpv(ser, cmd, "Playing audio CD", kind, track_titles, track_starts)
|
|
3302
3537
|
|
|
3303
3538
|
elif kind in ("vcd", "svcd", "video_data"):
|
|
3304
3539
|
with mounted_disc(device) as mount_dir:
|
|
@@ -3306,7 +3541,7 @@ def play_flow(ser):
|
|
|
3306
3541
|
if not files:
|
|
3307
3542
|
raise RuntimeError("No playable video files")
|
|
3308
3543
|
cmd = [
|
|
3309
|
-
|
|
3544
|
+
mpv,
|
|
3310
3545
|
"--input-ipc-server=" + MPV_SOCKET,
|
|
3311
3546
|
"--force-window=yes",
|
|
3312
3547
|
"--idle=no",
|
|
@@ -3395,11 +3630,13 @@ def _handbrake_json_blocks(text):
|
|
|
3395
3630
|
def handbrake_scan(device):
|
|
3396
3631
|
"""Return {'main_feature': int|None, 'titles': [{index,duration_s,chapters}]}
|
|
3397
3632
|
or None. Uses HandBrakeCLI, which does real main-feature detection."""
|
|
3398
|
-
|
|
3633
|
+
try:
|
|
3634
|
+
handbrake_cli = discstation_burn.tool("HandBrakeCLI")
|
|
3635
|
+
except FileNotFoundError:
|
|
3399
3636
|
return None
|
|
3400
3637
|
try:
|
|
3401
3638
|
r = subprocess.run(
|
|
3402
|
-
[
|
|
3639
|
+
[handbrake_cli, "--json", "--scan", "-i", device, "-t", "0"],
|
|
3403
3640
|
capture_output=True, text=True, timeout=180,
|
|
3404
3641
|
)
|
|
3405
3642
|
except (OSError, subprocess.TimeoutExpired) as e:
|
|
@@ -3431,7 +3668,7 @@ def handbrake_rip_main_feature(ser, device, out_dir, title_index):
|
|
|
3431
3668
|
send(ser, f"INFO:HandBrake title {title_index}")
|
|
3432
3669
|
send(ser, "PROGRESS:0%")
|
|
3433
3670
|
proc = subprocess.Popen(
|
|
3434
|
-
["HandBrakeCLI", "--json", "-i", device, "-o", str(dest),
|
|
3671
|
+
[discstation_burn.tool("HandBrakeCLI"), "--json", "-i", device, "-o", str(dest),
|
|
3435
3672
|
"-t", str(title_index), "-e", "x264", "-q", "20",
|
|
3436
3673
|
"--all-audio", "--all-subtitles"],
|
|
3437
3674
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
|
@@ -3479,6 +3716,50 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
|
|
|
3479
3716
|
out_dir = RIP_ROOT / time.strftime("rip_%Y%m%d_%H%M%S")
|
|
3480
3717
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
3481
3718
|
|
|
3719
|
+
if discstation_host.system_name() == "windows":
|
|
3720
|
+
# Neither dvdbackup nor HandBrakeCLI's libdvdread reliably reads this
|
|
3721
|
+
# drive on Windows (the latter hangs rather than erroring) - skip
|
|
3722
|
+
# both and just mirror the drive with robocopy, a plain recursive
|
|
3723
|
+
# file copy. Same real byte-count progress technique as the
|
|
3724
|
+
# dvdbackup path below (accurate, not an estimate - directory size
|
|
3725
|
+
# vs. known disc size).
|
|
3726
|
+
send(ser, "STATUS:Ripping disc...")
|
|
3727
|
+
send(ser, "INFO:Full VIDEO_TS copy")
|
|
3728
|
+
send(ser, "PROGRESS:0%")
|
|
3729
|
+
source = str(device).rstrip("\\/").rstrip(":") + ":\\"
|
|
3730
|
+
print(f"Ripping {source} to {out_dir}")
|
|
3731
|
+
proc = subprocess.Popen(
|
|
3732
|
+
["robocopy", source, str(out_dir), "/E", "/R:1", "/W:1"],
|
|
3733
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
|
3734
|
+
)
|
|
3735
|
+
disc_bytes = device_size_bytes(device)
|
|
3736
|
+
last_pct = -1
|
|
3737
|
+
try:
|
|
3738
|
+
for event in iter_process_events(proc, ser=ser):
|
|
3739
|
+
if event is None and disc_bytes > 0:
|
|
3740
|
+
pct = min(int(directory_size_bytes(out_dir) / disc_bytes * 100), 99)
|
|
3741
|
+
if pct > last_pct:
|
|
3742
|
+
last_pct = pct
|
|
3743
|
+
send(ser, f"PROGRESS:{pct}%")
|
|
3744
|
+
except CancelError:
|
|
3745
|
+
safe_send(ser, "CANCELLED:Rip cancelled")
|
|
3746
|
+
print("Rip cancelled by user")
|
|
3747
|
+
return
|
|
3748
|
+
except (KeyboardInterrupt, SystemExit):
|
|
3749
|
+
discstation_burn.stop_process(proc)
|
|
3750
|
+
safe_send(ser, "CANCELLED:Rip stopped")
|
|
3751
|
+
raise
|
|
3752
|
+
rc = proc.wait()
|
|
3753
|
+
if rc >= 8: # robocopy: 0-7 are success variants, only 8+ is a real failure
|
|
3754
|
+
raise RuntimeError(f"robocopy failed (exit {rc})")
|
|
3755
|
+
safe_send(ser, "PROGRESS:100%")
|
|
3756
|
+
safe_send(ser, "DONE:Rip complete!")
|
|
3757
|
+
print(f"Rip complete: {out_dir}")
|
|
3758
|
+
out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
|
|
3759
|
+
chown_to_sudo_user(out_dir)
|
|
3760
|
+
time.sleep(3)
|
|
3761
|
+
return
|
|
3762
|
+
|
|
3482
3763
|
scan = handbrake_scan(device)
|
|
3483
3764
|
if scan:
|
|
3484
3765
|
main = scan["main_feature"]
|
|
@@ -3940,7 +4221,17 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
|
|
|
3940
4221
|
if now - last_ping >= 5:
|
|
3941
4222
|
last_ping = now
|
|
3942
4223
|
safe_send(ser, "PING")
|
|
3943
|
-
|
|
4224
|
+
if isinstance(ser, VirtualSerial):
|
|
4225
|
+
# Safe point (no flow active) to check whether a real ESP32
|
|
4226
|
+
# has appeared - hand off to it instead of the web remote.
|
|
4227
|
+
try:
|
|
4228
|
+
port = discstation_host.serial_port()
|
|
4229
|
+
except Exception:
|
|
4230
|
+
port = None
|
|
4231
|
+
if port:
|
|
4232
|
+
raise _HardwareAttached(port)
|
|
4233
|
+
else:
|
|
4234
|
+
check_serial_alive(ser)
|
|
3944
4235
|
|
|
3945
4236
|
# Slow full classify as a backstop (type changes, stuck "reading...").
|
|
3946
4237
|
if (not _tray_open and _disc_poll_future is None
|
|
@@ -4002,6 +4293,24 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
|
|
|
4002
4293
|
_detect_cache.pop(device, None)
|
|
4003
4294
|
continue
|
|
4004
4295
|
|
|
4296
|
+
if line == "CONFIRM" and _tray_open:
|
|
4297
|
+
# Reached only outside eject_disc's own ~60s WAITING window (that
|
|
4298
|
+
# loop consumes its own CONFIRM internally) - the web remote's
|
|
4299
|
+
# EJECT/CLOSE toggle sends this any time later to close the tray.
|
|
4300
|
+
try:
|
|
4301
|
+
device = discstation_burn.disc_device()
|
|
4302
|
+
except FileNotFoundError:
|
|
4303
|
+
if discstation_host.system_name() == "darwin":
|
|
4304
|
+
device = None
|
|
4305
|
+
else:
|
|
4306
|
+
raise
|
|
4307
|
+
close_tray(ser, device)
|
|
4308
|
+
last_disc_line = None
|
|
4309
|
+
last_status = None
|
|
4310
|
+
last_status_check = 0.0
|
|
4311
|
+
_detect_cache.pop(device, None)
|
|
4312
|
+
continue
|
|
4313
|
+
|
|
4005
4314
|
if not line.startswith("SELECT:"):
|
|
4006
4315
|
if line.startswith("WiFi") or line.startswith("IP:") or "ip:" in line.lower():
|
|
4007
4316
|
print(f"ESP32: {line}")
|
|
@@ -4075,7 +4384,10 @@ def check_pidfile():
|
|
|
4075
4384
|
if alive:
|
|
4076
4385
|
print(f"Already running (PID {old_pid}), exiting")
|
|
4077
4386
|
sys.exit(0)
|
|
4078
|
-
except (OSError, IOError):
|
|
4387
|
+
except (OSError, IOError, SystemError):
|
|
4388
|
+
# os.kill(pid, 0) on Windows raises SystemError (not OSError)
|
|
4389
|
+
# for some stale/reused PIDs ("WinError 87: The parameter is
|
|
4390
|
+
# incorrect") instead of just saying the process is gone.
|
|
4079
4391
|
pass
|
|
4080
4392
|
except (ValueError, OSError):
|
|
4081
4393
|
pass
|
|
@@ -4098,7 +4410,7 @@ def parse_args():
|
|
|
4098
4410
|
|
|
4099
4411
|
|
|
4100
4412
|
def main():
|
|
4101
|
-
global _active_ser, _line_buf
|
|
4413
|
+
global _active_ser, _line_buf, _appliance_mode
|
|
4102
4414
|
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
4103
4415
|
check_pidfile()
|
|
4104
4416
|
|
|
@@ -4122,18 +4434,32 @@ def main():
|
|
|
4122
4434
|
try:
|
|
4123
4435
|
_line_buf = b""
|
|
4124
4436
|
port = discstation_host.serial_port()
|
|
4125
|
-
if
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4437
|
+
if port:
|
|
4438
|
+
print(f"Using ESP32 serial port: {port}")
|
|
4439
|
+
ser = serial.Serial(port, discstation_burn.BAUD, timeout=1, write_timeout=1)
|
|
4440
|
+
if discstation_host.system_name() == "linux":
|
|
4441
|
+
ser.setDTR(False)
|
|
4442
|
+
time.sleep(0.1)
|
|
4443
|
+
ser.setDTR(True)
|
|
4444
|
+
time.sleep(2)
|
|
4445
|
+
discstation_burn.reset_serial_state()
|
|
4446
|
+
_appliance_mode = "hardware"
|
|
4447
|
+
else:
|
|
4448
|
+
# No ESP32 found - run fully useful off the on-screen web
|
|
4449
|
+
# remote instead of retrying forever (station_loop already
|
|
4450
|
+
# publishes status via status_sink regardless of ser).
|
|
4451
|
+
print("No ESP32 found - running in software-only mode (web remote).")
|
|
4452
|
+
ser = VirtualSerial()
|
|
4453
|
+
_appliance_mode = "software"
|
|
4135
4454
|
_active_ser = ser
|
|
4455
|
+
# Nothing else publishes an SSE update purely for an appliance-
|
|
4456
|
+
# mode flip (STATUS/PROGRESS/etc. events do, this doesn't) - push
|
|
4457
|
+
# one now so a connected web remote learns about a newly-attached
|
|
4458
|
+
# ESP32 immediately instead of only on its next reload.
|
|
4459
|
+
_sse_publish(_status_snapshot())
|
|
4136
4460
|
station_loop(ser, args.url, args.artist, args.album)
|
|
4461
|
+
except _HardwareAttached:
|
|
4462
|
+
print("ESP32 detected - handing off from the web remote to hardware.")
|
|
4137
4463
|
except (serial.SerialException, OSError, termios.error) as e:
|
|
4138
4464
|
print(f"Disconnected ({e}), reconnecting in 3s...")
|
|
4139
4465
|
time.sleep(3)
|