discstation 0.1.8 → 0.1.10

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/install-macos.sh CHANGED
@@ -27,7 +27,8 @@ if ! command -v brew >/dev/null 2>&1; then
27
27
  exit 1
28
28
  fi
29
29
 
30
- brew install python ffmpeg cdrdao dvdauthor node yt-dlp xorriso mpv libdiscid handbrake
30
+ brew install python ffmpeg cdrdao dvdauthor node yt-dlp xorriso mpv libdiscid handbrake \
31
+ libdvdcss dvdbackup libcdio-paranoia
31
32
  mkdir -p "$APP_DIR" "$VENV_DIR" "$CONFIG_DIR"
32
33
  cp -R "$ROOT_DIR/src/." "$APP_DIR/"
33
34
  python3 -m venv "$VENV_DIR"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discstation",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "DiscStation optical-media appliance host — one cross-OS installer",
5
5
  "bin": {
6
6
  "discstation-setup": "scripts/setup.mjs"
@@ -1461,8 +1461,6 @@ def disc_title(device):
1461
1461
  if t:
1462
1462
  return t
1463
1463
  elif kind == "audio_cd":
1464
- if discstation_host.system_name() == "darwin":
1465
- return "Apple Music"
1466
1464
  try:
1467
1465
  toc = audio_cd_toc(device)
1468
1466
  if toc and toc.get("track_count"):
@@ -1533,8 +1531,6 @@ def menu_items_for_disc(device):
1533
1531
  had = discstation_burn.WORK.rglob("movie.mpg")
1534
1532
  if any(True for _ in had):
1535
1533
  items.append("BURN MPG")
1536
- elif kind == "audio_cd" and discstation_host.system_name() == "darwin":
1537
- items = ["APPLE MUSIC"]
1538
1534
  elif kind in ("dvd_video", "audio_cd", "vcd", "svcd", "video_data", "data_disc", "data_cd"):
1539
1535
  items = ["PLAY", "RIP"]
1540
1536
  else:
@@ -1612,47 +1608,38 @@ def disc_video_files(mount_dir):
1612
1608
 
1613
1609
  def audio_cd_toc(device):
1614
1610
  if discstation_host.system_name() == "darwin":
1615
- toc_path = discstation_burn.WORK / "mac_audio_read.toc"
1611
+ paranoia = None
1612
+ for name in ("cd-paranoia", "cdparanoia"):
1613
+ try:
1614
+ paranoia = discstation_burn.tool(name)
1615
+ break
1616
+ except FileNotFoundError:
1617
+ continue
1618
+ if not paranoia:
1619
+ raise RuntimeError("cd-paranoia not installed (brew install libcdio-paranoia)")
1616
1620
  result = subprocess.run(
1617
- [discstation_burn.tool("cdrdao"), "read-toc", "--fast-toc",
1618
- "--device", discstation_host.cdrdao_device(device), str(toc_path)],
1621
+ [paranoia, "-Q", "-d", rip_device(device)],
1619
1622
  capture_output=True, text=True, timeout=30,
1620
1623
  )
1621
1624
  text = ensure_text(result.stdout) + ensure_text(result.stderr)
1622
- toc_text = toc_path.read_text(errors="replace") if toc_path.exists() else ""
1623
- toc_path.unlink(missing_ok=True)
1624
- if result.returncode != 0:
1625
- detail = next((line.strip() for line in reversed(text.splitlines()) if line.strip()), "cdrdao failed")
1625
+ # " 1. 18288 [04:03.63] 0 [00:00.00] no no 2"
1626
+ begins, lengths = [], []
1627
+ for line in text.splitlines():
1628
+ match = re.match(r"\s*(\d+)\.\s+(\d+)\s+\[[\d:.]+\]\s+(\d+)\s+\[", line)
1629
+ if match:
1630
+ lengths.append(int(match.group(2)))
1631
+ begins.append(int(match.group(3)))
1632
+ if not begins:
1633
+ detail = next((l.strip() for l in reversed(text.splitlines()) if l.strip()), "cd-paranoia -Q failed")
1626
1634
  raise RuntimeError(f"Could not read macOS CD TOC: {detail[:100]}")
1627
-
1628
- durations = []
1629
- for block in re.split(r"(?m)^\s*//\s*Track\s+\d+\s*$", toc_text)[1:]:
1630
- file_lines = re.findall(r"(?m)^\s*FILE\b.*$", block)
1631
- file_times = re.findall(r"\d+:\d+:\d+", file_lines[-1]) if file_lines else []
1632
- pregap_times = re.findall(r"(?m)^\s*SILENCE\s+(\d+:\d+:\d+)", block)
1633
- if file_times:
1634
- duration = _msf_frames(file_times[-1])
1635
- duration += sum(_msf_frames(value) for value in pregap_times)
1636
- durations.append(duration)
1637
- if not durations:
1638
- for line in text.splitlines():
1639
- match = re.match(r"\s*(\d+)\s+AUDIO.*?\((\d+)\).*?\((\d+)\)", line)
1640
- if match:
1641
- durations.append(int(match.group(3)) - int(match.group(2)))
1642
- if not durations:
1643
- raise RuntimeError("Could not read macOS CD TOC")
1644
- tracks = []
1645
- position = 150
1646
- for duration in durations:
1647
- tracks.append(position)
1648
- position += duration
1649
- leadout = position
1635
+ tracks = [begin + 150 for begin in begins] # LBA -> MB frame offset
1636
+ leadout = begins[-1] + lengths[-1] + 150
1650
1637
  return {
1651
1638
  "first_track": 1,
1652
- "track_count": len(durations),
1639
+ "track_count": len(tracks),
1653
1640
  "leadout": leadout,
1654
1641
  "tracks": tracks,
1655
- "toc": "+".join(map(str, [1, len(durations), leadout, *tracks])),
1642
+ "toc": "+".join(map(str, [1, len(tracks), leadout, *tracks])),
1656
1643
  }
1657
1644
  if _libdiscid is not None:
1658
1645
  try:
@@ -1698,11 +1685,6 @@ def audio_cd_toc(device):
1698
1685
  }
