discstation 0.1.33 → 0.1.35

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 CHANGED
@@ -141,11 +141,15 @@ self-signed cert. On startup the host prints the LAN URL to open
141
141
  - Upload files from any device on the LAN for data DVD burning
142
142
  - Submit YouTube URLs for video DVD burning
143
143
  - Dark theme, mobile-responsive, PWA (installable on phone)
144
- - **On-screen remote** — click the "REMOTE" tag next to the DiscStation
145
- wordmark to reveal a full control surface (mode buttons, EJECT/CLOSE
146
- toggle, live disc-status readout, playback transport). Fully functional
147
- with no ESP32 attached; greys out and auto-collapses the instant a
148
- physical remote is detected, so the two never fight for control.
144
+ - **On-screen remote** — opens automatically with no ESP32 attached, since
145
+ it's the only control surface in that case (toggle it via the "REMOTE"
146
+ tag next to the wordmark any time). Mode buttons only show what the disc
147
+ in the drive can actually do — a blank CD offers BURN DATA/BURN AUDIO, a
148
+ blank DVD offers BURN/BURN DATA, an already-recorded disc offers PLAY/RIP
149
+ — and picking one starts it directly, no separate confirm step. Also has
150
+ EJECT/CLOSE toggle, a live disc-status readout, and full playback
151
+ transport. Greys out and auto-collapses the instant a physical remote is
152
+ detected, so the two never fight for control.
149
153
 
150
154
  ## Mobile App
151
155
 
package/install-macos.sh CHANGED
@@ -50,7 +50,7 @@ if [[ -f "$ROOT_DIR/requirements-optional.txt" ]]; then
50
50
  fi
51
51
 
52
52
  DISC_DEVICE="${DISC_DEVICE:-}"
53
- DISC_PORT="${DISC_PORT:-$(ls /dev/cu.usbserial-* /dev/cu.usbmodem-* 2>/dev/null | head -1)}"
53
+ DISC_PORT="${DISC_PORT:-$(ls /dev/cu.usbserial-* /dev/cu.usbmodem-* 2>/dev/null | head -1 || true)}"
54
54
 
55
55
  if [[ ! -f "$CONFIG_DIR/server.crt" || ! -f "$CONFIG_DIR/server.key" ]]; then
56
56
  openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discstation",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "DiscStation optical-media appliance host — one cross-OS installer",
