discstation 0.1.34 → 0.1.37
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 +19 -12
- package/arduino/DiscStation/DiscStation.ino +13 -18
- package/discstation.env.example +1 -1
- package/docs/PLATFORM_SUPPORT.md +13 -31
- package/install-macos.sh +3 -6
- package/install.sh +2 -1
- package/package.json +1 -1
- package/requirements-optional.txt +5 -7
- package/scripts/check.sh +61 -0
- package/src/discstation.py +254 -733
- package/src/discstation_burn.py +18 -207
- package/src/static/app.js +32 -3
- package/src/static/style.css +0 -7
- package/src/win/_imapi.ps1 +66 -0
- package/src/win/audio-toc.ps1 +1 -9
- package/src/win/burn-audio.ps1 +2 -14
- package/src/win/burn-data.ps1 +4 -47
- package/src/win/burn-image.ps1 +4 -53
package/src/discstation.py
CHANGED
|
@@ -3,6 +3,7 @@ import argparse
|
|
|
3
3
|
import atexit
|
|
4
4
|
import collections
|
|
5
5
|
import concurrent.futures
|
|
6
|
+
import contextlib
|
|
6
7
|
import errno
|
|
7
8
|
try:
|
|
8
9
|
import fcntl # POSIX-only; used only in drive_status()'s Linux branch
|
|
@@ -12,10 +13,6 @@ import json
|
|
|
12
13
|
import mimetypes
|
|
13
14
|
import os
|
|
14
15
|
import signal
|
|
15
|
-
try:
|
|
16
|
-
import pwd
|
|
17
|
-
except ImportError:
|
|
18
|
-
pwd = None
|
|
19
16
|
import re
|
|
20
17
|
import shutil
|
|
21
18
|
import socket
|
|
@@ -27,6 +24,7 @@ import subprocess
|
|
|
27
24
|
import sys
|
|
28
25
|
import tempfile
|
|
29
26
|
import time
|
|
27
|
+
import types
|
|
30
28
|
import datetime
|
|
31
29
|
from pathlib import Path
|
|
32
30
|
from queue import Queue, Empty, Full
|
|
@@ -59,6 +57,7 @@ _web_status = "READY"
|
|
|
59
57
|
_web_progress = -1
|
|
60
58
|
_web_progress_active = False
|
|
61
59
|
_web_playing = False # a play_flow is currently active (transport controls apply)
|
|
60
|
+
_web_op_verb = "BURNING" # progress-bar verb for the current PROGRESS: stream
|
|
62
61
|
_operation_active = False # a burn/rip/play flow is holding the drive
|
|
63
62
|
_last_disc_info = {"disc_present": False, "capacity_bytes": 0, "capacity_gb": 0, "type": "none"}
|
|
64
63
|
_active_ser = None
|
|
@@ -85,6 +84,14 @@ class VirtualSerial:
|
|
|
85
84
|
with self._lock:
|
|
86
85
|
self._buf += text.encode(errors="ignore").strip() + b"\n"
|
|
87
86
|
|
|
87
|
+
def clear(self):
|
|
88
|
+
"""Drop any unconsumed input - used when a fresh SELECT: comes in so a
|
|
89
|
+
START that was queued for an abandoned earlier selection (the web
|
|
90
|
+
remote's mode buttons queue it right behind SELECT: so a one-click
|
|
91
|
+
burn works) can't leak forward and fire a different, unintended burn."""
|
|
92
|
+
with self._lock:
|
|
93
|
+
self._buf = b""
|
|
94
|
+
|
|
88
95
|
@property
|
|
89
96
|
def in_waiting(self):
|
|
90
97
|
with self._lock:
|
|
@@ -296,7 +303,14 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
296
303
|
_burn_url_queue.put(str(upload_dir))
|
|
297
304
|
size_str = f"{total / 1e6:.1f}MB" if total > 1e6 else f"{total / 1e3:.0f}KB"
|
|
298
305
|
_set_web_progress("UPLOAD READY", 100)
|
|
299
|
-
|
|
306
|
+
# Used to hardcode "Select BURN DATA on remote" - wrong mode name for
|
|
307
|
+
# an audio upload, and assumes hardware that may not exist. A mode
|
|
308
|
+
# may also already be selected (the one-click remote flow queues the
|
|
309
|
+
# burn before upload finishes), so this is just a status line, not
|
|
310
|
+
# an instruction to a specific next step.
|
|
311
|
+
tip = ("Select a mode on your remote." if _appliance_mode == "hardware"
|
|
312
|
+
else "Choose a burn mode below to start.")
|
|
313
|
+
self._respond(200, f'{len(files)} file(s) uploaded ({size_str}). {tip}')
|
|
300
314
|
|
|
301
315
|
def _handle_remote_button(self):
|
|
302
316
|
"""Web on-screen remote -> the exact same text-line protocol the
|
|
@@ -319,7 +333,9 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
319
333
|
def _serve_disc_info(self):
|
|
320
334
|
if _operation_active:
|
|
321
335
|
# a burn/rip/play holds the drive — don't probe it, serve last-known.
|
|
322
|
-
|
|
336
|
+
# menu_items empty: don't invite starting a second op on top of
|
|
337
|
+
# the one already running (CANCEL/EJECT stay available regardless).
|
|
338
|
+
self._respond(200, json.dumps({**_last_disc_info, "busy": True, "menu_items": [], "appliance": _appliance_mode}), "application/json")
|
|
323
339
|
return
|
|
324
340
|
info = {"disc_present": False, "capacity_bytes": 0, "capacity_gb": 0, "type": "none"}
|
|
325
341
|
try:
|
|
@@ -336,6 +352,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
336
352
|
info["type"] = di.web_type
|
|
337
353
|
info["kind"] = di.kind
|
|
338
354
|
info["label"] = di.label
|
|
355
|
+
info["menu_items"] = menu_items_for_disc(device) if di.present and not di.transient else []
|
|
339
356
|
except Exception as e:
|
|
340
357
|
print(f"Disc info error: {e}")
|
|
341
358
|
info["appliance"] = _appliance_mode
|
|
@@ -564,14 +581,13 @@ def start_web_server(port=8080):
|
|
|
564
581
|
|
|
565
582
|
|
|
566
583
|
def local_ip():
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
pass
|
|
584
|
+
# `hostname -I`'s first non-loopback address used to be the shortcut here,
|
|
585
|
+
# but it lists every interface with no notion of "the real one" - once
|
|
586
|
+
# Docker's docker0/br-* bridges (172.17/18.x, unreachable from outside
|
|
587
|
+
# this machine) exist, they can sort before the actual LAN NIC and get
|
|
588
|
+
# picked instead, showing a dead URL on the OLED/web remote. Asking the
|
|
589
|
+
# kernel what source address it'd use to reach the outside world sidesteps
|
|
590
|
+
# that entirely - Docker's bridges aren't in that route, no filtering needed.
|
|
575
591
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
576
592
|
try:
|
|
577
593
|
s.connect(('8.8.8.8', 80))
|
|
@@ -758,7 +774,7 @@ def _record_web_status(msg):
|
|
|
758
774
|
_web_progress_active = True
|
|
759
775
|
elif msg.startswith("PROGRESS:"):
|
|
760
776
|
value = msg[9:].strip()
|
|
761
|
-
_web_status = f"
|
|
777
|
+
_web_status = f"{_web_op_verb} {value}"
|
|
762
778
|
match = re.search(r"(\d+(?:\.\d+)?)", value)
|
|
763
779
|
if match:
|
|
764
780
|
_web_progress = min(100, max(0, int(float(match.group(1)))))
|
|
@@ -801,53 +817,6 @@ def safe_send(ser, msg):
|
|
|
801
817
|
discstation_burn.status_sink = _record_web_status
|
|
802
818
|
|
|
803
819
|
|
|
804
|
-
def run_as_desktop_user(cmd):
|
|
805
|
-
if os.name != "posix" or pwd is None:
|
|
806
|
-
return cmd
|
|
807
|
-
sudo_user = os.environ.get("SUDO_USER")
|
|
808
|
-
if os.geteuid() == 0 and sudo_user and sudo_user != "root":
|
|
809
|
-
home = Path(discstation_burn.USER_HOME)
|
|
810
|
-
uid = pwd.getpwnam(sudo_user).pw_uid
|
|
811
|
-
return [
|
|
812
|
-
"sudo", "-u", sudo_user,
|
|
813
|
-
"env",
|
|
814
|
-
"DISPLAY=" + os.environ.get("DISPLAY", ":0"),
|
|
815
|
-
"XAUTHORITY=" + str(home / ".Xauthority"),
|
|
816
|
-
"XDG_RUNTIME_DIR=/run/user/" + str(uid),
|
|
817
|
-
*cmd,
|
|
818
|
-
]
|
|
819
|
-
return cmd
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
def chown_to_sudo_user(path):
|
|
823
|
-
sudo_user = os.environ.get("SUDO_USER")
|
|
824
|
-
if os.name != "posix" or pwd is None or not hasattr(os, "geteuid"):
|
|
825
|
-
return
|
|
826
|
-
if os.geteuid() != 0 or not sudo_user or sudo_user == "root":
|
|
827
|
-
return
|
|
828
|
-
|
|
829
|
-
try:
|
|
830
|
-
pw_record = pwd.getpwnam(sudo_user)
|
|
831
|
-
except KeyError:
|
|
832
|
-
return
|
|
833
|
-
|
|
834
|
-
uid = pw_record.pw_uid
|
|
835
|
-
gid = pw_record.pw_gid
|
|
836
|
-
root_path = Path(path)
|
|
837
|
-
|
|
838
|
-
for current_root, dirs, files in os.walk(root_path):
|
|
839
|
-
try:
|
|
840
|
-
os.chown(current_root, uid, gid)
|
|
841
|
-
except OSError:
|
|
842
|
-
pass
|
|
843
|
-
for name in dirs + files:
|
|
844
|
-
item = Path(current_root) / name
|
|
845
|
-
try:
|
|
846
|
-
os.chown(item, uid, gid)
|
|
847
|
-
except OSError:
|
|
848
|
-
pass
|
|
849
|
-
|
|
850
|
-
|
|
851
820
|
def _mpv_ipc(payload, timeout=None):
|
|
852
821
|
"""Send one JSON line to mpv's IPC endpoint. Windows = named pipe, POSIX =
|
|
853
822
|
AF_UNIX socket. Returns the raw reply bytes (b"" if not read), or raises OSError."""
|
|
@@ -918,7 +887,7 @@ def read_serial_line(ser, timeout=0.1):
|
|
|
918
887
|
# Always make at least one non-blocking pass, even for timeout<=0 - the
|
|
919
888
|
# `remaining <= 0: break` at the bottom still ends it after that pass.
|
|
920
889
|
# (`while time.monotonic() < deadline` used to skip the body entirely for
|
|
921
|
-
# timeout=0, which is exactly how
|
|
890
|
+
# timeout=0, which is exactly how check_cancel() calls this - so cancel
|
|
922
891
|
# detection during rips silently never read the port.)
|
|
923
892
|
while True:
|
|
924
893
|
if _line_buf:
|
|
@@ -973,7 +942,16 @@ def check_serial_alive(ser=None):
|
|
|
973
942
|
"""Raise serial.SerialException if the ESP32 link looks dead, so main()'s
|
|
974
943
|
reconnect loop can re-scan for the (possibly renumbered) serial port.
|
|
975
944
|
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).
|
|
945
|
+
stale handle (writes to a re-enumerated /dev/ttyUSBN fail silently).
|
|
946
|
+
|
|
947
|
+
Meaningless (and actively harmful) in pure web/software mode - there's no
|
|
948
|
+
ESP32 to go quiet, and serial_activity_age() only advances on real
|
|
949
|
+
incoming bytes, so a user just reading the screen for >35s before
|
|
950
|
+
clicking the next button on the web remote looked identical to a dead
|
|
951
|
+
link and killed the burn ("ESP32 not responding") with no ESP32 in the
|
|
952
|
+
picture at all."""
|
|
953
|
+
if isinstance(ser, VirtualSerial):
|
|
954
|
+
return
|
|
977
955
|
if discstation_burn.serial_write_failed():
|
|
978
956
|
raise serial.SerialException("serial write failed (ESP32 link lost)")
|
|
979
957
|
if discstation_burn.serial_activity_age() >= 35:
|
|
@@ -1048,10 +1026,16 @@ def eject_disc(ser, device):
|
|
|
1048
1026
|
_tray_open = True
|
|
1049
1027
|
_tray_open_since = time.monotonic()
|
|
1050
1028
|
safe_send(ser, "WAITING:Press SELECT/to close tray")
|
|
1029
|
+
# WAITING: isn't in _record_web_status()'s prefix whitelist, so the
|
|
1030
|
+
# send above never reaches the web page - _tray_open has to be
|
|
1031
|
+
# published explicitly here, same as DISC: gets its own dedicated
|
|
1032
|
+
# publish for the same reason.
|
|
1033
|
+
_sse_publish(_status_snapshot())
|
|
1051
1034
|
last_ping = time.time()
|
|
1052
1035
|
deadline = time.time() + 60
|
|
1053
1036
|
tray_was_cancelled = False
|
|
1054
1037
|
last_status_check = 0
|
|
1038
|
+
closed_confirms = 0
|
|
1055
1039
|
# Let the eject settle before touching the drive again (the reclose guard).
|
|
1056
1040
|
settle_until = time.time() + 3
|
|
1057
1041
|
while time.time() < deadline:
|
|
@@ -1061,9 +1045,20 @@ def eject_disc(ser, device):
|
|
|
1061
1045
|
if time.time() >= settle_until and time.time() - last_status_check >= 1.5:
|
|
1062
1046
|
last_status_check = time.time()
|
|
1063
1047
|
if drive_status(device) in ("disc", "no_disc"):
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1048
|
+
# This USB-ATAPI bridge's status reporting is known flaky
|
|
1049
|
+
# (see udev_cdrom_properties' own docstring) - one read
|
|
1050
|
+
# right after an eject was seen live to falsely report
|
|
1051
|
+
# closed, reclosing the wait loop within ~11s of a real
|
|
1052
|
+
# eject. Require two consecutive agreeing reads before
|
|
1053
|
+
# believing it.
|
|
1054
|
+
closed_confirms += 1
|
|
1055
|
+
if closed_confirms >= 2:
|
|
1056
|
+
print("Tray closed — continuing")
|
|
1057
|
+
_tray_open = False
|
|
1058
|
+
_sse_publish(_status_snapshot())
|
|
1059
|
+
break
|
|
1060
|
+
else:
|
|
1061
|
+
closed_confirms = 0
|
|
1067
1062
|
line = read_serial_line(ser, timeout=0.1)
|
|
1068
1063
|
if not line:
|
|
1069
1064
|
continue
|
|
@@ -1080,6 +1075,7 @@ def eject_disc(ser, device):
|
|
|
1080
1075
|
r = subprocess.run(close_cmd, timeout=10, capture_output=True)
|
|
1081
1076
|
if r.returncode == 0:
|
|
1082
1077
|
_tray_open = False
|
|
1078
|
+
_sse_publish(_status_snapshot())
|
|
1083
1079
|
break
|
|
1084
1080
|
except Exception:
|
|
1085
1081
|
pass
|
|
@@ -1129,6 +1125,67 @@ def append_burn_history(entry):
|
|
|
1129
1125
|
f.write(json.dumps(entry) + "\n")
|
|
1130
1126
|
|
|
1131
1127
|
|
|
1128
|
+
@contextlib.contextmanager
|
|
1129
|
+
def _burn_history(entry, swallow_cancel=False):
|
|
1130
|
+
"""Record a burn attempt in the history file however the block ends
|
|
1131
|
+
(success / cancelled / error). `entry` holds the fixed fields (title,
|
|
1132
|
+
disc_type, mode, speed, ...). A cancelled burn re-raises unless
|
|
1133
|
+
swallow_cancel, in which case the caller checks `.cancelled` and returns."""
|
|
1134
|
+
start = time.time()
|
|
1135
|
+
outcome = types.SimpleNamespace(cancelled=False)
|
|
1136
|
+
|
|
1137
|
+
def record(ok, error=None):
|
|
1138
|
+
row = {"timestamp": datetime.datetime.now().isoformat(), **entry,
|
|
1139
|
+
"success": ok, "duration_s": round(time.time() - start)}
|
|
1140
|
+
if error:
|
|
1141
|
+
row["error"] = error
|
|
1142
|
+
append_burn_history(row)
|
|
1143
|
+
|
|
1144
|
+
try:
|
|
1145
|
+
yield outcome
|
|
1146
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1147
|
+
record(False, "Cancelled")
|
|
1148
|
+
raise
|
|
1149
|
+
except CancelError:
|
|
1150
|
+
record(False, "Cancelled")
|
|
1151
|
+
if not swallow_cancel:
|
|
1152
|
+
raise
|
|
1153
|
+
outcome.cancelled = True
|
|
1154
|
+
except Exception as e:
|
|
1155
|
+
record(False, str(e)[:100])
|
|
1156
|
+
raise
|
|
1157
|
+
else:
|
|
1158
|
+
record(True)
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _wait_for_start(ser, starting=None, mode=None):
|
|
1162
|
+
"""Wait on the remote for START (CANCEL/STOP aborts). Returns
|
|
1163
|
+
(mode, speed) - speed is None unless a SPEED: line came first - or None if
|
|
1164
|
+
the user cancelled. A non-None `mode` (the video flow's) may also be set by
|
|
1165
|
+
MODE:<m> / START:<m>. `starting` names the burn in the status line
|
|
1166
|
+
(defaults to the mode)."""
|
|
1167
|
+
speed = None
|
|
1168
|
+
print("Waiting for burn START button...")
|
|
1169
|
+
while True:
|
|
1170
|
+
line = wait_for_button(ser)
|
|
1171
|
+
if line in ("CANCEL", "PLAY_STOP"):
|
|
1172
|
+
safe_send(ser, "CANCELLED:Cancelled")
|
|
1173
|
+
print("Burn cancelled by user")
|
|
1174
|
+
return None
|
|
1175
|
+
if line.startswith("MODE:") and mode is not None:
|
|
1176
|
+
mode = discstation_burn.normalize_mode(line.split(":", 1)[1])
|
|
1177
|
+
print(f"Burn mode: {mode}")
|
|
1178
|
+
elif line.startswith("SPEED:"):
|
|
1179
|
+
speed = line.split(":", 1)[1].strip()
|
|
1180
|
+
print(f"Burn speed: {speed}")
|
|
1181
|
+
elif line == "START" or line.startswith("START:"):
|
|
1182
|
+
if mode is not None and ":" in line:
|
|
1183
|
+
mode = discstation_burn.normalize_mode(line.split(":", 1)[1])
|
|
1184
|
+
print(f"Starting {starting or mode} burn")
|
|
1185
|
+
send(ser, f"STATUS:Starting {starting or mode}...")
|
|
1186
|
+
return mode, speed
|
|
1187
|
+
|
|
1188
|
+
|
|
1132
1189
|
def run_probe(cmd, timeout=8, name=None):
|
|
1133
1190
|
try:
|
|
1134
1191
|
return subprocess.run(
|
|
@@ -1248,60 +1305,6 @@ def _device_present(device):
|
|
|
1248
1305
|
return False
|
|
1249
1306
|
|
|
1250
1307
|
|
|
1251
|
-
def _tray_closed_with_disc(device):
|
|
1252
|
-
if not device:
|
|
1253
|
-
return False
|
|
1254
|
-
global _tray_open
|
|
1255
|
-
if not _device_present(device):
|
|
1256
|
-
return False
|
|
1257
|
-
properties = udev_cdrom_properties(device)
|
|
1258
|
-
if properties.get("ID_CDROM_MEDIA") == "1":
|
|
1259
|
-
_tray_open = False
|
|
1260
|
-
return True
|
|
1261
|
-
if properties.get("ID_CDROM_MEDIA_STATE") == "blank":
|
|
1262
|
-
_tray_open = False
|
|
1263
|
-
return True
|
|
1264
|
-
return False
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
def disc_present(device):
|
|
1268
|
-
if not device:
|
|
1269
|
-
return False
|
|
1270
|
-
if _tray_closed_with_disc(device):
|
|
1271
|
-
return True
|
|
1272
|
-
if _tray_open:
|
|
1273
|
-
return False
|
|
1274
|
-
if not _device_present(device):
|
|
1275
|
-
return False
|
|
1276
|
-
if discstation_host.system_name() != "linux":
|
|
1277
|
-
properties = udev_cdrom_properties(device)
|
|
1278
|
-
return properties.get("ID_CDROM_MEDIA") == "1"
|
|
1279
|
-
|
|
1280
|
-
toc = run_probe(["wodim", "-toc", "dev=" + device], name="wodim-toc", timeout=PROBE_TIMEOUT_WODIM_TOC)
|
|
1281
|
-
toc_text = ensure_text(toc.stdout) + ensure_text(toc.stderr)
|
|
1282
|
-
no_media_markers = _NO_MEDIA_MARKERS
|
|
1283
|
-
if toc.returncode == 124:
|
|
1284
|
-
return is_blank_disc(device)
|
|
1285
|
-
if toc_text.strip() and any(marker in toc_text.lower() for marker in no_media_markers):
|
|
1286
|
-
return False
|
|
1287
|
-
|
|
1288
|
-
dvd = run_probe(["lsdvd", device], name="lsdvd", timeout=PROBE_TIMEOUT_LSDVD)
|
|
1289
|
-
if dvd.returncode == 0:
|
|
1290
|
-
return True
|
|
1291
|
-
|
|
1292
|
-
fs = run_probe(["blkid", "-o", "value", "-s", "TYPE", device], name="blkid", timeout=PROBE_TIMEOUT_BLKID)
|
|
1293
|
-
if fs.returncode == 0 and ensure_text(fs.stdout).strip():
|
|
1294
|
-
return True
|
|
1295
|
-
|
|
1296
|
-
if "first:" in toc_text and "track:" in toc_text:
|
|
1297
|
-
return True
|
|
1298
|
-
|
|
1299
|
-
if toc_text.strip() and not any(marker in toc_text.lower() for marker in no_media_markers):
|
|
1300
|
-
return True
|
|
1301
|
-
|
|
1302
|
-
return is_blank_disc(device)
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
1308
|
def is_blank_disc(device):
|
|
1306
1309
|
if not device:
|
|
1307
1310
|
return False
|
|
@@ -1863,10 +1866,13 @@ def menu_items_for_disc(device):
|
|
|
1863
1866
|
kind = disc_kind(device)
|
|
1864
1867
|
items = []
|
|
1865
1868
|
if kind == "blank" or is_rewritable_disc(device):
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1869
|
+
# CD-R/RW can't hold a DVD-video authoring job (nowhere near the
|
|
1870
|
+
# space) and DVD blanks can't take Red Book audio (wrong format
|
|
1871
|
+
# entirely, would just fail) - offer only what's physically possible
|
|
1872
|
+
# for the media that's actually in the drive.
|
|
1873
|
+
props = udev_cdrom_properties(device)
|
|
1874
|
+
is_cd = props.get("ID_CDROM_MEDIA_CD_R") == "1" or props.get("ID_CDROM_MEDIA_CD_RW") == "1"
|
|
1875
|
+
items = ["BURN DATA", "BURN AUDIO"] if is_cd else ["BURN", "BURN DATA"]
|
|
1870
1876
|
elif kind in ("dvd_video", "audio_cd", "vcd", "svcd", "video_data", "data_disc", "data_cd"):
|
|
1871
1877
|
items = ["PLAY", "RIP"]
|
|
1872
1878
|
else:
|
|
@@ -2166,10 +2172,6 @@ def _release_meta(release, track_count, toc=None):
|
|
|
2166
2172
|
return None
|
|
2167
2173
|
|
|
2168
2174
|
|
|
2169
|
-
# Back-compat alias (older name).
|
|
2170
|
-
metadata_from_musicbrainz_release = _release_meta
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
2175
|
def musicbrainz_release_details(release_id):
|
|
2174
2176
|
if _mb is not None:
|
|
2175
2177
|
return _mb.get_release_by_id(
|
|
@@ -2226,58 +2228,6 @@ def musicbrainz_lookup(device, track_count):
|
|
|
2226
2228
|
return None
|
|
2227
2229
|
|
|
2228
2230
|
|
|
2229
|
-
def musicbrainz_lookup_by_album_hints(album_artist, album, track_count):
|
|
2230
|
-
if not album:
|
|
2231
|
-
return None
|
|
2232
|
-
|
|
2233
|
-
if _mb is not None:
|
|
2234
|
-
fields = {"release": album}
|
|
2235
|
-
if album_artist:
|
|
2236
|
-
fields["artist"] = album_artist
|
|
2237
|
-
try:
|
|
2238
|
-
hits = _mb.search_releases(limit=8, **fields).get("release-list", [])
|
|
2239
|
-
except _mb.WebServiceError as e:
|
|
2240
|
-
print(f"MusicBrainz search failed: {e}")
|
|
2241
|
-
hits = []
|
|
2242
|
-
for hit in hits:
|
|
2243
|
-
release_id = hit.get("id")
|
|
2244
|
-
if not release_id:
|
|
2245
|
-
continue
|
|
2246
|
-
try:
|
|
2247
|
-
details = musicbrainz_release_details(release_id)
|
|
2248
|
-
except Exception:
|
|
2249
|
-
continue
|
|
2250
|
-
metadata = _release_meta(details, track_count)
|
|
2251
|
-
if metadata:
|
|
2252
|
-
metadata["source"] = "musicbrainz-search"
|
|
2253
|
-
return metadata
|
|
2254
|
-
return None
|
|
2255
|
-
|
|
2256
|
-
# --- fallback: raw ws/2 search ---
|
|
2257
|
-
query_parts = [f'release:"{album}"']
|
|
2258
|
-
if album_artist:
|
|
2259
|
-
query_parts.append(f'artist:"{album_artist}"')
|
|
2260
|
-
response = requests.get(
|
|
2261
|
-
"https://musicbrainz.org/ws/2/release/",
|
|
2262
|
-
params={"query": " AND ".join(query_parts), "fmt": "json", "limit": 8},
|
|
2263
|
-
headers={"User-Agent": USER_AGENT}, timeout=20,
|
|
2264
|
-
)
|
|
2265
|
-
response.raise_for_status()
|
|
2266
|
-
for release in response.json().get("releases", []):
|
|
2267
|
-
release_id = release.get("id")
|
|
2268
|
-
if not release_id:
|
|
2269
|
-
continue
|
|
2270
|
-
try:
|
|
2271
|
-
details = musicbrainz_release_details(release_id)
|
|
2272
|
-
except Exception:
|
|
2273
|
-
continue
|
|
2274
|
-
metadata = _release_meta(details, track_count)
|
|
2275
|
-
if metadata:
|
|
2276
|
-
metadata["source"] = "musicbrainz-search"
|
|
2277
|
-
return metadata
|
|
2278
|
-
time.sleep(1)
|
|
2279
|
-
return None
|
|
2280
|
-
|
|
2281
2231
|
def cddb_sum(value):
|
|
2282
2232
|
return sum(int(ch) for ch in str(value))
|
|
2283
2233
|
|
|
@@ -2410,7 +2360,7 @@ def musicbrainz_release_id_search(album_artist, album):
|
|
|
2410
2360
|
return releases[0].get("id") if releases else None
|
|
2411
2361
|
|
|
2412
2362
|
|
|
2413
|
-
def audio_metadata_lookup(device, track_count
|
|
2363
|
+
def audio_metadata_lookup(device, track_count):
|
|
2414
2364
|
metadata = None
|
|
2415
2365
|
|
|
2416
2366
|
for attempt in range(2):
|
|
@@ -2423,17 +2373,6 @@ def audio_metadata_lookup(device, track_count, artist_hint=None, album_hint=None
|
|
|
2423
2373
|
print(f"MusicBrainz lookup failed, retrying... ({e})")
|
|
2424
2374
|
time.sleep(1)
|
|
2425
2375
|
|
|
2426
|
-
if not metadata and album_hint:
|
|
2427
|
-
for attempt in range(2):
|
|
2428
|
-
try:
|
|
2429
|
-
metadata = musicbrainz_lookup_by_album_hints(artist_hint, album_hint, track_count)
|
|
2430
|
-
if metadata:
|
|
2431
|
-
break
|
|
2432
|
-
except Exception as e:
|
|
2433
|
-
if attempt == 0:
|
|
2434
|
-
print(f"MusicBrainz album search failed, retrying... ({e})")
|
|
2435
|
-
time.sleep(1)
|
|
2436
|
-
|
|
2437
2376
|
if metadata and not metadata.get("release_id"):
|
|
2438
2377
|
try:
|
|
2439
2378
|
metadata["release_id"] = musicbrainz_release_id_search(
|
|
@@ -2548,60 +2487,6 @@ def tag_flac(path, track_meta, album_meta, cover_path):
|
|
|
2548
2487
|
audio.save()
|
|
2549
2488
|
|
|
2550
2489
|
|
|
2551
|
-
def retag_audio_rip(rip_dir, artist_hint, album_hint):
|
|
2552
|
-
rip_dir = Path(rip_dir)
|
|
2553
|
-
flacs = sorted(rip_dir.glob("*.flac"))
|
|
2554
|
-
if not flacs:
|
|
2555
|
-
raise RuntimeError(f"No FLAC files found in {rip_dir}")
|
|
2556
|
-
if not album_hint:
|
|
2557
|
-
raise RuntimeError("Retag needs --album")
|
|
2558
|
-
|
|
2559
|
-
metadata = musicbrainz_lookup_by_album_hints(artist_hint, album_hint, len(flacs))
|
|
2560
|
-
if not metadata:
|
|
2561
|
-
raise RuntimeError("Could not find album metadata")
|
|
2562
|
-
|
|
2563
|
-
target_dir = unique_dir(RIP_ROOT / safe_path_name(f"{metadata['album_artist']} - {metadata['album']}"))
|
|
2564
|
-
if rip_dir != target_dir:
|
|
2565
|
-
rip_dir.rename(target_dir)
|
|
2566
|
-
else:
|
|
2567
|
-
target_dir = rip_dir
|
|
2568
|
-
|
|
2569
|
-
write_album_info(target_dir, metadata)
|
|
2570
|
-
cover_path = download_cover_art(
|
|
2571
|
-
metadata.get("release_id"),
|
|
2572
|
-
target_dir,
|
|
2573
|
-
metadata.get("release_group_id"),
|
|
2574
|
-
)
|
|
2575
|
-
|
|
2576
|
-
renamed = []
|
|
2577
|
-
for index, src in enumerate(sorted(target_dir.glob("*.flac")), start=1):
|
|
2578
|
-
if index > len(metadata["tracks"]):
|
|
2579
|
-
break
|
|
2580
|
-
|
|
2581
|
-
track_meta = metadata["tracks"][index - 1]
|
|
2582
|
-
out_file = target_dir / f"{index:02d} - {safe_path_name(track_meta['title'])}.flac"
|
|
2583
|
-
if src != out_file:
|
|
2584
|
-
if out_file.exists():
|
|
2585
|
-
out_file.unlink()
|
|
2586
|
-
src.rename(out_file)
|
|
2587
|
-
|
|
2588
|
-
tag_flac(out_file, track_meta, metadata, cover_path)
|
|
2589
|
-
renamed.append(out_file)
|
|
2590
|
-
|
|
2591
|
-
chown_to_sudo_user(target_dir)
|
|
2592
|
-
return target_dir, cover_path, renamed
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
def latest_audio_rip_dir():
|
|
2596
|
-
candidates = sorted(
|
|
2597
|
-
path for path in RIP_ROOT.glob("audio_cd_*")
|
|
2598
|
-
if path.is_dir() and list(path.glob("*.flac"))
|
|
2599
|
-
)
|
|
2600
|
-
if not candidates:
|
|
2601
|
-
raise RuntimeError("No audio_cd_* rip folders found")
|
|
2602
|
-
return candidates[-1]
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
2490
|
def parse_ffmpeg_time(line):
|
|
2606
2491
|
marker = "time="
|
|
2607
2492
|
if marker not in line:
|
|
@@ -2617,41 +2502,22 @@ def parse_ffmpeg_time(line):
|
|
|
2617
2502
|
from discstation_burn import CancelError
|
|
2618
2503
|
|
|
2619
2504
|
|
|
2620
|
-
def _check_cancel(ser):
|
|
2621
|
-
try:
|
|
2622
|
-
line = read_serial_line(ser, timeout=0)
|
|
2623
|
-
return line in ("CANCEL", "PLAY_STOP") if line else False
|
|
2624
|
-
except OSError:
|
|
2625
|
-
return False
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
2505
|
def _raise_if_cancelled(ser):
|
|
2629
2506
|
"""Poll for a CANCEL press between blocking phases that have no
|
|
2630
2507
|
subprocess loop of their own (metadata lookups, scans, cover-art
|
|
2631
2508
|
downloads). Doesn't interrupt a call in progress, but catches the press
|
|
2632
2509
|
the moment the phase returns."""
|
|
2633
|
-
if
|
|
2510
|
+
if discstation_burn.check_cancel(ser):
|
|
2634
2511
|
raise CancelError
|
|
2635
2512
|
|
|
2636
2513
|
|
|
2637
2514
|
def iter_process_events(proc, idle_seconds=1.0, ser=None):
|
|
2638
|
-
lines =
|
|
2639
|
-
finished = object()
|
|
2640
|
-
|
|
2641
|
-
def read_output():
|
|
2642
|
-
try:
|
|
2643
|
-
for line in proc.stdout:
|
|
2644
|
-
lines.put(line.rstrip("\r\n"))
|
|
2645
|
-
finally:
|
|
2646
|
-
lines.put(finished)
|
|
2647
|
-
|
|
2648
|
-
reader = threading.Thread(target=read_output, daemon=True)
|
|
2649
|
-
reader.start()
|
|
2515
|
+
lines, finished = discstation_burn.proc_line_queue(proc)
|
|
2650
2516
|
last_ping = time.time()
|
|
2651
2517
|
output_done = False
|
|
2652
2518
|
while proc.poll() is None or not output_done:
|
|
2653
2519
|
if ser is not None:
|
|
2654
|
-
if
|
|
2520
|
+
if discstation_burn.check_cancel(ser):
|
|
2655
2521
|
discstation_burn.stop_process(proc)
|
|
2656
2522
|
raise CancelError
|
|
2657
2523
|
if time.time() - last_ping >= 5:
|
|
@@ -2666,7 +2532,6 @@ def iter_process_events(proc, idle_seconds=1.0, ser=None):
|
|
|
2666
2532
|
output_done = True
|
|
2667
2533
|
else:
|
|
2668
2534
|
yield line
|
|
2669
|
-
reader.join(timeout=1)
|
|
2670
2535
|
|
|
2671
2536
|
|
|
2672
2537
|
def rip_device(device):
|
|
@@ -2700,33 +2565,22 @@ def directory_size_bytes(path):
|
|
|
2700
2565
|
return total
|
|
2701
2566
|
|
|
2702
2567
|
|
|
2703
|
-
def
|
|
2704
|
-
"""
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2568
|
+
def _wait_for_source(ser, missing="URL or file path"):
|
|
2569
|
+
"""Block until the web remote supplies a source (an upload dir or a pasted
|
|
2570
|
+
URL/path). Returns None - after telling the remote why - if the user
|
|
2571
|
+
cancelled or sent nothing."""
|
|
2572
|
+
url = wait_for_web_url(ser)
|
|
2573
|
+
if url is None:
|
|
2574
|
+
safe_send(ser, "CANCELLED:Cancelled")
|
|
2575
|
+
elif not url:
|
|
2576
|
+
safe_send(ser, f"ERROR:Need {missing}")
|
|
2577
|
+
return url or None
|
|
2710
2578
|
|
|
2711
2579
|
|
|
2712
|
-
def burn_flow(ser
|
|
2580
|
+
def burn_flow(ser):
|
|
2581
|
+
url = _wait_for_source(ser)
|
|
2713
2582
|
if not url:
|
|
2714
|
-
|
|
2715
|
-
safe_send(ser, "STATUS:Enter URL or file path in terminal")
|
|
2716
|
-
print("=== Enter URL or file path below, then press Enter ===")
|
|
2717
|
-
try:
|
|
2718
|
-
url = sys.stdin.readline().strip()
|
|
2719
|
-
except (EOFError, KeyboardInterrupt, OSError):
|
|
2720
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
2721
|
-
return
|
|
2722
|
-
else:
|
|
2723
|
-
url = wait_for_web_url(ser)
|
|
2724
|
-
if url is None:
|
|
2725
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
2726
|
-
return
|
|
2727
|
-
if not url:
|
|
2728
|
-
safe_send(ser, "ERROR:Need URL or file path")
|
|
2729
|
-
return
|
|
2583
|
+
return
|
|
2730
2584
|
|
|
2731
2585
|
device = discstation_burn.disc_device()
|
|
2732
2586
|
disc_bytes = discstation_burn.disc_capacity_bytes(device)
|
|
@@ -2786,31 +2640,13 @@ def burn_flow(ser, url):
|
|
|
2786
2640
|
except RuntimeError:
|
|
2787
2641
|
pass
|
|
2788
2642
|
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
line = wait_for_button(ser)
|
|
2794
|
-
if line == "CANCEL" or line == "PLAY_STOP":
|
|
2795
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
2796
|
-
print("Burn cancelled by user")
|
|
2797
|
-
return
|
|
2798
|
-
if line.startswith("MODE:"):
|
|
2799
|
-
selected_mode = discstation_burn.normalize_mode(line.split(":", 1)[1])
|
|
2800
|
-
print(f"Burn mode: {selected_mode}")
|
|
2801
|
-
elif line.startswith("SPEED:"):
|
|
2802
|
-
burn_speed = line.split(":", 1)[1].strip()
|
|
2803
|
-
print(f"Burn speed: {burn_speed}")
|
|
2804
|
-
elif line == "START" or line.startswith("START:"):
|
|
2805
|
-
if ":" in line:
|
|
2806
|
-
selected_mode = discstation_burn.normalize_mode(line.split(":", 1)[1])
|
|
2807
|
-
print(f"Starting burn flow in {selected_mode} mode")
|
|
2808
|
-
send(ser, f"STATUS:Starting {selected_mode}...")
|
|
2809
|
-
break
|
|
2643
|
+
started = _wait_for_start(ser, mode="AUTO")
|
|
2644
|
+
if not started:
|
|
2645
|
+
return
|
|
2646
|
+
selected_mode, burn_speed = started
|
|
2810
2647
|
|
|
2811
|
-
start_time = time.time()
|
|
2812
2648
|
disc_type_label = "DL" if dl_info["is_dual_layer"] else "SL"
|
|
2813
|
-
|
|
2649
|
+
with _burn_history({"title": title, "disc_type": disc_type_label, "mode": selected_mode, "speed": burn_speed or "Auto"}):
|
|
2814
2650
|
plan = discstation_burn.bitrate_plan(duration, selected_mode, disc_bytes)
|
|
2815
2651
|
video = discstation_burn.download(ser, url, job_dir)
|
|
2816
2652
|
mpg, dvd_aspect = discstation_burn.convert(ser, video, job_dir, selected_mode, disc_bytes)
|
|
@@ -2834,121 +2670,9 @@ def burn_flow(ser, url):
|
|
|
2834
2670
|
safe_send(ser, "DONE:Test complete!")
|
|
2835
2671
|
print(f"Test complete. DVD folder: {dvd_dir}")
|
|
2836
2672
|
|
|
2837
|
-
append_burn_history({
|
|
2838
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
2839
|
-
"title": title,
|
|
2840
|
-
"disc_type": disc_type_label,
|
|
2841
|
-
"mode": selected_mode,
|
|
2842
|
-
"speed": burn_speed or "Auto",
|
|
2843
|
-
"success": True,
|
|
2844
|
-
"duration_s": round(time.time() - start_time),
|
|
2845
|
-
})
|
|
2846
|
-
except (KeyboardInterrupt, SystemExit):
|
|
2847
|
-
append_burn_history({
|
|
2848
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
2849
|
-
"title": title,
|
|
2850
|
-
"disc_type": disc_type_label,
|
|
2851
|
-
"mode": selected_mode,
|
|
2852
|
-
"speed": burn_speed or "Auto",
|
|
2853
|
-
"success": False,
|
|
2854
|
-
"error": "Cancelled",
|
|
2855
|
-
"duration_s": round(time.time() - start_time),
|
|
2856
|
-
})
|
|
2857
|
-
raise
|
|
2858
|
-
except Exception as e:
|
|
2859
|
-
append_burn_history({
|
|
2860
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
2861
|
-
"title": title,
|
|
2862
|
-
"disc_type": disc_type_label,
|
|
2863
|
-
"mode": selected_mode,
|
|
2864
|
-
"speed": burn_speed or "Auto",
|
|
2865
|
-
"success": False,
|
|
2866
|
-
"error": str(e)[:100],
|
|
2867
|
-
"duration_s": round(time.time() - start_time),
|
|
2868
|
-
})
|
|
2869
|
-
raise
|
|
2870
|
-
|
|
2871
2673
|
time.sleep(3)
|
|
2872
2674
|
|
|
2873
2675
|
|
|
2874
|
-
def _mpg_label(job_dir):
|
|
2875
|
-
label = "DVD_VIDEO"
|
|
2876
|
-
dl = job_dir / "download"
|
|
2877
|
-
if dl.is_dir():
|
|
2878
|
-
for f in sorted(dl.iterdir()):
|
|
2879
|
-
if f.suffix.lower() in discstation_burn.VIDEO_EXTS:
|
|
2880
|
-
label = discstation_burn.sanitize_disc_label(f.stem)
|
|
2881
|
-
break
|
|
2882
|
-
else:
|
|
2883
|
-
for f in sorted(dl.iterdir()):
|
|
2884
|
-
label = discstation_burn.sanitize_disc_label(f.stem)
|
|
2885
|
-
break
|
|
2886
|
-
if label == "DVD_VIDEO" or not label:
|
|
2887
|
-
label = discstation_burn.sanitize_disc_label(job_dir.name)
|
|
2888
|
-
return label
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
def burn_mpg_flow(ser):
|
|
2892
|
-
jobs = sorted(discstation_burn.WORK.glob("job_*"), reverse=True)
|
|
2893
|
-
candidates = []
|
|
2894
|
-
for jd in jobs:
|
|
2895
|
-
mpg = jd / "movie.mpg"
|
|
2896
|
-
if mpg.exists():
|
|
2897
|
-
label = _mpg_label(jd)
|
|
2898
|
-
candidates.append((mpg, label))
|
|
2899
|
-
names = [c[1][:20] for c in candidates] + ["Enter path..."]
|
|
2900
|
-
safe_send(ser, f"MENU_ITEMS:{','.join(names)}")
|
|
2901
|
-
safe_send(ser, "HOME:Select MPG to burn")
|
|
2902
|
-
mpg = None
|
|
2903
|
-
disc_label = None
|
|
2904
|
-
while True:
|
|
2905
|
-
line = read_serial_line(ser, timeout=0.5)
|
|
2906
|
-
if not line:
|
|
2907
|
-
continue
|
|
2908
|
-
if line.startswith("SELECT:"):
|
|
2909
|
-
sel = line.split(":", 1)[1].strip()
|
|
2910
|
-
if sel == "Enter path...":
|
|
2911
|
-
path_str = wait_for_web_url(ser)
|
|
2912
|
-
if path_str is None:
|
|
2913
|
-
refresh_main_menu(ser)
|
|
2914
|
-
return
|
|
2915
|
-
path_str = path_str.strip()
|
|
2916
|
-
p = Path(path_str)
|
|
2917
|
-
if not p.exists():
|
|
2918
|
-
safe_send(ser, "ERROR:Path not found")
|
|
2919
|
-
time.sleep(2)
|
|
2920
|
-
continue
|
|
2921
|
-
if p.is_dir():
|
|
2922
|
-
mpg = p / "movie.mpg"
|
|
2923
|
-
if not mpg.exists():
|
|
2924
|
-
safe_send(ser, "ERROR:No movie.mpg in dir")
|
|
2925
|
-
time.sleep(2)
|
|
2926
|
-
continue
|
|
2927
|
-
elif p.suffix.lower() == ".mpg":
|
|
2928
|
-
mpg = p
|
|
2929
|
-
else:
|
|
2930
|
-
safe_send(ser, "ERROR:Not an .mpg file")
|
|
2931
|
-
time.sleep(2)
|
|
2932
|
-
continue
|
|
2933
|
-
disc_label = discstation_burn.sanitize_disc_label(mpg.stem)
|
|
2934
|
-
break
|
|
2935
|
-
else:
|
|
2936
|
-
idx = next((i for i, n in enumerate(candidates) if n[1][:20] == sel), None)
|
|
2937
|
-
if idx is not None:
|
|
2938
|
-
mpg, disc_label = candidates[idx]
|
|
2939
|
-
break
|
|
2940
|
-
elif line == "CANCEL":
|
|
2941
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
2942
|
-
refresh_main_menu(ser)
|
|
2943
|
-
return
|
|
2944
|
-
time.sleep(0.05)
|
|
2945
|
-
|
|
2946
|
-
device = discstation_burn.disc_device()
|
|
2947
|
-
dl_info = discstation_burn.detect_disc_type(device)
|
|
2948
|
-
disc_bytes = dl_info["capacity"]
|
|
2949
|
-
discstation_burn.remux_and_burn(ser, mpg, disc_label, disc_bytes, dl_info)
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
2676
|
def _copy_to_job(ser, src, dst_dir):
|
|
2953
2677
|
if src.is_dir():
|
|
2954
2678
|
items = sorted(src.iterdir())
|
|
@@ -2964,25 +2688,9 @@ def _copy_to_job(ser, src, dst_dir):
|
|
|
2964
2688
|
def burn_data_flow(ser):
|
|
2965
2689
|
global _last_upload_label
|
|
2966
2690
|
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
try:
|
|
2971
|
-
url = sys.stdin.readline().strip()
|
|
2972
|
-
except (EOFError, KeyboardInterrupt, OSError):
|
|
2973
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
2974
|
-
return
|
|
2975
|
-
if not url:
|
|
2976
|
-
safe_send(ser, "ERROR:Need URL or file path")
|
|
2977
|
-
return
|
|
2978
|
-
else:
|
|
2979
|
-
url = wait_for_web_url(ser)
|
|
2980
|
-
if url is None:
|
|
2981
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
2982
|
-
return
|
|
2983
|
-
if not url:
|
|
2984
|
-
safe_send(ser, "ERROR:Need URL or file path")
|
|
2985
|
-
return
|
|
2691
|
+
url = _wait_for_source(ser)
|
|
2692
|
+
if not url:
|
|
2693
|
+
return
|
|
2986
2694
|
|
|
2987
2695
|
device = discstation_burn.disc_device()
|
|
2988
2696
|
dl_info = discstation_burn.detect_disc_type(device)
|
|
@@ -3011,21 +2719,10 @@ def burn_data_flow(ser):
|
|
|
3011
2719
|
|
|
3012
2720
|
send(ser, f"TITLE:{title}")
|
|
3013
2721
|
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
if line == "CANCEL" or line == "PLAY_STOP":
|
|
3019
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
3020
|
-
print("Burn cancelled by user")
|
|
3021
|
-
return
|
|
3022
|
-
if line.startswith("SPEED:"):
|
|
3023
|
-
burn_speed = line.split(":", 1)[1].strip()
|
|
3024
|
-
print(f"Burn speed: {burn_speed}")
|
|
3025
|
-
elif line == "START" or line.startswith("START:"):
|
|
3026
|
-
print("Starting data burn...")
|
|
3027
|
-
send(ser, "STATUS:Starting data burn...")
|
|
3028
|
-
break
|
|
2722
|
+
started = _wait_for_start(ser, "data burn")
|
|
2723
|
+
if not started:
|
|
2724
|
+
return
|
|
2725
|
+
burn_speed = started[1]
|
|
3029
2726
|
|
|
3030
2727
|
if not can_burn_disc(device):
|
|
3031
2728
|
raise RuntimeError("No writable disc in drive")
|
|
@@ -3036,8 +2733,7 @@ def burn_data_flow(ser):
|
|
|
3036
2733
|
download_dir = job_dir / "download"
|
|
3037
2734
|
download_dir.mkdir()
|
|
3038
2735
|
|
|
3039
|
-
|
|
3040
|
-
try:
|
|
2736
|
+
with _burn_history({"title": title, "disc_type": "Data DVD", "mode": "DATA", "speed": burn_speed or "Auto"}, swallow_cancel=True) as h:
|
|
3041
2737
|
is_dir = local_path.is_dir() if local_path.exists() else False
|
|
3042
2738
|
if is_dir:
|
|
3043
2739
|
files_to_burn = [local_path]
|
|
@@ -3079,75 +2775,15 @@ def burn_data_flow(ser):
|
|
|
3079
2775
|
safe_send(ser, "DONE:Data disc complete!")
|
|
3080
2776
|
print("Data burn complete.")
|
|
3081
2777
|
|
|
3082
|
-
|
|
3083
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
3084
|
-
"title": title,
|
|
3085
|
-
"disc_type": "Data DVD",
|
|
3086
|
-
"mode": "DATA",
|
|
3087
|
-
"speed": burn_speed or "Auto",
|
|
3088
|
-
"success": True,
|
|
3089
|
-
"duration_s": round(time.time() - start_time),
|
|
3090
|
-
})
|
|
3091
|
-
except (KeyboardInterrupt, SystemExit):
|
|
3092
|
-
append_burn_history({
|
|
3093
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
3094
|
-
"title": title,
|
|
3095
|
-
"disc_type": "Data DVD",
|
|
3096
|
-
"mode": "DATA",
|
|
3097
|
-
"speed": burn_speed or "Auto",
|
|
3098
|
-
"success": False,
|
|
3099
|
-
"error": "Cancelled",
|
|
3100
|
-
"duration_s": round(time.time() - start_time),
|
|
3101
|
-
})
|
|
3102
|
-
raise
|
|
3103
|
-
except CancelError:
|
|
3104
|
-
append_burn_history({
|
|
3105
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
3106
|
-
"title": title,
|
|
3107
|
-
"disc_type": "Data DVD",
|
|
3108
|
-
"mode": "DATA",
|
|
3109
|
-
"speed": burn_speed or "Auto",
|
|
3110
|
-
"success": False,
|
|
3111
|
-
"error": "Cancelled",
|
|
3112
|
-
"duration_s": round(time.time() - start_time),
|
|
3113
|
-
})
|
|
2778
|
+
if h.cancelled:
|
|
3114
2779
|
return
|
|
3115
|
-
except Exception as e:
|
|
3116
|
-
append_burn_history({
|
|
3117
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
3118
|
-
"title": title,
|
|
3119
|
-
"disc_type": "Data DVD",
|
|
3120
|
-
"mode": "DATA",
|
|
3121
|
-
"speed": burn_speed or "Auto",
|
|
3122
|
-
"success": False,
|
|
3123
|
-
"error": str(e)[:100],
|
|
3124
|
-
"duration_s": round(time.time() - start_time),
|
|
3125
|
-
})
|
|
3126
|
-
raise
|
|
3127
|
-
|
|
3128
2780
|
time.sleep(3)
|
|
3129
2781
|
|
|
3130
2782
|
|
|
3131
2783
|
def burn_audio_flow(ser):
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
try:
|
|
3136
|
-
url = sys.stdin.readline().strip()
|
|
3137
|
-
except (EOFError, KeyboardInterrupt, OSError):
|
|
3138
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
3139
|
-
return
|
|
3140
|
-
if not url:
|
|
3141
|
-
safe_send(ser, "ERROR:Need path to audio files")
|
|
3142
|
-
return
|
|
3143
|
-
else:
|
|
3144
|
-
url = wait_for_web_url(ser)
|
|
3145
|
-
if url is None:
|
|
3146
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
3147
|
-
return
|
|
3148
|
-
if not url:
|
|
3149
|
-
safe_send(ser, "ERROR:Need path to audio files")
|
|
3150
|
-
return
|
|
2784
|
+
url = _wait_for_source(ser, "path to audio files")
|
|
2785
|
+
if not url:
|
|
2786
|
+
return
|
|
3151
2787
|
|
|
3152
2788
|
src_path = Path(url)
|
|
3153
2789
|
if not src_path.exists():
|
|
@@ -3210,18 +2846,10 @@ def burn_audio_flow(ser):
|
|
|
3210
2846
|
send(ser, f"META:Dur {mins}m{secs}s")
|
|
3211
2847
|
send(ser, f"FIT:CD-R {fits}")
|
|
3212
2848
|
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
if line == "CANCEL" or line == "PLAY_STOP":
|
|
3218
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
3219
|
-
return
|
|
3220
|
-
if line.startswith("SPEED:"):
|
|
3221
|
-
burn_speed = line.split(":", 1)[1].strip()
|
|
3222
|
-
elif line == "START" or line.startswith("START:"):
|
|
3223
|
-
send(ser, "STATUS:Starting audio burn...")
|
|
3224
|
-
break
|
|
2849
|
+
started = _wait_for_start(ser, "audio burn")
|
|
2850
|
+
if not started:
|
|
2851
|
+
return
|
|
2852
|
+
burn_speed = started[1]
|
|
3225
2853
|
|
|
3226
2854
|
device = discstation_burn.disc_device()
|
|
3227
2855
|
if not can_burn_disc(device):
|
|
@@ -3229,95 +2857,23 @@ def burn_audio_flow(ser):
|
|
|
3229
2857
|
if total_dur > 4740:
|
|
3230
2858
|
raise RuntimeError(f"Too long for CD-R: {int(total_dur/60)}m{int(total_dur%60)}s > 79m")
|
|
3231
2859
|
|
|
3232
|
-
|
|
3233
|
-
|
|
2860
|
+
entry = {"title": disc_label, "fingerprint": fingerprint, "track_titles": track_titles,
|
|
2861
|
+
"disc_type": "Audio CD", "mode": "AUDIO", "speed": burn_speed or "Auto"}
|
|
2862
|
+
with _burn_history(entry, swallow_cancel=True) as h:
|
|
3234
2863
|
discstation_burn.burn_audio_cd(ser, audio_files, disc_label, burn_speed)
|
|
3235
2864
|
safe_send(ser, "DONE:Audio CD complete!")
|
|
3236
|
-
|
|
3237
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
3238
|
-
"title": disc_label,
|
|
3239
|
-
"fingerprint": fingerprint,
|
|
3240
|
-
"track_titles": track_titles,
|
|
3241
|
-
"disc_type": "Audio CD",
|
|
3242
|
-
"mode": "AUDIO",
|
|
3243
|
-
"speed": burn_speed or "Auto",
|
|
3244
|
-
"success": True,
|
|
3245
|
-
"duration_s": round(time.time() - start_time),
|
|
3246
|
-
})
|
|
3247
|
-
except (KeyboardInterrupt, SystemExit):
|
|
3248
|
-
raise
|
|
3249
|
-
except CancelError:
|
|
3250
|
-
append_burn_history({
|
|
3251
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
3252
|
-
"title": disc_label,
|
|
3253
|
-
"fingerprint": fingerprint,
|
|
3254
|
-
"track_titles": track_titles,
|
|
3255
|
-
"disc_type": "Audio CD",
|
|
3256
|
-
"mode": "AUDIO",
|
|
3257
|
-
"speed": burn_speed or "Auto",
|
|
3258
|
-
"success": False,
|
|
3259
|
-
"error": "Cancelled",
|
|
3260
|
-
"duration_s": round(time.time() - start_time),
|
|
3261
|
-
})
|
|
2865
|
+
if h.cancelled:
|
|
3262
2866
|
return
|
|
3263
|
-
except Exception as e:
|
|
3264
|
-
append_burn_history({
|
|
3265
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
3266
|
-
"title": disc_label,
|
|
3267
|
-
"fingerprint": fingerprint,
|
|
3268
|
-
"track_titles": track_titles,
|
|
3269
|
-
"disc_type": "Audio CD",
|
|
3270
|
-
"mode": "AUDIO",
|
|
3271
|
-
"speed": burn_speed or "Auto",
|
|
3272
|
-
"success": False,
|
|
3273
|
-
"error": str(e)[:100],
|
|
3274
|
-
"duration_s": round(time.time() - start_time),
|
|
3275
|
-
})
|
|
3276
|
-
raise
|
|
3277
|
-
|
|
3278
2867
|
time.sleep(3)
|
|
3279
2868
|
|
|
3280
2869
|
|
|
3281
|
-
def _iter_proc_lines(proc, ser):
|
|
3282
|
-
lines = Queue()
|
|
3283
|
-
finished = object()
|
|
3284
|
-
|
|
3285
|
-
def read_output():
|
|
3286
|
-
try:
|
|
3287
|
-
for line in proc.stdout:
|
|
3288
|
-
lines.put(line.rstrip("\r\n"))
|
|
3289
|
-
finally:
|
|
3290
|
-
lines.put(finished)
|
|
3291
|
-
|
|
3292
|
-
reader = threading.Thread(target=read_output, daemon=True)
|
|
3293
|
-
reader.start()
|
|
3294
|
-
last_ping = time.time()
|
|
3295
|
-
output_done = False
|
|
3296
|
-
while proc.poll() is None or not output_done:
|
|
3297
|
-
if time.time() - last_ping >= 5:
|
|
3298
|
-
discstation_burn.send(ser, "PING")
|
|
3299
|
-
last_ping = time.time()
|
|
3300
|
-
if _check_cancel(ser):
|
|
3301
|
-
discstation_burn.stop_process(proc)
|
|
3302
|
-
return
|
|
3303
|
-
try:
|
|
3304
|
-
line = lines.get(timeout=0.5)
|
|
3305
|
-
except Empty:
|
|
3306
|
-
continue
|
|
3307
|
-
if line is finished:
|
|
3308
|
-
output_done = True
|
|
3309
|
-
else:
|
|
3310
|
-
yield line
|
|
3311
|
-
reader.join(timeout=1)
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
2870
|
# --- OLED spectrum visualizer -----------------------------------------------
|
|
3315
2871
|
# Taps the real audio the host is playing - PulseAudio's monitor source on
|
|
3316
|
-
# Linux
|
|
3317
|
-
# for the one-time setup) - not a simulation, and streams it to the remote as
|
|
2872
|
+
# Linux - not a simulation, and streams it to the remote as
|
|
3318
2873
|
# "VU:<16 comma-separated 0-63 levels>" at ~15fps. Needs numpy; anywhere else
|
|
3319
|
-
# (
|
|
3320
|
-
#
|
|
2874
|
+
# (macOS can't capture system audio without a signed helper - see
|
|
2875
|
+
# docs/PLATFORM_SUPPORT.md) this quietly no-ops and the remote just shows its
|
|
2876
|
+
# normal PLAY text screen.
|
|
3321
2877
|
VU_BARS = 16 # must match the firmware's VU_BARS
|
|
3322
2878
|
VU_RATE_HZ = 15
|
|
3323
2879
|
VU_SAMPLE_RATE = 22050
|
|
@@ -3335,55 +2891,19 @@ def _pulse_default_monitor():
|
|
|
3335
2891
|
return f"{sink}.monitor" if sink else None
|
|
3336
2892
|
|
|
3337
2893
|
|
|
3338
|
-
def _darwin_blackhole_input():
|
|
3339
|
-
"""Index of the 'BlackHole' avfoundation audio device, or None if it's not
|
|
3340
|
-
installed. macOS has no built-in loopback source - this requires the user
|
|
3341
|
-
to `brew install blackhole-2ch` and set a Multi-Output Device (BlackHole +
|
|
3342
|
-
real speakers) as the system's default output, so audio is both audible
|
|
3343
|
-
and tapped (see docs/PLATFORM_SUPPORT.md)."""
|
|
3344
|
-
try:
|
|
3345
|
-
# Device list is on stderr; ffmpeg exits non-zero here, that's normal.
|
|
3346
|
-
out = subprocess.run(["ffmpeg", "-f", "avfoundation", "-list_devices", "true", "-i", ""],
|
|
3347
|
-
capture_output=True, text=True, timeout=5).stderr
|
|
3348
|
-
except Exception:
|
|
3349
|
-
return None
|
|
3350
|
-
in_audio = False
|
|
3351
|
-
for line in out.splitlines():
|
|
3352
|
-
if "AVFoundation audio devices" in line:
|
|
3353
|
-
in_audio = True
|
|
3354
|
-
continue
|
|
3355
|
-
if in_audio:
|
|
3356
|
-
m = re.search(r"\[(\d+)\]\s+(.*)", line)
|
|
3357
|
-
if m and "blackhole" in m.group(2).lower():
|
|
3358
|
-
return m.group(1)
|
|
3359
|
-
return None
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
2894
|
def _vu_capture_cmd():
|
|
3363
|
-
"""
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
# steady trickle, which starves the visualizer for longer than the
|
|
3376
|
-
# firmware's fallback timeout and flickers back to the text screen.
|
|
3377
|
-
return ["parec", "--format=s16le", f"--rate={VU_SAMPLE_RATE}", "--channels=1",
|
|
3378
|
-
"--latency-msec=50", "-d", monitor]
|
|
3379
|
-
if system == "darwin":
|
|
3380
|
-
idx = _darwin_blackhole_input()
|
|
3381
|
-
if idx is None:
|
|
3382
|
-
return None
|
|
3383
|
-
return ["ffmpeg", "-f", "avfoundation", "-i", f":{idx}",
|
|
3384
|
-
"-ac", "1", "-ar", str(VU_SAMPLE_RATE), "-f", "s16le",
|
|
3385
|
-
"-loglevel", "error", "-"]
|
|
3386
|
-
return None
|
|
2895
|
+
"""parec argv streaming raw s16le mono PCM at VU_SAMPLE_RATE from the
|
|
2896
|
+
default sink's monitor, or None if PulseAudio has no default sink."""
|
|
2897
|
+
monitor = _pulse_default_monitor()
|
|
2898
|
+
if not monitor:
|
|
2899
|
+
return None
|
|
2900
|
+
# --latency-msec=50: PulseAudio's default capture buffer is several
|
|
2901
|
+
# hundred ms to seconds (tuned for robust recording, not streaming) -
|
|
2902
|
+
# without this, parec hands us data in ~1.5-2s bursts instead of a
|
|
2903
|
+
# steady trickle, which starves the visualizer for longer than the
|
|
2904
|
+
# firmware's fallback timeout and flickers back to the text screen.
|
|
2905
|
+
return ["parec", "--format=s16le", f"--rate={VU_SAMPLE_RATE}", "--channels=1",
|
|
2906
|
+
"--latency-msec=50", "-d", monitor]
|
|
3387
2907
|
|
|
3388
2908
|
|
|
3389
2909
|
def _vu_loop(ser, stop_event, pause_event):
|
|
@@ -3443,11 +2963,9 @@ def _vu_loop(ser, stop_event, pause_event):
|
|
|
3443
2963
|
|
|
3444
2964
|
def start_vu_visualizer(ser):
|
|
3445
2965
|
"""Best-effort: returns (stop_event, pause_event), or (None, None) if the
|
|
3446
|
-
visualizer can't run here (no numpy, no
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
else, or if the OS-specific capture device isn't set up."""
|
|
3450
|
-
if _np is None or isinstance(ser, VirtualSerial) or discstation_host.system_name() not in ("linux", "darwin"):
|
|
2966
|
+
visualizer can't run here (no numpy, no PulseAudio, or a web-only link).
|
|
2967
|
+
Linux only."""
|
|
2968
|
+
if _np is None or isinstance(ser, VirtualSerial) or discstation_host.system_name() != "linux":
|
|
3451
2969
|
return None, None
|
|
3452
2970
|
stop_event = threading.Event()
|
|
3453
2971
|
pause_event = threading.Event()
|
|
@@ -3500,7 +3018,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None, s
|
|
|
3500
3018
|
# floods journald until the pipe backs up and our own print()/status
|
|
3501
3019
|
# writes block, wedging the whole play loop. We drive mpv over the IPC
|
|
3502
3020
|
# socket, so none of that output is wanted.
|
|
3503
|
-
proc = subprocess.Popen(
|
|
3021
|
+
proc = subprocess.Popen(cmd, env=env,
|
|
3504
3022
|
stdin=(stdin_proc.stdout if stdin_proc else None),
|
|
3505
3023
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
3506
3024
|
if stdin_proc:
|
|
@@ -3524,6 +3042,10 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None, s
|
|
|
3524
3042
|
current_volume = None
|
|
3525
3043
|
current_track = None
|
|
3526
3044
|
last_track_poll = 0
|
|
3045
|
+
# Set to "eject" below when EJECT (not PLAY_STOP/CANCEL/HOME) is what
|
|
3046
|
+
# ended playback - returned to the caller so it can actually eject
|
|
3047
|
+
# the tray afterward, not just return to the menu.
|
|
3048
|
+
stop_reason = None
|
|
3527
3049
|
track_titles = track_titles or []
|
|
3528
3050
|
track_starts = track_starts or []
|
|
3529
3051
|
send(ser, "PLAY_MODE:AUDIO_CD" if kind == "audio_cd" else "PLAY_MODE:DEFAULT")
|
|
@@ -3566,12 +3088,18 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None, s
|
|
|
3566
3088
|
vu_pause.set() if paused else vu_pause.clear()
|
|
3567
3089
|
send(ser, "PLAY_STATUS:PAUSED" if paused else "PLAY_STATUS:PLAYING")
|
|
3568
3090
|
|
|
3569
|
-
elif line in ("PLAY_STOP", "EJECT"):
|
|
3570
|
-
#
|
|
3571
|
-
#
|
|
3572
|
-
#
|
|
3091
|
+
elif line in ("PLAY_STOP", "EJECT", "CANCEL", "HOME"):
|
|
3092
|
+
# All four end playback - EJECT alone also pops the tray
|
|
3093
|
+
# afterward (the web remote has its own dedicated STOP
|
|
3094
|
+
# button now, so EJECT no longer needs to double as one -
|
|
3095
|
+
# a user pressing eject while music plays wants the disc
|
|
3096
|
+
# out, not just silence). CANCEL/HOME return to the menu
|
|
3097
|
+
# exactly like they do everywhere else - previously
|
|
3098
|
+
# unhandled here, so they were silently dropped mid-play.
|
|
3573
3099
|
send(ser, "STATUS:Stopping play")
|
|
3574
3100
|
discstation_burn.stop_process(proc)
|
|
3101
|
+
if line == "EJECT":
|
|
3102
|
+
stop_reason = "eject"
|
|
3575
3103
|
break
|
|
3576
3104
|
|
|
3577
3105
|
elif line == "FF:BIG":
|
|
@@ -3646,6 +3174,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None, s
|
|
|
3646
3174
|
os.unlink(MPV_SOCKET)
|
|
3647
3175
|
except OSError:
|
|
3648
3176
|
pass
|
|
3177
|
+
return stop_reason
|
|
3649
3178
|
|
|
3650
3179
|
|
|
3651
3180
|
def _play_audio_cd_windows(ser, device, track_titles):
|
|
@@ -3659,16 +3188,7 @@ def _play_audio_cd_windows(ser, device, track_titles):
|
|
|
3659
3188
|
cmd, kwargs = discstation_host.ps_cmd("play-audio-cd.ps1", device, str(cmd_file))
|
|
3660
3189
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **kwargs)
|
|
3661
3190
|
|
|
3662
|
-
lines =
|
|
3663
|
-
|
|
3664
|
-
def read_output():
|
|
3665
|
-
try:
|
|
3666
|
-
for line in proc.stdout:
|
|
3667
|
-
lines.put(line.rstrip("\r\n"))
|
|
3668
|
-
finally:
|
|
3669
|
-
lines.put(None)
|
|
3670
|
-
|
|
3671
|
-
threading.Thread(target=read_output, daemon=True).start()
|
|
3191
|
+
lines, eof = discstation_burn.proc_line_queue(proc)
|
|
3672
3192
|
|
|
3673
3193
|
def send_cmd(text):
|
|
3674
3194
|
cmd_file.write_text(text + "\n")
|
|
@@ -3691,6 +3211,8 @@ def _play_audio_cd_windows(ser, device, track_titles):
|
|
|
3691
3211
|
line = lines.get(timeout=0.1)
|
|
3692
3212
|
except Empty:
|
|
3693
3213
|
line = None
|
|
3214
|
+
if line is eof:
|
|
3215
|
+
line = None
|
|
3694
3216
|
if line:
|
|
3695
3217
|
if line.startswith("TRACK:"):
|
|
3696
3218
|
try:
|
|
@@ -3766,7 +3288,12 @@ def play_flow(ser):
|
|
|
3766
3288
|
except FileNotFoundError:
|
|
3767
3289
|
raise RuntimeError("mpv not found")
|
|
3768
3290
|
|
|
3291
|
+
# "eject" once a play loop reports EJECT ended it (see _run_mpv) - acted
|
|
3292
|
+
# on once, after the kind dispatch below, regardless of which branch ran.
|
|
3293
|
+
stop_reason = None
|
|
3294
|
+
|
|
3769
3295
|
def play_vob_fallback():
|
|
3296
|
+
nonlocal stop_reason
|
|
3770
3297
|
# No DVD-menu engine available (libdvdnav missing, or on Windows
|
|
3771
3298
|
# where the plain mpv build never has it) - play the main title's
|
|
3772
3299
|
# VOBs directly off the mounted volume instead (no menus).
|
|
@@ -3786,7 +3313,7 @@ def play_flow(ser):
|
|
|
3786
3313
|
"--idle=no",
|
|
3787
3314
|
*[str(path) for path in files],
|
|
3788
3315
|
]
|
|
3789
|
-
_run_mpv(ser, cmd, "Playing DVD", kind)
|
|
3316
|
+
stop_reason = _run_mpv(ser, cmd, "Playing DVD", kind)
|
|
3790
3317
|
|
|
3791
3318
|
if kind == "dvd_video":
|
|
3792
3319
|
if discstation_host.system_name() == "darwin":
|
|
@@ -3799,7 +3326,7 @@ def play_flow(ser):
|
|
|
3799
3326
|
"--dvd-device=" + rip_device(device),
|
|
3800
3327
|
"dvdnav://",
|
|
3801
3328
|
]
|
|
3802
|
-
_run_mpv(ser, cmd, "Playing DVD", kind)
|
|
3329
|
+
stop_reason = _run_mpv(ser, cmd, "Playing DVD", kind)
|
|
3803
3330
|
except RuntimeError:
|
|
3804
3331
|
# libdvdnav couldn't open the disc.
|
|
3805
3332
|
play_vob_fallback()
|
|
@@ -3845,7 +3372,7 @@ def play_flow(ser):
|
|
|
3845
3372
|
if audio_device:
|
|
3846
3373
|
cmd.insert(1, "--audio-device=" + audio_device)
|
|
3847
3374
|
print(f"Audio CD output: {audio_device}")
|
|
3848
|
-
_run_mpv(ser, cmd, "Playing audio CD", kind, track_titles, track_starts, stdin_proc=rip_proc)
|
|
3375
|
+
stop_reason = _run_mpv(ser, cmd, "Playing audio CD", kind, track_titles, track_starts, stdin_proc=rip_proc)
|
|
3849
3376
|
else:
|
|
3850
3377
|
audio_device = discstation_host.audio_output_device()
|
|
3851
3378
|
cmd = [
|
|
@@ -3860,7 +3387,7 @@ def play_flow(ser):
|
|
|
3860
3387
|
if audio_device:
|
|
3861
3388
|
cmd.insert(1, "--audio-device=" + audio_device)
|
|
3862
3389
|
print(f"Audio CD output: {audio_device}")
|
|
3863
|
-
_run_mpv(ser, cmd, "Playing audio CD", kind, track_titles, track_starts)
|
|
3390
|
+
stop_reason = _run_mpv(ser, cmd, "Playing audio CD", kind, track_titles, track_starts)
|
|
3864
3391
|
|
|
3865
3392
|
elif kind in ("vcd", "svcd", "video_data"):
|
|
3866
3393
|
with mounted_disc(device) as mount_dir:
|
|
@@ -3874,11 +3401,23 @@ def play_flow(ser):
|
|
|
3874
3401
|
"--idle=no",
|
|
3875
3402
|
*[str(path) for path in files],
|
|
3876
3403
|
]
|
|
3877
|
-
_run_mpv(ser, cmd, f"Playing {kind.upper()}", kind)
|
|
3404
|
+
stop_reason = _run_mpv(ser, cmd, f"Playing {kind.upper()}", kind)
|
|
3878
3405
|
|
|
3879
3406
|
else:
|
|
3880
3407
|
raise RuntimeError(f"Unsupported disc: {kind}")
|
|
3881
3408
|
|
|
3409
|
+
if stop_reason == "eject":
|
|
3410
|
+
safe_send(ser, "STATUS:Ejecting...")
|
|
3411
|
+
# mpv/cd-paranoia just got killed above - give the OS a moment to
|
|
3412
|
+
# actually release the device handle before touching it again, or
|
|
3413
|
+
# the primary `eject` command can hit "Device or resource busy" and
|
|
3414
|
+
# fall back to the raw SCSI path, seen live to behave differently
|
|
3415
|
+
# (the tray got marked closed again within ~11s of a real eject).
|
|
3416
|
+
time.sleep(1)
|
|
3417
|
+
try:
|
|
3418
|
+
eject_disc(ser, device)
|
|
3419
|
+
except Exception as e:
|
|
3420
|
+
print(f"Eject after play failed: {e}")
|
|
3882
3421
|
safe_send(ser, "DONE:Playback stopped")
|
|
3883
3422
|
time.sleep(3)
|
|
3884
3423
|
|
|
@@ -4020,12 +3559,12 @@ def handbrake_rip_main_feature(ser, device, out_dir, title_index):
|
|
|
4020
3559
|
return dest
|
|
4021
3560
|
|
|
4022
3561
|
|
|
4023
|
-
def rip_flow(ser
|
|
3562
|
+
def rip_flow(ser):
|
|
4024
3563
|
device = discstation_burn.disc_device()
|
|
4025
3564
|
kind = disc_kind(device)
|
|
4026
3565
|
|
|
4027
3566
|
if kind == "audio_cd":
|
|
4028
|
-
rip_audio_cd(ser, device
|
|
3567
|
+
rip_audio_cd(ser, device)
|
|
4029
3568
|
return
|
|
4030
3569
|
|
|
4031
3570
|
if kind in ("vcd", "svcd", "video_data"):
|
|
@@ -4083,7 +3622,6 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
|
|
|
4083
3622
|
safe_send(ser, "DONE:Rip complete!")
|
|
4084
3623
|
print(f"Rip complete: {out_dir}")
|
|
4085
3624
|
out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
|
|
4086
|
-
chown_to_sudo_user(out_dir)
|
|
4087
3625
|
time.sleep(3)
|
|
4088
3626
|
return
|
|
4089
3627
|
|
|
@@ -4108,7 +3646,6 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
|
|
|
4108
3646
|
safe_send(ser, "DONE:Rip complete!")
|
|
4109
3647
|
print(f"Rip complete: {out_dir}")
|
|
4110
3648
|
out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
|
|
4111
|
-
chown_to_sudo_user(out_dir)
|
|
4112
3649
|
time.sleep(3)
|
|
4113
3650
|
return
|
|
4114
3651
|
|
|
@@ -4158,7 +3695,7 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
|
|
|
4158
3695
|
raise
|
|
4159
3696
|
|
|
4160
3697
|
if proc.wait() != 0:
|
|
4161
|
-
if
|
|
3698
|
+
if drive_status(device) in ("open", "no_disc"):
|
|
4162
3699
|
raise RuntimeError("Disc was removed during rip")
|
|
4163
3700
|
raise RuntimeError("Rip failed")
|
|
4164
3701
|
|
|
@@ -4166,7 +3703,6 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
|
|
|
4166
3703
|
safe_send(ser, "DONE:Rip complete!")
|
|
4167
3704
|
print(f"Rip complete: {out_dir}")
|
|
4168
3705
|
out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
|
|
4169
|
-
chown_to_sudo_user(out_dir)
|
|
4170
3706
|
time.sleep(3)
|
|
4171
3707
|
|
|
4172
3708
|
|
|
@@ -4206,7 +3742,6 @@ def rip_video_disc(ser, device, kind):
|
|
|
4206
3742
|
safe_send(ser, "DONE:Rip complete!")
|
|
4207
3743
|
print(f"Video rip complete: {out_dir}")
|
|
4208
3744
|
out_dir = _finalize_video_rip(ser, out_dir, device, kind)
|
|
4209
|
-
chown_to_sudo_user(out_dir)
|
|
4210
3745
|
time.sleep(3)
|
|
4211
3746
|
|
|
4212
3747
|
|
|
@@ -4237,7 +3772,7 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
|
|
|
4237
3772
|
)
|
|
4238
3773
|
output = []
|
|
4239
3774
|
try:
|
|
4240
|
-
for line in
|
|
3775
|
+
for line in discstation_burn.iter_proc_or_cancel(proc, ser):
|
|
4241
3776
|
output.append(line)
|
|
4242
3777
|
count = len(list(wav_dir.glob("*.wav")))
|
|
4243
3778
|
if count:
|
|
@@ -4281,18 +3816,17 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
|
|
|
4281
3816
|
shutil.rmtree(str(wav_dir), ignore_errors=True)
|
|
4282
3817
|
safe_send(ser, "PROGRESS:100%")
|
|
4283
3818
|
safe_send(ser, "DONE:Rip complete!")
|
|
4284
|
-
chown_to_sudo_user(out_dir)
|
|
4285
3819
|
time.sleep(3)
|
|
4286
3820
|
|
|
4287
3821
|
|
|
4288
|
-
def rip_audio_cd(ser, device
|
|
3822
|
+
def rip_audio_cd(ser, device):
|
|
4289
3823
|
chapters = audio_cd_chapters(device)
|
|
4290
3824
|
metadata = None
|
|
4291
3825
|
cover_path = None
|
|
4292
3826
|
|
|
4293
3827
|
_raise_if_cancelled(ser)
|
|
4294
3828
|
send(ser, "STATUS:Looking up CD...")
|
|
4295
|
-
metadata = audio_metadata_lookup(device, len(chapters)
|
|
3829
|
+
metadata = audio_metadata_lookup(device, len(chapters))
|
|
4296
3830
|
_raise_if_cancelled(ser)
|
|
4297
3831
|
|
|
4298
3832
|
if metadata:
|
|
@@ -4368,7 +3902,7 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
|
|
|
4368
3902
|
)
|
|
4369
3903
|
|
|
4370
3904
|
try:
|
|
4371
|
-
for line in
|
|
3905
|
+
for line in discstation_burn.iter_proc_or_cancel(proc, ser):
|
|
4372
3906
|
print(line, end="")
|
|
4373
3907
|
secs = parse_ffmpeg_time(line)
|
|
4374
3908
|
if secs is not None and rip_duration > 0:
|
|
@@ -4385,7 +3919,7 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
|
|
|
4385
3919
|
safe_send(ser, "CANCELLED:Rip cancelled")
|
|
4386
3920
|
return
|
|
4387
3921
|
elif proc.returncode != 0:
|
|
4388
|
-
if
|
|
3922
|
+
if drive_status(device) in ("open", "no_disc"):
|
|
4389
3923
|
raise RuntimeError("Disc was removed during rip")
|
|
4390
3924
|
raise RuntimeError("Audio CD rip failed")
|
|
4391
3925
|
|
|
@@ -4421,12 +3955,11 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
|
|
|
4421
3955
|
safe_send(ser, "PROGRESS:100%")
|
|
4422
3956
|
safe_send(ser, "DONE:Rip complete!")
|
|
4423
3957
|
print(f"Audio rip complete: {out_dir}")
|
|
4424
|
-
chown_to_sudo_user(out_dir)
|
|
4425
3958
|
time.sleep(3)
|
|
4426
3959
|
|
|
4427
3960
|
|
|
4428
|
-
def station_loop(ser
|
|
4429
|
-
global _last_burn_result, _last_burn_result_time, _tray_open, _tray_open_since, _operation_active
|
|
3961
|
+
def station_loop(ser):
|
|
3962
|
+
global _last_burn_result, _last_burn_result_time, _tray_open, _tray_open_since, _operation_active, _web_op_verb
|
|
4430
3963
|
discstation_burn.cleanup_old_jobs()
|
|
4431
3964
|
try:
|
|
4432
3965
|
device = discstation_burn.disc_device()
|
|
@@ -4654,6 +4187,14 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
|
|
|
4654
4187
|
|
|
4655
4188
|
mode = line.split(":", 1)[1].strip().upper()
|
|
4656
4189
|
print(f"Selected: {mode}")
|
|
4190
|
+
_web_op_verb = "RIPPING" if mode == "RIP" else "BURNING"
|
|
4191
|
+
# The web remote's mode buttons queue START right behind SELECT: for a
|
|
4192
|
+
# one-click burn (see app.js) - if an earlier selection was abandoned
|
|
4193
|
+
# before its own START got consumed (e.g. never uploaded/confirmed a
|
|
4194
|
+
# URL), that stale START would otherwise sit buffered and fire this
|
|
4195
|
+
# new, different selection instead. Drop anything unconsumed first.
|
|
4196
|
+
if isinstance(ser, VirtualSerial):
|
|
4197
|
+
ser.clear()
|
|
4657
4198
|
|
|
4658
4199
|
# The user picked a mode — they want to act on a disc, so the drive is
|
|
4659
4200
|
# fair game again even if it was ejected from the OLED earlier.
|
|
@@ -4661,16 +4202,13 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
|
|
|
4661
4202
|
_operation_active = True # stop /disc-info probing the drive during the flow
|
|
4662
4203
|
try:
|
|
4663
4204
|
if mode == "BURN":
|
|
4664
|
-
burn_flow(ser
|
|
4205
|
+
burn_flow(ser)
|
|
4665
4206
|
_last_burn_result = "Burn complete"
|
|
4666
4207
|
elif mode == "PLAY":
|
|
4667
4208
|
play_flow(ser)
|
|
4668
4209
|
elif mode == "RIP":
|
|
4669
|
-
rip_flow(ser
|
|
4210
|
+
rip_flow(ser)
|
|
4670
4211
|
_last_burn_result = "Rip complete"
|
|
4671
|
-
elif mode == "BURN MPG":
|
|
4672
|
-
burn_mpg_flow(ser)
|
|
4673
|
-
_last_burn_result = "Burn complete"
|
|
4674
4212
|
elif mode == "BURN DATA":
|
|
4675
4213
|
burn_data_flow(ser)
|
|
4676
4214
|
_last_burn_result = "Burn complete"
|
|
@@ -4738,15 +4276,7 @@ def check_pidfile():
|
|
|
4738
4276
|
|
|
4739
4277
|
def parse_args():
|
|
4740
4278
|
parser = argparse.ArgumentParser(description="Physical DVD station controller")
|
|
4741
|
-
parser.add_argument("--artist", help="Audio CD album artist hint for metadata fallback")
|
|
4742
|
-
parser.add_argument("--album", help="Audio CD album title hint for metadata fallback")
|
|
4743
|
-
parser.add_argument(
|
|
4744
|
-
"--retag-latest-audio",
|
|
4745
|
-
action="store_true",
|
|
4746
|
-
help="Retag the newest generic audio_cd_* rip using --artist/--album, then exit",
|
|
4747
|
-
)
|
|
4748
4279
|
parser.add_argument("--port", type=int, default=8080, help="Web interface port")
|
|
4749
|
-
parser.add_argument("url", nargs="?", help="YouTube URL or file path for burn mode")
|
|
4750
4280
|
return parser.parse_args()
|
|
4751
4281
|
|
|
4752
4282
|
|
|
@@ -4760,15 +4290,6 @@ def main():
|
|
|
4760
4290
|
exit_code = 0
|
|
4761
4291
|
|
|
4762
4292
|
try:
|
|
4763
|
-
if args.retag_latest_audio:
|
|
4764
|
-
rip_dir = latest_audio_rip_dir()
|
|
4765
|
-
new_dir, cover_path, renamed = retag_audio_rip(rip_dir, args.artist, args.album)
|
|
4766
|
-
print(f"Retagged: {new_dir}")
|
|
4767
|
-
print(f"Cover: {cover_path or 'not found'}")
|
|
4768
|
-
for path in renamed:
|
|
4769
|
-
print(path.name)
|
|
4770
|
-
return
|
|
4771
|
-
|
|
4772
4293
|
start_web_server(args.port)
|
|
4773
4294
|
|
|
4774
4295
|
while True:
|
|
@@ -4814,7 +4335,7 @@ def main():
|
|
|
4814
4335
|
# one now so a connected web remote learns about a newly-attached
|
|
4815
4336
|
# ESP32 immediately instead of only on its next reload.
|
|
4816
4337
|
_sse_publish(_status_snapshot())
|
|
4817
|
-
station_loop(ser
|
|
4338
|
+
station_loop(ser)
|
|
4818
4339
|
except _HardwareAttached:
|
|
4819
4340
|
print("ESP32 detected - handing off from the web remote to hardware.")
|
|
4820
4341
|
except (serial.SerialException, OSError, termios.error) as e:
|