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_burn.py
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
import datetime
|
|
3
2
|
import json
|
|
4
3
|
import os
|
|
5
4
|
import re
|
|
6
5
|
import serial
|
|
7
6
|
import shutil
|
|
8
7
|
import subprocess
|
|
9
|
-
import sys
|
|
10
8
|
import threading
|
|
11
9
|
import time
|
|
12
10
|
import unicodedata
|
|
@@ -169,24 +167,6 @@ def reset_drive(device=None):
|
|
|
169
167
|
print("reset_drive: no matching USB optical device found to reset")
|
|
170
168
|
return False
|
|
171
169
|
|
|
172
|
-
def _esp32_port_from_sysfs():
|
|
173
|
-
"""Find the ESP32's USB serial port by matching VID/PID 303a:1001
|
|
174
|
-
in sysfs. Returns the device path (e.g. /dev/ttyACM0) or None."""
|
|
175
|
-
for tty in Path("/sys/class/tty").glob("ttyACM*"):
|
|
176
|
-
uevent = tty / "device" / "uevent"
|
|
177
|
-
if uevent.exists():
|
|
178
|
-
modalias = uevent.read_text()
|
|
179
|
-
if "303a/1001" in modalias or "303a:1001" in modalias:
|
|
180
|
-
dev = Path("/dev") / tty.name
|
|
181
|
-
if dev.exists():
|
|
182
|
-
return str(dev)
|
|
183
|
-
return None
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def detect_esp32_port():
|
|
187
|
-
return discstation_host.serial_port() or ""
|
|
188
|
-
|
|
189
|
-
PORT = detect_esp32_port()
|
|
190
170
|
BAUD = 115200
|
|
191
171
|
|
|
192
172
|
|
|
@@ -204,19 +184,26 @@ def check_cancel(ser):
|
|
|
204
184
|
return False
|
|
205
185
|
|
|
206
186
|
|
|
207
|
-
def
|
|
208
|
-
|
|
209
|
-
|
|
187
|
+
def proc_line_queue(proc):
|
|
188
|
+
"""Pump proc.stdout into a Queue from a daemon thread. Returns
|
|
189
|
+
(queue, done): `done` is the sentinel put on the queue at EOF."""
|
|
190
|
+
lines, done = Queue(), object()
|
|
210
191
|
|
|
211
|
-
def
|
|
192
|
+
def pump():
|
|
212
193
|
try:
|
|
213
194
|
for line in proc.stdout:
|
|
214
195
|
lines.put(line.rstrip("\r\n"))
|
|
215
196
|
finally:
|
|
216
|
-
lines.put(
|
|
197
|
+
lines.put(done)
|
|
198
|
+
|
|
199
|
+
threading.Thread(target=pump, daemon=True).start()
|
|
200
|
+
return lines, done
|
|
217
201
|
|
|
218
|
-
|
|
219
|
-
|
|
202
|
+
|
|
203
|
+
def iter_proc_or_cancel(proc, ser):
|
|
204
|
+
"""Yield proc's output lines, PINGing the remote every 5s; a CANCEL from
|
|
205
|
+
the remote stops the process and ends the iteration."""
|
|
206
|
+
lines, done = proc_line_queue(proc)
|
|
220
207
|
last_ping = time.time()
|
|
221
208
|
output_done = False
|
|
222
209
|
while proc.poll() is None or not output_done:
|
|
@@ -231,14 +218,12 @@ def iter_proc_or_cancel(proc, ser):
|
|
|
231
218
|
line = lines.get(timeout=0.2)
|
|
232
219
|
except Empty:
|
|
233
220
|
continue
|
|
234
|
-
if line is
|
|
221
|
+
if line is done:
|
|
235
222
|
output_done = True
|
|
236
223
|
else:
|
|
237
224
|
yield line
|
|
238
|
-
reader.join(timeout=1)
|
|
239
225
|
|
|
240
226
|
|
|
241
|
-
DVD_DEVICE = os.environ.get("DISC_DEVICE") or os.environ.get("DVD_DEVICE")
|
|
242
227
|
DISC_SPEED = os.environ.get("DISC_SPEED")
|
|
243
228
|
DISC_DISC_BYTES = 4_700_000_000
|
|
244
229
|
DVD_DL_BYTES = 8_500_000_000
|
|
@@ -1737,18 +1722,7 @@ def _run_windows_burn(ser, script, *script_args):
|
|
|
1737
1722
|
cmd, kwargs = discstation_host.ps_cmd(script, *script_args)
|
|
1738
1723
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **kwargs)
|
|
1739
1724
|
|
|
1740
|
-
lines =
|
|
1741
|
-
finished = object()
|
|
1742
|
-
|
|
1743
|
-
def read_output():
|
|
1744
|
-
try:
|
|
1745
|
-
for line in proc.stdout:
|
|
1746
|
-
lines.put(line.rstrip("\r\n"))
|
|
1747
|
-
finally:
|
|
1748
|
-
lines.put(finished)
|
|
1749
|
-
|
|
1750
|
-
reader = threading.Thread(target=read_output, daemon=True)
|
|
1751
|
-
reader.start()
|
|
1725
|
+
lines, finished = proc_line_queue(proc)
|
|
1752
1726
|
|
|
1753
1727
|
out_lines, last_pct, last_real_progress, start = [], -1, 0.0, time.time()
|
|
1754
1728
|
last_ping, output_done = time.time(), False
|
|
@@ -1784,7 +1758,6 @@ def _run_windows_burn(ser, script, *script_args):
|
|
|
1784
1758
|
if est_pct > last_pct:
|
|
1785
1759
|
last_pct = est_pct
|
|
1786
1760
|
send(ser, f"PROGRESS:{est_pct}%")
|
|
1787
|
-
reader.join(timeout=1)
|
|
1788
1761
|
except (KeyboardInterrupt, SystemExit):
|
|
1789
1762
|
stop_process(proc)
|
|
1790
1763
|
raise
|
|
@@ -1841,10 +1814,8 @@ def remux_and_author(ser, mpg, disc_label, disc_capacity, dvd_aspect=None):
|
|
|
1841
1814
|
silent blocking subprocess call that lets the ESP32's 30s watchdog
|
|
1842
1815
|
flip the display to "disconnected" partway through.
|
|
1843
1816
|
|
|
1844
|
-
This is the single place the remux+author sequence lives
|
|
1845
|
-
|
|
1846
|
-
any CLI entry point all call this (via remux_and_burn below, or
|
|
1847
|
-
directly), so a fix here only has to happen once.
|
|
1817
|
+
This is the single place the remux+author sequence lives (burn_flow
|
|
1818
|
+
calls it after the download/convert pipeline).
|
|
1848
1819
|
"""
|
|
1849
1820
|
check_encoded_size(ser, mpg, disc_capacity)
|
|
1850
1821
|
send(ser, f"TITLE:{disc_label}")
|
|
@@ -1894,163 +1865,3 @@ def remux_and_author(ser, mpg, disc_label, disc_capacity, dvd_aspect=None):
|
|
|
1894
1865
|
dvd_dir = author(ser, fixed, fixed.parent, dvd_aspect)
|
|
1895
1866
|
check_dvd_size(ser, dvd_dir, disc_capacity)
|
|
1896
1867
|
return dvd_dir
|
|
1897
|
-
|
|
1898
|
-
def remux_and_burn(ser, mpg, disc_label, disc_capacity, dl_info, burn_speed=None, dvd_aspect=None):
|
|
1899
|
-
dvd_dir = remux_and_author(ser, mpg, disc_label, disc_capacity, dvd_aspect)
|
|
1900
|
-
wait_for_burn_confirm(ser, dvd_dir, disc_capacity)
|
|
1901
|
-
burn(ser, dvd_dir, disc_label, burn_speed, dl_info["is_dual_layer"])
|
|
1902
|
-
safe_send(ser, "DONE:Burn complete!")
|
|
1903
|
-
print(f"Burned {mpg} as {disc_label}")
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
def main():
|
|
1907
|
-
if len(sys.argv) < 2:
|
|
1908
|
-
print("Usage: python3 discstation_burn.py 'video URL or file path'")
|
|
1909
|
-
sys.exit(1)
|
|
1910
|
-
url = sys.argv[1]
|
|
1911
|
-
WORK.mkdir(parents=True, exist_ok=True)
|
|
1912
|
-
job_dir = WORK / time.strftime("job_%Y%m%d_%H%M%S")
|
|
1913
|
-
job_dir.mkdir()
|
|
1914
|
-
ser = None
|
|
1915
|
-
try:
|
|
1916
|
-
ser = serial.Serial(PORT, BAUD, timeout=1)
|
|
1917
|
-
reset_serial_state()
|
|
1918
|
-
time.sleep(2)
|
|
1919
|
-
print("Connected to DiscStation")
|
|
1920
|
-
print("Running preflight...")
|
|
1921
|
-
send(ser, "STATUS:Preflight...")
|
|
1922
|
-
info = get_video_info(url, ser)
|
|
1923
|
-
title = info["title"]
|
|
1924
|
-
duration = info["duration"]
|
|
1925
|
-
duration_line, fit_line, can_fit = preflight_lines(duration)
|
|
1926
|
-
print(f"Title: {title}")
|
|
1927
|
-
print(f"Duration: {format_duration(duration)}")
|
|
1928
|
-
print(f"Preflight: {fit_line}")
|
|
1929
|
-
print(f"DVD drive: {disc_device()}")
|
|
1930
|
-
disc_label = sanitize_disc_label(title)
|
|
1931
|
-
print(f"Disc label: {disc_label}")
|
|
1932
|
-
send(ser, f"TITLE:{title}")
|
|
1933
|
-
send(ser, f"META:{duration_line}")
|
|
1934
|
-
send(ser, f"FIT:{fit_line}")
|
|
1935
|
-
if not can_fit:
|
|
1936
|
-
raise RuntimeError("Video too long for DVD5")
|
|
1937
|
-
|
|
1938
|
-
device = disc_device()
|
|
1939
|
-
dl_info = detect_disc_type(device)
|
|
1940
|
-
if dl_info["is_dual_layer"]:
|
|
1941
|
-
sl_target = int(os.environ.get("DISC_TARGET_BYTES", "4300000000"))
|
|
1942
|
-
try:
|
|
1943
|
-
sl_plan = bitrate_plan(duration, "AUTO", sl_target)
|
|
1944
|
-
if sl_plan:
|
|
1945
|
-
warn = f"DL disc for SL content"
|
|
1946
|
-
print(f"WARNING: {warn}")
|
|
1947
|
-
safe_send(ser, f"WARNING:{warn}")
|
|
1948
|
-
time.sleep(3)
|
|
1949
|
-
except RuntimeError:
|
|
1950
|
-
pass
|
|
1951
|
-
|
|
1952
|
-
print("Waiting for button press...")
|
|
1953
|
-
selected_mode = "AUTO"
|
|
1954
|
-
burn_speed = None
|
|
1955
|
-
while True:
|
|
1956
|
-
if ser.in_waiting:
|
|
1957
|
-
resp = ser.readline().decode(errors='ignore').strip()
|
|
1958
|
-
note_serial_activity()
|
|
1959
|
-
if resp == "CANCEL":
|
|
1960
|
-
print("Cancelled by user")
|
|
1961
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
1962
|
-
sys.exit(130)
|
|
1963
|
-
elif resp.startswith("MODE:"):
|
|
1964
|
-
selected_mode = normalize_mode(resp.split(":", 1)[1])
|
|
1965
|
-
print(f"Mode: {selected_mode}")
|
|
1966
|
-
elif resp.startswith("SPEED:"):
|
|
1967
|
-
burn_speed = resp.split(":", 1)[1].strip()
|
|
1968
|
-
print(f"Burn speed: {burn_speed}")
|
|
1969
|
-
elif resp == "START" or resp.startswith("START:"):
|
|
1970
|
-
if ":" in resp:
|
|
1971
|
-
selected_mode = normalize_mode(resp.split(":", 1)[1])
|
|
1972
|
-
print(f"Button pressed - starting in {selected_mode} mode!")
|
|
1973
|
-
send(ser, f"STATUS:Starting {selected_mode}...")
|
|
1974
|
-
break
|
|
1975
|
-
time.sleep(0.1)
|
|
1976
|
-
|
|
1977
|
-
start_time = time.time()
|
|
1978
|
-
disc_type_label = "DL" if dl_info["is_dual_layer"] else "SL"
|
|
1979
|
-
try:
|
|
1980
|
-
disc_bytes = dl_info["capacity"]
|
|
1981
|
-
plan = bitrate_plan(duration, selected_mode, disc_bytes)
|
|
1982
|
-
video = download(ser, url, job_dir)
|
|
1983
|
-
mpg, dvd_aspect = convert(ser, video, job_dir, selected_mode, disc_bytes)
|
|
1984
|
-
dvd = remux_and_author(ser, mpg, disc_label, disc_bytes, dvd_aspect)
|
|
1985
|
-
if plan["burn"]:
|
|
1986
|
-
burn(ser, dvd, disc_label, burn_speed, dl_info["is_dual_layer"])
|
|
1987
|
-
send(ser, "DONE:Disc complete!")
|
|
1988
|
-
print("Done!")
|
|
1989
|
-
else:
|
|
1990
|
-
send(ser, "DONE:Test complete!")
|
|
1991
|
-
print("Test complete. DVD folder was built but not burned.")
|
|
1992
|
-
append_history({
|
|
1993
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
1994
|
-
"title": title,
|
|
1995
|
-
"disc_type": disc_type_label,
|
|
1996
|
-
"mode": selected_mode,
|
|
1997
|
-
"speed": burn_speed or "Auto",
|
|
1998
|
-
"success": True,
|
|
1999
|
-
"duration_s": round(time.time() - start_time),
|
|
2000
|
-
})
|
|
2001
|
-
except (KeyboardInterrupt, SystemExit):
|
|
2002
|
-
append_history({
|
|
2003
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
2004
|
-
"title": title,
|
|
2005
|
-
"disc_type": disc_type_label,
|
|
2006
|
-
"mode": selected_mode,
|
|
2007
|
-
"speed": burn_speed or "Auto",
|
|
2008
|
-
"success": False,
|
|
2009
|
-
"error": "Cancelled",
|
|
2010
|
-
"duration_s": round(time.time() - start_time),
|
|
2011
|
-
})
|
|
2012
|
-
raise
|
|
2013
|
-
except Exception as e:
|
|
2014
|
-
append_history({
|
|
2015
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
2016
|
-
"title": title,
|
|
2017
|
-
"disc_type": disc_type_label,
|
|
2018
|
-
"mode": selected_mode,
|
|
2019
|
-
"speed": burn_speed or "Auto",
|
|
2020
|
-
"success": False,
|
|
2021
|
-
"error": str(e)[:100],
|
|
2022
|
-
"duration_s": round(time.time() - start_time),
|
|
2023
|
-
})
|
|
2024
|
-
raise
|
|
2025
|
-
except (KeyboardInterrupt, SystemExit):
|
|
2026
|
-
safe_send(ser, "CANCELLED:Stopped")
|
|
2027
|
-
print("Cancelled")
|
|
2028
|
-
sys.exit(130)
|
|
2029
|
-
except RuntimeError as e:
|
|
2030
|
-
msg = str(e)
|
|
2031
|
-
if msg == "Cancelled":
|
|
2032
|
-
safe_send(ser, "CANCELLED:Stopped")
|
|
2033
|
-
else:
|
|
2034
|
-
safe_send(ser, f"ERROR:{msg[:20]}")
|
|
2035
|
-
print(f"Error: {e}")
|
|
2036
|
-
sys.exit(1)
|
|
2037
|
-
except Exception as e:
|
|
2038
|
-
safe_send(ser, f"ERROR:{str(e)[:20]}")
|
|
2039
|
-
print(f"Error: {e}")
|
|
2040
|
-
sys.exit(1)
|
|
2041
|
-
finally:
|
|
2042
|
-
if ser:
|
|
2043
|
-
ser.close()
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
BURN_HISTORY = WORK / "burn_history.jsonl"
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
def append_history(entry):
|
|
2050
|
-
BURN_HISTORY.parent.mkdir(parents=True, exist_ok=True)
|
|
2051
|
-
with open(BURN_HISTORY, "a") as f:
|
|
2052
|
-
f.write(json.dumps(entry) + "\n")
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
if __name__ == "__main__":
|
|
2056
|
-
main()
|
package/src/static/app.js
CHANGED
|
@@ -92,6 +92,10 @@
|
|
|
92
92
|
function applyRemoteState(progress) {
|
|
93
93
|
const hardware = progress.appliance === "hardware";
|
|
94
94
|
if (hardware && !$("remote-panel").hidden) setRemoteVisible(false, true);
|
|
95
|
+
// Software mode has no other control surface at all - don't make it
|
|
96
|
+
// opt-in-via-logo-click to discover the only way to actually use the
|
|
97
|
+
// appliance. Only forces it open (never closes it back on the user).
|
|
98
|
+
if (!hardware && $("remote-panel").hidden) setRemoteVisible(true, true);
|
|
95
99
|
$("remote-note").textContent = hardware
|
|
96
100
|
? "A physical remote is attached — on-screen controls are disabled."
|
|
97
101
|
: "No physical remote detected — control DiscStation from here.";
|
|
@@ -119,9 +123,20 @@
|
|
|
119
123
|
});
|
|
120
124
|
setRemoteVisible(localStorage.getItem(remoteKey) === "1");
|
|
121
125
|
|
|
122
|
-
$("remote-controls").addEventListener("click", (event) => {
|
|
126
|
+
$("remote-controls").addEventListener("click", async (event) => {
|
|
123
127
|
const button = event.target.closest("[data-cmd]");
|
|
124
|
-
if (button
|
|
128
|
+
if (!button || button.disabled) return;
|
|
129
|
+
const cmd = button.dataset.cmd;
|
|
130
|
+
await sendRemoteCmd(cmd);
|
|
131
|
+
// On real hardware START is a long-press of the encoder on the burn-ready
|
|
132
|
+
// review screen, not its own button - a one-click "BURN AUDIO"/"BURN
|
|
133
|
+
// DATA"/"BURN" here is the whole point of the web remote, so chase the
|
|
134
|
+
// mode select straight through to starting the burn instead of leaving
|
|
135
|
+
// the user stuck with nothing left to press. (The backend queues this
|
|
136
|
+
// harmlessly if files/URL aren't in yet - it's only consumed once the
|
|
137
|
+
// burn's actually at its own "waiting for start" step, and gets flushed
|
|
138
|
+
// if a different mode gets selected first so it can't fire the wrong burn.)
|
|
139
|
+
if (cmd.startsWith("SELECT:BURN")) await sendRemoteCmd("START");
|
|
125
140
|
});
|
|
126
141
|
let volumeTimer;
|
|
127
142
|
$("remote-volume").addEventListener("input", (event) => {
|
|
@@ -130,11 +145,25 @@
|
|
|
130
145
|
volumeTimer = setTimeout(() => sendRemoteCmd(`POT:${value}`), 150);
|
|
131
146
|
});
|
|
132
147
|
|
|
148
|
+
// Mode-select buttons this server-computed list governs - CANCEL/EJECT are
|
|
149
|
+
// controls, not modes, and stay available regardless (same as hardware).
|
|
150
|
+
const MENU_MODES = ["BURN", "BURN DATA", "BURN AUDIO", "RIP", "PLAY"];
|
|
151
|
+
|
|
152
|
+
function applyMenuItems(items) {
|
|
153
|
+
if (!items) return; // unknown (fetch error, etc.) - leave as-is
|
|
154
|
+
const allowed = new Set(items);
|
|
155
|
+
MENU_MODES.forEach((mode) => {
|
|
156
|
+
const btn = document.querySelector(`#remote-controls [data-cmd="SELECT:${mode}"]`);
|
|
157
|
+
if (btn) btn.hidden = !allowed.has(mode);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
133
161
|
async function loadDiscInfo() {
|
|
134
162
|
try {
|
|
135
163
|
const response = await fetch("/disc-info", { cache: "no-store" });
|
|
136
164
|
const info = await response.json();
|
|
137
165
|
renderDiscStatus(info);
|
|
166
|
+
applyMenuItems(info.menu_items);
|
|
138
167
|
if (info.busy) return; // burn/rip in progress — keep current
|
|
139
168
|
state.discBytes = Number(info.capacity_bytes || 0);
|
|
140
169
|
state.discType = info.type || "none";
|
|
@@ -150,7 +179,7 @@
|
|
|
150
179
|
const el = $("remote-disc-status");
|
|
151
180
|
if (!el) return;
|
|
152
181
|
if (!info) { el.textContent = "DISC: UNKNOWN"; return; }
|
|
153
|
-
if (info.busy) { el.textContent = "DISC: BUSY
|
|
182
|
+
if (info.busy) { el.textContent = "DISC: BUSY"; return; }
|
|
154
183
|
if (!info.disc_present) { el.textContent = "DISC: NONE"; return; }
|
|
155
184
|
const kind = (info.type || info.kind || "unknown").toUpperCase();
|
|
156
185
|
const label = info.label ? ` "${info.label}"` : "";
|
package/src/static/style.css
CHANGED
|
@@ -177,13 +177,6 @@ h1 {
|
|
|
177
177
|
font-weight: 900;
|
|
178
178
|
}
|
|
179
179
|
h1 em { font-style: normal; -webkit-text-stroke: 1px var(--ink); color: transparent; }
|
|
180
|
-
.lede {
|
|
181
|
-
max-width: 560px;
|
|
182
|
-
margin: 28px 0 26px;
|
|
183
|
-
color: var(--muted);
|
|
184
|
-
font-size: 12px;
|
|
185
|
-
line-height: 1.8;
|
|
186
|
-
}
|
|
187
180
|
.hero-specs {
|
|
188
181
|
display: flex;
|
|
189
182
|
flex-wrap: wrap;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Shared IMAPI2 helpers, dot-sourced by the burn scripts (same pattern as _json.ps1).
|
|
2
|
+
|
|
3
|
+
function Get-Recorder([string]$letter) {
|
|
4
|
+
$master = New-Object -ComObject "IMAPI2.MsftDiscMaster2"
|
|
5
|
+
for ($i = 0; $i -lt $master.Count; $i++) {
|
|
6
|
+
$rec = New-Object -ComObject "IMAPI2.MsftDiscRecorder2"
|
|
7
|
+
$rec.InitializeDiscRecorder($master.Item($i))
|
|
8
|
+
foreach ($p in $rec.VolumePathNames) {
|
|
9
|
+
if ($p -and $p.TrimEnd('\') -ieq $letter) { return $rec }
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
throw "No optical recorder for $letter"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
# Apply an optional "8x"-style write speed; not fatal if the drive refuses it.
|
|
16
|
+
function Set-WriteSpeed($fmt, [string]$speed) {
|
|
17
|
+
if ($speed -and $speed -match '^\d+') {
|
|
18
|
+
try { $fmt.SetWriteSpeed([int]($speed -replace '\D',''), $false) } catch {}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# A data-disc (IMAPI2.MsftDiscFormat2Data) writer bound to the drive letter.
|
|
23
|
+
function New-DataWriter([string]$letter, [string]$speed) {
|
|
24
|
+
$rec = Get-Recorder $letter
|
|
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
|
+
# Without this the disc session never finalizes - the drive reports the
|
|
30
|
+
# disc as still blank afterward even though the data is physically there.
|
|
31
|
+
try { $fmt.ForceMediaToBeClosed = $true } catch {}
|
|
32
|
+
Set-WriteSpeed $fmt $speed
|
|
33
|
+
return $fmt
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Write $stream to the disc, streaming "PROGRESS:<pct>" lines, then eject.
|
|
37
|
+
# Sets $global:BurnExit (0 ok, 1 failed) for the caller to `exit` with.
|
|
38
|
+
function Write-DiscStream($fmt, $stream) {
|
|
39
|
+
# IMAPI2 raises an Update event with sector counts. Not fatal if
|
|
40
|
+
# registration fails (seen on some setups: "Cannot register for the
|
|
41
|
+
# specified event... does not exist") - the burn itself doesn't need it,
|
|
42
|
+
# just no live PROGRESS lines.
|
|
43
|
+
try {
|
|
44
|
+
Register-ObjectEvent -InputObject $fmt -EventName "Update" -SourceIdentifier "burn" -Action {
|
|
45
|
+
$s = $EventArgs
|
|
46
|
+
try {
|
|
47
|
+
$done = [double]$s.LastWrittenLba
|
|
48
|
+
$tot = [double]$s.SectorCount
|
|
49
|
+
if ($tot -gt 0) { Write-Output ("PROGRESS:" + [int]([math]::Min(99, $done * 100.0 / $tot))) }
|
|
50
|
+
} catch {}
|
|
51
|
+
} | Out-Null
|
|
52
|
+
} catch {}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
$fmt.Write($stream)
|
|
56
|
+
Write-Output "PROGRESS:100"
|
|
57
|
+
$global:BurnExit = 0
|
|
58
|
+
} catch {
|
|
59
|
+
Write-Error ("burn failed: " + $_.Exception.Message)
|
|
60
|
+
$global:BurnExit = 1
|
|
61
|
+
} finally {
|
|
62
|
+
try { $stream.Close() } catch {}
|
|
63
|
+
Unregister-Event -SourceIdentifier "burn" -ErrorAction SilentlyContinue
|
|
64
|
+
try { $fmt.Recorder.EjectMedia() } catch {}
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/win/audio-toc.ps1
CHANGED
|
@@ -6,15 +6,7 @@ param([Parameter(Mandatory = $true)] [string] $Drive)
|
|
|
6
6
|
$ErrorActionPreference = "Stop"
|
|
7
7
|
. (Join-Path $PSScriptRoot "_json.ps1")
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
}
|
|
9
|
+
. (Join-Path $PSScriptRoot "_imapi.ps1")
|
|
18
10
|
|
|
19
11
|
$rec = Get-Recorder $Drive
|
|
20
12
|
$raw = New-Object -ComObject "IMAPI2.MsftDiscFormat2RawCD"
|
package/src/win/burn-audio.ps1
CHANGED
|
@@ -11,17 +11,7 @@ $ErrorActionPreference = "Stop"
|
|
|
11
11
|
$wavs = @(Get-ChildItem -LiteralPath $WavDir -Filter *.wav | Sort-Object Name)
|
|
12
12
|
if ($wavs.Count -eq 0) { Write-Error "no WAV files in $WavDir"; exit 2 }
|
|
13
13
|
|
|
14
|
-
|
|
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
|
-
}
|
|
14
|
+
. (Join-Path $PSScriptRoot "_imapi.ps1")
|
|
25
15
|
|
|
26
16
|
$rec = Get-Recorder $Drive
|
|
27
17
|
$fmt = New-Object -ComObject "IMAPI2.MsftDiscFormat2TrackAtOnce"
|
|
@@ -29,9 +19,7 @@ if (-not $fmt.IsRecorderSupported($rec)) { Write-Error "recorder not supported";
|
|
|
29
19
|
$fmt.Recorder = $rec
|
|
30
20
|
$fmt.ClientName = "DiscStation"
|
|
31
21
|
try { $fmt.NumberOfExistingTracks } catch {}
|
|
32
|
-
|
|
33
|
-
try { $fmt.SetWriteSpeed([int]($Speed -replace '\D',''), $false) } catch {}
|
|
34
|
-
}
|
|
22
|
+
Set-WriteSpeed $fmt $Speed
|
|
35
23
|
|
|
36
24
|
$prepared = @()
|
|
37
25
|
foreach ($w in $wavs) {
|
package/src/win/burn-data.ps1
CHANGED
|
@@ -11,29 +11,9 @@ param(
|
|
|
11
11
|
$ErrorActionPreference = "Stop"
|
|
12
12
|
if (-not (Test-Path $Source)) { Write-Error "source not found: $Source"; exit 2 }
|
|
13
13
|
|
|
14
|
-
|
|
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
|
-
}
|
|
14
|
+
. (Join-Path $PSScriptRoot "_imapi.ps1")
|
|
25
15
|
|
|
26
|
-
$
|
|
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
|
-
# 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 {}
|
|
34
|
-
if ($Speed -and $Speed -match '^\d+') {
|
|
35
|
-
try { $fmt.SetWriteSpeed([int]($Speed -replace '\D',''), $false) } catch {}
|
|
36
|
-
}
|
|
16
|
+
$fmt = New-DataWriter $Drive $Speed
|
|
37
17
|
|
|
38
18
|
# Filesystem image: ISO9660 + Joliet + UDF, sized to the loaded media.
|
|
39
19
|
$fsi = New-Object -ComObject "IMAPI2FS.MsftFileSystemImage"
|
|
@@ -56,28 +36,5 @@ if ($item.PSIsContainer) {
|
|
|
56
36
|
$result = $fsi.CreateResultImage()
|
|
57
37
|
$stream = $result.ImageStream
|
|
58
38
|
|
|
59
|
-
|
|
60
|
-
|
|
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 {}
|
|
72
|
-
|
|
73
|
-
try {
|
|
74
|
-
$fmt.Write($stream)
|
|
75
|
-
Write-Output "PROGRESS:100"
|
|
76
|
-
exit 0
|
|
77
|
-
} catch {
|
|
78
|
-
Write-Error ("burn failed: " + $_.Exception.Message)
|
|
79
|
-
exit 1
|
|
80
|
-
} finally {
|
|
81
|
-
Unregister-Event -SourceIdentifier "burn" -ErrorAction SilentlyContinue
|
|
82
|
-
try { $rec.EjectMedia() } catch {}
|
|
83
|
-
}
|
|
39
|
+
Write-DiscStream $fmt $stream
|
|
40
|
+
exit $global:BurnExit
|
package/src/win/burn-image.ps1
CHANGED
|
@@ -8,61 +8,12 @@ param(
|
|
|
8
8
|
|
|
9
9
|
$ErrorActionPreference = "Stop"
|
|
10
10
|
if (-not (Test-Path $Iso)) { Write-Error "ISO not found: $Iso"; exit 2 }
|
|
11
|
+
. (Join-Path $PSScriptRoot "_imapi.ps1")
|
|
11
12
|
|
|
12
|
-
|
|
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. 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 {}
|
|
51
|
-
|
|
13
|
+
$fmt = New-DataWriter $Drive $Speed
|
|
52
14
|
$stream = New-Object -ComObject "ADODB.Stream"
|
|
53
15
|
$stream.Type = 1 # binary
|
|
54
16
|
$stream.Open()
|
|
55
17
|
$stream.LoadFromFile($Iso)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
$fmt.Write($stream)
|
|
59
|
-
Write-Output "PROGRESS:100"
|
|
60
|
-
exit 0
|
|
61
|
-
} catch {
|
|
62
|
-
Write-Error ("burn failed: " + $_.Exception.Message)
|
|
63
|
-
exit 1
|
|
64
|
-
} finally {
|
|
65
|
-
$stream.Close()
|
|
66
|
-
Unregister-Event -SourceIdentifier "burn" -ErrorAction SilentlyContinue
|
|
67
|
-
try { $rec.EjectMedia() } catch {}
|
|
68
|
-
}
|
|
18
|
+
Write-DiscStream $fmt $stream
|
|
19
|
+
exit $global:BurnExit
|