1699
1686
 
1700
1687
 
1701
- def _msf_frames(value):
1702
- minutes, seconds, frames = (int(part) for part in value.split(":"))
1703
- return (minutes * 60 + seconds) * 75 + frames
1704
-
1705
-
1706
1688
  def audio_cd_chapters(device):
1707
1689
  if discstation_host.system_name() == "darwin":
1708
1690
  toc = audio_cd_toc(device)
@@ -2319,7 +2301,19 @@ def iter_process_events(proc, idle_seconds=1.0, ser=None):
2319
2301
  reader.join(timeout=1)
2320
2302
 
2321
2303
 
2304
+ def rip_device(device):
2305
+ """The node a ripper should read. macOS libdvdread/HandBrake/cd-paranoia
2306
+ want the raw char node (/dev/rdiskN); Linux and others use `device` as-is."""
2307
+ if device and discstation_host.system_name() == "darwin":
2308
+ name = Path(device).name
2309
+ if name.startswith("disk"):
2310
+ return f"/dev/r{name}"
2311
+ return device
2312
+
2313
+
2322
2314
  def device_size_bytes(device):
2315
+ if discstation_host.system_name() != "linux":
2316
+ return discstation_host.media_capacity_bytes(device) or 0
2323
2317
  result = run_probe(["blockdev", "--getsize64", device], timeout=3)
2324
2318
  try:
2325
2319
  return int(ensure_text(result.stdout).strip() or "0")
@@ -2939,15 +2933,16 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
2939
2933
  pass
2940
2934
 
2941
2935
  env = os.environ.copy()
