discstation 0.1.0
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/LICENSE +21 -0
- package/README.md +137 -0
- package/arduino/c6/DiscStation_C6.ino +914 -0
- package/arduino/v1/DiscStation.ino +957 -0
- package/discstation.env.example +19 -0
- package/docs/PLATFORM_SUPPORT.md +39 -0
- package/install-macos.sh +80 -0
- package/install-windows.ps1 +24 -0
- package/install.sh +56 -0
- package/package.json +48 -0
- package/requirements.txt +20 -0
- package/scripts/setup.mjs +78 -0
- package/src/discstation.py +3984 -0
- package/src/discstation_burn.py +1697 -0
- package/src/discstation_host.py +380 -0
- package/src/discstation_meta.py +150 -0
- package/src/static/app.js +268 -0
- package/src/static/index.html +111 -0
- package/src/static/style.css +328 -0
- package/systemd/discstation.service +13 -0
|
@@ -0,0 +1,1697 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import datetime
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import serial
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import unicodedata
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from queue import Empty, Queue
|
|
15
|
+
|
|
16
|
+
import discstation_host
|
|
17
|
+
|
|
18
|
+
CLEANUP_DAYS = int(os.environ.get("DISC_CLEANUP_DAYS", "2"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _env_int(name, default):
|
|
22
|
+
try:
|
|
23
|
+
return int(os.environ.get(name, default))
|
|
24
|
+
except (TypeError, ValueError):
|
|
25
|
+
return int(default)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CancelError(Exception):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
SERIAL_WRITE_LOCK = threading.Lock()
|
|
33
|
+
|
|
34
|
+
# Shared "last time we actually heard from the ESP32" timestamp. Every place
|
|
35
|
+
# that reads a line off the serial port (station_loop's main loop, eject's
|
|
36
|
+
# tray-wait loop, burn/rip/play sub-loops, wait_for_burn_confirm, etc.) marks
|
|
37
|
+
# this on receipt of ANY line. The main watchdog in station_loop checks the
|
|
38
|
+
# age of this shared value instead of a loop-local variable, so a long
|
|
39
|
+
# blocking call (e.g. waiting up to 60s for the user to close the tray)
|
|
40
|
+
# can't make the watchdog think the ESP32 went silent the instant that call
|
|
41
|
+
# returns, even though it was responding the whole time.
|
|
42
|
+
_SERIAL_ACTIVITY_LOCK = threading.Lock()
|
|
43
|
+
_last_serial_activity = time.monotonic()
|
|
44
|
+
_serial_write_failed = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def note_serial_activity():
|
|
48
|
+
global _last_serial_activity
|
|
49
|
+
with _SERIAL_ACTIVITY_LOCK:
|
|
50
|
+
_last_serial_activity = time.monotonic()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def serial_activity_age():
|
|
54
|
+
with _SERIAL_ACTIVITY_LOCK:
|
|
55
|
+
return time.monotonic() - _last_serial_activity
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def reset_serial_state():
|
|
59
|
+
global _serial_write_failed
|
|
60
|
+
_serial_write_failed = False
|
|
61
|
+
note_serial_activity()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def serial_write_failed():
|
|
65
|
+
return _serial_write_failed
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_USB_OPTICAL_TOKENS = (
|
|
69
|
+
"slim", "dvd", "mediatek", "asus", "sdrw", "yzwy", "disk",
|
|
70
|
+
"ugreen", "asmedia", "ihas", "atapi", "optiarc", "lite-on", "liteon",
|
|
71
|
+
"hl-dt-st", "tsstcorp", "pioneer", "plextor", "nec", "bd-re", "blu-ray",
|
|
72
|
+
"sata bridge", "storage device", "external", "optical",
|
|
73
|
+
)
|
|
74
|
+
_USB_OPTICAL_VIDS = {"174c", "152d", "0480", "1c6b", "13fd", "04e8", "05e3", "357d"}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read_attr(directory, name):
|
|
78
|
+
try:
|
|
79
|
+
return (directory / name).read_text().strip()
|
|
80
|
+
except OSError:
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _run_usb_reset_hook(hook, device, vid, pid, busnode):
|
|
85
|
+
cmd = hook.format(dev=device, vid=vid, pid=pid, busnode=str(busnode))
|
|
86
|
+
print(f"reset_drive: running DISCSTATION_USB_RESET_CMD: {cmd}")
|
|
87
|
+
try:
|
|
88
|
+
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=20)
|
|
89
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
90
|
+
print(f"reset_drive: hook failed: {e}")
|
|
91
|
+
return False
|
|
92
|
+
if r.returncode != 0:
|
|
93
|
+
print(f"reset_drive: hook exited {r.returncode}: {r.stderr.strip()}")
|
|
94
|
+
return False
|
|
95
|
+
time.sleep(6)
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _toggle_usb_authorized(auth_path, label):
|
|
100
|
+
try:
|
|
101
|
+
with open(auth_path, "w") as f:
|
|
102
|
+
f.write("0\n")
|
|
103
|
+
time.sleep(2)
|
|
104
|
+
with open(auth_path, "w") as f:
|
|
105
|
+
f.write("1\n")
|
|
106
|
+
time.sleep(6)
|
|
107
|
+
print(f"reset_drive: re-authorized USB node {auth_path.parent} ({label})")
|
|
108
|
+
return True
|
|
109
|
+
except PermissionError:
|
|
110
|
+
user = os.environ.get("USER", "the service user")
|
|
111
|
+
print(f"reset_drive: no permission to re-authorize USB node {auth_path.parent} "
|
|
112
|
+
f"({label}); install a udev rule granting '{user}' write access to that "
|
|
113
|
+
f"node's 'authorized', or set DISCSTATION_USB_RESET_CMD to a privileged helper")
|
|
114
|
+
return False
|
|
115
|
+
except OSError as e:
|
|
116
|
+
print(f"reset_drive: failed to toggle {auth_path}: {e}")
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def reset_drive(device=None):
|
|
121
|
+
"""Power-cycle the USB optical enclosure by toggling its sysfs 'authorized'
|
|
122
|
+
flag (or via DISCSTATION_USB_RESET_CMD). Best-effort; returns True on a
|
|
123
|
+
completed toggle/hook, False otherwise."""
|
|
124
|
+
if discstation_host.system_name() != "linux":
|
|
125
|
+
return False
|
|
126
|
+
if not device:
|
|
127
|
+
try:
|
|
128
|
+
device = disc_device()
|
|
129
|
+
except Exception:
|
|
130
|
+
device = "/dev/sr0"
|
|
131
|
+
hook = os.environ.get("DISCSTATION_USB_RESET_CMD")
|
|
132
|
+
|
|
133
|
+
# Primary: walk sysfs up from the block device to its USB device node.
|
|
134
|
+
try:
|
|
135
|
+
cur = (Path("/sys/block") / Path(device).name / "device").resolve()
|
|
136
|
+
for _ in range(12):
|
|
137
|
+
if (cur / "idVendor").is_file() and (cur / "authorized").is_file():
|
|
138
|
+
vid = _read_attr(cur, "idVendor")
|
|
139
|
+
pid = _read_attr(cur, "idProduct")
|
|
140
|
+
label = f"{vid}:{pid} {_read_attr(cur, 'product')!r}"
|
|
141
|
+
if hook:
|
|
142
|
+
return _run_usb_reset_hook(hook, device, vid, pid, cur)
|
|
143
|
+
return _toggle_usb_authorized(cur / "authorized", label)
|
|
144
|
+
if cur.parent == cur or str(cur) in ("/sys", "/"):
|
|
145
|
+
break
|
|
146
|
+
cur = cur.parent
|
|
147
|
+
except OSError:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
# Fallback: scan every USB device, match on identity strings / known VIDs.
|
|
151
|
+
for auth in Path("/sys/bus/usb/devices").glob("*/authorized"):
|
|
152
|
+
parent = auth.parent
|
|
153
|
+
vid = _read_attr(parent, "idVendor").lower()
|
|
154
|
+
pid = _read_attr(parent, "idProduct")
|
|
155
|
+
product = _read_attr(parent, "product")
|
|
156
|
+
haystack = f"{product} {_read_attr(parent, 'manufacturer')}".lower()
|
|
157
|
+
if vid in _USB_OPTICAL_VIDS or any(tok in haystack for tok in _USB_OPTICAL_TOKENS):
|
|
158
|
+
label = f"{vid}:{pid} {product!r}"
|
|
159
|
+
if hook:
|
|
160
|
+
return _run_usb_reset_hook(hook, device, vid, pid, parent)
|
|
161
|
+
if _toggle_usb_authorized(auth, label):
|
|
162
|
+
return True
|
|
163
|
+
print("reset_drive: no matching USB optical device found to reset")
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
def _esp32_port_from_sysfs():
|
|
167
|
+
"""Find the ESP32's USB serial port by matching VID/PID 303a:1001
|
|
168
|
+
in sysfs. Returns the device path (e.g. /dev/ttyACM0) or None."""
|
|
169
|
+
for tty in Path("/sys/class/tty").glob("ttyACM*"):
|
|
170
|
+
uevent = tty / "device" / "uevent"
|
|
171
|
+
if uevent.exists():
|
|
172
|
+
modalias = uevent.read_text()
|
|
173
|
+
if "303a/1001" in modalias or "303a:1001" in modalias:
|
|
174
|
+
dev = Path("/dev") / tty.name
|
|
175
|
+
if dev.exists():
|
|
176
|
+
return str(dev)
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def detect_esp32_port():
|
|
181
|
+
return discstation_host.serial_port() or ""
|
|
182
|
+
|
|
183
|
+
PORT = detect_esp32_port()
|
|
184
|
+
BAUD = 115200
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def check_cancel(ser):
|
|
188
|
+
try:
|
|
189
|
+
if ser and ser.in_waiting:
|
|
190
|
+
line = ser.readline().decode(errors="ignore").strip()
|
|
191
|
+
note_serial_activity()
|
|
192
|
+
return line in ("CANCEL", "PLAY_STOP")
|
|
193
|
+
except (serial.SerialException, OSError):
|
|
194
|
+
pass
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def iter_proc_or_cancel(proc, ser):
|
|
199
|
+
lines = Queue()
|
|
200
|
+
finished = object()
|
|
201
|
+
|
|
202
|
+
def read_output():
|
|
203
|
+
try:
|
|
204
|
+
for line in proc.stdout:
|
|
205
|
+
lines.put(line.rstrip("\r\n"))
|
|
206
|
+
finally:
|
|
207
|
+
lines.put(finished)
|
|
208
|
+
|
|
209
|
+
reader = threading.Thread(target=read_output, daemon=True)
|
|
210
|
+
reader.start()
|
|
211
|
+
last_ping = time.time()
|
|
212
|
+
output_done = False
|
|
213
|
+
while proc.poll() is None or not output_done:
|
|
214
|
+
if time.time() - last_ping >= 5:
|
|
215
|
+
last_ping = time.time()
|
|
216
|
+
send(ser, "PING")
|
|
217
|
+
|
|
218
|
+
if check_cancel(ser):
|
|
219
|
+
stop_process(proc)
|
|
220
|
+
return
|
|
221
|
+
try:
|
|
222
|
+
line = lines.get(timeout=0.2)
|
|
223
|
+
except Empty:
|
|
224
|
+
continue
|
|
225
|
+
if line is finished:
|
|
226
|
+
output_done = True
|
|
227
|
+
else:
|
|
228
|
+
yield line
|
|
229
|
+
reader.join(timeout=1)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
DVD_DEVICE = os.environ.get("DISC_DEVICE") or os.environ.get("DVD_DEVICE")
|
|
233
|
+
DISC_SPEED = os.environ.get("DISC_SPEED")
|
|
234
|
+
DISC_DISC_BYTES = 4_700_000_000
|
|
235
|
+
DVD_DL_BYTES = 8_500_000_000
|
|
236
|
+
DISC_TARGET_BYTES = int(os.environ.get("DISC_TARGET_BYTES", "4300000000"))
|
|
237
|
+
DVD_MUX_SAFETY = float(os.environ.get("DVD_MUX_SAFETY", "0.92"))
|
|
238
|
+
AUDIO_BITRATE_K = int(os.environ.get("DVD_AUDIO_KBPS", "192"))
|
|
239
|
+
MIN_VIDEO_BITRATE_K = 500
|
|
240
|
+
MAX_VIDEO_BITRATE_K = 7150
|
|
241
|
+
MAX_VIDEO_PEAK_K = 9000
|
|
242
|
+
YTDLP_FORMAT = os.environ.get(
|
|
243
|
+
"YTDLP_FORMAT",
|
|
244
|
+
"bestvideo[height<=720][vcodec^=avc1][ext=mp4]+bestaudio[acodec^=mp4a][ext=m4a]/"
|
|
245
|
+
"bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/"
|
|
246
|
+
"best[height<=720][ext=mp4]/best[ext=mp4]/bv*+ba/b",
|
|
247
|
+
)
|
|
248
|
+
YTDLP_PLAYER_CLIENTS = tuple(
|
|
249
|
+
client.strip()
|
|
250
|
+
for client in os.environ.get("YTDLP_PLAYER_CLIENTS", "web_embedded,android_vr").split(",")
|
|
251
|
+
if client.strip()
|
|
252
|
+
)
|
|
253
|
+
YTDLP_HTTP_CHUNK_SIZE = os.environ.get("YTDLP_HTTP_CHUNK_SIZE", "1M")
|
|
254
|
+
YTDLP_RETRIES = os.environ.get("YTDLP_RETRIES", "3")
|
|
255
|
+
YTDLP_FRAGMENT_RETRIES = os.environ.get("YTDLP_FRAGMENT_RETRIES", "3")
|
|
256
|
+
YTDLP_COOKIES = os.environ.get("YTDLP_COOKIES")
|
|
257
|
+
YTDLP_COOKIES_FROM_BROWSER = os.environ.get("YTDLP_COOKIES_FROM_BROWSER")
|
|
258
|
+
YTDLP_USER_AGENT = os.environ.get("YTDLP_USER_AGENT")
|
|
259
|
+
YTDLP_PO_TOKEN = os.environ.get("YTDLP_PO_TOKEN")
|
|
260
|
+
YTDLP_EXTRACTOR_ARGS = os.environ.get("YTDLP_EXTRACTOR_ARGS")
|
|
261
|
+
DISC_OUTPUT_LIMIT_BYTES = int(
|
|
262
|
+
os.environ.get("DISC_OUTPUT_LIMIT_BYTES", str(DISC_TARGET_BYTES))
|
|
263
|
+
)
|
|
264
|
+
DISC_DL_OUTPUT_LIMIT_BYTES = int(
|
|
265
|
+
os.environ.get("DISC_DL_OUTPUT_LIMIT_BYTES", "8000000000")
|
|
266
|
+
)
|
|
267
|
+
MODE_SETTINGS = {
|
|
268
|
+
"AUTO": {
|
|
269
|
+
"target_bytes": DISC_TARGET_BYTES,
|
|
270
|
+
"safety": DVD_MUX_SAFETY,
|
|
271
|
+
"audio_k": AUDIO_BITRATE_K,
|
|
272
|
+
"min_video_k": MIN_VIDEO_BITRATE_K,
|
|
273
|
+
"max_video_k": MAX_VIDEO_BITRATE_K,
|
|
274
|
+
"peak_video_k": MAX_VIDEO_PEAK_K,
|
|
275
|
+
"burn": True,
|
|
276
|
+
},
|
|
277
|
+
"BEST": {
|
|
278
|
+
"target_bytes": int(os.environ.get("DISC_BEST_TARGET_BYTES", "4450000000")),
|
|
279
|
+
"safety": float(os.environ.get("DISC_BEST_SAFETY", "0.95")),
|
|
280
|
+
"audio_k": int(os.environ.get("DISC_BEST_AUDIO_KBPS", "224")),
|
|
281
|
+
"min_video_k": 700,
|
|
282
|
+
"max_video_k": 8000,
|
|
283
|
+
"peak_video_k": 9000,
|
|
284
|
+
"burn": True,
|
|
285
|
+
},
|
|
286
|
+
"LONG": {
|
|
287
|
+
"target_bytes": int(os.environ.get("DISC_LONG_TARGET_BYTES", "4300000000")),
|
|
288
|
+
"safety": float(os.environ.get("DISC_LONG_SAFETY", "0.90")),
|
|
289
|
+
"audio_k": int(os.environ.get("DISC_LONG_AUDIO_KBPS", "128")),
|
|
290
|
+
"min_video_k": 350,
|
|
291
|
+
"max_video_k": 3500,
|
|
292
|
+
"peak_video_k": 6000,
|
|
293
|
+
"burn": True,
|
|
294
|
+
},
|
|
295
|
+
"TEST": {
|
|
296
|
+
"target_bytes": DISC_TARGET_BYTES,
|
|
297
|
+
"safety": DVD_MUX_SAFETY,
|
|
298
|
+
"audio_k": AUDIO_BITRATE_K,
|
|
299
|
+
"min_video_k": MIN_VIDEO_BITRATE_K,
|
|
300
|
+
"max_video_k": MAX_VIDEO_BITRATE_K,
|
|
301
|
+
"peak_video_k": MAX_VIDEO_PEAK_K,
|
|
302
|
+
"burn": False,
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
USER_HOME = discstation_host.user_home()
|
|
307
|
+
WORK = discstation_host.cache_dir()
|
|
308
|
+
|
|
309
|
+
def cleanup_old_jobs():
|
|
310
|
+
cutoff = time.time() - CLEANUP_DAYS * 86400
|
|
311
|
+
if not WORK.is_dir():
|
|
312
|
+
return
|
|
313
|
+
removed = 0
|
|
314
|
+
for entry in WORK.iterdir():
|
|
315
|
+
if entry.name.startswith("job_") and entry.is_dir():
|
|
316
|
+
try:
|
|
317
|
+
mtime = entry.stat().st_mtime
|
|
318
|
+
if mtime < cutoff:
|
|
319
|
+
shutil.rmtree(entry, ignore_errors=True)
|
|
320
|
+
removed += 1
|
|
321
|
+
except OSError:
|
|
322
|
+
pass
|
|
323
|
+
if removed:
|
|
324
|
+
print(f"Cleaned up {removed} old job(s) (> {CLEANUP_DAYS}d)")
|
|
325
|
+
|
|
326
|
+
def tool(name):
|
|
327
|
+
return discstation_host.tool(name)
|
|
328
|
+
|
|
329
|
+
def js_runtime_arg():
|
|
330
|
+
override = os.environ.get("YTDLP_JS_RUNTIME")
|
|
331
|
+
if override:
|
|
332
|
+
return ["--js-runtimes", override]
|
|
333
|
+
for name in ("node", "/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node", "/bin/node"):
|
|
334
|
+
path = shutil.which(name) if not name.startswith("/") else name
|
|
335
|
+
if path and Path(path).exists():
|
|
336
|
+
return ["--js-runtimes", f"node:{path}"]
|
|
337
|
+
return []
|
|
338
|
+
|
|
339
|
+
def remote_components_arg():
|
|
340
|
+
value = os.environ.get("YTDLP_REMOTE_COMPONENTS", "ejs:github")
|
|
341
|
+
if value.lower() in ("", "0", "false", "none", "off"):
|
|
342
|
+
return []
|
|
343
|
+
return ["--remote-components", value]
|
|
344
|
+
|
|
345
|
+
def ffmpeg_location_arg():
|
|
346
|
+
try:
|
|
347
|
+
return ["--ffmpeg-location", str(Path(tool("ffmpeg")).parent)]
|
|
348
|
+
except FileNotFoundError:
|
|
349
|
+
return []
|
|
350
|
+
|
|
351
|
+
def ytdlp_base_args(player_client=None):
|
|
352
|
+
args = [tool('yt-dlp'), "--no-playlist", *js_runtime_arg(), *remote_components_arg(), *ffmpeg_location_arg()]
|
|
353
|
+
if player_client or YTDLP_PO_TOKEN or YTDLP_EXTRACTOR_ARGS:
|
|
354
|
+
extractor_args = []
|
|
355
|
+
if player_client:
|
|
356
|
+
extractor_args.append(f"youtube:player_client={player_client}")
|
|
357
|
+
if YTDLP_PO_TOKEN:
|
|
358
|
+
extractor_args.append(f"youtube:po_token={YTDLP_PO_TOKEN}")
|
|
359
|
+
if YTDLP_EXTRACTOR_ARGS:
|
|
360
|
+
extractor_args.append(YTDLP_EXTRACTOR_ARGS)
|
|
361
|
+
args += ["--extractor-args", ";".join(extractor_args)]
|
|
362
|
+
if YTDLP_HTTP_CHUNK_SIZE:
|
|
363
|
+
args += ["--http-chunk-size", YTDLP_HTTP_CHUNK_SIZE]
|
|
364
|
+
args += ["--retries", YTDLP_RETRIES, "--fragment-retries", YTDLP_FRAGMENT_RETRIES]
|
|
365
|
+
if YTDLP_COOKIES:
|
|
366
|
+
args += ["--cookies", YTDLP_COOKIES]
|
|
367
|
+
elif YTDLP_COOKIES_FROM_BROWSER:
|
|
368
|
+
args += ["--cookies-from-browser", YTDLP_COOKIES_FROM_BROWSER]
|
|
369
|
+
if YTDLP_USER_AGENT:
|
|
370
|
+
args += ["--user-agent", YTDLP_USER_AGENT]
|
|
371
|
+
return args
|
|
372
|
+
|
|
373
|
+
def disc_device():
|
|
374
|
+
return discstation_host.disc_device()
|
|
375
|
+
|
|
376
|
+
def _udevadm_props(device):
|
|
377
|
+
# Prefer the shared implementation in discstation (pyudev-backed, with a
|
|
378
|
+
# cdrom_id refresh). Lazy import to avoid the import cycle with discstation.
|
|
379
|
+
try:
|
|
380
|
+
from discstation import udev_cdrom_properties
|
|
381
|
+
return udev_cdrom_properties(device)
|
|
382
|
+
except Exception:
|
|
383
|
+
pass
|
|
384
|
+
if discstation_host.system_name() != "linux":
|
|
385
|
+
return discstation_host.media_properties(device)
|
|
386
|
+
try:
|
|
387
|
+
r = subprocess.run(
|
|
388
|
+
["udevadm", "info", "--query=property", "--name", device],
|
|
389
|
+
capture_output=True, text=True, timeout=3,
|
|
390
|
+
)
|
|
391
|
+
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
|
392
|
+
return {}
|
|
393
|
+
props = {}
|
|
394
|
+
for line in r.stdout.splitlines():
|
|
395
|
+
if "=" in line:
|
|
396
|
+
k, v = line.split("=", 1)
|
|
397
|
+
props[k] = v
|
|
398
|
+
return props
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def disc_capacity_bytes(device):
|
|
402
|
+
override = os.environ.get("DISC_DISC_BYTES")
|
|
403
|
+
if override:
|
|
404
|
+
try:
|
|
405
|
+
return int(override)
|
|
406
|
+
except ValueError:
|
|
407
|
+
pass
|
|
408
|
+
|
|
409
|
+
if discstation_host.system_name() != "linux":
|
|
410
|
+
return discstation_host.media_capacity_bytes(device)
|
|
411
|
+
props = _udevadm_props(device)
|
|
412
|
+
is_dl = (
|
|
413
|
+
props.get("ID_CDROM_MEDIA_DVD_PLUS_R_DL") == "1" or
|
|
414
|
+
props.get("ID_CDROM_MEDIA_DVD_R_DL") == "1" or
|
|
415
|
+
props.get("ID_CDROM_MEDIA_DVD_R_DL_SEQ") == "1"
|
|
416
|
+
)
|
|
417
|
+
expected_min = 1_000_000_000
|
|
418
|
+
expected_max = DVD_DL_BYTES if is_dl else DISC_DISC_BYTES
|
|
419
|
+
|
|
420
|
+
mediainfo_timeout = _env_int("DISCSTATION_PROBE_TIMEOUT_MEDIAINFO", 12)
|
|
421
|
+
best = None
|
|
422
|
+
for attempt in range(3):
|
|
423
|
+
try:
|
|
424
|
+
r = subprocess.run(
|
|
425
|
+
["dvd+rw-mediainfo", device],
|
|
426
|
+
capture_output=True, text=True, timeout=mediainfo_timeout,
|
|
427
|
+
)
|
|
428
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
429
|
+
time.sleep(0.5)
|
|
430
|
+
continue
|
|
431
|
+
if r.returncode == 0:
|
|
432
|
+
for line in r.stdout.splitlines():
|
|
433
|
+
line = line.strip()
|
|
434
|
+
if "Free Blocks:" in line:
|
|
435
|
+
parts = line.split()
|
|
436
|
+
if len(parts) >= 3:
|
|
437
|
+
try:
|
|
438
|
+
blocks = int(parts[2].split("*")[0])
|
|
439
|
+
cap = blocks * 2048
|
|
440
|
+
if best is None or cap > best:
|
|
441
|
+
best = cap
|
|
442
|
+
except (ValueError, IndexError):
|
|
443
|
+
pass
|
|
444
|
+
if best is not None and best > expected_max // 2:
|
|
445
|
+
break
|
|
446
|
+
time.sleep(0.5)
|
|
447
|
+
|
|
448
|
+
if best is not None:
|
|
449
|
+
if best > expected_max + 100_000_000:
|
|
450
|
+
best = expected_max
|
|
451
|
+
elif best < expected_min:
|
|
452
|
+
best = None
|
|
453
|
+
elif is_dl and best < DISC_DISC_BYTES:
|
|
454
|
+
best = None
|
|
455
|
+
|
|
456
|
+
if best is not None:
|
|
457
|
+
return best
|
|
458
|
+
|
|
459
|
+
if props.get("ID_CDROM_MEDIA_STATE") == "blank":
|
|
460
|
+
if is_dl:
|
|
461
|
+
return DVD_DL_BYTES
|
|
462
|
+
if is_dl:
|
|
463
|
+
return DVD_DL_BYTES
|
|
464
|
+
if props.get("ID_CDROM_MEDIA_DVD_PLUS_R") == "1" or \
|
|
465
|
+
props.get("ID_CDROM_MEDIA_DVD_R") == "1":
|
|
466
|
+
return DISC_DISC_BYTES
|
|
467
|
+
|
|
468
|
+
# Last resort: a raw block size (works for finalized/pressed discs where
|
|
469
|
+
# dvd+rw-mediainfo reports no free blocks; 0/absent for audio CDs).
|
|
470
|
+
try:
|
|
471
|
+
r = subprocess.run(["blockdev", "--getsize64", device],
|
|
472
|
+
capture_output=True, text=True, timeout=5)
|
|
473
|
+
if r.returncode == 0:
|
|
474
|
+
val = int(r.stdout.strip())
|
|
475
|
+
if val >= expected_min:
|
|
476
|
+
return min(val, expected_max)
|
|
477
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError):
|
|
478
|
+
pass
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
def detect_disc_type(device):
|
|
482
|
+
props = _udevadm_props(device)
|
|
483
|
+
is_dl = (
|
|
484
|
+
props.get("ID_CDROM_MEDIA_DVD_PLUS_R_DL") == "1" or
|
|
485
|
+
props.get("ID_CDROM_MEDIA_DVD_R_DL") == "1" or
|
|
486
|
+
props.get("ID_CDROM_MEDIA_DVD_R_DL_SEQ") == "1"
|
|
487
|
+
)
|
|
488
|
+
is_sl = (
|
|
489
|
+
props.get("ID_CDROM_MEDIA_DVD_PLUS_R") == "1" or
|
|
490
|
+
props.get("ID_CDROM_MEDIA_DVD_R") == "1"
|
|
491
|
+
)
|
|
492
|
+
is_blank = props.get("ID_CDROM_MEDIA_STATE") == "blank"
|
|
493
|
+
media_type = props.get("ID_CDROM_MEDIA", "")
|
|
494
|
+
|
|
495
|
+
status = "blank" if is_blank else props.get("ID_CDROM_MEDIA_STATE", "unknown")
|
|
496
|
+
|
|
497
|
+
capacity = disc_capacity_bytes(device)
|
|
498
|
+
if capacity is None:
|
|
499
|
+
capacity = DVD_DL_BYTES if is_dl else (DISC_DISC_BYTES if is_sl else None)
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
"is_dual_layer": is_dl,
|
|
503
|
+
"is_single_layer": is_sl,
|
|
504
|
+
"is_blank": is_blank,
|
|
505
|
+
"status": status,
|
|
506
|
+
"media_type": media_type,
|
|
507
|
+
"capacity": capacity,
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
def send(ser, msg):
|
|
511
|
+
global _serial_write_failed
|
|
512
|
+
if not ser:
|
|
513
|
+
return False
|
|
514
|
+
try:
|
|
515
|
+
with SERIAL_WRITE_LOCK:
|
|
516
|
+
ser.write((msg + '\n').encode())
|
|
517
|
+
except (serial.SerialException, OSError) as e:
|
|
518
|
+
if not _serial_write_failed:
|
|
519
|
+
print(f"serial send error ('{msg[:30]}'): {e}")
|
|
520
|
+
_serial_write_failed = True
|
|
521
|
+
return False
|
|
522
|
+
except Exception as e:
|
|
523
|
+
print(f"serial send error ('{msg[:30]}'): {e}")
|
|
524
|
+
return False
|
|
525
|
+
return True
|
|
526
|
+
|
|
527
|
+
def safe_send(ser, msg):
|
|
528
|
+
if not ser:
|
|
529
|
+
return False
|
|
530
|
+
try:
|
|
531
|
+
return send(ser, msg)
|
|
532
|
+
except Exception:
|
|
533
|
+
return False
|
|
534
|
+
|
|
535
|
+
def stop_process(proc):
|
|
536
|
+
if proc.poll() is not None:
|
|
537
|
+
return
|
|
538
|
+
proc.terminate()
|
|
539
|
+
try:
|
|
540
|
+
proc.wait(timeout=5)
|
|
541
|
+
except subprocess.TimeoutExpired:
|
|
542
|
+
proc.kill()
|
|
543
|
+
proc.wait()
|
|
544
|
+
|
|
545
|
+
def normalize_mode(mode):
|
|
546
|
+
mode = (mode or "AUTO").strip().upper()
|
|
547
|
+
return mode if mode in MODE_SETTINGS else "AUTO"
|
|
548
|
+
|
|
549
|
+
def probe_duration(infile):
|
|
550
|
+
r = subprocess.run(
|
|
551
|
+
[tool('ffprobe'), '-v', 'quiet', '-show_entries', 'format=duration',
|
|
552
|
+
'-of', 'default=noprint_wrappers=1:nokey=1', str(infile)],
|
|
553
|
+
capture_output=True, text=True)
|
|
554
|
+
return float(r.stdout.strip()) if r.stdout.strip() else 0
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def disc_output_limit_bytes(disc_bytes=None):
|
|
559
|
+
"""Return the conservative payload limit used before authoring/burning."""
|
|
560
|
+
if disc_bytes and disc_bytes > 6_000_000_000:
|
|
561
|
+
return min(int(disc_bytes), DISC_DL_OUTPUT_LIMIT_BYTES)
|
|
562
|
+
if disc_bytes:
|
|
563
|
+
return min(int(disc_bytes), DISC_OUTPUT_LIMIT_BYTES)
|
|
564
|
+
return DISC_OUTPUT_LIMIT_BYTES
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def bitrate_plan(duration, mode="AUTO", disc_bytes=None):
|
|
568
|
+
mode = normalize_mode(mode)
|
|
569
|
+
settings = MODE_SETTINGS[mode]
|
|
570
|
+
|
|
571
|
+
# Use detected capacity when available, but never exceed the configured
|
|
572
|
+
# conservative payload limit for the disc layer.
|
|
573
|
+
target_bytes = disc_bytes if disc_bytes else settings["target_bytes"]
|
|
574
|
+
target_bytes = min(target_bytes, disc_output_limit_bytes(disc_bytes))
|
|
575
|
+
|
|
576
|
+
if duration <= 0:
|
|
577
|
+
return {
|
|
578
|
+
"mode": mode,
|
|
579
|
+
"video_k": min(3600, settings["max_video_k"]),
|
|
580
|
+
"audio_k": settings["audio_k"],
|
|
581
|
+
"max_video_k": settings["max_video_k"],
|
|
582
|
+
"peak_video_k": settings.get("peak_video_k", settings["max_video_k"]),
|
|
583
|
+
"burn": settings["burn"],
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
usable_bits = target_bytes * 8 * settings["safety"]
|
|
587
|
+
total_kbps = usable_bits / duration / 1000
|
|
588
|
+
video_kbps = int(total_kbps - settings["audio_k"])
|
|
589
|
+
|
|
590
|
+
if video_kbps < settings["min_video_k"]:
|
|
591
|
+
raise RuntimeError(f"Video too long for {mode}")
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
"mode": mode,
|
|
595
|
+
"video_k": min(video_kbps, settings["max_video_k"]),
|
|
596
|
+
"audio_k": settings["audio_k"],
|
|
597
|
+
"max_video_k": settings["max_video_k"],
|
|
598
|
+
"peak_video_k": settings.get("peak_video_k", settings["max_video_k"]),
|
|
599
|
+
"burn": settings["burn"],
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
def tree_size(path):
|
|
603
|
+
return sum(p.stat().st_size for p in Path(path).rglob("*") if p.is_file())
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def check_encoded_size(ser, mpg, disc_bytes=None):
|
|
607
|
+
size = Path(mpg).stat().st_size
|
|
608
|
+
limit = disc_output_limit_bytes(disc_bytes)
|
|
609
|
+
send(ser, f"PROGRESS:{size / 1_000_000_000:.2f}GB / {limit / 1_000_000_000:.2f}GB")
|
|
610
|
+
if size > limit:
|
|
611
|
+
raise RuntimeError(
|
|
612
|
+
f"Video output {size / 1_000_000_000:.2f}GB exceeds safe limit "
|
|
613
|
+
f"{limit / 1_000_000_000:.2f}GB"
|
|
614
|
+
)
|
|
615
|
+
return size
|
|
616
|
+
|
|
617
|
+
def sanitize_disc_label(title):
|
|
618
|
+
label = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode()
|
|
619
|
+
label = re.sub(r"[^A-Za-z0-9]+", "_", label.upper()).strip("_")
|
|
620
|
+
return (label or "DVD_VIDEO")[:32]
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def audio_disc_title(title):
|
|
624
|
+
label = unicodedata.normalize("NFKC", str(title or ""))
|
|
625
|
+
label = re.sub(r"[\x00-\x1f\x7f\"]", " ", label)
|
|
626
|
+
label = " ".join(label.split())
|
|
627
|
+
return label[:64] or "Audio CD"
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def cdrdao_text(value):
|
|
631
|
+
return audio_disc_title(value).replace("\\", "\\\\").replace('"', '\\"')
|
|
632
|
+
|
|
633
|
+
def format_duration(seconds):
|
|
634
|
+
if not seconds or seconds <= 0:
|
|
635
|
+
return "Dur unknown"
|
|
636
|
+
|
|
637
|
+
total_minutes = int(round(seconds / 60))
|
|
638
|
+
hours = total_minutes // 60
|
|
639
|
+
minutes = total_minutes % 60
|
|
640
|
+
|
|
641
|
+
if hours:
|
|
642
|
+
return f"Dur {hours}h{minutes:02d}m"
|
|
643
|
+
return f"Dur {minutes}m"
|
|
644
|
+
|
|
645
|
+
def preflight_lines(duration, disc_bytes=None):
|
|
646
|
+
duration_line = format_duration(duration)
|
|
647
|
+
|
|
648
|
+
label = "DVD"
|
|
649
|
+
if disc_bytes:
|
|
650
|
+
if disc_bytes < 1_500_000_000:
|
|
651
|
+
label = "CD"
|
|
652
|
+
elif disc_bytes > 6_000_000_000:
|
|
653
|
+
label = "DVD9"
|
|
654
|
+
else:
|
|
655
|
+
label = "DVD5"
|
|
656
|
+
|
|
657
|
+
if not duration or duration <= 0:
|
|
658
|
+
return duration_line, f"{label} estimate unknown", True
|
|
659
|
+
|
|
660
|
+
ok_modes = []
|
|
661
|
+
for mode in ("AUTO", "BEST", "LONG"):
|
|
662
|
+
try:
|
|
663
|
+
ok_modes.append(bitrate_plan(duration, mode, disc_bytes))
|
|
664
|
+
except RuntimeError:
|
|
665
|
+
pass
|
|
666
|
+
|
|
667
|
+
if not ok_modes:
|
|
668
|
+
return duration_line, f"Too long for {label}", False
|
|
669
|
+
|
|
670
|
+
auto_plan = next((plan for plan in ok_modes if plan["mode"] == "AUTO"), None)
|
|
671
|
+
if auto_plan:
|
|
672
|
+
return duration_line, f"{label} OK {auto_plan['video_k']}k", True
|
|
673
|
+
|
|
674
|
+
return duration_line, "Use LONG mode", True
|
|
675
|
+
|
|
676
|
+
VIDEO_EXTS = {'.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.m4v', '.mpg', '.mpeg', '.vob', '.ts', '.webm', '.ogv'}
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def is_local_file(path):
|
|
680
|
+
return Path(path).is_file()
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def find_video_files(path):
|
|
684
|
+
p = Path(path)
|
|
685
|
+
if p.is_file():
|
|
686
|
+
return [p] if p.suffix.lower() in VIDEO_EXTS else []
|
|
687
|
+
if p.is_dir():
|
|
688
|
+
files = sorted(p.iterdir())
|
|
689
|
+
videos = [f for f in files if f.is_file() and f.suffix.lower() in VIDEO_EXTS]
|
|
690
|
+
return videos
|
|
691
|
+
return []
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def get_local_video_info(path):
|
|
695
|
+
p = Path(path)
|
|
696
|
+
dur = probe_duration(p)
|
|
697
|
+
stem = p.stem.replace("_", " ").title() or "Local file"
|
|
698
|
+
return {"title": stem, "duration": dur}
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def get_video_info(source):
|
|
702
|
+
p = Path(source)
|
|
703
|
+
if p.is_dir():
|
|
704
|
+
videos = find_video_files(source)
|
|
705
|
+
if not videos:
|
|
706
|
+
raise RuntimeError(f"No video files found in directory: {source}")
|
|
707
|
+
total_dur = sum(probe_duration(v) for v in videos)
|
|
708
|
+
return {"title": p.name.replace("_", " ").title(), "duration": total_dur, "files": videos}
|
|
709
|
+
if is_local_file(source):
|
|
710
|
+
return get_local_video_info(source)
|
|
711
|
+
errors = []
|
|
712
|
+
for player_client in YTDLP_PLAYER_CLIENTS or (None,):
|
|
713
|
+
r = subprocess.run(
|
|
714
|
+
[*ytdlp_base_args(player_client), '--dump-single-json', '--skip-download', source],
|
|
715
|
+
capture_output=True,
|
|
716
|
+
text=True,
|
|
717
|
+
)
|
|
718
|
+
if r.returncode != 0:
|
|
719
|
+
errors.append(r.stderr.strip() or f"{player_client or 'default'} client failed")
|
|
720
|
+
continue
|
|
721
|
+
try:
|
|
722
|
+
data = json.loads(r.stdout)
|
|
723
|
+
except json.JSONDecodeError as e:
|
|
724
|
+
errors.append(f"Could not parse video info: {e}")
|
|
725
|
+
continue
|
|
726
|
+
return {
|
|
727
|
+
"title": data.get("title") or "Untitled video",
|
|
728
|
+
"duration": float(data.get("duration") or 0),
|
|
729
|
+
}
|
|
730
|
+
detail = next((error for error in reversed(errors) if error), "Could not get video info")
|
|
731
|
+
raise RuntimeError(detail[:300])
|
|
732
|
+
|
|
733
|
+
def _start_keepalive(ser):
|
|
734
|
+
"""Start a background PING sender. The ESP32 firmware treats any
|
|
735
|
+
received message as a heartbeat (lastMsgTime), and shows itself as
|
|
736
|
+
disconnected after PING_TIMEOUT_MS (30s) of silence. Any blocking
|
|
737
|
+
operation longer than that — a big file copy, an ffmpeg concat —
|
|
738
|
+
needs one of these running, or the OLED will flip to "disconnected"
|
|
739
|
+
partway through even though nothing is actually wrong.
|
|
740
|
+
Returns (stop_event, thread); caller must stop_event.set() and
|
|
741
|
+
thread.join() when the blocking operation finishes."""
|
|
742
|
+
stop_ping = threading.Event()
|
|
743
|
+
def _ping_thread():
|
|
744
|
+
while not stop_ping.is_set():
|
|
745
|
+
try:
|
|
746
|
+
send(ser, "PING")
|
|
747
|
+
except Exception:
|
|
748
|
+
pass
|
|
749
|
+
stop_ping.wait(5)
|
|
750
|
+
pt = threading.Thread(target=_ping_thread, daemon=True)
|
|
751
|
+
pt.start()
|
|
752
|
+
return stop_ping, pt
|
|
753
|
+
|
|
754
|
+
def copy_with_keepalive(ser, src, dest, base_pct=0, pct_span=100):
|
|
755
|
+
"""Chunked copy with PROGRESS updates and a keepalive ping thread,
|
|
756
|
+
so large copies don't sit silent long enough to trip the ESP32's
|
|
757
|
+
connection watchdog. base_pct/pct_span let callers map one file's
|
|
758
|
+
progress into a slice of an overall multi-file progress range."""
|
|
759
|
+
src_size = src.stat().st_size
|
|
760
|
+
copied = 0
|
|
761
|
+
last_beat = time.time()
|
|
762
|
+
stop_ping, pt = _start_keepalive(ser)
|
|
763
|
+
try:
|
|
764
|
+
with open(str(src), 'rb') as fin, open(str(dest), 'wb') as fout:
|
|
765
|
+
while True:
|
|
766
|
+
if check_cancel(ser):
|
|
767
|
+
fout.close()
|
|
768
|
+
dest.unlink(missing_ok=True)
|
|
769
|
+
safe_send(ser, "CANCELLED:Copy cancelled")
|
|
770
|
+
raise CancelError("Cancelled")
|
|
771
|
+
chunk = fin.read(1024 * 1024)
|
|
772
|
+
if not chunk:
|
|
773
|
+
break
|
|
774
|
+
fout.write(chunk)
|
|
775
|
+
copied += len(chunk)
|
|
776
|
+
now = time.time()
|
|
777
|
+
if now - last_beat >= 5:
|
|
778
|
+
last_beat = now
|
|
779
|
+
frac = min(copied / src_size, 1.0) if src_size else 1.0
|
|
780
|
+
pct = min(int(base_pct + frac * pct_span), 99)
|
|
781
|
+
send(ser, f"PROGRESS:{pct}%")
|
|
782
|
+
except (KeyboardInterrupt, SystemExit):
|
|
783
|
+
dest.unlink(missing_ok=True)
|
|
784
|
+
raise
|
|
785
|
+
finally:
|
|
786
|
+
stop_ping.set()
|
|
787
|
+
pt.join(timeout=3)
|
|
788
|
+
|
|
789
|
+
def concat_videos(ser, files, dest):
|
|
790
|
+
if len(files) == 1:
|
|
791
|
+
files[0].replace(dest)
|
|
792
|
+
return dest
|
|
793
|
+
flist = dest.parent / "concat.txt"
|
|
794
|
+
flist.write_text("".join(f"file '{f.resolve()}'\n" for f in files))
|
|
795
|
+
send(ser, "STATUS:Merging video files...")
|
|
796
|
+
stop_ping, pt = _start_keepalive(ser)
|
|
797
|
+
try:
|
|
798
|
+
subprocess.run(
|
|
799
|
+
[tool('ffmpeg'), '-y', '-f', 'concat', '-safe', '0', '-i', str(flist),
|
|
800
|
+
'-c', 'copy', str(dest)],
|
|
801
|
+
capture_output=True)
|
|
802
|
+
finally:
|
|
803
|
+
stop_ping.set()
|
|
804
|
+
pt.join(timeout=3)
|
|
805
|
+
flist.unlink()
|
|
806
|
+
for f in files:
|
|
807
|
+
f.unlink()
|
|
808
|
+
return dest
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def download(ser, source, job_dir):
|
|
812
|
+
p = Path(source)
|
|
813
|
+
if p.is_dir():
|
|
814
|
+
videos = find_video_files(source)
|
|
815
|
+
if not videos:
|
|
816
|
+
raise RuntimeError(f"No video files in directory: {source}")
|
|
817
|
+
download_dir = job_dir / "download"
|
|
818
|
+
download_dir.mkdir(parents=True, exist_ok=True)
|
|
819
|
+
send(ser, "STATUS:Copying files...")
|
|
820
|
+
dest = download_dir / f"{p.name}.mp4"
|
|
821
|
+
copies = []
|
|
822
|
+
n = len(videos)
|
|
823
|
+
for i, v in enumerate(videos):
|
|
824
|
+
c = download_dir / v.name
|
|
825
|
+
copy_with_keepalive(ser, v, c, base_pct=int(i * 100 / n), pct_span=100 / n)
|
|
826
|
+
copies.append(c)
|
|
827
|
+
for srt in sorted(p.glob("*.srt")):
|
|
828
|
+
shutil.copy2(str(srt), str(download_dir / srt.name))
|
|
829
|
+
return concat_videos(ser, copies, dest)
|
|
830
|
+
|
|
831
|
+
if is_local_file(source):
|
|
832
|
+
src = Path(source)
|
|
833
|
+
download_dir = job_dir / "download"
|
|
834
|
+
download_dir.mkdir(parents=True, exist_ok=True)
|
|
835
|
+
dest = download_dir / src.name
|
|
836
|
+
send(ser, "STATUS:Copying...")
|
|
837
|
+
time.sleep(0.3)
|
|
838
|
+
try:
|
|
839
|
+
copy_with_keepalive(ser, src, dest)
|
|
840
|
+
except (KeyboardInterrupt, SystemExit):
|
|
841
|
+
raise
|
|
842
|
+
for srt in sorted(src.parent.glob("*.srt")):
|
|
843
|
+
shutil.copy2(str(srt), str(download_dir / srt.name))
|
|
844
|
+
return dest
|
|
845
|
+
|
|
846
|
+
send(ser, "STATUS:Downloading...")
|
|
847
|
+
download_dir = job_dir / "download"
|
|
848
|
+
download_dir.mkdir(parents=True, exist_ok=True)
|
|
849
|
+
out = str(download_dir / "%(title)s.%(ext)s")
|
|
850
|
+
all_lines = []
|
|
851
|
+
errors = []
|
|
852
|
+
clients = YTDLP_PLAYER_CLIENTS or (None,)
|
|
853
|
+
for attempt, player_client in enumerate(clients, start=1):
|
|
854
|
+
if attempt > 1:
|
|
855
|
+
for existing in download_dir.iterdir():
|
|
856
|
+
if existing.is_file():
|
|
857
|
+
existing.unlink(missing_ok=True)
|
|
858
|
+
|
|
859
|
+
proc = subprocess.Popen(
|
|
860
|
+
[*ytdlp_base_args(player_client), '-f', YTDLP_FORMAT, '-o', out, source],
|
|
861
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
862
|
+
last_prog = 0
|
|
863
|
+
attempt_lines = []
|
|
864
|
+
try:
|
|
865
|
+
for line in iter_proc_or_cancel(proc, ser):
|
|
866
|
+
attempt_lines.append(line)
|
|
867
|
+
print(f"yt-dlp: {line}")
|
|
868
|
+
m = re.search(r'(\d+\.\d+)%', line)
|
|
869
|
+
if m:
|
|
870
|
+
now = time.time()
|
|
871
|
+
if now - last_prog >= 0.2:
|
|
872
|
+
send(ser, f"PROGRESS:{m.group(1)}%")
|
|
873
|
+
last_prog = now
|
|
874
|
+
if 'Merging' in line:
|
|
875
|
+
send(ser, "INFO:Merging streams...")
|
|
876
|
+
except (KeyboardInterrupt, SystemExit):
|
|
877
|
+
stop_process(proc)
|
|
878
|
+
raise
|
|
879
|
+
rc = proc.wait()
|
|
880
|
+
all_lines += [f"[player_client={player_client or 'default'}]", *attempt_lines]
|
|
881
|
+
if rc == 0:
|
|
882
|
+
files = [p for p in download_dir.iterdir()
|
|
883
|
+
if p.is_file() and not p.name.endswith(('.part', '.ytdl'))]
|
|
884
|
+
if files:
|
|
885
|
+
(job_dir / "yt-dlp.log").write_text("\n".join(all_lines) + "\n")
|
|
886
|
+
return max(files, key=lambda p: p.stat().st_mtime)
|
|
887
|
+
errors.append("No downloaded file")
|
|
888
|
+
continue
|
|
889
|
+
if proc.returncode == -15:
|
|
890
|
+
(job_dir / "yt-dlp.log").write_text("\n".join(all_lines) + "\n")
|
|
891
|
+
safe_send(ser, "CANCELLED:Download cancelled")
|
|
892
|
+
raise CancelError("Cancelled")
|
|
893
|
+
detail = next(
|
|
894
|
+
(line.strip() for line in reversed(attempt_lines)
|
|
895
|
+
if "error" in line.lower() or line.startswith("ERROR:")),
|
|
896
|
+
"yt-dlp exited unsuccessfully",
|
|
897
|
+
)
|
|
898
|
+
errors.append(f"{player_client or 'default'}: {detail[:180]}")
|
|
899
|
+
|
|
900
|
+
(job_dir / "yt-dlp.log").write_text("\n".join(all_lines) + "\n")
|
|
901
|
+
for partial in download_dir.glob("*.part"):
|
|
902
|
+
partial.unlink(missing_ok=True)
|
|
903
|
+
detail = " | ".join(errors) if errors else "yt-dlp exited unsuccessfully"
|
|
904
|
+
raise RuntimeError(f"Download failed: {detail[:300]}")
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def find_subtitle_files(video_path):
|
|
908
|
+
p = Path(video_path)
|
|
909
|
+
srt_files = sorted(p.parent.glob("*.srt"))
|
|
910
|
+
# also check for .srt with same stem
|
|
911
|
+
same_stem = p.parent.glob(f"{p.stem}.*.srt")
|
|
912
|
+
for f in same_stem:
|
|
913
|
+
if f not in srt_files:
|
|
914
|
+
srt_files.append(f)
|
|
915
|
+
eng = p.parent.glob("*.eng.srt")
|
|
916
|
+
for f in eng:
|
|
917
|
+
if f not in srt_files:
|
|
918
|
+
srt_files.append(f)
|
|
919
|
+
return srt_files
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def extract_embedded_subtitles(video_path, job_dir):
|
|
923
|
+
sub_dir = job_dir / "subtitles"
|
|
924
|
+
sub_dir.mkdir(exist_ok=True)
|
|
925
|
+
r = subprocess.run(
|
|
926
|
+
[tool('ffmpeg'), '-i', str(video_path)],
|
|
927
|
+
capture_output=True, text=True)
|
|
928
|
+
count = 0
|
|
929
|
+
for line in r.stderr.split('\n'):
|
|
930
|
+
if 'Subtitle:' in line:
|
|
931
|
+
count += 1
|
|
932
|
+
extracted = []
|
|
933
|
+
for i in range(count):
|
|
934
|
+
out = sub_dir / f"sub_{i}.srt"
|
|
935
|
+
subprocess.run(
|
|
936
|
+
[tool('ffmpeg'), '-y', '-i', str(video_path),
|
|
937
|
+
'-map', f'0:s:{i}', str(out)],
|
|
938
|
+
capture_output=True)
|
|
939
|
+
if out.exists() and out.stat().st_size > 10:
|
|
940
|
+
extracted.append(out)
|
|
941
|
+
return extracted
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def add_subtitles(ser, mpg_path, srt_files, job_dir):
|
|
945
|
+
if not srt_files:
|
|
946
|
+
return mpg_path
|
|
947
|
+
send(ser, "STATUS:Adding subtitles...")
|
|
948
|
+
out_path = job_dir / "movie_subbed.mpg"
|
|
949
|
+
streams = ""
|
|
950
|
+
for srt in srt_files:
|
|
951
|
+
streams += f'''
|
|
952
|
+
<textsub filename="{srt}" characterset="UTF-8"
|
|
953
|
+
fontsize="28" font="sans-serif"
|
|
954
|
+
horizontal-align="center" vertical-align="bottom"
|
|
955
|
+
left-margin="20" right-margin="20" top-margin="20" bottom-margin="30"/>'''
|
|
956
|
+
xml = f'<subpictures><stream>{streams}\n </stream>\n</subpictures>\n'
|
|
957
|
+
xml_path = job_dir / "spumux.xml"
|
|
958
|
+
xml_path.write_text(xml)
|
|
959
|
+
with open(mpg_path, 'rb') as fin:
|
|
960
|
+
with open(out_path, 'wb') as fout:
|
|
961
|
+
r = subprocess.run(
|
|
962
|
+
[tool('spumux'), '-m', 'dvd', '-s', '0', str(xml_path)],
|
|
963
|
+
stdin=fin, stdout=fout, stderr=subprocess.PIPE)
|
|
964
|
+
if r.returncode != 0:
|
|
965
|
+
print(f"spumux error: {r.stderr.decode(errors='ignore')}")
|
|
966
|
+
safe_send(ser, "WARNING:Subtitle failed, continuing")
|
|
967
|
+
if out_path.exists():
|
|
968
|
+
out_path.unlink()
|
|
969
|
+
return mpg_path
|
|
970
|
+
if out_path.exists() and out_path.stat().st_size > 0:
|
|
971
|
+
mpg_path.unlink()
|
|
972
|
+
return out_path
|
|
973
|
+
return mpg_path
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def probe_aspect(infile):
|
|
977
|
+
r = subprocess.run(
|
|
978
|
+
[tool('ffprobe'), '-v', 'error',
|
|
979
|
+
'-select_streams', 'v:0',
|
|
980
|
+
'-show_entries', 'stream=width,height,display_aspect_ratio',
|
|
981
|
+
'-of', 'csv=p=0', str(infile)],
|
|
982
|
+
capture_output=True, text=True, timeout=10)
|
|
983
|
+
if r.returncode != 0:
|
|
984
|
+
return None
|
|
985
|
+
parts = r.stdout.strip().split(',')
|
|
986
|
+
if len(parts) < 2:
|
|
987
|
+
return None
|
|
988
|
+
try:
|
|
989
|
+
w, h = int(parts[0]), int(parts[1])
|
|
990
|
+
except ValueError:
|
|
991
|
+
return None
|
|
992
|
+
dar = parts[2] if len(parts) > 2 and parts[2] else ""
|
|
993
|
+
if dar:
|
|
994
|
+
try:
|
|
995
|
+
n, d = dar.split(":")
|
|
996
|
+
return float(n) / float(d)
|
|
997
|
+
except (ValueError, ZeroDivisionError):
|
|
998
|
+
pass
|
|
999
|
+
return w / h if h else None
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _run_ffmpeg_pass(ser, cmd, total, pass_label):
|
|
1003
|
+
send(ser, f"INFO:{pass_label}")
|
|
1004
|
+
proc = subprocess.Popen(cmd,
|
|
1005
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
1006
|
+
last_prog = 0
|
|
1007
|
+
try:
|
|
1008
|
+
for line in iter_proc_or_cancel(proc, ser):
|
|
1009
|
+
m = re.search(r'time=(\d+):(\d+):(\d+\.\d+)', line)
|
|
1010
|
+
if m and total > 0:
|
|
1011
|
+
now = time.time()
|
|
1012
|
+
if now - last_prog >= 0.2:
|
|
1013
|
+
secs = int(m.group(1))*3600 + int(m.group(2))*60 + float(m.group(3))
|
|
1014
|
+
pct = min(int(secs / total * 100), 99)
|
|
1015
|
+
send(ser, f"PROGRESS:{pct}%")
|
|
1016
|
+
last_prog = now
|
|
1017
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1018
|
+
stop_process(proc)
|
|
1019
|
+
raise
|
|
1020
|
+
if proc.wait() != 0:
|
|
1021
|
+
if proc.returncode == -15:
|
|
1022
|
+
safe_send(ser, "CANCELLED:Convert cancelled")
|
|
1023
|
+
raise CancelError("Cancelled")
|
|
1024
|
+
raise RuntimeError("FFmpeg encode failed")
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def convert(ser, infile, job_dir, mode, disc_bytes=None):
|
|
1028
|
+
send(ser, "STATUS:Converting...")
|
|
1029
|
+
out = job_dir / "movie.mpg"
|
|
1030
|
+
total = probe_duration(infile)
|
|
1031
|
+
plan = bitrate_plan(total, mode, disc_bytes)
|
|
1032
|
+
print(f"Convert: mode={mode} dur={total:.0f}s video_k={plan['video_k']}k "
|
|
1033
|
+
f"peak_k={plan['peak_video_k']}k audio_k={plan['audio_k']}k "
|
|
1034
|
+
f"target_bytes={disc_output_limit_bytes(disc_bytes)}", flush=True)
|
|
1035
|
+
send(ser, f"PROGRESS:{plan['mode']} {plan['video_k']}k")
|
|
1036
|
+
send(ser, f"INFO:AC3 {plan['audio_k']}k audio")
|
|
1037
|
+
aspect = probe_aspect(infile)
|
|
1038
|
+
is_wide = aspect is not None and aspect > 1.4
|
|
1039
|
+
dvd_aspect = "16:9" if is_wide else "4:3"
|
|
1040
|
+
logfile = str(job_dir / "2pass")
|
|
1041
|
+
# CBR pinning: b:v, minrate, and maxrate all equal to the planned video
|
|
1042
|
+
# bitrate. ffmpeg's native mpeg2video ratecontrol treats -b:v as a ceiling
|
|
1043
|
+
# it's free to undershoot, not a promise — the old -maxrate {peak_k}k left
|
|
1044
|
+
# a wide gap (e.g. 5515k target vs 9000k peak) that gave it room to do
|
|
1045
|
+
# exactly that. Pinning all three together forces it to spend the bitrate
|
|
1046
|
+
# the disc-capacity plan actually called for.
|
|
1047
|
+
bufsize_k = 1835 # DVD spec VBV buffer (224 KB = 1,835,008 bits) — fixed, not scaled by peak_k
|
|
1048
|
+
base = [
|
|
1049
|
+
tool('ffmpeg'), '-y', '-i', str(infile),
|
|
1050
|
+
'-map', '0:v:0', '-map', '0:a:0?', '-sn',
|
|
1051
|
+
'-c:v', 'mpeg2video', '-s', '720x576', '-r', '25', '-g', '15',
|
|
1052
|
+
'-aspect', dvd_aspect,
|
|
1053
|
+
'-b:v', f"{plan['video_k']}k",
|
|
1054
|
+
'-minrate', f"{plan['video_k']}k",
|
|
1055
|
+
'-maxrate', f"{plan['video_k']}k",
|
|
1056
|
+
'-bufsize', f'{bufsize_k}k',
|
|
1057
|
+
'-packetsize', '2048',
|
|
1058
|
+
]
|
|
1059
|
+
pass1 = base + ['-pass', '1', '-passlogfile', logfile,
|
|
1060
|
+
'-an', '-f', 'null', discstation_host.null_device()]
|
|
1061
|
+
pass2 = base + ['-pass', '2', '-passlogfile', logfile,
|
|
1062
|
+
'-c:a', 'ac3', '-b:a', f"{plan['audio_k']}k", str(out)]
|
|
1063
|
+
_run_ffmpeg_pass(ser, pass1, total, "Pass 1/2 (analyze)")
|
|
1064
|
+
_run_ffmpeg_pass(ser, pass2, total, "Pass 2/2 (encode)")
|
|
1065
|
+
if out.stat().st_size < 100_000_000:
|
|
1066
|
+
print(f"WARNING: movie.mpg only {out.stat().st_size} bytes — possible encode failure", flush=True)
|
|
1067
|
+
else:
|
|
1068
|
+
print(f"movie.mpg: {out.stat().st_size / 1e9:.2f}GB for {total:.0f}s "
|
|
1069
|
+
f"({out.stat().st_size * 8 / total / 1000:.0f}k avg bitrate)", flush=True)
|
|
1070
|
+
for log_suffix in ('', '.log', '.log.mbtree'):
|
|
1071
|
+
p = job_dir / f"2pass{log_suffix}"
|
|
1072
|
+
if p.exists():
|
|
1073
|
+
p.unlink()
|
|
1074
|
+
return out, dvd_aspect
|
|
1075
|
+
|
|
1076
|
+
def author(ser, mpg, job_dir, aspect="4:3"):
|
|
1077
|
+
send(ser, "STATUS:Authoring DVD...")
|
|
1078
|
+
send(ser, "PROGRESS:Building IFO/VOB")
|
|
1079
|
+
dvd_dir = job_dir / "dvd_out"
|
|
1080
|
+
dvd_dir.mkdir(parents=True, exist_ok=True)
|
|
1081
|
+
xml = f"""<dvdauthor dest={chr(34) + str(dvd_dir) + chr(34)} format="pal">
|
|
1082
|
+
<vmgm />
|
|
1083
|
+
<titleset>
|
|
1084
|
+
<titles>
|
|
1085
|
+
<pgc>
|
|
1086
|
+
<vob file={chr(34) + str(mpg) + chr(34)} />
|
|
1087
|
+
</pgc>
|
|
1088
|
+
</titles>
|
|
1089
|
+
</titleset>
|
|
1090
|
+
</dvdauthor>"""
|
|
1091
|
+
xml_path = job_dir / "dvd.xml"
|
|
1092
|
+
with open(xml_path, 'w') as f:
|
|
1093
|
+
f.write(xml)
|
|
1094
|
+
env = os.environ.copy()
|
|
1095
|
+
env['VIDEO_FORMAT'] = 'PAL'
|
|
1096
|
+
proc = subprocess.Popen(
|
|
1097
|
+
[tool('dvdauthor'), '-x', str(xml_path)],
|
|
1098
|
+
env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
|
1099
|
+
)
|
|
1100
|
+
for line in iter_proc_or_cancel(proc, ser):
|
|
1101
|
+
print(line, end="")
|
|
1102
|
+
if proc.wait() != 0:
|
|
1103
|
+
if proc.returncode == -15:
|
|
1104
|
+
safe_send(ser, "CANCELLED:Authoring cancelled")
|
|
1105
|
+
raise CancelError("Cancelled")
|
|
1106
|
+
raise RuntimeError("DVD authoring failed")
|
|
1107
|
+
return dvd_dir
|
|
1108
|
+
|
|
1109
|
+
def check_dvd_size(ser, dvd_dir, disc_bytes=None):
|
|
1110
|
+
send(ser, "STATUS:Checking size...")
|
|
1111
|
+
size = tree_size(dvd_dir)
|
|
1112
|
+
limit = disc_output_limit_bytes(disc_bytes)
|
|
1113
|
+
send(ser, f"PROGRESS:{size / 1_000_000_000:.2f}GB / {limit / 1_000_000_000:.2f}GB")
|
|
1114
|
+
if size > limit:
|
|
1115
|
+
raise RuntimeError(
|
|
1116
|
+
f"DVD output {size / 1_000_000_000:.2f}GB exceeds safe limit "
|
|
1117
|
+
f"{limit / 1_000_000_000:.2f}GB"
|
|
1118
|
+
)
|
|
1119
|
+
return size
|
|
1120
|
+
|
|
1121
|
+
def wait_for_burn_confirm(ser, dvd_dir, disc_capacity):
|
|
1122
|
+
data_size = tree_size(dvd_dir)
|
|
1123
|
+
detected_capacity = disc_capacity_bytes(disc_device())
|
|
1124
|
+
actual_cap = disc_output_limit_bytes(detected_capacity or disc_capacity)
|
|
1125
|
+
cap_gb = actual_cap / 1_000_000_000
|
|
1126
|
+
data_gb = data_size / 1_000_000_000
|
|
1127
|
+
line = f"WAITING:{data_gb:.2f}GB / {cap_gb:.1f}GB"
|
|
1128
|
+
send(ser, line)
|
|
1129
|
+
last_ping = time.time()
|
|
1130
|
+
while True:
|
|
1131
|
+
if serial_write_failed():
|
|
1132
|
+
raise serial.SerialException("ESP32 serial link lost before burn confirmation")
|
|
1133
|
+
if time.time() - last_ping >= 5:
|
|
1134
|
+
last_ping = time.time()
|
|
1135
|
+
send(ser, "PING")
|
|
1136
|
+
try:
|
|
1137
|
+
if ser and ser.in_waiting:
|
|
1138
|
+
resp = ser.readline().decode(errors="ignore").strip()
|
|
1139
|
+
note_serial_activity()
|
|
1140
|
+
if resp == "CONFIRM" or resp == "START":
|
|
1141
|
+
return True
|
|
1142
|
+
if resp == "CANCEL":
|
|
1143
|
+
raise RuntimeError("Burn cancelled by user")
|
|
1144
|
+
except OSError:
|
|
1145
|
+
raise RuntimeError("Serial error during burn confirm")
|
|
1146
|
+
time.sleep(0.05)
|
|
1147
|
+
|
|
1148
|
+
def _is_dvd_plus_rw(device):
|
|
1149
|
+
if discstation_host.system_name() != "linux":
|
|
1150
|
+
return False
|
|
1151
|
+
props = _udevadm_props(device)
|
|
1152
|
+
if props.get("ID_CDROM_MEDIA_DVD_PLUS_RW") == "1":
|
|
1153
|
+
return True
|
|
1154
|
+
try:
|
|
1155
|
+
result = subprocess.run(
|
|
1156
|
+
["dvd+rw-mediainfo", device],
|
|
1157
|
+
capture_output=True,
|
|
1158
|
+
text=True,
|
|
1159
|
+
check=False,
|
|
1160
|
+
timeout=5,
|
|
1161
|
+
)
|
|
1162
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1163
|
+
return False
|
|
1164
|
+
return bool(re.search(r"mounted media:.*dvd\+rw", result.stdout + result.stderr, re.IGNORECASE))
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def _format_dvd_plus_rw(ser, device):
|
|
1168
|
+
if not _is_dvd_plus_rw(device):
|
|
1169
|
+
return False
|
|
1170
|
+
send(ser, "STATUS:Preparing rewritable disc...")
|
|
1171
|
+
proc = subprocess.Popen(
|
|
1172
|
+
[tool("dvd+rw-format"), "-force", device],
|
|
1173
|
+
stdout=subprocess.PIPE,
|
|
1174
|
+
stderr=subprocess.STDOUT,
|
|
1175
|
+
text=True,
|
|
1176
|
+
)
|
|
1177
|
+
out_lines = []
|
|
1178
|
+
try:
|
|
1179
|
+
for line in iter_proc_or_cancel(proc, ser):
|
|
1180
|
+
out_lines.append(line)
|
|
1181
|
+
print(f"dvd+rw-format: {line}")
|
|
1182
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1183
|
+
stop_process(proc)
|
|
1184
|
+
raise
|
|
1185
|
+
rc = proc.wait()
|
|
1186
|
+
if rc == -15:
|
|
1187
|
+
safe_send(ser, "CANCELLED:Burn cancelled")
|
|
1188
|
+
raise CancelError("Cancelled")
|
|
1189
|
+
if rc != 0:
|
|
1190
|
+
detail = next((line for line in reversed(out_lines) if line.strip()), "dvd+rw-format failed")
|
|
1191
|
+
raise RuntimeError(f"DVD+RW preparation failed: {detail[:120]}")
|
|
1192
|
+
return True
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def _run_growisofs(ser, growisofs_cmd, log_path, device=None):
|
|
1196
|
+
"""Shared growisofs runner — unmount, format old DVD+RW media, then write."""
|
|
1197
|
+
write_device = device or disc_device()
|
|
1198
|
+
out_lines = []
|
|
1199
|
+
rc = 1
|
|
1200
|
+
for attempt in range(2):
|
|
1201
|
+
if discstation_host.system_name() == "linux":
|
|
1202
|
+
discstation_host.unmount_device(write_device)
|
|
1203
|
+
proc = subprocess.Popen(
|
|
1204
|
+
growisofs_cmd,
|
|
1205
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
1206
|
+
out_lines = []
|
|
1207
|
+
last_prog = 0
|
|
1208
|
+
try:
|
|
1209
|
+
for line in iter_proc_or_cancel(proc, ser):
|
|
1210
|
+
out_lines.append(line)
|
|
1211
|
+
m = re.search(r'(\d+\.\d+)%', line)
|
|
1212
|
+
if m:
|
|
1213
|
+
now = time.time()
|
|
1214
|
+
if now - last_prog >= 0.2:
|
|
1215
|
+
send(ser, f"PROGRESS:{m.group(1)}%")
|
|
1216
|
+
last_prog = now
|
|
1217
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1218
|
+
stop_process(proc)
|
|
1219
|
+
raise
|
|
1220
|
+
rc = proc.wait()
|
|
1221
|
+
output_text = "\n".join(out_lines)
|
|
1222
|
+
if rc == 0:
|
|
1223
|
+
break
|
|
1224
|
+
if attempt == 0 and "already carries isofs" in output_text.lower():
|
|
1225
|
+
if _format_dvd_plus_rw(ser, write_device):
|
|
1226
|
+
continue
|
|
1227
|
+
break
|
|
1228
|
+
|
|
1229
|
+
if rc != 0:
|
|
1230
|
+
output_text = "\n".join(out_lines)
|
|
1231
|
+
with open(log_path, 'w') as f:
|
|
1232
|
+
f.write(output_text)
|
|
1233
|
+
for line in out_lines[-10:]:
|
|
1234
|
+
print(f"growisofs: {line}")
|
|
1235
|
+
if rc == -15:
|
|
1236
|
+
safe_send(ser, "CANCELLED:Burn cancelled")
|
|
1237
|
+
raise CancelError("Cancelled")
|
|
1238
|
+
if "no such device" in output_text.lower() or "unable to test unit ready" in output_text.lower():
|
|
1239
|
+
raise RuntimeError("Optical drive disconnected during burn; check USB cable/hub")
|
|
1240
|
+
if "already carries isofs" in output_text.lower():
|
|
1241
|
+
raise RuntimeError("Optical disc still contains an ISO filesystem after preparation")
|
|
1242
|
+
if "unable to reload tray" in output_text:
|
|
1243
|
+
safe_send(ser, "INFO:Disc written OK (tray reload skipped)")
|
|
1244
|
+
print("growisofs: tray reload failed after successful write — disc is fine")
|
|
1245
|
+
return
|
|
1246
|
+
safe_send(ser, "INFO:Burn failed, check log")
|
|
1247
|
+
raise RuntimeError("Disc burn failed")
|
|
1248
|
+
try:
|
|
1249
|
+
discstation_host.eject_device(write_device)
|
|
1250
|
+
except Exception as e:
|
|
1251
|
+
print(f"Disc eject skipped: {e}")
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def burn(ser, dvd_dir, disc_label, speed=None, is_dual_layer=False):
|
|
1255
|
+
if discstation_host.system_name() != "linux":
|
|
1256
|
+
WORK.mkdir(parents=True, exist_ok=True)
|
|
1257
|
+
image_path = WORK / f"video_{time.strftime('%Y%m%d_%H%M%S')}.iso"
|
|
1258
|
+
try:
|
|
1259
|
+
discstation_host.build_data_image([dvd_dir], image_path, disc_label, video=True)
|
|
1260
|
+
burn_iso(ser, image_path, speed, is_dual_layer)
|
|
1261
|
+
finally:
|
|
1262
|
+
image_path.unlink(missing_ok=True)
|
|
1263
|
+
return
|
|
1264
|
+
send(ser, "STATUS:Burning disc...")
|
|
1265
|
+
send(ser, "PROGRESS:Starting burn")
|
|
1266
|
+
send(ser, f"INFO:Label {disc_label[:13]}")
|
|
1267
|
+
device = disc_device()
|
|
1268
|
+
growisofs_cmd = [tool('growisofs'), '-dvd-compat', '-Z', device]
|
|
1269
|
+
speed = speed or DISC_SPEED
|
|
1270
|
+
if speed and speed.lower() != "auto":
|
|
1271
|
+
if is_dual_layer:
|
|
1272
|
+
speed_num = int(re.sub(r'[^0-9]', '', speed) or '6')
|
|
1273
|
+
if speed_num > 4:
|
|
1274
|
+
speed = "4x"
|
|
1275
|
+
send(ser, "INFO:Capped DL speed to 4x")
|
|
1276
|
+
growisofs_cmd += ['-speed', speed.rstrip('x')]
|
|
1277
|
+
growisofs_cmd += ['-V', disc_label, '-dvd-video', str(dvd_dir)]
|
|
1278
|
+
_run_growisofs(ser, growisofs_cmd, dvd_dir.parent / "growisofs.log", device)
|
|
1279
|
+
|
|
1280
|
+
def burn_data(ser, source_paths, disc_label, speed=None, is_dual_layer=False):
|
|
1281
|
+
"""Burn files as a data DVD — no conversion, no authoring, original quality."""
|
|
1282
|
+
if discstation_host.system_name() != "linux":
|
|
1283
|
+
WORK.mkdir(parents=True, exist_ok=True)
|
|
1284
|
+
image_path = WORK / f"data_{time.strftime('%Y%m%d_%H%M%S')}.iso"
|
|
1285
|
+
try:
|
|
1286
|
+
discstation_host.build_data_image(source_paths, image_path, disc_label)
|
|
1287
|
+
burn_iso(ser, image_path, speed, is_dual_layer)
|
|
1288
|
+
finally:
|
|
1289
|
+
image_path.unlink(missing_ok=True)
|
|
1290
|
+
return
|
|
1291
|
+
send(ser, "STATUS:Burning data disc...")
|
|
1292
|
+
send(ser, "PROGRESS:Starting")
|
|
1293
|
+
send(ser, f"INFO:Label {disc_label[:13]}")
|
|
1294
|
+
device = disc_device()
|
|
1295
|
+
growisofs_cmd = [tool('growisofs'), '-dvd-compat', '-Z', device]
|
|
1296
|
+
speed = speed or DISC_SPEED
|
|
1297
|
+
if speed and speed.lower() != "auto":
|
|
1298
|
+
if is_dual_layer:
|
|
1299
|
+
speed_num = int(re.sub(r'[^0-9]', '', speed) or '6')
|
|
1300
|
+
if speed_num > 4:
|
|
1301
|
+
speed = "4x"
|
|
1302
|
+
send(ser, "INFO:Capped DL speed to 4x")
|
|
1303
|
+
growisofs_cmd += ['-speed', speed.rstrip('x')]
|
|
1304
|
+
growisofs_cmd += ['-R', '-J', '-joliet-long', '-allow-limited-size', '-V', disc_label]
|
|
1305
|
+
growisofs_cmd += [str(p) for p in source_paths]
|
|
1306
|
+
_run_growisofs(ser, growisofs_cmd, source_paths[0].parent / "growisofs.log", device)
|
|
1307
|
+
|
|
1308
|
+
def burn_audio_cd(ser, audio_files, disc_label, speed=None):
|
|
1309
|
+
"""Convert audio files to CD-DA WAV and burn via cdrdao with CD-TEXT."""
|
|
1310
|
+
send(ser, "STATUS:Reading tags...")
|
|
1311
|
+
track_meta = []
|
|
1312
|
+
album_artist = ""
|
|
1313
|
+
album_title = ""
|
|
1314
|
+
for f in audio_files:
|
|
1315
|
+
artist, title = "", f.stem
|
|
1316
|
+
try:
|
|
1317
|
+
if f.suffix.lower() == ".flac":
|
|
1318
|
+
from mutagen.flac import FLAC
|
|
1319
|
+
a = FLAC(str(f))
|
|
1320
|
+
artist = a.get("albumartist", [a.get("artist", [""])[0]])[0]
|
|
1321
|
+
title = a.get("title", [f.stem])[0]
|
|
1322
|
+
if not album_title:
|
|
1323
|
+
album_title = a.get("album", [""])[0]
|
|
1324
|
+
album_artist = artist
|
|
1325
|
+
elif f.suffix.lower() == ".mp3":
|
|
1326
|
+
from mutagen.mp3 import MP3
|
|
1327
|
+
a = MP3(str(f))
|
|
1328
|
+
artist = str(a.get("TPE1", a.get("TPE2", "")))
|
|
1329
|
+
title = str(a.get("TIT2", f.stem))
|
|
1330
|
+
if not album_title:
|
|
1331
|
+
album_title = str(a.get("TALB", ""))
|
|
1332
|
+
album_artist = str(a.get("TPE2", artist))
|
|
1333
|
+
elif f.suffix.lower() == ".m4a":
|
|
1334
|
+
from mutagen.mp4 import MP4
|
|
1335
|
+
a = MP4(str(f))
|
|
1336
|
+
artist = a.get("\xa9ART", [""])[0]
|
|
1337
|
+
title = a.get("\xa9nam", [f.stem])[0]
|
|
1338
|
+
if not album_title:
|
|
1339
|
+
album_title = a.get("\xa9alb", [""])[0]
|
|
1340
|
+
album_artist = a.get("aART", [artist])[0]
|
|
1341
|
+
except Exception:
|
|
1342
|
+
pass
|
|
1343
|
+
track_meta.append((artist, title))
|
|
1344
|
+
album_artist = album_artist or "Unknown Artist"
|
|
1345
|
+
# Keep the album name read from the file tags; only fall back to the
|
|
1346
|
+
# folder/disc label when the tags had nothing.
|
|
1347
|
+
album_title = album_title or audio_disc_title(disc_label)
|
|
1348
|
+
|
|
1349
|
+
send(ser, "STATUS:Converting audio...")
|
|
1350
|
+
send(ser, "PROGRESS:0%")
|
|
1351
|
+
tmp_dir = Path(audio_files[0]).parent / ".cd_tmp"
|
|
1352
|
+
shutil.rmtree(str(tmp_dir), ignore_errors=True)
|
|
1353
|
+
tmp_dir.mkdir(exist_ok=True)
|
|
1354
|
+
total = len(audio_files)
|
|
1355
|
+
for i, f in enumerate(audio_files):
|
|
1356
|
+
wav = tmp_dir / f"track_{i + 1:02d}.wav"
|
|
1357
|
+
subprocess.run(
|
|
1358
|
+
[tool('ffmpeg'), '-y', '-i', str(f), '-ar', '44100', '-ac', '2',
|
|
1359
|
+
'-sample_fmt', 's16', str(wav)],
|
|
1360
|
+
capture_output=True, check=True)
|
|
1361
|
+
pct = int((i + 1) / total * 30)
|
|
1362
|
+
send(ser, f"PROGRESS:{pct}%")
|
|
1363
|
+
|
|
1364
|
+
send(ser, "STATUS:Writing TOC...")
|
|
1365
|
+
# CD-TEXT LANGUAGE 0 is EN (single-byte). Fold to ASCII and cap at the
|
|
1366
|
+
# 160-char CD-TEXT field limit so cdrdao/the drive don't reject the pack.
|
|
1367
|
+
def _cdt(value):
|
|
1368
|
+
text = cdrdao_text(value)
|
|
1369
|
+
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
|
|
1370
|
+
return text.strip()[:160]
|
|
1371
|
+
|
|
1372
|
+
album_t = _cdt(album_title) or "Audio CD"
|
|
1373
|
+
album_p = _cdt(album_artist) or "Unknown Artist"
|
|
1374
|
+
toc_lines = ["CD_DA"]
|
|
1375
|
+
toc_lines.append("CD_TEXT {")
|
|
1376
|
+
toc_lines.append(" LANGUAGE_MAP { 0: EN }")
|
|
1377
|
+
toc_lines.append(" LANGUAGE 0 {")
|
|
1378
|
+
toc_lines.append(f' TITLE "{album_t}"')
|
|
1379
|
+
toc_lines.append(f' PERFORMER "{album_p}"')
|
|
1380
|
+
toc_lines.append(" }")
|
|
1381
|
+
toc_lines.append("}")
|
|
1382
|
+
toc_lines.append("")
|
|
1383
|
+
for i, (artist, title) in enumerate(track_meta):
|
|
1384
|
+
wav = tmp_dir / f"track_{i + 1:02d}.wav"
|
|
1385
|
+
track_t = _cdt(title) or f"Track {i + 1:02d}"
|
|
1386
|
+
track_p = _cdt(artist) or album_p
|
|
1387
|
+
toc_lines.append("TRACK AUDIO")
|
|
1388
|
+
toc_lines.append("CD_TEXT {")
|
|
1389
|
+
toc_lines.append(" LANGUAGE 0 {")
|
|
1390
|
+
toc_lines.append(f' TITLE "{track_t}"')
|
|
1391
|
+
toc_lines.append(f' PERFORMER "{track_p}"')
|
|
1392
|
+
toc_lines.append(" }")
|
|
1393
|
+
toc_lines.append("}")
|
|
1394
|
+
toc_lines.append(f'FILE "{wav}" 0')
|
|
1395
|
+
toc_lines.append("")
|
|
1396
|
+
toc_path = tmp_dir / "disc.toc"
|
|
1397
|
+
toc_path.write_text("\n".join(toc_lines) + "\n")
|
|
1398
|
+
print(f"CD-TEXT: album={album_t!r} performer={album_p!r}, "
|
|
1399
|
+
f"{len(track_meta)} track titles")
|
|
1400
|
+
send(ser, "PROGRESS:35%")
|
|
1401
|
+
|
|
1402
|
+
send(ser, "STATUS:Burning audio CD...")
|
|
1403
|
+
# The cooked generic-mmc writer does NOT lay down the CD-TEXT lead-in on most
|
|
1404
|
+
# ATAPI drives; the raw writer does. Override with DISCSTATION_CDRDAO_DRIVER
|
|
1405
|
+
# (set it empty to let cdrdao auto-pick).
|
|
1406
|
+
cdrdao_cmd = [tool('cdrdao'), 'write', '--buffers', '64',
|
|
1407
|
+
'--device', discstation_host.cdrdao_device(disc_device())]
|
|
1408
|
+
driver = os.environ.get("DISCSTATION_CDRDAO_DRIVER", "generic-mmc-raw")
|
|
1409
|
+
if driver:
|
|
1410
|
+
cdrdao_cmd += ['--driver', driver]
|
|
1411
|
+
speed_ = speed or DISC_SPEED
|
|
1412
|
+
if speed_ and speed_.lower() != "auto":
|
|
1413
|
+
cdrdao_cmd += ['--speed', speed_.rstrip('x')]
|
|
1414
|
+
cdrdao_cmd.append(str(toc_path)) # toc-file must come after all options
|
|
1415
|
+
|
|
1416
|
+
proc = subprocess.Popen(cdrdao_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
1417
|
+
out_lines = []
|
|
1418
|
+
log_path = WORK / "cdrdao.log"
|
|
1419
|
+
last_prog = 0
|
|
1420
|
+
try:
|
|
1421
|
+
for line in iter_proc_or_cancel(proc, ser):
|
|
1422
|
+
out_lines.append(line)
|
|
1423
|
+
m = re.search(r'(\d+)\s*%', line)
|
|
1424
|
+
if m:
|
|
1425
|
+
pct = 35 + int(int(m.group(1)) * 0.65)
|
|
1426
|
+
now = time.time()
|
|
1427
|
+
if now - last_prog >= 0.2:
|
|
1428
|
+
send(ser, f"PROGRESS:{pct}%")
|
|
1429
|
+
last_prog = now
|
|
1430
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1431
|
+
stop_process(proc)
|
|
1432
|
+
raise
|
|
1433
|
+
finally:
|
|
1434
|
+
for w in tmp_dir.glob("*.wav"):
|
|
1435
|
+
w.unlink(missing_ok=True)
|
|
1436
|
+
toc_path.unlink(missing_ok=True)
|
|
1437
|
+
shutil.rmtree(str(tmp_dir), ignore_errors=True)
|
|
1438
|
+
rc = proc.wait()
|
|
1439
|
+
log_path.write_text("\n".join(out_lines) + "\n")
|
|
1440
|
+
if rc != 0:
|
|
1441
|
+
for line in out_lines[-10:]:
|
|
1442
|
+
print(f"cdrdao: {line}")
|
|
1443
|
+
if rc == -15:
|
|
1444
|
+
safe_send(ser, "CANCELLED:Burn cancelled")
|
|
1445
|
+
raise CancelError("Cancelled")
|
|
1446
|
+
detail = next((line.strip() for line in reversed(out_lines) if line.strip()), "cdrdao failed")
|
|
1447
|
+
safe_send(ser, f"INFO:CD burn failed; see {log_path.name}")
|
|
1448
|
+
raise RuntimeError(f"Disc burn failed: {detail[:80]}")
|
|
1449
|
+
try:
|
|
1450
|
+
discstation_host.eject_device(disc_device())
|
|
1451
|
+
except Exception as e:
|
|
1452
|
+
print(f"CD eject skipped: {e}")
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def burn_iso(ser, iso_path, speed=None, is_dual_layer=False):
|
|
1456
|
+
"""Burn a pre-built ISO directly to disc — no filesystem building."""
|
|
1457
|
+
if discstation_host.system_name() != "linux":
|
|
1458
|
+
send(ser, "STATUS:Burning image...")
|
|
1459
|
+
_run_growisofs(ser, discstation_host.iso_burn_command(disc_device(), iso_path), iso_path.parent / "discstation-burn.log")
|
|
1460
|
+
return
|
|
1461
|
+
send(ser, "STATUS:Burning ISO...")
|
|
1462
|
+
send(ser, "PROGRESS:Starting")
|
|
1463
|
+
device = disc_device()
|
|
1464
|
+
growisofs_cmd = [tool('growisofs'), '-dvd-compat', '-Z', f"{device}={iso_path}"]
|
|
1465
|
+
speed = speed or DISC_SPEED
|
|
1466
|
+
if speed and speed.lower() != "auto":
|
|
1467
|
+
if is_dual_layer:
|
|
1468
|
+
speed_num = int(re.sub(r'[^0-9]', '', speed) or '6')
|
|
1469
|
+
if speed_num > 4:
|
|
1470
|
+
speed = "4x"
|
|
1471
|
+
send(ser, "INFO:Capped DL speed to 4x")
|
|
1472
|
+
growisofs_cmd += ['-speed', speed.rstrip('x')]
|
|
1473
|
+
_run_growisofs(ser, growisofs_cmd, iso_path.parent / "growisofs.log", device)
|
|
1474
|
+
|
|
1475
|
+
def remux_and_author(ser, mpg, disc_label, disc_capacity, dvd_aspect=None):
|
|
1476
|
+
"""Remux an existing DVD-compliant .mpg to fix mux-rate/timestamp
|
|
1477
|
+
issues (falling back to the original file if the remux itself
|
|
1478
|
+
fails), then author and size-check it. Returns the authored dvd_dir.
|
|
1479
|
+
|
|
1480
|
+
Uses _run_ffmpeg_pass for each remux step — same as convert()'s
|
|
1481
|
+
encode passes — so the OLED gets real PROGRESS updates and the
|
|
1482
|
+
built-in PING keepalive from iter_proc_or_cancel, instead of a
|
|
1483
|
+
silent blocking subprocess call that lets the ESP32's 30s watchdog
|
|
1484
|
+
flip the display to "disconnected" partway through.
|
|
1485
|
+
|
|
1486
|
+
This is the single place the remux+author sequence lives — the
|
|
1487
|
+
full download/convert pipeline, the OLED burn_mpg_flow picker, and
|
|
1488
|
+
any CLI entry point all call this (via remux_and_burn below, or
|
|
1489
|
+
directly), so a fix here only has to happen once.
|
|
1490
|
+
"""
|
|
1491
|
+
check_encoded_size(ser, mpg, disc_capacity)
|
|
1492
|
+
send(ser, f"TITLE:{disc_label}")
|
|
1493
|
+
send(ser, f"INFO:Burning {mpg.parent.name}")
|
|
1494
|
+
|
|
1495
|
+
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")
|
|
1498
|
+
fixed = mpg.parent / "movie_fixed.mpg"
|
|
1499
|
+
if fixed.exists():
|
|
1500
|
+
fixed.unlink()
|
|
1501
|
+
|
|
1502
|
+
total = probe_duration(mpg)
|
|
1503
|
+
|
|
1504
|
+
try:
|
|
1505
|
+
_run_ffmpeg_pass(ser,
|
|
1506
|
+
[tool("ffmpeg"), "-y", "-i", str(mpg), "-map", "0:v", "-c:v", "copy", str(v_es)],
|
|
1507
|
+
total, "Remux 1/3 (video)")
|
|
1508
|
+
_run_ffmpeg_pass(ser,
|
|
1509
|
+
[tool("ffmpeg"), "-y", "-i", str(mpg), "-map", "0:a", "-c:a", "copy", str(a_es)],
|
|
1510
|
+
total, "Remux 2/3 (audio)")
|
|
1511
|
+
_run_ffmpeg_pass(ser,
|
|
1512
|
+
[tool("ffmpeg"), "-y", "-i", str(v_es), "-i", str(a_es),
|
|
1513
|
+
"-c", "copy", "-muxrate", "10080k", "-f", "dvd", str(fixed)],
|
|
1514
|
+
total, "Remux 3/3 (mux)")
|
|
1515
|
+
except RuntimeError as e:
|
|
1516
|
+
if str(e) == "Cancelled":
|
|
1517
|
+
raise
|
|
1518
|
+
safe_send(ser, "ERROR:Remux failed, using original")
|
|
1519
|
+
time.sleep(2)
|
|
1520
|
+
fixed = mpg
|
|
1521
|
+
finally:
|
|
1522
|
+
for f in (v_es, a_es):
|
|
1523
|
+
if f.exists():
|
|
1524
|
+
f.unlink()
|
|
1525
|
+
|
|
1526
|
+
check_encoded_size(ser, fixed, disc_capacity)
|
|
1527
|
+
dvd_out = fixed.parent / "dvd_out"
|
|
1528
|
+
if dvd_out.exists():
|
|
1529
|
+
shutil.rmtree(dvd_out)
|
|
1530
|
+
|
|
1531
|
+
if dvd_aspect is None:
|
|
1532
|
+
aspect = probe_aspect(fixed)
|
|
1533
|
+
dvd_aspect = "16:9" if (aspect is not None and aspect > 1.4) else "4:3"
|
|
1534
|
+
|
|
1535
|
+
dvd_dir = author(ser, fixed, fixed.parent, dvd_aspect)
|
|
1536
|
+
check_dvd_size(ser, dvd_dir, disc_capacity)
|
|
1537
|
+
return dvd_dir
|
|
1538
|
+
|
|
1539
|
+
def remux_and_burn(ser, mpg, disc_label, disc_capacity, dl_info, burn_speed=None, dvd_aspect=None):
|
|
1540
|
+
dvd_dir = remux_and_author(ser, mpg, disc_label, disc_capacity, dvd_aspect)
|
|
1541
|
+
wait_for_burn_confirm(ser, dvd_dir, disc_capacity)
|
|
1542
|
+
burn(ser, dvd_dir, disc_label, burn_speed, dl_info["is_dual_layer"])
|
|
1543
|
+
safe_send(ser, "DONE:Burn complete!")
|
|
1544
|
+
print(f"Burned {mpg} as {disc_label}")
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
def main():
|
|
1548
|
+
if len(sys.argv) < 2:
|
|
1549
|
+
print("Usage: python3 discstation_burn.py 'video URL or file path'")
|
|
1550
|
+
sys.exit(1)
|
|
1551
|
+
url = sys.argv[1]
|
|
1552
|
+
WORK.mkdir(parents=True, exist_ok=True)
|
|
1553
|
+
job_dir = WORK / time.strftime("job_%Y%m%d_%H%M%S")
|
|
1554
|
+
job_dir.mkdir()
|
|
1555
|
+
ser = None
|
|
1556
|
+
try:
|
|
1557
|
+
ser = serial.Serial(PORT, BAUD, timeout=1)
|
|
1558
|
+
reset_serial_state()
|
|
1559
|
+
time.sleep(2)
|
|
1560
|
+
print("Connected to DiscStation")
|
|
1561
|
+
print("Running preflight...")
|
|
1562
|
+
send(ser, "STATUS:Preflight...")
|
|
1563
|
+
info = get_video_info(url)
|
|
1564
|
+
title = info["title"]
|
|
1565
|
+
duration = info["duration"]
|
|
1566
|
+
duration_line, fit_line, can_fit = preflight_lines(duration)
|
|
1567
|
+
print(f"Title: {title}")
|
|
1568
|
+
print(f"Duration: {format_duration(duration)}")
|
|
1569
|
+
print(f"Preflight: {fit_line}")
|
|
1570
|
+
print(f"DVD drive: {disc_device()}")
|
|
1571
|
+
disc_label = sanitize_disc_label(title)
|
|
1572
|
+
print(f"Disc label: {disc_label}")
|
|
1573
|
+
send(ser, f"TITLE:{title}")
|
|
1574
|
+
send(ser, f"META:{duration_line}")
|
|
1575
|
+
send(ser, f"FIT:{fit_line}")
|
|
1576
|
+
if not can_fit:
|
|
1577
|
+
raise RuntimeError("Video too long for DVD5")
|
|
1578
|
+
|
|
1579
|
+
device = disc_device()
|
|
1580
|
+
dl_info = detect_disc_type(device)
|
|
1581
|
+
if dl_info["is_dual_layer"]:
|
|
1582
|
+
sl_target = int(os.environ.get("DISC_TARGET_BYTES", "4300000000"))
|
|
1583
|
+
try:
|
|
1584
|
+
sl_plan = bitrate_plan(duration, "AUTO", sl_target)
|
|
1585
|
+
if sl_plan:
|
|
1586
|
+
warn = f"DL disc for SL content"
|
|
1587
|
+
print(f"WARNING: {warn}")
|
|
1588
|
+
safe_send(ser, f"WARNING:{warn}")
|
|
1589
|
+
time.sleep(3)
|
|
1590
|
+
except RuntimeError:
|
|
1591
|
+
pass
|
|
1592
|
+
|
|
1593
|
+
print("Waiting for button press...")
|
|
1594
|
+
selected_mode = "AUTO"
|
|
1595
|
+
burn_speed = None
|
|
1596
|
+
while True:
|
|
1597
|
+
if ser.in_waiting:
|
|
1598
|
+
resp = ser.readline().decode(errors='ignore').strip()
|
|
1599
|
+
note_serial_activity()
|
|
1600
|
+
if resp == "CANCEL":
|
|
1601
|
+
print("Cancelled by user")
|
|
1602
|
+
safe_send(ser, "CANCELLED:Cancelled")
|
|
1603
|
+
sys.exit(130)
|
|
1604
|
+
elif resp.startswith("MODE:"):
|
|
1605
|
+
selected_mode = normalize_mode(resp.split(":", 1)[1])
|
|
1606
|
+
print(f"Mode: {selected_mode}")
|
|
1607
|
+
elif resp.startswith("SPEED:"):
|
|
1608
|
+
burn_speed = resp.split(":", 1)[1].strip()
|
|
1609
|
+
print(f"Burn speed: {burn_speed}")
|
|
1610
|
+
elif resp == "START" or resp.startswith("START:"):
|
|
1611
|
+
if ":" in resp:
|
|
1612
|
+
selected_mode = normalize_mode(resp.split(":", 1)[1])
|
|
1613
|
+
print(f"Button pressed - starting in {selected_mode} mode!")
|
|
1614
|
+
send(ser, f"STATUS:Starting {selected_mode}...")
|
|
1615
|
+
break
|
|
1616
|
+
time.sleep(0.1)
|
|
1617
|
+
|
|
1618
|
+
start_time = time.time()
|
|
1619
|
+
disc_type_label = "DL" if dl_info["is_dual_layer"] else "SL"
|
|
1620
|
+
try:
|
|
1621
|
+
disc_bytes = dl_info["capacity"]
|
|
1622
|
+
plan = bitrate_plan(duration, selected_mode, disc_bytes)
|
|
1623
|
+
video = download(ser, url, job_dir)
|
|
1624
|
+
mpg, dvd_aspect = convert(ser, video, job_dir, selected_mode, disc_bytes)
|
|
1625
|
+
dvd = remux_and_author(ser, mpg, disc_label, disc_bytes, dvd_aspect)
|
|
1626
|
+
if plan["burn"]:
|
|
1627
|
+
burn(ser, dvd, disc_label, burn_speed, dl_info["is_dual_layer"])
|
|
1628
|
+
send(ser, "DONE:Disc complete!")
|
|
1629
|
+
print("Done!")
|
|
1630
|
+
else:
|
|
1631
|
+
send(ser, "DONE:Test complete!")
|
|
1632
|
+
print("Test complete. DVD folder was built but not burned.")
|
|
1633
|
+
append_history({
|
|
1634
|
+
"timestamp": datetime.datetime.now().isoformat(),
|
|
1635
|
+
"title": title,
|
|
1636
|
+
"disc_type": disc_type_label,
|
|
1637
|
+
"mode": selected_mode,
|
|
1638
|
+
"speed": burn_speed or "Auto",
|
|
1639
|
+
"success": True,
|
|
1640
|
+
"duration_s": round(time.time() - start_time),
|
|
1641
|
+
})
|
|
1642
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1643
|
+
append_history({
|
|
1644
|
+
"timestamp": datetime.datetime.now().isoformat(),
|
|
1645
|
+
"title": title,
|
|
1646
|
+
"disc_type": disc_type_label,
|
|
1647
|
+
"mode": selected_mode,
|
|
1648
|
+
"speed": burn_speed or "Auto",
|
|
1649
|
+
"success": False,
|
|
1650
|
+
"error": "Cancelled",
|
|
1651
|
+
"duration_s": round(time.time() - start_time),
|
|
1652
|
+
})
|
|
1653
|
+
raise
|
|
1654
|
+
except Exception as e:
|
|
1655
|
+
append_history({
|
|
1656
|
+
"timestamp": datetime.datetime.now().isoformat(),
|
|
1657
|
+
"title": title,
|
|
1658
|
+
"disc_type": disc_type_label,
|
|
1659
|
+
"mode": selected_mode,
|
|
1660
|
+
"speed": burn_speed or "Auto",
|
|
1661
|
+
"success": False,
|
|
1662
|
+
"error": str(e)[:100],
|
|
1663
|
+
"duration_s": round(time.time() - start_time),
|
|
1664
|
+
})
|
|
1665
|
+
raise
|
|
1666
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1667
|
+
safe_send(ser, "CANCELLED:Stopped")
|
|
1668
|
+
print("Cancelled")
|
|
1669
|
+
sys.exit(130)
|
|
1670
|
+
except RuntimeError as e:
|
|
1671
|
+
msg = str(e)
|
|
1672
|
+
if msg == "Cancelled":
|
|
1673
|
+
safe_send(ser, "CANCELLED:Stopped")
|
|
1674
|
+
else:
|
|
1675
|
+
safe_send(ser, f"ERROR:{msg[:20]}")
|
|
1676
|
+
print(f"Error: {e}")
|
|
1677
|
+
sys.exit(1)
|
|
1678
|
+
except Exception as e:
|
|
1679
|
+
safe_send(ser, f"ERROR:{str(e)[:20]}")
|
|
1680
|
+
print(f"Error: {e}")
|
|
1681
|
+
sys.exit(1)
|
|
1682
|
+
finally:
|
|
1683
|
+
if ser:
|
|
1684
|
+
ser.close()
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
BURN_HISTORY = WORK / "burn_history.jsonl"
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
def append_history(entry):
|
|
1691
|
+
BURN_HISTORY.parent.mkdir(parents=True, exist_ok=True)
|
|
1692
|
+
with open(BURN_HISTORY, "a") as f:
|
|
1693
|
+
f.write(json.dumps(entry) + "\n")
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
if __name__ == "__main__":
|
|
1697
|
+
main()
|