discstation 0.1.20 → 0.1.21

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.
@@ -721,7 +721,7 @@ def get_local_video_info(path):
721
721
  return {"title": stem, "duration": dur}
722
722
 
723
723
 
724
- def get_video_info(source):
724
+ def get_video_info(source, ser=None):
725
725
  p = Path(source)
726
726
  if p.is_dir():
727
727
  videos = find_video_files(source)
@@ -733,11 +733,25 @@ def get_video_info(source):
733
733
  return get_local_video_info(source)
734
734
  errors = []
735
735
  for player_client in YTDLP_PLAYER_CLIENTS or (None,):
736
- r = subprocess.run(
737
- [*ytdlp_base_args(player_client), '--dump-single-json', '--skip-download', source],
738
- capture_output=True,
739
- text=True,
740
- )
736
+ # This yt-dlp metadata fetch can take a while (retries, slow network) -
737
+ # without a PING during the wait, the ESP32's 30s silence watchdog
738
+ # flips the OLED to "disconnected" while the host is still alive and
739
+ # just blocked on subprocess.run. Same keepalive download() uses.
740
+ stop_ping, pt = _start_keepalive(ser) if ser else (None, None)
741
+ try:
742
+ r = subprocess.run(
743
+ [*ytdlp_base_args(player_client), '--dump-single-json', '--skip-download', source],
744
+ capture_output=True,
745
+ text=True,
746
+ timeout=90,
747
+ )
748
+ except subprocess.TimeoutExpired:
749
+ errors.append(f"{player_client or 'default'} client timed out")
750
+ continue
751
+ finally:
752
+ if stop_ping:
753
+ stop_ping.set()
754
+ pt.join(timeout=1)
741
755
  if r.returncode != 0:
742
756
  errors.append(r.stderr.strip() or f"{player_client or 'default'} client failed")
743
757
  continue
@@ -1101,7 +1115,11 @@ def author(ser, mpg, job_dir, aspect="4:3"):
1101
1115
  send(ser, "PROGRESS:Building IFO/VOB")
1102
1116
  dvd_dir = job_dir / "dvd_out"
1103
1117
  dvd_dir.mkdir(parents=True, exist_ok=True)
1104
- xml = f"""<dvdauthor dest={chr(34) + str(dvd_dir) + chr(34)} format="pal">
1118
+ # Format is conveyed via the VIDEO_FORMAT env var below, not this XML
1119
+ # attribute - some dvdauthor builds (the Windows one) reject a `format`
1120
+ # attribute on <dvdauthor> outright ("Cannot match attribute 'format'"),
1121
+ # which would fail authoring on every single burn.
1122
+ xml = f"""<dvdauthor dest={chr(34) + str(dvd_dir) + chr(34)}>
1105
1123
  <vmgm />
1106
1124
  <titleset>
1107
1125
  <titles>
@@ -1566,23 +1584,83 @@ def _stage_dir(paths):
1566
1584
  return str(staging)
1567
1585
 
1568
1586
 
1587
+ def _estimate_burn_seconds(source):
1588
+ """Rough total write time for the synthetic progress fallback below,
1589
+ assuming a ~4x DVD write speed (5.54MB/s) - real speed varies by drive
1590
+ and media, so this is a ceiling to tune if it drifts, not a promise."""
1591
+ p = Path(source)
1592
+ size = tree_size(p) if p.is_dir() else (p.stat().st_size if p.is_file() else 0)
1593
+ return max(size / (5.54 * 1024 * 1024), 1.0)
1594
+
1595
+
1569
1596
  def _run_windows_burn(ser, script, *script_args):
