discstation 0.1.17 → 0.1.20
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 +7 -5
- package/docs/PLATFORM_SUPPORT.md +24 -5
- package/install-windows.ps1 +139 -18
- package/package.json +2 -1
- package/scripts/setup.mjs +1 -1
- package/src/discstation.py +127 -41
- package/src/discstation_burn.py +102 -9
- package/src/discstation_host.py +91 -3
- package/src/static/app.js +2 -1
- package/src/static/index.html +2 -2
- package/src/win/_json.ps1 +29 -0
- package/src/win/audio-toc.ps1 +43 -0
- package/src/win/burn-audio.ps1 +72 -0
- package/src/win/burn-data.ps1 +71 -0
- package/src/win/burn-image.ps1 +64 -0
- package/src/win/disc-info.ps1 +76 -0
- package/src/win/eject.ps1 +30 -0
package/src/discstation_burn.py
CHANGED
|
@@ -520,6 +520,8 @@ status_sink = None
|
|
|
520
520
|
|
|
521
521
|
def send(ser, msg):
|
|
522
522
|
global _serial_write_failed
|
|
523
|
+
if os.environ.get("DISCSTATION_DEBUG_SERIAL"):
|
|
524
|
+
print(f"[{time.time():.3f}] SEND {msg!r}", flush=True)
|
|
523
525
|
if status_sink is not None:
|
|
524
526
|
try:
|
|
525
527
|
status_sink(msg)
|
|
@@ -1274,22 +1276,31 @@ def _run_growisofs(ser, growisofs_cmd, log_path, device=None):
|
|
|
1274
1276
|
|
|
1275
1277
|
def _run_hdiutil_burn(ser, image_path, device=None):
|
|
1276
1278
|
"""Burn a pre-built ISO on macOS via `hdiutil burn -puppetstrings`, streaming
|
|
1277
|
-
its PERCENT: lines to the ESP32."""
|
|
1279
|
+
its PERCENT: lines to the ESP32 for both the write and verify passes."""
|
|
1278
1280
|
send(ser, "STATUS:Burning image...")
|
|
1279
1281
|
send(ser, "PROGRESS:0%")
|
|
1280
1282
|
cmd = discstation_host.iso_burn_command(device or disc_device(), image_path)
|
|
1283
|
+
if discstation_host.system_name() == "darwin":
|
|
1284
|
+
# hdiutil block-buffers stdout to a pipe -> no progress until it exits.
|
|
1285
|
+
# Run it under a pty (script relays the child's exit status verbatim).
|
|
1286
|
+
cmd = ["/usr/bin/script", "-q", "/dev/null", *cmd]
|
|
1281
1287
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
1282
1288
|
out_lines = []
|
|
1283
1289
|
last_pct = -1
|
|
1290
|
+
phase = "burn"
|
|
1284
1291
|
try:
|
|
1285
1292
|
for line in iter_proc_or_cancel(proc, ser):
|
|
1286
1293
|
out_lines.append(line)
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1294
|
+
low = line.lower()
|
|
1295
|
+
m = re.search(r"PERCENT:(-?[\d.]+)", line)
|
|
1296
|
+
pct = int(float(m.group(1))) if m else None
|
|
1297
|
+
if phase == "burn" and ("verif" in low or (pct is not None and last_pct > 90 and pct < 5)):
|
|
1298
|
+
phase = "verify"
|
|
1299
|
+
last_pct = -1
|
|
1300
|
+
send(ser, "STATUS:Verifying...")
|
|
1301
|
+
if pct is not None and 0 <= pct <= 100 and pct != last_pct:
|
|
1302
|
+
last_pct = pct
|
|
1303
|
+
send(ser, f"PROGRESS:{min(pct, 99)}%")
|
|
1293
1304
|
except (KeyboardInterrupt, SystemExit):
|
|
1294
1305
|
stop_process(proc)
|
|
1295
1306
|
raise
|
|
@@ -1300,8 +1311,11 @@ def _run_hdiutil_burn(ser, image_path, device=None):
|
|
|
1300
1311
|
safe_send(ser, "PROGRESS:100%")
|
|
1301
1312
|
try:
|
|
1302
1313
|
discstation_host.eject_device(device or disc_device())
|
|
1303
|
-
except Exception
|
|
1304
|
-
|
|
1314
|
+
except Exception:
|
|
1315
|
+
try:
|
|
1316
|
+
discstation_host.eject_device(None) # device-less `drutil eject`
|
|
1317
|
+
except Exception as e:
|
|
1318
|
+
print(f"Disc eject skipped: {e}")
|
|
1305
1319
|
|
|
1306
1320
|
|
|
1307
1321
|
def burn(ser, dvd_dir, disc_label, speed=None, is_dual_layer=False):
|
|
@@ -1311,6 +1325,14 @@ def burn(ser, dvd_dir, disc_label, speed=None, is_dual_layer=False):
|
|
|
1311
1325
|
try:
|
|
1312
1326
|
discstation_host.build_data_image([dvd_dir], image_path, disc_label, video=True)
|
|
1313
1327
|
burn_iso(ser, image_path, speed, is_dual_layer)
|
|
1328
|
+
except (RuntimeError, FileNotFoundError) as e:
|
|
1329
|
+
if discstation_host.system_name() != "windows":
|
|
1330
|
+
raise
|
|
1331
|
+
# no xorriso -> burn the VIDEO_TS tree as a plain data disc (plays on
|
|
1332
|
+
# modern players; not guaranteed on old set-tops).
|
|
1333
|
+
print(f"xorriso unavailable ({e}); burning VIDEO_TS as a data disc")
|
|
1334
|
+
_run_windows_burn(ser, "burn-data.ps1", disc_device(), str(dvd_dir),
|
|
1335
|
+
disc_label, re.sub(r"\D", "", speed or ""))
|
|
1314
1336
|
finally:
|
|
1315
1337
|
image_path.unlink(missing_ok=True)
|
|
1316
1338
|
return
|
|
@@ -1338,6 +1360,13 @@ def burn_data(ser, source_paths, disc_label, speed=None, is_dual_layer=False):
|
|
|
1338
1360
|
try:
|
|
1339
1361
|
discstation_host.build_data_image(source_paths, image_path, disc_label)
|
|
1340
1362
|
burn_iso(ser, image_path, speed, is_dual_layer)
|
|
1363
|
+
except (RuntimeError, FileNotFoundError) as e:
|
|
1364
|
+
if discstation_host.system_name() != "windows":
|
|
1365
|
+
raise
|
|
1366
|
+
print(f"xorriso unavailable ({e}); using IMAPI2 data burn")
|
|
1367
|
+
src = str(source_paths[0]) if len(source_paths) == 1 else _stage_dir(source_paths)
|
|
1368
|
+
_run_windows_burn(ser, "burn-data.ps1", disc_device(), src, disc_label,
|
|
1369
|
+
re.sub(r"\D", "", speed or ""))
|
|
1341
1370
|
finally:
|
|
1342
1371
|
image_path.unlink(missing_ok=True)
|
|
1343
1372
|
return
|
|
@@ -1452,6 +1481,17 @@ def burn_audio_cd(ser, audio_files, disc_label, speed=None):
|
|
|
1452
1481
|
f"{len(track_meta)} track titles")
|
|
1453
1482
|
send(ser, "PROGRESS:35%")
|
|
1454
1483
|
|
|
1484
|
+
if discstation_host.system_name() == "windows":
|
|
1485
|
+
# No cdrdao on Windows — burn the prepared WAVs via IMAPI2 Track-At-Once.
|
|
1486
|
+
send(ser, "STATUS:Burning audio CD...")
|
|
1487
|
+
_run_windows_burn(ser, "burn-audio.ps1", disc_device(), str(tmp_dir),
|
|
1488
|
+
re.sub(r"\D", "", (speed or DISC_SPEED) or ""))
|
|
1489
|
+
for w in tmp_dir.glob("*.wav"):
|
|
1490
|
+
w.unlink(missing_ok=True)
|
|
1491
|
+
toc_path.unlink(missing_ok=True)
|
|
1492
|
+
safe_send(ser, "DONE:Audio CD complete!")
|
|
1493
|
+
return
|
|
1494
|
+
|
|
1455
1495
|
send(ser, "STATUS:Burning audio CD...")
|
|
1456
1496
|
# The cooked generic-mmc writer does NOT lay down the CD-TEXT lead-in on most
|
|
1457
1497
|
# ATAPI drives; the raw writer does. Override with DISCSTATION_CDRDAO_DRIVER
|
|
@@ -1512,11 +1552,64 @@ def burn_audio_cd(ser, audio_files, disc_label, speed=None):
|
|
|
1512
1552
|
print(f"CD eject skipped: {e}")
|
|
1513
1553
|
|
|
1514
1554
|
|
|
1555
|
+
def _stage_dir(paths):
|
|
1556
|
+
"""Copy several loose paths into one temp folder (IMAPI2 burn-data takes one)."""
|
|
1557
|
+
staging = WORK / f"stage_{time.strftime('%Y%m%d_%H%M%S')}"
|
|
1558
|
+
staging.mkdir(parents=True, exist_ok=True)
|
|
1559
|
+
for p in paths:
|
|
1560
|
+
p = Path(p)
|
|
1561
|
+
dest = staging / p.name
|
|
1562
|
+
if p.is_dir():
|
|
1563
|
+
shutil.copytree(p, dest, dirs_exist_ok=True)
|
|
1564
|
+
else:
|
|
1565
|
+
shutil.copy2(p, dest)
|
|
1566
|
+
return str(staging)
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def _run_windows_burn(ser, script, *script_args):
|
|
1570
|
+
"""Run a src/win/<script> IMAPI2 burn helper, streaming its PROGRESS:<pct>
|
|
1571
|
+
lines to the ESP32. Raises RuntimeError on a non-zero exit."""
|
|
1572
|
+
send(ser, "STATUS:Burning...")
|
|
1573
|
+
send(ser, "PROGRESS:0%")
|
|
1574
|
+
cmd, kwargs = discstation_host.ps_cmd(script, *script_args)
|
|
1575
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **kwargs)
|
|
1576
|
+
out_lines, last_pct = [], -1
|
|
1577
|
+
try:
|
|
1578
|
+
for line in iter_proc_or_cancel(proc, ser):
|
|
1579
|
+
out_lines.append(line)
|
|
1580
|
+
m = re.search(r"PROGRESS:(-?\d+)", line)
|
|
1581
|
+
if m:
|
|
1582
|
+
pct = int(m.group(1))
|
|
1583
|
+
if 0 <= pct <= 100 and pct != last_pct:
|
|
1584
|
+
last_pct = pct
|
|
1585
|
+
send(ser, f"PROGRESS:{min(pct, 99)}%")
|
|
1586
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1587
|
+
stop_process(proc)
|
|
1588
|
+
raise
|
|
1589
|
+
if proc.wait() != 0:
|
|
1590
|
+
detail = next((l for l in reversed(out_lines) if l.strip()), "burn failed")
|
|
1591
|
+
raise RuntimeError(f"Disc burn failed: {detail[:150]}")
|
|
1592
|
+
safe_send(ser, "PROGRESS:100%")
|
|
1593
|
+
|
|
1594
|
+
|
|
1515
1595
|
def burn_iso(ser, iso_path, speed=None, is_dual_layer=False):
|
|
1516
1596
|
"""Burn a pre-built ISO directly to disc — no filesystem building."""
|
|
1517
1597
|
if discstation_host.system_name() == "darwin":
|
|
1518
1598
|
_run_hdiutil_burn(ser, iso_path)
|
|
1519
1599
|
return
|
|
1600
|
+
if discstation_host.system_name() == "windows":
|
|
1601
|
+
drive = disc_device()
|
|
1602
|
+
spd = re.sub(r"\D", "", speed or "")
|
|
1603
|
+
try:
|
|
1604
|
+
_run_windows_burn(ser, "burn-image.ps1", drive, str(iso_path), spd)
|
|
1605
|
+
except RuntimeError:
|
|
1606
|
+
isoburn = shutil.which("isoburn") or os.path.join(
|
|
1607
|
+
os.environ.get("SystemRoot", r"C:\Windows"), "System32", "isoburn.exe")
|
|
1608
|
+
send(ser, "STATUS:Burning image (isoburn)...")
|
|
1609
|
+
if subprocess.run([isoburn, "/Q", drive, str(iso_path)]).returncode != 0:
|
|
1610
|
+
raise
|
|
1611
|
+
safe_send(ser, "PROGRESS:100%")
|
|
1612
|
+
return
|
|
1520
1613
|
if discstation_host.system_name() != "linux":
|
|
1521
1614
|
send(ser, "STATUS:Burning image...")
|
|
1522
1615
|
_run_growisofs(ser, discstation_host.iso_burn_command(disc_device(), iso_path), iso_path.parent / "discstation-burn.log")
|
package/src/discstation_host.py
CHANGED
|
@@ -98,6 +98,54 @@ def _mac_optical_device():
|
|
|
98
98
|
|
|
99
99
|
_last_disc_device = None
|
|
100
100
|
|
|
101
|
+
# --- Windows: IMAPI2 / WMI probes via bundled PowerShell helpers ---------------
|
|
102
|
+
_WIN_DIR = Path(__file__).resolve().parent / "win"
|
|
103
|
+
_win_info_cache = (0.0, None)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ps_cmd(script_name, *args):
|
|
107
|
+
"""Build a `powershell -File src/win/<script_name> <args>` argv, plus the
|
|
108
|
+
Popen/run kwargs that suppress the console window. The host runs as
|
|
109
|
+
pythonw.exe (no console); without CREATE_NO_WINDOW, Windows pops a
|
|
110
|
+
brand-new visible console for every one of these - and disc detection
|
|
111
|
+
polls every ~1.5s, so it would flash constantly."""
|
|
112
|
+
cmd = ["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
|
|
113
|
+
"-File", str(_WIN_DIR / script_name), *[str(a) for a in args]]
|
|
114
|
+
kwargs = {"creationflags": subprocess.CREATE_NO_WINDOW} if hasattr(subprocess, "CREATE_NO_WINDOW") else {}
|
|
115
|
+
return cmd, kwargs
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _run_ps(script_name, *args, timeout=25):
|
|
119
|
+
"""Run src/win/<script_name> and return (returncode, stdout, stderr)."""
|
|
120
|
+
cmd, kwargs = ps_cmd(script_name, *args)
|
|
121
|
+
try:
|
|
122
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, **kwargs)
|
|
123
|
+
return r.returncode, r.stdout, r.stderr
|
|
124
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
125
|
+
return 1, "", ""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _win_disc_info(force=False):
|
|
129
|
+
"""Cached (~2s) dict from src/win/disc-info.ps1, or {} on failure."""
|
|
130
|
+
import json as _json
|
|
131
|
+
import time
|
|
132
|
+
global _win_info_cache
|
|
133
|
+
ts, cached = _win_info_cache
|
|
134
|
+
if not force and cached is not None and time.time() - ts < 2.0:
|
|
135
|
+
return cached
|
|
136
|
+
override = os.environ.get("DISC_DEVICE") or os.environ.get("DVD_DEVICE") or ""
|
|
137
|
+
rc, out, _ = _run_ps("disc-info.ps1", *([override] if override else []), timeout=20)
|
|
138
|
+
info = {}
|
|
139
|
+
for line in out.splitlines():
|
|
140
|
+
line = line.strip()
|
|
141
|
+
if line.startswith("{"):
|
|
142
|
+
try:
|
|
143
|
+
info = _json.loads(line)
|
|
144
|
+
except ValueError:
|
|
145
|
+
pass
|
|
146
|
+
_win_info_cache = (time.time(), info)
|
|
147
|
+
return info
|
|
148
|
+
|
|
101
149
|
|
|
102
150
|
def disc_device():
|
|
103
151
|
global _last_disc_device
|
|
@@ -140,13 +188,22 @@ def disc_device():
|
|
|
140
188
|
if "/dev/disk" in line and ("CD" in line or "DVD" in line or "optical" in line.lower()):
|
|
141
189
|
return line.strip().split()[0]
|
|
142
190
|
elif system == "windows":
|
|
143
|
-
|
|
191
|
+
info = _win_disc_info()
|
|
192
|
+
if info.get("drive"):
|
|
193
|
+
return info["drive"]
|
|
194
|
+
if override:
|
|
195
|
+
return override
|
|
144
196
|
raise FileNotFoundError("No optical disc drive found; set DISC_DEVICE explicitly")
|
|
145
197
|
|
|
146
198
|
|
|
147
199
|
def drive_status():
|
|
148
200
|
"""Non-Linux equivalent of the CDROM_DRIVE_STATUS ioctl.
|
|
149
|
-
Returns 'disc' | 'no_disc' | 'unknown'. macOS:
|
|
201
|
+
Returns 'disc' | 'no_disc' | 'unknown'. macOS: `drutil status`; Windows: IMAPI2/WMI."""
|
|
202
|
+
if system_name() == "windows":
|
|
203
|
+
info = _win_disc_info()
|
|
204
|
+
if not info:
|
|
205
|
+
return "unknown"
|
|
206
|
+
return "disc" if info.get("media_loaded") else "no_disc"
|
|
150
207
|
if system_name() != "darwin":
|
|
151
208
|
return "unknown"
|
|
152
209
|
try:
|
|
@@ -244,10 +301,35 @@ def media_properties(device):
|
|
|
244
301
|
props["ID_CDROM_MEDIA_DVD_PLUS_R"] = "1"
|
|
245
302
|
_tag_rewritable(props, optical)
|
|
246
303
|
return props
|
|
304
|
+
if system_name() == "windows":
|
|
305
|
+
info = _win_disc_info()
|
|
306
|
+
if not info.get("media_loaded"):
|
|
307
|
+
return {}
|
|
308
|
+
props = {"ID_CDROM": "1", "ID_CDROM_MEDIA": "1"}
|
|
309
|
+
if info.get("label"):
|
|
310
|
+
props["ID_FS_LABEL"] = info["label"]
|
|
311
|
+
if info.get("fs") in ("udf", "iso9660"):
|
|
312
|
+
props["ID_FS_TYPE"] = info["fs"]
|
|
313
|
+
mtype = (info.get("media_type") or "").lower()
|
|
314
|
+
if mtype == "audio_cd" or (not info.get("fs") and not info.get("blank") and mtype.startswith("cd")):
|
|
315
|
+
props["ID_CDROM_MEDIA_TYPE"] = "audio"
|
|
316
|
+
elif mtype.startswith("dvd") or mtype.startswith("bd"):
|
|
317
|
+
props["ID_CDROM_MEDIA_TYPE"] = "dvd"
|
|
318
|
+
if info.get("blank"):
|
|
319
|
+
props["ID_CDROM_MEDIA_STATE"] = "blank"
|
|
320
|
+
if "dvd+r dl" in mtype or "dvd-r dl" in mtype:
|
|
321
|
+
props["ID_CDROM_MEDIA_DVD_PLUS_R_DL"] = "1"
|
|
322
|
+
elif ("dvd+r" in mtype or "dvd-r" in mtype) and "rw" not in mtype:
|
|
323
|
+
props["ID_CDROM_MEDIA_DVD_PLUS_R"] = "1"
|
|
324
|
+
if info.get("rewritable"):
|
|
325
|
+
_tag_rewritable(props, mtype)
|
|
326
|
+
return props
|
|
247
327
|
return {}
|
|
248
328
|
|
|
249
329
|
|
|
250
330
|
def media_capacity_bytes(device):
|
|
331
|
+
if system_name() == "windows":
|
|
332
|
+
return _win_disc_info().get("capacity_bytes") or None
|
|
251
333
|
if system_name() == "darwin":
|
|
252
334
|
try:
|
|
253
335
|
result = subprocess.run(["/usr/sbin/diskutil", "info", device], capture_output=True, text=True, check=False, timeout=3)
|
|
@@ -451,6 +533,12 @@ def eject_device(device, close=False):
|
|
|
451
533
|
except (OSError, subprocess.TimeoutExpired):
|
|
452
534
|
pass
|
|
453
535
|
return False
|
|
536
|
+
elif system == "windows":
|
|
537
|
+
args = [device] if device else []
|
|
538
|
+
if close:
|
|
539
|
+
args.append("-Close")
|
|
540
|
+
rc, _, _ = _run_ps("eject.ps1", *args, timeout=20)
|
|
541
|
+
return rc == 0
|
|
454
542
|
else:
|
|
455
|
-
raise RuntimeError("Automatic optical-drive eject is not implemented on
|
|
543
|
+
raise RuntimeError("Automatic optical-drive eject is not implemented on this OS")
|
|
456
544
|
return subprocess.run(command, capture_output=True, text=True, timeout=10).returncode == 0
|
package/src/static/app.js
CHANGED
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
try {
|
|
78
78
|
const response = await fetch("/disc-info", { cache: "no-store" });
|
|
79
79
|
const info = await response.json();
|
|
80
|
+
if (info.busy) return; // burn/rip in progress — keep current
|
|
80
81
|
state.discBytes = Number(info.capacity_bytes || 0);
|
|
81
82
|
state.discType = info.type || "none";
|
|
82
83
|
renderSelection();
|
|
@@ -281,7 +282,7 @@
|
|
|
281
282
|
setInterval(loadDiscInfo, 15000);
|
|
282
283
|
}
|
|
283
284
|
|
|
284
|
-
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=
|
|
285
|
+
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=8").catch(() => {});
|
|
285
286
|
window.addEventListener("beforeinstallprompt", (event) => {
|
|
286
287
|
event.preventDefault();
|
|
287
288
|
const button = document.createElement("button");
|
package/src/static/index.html
CHANGED
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
</footer>
|
|
106
106
|
</div>
|
|
107
107
|
<div id="install-slot"></div>
|
|
108
|
-
<script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=
|
|
109
|
-
<script src="/static/app.js?v=
|
|
108
|
+
<script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=8").catch(() => {});</script>
|
|
109
|
+
<script src="/static/app.js?v=8" defer></script>
|
|
110
110
|
</body>
|
|
111
111
|
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Minimal JSON emitter - works on PowerShell 2.0 (Win7) and up.
|
|
2
|
+
function ConvertTo-JsonCompat {
|
|
3
|
+
param([Parameter(ValueFromPipeline = $true)] $obj)
|
|
4
|
+
if ($null -eq $obj) { return 'null' }
|
|
5
|
+
switch ($obj.GetType().Name) {
|
|
6
|
+
'Boolean' { return $obj.ToString().ToLower() }
|
|
7
|
+
'Int32' { return $obj.ToString() }
|
|
8
|
+
'Int64' { return $obj.ToString() }
|
|
9
|
+
'Double' { return $obj.ToString([System.Globalization.CultureInfo]::InvariantCulture) }
|
|
10
|
+
'String' {
|
|
11
|
+
$s = $obj -replace '\\', '\\' -replace '"', '\"' -replace "`r", '\r' -replace "`n", '\n' -replace "`t", '\t'
|
|
12
|
+
return '"' + $s + '"'
|
|
13
|
+
}
|
|
14
|
+
'Hashtable' {
|
|
15
|
+
$parts = @()
|
|
16
|
+
foreach ($k in $obj.Keys) { $parts += ('"' + $k + '":' + (ConvertTo-JsonCompat $obj[$k])) }
|
|
17
|
+
return '{' + ($parts -join ',') + '}'
|
|
18
|
+
}
|
|
19
|
+
'Object[]' {
|
|
20
|
+
$parts = @()
|
|
21
|
+
foreach ($v in $obj) { $parts += (ConvertTo-JsonCompat $v) }
|
|
22
|
+
return '[' + ($parts -join ',') + ']'
|
|
23
|
+
}
|
|
24
|
+
default {
|
|
25
|
+
$s = "$obj" -replace '\\', '\\' -replace '"', '\"'
|
|
26
|
+
return '"' + $s + '"'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Read an audio CD's table of contents via IMAPI2 raw reader. Emits JSON:
|
|
2
|
+
# {"track_count":N,"leadout":L,"tracks":[o1,o2,...]} (frame offsets, +150)
|
|
3
|
+
# Usage: audio-toc.ps1 <drive e.g. D:>
|
|
4
|
+
param([Parameter(Mandatory = $true)] [string] $Drive)
|
|
5
|
+
|
|
6
|
+
$ErrorActionPreference = "Stop"
|
|
7
|
+
. (Join-Path $PSScriptRoot "_json.ps1")
|
|
8
|
+
|
|
9
|
+
function Get-Recorder([string]$letter) {
|
|
10
|
+
$master = New-Object -ComObject "IMAPI2.MsftDiscMaster2"
|
|
11
|
+
for ($i = 0; $i -lt $master.Count; $i++) {
|
|
12
|
+
$rec = New-Object -ComObject "IMAPI2.MsftDiscRecorder2"
|
|
13
|
+
$rec.InitializeDiscRecorder($master.Item($i))
|
|
14
|
+
foreach ($p in $rec.VolumePathNames) { if ($p -and $p.TrimEnd('\') -ieq $letter) { return $rec } }
|
|
15
|
+
}
|
|
16
|
+
throw "No optical recorder for $letter"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
$rec = Get-Recorder $Drive
|
|
20
|
+
$raw = New-Object -ComObject "IMAPI2.MsftDiscFormat2RawCD"
|
|
21
|
+
$raw.Recorder = $rec
|
|
22
|
+
$raw.ClientName = "DiscStation"
|
|
23
|
+
|
|
24
|
+
$toc = $raw.ReadDiscInformation() # not always present; fall through to raw TOC
|
|
25
|
+
$fmt = New-Object -ComObject "IMAPI2.MsftDiscFormat2Data"
|
|
26
|
+
$fmt.Recorder = $rec
|
|
27
|
+
|
|
28
|
+
# MsftDiscFormat2RawCD.get_TocInformation() -> byte array of the raw TOC (MMC-3).
|
|
29
|
+
$bytes = $raw.ReadTocInformation()
|
|
30
|
+
# TOC header: [0..1]=data length, [2]=first track, [3]=last track.
|
|
31
|
+
$first = $bytes[2]; $last = $bytes[3]
|
|
32
|
+
$offsets = @()
|
|
33
|
+
$leadout = 0
|
|
34
|
+
for ($i = 4; $i + 7 -lt $bytes.Length; $i += 8) {
|
|
35
|
+
$trk = $bytes[$i + 2]
|
|
36
|
+
# LBA is big-endian in bytes [i+4..i+7]
|
|
37
|
+
$lba = ($bytes[$i+4] -shl 24) -bor ($bytes[$i+5] -shl 16) -bor ($bytes[$i+6] -shl 8) -bor $bytes[$i+7]
|
|
38
|
+
if ($trk -eq 0xAA) { $leadout = $lba + 150 }
|
|
39
|
+
elseif ($trk -ge $first -and $trk -le $last) { $offsets += ($lba + 150) }
|
|
40
|
+
}
|
|
41
|
+
if ($offsets.Count -eq 0) { Write-Error "no audio tracks in TOC"; exit 2 }
|
|
42
|
+
|
|
43
|
+
Write-Output (ConvertTo-JsonCompat @{ track_count = $offsets.Count; leadout = $leadout; tracks = $offsets })
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Burn an audio CD (Red Book) from a folder of 16-bit / 44.1 kHz stereo WAV
|
|
2
|
+
# files via IMAPI2 Track-At-Once. Streams "PROGRESS:<pct>".
|
|
3
|
+
# Usage: burn-audio.ps1 <drive e.g. D:> <wav folder> [speed]
|
|
4
|
+
param(
|
|
5
|
+
[Parameter(Mandatory = $true)] [string] $Drive,
|
|
6
|
+
[Parameter(Mandatory = $true)] [string] $WavDir,
|
|
7
|
+
[string] $Speed = ""
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
$ErrorActionPreference = "Stop"
|
|
11
|
+
$wavs = @(Get-ChildItem -LiteralPath $WavDir -Filter *.wav | Sort-Object Name)
|
|
12
|
+
if ($wavs.Count -eq 0) { Write-Error "no WAV files in $WavDir"; exit 2 }
|
|
13
|
+
|
|
14
|
+
function Get-Recorder([string]$letter) {
|
|
15
|
+
$master = New-Object -ComObject "IMAPI2.MsftDiscMaster2"
|
|
16
|
+
for ($i = 0; $i -lt $master.Count; $i++) {
|
|
17
|
+
$rec = New-Object -ComObject "IMAPI2.MsftDiscRecorder2"
|
|
18
|
+
$rec.InitializeDiscRecorder($master.Item($i))
|
|
19
|
+
foreach ($p in $rec.VolumePathNames) {
|
|
20
|
+
if ($p -and $p.TrimEnd('\') -ieq $letter) { return $rec }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
throw "No optical recorder for $letter"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
$rec = Get-Recorder $Drive
|
|
27
|
+
$fmt = New-Object -ComObject "IMAPI2.MsftDiscFormat2TrackAtOnce"
|
|
28
|
+
if (-not $fmt.IsRecorderSupported($rec)) { Write-Error "recorder not supported"; exit 3 }
|
|
29
|
+
$fmt.Recorder = $rec
|
|
30
|
+
$fmt.ClientName = "DiscStation"
|
|
31
|
+
try { $fmt.NumberOfExistingTracks } catch {}
|
|
32
|
+
if ($Speed -and $Speed -match '^\d+') {
|
|
33
|
+
try { $fmt.SetWriteSpeed([int]($Speed -replace '\D',''), $false) } catch {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
$prepared = @()
|
|
37
|
+
foreach ($w in $wavs) {
|
|
38
|
+
# IMAPI2 wants raw 44100/16/2 PCM. Strip the 44-byte WAV header.
|
|
39
|
+
$bytes = [System.IO.File]::ReadAllBytes($w.FullName)
|
|
40
|
+
$offset = 44
|
|
41
|
+
$idx = -1
|
|
42
|
+
for ($i = 12; $i -lt [Math]::Min($bytes.Length - 8, 4096); $i++) {
|
|
43
|
+
if ($bytes[$i] -eq 0x64 -and $bytes[$i+1] -eq 0x61 -and $bytes[$i+2] -eq 0x74 -and $bytes[$i+3] -eq 0x61) {
|
|
44
|
+
$offset = $i + 8; break
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
$raw = New-Object byte[] ($bytes.Length - $offset)
|
|
48
|
+
[Array]::Copy($bytes, $offset, $raw, 0, $raw.Length)
|
|
49
|
+
$prepared += ,@{ name = $w.Name; data = $raw }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
$total = $prepared.Count
|
|
53
|
+
$done = 0
|
|
54
|
+
try {
|
|
55
|
+
foreach ($t in $prepared) {
|
|
56
|
+
$stream = New-Object -ComObject "ADODB.Stream"
|
|
57
|
+
$stream.Type = 1; $stream.Open()
|
|
58
|
+
$stream.Write($t.data)
|
|
59
|
+
$stream.Position = 0
|
|
60
|
+
$fmt.AddAudioTrack($stream)
|
|
61
|
+
$stream.Close()
|
|
62
|
+
$done++
|
|
63
|
+
Write-Output ("PROGRESS:" + [int]([math]::Min(99, $done * 100.0 / $total)))
|
|
64
|
+
}
|
|
65
|
+
$fmt.Close()
|
|
66
|
+
$fmt.Recorder.EjectMedia()
|
|
67
|
+
Write-Output "PROGRESS:100"
|
|
68
|
+
exit 0
|
|
69
|
+
} catch {
|
|
70
|
+
Write-Error ("audio burn failed: " + $_.Exception.Message)
|
|
71
|
+
exit 1
|
|
72
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Build a data-disc filesystem image from a folder (or single file) and burn it
|
|
2
|
+
# via IMAPI2 - no external mkisofs needed. Streams "PROGRESS:<pct>".
|
|
3
|
+
# Usage: burn-data.ps1 <drive e.g. D:> <source folder-or-file> <label> [speed]
|
|
4
|
+
param(
|
|
5
|
+
[Parameter(Mandatory = $true)] [string] $Drive,
|
|
6
|
+
[Parameter(Mandatory = $true)] [string] $Source,
|
|
7
|
+
[Parameter(Mandatory = $true)] [string] $Label,
|
|
8
|
+
[string] $Speed = ""
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
$ErrorActionPreference = "Stop"
|
|
12
|
+
if (-not (Test-Path $Source)) { Write-Error "source not found: $Source"; exit 2 }
|
|
13
|
+
|
|
14
|
+
function Get-Recorder([string]$letter) {
|
|
15
|
+
$master = New-Object -ComObject "IMAPI2.MsftDiscMaster2"
|
|
16
|
+
for ($i = 0; $i -lt $master.Count; $i++) {
|
|
17
|
+
$rec = New-Object -ComObject "IMAPI2.MsftDiscRecorder2"
|
|
18
|
+
$rec.InitializeDiscRecorder($master.Item($i))
|
|
19
|
+
foreach ($p in $rec.VolumePathNames) {
|
|
20
|
+
if ($p -and $p.TrimEnd('\') -ieq $letter) { return $rec }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
throw "No optical recorder for $letter"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
$rec = Get-Recorder $Drive
|
|
27
|
+
$fmt = New-Object -ComObject "IMAPI2.MsftDiscFormat2Data"
|
|
28
|
+
if (-not $fmt.IsRecorderSupported($rec)) { Write-Error "recorder not supported"; exit 3 }
|
|
29
|
+
$fmt.Recorder = $rec
|
|
30
|
+
$fmt.ClientName = "DiscStation"
|
|
31
|
+
if ($Speed -and $Speed -match '^\d+') {
|
|
32
|
+
try { $fmt.SetWriteSpeed([int]($Speed -replace '\D',''), $false) } catch {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Filesystem image: ISO9660 + Joliet + UDF, sized to the loaded media.
|
|
36
|
+
$fsi = New-Object -ComObject "IMAPI2FS.MsftFileSystemImage"
|
|
37
|
+
try { $fsi.ChooseImageDefaultsForMediaType($fmt.CurrentPhysicalMediaType) } catch {}
|
|
38
|
+
$fsi.FileSystemsToCreate = 7 # ISO9660 | Joliet | UDF
|
|
39
|
+
$fsi.VolumeName = ($Label -replace '[^A-Za-z0-9_\- ]', '').Substring(0, [Math]::Min(32, ($Label -replace '[^A-Za-z0-9_\- ]', '').Length))
|
|
40
|
+
$fsi.FreeMediaBlocks = -1 # -1 = use the whole disc
|
|
41
|
+
|
|
42
|
+
$item = Get-Item -LiteralPath $Source
|
|
43
|
+
if ($item.PSIsContainer) {
|
|
44
|
+
foreach ($child in Get-ChildItem -LiteralPath $Source) { $fsi.Root.AddTree($child.FullName, $false) }
|
|
45
|
+
} else {
|
|
46
|
+
$fsi.Root.AddTree($item.FullName, $false)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
$result = $fsi.CreateResultImage()
|
|
50
|
+
$stream = $result.ImageStream
|
|
51
|
+
|
|
52
|
+
Register-ObjectEvent -InputObject $fmt -EventName "Update" -SourceIdentifier "burn" -Action {
|
|
53
|
+
$s = $EventArgs
|
|
54
|
+
try {
|
|
55
|
+
$done = [double]$s.LastWrittenLba
|
|
56
|
+
$tot = [double]$s.SectorCount
|
|
57
|
+
if ($tot -gt 0) { Write-Output ("PROGRESS:" + [int]([math]::Min(99, $done * 100.0 / $tot))) }
|
|
58
|
+
} catch {}
|
|
59
|
+
} | Out-Null
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
$fmt.Write($stream)
|
|
63
|
+
Write-Output "PROGRESS:100"
|
|
64
|
+
exit 0
|
|
65
|
+
} catch {
|
|
66
|
+
Write-Error ("burn failed: " + $_.Exception.Message)
|
|
67
|
+
exit 1
|
|
68
|
+
} finally {
|
|
69
|
+
Unregister-Event -SourceIdentifier "burn" -ErrorAction SilentlyContinue
|
|
70
|
+
try { $rec.EjectMedia() } catch {}
|
|
71
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Burn a pre-built ISO to the optical drive via IMAPI2. Streams "PROGRESS:<pct>"
|
|
2
|
+
# lines to stdout. Usage: burn-image.ps1 <drive e.g. D:> <iso path> [speed]
|
|
3
|
+
param(
|
|
4
|
+
[Parameter(Mandatory = $true)] [string] $Drive,
|
|
5
|
+
[Parameter(Mandatory = $true)] [string] $Iso,
|
|
6
|
+
[string] $Speed = ""
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
$ErrorActionPreference = "Stop"
|
|
10
|
+
if (-not (Test-Path $Iso)) { Write-Error "ISO not found: $Iso"; exit 2 }
|
|
11
|
+
|
|
12
|
+
function Get-Recorder([string]$letter) {
|
|
13
|
+
$master = New-Object -ComObject "IMAPI2.MsftDiscMaster2"
|
|
14
|
+
for ($i = 0; $i -lt $master.Count; $i++) {
|
|
15
|
+
$rec = New-Object -ComObject "IMAPI2.MsftDiscRecorder2"
|
|
16
|
+
$rec.InitializeDiscRecorder($master.Item($i))
|
|
17
|
+
foreach ($p in $rec.VolumePathNames) {
|
|
18
|
+
if ($p -and $p.TrimEnd('\') -ieq $letter) { return $rec }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
throw "No optical recorder for $letter"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
$rec = Get-Recorder $Drive
|
|
25
|
+
$fmt = New-Object -ComObject "IMAPI2.MsftDiscFormat2Data"
|
|
26
|
+
if (-not $fmt.IsRecorderSupported($rec)) { Write-Error "recorder not supported"; exit 3 }
|
|
27
|
+
$fmt.Recorder = $rec
|
|
28
|
+
$fmt.ClientName = "DiscStation"
|
|
29
|
+
try { $fmt.ForceMediaToBeClosed = $true } catch {}
|
|
30
|
+
if ($Speed -and $Speed -match '^\d+') {
|
|
31
|
+
try { $fmt.SetWriteSpeed([int]($Speed -replace '\D',''), $false) } catch {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Progress: IMAPI2 raises an Update event with sector counts.
|
|
35
|
+
$script:total = 1
|
|
36
|
+
Register-ObjectEvent -InputObject $fmt -EventName "Update" -SourceIdentifier "burn" -Action {
|
|
37
|
+
$s = $EventArgs
|
|
38
|
+
try {
|
|
39
|
+
$done = [double]$s.LastWrittenLba
|
|
40
|
+
$tot = [double]$s.SectorCount
|
|
41
|
+
if ($tot -gt 0) {
|
|
42
|
+
$pct = [int]([math]::Min(99, $done * 100.0 / $tot))
|
|
43
|
+
Write-Output "PROGRESS:$pct"
|
|
44
|
+
}
|
|
45
|
+
} catch {}
|
|
46
|
+
} | Out-Null
|
|
47
|
+
|
|
48
|
+
$stream = New-Object -ComObject "ADODB.Stream"
|
|
49
|
+
$stream.Type = 1 # binary
|
|
50
|
+
$stream.Open()
|
|
51
|
+
$stream.LoadFromFile($Iso)
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
$fmt.Write($stream)
|
|
55
|
+
Write-Output "PROGRESS:100"
|
|
56
|
+
exit 0
|
|
57
|
+
} catch {
|
|
58
|
+
Write-Error ("burn failed: " + $_.Exception.Message)
|
|
59
|
+
exit 1
|
|
60
|
+
} finally {
|
|
61
|
+
$stream.Close()
|
|
62
|
+
Unregister-Event -SourceIdentifier "burn" -ErrorAction SilentlyContinue
|
|
63
|
+
try { $rec.EjectMedia() } catch {}
|
|
64
|
+
}
|