2942
- if "DISPLAY" not in env:
2943
- env["DISPLAY"] = ":0"
2944
- try:
2945
- uid = os.getuid()
2946
- home_xauth = Path.home() / ".Xauthority"
2947
- env.setdefault("XAUTHORITY", str(home_xauth) if home_xauth.exists() else f"/run/user/{uid}/.Xauthority")
2948
- env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{uid}")
2949
- except Exception:
2950
- pass
2936
+ if discstation_host.system_name() == "linux":
2937
+ if "DISPLAY" not in env:
2938
+ env["DISPLAY"] = ":0"
2939
+ try:
2940
+ uid = os.getuid()
2941
+ home_xauth = Path.home() / ".Xauthority"
2942
+ env.setdefault("XAUTHORITY", str(home_xauth) if home_xauth.exists() else f"/run/user/{uid}/.Xauthority")
2943
+ env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{uid}")
2944
+ except Exception:
2945
+ pass
2951
2946
 
2952
2947
  proc = subprocess.Popen(run_as_desktop_user(cmd), env=env)
2953
2948
 
@@ -3083,30 +3078,41 @@ def play_flow(ser):
3083
3078
  kind = disc_kind(device)
3084
3079
  print(f"Disc type: {kind}")
3085
3080
 
3086
- if kind == "audio_cd" and discstation_host.system_name() == "darwin":
3087
- raise RuntimeError("Apple Music handles audio CD playback on macOS")
3088
3081
  if not shutil.which("mpv"):
3089
3082
  raise RuntimeError("mpv not found")
3090
3083
 
3091
3084
  if kind == "dvd_video":
3092
3085
  if discstation_host.system_name() == "darwin":
3093
- with mounted_disc(device) as mount_dir:
3094
- video_ts = mount_dir / "VIDEO_TS"
3095
- files = sorted(
3096
- path for path in video_ts.glob("VTS_01_*.VOB")
3097
- if re.search(r"_\d+\.VOB$", path.name, re.IGNORECASE)
3098
- and not path.name.upper().endswith("_0.VOB")
3099
- )
3100
- if not files:
3101
- raise RuntimeError("No playable DVD title found")
3086
+ try:
3102
3087
  cmd = [
3103
3088
  "mpv",
3104
3089
  "--input-ipc-server=" + MPV_SOCKET,
3105
3090
  "--force-window=yes",
3106
3091
  "--idle=no",
3107
- *[str(path) for path in files],
3092
+ "--dvd-device=" + rip_device(device),
3093
+ "dvdnav://",
3108
3094
  ]
3109
3095
  _run_mpv(ser, cmd, "Playing DVD", kind)
3096
+ except RuntimeError:
3097
+ # libdvdnav couldn't open the disc — fall back to playing the
3098
+ # main title's VOBs off the mounted volume (no menus).
3099
+ with mounted_disc(device) as mount_dir:
3100
+ video_ts = mount_dir / "VIDEO_TS"
3101
+ files = sorted(
3102
+ path for path in video_ts.glob("VTS_01_*.VOB")
3103
+ if re.search(r"_\d+\.VOB$", path.name, re.IGNORECASE)
3104
+ and not path.name.upper().endswith("_0.VOB")
3105
+ )
3106
+ if not files:
3107
+ raise RuntimeError("No playable DVD title found")
3108
+ cmd = [
3109
+ "mpv",
3110
+ "--input-ipc-server=" + MPV_SOCKET,
3111
+ "--force-window=yes",
3112
+ "--idle=no",
3113
+ *[str(path) for path in files],
3114
+ ]
3115
+ _run_mpv(ser, cmd, "Playing DVD", kind)
3110
3116
  else:
3111
3117
  cmd = [
3112
3118
  "mpv",
@@ -3125,7 +3131,7 @@ def play_flow(ser):
3125
3131
  "--input-ipc-server=" + MPV_SOCKET,
3126
3132
  "--force-window=no",
3127
3133
  "--idle=no",
3128
- "--cdrom-device=" + device,
3134
+ "--cdrom-device=" + rip_device(device),
3129
3135
  "--cdda-cdtext=yes",
3130
3136
  "cdda://",
3131
3137
  ]
@@ -3294,8 +3300,6 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
3294
3300
  device = discstation_burn.disc_device()
3295
3301
  kind = disc_kind(device)
3296
3302
 
