discstation 0.1.9 → 0.1.11

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/README.md CHANGED
@@ -17,23 +17,27 @@ playback, ripping, phone uploads, and ESP32 remote control.
17
17
  | **Burn Data DVD** | Any files/folders → ISO/Joliet data disc (no quality loss) |
18
18
  | **Burn MPG** | Re-burn a previously converted movie.mpg |
19
19
  | **Play** | Playback via mpv (DVD-Video, Audio CD, VCD, SVCD) |
20
- | **Rip** | Audio CD → FLAC with MusicBrainz metadata |
20
+ | **Rip** | Audio CD → FLAC (MusicBrainz); DVD-Video → VIDEO_TS mirror or HandBrake MKV (TMDb naming) |
21
21
 
22
22
  ## Project Structure
23
23
 
24
24
  ```
25
25
  .
26
26
  ├── src/
27
- │ ├── discstation.py # Main orchestrator, serial, web server
28
- └── discstation_burn.py # Burn pipeline (download, convert, author, burn)
27
+ │ ├── discstation.py # Main orchestrator, serial, web server
28
+ ├── discstation_burn.py # Burn pipeline (download, convert, author, burn)
29
+ │ ├── discstation_host.py # Per-OS device/path/backend abstraction
30
+ │ ├── discstation_meta.py # TMDb video metadata
31
+ │ └── static/ # Built-in web UI (index.html, app.js, style.css)
32
+ ├── mobile/ # Expo (React Native) companion app
29
33
  ├── arduino/
30
- │ ├── c6/ # ESP32-C6 firmware
31
- │ └── v1/ # ESP32 DevKit firmware
32
- ├── docs/ # Platform support notes
33
- ├── install.sh # Linux host installer
34
- ├── install-macos.sh # macOS host bootstrap
35
- ├── install-windows.ps1 # Windows host bootstrap
36
- ├── systemd/ # Linux auto-start unit
34
+ │ ├── c6/ # ESP32-C6 firmware
35
+ │ └── v1/ # ESP32 DevKit firmware
36
+ ├── scripts/setup.mjs # `discstation-setup` cross-OS installer dispatch
37
+ ├── docs/ # Platform support notes
38
+ ├── install.sh / install-macos.sh / install-windows.ps1
39
+ ├── requirements.txt / requirements-optional.txt
40
+ ├── systemd/ # Linux auto-start unit
37
41
  └── README.md
38
42
  ```
39
43
 
@@ -52,7 +56,7 @@ discstation-setup # dispatches to the installer for your OS
52
56
  | OS | What runs | Optical support |
53
57
  |----|-----------|-----------------|
54
58
  | Linux | `install.sh` — apt deps, venv, systemd `--user` service, self-signed cert | full burn / rip / play |
55
- | macOS | `install-macos.sh` — Homebrew deps, venv, launchd agent, cert | experimental (set `DISC_DEVICE` if detection fails) |
59
+ | macOS | `install-macos.sh` — Homebrew deps, venv, launchd agent, cert | burn / rip / play (audio-CD *burning* is best-effort; set `DISC_DEVICE` if detection fails) |
56
60
  | Windows | `install-windows.ps1` — files + venv | web / serial control only, no burn backend |
57
61
 
58
62
  ### Or run the platform script directly
@@ -75,9 +79,10 @@ PowerShell -ExecutionPolicy Bypass -File .\install-windows.ps1
75
79
 
76
80
  ## Platform Support
77
81
 
78
- Linux currently supports the complete optical workflow. macOS and Windows use
79
- the shared Python/web workflows, but their native optical burning backends are
80
- still being implemented. See `docs/PLATFORM_SUPPORT.md`.
82
+ Linux and macOS both run the complete optical workflow (burn, rip, play)
83
+ macOS via `xorriso` / `hdiutil` / `cd-paranoia` / `dvdbackup` / HandBrake, with
84
+ audio-CD *burning* the one best-effort area. Windows runs the shared Python/web
85
+ workflow only; it has no burn backend yet. See `docs/PLATFORM_SUPPORT.md`.
81
86
 
82
87
  ## Web Interface
83
88
 
@@ -102,6 +107,11 @@ Built-in web server on port 8080 (HTTPS with self-signed cert):
102
107
  | `DISC_CLEANUP_DAYS` | 2 | Auto-cleanup old job directories |
103
108
  | `DISC_OUTPUT_LIMIT_BYTES` | 4300000000 | Conservative DVD5 payload limit |
