discstation 0.1.17 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.17",
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
@@ -53,6 +59,8 @@ _last_upload_label = None
53
59
  _web_status = "READY"
54
60
  _web_progress = -1
55
61
  _web_progress_active = False
62
+ _operation_active = False # a burn/rip/play flow is holding the drive
63
+ _last_disc_info = {"disc_present": False, "capacity_bytes": 0, "capacity_gb": 0, "type": "none"}
56
64
  _active_ser = None
57
65
  STATIC_DIR = Path(__file__).resolve().parent / "static"
58
66
 
@@ -146,6 +154,10 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
146
154
  self._respond(200, f'{len(files)} file(s) uploaded ({size_str}). Select BURN DATA on remote.')
147
155
 
148
156
  def _serve_disc_info(self):
157
+ if _operation_active:
158
+ # a burn/rip/play holds the drive — don't probe it, serve last-known.
159
+ self._respond(200, json.dumps({**_last_disc_info, "busy": True}), "application/json")
160
+ return
149
161
  info = {"disc_present": False, "capacity_bytes": 0, "capacity_gb": 0, "type": "none"}
150
162
  try:
151
163
  device = discstation_burn.disc_device()
@@ -163,6 +175,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
163
175
  info["label"] = di.label
164
176
  except Exception as e:
165
177
  print(f"Disc info error: {e}")
178
+ _last_disc_info.update(info)
166
179
  self._respond(200, json.dumps(info), "application/json")
167
180
 
168
181
  def _serve_sse(self):
@@ -212,7 +225,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
212
225
  def _serve_sw(self):
