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 +24 -14
- package/docs/PLATFORM_SUPPORT.md +20 -13
- package/package.json +1 -1
- package/scripts/setup.mjs +1 -1
- package/src/discstation.py +43 -23
- package/src/discstation_burn.py +52 -3
- package/src/discstation_host.py +12 -11
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
|
|
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
|
|
28
|
-
│
|
|
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/
|
|
31
|
-
│ └── v1/
|
|
32
|
-
├──
|
|
33
|
-
├──
|
|
34
|
-
├── install-macos.sh
|
|
35
|
-
├──
|
|
36
|
-
├── systemd/
|
|
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 |
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
package/docs/PLATFORM_SUPPORT.md
CHANGED
|
@@ -9,16 +9,23 @@ Run `./install.sh` on Debian, Ubuntu, or Raspberry Pi OS.
|
|
|
9
9
|
|
|
10
10
|
## macOS
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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.
|
|
39
|
-
|
|
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
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 (
|
|
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)
|
package/src/discstation.py
CHANGED
|
@@ -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 "
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
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):
|
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
|
@@ -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
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
command = [tool("xorriso"), "-as", "mkisofs", "-
|
|
303
|
-
|
|
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
|
-
|
|
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")
|