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 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.9",
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")
@@ -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
- command = [
3481
- discstation_burn.tool("cdda2wav"), "-D",
3482
- discstation_host.cdrdao_device(device), "-B", "-O", "wav", "-x",
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()), "cdda2wav failed")
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"
@@ -353,8 +353,20 @@ def cdrdao_device(device):
353
353
 
354
354
 
355
355
  def unmount_device(device):
356
- """Unmount Linux optical media before handing the device to a writer."""
357
- if system_name() != "linux" or not device:
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")