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 +2 -1
- package/package.json +1 -1
- package/src/discstation.py +96 -120
- package/src/discstation_burn.py +52 -3
- package/src/discstation_host.py +26 -9
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
package/src/discstation.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
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
|
-
|
|
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(
|
|
1639
|
+
"track_count": len(tracks),
|
|
1653
1640
|
"leadout": leadout,
|
|
1654
1641
|
"tracks": tracks,
|
|
1655
|
-
"toc": "+".join(map(str, [1, len(
|
|
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 "
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
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()), "
|
|
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
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
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):
|
package/src/discstation_burn.py
CHANGED
|
@@ -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',
|
|
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
|
-
|
|
1497
|
-
|
|
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()
|
package/src/discstation_host.py
CHANGED
|
@@ -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
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
command = [tool("xorriso"), "-as", "mkisofs", "-
|
|
303
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
357
|
-
|
|
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")
|