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 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 SELECT for 10 s** on the home screen
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 SELECT this long on HOME to wipe Wi-Fi creds
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
- if (uiState == UI_PLAY && (long)(millis() - vuSuppressUntil) >= 0) drawPlayVisualizer();
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;
@@ -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
- The visualizer needs a one-time manual setup, since macOS has no built-in
33
- way to tap "whatever's currently playing" the way Linux's PulseAudio does:
34
-
35
- 1. `brew install blackhole-2ch` (done automatically by `install-macos.sh`).
36
- 2. Open **Audio MIDI Setup** (in Applications/Utilities), click **+** →
37
- **Create Multi-Output Device**, and check both **BlackHole 2ch** and your
38
- real output (speakers/headphones).
39
- 3. In **System Settings → Sound**, set that Multi-Output Device as the
40
- default output. Audio stays audible (routed to your real device) and is
41
- simultaneously tapped (via BlackHole) for the visualizer's FFT.
42
-
43
- Without this setup the visualizer silently does nothing and PLAY just shows
44
- its normal text screen — nothing else is affected.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discstation",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "description": "DiscStation optical-media appliance host — one cross-OS installer",
5
5
  "bin": {
6
6
  "discstation-setup": "scripts/setup.mjs",
@@ -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 = "DVD5" if not dl_info["is_dual_layer"] else "DVD9"
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 _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
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
- mpv_command(["add", "chapter", 1])
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
- mpv_command(["add", "chapter", 1])
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
- mpv_command(["add", "chapter", -1])
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
- mpv_command(["add", "chapter", -1])
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
- if discstation_host.system_name() == "windows":
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 = [
@@ -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
- expected_min = 1_000_000_000
427
- expected_max = DVD_DL_BYTES if is_dl else DISC_DISC_BYTES
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...")