3297
- if kind == "audio_cd" and discstation_host.system_name() == "darwin":
3298
- raise RuntimeError("Apple Music handles audio CD ripping on macOS")
3299
3303
  if kind == "audio_cd":
3300
3304
  rip_audio_cd(ser, device, artist_hint, album_hint)
3301
3305
  return
@@ -3304,13 +3308,14 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
3304
3308
  rip_video_disc(ser, device, kind)
3305
3309
  return
3306
3310
 
3307
- if kind == "dvd_video" and discstation_host.system_name() == "darwin":
3308
- rip_dvd_video_macos(ser, device)
3309
- return
3310
-
3311
3311
  if kind != "dvd_video":
3312
3312
  raise RuntimeError(f"Unsupported disc: {kind}")
3313
3313
 
3314
+ # libdvdread / HandBrake / dvdbackup want the raw node on macOS and the
3315
+ # auto-mounted UDF/ISO volume released first (no-ops on Linux).
3316
+ device = rip_device(device)
3317
+ discstation_host.unmount_device(device)
3318
+
3314
3319
  out_dir = RIP_ROOT / time.strftime("rip_%Y%m%d_%H%M%S")
3315
3320
  out_dir.mkdir(parents=True, exist_ok=True)
3316
3321
 
@@ -3404,45 +3409,6 @@ def remux_or_copy_video(src, dest):
3404
3409
  shutil.copy2(src, dest.with_suffix(src.suffix.lower()))
3405
3410
 
3406
3411
 
3407
- def rip_dvd_video_macos(ser, device):
3408
- out_dir = RIP_ROOT / f"dvd_video_{time.strftime('%Y%m%d_%H%M%S')}"
3409
- out_dir.mkdir(parents=True, exist_ok=True)
3410
- send(ser, "STATUS:Ripping DVD")
3411
- send(ser, "INFO:Copying VIDEO_TS")
3412
- send(ser, "PROGRESS:0%")
3413
-
3414
- with mounted_disc(device) as mount_dir:
3415
- source_dir = mount_dir / "VIDEO_TS"
3416
- if not source_dir.is_dir():
3417
- raise RuntimeError("VIDEO_TS directory not found")
3418
- files = sorted(path for path in source_dir.rglob("*") if path.is_file())
3419
- total_bytes = sum(path.stat().st_size for path in files)
3420
- copied_bytes = 0
3421
- for source in files:
3422
- relative = source.relative_to(source_dir)
3423
- destination = out_dir / "VIDEO_TS" / relative
3424
- destination.parent.mkdir(parents=True, exist_ok=True)
3425
- span = (source.stat().st_size / total_bytes * 100) if total_bytes else 0
3426
- discstation_burn.copy_with_keepalive(
3427
- ser,
3428
- source,
3429
- destination,
3430
- base_pct=(copied_bytes / total_bytes * 100) if total_bytes else 0,
3431
- pct_span=span,
3432
- )
3433
- copied_bytes += source.stat().st_size
3434
-
3435
- (out_dir / "disc_info.json").write_text(
3436
- json.dumps({"kind": "dvd_video", "files": [str(path.relative_to(out_dir)) for path in (out_dir / "VIDEO_TS").rglob("*") if path.is_file()]}, indent=2),
3437
- )
3438
- safe_send(ser, "PROGRESS:100%")
3439
- safe_send(ser, "DONE:Rip complete!")
3440
- print(f"DVD rip complete: {out_dir}")
3441
- out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
3442
- chown_to_sudo_user(out_dir)
3443
- time.sleep(3)
3444
-
3445
-
3446
3412
  def rip_video_disc(ser, device, kind):
3447
3413
  out_dir = RIP_ROOT / f"{kind}_{time.strftime('%Y%m%d_%H%M%S')}"
3448
3414
  out_dir.mkdir(parents=True, exist_ok=True)
@@ -3477,10 +3443,17 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
3477
3443
  raise RuntimeError("No audio CD tracks found")
3478
3444
  wav_dir = out_dir / ".wav"
