discstation 0.1.18 → 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 +114 -38
- package/src/discstation_burn.py +81 -0
- package/src/discstation_host.py +91 -3
- 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/README.md
CHANGED
|
@@ -64,7 +64,7 @@ Windows.
|
|
|
64
64
|
|----|-----------|-----------------|
|
|
65
65
|
| Linux | `install.sh` — apt deps, venv, systemd `--user` service, self-signed cert | full burn / rip / play |
|
|
66
66
|
| macOS | `install-macos.sh` — Homebrew deps, venv, launchd agent, cert | burn / rip / play (audio-CD *burning* is best-effort; set `DISC_DEVICE` if detection fails) |
|
|
67
|
-
| Windows | `install-windows.ps1` —
|
|
67
|
+
| Windows | `install-windows.ps1` — Python + venv, self-signed cert, firewall rules, auto-start Scheduled Task | burn (ISO / data / audio CD) via IMAPI2 on Windows 10/11 and 7; DVD rip (HandBrake main-feature mode) and play (mpv) use the shared cross-platform path; full VIDEO_TS mirror rip and audio-CD rip aren't implemented on Windows yet |
|
|
68
68
|
|
|
69
69
|
### Or run the platform script directly
|
|
70
70
|
|
|
@@ -80,7 +80,7 @@ arduino-cli lib install QRCode
|
|
|
80
80
|
# macOS host
|
|
81
81
|
./install-macos.sh
|
|
82
82
|
|
|
83
|
-
# Windows host
|
|
83
|
+
# Windows host
|
|
84
84
|
PowerShell -ExecutionPolicy Bypass -File .\install-windows.ps1
|
|
85
85
|
```
|
|
86
86
|
|
|
@@ -88,8 +88,10 @@ PowerShell -ExecutionPolicy Bypass -File .\install-windows.ps1
|
|
|
88
88
|
|
|
89
89
|
Linux and macOS both run the complete optical workflow (burn, rip, play) —
|
|
90
90
|
macOS via `xorriso` / `hdiutil` / `cd-paranoia` / `dvdbackup` / HandBrake, with
|
|
91
|
-
audio-CD *burning* the one best-effort area. Windows
|
|
92
|
-
|
|
91
|
+
audio-CD *burning* the one best-effort area. Windows burns (ISO / data / audio
|
|
92
|
+
CD) via IMAPI2 and plays via mpv; DVD ripping works in HandBrake main-feature
|
|
93
|
+
mode but not full VIDEO_TS mirror or audio-CD ripping yet. See
|
|
94
|
+
`docs/PLATFORM_SUPPORT.md`.
|
|
93
95
|
|
|
94
96
|
## Web Interface
|
|
95
97
|
|
|
@@ -114,7 +116,7 @@ Built-in web server on port 8080 (HTTPS with self-signed cert):
|
|
|
114
116
|
| `DISC_CLEANUP_DAYS` | 2 | Auto-cleanup old job directories |
|
|
115
117
|
| `DISC_OUTPUT_LIMIT_BYTES` | 4300000000 | Conservative DVD5 payload limit |
|
|
116
118
|
| `DISC_DL_OUTPUT_LIMIT_BYTES` | 8000000000 | Conservative DVD9 payload limit |
|
|
117
|
-
| `DISC_DEVICE` | auto | Optical drive node override
|
|
119
|
+
| `DISC_DEVICE` | auto | Optical drive node override |
|
|
118
120
|
| `DISC_PORT` | auto | ESP32 serial port override |
|
|
119
121
|
| `DISCSTATION_HTTP_PORT` | 8081 | Plain-HTTP port for the mobile app (`0` disables) |
|
|
120
122
|
| `DISCSTATION_DVD_RIP_MODE` | mirror | `mkv` = HandBrake main-feature transcode instead of a full VIDEO_TS mirror |
|
package/docs/PLATFORM_SUPPORT.md
CHANGED
|
@@ -29,10 +29,28 @@ the drive on recent macOS; DiscStation reports this clearly instead of hanging.
|
|
|
29
29
|
|
|
30
30
|
## Windows
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
Runs the same Python host and web UI as Linux/macOS, triggered the same way
|
|
33
|
+
via the ESP32 OLED remote. `install-windows.ps1` sets up Python + venv, a
|
|
34
|
+
self-signed cert, firewall rules for 8080/8081, and a per-user auto-start
|
|
35
|
+
Scheduled Task (headless `pythonw.exe`, no admin needed).
|
|
36
|
+
|
|
37
|
+
- **Detect / eject** — WMI (`Win32_CDROMDrive`, `Win32_LogicalDisk`) + IMAPI2
|
|
38
|
+
for media type, blank/rewritable state, and capacity
|
|
39
|
+
(`src/win/disc-info.ps1`, `src/win/eject.ps1`).
|
|
40
|
+
- **Burn** — data, ISO, and audio CD all go through IMAPI2
|
|
41
|
+
(`src/win/burn-{image,data,audio}.ps1`), with live progress streamed to the
|
|
42
|
+
OLED; `isoburn.exe /q` is the zero-dependency fallback for a raw ISO.
|
|
43
|
+
Works on Windows 7 SP1 and 10/11.
|
|
44
|
+
- **Rip** — DVD main-feature mode via HandBrakeCLI (winget on 10/11) works;
|
|
45
|
+
a full unencrypted VIDEO_TS mirror and audio-CD ripping aren't implemented
|
|
46
|
+
yet (no Windows path for `dvdbackup`/`cd-paranoia`).
|
|
47
|
+
- **Play** — `mpv`, via the same code path as Linux/macOS (device letter for
|
|
48
|
+
DVD-Video, `cdda://` for audio CD, mounted drive for data/VCD).
|
|
49
|
+
|
|
50
|
+
Windows 7 (no `winget`) gets the host, detection, eject, and all three burn
|
|
51
|
+
modes; rip/play need tools that don't install cleanly on 7, so those OLED
|
|
52
|
+
actions report "not supported" there. Set `DISC_DEVICE` to override drive
|
|
53
|
+
auto-detection.
|
|
36
54
|
|
|
37
55
|
## Shared Components
|
|
38
56
|
|
|
@@ -43,4 +61,5 @@ instead of silently attempting Linux commands.
|
|
|
43
61
|
- QR-code web URL display
|
|
44
62
|
|
|
45
63
|
The optical-drive backend is the platform boundary. Linux and macOS are
|
|
46
|
-
complete (bar macOS audio-CD burning); Windows
|
|
64
|
+
complete (bar macOS audio-CD burning); Windows burns and plays, with DVD-mirror
|
|
65
|
+
and audio-CD ripping still to come.
|
package/install-windows.ps1
CHANGED
|
@@ -1,29 +1,150 @@
|
|
|
1
|
+
# DiscStation host installer for Windows 10/11 (and best-effort on 7 SP1).
|
|
2
|
+
# powershell -ExecutionPolicy Bypass -File install-windows.ps1
|
|
3
|
+
# Sets up: Python + venv, optical CLI tools (winget), self-signed cert,
|
|
4
|
+
# a per-user auto-start Scheduled Task, and inbound firewall rules for 8080/8081.
|
|
1
5
|
$ErrorActionPreference = "Stop"
|
|
6
|
+
$ProgressPreference = "SilentlyContinue" # Invoke-WebRequest's progress bar breaks over SSH/non-interactive hosts
|
|
2
7
|
|
|
3
|
-
$Root
|
|
4
|
-
$
|
|
5
|
-
$
|
|
8
|
+
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
9
|
+
$Base = if ($env:DISCSTATION_CONFIG_DIR) { $env:DISCSTATION_CONFIG_DIR } else { Join-Path $env:APPDATA "DiscStation" }
|
|
10
|
+
$App = if ($env:DISCSTATION_APP_DIR) { $env:DISCSTATION_APP_DIR } else { Join-Path $Base "app" }
|
|
11
|
+
$Venv = if ($env:DISCSTATION_VENV_DIR) { $env:DISCSTATION_VENV_DIR } else { Join-Path $Base "venv" }
|
|
12
|
+
$IsWin7 = [Environment]::OSVersion.Version.Major -eq 6
|
|
13
|
+
$winget = (Get-Command winget -ErrorAction SilentlyContinue)
|
|
6
14
|
|
|
7
|
-
|
|
8
|
-
|
|
15
|
+
function Have($name) { [bool](Get-Command $name -ErrorAction SilentlyContinue) }
|
|
16
|
+
function Winget-Install($id) {
|
|
17
|
+
if (-not $winget) { return $false }
|
|
18
|
+
try { winget install -e --id $id --accept-source-agreements --accept-package-agreements --silent | Out-Null; return $true }
|
|
19
|
+
catch { Write-Host " winget $id failed (skipping)"; return $false }
|
|
9
20
|
}
|
|
10
21
|
|
|
11
|
-
|
|
22
|
+
# --- 1. Python -------------------------------------------------------------------
|
|
23
|
+
if (-not (Have "python") -and -not (Have "py")) {
|
|
24
|
+
Write-Host "Installing Python..."
|
|
25
|
+
if ($winget -and -not $IsWin7) {
|
|
26
|
+
Winget-Install "Python.Python.3.12" | Out-Null
|
|
27
|
+
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "User") + ";" + $env:Path
|
|
28
|
+
} else {
|
|
29
|
+
$pyver = if ($IsWin7) { "3.8.10" } else { "3.12.6" }
|
|
30
|
+
$url = "https://www.python.org/ftp/python/$pyver/python-$pyver-amd64.exe"
|
|
31
|
+
$exe = Join-Path $env:TEMP "python-$pyver-amd64.exe"
|
|
32
|
+
Invoke-WebRequest $url -OutFile $exe -UseBasicParsing
|
|
33
|
+
Start-Process $exe -ArgumentList "/quiet","InstallAllUsers=0","PrependPath=1","Include_test=0" -Wait
|
|
34
|
+
$env:Path = (Join-Path $env:LOCALAPPDATA "Programs\Python\Python$($pyver.Substring(0,4) -replace '\.','')") + ";" + $env:Path + ";" +
|
|
35
|
+
(Join-Path $env:LOCALAPPDATA "Programs\Python\Python$($pyver.Substring(0,4) -replace '\.','')\Scripts")
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (Have "python") { $PyExe = "python"; $PyArgs = @() }
|
|
39
|
+
elseif (Have "py") { $PyExe = "py"; $PyArgs = @("-3") }
|
|
40
|
+
else { throw "Python install failed. Install Python 3 manually and re-run." }
|
|
41
|
+
|
|
42
|
+
# --- 2. Optical CLI tools (winget when available; Win10/11 only; best-effort,
|
|
43
|
+
# strictly time-bounded so a slow/blocked mirror can never hang the install) --
|
|
44
|
+
if ($winget -and -not $IsWin7) {
|
|
45
|
+
Write-Host "Installing optical tools via winget (best effort)..."
|
|
46
|
+
foreach ($id in "libburnia.xorriso","mpv.mpv","HandBrake.HandBrake.CLI","Gyan.FFmpeg","yt-dlp.yt-dlp") {
|
|
47
|
+
Winget-Install $id | Out-Null
|
|
48
|
+
}
|
|
49
|
+
} elseif (-not $IsWin7) {
|
|
50
|
+
Write-Host "winget unavailable - fetching optical tools directly (best effort, 25s timeout each)..."
|
|
51
|
+
$tools = Join-Path $Base "tools"
|
|
52
|
+
New-Item -ItemType Directory -Force -Path $tools | Out-Null
|
|
53
|
+
function Get-Zip($url, $dest) {
|
|
54
|
+
try {
|
|
55
|
+
$zip = Join-Path $env:TEMP ([IO.Path]::GetFileName($url))
|
|
56
|
+
Invoke-WebRequest $url -OutFile $zip -UseBasicParsing -TimeoutSec 25
|
|
57
|
+
Expand-Archive -Path $zip -DestinationPath $dest -Force
|
|
58
|
+
Remove-Item $zip -ErrorAction SilentlyContinue
|
|
59
|
+
return $true
|
|
60
|
+
} catch { Write-Host " fetch failed: $url"; return $false }
|
|
61
|
+
}
|
|
62
|
+
if (-not (Have "yt-dlp")) {
|
|
63
|
+
try { Invoke-WebRequest "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" -OutFile (Join-Path $tools "yt-dlp.exe") -UseBasicParsing -TimeoutSec 25 }
|
|
64
|
+
catch { Write-Host " yt-dlp fetch skipped" }
|
|
65
|
+
}
|
|
66
|
+
if (-not (Have "ffmpeg")) { Get-Zip "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" $tools | Out-Null }
|
|
67
|
+
if (-not (Have "mpv")) { Get-Zip "https://sourceforge.net/projects/mpv-player-windows/files/latest/download" $tools | Out-Null }
|
|
68
|
+
if (Test-Path $tools) {
|
|
69
|
+
$env:Path = $env:Path + ";" + $tools + ";" +
|
|
70
|
+
((Get-ChildItem $tools -Recurse -Filter "ffmpeg.exe" -ErrorAction SilentlyContinue | Select-Object -First 1).DirectoryName)
|
|
71
|
+
}
|
|
72
|
+
Write-Host "xorriso and HandBrakeCLI have no simple direct-download URL - install manually if you need"
|
|
73
|
+
Write-Host "video-DVD authoring / DVD ripping (see the plan's Windows setup notes)."
|
|
74
|
+
} else {
|
|
75
|
+
Write-Host "winget unavailable (Windows 7). ISO + data + audio burn work via IMAPI2."
|
|
76
|
+
Write-Host "For rip/play/video-DVD install manually: xorriso, HandBrakeCLI 1.5.1, ffmpeg, mpv, yt-dlp."
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# --- 3. App files + venv ------------------------------------------------------
|
|
80
|
+
New-Item -ItemType Directory -Force -Path $Base, $App | Out-Null
|
|
12
81
|
Copy-Item -Recurse -Force (Join-Path $Root "src\*") $App
|
|
13
|
-
|
|
14
|
-
& (Join-Path $Venv "Scripts\python.exe") -m pip install --upgrade pip
|
|
82
|
+
& $PyExe @PyArgs -m venv $Venv
|
|
15
83
|
$Py = Join-Path $Venv "Scripts\python.exe"
|
|
84
|
+
& $Py -m pip install --upgrade pip
|
|
16
85
|
$Req = Join-Path $Root "requirements.txt"
|
|
17
|
-
if (Test-Path $Req) {
|
|
18
|
-
|
|
19
|
-
} else {
|
|
20
|
-
|
|
86
|
+
if (Test-Path $Req) { & $Py -m pip install -r $Req } else { & $Py -m pip install pyserial mutagen requests yt-dlp }
|
|
87
|
+
$Opt = Join-Path $Root "requirements-optional.txt"
|
|
88
|
+
try { if (Test-Path $Opt) { & $Py -m pip install -r $Opt } else { & $Py -m pip install musicbrainzngs tmdbsimple } }
|
|
89
|
+
catch { Write-Host "Optional metadata deps skipped (host still works)." }
|
|
90
|
+
|
|
91
|
+
# --- 4. Self-signed cert (HTTPS on :8080; :8081 works without it) --------------
|
|
92
|
+
$crt = Join-Path $Base "server.crt"; $key = Join-Path $Base "server.key"
|
|
93
|
+
if (-not (Test-Path $crt) -or -not (Test-Path $key)) {
|
|
94
|
+
try {
|
|
95
|
+
$c = New-SelfSignedCertificate -DnsName "discstation.local" -CertStoreLocation "Cert:\CurrentUser\My" -NotAfter (Get-Date).AddYears(10)
|
|
96
|
+
$pwd = ConvertTo-SecureString -String "discstation" -Force -AsPlainText
|
|
97
|
+
$pfx = Join-Path $env:TEMP "ds.pfx"
|
|
98
|
+
Export-PfxCertificate -Cert $c -FilePath $pfx -Password $pwd | Out-Null
|
|
99
|
+
& $Py -c "import ssl" 2>$null
|
|
100
|
+
# Convert PFX -> PEM via Python cryptography if present, else leave HTTPS off.
|
|
101
|
+
& $Py -m pip install cryptography 2>$null
|
|
102
|
+
& $Py -c "import sys;from cryptography.hazmat.primitives.serialization import pkcs12,Encoding,PrivateFormat,NoEncryption;d=open(sys.argv[1],'rb').read();k,c,_=pkcs12.load_key_and_certificates(d,b'discstation');open(sys.argv[2],'wb').write(c.public_bytes(Encoding.PEM));open(sys.argv[3],'wb').write(k.private_bytes(Encoding.PEM,PrivateFormat.TraditionalOpenSSL,NoEncryption()))" $pfx $crt $key
|
|
103
|
+
Remove-Item $pfx -ErrorAction SilentlyContinue
|
|
104
|
+
} catch { Write-Host "Cert generation skipped; the host will serve plain HTTP on 8081." }
|
|
21
105
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
106
|
+
|
|
107
|
+
# --- 5. Firewall -------------------------------------------------------------
|
|
108
|
+
foreach ($port in 8080, 8081) {
|
|
109
|
+
$name = "DiscStation $port"
|
|
110
|
+
try {
|
|
111
|
+
if (Get-Command New-NetFirewallRule -ErrorAction SilentlyContinue) {
|
|
112
|
+
if (-not (Get-NetFirewallRule -DisplayName $name -ErrorAction SilentlyContinue)) {
|
|
113
|
+
New-NetFirewallRule -DisplayName $name -Direction Inbound -Action Allow -Protocol TCP -LocalPort $port | Out-Null
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
netsh advfirewall firewall add rule name="$name" dir=in action=allow protocol=TCP localport=$port | Out-Null
|
|
117
|
+
}
|
|
118
|
+
} catch {}
|
|
25
119
|
}
|
|
26
120
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
121
|
+
# --- 6. Auto-start Scheduled Task -----------------------------------------------
|
|
122
|
+
$pyw = Join-Path $Venv "Scripts\pythonw.exe"
|
|
123
|
+
$target = Join-Path $App "discstation.py"
|
|
124
|
+
try {
|
|
125
|
+
if (Get-Command Register-ScheduledTask -ErrorAction SilentlyContinue) {
|
|
126
|
+
$action = New-ScheduledTaskAction -Execute $pyw -Argument "`"$target`"" -WorkingDirectory $App
|
|
127
|
+
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
|
|
128
|
+
$set = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
|
|
129
|
+
# S4U: runs headlessly for this user without needing an active interactive
|
|
130
|
+
# desktop session (Interactive logon type only fires while one exists, so
|
|
131
|
+
# e.g. Start-ScheduledTask over SSH/no console session would silently no-op).
|
|
132
|
+
# RunLevel Limited (standard, non-elevated): nothing here needs admin -
|
|
133
|
+
# IMAPI2 burning, WMI reads, and binding ports >1024 all work as a normal
|
|
134
|
+
# user, and elevation is what put "Administrator" on the flashing console
|
|
135
|
+
# windows this used to spawn.
|
|
136
|
+
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType S4U -RunLevel Limited
|
|
137
|
+
Register-ScheduledTask -TaskName "DiscStation" -Action $action -Trigger $trigger -Settings $set -Principal $principal -Force | Out-Null
|
|
138
|
+
Start-ScheduledTask -TaskName "DiscStation"
|
|
139
|
+
} else {
|
|
140
|
+
schtasks /create /tn "DiscStation" /sc onlogon /f /tr "`"$pyw`" `"$target`"" | Out-Null
|
|
141
|
+
schtasks /run /tn "DiscStation" | Out-Null
|
|
142
|
+
}
|
|
143
|
+
} catch { Write-Host "Auto-start task not created: $_" }
|
|
144
|
+
|
|
145
|
+
$ip = (Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -notmatch '^127\.|^169\.254\.' } | Select-Object -First 1).IPAddress
|
|
146
|
+
Write-Host ""
|
|
147
|
+
Write-Host "DiscStation installed at $App"
|
|
148
|
+
Write-Host " Web UI: http://localhost:8081/ (also http://$ip`:8081 on the LAN)"
|
|
149
|
+
Write-Host " Run 'discstation' any time to open it. Detection/eject/ISO+data+audio burn use IMAPI2 (no extra tools)."
|
|
150
|
+
try { node (Join-Path $Root "scripts\open.mjs") } catch {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discstation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"description": "DiscStation optical-media appliance host — one cross-OS installer",
|
|
5
5
|
"bin": {
|
|
6
6
|
"discstation-setup": "scripts/setup.mjs",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"scripts/",
|
|
22
22
|
"src/*.py",
|
|
23
23
|
"src/static/",
|
|
24
|
+
"src/win/",
|
|
24
25
|
"arduino/",
|
|
25
26
|
"systemd/",
|
|
26
27
|
"docs/",
|
package/scripts/setup.mjs
CHANGED
|
@@ -19,7 +19,7 @@ if (args.includes('-h') || args.includes('--help')) {
|
|
|
19
19
|
Runs the installer for the current OS:
|
|
20
20
|
linux install.sh full: apt deps, venv, systemd --user service, self-signed cert
|
|
21
21
|
darwin install-macos.sh full: Homebrew deps, venv, launchd agent, cert (audio-CD burn is best-effort)
|
|
22
|
-
windows install-windows.ps1
|
|
22
|
+
windows install-windows.ps1 venv + cert + firewall + auto-start task; burn via IMAPI2 (10/11 and 7)
|
|
23
23
|
|
|
24
24
|
Prereqs: Node 16+, Python 3 (python3 / py on PATH), and either bash (linux/macOS)
|
|
25
25
|
or PowerShell (Windows). Homebrew is auto-installed on macOS if missing; the
|
package/src/discstation.py
CHANGED
|
@@ -4,7 +4,10 @@ import atexit
|
|
|
4
4
|
import collections
|
|
5
5
|
import concurrent.futures
|
|
6
6
|
import errno
|
|
7
|
-
|
|
7
|
+
try:
|
|
8
|
+
import fcntl # POSIX-only; used only in drive_status()'s Linux branch
|
|
9
|
+
except ImportError:
|
|
10
|
+
fcntl = None
|
|
8
11
|
import json
|
|
9
12
|
import mimetypes
|
|
10
13
|
import os
|
|
@@ -16,7 +19,10 @@ except ImportError:
|
|
|
16
19
|
import re
|
|
17
20
|
import shutil
|
|
18
21
|
import socket
|
|
19
|
-
|
|
22
|
+
try:
|
|
23
|
+
import ssl # optional: HTTPS on :8080. The plain-HTTP :8081 listener works without it.
|
|
24
|
+
except ImportError:
|
|
25
|
+
ssl = None
|
|
20
26
|
import subprocess
|
|
21
27
|
import sys
|
|
22
28
|
import tempfile
|
|
@@ -349,13 +355,17 @@ def start_web_server(port=8080):
|
|
|
349
355
|
cert_dir = discstation_host.config_dir()
|
|
350
356
|
cert = cert_dir / 'server.crt'
|
|
351
357
|
key = cert_dir / 'server.key'
|
|
352
|
-
if cert.exists() and key.exists():
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
358
|
+
if ssl is not None and cert.exists() and key.exists():
|
|
359
|
+
try:
|
|
360
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
361
|
+
ctx.load_cert_chain(str(cert), str(key))
|
|
362
|
+
server.socket = ctx.wrap_socket(server.socket, server_side=True)
|
|
363
|
+
print(f"Web interface on https://0.0.0.0:{port}")
|
|
364
|
+
except (ssl.SSLError, OSError) as e:
|
|
365
|
+
print(f"TLS disabled ({e}); serving plain HTTP on {port}")
|
|
357
366
|
else:
|
|
358
|
-
print(f"Web interface on http://0.0.0.0:{port}"
|
|
367
|
+
print(f"Web interface on http://0.0.0.0:{port}"
|
|
368
|
+
+ ("" if ssl is not None else " (ssl module unavailable)"))
|
|
359
369
|
|
|
360
370
|
_web_server = server
|
|
361
371
|
t = threading.Thread(target=server.serve_forever, daemon=True)
|
|
@@ -422,7 +432,8 @@ def wait_for_web_url(ser):
|
|
|
422
432
|
check_serial_alive(ser)
|
|
423
433
|
|
|
424
434
|
|
|
425
|
-
MPV_SOCKET =
|
|
435
|
+
MPV_SOCKET = (r"\\.\pipe\discstation-mpv" if os.name == "nt"
|
|
436
|
+
else str(Path(tempfile.gettempdir()) / "discstation_mpv.sock"))
|
|
426
437
|
RIP_ROOT = discstation_burn.USER_HOME / "dvd_rips"
|
|
427
438
|
USER_AGENT = "DVDStation/0.1 (local appliance; phuju)"
|
|
428
439
|
DISC_POLL_SECONDS = 6
|
|
@@ -649,12 +660,26 @@ def chown_to_sudo_user(path):
|
|
|
649
660
|
pass
|
|
650
661
|
|
|
651
662
|
|
|
663
|
+
def _mpv_ipc(payload, timeout=None):
|
|
664
|
+
"""Send one JSON line to mpv's IPC endpoint. Windows = named pipe, POSIX =
|
|
665
|
+
AF_UNIX socket. Returns the raw reply bytes (b"" if not read), or raises OSError."""
|
|
666
|
+
if os.name == "nt":
|
|
667
|
+
with open(MPV_SOCKET, "r+b", buffering=0) as pipe:
|
|
668
|
+
pipe.write(payload)
|
|
669
|
+
if timeout is None:
|
|
670
|
+
return b""
|
|
671
|
+
return pipe.read(4096) or b""
|
|
672
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
|
673
|
+
if timeout is not None:
|
|
674
|
+
sock.settimeout(timeout)
|
|
675
|
+
sock.connect(MPV_SOCKET)
|
|
676
|
+
sock.sendall(payload)
|
|
677
|
+
return sock.recv(4096) if timeout is not None else b""
|
|
678
|
+
|
|
679
|
+
|
|
652
680
|
def mpv_command(command):
|
|
653
681
|
try:
|
|
654
|
-
|
|
655
|
-
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
|
656
|
-
sock.connect(MPV_SOCKET)
|
|
657
|
-
sock.sendall(payload)
|
|
682
|
+
_mpv_ipc(json.dumps({"command": command}).encode() + b"\n")
|
|
658
683
|
except OSError:
|
|
659
684
|
return False
|
|
660
685
|
return True
|
|
@@ -663,11 +688,7 @@ def mpv_command(command):
|
|
|
663
688
|
def mpv_query(command):
|
|
664
689
|
try:
|
|
665
690
|
payload = json.dumps({"command": command, "request_id": 1}).encode() + b"\n"
|
|
666
|
-
|
|
667
|
-
sock.settimeout(0.5)
|
|
668
|
-
sock.connect(MPV_SOCKET)
|
|
669
|
-
sock.sendall(payload)
|
|
670
|
-
response = json.loads(sock.recv(4096).decode(errors="ignore"))
|
|
691
|
+
response = json.loads(_mpv_ipc(payload, timeout=0.5).decode(errors="ignore"))
|
|
671
692
|
return response.get("data")
|
|
672
693
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
673
694
|
return None
|
|
@@ -678,14 +699,11 @@ def wait_for_socket(path, proc, timeout=8):
|
|
|
678
699
|
while time.time() < deadline:
|
|
679
700
|
if proc.poll() is not None:
|
|
680
701
|
return False
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
return True
|
|
687
|
-
except OSError:
|
|
688
|
-
pass
|
|
702
|
+
try:
|
|
703
|
+
_mpv_ipc(b'{"command":["get_property","idle-active"]}\n', timeout=0.25)
|
|
704
|
+
return True
|
|
705
|
+
except OSError:
|
|
706
|
+
pass
|
|
689
707
|
time.sleep(0.1)
|
|
690
708
|
return False
|
|
691
709
|
|
|
@@ -998,11 +1016,28 @@ _tray_open = False
|
|
|
998
1016
|
_tray_open_since = 0.0 # time.monotonic() of the last OLED-initiated eject
|
|
999
1017
|
|
|
1000
1018
|
|
|
1019
|
+
def _device_present(device):
|
|
1020
|
+
"""Is `device` still a live drive node? On Linux/macOS that's a real
|
|
1021
|
+
filesystem path that can disappear (e.g. after an eject) - Path.exists()
|
|
1022
|
+
answers that correctly. On Windows `device` is a bare drive letter ("D:");
|
|
1023
|
+
Path("D:").exists() raises OSError (WinError 1) instead of returning False,
|
|
1024
|
+
and the drive letter is stable regardless of media state anyway (the real
|
|
1025
|
+
presence signal is ID_CDROM_MEDIA, checked downstream via media_properties())."""
|
|
1026
|
+
if not device:
|
|
1027
|
+
return False
|
|
1028
|
+
if discstation_host.system_name() == "windows":
|
|
1029
|
+
return True
|
|
1030
|
+
try:
|
|
1031
|
+
return Path(device).exists()
|
|
1032
|
+
except OSError:
|
|
1033
|
+
return False
|
|
1034
|
+
|
|
1035
|
+
|
|
1001
1036
|
def _tray_closed_with_disc(device):
|
|
1002
1037
|
if not device:
|
|
1003
1038
|
return False
|
|
1004
1039
|
global _tray_open
|
|
1005
|
-
if not
|
|
1040
|
+
if not _device_present(device):
|
|
1006
1041
|
return False
|
|
1007
1042
|
properties = udev_cdrom_properties(device)
|
|
1008
1043
|
if properties.get("ID_CDROM_MEDIA") == "1":
|
|
@@ -1021,7 +1056,7 @@ def disc_present(device):
|
|
|
1021
1056
|
return True
|
|
1022
1057
|
if _tray_open:
|
|
1023
1058
|
return False
|
|
1024
|
-
if not
|
|
1059
|
+
if not _device_present(device):
|
|
1025
1060
|
return False
|
|
1026
1061
|
if discstation_host.system_name() != "linux":
|
|
1027
1062
|
properties = udev_cdrom_properties(device)
|
|
@@ -1055,7 +1090,7 @@ def disc_present(device):
|
|
|
1055
1090
|
def is_blank_disc(device):
|
|
1056
1091
|
if not device:
|
|
1057
1092
|
return False
|
|
1058
|
-
if not
|
|
1093
|
+
if not _device_present(device):
|
|
1059
1094
|
return False
|
|
1060
1095
|
|
|
1061
1096
|
properties = udev_cdrom_properties(device, refresh=True)
|
|
@@ -1107,7 +1142,7 @@ def is_rewritable_disc(device):
|
|
|
1107
1142
|
if not device:
|
|
1108
1143
|
return False
|
|
1109
1144
|
"""Return whether the inserted medium can be overwritten."""
|
|
1110
|
-
if not
|
|
1145
|
+
if not _device_present(device):
|
|
1111
1146
|
return False
|
|
1112
1147
|
|
|
1113
1148
|
properties = udev_cdrom_properties(device)
|
|
@@ -1227,7 +1262,7 @@ def _media_quick_state(device, props):
|
|
|
1227
1262
|
return "unsure"
|
|
1228
1263
|
# st == "unknown": fall through to the legacy probes below
|
|
1229
1264
|
try:
|
|
1230
|
-
if not
|
|
1265
|
+
if not _device_present(device):
|
|
1231
1266
|
return "empty"
|
|
1232
1267
|
except OSError:
|
|
1233
1268
|
return "empty"
|
|
@@ -1317,6 +1352,11 @@ def _classify_disc(device, props, failed, deadline):
|
|
|
1317
1352
|
(blkid -> lsdvd -> wodim -toc -> fs fallback -> blank) but records which
|
|
1318
1353
|
probes timed out / were missing so the caller can retry."""
|
|
1319
1354
|
if discstation_host.system_name() != "linux":
|
|
1355
|
+
if not props.get("ID_CDROM_MEDIA"):
|
|
1356
|
+
# media_properties() returned nothing -> no disc loaded (this is the
|
|
1357
|
+
# only "no media" signal on a platform like Windows where the drive
|
|
1358
|
+
# letter/device path exists whether or not media is present).
|
|
1359
|
+
return _disc_info(False, "none")
|
|
1320
1360
|
if props.get("ID_CDROM_MEDIA_TYPE") == "audio":
|
|
1321
1361
|
return _disc_info(True, "audio_cd", web_type="AUDIO_CD")
|
|
1322
1362
|
if props.get("ID_FS_TYPE") in ("udf", "iso9660"):
|
|
@@ -1627,6 +1667,11 @@ class mounted_disc:
|
|
|
1627
1667
|
self.owned_mount = False
|
|
1628
1668
|
|
|
1629
1669
|
def __enter__(self):
|
|
1670
|
+
if discstation_host.system_name() == "windows":
|
|
1671
|
+
# the optical disc is already mounted by the OS as its drive letter
|
|
1672
|
+
letter = str(self.device).rstrip("\\/").rstrip(":") + ":\\"
|
|
1673
|
+
self.mount_path = Path(letter)
|
|
1674
|
+
return self.mount_path
|
|
1630
1675
|
if discstation_host.system_name() == "darwin":
|
|
1631
1676
|
properties = discstation_host.media_properties(self.device)
|
|
1632
1677
|
existing_mount = properties.get("ID_MOUNT_POINT")
|
|
@@ -1688,6 +1733,24 @@ def disc_video_files(mount_dir):
|
|
|
1688
1733
|
|
|
1689
1734
|
|
|
1690
1735
|
def audio_cd_toc(device):
|
|
1736
|
+
if discstation_host.system_name() == "windows":
|
|
1737
|
+
rc, out, err = discstation_host._run_ps("audio-toc.ps1", device, timeout=25)
|
|
1738
|
+
info = {}
|
|
1739
|
+
for line in out.splitlines():
|
|
1740
|
+
if line.strip().startswith("{"):
|
|
1741
|
+
try:
|
|
1742
|
+
info = json.loads(line)
|
|
1743
|
+
except ValueError:
|
|
1744
|
+
pass
|
|
1745
|
+
tracks = info.get("tracks") or []
|
|
1746
|
+
if not tracks:
|
|
1747
|
+
raise RuntimeError(f"Could not read CD TOC: {(err or out)[:120]}")
|
|
1748
|
+
n = int(info["track_count"])
|
|
1749
|
+
return {
|
|
1750
|
+
"first_track": 1, "track_count": n, "leadout": int(info["leadout"]),
|
|
1751
|
+
"tracks": tracks,
|
|
1752
|
+
"toc": "+".join(map(str, [1, n, int(info["leadout"]), *tracks])),
|
|
1753
|
+
}
|
|
1691
1754
|
if discstation_host.system_name() == "darwin":
|
|
1692
1755
|
paranoia = None
|
|
1693
1756
|
for name in ("cd-paranoia", "cdparanoia"):
|
|
@@ -1767,7 +1830,7 @@ def audio_cd_toc(device):
|
|
|
1767
1830
|
|
|
1768
1831
|
|
|
1769
1832
|
def audio_cd_chapters(device):
|
|
1770
|
-
if discstation_host.system_name()
|
|
1833
|
+
if discstation_host.system_name() in ("darwin", "windows"):
|
|
1771
1834
|
toc = audio_cd_toc(device)
|
|
1772
1835
|
tracks = toc["tracks"]
|
|
1773
1836
|
first = tracks[0]
|
|
@@ -2413,9 +2476,18 @@ def directory_size_bytes(path):
|
|
|
2413
2476
|
return total
|
|
2414
2477
|
|
|
2415
2478
|
|
|
2479
|
+
def _stdin_is_tty():
|
|
2480
|
+
"""sys.stdin is None under pythonw.exe (no console) - plain .isatty() would
|
|
2481
|
+
AttributeError. Also guards a closed/redirected stdin under systemd/launchd."""
|
|
2482
|
+
try:
|
|
2483
|
+
return sys.stdin is not None and sys.stdin.isatty()
|
|
2484
|
+
except (AttributeError, ValueError, OSError):
|
|
2485
|
+
return False
|
|
2486
|
+
|
|
2487
|
+
|
|
2416
2488
|
def burn_flow(ser, url):
|
|
2417
2489
|
if not url:
|
|
2418
|
-
if
|
|
2490
|
+
if _stdin_is_tty():
|
|
2419
2491
|
safe_send(ser, "STATUS:Enter URL or file path in terminal")
|
|
2420
2492
|
print("=== Enter URL or file path below, then press Enter ===")
|
|
2421
2493
|
try:
|
|
@@ -2671,7 +2743,7 @@ def burn_data_flow(ser):
|
|
|
2671
2743
|
if _last_upload_dir and Path(_last_upload_dir).exists():
|
|
2672
2744
|
url = _last_upload_dir
|
|
2673
2745
|
_last_upload_dir = None
|
|
2674
|
-
elif
|
|
2746
|
+
elif _stdin_is_tty():
|
|
2675
2747
|
safe_send(ser, "STATUS:Enter URL or file path in terminal")
|
|
2676
2748
|
print("=== Enter URL or file path below, then press Enter ===")
|
|
2677
2749
|
try:
|
|
@@ -2836,7 +2908,7 @@ def burn_data_flow(ser):
|
|
|
2836
2908
|
|
|
2837
2909
|
|
|
2838
2910
|
def burn_audio_flow(ser):
|
|
2839
|
-
if
|
|
2911
|
+
if _stdin_is_tty():
|
|
2840
2912
|
safe_send(ser, "STATUS:Enter path to audio files in terminal")
|
|
2841
2913
|
print("=== Enter path to audio files/folder, then press Enter ===")
|
|
2842
2914
|
try:
|
|
@@ -3017,7 +3089,7 @@ def _iter_proc_lines(proc, ser):
|
|
|
3017
3089
|
def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
|
|
3018
3090
|
try:
|
|
3019
3091
|
os.unlink(MPV_SOCKET)
|
|
3020
|
-
except
|
|
3092
|
+
except OSError:
|
|
3021
3093
|
pass
|
|
3022
3094
|
|
|
3023
3095
|
env = os.environ.copy()
|
|
@@ -3157,7 +3229,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
|
|
|
3157
3229
|
discstation_burn.stop_process(proc)
|
|
3158
3230
|
try:
|
|
3159
3231
|
os.unlink(MPV_SOCKET)
|
|
3160
|
-
except
|
|
3232
|
+
except OSError:
|
|
3161
3233
|
pass
|
|
3162
3234
|
|
|
3163
3235
|
|
|
@@ -3979,7 +4051,7 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
|
|
|
3979
4051
|
refresh_main_menu(ser)
|
|
3980
4052
|
|
|
3981
4053
|
|
|
3982
|
-
PIDFILE = "
|
|
4054
|
+
PIDFILE = os.path.join(tempfile.gettempdir(), "discstation.pid")
|
|
3983
4055
|
|
|
3984
4056
|
|
|
3985
4057
|
def check_pidfile():
|
|
@@ -3992,6 +4064,10 @@ def check_pidfile():
|
|
|
3992
4064
|
if sys.platform == "linux":
|
|
3993
4065
|
with open(f"/proc/{old_pid}/cmdline") as f:
|
|
3994
4066
|
alive = "discstation" in f.read()
|
|
4067
|
+
elif os.name == "nt":
|
|
4068
|
+
tl = subprocess.run(["tasklist", "/FI", f"PID eq {old_pid}", "/FO", "CSV", "/NH"],
|
|
4069
|
+
capture_output=True, text=True)
|
|
4070
|
+
alive = "python" in tl.stdout.lower()
|
|
3995
4071
|
else:
|
|
3996
4072
|
ps = subprocess.run(["ps", "-p", str(old_pid), "-o", "command="],
|
|
3997
4073
|
capture_output=True, text=True)
|
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)
|
|
@@ -1323,6 +1325,14 @@ def burn(ser, dvd_dir, disc_label, speed=None, is_dual_layer=False):
|
|
|
1323
1325
|
try:
|
|
1324
1326
|
discstation_host.build_data_image([dvd_dir], image_path, disc_label, video=True)
|
|
1325
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 ""))
|
|
1326
1336
|
finally:
|
|
1327
1337
|
image_path.unlink(missing_ok=True)
|
|
1328
1338
|
return
|
|
@@ -1350,6 +1360,13 @@ def burn_data(ser, source_paths, disc_label, speed=None, is_dual_layer=False):
|
|
|
1350
1360
|
try:
|
|
1351
1361
|
discstation_host.build_data_image(source_paths, image_path, disc_label)
|
|
1352
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 ""))
|
|
1353
1370
|
finally:
|
|
1354
1371
|
image_path.unlink(missing_ok=True)
|
|
1355
1372
|
return
|
|
@@ -1464,6 +1481,17 @@ def burn_audio_cd(ser, audio_files, disc_label, speed=None):
|
|
|
1464
1481
|
f"{len(track_meta)} track titles")
|
|
1465
1482
|
send(ser, "PROGRESS:35%")
|
|
1466
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
|
+
|
|
1467
1495
|
send(ser, "STATUS:Burning audio CD...")
|
|
1468
1496
|
# The cooked generic-mmc writer does NOT lay down the CD-TEXT lead-in on most
|
|
1469
1497
|
# ATAPI drives; the raw writer does. Override with DISCSTATION_CDRDAO_DRIVER
|
|
@@ -1524,11 +1552,64 @@ def burn_audio_cd(ser, audio_files, disc_label, speed=None):
|
|
|
1524
1552
|
print(f"CD eject skipped: {e}")
|
|
1525
1553
|
|
|
1526
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
|
+
|
|
1527
1595
|
def burn_iso(ser, iso_path, speed=None, is_dual_layer=False):
|
|
1528
1596
|
"""Burn a pre-built ISO directly to disc — no filesystem building."""
|
|
1529
1597
|
if discstation_host.system_name() == "darwin":
|
|
1530
1598
|
_run_hdiutil_burn(ser, iso_path)
|
|
1531
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
|
|
1532
1613
|
if discstation_host.system_name() != "linux":
|
|
1533
1614
|
send(ser, "STATUS:Burning image...")
|
|
1534
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
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Optical drive + media state for DiscStation. Emits one JSON line.
|
|
2
|
+
# Works on PowerShell 2.0 (Win7) and later. Optional arg: a drive letter ("D:")
|
|
3
|
+
# to force; otherwise the first optical drive is used.
|
|
4
|
+
param([string]$Drive = "")
|
|
5
|
+
|
|
6
|
+
$ErrorActionPreference = "Stop"
|
|
7
|
+
. (Join-Path $PSScriptRoot "_json.ps1")
|
|
8
|
+
|
|
9
|
+
$out = @{ drive = ""; media_loaded = $false; blank = $false; label = "";
|
|
10
|
+
fs = ""; media_type = ""; rewritable = $false; capacity_bytes = 0 }
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
$cd = @(Get-WmiObject Win32_CDROMDrive)
|
|
14
|
+
if ($Drive) { $cd = @($cd | Where-Object { $_.Drive -eq $Drive }) }
|
|
15
|
+
if ($cd.Count -eq 0) { Write-Output (ConvertTo-JsonCompat $out); exit 0 }
|
|
16
|
+
$d = $cd[0]
|
|
17
|
+
$out.drive = $d.Drive
|
|
18
|
+
# NOTE: [bool]"False" is $true in PowerShell (any non-empty string casts
|
|
19
|
+
# truthy) -- compare explicitly instead of casting.
|
|
20
|
+
$out.media_loaded = ($d.MediaLoaded -eq $true) -or ("$($d.MediaLoaded)" -eq "True")
|
|
21
|
+
} catch { Write-Output (ConvertTo-JsonCompat $out); exit 0 }
|
|
22
|
+
|
|
23
|
+
if (-not $out.media_loaded) { Write-Output (ConvertTo-JsonCompat $out); exit 0 }
|
|
24
|
+
|
|
25
|
+
# Volume label + filesystem (WMI logical disk).
|
|
26
|
+
try {
|
|
27
|
+
$ld = Get-WmiObject Win32_LogicalDisk -Filter ("DeviceID='" + $out.drive + "'")
|
|
28
|
+
if ($ld) {
|
|
29
|
+
if ($ld.VolumeName) { $out.label = $ld.VolumeName }
|
|
30
|
+
if ($ld.FileSystem) {
|
|
31
|
+
$fs = $ld.FileSystem.ToLower()
|
|
32
|
+
if ($fs -match "udf") { $out.fs = "udf" }
|
|
33
|
+
elseif ($fs -match "cdfs|iso9660") { $out.fs = "iso9660" }
|
|
34
|
+
else { $out.fs = $fs }
|
|
35
|
+
}
|
|
36
|
+
if ($ld.Size) { $out.capacity_bytes = [int64]$ld.Size }
|
|
37
|
+
}
|
|
38
|
+
} catch {}
|
|
39
|
+
|
|
40
|
+
# IMAPI2: physical media type, blank flag, recordable capacity.
|
|
41
|
+
try {
|
|
42
|
+
$master = New-Object -ComObject "IMAPI2.MsftDiscMaster2"
|
|
43
|
+
for ($i = 0; $i -lt $master.Count; $i++) {
|
|
44
|
+
$rec = New-Object -ComObject "IMAPI2.MsftDiscRecorder2"
|
|
45
|
+
$rec.InitializeDiscRecorder($master.Item($i))
|
|
46
|
+
$match = $false
|
|
47
|
+
foreach ($p in $rec.VolumePathNames) { if ($p -and $p.TrimEnd('\') -ieq $out.drive) { $match = $true } }
|
|
48
|
+
if (-not $match) { continue }
|
|
49
|
+
$fmt = New-Object -ComObject "IMAPI2.MsftDiscFormat2Data"
|
|
50
|
+
if (-not $fmt.IsRecorderSupported($rec)) { break }
|
|
51
|
+
$fmt.Recorder = $rec
|
|
52
|
+
$fmt.ClientName = "DiscStation"
|
|
53
|
+
try { $out.blank = ($fmt.MediaHeuristicallyBlank -eq $true) } catch {}
|
|
54
|
+
try { if ($fmt.MediaPhysicallyBlank) { $out.blank = $true } } catch {}
|
|
55
|
+
try { $out.capacity_bytes = [int64]$fmt.TotalSectorsOnMedia * 2048 } catch {}
|
|
56
|
+
$t = 0; try { $t = [int]$fmt.CurrentPhysicalMediaType } catch {}
|
|
57
|
+
# IMAPI_MEDIA_PHYSICAL_TYPE
|
|
58
|
+
$map = @{ 1="cd-rom"; 2="cd-r"; 3="cd-rw"; 4="dvd-rom"; 5="dvd-r"; 6="dvd-ram";
|
|
59
|
+
7="dvd+r"; 8="dvd+rw"; 9="dvd+r dl"; 10="dvd-r dl"; 12="dvd+rw dl";
|
|
60
|
+
16="bd-rom"; 17="bd-r"; 18="bd-re" }
|
|
61
|
+
if ($map.ContainsKey($t)) { $out.media_type = $map[$t] }
|
|
62
|
+
if ($out.media_type -match "rw|ram|-re") { $out.rewritable = $true }
|
|
63
|
+
break
|
|
64
|
+
}
|
|
65
|
+
} catch {}
|
|
66
|
+
|
|
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
|
+
}
|
|
75
|
+
|
|
76
|
+
Write-Output (ConvertTo-JsonCompat $out)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Eject or close the optical tray via IMAPI2, with a Shell.Application fallback.
|
|
2
|
+
# Usage: eject.ps1 <drive letter e.g. D:> [close]
|
|
3
|
+
param([string]$Drive = "", [switch]$Close)
|
|
4
|
+
|
|
5
|
+
$ErrorActionPreference = "Stop"
|
|
6
|
+
$ok = $false
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
$master = New-Object -ComObject "IMAPI2.MsftDiscMaster2"
|
|
10
|
+
for ($i = 0; $i -lt $master.Count; $i++) {
|
|
11
|
+
$rec = New-Object -ComObject "IMAPI2.MsftDiscRecorder2"
|
|
12
|
+
$rec.InitializeDiscRecorder($master.Item($i))
|
|
13
|
+
$match = -not $Drive
|
|
14
|
+
foreach ($p in $rec.VolumePathNames) { if ($p -and $Drive -and $p.TrimEnd('\') -ieq $Drive) { $match = $true } }
|
|
15
|
+
if (-not $match) { continue }
|
|
16
|
+
if ($Close) { $rec.CloseTray() } else { $rec.EjectMedia() }
|
|
17
|
+
$ok = $true
|
|
18
|
+
break
|
|
19
|
+
}
|
|
20
|
+
} catch {}
|
|
21
|
+
|
|
22
|
+
if (-not $ok -and -not $Close -and $Drive) {
|
|
23
|
+
try {
|
|
24
|
+
$sh = New-Object -ComObject "Shell.Application"
|
|
25
|
+
$sh.Namespace(17).ParseName($Drive).InvokeVerb("Eject")
|
|
26
|
+
$ok = $true
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if ($ok) { exit 0 } else { Write-Error "eject failed"; exit 1 }
|