104
109
  | `DISC_DL_OUTPUT_LIMIT_BYTES` | 8000000000 | Conservative DVD9 payload limit |
110
+ | `DISC_DEVICE` | auto | Optical drive node override (required on Windows) |
111
+ | `DISC_PORT` | auto | ESP32 serial port override |
112
+ | `DISCSTATION_HTTP_PORT` | 8081 | Plain-HTTP port for the mobile app (`0` disables) |
113
+ | `DISCSTATION_DVD_RIP_MODE` | mirror | `mkv` = HandBrake main-feature transcode instead of a full VIDEO_TS mirror |
114
+ | `DISC_AUDIO_DEVICE` | auto | mpv audio-output device override |
105
115
 
106
116
  ### YouTube Download Configuration
107
117
 
@@ -9,16 +9,23 @@ Run `./install.sh` on Debian, Ubuntu, or Raspberry Pi OS.
9
9
 
10
10
  ## macOS
11
11
 
12
- The Python host and web/control workflow run with Homebrew dependencies. Data
13
- and DVD image burning use `hdiutil`; DVD-Video discs can be mounted read-only
14
- for playback and VIDEO_TS ripping; audio CD burning uses cdrdao. Optical-device
15
- discovery and video/audio workflows are experimental; use `DISC_DEVICE`
16
- when automatic detection does not find the external drive. Apple Music owns
17
- audio CD playback and ripping on macOS, so DiscStation does not probe or access
18
- audio tracks for those operations. Audio burns include CD-TEXT from the source
19
- folder/file name and track metadata. Apple Music may still show generic track
20
- names when its online CD lookup has no match; CD-TEXT-capable players can read
21
- the embedded names. The macOS installer installs a persistent `launchd` service.
12
+ Near-complete native support via Homebrew dependencies (`install-macos.sh`
13
+ installs them and a persistent `launchd` agent):
14
+
15
+ - **Detect** optical device via `ioreg` / `drutil` / `diskutil`; blank and
16
+ rewritable media are classified, so the OLED offers the burn menu.
17
+ - **Burn** data and DVD-Video images are built with `xorriso -as mkisofs`
18
+ (`-dvd-video` for a set-top-compatible VIDEO_TS layout) and written with
19
+ `hdiutil burn` (progress streamed from its `-puppetstrings` output).
20
+ - **Rip** audio CD via `cd-paranoia` FLAC with MusicBrainz metadata and
21
+ cover art; DVD-Video via `dvdbackup -M` (or HandBrake main-feature with
22
+ `DISCSTATION_DVD_RIP_MODE=mkv`), `libdvdcss` handling CSS.
23
+ - **Play** — `mpv`, with `dvdnav://` menu navigation for DVD-Video and a
24
+ mounted-VOB fallback.
25
+
26
+ Set `DISC_DEVICE` if automatic drive detection fails. The one known limitation
27
+ is **audio-CD *burning*** — `cdrdao` is the only option and often cannot claim
28
+ the drive on recent macOS; DiscStation reports this clearly instead of hanging.
22
29
 
23
30
  ## Windows
24
31
 
@@ -32,8 +39,8 @@ instead of silently attempting Linux commands.
32
39
  - DiscStation workflows and web UI
33
40
  - ESP32 serial protocol
34
41
  - File upload and folder preservation
35
- - Video/audio metadata handling
42
+ - Video/audio metadata handling (MusicBrainz, TMDb)
36
43
  - QR-code web URL display
37
44
 