3479
3445
  wav_dir.mkdir(parents=True, exist_ok=True)
3480
- command = [
3481
- discstation_burn.tool("cdda2wav"), "-D",
3482
- discstation_host.cdrdao_device(device), "-B", "-O", "wav", "-x",
3483
- ]
3446
+ paranoia = None
3447
+ for name in ("cd-paranoia", "cdparanoia"):
3448
+ try:
3449
+ paranoia = discstation_burn.tool(name)
3450
+ break
3451
+ except FileNotFoundError:
3452
+ continue
3453
+ if not paranoia:
3454
+ raise RuntimeError("cd-paranoia not installed (brew install libcdio-paranoia)")
3455
+ # -B batch mode writes track01.cdda.wav, track02.cdda.wav, ... in cwd.
3456
+ command = [paranoia, "-B", "-d", rip_device(device), "1-"]
3484
3457
  send(ser, "STATUS:RIPPING AUDIO CD")
3485
3458
  send(ser, "PROGRESS:0%")
3486
3459
  proc = subprocess.Popen(
@@ -3502,7 +3475,7 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
3502
3475
  raise
3503
3476
  proc.wait()
3504
3477
  if proc.returncode != 0:
3505
- detail = next((line.strip() for line in reversed(output) if line.strip()), "cdda2wav failed")
3478
+ detail = next((line.strip() for line in reversed(output) if line.strip()), "cd-paranoia failed")
3506
3479
  raise RuntimeError(f"Audio CD rip failed: {detail[:80]}")
3507
3480
 
3508
3481
  wav_files = sorted(wav_dir.glob("*.wav"))
@@ -3882,9 +3855,6 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
3882
3855
  _last_burn_result = "Burn complete"
3883
3856
  elif mode == "PLAY":
3884
3857
  play_flow(ser)
3885
- elif mode == "APPLE MUSIC":
3886
- safe_send(ser, "STATUS:Apple Music handles this audio CD")
3887
- time.sleep(2)
3888
3858
  elif mode == "RIP":
3889
3859
  rip_flow(ser, artist_hint, album_hint)
3890
3860
  _last_burn_result = "Rip complete"
@@ -3924,10 +3894,16 @@ def check_pidfile():
3924
3894
  old_pid = int(f.read().strip())
3925
3895
  try:
3926
3896
  os.kill(old_pid, 0)
3927
- with open(f"/proc/{old_pid}/cmdline") as f:
3928
- if "discstation" in f.read():
3929
- print(f"Already running (PID {old_pid}), exiting")
3930
- sys.exit(0)
3897
+ if sys.platform == "linux":
3898
+ with open(f"/proc/{old_pid}/cmdline") as f:
3899
+ alive = "discstation" in f.read()
3900
+ else:
3901
+ ps = subprocess.run(["ps", "-p", str(old_pid), "-o", "command="],
3902
+ capture_output=True, text=True)
3903
+ alive = "discstation" in ps.stdout
3904
+ if alive:
3905
+ print(f"Already running (PID {old_pid}), exiting")
3906
+ sys.exit(0)
3931
3907
  except (OSError, IOError):
3932
3908
  pass
3933
3909
  except (ValueError, OSError):
@@ -121,6 +121,12 @@ def reset_drive(device=None):
121
121
  """Power-cycle the USB optical enclosure by toggling its sysfs 'authorized'
122
122
  flag (or via DISCSTATION_USB_RESET_CMD). Best-effort; returns True on a
123
123
  completed toggle/hook, False otherwise."""
124
+ if discstation_host.system_name() == "darwin":
125
+ # ponytail: no USB re-enumeration on macOS; an eject/reload is the only
126
+ # soft reset available and it drops whatever disc is loaded.
127
+ for cmd in (["/usr/bin/drutil", "eject"], ["/usr/bin/drutil", "tray", "close"]):
128
+ subprocess.run(cmd, capture_output=True, timeout=15, check=False)
129
+ return True
124
130
  if discstation_host.system_name() != "linux":
125
131
  return False
126
132
  if not device:
@@ -1251,6 +1257,38 @@ def _run_growisofs(ser, growisofs_cmd, log_path, device=None):
1251
1257
  print(f"Disc eject skipped: {e}")
1252
1258
 
1253
1259
 
1260
+ def _run_hdiutil_burn(ser, image_path, device=None):
1261
+ """Burn a pre-built ISO on macOS via `hdiutil burn -puppetstrings`, streaming
1262
+ its PERCENT: lines to the ESP32."""
1263
+ send(ser, "STATUS:Burning image...")
1264
+ send(ser, "PROGRESS:0%")
1265
+ cmd = discstation_host.iso_burn_command(device or disc_device(), image_path)
1266
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
1267
+ out_lines = []
1268
+ last_pct = -1
1269
+ try:
1270
+ for line in iter_proc_or_cancel(proc, ser):
1271
+ out_lines.append(line)
1272
+ m = re.search(r"PERCENT:([\d.]+)", line)
1273
+ if m:
1274
+ pct = int(float(m.group(1)))
1275
+ if 0 <= pct <= 100 and pct != last_pct:
1276
+ last_pct = pct
1277
+ send(ser, f"PROGRESS:{min(pct, 99)}%")
1278
+ except (KeyboardInterrupt, SystemExit):
1279
+ stop_process(proc)
1280
+ raise
1281
+ rc = proc.wait()
1282
+ if rc != 0:
1283
+ detail = next((l for l in reversed(out_lines) if l.strip()), "hdiutil burn failed")
1284
+ raise RuntimeError(f"Disc burn failed: {detail[:120]}")
1285
+ safe_send(ser, "PROGRESS:100%")
1286
+ try:
1287
+ discstation_host.eject_device(device or disc_device())
1288
+ except Exception as e:
1289
+ print(f"Disc eject skipped: {e}")
1290
+
1291
+
1254
1292
  def burn(ser, dvd_dir, disc_label, speed=None, is_dual_layer=False):
1255
1293
  if discstation_host.system_name() != "linux":
1256
1294
  WORK.mkdir(parents=True, exist_ok=True)
@@ -1403,8 +1441,15 @@ def burn_audio_cd(ser, audio_files, disc_label, speed=None):
1403
1441
  # The cooked generic-mmc writer does NOT lay down the CD-TEXT lead-in on most
1404
1442
  # ATAPI drives; the raw writer does. Override with DISCSTATION_CDRDAO_DRIVER
1405
1443
  # (set it empty to let cdrdao auto-pick).
1444
+ try:
1445
+ cdrdao_write_dev = discstation_host.cdrdao_device(disc_device())
1446
+ except RuntimeError:
1447
+ if discstation_host.system_name() == "darwin":
1448
+ raise RuntimeError("Audio CD burning is not supported on this Mac "
1449
+ "(cdrdao cannot access the optical drive)")
1450
+ raise
1406
1451
  cdrdao_cmd = [tool('cdrdao'), 'write', '--buffers', '64',
1407
- '--device', discstation_host.cdrdao_device(disc_device())]
1452
+ '--device', cdrdao_write_dev]
1408
1453
  driver = os.environ.get("DISCSTATION_CDRDAO_DRIVER", "generic-mmc-raw")
