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 +7 -5
- package/docs/PLATFORM_SUPPORT.md +24 -5
- package/install-windows.ps1 +139 -18
- package/package.json +2 -1
- package/scripts/setup.mjs +1 -1
- package/src/discstation.py +127 -41
- package/src/discstation_burn.py +102 -9
- package/src/discstation_host.py +91 -3
- package/src/static/app.js +2 -1
- package/src/static/index.html +2 -2
- package/src/win/_json.ps1 +29 -0
- package/src/win/audio-toc.ps1 +43 -0
- package/src/win/burn-audio.ps1 +72 -0
- package/src/win/burn-data.ps1 +71 -0
- package/src/win/burn-image.ps1 +64 -0
- package/src/win/disc-info.ps1 +76 -0
- package/src/win/eject.ps1 +30 -0
package/README.md
CHANGED
|
@@ -64,7 +64,7 @@ Windows.
|
|
|
64
64
|
|----|-----------|-----------------|
|
|
65
65
|
| Linux | `install.sh` — apt deps, venv, systemd `--user` service, self-signed cert | full burn / rip / play |
|
|
66
66
|
| macOS | `install-macos.sh` — Homebrew deps, venv, launchd agent, cert | burn / rip / play (audio-CD *burning* is best-effort; set `DISC_DEVICE` if detection fails) |
|
|
67
|
-
| Windows | `install-windows.ps1` —
|
|
67
|
+
| Windows | `install-windows.ps1` — Python + venv, self-signed cert, firewall rules, auto-start Scheduled Task | burn (ISO / data / audio CD) via IMAPI2 on Windows 10/11 and 7; DVD rip (HandBrake main-feature mode) and play (mpv) use the shared cross-platform path; full VIDEO_TS mirror rip and audio-CD rip aren't implemented on Windows yet |
|
|
68
68
|
|
|
69
69
|
### Or run the platform script directly
|
|
70
70
|
|
|
@@ -80,7 +80,7 @@ arduino-cli lib install QRCode
|
|
|
80
80
|
# macOS host
|
|
81
81
|
./install-macos.sh
|
|
82
82
|
|
|
83
|
-
# Windows host
|
|
83
|
+
# Windows host
|
|
84
84
|
PowerShell -ExecutionPolicy Bypass -File .\install-windows.ps1
|
|
85
85
|
```
|
|
86
86
|
|
|
@@ -88,8 +88,10 @@ PowerShell -ExecutionPolicy Bypass -File .\install-windows.ps1
|
|
|
88
88
|
|
|
89
89
|
Linux and macOS both run the complete optical workflow (burn, rip, play) —
|
|
90
90
|
macOS via `xorriso` / `hdiutil` / `cd-paranoia` / `dvdbackup` / HandBrake, with
|
|
91
|
-
audio-CD *burning* the one best-effort area. Windows
|
|
92
|
-
|
|
91
|
+
audio-CD *burning* the one best-effort area. Windows burns (ISO / data / audio
|
|
92
|
+
CD) via IMAPI2 and plays via mpv; DVD ripping works in HandBrake main-feature
|
|
93
|
+
mode but not full VIDEO_TS mirror or audio-CD ripping yet. See
|
|
94
|
+
`docs/PLATFORM_SUPPORT.md`.
|
|
93
95
|
|
|
94
96
|
## Web Interface
|
|
95
97
|
|
|
@@ -114,7 +116,7 @@ Built-in web server on port 8080 (HTTPS with self-signed cert):
|
|
|
114
116
|
| `DISC_CLEANUP_DAYS` | 2 | Auto-cleanup old job directories |
|
|
115
117
|
| `DISC_OUTPUT_LIMIT_BYTES` | 4300000000 | Conservative DVD5 payload limit |
|
|
116
118
|
| `DISC_DL_OUTPUT_LIMIT_BYTES` | 8000000000 | Conservative DVD9 payload limit |
|
|
117
|
-
| `DISC_DEVICE` | auto | Optical drive node override
|
|
119
|
+
| `DISC_DEVICE` | auto | Optical drive node override |
|
|
118
120
|
| `DISC_PORT` | auto | ESP32 serial port override |
|
|
119
121
|
| `DISCSTATION_HTTP_PORT` | 8081 | Plain-HTTP port for the mobile app (`0` disables) |
|
|
120
122
|
| `DISCSTATION_DVD_RIP_MODE` | mirror | `mkv` = HandBrake main-feature transcode instead of a full VIDEO_TS mirror |
|
package/docs/PLATFORM_SUPPORT.md
CHANGED
|
@@ -29,10 +29,28 @@ the drive on recent macOS; DiscStation reports this clearly instead of hanging.
|
|
|
29
29
|
|
|
30
30
|
## Windows
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
Runs the same Python host and web UI as Linux/macOS, triggered the same way
|
|
33
|
+
via the ESP32 OLED remote. `install-windows.ps1` sets up Python + venv, a
|
|
34
|
+
self-signed cert, firewall rules for 8080/8081, and a per-user auto-start
|
|
35
|
+
Scheduled Task (headless `pythonw.exe`, no admin needed).
|
|
36
|
+
|
|
37
|
+
- **Detect / eject** — WMI (`Win32_CDROMDrive`, `Win32_LogicalDisk`) + IMAPI2
|
|
38
|
+
for media type, blank/rewritable state, and capacity
|
|
39
|
+
(`src/win/disc-info.ps1`, `src/win/eject.ps1`).
|
|
40
|
+
- **Burn** — data, ISO, and audio CD all go through IMAPI2
|
|
41
|
+
(`src/win/burn-{image,data,audio}.ps1`), with live progress streamed to the
|
|
42
|
+
OLED; `isoburn.exe /q` is the zero-dependency fallback for a raw ISO.
|
|
43
|
+
Works on Windows 7 SP1 and 10/11.
|
|
44
|
+
- **Rip** — DVD main-feature mode via HandBrakeCLI (winget on 10/11) works;
|
|
45
|
+
a full unencrypted VIDEO_TS mirror and audio-CD ripping aren't implemented
|
|
46
|
+
yet (no Windows path for `dvdbackup`/`cd-paranoia`).
|
|
47
|
+
- **Play** — `mpv`, via the same code path as Linux/macOS (device letter for
|
|
48
|
+
DVD-Video, `cdda://` for audio CD, mounted drive for data/VCD).
|
|
49
|
+
|
|
50
|
+
Windows 7 (no `winget`) gets the host, detection, eject, and all three burn
|
|
51
|
+
modes; rip/play need tools that don't install cleanly on 7, so those OLED
|
|
52
|
+
actions report "not supported" there. Set `DISC_DEVICE` to override drive
|
|
53
|
+
auto-detection.
|
|
36
54
|
|
|
37
55
|
## Shared Components
|
|
38
56
|
|
|
@@ -43,4 +61,5 @@ instead of silently attempting Linux commands.
|
|
|
43
61
|
- QR-code web URL display
|
|
44
62
|
|
|
45
63
|
The optical-drive backend is the platform boundary. Linux and macOS are
|
|
46
|
-
complete (bar macOS audio-CD burning); Windows
|
|
64
|
+
complete (bar macOS audio-CD burning); Windows burns and plays, with DVD-mirror
|
|
65
|
+
and audio-CD ripping still to come.
|
package/install-windows.ps1
CHANGED
|
@@ -1,29 +1,150 @@
|
|
|
1
|
+
# DiscStation host installer for Windows 10/11 (and best-effort on 7 SP1).
|
|
2
|
+
# powershell -ExecutionPolicy Bypass -File install-windows.ps1
|
|
3
|
+
# Sets up: Python + venv, optical CLI tools (winget), self-signed cert,
|
|
4
|
+
# a per-user auto-start Scheduled Task, and inbound firewall rules for 8080/8081.
|
|
1
5
|
$ErrorActionPreference = "Stop"
|
|
6
|
+
$ProgressPreference = "SilentlyContinue" # Invoke-WebRequest's progress bar breaks over SSH/non-interactive hosts
|
|
2
7
|
|
|
3
|
-
$Root
|
|
4
|
-
$
|
|
5
|
-
$
|
|
8
|
+
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
9
|
+
$Base = if ($env:DISCSTATION_CONFIG_DIR) { $env:DISCSTATION_CONFIG_DIR } else { Join-Path $env:APPDATA "DiscStation" }
|
|
10
|
+
$App = if ($env:DISCSTATION_APP_DIR) { $env:DISCSTATION_APP_DIR } else { Join-Path $Base "app" }
|
|
11
|
+
$Venv = if ($env:DISCSTATION_VENV_DIR) { $env:DISCSTATION_VENV_DIR } else { Join-Path $Base "venv" }
|
|
12
|
+
$IsWin7 = [Environment]::OSVersion.Version.Major -eq 6
|
|
13
|
+
$winget = (Get-Command winget -ErrorAction SilentlyContinue)
|
|
6
14
|
|
|
7
|
-
|
|
8
|
-
|
|
15
|
+
function Have($name) { [bool](Get-Command $name -ErrorAction SilentlyContinue) }
|
|
16
|
+
function Winget-Install($id) {
|
|
17
|
+
if (-not $winget) { return $false }
|
|
18
|
+
try { winget install -e --id $id --accept-source-agreements --accept-package-agreements --silent | Out-Null; return $true }
|
|
19
|
+
catch { Write-Host " winget $id failed (skipping)"; return $false }
|
|
9
20
|
}
|
|
10
21
|
|
|
11
|
-
|
|
22
|
+
# --- 1. Python -------------------------------------------------------------------
|
|
23
|
+
if (-not (Have "python") -and -not (Have "py")) {
|
|
24
|
+
Write-Host "Installing Python..."
|
|
25
|
+
if ($winget -and -not $IsWin7) {
|
|
26
|
+
Winget-Install "Python.Python.3.12" | Out-Null
|
|
27
|
+
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "User") + ";" + $env:Path
|
|
28
|
+
} else {
|
|
29
|
+
$pyver = if ($IsWin7) { "3.8.10" } else { "3.12.6" }
|
|
30
|
+
$url = "https://www.python.org/ftp/python/$pyver/python-$pyver-amd64.exe"
|
|
31
|
+
$exe = Join-Path $env:TEMP "python-$pyver-amd64.exe"
|
|
32
|
+
Invoke-WebRequest $url -OutFile $exe -UseBasicParsing
|
|
33
|
+
Start-Process $exe -ArgumentList "/quiet","InstallAllUsers=0","PrependPath=1","Include_test=0" -Wait
|
|
34
|
+
$env:Path = (Join-Path $env:LOCALAPPDATA "Programs\Python\Python$($pyver.Substring(0,4) -replace '\.','')") + ";" + $env:Path + ";" +
|
|
35
|
+
(Join-Path $env:LOCALAPPDATA "Programs\Python\Python$($pyver.Substring(0,4) -replace '\.','')\Scripts")
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (Have "python") { $PyExe = "python"; $PyArgs = @() }
|
|
39
|
+
elseif (Have "py") { $PyExe = "py"; $PyArgs = @("-3") }
|
|
40
|
+
else { throw "Python install failed. Install Python 3 manually and re-run." }
|
|
41
|
+
|
|
42
|
+
# --- 2. Optical CLI tools (winget when available; Win10/11 only; best-effort,
|
|
43
|
+
# strictly time-bounded so a slow/blocked mirror can never hang the install) --
|
|
44
|
+
if ($winget -and -not $IsWin7) {
|
|
45
|
+
Write-Host "Installing optical tools via winget (best effort)..."
|
|
46
|
+
foreach ($id in "libburnia.xorriso","mpv.mpv","HandBrake.HandBrake.CLI","Gyan.FFmpeg","yt-dlp.yt-dlp") {
|
|
47
|
+
Winget-Install $id | Out-Null
|
|
48
|
+
}
|
|
49
|
+
} elseif (-not $IsWin7) {
|
|
50
|
+
Write-Host "winget unavailable - fetching optical tools directly (best effort, 25s timeout each)..."
|
|
51
|
+
$tools = Join-Path $Base "tools"
|
|
52
|
+
New-Item -ItemType Directory -Force -Path $tools | Out-Null
|
|
53
|
+
function Get-Zip($url, $dest) {
|
|
54
|
+
try {
|
|
55
|
+
$zip = Join-Path $env:TEMP ([IO.Path]::GetFileName($url))
|
|
56
|
+
Invoke-WebRequest $url -OutFile $zip -UseBasicParsing -TimeoutSec 25
|
|
57
|
+
Expand-Archive -Path $zip -DestinationPath $dest -Force
|
|
58
|
+
Remove-Item $zip -ErrorAction SilentlyContinue
|
|
59
|
+
return $true
|
|
60
|
+
} catch { Write-Host " fetch failed: $url"; return $false }
|
|
61
|
+
}
|
|
62
|
+
if (-not (Have "yt-dlp")) {
|
|
63
|
+
try { Invoke-WebRequest "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" -OutFile (Join-Path $tools "yt-dlp.exe") -UseBasicParsing -TimeoutSec 25 }
|
|
64
|
+
catch { Write-Host " yt-dlp fetch skipped" }
|
|
65
|
+
}
|
|
66
|
+
if (-not (Have "ffmpeg")) { Get-Zip "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" $tools | Out-Null }
|
|
67
|
+
if (-not (Have "mpv")) { Get-Zip "https://sourceforge.net/projects/mpv-player-windows/files/latest/download" $tools | Out-Null }
|
|
68
|
+
if (Test-Path $tools) {
|
|
69
|
+
$env:Path = $env:Path + ";" + $tools + ";" +
|
|
70
|
+
((Get-ChildItem $tools -Recurse -Filter "ffmpeg.exe" -ErrorAction SilentlyContinue | Select-Object -First 1).DirectoryName)
|
|
71
|
+
}
|
|
72
|
+
Write-Host "xorriso and HandBrakeCLI have no simple direct-download URL - install manually if you need"
|
|
73
|
+
Write-Host "video-DVD authoring / DVD ripping (see the plan's Windows setup notes)."
|
|
74
|
+
} else {
|
|
75
|
+
Write-Host "winget unavailable (Windows 7). ISO + data + audio burn work via IMAPI2."
|
|
76
|
+
Write-Host "For rip/play/video-DVD install manually: xorriso, HandBrakeCLI 1.5.1, ffmpeg, mpv, yt-dlp."
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# --- 3. App files + venv ------------------------------------------------------
|
|
80
|
+
New-Item -ItemType Directory -Force -Path $Base, $App | Out-Null
|
|
12
81
|
Copy-Item -Recurse -Force (Join-Path $Root "src\*") $App
|
|
13
|
-
|
|
14
|
-
& (Join-Path $Venv "Scripts\python.exe") -m pip install --upgrade pip
|
|
82
|
+
& $PyExe @PyArgs -m venv $Venv
|
|
15
83
|
$Py = Join-Path $Venv "Scripts\python.exe"
|
|
84
|
+
& $Py -m pip install --upgrade pip
|
|
16
85
|
$Req = Join-Path $Root "requirements.txt"
|
|
17
|
-
if (Test-Path $Req) {
|
|
18
|
-
|
|
19
|
-
} else {
|
|
20
|
-
|
|
86
|
+
if (Test-Path $Req) { & $Py -m pip install -r $Req } else { & $Py -m pip install pyserial mutagen requests yt-dlp }
|
|
87
|
+
$Opt = Join-Path $Root "requirements-optional.txt"
|
|
88
|
+
try { if (Test-Path $Opt) { & $Py -m pip install -r $Opt } else { & $Py -m pip install musicbrainzngs tmdbsimple } }
|
|
89
|
+
catch { Write-Host "Optional metadata deps skipped (host still works)." }
|
|
90
|
+
|
|
91
|
+
# --- 4. Self-signed cert (HTTPS on :8080; :8081 works without it) --------------
|
|
92
|
+
$crt = Join-Path $Base "server.crt"; $key = Join-Path $Base "server.key"
|
|
93
|
+
if (-not (Test-Path $crt) -or -not (Test-Path $key)) {
|
|
94
|
+
try {
|
|
95
|
+
$c = New-SelfSignedCertificate -DnsName "discstation.local" -CertStoreLocation "Cert:\CurrentUser\My" -NotAfter (Get-Date).AddYears(10)
|
|
96
|
+
$pwd = ConvertTo-SecureString -String "discstation" -Force -AsPlainText
|
|
97
|
+
$pfx = Join-Path $env:TEMP "ds.pfx"
|
|
98
|
+
Export-PfxCertificate -Cert $c -FilePath $pfx -Password $pwd | Out-Null
|
|
99
|
+
& $Py -c "import ssl" 2>$null
|
|
100
|
+
# Convert PFX -> PEM via Python cryptography if present, else leave HTTPS off.
|
|
101
|
+
& $Py -m pip install cryptography 2>$null
|
|
102
|
+
& $Py -c "import sys;from cryptography.hazmat.primitives.serialization import pkcs12,Encoding,PrivateFormat,NoEncryption;d=open(sys.argv[1],'rb').read();k,c,_=pkcs12.load_key_and_certificates(d,b'discstation');open(sys.argv[2],'wb').write(c.public_bytes(Encoding.PEM));open(sys.argv[3],'wb').write(k.private_bytes(Encoding.PEM,PrivateFormat.TraditionalOpenSSL,NoEncryption()))" $pfx $crt $key
|
|
103
|
+
Remove-Item $pfx -ErrorAction SilentlyContinue
|
|
104
|
+
} catch { Write-Host "Cert generation skipped; the host will serve plain HTTP on 8081." }
|
|
21
105
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
106
|
+
|
|
107
|
+
# --- 5. Firewall -------------------------------------------------------------
|
|
108
|
+
foreach ($port in 8080, 8081) {
|
|
109
|
+
$name = "DiscStation $port"
|
|
110
|
+
try {
|
|
111
|
+
if (Get-Command New-NetFirewallRule -ErrorAction SilentlyContinue) {
|
|
112
|
+
if (-not (Get-NetFirewallRule -DisplayName $name -ErrorAction SilentlyContinue)) {
|
|
113
|
+
New-NetFirewallRule -DisplayName $name -Direction Inbound -Action Allow -Protocol TCP -LocalPort $port | Out-Null
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
netsh advfirewall firewall add rule name="$name" dir=in action=allow protocol=TCP localport=$port | Out-Null
|
|
117
|
+
}
|
|
118
|
+
} catch {}
|
|
25
119
|
}
|
|
26
120
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
121
|
+
# --- 6. Auto-start Scheduled Task -----------------------------------------------
|
|
122
|
+
$pyw = Join-Path $Venv "Scripts\pythonw.exe"
|
|
123
|
+
$target = Join-Path $App "discstation.py"
|
|
124
|
+
try {
|
|
125
|
+
if (Get-Command Register-ScheduledTask -ErrorAction SilentlyContinue) {
|
|
126
|
+
$action = New-ScheduledTaskAction -Execute $pyw -Argument "`"$target`"" -WorkingDirectory $App
|
|
127
|
+
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
|
|
128
|
+
$set = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
|
|
129
|
+
# S4U: runs headlessly for this user without needing an active interactive
|
|
130
|
+
# desktop session (Interactive logon type only fires while one exists, so
|
|
131
|
+
# e.g. Start-ScheduledTask over SSH/no console session would silently no-op).
|
|
132
|
+
# RunLevel Limited (standard, non-elevated): nothing here needs admin -
|
|
133
|
+
# IMAPI2 burning, WMI reads, and binding ports >1024 all work as a normal
|
|
134
|
+
# user, and elevation is what put "Administrator" on the flashing console
|
|
135
|
+
# windows this used to spawn.
|
|
136
|
+
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType S4U -RunLevel Limited
|
|
137
|
+
Register-ScheduledTask -TaskName "DiscStation" -Action $action -Trigger $trigger -Settings $set -Principal $principal -Force | Out-Null
|
|
138
|
+
Start-ScheduledTask -TaskName "DiscStation"
|
|
139
|
+
} else {
|
|
140
|
+
schtasks /create /tn "DiscStation" /sc onlogon /f /tr "`"$pyw`" `"$target`"" | Out-Null
|
|
141
|
+
schtasks /run /tn "DiscStation" | Out-Null
|
|
142
|
+
}
|
|
143
|
+
} catch { Write-Host "Auto-start task not created: $_" }
|
|
144
|
+
|
|
145
|
+
$ip = (Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -notmatch '^127\.|^169\.254\.' } | Select-Object -First 1).IPAddress
|
|
146
|
+
Write-Host ""
|
|
147
|
+
Write-Host "DiscStation installed at $App"
|
|
148
|
+
Write-Host " Web UI: http://localhost:8081/ (also http://$ip`:8081 on the LAN)"
|
|
149
|
+
Write-Host " Run 'discstation' any time to open it. Detection/eject/ISO+data+audio burn use IMAPI2 (no extra tools)."
|
|
150
|
+
try { node (Join-Path $Root "scripts\open.mjs") } catch {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discstation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"description": "DiscStation optical-media appliance host — one cross-OS installer",
|
|
5
5
|
"bin": {
|
|
6
6
|
"discstation-setup": "scripts/setup.mjs",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"scripts/",
|
|
22
22
|
"src/*.py",
|
|
23
23
|
"src/static/",
|
|
24
|
+
"src/win/",
|
|
24
25
|
"arduino/",
|
|
25
26
|
"systemd/",
|
|
26
27
|
"docs/",
|
package/scripts/setup.mjs
CHANGED
|
@@ -19,7 +19,7 @@ if (args.includes('-h') || args.includes('--help')) {
|
|
|
19
19
|
Runs the installer for the current OS:
|
|
20
20
|
linux install.sh full: apt deps, venv, systemd --user service, self-signed cert
|
|
21
21
|
darwin install-macos.sh full: Homebrew deps, venv, launchd agent, cert (audio-CD burn is best-effort)
|
|
22
|
-
windows install-windows.ps1
|
|
22
|
+
windows install-windows.ps1 venv + cert + firewall + auto-start task; burn via IMAPI2 (10/11 and 7)
|
|
23
23
|
|
|
24
24
|
Prereqs: Node 16+, Python 3 (python3 / py on PATH), and either bash (linux/macOS)
|
|
25
25
|
or PowerShell (Windows). Homebrew is auto-installed on macOS if missing; the
|
package/src/discstation.py
CHANGED
|
@@ -4,7 +4,10 @@ import atexit
|
|
|
4
4
|
import collections
|
|
5
5
|
import concurrent.futures
|
|
6
6
|
import errno
|
|
7
|
-
|
|
7
|
+
try:
|
|
8
|
+
import fcntl # POSIX-only; used only in drive_status()'s Linux branch
|
|
9
|
+
except ImportError:
|
|
10
|
+
fcntl = None
|
|
8
11
|
import json
|
|
9
12
|
import mimetypes
|
|
10
13
|
import os
|
|
@@ -16,7 +19,10 @@ except ImportError:
|
|
|
16
19
|
import re
|
|
17
20
|
import shutil
|
|
18
21
|
import socket
|
|
19
|
-
|
|
22
|
+
try:
|
|
23
|
+
import ssl # optional: HTTPS on :8080. The plain-HTTP :8081 listener works without it.
|
|
24
|
+
except ImportError:
|
|
25
|
+
ssl = None
|
|
20
26
|
import subprocess
|
|
21
27
|
import sys
|
|
22
28
|
import tempfile
|
|
@@ -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-
|
|
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-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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()
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 = "
|
|
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)
|