discstation 0.1.7 → 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.7",
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"
@@ -1046,6 +1046,9 @@ def is_rewritable_disc(device):
1046
1046
  "",
1047
1047
  )
1048
1048
  return "rw" in media_line
1049
+ # Non-Linux: the RW signal comes from the ID_CDROM_MEDIA_*_RW keys above,
1050
+ # which discstation_host.media_properties() tags from the drutil/diskutil
1051
+ # media type.
1049
1052
  return False
1050
1053
 
1051
1054
 
@@ -1248,6 +1251,12 @@ def _classify_disc(device, props, failed, deadline):
1248
1251
  print(f"Disc inspection failed: {e}")
1249
1252
  kind = kind or "data_disc"
1250
1253
  return _disc_info(True, kind, web_type=_web_type_for(kind, 0))
1254
+ if props.get("ID_CDROM_MEDIA_STATE") == "blank":
1255
+ try:
1256
+ cap = discstation_burn.disc_capacity_bytes(device) or 0
1257
+ except Exception:
1258
+ cap = 0
1259
+ return _disc_info(True, "blank", capacity_bytes=cap, web_type="BLANK")
1251
1260
  return _disc_info(True, "unknown", web_type="UNKNOWN", failed_probes=failed)
1252
1261
 
1253
1262
  # Fast path for a blank disc: cdrom_id reports this reliably in ~20ms, and
@@ -1452,8 +1461,6 @@ def disc_title(device):
1452
1461
  if t:
1453
1462
  return t
1454
1463
  elif kind == "audio_cd":
1455
- if discstation_host.system_name() == "darwin":
1456
- return "Apple Music"
1457
1464
  try:
1458
1465
  toc = audio_cd_toc(device)
1459
1466
  if toc and toc.get("track_count"):
@@ -1524,8 +1531,6 @@ def menu_items_for_disc(device):
1524
1531
  had = discstation_burn.WORK.rglob("movie.mpg")
1525
1532
  if any(True for _ in had):
1526
1533
  items.append("BURN MPG")
1527
- elif kind == "audio_cd" and discstation_host.system_name() == "darwin":
1528
- items = ["APPLE MUSIC"]
1529
1534
  elif kind in ("dvd_video", "audio_cd", "vcd", "svcd", "video_data", "data_disc", "data_cd"):
1530
1535
  items = ["PLAY", "RIP"]
1531
1536
  else:
@@ -1603,47 +1608,38 @@ def disc_video_files(mount_dir):
1603
1608
 
1604
1609
  def audio_cd_toc(device):
1605
1610
  if discstation_host.system_name() == "darwin":
1606
- 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)")
1607
1620
  result = subprocess.run(
1608
- [discstation_burn.tool("cdrdao"), "read-toc", "--fast-toc",
1609
- "--device", discstation_host.cdrdao_device(device), str(toc_path)],
1621
+ [paranoia, "-Q", "-d", rip_device(device)],
1610
1622
  capture_output=True, text=True, timeout=30,
1611
1623
  )
1612
1624
  text = ensure_text(result.stdout) + ensure_text(result.stderr)
1613
- toc_text = toc_path.read_text(errors="replace") if toc_path.exists() else ""
1614
- toc_path.unlink(missing_ok=True)
1615
- if result.returncode != 0:
1616
- 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")
1617
1634
  raise RuntimeError(f"Could not read macOS CD TOC: {detail[:100]}")