5
5
  "bin": {
6
6
  "discstation-setup": "scripts/setup.mjs",
@@ -59,6 +59,7 @@ _web_status = "READY"
59
59
  _web_progress = -1
60
60
  _web_progress_active = False
61
61
  _web_playing = False # a play_flow is currently active (transport controls apply)
62
+ _web_op_verb = "BURNING" # progress-bar verb for the current PROGRESS: stream
62
63
  _operation_active = False # a burn/rip/play flow is holding the drive
63
64
  _last_disc_info = {"disc_present": False, "capacity_bytes": 0, "capacity_gb": 0, "type": "none"}
64
65
  _active_ser = None
@@ -85,6 +86,14 @@ class VirtualSerial:
85
86
  with self._lock:
86
87
  self._buf += text.encode(errors="ignore").strip() + b"\n"
87
88
 
89
+ def clear(self):
90
+ """Drop any unconsumed input - used when a fresh SELECT: comes in so a
91
+ START that was queued for an abandoned earlier selection (the web
92
+ remote's mode buttons queue it right behind SELECT: so a one-click
93
+ burn works) can't leak forward and fire a different, unintended burn."""
94
+ with self._lock:
95
+ self._buf = b""
96
+
88
97
  @property
89
98
  def in_waiting(self):
90
99
  with self._lock:
@@ -296,7 +305,14 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
296
305
  _burn_url_queue.put(str(upload_dir))
297
306
  size_str = f"{total / 1e6:.1f}MB" if total > 1e6 else f"{total / 1e3:.0f}KB"
298
307
  _set_web_progress("UPLOAD READY", 100)
299
- self._respond(200, f'{len(files)} file(s) uploaded ({size_str}). Select BURN DATA on remote.')
308
+ # Used to hardcode "Select BURN DATA on remote" - wrong mode name for
309
+ # an audio upload, and assumes hardware that may not exist. A mode
310
+ # may also already be selected (the one-click remote flow queues the
311
+ # burn before upload finishes), so this is just a status line, not
312
+ # an instruction to a specific next step.
313
+ tip = ("Select a mode on your remote." if _appliance_mode == "hardware"
314
+ else "Choose a burn mode below to start.")
315
+ self._respond(200, f'{len(files)} file(s) uploaded ({size_str}). {tip}')
300
316
 
301
317
  def _handle_remote_button(self):
302
318
  """Web on-screen remote -> the exact same text-line protocol the
@@ -319,7 +335,9 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
319
335
  def _serve_disc_info(self):
320
336
  if _operation_active:
321
337
  # a burn/rip/play holds the drive — don't probe it, serve last-known.
322
- self._respond(200, json.dumps({**_last_disc_info, "busy": True, "appliance": _appliance_mode}), "application/json")
338
+ # menu_items empty: don't invite starting a second op on top of
339
+ # the one already running (CANCEL/EJECT stay available regardless).
340
+ self._respond(200, json.dumps({**_last_disc_info, "busy": True, "menu_items": [], "appliance": _appliance_mode}), "application/json")
323
341
  return
324
342
  info = {"disc_present": False, "capacity_bytes": 0, "capacity_gb": 0, "type": "none"}
325
343
  try:
@@ -336,6 +354,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
336
354
  info["type"] = di.web_type
337
355
  info["kind"] = di.kind
338
356
  info["label"] = di.label
357
+ info["menu_items"] = menu_items_for_disc(device) if di.present and not di.transient else []
339
358
  except Exception as e:
340
359
  print(f"Disc info error: {e}")
341
360
  info["appliance"] = _appliance_mode
@@ -564,14 +583,13 @@ def start_web_server(port=8080):
564
583
 
565
584
 
566
585
  def local_ip():
567
- try:
568
- result = subprocess.run(['hostname', '-I'], capture_output=True, text=True, timeout=2)
569
- ips = result.stdout.strip().split()
570
- for ip in ips:
571
- if ip.count('.') == 3 and not ip.startswith('127.'):
572
- return ip
573
- except Exception:
574
- pass
586
+ # `hostname -I`'s first non-loopback address used to be the shortcut here,
587
+ # but it lists every interface with no notion of "the real one" - once
588
+ # Docker's docker0/br-* bridges (172.17/18.x, unreachable from outside
589
+ # this machine) exist, they can sort before the actual LAN NIC and get
590
+ # picked instead, showing a dead URL on the OLED/web remote. Asking the
591
+ # kernel what source address it'd use to reach the outside world sidesteps
592
+ # that entirely - Docker's bridges aren't in that route, no filtering needed.
575
593
  s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
576
594
  try:
577
595
  s.connect(('8.8.8.8', 80))
@@ -758,7 +776,7 @@ def _record_web_status(msg):
758
776
  _web_progress_active = True
759
777
  elif msg.startswith("PROGRESS:"):
760
778
  value = msg[9:].strip()
761
- _web_status = f"BURNING {value}"
779
+ _web_status = f"{_web_op_verb} {value}"
762
780
  match = re.search(r"(\d+(?:\.\d+)?)", value)
763
781
  if match:
764
782
  _web_progress = min(100, max(0, int(float(match.group(1)))))
@@ -973,7 +991,16 @@ def check_serial_alive(ser=None):
973
991
  """Raise serial.SerialException if the ESP32 link looks dead, so main()'s
974
992
  reconnect loop can re-scan for the (possibly renumbered) serial port.
975
993
  Call this inside any long poll loop that would otherwise spin forever on a
976
- stale handle (writes to a re-enumerated /dev/ttyUSBN fail silently)."""
994
+ stale handle (writes to a re-enumerated /dev/ttyUSBN fail silently).
995
+
996
+ Meaningless (and actively harmful) in pure web/software mode - there's no
997
+ ESP32 to go quiet, and serial_activity_age() only advances on real
998
+ incoming bytes, so a user just reading the screen for >35s before
999
+ clicking the next button on the web remote looked identical to a dead
1000
+ link and killed the burn ("ESP32 not responding") with no ESP32 in the
1001
+ picture at all."""
1002
+ if isinstance(ser, VirtualSerial):
1003
+ return
977
1004
  if discstation_burn.serial_write_failed():
978
1005
  raise serial.SerialException("serial write failed (ESP32 link lost)")
979
1006
  if discstation_burn.serial_activity_age() >= 35:
@@ -1048,10 +1075,16 @@ def eject_disc(ser, device):
1048
1075
  _tray_open = True
1049
1076
  _tray_open_since = time.monotonic()
1050
1077
  safe_send(ser, "WAITING:Press SELECT/to close tray")
1078
+ # WAITING: isn't in _record_web_status()'s prefix whitelist, so the
1079
+ # send above never reaches the web page - _tray_open has to be
1080
+ # published explicitly here, same as DISC: gets its own dedicated
1081
+ # publish for the same reason.
1082
+ _sse_publish(_status_snapshot())
1051
1083
  last_ping = time.time()
1052
1084
  deadline = time.time() + 60
1053
1085
  tray_was_cancelled = False
1054
1086
  last_status_check = 0
1087
+ closed_confirms = 0
1055
1088
  # Let the eject settle before touching the drive again (the reclose guard).
1056
1089
  settle_until = time.time() + 3
1057
1090
  while time.time() < deadline:
@@ -1061,9 +1094,20 @@ def eject_disc(ser, device):
1061
1094
  if time.time() >= settle_until and time.time() - last_status_check >= 1.5:
1062
1095
  last_status_check = time.time()
1063
1096
  if drive_status(device) in ("disc", "no_disc"):
1064
- print("Tray closed — continuing")
1065
- _tray_open = False
1066
- break
1097
+ # This USB-ATAPI bridge's status reporting is known flaky
1098
+ # (see udev_cdrom_properties' own docstring) - one read
1099
+ # right after an eject was seen live to falsely report
1100
+ # closed, reclosing the wait loop within ~11s of a real
1101
+ # eject. Require two consecutive agreeing reads before
1102
+ # believing it.
1103
+ closed_confirms += 1
1104
+ if closed_confirms >= 2:
1105
+ print("Tray closed — continuing")
1106
+ _tray_open = False
1107
+ _sse_publish(_status_snapshot())
1108
+ break
1109
+ else:
1110
+ closed_confirms = 0
1067
1111
  line = read_serial_line(ser, timeout=0.1)
1068
1112
  if not line:
1069
1113
  continue
@@ -1080,6 +1124,7 @@ def eject_disc(ser, device):
1080
1124
  r = subprocess.run(close_cmd, timeout=10, capture_output=True)
1081
1125
  if r.returncode == 0:
1082
1126
  _tray_open = False
1127
+ _sse_publish(_status_snapshot())
1083
1128
  break
1084
1129
  except Exception:
1085
1130
  pass
@@ -1863,7 +1908,13 @@ def menu_items_for_disc(device):
1863
1908
  kind = disc_kind(device)
1864
1909
  items = []
1865
1910
  if kind == "blank" or is_rewritable_disc(device):
1866
- items = ["BURN", "BURN DATA", "BURN AUDIO"]
1911
+ # CD-R/RW can't hold a DVD-video authoring job (nowhere near the
1912
+ # space) and DVD blanks can't take Red Book audio (wrong format
1913
+ # entirely, would just fail) - offer only what's physically possible
1914
+ # for the media that's actually in the drive.
1915
+ props = udev_cdrom_properties(device)
1916
+ is_cd = props.get("ID_CDROM_MEDIA_CD_R") == "1" or props.get("ID_CDROM_MEDIA_CD_RW") == "1"
1917
+ items = ["BURN DATA", "BURN AUDIO"] if is_cd else ["BURN", "BURN DATA"]
1867
1918
  had = discstation_burn.WORK.rglob("movie.mpg")
1868
1919
  if any(True for _ in had):
1869
1920
  items.append("BURN MPG")
@@ -3524,6 +3575,10 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None, s
3524
3575
  current_volume = None
3525
3576
  current_track = None
3526
3577
  last_track_poll = 0
3578
+ # Set to "eject" below when EJECT (not PLAY_STOP/CANCEL/HOME) is what
3579
+ # ended playback - returned to the caller so it can actually eject
3580
+ # the tray afterward, not just return to the menu.
3581
+ stop_reason = None
3527
3582
  track_titles = track_titles or []
3528
3583
  track_starts = track_starts or []
3529
3584
  send(ser, "PLAY_MODE:AUDIO_CD" if kind == "audio_cd" else "PLAY_MODE:DEFAULT")
@@ -3566,12 +3621,18 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None, s
3566
3621
  vu_pause.set() if paused else vu_pause.clear()
3567
3622
  send(ser, "PLAY_STATUS:PAUSED" if paused else "PLAY_STATUS:PLAYING")
3568
3623
 
3569
- elif line in ("PLAY_STOP", "EJECT"):
3570
- # EJECT during playback = stop first; the web remote has no
3571
- # separate stop button, and without this the command is
3572
- # silently dropped here and playback never ends.
3624
+ elif line in ("PLAY_STOP", "EJECT", "CANCEL", "HOME"):
3625
+ # All four end playback - EJECT alone also pops the tray
3626
+ # afterward (the web remote has its own dedicated STOP
3627
+ # button now, so EJECT no longer needs to double as one -
3628
+ # a user pressing eject while music plays wants the disc
3629
+ # out, not just silence). CANCEL/HOME return to the menu
3630
+ # exactly like they do everywhere else - previously
3631
+ # unhandled here, so they were silently dropped mid-play.
3573
3632
  send(ser, "STATUS:Stopping play")
3574
3633
  discstation_burn.stop_process(proc)
3634
+ if line == "EJECT":
3635
+ stop_reason = "eject"
3575
3636
  break
3576
3637
 
3577
3638
  elif line == "FF:BIG":
@@ -3646,6 +3707,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None, s
3646
3707
  os.unlink(MPV_SOCKET)
3647
3708
  except OSError:
3648
3709
  pass
3710
+ return stop_reason
3649
3711
 
3650
3712
 
3651
3713
  def _play_audio_cd_windows(ser, device, track_titles):
@@ -3766,7 +3828,12 @@ def play_flow(ser):
3766
3828
  except FileNotFoundError:
3767
3829
  raise RuntimeError("mpv not found")
3768
3830
 
3831
+ # "eject" once a play loop reports EJECT ended it (see _run_mpv) - acted
3832
+ # on once, after the kind dispatch below, regardless of which branch ran.
3833
+ stop_reason = None
3834
+
3769
3835
  def play_vob_fallback():
3836
+ nonlocal stop_reason
3770
3837
  # No DVD-menu engine available (libdvdnav missing, or on Windows
3771
3838
  # where the plain mpv build never has it) - play the main title's
3772
3839
  # VOBs directly off the mounted volume instead (no menus).
@@ -3786,7 +3853,7 @@ def play_flow(ser):
3786
3853
  "--idle=no",
3787
3854
  *[str(path) for path in files],
3788
3855
  ]
