discstation 0.1.31 → 0.1.33
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 +3 -3
- package/arduino/DiscStation/DiscStation.ino +46 -4
- package/docs/PLATFORM_SUPPORT.md +43 -15
- package/package.json +1 -1
- package/src/discstation.py +74 -7
- package/src/discstation_burn.py +80 -6
package/README.md
CHANGED
|
@@ -25,8 +25,8 @@ remote can talk to the host over Wi-Fi instead of a USB cable.
|
|
|
25
25
|
the remote starts an open `DiscStation-XXXX` access point; join it from a
|
|
26
26
|
phone, the captive portal (or `http://192.168.4.1`) lists nearby networks
|
|
27
27
|
— pick yours, enter the password, done. It reconnects on every boot after
|
|
28
|
-
that. Change networks later: **hold
|
|
29
|
-
to wipe the credentials and reopen the portal.
|
|
28
|
+
that. Change networks later: **hold HOME/BACK for 10 s** on the home
|
|
29
|
+
screen to wipe the credentials and reopen the portal.
|
|
30
30
|
Power users can instead `cp arduino/<board>/secrets.h.example secrets.h`
|
|
31
31
|
and set `WIFI_SSID` / `WIFI_PASS` at build time (git-ignored).
|
|
32
32
|
- **Host side:** nothing to configure. With no USB cable present the host
|
|
@@ -47,7 +47,7 @@ remote can talk to the host over Wi-Fi instead of a USB cable.
|
|
|
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
49
|
| **Burn MPG** | Re-burn a previously converted movie.mpg |
|
|
50
|
-
| **Play** | Playback via mpv (DVD-Video, Audio CD, VCD, SVCD) |
|
|
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`) |
|
|
51
51
|
| **Rip** | Audio CD → FLAC (MusicBrainz); DVD-Video → VIDEO_TS mirror or HandBrake MKV (TMDb naming) |
|
|
52
52
|
|
|
53
53
|
## Project Structure
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
#define VU_ENTRY_DELAY_MS 10000 // stay on the text status screen this long after entering PLAY
|
|
48
48
|
#define VU_RESUME_DELAY_MS 7000 // ...and this long after any input while already in PLAY
|
|
49
49
|
|
|
50
|
-
#define WIFI_RESET_HOLD_MS 10000 // hold
|
|
50
|
+
#define WIFI_RESET_HOLD_MS 10000 // hold HOME/BACK this long on HOME to wipe Wi-Fi creds
|
|
51
51
|
#define WIFI_CONNECT_TIMEOUT_MS 18000 // give a stored-creds join this long before falling to the portal
|
|
52
52
|
#define WIFI_RETRY_MS 15000 // if a live link drops, force a re-join after this
|
|
53
53
|
|
|
@@ -272,6 +272,13 @@ uint8_t vuLevel[VU_BARS];
|
|
|
272
272
|
bool visualizerActive = false;
|
|
273
273
|
unsigned long lastVuAt = 0;
|
|
274
274
|
unsigned long vuSuppressUntil = 0; // bars withheld (text screen shown instead) until millis() reaches this
|
|
275
|
+
bool vuSeenReal = false; // has this PLAY session ever gotten a VU: frame with a nonzero bar -
|
|
276
|
+
// a session-level fact (macOS: never; Linux: within the first frame or
|
|
277
|
+
// two), decided once and left alone. Deliberately separate from
|
|
278
|
+
// visualizerActive/lastVuAt, which track "is the stream still alive"
|
|
279
|
+
// per frame (including legitimate all-zero frames during quiet music) -
|
|
280
|
+
// conflating the two into one gate previously caused the bars/screensaver
|
|
281
|
+
// to flicker on Linux every time a frame happened to read all zero.
|
|
275
282
|
int displayRotation = 0;
|
|
276
283
|
|
|
277
284
|
// Indeterminate progress animation
|
|
@@ -642,7 +649,7 @@ bool wakeDisplay() {
|
|
|
642
649
|
case UI_IP: drawIP(); break;
|
|
643
650
|
case UI_BURN_READY: drawBurnReady(); break;
|
|
644
651
|
case UI_PLAY:
|
|
645
|
-
if (visualizerActive && (long)(millis() - lastVuAt) < VU_TIMEOUT_MS &&
|
|
652
|
+
if (vuSeenReal && visualizerActive && (long)(millis() - lastVuAt) < VU_TIMEOUT_MS &&
|
|
646
653
|
(long)(millis() - vuSuppressUntil) >= 0) drawPlayVisualizer();
|
|
647
654
|
else drawPlay();
|
|
648
655
|
break;
|
|
@@ -724,18 +731,39 @@ void parseMessage(String msg) {
|
|
|
724
731
|
lastMsgTime = millis();
|
|
725
732
|
String rest = msg.substring(3);
|
|
726
733
|
for (int i = 0; i < VU_BARS; i++) vuLevel[i] = 0;
|
|
734
|
+
bool anyNonzero = false;
|
|
727
735
|
for (int i = 0; i < VU_BARS && rest.length() > 0; i++) {
|
|
728
736
|
int comma = rest.indexOf(',');
|
|
729
737
|
String tok = (comma < 0) ? rest : rest.substring(0, comma);
|
|
730
738
|
vuLevel[i] = (uint8_t)constrain(tok.toInt(), 0, 63);
|
|
739
|
+
if (vuLevel[i] > 0) anyNonzero = true;
|
|
731
740
|
if (comma < 0) break;
|
|
732
741
|
rest = rest.substring(comma + 1);
|
|
733
742
|
}
|
|
743
|
+
if (anyNonzero) vuSeenReal = true;
|
|
744
|
+
// lastVuAt/visualizerActive/lastInputTime update on EVERY frame, zero or
|
|
745
|
+
// not - they track "is the stream still alive", and real music routinely
|
|
746
|
+
// produces a frame where every bar reads 0 (quiet moment right after a
|
|
747
|
+
// loud transient keeps the adaptive reference elevated for a beat). Only
|
|
748
|
+
// gating those three on anyNonzero previously made ordinary quiet frames
|
|
749
|
+
// look like "host stopped sending" to the unrelated VU_TIMEOUT_MS check
|
|
750
|
+
// below, flickering bars/screensaver during normal playback.
|
|
734
751
|
visualizerActive = true;
|
|
735
752
|
lastVuAt = millis();
|
|
736
753
|
lastInputTime = millis(); // the visualizer's own continuous redraw already beats the
|
|
737
754
|
// power-bank shutoff - no need for the screensaver too
|
|
738
|
-
|
|
755
|
+
// Drawing bars (and reclaiming the screen from the disc-spinner screensaver,
|
|
756
|
+
// if it's up) is the one thing that DOES stay gated on vuSeenReal - a host
|
|
757
|
+
// that's technically capturing but getting only silence (e.g. macOS's
|
|
758
|
+
// blocked system-audio-capture - see docs/PLATFORM_SUPPORT.md) sends real
|
|
759
|
+
// VU: frames that are all zero forever. Without this gate, every one of
|
|
760
|
+
// those frames would re-draw a blank bars screen and set displayBlank =
|
|
761
|
+
// false, fighting the disc-spinner screensaver for the display every
|
|
762
|
+
// ~66ms instead of leaving it alone once chosen.
|
|
763
|
+
if (vuSeenReal && uiState == UI_PLAY && (long)(millis() - vuSuppressUntil) >= 0) {
|
|
764
|
+
displayBlank = false;
|
|
765
|
+
drawPlayVisualizer();
|
|
766
|
+
}
|
|
739
767
|
return;
|
|
740
768
|
}
|
|
741
769
|
|
|
@@ -796,6 +824,9 @@ void parseMessage(String msg) {
|
|
|
796
824
|
line2 = "Playing disc";
|
|
797
825
|
playSeekMode = false; // always start in volume mode
|
|
798
826
|
vuSuppressUntil = millis() + VU_ENTRY_DELAY_MS; // text screen first, bars after a beat
|
|
827
|
+
lastVuAt = 0; // fresh session - unknown yet whether the host even sends VU: at all
|
|
828
|
+
visualizerActive = false;
|
|
829
|
+
vuSeenReal = false; // unknown yet whether this session ever gets a real (nonzero) frame
|
|
799
830
|
Out.print("POT:"); // push the last-used volume so playback starts at it
|
|
800
831
|
Out.println(playVolume);
|
|
801
832
|
drawPlay();
|
|
@@ -1326,9 +1357,20 @@ void loop() {
|
|
|
1326
1357
|
// PLAY is included because a static "PLAYING" screen is just as low-current
|
|
1327
1358
|
// as HOME/STANDBY were - the power bank doesn't care what's on screen, only
|
|
1328
1359
|
// that the draw stays static this long.
|
|
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;
|
|
1329
1371
|
if (displayOk && !displayBlank &&
|
|
1330
1372
|
(uiState == UI_HOME || uiState == UI_STANDBY || uiState == UI_PLAY) &&
|
|
1331
|
-
(long)(millis() - lastInputTime) >= IDLE_BLANK_MS) {
|
|
1373
|
+
((long)(millis() - lastInputTime) >= IDLE_BLANK_MS || playSkippingToScreensaver)) {
|
|
1332
1374
|
displayBlank = true;
|
|
1333
1375
|
saverStep = 0;
|
|
1334
1376
|
lastSaverFrame = 0;
|
package/docs/PLATFORM_SUPPORT.md
CHANGED
|
@@ -27,21 +27,49 @@ 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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
30
|
+
### ESP32 remote: OLED spectrum visualizer — not currently working on macOS
|
|
31
|
+
|
|
32
|
+
**Confirmed broken, not just a setup step.** The visualizer's macOS capture
|
|
33
|
+
path (`ffmpeg -f avfoundation` reading the BlackHole loopback device) was
|
|
34
|
+
built and the BlackHole/Multi-Output-Device routing was set up and verified
|
|
35
|
+
correct on real hardware — but `ffmpeg`'s capture consistently returns
|
|
36
|
+
silence (valid-looking output, zero errors, every sample exactly `0`) even
|
|
37
|
+
with the native macOS Sound input level meter confirmed showing real,
|
|
38
|
+
moving signal on BlackHole at the same moment. That combination — real
|
|
39
|
+
signal present at the OS/driver level, silently zeroed once it reaches an
|
|
40
|
+
app's capture buffer — is macOS's system-audio-capture privacy protection
|
|
41
|
+
muting an untrusted capturer, not a permission checkbox that was missed:
|
|
42
|
+
Microphone and Screen & System Audio Recording were both granted to
|
|
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.
|
|
61
|
+
|
|
62
|
+
### Troubleshooting playback ("mpv not found" / play fails to start)
|
|
63
|
+
|
|
64
|
+
- **`install-macos.sh` installs `mpv` via Homebrew** — if playback fails to
|
|
65
|
+
start, confirm it actually landed: `mpv --version` in Terminal. If that
|
|
66
|
+
fails, either the installer never ran to completion or was interrupted;
|
|
67
|
+
re-run `install-macos.sh` (or `discstation-setup`) rather than installing
|
|
68
|
+
`mpv` in isolation, since other steps may be incomplete too.
|
|
69
|
+
- **Apple Music auto-opening on disc insert can hold the drive**, so
|
|
70
|
+
DiscStation's own `mpv` can fail or hang trying to claim it right after.
|
|
71
|
+
System Settings → **CDs & DVDs** (only shown with an optical drive
|
|
72
|
+
connected) → set "When you insert a music CD" to **Ignore**.
|
|
45
73
|
|
|
46
74
|
## Windows
|
|
47
75
|
|
package/package.json
CHANGED
package/src/discstation.py
CHANGED
|
@@ -128,6 +128,13 @@ class TcpSerial:
|
|
|
128
128
|
if p.isdigit():
|
|
129
129
|
port = int(p)
|
|
130
130
|
self._sock = socket.create_connection((host, port), timeout=connect_timeout)
|
|
131
|
+
# Nagle's algorithm is on by default and holds back small writes hoping
|
|
132
|
+
# to coalesce them - the visualizer sends a ~30-50 byte VU: frame ~15x/sec,
|
|
133
|
+
# exactly the pattern that gets delayed (commonly ~200ms with delayed-ACK
|
|
134
|
+
# on the other end), which read as visualizer lag over Wi-Fi even though
|
|
135
|
+
# the visualizer itself was untouched. Never an issue on the USB path,
|
|
136
|
+
# which isn't TCP at all.
|
|
137
|
+
self._sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
131
138
|
self._sock.settimeout(0.05)
|
|
132
139
|
self._buf = b""
|
|
133
140
|
self._lock = threading.Lock()
|
|
@@ -3056,7 +3063,7 @@ def burn_data_flow(ser):
|
|
|
3056
3063
|
for d in files_to_burn:
|
|
3057
3064
|
if d.is_dir():
|
|
3058
3065
|
total_bytes += sum(f.stat().st_size for f in d.rglob("*") if f.is_file())
|
|
3059
|
-
label = "
|
|
3066
|
+
label = "CD-R" if dl_info.get("is_cd") else ("DVD9" if dl_info["is_dual_layer"] else "DVD5")
|
|
3060
3067
|
usable = discstation_burn.disc_output_limit_bytes(disc_bytes)
|
|
3061
3068
|
if usable and total_bytes > usable:
|
|
3062
3069
|
size_gb = total_bytes / 1e9
|
|
@@ -3448,7 +3455,29 @@ def start_vu_visualizer(ser):
|
|
|
3448
3455
|
return stop_event, pause_event
|
|
3449
3456
|
|
|
3450
3457
|
|
|
3451
|
-
def
|
|
3458
|
+
def _audio_cd_track_seek(delta, track_starts):
|
|
3459
|
+
"""Seek to the start of the next/previous track using the CD TOC's own
|
|
3460
|
+
per-track offsets (already in seconds from track 1's start - see
|
|
3461
|
+
audio_track_metadata) instead of mpv's own chapter list. Works whether
|
|
3462
|
+
or not mpv has track chapters for this stream: macOS's piped
|
|
3463
|
+
cd-paranoia audio has none, since mpv just sees one continuous stream,
|
|
3464
|
+
not the disc itself - time-pos still works fine either way."""
|
|
3465
|
+
if not track_starts:
|
|
3466
|
+
mpv_command(["add", "chapter", delta])
|
|
3467
|
+
return
|
|
3468
|
+
track = mpv_query(["get_property", "chapter"])
|
|
3469
|
+
if not isinstance(track, (int, float)):
|
|
3470
|
+
position = mpv_query(["get_property", "time-pos"])
|
|
3471
|
+
track = max((i for i, start in enumerate(track_starts) if start <= position), default=0) \
|
|
3472
|
+
if isinstance(position, (int, float)) else 0
|
|
3473
|
+
target = max(0, min(len(track_starts) - 1, int(track) + delta))
|
|
3474
|
+
mpv_command(["seek", track_starts[target], "absolute"])
|
|
3475
|
+
|
|
3476
|
+
|
|
3477
|
+
def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None, stdin_proc=None):
|
|
3478
|
+
"""stdin_proc: an already-started subprocess whose stdout feeds mpv's
|
|
3479
|
+
stdin (e.g. macOS's cd-paranoia-into-mpv audio CD pipe) - mpv reads `-`
|
|
3480
|
+
as its input in cmd in that case. Stopped alongside mpv on cleanup."""
|
|
3452
3481
|
try:
|
|
3453
3482
|
os.unlink(MPV_SOCKET)
|
|
3454
3483
|
except OSError:
|
|
@@ -3472,7 +3501,14 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
|
|
|
3472
3501
|
# writes block, wedging the whole play loop. We drive mpv over the IPC
|
|
3473
3502
|
# socket, so none of that output is wanted.
|
|
3474
3503
|
proc = subprocess.Popen(run_as_desktop_user(cmd), env=env,
|
|
3504
|
+
stdin=(stdin_proc.stdout if stdin_proc else None),
|
|
3475
3505
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
3506
|
+
if stdin_proc:
|
|
3507
|
+
# Close our own copy of the read end now that mpv's had it duped into
|
|
3508
|
+
# its own stdin - otherwise we're also holding it open, so cd-paranoia
|
|
3509
|
+
# never gets SIGPIPE (and just hangs writing into a full pipe buffer)
|
|
3510
|
+
# if mpv exits first.
|
|
3511
|
+
stdin_proc.stdout.close()
|
|
3476
3512
|
vu_stop = None
|
|
3477
3513
|
|
|
3478
3514
|
try:
|
|
@@ -3540,7 +3576,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
|
|
|
3540
3576
|
|
|
3541
3577
|
elif line == "FF:BIG":
|
|
3542
3578
|
if kind == "audio_cd":
|
|
3543
|
-
|
|
3579
|
+
_audio_cd_track_seek(1, track_starts)
|
|
3544
3580
|
send(ser, "PLAY_STATUS:Next track")
|
|
3545
3581
|
else:
|
|
3546
3582
|
mpv_command(["seek", 120])
|
|
@@ -3554,7 +3590,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
|
|
|
3554
3590
|
except ValueError:
|
|
3555
3591
|
continue
|
|
3556
3592
|
if kind == "audio_cd":
|
|
3557
|
-
|
|
3593
|
+
_audio_cd_track_seek(1, track_starts)
|
|
3558
3594
|
send(ser, "PLAY_STATUS:Next track")
|
|
3559
3595
|
else:
|
|
3560
3596
|
mpv_command(["seek", seek_sec])
|
|
@@ -3564,7 +3600,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
|
|
|
3564
3600
|
|
|
3565
3601
|
elif line == "REW:BIG":
|
|
3566
3602
|
if kind == "audio_cd":
|
|
3567
|
-
|
|
3603
|
+
_audio_cd_track_seek(-1, track_starts)
|
|
3568
3604
|
send(ser, "PLAY_STATUS:Prev track")
|
|
3569
3605
|
else:
|
|
3570
3606
|
mpv_command(["seek", -120])
|
|
@@ -3578,7 +3614,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
|
|
|
3578
3614
|
except ValueError:
|
|
3579
3615
|
continue
|
|
3580
3616
|
if kind == "audio_cd":
|
|
3581
|
-
|
|
3617
|
+
_audio_cd_track_seek(-1, track_starts)
|
|
3582
3618
|
send(ser, "PLAY_STATUS:Prev track")
|
|
3583
3619
|
else:
|
|
3584
3620
|
mpv_command(["seek", -seek_sec])
|
|
@@ -3604,6 +3640,8 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
|
|
|
3604
3640
|
vu_stop.set()
|
|
3605
3641
|
if proc.poll() is None:
|
|
3606
3642
|
discstation_burn.stop_process(proc)
|
|
3643
|
+
if stdin_proc and stdin_proc.poll() is None:
|
|
3644
|
+
discstation_burn.stop_process(stdin_proc)
|
|
3607
3645
|
try:
|
|
3608
3646
|
os.unlink(MPV_SOCKET)
|
|
3609
3647
|
except OSError:
|
|
@@ -3774,11 +3812,40 @@ def play_flow(ser):
|
|
|
3774
3812
|
|
|
3775
3813
|
elif kind == "audio_cd":
|
|
3776
3814
|
_, track_titles, track_starts = audio_track_metadata(device)
|
|
3777
|
-
|
|
3815
|
+
system = discstation_host.system_name()
|
|
3816
|
+
if system == "windows":
|
|
3778
3817
|
# mpv on Windows has no libcdio - cdda:// is unavailable there
|
|
3779
3818
|
# ("disabled at compile-time"). Windows Media Player's own COM
|
|
3780
3819
|
# control plays it fine via Windows' native CD-audio support.
|
|
3781
3820
|
_play_audio_cd_windows(ser, device, track_titles)
|
|
3821
|
+
elif system == "darwin":
|
|
3822
|
+
# Homebrew's mpv formula doesn't depend on libcdio either (no
|
|
3823
|
+
# build option to add it) - confirmed live: `mpv cdda://` says
|
|
3824
|
+
# "protocol ... disabled at compile-time" and --cdrom-device
|
|
3825
|
+
# isn't even a recognized option. Same shape of gap as Windows,
|
|
3826
|
+
# different fix: stream the disc via cd-paranoia (already used
|
|
3827
|
+
# for ripping) into mpv's stdin instead of mpv opening the
|
|
3828
|
+
# drive itself. mpv still does all actual playback + IPC
|
|
3829
|
+
# control (pause/volume/stop), just fed a pipe instead of the
|
|
3830
|
+
# disc directly.
|
|
3831
|
+
paranoia = None
|
|
3832
|
+
for name in ("cd-paranoia", "cdparanoia"):
|
|
3833
|
+
try:
|
|
3834
|
+
paranoia = discstation_burn.tool(name)
|
|
3835
|
+
break
|
|
3836
|
+
except FileNotFoundError:
|
|
3837
|
+
continue
|
|
3838
|
+
if not paranoia:
|
|
3839
|
+
raise RuntimeError("cd-paranoia not installed (brew install libcdio-paranoia)")
|
|
3840
|
+
rip_proc = subprocess.Popen(
|
|
3841
|
+
[paranoia, "-d", rip_device(device), "1-", "-"],
|
|
3842
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
3843
|
+
audio_device = discstation_host.audio_output_device()
|
|
3844
|
+
cmd = [mpv, "--input-ipc-server=" + MPV_SOCKET, "--force-window=no", "--idle=no", "-"]
|
|
3845
|
+
if audio_device:
|
|
3846
|
+
cmd.insert(1, "--audio-device=" + audio_device)
|
|
3847
|
+
print(f"Audio CD output: {audio_device}")
|
|
3848
|
+
_run_mpv(ser, cmd, "Playing audio CD", kind, track_titles, track_starts, stdin_proc=rip_proc)
|
|
3782
3849
|
else:
|
|
3783
3850
|
audio_device = discstation_host.audio_output_device()
|
|
3784
3851
|
cmd = [
|
package/src/discstation_burn.py
CHANGED
|
@@ -242,6 +242,7 @@ DVD_DEVICE = os.environ.get("DISC_DEVICE") or os.environ.get("DVD_DEVICE")
|
|
|
242
242
|
DISC_SPEED = os.environ.get("DISC_SPEED")
|
|
243
243
|
DISC_DISC_BYTES = 4_700_000_000
|
|
244
244
|
DVD_DL_BYTES = 8_500_000_000
|
|
245
|
+
CD_R_BYTES = 700_000_000
|
|
245
246
|
DISC_TARGET_BYTES = int(os.environ.get("DISC_TARGET_BYTES", "4300000000"))
|
|
246
247
|
DVD_MUX_SAFETY = float(os.environ.get("DVD_MUX_SAFETY", "0.92"))
|
|
247
248
|
AUDIO_BITRATE_K = int(os.environ.get("DVD_AUDIO_KBPS", "192"))
|
|
@@ -423,8 +424,17 @@ def disc_capacity_bytes(device):
|
|
|
423
424
|
props.get("ID_CDROM_MEDIA_DVD_R_DL") == "1" or
|
|
424
425
|
props.get("ID_CDROM_MEDIA_DVD_R_DL_SEQ") == "1"
|
|
425
426
|
)
|
|
426
|
-
|
|
427
|
-
|
|
427
|
+
# ID_CDROM_MEDIA_CD_R/CD_RW is the media actually loaded, not the drive's
|
|
428
|
+
# read/write capability (ID_CDROM_CD_R, set for any combo drive
|
|
429
|
+
# regardless of what's inserted) - checking the wrong one here is why a
|
|
430
|
+
# blank CD-R used to get treated as an unreadable DVD (capacity "unknown,
|
|
431
|
+
# assuming DVD5") instead of a ~700MB CD.
|
|
432
|
+
is_cd = (
|
|
433
|
+
props.get("ID_CDROM_MEDIA_CD_R") == "1" or
|
|
434
|
+
props.get("ID_CDROM_MEDIA_CD_RW") == "1"
|
|
435
|
+
)
|
|
436
|
+
expected_min = 100_000_000 if is_cd else 1_000_000_000
|
|
437
|
+
expected_max = CD_R_BYTES if is_cd else (DVD_DL_BYTES if is_dl else DISC_DISC_BYTES)
|
|
428
438
|
|
|
429
439
|
mediainfo_timeout = _env_int("DISCSTATION_PROBE_TIMEOUT_MEDIAINFO", 12)
|
|
430
440
|
best = None
|
|
@@ -465,14 +475,13 @@ def disc_capacity_bytes(device):
|
|
|
465
475
|
if best is not None:
|
|
466
476
|
return best
|
|
467
477
|
|
|
468
|
-
if props.get("ID_CDROM_MEDIA_STATE") == "blank":
|
|
469
|
-
if is_dl:
|
|
470
|
-
return DVD_DL_BYTES
|
|
471
478
|
if is_dl:
|
|
472
479
|
return DVD_DL_BYTES
|
|
473
480
|
if props.get("ID_CDROM_MEDIA_DVD_PLUS_R") == "1" or \
|
|
474
481
|
props.get("ID_CDROM_MEDIA_DVD_R") == "1":
|
|
475
482
|
return DISC_DISC_BYTES
|
|
483
|
+
if is_cd:
|
|
484
|
+
return CD_R_BYTES
|
|
476
485
|
|
|
477
486
|
# Last resort: a raw block size (works for finalized/pressed discs where
|
|
478
487
|
# dvd+rw-mediainfo reports no free blocks; 0/absent for audio CDs).
|
|
@@ -498,6 +507,10 @@ def detect_disc_type(device):
|
|
|
498
507
|
props.get("ID_CDROM_MEDIA_DVD_PLUS_R") == "1" or
|
|
499
508
|
props.get("ID_CDROM_MEDIA_DVD_R") == "1"
|
|
500
509
|
)
|
|
510
|
+
is_cd = (
|
|
511
|
+
props.get("ID_CDROM_MEDIA_CD_R") == "1" or
|
|
512
|
+
props.get("ID_CDROM_MEDIA_CD_RW") == "1"
|
|
513
|
+
)
|
|
501
514
|
is_blank = props.get("ID_CDROM_MEDIA_STATE") == "blank"
|
|
502
515
|
media_type = props.get("ID_CDROM_MEDIA", "")
|
|
503
516
|
|
|
@@ -505,11 +518,12 @@ def detect_disc_type(device):
|
|
|
505
518
|
|
|
506
519
|
capacity = disc_capacity_bytes(device)
|
|
507
520
|
if capacity is None:
|
|
508
|
-
capacity = DVD_DL_BYTES if is_dl else (DISC_DISC_BYTES if is_sl else None)
|
|
521
|
+
capacity = DVD_DL_BYTES if is_dl else (CD_R_BYTES if is_cd else (DISC_DISC_BYTES if is_sl else None))
|
|
509
522
|
|
|
510
523
|
return {
|
|
511
524
|
"is_dual_layer": is_dl,
|
|
512
525
|
"is_single_layer": is_sl,
|
|
526
|
+
"is_cd": is_cd,
|
|
513
527
|
"is_blank": is_blank,
|
|
514
528
|
"status": status,
|
|
515
529
|
"media_type": media_type,
|
|
@@ -1414,6 +1428,13 @@ def burn_data(ser, source_paths, disc_label, speed=None, is_dual_layer=False):
|
|
|
1414
1428
|
send(ser, "PROGRESS:Starting")
|
|
1415
1429
|
send(ser, f"INFO:Label {disc_label[:13]}")
|
|
1416
1430
|
device = disc_device()
|
|
1431
|
+
# growisofs (dvd+rw-tools) only writes DVD/BD media - it refuses CD-R/RW
|
|
1432
|
+
# outright ("media is not recognized as recordable DVD"), so a blank CD-R
|
|
1433
|
+
# needs the CD-specific tool (genisoimage build + wodim write) instead.
|
|
1434
|
+
props = _udevadm_props(device)
|
|
1435
|
+
if props.get("ID_CDROM_MEDIA_CD_R") == "1" or props.get("ID_CDROM_MEDIA_CD_RW") == "1":
|
|
1436
|
+
_burn_data_cd(ser, source_paths, disc_label, speed, device)
|
|
1437
|
+
return
|
|
1417
1438
|
growisofs_cmd = [tool('growisofs'), '-dvd-compat', '-Z', device]
|
|
1418
1439
|
speed = speed or DISC_SPEED
|
|
1419
1440
|
if speed and speed.lower() != "auto":
|
|
@@ -1427,6 +1448,59 @@ def burn_data(ser, source_paths, disc_label, speed=None, is_dual_layer=False):
|
|
|
1427
1448
|
growisofs_cmd += [str(p) for p in source_paths]
|
|
1428
1449
|
_run_growisofs(ser, growisofs_cmd, source_paths[0].parent / "growisofs.log", device)
|
|
1429
1450
|
|
|
1451
|
+
def _burn_data_cd(ser, source_paths, disc_label, speed, device):
|
|
1452
|
+
"""Build an ISO9660/Joliet image with genisoimage and burn it with wodim -
|
|
1453
|
+
the CD-R/RW equivalent of the growisofs path above (growisofs can't
|
|
1454
|
+
write CD media at all)."""
|
|
1455
|
+
WORK.mkdir(parents=True, exist_ok=True)
|
|
1456
|
+
iso_path = WORK / f"data_{time.strftime('%Y%m%d_%H%M%S')}.iso"
|
|
1457
|
+
mkisofs_cmd = [tool('genisoimage'), '-R', '-J', '-joliet-long', '-V', disc_label,
|
|
1458
|
+
'-o', str(iso_path)]
|
|
1459
|
+
mkisofs_cmd += [str(p) for p in source_paths]
|
|
1460
|
+
r = subprocess.run(mkisofs_cmd, capture_output=True, text=True)
|
|
1461
|
+
if r.returncode != 0:
|
|
1462
|
+
raise RuntimeError(f"ISO build failed: {r.stderr.strip()[-200:] or 'genisoimage error'}")
|
|
1463
|
+
|
|
1464
|
+
try:
|
|
1465
|
+
discstation_host.unmount_device(device)
|
|
1466
|
+
wodim_cmd = [tool('wodim'), '-v', f'dev={device}']
|
|
1467
|
+
speed = speed or DISC_SPEED
|
|
1468
|
+
if speed and speed.lower() != "auto":
|
|
1469
|
+
wodim_cmd += [f"speed={re.sub(r'[^0-9]', '', speed) or '8'}"]
|
|
1470
|
+
wodim_cmd.append(str(iso_path))
|
|
1471
|
+
|
|
1472
|
+
proc = subprocess.Popen(wodim_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
1473
|
+
out_lines = []
|
|
1474
|
+
last_prog = 0
|
|
1475
|
+
try:
|
|
1476
|
+
for line in iter_proc_or_cancel(proc, ser):
|
|
1477
|
+
out_lines.append(line)
|
|
1478
|
+
m = re.search(r'(\d+)%', line)
|
|
1479
|
+
if m:
|
|
1480
|
+
now = time.time()
|
|
1481
|
+
if now - last_prog >= 0.2:
|
|
1482
|
+
send(ser, f"PROGRESS:{m.group(1)}%")
|
|
1483
|
+
last_prog = now
|
|
1484
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1485
|
+
stop_process(proc)
|
|
1486
|
+
raise
|
|
1487
|
+
rc = proc.wait()
|
|
1488
|
+
if rc != 0:
|
|
1489
|
+
for line in out_lines[-10:]:
|
|
1490
|
+
print(f"wodim: {line}")
|
|
1491
|
+
if rc == -15:
|
|
1492
|
+
safe_send(ser, "CANCELLED:Burn cancelled")
|
|
1493
|
+
raise CancelError("Cancelled")
|
|
1494
|
+
safe_send(ser, "INFO:Burn failed, check log")
|
|
1495
|
+
raise RuntimeError("Disc burn failed")
|
|
1496
|
+
finally:
|
|
1497
|
+
iso_path.unlink(missing_ok=True)
|
|
1498
|
+
|
|
1499
|
+
try:
|
|
1500
|
+
discstation_host.eject_device(device)
|
|
1501
|
+
except Exception as e:
|
|
1502
|
+
print(f"Disc eject skipped: {e}")
|
|
1503
|
+
|
|
1430
1504
|
def burn_audio_cd(ser, audio_files, disc_label, speed=None):
|
|
1431
1505
|
"""Convert audio files to CD-DA WAV and burn via cdrdao with CD-TEXT."""
|
|
1432
1506
|
send(ser, "STATUS:Reading tags...")
|