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 +9 -5
- package/install-macos.sh +1 -1
- package/package.json +1 -1
- package/src/discstation.py +113 -26
- package/src/discstation_burn.py +80 -52
- package/src/static/app.js +32 -3
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** —
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
package/src/discstation.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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"
|
|
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
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
3571
|
-
#
|
|
3572
|
-
#
|
|
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.
|
package/src/discstation_burn.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
1611
|
-
#
|
|
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
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
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
|
|
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
|
|
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}"` : "";
|