1409
1454
  if driver:
1410
1455
  cdrdao_cmd += ['--driver', driver]
@@ -1454,6 +1499,9 @@ def burn_audio_cd(ser, audio_files, disc_label, speed=None):
1454
1499
 
1455
1500
  def burn_iso(ser, iso_path, speed=None, is_dual_layer=False):
1456
1501
  """Burn a pre-built ISO directly to disc — no filesystem building."""
1502
+ if discstation_host.system_name() == "darwin":
1503
+ _run_hdiutil_burn(ser, iso_path)
1504
+ return
1457
1505
  if discstation_host.system_name() != "linux":
1458
1506
  send(ser, "STATUS:Burning image...")
1459
1507
  _run_growisofs(ser, discstation_host.iso_burn_command(disc_device(), iso_path), iso_path.parent / "discstation-burn.log")
@@ -1493,8 +1541,9 @@ def remux_and_author(ser, mpg, disc_label, disc_capacity, dvd_aspect=None):
1493
1541
  send(ser, f"INFO:Burning {mpg.parent.name}")
1494
1542
 
1495
1543
  send(ser, "STATUS:Remuxing to fix timestamps...")
1496
- v_es = Path(f"/tmp/video_{os.getpid()}.m2v")
1497
- a_es = Path(f"/tmp/audio_{os.getpid()}.ac3")
1544
+ WORK.mkdir(parents=True, exist_ok=True)
1545
+ v_es = WORK / f"video_{os.getpid()}.m2v"
1546
+ a_es = WORK / f"audio_{os.getpid()}.ac3"
1498
1547
  fixed = mpg.parent / "movie_fixed.mpg"