1570
1597
  """Run a src/win/<script> IMAPI2 burn helper, streaming its PROGRESS:<pct>
1571
- lines to the ESP32. Raises RuntimeError on a non-zero exit."""
1598
+ lines to the ESP32. Raises RuntimeError on a non-zero exit.
1599
+
1600
+ IMAPI2's progress event doesn't reliably reach PowerShell on every
1601
+ setup (COM event dispatch needs the calling thread to pump messages,
1602
+ which it can't do while blocked inside the synchronous native Write()
1603
+ call) - so real PROGRESS lines may never arrive until the very end.
1604
+ While waiting, synthesize a smoothly-climbing estimate from elapsed
1605
+ time vs. the source size at a conservative write speed, capped at 95%
1606
+ until the process actually exits; real PROGRESS lines (from scripts
1607
+ where the event does work, e.g. burn-audio.ps1's per-track updates)
1608
+ still take priority whenever they show up.
1609
+ """
1572
1610
  send(ser, "STATUS:Burning...")
1573
1611
  send(ser, "PROGRESS:0%")
1612
+ est_total = _estimate_burn_seconds(script_args[1]) if len(script_args) > 1 else None
1574
1613
  cmd, kwargs = discstation_host.ps_cmd(script, *script_args)
1575
1614
  proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **kwargs)
1576
- out_lines, last_pct = [], -1
1615
+
1616
+ lines = Queue()
1617
+ finished = object()
1618
+
1619
+ def read_output():
1620
+ try:
1621
+ for line in proc.stdout:
1622
+ lines.put(line.rstrip("\r\n"))
1623
+ finally:
1624
+ lines.put(finished)
1625
+
1626
+ reader = threading.Thread(target=read_output, daemon=True)
1627
+ reader.start()
1628
+
1629
+ out_lines, last_pct, last_real_progress, start = [], -1, 0.0, time.time()
1630
+ last_ping, output_done = time.time(), False
1577
1631
  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)}%")
1632
+ while proc.poll() is None or not output_done:
1633
+ if time.time() - last_ping >= 5:
1634
+ last_ping = time.time()
1635
+ send(ser, "PING")
1636
+ if check_cancel(ser):
1637
+ stop_process(proc)
1638
+ return
1639
+ try:
1640
+ line = lines.get(timeout=0.2)
1641
+ except Empty:
1642
+ line = None
1643
+ if line is finished:
1644
+ output_done = True
1645
+ elif line is not None:
1646
+ out_lines.append(line)
1647
+ m = re.search(r"PROGRESS:(-?\d+)", line)
1648
+ if m:
1649
+ pct = int(m.group(1))
1650
+ if 0 <= pct <= 100 and pct != last_pct:
1651
+ last_pct, last_real_progress = pct, time.time()
1652
+ send(ser, f"PROGRESS:{min(pct, 99)}%")
1653
+ continue
1654
+ # No fresh real progress recently - IMAPI2's Write() blocks the
1655
+ # PowerShell thread the whole time, so its Update event often
1656
+ # never gets dispatched until the call returns. Estimate from
1657
+ # elapsed time instead of leaving the OLED stuck at 0%.
1658
+ if est_total and time.time() - last_real_progress > 3:
1659
+ est_pct = int(min(95, (time.time() - start) / est_total * 100))
1660
+ if est_pct > last_pct:
1661
+ last_pct = est_pct
1662
+ send(ser, f"PROGRESS:{est_pct}%")
1663
+ reader.join(timeout=1)
1586
1664
  except (KeyboardInterrupt, SystemExit):
1587
1665
  stop_process(proc)
1588
1666
  raise
@@ -1717,7 +1795,7 @@ def main():
1717
1795
  print("Connected to DiscStation")
1718
1796
  print("Running preflight...")
1719
1797
  send(ser, "STATUS:Preflight...")
1720
- info = get_video_info(url)
1798
+ info = get_video_info(url, ser)
1721
1799
  title = info["title"]
1722
1800
  duration = info["duration"]
1723
1801
  duration_line, fit_line, can_fit = preflight_lines(duration)
@@ -9,6 +9,8 @@ import platform
9
9
  import re
10
10
  import shutil
11
11
  import subprocess
12
+ import sys
13
+ import threading
12
14
  from pathlib import Path
13
15
 
14
16
  from serial.tools import list_ports
@@ -101,6 +103,7 @@ _last_disc_device = None
101
103
  # --- Windows: IMAPI2 / WMI probes via bundled PowerShell helpers ---------------
102
104
  _WIN_DIR = Path(__file__).resolve().parent / "win"
103
105
  _win_info_cache = (0.0, None)
106
+ _win_info_lock = threading.Lock()
104
107
 
105
108
 
106
109
  def ps_cmd(script_name, *args):
@@ -126,25 +129,34 @@ def _run_ps(script_name, *args, timeout=25):
126
129
 
127
130
 
128
131
  def _win_disc_info(force=False):
129
- """Cached (~2s) dict from src/win/disc-info.ps1, or {} on failure."""
132
+ """Cached (~2s) dict from src/win/disc-info.ps1, or {} on failure.
133
+
134
+ Locked: disc-info.ps1's IMAPI2 calls appear to serialize on the physical
135
+ drive, so a burst of concurrent callers (multiple web UI tabs, the HTTP
136
+ /disc-info endpoint racing station_loop's own poll) each missing the
137
+ cache at once used to spawn a pile of concurrent powershell.exe
138
+ processes that queued up behind each other and blew past the timeout -
139
+ reporting a false "no disc". One in-flight refresh, shared by whoever's
140
+ waiting, instead of one per caller."""
130
141
  import json as _json
131
142
  import time
132
143
  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
144
+ with _win_info_lock:
145
+ ts, cached = _win_info_cache
146
+ if not force and cached is not None and time.time() - ts < 2.0:
147
+ return cached
148
+ override = os.environ.get("DISC_DEVICE") or os.environ.get("DVD_DEVICE") or ""
149
+ rc, out, _ = _run_ps("disc-info.ps1", *([override] if override else []), timeout=20)
150
+ info = {}
151
+ for line in out.splitlines():
152
+ line = line.strip()
153
+ if line.startswith("{"):
154
+ try:
155
+ info = _json.loads(line)
156
+ except ValueError:
157
+ pass
158
+ _win_info_cache = (time.time(), info)
159
+ return info
148
160
 
149
161
 
150
162
  def disc_device():
@@ -349,9 +361,28 @@ def tool(name):
349
361
  local = user_home() / ".local" / "bin" / name
350
362
  if local.exists():
351
363
  return str(local)
364
+ # pip-installed console scripts (yt-dlp, etc. from requirements.txt) land
365
+ # in the venv's own Scripts/bin dir, which isn't necessarily on PATH -
366
+ # e.g. the Windows Scheduled Task runs pythonw.exe directly, no shell
367
+ # activation. sys.exec_prefix is the venv root we're actually running in.
368
+ venv_bin = Path(sys.exec_prefix) / ("Scripts" if system_name() == "windows" else "bin")
369
+ for candidate_name in ((name + ".exe", name) if system_name() == "windows" else (name,)):
370
+ candidate = venv_bin / candidate_name
371
+ if candidate.exists():
372
+ return str(candidate)
352
373
  path = shutil.which(name)
353
374
  if path:
354
375
  return path
376
+ if system_name() == "windows":
377
+ # install-windows.ps1's no-winget fallback downloads ffmpeg/yt-dlp
378
+ # into config_dir()/tools (e.g. an extracted "ffmpeg-9.0-essentials
379
+ # build\bin\"), and only puts it on the *installer script's own*
380
+ # session PATH - gone the moment the installer exits. Search that
381
+ # tree directly instead of relying on PATH.
382
+ exe = name if name.lower().endswith(".exe") else name + ".exe"
383
+ match = next((config_dir() / "tools").rglob(exe), None) if (config_dir() / "tools").exists() else None
384
+ if match:
385
+ return str(match)
355
386
  if system_name() == "darwin":
356
387
  candidates = [
357
388
  Path("/opt/homebrew/bin") / name,
package/src/static/app.js CHANGED
@@ -66,6 +66,7 @@
66
66
  setConnection(true);
67
67
  setLiveStatus(progress.status);
68
68
  setProgress(progress.status, Number(progress.progress), progress.active);
69
+ applyRemoteState(progress);
69
70
  } catch (_) {
70
71
  setConnection(false);
71
72
  setLiveStatus("OFFLINE");
@@ -73,10 +74,67 @@
73
74
  }
74
75
  }
75
76
 
77
+ // --- On-screen remote: the exact same text commands the ESP32 sends -----
78
+ // POSTed to /remote/button, which feeds a VirtualSerial standing in for a
79
+ // real appliance. Only usable when no hardware remote is attached.
80
+ const remoteKey = "discstation-remote-visible";
81
+
82
+ function setRemoteVisible(visible, persist = false) {
83
+ $("remote-panel").hidden = !visible;
84
+ $("brand-link").classList.toggle("remote-active", visible);
85
+ if (persist) localStorage.setItem(remoteKey, visible ? "1" : "0");
86
+ }
87
+
88
+ function toggleRemotePanel() {
89
+ setRemoteVisible($("remote-panel").hidden, true);
90
+ }
91
+
92
+ function applyRemoteState(progress) {
93
+ const hardware = progress.appliance === "hardware";
94
+ if (hardware && !$("remote-panel").hidden) setRemoteVisible(false, true);
95
+ $("remote-note").textContent = hardware
96
+ ? "A physical remote is attached — on-screen controls are disabled."
97
+ : "No physical remote detected — control DiscStation from here.";
98
+ $("remote-controls").querySelectorAll("button, input").forEach((el) => { el.disabled = hardware; });
99
+ $("remote-transport").hidden = !progress.playing;
100
+ const ejectBtn = $("remote-eject-btn");
101
+ const open = !!progress.tray_open;
102
+ ejectBtn.textContent = open ? "CLOSE TRAY" : "EJECT";
103
+ ejectBtn.dataset.cmd = open ? "CONFIRM" : "EJECT";
104
+ }
105
+
106
+ async function sendRemoteCmd(cmd) {
107
+ try {
108
+ await fetch("/remote/button", {
109
+ method: "POST",
110
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
111
+ body: new URLSearchParams({ cmd })
112
+ });
113
+ } catch (_) { /* next poll/SSE tick reflects reality */ }
114
+ }
115
+
116
+ $("brand-link").addEventListener("click", (event) => {
117
+ event.preventDefault();
118
+ toggleRemotePanel();
119
+ });
120
+ setRemoteVisible(localStorage.getItem(remoteKey) === "1");
121
+
122
+ $("remote-controls").addEventListener("click", (event) => {
123
+ const button = event.target.closest("[data-cmd]");
124
+ if (button && !button.disabled) sendRemoteCmd(button.dataset.cmd);
125
+ });
126
+ let volumeTimer;
127
+ $("remote-volume").addEventListener("input", (event) => {
128
+ clearTimeout(volumeTimer);
129
+ const value = event.target.value;
130
+ volumeTimer = setTimeout(() => sendRemoteCmd(`POT:${value}`), 150);
131
+ });
132
+
76
133
  async function loadDiscInfo() {
77
134
  try {
78
135
  const response = await fetch("/disc-info", { cache: "no-store" });
79
136
  const info = await response.json();
137
+ renderDiscStatus(info);
80
138
  if (info.busy) return; // burn/rip in progress — keep current
81
139
  state.discBytes = Number(info.capacity_bytes || 0);
82
140
  state.discType = info.type || "none";
@@ -84,9 +142,22 @@
84
142
  } catch (_) {
85
143
  state.discBytes = 0;
86
144
  state.discType = "none";
145
+ renderDiscStatus(null);
87
146
  }
88
147
  }
89
148
 
149
+ function renderDiscStatus(info) {
150
+ const el = $("remote-disc-status");
151
+ if (!el) return;
152
+ if (!info) { el.textContent = "DISC: UNKNOWN"; return; }
153
+ if (info.busy) { el.textContent = "DISC: BUSY (see status above)"; return; }
154
+ if (!info.disc_present) { el.textContent = "DISC: NONE"; return; }
155
+ const kind = (info.type || info.kind || "unknown").toUpperCase();
156
+ const label = info.label ? ` "${info.label}"` : "";
157
+ const size = info.capacity_gb ? ` // ${info.capacity_gb}GB` : "";
158
+ el.textContent = `DISC: ${kind}${label}${size}`;
159
+ }
160
+
90
161
  function renderSelection() {
91
162
  const list = $("selection-list");
92
163
  const total = state.entries.reduce((sum, entry) => sum + entry.file.size, 0);
@@ -269,6 +340,7 @@
269
340
  setConnection(true);
270
341
  setLiveStatus(d.status);
271
342
  setProgress(d.status, Number(d.progress), d.active);
343
+ applyRemoteState(d);
272
344
  });
273
345
  es.addEventListener("open", () => { setConnection(true); loadDiscInfo(); });
274
346
  es.addEventListener("error", () => {
@@ -13,9 +13,10 @@
13
13
  <body>
14
14
  <div class="page-shell">
15
15
  <header class="topbar">
16
- <a class="brand" href="/" aria-label="DiscStation home">
16
+ <a class="brand" href="/" id="brand-link" aria-label="DiscStation home / toggle remote">
17
17
  <span class="brand-mark">DS</span>
18
18
  <span>DISCSTATION</span>
19
+ <span id="remote-hint" class="remote-hint">REMOTE</span>
19
20
  </a>
20
21
  <div class="topbar-actions">
21
22
  <button id="theme-toggle" class="theme-toggle" type="button" aria-label="Switch to dark mode">&#x263E;</button>
@@ -34,6 +35,39 @@
34
35
  <div><strong>04</strong><span>RIP / PLAY</span></div>
35
36
  </section>
36
37
 
38
+ <section id="remote-panel" class="remote-panel section-rule" hidden aria-labelledby="remote-title">
39
+ <div class="panel-heading">
40
+ <div>
41
+ <div class="section-kicker">ON-SCREEN REMOTE</div>
42
+ <h2 id="remote-title">CONTROL SURFACE</h2>
43
+ </div>
44
+ <span class="panel-index">DISCSTN-02</span>
45
+ </div>
46
+ <p id="remote-note" class="field-note"></p>
47
+ <div id="remote-disc-status" class="remote-disc-status">DISC: CHECKING</div>
48
+ <div id="remote-controls">
49
+ <div class="remote-grid">
50
+ <button class="outline-button" type="button" data-cmd="SELECT:BURN">BURN</button>
51
+ <button class="outline-button" type="button" data-cmd="SELECT:BURN DATA">BURN DATA</button>
52
+ <button class="outline-button" type="button" data-cmd="SELECT:BURN AUDIO">BURN AUDIO</button>
53
+ <button class="outline-button" type="button" data-cmd="SELECT:RIP">RIP</button>
54
+ <button class="outline-button" type="button" data-cmd="SELECT:PLAY">PLAY</button>
55
+ <button class="outline-button" type="button" data-cmd="CANCEL">CANCEL / HOME</button>
56
+ <button class="outline-button" type="button" id="remote-eject-btn" data-cmd="EJECT">EJECT</button>
57
+ </div>
58
+ <div id="remote-transport" class="remote-transport" hidden>
59
+ <div class="remote-grid">
60
+ <button class="outline-button" type="button" data-cmd="REW:BIG">&#x23EE; PREV</button>
61
+ <button class="outline-button" type="button" data-cmd="PLAY_BUTTON">&#x23EF; PLAY/PAUSE</button>
62
+ <button class="outline-button" type="button" data-cmd="FF:BIG">&#x23ED; NEXT</button>
63
+ <button class="outline-button" type="button" data-cmd="PLAY_STOP">&#x23F9; STOP</button>
64
+ </div>
65
+ <label class="field-label" for="remote-volume">VOLUME</label>
66
+ <input id="remote-volume" type="range" min="0" max="100" value="70">
67
+ </div>
68
+ </div>
69
+ </section>
70
+
37
71
  <section class="burn-panel section-rule" aria-labelledby="burn-title">
38
72
  <div class="panel-heading">
39
73
  <div>
@@ -105,7 +139,7 @@
105
139
  </footer>
106
140
  </div>
107
141
  <div id="install-slot"></div>
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>
142
+ <script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=17").catch(() => {});</script>
143
+ <script src="/static/app.js?v=17" defer></script>
110
144
  </body>
111
145
  </html>
@@ -99,6 +99,27 @@ button { color: inherit; }
99
99
  color: var(--ink);
100
100
  text-decoration: none;
101
101
  font-weight: 700;
102
+ cursor: pointer;
103
+ }
104
+
105
+ .remote-hint {
106
+ display: inline-flex;
107
+ align-items: center;
108
+ height: 16px;
109
+ padding: 0 6px;
110
+ border: 1px solid var(--soft-line);
111
+ color: var(--muted);
112
+ font-size: 8px;
113
+ font-weight: 700;
114
+ letter-spacing: 0.08em;
115
+ white-space: nowrap;
116
+ text-shadow: none;
117
+ transition: color 0.2s ease, border-color 0.2s ease, text-shadow 0.2s ease;
118
+ }
119
+ .brand.remote-active .remote-hint {
120
+ color: var(--ink);
121
+ border-color: var(--ink);
122
+ text-shadow: 0 0 6px var(--accent);
102
123
  }
103
124
 
104
125
  .brand-mark {
@@ -278,6 +299,21 @@ h2 { margin-top: 8px; font-family: var(--display); font-size: clamp(30px, 7vw, 6
278
299
  .form-message.error { color: #9d4035; }
279
300
  .form-message.ok { color: #287346; }
280
301
 
302
+ .remote-panel { padding: 26px 0 30px; }
303
+ .remote-disc-status {
304
+ margin: 10px 0 4px;
305
+ padding: 10px 12px;
306
+ border: 1px solid var(--ink);
307
+ background: var(--surface);
308
+ font-size: 11px;
309
+ font-weight: 700;
310
+ letter-spacing: 0.06em;
311
+ }
312
+ .remote-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-top: 16px; }
313
+ #remote-controls button:disabled, #remote-controls input:disabled { cursor: not-allowed; opacity: 0.4; }
314
+ .remote-transport { margin-top: 20px; padding-top: 18px; border-top: 1px solid var(--soft-line); }
315
+ .remote-transport input[type="range"] { width: 100%; margin-top: 4px; accent-color: var(--accent); }
316
+
281
317
  .footer-stamp {
282
318
  display: flex;
283
319
  justify-content: space-between;
@@ -315,6 +351,8 @@ h2 { margin-top: 8px; font-family: var(--display); font-size: clamp(30px, 7vw, 6
315
351
  .capability-strip div:not(:last-child) { border-right: 1px solid var(--soft-line); }
316
352
  .capability-strip div + div { padding-left: 18px; }
317
353
  .burn-panel { width: min(100%, 720px); margin-left: auto; margin-right: auto; }
354
+ .remote-panel { width: min(100%, 720px); margin-left: auto; margin-right: auto; }
355
+ .remote-panel .remote-grid { grid-template-columns: repeat(3, 1fr); }
318
356
  }
319
357
 
320
358
  @media (max-width: 600px) {
@@ -44,14 +44,28 @@ foreach ($w in $wavs) {
44
44
  $offset = $i + 8; break
45
45
  }
46
46
  }
47
- $raw = New-Object byte[] ($bytes.Length - $offset)
48
- [Array]::Copy($bytes, $offset, $raw, 0, $raw.Length)
47
+ $rawLen = $bytes.Length - $offset
48
+ # Red Book requires each track's byte length to be an exact multiple of
49
+ # the 2352-byte CD-DA sector - real-world track lengths essentially
50
+ # never land on that boundary naturally. AddAudioTrack rejects anything
51
+ # else outright ("The provided audio stream is not valid."). Pad with
52
+ # silence up to the next sector boundary (and up to the 4-second/
53
+ # 300-sector minimum a track must have) rather than trim real audio.
54
+ $sectorSize = 2352
55
+ $minLen = 300 * $sectorSize
56
+ $paddedLen = [Math]::Ceiling([Math]::Max($rawLen, $minLen) / $sectorSize) * $sectorSize
57
+ $raw = New-Object byte[] $paddedLen
58
+ [Array]::Copy($bytes, $offset, $raw, 0, $rawLen)
49
59
  $prepared += ,@{ name = $w.Name; data = $raw }
50
60
  }
51
61
 
52
62
  $total = $prepared.Count
53
63
  $done = 0
54
64
  try {
65
+ # AddAudioTrack throws E_IMAPI_DF2TAO_MEDIA_IS_NOT_PREPARED ("only valid
66
+ # when media has been prepared") without this - PrepareMedia locks the
67
+ # drive for the write session, ReleaseMedia below hands it back.
68
+ $fmt.PrepareMedia()
55
69
  foreach ($t in $prepared) {
56
70
  $stream = New-Object -ComObject "ADODB.Stream"
57
71
  $stream.Type = 1; $stream.Open()
@@ -62,11 +76,12 @@ try {
62
76
  $done++
63
77
  Write-Output ("PROGRESS:" + [int]([math]::Min(99, $done * 100.0 / $total)))
64
78
  }
65
- $fmt.Close()
79
+ $fmt.ReleaseMedia()
66
80
  $fmt.Recorder.EjectMedia()
67
81
  Write-Output "PROGRESS:100"
68
82
  exit 0
69
83
  } catch {
84
+ try { $fmt.ReleaseMedia() } catch {}
70
85
  Write-Error ("audio burn failed: " + $_.Exception.Message)
71
86
  exit 1
72
87
  }
@@ -28,6 +28,9 @@ $fmt = New-Object -ComObject "IMAPI2.MsftDiscFormat2Data"
28
28
  if (-not $fmt.IsRecorderSupported($rec)) { Write-Error "recorder not supported"; exit 3 }
29
29
  $fmt.Recorder = $rec
30
30
  $fmt.ClientName = "DiscStation"
31
+ # Without this the disc session never finalizes - the drive reports the
32
+ # disc as still blank afterward even though the data is physically there.
33
+ try { $fmt.ForceMediaToBeClosed = $true } catch {}
31
34
  if ($Speed -and $Speed -match '^\d+') {
32
35
  try { $fmt.SetWriteSpeed([int]($Speed -replace '\D',''), $false) } catch {}
33
36
  }
@@ -41,7 +44,11 @@ $fsi.FreeMediaBlocks = -1 # -1 = use the whole disc
41
44
 
42
45
  $item = Get-Item -LiteralPath $Source
43
46
  if ($item.PSIsContainer) {
44
- foreach ($child in Get-ChildItem -LiteralPath $Source) { $fsi.Root.AddTree($child.FullName, $false) }
47
+ # AddTree's 2nd arg is IncludeBaseDirectory: $false flattens a folder
48
+ # child into just its contents at the disc root (dropping the folder
49
+ # name entirely) - wrong for VIDEO_TS/AUDIO_TS or any subfolder, which
50
+ # need to keep their own name. $true preserves it as a real subfolder.
51
+ foreach ($child in Get-ChildItem -LiteralPath $Source) { $fsi.Root.AddTree($child.FullName, $true) }
45
52
  } else {
46
53
  $fsi.Root.AddTree($item.FullName, $false)
47
54
  }
@@ -49,14 +56,19 @@ if ($item.PSIsContainer) {
49
56
  $result = $fsi.CreateResultImage()
50
57
  $stream = $result.ImageStream
51
58
 
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
59
+ # Not fatal if registration fails (seen on some setups: "Cannot register
60
+ # for the specified event... does not exist") - the burn itself doesn't
61
+ # need it, just no live PROGRESS lines.
62
+ try {
63
+ Register-ObjectEvent -InputObject $fmt -EventName "Update" -SourceIdentifier "burn" -Action {
64
+ $s = $EventArgs
65
+ try {
66
+ $done = [double]$s.LastWrittenLba
67
+ $tot = [double]$s.SectorCount
68
+ if ($tot -gt 0) { Write-Output ("PROGRESS:" + [int]([math]::Min(99, $done * 100.0 / $tot))) }
69
+ } catch {}
70
+ } | Out-Null
71
+ } catch {}
60
72
 
61
73
  try {
62
74
  $fmt.Write($stream)
@@ -31,19 +31,23 @@ if ($Speed -and $Speed -match '^\d+') {
31
31
  try { $fmt.SetWriteSpeed([int]($Speed -replace '\D',''), $false) } catch {}
32
32
  }
33
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
34
+ # Progress: IMAPI2 raises an Update event with sector counts. Not fatal if
35
+ # registration fails (seen on some setups: "Cannot register for the
36
+ # specified event... does not exist") - the burn itself doesn't need it,
37
+ # just no live PROGRESS lines.
38
+ try {
39
+ Register-ObjectEvent -InputObject $fmt -EventName "Update" -SourceIdentifier "burn" -Action {
40
+ $s = $EventArgs
41
+ try {
42
+ $done = [double]$s.LastWrittenLba
43
+ $tot = [double]$s.SectorCount
44
+ if ($tot -gt 0) {
45
+ $pct = [int]([math]::Min(99, $done * 100.0 / $tot))
46
+ Write-Output "PROGRESS:$pct"
47
+ }
48
+ } catch {}
49
+ } | Out-Null
50
+ } catch {}
47
51
 
48
52
  $stream = New-Object -ComObject "ADODB.Stream"
49
53
  $stream.Type = 1 # binary
@@ -23,11 +23,18 @@ try {
23
23
  if (-not $out.media_loaded) { Write-Output (ConvertTo-JsonCompat $out); exit 0 }
24
24
 
25
25
  # Volume label + filesystem (WMI logical disk).
26
+ $isAudioCd = $false
26
27
  try {
27
28
  $ld = Get-WmiObject Win32_LogicalDisk -Filter ("DeviceID='" + $out.drive + "'")
28
29
  if ($ld) {
29
30
  if ($ld.VolumeName) { $out.label = $ld.VolumeName }
30
- if ($ld.FileSystem) {
31
+ # Windows presents a synthetic CDFS view (fixed volume name "Audio
32
+ # CD") for audio discs, purely so Explorer can browse track01.cda
33
+ # files - it's not a real filesystem. Treating it as one made every
34
+ # audio CD misclassify as a data disc.
35
+ if ($ld.VolumeName -eq "Audio CD" -and "$($ld.FileSystem)" -ieq "CDFS") {
36
+ $isAudioCd = $true
37
+ } elseif ($ld.FileSystem) {
31
38
  $fs = $ld.FileSystem.ToLower()
32
39
  if ($fs -match "udf") { $out.fs = "udf" }
33
40
  elseif ($fs -match "cdfs|iso9660") { $out.fs = "iso9660" }
@@ -64,13 +71,11 @@ try {
64
71
  }
65
72
  } catch {}
66
73
 
67
- # An audio CD has readable media, no filesystem, and (usually) no IMAPI type.
68
- if (-not $out.fs -and -not $out.blank -and ($out.media_type -eq "" -or $out.media_type -match "^cd")) {
69
- try {
70
- $ld2 = Get-WmiObject Win32_CDROMDrive -Filter ("Drive='" + $out.drive + "'")
71
- # Win32_CDROMDrive has no track info; treat "media loaded, no FS, not blank" as audio.
72
- $out.media_type = "audio_cd"
73
- } catch {}
74
+ # $isAudioCd (Windows' own synthetic "Audio CD" CDFS view, detected above) is
75
+ # authoritative - overrides whatever IMAPI2's physical-media-type guess said,
76
+ # since a finalized audio CD-R still reports as generic "cd-rom" there.
77
+ if ($isAudioCd) {
78
+ $out.media_type = "audio_cd"
74
79
  }
75
80
 
76
81
  Write-Output (ConvertTo-JsonCompat $out)