discstation 0.1.18 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 |
@@ -510,7 +510,13 @@ void parseMessage(String msg) {
510
510
  line1 = msg.substring(7);
511
511
  line2 = "";
512
512
  line3 = "";
513
- progressPercent = -1;
513
+ // Don't reset progressPercent here - a STATUS: label change (e.g.
514
+ // "Copying files..." -> "Converting...") is often immediately
515
+ // followed by a fresh PROGRESS: for the same ongoing operation.
516
+ // Wiping it every time flipped the bar to the indeterminate dots
517
+ // and back on every single label update, flickering between the
518
+ // two. DONE:/CANCELLED:/ERROR:/NO_DISC: still reset it below - those
519
+ // really are the end of an operation.
514
520
  drawStatus();
515
521
 
516
522
  } else if (msg.startsWith("PROGRESS:")) {
@@ -535,7 +535,10 @@ void parseMessage(String msg) {
535
535
  line1 = msg.substring(7);
536
536
  line2 = "";
537
537
  line3 = "";
538
- progressPercent = -1;
538
+ // Don't reset progressPercent here - see arduino/c6/DiscStation_C6.ino's
539
+ // matching comment: STATUS: label changes mid-operation are usually
540
+ // immediately followed by a fresh PROGRESS:, so wiping it every time
541
+ // flickered the bar to the indeterminate dots and back.
539
542
  drawStatus();
540
543
 
541
544
  } else if (msg.startsWith("PROGRESS:")) {
@@ -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,173 @@
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")) {
68
+ # The SourceForge "latest" mpv build is a .7z (Expand-Archive can't
69
+ # open it); mpv's own first-party CI release ships plain .zip builds.
70
+ try {
71
+ $mpvRelease = Invoke-RestMethod "https://api.github.com/repos/mpv-player/mpv/releases/tags/git-release" -UseBasicParsing -TimeoutSec 25
72
+ $mpvAsset = $mpvRelease.assets | Where-Object { $_.name -match "x86_64-w64-mingw32\.zip$" } | Select-Object -First 1
73
+ if ($mpvAsset) { Get-Zip $mpvAsset.browser_download_url (Join-Path $tools "mpv") | Out-Null }
74
+ } catch { Write-Host " mpv fetch skipped" }
75
+ }
76
+ if (-not (Have "HandBrakeCLI")) {
77
+ try {
78
+ $hbRelease = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" -UseBasicParsing -TimeoutSec 25
79
+ $hbAsset = $hbRelease.assets | Where-Object { $_.name -match "^HandBrakeCLI-.*-win-x86_64\.zip$" } | Select-Object -First 1
80
+ if ($hbAsset) { Get-Zip $hbAsset.browser_download_url (Join-Path $tools "handbrake") | Out-Null }
81
+ } catch { Write-Host " HandBrakeCLI fetch skipped" }
82
+ }
83
+ # dvdauthor + spumux (DVD-Video authoring/subtitles) have no winget package;
84
+ # this VideoHelp-hosted plain .zip (no rar/unrar needed) is the only
85
+ # reliable direct-download source found.
86
+ if (-not (Have "dvdauthor")) { Get-Zip "https://download.videohelp.com/gfd/edcounter.php?file=download/dvdauthor_winbin.zip" (Join-Path $tools "dvdauthor") | Out-Null }
87
+ if (Test-Path $tools) {
88
+ $env:Path = $env:Path + ";" + $tools + ";" +
89
+ ((Get-ChildItem $tools -Recurse -Filter "ffmpeg.exe" -ErrorAction SilentlyContinue | Select-Object -First 1).DirectoryName)
90
+ }
91
+ Write-Host "xorriso and HandBrakeCLI have no simple direct-download URL - install manually if you need"
92
+ Write-Host "video-DVD ISO packaging / DVD ripping (see the plan's Windows setup notes)."
93
+ } else {
94
+ Write-Host "winget unavailable (Windows 7). ISO + data + audio burn work via IMAPI2."
95
+ Write-Host "For rip/play/video-DVD install manually: xorriso, HandBrakeCLI 1.5.1, ffmpeg, mpv, yt-dlp."
96
+ }
97
+
98
+ # --- 3. App files + venv ------------------------------------------------------
99
+ New-Item -ItemType Directory -Force -Path $Base, $App | Out-Null
12
100
  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
101
+ & $PyExe @PyArgs -m venv $Venv
15
102
  $Py = Join-Path $Venv "Scripts\python.exe"
103
+ & $Py -m pip install --upgrade pip
16
104
  $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