1618
-
1619
- durations = []
1620
- for block in re.split(r"(?m)^\s*//\s*Track\s+\d+\s*$", toc_text)[1:]:
1621
- file_lines = re.findall(r"(?m)^\s*FILE\b.*$", block)
1622
- file_times = re.findall(r"\d+:\d+:\d+", file_lines[-1]) if file_lines else []
1623
- pregap_times = re.findall(r"(?m)^\s*SILENCE\s+(\d+:\d+:\d+)", block)
1624
- if file_times:
1625
- duration = _msf_frames(file_times[-1])
1626
- duration += sum(_msf_frames(value) for value in pregap_times)
1627
- durations.append(duration)
1628
- if not durations:
1629
- for line in text.splitlines():
1630
- match = re.match(r"\s*(\d+)\s+AUDIO.*?\((\d+)\).*?\((\d+)\)", line)
1631
- if match:
1632
- durations.append(int(match.group(3)) - int(match.group(2)))
1633
- if not durations:
1634
- raise RuntimeError("Could not read macOS CD TOC")
1635
- tracks = []
1636
- position = 150
1637
- for duration in durations:
1638
- tracks.append(position)
1639
- position += duration
1640
- leadout = position
1635
+ tracks = [begin + 150 for begin in begins] # LBA -> MB frame offset
1636
+ leadout = begins[-1] + lengths[-1] + 150
1641
1637
  return {
1642
1638
  "first_track": 1,
1643
- "track_count": len(durations),
1639
+ "track_count": len(tracks),
1644
1640
  "leadout": leadout,
1645
1641
  "tracks": tracks,
1646
- "toc": "+".join(map(str, [1, len(durations), leadout, *tracks])),
1642
+ "toc": "+".join(map(str, [1, len(tracks), leadout, *tracks])),
1647
1643
  }
1648
1644
  if _libdiscid is not None:
1649
1645
  try:
@@ -1689,11 +1685,6 @@ def audio_cd_toc(device):
1689
1685
  }
1690
1686
 
1691
1687
 
1692
- def _msf_frames(value):
1693
- minutes, seconds, frames = (int(part) for part in value.split(":"))
1694
- return (minutes * 60 + seconds) * 75 + frames
1695
-
1696
-
1697
1688
  def audio_cd_chapters(device):
1698
1689
  if discstation_host.system_name() == "darwin":
1699
1690
  toc = audio_cd_toc(device)
@@ -2310,7 +2301,19 @@ def iter_process_events(proc, idle_seconds=1.0, ser=None):
2310
2301
  reader.join(timeout=1)
2311
2302
 
2312
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
+
2313
2314
  def device_size_bytes(device):
2315
+ if discstation_host.system_name() != "linux":
2316
+ return discstation_host.media_capacity_bytes(device) or 0
2314
2317
  result = run_probe(["blockdev", "--getsize64", device], timeout=3)
2315
2318
  try:
2316
2319
  return int(ensure_text(result.stdout).strip() or "0")
@@ -3074,8 +3077,6 @@ def play_flow(ser):
3074
3077
  kind = disc_kind(device)
3075
3078
  print(f"Disc type: {kind}")
3076
3079
 
3077
- if kind == "audio_cd" and discstation_host.system_name() == "darwin":
3078
- raise RuntimeError("Apple Music handles audio CD playback on macOS")
3079
3080
  if not shutil.which("mpv"):
3080
3081
  raise RuntimeError("mpv not found")
3081
3082
 
@@ -3116,7 +3117,7 @@ def play_flow(ser):
3116
3117
  "--input-ipc-server=" + MPV_SOCKET,
3117
3118
  "--force-window=no",
3118
3119
  "--idle=no",
3119
- "--cdrom-device=" + device,
3120
+ "--cdrom-device=" + rip_device(device),
3120
3121
  "--cdda-cdtext=yes",
3121
3122
  "cdda://",
3122
3123
  ]
@@ -3285,8 +3286,6 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
3285
3286
  device = discstation_burn.disc_device()
3286
3287
  kind = disc_kind(device)
3287
3288
 
3288
- if kind == "audio_cd" and discstation_host.system_name() == "darwin":
3289
- raise RuntimeError("Apple Music handles audio CD ripping on macOS")
3290
3289
  if kind == "audio_cd":
3291
3290
  rip_audio_cd(ser, device, artist_hint, album_hint)
3292
3291
  return
@@ -3295,13 +3294,14 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
3295
3294
  rip_video_disc(ser, device, kind)
3296
3295
  return
3297
3296
 
3298
- if kind == "dvd_video" and discstation_host.system_name() == "darwin":
3299
- rip_dvd_video_macos(ser, device)
3300
- return
3301
-
3302
3297
  if kind != "dvd_video":
3303
3298
  raise RuntimeError(f"Unsupported disc: {kind}")
3304
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
+
3305
3305
  out_dir = RIP_ROOT / time.strftime("rip_%Y%m%d_%H%M%S")
3306
3306
  out_dir.mkdir(parents=True, exist_ok=True)