1499
1548
  if fixed.exists():
1500
1549
  fixed.unlink()
@@ -295,12 +295,16 @@ def can_use_linux_optical_backend():
295
295
 
296
296
  def build_data_image(source_paths, output_path, label, video=False):
297
297
  system = system_name()
298
- if system == "darwin":
299
- command = [tool("hdiutil"), "makehybrid", "-o", str(output_path), "-iso", "-joliet", "-udf"]
300
- command += ["-default-volume-name", label, *[str(path) for path in source_paths]]
301
- elif system == "windows":
302
- command = [tool("xorriso"), "-as", "mkisofs", "-iso-level", "3", "-J", "-R", "-V", label, "-o", str(output_path)]
303
- command += [str(path) for path in source_paths]
298
+ if system in ("darwin", "windows"):
299
+ # xorriso's mkisofs emulation is the same lineage as Linux's
300
+ # genisoimage/growisofs; -dvd-video gives a set-top-compatible
301
+ # VIDEO_TS layout (the arg is the dir *containing* VIDEO_TS).
302
+ command = [tool("xorriso"), "-as", "mkisofs", "-V", label, "-o", str(output_path)]
303
+ if video:
304
+ command += ["-dvd-video", "-udf", str(source_paths[0])]
305
+ else:
306
+ command += ["-iso-level", "3", "-J", "-R", "-udf",
307
+ *[str(path) for path in source_paths]]
304
308
  else:
305
309
  raise RuntimeError("Image building is only used by non-Linux optical backends")
306
310
  subprocess.run(command, check=True, capture_output=True, text=True)
@@ -310,7 +314,8 @@ def build_data_image(source_paths, output_path, label, video=False):
310
314
  def iso_burn_command(device, image_path):
311
315
  system = system_name()
312
316
  if system == "darwin":
313
- return [tool("hdiutil"), "burn", str(image_path)]
317
+ # -puppetstrings emits machine-readable PERCENT: / MESSAGE: lines.
318
+ return [tool("hdiutil"), "burn", "-puppetstrings", str(image_path)]
314
319
  if system == "windows":
315
320
  return [tool("isoburn.exe"), "/Q", device, str(image_path)]
316
321
  raise RuntimeError("ISO command requested on Linux; use growisofs backend")
@@ -353,8 +358,20 @@ def cdrdao_device(device):
353
358
 
354
359
 
355
360
  def unmount_device(device):
356
- """Unmount Linux optical media before handing the device to a writer."""
357
- if system_name() != "linux" or not device:
361
+ """Release an auto-mounted optical volume before handing the raw device to a
362
+ writer or ripper. Media stays loaded."""
363
+ if not device:
364
+ return True
365
+
366
+ if system_name() == "darwin":
367
+ disk = re.sub(r"^/dev/r", "/dev/", device) # diskutil wants the block node
368
+ subprocess.run(
369
+ ["/usr/sbin/diskutil", "unmountDisk", "force", disk],
370
+ capture_output=True, text=True, check=False, timeout=30,
371
+ )
372
+ return True
373
+
374
+ if system_name() != "linux":
358
375
  return True
359
376
 
360
377
  udisksctl = shutil.which("udisksctl")