3789
- _run_mpv(ser, cmd, "Playing DVD", kind)
3856
+ stop_reason = _run_mpv(ser, cmd, "Playing DVD", kind)
3790
3857
 
3791
3858
  if kind == "dvd_video":
3792
3859
  if discstation_host.system_name() == "darwin":
@@ -3799,7 +3866,7 @@ def play_flow(ser):
3799
3866
  "--dvd-device=" + rip_device(device),
3800
3867
  "dvdnav://",
3801
3868
  ]
3802
- _run_mpv(ser, cmd, "Playing DVD", kind)
3869
+ stop_reason = _run_mpv(ser, cmd, "Playing DVD", kind)
3803
3870
  except RuntimeError:
3804
3871
  # libdvdnav couldn't open the disc.
3805
3872
  play_vob_fallback()
@@ -3845,7 +3912,7 @@ def play_flow(ser):
3845
3912
  if audio_device:
3846
3913
  cmd.insert(1, "--audio-device=" + audio_device)
3847
3914
  print(f"Audio CD output: {audio_device}")
3848
- _run_mpv(ser, cmd, "Playing audio CD", kind, track_titles, track_starts, stdin_proc=rip_proc)
3915
+ stop_reason = _run_mpv(ser, cmd, "Playing audio CD", kind, track_titles, track_starts, stdin_proc=rip_proc)
3849
3916
  else:
3850
3917
  audio_device = discstation_host.audio_output_device()
3851
3918
  cmd = [
@@ -3860,7 +3927,7 @@ def play_flow(ser):
3860
3927
  if audio_device:
3861
3928
  cmd.insert(1, "--audio-device=" + audio_device)
3862
3929
  print(f"Audio CD output: {audio_device}")
3863
- _run_mpv(ser, cmd, "Playing audio CD", kind, track_titles, track_starts)
3930
+ stop_reason = _run_mpv(ser, cmd, "Playing audio CD", kind, track_titles, track_starts)
3864
3931
 
3865
3932
  elif kind in ("vcd", "svcd", "video_data"):
3866
3933
  with mounted_disc(device) as mount_dir:
@@ -3874,11 +3941,23 @@ def play_flow(ser):
3874
3941
  "--idle=no",
3875
3942
  *[str(path) for path in files],
3876
3943
  ]
3877
- _run_mpv(ser, cmd, f"Playing {kind.upper()}", kind)
3944
+ stop_reason = _run_mpv(ser, cmd, f"Playing {kind.upper()}", kind)
3878
3945
 
3879
3946
  else:
3880
3947
  raise RuntimeError(f"Unsupported disc: {kind}")
3881
3948
 
3949
+ if stop_reason == "eject":
3950
+ safe_send(ser, "STATUS:Ejecting...")
3951
+ # mpv/cd-paranoia just got killed above - give the OS a moment to
3952
+ # actually release the device handle before touching it again, or
3953
+ # the primary `eject` command can hit "Device or resource busy" and
3954
+ # fall back to the raw SCSI path, seen live to behave differently
3955
+ # (the tray got marked closed again within ~11s of a real eject).
3956
+ time.sleep(1)
3957
+ try:
3958
+ eject_disc(ser, device)
3959
+ except Exception as e:
3960
+ print(f"Eject after play failed: {e}")
3882
3961
  safe_send(ser, "DONE:Playback stopped")
