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 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` — files + venv | web / serial control only, no burn backend |
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 (web/control workflow)
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 runs the shared Python/web
92
- workflow only; it has no burn backend yet. See `docs/PLATFORM_SUPPORT.md`.
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 (required on Windows) |
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 |
@@ -29,10 +29,28 @@ the drive on recent macOS; DiscStation reports this clearly instead of hanging.
29
29
 
30
30
  ## Windows
31
31
 
32
- The Python host and web/control workflow can run from PowerShell. Optical
33
- burning needs a Windows IMAPI backend or a separately installed compatible
34
- burning tool. The Windows installer deliberately reports this limitation
35
- instead of silently attempting Linux commands.
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 still needs its writer backend.
64
+ complete (bar macOS audio-CD burning); Windows burns and plays, with DVD-mirror
65
+ and audio-CD ripping still to come.
@@ -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 = Split-Path -Parent $MyInvocation.MyCommand.Path
4
- $App = if ($env:DISCSTATION_APP_DIR) { $env:DISCSTATION_APP_DIR } else { Join-Path $env:LOCALAPPDATA "DiscStation\app" }
5
- $Venv = if ($env:DISCSTATION_VENV_DIR) { $env:DISCSTATION_VENV_DIR } else { Join-Path $env:LOCALAPPDATA "DiscStation\venv" }
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
- if (-not (Get-Command py -ErrorAction SilentlyContinue)) {
8
- throw "Install Python 3 from https://www.python.org/downloads/windows/ first."
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
- New-Item -ItemType Directory -Force -Path $App, $Venv | Out-Null
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
- py -3 -m venv $Venv
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
- & $Py -m pip install -r $Req
19
- } else {
20
- & $Py -m pip install pyserial mutagen requests yt-dlp
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
- # Optional metadata deps — best effort, never fatal.
23
- try { & $Py -m pip install musicbrainzngs tmdbsimple } catch {
24
- Write-Host "Optional metadata deps skipped (host still works)."
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
- Write-Host "DiscStation host files installed at $App"
28
- Write-Host "The web/control workflow is available; optical burning requires a Windows IMAPI backend or compatible burning tools."
29
- Write-Host "Run: $Venv\Scripts\python.exe $App\discstation.py"
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.18",
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 files + venv only; web/serial control, no burn backend
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
@@ -4,7 +4,10 @@ import atexit
4
4
  import collections
5
5
  import concurrent.futures
6
6
  import errno
7
- import fcntl
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
- import ssl
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
- ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
354
- ctx.load_cert_chain(str(cert), str(key))
355
- server.socket = ctx.wrap_socket(server.socket, server_side=True)
356
- print(f"Web interface on https://0.0.0.0:{port}")
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 = str(Path(tempfile.gettempdir()) / "discstation_mpv.sock")
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
- payload = json.dumps({"command": command}).encode() + b"\n"
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
- with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
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
- if Path(path).exists():
682
- try:
683
- with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
684
- sock.settimeout(0.25)
685
- sock.connect(path)
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 Path(device).exists():
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 Path(device).exists():
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 Path(device).exists():
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 Path(device).exists():
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 Path(device).exists():
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() == "darwin":
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 sys.stdin.isatty():
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 sys.stdin.isatty():
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 sys.stdin.isatty():
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 FileNotFoundError:
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 FileNotFoundError:
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 = "/tmp/discstation.pid"
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)
@@ -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")
@@ -98,6 +98,54 @@ def _mac_optical_device():
98
98
 
99
99
  _last_disc_device = None
100
100
 
101
+ # --- Windows: IMAPI2 / WMI probes via bundled PowerShell helpers ---------------
102
+ _WIN_DIR = Path(__file__).resolve().parent / "win"
103
+ _win_info_cache = (0.0, None)
104
+
105
+
106
+ def ps_cmd(script_name, *args):
107
+ """Build a `powershell -File src/win/<script_name> <args>` argv, plus the
108
+ Popen/run kwargs that suppress the console window. The host runs as
109
+ pythonw.exe (no console); without CREATE_NO_WINDOW, Windows pops a
110
+ brand-new visible console for every one of these - and disc detection
111
+ polls every ~1.5s, so it would flash constantly."""
112
+ cmd = ["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
113
+ "-File", str(_WIN_DIR / script_name), *[str(a) for a in args]]
114
+ kwargs = {"creationflags": subprocess.CREATE_NO_WINDOW} if hasattr(subprocess, "CREATE_NO_WINDOW") else {}
115
+ return cmd, kwargs
116
+
117
+
118
+ def _run_ps(script_name, *args, timeout=25):
119
+ """Run src/win/<script_name> and return (returncode, stdout, stderr)."""
120
+ cmd, kwargs = ps_cmd(script_name, *args)
121
+ try:
122
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, **kwargs)
123
+ return r.returncode, r.stdout, r.stderr
124
+ except (OSError, subprocess.TimeoutExpired):
125
+ return 1, "", ""
126
+
127
+
128
+ def _win_disc_info(force=False):
129
+ """Cached (~2s) dict from src/win/disc-info.ps1, or {} on failure."""
130
+ import json as _json
131
+ import time
132
+ global _win_info_cache
133
+ ts, cached = _win_info_cache
134
+ if not force and cached is not None and time.time() - ts < 2.0:
135
+ return cached
136
+ override = os.environ.get("DISC_DEVICE") or os.environ.get("DVD_DEVICE") or ""
137
+ rc, out, _ = _run_ps("disc-info.ps1", *([override] if override else []), timeout=20)
138
+ info = {}
139
+ for line in out.splitlines():
140
+ line = line.strip()
141
+ if line.startswith("{"):
142
+ try:
143
+ info = _json.loads(line)
144
+ except ValueError:
145
+ pass
146
+ _win_info_cache = (time.time(), info)
147
+ return info
148
+
101
149
 
102
150
  def disc_device():
103
151
  global _last_disc_device
@@ -140,13 +188,22 @@ def disc_device():
140
188
  if "/dev/disk" in line and ("CD" in line or "DVD" in line or "optical" in line.lower()):
141
189
  return line.strip().split()[0]
142
190
  elif system == "windows":
143
- raise RuntimeError("Set DISC_DEVICE to the optical drive letter on Windows")
191
+ info = _win_disc_info()
192
+ if info.get("drive"):
193
+ return info["drive"]
194
+ if override:
195
+ return override
144
196
  raise FileNotFoundError("No optical disc drive found; set DISC_DEVICE explicitly")
145
197
 
146
198
 
147
199
  def drive_status():
148
200
  """Non-Linux equivalent of the CDROM_DRIVE_STATUS ioctl.
149
- Returns 'disc' | 'no_disc' | 'unknown'. macOS: parse `drutil status`."""
201
+ Returns 'disc' | 'no_disc' | 'unknown'. macOS: `drutil status`; Windows: IMAPI2/WMI."""
202
+ if system_name() == "windows":
203
+ info = _win_disc_info()
204
+ if not info:
205
+ return "unknown"
206
+ return "disc" if info.get("media_loaded") else "no_disc"
150
207
  if system_name() != "darwin":
151
208
  return "unknown"
152
209
  try:
@@ -244,10 +301,35 @@ def media_properties(device):
244
301
  props["ID_CDROM_MEDIA_DVD_PLUS_R"] = "1"
245
302
  _tag_rewritable(props, optical)
246
303
  return props
304
+ if system_name() == "windows":
305
+ info = _win_disc_info()
306
+ if not info.get("media_loaded"):
307
+ return {}
308
+ props = {"ID_CDROM": "1", "ID_CDROM_MEDIA": "1"}
309
+ if info.get("label"):
310
+ props["ID_FS_LABEL"] = info["label"]
311
+ if info.get("fs") in ("udf", "iso9660"):
312
+ props["ID_FS_TYPE"] = info["fs"]
313
+ mtype = (info.get("media_type") or "").lower()
314
+ if mtype == "audio_cd" or (not info.get("fs") and not info.get("blank") and mtype.startswith("cd")):
315
+ props["ID_CDROM_MEDIA_TYPE"] = "audio"
316
+ elif mtype.startswith("dvd") or mtype.startswith("bd"):
317
+ props["ID_CDROM_MEDIA_TYPE"] = "dvd"
318
+ if info.get("blank"):
319
+ props["ID_CDROM_MEDIA_STATE"] = "blank"
320
+ if "dvd+r dl" in mtype or "dvd-r dl" in mtype:
321
+ props["ID_CDROM_MEDIA_DVD_PLUS_R_DL"] = "1"
322
+ elif ("dvd+r" in mtype or "dvd-r" in mtype) and "rw" not in mtype:
323
+ props["ID_CDROM_MEDIA_DVD_PLUS_R"] = "1"
324
+ if info.get("rewritable"):
325
+ _tag_rewritable(props, mtype)
326
+ return props
247
327
  return {}
248
328
 
249
329
 
250
330
  def media_capacity_bytes(device):
331
+ if system_name() == "windows":
332
+ return _win_disc_info().get("capacity_bytes") or None
251
333
  if system_name() == "darwin":
252
334
  try:
253
335
  result = subprocess.run(["/usr/sbin/diskutil", "info", device], capture_output=True, text=True, check=False, timeout=3)
@@ -451,6 +533,12 @@ def eject_device(device, close=False):
451
533
  except (OSError, subprocess.TimeoutExpired):
452
534
  pass
453
535
  return False
536
+ elif system == "windows":
537
+ args = [device] if device else []
538
+ if close:
539
+ args.append("-Close")
540
+ rc, _, _ = _run_ps("eject.ps1", *args, timeout=20)
541
+ return rc == 0
454
542
  else:
455
- raise RuntimeError("Automatic optical-drive eject is not implemented on Windows")
543
+ raise RuntimeError("Automatic optical-drive eject is not implemented on this OS")
456
544
  return subprocess.run(command, capture_output=True, text=True, timeout=10).returncode == 0
@@ -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 }