38
- The optical-drive backend is the platform boundary. It should be completed per
39
- OS before claiming full native burning support on that platform.
45
+ The optical-drive backend is the platform boundary. Linux and macOS are
46
+ complete (bar macOS audio-CD burning); Windows still needs its writer backend.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discstation",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "DiscStation optical-media appliance host — one cross-OS installer",
5
5
  "bin": {
6
6
  "discstation-setup": "scripts/setup.mjs"
package/scripts/setup.mjs CHANGED
@@ -18,7 +18,7 @@ if (args.includes('-h') || args.includes('--help')) {
18
18
 
19
19
  Runs the installer for the current OS:
20
20
  linux install.sh full: apt deps, venv, systemd --user service, self-signed cert
21
- darwin install-macos.sh full: Homebrew deps, venv, launchd agent, cert (optical is experimental)
21
+ darwin install-macos.sh full: Homebrew deps, venv, launchd agent, cert (audio-CD burn is best-effort)
22
22
  windows install-windows.ps1 files + venv only; web/serial control, no burn backend
23
23
 
24
24
  Prereqs: Node 16+, Python 3 (python3 / py on PATH), and either bash (linux/macOS)
@@ -2933,15 +2933,16 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
2933
2933
  pass
2934
2934
 
2935
2935
  env = os.environ.copy()
2936
- if "DISPLAY" not in env:
2937
- env["DISPLAY"] = ":0"
2938
- try:
2939
- uid = os.getuid()
2940
- home_xauth = Path.home() / ".Xauthority"
2941
- env.setdefault("XAUTHORITY", str(home_xauth) if home_xauth.exists() else f"/run/user/{uid}/.Xauthority")
2942
- env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{uid}")
2943
- except Exception:
2944
- 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
2945
2946
 
2946
2947
  proc = subprocess.Popen(run_as_desktop_user(cmd), env=env)
2947
2948
 
@@ -3082,23 +3083,36 @@ def play_flow(ser):
3082
3083
 
3083
3084
  if kind == "dvd_video":
3084
3085
  if discstation_host.system_name() == "darwin":
3085
- with mounted_disc(device) as mount_dir:
3086
- video_ts = mount_dir / "VIDEO_TS"
3087
- files = sorted(
3088
- path for path in video_ts.glob("VTS_01_*.VOB")
3089
- if re.search(r"_\d+\.VOB$", path.name, re.IGNORECASE)
3090
- and not path.name.upper().endswith("_0.VOB")
3091
- )
3092
- if not files:
3093
- raise RuntimeError("No playable DVD title found")
3086
+ try:
3094
3087
  cmd = [
3095
3088
  "mpv",
3096
3089
  "--input-ipc-server=" + MPV_SOCKET,
3097
3090
  "--force-window=yes",
3098
3091
  "--idle=no",
3099
- *[str(path) for path in files],
3092
+ "--dvd-device=" + rip_device(device),
3093
+ "dvdnav://",
3100
3094
  ]
3101
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)
3102
3116
  else:
3103
3117
  cmd = [
3104
3118
  "mpv",
@@ -3880,10 +3894,16 @@ def check_pidfile():
3880
3894
  old_pid = int(f.read().strip())
3881
3895
  try:
3882
3896
  os.kill(old_pid, 0)
3883
- with open(f"/proc/{old_pid}/cmdline") as f:
3884
- if "discstation" in f.read():
3885
- print(f"Already running (PID {old_pid}), exiting")
3886
- 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)
3887
3907
  except (OSError, IOError):
3888
3908
  pass
3889
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()
@@ -289,18 +289,18 @@ def null_device():
289
289
  return "NUL" if system_name() == "windows" else "/dev/null"
290
290
 
291
291
 
292
- def can_use_linux_optical_backend():
293
- return system_name() == "linux" and all(shutil.which(name) for name in ("growisofs", "wodim", "cdrdao"))
294
-
295
-
296
292
  def build_data_image(source_paths, output_path, label, video=False):
297
293
  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]
294
+ if system in ("darwin", "windows"):
295
+ # xorriso's mkisofs emulation is the same lineage as Linux's
296
+ # genisoimage/growisofs; -dvd-video gives a set-top-compatible
297
+ # VIDEO_TS layout (the arg is the dir *containing* VIDEO_TS).
298
+ command = [tool("xorriso"), "-as", "mkisofs", "-V", label, "-o", str(output_path)]
299
+ if video:
300
+ command += ["-dvd-video", "-udf", str(source_paths[0])]
301
+ else:
302
+ command += ["-iso-level", "3", "-J", "-R", "-udf",
303
+ *[str(path) for path in source_paths]]
304
304
  else:
305
305
  raise RuntimeError("Image building is only used by non-Linux optical backends")
306
306
  subprocess.run(command, check=True, capture_output=True, text=True)
@@ -310,7 +310,8 @@ def build_data_image(source_paths, output_path, label, video=False):
310
310
  def iso_burn_command(device, image_path):
311
311
  system = system_name()
312
312
  if system == "darwin":
313
- return [tool("hdiutil"), "burn", str(image_path)]
313
+ # -puppetstrings emits machine-readable PERCENT: / MESSAGE: lines.
314
+ return [tool("hdiutil"), "burn", "-puppetstrings", str(image_path)]
314
315
  if system == "windows":
315
316
  return [tool("isoburn.exe"), "/Q", device, str(image_path)]
316
317
  raise RuntimeError("ISO command requested on Linux; use growisofs backend")