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.
@@ -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()