105
+ if (Test-Path $Req) { & $Py -m pip install -r $Req } else { & $Py -m pip install pyserial mutagen requests yt-dlp }
106
+ $Opt = Join-Path $Root "requirements-optional.txt"
107
+ try { if (Test-Path $Opt) { & $Py -m pip install -r $Opt } else { & $Py -m pip install musicbrainzngs tmdbsimple } }
108
+ catch { Write-Host "Optional metadata deps skipped (host still works)." }
109
+
110
+ # --- 4. Self-signed cert (HTTPS on :8080; :8081 works without it) --------------
111
+ $crt = Join-Path $Base "server.crt"; $key = Join-Path $Base "server.key"
112
+ if (-not (Test-Path $crt) -or -not (Test-Path $key)) {
113
+ try {
114
+ $c = New-SelfSignedCertificate -DnsName "discstation.local" -CertStoreLocation "Cert:\CurrentUser\My" -NotAfter (Get-Date).AddYears(10)
115
+ $pwd = ConvertTo-SecureString -String "discstation" -Force -AsPlainText
116
+ $pfx = Join-Path $env:TEMP "ds.pfx"
117
+ Export-PfxCertificate -Cert $c -FilePath $pfx -Password $pwd | Out-Null
118
+ & $Py -c "import ssl" 2>$null
119
+ # Convert PFX -> PEM via Python cryptography if present, else leave HTTPS off.
120
+ & $Py -m pip install cryptography 2>$null
121
+ & $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
122
+ Remove-Item $pfx -ErrorAction SilentlyContinue
123
+ } catch { Write-Host "Cert generation skipped; the host will serve plain HTTP on 8081." }
21
124
  }
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)."
125
+
126
+ # --- 5. Firewall -------------------------------------------------------------
127
+ foreach ($port in 8080, 8081) {
128
+ $name = "DiscStation $port"
129
+ try {
130
+ if (Get-Command New-NetFirewallRule -ErrorAction SilentlyContinue) {
131
+ if (-not (Get-NetFirewallRule -DisplayName $name -ErrorAction SilentlyContinue)) {
132
+ New-NetFirewallRule -DisplayName $name -Direction Inbound -Action Allow -Protocol TCP -LocalPort $port | Out-Null
133
+ }
134
+ } else {
135
+ netsh advfirewall firewall add rule name="$name" dir=in action=allow protocol=TCP localport=$port | Out-Null
136
+ }
137
+ } catch {}
25
138
  }
26
139
 
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"
140
+ # --- 6. Auto-start Scheduled Task -----------------------------------------------
141
+ $pyw = Join-Path $Venv "Scripts\pythonw.exe"
142
+ $target = Join-Path $App "discstation.py"
143
+ try {
144
+ if (Get-Command Register-ScheduledTask -ErrorAction SilentlyContinue) {
145
+ $action = New-ScheduledTaskAction -Execute $pyw -Argument "`"$target`"" -WorkingDirectory $App
146
+ $trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
147
+ $set = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
148
+ # Interactive: attaches to the logged-in desktop session, same
149
+ # lifecycle as `systemctl --user`/launchd LaunchAgents on the other
150
+ # two platforms - required so PLAY's mpv window actually renders
151
+ # somewhere visible (S4U runs headlessly with no session to render
152
+ # into, which silently made every mpv window invisible). Trade-off,
153
+ # same one Linux/macOS already accept: won't start until someone is
154
+ # logged into the desktop.
155
+ # RunLevel Limited (standard, non-elevated): nothing here needs admin -
156
+ # IMAPI2 burning, WMI reads, and binding ports >1024 all work as a normal
157
+ # user, and elevation is what put "Administrator" on the flashing console
158
+ # windows this used to spawn.
159
+ $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited
160
+ Register-ScheduledTask -TaskName "DiscStation" -Action $action -Trigger $trigger -Settings $set -Principal $principal -Force | Out-Null
161
+ Start-ScheduledTask -TaskName "DiscStation"
162
+ } else {
163
+ schtasks /create /tn "DiscStation" /sc onlogon /f /tr "`"$pyw`" `"$target`"" | Out-Null
164
+ schtasks /run /tn "DiscStation" | Out-Null
165
+ }
166
+ } catch { Write-Host "Auto-start task not created: $_" }
167
+
168
+ $ip = (Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -notmatch '^127\.|^169\.254\.' } | Select-Object -First 1).IPAddress
169
+ Write-Host ""
170
+ Write-Host "DiscStation installed at $App"
171
+ Write-Host " Web UI: http://localhost:8081/ (also http://$ip`:8081 on the LAN)"
172
+ Write-Host " Run 'discstation' any time to open it. Detection/eject/ISO+data+audio burn use IMAPI2 (no extra tools)."
173
+ 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.21",
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