discstation 0.1.8 → 0.1.9
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 +53 -97
- package/src/discstation_host.py +14 -2
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")
|
|
@@ -3083,8 +3077,6 @@ def play_flow(ser):
|
|
|
3083
3077
|
kind = disc_kind(device)
|
|
3084
3078
|
print(f"Disc type: {kind}")
|
|
3085
3079
|
|
|
3086
|
-
if kind == "audio_cd" and discstation_host.system_name() == "darwin":
|
|
3087
|
-
raise RuntimeError("Apple Music handles audio CD playback on macOS")
|
|
3088
3080
|
if not shutil.which("mpv"):
|
|
3089
3081
|
raise RuntimeError("mpv not found")
|
|
3090
3082
|
|
|
@@ -3125,7 +3117,7 @@ def play_flow(ser):
|
|
|
3125
3117
|
"--input-ipc-server=" + MPV_SOCKET,
|
|
3126
3118
|
"--force-window=no",
|
|
3127
3119
|
"--idle=no",
|
|
3128
|
-
"--cdrom-device=" + device,
|
|
3120
|
+
"--cdrom-device=" + rip_device(device),
|
|
3129
3121
|
"--cdda-cdtext=yes",
|
|
3130
3122
|
"cdda://",
|
|
3131
3123
|
]
|
|
@@ -3294,8 +3286,6 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
|
|
|
3294
3286
|
device = discstation_burn.disc_device()
|
|
3295
3287
|
kind = disc_kind(device)
|
|
3296
3288
|
|
|
3297
|
-
if kind == "audio_cd" and discstation_host.system_name() == "darwin":
|
|
3298
|
-
raise RuntimeError("Apple Music handles audio CD ripping on macOS")
|
|
3299
3289
|
if kind == "audio_cd":
|
|
3300
3290
|
rip_audio_cd(ser, device, artist_hint, album_hint)
|
|
3301
3291
|
return
|
|
@@ -3304,13 +3294,14 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
|
|
|
3304
3294
|
rip_video_disc(ser, device, kind)
|
|
3305
3295
|
return
|
|
3306
3296
|
|
|
3307
|
-
if kind == "dvd_video" and discstation_host.system_name() == "darwin":
|
|
3308
|
-
rip_dvd_video_macos(ser, device)
|
|
3309
|
-
return
|
|
3310
|
-
|
|
3311
3297
|
if kind != "dvd_video":
|
|
3312
3298
|
raise RuntimeError(f"Unsupported disc: {kind}")
|
|
3313
3299
|
|
|
3300
|
+
# libdvdread / HandBrake / dvdbackup want the raw node on macOS and the
|
|
3301
|
+
# auto-mounted UDF/ISO volume released first (no-ops on Linux).
|
|
3302
|
+
device = rip_device(device)
|
|
3303
|
+
discstation_host.unmount_device(device)
|
|
3304
|
+
|
|
3314
3305
|
out_dir = RIP_ROOT / time.strftime("rip_%Y%m%d_%H%M%S")
|
|
3315
3306
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
3316
3307
|
|
|
@@ -3404,45 +3395,6 @@ def remux_or_copy_video(src, dest):
|
|
|
3404
3395
|
shutil.copy2(src, dest.with_suffix(src.suffix.lower()))
|
|
3405
3396
|
|
|
3406
3397
|
|
|
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
3398
|
def rip_video_disc(ser, device, kind):
|
|
3447
3399
|
out_dir = RIP_ROOT / f"{kind}_{time.strftime('%Y%m%d_%H%M%S')}"
|
|
3448
3400
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -3477,10 +3429,17 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
|
|
|
3477
3429
|
raise RuntimeError("No audio CD tracks found")
|
|
3478
3430
|
wav_dir = out_dir / ".wav"
|
|
3479
3431
|
wav_dir.mkdir(parents=True, exist_ok=True)
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3432
|
+
paranoia = None
|
|
3433
|
+
for name in ("cd-paranoia", "cdparanoia"):
|
|
3434
|
+
try:
|
|
3435
|
+
paranoia = discstation_burn.tool(name)
|
|
3436
|
+
break
|
|
3437
|
+
except FileNotFoundError:
|
|
3438
|
+
continue
|
|
3439
|
+
if not paranoia:
|
|
3440
|
+
raise RuntimeError("cd-paranoia not installed (brew install libcdio-paranoia)")
|
|
3441
|
+
# -B batch mode writes track01.cdda.wav, track02.cdda.wav, ... in cwd.
|
|
3442
|
+
command = [paranoia, "-B", "-d", rip_device(device), "1-"]
|
|
3484
3443
|
send(ser, "STATUS:RIPPING AUDIO CD")
|
|
3485
3444
|
send(ser, "PROGRESS:0%")
|
|
3486
3445
|
proc = subprocess.Popen(
|
|
@@ -3502,7 +3461,7 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
|
|
|
3502
3461
|
raise
|
|
3503
3462
|
proc.wait()
|
|
3504
3463
|
if proc.returncode != 0:
|
|
3505
|
-
detail = next((line.strip() for line in reversed(output) if line.strip()), "
|
|
3464
|
+
detail = next((line.strip() for line in reversed(output) if line.strip()), "cd-paranoia failed")
|
|
3506
3465
|
raise RuntimeError(f"Audio CD rip failed: {detail[:80]}")
|
|
3507
3466
|
|
|
3508
3467
|
wav_files = sorted(wav_dir.glob("*.wav"))
|
|
@@ -3882,9 +3841,6 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
|
|
|
3882
3841
|
_last_burn_result = "Burn complete"
|
|
3883
3842
|
elif mode == "PLAY":
|
|
3884
3843
|
play_flow(ser)
|
|
3885
|
-
elif mode == "APPLE MUSIC":
|
|
3886
|
-
safe_send(ser, "STATUS:Apple Music handles this audio CD")
|
|
3887
|
-
time.sleep(2)
|
|
3888
3844
|
elif mode == "RIP":
|
|
3889
3845
|
rip_flow(ser, artist_hint, album_hint)
|
|
3890
3846
|
_last_burn_result = "Rip complete"
|
package/src/discstation_host.py
CHANGED
|
@@ -353,8 +353,20 @@ def cdrdao_device(device):
|
|
|
353
353
|
|
|
354
354
|
|
|
355
355
|
def unmount_device(device):
|
|
356
|
-
"""
|
|
357
|
-
|
|
356
|
+
"""Release an auto-mounted optical volume before handing the raw device to a
|
|
357
|
+
writer or ripper. Media stays loaded."""
|
|
358
|
+
if not device:
|
|
359
|
+
return True
|
|
360
|
+
|
|
361
|
+
if system_name() == "darwin":
|
|
362
|
+
disk = re.sub(r"^/dev/r", "/dev/", device) # diskutil wants the block node
|
|
363
|
+
subprocess.run(
|
|
364
|
+
["/usr/sbin/diskutil", "unmountDisk", "force", disk],
|
|
365
|
+
capture_output=True, text=True, check=False, timeout=30,
|
|
366
|
+
)
|
|
367
|
+
return True
|
|
368
|
+
|
|
369
|
+
if system_name() != "linux":
|
|
358
370
|
return True
|
|
359
371
|
|
|
360
372
|
udisksctl = shutil.which("udisksctl")
|