3883
3962
  time.sleep(3)
3884
3963
 
@@ -4426,7 +4505,7 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
4426
4505
 
4427
4506
 
4428
4507
  def station_loop(ser, url, artist_hint=None, album_hint=None):
4429
- global _last_burn_result, _last_burn_result_time, _tray_open, _tray_open_since, _operation_active
4508
+ global _last_burn_result, _last_burn_result_time, _tray_open, _tray_open_since, _operation_active, _web_op_verb
4430
4509
  discstation_burn.cleanup_old_jobs()
4431
4510
  try:
4432
4511
  device = discstation_burn.disc_device()
@@ -4654,6 +4733,14 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
4654
4733
 
4655
4734
  mode = line.split(":", 1)[1].strip().upper()
4656
4735
  print(f"Selected: {mode}")
4736
+ _web_op_verb = "RIPPING" if mode == "RIP" else "BURNING"
4737
+ # The web remote's mode buttons queue START right behind SELECT: for a
4738
+ # one-click burn (see app.js) - if an earlier selection was abandoned
4739
+ # before its own START got consumed (e.g. never uploaded/confirmed a
4740
+ # URL), that stale START would otherwise sit buffered and fire this
4741
+ # new, different selection instead. Drop anything unconsumed first.
4742
+ if isinstance(ser, VirtualSerial):
4743
+ ser.clear()
4657
4744
 
4658
4745
  # The user picked a mode — they want to act on a disc, so the drive is
4659
4746
  # fair game again even if it was ejected from the OLED earlier.
@@ -1567,30 +1567,27 @@ def burn_audio_cd(ser, audio_files, disc_label, speed=None):
1567
1567
 
1568
1568
  album_t = _cdt(album_title) or "Audio CD"
1569
1569
  album_p = _cdt(album_artist) or "Unknown Artist"
1570
- toc_lines = ["CD_DA"]
1571
- toc_lines.append("CD_TEXT {")
1572
- toc_lines.append(" LANGUAGE_MAP { 0: EN }")
1573
- toc_lines.append(" LANGUAGE 0 {")
1574
- toc_lines.append(f' TITLE "{album_t}"')
1575
- toc_lines.append(f' PERFORMER "{album_p}"')
1576
- toc_lines.append(" }")
1577
- toc_lines.append("}")
1578
- toc_lines.append("")
1579
- for i, (artist, title) in enumerate(track_meta):
1580
- wav = tmp_dir / f"track_{i + 1:02d}.wav"
1581
- track_t = _cdt(title) or f"Track {i + 1:02d}"
1582
- track_p = _cdt(artist) or album_p
1583
- toc_lines.append("TRACK AUDIO")
1584
- toc_lines.append("CD_TEXT {")
1585
- toc_lines.append(" LANGUAGE 0 {")
1586
- toc_lines.append(f' TITLE "{track_t}"')
1587
- toc_lines.append(f' PERFORMER "{track_p}"')
1588
- toc_lines.append(" }")
1589
- toc_lines.append("}")
1590
- toc_lines.append(f'FILE "{wav}" 0')
1591
- toc_lines.append("")
1592
1570
  toc_path = tmp_dir / "disc.toc"
