discstation 0.1.18 → 0.1.21
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 +7 -5
- package/arduino/c6/DiscStation_C6.ino +7 -1
- package/arduino/v1/DiscStation.ino +4 -1
- package/docs/PLATFORM_SUPPORT.md +24 -5
- package/install-windows.ps1 +162 -18
- package/package.json +2 -1
- package/scripts/setup.mjs +1 -1
- package/src/discstation.py +522 -120
- package/src/discstation_burn.py +167 -8
- package/src/discstation_host.py +122 -3
- package/src/static/app.js +72 -0
- package/src/static/index.html +37 -3
- package/src/static/style.css +38 -0
- package/src/win/_json.ps1 +29 -0
- package/src/win/audio-toc.ps1 +43 -0
- package/src/win/burn-audio.ps1 +87 -0
- package/src/win/burn-data.ps1 +83 -0
- package/src/win/burn-image.ps1 +68 -0
- package/src/win/disc-info.ps1 +81 -0
- package/src/win/eject.ps1 +30 -0
- package/src/win/play-audio-cd.ps1 +94 -0
package/src/discstation_burn.py
CHANGED
|
@@ -520,6 +520,8 @@ status_sink = None
|
|
|
520
520
|
|
|
521
521
|
def send(ser, msg):
|
|
522
522
|
global _serial_write_failed
|
|
523
|
+
if os.environ.get("DISCSTATION_DEBUG_SERIAL"):
|
|
524
|
+
print(f"[{time.time():.3f}] SEND {msg!r}", flush=True)
|
|
523
525
|
if status_sink is not None:
|
|
524
526
|
try:
|
|
525
527
|
status_sink(msg)
|
|
@@ -719,7 +721,7 @@ def get_local_video_info(path):
|
|
|
719
721
|
return {"title": stem, "duration": dur}
|
|
720
722
|
|
|
721
723
|
|
|
722
|
-
def get_video_info(source):
|
|
724
|
+
def get_video_info(source, ser=None):
|
|
723
725
|
p = Path(source)
|
|
724
726
|
if p.is_dir():
|
|
725
727
|
videos = find_video_files(source)
|
|
@@ -731,11 +733,25 @@ def get_video_info(source):
|
|
|
731
733
|
return get_local_video_info(source)
|
|
732
734
|
errors = []
|
|
733
735
|
for player_client in YTDLP_PLAYER_CLIENTS or (None,):
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
)
|
|
736
|
+
# This yt-dlp metadata fetch can take a while (retries, slow network) -
|
|
737
|
+
# without a PING during the wait, the ESP32's 30s silence watchdog
|
|
738
|
+
# flips the OLED to "disconnected" while the host is still alive and
|
|
739
|
+
# just blocked on subprocess.run. Same keepalive download() uses.
|
|
740
|
+
stop_ping, pt = _start_keepalive(ser) if ser else (None, None)
|
|
741
|
+
try:
|
|
742
|
+
r = subprocess.run(
|
|
743
|
+
[*ytdlp_base_args(player_client), '--dump-single-json', '--skip-download', source],
|
|
744
|
+
capture_output=True,
|
|
745
|
+
text=True,
|
|
746
|
+
timeout=90,
|
|
747
|
+
)
|
|
748
|
+
except subprocess.TimeoutExpired:
|
|
749
|
+
errors.append(f"{player_client or 'default'} client timed out")
|
|
750
|
+
continue
|
|
751
|
+
finally:
|
|
752
|
+
if stop_ping:
|
|
753
|
+
stop_ping.set()
|
|
754
|
+
pt.join(timeout=1)
|
|
739
755
|
if r.returncode != 0:
|
|
740
756
|
errors.append(r.stderr.strip() or f"{player_client or 'default'} client failed")
|
|
741
757
|
continue
|
|
@@ -1099,7 +1115,11 @@ def author(ser, mpg, job_dir, aspect="4:3"):
|
|
|
1099
1115
|
send(ser, "PROGRESS:Building IFO/VOB")
|
|
1100
1116
|
dvd_dir = job_dir / "dvd_out"
|
|
1101
1117
|
dvd_dir.mkdir(parents=True, exist_ok=True)
|
|
1102
|
-
|
|
1118
|
+
# Format is conveyed via the VIDEO_FORMAT env var below, not this XML
|
|
1119
|
+
# attribute - some dvdauthor builds (the Windows one) reject a `format`
|
|
1120
|
+
# attribute on <dvdauthor> outright ("Cannot match attribute 'format'"),
|
|
1121
|
+
# which would fail authoring on every single burn.
|
|
1122
|
+
xml = f"""<dvdauthor dest={chr(34) + str(dvd_dir) + chr(34)}>
|
|
1103
1123
|
<vmgm />
|
|
1104
1124
|
<titleset>
|
|
1105
1125
|
<titles>
|
|
@@ -1323,6 +1343,14 @@ def burn(ser, dvd_dir, disc_label, speed=None, is_dual_layer=False):
|
|
|
1323
1343
|
try:
|
|
1324
1344
|
discstation_host.build_data_image([dvd_dir], image_path, disc_label, video=True)
|
|
1325
1345
|
burn_iso(ser, image_path, speed, is_dual_layer)
|
|
1346
|
+
except (RuntimeError, FileNotFoundError) as e:
|
|
1347
|
+
if discstation_host.system_name() != "windows":
|
|
1348
|
+
raise
|
|
1349
|
+
# no xorriso -> burn the VIDEO_TS tree as a plain data disc (plays on
|
|
1350
|
+
# modern players; not guaranteed on old set-tops).
|
|
1351
|
+
print(f"xorriso unavailable ({e}); burning VIDEO_TS as a data disc")
|
|
1352
|
+
_run_windows_burn(ser, "burn-data.ps1", disc_device(), str(dvd_dir),
|
|
1353
|
+
disc_label, re.sub(r"\D", "", speed or ""))
|
|
1326
1354
|
finally:
|
|
1327
1355
|
image_path.unlink(missing_ok=True)
|
|
1328
1356
|
return
|
|
@@ -1350,6 +1378,13 @@ def burn_data(ser, source_paths, disc_label, speed=None, is_dual_layer=False):
|
|
|
1350
1378
|
try:
|
|
1351
1379
|
discstation_host.build_data_image(source_paths, image_path, disc_label)
|
|
1352
1380
|
burn_iso(ser, image_path, speed, is_dual_layer)
|
|
1381
|
+
except (RuntimeError, FileNotFoundError) as e:
|
|
1382
|
+
if discstation_host.system_name() != "windows":
|
|
1383
|
+
raise
|
|
1384
|
+
print(f"xorriso unavailable ({e}); using IMAPI2 data burn")
|
|
1385
|
+
src = str(source_paths[0]) if len(source_paths) == 1 else _stage_dir(source_paths)
|
|
1386
|
+
_run_windows_burn(ser, "burn-data.ps1", disc_device(), src, disc_label,
|
|
1387
|
+
re.sub(r"\D", "", speed or ""))
|
|
1353
1388
|
finally:
|
|
1354
1389
|
image_path.unlink(missing_ok=True)
|
|
1355
1390
|
return
|
|
@@ -1464,6 +1499,17 @@ def burn_audio_cd(ser, audio_files, disc_label, speed=None):
|
|
|
1464
1499
|
f"{len(track_meta)} track titles")
|
|
1465
1500
|
send(ser, "PROGRESS:35%")
|
|
1466
1501
|
|
|
1502
|
+
if discstation_host.system_name() == "windows":
|
|
1503
|
+
# No cdrdao on Windows — burn the prepared WAVs via IMAPI2 Track-At-Once.
|
|
1504
|
+
send(ser, "STATUS:Burning audio CD...")
|
|
1505
|
+
_run_windows_burn(ser, "burn-audio.ps1", disc_device(), str(tmp_dir),
|
|
1506
|
+
re.sub(r"\D", "", (speed or DISC_SPEED) or ""))
|
|
1507
|
+
for w in tmp_dir.glob("*.wav"):
|
|
1508
|
+
w.unlink(missing_ok=True)
|
|
1509
|
+
toc_path.unlink(missing_ok=True)
|
|
1510
|
+
safe_send(ser, "DONE:Audio CD complete!")
|
|
1511
|
+
return
|
|
1512
|
+
|
|
1467
1513
|
send(ser, "STATUS:Burning audio CD...")
|
|
1468
1514
|
# The cooked generic-mmc writer does NOT lay down the CD-TEXT lead-in on most
|
|
1469
1515
|
# ATAPI drives; the raw writer does. Override with DISCSTATION_CDRDAO_DRIVER
|
|
@@ -1524,11 +1570,124 @@ def burn_audio_cd(ser, audio_files, disc_label, speed=None):
|
|
|
1524
1570
|
print(f"CD eject skipped: {e}")
|
|
1525
1571
|
|
|
1526
1572
|
|
|
1573
|
+
def _stage_dir(paths):
|
|
1574
|
+
"""Copy several loose paths into one temp folder (IMAPI2 burn-data takes one)."""
|
|
1575
|
+
staging = WORK / f"stage_{time.strftime('%Y%m%d_%H%M%S')}"
|
|
1576
|
+
staging.mkdir(parents=True, exist_ok=True)
|
|
1577
|
+
for p in paths:
|
|
1578
|
+
p = Path(p)
|
|
1579
|
+
dest = staging / p.name
|
|
1580
|
+
if p.is_dir():
|
|
1581
|
+
shutil.copytree(p, dest, dirs_exist_ok=True)
|
|
1582
|
+
else:
|
|
1583
|
+
shutil.copy2(p, dest)
|
|
1584
|
+
return str(staging)
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
def _estimate_burn_seconds(source):
|
|
1588
|
+
"""Rough total write time for the synthetic progress fallback below,
|
|
1589
|
+
assuming a ~4x DVD write speed (5.54MB/s) - real speed varies by drive
|
|
1590
|
+
and media, so this is a ceiling to tune if it drifts, not a promise."""
|
|
1591
|
+
p = Path(source)
|
|
1592
|
+
size = tree_size(p) if p.is_dir() else (p.stat().st_size if p.is_file() else 0)
|
|
1593
|
+
return max(size / (5.54 * 1024 * 1024), 1.0)
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
def _run_windows_burn(ser, script, *script_args):
|
|
1597
|
+
"""Run a src/win/<script> IMAPI2 burn helper, streaming its PROGRESS:<pct>
|
|
1598
|
+
lines to the ESP32. Raises RuntimeError on a non-zero exit.
|
|
1599
|
+
|
|
1600
|
+
IMAPI2's progress event doesn't reliably reach PowerShell on every
|
|
1601
|
+
setup (COM event dispatch needs the calling thread to pump messages,
|
|
1602
|
+
which it can't do while blocked inside the synchronous native Write()
|
|
1603
|
+
call) - so real PROGRESS lines may never arrive until the very end.
|
|
1604
|
+
While waiting, synthesize a smoothly-climbing estimate from elapsed
|
|
1605
|
+
time vs. the source size at a conservative write speed, capped at 95%
|
|
1606
|
+
until the process actually exits; real PROGRESS lines (from scripts
|
|
1607
|
+
where the event does work, e.g. burn-audio.ps1's per-track updates)
|
|
1608
|
+
still take priority whenever they show up.
|
|
1609
|
+
"""
|
|
1610
|
+
send(ser, "STATUS:Burning...")
|
|
1611
|
+
send(ser, "PROGRESS:0%")
|
|
1612
|
+
est_total = _estimate_burn_seconds(script_args[1]) if len(script_args) > 1 else None
|
|
1613
|
+
cmd, kwargs = discstation_host.ps_cmd(script, *script_args)
|
|
1614
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **kwargs)
|
|
1615
|
+
|
|
1616
|
+
lines = Queue()
|
|
1617
|
+
finished = object()
|
|
1618
|
+
|
|
1619
|
+
def read_output():
|
|
1620
|
+
try:
|
|
1621
|
+
for line in proc.stdout:
|
|
1622
|
+
lines.put(line.rstrip("\r\n"))
|
|
1623
|
+
finally:
|
|
1624
|
+
lines.put(finished)
|
|
1625
|
+
|
|
1626
|
+
reader = threading.Thread(target=read_output, daemon=True)
|
|
1627
|
+
reader.start()
|
|
1628
|
+
|
|
1629
|
+
out_lines, last_pct, last_real_progress, start = [], -1, 0.0, time.time()
|
|
1630
|
+
last_ping, output_done = time.time(), False
|
|
1631
|
+
try:
|
|
1632
|
+
while proc.poll() is None or not output_done:
|
|
1633
|
+
if time.time() - last_ping >= 5:
|
|
1634
|
+
last_ping = time.time()
|
|
1635
|
+
send(ser, "PING")
|
|
1636
|
+
if check_cancel(ser):
|
|
1637
|
+
stop_process(proc)
|
|
1638
|
+
return
|
|
1639
|
+
try:
|
|
1640
|
+
line = lines.get(timeout=0.2)
|
|
1641
|
+
except Empty:
|
|
1642
|
+
line = None
|
|
1643
|
+
if line is finished:
|
|
1644
|
+
output_done = True
|
|
1645
|
+
elif line is not None:
|
|
1646
|
+
out_lines.append(line)
|
|
1647
|
+
m = re.search(r"PROGRESS:(-?\d+)", line)
|
|
1648
|
+
if m:
|
|
1649
|
+
pct = int(m.group(1))
|
|
1650
|
+
if 0 <= pct <= 100 and pct != last_pct:
|
|
1651
|
+
last_pct, last_real_progress = pct, time.time()
|
|
1652
|
+
send(ser, f"PROGRESS:{min(pct, 99)}%")
|
|
1653
|
+
continue
|
|
1654
|
+
# No fresh real progress recently - IMAPI2's Write() blocks the
|
|
1655
|
+
# PowerShell thread the whole time, so its Update event often
|
|
1656
|
+
# never gets dispatched until the call returns. Estimate from
|
|
1657
|
+
# elapsed time instead of leaving the OLED stuck at 0%.
|
|
1658
|
+
if est_total and time.time() - last_real_progress > 3:
|
|
1659
|
+
est_pct = int(min(95, (time.time() - start) / est_total * 100))
|
|
1660
|
+
if est_pct > last_pct:
|
|
1661
|
+
last_pct = est_pct
|
|
1662
|
+
send(ser, f"PROGRESS:{est_pct}%")
|
|
1663
|
+
reader.join(timeout=1)
|
|
1664
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1665
|
+
stop_process(proc)
|
|
1666
|
+
raise
|
|
1667
|
+
if proc.wait() != 0:
|
|
1668
|
+
detail = next((l for l in reversed(out_lines) if l.strip()), "burn failed")
|
|
1669
|
+
raise RuntimeError(f"Disc burn failed: {detail[:150]}")
|
|
1670
|
+
safe_send(ser, "PROGRESS:100%")
|
|
1671
|
+
|
|
1672
|
+
|
|
1527
1673
|
def burn_iso(ser, iso_path, speed=None, is_dual_layer=False):
|
|
1528
1674
|
"""Burn a pre-built ISO directly to disc — no filesystem building."""
|
|
1529
1675
|
if discstation_host.system_name() == "darwin":
|
|
1530
1676
|
_run_hdiutil_burn(ser, iso_path)
|
|
1531
1677
|
return
|
|
1678
|
+
if discstation_host.system_name() == "windows":
|
|
1679
|
+
drive = disc_device()
|
|
1680
|
+
spd = re.sub(r"\D", "", speed or "")
|
|
1681
|
+
try:
|
|
1682
|
+
_run_windows_burn(ser, "burn-image.ps1", drive, str(iso_path), spd)
|
|
1683
|
+
except RuntimeError:
|
|
1684
|
+
isoburn = shutil.which("isoburn") or os.path.join(
|
|
1685
|
+
os.environ.get("SystemRoot", r"C:\Windows"), "System32", "isoburn.exe")
|
|
1686
|
+
send(ser, "STATUS:Burning image (isoburn)...")
|
|
1687
|
+
if subprocess.run([isoburn, "/Q", drive, str(iso_path)]).returncode != 0:
|
|
1688
|
+
raise
|
|
1689
|
+
safe_send(ser, "PROGRESS:100%")
|
|
1690
|
+
return
|
|
1532
1691
|
if discstation_host.system_name() != "linux":
|
|
1533
1692
|
send(ser, "STATUS:Burning image...")
|
|
1534
1693
|
_run_growisofs(ser, discstation_host.iso_burn_command(disc_device(), iso_path), iso_path.parent / "discstation-burn.log")
|
|
@@ -1636,7 +1795,7 @@ def main():
|
|
|
1636
1795
|
print("Connected to DiscStation")
|
|
1637
1796
|
print("Running preflight...")
|
|
1638
1797
|
send(ser, "STATUS:Preflight...")
|
|
1639
|
-
info = get_video_info(url)
|
|
1798
|
+
info = get_video_info(url, ser)
|
|
1640
1799
|
title = info["title"]
|
|
1641
1800
|
duration = info["duration"]
|
|
1642
1801
|
duration_line, fit_line, can_fit = preflight_lines(duration)
|
package/src/discstation_host.py
CHANGED
|
@@ -9,6 +9,8 @@ import platform
|
|
|
9
9
|
import re
|
|
10
10
|
import shutil
|
|
11
11
|
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
|
|
14
16
|
from serial.tools import list_ports
|
|
@@ -98,6 +100,64 @@ def _mac_optical_device():
|
|
|
98
100
|
|
|
99
101
|
_last_disc_device = None
|
|
100
102
|
|
|
103
|
+
# --- Windows: IMAPI2 / WMI probes via bundled PowerShell helpers ---------------
|
|
104
|
+
_WIN_DIR = Path(__file__).resolve().parent / "win"
|
|
105
|
+
_win_info_cache = (0.0, None)
|
|
106
|
+
_win_info_lock = threading.Lock()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def ps_cmd(script_name, *args):
|
|
110
|
+
"""Build a `powershell -File src/win/<script_name> <args>` argv, plus the
|
|
111
|
+
Popen/run kwargs that suppress the console window. The host runs as
|
|
112
|
+
pythonw.exe (no console); without CREATE_NO_WINDOW, Windows pops a
|
|
113
|
+
brand-new visible console for every one of these - and disc detection
|
|
114
|
+
polls every ~1.5s, so it would flash constantly."""
|
|
115
|
+
cmd = ["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
|
|
116
|
+
"-File", str(_WIN_DIR / script_name), *[str(a) for a in args]]
|
|
117
|
+
kwargs = {"creationflags": subprocess.CREATE_NO_WINDOW} if hasattr(subprocess, "CREATE_NO_WINDOW") else {}
|
|
118
|
+
return cmd, kwargs
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _run_ps(script_name, *args, timeout=25):
|
|
122
|
+
"""Run src/win/<script_name> and return (returncode, stdout, stderr)."""
|
|
123
|
+
cmd, kwargs = ps_cmd(script_name, *args)
|
|
124
|
+
try:
|
|
125
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, **kwargs)
|
|
126
|
+
return r.returncode, r.stdout, r.stderr
|
|
127
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
128
|
+
return 1, "", ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _win_disc_info(force=False):
|
|
132
|
+
"""Cached (~2s) dict from src/win/disc-info.ps1, or {} on failure.
|
|
133
|
+
|
|
134
|
+
Locked: disc-info.ps1's IMAPI2 calls appear to serialize on the physical
|
|
135
|
+
drive, so a burst of concurrent callers (multiple web UI tabs, the HTTP
|
|
136
|
+
/disc-info endpoint racing station_loop's own poll) each missing the
|
|
137
|
+
cache at once used to spawn a pile of concurrent powershell.exe
|
|
138
|
+
processes that queued up behind each other and blew past the timeout -
|
|
139
|
+
reporting a false "no disc". One in-flight refresh, shared by whoever's
|
|
140
|
+
waiting, instead of one per caller."""
|
|
141
|
+
import json as _json
|
|
142
|
+
import time
|
|
143
|
+
global _win_info_cache
|
|
144
|
+
with _win_info_lock:
|
|
145
|
+
ts, cached = _win_info_cache
|
|
146
|
+
if not force and cached is not None and time.time() - ts < 2.0:
|
|
147
|
+
return cached
|
|
148
|
+
override = os.environ.get("DISC_DEVICE") or os.environ.get("DVD_DEVICE") or ""
|
|
149
|
+
rc, out, _ = _run_ps("disc-info.ps1", *([override] if override else []), timeout=20)
|
|
150
|
+
info = {}
|
|
151
|
+
for line in out.splitlines():
|
|
152
|
+
line = line.strip()
|
|
153
|
+
if line.startswith("{"):
|
|
154
|
+
try:
|
|
155
|
+
info = _json.loads(line)
|
|
156
|
+
except ValueError:
|
|
157
|
+
pass
|
|
158
|
+
_win_info_cache = (time.time(), info)
|
|
159
|
+
return info
|
|
160
|
+
|
|
101
161
|
|
|
102
162
|
def disc_device():
|
|
103
163
|
global _last_disc_device
|
|
@@ -140,13 +200,22 @@ def disc_device():
|
|
|
140
200
|
if "/dev/disk" in line and ("CD" in line or "DVD" in line or "optical" in line.lower()):
|
|
141
201
|
return line.strip().split()[0]
|
|
142
202
|
elif system == "windows":
|
|
143
|
-
|
|
203
|
+
info = _win_disc_info()
|
|
204
|
+
if info.get("drive"):
|
|
205
|
+
return info["drive"]
|
|
206
|
+
if override:
|
|
207
|
+
return override
|
|
144
208
|
raise FileNotFoundError("No optical disc drive found; set DISC_DEVICE explicitly")
|
|
145
209
|
|
|
146
210
|
|
|
147
211
|
def drive_status():
|
|
148
212
|
"""Non-Linux equivalent of the CDROM_DRIVE_STATUS ioctl.
|
|
149
|
-
Returns 'disc' | 'no_disc' | 'unknown'. macOS:
|
|
213
|
+
Returns 'disc' | 'no_disc' | 'unknown'. macOS: `drutil status`; Windows: IMAPI2/WMI."""
|
|
214
|
+
if system_name() == "windows":
|
|
215
|
+
info = _win_disc_info()
|
|
216
|
+
if not info:
|
|
217
|
+
return "unknown"
|
|
218
|
+
return "disc" if info.get("media_loaded") else "no_disc"
|
|
150
219
|
if system_name() != "darwin":
|
|
151
220
|
return "unknown"
|
|
152
221
|
try:
|
|
@@ -244,10 +313,35 @@ def media_properties(device):
|
|
|
244
313
|
props["ID_CDROM_MEDIA_DVD_PLUS_R"] = "1"
|
|
245
314
|
_tag_rewritable(props, optical)
|
|
246
315
|
return props
|
|
316
|
+
if system_name() == "windows":
|
|
317
|
+
info = _win_disc_info()
|
|
318
|
+
if not info.get("media_loaded"):
|
|
319
|
+
return {}
|
|
320
|
+
props = {"ID_CDROM": "1", "ID_CDROM_MEDIA": "1"}
|
|
321
|
+
if info.get("label"):
|
|
322
|
+
props["ID_FS_LABEL"] = info["label"]
|
|
323
|
+
if info.get("fs") in ("udf", "iso9660"):
|
|
324
|
+
props["ID_FS_TYPE"] = info["fs"]
|
|
325
|
+
mtype = (info.get("media_type") or "").lower()
|
|
326
|
+
if mtype == "audio_cd" or (not info.get("fs") and not info.get("blank") and mtype.startswith("cd")):
|
|
327
|
+
props["ID_CDROM_MEDIA_TYPE"] = "audio"
|
|
328
|
+
elif mtype.startswith("dvd") or mtype.startswith("bd"):
|
|
329
|
+
props["ID_CDROM_MEDIA_TYPE"] = "dvd"
|
|
330
|
+
if info.get("blank"):
|
|
331
|
+
props["ID_CDROM_MEDIA_STATE"] = "blank"
|
|
332
|
+
if "dvd+r dl" in mtype or "dvd-r dl" in mtype:
|
|
333
|
+
props["ID_CDROM_MEDIA_DVD_PLUS_R_DL"] = "1"
|
|
334
|
+
elif ("dvd+r" in mtype or "dvd-r" in mtype) and "rw" not in mtype:
|
|
335
|
+
props["ID_CDROM_MEDIA_DVD_PLUS_R"] = "1"
|
|
336
|
+
if info.get("rewritable"):
|
|
337
|
+
_tag_rewritable(props, mtype)
|
|
338
|
+
return props
|
|
247
339
|
return {}
|
|
248
340
|
|
|
249
341
|
|
|
250
342
|
def media_capacity_bytes(device):
|
|
343
|
+
if system_name() == "windows":
|
|
344
|
+
return _win_disc_info().get("capacity_bytes") or None
|
|
251
345
|
if system_name() == "darwin":
|
|
252
346
|
try:
|
|
253
347
|
result = subprocess.run(["/usr/sbin/diskutil", "info", device], capture_output=True, text=True, check=False, timeout=3)
|
|
@@ -267,9 +361,28 @@ def tool(name):
|
|
|
267
361
|
local = user_home() / ".local" / "bin" / name
|
|
268
362
|
if local.exists():
|
|
269
363
|
return str(local)
|
|
364
|
+
# pip-installed console scripts (yt-dlp, etc. from requirements.txt) land
|
|
365
|
+
# in the venv's own Scripts/bin dir, which isn't necessarily on PATH -
|
|
366
|
+
# e.g. the Windows Scheduled Task runs pythonw.exe directly, no shell
|
|
367
|
+
# activation. sys.exec_prefix is the venv root we're actually running in.
|
|
368
|
+
venv_bin = Path(sys.exec_prefix) / ("Scripts" if system_name() == "windows" else "bin")
|
|
369
|
+
for candidate_name in ((name + ".exe", name) if system_name() == "windows" else (name,)):
|
|
370
|
+
candidate = venv_bin / candidate_name
|
|
371
|
+
if candidate.exists():
|
|
372
|
+
return str(candidate)
|
|
270
373
|
path = shutil.which(name)
|
|
271
374
|
if path:
|
|
272
375
|
return path
|
|
376
|
+
if system_name() == "windows":
|
|
377
|
+
# install-windows.ps1's no-winget fallback downloads ffmpeg/yt-dlp
|
|
378
|
+
# into config_dir()/tools (e.g. an extracted "ffmpeg-9.0-essentials
|
|
379
|
+
# build\bin\"), and only puts it on the *installer script's own*
|
|
380
|
+
# session PATH - gone the moment the installer exits. Search that
|
|
381
|
+
# tree directly instead of relying on PATH.
|
|
382
|
+
exe = name if name.lower().endswith(".exe") else name + ".exe"
|
|
383
|
+
match = next((config_dir() / "tools").rglob(exe), None) if (config_dir() / "tools").exists() else None
|
|
384
|
+
if match:
|
|
385
|
+
return str(match)
|
|
273
386
|
if system_name() == "darwin":
|
|
274
387
|
candidates = [
|
|
275
388
|
Path("/opt/homebrew/bin") / name,
|
|
@@ -451,6 +564,12 @@ def eject_device(device, close=False):
|
|
|
451
564
|
except (OSError, subprocess.TimeoutExpired):
|
|
452
565
|
pass
|
|
453
566
|
return False
|
|
567
|
+
elif system == "windows":
|
|
568
|
+
args = [device] if device else []
|
|
569
|
+
if close:
|
|
570
|
+
args.append("-Close")
|
|
571
|
+
rc, _, _ = _run_ps("eject.ps1", *args, timeout=20)
|
|
572
|
+
return rc == 0
|
|
454
573
|
else:
|
|
455
|
-
raise RuntimeError("Automatic optical-drive eject is not implemented on
|
|
574
|
+
raise RuntimeError("Automatic optical-drive eject is not implemented on this OS")
|
|
456
575
|
return subprocess.run(command, capture_output=True, text=True, timeout=10).returncode == 0
|
package/src/static/app.js
CHANGED
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
setConnection(true);
|
|
67
67
|
setLiveStatus(progress.status);
|
|
68
68
|
setProgress(progress.status, Number(progress.progress), progress.active);
|
|
69
|
+
applyRemoteState(progress);
|
|
69
70
|
} catch (_) {
|
|
70
71
|
setConnection(false);
|
|
71
72
|
setLiveStatus("OFFLINE");
|
|
@@ -73,10 +74,67 @@
|
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
// --- On-screen remote: the exact same text commands the ESP32 sends -----
|
|
78
|
+
// POSTed to /remote/button, which feeds a VirtualSerial standing in for a
|
|
79
|
+
// real appliance. Only usable when no hardware remote is attached.
|
|
80
|
+
const remoteKey = "discstation-remote-visible";
|
|
81
|
+
|
|
82
|
+
function setRemoteVisible(visible, persist = false) {
|
|
83
|
+
$("remote-panel").hidden = !visible;
|
|
84
|
+
$("brand-link").classList.toggle("remote-active", visible);
|
|
85
|
+
if (persist) localStorage.setItem(remoteKey, visible ? "1" : "0");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function toggleRemotePanel() {
|
|
89
|
+
setRemoteVisible($("remote-panel").hidden, true);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function applyRemoteState(progress) {
|
|
93
|
+
const hardware = progress.appliance === "hardware";
|
|
94
|
+
if (hardware && !$("remote-panel").hidden) setRemoteVisible(false, true);
|
|
95
|
+
$("remote-note").textContent = hardware
|
|
96
|
+
? "A physical remote is attached — on-screen controls are disabled."
|
|
97
|
+
: "No physical remote detected — control DiscStation from here.";
|
|
98
|
+
$("remote-controls").querySelectorAll("button, input").forEach((el) => { el.disabled = hardware; });
|
|
99
|
+
$("remote-transport").hidden = !progress.playing;
|
|
100
|
+
const ejectBtn = $("remote-eject-btn");
|
|
101
|
+
const open = !!progress.tray_open;
|
|
102
|
+
ejectBtn.textContent = open ? "CLOSE TRAY" : "EJECT";
|
|
103
|
+
ejectBtn.dataset.cmd = open ? "CONFIRM" : "EJECT";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function sendRemoteCmd(cmd) {
|
|
107
|
+
try {
|
|
108
|
+
await fetch("/remote/button", {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
111
|
+
body: new URLSearchParams({ cmd })
|
|
112
|
+
});
|
|
113
|
+
} catch (_) { /* next poll/SSE tick reflects reality */ }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
$("brand-link").addEventListener("click", (event) => {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
toggleRemotePanel();
|
|
119
|
+
});
|
|
120
|
+
setRemoteVisible(localStorage.getItem(remoteKey) === "1");
|
|
121
|
+
|
|
122
|
+
$("remote-controls").addEventListener("click", (event) => {
|
|
123
|
+
const button = event.target.closest("[data-cmd]");
|
|
124
|
+
if (button && !button.disabled) sendRemoteCmd(button.dataset.cmd);
|
|
125
|
+
});
|
|
126
|
+
let volumeTimer;
|
|
127
|
+
$("remote-volume").addEventListener("input", (event) => {
|
|
128
|
+
clearTimeout(volumeTimer);
|
|
129
|
+
const value = event.target.value;
|
|
130
|
+
volumeTimer = setTimeout(() => sendRemoteCmd(`POT:${value}`), 150);
|
|
131
|
+
});
|
|
132
|
+
|
|
76
133
|
async function loadDiscInfo() {
|
|
77
134
|
try {
|
|
78
135
|
const response = await fetch("/disc-info", { cache: "no-store" });
|
|
79
136
|
const info = await response.json();
|
|
137
|
+
renderDiscStatus(info);
|
|
80
138
|
if (info.busy) return; // burn/rip in progress — keep current
|
|
81
139
|
state.discBytes = Number(info.capacity_bytes || 0);
|
|
82
140
|
state.discType = info.type || "none";
|
|
@@ -84,9 +142,22 @@
|
|
|
84
142
|
} catch (_) {
|
|
85
143
|
state.discBytes = 0;
|
|
86
144
|
state.discType = "none";
|
|
145
|
+
renderDiscStatus(null);
|
|
87
146
|
}
|
|
88
147
|
}
|
|
89
148
|
|
|
149
|
+
function renderDiscStatus(info) {
|
|
150
|
+
const el = $("remote-disc-status");
|
|
151
|
+
if (!el) return;
|
|
152
|
+
if (!info) { el.textContent = "DISC: UNKNOWN"; return; }
|
|
153
|
+
if (info.busy) { el.textContent = "DISC: BUSY (see status above)"; return; }
|
|
154
|
+
if (!info.disc_present) { el.textContent = "DISC: NONE"; return; }
|
|
155
|
+
const kind = (info.type || info.kind || "unknown").toUpperCase();
|
|
156
|
+
const label = info.label ? ` "${info.label}"` : "";
|
|
157
|
+
const size = info.capacity_gb ? ` // ${info.capacity_gb}GB` : "";
|
|
158
|
+
el.textContent = `DISC: ${kind}${label}${size}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
90
161
|
function renderSelection() {
|
|
91
162
|
const list = $("selection-list");
|
|
92
163
|
const total = state.entries.reduce((sum, entry) => sum + entry.file.size, 0);
|
|
@@ -269,6 +340,7 @@
|
|
|
269
340
|
setConnection(true);
|
|
270
341
|
setLiveStatus(d.status);
|
|
271
342
|
setProgress(d.status, Number(d.progress), d.active);
|
|
343
|
+
applyRemoteState(d);
|
|
272
344
|
});
|
|
273
345
|
es.addEventListener("open", () => { setConnection(true); loadDiscInfo(); });
|
|
274
346
|
es.addEventListener("error", () => {
|
package/src/static/index.html
CHANGED
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
<body>
|
|
14
14
|
<div class="page-shell">
|
|
15
15
|
<header class="topbar">
|
|
16
|
-
<a class="brand" href="/" aria-label="DiscStation home">
|
|
16
|
+
<a class="brand" href="/" id="brand-link" aria-label="DiscStation home / toggle remote">
|
|
17
17
|
<span class="brand-mark">DS</span>
|
|
18
18
|
<span>DISCSTATION</span>
|
|
19
|
+
<span id="remote-hint" class="remote-hint">REMOTE</span>
|
|
19
20
|
</a>
|
|
20
21
|
<div class="topbar-actions">
|
|
21
22
|
<button id="theme-toggle" class="theme-toggle" type="button" aria-label="Switch to dark mode">☾</button>
|
|
@@ -34,6 +35,39 @@
|
|
|
34
35
|
<div><strong>04</strong><span>RIP / PLAY</span></div>
|
|
35
36
|
</section>
|
|
36
37
|
|
|
38
|
+
<section id="remote-panel" class="remote-panel section-rule" hidden aria-labelledby="remote-title">
|
|
39
|
+
<div class="panel-heading">
|
|
40
|
+
<div>
|
|
41
|
+
<div class="section-kicker">ON-SCREEN REMOTE</div>
|
|
42
|
+
<h2 id="remote-title">CONTROL SURFACE</h2>
|
|
43
|
+
</div>
|
|
44
|
+
<span class="panel-index">DISCSTN-02</span>
|
|
45
|
+
</div>
|
|
46
|
+
<p id="remote-note" class="field-note"></p>
|
|
47
|
+
<div id="remote-disc-status" class="remote-disc-status">DISC: CHECKING</div>
|
|
48
|
+
<div id="remote-controls">
|
|
49
|
+
<div class="remote-grid">
|
|
50
|
+
<button class="outline-button" type="button" data-cmd="SELECT:BURN">BURN</button>
|
|
51
|
+
<button class="outline-button" type="button" data-cmd="SELECT:BURN DATA">BURN DATA</button>
|
|
52
|
+
<button class="outline-button" type="button" data-cmd="SELECT:BURN AUDIO">BURN AUDIO</button>
|
|
53
|
+
<button class="outline-button" type="button" data-cmd="SELECT:RIP">RIP</button>
|
|
54
|
+
<button class="outline-button" type="button" data-cmd="SELECT:PLAY">PLAY</button>
|
|
55
|
+
<button class="outline-button" type="button" data-cmd="CANCEL">CANCEL / HOME</button>
|
|
56
|
+
<button class="outline-button" type="button" id="remote-eject-btn" data-cmd="EJECT">EJECT</button>
|
|
57
|
+
</div>
|
|
58
|
+
<div id="remote-transport" class="remote-transport" hidden>
|
|
59
|
+
<div class="remote-grid">
|
|
60
|
+
<button class="outline-button" type="button" data-cmd="REW:BIG">⏮ PREV</button>
|
|
61
|
+
<button class="outline-button" type="button" data-cmd="PLAY_BUTTON">⏯ PLAY/PAUSE</button>
|
|
62
|
+
<button class="outline-button" type="button" data-cmd="FF:BIG">⏭ NEXT</button>
|
|
63
|
+
<button class="outline-button" type="button" data-cmd="PLAY_STOP">⏹ STOP</button>
|
|
64
|
+
</div>
|
|
65
|
+
<label class="field-label" for="remote-volume">VOLUME</label>
|
|
66
|
+
<input id="remote-volume" type="range" min="0" max="100" value="70">
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</section>
|
|
70
|
+
|
|
37
71
|
<section class="burn-panel section-rule" aria-labelledby="burn-title">
|
|
38
72
|
<div class="panel-heading">
|
|
39
73
|
<div>
|
|
@@ -105,7 +139,7 @@
|
|
|
105
139
|
</footer>
|
|
106
140
|
</div>
|
|
107
141
|
<div id="install-slot"></div>
|
|
108
|
-
<script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=
|
|
109
|
-
<script src="/static/app.js?v=
|
|
142
|
+
<script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=17").catch(() => {});</script>
|
|
143
|
+
<script src="/static/app.js?v=17" defer></script>
|
|
110
144
|
</body>
|
|
111
145
|
</html>
|
package/src/static/style.css
CHANGED
|
@@ -99,6 +99,27 @@ button { color: inherit; }
|
|
|
99
99
|
color: var(--ink);
|
|
100
100
|
text-decoration: none;
|
|
101
101
|
font-weight: 700;
|
|
102
|
+
cursor: pointer;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.remote-hint {
|
|
106
|
+
display: inline-flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
height: 16px;
|
|
109
|
+
padding: 0 6px;
|
|
110
|
+
border: 1px solid var(--soft-line);
|
|
111
|
+
color: var(--muted);
|
|
112
|
+
font-size: 8px;
|
|
113
|
+
font-weight: 700;
|
|
114
|
+
letter-spacing: 0.08em;
|
|
115
|
+
white-space: nowrap;
|
|
116
|
+
text-shadow: none;
|
|
117
|
+
transition: color 0.2s ease, border-color 0.2s ease, text-shadow 0.2s ease;
|
|
118
|
+
}
|
|
119
|
+
.brand.remote-active .remote-hint {
|
|
120
|
+
color: var(--ink);
|
|
121
|
+
border-color: var(--ink);
|
|
122
|
+
text-shadow: 0 0 6px var(--accent);
|
|
102
123
|
}
|
|
103
124
|
|
|
104
125
|
.brand-mark {
|
|
@@ -278,6 +299,21 @@ h2 { margin-top: 8px; font-family: var(--display); font-size: clamp(30px, 7vw, 6
|
|
|
278
299
|
.form-message.error { color: #9d4035; }
|
|
279
300
|
.form-message.ok { color: #287346; }
|
|
280
301
|
|
|
302
|
+
.remote-panel { padding: 26px 0 30px; }
|
|
303
|
+
.remote-disc-status {
|
|
304
|
+
margin: 10px 0 4px;
|
|
305
|
+
padding: 10px 12px;
|
|
306
|
+
border: 1px solid var(--ink);
|
|
307
|
+
background: var(--surface);
|
|
308
|
+
font-size: 11px;
|
|
309
|
+
font-weight: 700;
|
|
310
|
+
letter-spacing: 0.06em;
|
|
311
|
+
}
|
|
312
|
+
.remote-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-top: 16px; }
|
|
313
|
+
#remote-controls button:disabled, #remote-controls input:disabled { cursor: not-allowed; opacity: 0.4; }
|
|
314
|
+
.remote-transport { margin-top: 20px; padding-top: 18px; border-top: 1px solid var(--soft-line); }
|
|
315
|
+
.remote-transport input[type="range"] { width: 100%; margin-top: 4px; accent-color: var(--accent); }
|
|
316
|
+
|
|
281
317
|
.footer-stamp {
|
|
282
318
|
display: flex;
|
|
283
319
|
justify-content: space-between;
|
|
@@ -315,6 +351,8 @@ h2 { margin-top: 8px; font-family: var(--display); font-size: clamp(30px, 7vw, 6
|
|
|
315
351
|
.capability-strip div:not(:last-child) { border-right: 1px solid var(--soft-line); }
|
|
316
352
|
.capability-strip div + div { padding-left: 18px; }
|
|
317
353
|
.burn-panel { width: min(100%, 720px); margin-left: auto; margin-right: auto; }
|
|
354
|
+
.remote-panel { width: min(100%, 720px); margin-left: auto; margin-right: auto; }
|
|
355
|
+
.remote-panel .remote-grid { grid-template-columns: repeat(3, 1fr); }
|
|
318
356
|
}
|
|
319
357
|
|
|
320
358
|
@media (max-width: 600px) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Minimal JSON emitter - works on PowerShell 2.0 (Win7) and up.
|
|
2
|
+
function ConvertTo-JsonCompat {
|
|
3
|
+
param([Parameter(ValueFromPipeline = $true)] $obj)
|
|
4
|
+
if ($null -eq $obj) { return 'null' }
|
|
5
|
+
switch ($obj.GetType().Name) {
|
|
6
|
+
'Boolean' { return $obj.ToString().ToLower() }
|
|
7
|
+
'Int32' { return $obj.ToString() }
|
|
8
|
+
'Int64' { return $obj.ToString() }
|
|
9
|
+
'Double' { return $obj.ToString([System.Globalization.CultureInfo]::InvariantCulture) }
|
|
10
|
+
'String' {
|
|
11
|
+
$s = $obj -replace '\\', '\\' -replace '"', '\"' -replace "`r", '\r' -replace "`n", '\n' -replace "`t", '\t'
|
|
12
|
+
return '"' + $s + '"'
|
|
13
|
+
}
|
|
14
|
+
'Hashtable' {
|
|
15
|
+
$parts = @()
|
|
16
|
+
foreach ($k in $obj.Keys) { $parts += ('"' + $k + '":' + (ConvertTo-JsonCompat $obj[$k])) }
|
|
17
|
+
return '{' + ($parts -join ',') + '}'
|
|
18
|
+
}
|
|
19
|
+
'Object[]' {
|
|
20
|
+
$parts = @()
|
|
21
|
+
foreach ($v in $obj) { $parts += (ConvertTo-JsonCompat $v) }
|
|
22
|
+
return '[' + ($parts -join ',') + ']'
|
|
23
|
+
}
|
|
24
|
+
default {
|
|
25
|
+
$s = "$obj" -replace '\\', '\\' -replace '"', '\"'
|
|
26
|
+
return '"' + $s + '"'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|