213
226
  sw = '''self.addEventListener('install', e => {
214
227
  self.skipWaiting();
215
- caches.open('discstation-v7').then(c => c.addAll(['/','/static/style.css?v=7','/static/app.js?v=7']));
228
+ caches.open('discstation-v8').then(c => c.addAll(['/','/static/style.css?v=8','/static/app.js?v=8']));
216
229
  });
217
230
  self.addEventListener('activate', e => e.waitUntil(clients.claim()));
218
231
  self.addEventListener('fetch', e => {
@@ -221,7 +234,7 @@ self.addEventListener('fetch', e => {
221
234
  if (path === '/' || path.startsWith('/static/')) {
222
235
  e.respondWith(fetch(e.request).then(r => {
223
236
  const copy = r.clone();
224
- caches.open('discstation-v7').then(c => c.put(e.request, copy));
237
+ caches.open('discstation-v8').then(c => c.put(e.request, copy));
225
238
  return r;
226
239
  }).catch(() => caches.match(e.request)));
227
240
  } else {
@@ -342,13 +355,17 @@ def start_web_server(port=8080):
342
355
  cert_dir = discstation_host.config_dir()
343
356
  cert = cert_dir / 'server.crt'
344
357
  key = cert_dir / 'server.key'
345
- if cert.exists() and key.exists():
346
- ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
347
- ctx.load_cert_chain(str(cert), str(key))
348
- server.socket = ctx.wrap_socket(server.socket, server_side=True)
349
- 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}")
350
366
  else:
351
- 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)"))
352
369
 
353
370
  _web_server = server
354
371
  t = threading.Thread(target=server.serve_forever, daemon=True)
@@ -415,7 +432,8 @@ def wait_for_web_url(ser):
415
432
  check_serial_alive(ser)
416
433
 
417
434
 
418
- 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"))
419
437
  RIP_ROOT = discstation_burn.USER_HOME / "dvd_rips"
420
438
  USER_AGENT = "DVDStation/0.1 (local appliance; phuju)"
421
439
  DISC_POLL_SECONDS = 6
@@ -642,12 +660,26 @@ def chown_to_sudo_user(path):
642
660
  pass
643
661
 
644
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
+
645
680
  def mpv_command(command):
646
681
  try:
647
- payload = json.dumps({"command": command}).encode() + b"\n"
648
- with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
649
- sock.connect(MPV_SOCKET)
650
- sock.sendall(payload)
682
+ _mpv_ipc(json.dumps({"command": command}).encode() + b"\n")
651
683
  except OSError:
652
684
  return False
653
685
  return True
@@ -656,11 +688,7 @@ def mpv_command(command):
656
688
  def mpv_query(command):
657
689
  try:
658
690
  payload = json.dumps({"command": command, "request_id": 1}).encode() + b"\n"
659
- with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
660
- sock.settimeout(0.5)
661
- sock.connect(MPV_SOCKET)
662
- sock.sendall(payload)
663
- response = json.loads(sock.recv(4096).decode(errors="ignore"))
691
+ response = json.loads(_mpv_ipc(payload, timeout=0.5).decode(errors="ignore"))
664
692
  return response.get("data")
665
693
  except (OSError, ValueError, json.JSONDecodeError):
666
694
  return None
@@ -671,14 +699,11 @@ def wait_for_socket(path, proc, timeout=8):
671
699
  while time.time() < deadline:
672
700
  if proc.poll() is not None:
673
701
  return False
674
- if Path(path).exists():
675
- try:
676
- with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
677
- sock.settimeout(0.25)
678
- sock.connect(path)
679
- return True
680
- except OSError:
681
- pass
702
+ try:
703
+ _mpv_ipc(b'{"command":["get_property","idle-active"]}\n', timeout=0.25)
704
+ return True
705
+ except OSError:
706
+ pass
682
707
  time.sleep(0.1)
683
708
  return False
684
709
 
@@ -991,11 +1016,28 @@ _tray_open = False
991
1016
  _tray_open_since = 0.0 # time.monotonic() of the last OLED-initiated eject
992
1017
 
993
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
+
994
1036
  def _tray_closed_with_disc(device):
995
1037
  if not device:
996
1038
  return False
997
1039
  global _tray_open
998
- if not Path(device).exists():
1040
+ if not _device_present(device):
999
1041
  return False
1000
1042
  properties = udev_cdrom_properties(device)
1001
1043
  if properties.get("ID_CDROM_MEDIA") == "1":
@@ -1014,7 +1056,7 @@ def disc_present(device):
1014
1056
  return True
1015
1057
  if _tray_open:
1016
1058
  return False
1017
- if not Path(device).exists():
1059
+ if not _device_present(device):
1018
1060
  return False
1019
1061
  if discstation_host.system_name() != "linux":
1020
1062
  properties = udev_cdrom_properties(device)
@@ -1048,7 +1090,7 @@ def disc_present(device):
1048
1090
  def is_blank_disc(device):
1049
1091
  if not device:
1050
1092
  return False
1051
- if not Path(device).exists():
1093
+ if not _device_present(device):
1052
1094
  return False
1053
1095
 
1054
1096
  properties = udev_cdrom_properties(device, refresh=True)
@@ -1100,7 +1142,7 @@ def is_rewritable_disc(device):
1100
1142
  if not device:
1101
1143
  return False
1102
1144
  """Return whether the inserted medium can be overwritten."""
1103
- if not Path(device).exists():
1145
+ if not _device_present(device):
1104
1146
  return False
1105
1147
 
1106
1148
  properties = udev_cdrom_properties(device)
@@ -1220,7 +1262,7 @@ def _media_quick_state(device, props):
1220
1262
  return "unsure"
1221
1263
  # st == "unknown": fall through to the legacy probes below
1222
1264
  try:
1223
- if not Path(device).exists():
1265
+ if not _device_present(device):
1224
1266
  return "empty"
1225
1267
  except OSError:
1226
1268
  return "empty"
@@ -1310,6 +1352,11 @@ def _classify_disc(device, props, failed, deadline):
1310
1352
  (blkid -> lsdvd -> wodim -toc -> fs fallback -> blank) but records which
1311
1353
  probes timed out / were missing so the caller can retry."""
1312
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")
1313
1360
  if props.get("ID_CDROM_MEDIA_TYPE") == "audio":
1314
1361
  return _disc_info(True, "audio_cd", web_type="AUDIO_CD")
1315
1362
  if props.get("ID_FS_TYPE") in ("udf", "iso9660"):
@@ -1620,6 +1667,11 @@ class mounted_disc:
1620
1667
  self.owned_mount = False
1621
1668
 
1622
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
1623
1675
  if discstation_host.system_name() == "darwin":
1624
1676
  properties = discstation_host.media_properties(self.device)
1625
1677
  existing_mount = properties.get("ID_MOUNT_POINT")
@@ -1681,6 +1733,24 @@ def disc_video_files(mount_dir):
1681
1733
 
1682
1734
 
1683
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
+ }
1684
1754
  if discstation_host.system_name() == "darwin":
1685
1755
  paranoia = None
1686
1756
  for name in ("cd-paranoia", "cdparanoia"):
@@ -1760,7 +1830,7 @@ def audio_cd_toc(device):
1760
1830
 
1761
1831
 
1762
1832
  def audio_cd_chapters(device):
1763
- if discstation_host.system_name() == "darwin":
1833
+ if discstation_host.system_name() in ("darwin", "windows"):
1764
1834
  toc = audio_cd_toc(device)
1765
1835
  tracks = toc["tracks"]
1766
1836
  first = tracks[0]
@@ -2406,9 +2476,18 @@ def directory_size_bytes(path):
2406
2476
  return total
2407
2477
 
2408
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
+
2409
2488
  def burn_flow(ser, url):
2410
2489
  if not url:
2411
- if sys.stdin.isatty():
2490
+ if _stdin_is_tty():
2412
2491
  safe_send(ser, "STATUS:Enter URL or file path in terminal")
2413
2492
  print("=== Enter URL or file path below, then press Enter ===")
2414
2493
  try:
@@ -2664,7 +2743,7 @@ def burn_data_flow(ser):
2664
2743
  if _last_upload_dir and Path(_last_upload_dir).exists():
2665
2744
  url = _last_upload_dir
2666
2745
  _last_upload_dir = None
2667
- elif sys.stdin.isatty():
2746
+ elif _stdin_is_tty():
2668
2747
  safe_send(ser, "STATUS:Enter URL or file path in terminal")
2669
2748
  print("=== Enter URL or file path below, then press Enter ===")
2670
2749
  try:
@@ -2829,7 +2908,7 @@ def burn_data_flow(ser):
2829
2908
 
2830
2909
 
2831
2910
  def burn_audio_flow(ser):
2832
- if sys.stdin.isatty():
2911
+ if _stdin_is_tty():
2833
2912
  safe_send(ser, "STATUS:Enter path to audio files in terminal")
2834
2913
  print("=== Enter path to audio files/folder, then press Enter ===")
2835
2914
  try:
@@ -3010,7 +3089,7 @@ def _iter_proc_lines(proc, ser):
3010
3089
  def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3011
3090
  try:
3012
3091
  os.unlink(MPV_SOCKET)
3013
- except FileNotFoundError:
3092
+ except OSError:
3014
3093
  pass
3015
3094
 
3016
3095
  env = os.environ.copy()
@@ -3150,7 +3229,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3150
3229
  discstation_burn.stop_process(proc)
3151
3230
  try:
3152
3231
  os.unlink(MPV_SOCKET)
3153
- except FileNotFoundError:
3232
+ except OSError:
3154
3233
  pass
3155
3234
 
3156
3235
 
@@ -3731,7 +3810,7 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
3731
3810
 
3732
3811
 
3733
3812
  def station_loop(ser, url, artist_hint=None, album_hint=None):
3734
- global _last_burn_result, _last_burn_result_time, _tray_open, _tray_open_since
3813
+ global _last_burn_result, _last_burn_result_time, _tray_open, _tray_open_since, _operation_active
3735
3814
  discstation_burn.cleanup_old_jobs()
3736
3815
  try:
3737
3816
  device = discstation_burn.disc_device()
@@ -3934,6 +4013,7 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
3934
4013
  # The user picked a mode — they want to act on a disc, so the drive is
3935
4014
  # fair game again even if it was ejected from the OLED earlier.
3936
4015
  _tray_open = False
4016
+ _operation_active = True # stop /disc-info probing the drive during the flow
3937
4017
  try:
3938
4018
  if mode == "BURN":
3939
4019
  burn_flow(ser, url)
@@ -3964,12 +4044,14 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
3964
4044
  _last_burn_result = f"ERROR: {e}"
3965
4045
  print(f"Error in {mode}: {e}")
3966
4046
  time.sleep(4)
4047
+ finally:
4048
+ _operation_active = False
3967
4049
 
3968
4050
  _last_burn_result_time = time.time()
3969
4051
  refresh_main_menu(ser)
3970
4052
 
3971
4053
 
3972
- PIDFILE = "/tmp/discstation.pid"
4054
+ PIDFILE = os.path.join(tempfile.gettempdir(), "discstation.pid")
3973
4055
 
3974
4056
 
3975
4057
  def check_pidfile():
@@ -3982,6 +4064,10 @@ def check_pidfile():
3982
4064
  if sys.platform == "linux":
3983
4065
  with open(f"/proc/{old_pid}/cmdline") as f:
3984
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()
3985
4071
  else:
3986
4072
  ps = subprocess.run(["ps", "-p", str(old_pid), "-o", "command="],
3987
4073
  capture_output=True, text=True)