1593
- toc_path.write_text("\n".join(toc_lines) + "\n")
1571
+
1572
+ def _write_toc(include_cdtext):
1573
+ toc_lines = ["CD_DA"]
1574
+ if include_cdtext:
1575
+ toc_lines += ["CD_TEXT {", " LANGUAGE_MAP { 0: EN }", " LANGUAGE 0 {",
1576
+ f' TITLE "{album_t}"', f' PERFORMER "{album_p}"',
1577
+ " }", "}", ""]
1578
+ for i, (artist, title) in enumerate(track_meta):
1579
+ wav = tmp_dir / f"track_{i + 1:02d}.wav"
1580
+ toc_lines.append("TRACK AUDIO")
1581
+ if include_cdtext:
1582
+ track_t = _cdt(title) or f"Track {i + 1:02d}"
1583
+ track_p = _cdt(artist) or album_p
1584
+ toc_lines += ["CD_TEXT {", " LANGUAGE 0 {", f' TITLE "{track_t}"',
1585
+ f' PERFORMER "{track_p}"', " }", "}"]
1586
+ toc_lines.append(f'FILE "{wav}" 0')
1587
+ toc_lines.append("")
1588
+ toc_path.write_text("\n".join(toc_lines) + "\n")
1589
+
1590
+ _write_toc(include_cdtext=True)
1594
1591
  print(f"CD-TEXT: album={album_t!r} performer={album_p!r}, "
1595
1592
  f"{len(track_meta)} track titles")
1596
1593
  send(ser, "PROGRESS:35%")
@@ -1607,9 +1604,8 @@ def burn_audio_cd(ser, audio_files, disc_label, speed=None):
1607
1604
  return
1608
1605
 
1609
1606
  send(ser, "STATUS:Burning audio CD...")
1610
- # The cooked generic-mmc writer does NOT lay down the CD-TEXT lead-in on most
1611
- # ATAPI drives; the raw writer does. Override with DISCSTATION_CDRDAO_DRIVER
1612
- # (set it empty to let cdrdao auto-pick).
1607
+ # DISCSTATION_CDRDAO_DRIVER forces one specific driver (set it empty for
1608
+ # cdrdao to auto-pick) instead of the try-in-order fallback below.
1613
1609
  try:
1614
1610
  cdrdao_write_dev = discstation_host.cdrdao_device(disc_device())
1615
1611
  except RuntimeError:
@@ -1617,39 +1613,71 @@ def burn_audio_cd(ser, audio_files, disc_label, speed=None):
1617
1613
  raise RuntimeError("Audio CD burning is not supported on this Mac "
1618
1614
  "(cdrdao cannot access the optical drive)")
1619
1615
  raise
