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.
@@ -4,7 +4,10 @@ import atexit
4
4
  import collections
5
5
  import concurrent.futures
6
6
  import errno
7
- import fcntl
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
- import ssl
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
- _last_upload_dir = str(upload_dir)
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-v8').then(c => c.addAll(['/','/static/style.css?v=8','/static/app.js?v=8']));
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-v8').then(c => c.put(e.request, copy));
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
- ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
354
- ctx.load_cert_chain(str(cert), str(key))
355
- server.socket = ctx.wrap_socket(server.socket, server_side=True)
356
- print(f"Web interface on https://0.0.0.0:{port}")
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 = str(Path(tempfile.gettempdir()) / "discstation_mpv.sock")
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, "active": _web_progress_active}
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("STATUS:"):
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
- payload = json.dumps({"command": command}).encode() + b"\n"
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
- with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
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
- if Path(path).exists():
682
- try:
683
- with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
684
- sock.settimeout(0.25)
685
- sock.connect(path)
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 Path(device).exists():
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 Path(device).exists():
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 Path(device).exists():
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 Path(device).exists():
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 Path(device).exists():
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() == "darwin":
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 sys.stdin.isatty():
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 _last_upload_dir, _last_upload_label
2857
+ global _last_upload_label
2670
2858
 
2671
- if _last_upload_dir and Path(_last_upload_dir).exists():
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 sys.stdin.isatty():
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
- for f in sorted(src_path.iterdir()):
2868
- if f.suffix.lower() in audio_exts:
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 FileNotFoundError:
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 FileNotFoundError:
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
- if not shutil.which("mpv"):
3170
- raise RuntimeError("mpv not found")
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
- "mpv",
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 — fall back to playing the
3186
- # main title's VOBs off the mounted volume (no menus).
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
- "mpv",
3525
+ mpv,
3207
3526
  "--input-ipc-server=" + MPV_SOCKET,
3208
- "--force-window=yes",
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
- _run_mpv(ser, cmd, "Playing DVD", kind)
3213
-
3214
- elif kind == "audio_cd":
3215
- _, track_titles, track_starts = audio_track_metadata(device)
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
- "mpv",
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
- if not shutil.which("HandBrakeCLI"):
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
- ["HandBrakeCLI", "--json", "--scan", "-i", device, "-t", "0"],
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
- check_serial_alive(ser)
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 = "/tmp/discstation.pid"
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 not port:
4050
- raise serial.SerialException("No ESP32 serial port found")
4051
- print(f"Using ESP32 serial port: {port}")
4052
- ser = serial.Serial(port, discstation_burn.BAUD, timeout=1, write_timeout=1)
4053
- if discstation_host.system_name() == "linux":
4054
- ser.setDTR(False)
4055
- time.sleep(0.1)
4056
- ser.setDTR(True)
4057
- time.sleep(2)
4058
- discstation_burn.reset_serial_state()
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)