discstation 0.1.18 → 0.1.21
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 +7 -5
- package/arduino/c6/DiscStation_C6.ino +7 -1
- package/arduino/v1/DiscStation.ino +4 -1
- package/docs/PLATFORM_SUPPORT.md +24 -5
- package/install-windows.ps1 +162 -18
- package/package.json +2 -1
- package/scripts/setup.mjs +1 -1
- package/src/discstation.py +522 -120
- package/src/discstation_burn.py +167 -8
- package/src/discstation_host.py +122 -3
- package/src/static/app.js +72 -0
- package/src/static/index.html +37 -3
- package/src/static/style.css +38 -0
- package/src/win/_json.ps1 +29 -0
- package/src/win/audio-toc.ps1 +43 -0
- package/src/win/burn-audio.ps1 +87 -0
- package/src/win/burn-data.ps1 +83 -0
- package/src/win/burn-image.ps1 +68 -0
- package/src/win/disc-info.ps1 +81 -0
- package/src/win/eject.ps1 +30 -0
- package/src/win/play-audio-cd.ps1 +94 -0
package/src/discstation.py
CHANGED
|
@@ -4,7 +4,10 @@ import atexit
|
|
|
4
4
|
import collections
|
|
5
5
|
import concurrent.futures
|
|
6
6
|
import errno
|
|
7
|
-
|
|
7
|
+
try:
|
|
8
|
+
import fcntl # POSIX-only; used only in drive_status()'s Linux branch
|
|
9
|
+
except ImportError:
|
|
10
|
+
fcntl = None
|
|
8
11
|
import json
|
|
9
12
|
import mimetypes
|
|
10
13
|
import os
|
|
@@ -16,7 +19,10 @@ except ImportError:
|
|
|
16
19
|
import re
|
|
17
20
|
import shutil
|
|
18
21
|
import socket
|
|
19
|
-
|
|
22
|
+
try:
|
|
23
|
+
import ssl # optional: HTTPS on :8080. The plain-HTTP :8081 listener works without it.
|
|
24
|
+
except ImportError:
|
|
25
|
+
ssl = None
|
|
20
26
|
import subprocess
|
|
21
27
|
import sys
|
|
22
28
|
import tempfile
|
|
@@ -48,17 +54,71 @@ _web_port = 8080
|
|
|
48
54
|
_web_server = None
|
|
49
55
|
_last_burn_result = None
|
|
50
56
|
_last_burn_result_time = 0
|
|
51
|
-
_last_upload_dir = None
|
|
52
57
|
_last_upload_label = None
|
|
53
58
|
_web_status = "READY"
|
|
54
59
|
_web_progress = -1
|
|
55
60
|
_web_progress_active = False
|
|
61
|
+
_web_playing = False # a play_flow is currently active (transport controls apply)
|
|
56
62
|
_operation_active = False # a burn/rip/play flow is holding the drive
|
|
57
63
|
_last_disc_info = {"disc_present": False, "capacity_bytes": 0, "capacity_gb": 0, "type": "none"}
|
|
58
64
|
_active_ser = None
|
|
65
|
+
_appliance_mode = "hardware" # "hardware" (real ESP32) or "software" (web remote only)
|
|
59
66
|
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
|
60
67
|
|
|
61
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
|
+
|
|
62
122
|
class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
63
123
|
def do_GET(self):
|
|
64
124
|
path = urllib.parse.urlsplit(self.path).path
|
|
@@ -68,11 +128,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
68
128
|
result = _last_burn_result
|
|
69
129
|
self._respond(200, _web_status or result or 'Idle')
|
|
70
130
|
elif path == '/progress':
|
|
71
|
-
self._respond(200, json.dumps(
|
|
72
|
-
"status": _web_status or "READY",
|
|
73
|
-
"progress": _web_progress,
|
|
74
|
-
"active": _web_progress_active,
|
|
75
|
-
}), "application/json")
|
|
131
|
+
self._respond(200, json.dumps(_status_snapshot()), "application/json")
|
|
76
132
|
elif path == '/disc-info':
|
|
77
133
|
self._serve_disc_info()
|
|
78
134
|
elif path == '/events':
|
|
@@ -96,6 +152,8 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
96
152
|
self._handle_url()
|
|
97
153
|
elif path == '/set-label':
|
|
98
154
|
self._handle_set_label()
|
|
155
|
+
elif path == '/remote/button':
|
|
156
|
+
self._handle_remote_button()
|
|
99
157
|
else:
|
|
100
158
|
self.send_error(404)
|
|
101
159
|
|
|
@@ -111,7 +169,6 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
111
169
|
self._respond(400, 'Missing URL')
|
|
112
170
|
|
|
113
171
|
def _handle_upload(self):
|
|
114
|
-
global _last_upload_dir
|
|
115
172
|
_set_web_progress("UPLOADING", 0)
|
|
116
173
|
files = self._parse_multipart(lambda percent: _set_web_progress("UPLOADING", percent))
|
|
117
174
|
if not files:
|
|
@@ -142,15 +199,42 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
142
199
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
143
200
|
dest.write_bytes(data)
|
|
144
201
|
total += len(data)
|
|
145
|
-
|
|
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))
|
|
146
212
|
size_str = f"{total / 1e6:.1f}MB" if total > 1e6 else f"{total / 1e3:.0f}KB"
|
|
147
213
|
_set_web_progress("UPLOAD READY", 100)
|
|
148
214
|
self._respond(200, f'{len(files)} file(s) uploaded ({size_str}). Select BURN DATA on remote.')
|
|
149
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
|
+
|
|
150
234
|
def _serve_disc_info(self):
|
|
151
235
|
if _operation_active:
|
|
152
236
|
# a burn/rip/play holds the drive — don't probe it, serve last-known.
|
|
153
|
-
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")
|
|
154
238
|
return
|
|
155
239
|
info = {"disc_present": False, "capacity_bytes": 0, "capacity_gb": 0, "type": "none"}
|
|
156
240
|
try:
|
|
@@ -169,6 +253,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
169
253
|
info["label"] = di.label
|
|
170
254
|
except Exception as e:
|
|
171
255
|
print(f"Disc info error: {e}")
|
|
256
|
+
info["appliance"] = _appliance_mode
|
|
172
257
|
_last_disc_info.update(info)
|
|
173
258
|
self._respond(200, json.dumps(info), "application/json")
|
|
174
259
|
|
|
@@ -219,7 +304,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
219
304
|
def _serve_sw(self):
|
|
220
305
|
sw = '''self.addEventListener('install', e => {
|
|
221
306
|
self.skipWaiting();
|
|
222
|
-
caches.open('discstation-
|
|
307
|
+
caches.open('discstation-v17').then(c => c.addAll(['/','/static/style.css?v=17','/static/app.js?v=17']));
|
|
223
308
|
});
|
|
224
309
|
self.addEventListener('activate', e => e.waitUntil(clients.claim()));
|
|
225
310
|
self.addEventListener('fetch', e => {
|
|
@@ -228,7 +313,7 @@ self.addEventListener('fetch', e => {
|
|
|
228
313
|
if (path === '/' || path.startsWith('/static/')) {
|
|
229
314
|
e.respondWith(fetch(e.request).then(r => {
|
|
230
315
|
const copy = r.clone();
|
|
231
|
-
caches.open('discstation-
|
|
316
|
+
caches.open('discstation-v17').then(c => c.put(e.request, copy));
|
|
232
317
|
return r;
|
|
233
318
|
}).catch(() => caches.match(e.request)));
|
|
234
319
|
} else {
|
|
@@ -349,13 +434,17 @@ def start_web_server(port=8080):
|
|
|
349
434
|
cert_dir = discstation_host.config_dir()
|
|
350
435
|
cert = cert_dir / 'server.crt'
|
|
351
436
|
key = cert_dir / 'server.key'
|
|
352
|
-
if cert.exists() and key.exists():
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
437
|
+
if ssl is not None and cert.exists() and key.exists():
|
|
438
|
+
try:
|
|
439
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
440
|
+
ctx.load_cert_chain(str(cert), str(key))
|
|
441
|
+
server.socket = ctx.wrap_socket(server.socket, server_side=True)
|
|
442
|
+
print(f"Web interface on https://0.0.0.0:{port}")
|
|
443
|
+
except (ssl.SSLError, OSError) as e:
|
|
444
|
+
print(f"TLS disabled ({e}); serving plain HTTP on {port}")
|
|
357
445
|
else:
|
|
358
|
-
print(f"Web interface on http://0.0.0.0:{port}"
|
|
446
|
+
print(f"Web interface on http://0.0.0.0:{port}"
|
|
447
|
+
+ ("" if ssl is not None else " (ssl module unavailable)"))
|
|
359
448
|
|
|
360
449
|
_web_server = server
|
|
361
450
|
t = threading.Thread(target=server.serve_forever, daemon=True)
|
|
@@ -367,6 +456,7 @@ def start_web_server(port=8080):
|
|
|
367
456
|
http_port = int(os.environ.get("DISCSTATION_HTTP_PORT", "8081"))
|
|
368
457
|
except ValueError:
|
|
369
458
|
http_port = 8081
|
|
459
|
+
plain_http_up = False
|
|
370
460
|
if http_port and http_port != port:
|
|
371
461
|
try:
|
|
372
462
|
plain = socketserver.ThreadingTCPServer(('', http_port), _WebHandler, bind_and_activate=False)
|
|
@@ -376,9 +466,15 @@ def start_web_server(port=8080):
|
|
|
376
466
|
plain.server_activate()
|
|
377
467
|
threading.Thread(target=plain.serve_forever, daemon=True).start()
|
|
378
468
|
print(f"Plain HTTP (mobile app) on http://0.0.0.0:{http_port}")
|
|
469
|
+
plain_http_up = True
|
|
379
470
|
except OSError as e:
|
|
380
471
|
print(f"Plain HTTP listener not started on {http_port}: {e}")
|
|
381
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
|
+
|
|
382
478
|
return server
|
|
383
479
|
|
|
384
480
|
|
|
@@ -422,7 +518,8 @@ def wait_for_web_url(ser):
|
|
|
422
518
|
check_serial_alive(ser)
|
|
423
519
|
|
|
424
520
|
|
|
425
|
-
MPV_SOCKET =
|
|
521
|
+
MPV_SOCKET = (r"\\.\pipe\discstation-mpv" if os.name == "nt"
|
|
522
|
+
else str(Path(tempfile.gettempdir()) / "discstation_mpv.sock"))
|
|
426
523
|
RIP_ROOT = discstation_burn.USER_HOME / "dvd_rips"
|
|
427
524
|
USER_AGENT = "DVDStation/0.1 (local appliance; phuju)"
|
|
428
525
|
DISC_POLL_SECONDS = 6
|
|
@@ -528,7 +625,9 @@ _sse_lock = threading.Lock()
|
|
|
528
625
|
|
|
529
626
|
|
|
530
627
|
def _status_snapshot():
|
|
531
|
-
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}
|
|
532
631
|
|
|
533
632
|
|
|
534
633
|
def _sse_publish(event):
|
|
@@ -554,11 +653,17 @@ def _set_web_progress(phase, percent=-1):
|
|
|
554
653
|
|
|
555
654
|
|
|
556
655
|
def _record_web_status(msg):
|
|
557
|
-
global _web_status, _web_progress, _web_progress_active
|
|
656
|
+
global _web_status, _web_progress, _web_progress_active, _web_playing
|
|
558
657
|
if msg.startswith("DISC:"):
|
|
559
658
|
_sse_publish({"type": "disc-changed"})
|
|
560
659
|
return
|
|
561
|
-
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:"):
|
|
562
667
|
_web_status = msg[7:].strip() or "READY"
|
|
563
668
|
_web_progress_active = True
|
|
564
669
|
elif msg.startswith("PROGRESS:"):
|
|
@@ -572,12 +677,15 @@ def _record_web_status(msg):
|
|
|
572
677
|
_web_status = msg[5:].strip() or "DONE"
|
|
573
678
|
_web_progress = 100
|
|
574
679
|
_web_progress_active = False
|
|
680
|
+
_web_playing = False
|
|
575
681
|
elif msg.startswith("ERROR:"):
|
|
576
682
|
_web_status = msg[6:].strip() or "ERROR"
|
|
577
683
|
_web_progress_active = False
|
|
684
|
+
_web_playing = False
|
|
578
685
|
elif msg.startswith("CANCELLED:"):
|
|
579
686
|
_web_status = msg[10:].strip() or "CANCELLED"
|
|
580
687
|
_web_progress_active = False
|
|
688
|
+
_web_playing = False
|
|
581
689
|
elif msg.startswith(("STANDBY:", "HOME:")):
|
|
582
690
|
# idle again (tray open, insert disc, back to the menu) — clear any
|
|
583
691
|
# lingering "Ejecting..." / progress state on the web UI.
|
|
@@ -585,6 +693,7 @@ def _record_web_status(msg):
|
|
|
585
693
|
_web_status = "READY" if text in ("", "DiscStation", "Select mode", "Starting...") else text
|
|
586
694
|
_web_progress = -1
|
|
587
695
|
_web_progress_active = False
|
|
696
|
+
_web_playing = False
|
|
588
697
|
else:
|
|
589
698
|
return
|
|
590
699
|
_sse_publish(_status_snapshot())
|
|
@@ -649,12 +758,26 @@ def chown_to_sudo_user(path):
|
|
|
649
758
|
pass
|
|
650
759
|
|
|
651
760
|
|
|
761
|
+
def _mpv_ipc(payload, timeout=None):
|
|
762
|
+
"""Send one JSON line to mpv's IPC endpoint. Windows = named pipe, POSIX =
|
|
763
|
+
AF_UNIX socket. Returns the raw reply bytes (b"" if not read), or raises OSError."""
|
|
764
|
+
if os.name == "nt":
|
|
765
|
+
with open(MPV_SOCKET, "r+b", buffering=0) as pipe:
|
|
766
|
+
pipe.write(payload)
|
|
767
|
+
if timeout is None:
|
|
768
|
+
return b""
|
|
769
|
+
return pipe.read(4096) or b""
|
|
770
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
|
771
|
+
if timeout is not None:
|
|
772
|
+
sock.settimeout(timeout)
|
|
773
|
+
sock.connect(MPV_SOCKET)
|
|
774
|
+
sock.sendall(payload)
|
|
775
|
+
return sock.recv(4096) if timeout is not None else b""
|
|
776
|
+
|
|
777
|
+
|
|
652
778
|
def mpv_command(command):
|
|
653
779
|
try:
|
|
654
|
-
|
|
655
|
-
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
|
656
|
-
sock.connect(MPV_SOCKET)
|
|
657
|
-
sock.sendall(payload)
|
|
780
|
+
_mpv_ipc(json.dumps({"command": command}).encode() + b"\n")
|
|
658
781
|
except OSError:
|
|
659
782
|
return False
|
|
660
783
|
return True
|
|
@@ -663,11 +786,7 @@ def mpv_command(command):
|
|
|
663
786
|
def mpv_query(command):
|
|
664
787
|
try:
|
|
665
788
|
payload = json.dumps({"command": command, "request_id": 1}).encode() + b"\n"
|
|
666
|
-
|
|
667
|
-
sock.settimeout(0.5)
|
|
668
|
-
sock.connect(MPV_SOCKET)
|
|
669
|
-
sock.sendall(payload)
|
|
670
|
-
response = json.loads(sock.recv(4096).decode(errors="ignore"))
|
|
789
|
+
response = json.loads(_mpv_ipc(payload, timeout=0.5).decode(errors="ignore"))
|
|
671
790
|
return response.get("data")
|
|
672
791
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
673
792
|
return None
|
|
@@ -678,14 +797,11 @@ def wait_for_socket(path, proc, timeout=8):
|
|
|
678
797
|
while time.time() < deadline:
|
|
679
798
|
if proc.poll() is not None:
|
|
680
799
|
return False
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
return True
|
|
687
|
-
except OSError:
|
|
688
|
-
pass
|
|
800
|
+
try:
|
|
801
|
+
_mpv_ipc(b'{"command":["get_property","idle-active"]}\n', timeout=0.25)
|
|
802
|
+
return True
|
|
803
|
+
except OSError:
|
|
804
|
+
pass
|
|
689
805
|
time.sleep(0.1)
|
|
690
806
|
return False
|
|
691
807
|
|
|
@@ -887,6 +1003,24 @@ def eject_disc(ser, device):
|
|
|
887
1003
|
return ok
|
|
888
1004
|
|
|
889
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
|
+
|
|
890
1024
|
HISTORY_FILE = discstation_burn.WORK / "burn_history.jsonl"
|
|
891
1025
|
|
|
892
1026
|
|
|
@@ -998,11 +1132,28 @@ _tray_open = False
|
|
|
998
1132
|
_tray_open_since = 0.0 # time.monotonic() of the last OLED-initiated eject
|
|
999
1133
|
|
|
1000
1134
|
|
|
1135
|
+
def _device_present(device):
|
|
1136
|
+
"""Is `device` still a live drive node? On Linux/macOS that's a real
|
|
1137
|
+
filesystem path that can disappear (e.g. after an eject) - Path.exists()
|
|
1138
|
+
answers that correctly. On Windows `device` is a bare drive letter ("D:");
|
|
1139
|
+
Path("D:").exists() raises OSError (WinError 1) instead of returning False,
|
|
1140
|
+
and the drive letter is stable regardless of media state anyway (the real
|
|
1141
|
+
presence signal is ID_CDROM_MEDIA, checked downstream via media_properties())."""
|
|
1142
|
+
if not device:
|
|
1143
|
+
return False
|
|
1144
|
+
if discstation_host.system_name() == "windows":
|
|
1145
|
+
return True
|
|
1146
|
+
try:
|
|
1147
|
+
return Path(device).exists()
|
|
1148
|
+
except OSError:
|
|
1149
|
+
return False
|
|
1150
|
+
|
|
1151
|
+
|
|
1001
1152
|
def _tray_closed_with_disc(device):
|
|
1002
1153
|
if not device:
|
|
1003
1154
|
return False
|
|
1004
1155
|
global _tray_open
|
|
1005
|
-
if not
|
|
1156
|
+
if not _device_present(device):
|
|
1006
1157
|
return False
|
|
1007
1158
|
properties = udev_cdrom_properties(device)
|
|
1008
1159
|
if properties.get("ID_CDROM_MEDIA") == "1":
|
|
@@ -1021,7 +1172,7 @@ def disc_present(device):
|
|
|
1021
1172
|
return True
|
|
1022
1173
|
if _tray_open:
|
|
1023
1174
|
return False
|
|
1024
|
-
if not
|
|
1175
|
+
if not _device_present(device):
|
|
1025
1176
|
return False
|
|
1026
1177
|
if discstation_host.system_name() != "linux":
|
|
1027
1178
|
properties = udev_cdrom_properties(device)
|
|
@@ -1055,7 +1206,7 @@ def disc_present(device):
|
|
|
1055
1206
|
def is_blank_disc(device):
|
|
1056
1207
|
if not device:
|
|
1057
1208
|
return False
|
|
1058
|
-
if not
|
|
1209
|
+
if not _device_present(device):
|
|
1059
1210
|
return False
|
|
1060
1211
|
|
|
1061
1212
|
properties = udev_cdrom_properties(device, refresh=True)
|
|
@@ -1107,7 +1258,7 @@ def is_rewritable_disc(device):
|
|
|
1107
1258
|
if not device:
|
|
1108
1259
|
return False
|
|
1109
1260
|
"""Return whether the inserted medium can be overwritten."""
|
|
1110
|
-
if not
|
|
1261
|
+
if not _device_present(device):
|
|
1111
1262
|
return False
|
|
1112
1263
|
|
|
1113
1264
|
properties = udev_cdrom_properties(device)
|
|
@@ -1227,7 +1378,7 @@ def _media_quick_state(device, props):
|
|
|
1227
1378
|
return "unsure"
|
|
1228
1379
|
# st == "unknown": fall through to the legacy probes below
|
|
1229
1380
|
try:
|
|
1230
|
-
if not
|
|
1381
|
+
if not _device_present(device):
|
|
1231
1382
|
return "empty"
|
|
1232
1383
|
except OSError:
|
|
1233
1384
|
return "empty"
|
|
@@ -1317,6 +1468,11 @@ def _classify_disc(device, props, failed, deadline):
|
|
|
1317
1468
|
(blkid -> lsdvd -> wodim -toc -> fs fallback -> blank) but records which
|
|
1318
1469
|
probes timed out / were missing so the caller can retry."""
|
|
1319
1470
|
if discstation_host.system_name() != "linux":
|
|
1471
|
+
if not props.get("ID_CDROM_MEDIA"):
|
|
1472
|
+
# media_properties() returned nothing -> no disc loaded (this is the
|
|
1473
|
+
# only "no media" signal on a platform like Windows where the drive
|
|
1474
|
+
# letter/device path exists whether or not media is present).
|
|
1475
|
+
return _disc_info(False, "none")
|
|
1320
1476
|
if props.get("ID_CDROM_MEDIA_TYPE") == "audio":
|
|
1321
1477
|
return _disc_info(True, "audio_cd", web_type="AUDIO_CD")
|
|
1322
1478
|
if props.get("ID_FS_TYPE") in ("udf", "iso9660"):
|
|
@@ -1627,6 +1783,11 @@ class mounted_disc:
|
|
|
1627
1783
|
self.owned_mount = False
|
|
1628
1784
|
|
|
1629
1785
|
def __enter__(self):
|
|
1786
|
+
if discstation_host.system_name() == "windows":
|
|
1787
|
+
# the optical disc is already mounted by the OS as its drive letter
|
|
1788
|
+
letter = str(self.device).rstrip("\\/").rstrip(":") + ":\\"
|
|
1789
|
+
self.mount_path = Path(letter)
|
|
1790
|
+
return self.mount_path
|
|
1630
1791
|
if discstation_host.system_name() == "darwin":
|
|
1631
1792
|
properties = discstation_host.media_properties(self.device)
|
|
1632
1793
|
existing_mount = properties.get("ID_MOUNT_POINT")
|
|
@@ -1688,6 +1849,24 @@ def disc_video_files(mount_dir):
|
|
|
1688
1849
|
|
|
1689
1850
|
|
|
1690
1851
|
def audio_cd_toc(device):
|
|
1852
|
+
if discstation_host.system_name() == "windows":
|
|
1853
|
+
rc, out, err = discstation_host._run_ps("audio-toc.ps1", device, timeout=25)
|
|
1854
|
+
info = {}
|
|
1855
|
+
for line in out.splitlines():
|
|
1856
|
+
if line.strip().startswith("{"):
|
|
1857
|
+
try:
|
|
1858
|
+
info = json.loads(line)
|
|
1859
|
+
except ValueError:
|
|
1860
|
+
pass
|
|
1861
|
+
tracks = info.get("tracks") or []
|
|
1862
|
+
if not tracks:
|
|
1863
|
+
raise RuntimeError(f"Could not read CD TOC: {(err or out)[:120]}")
|
|
1864
|
+
n = int(info["track_count"])
|
|
1865
|
+
return {
|
|
1866
|
+
"first_track": 1, "track_count": n, "leadout": int(info["leadout"]),
|
|
1867
|
+
"tracks": tracks,
|
|
1868
|
+
"toc": "+".join(map(str, [1, n, int(info["leadout"]), *tracks])),
|
|
1869
|
+
}
|
|
1691
1870
|
if discstation_host.system_name() == "darwin":
|
|
1692
1871
|
paranoia = None
|
|
1693
1872
|
for name in ("cd-paranoia", "cdparanoia"):
|
|
@@ -1767,7 +1946,7 @@ def audio_cd_toc(device):
|
|
|
1767
1946
|
|
|
1768
1947
|
|
|
1769
1948
|
def audio_cd_chapters(device):
|
|
1770
|
-
if discstation_host.system_name()
|
|
1949
|
+
if discstation_host.system_name() in ("darwin", "windows"):
|
|
1771
1950
|
toc = audio_cd_toc(device)
|
|
1772
1951
|
tracks = toc["tracks"]
|
|
1773
1952
|
first = tracks[0]
|
|
@@ -2413,9 +2592,18 @@ def directory_size_bytes(path):
|
|
|
2413
2592
|
return total
|
|
2414
2593
|
|
|
2415
2594
|
|
|
2595
|
+
def _stdin_is_tty():
|
|
2596
|
+
"""sys.stdin is None under pythonw.exe (no console) - plain .isatty() would
|
|
2597
|
+
AttributeError. Also guards a closed/redirected stdin under systemd/launchd."""
|
|
2598
|
+
try:
|
|
2599
|
+
return sys.stdin is not None and sys.stdin.isatty()
|
|
2600
|
+
except (AttributeError, ValueError, OSError):
|
|
2601
|
+
return False
|
|
2602
|
+
|
|
2603
|
+
|
|
2416
2604
|
def burn_flow(ser, url):
|
|
2417
2605
|
if not url:
|
|
2418
|
-
if
|
|
2606
|
+
if _stdin_is_tty():
|
|
2419
2607
|
safe_send(ser, "STATUS:Enter URL or file path in terminal")
|
|
2420
2608
|
print("=== Enter URL or file path below, then press Enter ===")
|
|
2421
2609
|
try:
|
|
@@ -2447,7 +2635,7 @@ def burn_flow(ser, url):
|
|
|
2447
2635
|
job_dir.mkdir()
|
|
2448
2636
|
|
|
2449
2637
|
send(ser, "STATUS:Preflight...")
|
|
2450
|
-
info = discstation_burn.get_video_info(url)
|
|
2638
|
+
info = discstation_burn.get_video_info(url, ser)
|
|
2451
2639
|
title = info["title"]
|
|
2452
2640
|
duration = info["duration"]
|
|
2453
2641
|
duration_line, fit_line, can_fit = discstation_burn.preflight_lines(duration, disc_bytes)
|
|
@@ -2666,12 +2854,9 @@ def _copy_to_job(ser, src, dst_dir):
|
|
|
2666
2854
|
|
|
2667
2855
|
|
|
2668
2856
|
def burn_data_flow(ser):
|
|
2669
|
-
global
|
|
2857
|
+
global _last_upload_label
|
|
2670
2858
|
|
|
2671
|
-
if
|
|
2672
|
-
url = _last_upload_dir
|
|
2673
|
-
_last_upload_dir = None
|
|
2674
|
-
elif sys.stdin.isatty():
|
|
2859
|
+
if _stdin_is_tty():
|
|
2675
2860
|
safe_send(ser, "STATUS:Enter URL or file path in terminal")
|
|
2676
2861
|
print("=== Enter URL or file path below, then press Enter ===")
|
|
2677
2862
|
try:
|
|
@@ -2704,7 +2889,7 @@ def burn_data_flow(ser):
|
|
|
2704
2889
|
title = local_path.name if local_path.is_dir() else local_path.stem
|
|
2705
2890
|
else:
|
|
2706
2891
|
send(ser, "STATUS:Probing source...")
|
|
2707
|
-
info = discstation_burn.get_video_info(url)
|
|
2892
|
+
info = discstation_burn.get_video_info(url, ser)
|
|
2708
2893
|
title = info["title"]
|
|
2709
2894
|
|
|
2710
2895
|
if _last_upload_label:
|
|
@@ -2836,7 +3021,7 @@ def burn_data_flow(ser):
|
|
|
2836
3021
|
|
|
2837
3022
|
|
|
2838
3023
|
def burn_audio_flow(ser):
|
|
2839
|
-
if
|
|
3024
|
+
if _stdin_is_tty():
|
|
2840
3025
|
safe_send(ser, "STATUS:Enter path to audio files in terminal")
|
|
2841
3026
|
print("=== Enter path to audio files/folder, then press Enter ===")
|
|
2842
3027
|
try:
|
|
@@ -2864,8 +3049,12 @@ def burn_audio_flow(ser):
|
|
|
2864
3049
|
audio_files = []
|
|
2865
3050
|
audio_exts = {".wav", ".flac", ".mp3", ".aac", ".ogg", ".wma", ".m4a", ".opus"}
|
|
2866
3051
|
if src_path.is_dir():
|
|
2867
|
-
|
|
2868
|
-
|
|
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:
|
|
2869
3058
|
audio_files.append(f)
|
|
2870
3059
|
elif src_path.is_file():
|
|
2871
3060
|
audio_files = [src_path]
|
|
@@ -3017,7 +3206,7 @@ def _iter_proc_lines(proc, ser):
|
|
|
3017
3206
|
def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
|
|
3018
3207
|
try:
|
|
3019
3208
|
os.unlink(MPV_SOCKET)
|
|
3020
|
-
except
|
|
3209
|
+
except OSError:
|
|
3021
3210
|
pass
|
|
3022
3211
|
|
|
3023
3212
|
env = os.environ.copy()
|
|
@@ -3157,23 +3346,155 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
|
|
|
3157
3346
|
discstation_burn.stop_process(proc)
|
|
3158
3347
|
try:
|
|
3159
3348
|
os.unlink(MPV_SOCKET)
|
|
3160
|
-
except
|
|
3349
|
+
except OSError:
|
|
3161
3350
|
pass
|
|
3162
3351
|
|
|
3163
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
|
+
|
|
3164
3457
|
def play_flow(ser):
|
|
3165
3458
|
device = discstation_burn.disc_device()
|
|
3166
3459
|
kind = disc_kind(device)
|
|
3167
3460
|
print(f"Disc type: {kind}")
|
|
3168
3461
|
|
|
3169
|
-
|
|
3170
|
-
|
|
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)
|
|
3171
3492
|
|
|
3172
3493
|
if kind == "dvd_video":
|
|
3173
3494
|
if discstation_host.system_name() == "darwin":
|
|
3174
3495
|
try:
|
|
3175
3496
|
cmd = [
|
|
3176
|
-
|
|
3497
|
+
mpv,
|
|
3177
3498
|
"--input-ipc-server=" + MPV_SOCKET,
|
|
3178
3499
|
"--force-window=yes",
|
|
3179
3500
|
"--idle=no",
|
|
@@ -3182,51 +3503,37 @@ def play_flow(ser):
|
|
|
3182
3503
|
]
|
|
3183
3504
|
_run_mpv(ser, cmd, "Playing DVD", kind)
|
|
3184
3505
|
except RuntimeError:
|
|
3185
|
-
# libdvdnav couldn't open the disc
|
|
3186
|
-
|
|
3187
|
-
with mounted_disc(device) as mount_dir:
|
|
3188
|
-
video_ts = mount_dir / "VIDEO_TS"
|
|
3189
|
-
files = sorted(
|
|
3190
|
-
path for path in video_ts.glob("VTS_01_*.VOB")
|
|
3191
|
-
if re.search(r"_\d+\.VOB$", path.name, re.IGNORECASE)
|
|
3192
|
-
and not path.name.upper().endswith("_0.VOB")
|
|
3193
|
-
)
|
|
3194
|
-
if not files:
|
|
3195
|
-
raise RuntimeError("No playable DVD title found")
|
|
3196
|
-
cmd = [
|
|
3197
|
-
"mpv",
|
|
3198
|
-
"--input-ipc-server=" + MPV_SOCKET,
|
|
3199
|
-
"--force-window=yes",
|
|
3200
|
-
"--idle=no",
|
|
3201
|
-
*[str(path) for path in files],
|
|
3202
|
-
]
|
|
3203
|
-
_run_mpv(ser, cmd, "Playing DVD", kind)
|
|
3506
|
+
# libdvdnav couldn't open the disc.
|
|
3507
|
+
play_vob_fallback()
|
|
3204
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()
|
|
3205
3524
|
cmd = [
|
|
3206
|
-
|
|
3525
|
+
mpv,
|
|
3207
3526
|
"--input-ipc-server=" + MPV_SOCKET,
|
|
3208
|
-
"--force-window=
|
|
3527
|
+
"--force-window=no",
|
|
3209
3528
|
"--idle=no",
|
|
3210
|
-
device,
|
|
3529
|
+
"--cdrom-device=" + rip_device(device),
|
|
3530
|
+
"--cdda-cdtext=yes",
|
|
3531
|
+
"cdda://",
|
|
3211
3532
|
]
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
audio_device = discstation_host.audio_output_device()
|
|
3217
|
-
cmd = [
|
|
3218
|
-
"mpv",
|
|
3219
|
-
"--input-ipc-server=" + MPV_SOCKET,
|
|
3220
|
-
"--force-window=no",
|
|
3221
|
-
"--idle=no",
|
|
3222
|
-
"--cdrom-device=" + rip_device(device),
|
|
3223
|
-
"--cdda-cdtext=yes",
|
|
3224
|
-
"cdda://",
|
|
3225
|
-
]
|
|
3226
|
-
if audio_device:
|
|
3227
|
-
cmd.insert(1, "--audio-device=" + audio_device)
|
|
3228
|
-
print(f"Audio CD output: {audio_device}")
|
|
3229
|
-
_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)
|
|
3230
3537
|
|
|
3231
3538
|
elif kind in ("vcd", "svcd", "video_data"):
|
|
3232
3539
|
with mounted_disc(device) as mount_dir:
|
|
@@ -3234,7 +3541,7 @@ def play_flow(ser):
|
|
|
3234
3541
|
if not files:
|
|
3235
3542
|
raise RuntimeError("No playable video files")
|
|
3236
3543
|
cmd = [
|
|
3237
|
-
|
|
3544
|
+
mpv,
|
|
3238
3545
|
"--input-ipc-server=" + MPV_SOCKET,
|
|
3239
3546
|
"--force-window=yes",
|
|
3240
3547
|
"--idle=no",
|
|
@@ -3323,11 +3630,13 @@ def _handbrake_json_blocks(text):
|
|
|
3323
3630
|
def handbrake_scan(device):
|
|
3324
3631
|
"""Return {'main_feature': int|None, 'titles': [{index,duration_s,chapters}]}
|
|
3325
3632
|
or None. Uses HandBrakeCLI, which does real main-feature detection."""
|
|
3326
|
-
|
|
3633
|
+
try:
|
|
3634
|
+
handbrake_cli = discstation_burn.tool("HandBrakeCLI")
|
|
3635
|
+
except FileNotFoundError:
|
|
3327
3636
|
return None
|
|
3328
3637
|
try:
|
|
3329
3638
|
r = subprocess.run(
|
|
3330
|
-
[
|
|
3639
|
+
[handbrake_cli, "--json", "--scan", "-i", device, "-t", "0"],
|
|
3331
3640
|
capture_output=True, text=True, timeout=180,
|
|
3332
3641
|
)
|
|
3333
3642
|
except (OSError, subprocess.TimeoutExpired) as e:
|
|
@@ -3359,7 +3668,7 @@ def handbrake_rip_main_feature(ser, device, out_dir, title_index):
|
|
|
3359
3668
|
send(ser, f"INFO:HandBrake title {title_index}")
|
|
3360
3669
|
send(ser, "PROGRESS:0%")
|
|
3361
3670
|
proc = subprocess.Popen(
|
|
3362
|
-
["HandBrakeCLI", "--json", "-i", device, "-o", str(dest),
|
|
3671
|
+
[discstation_burn.tool("HandBrakeCLI"), "--json", "-i", device, "-o", str(dest),
|
|
3363
3672
|
"-t", str(title_index), "-e", "x264", "-q", "20",
|
|
3364
3673
|
"--all-audio", "--all-subtitles"],
|
|
3365
3674
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
|
@@ -3407,6 +3716,50 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
|
|
|
3407
3716
|
out_dir = RIP_ROOT / time.strftime("rip_%Y%m%d_%H%M%S")
|
|
3408
3717
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
3409
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
|
+
|
|
3410
3763
|
scan = handbrake_scan(device)
|
|
3411
3764
|
if scan:
|
|
3412
3765
|
main = scan["main_feature"]
|
|
@@ -3868,7 +4221,17 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
|
|
|
3868
4221
|
if now - last_ping >= 5:
|
|
3869
4222
|
last_ping = now
|
|
3870
4223
|
safe_send(ser, "PING")
|
|
3871
|
-
|
|
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)
|
|
3872
4235
|
|
|
3873
4236
|
# Slow full classify as a backstop (type changes, stuck "reading...").
|
|
3874
4237
|
if (not _tray_open and _disc_poll_future is None
|
|
@@ -3930,6 +4293,24 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
|
|
|
3930
4293
|
_detect_cache.pop(device, None)
|
|
3931
4294
|
continue
|
|
3932
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
|
+
|
|
3933
4314
|
if not line.startswith("SELECT:"):
|
|
3934
4315
|
if line.startswith("WiFi") or line.startswith("IP:") or "ip:" in line.lower():
|
|
3935
4316
|
print(f"ESP32: {line}")
|
|
@@ -3979,7 +4360,7 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
|
|
|
3979
4360
|
refresh_main_menu(ser)
|
|
3980
4361
|
|
|
3981
4362
|
|
|
3982
|
-
PIDFILE = "
|
|
4363
|
+
PIDFILE = os.path.join(tempfile.gettempdir(), "discstation.pid")
|
|
3983
4364
|
|
|
3984
4365
|
|
|
3985
4366
|
def check_pidfile():
|
|
@@ -3992,6 +4373,10 @@ def check_pidfile():
|
|
|
3992
4373
|
if sys.platform == "linux":
|
|
3993
4374
|
with open(f"/proc/{old_pid}/cmdline") as f:
|
|
3994
4375
|
alive = "discstation" in f.read()
|
|
4376
|
+
elif os.name == "nt":
|
|
4377
|
+
tl = subprocess.run(["tasklist", "/FI", f"PID eq {old_pid}", "/FO", "CSV", "/NH"],
|
|
4378
|
+
capture_output=True, text=True)
|
|
4379
|
+
alive = "python" in tl.stdout.lower()
|
|
3995
4380
|
else:
|
|
3996
4381
|
ps = subprocess.run(["ps", "-p", str(old_pid), "-o", "command="],
|
|
3997
4382
|
capture_output=True, text=True)
|
|
@@ -3999,7 +4384,10 @@ def check_pidfile():
|
|
|
3999
4384
|
if alive:
|
|
4000
4385
|
print(f"Already running (PID {old_pid}), exiting")
|
|
4001
4386
|
sys.exit(0)
|
|
4002
|
-
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.
|
|
4003
4391
|
pass
|
|
4004
4392
|
except (ValueError, OSError):
|
|
4005
4393
|
pass
|
|
@@ -4022,7 +4410,7 @@ def parse_args():
|
|
|
4022
4410
|
|
|
4023
4411
|
|
|
4024
4412
|
def main():
|
|
4025
|
-
global _active_ser, _line_buf
|
|
4413
|
+
global _active_ser, _line_buf, _appliance_mode
|
|
4026
4414
|
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
4027
4415
|
check_pidfile()
|
|
4028
4416
|
|
|
@@ -4046,18 +4434,32 @@ def main():
|
|
|
4046
4434
|
try:
|
|
4047
4435
|
_line_buf = b""
|
|
4048
4436
|
port = discstation_host.serial_port()
|
|
4049
|
-
if
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
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"
|
|
4059
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())
|
|
4060
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.")
|
|
4061
4463
|
except (serial.SerialException, OSError, termios.error) as e:
|
|
4062
4464
|
print(f"Disconnected ({e}), reconnecting in 3s...")
|
|
4063
4465
|
time.sleep(3)
|