1620
- cdrdao_cmd = [tool('cdrdao'), 'write', '--buffers', '64',
1621
- '--device', cdrdao_write_dev]
1622
- driver = os.environ.get("DISCSTATION_CDRDAO_DRIVER", "generic-mmc-raw")
1623
- if driver:
1624
- cdrdao_cmd += ['--driver', driver]
1625
- speed_ = speed or DISC_SPEED
1626
- if speed_ and speed_.lower() != "auto":
1627
- cdrdao_cmd += ['--speed', speed_.rstrip('x')]
1628
- cdrdao_cmd.append(str(toc_path)) # toc-file must come after all options
1629
-
1630
- proc = subprocess.Popen(cdrdao_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
1631
- out_lines = []
1616
+ def _run_cdrdao(driver):
1617
+ cmd = [tool('cdrdao'), 'write', '--buffers', '64', '--device', cdrdao_write_dev]
1618
+ if driver:
1619
+ cmd += ['--driver', driver]
1620
+ speed_ = speed or DISC_SPEED
1621
+ if speed_ and speed_.lower() != "auto":
1622
+ cmd += ['--speed', speed_.rstrip('x')]
1623
+ cmd.append(str(toc_path)) # toc-file must come after all options
1624
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
1625
+ lines = []
1626
+ last_prog = 0
1627
+ try:
1628
+ for line in iter_proc_or_cancel(proc, ser):
1629
+ lines.append(line)
1630
+ # "Wrote 12 of 650 MB (Buffers 99% 100%)." is the real write
1631
+ # progress - matching on the first bare NN% instead (as this
1632
+ # used to) grabs the *buffer fill* percentage, which BURN-Proof
1633
+ # holds near 100% for virtually the whole burn, so the OLED
1634
+ # jumped straight from "Converting" to 100% and sat there.
1635
+ m = re.search(r'Wrote\s+(\d+)\s+of\s+(\d+)\s+MB', line)
1636
+ if m and int(m.group(2)) > 0:
1637
+ frac = int(m.group(1)) / int(m.group(2))
1638
+ pct = 35 + int(frac * 65)
1639
+ now = time.time()
1640
+ if now - last_prog >= 0.2:
1641
+ send(ser, f"PROGRESS:{pct}%")
1642
+ last_prog = now
1643
+ except (KeyboardInterrupt, SystemExit):
1644
+ stop_process(proc)
1645
+ raise
1646
+ return proc.wait(), lines
1647
+
1632
1648
  log_path = WORK / "cdrdao.log"
1633
- last_prog = 0
1649
+ override = os.environ.get("DISCSTATION_CDRDAO_DRIVER")
1650
+ # generic-mmc-raw (raw P-W sub-channel writing) is what guarantees CD-TEXT,
1651
+ # but some drives reject raw sub-channel writing outright - confirmed live
1652
+ # on this drive with --simulate: it fails at the lead-in even with CD-TEXT
1653
+ # stripped out entirely, so it's the raw *driver* that's incompatible here,
1654
+ # not CD-TEXT itself. generic-mmc (cooked) writes fine on the same drive
1655
+ # (also confirmed with --simulate) and still carries the CD-TEXT blocks in
1656
+ # the TOC, so it's worth trying with labels intact before dropping them -
1657
+ # only the last resort actually removes CD-TEXT from the TOC.
1658
+ drivers_to_try = [override] if override else ["generic-mmc-raw", "generic-mmc"]
1634
1659
  try:
1635
- for line in iter_proc_or_cancel(proc, ser):
1636
- out_lines.append(line)
1637
- m = re.search(r'(\d+)\s*%', line)
1638
- if m:
1639
- pct = 35 + int(int(m.group(1)) * 0.65)
1640
- now = time.time()
1641
- if now - last_prog >= 0.2:
1642
- send(ser, f"PROGRESS:{pct}%")
1643
- last_prog = now
1644
- except (KeyboardInterrupt, SystemExit):
1645
- stop_process(proc)
1646
- raise
1660
+ rc, out_lines = -1, []
1661
+ for i, driver in enumerate(drivers_to_try):
1662
+ if i > 0:
1663
+ print(f"cdrdao: write failed with previous driver - retrying with --driver {driver or 'auto'}")
1664
+ send(ser, "STATUS:Retrying burn...")
1665
+ rc, lines = _run_cdrdao(driver)
1666
+ out_lines += ([f"--- driver {driver or 'auto'} ---"] if i else []) + lines
1667
+ if rc == 0 or rc == -15:
1668
+ break
1669
+ if rc != 0 and rc != -15 and not override:
1670
+ print("cdrdao: all driver modes failed - retrying without CD-TEXT")
1671
+ send(ser, "STATUS:Retrying without CD-TEXT...")
1672
+ _write_toc(include_cdtext=False)
1673
+ rc2, lines2 = _run_cdrdao(None)
1674
+ out_lines += ["--- retry without CD-TEXT ---"] + lines2
1675
+ rc = rc2
1647
1676
  finally:
1648
1677
  for w in tmp_dir.glob("*.wav"):
1649
1678
  w.unlink(missing_ok=True)
1650
1679
  toc_path.unlink(missing_ok=True)
1651
1680
  shutil.rmtree(str(tmp_dir), ignore_errors=True)
1652
- rc = proc.wait()
1653
1681
  log_path.write_text("\n".join(out_lines) + "\n")
1654
1682
  if rc != 0:
1655
1683
  for line in out_lines[-10:]:
package/src/static/app.js CHANGED
@@ -92,6 +92,10 @@
92
92
  function applyRemoteState(progress) {
93
93
  const hardware = progress.appliance === "hardware";
94
94
  if (hardware && !$("remote-panel").hidden) setRemoteVisible(false, true);
95
+ // Software mode has no other control surface at all - don't make it
96
+ // opt-in-via-logo-click to discover the only way to actually use the
97
+ // appliance. Only forces it open (never closes it back on the user).
98
+ if (!hardware && $("remote-panel").hidden) setRemoteVisible(true, true);
95
99
  $("remote-note").textContent = hardware
96
100
  ? "A physical remote is attached — on-screen controls are disabled."
97
101
  : "No physical remote detected — control DiscStation from here.";
@@ -119,9 +123,20 @@
119
123
  });