3307
3307
 
@@ -3395,45 +3395,6 @@ def remux_or_copy_video(src, dest):
3395
3395
  shutil.copy2(src, dest.with_suffix(src.suffix.lower()))
3396
3396
 
3397
3397
 
3398
- def rip_dvd_video_macos(ser, device):
3399
- out_dir = RIP_ROOT / f"dvd_video_{time.strftime('%Y%m%d_%H%M%S')}"
3400
- out_dir.mkdir(parents=True, exist_ok=True)
3401
- send(ser, "STATUS:Ripping DVD")
3402
- send(ser, "INFO:Copying VIDEO_TS")
3403
- send(ser, "PROGRESS:0%")
3404
-
3405
- with mounted_disc(device) as mount_dir:
3406
- source_dir = mount_dir / "VIDEO_TS"
3407
- if not source_dir.is_dir():
3408
- raise RuntimeError("VIDEO_TS directory not found")
3409
- files = sorted(path for path in source_dir.rglob("*") if path.is_file())
3410
- total_bytes = sum(path.stat().st_size for path in files)
3411
- copied_bytes = 0
3412
- for source in files:
3413
- relative = source.relative_to(source_dir)
3414
- destination = out_dir / "VIDEO_TS" / relative
3415
- destination.parent.mkdir(parents=True, exist_ok=True)
3416
- span = (source.stat().st_size / total_bytes * 100) if total_bytes else 0
3417
- discstation_burn.copy_with_keepalive(
3418
- ser,
3419
- source,
3420
- destination,
3421
- base_pct=(copied_bytes / total_bytes * 100) if total_bytes else 0,
3422
- pct_span=span,
3423
- )
3424
- copied_bytes += source.stat().st_size
3425
-
3426
- (out_dir / "disc_info.json").write_text(
3427
- 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),
3428
- )
3429
- safe_send(ser, "PROGRESS:100%")
3430
- safe_send(ser, "DONE:Rip complete!")
3431
- print(f"DVD rip complete: {out_dir}")
3432
- out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
3433
- chown_to_sudo_user(out_dir)
3434
- time.sleep(3)
3435
-
3436
-
3437
3398
  def rip_video_disc(ser, device, kind):
3438
3399
  out_dir = RIP_ROOT / f"{kind}_{time.strftime('%Y%m%d_%H%M%S')}"
3439
3400
  out_dir.mkdir(parents=True, exist_ok=True)
@@ -3468,10 +3429,17 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
3468
3429
  raise RuntimeError("No audio CD tracks found")
3469
3430
  wav_dir = out_dir / ".wav"
3470
3431
  wav_dir.mkdir(parents=True, exist_ok=True)
3471
- command = [
3472
- discstation_burn.tool("cdda2wav"), "-D",
3473
- discstation_host.cdrdao_device(device), "-B", "-O", "wav", "-x",
3474
- ]
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-"]
3475
3443
  send(ser, "STATUS:RIPPING AUDIO CD")
3476
3444
  send(ser, "PROGRESS:0%")
