discstation 0.1.34 → 0.1.37
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 +19 -12
- package/arduino/DiscStation/DiscStation.ino +13 -18
- package/discstation.env.example +1 -1
- package/docs/PLATFORM_SUPPORT.md +13 -31
- package/install-macos.sh +3 -6
- package/install.sh +2 -1
- package/package.json +1 -1
- package/requirements-optional.txt +5 -7
- package/scripts/check.sh +61 -0
- package/src/discstation.py +254 -733
- package/src/discstation_burn.py +18 -207
- package/src/static/app.js +32 -3
- package/src/static/style.css +0 -7
- package/src/win/_imapi.ps1 +66 -0
- package/src/win/audio-toc.ps1 +1 -9
- package/src/win/burn-audio.ps1 +2 -14
- package/src/win/burn-data.ps1 +4 -47
- package/src/win/burn-image.ps1 +4 -53
package/README.md
CHANGED
|
@@ -46,8 +46,7 @@ remote can talk to the host over Wi-Fi instead of a USB cable.
|
|
|
46
46
|
|------|-------------|
|
|
47
47
|
| **Burn Video DVD** | YouTube URL or local file → ffmpeg 2-pass → DVD-Video disc |
|
|
48
48
|
| **Burn Data DVD** | Any files/folders → ISO/Joliet data disc (no quality loss) |
|
|
49
|
-
| **
|
|
50
|
-
| **Play** | Playback via mpv (DVD-Video, Audio CD, VCD, SVCD) — the OLED remote shows a live spectrum visualizer of the actual audio (Linux only for now; see `docs/PLATFORM_SUPPORT.md`) |
|
|
49
|
+
| **Play** | Playback via mpv (DVD-Video, Audio CD, VCD, SVCD) — the OLED remote shows a live spectrum visualizer of the actual audio (Linux only for now; see `docs/PLATFORM_SUPPORT.md`); while a track plays the OLED keeps the track/volume screen (or the visualizer) up — the spinning-disc screensaver only appears on the home/standby screens or after playback has been paused for 15 s |
|
|
51
50
|
| **Rip** | Audio CD → FLAC (MusicBrainz); DVD-Video → VIDEO_TS mirror or HandBrake MKV (TMDb naming) |
|
|
52
51
|
|
|
53
52
|
## Project Structure
|
|
@@ -63,6 +62,7 @@ remote can talk to the host over Wi-Fi instead of a USB cable.
|
|
|
63
62
|
├── mobile/ # Expo (React Native) companion app
|
|
64
63
|
├── arduino/DiscStation/ # ESP32 DevKit remote firmware
|
|
65
64
|
├── scripts/setup.mjs # `discstation-setup` — cross-OS installer dispatch
|
|
65
|
+
├── scripts/check.sh # static checks + smoke test (run before committing)
|
|
66
66
|
├── docs/ # Platform support notes
|
|
67
67
|
├── install.sh / install-macos.sh / install-windows.ps1
|
|
68
68
|
├── requirements.txt / requirements-optional.txt
|
|
@@ -94,7 +94,9 @@ dock/taskbar icon on macOS, Linux, and Windows.
|
|
|
94
94
|
**Updating:** `npm install -g discstation` alone now redeploys and restarts
|
|
95
95
|
the already-running service automatically (a `postinstall` hook copies the
|
|
96
96
|
updated files over and restarts it) — no need to re-run `discstation-setup`
|
|
97
|
-
after every update.
|
|
97
|
+
after every update. Note this deploys whatever version is *published*, so installing an
|
|
98
|
+
older published release over a newer git checkout rolls the running service
|
|
99
|
+
back to it until you publish (or re-copy `src/`) again. `discstation-setup` itself is still what performs the
|
|
98
100
|
first install (system packages, venv, cert, service registration).
|
|
99
101
|
|
|
100
102
|
Support by OS:
|
|
@@ -141,19 +143,24 @@ self-signed cert. On startup the host prints the LAN URL to open
|
|
|
141
143
|
- Upload files from any device on the LAN for data DVD burning
|
|
142
144
|
- Submit YouTube URLs for video DVD burning
|
|
143
145
|
- Dark theme, mobile-responsive, PWA (installable on phone)
|
|
144
|
-
- **On-screen remote** —
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
- **On-screen remote** — opens automatically with no ESP32 attached, since
|
|
147
|
+
it's the only control surface in that case (toggle it via the "REMOTE"
|
|
148
|
+
tag next to the wordmark any time). Mode buttons only show what the disc
|
|
149
|
+
in the drive can actually do — a blank CD offers BURN DATA/BURN AUDIO, a
|
|
150
|
+
blank DVD offers BURN/BURN DATA, an already-recorded disc offers PLAY/RIP
|
|
151
|
+
— and picking one starts it directly, no separate confirm step. Also has
|
|
152
|
+
EJECT/CLOSE toggle, a live disc-status readout, and full playback
|
|
153
|
+
transport. Greys out and auto-collapses the instant a physical remote is
|
|
154
|
+
detected, so the two never fight for control.
|
|
149
155
|
|
|
150
156
|
## Mobile App
|
|
151
157
|
|
|
152
158
|
The `mobile/` Expo app (enter the host's LAN address in its settings screen)
|
|
153
|
-
has the same on-screen remote as the web UI
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
has the same on-screen remote as the web UI, rendered inline in the main
|
|
160
|
+
screen rather than a popup — same "REMOTE" tag toggle, opens automatically
|
|
161
|
+
with no ESP32 attached, mode buttons filtered to what the disc actually
|
|
162
|
+
supports, and picking one starts it directly. Same auto-collapse behavior
|
|
163
|
+
when a physical ESP32 shows up, same underlying `/remote/button` protocol.
|
|
157
164
|
|
|
158
165
|
## Disc Support
|
|
159
166
|
|
|
@@ -33,7 +33,6 @@
|
|
|
33
33
|
#define LONG_PRESS_MS 1000
|
|
34
34
|
#define ENC_CLICK_GUARD_MS 200 // ignore new SW presses this soon after a rotation tick (rotation vibration can bounce SW low)
|
|
35
35
|
#define DONE_RESET_MS 30000
|
|
36
|
-
#define STANDBY_BLANK_MS 60000
|
|
37
36
|
#define IDLE_BLANK_MS 15000 // no input this long on HOME/STANDBY -> spinning-disc screensaver
|
|
38
37
|
// (this power bank's no-load auto-shutoff trips at ~30s idle; keep this
|
|
39
38
|
// well under that so the screensaver's current draw beats it there)
|
|
@@ -272,6 +271,7 @@ uint8_t vuLevel[VU_BARS];
|
|
|
272
271
|
bool visualizerActive = false;
|
|
273
272
|
unsigned long lastVuAt = 0;
|
|
274
273
|
unsigned long vuSuppressUntil = 0; // bars withheld (text screen shown instead) until millis() reaches this
|
|
274
|
+
bool playPaused = false; // last PLAY_STATUS was PAUSED - the only PLAY state the idle screensaver may take over
|
|
275
275
|
bool vuSeenReal = false; // has this PLAY session ever gotten a VU: frame with a nonzero bar -
|
|
276
276
|
// a session-level fact (macOS: never; Linux: within the first frame or
|
|
277
277
|
// two), decided once and left alone. Deliberately separate from
|
|
@@ -827,6 +827,9 @@ void parseMessage(String msg) {
|
|
|
827
827
|
lastVuAt = 0; // fresh session - unknown yet whether the host even sends VU: at all
|
|
828
828
|
visualizerActive = false;
|
|
829
829
|
vuSeenReal = false; // unknown yet whether this session ever gets a real (nonzero) frame
|
|
830
|
+
playPaused = false;
|
|
831
|
+
displayBlank = false; // a fresh session must never inherit a running screensaver
|
|
832
|
+
lastInputTime = millis();
|
|
830
833
|
Out.print("POT:"); // push the last-used volume so playback starts at it
|
|
831
834
|
Out.println(playVolume);
|
|
832
835
|
drawPlay();
|
|
@@ -838,6 +841,9 @@ void parseMessage(String msg) {
|
|
|
838
841
|
} else if (msg.startsWith("PLAY_STATUS:")) {
|
|
839
842
|
line1 = msg.substring(12);
|
|
840
843
|
if (line1.length() > 20) line1 = line1.substring(0, 20);
|
|
844
|
+
playPaused = (line1 == "PAUSED");
|
|
845
|
+
if (playPaused) lastInputTime = millis(); // idle timer starts from the pause
|
|
846
|
+
else displayBlank = false; // resumed (even from the web remote) - drop the screensaver
|
|
841
847
|
if (line1 == "PLAYING" || line1 == "PAUSED" || (audioPlayMode && line1.startsWith("TRACK "))) {
|
|
842
848
|
playStatusTemp = false;
|
|
843
849
|
} else {
|
|
@@ -1353,24 +1359,13 @@ void loop() {
|
|
|
1353
1359
|
if (uiState == UI_PLAY && !displayBlank) drawPlay();
|
|
1354
1360
|
}
|
|
1355
1361
|
|
|
1356
|
-
// --- Idle disc screensaver (HOME + STANDBY
|
|
1357
|
-
//
|
|
1358
|
-
//
|
|
1359
|
-
//
|
|
1360
|
-
//
|
|
1361
|
-
// In PLAY specifically, if this session has never gotten a single REAL
|
|
1362
|
-
// (nonzero) VU: frame (!vuSeenReal - the host/platform doesn't support the
|
|
1363
|
-
// visualizer, e.g. macOS today), don't make the user wait out the full
|
|
1364
|
-
// generic idle timer for an animation - drop into the spinning-disc
|
|
1365
|
-
// screensaver as soon as the text-hold window (vuSuppressUntil) expires.
|
|
1366
|
-
// vuSeenReal (not lastVuAt, which now updates on every frame including
|
|
1367
|
-
// all-zero ones) is deliberately a session-level, decided-once fact, so
|
|
1368
|
-
// this can't re-trigger mid-playback and flicker against the bars.
|
|
1369
|
-
bool playSkippingToScreensaver = (uiState == UI_PLAY) && !vuSeenReal &&
|
|
1370
|
-
(long)(millis() - vuSuppressUntil) >= 0;
|
|
1362
|
+
// --- Idle disc screensaver (HOME + STANDBY, or PLAY while paused) ---
|
|
1363
|
+
// Never while a track is actually playing - the track/volume status (or
|
|
1364
|
+
// visualizer) owns the screen then. A static screen is what the power bank
|
|
1365
|
+
// needs to see stay drawing, so the saver covers every idle/paused state.
|
|
1371
1366
|
if (displayOk && !displayBlank &&
|
|
1372
|
-
(uiState == UI_HOME || uiState == UI_STANDBY || uiState == UI_PLAY) &&
|
|
1373
|
-
(
|
|
1367
|
+
(uiState == UI_HOME || uiState == UI_STANDBY || (uiState == UI_PLAY && playPaused)) &&
|
|
1368
|
+
(long)(millis() - lastInputTime) >= IDLE_BLANK_MS) {
|
|
1374
1369
|
displayBlank = true;
|
|
1375
1370
|
saverStep = 0;
|
|
1376
1371
|
lastSaverFrame = 0;
|
package/discstation.env.example
CHANGED
|
@@ -20,7 +20,7 @@ DISC_DL_OUTPUT_LIMIT_BYTES=8000000000
|
|
|
20
20
|
# ~/.local/share/discstation/tmdb.key
|
|
21
21
|
# DISCSTATION_TMDB_API_KEY=
|
|
22
22
|
# YTDLP_COOKIES_FROM_BROWSER=firefox
|
|
23
|
-
# YTDLP_COOKIES
|
|
23
|
+
# YTDLP_COOKIES=~/.config/discstation/youtube-cookies.txt
|
|
24
24
|
# YTDLP_USER_AGENT=Mozilla/5.0 ...
|
|
25
25
|
# YTDLP_PO_TOKEN=web_embedded.gvs+TOKEN
|
|
26
26
|
# YTDLP_EXTRACTOR_ARGS=youtube:player_client=web_embedded
|
package/docs/PLATFORM_SUPPORT.md
CHANGED
|
@@ -27,37 +27,19 @@ Set `DISC_DEVICE` if automatic drive detection fails. The one known limitation
|
|
|
27
27
|
is **audio-CD *burning*** — `cdrdao` is the only option and often cannot claim
|
|
28
28
|
the drive on recent macOS; DiscStation reports this clearly instead of hanging.
|
|
29
29
|
|
|
30
|
-
### ESP32 remote: OLED spectrum visualizer —
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
app
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
`ffmpeg` with no change.
|
|
44
|
-
|
|
45
|
-
The legitimate modern API for this (CoreAudio's Process Tap,
|
|
46
|
-
`AudioHardwareCreateProcessTap`, macOS 14.2+) requires a properly
|
|
47
|
-
code-signed app bundle requesting a specific entitlement, with its own
|
|
48
|
-
dedicated system consent dialog — a bare ad-hoc-signed Homebrew CLI binary
|
|
49
|
-
like `ffmpeg` structurally cannot satisfy that, regardless of which Privacy
|
|
50
|
-
& Security toggles are flipped. Making the visualizer work on macOS would
|
|
51
|
-
mean building a small signed helper app around that API — a real, separate
|
|
52
|
-
project, not started.
|
|
53
|
-
|
|
54
|
-
**Everything else works normally** — audio-CD/DVD/video playback, ripping,
|
|
55
|
-
burning, and the web UI are all unaffected. PLAY just always shows the
|
|
56
|
-
normal text status screen on macOS instead of ever switching to bars.
|
|
57
|
-
|
|
58
|
-
If BlackHole is already installed from an earlier attempt at this, it's
|
|
59
|
-
harmless to leave in place — it just won't do anything useful for
|
|
60
|
-
DiscStation until/unless the above gets built.
|
|
30
|
+
### ESP32 remote: OLED spectrum visualizer — Linux only
|
|
31
|
+
|
|
32
|
+
The bars need a way to tap the audio being played. On Linux that's PulseAudio's
|
|
33
|
+
monitor source (`parec`). macOS has no equivalent a plain CLI tool can use: a
|
|
34
|
+
capture through a loopback driver (BlackHole + `ffmpeg -f avfoundation`) was
|
|
35
|
+
built and verified end to end on real hardware, and returns pure silence
|
|
36
|
+
(every sample `0`, no errors) even while the native Sound input meter shows
|
|
37
|
+
signal — macOS's system-audio-capture privacy protection mutes untrusted
|
|
38
|
+
capturers, and no Privacy & Security toggle changes that. The supported route
|
|
39
|
+
is CoreAudio's Process Tap (`AudioHardwareCreateProcessTap`, macOS 14.2+),
|
|
40
|
+
which needs a code-signed app bundle with its own consent dialog — a separate
|
|
41
|
+
project, not started, so the capture code was removed. PLAY just shows the
|
|
42
|
+
normal text status screen on macOS; everything else is unaffected.
|
|
61
43
|
|
|
62
44
|
### Troubleshooting playback ("mpv not found" / play fails to start)
|
|
63
45
|
|
package/install-macos.sh
CHANGED
|
@@ -28,7 +28,7 @@ if ! command -v brew >/dev/null 2>&1; then
|
|
|
28
28
|
fi
|
|
29
29
|
|
|
30
30
|
brew install python ffmpeg cdrdao dvdauthor node yt-dlp xorriso mpv libdiscid handbrake \
|
|
31
|
-
libdvdcss dvdbackup libcdio-paranoia
|
|
31
|
+
libdvdcss dvdbackup libcdio-paranoia
|
|
32
32
|
mkdir -p "$APP_DIR" "$VENV_DIR" "$CONFIG_DIR"
|
|
33
33
|
cp -R "$ROOT_DIR/src/." "$APP_DIR/"
|
|
34
34
|
python3 -m venv "$VENV_DIR"
|
|
@@ -40,12 +40,9 @@ else
|
|
|
40
40
|
fi
|
|
41
41
|
# Optional metadata deps — best effort. python-libdiscid (a C extension) is
|
|
42
42
|
# Linux-only in requirements-optional.txt; on macOS install the pure-Python
|
|
43
|
-
# bits and let brew's libdiscid cover disc IDs via the CLI tools.
|
|
44
|
-
# for the OLED spectrum visualizer (needs the blackhole-2ch brew package
|
|
45
|
-
# above too, plus a one-time Multi-Output Device setup - see
|
|
46
|
-
# docs/PLATFORM_SUPPORT.md).
|
|
43
|
+
# bits and let brew's libdiscid cover disc IDs via the CLI tools.
|
|
47
44
|
if [[ -f "$ROOT_DIR/requirements-optional.txt" ]]; then
|
|
48
|
-
"$VENV_DIR/bin/python" -m pip install musicbrainzngs tmdbsimple
|
|
45
|
+
"$VENV_DIR/bin/python" -m pip install musicbrainzngs tmdbsimple \
|
|
49
46
|
|| printf 'Optional metadata deps skipped (host still works).\n'
|
|
50
47
|
fi
|
|
51
48
|
|
package/install.sh
CHANGED
|
@@ -14,7 +14,8 @@ fi
|
|
|
14
14
|
if command -v apt-get >/dev/null 2>&1; then
|
|
15
15
|
sudo apt-get update
|
|
16
16
|
sudo apt-get install -y \
|
|
17
|
-
cdrdao dvdauthor ffmpeg genisoimage growisofs handbrake-cli
|
|
17
|
+
cdrdao dvdauthor dvdbackup eject ffmpeg genisoimage growisofs handbrake-cli \
|
|
18
|
+
lsdvd mpv sg3-utils \
|
|
18
19
|
nodejs openssl python3 python3-pip python3-serial python3-mutagen \
|
|
19
20
|
python3-requests python3-pyudev python3-libdiscid python3-musicbrainzngs \
|
|
20
21
|
python3-numpy pulseaudio-utils libdiscid0 python3-venv wodim
|
package/package.json
CHANGED
|
@@ -13,10 +13,8 @@ python-libdiscid>=2.0 ; sys_platform == "linux"
|
|
|
13
13
|
musicbrainzngs>=0.7.1
|
|
14
14
|
tmdbsimple>=2.9
|
|
15
15
|
|
|
16
|
-
# OLED spectrum visualizer during playback
|
|
17
|
-
# (pulseaudio-utils apt package) - prefer python3-numpy via apt
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
# there since install-macos.sh's venv isn't externally-managed.
|
|
22
|
-
numpy>=1.24 ; sys_platform == "linux" or sys_platform == "darwin"
|
|
16
|
+
# OLED spectrum visualizer during playback (Linux only). Needs `parec`/`pactl`
|
|
17
|
+
# (pulseaudio-utils apt package) - prefer python3-numpy via apt, pip installs
|
|
18
|
+
# are blocked on a system Python. macOS can't capture system audio from a CLI
|
|
19
|
+
# tool - see docs/PLATFORM_SUPPORT.md.
|
|
20
|
+
numpy>=1.24 ; sys_platform == "linux"
|
package/scripts/check.sh
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Static sanity checks for every language in the repo. Run before committing.
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
cd "$(dirname "$0")/.."
|
|
5
|
+
|
|
6
|
+
python3 -m py_compile src/*.py
|
|
7
|
+
for f in install.sh install-macos.sh scripts/*.sh; do bash -n "$f"; done
|
|
8
|
+
for f in scripts/*.mjs scripts/lib/*.mjs src/static/app.js; do node --check "$f"; done
|
|
9
|
+
(cd mobile && ./node_modules/.bin/tsc --noEmit --noUnusedLocals)
|
|
10
|
+
|
|
11
|
+
# Import the host and exercise the pure status/menu logic with canned input.
|
|
12
|
+
(cd src && python3 - <<'EOF'
|
|
13
|
+
import discstation as d
|
|
14
|
+
d._record_web_status("PLAY_MODE:AUDIO_CD")
|
|
15
|
+
assert d._status_snapshot()["playing"] is True
|
|
16
|
+
d._record_web_status("PROGRESS:42%")
|
|
17
|
+
assert d._status_snapshot()["progress"] == 42
|
|
18
|
+
d._record_web_status("DONE:ok")
|
|
19
|
+
assert d._status_snapshot()["playing"] is False
|
|
20
|
+
|
|
21
|
+
# Burn-flow helpers: history recording for every exit path, START/SPEED/MODE/CANCEL.
|
|
22
|
+
import json, pathlib, tempfile
|
|
23
|
+
d.HISTORY_FILE = pathlib.Path(tempfile.mkdtemp()) / "h.jsonl"
|
|
24
|
+
with d._burn_history({"title": "t"}):
|
|
25
|
+
pass
|
|
26
|
+
try:
|
|
27
|
+
with d._burn_history({"title": "t"}):
|
|
28
|
+
raise RuntimeError("boom")
|
|
29
|
+
except RuntimeError:
|
|
30
|
+
pass
|
|
31
|
+
with d._burn_history({"title": "t"}, swallow_cancel=True) as h:
|
|
32
|
+
raise d.CancelError("x")
|
|
33
|
+
assert h.cancelled
|
|
34
|
+
rows = [json.loads(l) for l in open(d.HISTORY_FILE)]
|
|
35
|
+
assert [r["success"] for r in rows] == [True, False, False] and rows[1]["error"] == "boom"
|
|
36
|
+
ser = d.VirtualSerial()
|
|
37
|
+
for line in ("SPEED:6x", "START"):
|
|
38
|
+
ser.push_line(line)
|
|
39
|
+
assert d._wait_for_start(ser, "audio burn") == (None, "6x")
|
|
40
|
+
for line in ("MODE:BEST", "START"):
|
|
41
|
+
ser.push_line(line)
|
|
42
|
+
assert d._wait_for_start(ser, mode="AUTO") == ("BEST", None)
|
|
43
|
+
ser.push_line("CANCEL")
|
|
44
|
+
assert d._wait_for_start(ser, "x") is None
|
|
45
|
+
|
|
46
|
+
# Subprocess-output iterators (shared reader thread) + cancel handling.
|
|
47
|
+
import subprocess, sys
|
|
48
|
+
def spawn(code):
|
|
49
|
+
return subprocess.Popen([sys.executable, "-c", code], stdout=subprocess.PIPE, text=True)
|
|
50
|
+
assert list(d.discstation_burn.iter_proc_or_cancel(spawn("print('a');print('b')"), ser)) == ["a", "b"]
|
|
51
|
+
assert [e for e in d.iter_process_events(spawn("print('x')"), idle_seconds=0.2, ser=ser) if e] == ["x"]
|
|
52
|
+
ser.push_line("CANCEL")
|
|
53
|
+
try:
|
|
54
|
+
list(d.iter_process_events(spawn("import time;time.sleep(30)"), idle_seconds=0.2, ser=ser))
|
|
55
|
+
raise SystemExit("cancel not raised")
|
|
56
|
+
except d.CancelError:
|
|
57
|
+
pass
|
|
58
|
+
print("smoke ok")
|
|
59
|
+
EOF
|
|
60
|
+
)
|
|
61
|
+
echo "all checks passed"
|