120
124
  setRemoteVisible(localStorage.getItem(remoteKey) === "1");
121
125
 
122
- $("remote-controls").addEventListener("click", (event) => {
126
+ $("remote-controls").addEventListener("click", async (event) => {
123
127
  const button = event.target.closest("[data-cmd]");
124
- if (button && !button.disabled) sendRemoteCmd(button.dataset.cmd);
128
+ if (!button || button.disabled) return;
129
+ const cmd = button.dataset.cmd;
130
+ await sendRemoteCmd(cmd);
131
+ // On real hardware START is a long-press of the encoder on the burn-ready
132
+ // review screen, not its own button - a one-click "BURN AUDIO"/"BURN
133
+ // DATA"/"BURN" here is the whole point of the web remote, so chase the
134
+ // mode select straight through to starting the burn instead of leaving
135
+ // the user stuck with nothing left to press. (The backend queues this
136
+ // harmlessly if files/URL aren't in yet - it's only consumed once the
137
+ // burn's actually at its own "waiting for start" step, and gets flushed
138
+ // if a different mode gets selected first so it can't fire the wrong burn.)
139
+ if (cmd.startsWith("SELECT:BURN")) await sendRemoteCmd("START");
125
140
  });
126
141
  let volumeTimer;
127
142
  $("remote-volume").addEventListener("input", (event) => {
@@ -130,11 +145,25 @@
130
145
  volumeTimer = setTimeout(() => sendRemoteCmd(`POT:${value}`), 150);
131
146
  });
132
147
 
148
+ // Mode-select buttons this server-computed list governs - CANCEL/EJECT are
149
+ // controls, not modes, and stay available regardless (same as hardware).
150
+ const MENU_MODES = ["BURN", "BURN DATA", "BURN AUDIO", "RIP", "PLAY"];
151
+
152
+ function applyMenuItems(items) {
153
+ if (!items) return; // unknown (fetch error, etc.) - leave as-is
154
+ const allowed = new Set(items);
155
+ MENU_MODES.forEach((mode) => {
156
+ const btn = document.querySelector(`#remote-controls [data-cmd="SELECT:${mode}"]`);
157
+ if (btn) btn.hidden = !allowed.has(mode);
158
+ });
159
+ }
160
+
133
161
  async function loadDiscInfo() {
134
162
  try {
135
163
  const response = await fetch("/disc-info", { cache: "no-store" });
136
164
  const info = await response.json();
137
165
  renderDiscStatus(info);
166
+ applyMenuItems(info.menu_items);
138
167
  if (info.busy) return; // burn/rip in progress — keep current
139
168
  state.discBytes = Number(info.capacity_bytes || 0);
140
169
  state.discType = info.type || "none";
@@ -150,7 +179,7 @@
150
179
  const el = $("remote-disc-status");
151
180
  if (!el) return;
152
181
  if (!info) { el.textContent = "DISC: UNKNOWN"; return; }
153
- if (info.busy) { el.textContent = "DISC: BUSY (see status above)"; return; }
182
+ if (info.busy) { el.textContent = "DISC: BUSY"; return; }
154
183
  if (!info.disc_present) { el.textContent = "DISC: NONE"; return; }
155
184
  const kind = (info.type || info.kind || "unknown").toUpperCase();
156
185
  const label = info.label ? ` "${info.label}"` : "";