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.
@@ -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
- m = re.search(r"PERCENT:([\d.]+)", line)
1288
- if m:
1289
- pct = int(float(m.group(1)))
1290
- if 0 <= pct <= 100 and pct != last_pct:
1291
- last_pct = pct
1292
- send(ser, f"PROGRESS:{min(pct, 99)}%")
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 as e:
1304
- print(f"Disc eject skipped: {e}")
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")
@@ -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
- raise RuntimeError("Set DISC_DEVICE to the optical drive letter on Windows")
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: parse `drutil status`."""
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 Windows")
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=7").catch(() => {});
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");
@@ -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=7").catch(() => {});</script>
109
- <script src="/static/app.js?v=7" defer></script>
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
+ }