3477
3445
  proc = subprocess.Popen(
@@ -3493,7 +3461,7 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
3493
3461
  raise
3494
3462
  proc.wait()
3495
3463
  if proc.returncode != 0:
3496
- 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")
3497
3465
  raise RuntimeError(f"Audio CD rip failed: {detail[:80]}")
3498
3466
 
3499
3467
  wav_files = sorted(wav_dir.glob("*.wav"))
@@ -3873,9 +3841,6 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
3873
3841
  _last_burn_result = "Burn complete"
3874
3842
  elif mode == "PLAY":
3875
3843
  play_flow(ser)
3876
- elif mode == "APPLE MUSIC":
3877
- safe_send(ser, "STATUS:Apple Music handles this audio CD")
3878
- time.sleep(2)
3879
3844
  elif mode == "RIP":
3880
3845
  rip_flow(ser, artist_hint, album_hint)
3881
3846
  _last_burn_result = "Rip complete"
@@ -163,6 +163,19 @@ def drive_status():
163
163
  return "unknown"
164
164
 
165
165
 
166
+ def _tag_rewritable(props, media_type):
167
+ """Set the ID_CDROM_MEDIA_*_RW key that discstation.is_rewritable_disc reads,
168
+ from a lowercased media-type string (drutil 'Type:' or diskutil 'Optical
169
+ Media Type'). ponytail: substring match — same heuristic as the rest of this
170
+ module."""
171
+ if "cd-rw" in media_type or "cdrw" in media_type:
172
+ props["ID_CDROM_MEDIA_CD_RW"] = "1"
173
+ elif "dvd-rw" in media_type or "dvd-ram" in media_type:
174
+ props["ID_CDROM_MEDIA_DVD_RW"] = "1"
175
+ elif "dvd+rw" in media_type or "bd-re" in media_type:
176
+ props["ID_CDROM_MEDIA_DVD_PLUS_RW"] = "1"
177
+
178
+
166
179
  def _drutil_media_properties():
167
180
  result = subprocess.run(["/usr/bin/drutil", "status"], capture_output=True, text=True, check=False, timeout=3)
168
181
  text = result.stdout + result.stderr
@@ -170,12 +183,16 @@ def _drutil_media_properties():
170
183
  if result.returncode != 0 or "no media" in lowered:
171
184
  return {}
172
185
  props = {"ID_CDROM": "1", "ID_CDROM_MEDIA": "1"}
173
- if "space used:" in lowered and "00:00:00" in lowered.split("space used:", 1)[1][:24]:
186
+ blank = "space used:" in lowered and "00:00:00" in lowered.split("space used:", 1)[1][:24]
187
+ if blank:
174
188
  props["ID_CDROM_MEDIA_STATE"] = "blank"
175
- if "type: cd" in lowered:
176
- props["ID_CDROM_MEDIA_TYPE"] = "audio"
177
- elif "type: dvd" in lowered:
189
+ match = re.search(r"type:\s*([a-z0-9+\-]+)", lowered)
190
+ media_type = match.group(1) if match else ""
191
+ if media_type.startswith("dvd") or media_type.startswith("bd"):
178
192
  props["ID_CDROM_MEDIA_TYPE"] = "dvd"
193
+ elif media_type.startswith("cd") and not blank and "-r" not in media_type:
194
+ props["ID_CDROM_MEDIA_TYPE"] = "audio"
195
+ _tag_rewritable(props, media_type)
179
196
  return props
180
197
 
181
198
 
@@ -187,7 +204,7 @@ def media_properties(device):
187
204
  except subprocess.TimeoutExpired:
188
205
  return _drutil_media_properties()
189
206
  if disk.returncode != 0:
190
- return {}
207
+ return _drutil_media_properties()
191
208
  fields = {}
192
209
  for line in disk.stdout.splitlines():
193
210
  if ":" in line:
@@ -195,7 +212,7 @@ def media_properties(device):
195
212
  fields[key.strip()] = value.strip()
196
213
  optical = fields.get("Optical Media Type", "")
197
214
  if not optical and not fields.get("Device / Media Name"):
198
- return {}
215
+ return _drutil_media_properties()
199
216
  props = {"ID_CDROM": "1", "ID_CDROM_MEDIA": "1"}
200
217
  label = fields.get("Volume Name", "")
201
218
  filesystem = fields.get("Type (Bundle)") or fields.get("File System Personality")
@@ -223,8 +240,9 @@ def media_properties(device):
223
240
  optical = optical.lower()
224
241
  if "dvd+r dl" in optical or "dvd-r dl" in optical:
225
242
  props["ID_CDROM_MEDIA_DVD_PLUS_R_DL"] = "1"
226
- elif "dvd+r" in optical or "dvd-r" in optical:
243
+ elif ("dvd+r" in optical or "dvd-r" in optical) and "rw" not in optical:
227
244
  props["ID_CDROM_MEDIA_DVD_PLUS_R"] = "1"
245
+ _tag_rewritable(props, optical)
228
246
  return props
229
247
  return {}
230
248
 
@@ -335,8 +353,20 @@ def cdrdao_device(device):
335
353
 
336
354
 
337
355
  def unmount_device(device):
338
- """Unmount Linux optical media before handing the device to a writer."""
339
- 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":
340
370
  return True
341
371
 
342
372
  udisksctl = shutil.which("udisksctl")