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.
@@ -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
- _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))
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-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']));
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-v8').then(c => c.put(e.request, copy));
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, "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}
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("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:"):
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 _last_upload_dir, _last_upload_label
2857
+ global _last_upload_label
2742
2858
 
2743
- if _last_upload_dir and Path(_last_upload_dir).exists():
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
- for f in sorted(src_path.iterdir()):
2940
- 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:
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
- if not shutil.which("mpv"):
3242
- 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)
3243
3492
 
3244
3493
  if kind == "dvd_video":
3245
3494
  if discstation_host.system_name() == "darwin":
3246
3495
  try:
3247
3496
  cmd = [
3248
- "mpv",
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 — fall back to playing the
3258
- # main title's VOBs off the mounted volume (no menus).
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
- "mpv",
3525
+ mpv,
3279
3526
  "--input-ipc-server=" + MPV_SOCKET,
3280
- "--force-window=yes",
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
- _run_mpv(ser, cmd, "Playing DVD", kind)
3285
-
3286
- elif kind == "audio_cd":
3287
- _, track_titles, track_starts = audio_track_metadata(device)
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
- "mpv",
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
- if not shutil.which("HandBrakeCLI"):
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
- ["HandBrakeCLI", "--json", "--scan", "-i", device, "-t", "0"],
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
- 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)
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 not port:
4126
- raise serial.SerialException("No ESP32 serial port found")
4127
- print(f"Using ESP32 serial port: {port}")
4128
- ser = serial.Serial(port, discstation_burn.BAUD, timeout=1, write_timeout=1)
4129
- if discstation_host.system_name() == "linux":
4130
- ser.setDTR(False)
4131
- time.sleep(0.1)
4132
- ser.setDTR(True)
4133
- time.sleep(2)
4134
- 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"
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)