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,3984 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import atexit
4
+ import collections
5
+ import concurrent.futures
6
+ import errno
7
+ import fcntl
8
+ import json
9
+ import mimetypes
10
+ import os
11
+ import signal
12
+ try:
13
+ import pwd
14
+ except ImportError:
15
+ pwd = None
16
+ import re
17
+ import shutil
18
+ import socket
19
+ import ssl
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+ import time
24
+ import datetime
25
+ from pathlib import Path
26
+ from queue import Queue, Empty
27
+ import http.server
28
+ import socketserver
29
+ try:
30
+ import termios
31
+ except ImportError:
32
+ class _TermiosCompat:
33
+ error = OSError
34
+ termios = _TermiosCompat()
35
+ import threading
36
+ import urllib.parse
37
+
38
+ import requests
39
+ import serial
40
+ from mutagen.flac import FLAC, Picture
41
+
42
+ import discstation_burn
43
+ import discstation_host
44
+
45
+ # Web interface for URL input and file upload
46
+ _burn_url_queue = Queue()
47
+ _web_port = 8080
48
+ _web_server = None
49
+ _last_burn_result = None
50
+ _last_burn_result_time = 0
51
+ _last_upload_dir = None
52
+ _last_upload_label = None
53
+ _web_status = "READY"
54
+ _web_progress = -1
55
+ _web_progress_active = False
56
+ _active_ser = None
57
+ STATIC_DIR = Path(__file__).resolve().parent / "static"
58
+
59
+
60
+ class _WebHandler(http.server.BaseHTTPRequestHandler):
61
+ def do_GET(self):
62
+ path = urllib.parse.urlsplit(self.path).path
63
+ if path == '/':
64
+ self._serve_page()
65
+ elif path == '/status':
66
+ result = _last_burn_result
67
+ self._respond(200, _web_status or result or 'Idle')
68
+ elif path == '/progress':
69
+ self._respond(200, json.dumps({
70
+ "status": _web_status or "READY",
71
+ "progress": _web_progress,
72
+ "active": _web_progress_active,
73
+ }), "application/json")
74
+ elif path == '/disc-info':
75
+ self._serve_disc_info()
76
+ elif path == '/sw.js':
77
+ self._serve_sw()
78
+ elif path == '/manifest.json':
79
+ self._serve_manifest()
80
+ elif path.startswith('/static/'):
81
+ self._serve_static(path[8:])
82
+ else:
83
+ self.send_error(404)
84
+
85
+ def do_POST(self):
86
+ path = urllib.parse.urlsplit(self.path).path
87
+ if path == '/':
88
+ content_type = self.headers.get('Content-Type', '')
89
+ if 'multipart/form-data' in content_type:
90
+ self._handle_upload()
91
+ else:
92
+ self._handle_url()
93
+ elif path == '/set-label':
94
+ self._handle_set_label()
95
+ else:
96
+ self.send_error(404)
97
+
98
+ def _handle_url(self):
99
+ length = int(self.headers.get('Content-Length', 0))
100
+ body = self.rfile.read(length).decode()
101
+ params = urllib.parse.parse_qs(body)
102
+ url = params.get('url', [''])[0].strip()
103
+ if url:
104
+ _burn_url_queue.put(url)
105
+ self._respond(200, 'URL received. Starting burn...')
106
+ else:
107
+ self._respond(400, 'Missing URL')
108
+
109
+ def _handle_upload(self):
110
+ global _last_upload_dir
111
+ _set_web_progress("UPLOADING", 0)
112
+ files = self._parse_multipart(lambda percent: _set_web_progress("UPLOADING", percent))
113
+ if not files:
114
+ self._respond(400, 'No files uploaded')
115
+ return
116
+ paths_list = []
117
+ for filename, data in list(files):
118
+ if filename == '_paths':
119
+ try:
120
+ parsed = json.loads(data.decode())
121
+ paths_list = parsed if isinstance(parsed, list) else []
122
+ except Exception as e:
123
+ print(f"Upload path metadata error: {e}")
124
+ files.remove((filename, data))
125
+ upload_dir = Path(discstation_burn.WORK) / f"upload_{time.strftime('%Y%m%d_%H%M%S')}"
126
+ upload_dir.mkdir(parents=True, exist_ok=True)
127
+ total = 0
128
+ for i, (filename, data) in enumerate(files):
129
+ rel = filename
130
+ if i < len(paths_list) and paths_list[i].get('p'):
131
+ p = paths_list[i]['p']
132
+ if p != paths_list[i].get('n', ''):
133
+ rel = p
134
+ dest = (upload_dir / rel.lstrip('/')).resolve()
135
+ if upload_dir.resolve() not in dest.parents:
136
+ print(f"Skipping unsafe upload path: {rel}")
137
+ continue
138
+ dest.parent.mkdir(parents=True, exist_ok=True)
139
+ dest.write_bytes(data)
140
+ total += len(data)
141
+ _last_upload_dir = str(upload_dir)
142
+ size_str = f"{total / 1e6:.1f}MB" if total > 1e6 else f"{total / 1e3:.0f}KB"
143
+ _set_web_progress("UPLOAD READY", 100)
144
+ self._respond(200, f'{len(files)} file(s) uploaded ({size_str}). Select BURN DATA on remote.')
145
+
146
+ def _serve_disc_info(self):
147
+ info = {"disc_present": False, "capacity_bytes": 0, "capacity_gb": 0, "type": "none"}
148
+ try:
149
+ device = discstation_burn.disc_device()
150
+ di = detect_disc(device, settle=False, budget=15)
151
+ info["disc_present"] = di.present
152
+ info["capacity_bytes"] = di.capacity_bytes
153
+ info["capacity_gb"] = round(di.capacity_bytes / 1e9, 2)
154
+ if not di.present:
155
+ info["type"] = "none"
156
+ elif di.transient:
157
+ info["type"] = "reading"
158
+ else:
159
+ info["type"] = di.web_type
160
+ info["kind"] = di.kind
161
+ info["label"] = di.label
162
+ except Exception as e:
163
+ print(f"Disc info error: {e}")
164
+ self._respond(200, json.dumps(info), "application/json")
165
+
166
+ def _handle_set_label(self):
167
+ global _last_upload_label
168
+ length = int(self.headers.get('Content-Length', 0))
169
+ body = self.rfile.read(length).decode()
170
+ params = urllib.parse.parse_qs(body)
171
+ label = params.get('label', [''])[0].strip()
172
+ if label:
173
+ _last_upload_label = label
174
+ self._respond(200, f'Label set: {label}')
175
+ else:
176
+ self._respond(400, 'Missing label')
177
+
178
+ def _serve_sw(self):
179
+ sw = '''self.addEventListener('install', e => {
180
+ self.skipWaiting();
181
+ caches.open('discstation-v6').then(c => c.addAll(['/','/static/style.css?v=6','/static/app.js?v=6']));
182
+ });
183
+ self.addEventListener('activate', e => e.waitUntil(clients.claim()));
184
+ self.addEventListener('fetch', e => {
185
+ const path = new URL(e.request.url).pathname;
186
+ if (path === '/' || path.startsWith('/static/')) {
187
+ e.respondWith(fetch(e.request).then(r => {
188
+ const copy = r.clone();
189
+ caches.open('discstation-v6').then(c => c.put(e.request, copy));
190
+ return r;
191
+ }).catch(() => caches.match(e.request)));
192
+ } else {
193
+ e.respondWith(caches.match(e.request).then(r => r || fetch(e.request)));
194
+ }
195
+ });'''
196
+ self._respond(200, sw, 'application/javascript')
197
+
198
+ def _serve_manifest(self):
199
+ icon_svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" fill="#f0ede4"/><circle cx="256" cy="256" r="210" fill="none" stroke="#1a1a1a" stroke-width="20"/><circle cx="256" cy="256" r="68" fill="none" stroke="#1a1a1a" stroke-width="16"/><path d="M256 188v68h68" fill="none" stroke="#1a1a1a" stroke-width="16"/></svg>'
200
+ manifest = {
201
+ "name": "DiscStation",
202
+ "short_name": "DiscStation",
203
+ "start_url": "/",
204
+ "display": "standalone",
205
+ "background_color": "#f0ede4",
206
+ "theme_color": "#f0ede4",
207
+ "description": "DiscStation physical media instrument",
208
+ "icons": [{
209
+ "src": "data:image/svg+xml," + urllib.parse.quote(icon_svg),
210
+ "sizes": "512x512",
211
+ "type": "image/svg+xml",
212
+ "purpose": "any maskable"
213
+ }]
214
+ }
215
+ self._respond(200, json.dumps(manifest), 'application/json')
216
+
217
+ def _parse_multipart(self, progress_callback=None):
218
+ content_type = self.headers.get('Content-Type', '')
219
+ boundary = None
220
+ for part in content_type.split(';'):
221
+ part = part.strip()
222
+ if part.lower().startswith('boundary='):
223
+ boundary = part[9:].strip('"')
224
+ if not boundary:
225
+ return []
226
+ content_length = int(self.headers.get('Content-Length', 0))
227
+ chunks = []
228
+ received = 0
229
+ while received < content_length:
230
+ chunk = self.rfile.read(min(1024 * 1024, content_length - received))
231
+ if not chunk:
232
+ break
233
+ chunks.append(chunk)
234
+ received += len(chunk)
235
+ if progress_callback and content_length:
236
+ progress_callback(min(99, int(received * 100 / content_length)))
237
+ raw = b"".join(chunks)
238
+ boundary_b = ('--' + boundary).encode()
239
+ parts = raw.split(boundary_b)[1:-1]
240
+ files = []
241
+ for part in parts:
242
+ if part.startswith(b'--'):
243
+ break
244
+ header_end = part.find(b'\r\n\r\n')
245
+ if header_end < 0:
246
+ continue
247
+ headers_raw = part[:header_end].decode(errors='ignore')
248
+ body = part[header_end + 4:]
249
+ if body.endswith(b'\r\n'):
250
+ body = body[:-2]
251
+ filename = None
252
+ for line in headers_raw.split('\r\n'):
253
+ if line.lower().startswith('content-disposition:'):
254
+ for attr in line.split(';'):
255
+ attr = attr.strip()
256
+ if attr.startswith('filename='):
257
+ filename = attr[10:].strip('"')
258
+ field_name = None
259
+ for line in headers_raw.split('\r\n'):
260
+ if line.lower().startswith('content-disposition:'):
261
+ for attr in line.split(';'):
262
+ attr = attr.strip()
263
+ if attr.startswith('name='):
264
+ field_name = attr[5:].strip('"')
265
+ if body and (filename or field_name == '_paths'):
266
+ files.append((filename or field_name, body))
267
+ return files
268
+
269
+ def _serve_page(self):
270
+ self._serve_static("index.html", "text/html; charset=utf-8")
271
+
272
+ def _serve_static(self, relative_path, content_type=None):
273
+ root = STATIC_DIR.resolve()
274
+ requested = (root / relative_path).resolve()
275
+ if root not in requested.parents or not requested.is_file():
276
+ self.send_error(404)
277
+ return
278
+ content_type = content_type or mimetypes.guess_type(str(requested))[0] or "application/octet-stream"
279
+ self._respond(200, requested.read_bytes(), content_type)
280
+
281
+ def _respond(self, code, body, ctype='text/plain'):
282
+ try:
283
+ self.send_response(code)
284
+ self.send_header('Content-Type', ctype)
285
+ self.send_header('Connection', 'close')
286
+ self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
287
+ self.end_headers()
288
+ self.wfile.write(body.encode() if isinstance(body, str) else body)
289
+ except (BrokenPipeError, ConnectionResetError, ssl.SSLEOFError):
290
+ pass
291
+
292
+ def log_message(self, fmt, *args):
293
+ pass
294
+
295
+
296
+ def start_web_server(port=8080):
297
+ global _web_server, _web_port
298
+ if _web_server:
299
+ return _web_server
300
+ _web_port = port
301
+ server = socketserver.ThreadingTCPServer(('', port), _WebHandler, bind_and_activate=False)
302
+ server.allow_reuse_address = True
303
+ server.server_bind()
304
+ server.server_activate()
305
+
306
+ cert_dir = discstation_host.config_dir()
307
+ cert = cert_dir / 'server.crt'
308
+ key = cert_dir / 'server.key'
309
+ if cert.exists() and key.exists():
310
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
311
+ ctx.load_cert_chain(str(cert), str(key))
312
+ server.socket = ctx.wrap_socket(server.socket, server_side=True)
313
+ print(f"Web interface on https://0.0.0.0:{port}")
314
+ else:
315
+ print(f"Web interface on http://0.0.0.0:{port}")
316
+
317
+ _web_server = server
318
+ t = threading.Thread(target=server.serve_forever, daemon=True)
319
+ t.start()
320
+
321
+ # Plain-HTTP listener for the mobile app (Expo Go can't use the self-signed
322
+ # cert). Same handler, LAN only. Disable with DISCSTATION_HTTP_PORT=0.
323
+ try:
324
+ http_port = int(os.environ.get("DISCSTATION_HTTP_PORT", "8081"))
325
+ except ValueError:
326
+ http_port = 8081
327
+ if http_port and http_port != port:
328
+ try:
329
+ plain = socketserver.ThreadingTCPServer(('', http_port), _WebHandler)
330
+ plain.allow_reuse_address = True
331
+ threading.Thread(target=plain.serve_forever, daemon=True).start()
332
+ print(f"Plain HTTP (mobile app) on http://0.0.0.0:{http_port}")
333
+ except OSError as e:
334
+ print(f"Plain HTTP listener not started on {http_port}: {e}")
335
+
336
+ return server
337
+
338
+
339
+ def local_ip():
340
+ try:
341
+ result = subprocess.run(['hostname', '-I'], capture_output=True, text=True, timeout=2)
342
+ ips = result.stdout.strip().split()
343
+ for ip in ips:
344
+ if ip.count('.') == 3 and not ip.startswith('127.'):
345
+ return ip
346
+ except Exception:
347
+ pass
348
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
349
+ try:
350
+ s.connect(('8.8.8.8', 80))
351
+ return s.getsockname()[0]
352
+ except Exception:
353
+ return '127.0.0.1'
354
+ finally:
355
+ s.close()
356
+
357
+
358
+ def wait_for_web_url(ser):
359
+ ip = local_ip()
360
+ protocol = "https" if (discstation_host.config_dir() / 'server.crt').exists() else "http"
361
+ url = f"{protocol}://{ip}:{_web_port}"
362
+ safe_send(ser, f"IP:{url}")
363
+ print(f"URL displayed: {url}")
364
+ while True:
365
+ line = read_serial_line(ser, timeout=0.5)
366
+ if line and line.strip().upper() in ("CANCEL", "HOME"):
367
+ safe_send(ser, "STANDBY:Insert disc")
368
+ print("User cancelled URL input")
369
+ return None
370
+ try:
371
+ url = _burn_url_queue.get(timeout=5)
372
+ safe_send(ser, f"STATUS:Got URL, starting...")
373
+ return url
374
+ except Empty:
375
+ safe_send(ser, "PING")
376
+ check_serial_alive(ser)
377
+
378
+
379
+ MPV_SOCKET = str(Path(tempfile.gettempdir()) / "discstation_mpv.sock")
380
+ RIP_ROOT = discstation_burn.USER_HOME / "dvd_rips"
381
+ USER_AGENT = "DVDStation/0.1 (local appliance; phuju)"
382
+ DISC_POLL_SECONDS = 6
383
+
384
+ # Optional metadata libraries. If import/setup fails the code falls back to the
385
+ # hand-rolled requests-based lookups further down.
386
+ try:
387
+ import libdiscid as _libdiscid
388
+ except Exception:
389
+ _libdiscid = None
390
+
391
+ try:
392
+ import musicbrainzngs as _mb
393
+ _mb.set_useragent("DiscStation", "0.1", "https://github.com/phuju/dvd-station")
394
+ _mb.set_rate_limit(1.0, 1) # MB asks for <=1 req/s; replaces manual time.sleep(1)
395
+ except Exception:
396
+ _mb = None
397
+
398
+ try:
399
+ import discstation_meta # TMDb video metadata (optional)
400
+ except Exception:
401
+ discstation_meta = None
402
+
403
+
404
+ def _env_num(name, default, cast):
405
+ try:
406
+ return cast(os.environ.get(name, default))
407
+ except (TypeError, ValueError):
408
+ return cast(default)
409
+
410
+
411
+ # --- Disc-detection tuning -------------------------------------------------
412
+ # Probe timeouts are CEILINGS, not fixed costs: a healthy drive returns well
413
+ # under these. They are large because this appliance's USB optical bridge can
414
+ # need 5-15s on the first read after a disc loads. Every value is overridable
415
+ # via a DISCSTATION_* environment variable (set them in the systemd unit).
416
+ PROBE_TIMEOUT_UDEV = _env_num("DISCSTATION_PROBE_TIMEOUT_UDEV", 4, int)
417
+ PROBE_TIMEOUT_BLKID = _env_num("DISCSTATION_PROBE_TIMEOUT_BLKID", 8, int)
418
+ PROBE_TIMEOUT_LSDVD = _env_num("DISCSTATION_PROBE_TIMEOUT_LSDVD", 8, int)
419
+ PROBE_TIMEOUT_WODIM_TOC = _env_num("DISCSTATION_PROBE_TIMEOUT_WODIM_TOC", 12, int)
420
+ PROBE_TIMEOUT_MEDIAINFO = _env_num("DISCSTATION_PROBE_TIMEOUT_MEDIAINFO", 12, int)
421
+
422
+ # Post-insert settle wait + classification retry budget.
423
+ DISC_SETTLE_TIMEOUT = _env_num("DISCSTATION_DISC_SETTLE_TIMEOUT", 8, int)
424
+ DISC_SETTLE_POLL = _env_num("DISCSTATION_DISC_SETTLE_POLL", 1.0, float)
425
+ DISC_DETECT_RETRIES = _env_num("DISCSTATION_DISC_DETECT_RETRIES", 2, int)
426
+ DISC_DETECT_RETRY_DELAY = _env_num("DISCSTATION_DISC_DETECT_RETRY_DELAY", 2.0, float)
427
+ DISC_DETECT_BUDGET = _env_num("DISCSTATION_DISC_DETECT_BUDGET", 18, int)
428
+ DISC_DETECT_CACHE_TTL = _env_num("DISCSTATION_DISC_DETECT_CACHE_TTL", 5.0, float)
429
+
430
+ _NO_MEDIA_MARKERS = (
431
+ "no medium", "no disk", "no disc", "cannot load media",
432
+ "tray open", "medium not present",
433
+ )
434
+
435
+ DiscInfo = collections.namedtuple(
436
+ "DiscInfo",
437
+ "present kind capacity_bytes label web_type transient failed_probes",
438
+ )
439
+
440
+
441
+ def _disc_info(present=False, kind="none", capacity_bytes=0, label="",
442
+ web_type="none", transient=False, failed_probes=()):
443
+ return DiscInfo(bool(present), kind, int(capacity_bytes or 0), label or "",
444
+ web_type, bool(transient), tuple(failed_probes))
445
+
446
+
447
+ def _web_type_for(kind, capacity_bytes):
448
+ simple = {
449
+ "blank": "BLANK", "audio_cd": "AUDIO_CD", "data_cd": "DATA_CD",
450
+ "vcd": "VCD", "svcd": "SVCD", "video_data": "VIDEO", "none": "none",
451
+ }
452
+ if kind in simple:
453
+ return simple[kind]
454
+ if kind in ("dvd_video", "data_disc"):
455
+ if capacity_bytes and capacity_bytes > 6_000_000_000:
456
+ return "DVD9"
457
+ if capacity_bytes:
458
+ return "DVD5"
459
+ return "DVD-Video" if kind == "dvd_video" else "DATA_DISC"
460
+ return "UNKNOWN"
461
+
462
+
463
+ def _udev_dvd_recordable(props):
464
+ return any(props.get(k) == "1" for k in (
465
+ "ID_CDROM_MEDIA_DVD_PLUS_R", "ID_CDROM_MEDIA_DVD_R",
466
+ "ID_CDROM_MEDIA_DVD_PLUS_R_DL", "ID_CDROM_MEDIA_DVD_R_DL",
467
+ "ID_CDROM_MEDIA_DVD_RW", "ID_CDROM_MEDIA_DVD_PLUS_RW",
468
+ ))
469
+
470
+
471
+ def ensure_text(value):
472
+ if value is None:
473
+ return ""
474
+ if isinstance(value, bytes):
475
+ return value.decode(errors="ignore")
476
+ return str(value)
477
+
478
+
479
+ def _set_web_progress(phase, percent=-1):
480
+ global _web_status, _web_progress, _web_progress_active
481
+ _web_status = phase
482
+ _web_progress = max(-1, min(100, int(percent))) if percent is not None else -1
483
+ _web_progress_active = True
484
+ if _active_ser:
485
+ discstation_burn.safe_send(_active_ser, f"STATUS:{phase}")
486
+ if _web_progress >= 0:
487
+ discstation_burn.safe_send(_active_ser, f"PROGRESS:{_web_progress}%")
488
+
489
+
490
+ def _record_web_status(msg):
491
+ global _web_status, _web_progress, _web_progress_active
492
+ if msg.startswith("STATUS:"):
493
+ _web_status = msg[7:].strip() or "READY"
494
+ _web_progress_active = True
495
+ elif msg.startswith("PROGRESS:"):
496
+ value = msg[9:].strip()
497
+ _web_status = f"BURNING {value}"
498
+ match = re.search(r"(\d+(?:\.\d+)?)", value)
499
+ if match:
500
+ _web_progress = min(100, max(0, int(float(match.group(1)))))
501
+ _web_progress_active = True
502
+ elif msg.startswith("DONE:"):
503
+ _web_status = msg[5:].strip() or "DONE"
504
+ _web_progress = 100
505
+ _web_progress_active = False
506
+ elif msg.startswith("ERROR:"):
507
+ _web_status = msg[6:].strip() or "ERROR"
508
+ _web_progress_active = False
509
+ elif msg.startswith("CANCELLED:"):
510
+ _web_status = msg[10:].strip() or "CANCELLED"
511
+ _web_progress_active = False
512
+
513
+
514
+ def send(ser, msg):
515
+ _record_web_status(msg)
516
+ discstation_burn.send(ser, msg)
517
+
518
+
519
+ def safe_send(ser, msg):
520
+ _record_web_status(msg)
521
+ discstation_burn.safe_send(ser, msg)
522
+
523
+
524
+ def run_as_desktop_user(cmd):
525
+ if os.name != "posix" or pwd is None:
526
+ return cmd
527
+ sudo_user = os.environ.get("SUDO_USER")
528
+ if os.geteuid() == 0 and sudo_user and sudo_user != "root":
529
+ home = Path(discstation_burn.USER_HOME)
530
+ uid = pwd.getpwnam(sudo_user).pw_uid
531
+ return [
532
+ "sudo", "-u", sudo_user,
533
+ "env",
534
+ "DISPLAY=" + os.environ.get("DISPLAY", ":0"),
535
+ "XAUTHORITY=" + str(home / ".Xauthority"),
536
+ "XDG_RUNTIME_DIR=/run/user/" + str(uid),
537
+ *cmd,
538
+ ]
539
+ return cmd
540
+
541
+
542
+ def chown_to_sudo_user(path):
543
+ sudo_user = os.environ.get("SUDO_USER")
544
+ if os.name != "posix" or pwd is None or not hasattr(os, "geteuid"):
545
+ return
546
+ if os.geteuid() != 0 or not sudo_user or sudo_user == "root":
547
+ return
548
+
549
+ try:
550
+ pw_record = pwd.getpwnam(sudo_user)
551
+ except KeyError:
552
+ return
553
+
554
+ uid = pw_record.pw_uid
555
+ gid = pw_record.pw_gid
556
+ root_path = Path(path)
557
+
558
+ for current_root, dirs, files in os.walk(root_path):
559
+ try:
560
+ os.chown(current_root, uid, gid)
561
+ except OSError:
562
+ pass
563
+ for name in dirs + files:
564
+ item = Path(current_root) / name
565
+ try:
566
+ os.chown(item, uid, gid)
567
+ except OSError:
568
+ pass
569
+
570
+
571
+ def mpv_command(command):
572
+ try:
573
+ payload = json.dumps({"command": command}).encode() + b"\n"
574
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
575
+ sock.connect(MPV_SOCKET)
576
+ sock.sendall(payload)
577
+ except OSError:
578
+ return False
579
+ return True
580
+
581
+
582
+ def mpv_query(command):
583
+ try:
584
+ payload = json.dumps({"command": command, "request_id": 1}).encode() + b"\n"
585
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
586
+ sock.settimeout(0.5)
587
+ sock.connect(MPV_SOCKET)
588
+ sock.sendall(payload)
589
+ response = json.loads(sock.recv(4096).decode(errors="ignore"))
590
+ return response.get("data")
591
+ except (OSError, ValueError, json.JSONDecodeError):
592
+ return None
593
+
594
+
595
+ def wait_for_socket(path, proc, timeout=8):
596
+ deadline = time.time() + timeout
597
+ while time.time() < deadline:
598
+ if proc.poll() is not None:
599
+ return False
600
+ if Path(path).exists():
601
+ try:
602
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
603
+ sock.settimeout(0.25)
604
+ sock.connect(path)
605
+ return True
606
+ except OSError:
607
+ pass
608
+ time.sleep(0.1)
609
+ return False
610
+
611
+
612
+ def wait_for_button(ser):
613
+ last_ping = time.time()
614
+ while True:
615
+ line = read_serial_line(ser, timeout=0.1)
616
+ if line:
617
+ return line
618
+ now = time.time()
619
+ if now - last_ping >= 5:
620
+ last_ping = now
621
+ safe_send(ser, "PING")
622
+ check_serial_alive(ser)
623
+
624
+
625
+ _line_buf = b""
626
+
627
+
628
+ def read_serial_line(ser, timeout=0.1):
629
+ global _line_buf
630
+ deadline = time.monotonic() + timeout
631
+ while time.monotonic() < deadline:
632
+ if _line_buf:
633
+ _line_buf = _line_buf.lstrip(b'\r\n')
634
+ if _line_buf:
635
+ idx = _line_buf.find(b"\n")
636
+ if idx >= 0:
637
+ line = _line_buf[:idx]
638
+ _line_buf = _line_buf[idx + 1:]
639
+ decoded = line.decode(errors="ignore").strip() or None
640
+ if decoded:
641
+ discstation_burn.note_serial_activity()
642
+ return decoded
643
+ try:
644
+ waiting = getattr(ser, "in_waiting", None)
645
+ if waiting is not None:
646
+ chunk = ser.read(min(4096, waiting)) if waiting else b""
647
+ elif hasattr(ser, "fd"):
648
+ chunk = os.read(ser.fd, 4096)
649
+ else:
650
+ chunk = b""
651
+ except serial.SerialException:
652
+ raise
653
+ except OSError as e:
654
+ raise serial.SerialException(f"serial read failed: {e}") from e
655
+ except AttributeError:
656
+ return None
657
+ if chunk:
658
+ chunk = _line_buf + chunk
659
+ _line_buf = b""
660
+ idx = chunk.find(b"\n")
661
+ if idx >= 0:
662
+ _line_buf = chunk[idx + 1:]
663
+ chunk = chunk[:idx]
664
+ decoded = chunk.decode(errors="ignore").strip() or None
665
+ if decoded:
666
+ discstation_burn.note_serial_activity()
667
+ return decoded
668
+ _line_buf = chunk
669
+ remaining = deadline - time.monotonic()
670
+ if remaining <= 0:
671
+ break
672
+ time.sleep(min(remaining, 0.05))
673
+ return None
674
+
675
+
676
+ def check_serial_alive(ser=None):
677
+ """Raise serial.SerialException if the ESP32 link looks dead, so main()'s
678
+ reconnect loop can re-scan for the (possibly renumbered) serial port.
679
+ Call this inside any long poll loop that would otherwise spin forever on a
680
+ stale handle (writes to a re-enumerated /dev/ttyUSBN fail silently)."""
681
+ if discstation_burn.serial_write_failed():
682
+ raise serial.SerialException("serial write failed (ESP32 link lost)")
683
+ if discstation_burn.serial_activity_age() >= 35:
684
+ raise serial.SerialException("ESP32 not responding")
685
+
686
+
687
+ def send_disc_info(ser, device, status_line=None):
688
+ if status_line is None:
689
+ status_line = disc_status_line(device)
690
+ send(ser, f"DISC:{status_line}")
691
+ title = disc_title(device)
692
+ safe_send(ser, f"DISC_NAME:{title}")
693
+ items = menu_items_for_disc(device)
694
+ safe_send(ser, f"MENU_ITEMS:{','.join(items)}")
695
+
696
+ def show_home(ser):
697
+ send(ser, "HOME:Select mode")
698
+
699
+
700
+ def refresh_main_menu(ser):
701
+ """Restore the disc menu after a temporary picker changes MENU_ITEMS."""
702
+ try:
703
+ device = discstation_burn.disc_device()
704
+ send_disc_info(ser, device)
705
+ except Exception as e:
706
+ print(f"Menu refresh error: {e}")
707
+ show_home(ser)
708
+
709
+
710
+ def show_standby(ser):
711
+ safe_send(ser, "STANDBY:DiscStation")
712
+
713
+
714
+ def eject_disc(ser, device):
715
+ global _tray_open, _tray_open_since
716
+ print(f"Ejecting disc from {device}")
717
+ if discstation_host.system_name() != "linux":
718
+ try:
719
+ ok = discstation_host.eject_device(device)
720
+ except Exception as e:
721
+ print(f"Cross-platform eject error: {e}")
722
+ ok = False
723
+ if ok:
724
+ _tray_open = True
725
+ _tray_open_since = time.monotonic()
726
+ safe_send(ser, "STANDBY:Tray open")
727
+ else:
728
+ safe_send(ser, "ERROR:Eject failed")
729
+ safe_send(ser, "STANDBY:Insert disc")
730
+ return ok
731
+ subprocess.run(["sync"], timeout=5)
732
+
733
+ subprocess.run(["sg_raw", device, "1e", "00", "00", "00", "00", "00"],
734
+ timeout=5, capture_output=True)
735
+
736
+ ok = False
737
+ for cmd in (["eject", device], ["sg_raw", device, "1b", "00", "00", "00", "02", "00"]):
738
+ if ok:
739
+ break
740
+ try:
741
+ r = subprocess.run(cmd, timeout=10, capture_output=True)
742
+ ok = r.returncode == 0
743
+ if ok:
744
+ print(f"{cmd[0]} eject ok")
745
+ else:
746
+ err = (r.stderr or r.stdout or b"failed").decode(errors="ignore").strip()[:40]
747
+ print(f"{cmd[0]} eject failed: {err}")
748
+ except Exception as e:
749
+ print(f"{cmd[0]} eject error: {e}")
750
+
751
+ if ok:
752
+ _tray_open = True
753
+ _tray_open_since = time.monotonic()
754
+ safe_send(ser, "WAITING:Press SELECT/to close tray")
755
+ last_ping = time.time()
756
+ deadline = time.time() + 60
757
+ tray_was_cancelled = False
758
+ last_status_check = 0
759
+ # Let the eject settle before touching the drive again (the reclose guard).
760
+ settle_until = time.time() + 3
761
+ while time.time() < deadline:
762
+ if time.time() - last_ping >= 5:
763
+ last_ping = time.time()
764
+ safe_send(ser, "PING")
765
+ if time.time() >= settle_until and time.time() - last_status_check >= 1.5:
766
+ last_status_check = time.time()
767
+ if drive_status(device) in ("disc", "no_disc"):
768
+ print("Tray closed — continuing")
769
+ _tray_open = False
770
+ break
771
+ line = read_serial_line(ser, timeout=0.1)
772
+ if not line:
773
+ continue
774
+ if line == "PONG":
775
+ continue
776
+ if line == "CONFIRM":
777
+ print("Closing tray...")
778
+ safe_send(ser, "STATUS:Closing tray...")
779
+ for close_cmd in (
780
+ ["eject", "-t", device],
781
+ ["sg_raw", device, "1b", "00", "00", "00", "03", "00"],
782
+ ):
783
+ try:
784
+ r = subprocess.run(close_cmd, timeout=10, capture_output=True)
785
+ if r.returncode == 0:
786
+ _tray_open = False
787
+ break
788
+ except Exception:
789
+ pass
790
+ time.sleep(2)
791
+ break
792
+ if line == "CANCEL":
793
+ print("Tray left open")
794
+ tray_was_cancelled = True
795
+ break
796
+ else:
797
+ print("Tray close timed out")
798
+ tray_was_cancelled = True
799
+ else:
800
+ tray_was_cancelled = False
801
+ safe_send(ser, "ERROR:Eject failed")
802
+ if tray_was_cancelled:
803
+ safe_send(ser, "STANDBY:Tray open")
804
+ else:
805
+ safe_send(ser, "STANDBY:Insert disc")
806
+ return ok
807
+
808
+
809
+ HISTORY_FILE = discstation_burn.WORK / "burn_history.jsonl"
810
+
811
+
812
+ def append_burn_history(entry):
813
+ HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
814
+ with open(HISTORY_FILE, "a") as f:
815
+ f.write(json.dumps(entry) + "\n")
816
+
817
+
818
+ def run_probe(cmd, timeout=8, name=None):
819
+ try:
820
+ return subprocess.run(
821
+ cmd,
822
+ capture_output=True,
823
+ text=True,
824
+ timeout=timeout,
825
+ )
826
+ except subprocess.TimeoutExpired as e:
827
+ return subprocess.CompletedProcess(cmd, 124, ensure_text(e.stdout), ensure_text(e.stderr))
828
+ except (FileNotFoundError, OSError) as e:
829
+ return subprocess.CompletedProcess(cmd, 127, "", str(e))
830
+
831
+
832
+ try:
833
+ import pyudev as _pyudev
834
+ _UDEV_CTX = _pyudev.Context()
835
+ except Exception: # pyudev missing, or libudev not loadable
836
+ _pyudev = None
837
+ _UDEV_CTX = None
838
+
839
+
840
+ def _udev_props_via_pyudev(device):
841
+ """Return the udev property dict for `device` via pyudev, or None if pyudev
842
+ is unavailable / errored (caller then falls back to `udevadm info`)."""
843
+ if _pyudev is None:
844
+ return None
845
+ try:
846
+ dev = _pyudev.Devices.from_device_file(_UDEV_CTX, device)
847
+ return dict(dev.properties)
848
+ except Exception:
849
+ return None
850
+
851
+
852
+ _CDROM_ID_BIN = None
853
+
854
+
855
+ def _cdrom_id_path():
856
+ global _CDROM_ID_BIN
857
+ if _CDROM_ID_BIN is None:
858
+ _CDROM_ID_BIN = ""
859
+ for cand in ("/usr/lib/udev/cdrom_id", "/lib/udev/cdrom_id"):
860
+ if Path(cand).exists():
861
+ _CDROM_ID_BIN = cand
862
+ break
863
+ return _CDROM_ID_BIN
864
+
865
+
866
+ def _refresh_udev(device):
867
+ """Best-effort re-probe so ID_CDROM_MEDIA* reflects the disc that is in the
868
+ drive *now*. USB ATAPI bridges frequently emit no media-change uevent, so
869
+ `udevadm info` otherwise serves stale properties from the last change.
870
+ Never raises; returns a dict of freshly read ID_CDROM*/ID_FS* keys."""
871
+ if discstation_host.system_name() != "linux":
872
+ return {}
873
+ try:
874
+ run_probe(
875
+ ["udevadm", "trigger", "--settle", "--subsystem-match=block",
876
+ "--name-match", Path(device).name],
877
+ name="udevadm-trigger", timeout=5,
878
+ )
879
+ except Exception:
880
+ pass
881
+ extra = {}
882
+ binpath = _cdrom_id_path()
883
+ if binpath:
884
+ r = run_probe([binpath, device], name="cdrom_id", timeout=PROBE_TIMEOUT_UDEV)
885
+ if r.returncode == 0:
886
+ for line in ensure_text(r.stdout).splitlines():
887
+ line = line.strip()
888
+ if "=" in line and (line.startswith("ID_CDROM") or line.startswith("ID_FS")):
889
+ key, value = line.split("=", 1)
890
+ extra[key] = value
891
+ return extra
892
+
893
+
894
+ def udev_cdrom_properties(device, refresh=False):
895
+ if discstation_host.system_name() != "linux":
896
+ return discstation_host.media_properties(device)
897
+ overlay = _refresh_udev(device) if refresh else {}
898
+ properties = _udev_props_via_pyudev(device)
899
+ if properties is None:
900
+ # pyudev unavailable — fall back to parsing `udevadm info` output.
901
+ result = run_probe(
902
+ ["udevadm", "info", "--query=property", "--name", device],
903
+ name="udevadm-info", timeout=PROBE_TIMEOUT_UDEV,
904
+ )
905
+ properties = {}
906
+ if result.returncode == 0:
907
+ for line in ensure_text(result.stdout).splitlines():
908
+ if "=" not in line:
909
+ continue
910
+ key, value = line.split("=", 1)
911
+ properties[key] = value
912
+ properties.update(overlay) # a fresh cdrom_id read wins over the cached udev db
913
+ return properties
914
+
915
+
916
+ _tray_open = False
917
+ _tray_open_since = 0.0 # time.monotonic() of the last OLED-initiated eject
918
+
919
+
920
+ def _tray_closed_with_disc(device):
921
+ global _tray_open
922
+ if not Path(device).exists():
923
+ return False
924
+ properties = udev_cdrom_properties(device)
925
+ if properties.get("ID_CDROM_MEDIA") == "1":
926
+ _tray_open = False
927
+ return True
928
+ if properties.get("ID_CDROM_MEDIA_STATE") == "blank":
929
+ _tray_open = False
930
+ return True
931
+ return False
932
+
933
+
934
+ def disc_present(device):
935
+ if _tray_closed_with_disc(device):
936
+ return True
937
+ if _tray_open:
938
+ return False
939
+ if not Path(device).exists():
940
+ return False
941
+ if discstation_host.system_name() != "linux":
942
+ properties = udev_cdrom_properties(device)
943
+ return properties.get("ID_CDROM_MEDIA") == "1"
944
+
945
+ toc = run_probe(["wodim", "-toc", "dev=" + device], name="wodim-toc", timeout=PROBE_TIMEOUT_WODIM_TOC)
946
+ toc_text = ensure_text(toc.stdout) + ensure_text(toc.stderr)
947
+ no_media_markers = _NO_MEDIA_MARKERS
948
+ if toc.returncode == 124:
949
+ return is_blank_disc(device)
950
+ if toc_text.strip() and any(marker in toc_text.lower() for marker in no_media_markers):
951
+ return False
952
+
953
+ dvd = run_probe(["lsdvd", device], name="lsdvd", timeout=PROBE_TIMEOUT_LSDVD)
954
+ if dvd.returncode == 0:
955
+ return True
956
+
957
+ fs = run_probe(["blkid", "-o", "value", "-s", "TYPE", device], name="blkid", timeout=PROBE_TIMEOUT_BLKID)
958
+ if fs.returncode == 0 and ensure_text(fs.stdout).strip():
959
+ return True
960
+
961
+ if "first:" in toc_text and "track:" in toc_text:
962
+ return True
963
+
964
+ if toc_text.strip() and not any(marker in toc_text.lower() for marker in no_media_markers):
965
+ return True
966
+
967
+ return is_blank_disc(device)
968
+
969
+
970
+ def is_blank_disc(device):
971
+ if not Path(device).exists():
972
+ return False
973
+
974
+ properties = udev_cdrom_properties(device, refresh=True)
975
+ media = properties.get("ID_CDROM_MEDIA")
976
+ if media == "0":
977
+ return False
978
+ if discstation_host.system_name() != "linux":
979
+ return properties.get("ID_CDROM_MEDIA_STATE") == "blank"
980
+
981
+ # dvd+rw-mediainfo talks to the drive directly and reliably reports blank
982
+ # status even when udev's media flag is stale/missing on this USB bridge.
983
+ info = run_probe(["dvd+rw-mediainfo", device], name="mediainfo", timeout=PROBE_TIMEOUT_MEDIAINFO)
984
+ if info.returncode == 0:
985
+ # dvd+rw-mediainfo pads its labels ("Disc status: blank"), so
986
+ # collapse runs of whitespace before matching.
987
+ text = re.sub(r"\s+", " ", ensure_text(info.stdout).lower())
988
+ if "disc status: blank" in text or "disc status: empty" in text:
989
+ return True
990
+ if "state of last session: empty" in text:
991
+ return True
992
+ if "disc status:" in text:
993
+ return False
994
+
995
+ if properties.get("ID_CDROM_MEDIA_STATE") == "blank":
996
+ return True
997
+
998
+ # The weaker "no filesystem / no TOC => blank" evidence below misfires on an
999
+ # empty tray, so only trust it once udev confirms a disc is actually loaded.
1000
+ if media != "1":
1001
+ return False
1002
+
1003
+ fs = run_probe(["blkid", "-o", "value", "-s", "TYPE", device], name="blkid", timeout=PROBE_TIMEOUT_BLKID)
1004
+ if fs.returncode == 0 and ensure_text(fs.stdout).strip():
1005
+ return False
1006
+
1007
+ dvd = run_probe(["lsdvd", device], name="lsdvd", timeout=PROBE_TIMEOUT_LSDVD)
1008
+ if dvd.returncode == 0:
1009
+ return False
1010
+
1011
+ toc = run_probe(["wodim", "-toc", "dev=" + device], name="wodim-toc", timeout=PROBE_TIMEOUT_WODIM_TOC)
1012
+ toc_text = ensure_text(toc.stdout) + ensure_text(toc.stderr)
1013
+ if "first:" in toc_text and "track:" in toc_text:
1014
+ return False
1015
+
1016
+ return True
1017
+
1018
+
1019
+ def is_rewritable_disc(device):
1020
+ """Return whether the inserted medium can be overwritten."""
1021
+ if not Path(device).exists():
1022
+ return False
1023
+
1024
+ properties = udev_cdrom_properties(device)
1025
+ if any(properties.get(key) == "1" for key in (
1026
+ "ID_CDROM_MEDIA_CD_RW",
1027
+ "ID_CDROM_MEDIA_DVD_RW",
1028
+ "ID_CDROM_MEDIA_DVD_RW_SEQ",
1029
+ "ID_CDROM_MEDIA_DVD_PLUS_RW",
1030
+ )):
1031
+ return True
1032
+
1033
+ if discstation_host.system_name() == "linux":
1034
+ info = run_probe(["dvd+rw-mediainfo", device], timeout=3)
1035
+ text = ensure_text(info.stdout) + ensure_text(info.stderr)
1036
+ media_line = next(
1037
+ (line.lower() for line in text.splitlines() if "mounted media:" in line.lower()),
1038
+ "",
1039
+ )
1040
+ return "rw" in media_line
1041
+ return False
1042
+
1043
+
1044
+ def can_burn_disc(device):
1045
+ return is_blank_disc(device) or is_rewritable_disc(device)
1046
+
1047
+
1048
+ _DISC_LABELS = {
1049
+ "audio_cd": "Disc: Audio CD",
1050
+ "dvd_video": "Disc: DVD-Video",
1051
+ "vcd": "Disc: VCD",
1052
+ "svcd": "Disc: SVCD",
1053
+ "video_data": "Disc: Video data",
1054
+ "data_disc": "Disc: Data disc",
1055
+ "data_cd": "Disc: Data CD",
1056
+ "blank": "Disc: Blank",
1057
+ "unknown": "Disc: unknown",
1058
+ }
1059
+
1060
+
1061
+ def disc_status_line(device):
1062
+ try:
1063
+ info = detect_disc(device)
1064
+ except Exception as e:
1065
+ print(f"disc status error: {e}")
1066
+ return "Disc: reading..."
1067
+ if not info.present:
1068
+ return "Disc: none"
1069
+ if info.transient:
1070
+ return "Disc: reading..."
1071
+ return _DISC_LABELS.get(info.kind, "Disc: unknown")
1072
+
1073
+
1074
+ def disc_kind(device):
1075
+ try:
1076
+ return detect_disc(device).kind
1077
+ except Exception as e:
1078
+ print(f"disc kind error: {e}")
1079
+ return "unknown"
1080
+
1081
+
1082
+ # ---------------------------------------------------------------------------
1083
+ # Shared disc-detection core. Both the ESP32/LCD path (disc_status_line /
1084
+ # disc_kind) and the web path (_serve_disc_info) go through detect_disc(), so
1085
+ # they always agree. A transient USB timeout yields a retry and then
1086
+ # "reading..." rather than a sticky, wrong "unknown".
1087
+ # ---------------------------------------------------------------------------
1088
+
1089
+ _detect_cache = {}
1090
+ _detect_lock = threading.Lock()
1091
+ _stuck_cycles = {}
1092
+
1093
+
1094
+ _CDROM_DRIVE_STATUS = 0x5326 # CDS_NO_DISC=1 TRAY_OPEN=2 DRIVE_NOT_READY=3 DISC_OK=4
1095
+
1096
+
1097
+ def drive_status(device):
1098
+ """Fast, reliable drive state via the CDROM_DRIVE_STATUS ioctl.
1099
+
1100
+ Returns 'disc' | 'no_disc' | 'open' | 'loading' | 'unknown'. Single ioctl on
1101
+ an O_NONBLOCK fd — does not consult the (stale on this USB bridge) udev db and
1102
+ does not disturb the tray. Never raises."""
1103
+ try:
1104
+ fd = os.open(device, os.O_RDONLY | os.O_NONBLOCK)
1105
+ except OSError:
1106
+ return "unknown"
1107
+ try:
1108
+ st = fcntl.ioctl(fd, _CDROM_DRIVE_STATUS, 0)
1109
+ except OSError:
1110
+ return "unknown"
1111
+ finally:
1112
+ try:
1113
+ os.close(fd)
1114
+ except OSError:
1115
+ pass
1116
+ return {4: "disc", 1: "no_disc", 2: "open", 3: "loading"}.get(st, "unknown")
1117
+
1118
+
1119
+ def _media_quick_state(device, props):
1120
+ """Fast, cheap read of whether a disc is loaded. No long probes."""
1121
+ if _tray_open:
1122
+ return "empty" # deliberate OLED eject — do not poke the drive
1123
+ st = drive_status(device)
1124
+ if st in ("open", "no_disc"):
1125
+ return "empty"
1126
+ if st == "disc":
1127
+ return "present"
1128
+ if st == "loading":
1129
+ return "unsure"
1130
+ # st == "unknown": fall through to the legacy probes below
1131
+ try:
1132
+ if not Path(device).exists():
1133
+ return "empty"
1134
+ except OSError:
1135
+ return "empty"
1136
+ if props.get("ID_CDROM_MEDIA") == "1" or props.get("ID_CDROM_MEDIA_STATE") == "blank":
1137
+ return "present"
1138
+ r = run_probe(["wodim", "-toc", "dev=" + device], name="wodim-toc",
1139
+ timeout=min(4, PROBE_TIMEOUT_WODIM_TOC))
1140
+ txt = (ensure_text(r.stdout) + ensure_text(r.stderr)).lower()
1141
+ if "first:" in txt and "track:" in txt:
1142
+ return "present"
1143
+ if txt.strip() and any(m in txt for m in _NO_MEDIA_MARKERS):
1144
+ return "empty"
1145
+ try:
1146
+ fd = os.open(device, os.O_RDONLY | os.O_NONBLOCK)
1147
+ except OSError as e:
1148
+ return "empty" if e.errno in (errno.ENOMEDIUM, errno.ENXIO) else "unsure"
1149
+ try:
1150
+ os.read(fd, 2048)
1151
+ return "present"
1152
+ except OSError as e:
1153
+ # EIO happens on a perfectly good audio CD, so it is NOT proof of "empty".
1154
+ return "empty" if e.errno in (errno.ENOMEDIUM, errno.ENXIO) else "unsure"
1155
+ finally:
1156
+ os.close(fd)
1157
+
1158
+
1159
+ def wait_for_disc_ready(device, timeout=None):
1160
+ """Block until the drive reports a stable media state after a tray close.
1161
+ Returns (status, elapsed) where status is 'empty' | 'ready' | 'timeout'."""
1162
+ if timeout is None:
1163
+ timeout = DISC_SETTLE_TIMEOUT
1164
+ if discstation_host.system_name() != "linux":
1165
+ return "ready", 0.0
1166
+ start = time.monotonic()
1167
+ while True:
1168
+ props = udev_cdrom_properties(device, refresh=True)
1169
+ state = _media_quick_state(device, props)
1170
+ elapsed = time.monotonic() - start
1171
+ if state == "empty":
1172
+ return "empty", elapsed
1173
+ if state == "present":
1174
+ return "ready", elapsed
1175
+ if elapsed >= timeout:
1176
+ return "timeout", elapsed
1177
+ time.sleep(DISC_SETTLE_POLL)
1178
+
1179
+
1180
+ def _priv_mount_error(exc):
1181
+ s = str(exc).lower()
1182
+ return any(m in s for m in (
1183
+ "must be superuser", "permission denied", "only root",
1184
+ "operation not permitted", "are you root",
1185
+ ))
1186
+
1187
+
1188
+ def _probe(name, cmd, timeout, deadline, failed):
1189
+ remaining = deadline - time.monotonic()
1190
+ if remaining <= 0:
1191
+ failed.append(f"{name}:timeout")
1192
+ return subprocess.CompletedProcess(cmd, 124, "", "")
1193
+ r = run_probe(cmd, name=name, timeout=max(1, int(min(timeout, remaining))))
1194
+ if r.returncode == 124:
1195
+ failed.append(f"{name}:timeout")
1196
+ elif r.returncode == 127:
1197
+ failed.append(f"{name}:missing")
1198
+ return r
1199
+
1200
+
1201
+ def _cd_kind_from_toc(toc_text):
1202
+ """audio_cd vs data_cd from a `wodim -toc` dump (lowercased stdout+stderr).
1203
+
1204
+ Decided by the per-track CONTROL field: bit 2 (0x04) set == data track.
1205
+ The old `"control: 2" in text` test only caught the "digital copy permitted"
1206
+ bit, so plain audio CDs (every track `control: 0`, including this appliance's
1207
+ own cdrdao burns) were misread as data_cd."""
1208
+ controls = [int(c) for c in re.findall(
1209
+ r"track:\s*\d+\b[^\n]*?control:\s*(\d+)", toc_text)]
1210
+ if not controls:
1211
+ return "audio_cd" # readable track TOC, no filesystem -> almost always audio
1212
+ if all(c & 0x04 for c in controls):
1213
+ return "data_cd"
1214
+ return "audio_cd" # >=1 audio track (incl. mixed-mode / CD-Extra)
1215
+
1216
+
1217
+ def _classify_disc(device, props, failed, deadline):
1218
+ """One pass of the probe chain. Mirrors the historical disc_kind ordering
1219
+ (blkid -> lsdvd -> wodim -toc -> fs fallback -> blank) but records which
1220
+ probes timed out / were missing so the caller can retry."""
1221
+ if discstation_host.system_name() != "linux":
1222
+ if props.get("ID_CDROM_MEDIA_TYPE") == "audio":
1223
+ return _disc_info(True, "audio_cd", web_type="AUDIO_CD")
1224
+ if props.get("ID_FS_TYPE") in ("udf", "iso9660"):
1225
+ kind = None
1226
+ try:
1227
+ with mounted_disc(device) as mount_dir:
1228
+ if (mount_dir / "VIDEO_TS").is_dir():
1229
+ kind = "dvd_video"
1230
+ else:
1231
+ k, _ = disc_video_files(mount_dir)
1232
+ kind = k
1233
+ except (OSError, RuntimeError) as e:
1234
+ print(f"Disc inspection failed: {e}")
1235
+ kind = kind or "data_disc"
1236
+ return _disc_info(True, kind, web_type=_web_type_for(kind, 0))
1237
+ return _disc_info(True, "unknown", web_type="UNKNOWN", failed_probes=failed)
1238
+
1239
+ # Fast path for a blank disc: cdrom_id reports this reliably in ~20ms, and
1240
+ # blkid/lsdvd/wodim all legitimately fail on blank media — running them here
1241
+ # is just an opportunity to time out on a slow drive.
1242
+ if props.get("ID_CDROM_MEDIA_STATE") == "blank":
1243
+ cap = 0
1244
+ if (deadline - time.monotonic()) > 8:
1245
+ try:
1246
+ cap = discstation_burn.disc_capacity_bytes(device) or 0
1247
+ except Exception:
1248
+ cap = 0
1249
+ return _disc_info(True, "blank", capacity_bytes=cap, web_type="BLANK")
1250
+
1251
+ fs = _probe("blkid", ["blkid", "-o", "value", "-s", "TYPE", device],
1252
+ PROBE_TIMEOUT_BLKID, deadline, failed)
1253
+ fstype = ensure_text(fs.stdout).strip() if fs.returncode == 0 else ""
1254
+
1255
+ kind = None
1256
+ if fstype:
1257
+ if time.monotonic() < deadline:
1258
+ try:
1259
+ with mounted_disc(device) as mount_dir:
1260
+ k, _ = disc_video_files(mount_dir)
1261
+ if k:
1262
+ kind = k
1263
+ except (OSError, RuntimeError) as e:
1264
+ if not _priv_mount_error(e):
1265
+ failed.append("mount:error")
1266
+ if kind is None:
1267
+ kind = "dvd_video" if fstype == "udf" else "data_disc"
1268
+
1269
+ dvd = None
1270
+ if kind is None:
1271
+ dvd = _probe("lsdvd", ["lsdvd", device], PROBE_TIMEOUT_LSDVD, deadline, failed)
1272
+ if dvd.returncode == 0:
1273
+ kind = "dvd_video"
1274
+
1275
+ toc_text = ""
1276
+ if kind is None:
1277
+ toc = _probe("wodim-toc", ["wodim", "-toc", "dev=" + device],
1278
+ PROBE_TIMEOUT_WODIM_TOC, deadline, failed)
1279
+ toc_text = (ensure_text(toc.stdout) + ensure_text(toc.stderr)).lower()
1280
+ if "first:" in toc_text and "track:" in toc_text:
1281
+ kind = _cd_kind_from_toc(toc_text)
1282
+
1283
+ toc_has_tracks = "first:" in toc_text and "track:" in toc_text
1284
+ media_present = (
1285
+ props.get("ID_CDROM_MEDIA") == "1"
1286
+ or bool(fstype)
1287
+ or (dvd is not None and dvd.returncode == 0)
1288
+ or toc_has_tracks
1289
+ )
1290
+ no_media = (
1291
+ bool(toc_text.strip())
1292
+ and any(m in toc_text for m in _NO_MEDIA_MARKERS)
1293
+ and not toc_has_tracks
1294
+ )
1295
+
1296
+ if kind is None:
1297
+ if no_media and not media_present:
1298
+ return _disc_info(False, "none", failed_probes=failed)
1299
+ if is_blank_disc(device):
1300
+ kind = "blank"
1301
+
1302
+ if kind is None:
1303
+ present = media_present or not no_media
1304
+ return _disc_info(present, "unknown", web_type="UNKNOWN",
1305
+ transient=bool(failed), failed_probes=failed)
1306
+
1307
+ # We have a definite kind — unrelated probe timeouts no longer make it transient.
1308
+ capacity = 0
1309
+ want_cap = kind in ("dvd_video", "data_disc", "blank") or _udev_dvd_recordable(props)
1310
+ if want_cap and (deadline - time.monotonic()) > 8:
1311
+ try:
1312
+ capacity = discstation_burn.disc_capacity_bytes(device) or 0
1313
+ except Exception as e:
1314
+ print(f"Disc capacity probe failed: {e}")
1315
+ if not capacity:
1316
+ bd = run_probe(["blockdev", "--getsize64", device], name="blockdev",
1317
+ timeout=PROBE_TIMEOUT_UDEV)
1318
+ if bd.returncode == 0:
1319
+ try:
1320
+ capacity = int(ensure_text(bd.stdout).strip())
1321
+ except ValueError:
1322
+ capacity = 0
1323
+
1324
+ label = props.get("ID_FS_LABEL", "")
1325
+ if kind == "dvd_video" and not label:
1326
+ if dvd is None:
1327
+ dvd = _probe("lsdvd", ["lsdvd", device], PROBE_TIMEOUT_LSDVD, deadline, failed)
1328
+ if dvd.returncode == 0:
1329
+ for line in ensure_text(dvd.stdout).splitlines():
1330
+ if line.startswith("Disc Title:"):
1331
+ label = line.split(":", 1)[1].strip()
1332
+ break
1333
+
1334
+ return _disc_info(True, kind, capacity_bytes=capacity, label=label,
1335
+ web_type=_web_type_for(kind, capacity),
1336
+ failed_probes=failed)
1337
+
1338
+
1339
+ def _maybe_reset_stuck_drive(device, failed_probes):
1340
+ if not any(p.endswith(":timeout") for p in failed_probes):
1341
+ _stuck_cycles[device] = 0
1342
+ return
1343
+ n = _stuck_cycles.get(device, 0) + 1
1344
+ _stuck_cycles[device] = n
1345
+ if n >= 2:
1346
+ print(f"Drive appears stuck ({n} cycles with probe timeouts), attempting USB reset...")
1347
+ try:
1348
+ discstation_burn.reset_drive(device)
1349
+ except Exception as e:
1350
+ print(f"USB reset failed: {e}")
1351
+ _stuck_cycles[device] = 0
1352
+
1353
+
1354
+ def _detect_disc_locked(device, settle, budget):
1355
+ deadline = time.monotonic() + (DISC_DETECT_BUDGET if budget is None else budget)
1356
+ props = udev_cdrom_properties(device, refresh=True)
1357
+
1358
+ if settle and discstation_host.system_name() == "linux":
1359
+ status, waited = wait_for_disc_ready(device)
1360
+ if status == "empty":
1361
+ info = _disc_info(False, "none")
1362
+ _detect_cache[device] = (time.monotonic(), info)
1363
+ return info
1364
+ props = udev_cdrom_properties(device, refresh=True)
1365
+
1366
+ result = None
1367
+ for attempt in range(max(1, DISC_DETECT_RETRIES)):
1368
+ failed = []
1369
+ result = _classify_disc(device, props, failed, deadline)
1370
+ if result.kind != "unknown" and not result.transient:
1371
+ if result.failed_probes:
1372
+ print(f"disc classify: {result.kind} "
1373
+ f"(partial probe failures: {list(result.failed_probes)})")
1374
+ _stuck_cycles[device] = 0
1375
+ _detect_cache[device] = (time.monotonic(), result)
1376
+ return result
1377
+ if (attempt < DISC_DETECT_RETRIES - 1
1378
+ and (deadline - time.monotonic()) > DISC_DETECT_RETRY_DELAY + 3):
1379
+ print(f"disc classify attempt {attempt + 1}: kind={result.kind} "
1380
+ f"failed_probes={failed} — retrying")
1381
+ time.sleep(DISC_DETECT_RETRY_DELAY)
1382
+ props = udev_cdrom_properties(device, refresh=True)
1383
+ else:
1384
+ break
1385
+
1386
+ if result is None:
1387
+ result = _disc_info(False, "none")
1388
+ if result.transient or result.failed_probes:
1389
+ print(f"disc classify: unresolved after {DISC_DETECT_RETRIES} attempts; "
1390
+ f"failed_probes={list(result.failed_probes)} — reporting as transient")
1391
+ _maybe_reset_stuck_drive(device, result.failed_probes)
1392
+ result = result._replace(kind="unknown", web_type="UNKNOWN", transient=True)
1393
+ elif result.kind == "unknown":
1394
+ print("disc classify: genuinely unknown (all probes ran, none matched)")
1395
+ _stuck_cycles[device] = 0
1396
+ _detect_cache[device] = (time.monotonic(), result)
1397
+ return result
1398
+
1399
+
1400
+ def detect_disc(device, settle=True, budget=None, force=False):
1401
+ """Return a DiscInfo for the disc in `device`. Results are cached briefly so
1402
+ disc_status_line / disc_title / menu_items_for_disc in one refresh burst do a
1403
+ single probe. `settle=False` skips the post-insert wait (used by the web
1404
+ endpoint, which can just poll again)."""
1405
+ if _tray_open:
1406
+ # Tray was deliberately ejected from the OLED — report "no disc" without
1407
+ # touching the drive (cdrom_id / wodim / open() would re-close the tray).
1408
+ return _disc_info(False, "none")
1409
+ if not force:
1410
+ cached = _detect_cache.get(device)
1411
+ if cached and time.monotonic() - cached[0] < DISC_DETECT_CACHE_TTL:
1412
+ return cached[1]
1413
+ with _detect_lock:
1414
+ if not force:
1415
+ cached = _detect_cache.get(device)
1416
+ if cached and time.monotonic() - cached[0] < DISC_DETECT_CACHE_TTL:
1417
+ return cached[1]
1418
+ return _detect_disc_locked(device, settle, budget)
1419
+
1420
+
1421
+ def disc_title(device):
1422
+ kind = disc_kind(device)
1423
+ if kind in ("data_disc", "data_cd", "video_data", "dvd_video"):
1424
+ properties = udev_cdrom_properties(device)
1425
+ label = properties.get("ID_FS_LABEL", "")
1426
+ if label:
1427
+ return label
1428
+ if kind != "dvd_video":
1429
+ return ""
1430
+ if kind == "dvd_video":
1431
+ dvd = run_probe(["lsdvd", device], timeout=5)
1432
+ if dvd.returncode == 0:
1433
+ for line in dvd.stdout.splitlines():
1434
+ if line.startswith("Disc Title:"):
1435
+ t = line.split(":", 1)[1].strip()
1436
+ if t:
1437
+ return t
1438
+ elif kind == "audio_cd":
1439
+ if discstation_host.system_name() == "darwin":
1440
+ return "Apple Music"
1441
+ try:
1442
+ toc = audio_cd_toc(device)
1443
+ if toc and toc.get("track_count"):
1444
+ n = toc["track_count"]
1445
+ leadout = toc.get("leadout", 0)
1446
+ tracks = toc.get("tracks", [])
1447
+ if tracks:
1448
+ total_frames = leadout - tracks[0]
1449
+ total_sec = int(total_frames / 75)
1450
+ else:
1451
+ total_sec = 0
1452
+ fingerprint = f"{n}-{total_sec}"
1453
+ match = None
1454
+ if HISTORY_FILE.exists():
1455
+ for line in open(HISTORY_FILE):
1456
+ try:
1457
+ entry = json.loads(line)
1458
+ if entry.get("disc_type") == "Audio CD" and entry.get("fingerprint") == fingerprint:
1459
+ match = entry
1460
+ break
1461
+ except Exception:
1462
+ pass
1463
+ if match and match.get("title"):
1464
+ return match["title"]
1465
+ return f"Audio CD ({n} tracks)"
1466
+ except Exception:
1467
+ pass
1468
+ return "Audio CD"
1469
+ elif kind == "vcd":
1470
+ return "VCD"
1471
+ elif kind == "svcd":
1472
+ return "SVCD"
1473
+ return ""
1474
+
1475
+
1476
+ def audio_track_metadata(device):
1477
+ """Return track count and saved names for a DiscStation-burned CD."""
1478
+ try:
1479
+ toc = audio_cd_toc(device)
1480
+ track_count = int(toc.get("track_count", 0))
1481
+ tracks = toc.get("tracks", [])
1482
+ leadout = toc.get("leadout", 0)
1483
+ if not track_count or not tracks:
1484
+ return 0, [], []
1485
+ fingerprint = f"{track_count}-{int((leadout - tracks[0]) / 75)}"
1486
+ titles = []
1487
+ if HISTORY_FILE.exists():
1488
+ for line in reversed(HISTORY_FILE.read_text().splitlines()):
1489
+ try:
1490
+ entry = json.loads(line)
1491
+ except Exception:
1492
+ continue
1493
+ if (entry.get("disc_type") == "Audio CD" and
1494
+ entry.get("success") and entry.get("fingerprint") == fingerprint):
1495
+ titles = entry.get("track_titles") or []
1496
+ break
1497
+ starts = [int((position - tracks[0]) / 75) for position in tracks]
1498
+ return track_count, titles, starts
1499
+ except Exception:
1500
+ return 0, [], []
1501
+
1502
+
1503
+ def menu_items_for_disc(device):
1504
+ kind = disc_kind(device)
1505
+ items = []
1506
+ if kind == "blank" or is_rewritable_disc(device):
1507
+ items = ["BURN", "BURN DATA", "BURN AUDIO"]
1508
+ had = discstation_burn.WORK.rglob("movie.mpg")
1509
+ if any(True for _ in had):
1510
+ items.append("BURN MPG")
1511
+ elif kind == "audio_cd" and discstation_host.system_name() == "darwin":
1512
+ items = ["APPLE MUSIC"]
1513
+ elif kind in ("dvd_video", "audio_cd", "vcd", "svcd", "video_data", "data_disc", "data_cd"):
1514
+ items = ["PLAY", "RIP"]
1515
+ else:
1516
+ items = ["PLAY", "RIP"]
1517
+ return items
1518
+
1519
+
1520
+ class mounted_disc:
1521
+ def __init__(self, device):
1522
+ self.device = device
1523
+ self.tmp = None
1524
+ self.mount_path = None
1525
+ self.owned_mount = False
1526
+
1527
+ def __enter__(self):
1528
+ if discstation_host.system_name() == "darwin":
1529
+ properties = discstation_host.media_properties(self.device)
1530
+ existing_mount = properties.get("ID_MOUNT_POINT")
1531
+ if existing_mount and Path(existing_mount).is_dir():
1532
+ self.mount_path = Path(existing_mount)
1533
+ return self.mount_path
1534
+ self.tmp = tempfile.TemporaryDirectory(prefix="discstation_disc_")
1535
+ command = [
1536
+ discstation_host.tool("hdiutil"), "attach", "-readonly", "-nobrowse",
1537
+ "-mountpoint", self.tmp.name, self.device,
1538
+ ]
1539
+ elif discstation_host.system_name() == "linux":
1540
+ self.tmp = tempfile.TemporaryDirectory(prefix="discstation_disc_")
1541
+ command = ["mount", "-o", "ro", self.device, self.tmp.name]
1542
+ else:
1543
+ self.tmp.cleanup()
1544
+ raise RuntimeError("Disc mounting backend is not configured for this operating system")
1545
+ result = subprocess.run(command, capture_output=True, text=True, timeout=30)
1546
+ if result.returncode != 0:
1547
+ self.tmp.cleanup()
1548
+ raise RuntimeError((result.stderr or result.stdout or "Could not mount disc").strip())
1549
+ self.mount_path = Path(self.tmp.name)
1550
+ self.owned_mount = True
1551
+ return self.mount_path
1552
+
1553
+ def __exit__(self, exc_type, exc, tb):
1554
+ if not self.owned_mount:
1555
+ return
1556
+ if discstation_host.system_name() == "darwin":
1557
+ subprocess.run(
1558
+ ["/usr/sbin/diskutil", "unmount", str(self.mount_path)],
1559
+ stdout=subprocess.DEVNULL,
1560
+ stderr=subprocess.DEVNULL,
1561
+ timeout=30,
1562
+ )
1563
+ else:
1564
+ subprocess.run(["umount", str(self.mount_path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
1565
+ self.tmp.cleanup()
1566
+
1567
+
1568
+ def disc_video_files(mount_dir):
1569
+ vcd_files = sorted((mount_dir / "MPEGAV").glob("*.DAT"))
1570
+ if vcd_files:
1571
+ return "vcd", vcd_files
1572
+
1573
+ svcd_files = sorted((mount_dir / "MPEG2").glob("*.MPG"))
1574
+ if svcd_files:
1575
+ return "svcd", svcd_files
1576
+
1577
+ video_exts = {".mpg", ".mpeg", ".mp4", ".mkv", ".avi", ".mov", ".webm", ".dat"}
1578
+ files = sorted(
1579
+ p for p in mount_dir.rglob("*")
1580
+ if p.is_file() and p.suffix.lower() in video_exts
1581
+ )
1582
+ if files:
1583
+ return "video_data", files
1584
+
1585
+ return None, []
1586
+
1587
+
1588
+ def audio_cd_toc(device):
1589
+ if discstation_host.system_name() == "darwin":
1590
+ toc_path = discstation_burn.WORK / "mac_audio_read.toc"
1591
+ result = subprocess.run(
1592
+ [discstation_burn.tool("cdrdao"), "read-toc", "--fast-toc",
1593
+ "--device", discstation_host.cdrdao_device(device), str(toc_path)],
1594
+ capture_output=True, text=True, timeout=30,
1595
+ )
1596
+ text = ensure_text(result.stdout) + ensure_text(result.stderr)
1597
+ toc_text = toc_path.read_text(errors="replace") if toc_path.exists() else ""
1598
+ toc_path.unlink(missing_ok=True)
1599
+ if result.returncode != 0:
1600
+ detail = next((line.strip() for line in reversed(text.splitlines()) if line.strip()), "cdrdao failed")
1601
+ raise RuntimeError(f"Could not read macOS CD TOC: {detail[:100]}")
1602
+
1603
+ durations = []
1604
+ for block in re.split(r"(?m)^\s*//\s*Track\s+\d+\s*$", toc_text)[1:]:
1605
+ file_lines = re.findall(r"(?m)^\s*FILE\b.*$", block)
1606
+ file_times = re.findall(r"\d+:\d+:\d+", file_lines[-1]) if file_lines else []
1607
+ pregap_times = re.findall(r"(?m)^\s*SILENCE\s+(\d+:\d+:\d+)", block)
1608
+ if file_times:
1609
+ duration = _msf_frames(file_times[-1])
1610
+ duration += sum(_msf_frames(value) for value in pregap_times)
1611
+ durations.append(duration)
1612
+ if not durations:
1613
+ for line in text.splitlines():
1614
+ match = re.match(r"\s*(\d+)\s+AUDIO.*?\((\d+)\).*?\((\d+)\)", line)
1615
+ if match:
1616
+ durations.append(int(match.group(3)) - int(match.group(2)))
1617
+ if not durations:
1618
+ raise RuntimeError("Could not read macOS CD TOC")
1619
+ tracks = []
1620
+ position = 150
1621
+ for duration in durations:
1622
+ tracks.append(position)
1623
+ position += duration
1624
+ leadout = position
1625
+ return {
1626
+ "first_track": 1,
1627
+ "track_count": len(durations),
1628
+ "leadout": leadout,
1629
+ "tracks": tracks,
1630
+ "toc": "+".join(map(str, [1, len(durations), leadout, *tracks])),
1631
+ }
1632
+ if _libdiscid is not None:
1633
+ try:
1634
+ d = _libdiscid.read(device)
1635
+ tracks = list(d.track_offsets)
1636
+ if tracks:
1637
+ return {
1638
+ "first_track": d.first_track,
1639
+ "track_count": len(tracks),
1640
+ "leadout": d.sectors,
1641
+ "tracks": tracks,
1642
+ "toc": d.toc, # space-separated MB TOC string
1643
+ "mb_discid": d.id, # real MusicBrainz disc ID
1644
+ "freedb_id": d.freedb_id,
1645
+ }
1646
+ except _libdiscid.DiscError as e:
1647
+ print(f"libdiscid read failed, falling back to wodim: {e}")
1648
+
1649
+ toc = run_probe(["wodim", "-toc", "dev=" + device], timeout=8)
1650
+ text = ensure_text(toc.stdout) + ensure_text(toc.stderr)
1651
+ tracks = []
1652
+ leadout = None
1653
+
1654
+ for line in text.splitlines():
1655
+ match = re.match(r"track:\s+(\d+)\s+lba:\s+(-?\d+)", line)
1656
+ if match:
1657
+ tracks.append(int(match.group(2)) + 150)
1658
+
1659
+ match = re.match(r"track:lout\s+lba:\s+(-?\d+)", line)
1660
+ if match:
1661
+ leadout = int(match.group(1)) + 150
1662
+
1663
+ if not tracks or leadout is None:
1664
+ raise RuntimeError("Could not read CD TOC")
1665
+
1666
+ toc_string = "+".join(map(str, [1, len(tracks), leadout, *tracks]))
1667
+ return {
1668
+ "first_track": 1,
1669
+ "track_count": len(tracks),
1670
+ "leadout": leadout,
1671
+ "tracks": tracks,
1672
+ "toc": toc_string,
1673
+ }
1674
+
1675
+
1676
+ def _msf_frames(value):
1677
+ minutes, seconds, frames = (int(part) for part in value.split(":"))
1678
+ return (minutes * 60 + seconds) * 75 + frames
1679
+
1680
+
1681
+ def audio_cd_chapters(device):
1682
+ if discstation_host.system_name() == "darwin":
1683
+ toc = audio_cd_toc(device)
1684
+ tracks = toc["tracks"]
1685
+ first = tracks[0]
1686
+ leadout = toc["leadout"]
1687
+ chapters = []
1688
+ for index, start in enumerate(tracks):
1689
+ end = tracks[index + 1] if index + 1 < len(tracks) else leadout
1690
+ chapters.append({
1691
+ "start_time": (start - first) / 75,
1692
+ "end_time": (end - first) / 75,
1693
+ "tags": {"title": f"Track {index + 1:02d}"},
1694
+ })
1695
+ return chapters
1696
+ r = run_probe(
1697
+ [
1698
+ "ffprobe", "-v", "quiet", "-f", "libcdio", "-i", device,
1699
+ "-print_format", "json", "-show_chapters", "-show_format",
1700
+ ],
1701
+ timeout=15,
1702
+ )
1703
+ if r.returncode != 0:
1704
+ raise RuntimeError("Could not read audio CD")
1705
+
1706
+ data = json.loads(r.stdout)
1707
+ return data.get("chapters", [])
1708
+
1709
+
1710
+ def write_audio_tracks_file(out_dir, chapters):
1711
+ path = out_dir / "tracks.txt"
1712
+ with path.open("w") as f:
1713
+ for index, chapter in enumerate(chapters, start=1):
1714
+ start = float(chapter.get("start_time", 0))
1715
+ end = float(chapter.get("end_time", 0))
1716
+ title = chapter.get("tags", {}).get("title", f"track {index:02d}")
1717
+ f.write(f"{index:02d}\t{start:.3f}\t{end:.3f}\t{title}\n")
1718
+ return path
1719
+
1720
+
1721
+ def safe_path_name(name):
1722
+ name = re.sub(r'[\\/:*?"<>|]+', "_", name.strip())
1723
+ name = re.sub(r"\s+", " ", name)
1724
+ return name[:120].strip(" ._") or "Unknown"
1725
+
1726
+
1727
+ def unique_dir(path):
1728
+ if not path.exists():
1729
+ return path
1730
+ for index in range(2, 100):
1731
+ candidate = path.with_name(f"{path.name} ({index})")
1732
+ if not candidate.exists():
1733
+ return candidate
1734
+ return path.with_name(f"{path.name} ({int(time.time())})")
1735
+
1736
+
1737
+ _MB_RELEASE_INCLUDES = ["recordings", "artists", "artist-credits", "release-groups"]
1738
+
1739
+
1740
+ def _mb_artist_phrase(credit):
1741
+ """Flatten a MusicBrainz artist-credit list into a display string."""
1742
+ if not credit:
1743
+ return ""
1744
+ out = []
1745
+ for part in credit:
1746
+ if isinstance(part, str):
1747
+ out.append(part)
1748
+ elif isinstance(part, dict):
1749
+ out.append((part.get("artist") or {}).get("name", "") or part.get("name", ""))
1750
+ out.append(part.get("joinphrase", ""))
1751
+ return "".join(out).strip()
1752
+
1753
+
1754
+ def _release_meta(release, track_count, toc=None):
1755
+ """Extract our metadata dict from a MusicBrainz release, accepting both the
1756
+ musicbrainzngs shape (`medium-list`/`track-list`) and the raw ws/2 JSON
1757
+ shape (`media`/`tracks`)."""
1758
+ media = release.get("medium-list") or release.get("media") or []
1759
+ for medium in media:
1760
+ tracks = medium.get("track-list") or medium.get("tracks") or []
1761
+ if track_count and len(tracks) != track_count:
1762
+ continue
1763
+
1764
+ album_artist = (
1765
+ release.get("artist-credit-phrase")
1766
+ or _mb_artist_phrase(release.get("artist-credit"))
1767
+ or "Unknown Artist"
1768
+ )
1769
+ date = release.get("date") or ""
1770
+ metadata = {
1771
+ "source": "musicbrainz",
1772
+ "release_id": release.get("id"),
1773
+ "release_group_id": (release.get("release-group") or {}).get("id"),
1774
+ "album": release.get("title") or "Unknown Album",
1775
+ "album_artist": album_artist,
1776
+ "date": date,
1777
+ "year": date[:4],
1778
+ "country": release.get("country") or "",
1779
+ "medium_position": medium.get("position", 1),
1780
+ "tracks": [],
1781
+ "toc": toc,
1782
+ }
1783
+ for index, track in enumerate(tracks, start=1):
1784
+ recording = track.get("recording") or {}
1785
+ artist = (
1786
+ track.get("artist-credit-phrase")
1787
+ or recording.get("artist-credit-phrase")
1788
+ or _mb_artist_phrase(track.get("artist-credit") or recording.get("artist-credit"))
1789
+ or album_artist
1790
+ )
1791
+ metadata["tracks"].append({
1792
+ "number": index,
1793
+ "title": track.get("title") or recording.get("title") or f"Track {index:02d}",
1794
+ "artist": artist,
1795
+ "recording_id": recording.get("id"),
1796
+ "release_track_id": track.get("id"),
1797
+ })
1798
+ return metadata
1799
+
1800
+ return None
1801
+
1802
+
1803
+ # Back-compat alias (older name).
1804
+ metadata_from_musicbrainz_release = _release_meta
1805
+
1806
+
1807
+ def musicbrainz_release_details(release_id):
1808
+ if _mb is not None:
1809
+ return _mb.get_release_by_id(
1810
+ release_id, includes=_MB_RELEASE_INCLUDES + ["media"],
1811
+ )["release"]
1812
+ response = requests.get(
1813
+ f"https://musicbrainz.org/ws/2/release/{release_id}",
1814
+ params={
1815
+ "inc": "recordings+artists+artist-credits+release-groups+media",
1816
+ "fmt": "json",
1817
+ },
1818
+ headers={"User-Agent": USER_AGENT},
1819
+ timeout=20,
1820
+ )
1821
+ response.raise_for_status()
1822
+ return response.json()
1823
+
1824
+
1825
+ def musicbrainz_lookup(device, track_count):
1826
+ toc = audio_cd_toc(device)
1827
+
1828
+ if _mb is not None and toc.get("mb_discid"):
1829
+ try:
1830
+ res = _mb.get_releases_by_discid(
1831
+ toc["mb_discid"], includes=["recordings", "artist-credits", "release-groups"],
1832
+ toc=toc.get("toc"), cdstubs=False,
1833
+ )
1834
+ except _mb.ResponseError as e:
1835
+ if getattr(getattr(e, "cause", None), "code", None) == 404:
1836
+ return None
1837
+ raise
1838
+ releases = res.get("disc", {}).get("release-list") or res.get("release-list") or []
1839
+ for release in releases:
1840
+ metadata = _release_meta(release, track_count, toc)
1841
+ if metadata:
1842
+ return metadata
1843
+ return None
1844
+
1845
+ # --- fallback: raw ws/2 disc-id lookup ---
1846
+ response = requests.get(
1847
+ "https://musicbrainz.org/ws/2/discid/-",
1848
+ params={
1849
+ "toc": toc["toc"],
1850
+ "inc": "recordings+artists+artist-credits+release-groups",
1851
+ "fmt": "json", "cdstubs": "no", "media-format": "all",
1852
+ },
1853
+ headers={"User-Agent": USER_AGENT}, timeout=20,
1854
+ )
1855
+ response.raise_for_status()
1856
+ for release in response.json().get("releases", []):
1857
+ metadata = _release_meta(release, track_count, toc)
1858
+ if metadata:
1859
+ return metadata
1860
+ return None
1861
+
1862
+
1863
+ def musicbrainz_lookup_by_album_hints(album_artist, album, track_count):
1864
+ if not album:
1865
+ return None
1866
+
1867
+ if _mb is not None:
1868
+ fields = {"release": album}
1869
+ if album_artist:
1870
+ fields["artist"] = album_artist
1871
+ try:
1872
+ hits = _mb.search_releases(limit=8, **fields).get("release-list", [])
1873
+ except _mb.WebServiceError as e:
1874
+ print(f"MusicBrainz search failed: {e}")
1875
+ hits = []
1876
+ for hit in hits:
1877
+ release_id = hit.get("id")
1878
+ if not release_id:
1879
+ continue
1880
+ try:
1881
+ details = musicbrainz_release_details(release_id)
1882
+ except Exception:
1883
+ continue
1884
+ metadata = _release_meta(details, track_count)
1885
+ if metadata:
1886
+ metadata["source"] = "musicbrainz-search"
1887
+ return metadata
1888
+ return None
1889
+
1890
+ # --- fallback: raw ws/2 search ---
1891
+ query_parts = [f'release:"{album}"']
1892
+ if album_artist:
1893
+ query_parts.append(f'artist:"{album_artist}"')
1894
+ response = requests.get(
1895
+ "https://musicbrainz.org/ws/2/release/",
1896
+ params={"query": " AND ".join(query_parts), "fmt": "json", "limit": 8},
1897
+ headers={"User-Agent": USER_AGENT}, timeout=20,
1898
+ )
1899
+ response.raise_for_status()
1900
+ for release in response.json().get("releases", []):
1901
+ release_id = release.get("id")
1902
+ if not release_id:
1903
+ continue
1904
+ try:
1905
+ details = musicbrainz_release_details(release_id)
1906
+ except Exception:
1907
+ continue
1908
+ metadata = _release_meta(details, track_count)
1909
+ if metadata:
1910
+ metadata["source"] = "musicbrainz-search"
1911
+ return metadata
1912
+ time.sleep(1)
1913
+ return None
1914
+
1915
+ def cddb_sum(value):
1916
+ return sum(int(ch) for ch in str(value))
1917
+
1918
+
1919
+ def cddb_disc_id(toc):
1920
+ tracks = toc["tracks"]
1921
+ leadout = toc["leadout"]
1922
+ total_seconds = (leadout - tracks[0]) // 75
1923
+ checksum = sum(cddb_sum(offset // 75) for offset in tracks)
1924
+ disc_id = ((checksum % 255) << 24) | (total_seconds << 8) | len(tracks)
1925
+ return f"{disc_id:08x}", total_seconds
1926
+
1927
+
1928
+ def cddb_get(command):
1929
+ response = requests.get(
1930
+ "http://gnudb.gnudb.org/~cddb/cddb.cgi",
1931
+ params={
1932
+ "cmd": command.replace("+", " "),
1933
+ "hello": "phuju localhost dvdstation 1.0",
1934
+ "proto": "6",
1935
+ },
1936
+ headers={"User-Agent": USER_AGENT},
1937
+ timeout=15,
1938
+ )
1939
+ response.raise_for_status()
1940
+ return response.text
1941
+
1942
+
1943
+ def parse_cddb_kv(text):
1944
+ data = {}
1945
+ for line in text.splitlines():
1946
+ if not line or line.startswith("#") or "=" not in line:
1947
+ continue
1948
+ key, value = line.split("=", 1)
1949
+ data[key] = data.get(key, "") + value
1950
+ return data
1951
+
1952
+
1953
+ def gnudb_lookup(device, track_count):
1954
+ toc = audio_cd_toc(device)
1955
+ if toc.get("freedb_id"):
1956
+ disc_id = toc["freedb_id"]
1957
+ total_seconds = (toc["leadout"] - toc["tracks"][0]) // 75
1958
+ else:
1959
+ disc_id, total_seconds = cddb_disc_id(toc)
1960
+ offsets = " ".join(str(offset) for offset in toc["tracks"])
1961
+ query = f"cddb query {disc_id} {track_count} {offsets} {total_seconds}"
1962
+ response = cddb_get(query)
1963
+ lines = [line.strip() for line in response.splitlines() if line.strip()]
1964
+ if not lines:
1965
+ return None
1966
+
1967
+ first = lines[0].split(" ", 3)
1968
+ if first[0] not in ("200", "210", "211"):
1969
+ return None
1970
+
1971
+ if first[0] == "200":
1972
+ category = first[1]
1973
+ read_id = first[2]
1974
+ else:
1975
+ parts = lines[1].split(" ", 2)
1976
+ if len(parts) < 2:
1977
+ return None
1978
+ category = parts[0]
1979
+ read_id = parts[1]
1980
+
1981
+ read_response = cddb_get(f"cddb read {category} {read_id}")
1982
+ kv = parse_cddb_kv(read_response)
1983
+ dtitle = kv.get("DTITLE", "Unknown Artist / Unknown Album")
1984
+
1985
+ if " / " in dtitle:
1986
+ album_artist, album = dtitle.split(" / ", 1)
1987
+ else:
1988
+ album_artist, album = "Unknown Artist", dtitle
1989
+
1990
+ metadata = {
1991
+ "source": "gnudb",
1992
+ "release_id": None,
1993
+ "release_group_id": None,
1994
+ "album": album.strip() or "Unknown Album",
1995
+ "album_artist": album_artist.strip() or "Unknown Artist",
1996
+ "date": kv.get("DYEAR", ""),
1997
+ "year": kv.get("DYEAR", ""),
1998
+ "genre": kv.get("DGENRE", ""),
1999
+ "tracks": [],
2000
+ "toc": toc,
2001
+ }
2002
+
2003
+ for index in range(track_count):
2004
+ title = kv.get(f"TTITLE{index}", f"Track {index + 1:02d}").strip()
2005
+ artist = metadata["album_artist"]
2006
+ if " / " in title:
2007
+ artist, title = title.split(" / ", 1)
2008
+ metadata["tracks"].append({
2009
+ "number": index + 1,
2010
+ "title": title or f"Track {index + 1:02d}",
2011
+ "artist": artist or metadata["album_artist"],
2012
+ "recording_id": None,
2013
+ "release_track_id": None,
2014
+ })
2015
+
2016
+ return metadata
2017
+
2018
+
2019
+ def musicbrainz_release_id_search(album_artist, album):
2020
+ if not album or album == "Unknown Album":
2021
+ return None
2022
+
2023
+ if _mb is not None:
2024
+ fields = {"release": album}
2025
+ if album_artist and album_artist != "Unknown Artist":
2026
+ fields["artist"] = album_artist
2027
+ try:
2028
+ hits = _mb.search_releases(limit=1, **fields).get("release-list", [])
2029
+ except _mb.WebServiceError:
2030
+ hits = []
2031
+ return hits[0].get("id") if hits else None
2032
+
2033
+ query_parts = [f'release:"{album}"']
2034
+ if album_artist and album_artist != "Unknown Artist":
2035
+ query_parts.append(f'artist:"{album_artist}"')
2036
+ response = requests.get(
2037
+ "https://musicbrainz.org/ws/2/release/",
2038
+ params={"query": " AND ".join(query_parts), "fmt": "json", "limit": 1},
2039
+ headers={"User-Agent": USER_AGENT},
2040
+ timeout=20,
2041
+ )
2042
+ response.raise_for_status()
2043
+ releases = response.json().get("releases", [])
2044
+ return releases[0].get("id") if releases else None
2045
+
2046
+
2047
+ def audio_metadata_lookup(device, track_count, artist_hint=None, album_hint=None):
2048
+ metadata = None
2049
+
2050
+ for attempt in range(2):
2051
+ try:
2052
+ metadata = musicbrainz_lookup(device, track_count)
2053
+ if metadata:
2054
+ break
2055
+ except Exception as e:
2056
+ if attempt == 0:
2057
+ print(f"MusicBrainz lookup failed, retrying... ({e})")
2058
+ time.sleep(1)
2059
+
2060
+ if not metadata and album_hint:
2061
+ for attempt in range(2):
2062
+ try:
2063
+ metadata = musicbrainz_lookup_by_album_hints(artist_hint, album_hint, track_count)
2064
+ if metadata:
2065
+ break
2066
+ except Exception as e:
2067
+ if attempt == 0:
2068
+ print(f"MusicBrainz album search failed, retrying... ({e})")
2069
+ time.sleep(1)
2070
+
2071
+ if metadata and not metadata.get("release_id"):
2072
+ try:
2073
+ metadata["release_id"] = musicbrainz_release_id_search(
2074
+ metadata.get("album_artist", ""),
2075
+ metadata.get("album", ""),
2076
+ )
2077
+ except Exception as e:
2078
+ print(f"MusicBrainz release search failed: {e}")
2079
+
2080
+ if metadata and metadata.get("release_id") and not metadata.get("release_group_id"):
2081
+ try:
2082
+ details = musicbrainz_release_details(metadata["release_id"])
2083
+ metadata["release_group_id"] = (details.get("release-group") or {}).get("id")
2084
+ except Exception as e:
2085
+ print(f"MusicBrainz release-group lookup failed: {e}")
2086
+
2087
+ if not metadata:
2088
+ try:
2089
+ metadata = gnudb_lookup(device, track_count)
2090
+ except Exception as e:
2091
+ print(f"GnuDB lookup failed: {e}")
2092
+
2093
+ return metadata
2094
+
2095
+
2096
+ def write_album_info(out_dir, metadata):
2097
+ path = out_dir / "album_info.json"
2098
+ with path.open("w") as f:
2099
+ json.dump(metadata or {"metadata_found": False}, f, indent=2, ensure_ascii=False)
2100
+ return path
2101
+
2102
+
2103
+ def _sniff_image_ext(data):
2104
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
2105
+ return "png"
2106
+ return "jpg"
2107
+
2108
+
2109
+ def download_cover_art(release_id, out_dir, release_group_id=None):
2110
+ if not release_id and not release_group_id:
2111
+ return None
2112
+
2113
+ if _mb is not None:
2114
+ for fetch in (
2115
+ (lambda: _mb.get_image_front(release_id, size=500)) if release_id else None,
2116
+ (lambda: _mb.get_release_group_image_front(release_group_id, size=500)) if release_group_id else None,
2117
+ ):
2118
+ if fetch is None:
2119
+ continue
2120
+ try:
2121
+ data = fetch()
2122
+ except Exception:
2123
+ continue
2124
+ if data:
2125
+ path = out_dir / f"cover.{_sniff_image_ext(data)}"
2126
+ path.write_bytes(data)
2127
+ return path
2128
+
2129
+ headers = {"User-Agent": USER_AGENT}
2130
+ candidates = []
2131
+ if release_id:
2132
+ candidates.extend(f"https://coverartarchive.org/release/{release_id}/{suffix}" for suffix in ("front-500", "front"))
2133
+ if release_group_id:
2134
+ candidates.extend(f"https://coverartarchive.org/release-group/{release_group_id}/{suffix}" for suffix in ("front-500", "front"))
2135
+
2136
+ for url in candidates:
2137
+ try:
2138
+ response = requests.get(url, headers=headers, timeout=30, allow_redirects=True)
2139
+ except requests.RequestException:
2140
+ continue
2141
+
2142
+ if response.status_code == 200 and response.content:
2143
+ content_type = response.headers.get("Content-Type", "image/jpeg").split(";", 1)[0]
2144
+ ext = "png" if "png" in content_type else "jpg"
2145
+ path = out_dir / f"cover.{ext}"
2146
+ path.write_bytes(response.content)
2147
+ return path
2148
+
2149
+ return None
2150
+
2151
+
2152
+ def tag_flac(path, track_meta, album_meta, cover_path):
2153
+ audio = FLAC(path)
2154
+ total = len(album_meta.get("tracks", []))
2155
+ number = track_meta["number"]
2156
+
2157
+ audio["TITLE"] = track_meta["title"]
2158
+ audio["ARTIST"] = track_meta["artist"]
2159
+ audio["ALBUM"] = album_meta["album"]
2160
+ audio["ALBUMARTIST"] = album_meta["album_artist"]
2161
+ audio["TRACKNUMBER"] = str(number)
2162
+ audio["TRACKTOTAL"] = str(total)
2163
+
2164
+ if album_meta.get("date"):
2165
+ audio["DATE"] = album_meta["date"]
2166
+ if album_meta.get("release_id"):
2167
+ audio["MUSICBRAINZ_ALBUMID"] = album_meta["release_id"]
2168
+ if track_meta.get("recording_id"):
2169
+ audio["MUSICBRAINZ_TRACKID"] = track_meta["recording_id"]
2170
+ if track_meta.get("release_track_id"):
2171
+ audio["MUSICBRAINZ_RELEASETRACKID"] = track_meta["release_track_id"]
2172
+
2173
+ if cover_path and cover_path.exists():
2174
+ picture = Picture()
2175
+ picture.type = 3
2176
+ picture.mime = "image/png" if cover_path.suffix.lower() == ".png" else "image/jpeg"
2177
+ picture.desc = "Cover"
2178
+ picture.data = cover_path.read_bytes()
2179
+ audio.clear_pictures()
2180
+ audio.add_picture(picture)
2181
+
2182
+ audio.save()
2183
+
2184
+
2185
+ def retag_audio_rip(rip_dir, artist_hint, album_hint):
2186
+ rip_dir = Path(rip_dir)
2187
+ flacs = sorted(rip_dir.glob("*.flac"))
2188
+ if not flacs:
2189
+ raise RuntimeError(f"No FLAC files found in {rip_dir}")
2190
+ if not album_hint:
2191
+ raise RuntimeError("Retag needs --album")
2192
+
2193
+ metadata = musicbrainz_lookup_by_album_hints(artist_hint, album_hint, len(flacs))
2194
+ if not metadata:
2195
+ raise RuntimeError("Could not find album metadata")
2196
+
2197
+ target_dir = unique_dir(RIP_ROOT / safe_path_name(f"{metadata['album_artist']} - {metadata['album']}"))
2198
+ if rip_dir != target_dir:
2199
+ rip_dir.rename(target_dir)
2200
+ else:
2201
+ target_dir = rip_dir
2202
+
2203
+ write_album_info(target_dir, metadata)
2204
+ cover_path = download_cover_art(
2205
+ metadata.get("release_id"),
2206
+ target_dir,
2207
+ metadata.get("release_group_id"),
2208
+ )
2209
+
2210
+ renamed = []
2211
+ for index, src in enumerate(sorted(target_dir.glob("*.flac")), start=1):
2212
+ if index > len(metadata["tracks"]):
2213
+ break
2214
+
2215
+ track_meta = metadata["tracks"][index - 1]
2216
+ out_file = target_dir / f"{index:02d} - {safe_path_name(track_meta['title'])}.flac"
2217
+ if src != out_file:
2218
+ if out_file.exists():
2219
+ out_file.unlink()
2220
+ src.rename(out_file)
2221
+
2222
+ tag_flac(out_file, track_meta, metadata, cover_path)
2223
+ renamed.append(out_file)
2224
+
2225
+ chown_to_sudo_user(target_dir)
2226
+ return target_dir, cover_path, renamed
2227
+
2228
+
2229
+ def latest_audio_rip_dir():
2230
+ candidates = sorted(
2231
+ path for path in RIP_ROOT.glob("audio_cd_*")
2232
+ if path.is_dir() and list(path.glob("*.flac"))
2233
+ )
2234
+ if not candidates:
2235
+ raise RuntimeError("No audio_cd_* rip folders found")
2236
+ return candidates[-1]
2237
+
2238
+
2239
+ def parse_ffmpeg_time(line):
2240
+ marker = "time="
2241
+ if marker not in line:
2242
+ return None
2243
+ value = line.split(marker, 1)[1].split()[0]
2244
+ try:
2245
+ hours, minutes, seconds = value.split(":")
2246
+ return int(hours) * 3600 + int(minutes) * 60 + float(seconds)
2247
+ except ValueError:
2248
+ return None
2249
+
2250
+
2251
+ from discstation_burn import CancelError
2252
+
2253
+
2254
+ def _check_cancel(ser):
2255
+ try:
2256
+ line = read_serial_line(ser, timeout=0)
2257
+ return line in ("CANCEL", "PLAY_STOP") if line else False
2258
+ except OSError:
2259
+ return False
2260
+
2261
+
2262
+ def iter_process_events(proc, idle_seconds=1.0, ser=None):
2263
+ lines = Queue()
2264
+ finished = object()
2265
+
2266
+ def read_output():
2267
+ try:
2268
+ for line in proc.stdout:
2269
+ lines.put(line.rstrip("\r\n"))
2270
+ finally:
2271
+ lines.put(finished)
2272
+
2273
+ reader = threading.Thread(target=read_output, daemon=True)
2274
+ reader.start()
2275
+ last_ping = time.time()
2276
+ output_done = False
2277
+ while proc.poll() is None or not output_done:
2278
+ if ser is not None:
2279
+ if _check_cancel(ser):
2280
+ discstation_burn.stop_process(proc)
2281
+ raise CancelError
2282
+ if time.time() - last_ping >= 5:
2283
+ discstation_burn.send(ser, "PING")
2284
+ last_ping = time.time()
2285
+ try:
2286
+ line = lines.get(timeout=idle_seconds)
2287
+ except Empty:
2288
+ yield None
2289
+ continue
2290
+ if line is finished:
2291
+ output_done = True
2292
+ else:
2293
+ yield line
2294
+ reader.join(timeout=1)
2295
+
2296
+
2297
+ def device_size_bytes(device):
2298
+ result = run_probe(["blockdev", "--getsize64", device], timeout=3)
2299
+ try:
2300
+ return int(ensure_text(result.stdout).strip() or "0")
2301
+ except ValueError:
2302
+ return 0
2303
+
2304
+
2305
+ def directory_size_bytes(path):
2306
+ total = 0
2307
+ for item in path.rglob("*"):
2308
+ try:
2309
+ if item.is_file():
2310
+ total += item.stat().st_size
2311
+ except OSError:
2312
+ pass
2313
+ return total
2314
+
2315
+
2316
+ def burn_flow(ser, url):
2317
+ if not url:
2318
+ if sys.stdin.isatty():
2319
+ safe_send(ser, "STATUS:Enter URL or file path in terminal")
2320
+ print("=== Enter URL or file path below, then press Enter ===")
2321
+ try:
2322
+ url = sys.stdin.readline().strip()
2323
+ except (EOFError, KeyboardInterrupt, OSError):
2324
+ safe_send(ser, "CANCELLED:Cancelled")
2325
+ return
2326
+ else:
2327
+ url = wait_for_web_url(ser)
2328
+ if url is None:
2329
+ safe_send(ser, "CANCELLED:Cancelled")
2330
+ return
2331
+ if not url:
2332
+ safe_send(ser, "ERROR:Need URL or file path")
2333
+ return
2334
+
2335
+ device = discstation_burn.disc_device()
2336
+ disc_bytes = discstation_burn.disc_capacity_bytes(device)
2337
+ if disc_bytes:
2338
+ print(f"Disc capacity: {disc_bytes / 1_000_000_000:.2f}GB")
2339
+ else:
2340
+ label_hint = "DVD5"
2341
+ if is_blank_disc(device):
2342
+ label_hint = "DVD5 (set DISC_DISC_BYTES=8500000000 for DL)"
2343
+ print(f"Disc capacity: unknown (assuming {label_hint})")
2344
+
2345
+ discstation_burn.WORK.mkdir(parents=True, exist_ok=True)
2346
+ job_dir = discstation_burn.WORK / time.strftime("job_%Y%m%d_%H%M%S")
2347
+ job_dir.mkdir()
2348
+
2349
+ send(ser, "STATUS:Preflight...")
2350
+ info = discstation_burn.get_video_info(url)
2351
+ title = info["title"]
2352
+ duration = info["duration"]
2353
+ duration_line, fit_line, can_fit = discstation_burn.preflight_lines(duration, disc_bytes)
2354
+ disc_label = discstation_burn.sanitize_disc_label(title)
2355
+
2356
+ print(f"Title: {title}")
2357
+ print(f"Duration: {discstation_burn.format_duration(duration)}")
2358
+ print(f"Preflight: {fit_line}")
2359
+ print(f"Disc label: {disc_label}")
2360
+ print(f"DVD drive: {device}")
2361
+
2362
+ send(ser, f"TITLE:{title}")
2363
+ send(ser, f"META:{duration_line}")
2364
+ send(ser, f"FIT:{fit_line}")
2365
+
2366
+ label_hint = "disc"
2367
+ if disc_bytes:
2368
+ if disc_bytes < 1_500_000_000:
2369
+ label_hint = "CD"
2370
+ elif disc_bytes > 6_000_000_000:
2371
+ label_hint = "DVD9"
2372
+ else:
2373
+ label_hint = "DVD5"
2374
+ if not can_fit:
2375
+ raise RuntimeError(f"Video too long for {label_hint}")
2376
+
2377
+ dl_info = discstation_burn.detect_disc_type(device)
2378
+ if dl_info["is_dual_layer"]:
2379
+ sl_target = int(os.environ.get("DISC_TARGET_BYTES", "4300000000"))
2380
+ try:
2381
+ sl_plan = discstation_burn.bitrate_plan(duration, "AUTO", sl_target)
2382
+ if sl_plan:
2383
+ warn = f"DL disc for {label_hint} content"
2384
+ print(f"WARNING: {warn}")
2385
+ safe_send(ser, f"WARNING:{warn}")
2386
+ time.sleep(3)
2387
+ safe_send(ser, f"TITLE:{title}")
2388
+ safe_send(ser, f"META:{duration_line}")
2389
+ safe_send(ser, f"FIT:{fit_line}")
2390
+ except RuntimeError:
2391
+ pass
2392
+
2393
+ selected_mode = "AUTO"
2394
+ burn_speed = None
2395
+ print("Waiting for burn START button...")
2396
+ while True:
2397
+ line = wait_for_button(ser)
2398
+ if line == "CANCEL" or line == "PLAY_STOP":
2399
+ safe_send(ser, "CANCELLED:Cancelled")
2400
+ print("Burn cancelled by user")
2401
+ return
2402
+ if line.startswith("MODE:"):
2403
+ selected_mode = discstation_burn.normalize_mode(line.split(":", 1)[1])
2404
+ print(f"Burn mode: {selected_mode}")
2405
+ elif line.startswith("SPEED:"):
2406
+ burn_speed = line.split(":", 1)[1].strip()
2407
+ print(f"Burn speed: {burn_speed}")
2408
+ elif line == "START" or line.startswith("START:"):
2409
+ if ":" in line:
2410
+ selected_mode = discstation_burn.normalize_mode(line.split(":", 1)[1])
2411
+ print(f"Starting burn flow in {selected_mode} mode")
2412
+ send(ser, f"STATUS:Starting {selected_mode}...")
2413
+ break
2414
+
2415
+ start_time = time.time()
2416
+ disc_type_label = "DL" if dl_info["is_dual_layer"] else "SL"
2417
+ try:
2418
+ plan = discstation_burn.bitrate_plan(duration, selected_mode, disc_bytes)
2419
+ video = discstation_burn.download(ser, url, job_dir)
2420
+ mpg, dvd_aspect = discstation_burn.convert(ser, video, job_dir, selected_mode, disc_bytes)
2421
+ discstation_burn.check_encoded_size(ser, mpg, disc_bytes)
2422
+ srt_files = discstation_burn.find_subtitle_files(video)
2423
+ if not srt_files:
2424
+ srt_files = discstation_burn.extract_embedded_subtitles(video, job_dir)
2425
+ if srt_files:
2426
+ safe_send(ser, f"INFO:{len(srt_files)} subtitle(s)")
2427
+ mpg = discstation_burn.add_subtitles(ser, mpg, srt_files, job_dir)
2428
+ dvd_dir = discstation_burn.remux_and_author(
2429
+ ser, mpg, disc_label, disc_bytes, dvd_aspect
2430
+ )
2431
+
2432
+ if plan["burn"]:
2433
+ discstation_burn.wait_for_burn_confirm(ser, dvd_dir, disc_bytes)
2434
+ discstation_burn.burn(ser, dvd_dir, disc_label, burn_speed, dl_info["is_dual_layer"])
2435
+ safe_send(ser, "DONE:Disc complete!")
2436
+ print("Burn complete.")
2437
+ else:
2438
+ safe_send(ser, "DONE:Test complete!")
2439
+ print(f"Test complete. DVD folder: {dvd_dir}")
2440
+
2441
+ append_burn_history({
2442
+ "timestamp": datetime.datetime.now().isoformat(),
2443
+ "title": title,
2444
+ "disc_type": disc_type_label,
2445
+ "mode": selected_mode,
2446
+ "speed": burn_speed or "Auto",
2447
+ "success": True,
2448
+ "duration_s": round(time.time() - start_time),
2449
+ })
2450
+ except (KeyboardInterrupt, SystemExit):
2451
+ append_burn_history({
2452
+ "timestamp": datetime.datetime.now().isoformat(),
2453
+ "title": title,
2454
+ "disc_type": disc_type_label,
2455
+ "mode": selected_mode,
2456
+ "speed": burn_speed or "Auto",
2457
+ "success": False,
2458
+ "error": "Cancelled",
2459
+ "duration_s": round(time.time() - start_time),
2460
+ })
2461
+ raise
2462
+ except Exception as e:
2463
+ append_burn_history({
2464
+ "timestamp": datetime.datetime.now().isoformat(),
2465
+ "title": title,
2466
+ "disc_type": disc_type_label,
2467
+ "mode": selected_mode,
2468
+ "speed": burn_speed or "Auto",
2469
+ "success": False,
2470
+ "error": str(e)[:100],
2471
+ "duration_s": round(time.time() - start_time),
2472
+ })
2473
+ raise
2474
+
2475
+ time.sleep(3)
2476
+
2477
+
2478
+ def _mpg_label(job_dir):
2479
+ label = "DVD_VIDEO"
2480
+ dl = job_dir / "download"
2481
+ if dl.is_dir():
2482
+ for f in sorted(dl.iterdir()):
2483
+ if f.suffix.lower() in discstation_burn.VIDEO_EXTS:
2484
+ label = discstation_burn.sanitize_disc_label(f.stem)
2485
+ break
2486
+ else:
2487
+ for f in sorted(dl.iterdir()):
2488
+ label = discstation_burn.sanitize_disc_label(f.stem)
2489
+ break
2490
+ if label == "DVD_VIDEO" or not label:
2491
+ label = discstation_burn.sanitize_disc_label(job_dir.name)
2492
+ return label
2493
+
2494
+
2495
+ def burn_mpg_flow(ser):
2496
+ jobs = sorted(discstation_burn.WORK.glob("job_*"), reverse=True)
2497
+ candidates = []
2498
+ for jd in jobs:
2499
+ mpg = jd / "movie.mpg"
2500
+ if mpg.exists():
2501
+ label = _mpg_label(jd)
2502
+ candidates.append((mpg, label))
2503
+ names = [c[1][:20] for c in candidates] + ["Enter path..."]
2504
+ safe_send(ser, f"MENU_ITEMS:{','.join(names)}")
2505
+ safe_send(ser, "HOME:Select MPG to burn")
2506
+ mpg = None
2507
+ disc_label = None
2508
+ while True:
2509
+ line = read_serial_line(ser, timeout=0.5)
2510
+ if not line:
2511
+ continue
2512
+ if line.startswith("SELECT:"):
2513
+ sel = line.split(":", 1)[1].strip()
2514
+ if sel == "Enter path...":
2515
+ path_str = wait_for_web_url(ser)
2516
+ if path_str is None:
2517
+ refresh_main_menu(ser)
2518
+ return
2519
+ path_str = path_str.strip()
2520
+ p = Path(path_str)
2521
+ if not p.exists():
2522
+ safe_send(ser, "ERROR:Path not found")
2523
+ time.sleep(2)
2524
+ continue
2525
+ if p.is_dir():
2526
+ mpg = p / "movie.mpg"
2527
+ if not mpg.exists():
2528
+ safe_send(ser, "ERROR:No movie.mpg in dir")
2529
+ time.sleep(2)
2530
+ continue
2531
+ elif p.suffix.lower() == ".mpg":
2532
+ mpg = p
2533
+ else:
2534
+ safe_send(ser, "ERROR:Not an .mpg file")
2535
+ time.sleep(2)
2536
+ continue
2537
+ disc_label = discstation_burn.sanitize_disc_label(mpg.stem)
2538
+ break
2539
+ else:
2540
+ idx = next((i for i, n in enumerate(candidates) if n[1][:20] == sel), None)
2541
+ if idx is not None:
2542
+ mpg, disc_label = candidates[idx]
2543
+ break
2544
+ elif line == "CANCEL":
2545
+ safe_send(ser, "CANCELLED:Cancelled")
2546
+ refresh_main_menu(ser)
2547
+ return
2548
+ time.sleep(0.05)
2549
+
2550
+ device = discstation_burn.disc_device()
2551
+ dl_info = discstation_burn.detect_disc_type(device)
2552
+ disc_bytes = dl_info["capacity"]
2553
+ discstation_burn.remux_and_burn(ser, mpg, disc_label, disc_bytes, dl_info)
2554
+
2555
+
2556
+ def _copy_to_job(ser, src, dst_dir):
2557
+ if src.is_dir():
2558
+ items = sorted(src.iterdir())
2559
+ n = len(items)
2560
+ for i, item in enumerate(items):
2561
+ if item.is_file():
2562
+ discstation_burn.copy_with_keepalive(ser, item, dst_dir / item.name,
2563
+ base_pct=int(i * 100 / n), pct_span=100 / n)
2564
+ else:
2565
+ discstation_burn.copy_with_keepalive(ser, src, dst_dir / src.name)
2566
+
2567
+
2568
+ def burn_data_flow(ser):
2569
+ global _last_upload_dir, _last_upload_label
2570
+
2571
+ if _last_upload_dir and Path(_last_upload_dir).exists():
2572
+ url = _last_upload_dir
2573
+ _last_upload_dir = None
2574
+ elif sys.stdin.isatty():
2575
+ safe_send(ser, "STATUS:Enter URL or file path in terminal")
2576
+ print("=== Enter URL or file path below, then press Enter ===")
2577
+ try:
2578
+ url = sys.stdin.readline().strip()
2579
+ except (EOFError, KeyboardInterrupt, OSError):
2580
+ safe_send(ser, "CANCELLED:Cancelled")
2581
+ return
2582
+ if not url:
2583
+ safe_send(ser, "ERROR:Need URL or file path")
2584
+ return
2585
+ else:
2586
+ url = wait_for_web_url(ser)
2587
+ if url is None:
2588
+ safe_send(ser, "CANCELLED:Cancelled")
2589
+ return
2590
+ if not url:
2591
+ safe_send(ser, "ERROR:Need URL or file path")
2592
+ return
2593
+
2594
+ device = discstation_burn.disc_device()
2595
+ dl_info = discstation_burn.detect_disc_type(device)
2596
+ disc_bytes = dl_info["capacity"]
2597
+ if disc_bytes:
2598
+ print(f"Disc capacity: {disc_bytes / 1_000_000_000:.2f}GB")
2599
+ else:
2600
+ print("Disc capacity: unknown (assuming DVD5)")
2601
+
2602
+ local_path = Path(url)
2603
+ if local_path.exists():
2604
+ title = local_path.name if local_path.is_dir() else local_path.stem
2605
+ else:
2606
+ send(ser, "STATUS:Probing source...")
2607
+ info = discstation_burn.get_video_info(url)
2608
+ title = info["title"]
2609
+
2610
+ if _last_upload_label:
2611
+ disc_label = discstation_burn.sanitize_disc_label(_last_upload_label)
2612
+ _last_upload_label = None
2613
+ else:
2614
+ disc_label = discstation_burn.sanitize_disc_label(title)
2615
+ print(f"Source: {url}")
2616
+ print(f"Disc label: {disc_label}")
2617
+ print(f"DVD drive: {device}")
2618
+
2619
+ send(ser, f"TITLE:{title}")
2620
+
2621
+ burn_speed = None
2622
+ print("Waiting for burn START button...")
2623
+ while True:
2624
+ line = wait_for_button(ser)
2625
+ if line == "CANCEL" or line == "PLAY_STOP":
2626
+ safe_send(ser, "CANCELLED:Cancelled")
2627
+ print("Burn cancelled by user")
2628
+ return
2629
+ if line.startswith("SPEED:"):
2630
+ burn_speed = line.split(":", 1)[1].strip()
2631
+ print(f"Burn speed: {burn_speed}")
2632
+ elif line == "START" or line.startswith("START:"):
2633
+ print("Starting data burn...")
2634
+ send(ser, "STATUS:Starting data burn...")
2635
+ break
2636
+
2637
+ if not can_burn_disc(device):
2638
+ raise RuntimeError("No writable disc in drive")
2639
+
2640
+ discstation_burn.WORK.mkdir(parents=True, exist_ok=True)
2641
+ job_dir = discstation_burn.WORK / time.strftime("job_%Y%m%d_%H%M%S")
2642
+ job_dir.mkdir()
2643
+ download_dir = job_dir / "download"
2644
+ download_dir.mkdir()
2645
+
2646
+ start_time = time.time()
2647
+ try:
2648
+ is_dir = local_path.is_dir() if local_path.exists() else False
2649
+ if is_dir:
2650
+ files_to_burn = [local_path]
2651
+ elif local_path.exists():
2652
+ safe_send(ser, "STATUS:Copying files...")
2653
+ _copy_to_job(ser, local_path, download_dir)
2654
+ files_to_burn = sorted(download_dir.iterdir())
2655
+ else:
2656
+ discstation_burn.download(ser, url, job_dir)
2657
+ files_to_burn = sorted(download_dir.iterdir())
2658
+
2659
+ if not files_to_burn:
2660
+ raise RuntimeError("No files to burn")
2661
+
2662
+ total_bytes = sum(f.stat().st_size for f in files_to_burn if f.is_file())
2663
+ for d in files_to_burn:
2664
+ if d.is_dir():
2665
+ total_bytes += sum(f.stat().st_size for f in d.rglob("*") if f.is_file())
2666
+ label = "DVD5" if not dl_info["is_dual_layer"] else "DVD9"
2667
+ usable = discstation_burn.disc_output_limit_bytes(disc_bytes)
2668
+ if usable and total_bytes > usable:
2669
+ size_gb = total_bytes / 1e9
2670
+ cap_gb = usable / 1e9
2671
+ raise RuntimeError(
2672
+ f"Data too large for {label}: {size_gb:.1f}GB > {cap_gb:.1f}GB disc")
2673
+
2674
+ is_iso = len(files_to_burn) == 1 and files_to_burn[0].suffix.lower() == '.iso'
2675
+ if is_iso:
2676
+ discstation_burn.burn_iso(ser, files_to_burn[0], burn_speed, dl_info["is_dual_layer"])
2677
+ else:
2678
+ discstation_burn.burn_data(ser, files_to_burn, disc_label, burn_speed, dl_info["is_dual_layer"])
2679
+ safe_send(ser, "DONE:Data disc complete!")
2680
+ print("Data burn complete.")
2681
+
2682
+ append_burn_history({
2683
+ "timestamp": datetime.datetime.now().isoformat(),
2684
+ "title": title,
2685
+ "disc_type": "Data DVD",
2686
+ "mode": "DATA",
2687
+ "speed": burn_speed or "Auto",
2688
+ "success": True,
2689
+ "duration_s": round(time.time() - start_time),
2690
+ })
2691
+ except (KeyboardInterrupt, SystemExit):
2692
+ append_burn_history({
2693
+ "timestamp": datetime.datetime.now().isoformat(),
2694
+ "title": title,
2695
+ "disc_type": "Data DVD",
2696
+ "mode": "DATA",
2697
+ "speed": burn_speed or "Auto",
2698
+ "success": False,
2699
+ "error": "Cancelled",
2700
+ "duration_s": round(time.time() - start_time),
2701
+ })
2702
+ raise
2703
+ except CancelError:
2704
+ append_burn_history({
2705
+ "timestamp": datetime.datetime.now().isoformat(),
2706
+ "title": title,
2707
+ "disc_type": "Data DVD",
2708
+ "mode": "DATA",
2709
+ "speed": burn_speed or "Auto",
2710
+ "success": False,
2711
+ "error": "Cancelled",
2712
+ "duration_s": round(time.time() - start_time),
2713
+ })
2714
+ return
2715
+ except Exception as e:
2716
+ append_burn_history({
2717
+ "timestamp": datetime.datetime.now().isoformat(),
2718
+ "title": title,
2719
+ "disc_type": "Data DVD",
2720
+ "mode": "DATA",
2721
+ "speed": burn_speed or "Auto",
2722
+ "success": False,
2723
+ "error": str(e)[:100],
2724
+ "duration_s": round(time.time() - start_time),
2725
+ })
2726
+ raise
2727
+
2728
+ time.sleep(3)
2729
+
2730
+
2731
+ def burn_audio_flow(ser):
2732
+ if sys.stdin.isatty():
2733
+ safe_send(ser, "STATUS:Enter path to audio files in terminal")
2734
+ print("=== Enter path to audio files/folder, then press Enter ===")
2735
+ try:
2736
+ url = sys.stdin.readline().strip()
2737
+ except (EOFError, KeyboardInterrupt, OSError):
2738
+ safe_send(ser, "CANCELLED:Cancelled")
2739
+ return
2740
+ if not url:
2741
+ safe_send(ser, "ERROR:Need path to audio files")
2742
+ return
2743
+ else:
2744
+ url = wait_for_web_url(ser)
2745
+ if url is None:
2746
+ safe_send(ser, "CANCELLED:Cancelled")
2747
+ return
2748
+ if not url:
2749
+ safe_send(ser, "ERROR:Need path to audio files")
2750
+ return
2751
+
2752
+ src_path = Path(url)
2753
+ if not src_path.exists():
2754
+ safe_send(ser, "ERROR:Path not found")
2755
+ return
2756
+
2757
+ audio_files = []
2758
+ audio_exts = {".wav", ".flac", ".mp3", ".aac", ".ogg", ".wma", ".m4a", ".opus"}
2759
+ if src_path.is_dir():
2760
+ for f in sorted(src_path.iterdir()):
2761
+ if f.suffix.lower() in audio_exts:
2762
+ audio_files.append(f)
2763
+ elif src_path.is_file():
2764
+ audio_files = [src_path]
2765
+
2766
+ if not audio_files:
2767
+ safe_send(ser, "ERROR:No audio files found")
2768
+ return
2769
+
2770
+ album_title = ""
2771
+ album_artist = ""
2772
+ track_titles = []
2773
+ total_dur = 0
2774
+ for f in audio_files:
2775
+ track_title = f.stem
2776
+ try:
2777
+ if f.suffix.lower() == ".flac":
2778
+ from mutagen.flac import FLAC
2779
+ a = FLAC(str(f))
2780
+ total_dur += a.info.length
2781
+ track_title = a.get("title", [f.stem])[0]
2782
+ if not album_title:
2783
+ album_title = a.get("album", [""])[0]
2784
+ album_artist = a.get("albumartist", [a.get("artist", [""])[0]])[0]
2785
+ elif f.suffix.lower() == ".mp3":
2786
+ from mutagen.mp3 import MP3
2787
+ a = MP3(str(f))
2788
+ total_dur += a.info.length
2789
+ track_title = str(a.get("TIT2", f.stem))
2790
+ if not album_title:
2791
+ album_title = str(a.get("TALB", ""))
2792
+ album_artist = str(a.get("TPE2", str(a.get("TPE1", ""))))
2793
+ else:
2794
+ total_dur += discstation_burn.probe_duration(str(f))
2795
+ except Exception:
2796
+ pass
2797
+ track_titles.append(track_title)
2798
+ fingerprint = f"{len(audio_files)}-{int(total_dur)}"
2799
+
2800
+ source_label = src_path.name if src_path.is_dir() else src_path.stem
2801
+ disc_label = discstation_burn.audio_disc_title(source_label)
2802
+ mins = int(total_dur / 60)
2803
+ secs = int(total_dur % 60)
2804
+ fits = "OK" if total_dur <= 4740 else "TOO LONG" # 79 min max for 700MB CD-R
2805
+ send(ser, f"TITLE:{disc_label}")
2806
+ send(ser, f"META:Dur {mins}m{secs}s")
2807
+ send(ser, f"FIT:CD-R {fits}")
2808
+
2809
+ burn_speed = None
2810
+ print("Waiting for START button...")
2811
+ while True:
2812
+ line = wait_for_button(ser)
2813
+ if line == "CANCEL" or line == "PLAY_STOP":
2814
+ safe_send(ser, "CANCELLED:Cancelled")
2815
+ return
2816
+ if line.startswith("SPEED:"):
2817
+ burn_speed = line.split(":", 1)[1].strip()
2818
+ elif line == "START" or line.startswith("START:"):
2819
+ send(ser, "STATUS:Starting audio burn...")
2820
+ break
2821
+
2822
+ device = discstation_burn.disc_device()
2823
+ if not can_burn_disc(device):
2824
+ raise RuntimeError("No writable disc in drive")
2825
+ if total_dur > 4740:
2826
+ raise RuntimeError(f"Too long for CD-R: {int(total_dur/60)}m{int(total_dur%60)}s > 79m")
2827
+
2828
+ start_time = time.time()
2829
+ try:
2830
+ discstation_burn.burn_audio_cd(ser, audio_files, disc_label, burn_speed)
2831
+ safe_send(ser, "DONE:Audio CD complete!")
2832
+ append_burn_history({
2833
+ "timestamp": datetime.datetime.now().isoformat(),
2834
+ "title": disc_label,
2835
+ "fingerprint": fingerprint,
2836
+ "track_titles": track_titles,
2837
+ "disc_type": "Audio CD",
2838
+ "mode": "AUDIO",
2839
+ "speed": burn_speed or "Auto",
2840
+ "success": True,
2841
+ "duration_s": round(time.time() - start_time),
2842
+ })
2843
+ except (KeyboardInterrupt, SystemExit):
2844
+ raise
2845
+ except CancelError:
2846
+ append_burn_history({
2847
+ "timestamp": datetime.datetime.now().isoformat(),
2848
+ "title": disc_label,
2849
+ "fingerprint": fingerprint,
2850
+ "track_titles": track_titles,
2851
+ "disc_type": "Audio CD",
2852
+ "mode": "AUDIO",
2853
+ "speed": burn_speed or "Auto",
2854
+ "success": False,
2855
+ "error": "Cancelled",
2856
+ "duration_s": round(time.time() - start_time),
2857
+ })
2858
+ return
2859
+ except Exception as e:
2860
+ append_burn_history({
2861
+ "timestamp": datetime.datetime.now().isoformat(),
2862
+ "title": disc_label,
2863
+ "fingerprint": fingerprint,
2864
+ "track_titles": track_titles,
2865
+ "disc_type": "Audio CD",
2866
+ "mode": "AUDIO",
2867
+ "speed": burn_speed or "Auto",
2868
+ "success": False,
2869
+ "error": str(e)[:100],
2870
+ "duration_s": round(time.time() - start_time),
2871
+ })
2872
+ raise
2873
+
2874
+ time.sleep(3)
2875
+
2876
+
2877
+ def _iter_proc_lines(proc, ser):
2878
+ lines = Queue()
2879
+ finished = object()
2880
+
2881
+ def read_output():
2882
+ try:
2883
+ for line in proc.stdout:
2884
+ lines.put(line.rstrip("\r\n"))
2885
+ finally:
2886
+ lines.put(finished)
2887
+
2888
+ reader = threading.Thread(target=read_output, daemon=True)
2889
+ reader.start()
2890
+ last_ping = time.time()
2891
+ output_done = False
2892
+ while proc.poll() is None or not output_done:
2893
+ if time.time() - last_ping >= 5:
2894
+ discstation_burn.send(ser, "PING")
2895
+ last_ping = time.time()
2896
+ if _check_cancel(ser):
2897
+ discstation_burn.stop_process(proc)
2898
+ return
2899
+ try:
2900
+ line = lines.get(timeout=0.5)
2901
+ except Empty:
2902
+ continue
2903
+ if line is finished:
2904
+ output_done = True
2905
+ else:
2906
+ yield line
2907
+ reader.join(timeout=1)
2908
+
2909
+
2910
+ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
2911
+ try:
2912
+ os.unlink(MPV_SOCKET)
2913
+ except FileNotFoundError:
2914
+ pass
2915
+
2916
+ env = os.environ.copy()
2917
+ if "DISPLAY" not in env:
2918
+ env["DISPLAY"] = ":0"
2919
+ try:
2920
+ uid = os.getuid()
2921
+ home_xauth = Path.home() / ".Xauthority"
2922
+ env.setdefault("XAUTHORITY", str(home_xauth) if home_xauth.exists() else f"/run/user/{uid}/.Xauthority")
2923
+ env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{uid}")
2924
+ except Exception:
2925
+ pass
2926
+
2927
+ proc = subprocess.Popen(run_as_desktop_user(cmd), env=env)
2928
+
2929
+ try:
2930
+ if not wait_for_socket(MPV_SOCKET, proc):
2931
+ discstation_burn.stop_process(proc)
2932
+ raise RuntimeError("Could not start mpv")
2933
+
2934
+ time.sleep(1)
2935
+ if proc.poll() is not None:
2936
+ raise RuntimeError("mpv could not open disc")
2937
+
2938
+ paused = False
2939
+ current_volume = None
2940
+ current_track = None
2941
+ last_track_poll = 0
2942
+ track_titles = track_titles or []
2943
+ track_starts = track_starts or []
2944
+ send(ser, "PLAY_MODE:AUDIO_CD" if kind == "audio_cd" else "PLAY_MODE:DEFAULT")
2945
+ send(ser, "PLAY:PLAYING")
2946
+ print(f"{label}. Short press toggles pause; long press stops.")
2947
+
2948
+ last_ping = time.time()
2949
+ while proc.poll() is None:
2950
+ if time.time() - last_ping >= 5:
2951
+ last_ping = time.time()
2952
+ safe_send(ser, "PING")
2953
+
2954
+ if kind == "audio_cd" and time.time() - last_track_poll >= 1:
2955
+ last_track_poll = time.time()
2956
+ track = mpv_query(["get_property", "chapter"])
2957
+ if not isinstance(track, (int, float)) and track_starts:
2958
+ position = mpv_query(["get_property", "time-pos"])
2959
+ if isinstance(position, (int, float)):
2960
+ track = max((i for i, start in enumerate(track_starts) if start <= position), default=0)
2961
+ if isinstance(track, (int, float)):
2962
+ track = int(track)
2963
+ if track != current_track:
2964
+ current_track = track
2965
+ title = track_titles[track] if 0 <= track < len(track_titles) else ""
2966
+ status = f"TRACK {track + 1:02d}"
2967
+ if title:
2968
+ status += f" // {title}"
2969
+ send(ser, f"PLAY_STATUS:{status}")
2970
+
2971
+ if ser.in_waiting:
2972
+ line = ser.readline().decode(errors="ignore").strip()
2973
+ discstation_burn.note_serial_activity()
2974
+
2975
+ if line == "PLAY_BUTTON":
2976
+ paused = not paused
2977
+ mpv_command(["set_property", "pause", paused])
2978
+ mpv_command(["set_property", "speed", 1.0])
2979
+ send(ser, "PLAY_STATUS:PAUSED" if paused else "PLAY_STATUS:PLAYING")
2980
+
2981
+ elif line == "PLAY_STOP":
2982
+ send(ser, "STATUS:Stopping play")
2983
+ discstation_burn.stop_process(proc)
2984
+ break
2985
+
2986
+ elif line == "FF:BIG":
2987
+ if kind == "audio_cd":
2988
+ mpv_command(["add", "chapter", 1])
2989
+ send(ser, "PLAY_STATUS:Next track")
2990
+ else:
2991
+ mpv_command(["seek", 120])
2992
+ mpv_command(["set_property", "pause", False])
2993
+ paused = False
2994
+ send(ser, "PLAY_STATUS:FF 120s")
2995
+
2996
+ elif line.startswith("FF:"):
2997
+ try:
2998
+ seek_sec = int(line.split(":", 1)[1])
2999
+ except ValueError:
3000
+ continue
3001
+ if kind == "audio_cd":
3002
+ mpv_command(["add", "chapter", 1])
3003
+ send(ser, "PLAY_STATUS:Next track")
3004
+ else:
3005
+ mpv_command(["seek", seek_sec])
3006
+ mpv_command(["set_property", "pause", False])
3007
+ paused = False
3008
+ send(ser, f"PLAY_STATUS:FF {seek_sec}s")
3009
+
3010
+ elif line == "REW:BIG":
3011
+ if kind == "audio_cd":
3012
+ mpv_command(["add", "chapter", -1])
3013
+ send(ser, "PLAY_STATUS:Prev track")
3014
+ else:
3015
+ mpv_command(["seek", -120])
3016
+ mpv_command(["set_property", "pause", False])
3017
+ paused = False
3018
+ send(ser, "PLAY_STATUS:REW 120s")
3019
+
3020
+ elif line.startswith("REW:"):
3021
+ try:
3022
+ seek_sec = int(line.split(":", 1)[1])
3023
+ except ValueError:
3024
+ continue
3025
+ if kind == "audio_cd":
3026
+ mpv_command(["add", "chapter", -1])
3027
+ send(ser, "PLAY_STATUS:Prev track")
3028
+ else:
3029
+ mpv_command(["seek", -seek_sec])
3030
+ mpv_command(["set_property", "pause", False])
3031
+ paused = False
3032
+ send(ser, f"PLAY_STATUS:REW {seek_sec}s")
3033
+
3034
+ elif line.startswith("POT:"):
3035
+ try:
3036
+ volume = int(line.split(":", 1)[1])
3037
+ except ValueError:
3038
+ continue
3039
+ if current_volume is None or abs(volume - current_volume) >= 3:
3040
+ current_volume = volume
3041
+ try:
3042
+ mpv_command(["set_property", "volume", volume])
3043
+ except OSError:
3044
+ break
3045
+
3046
+ time.sleep(0.05)
3047
+ finally:
3048
+ if proc.poll() is None:
3049
+ discstation_burn.stop_process(proc)
3050
+ try:
3051
+ os.unlink(MPV_SOCKET)
3052
+ except FileNotFoundError:
3053
+ pass
3054
+
3055
+
3056
+ def play_flow(ser):
3057
+ device = discstation_burn.disc_device()
3058
+ kind = disc_kind(device)
3059
+ print(f"Disc type: {kind}")
3060
+
3061
+ if kind == "audio_cd" and discstation_host.system_name() == "darwin":
3062
+ raise RuntimeError("Apple Music handles audio CD playback on macOS")
3063
+ if not shutil.which("mpv"):
3064
+ raise RuntimeError("mpv not found")
3065
+
3066
+ if kind == "dvd_video":
3067
+ if discstation_host.system_name() == "darwin":
3068
+ with mounted_disc(device) as mount_dir:
3069
+ video_ts = mount_dir / "VIDEO_TS"
3070
+ files = sorted(
3071
+ path for path in video_ts.glob("VTS_01_*.VOB")
3072
+ if re.search(r"_\d+\.VOB$", path.name, re.IGNORECASE)
3073
+ and not path.name.upper().endswith("_0.VOB")
3074
+ )
3075
+ if not files:
3076
+ raise RuntimeError("No playable DVD title found")
3077
+ cmd = [
3078
+ "mpv",
3079
+ "--input-ipc-server=" + MPV_SOCKET,
3080
+ "--force-window=yes",
3081
+ "--idle=no",
3082
+ *[str(path) for path in files],
3083
+ ]
3084
+ _run_mpv(ser, cmd, "Playing DVD", kind)
3085
+ else:
3086
+ cmd = [
3087
+ "mpv",
3088
+ "--input-ipc-server=" + MPV_SOCKET,
3089
+ "--force-window=yes",
3090
+ "--idle=no",
3091
+ device,
3092
+ ]
3093
+ _run_mpv(ser, cmd, "Playing DVD", kind)
3094
+
3095
+ elif kind == "audio_cd":
3096
+ _, track_titles, track_starts = audio_track_metadata(device)
3097
+ audio_device = discstation_host.audio_output_device()
3098
+ cmd = [
3099
+ "mpv",
3100
+ "--input-ipc-server=" + MPV_SOCKET,
3101
+ "--force-window=no",
3102
+ "--idle=no",
3103
+ "--cdrom-device=" + device,
3104
+ "--cdda-cdtext=yes",
3105
+ "cdda://",
3106
+ ]
3107
+ if audio_device:
3108
+ cmd.insert(1, "--audio-device=" + audio_device)
3109
+ print(f"Audio CD output: {audio_device}")
3110
+ _run_mpv(ser, cmd, "Playing audio CD", kind, track_titles, track_starts)
3111
+
3112
+ elif kind in ("vcd", "svcd", "video_data"):
3113
+ with mounted_disc(device) as mount_dir:
3114
+ _, files = disc_video_files(mount_dir)
3115
+ if not files:
3116
+ raise RuntimeError("No playable video files")
3117
+ cmd = [
3118
+ "mpv",
3119
+ "--input-ipc-server=" + MPV_SOCKET,
3120
+ "--force-window=yes",
3121
+ "--idle=no",
3122
+ *[str(path) for path in files],
3123
+ ]
3124
+ _run_mpv(ser, cmd, f"Playing {kind.upper()}", kind)
3125
+
3126
+ else:
3127
+ raise RuntimeError(f"Unsupported disc: {kind}")
3128
+
3129
+ safe_send(ser, "DONE:Playback stopped")
3130
+ time.sleep(3)
3131
+
3132
+
3133
+ def _finalize_video_rip(ser, out_dir, device, kind):
3134
+ """After a successful video rip: look the title up on TMDb, rename the
3135
+ output folder to "Title (Year)", and drop poster.jpg / movie.nfo. No-op if
3136
+ discstation_meta is unavailable or no key is configured. Returns the final
3137
+ directory."""
3138
+ out_dir = Path(out_dir)
3139
+ if discstation_meta is None or not discstation_meta.available():
3140
+ return out_dir
3141
+ try:
3142
+ guess = disc_title(device) or ""
3143
+ except Exception:
3144
+ guess = ""
3145
+ if not guess:
3146
+ return out_dir
3147
+ meta = discstation_meta.lookup(guess)
3148
+ if not meta:
3149
+ print(f"TMDb: no match for {guess!r}")
3150
+ return out_dir
3151
+
3152
+ target = out_dir
3153
+ new_name = discstation_meta.folder_name(meta)
3154
+ if new_name and Path(new_name).name != out_dir.name:
3155
+ candidate = unique_dir(RIP_ROOT / new_name)
3156
+ try:
3157
+ out_dir.rename(candidate)
3158
+ target = candidate
3159
+ except OSError as e:
3160
+ print(f"TMDb: could not rename rip dir: {e}")
3161
+
3162
+ discstation_meta.save_assets(target, meta)
3163
+ info_path = target / "disc_info.json"
3164
+ try:
3165
+ data = json.loads(info_path.read_text()) if info_path.exists() else {"kind": kind}
3166
+ except Exception:
3167
+ data = {"kind": kind}
3168
+ data["tmdb"] = meta
3169
+ try:
3170
+ info_path.write_text(json.dumps(data, indent=2))
3171
+ except OSError:
3172
+ pass
3173
+ safe_send(ser, f"INFO:{meta.get('title', '')} ({meta.get('year', '')})".strip())
3174
+ print(f"TMDb: {meta.get('title')} ({meta.get('year')}) -> {target}")
3175
+ return target
3176
+
3177
+
3178
+ def _handbrake_json_blocks(text):
3179
+ """HandBrakeCLI --json prints one or more 'Marker: {json}' blocks.
3180
+ Return {marker: parsed_obj}."""
3181
+ blocks = {}
3182
+ lines = text.splitlines()
3183
+ i = 0
3184
+ while i < len(lines):
3185
+ m = re.match(r"^([A-Za-z][A-Za-z ]*): \{$", lines[i])
3186
+ if not m:
3187
+ i += 1
3188
+ continue
3189
+ buf = ["{"]
3190
+ i += 1
3191
+ while i < len(lines):
3192
+ buf.append(lines[i])
3193
+ if lines[i] == "}":
3194
+ break
3195
+ i += 1
3196
+ try:
3197
+ blocks[m.group(1)] = json.loads("\n".join(buf))
3198
+ except ValueError:
3199
+ pass
3200
+ i += 1
3201
+ return blocks
3202
+
3203
+
3204
+ def handbrake_scan(device):
3205
+ """Return {'main_feature': int|None, 'titles': [{index,duration_s,chapters}]}
3206
+ or None. Uses HandBrakeCLI, which does real main-feature detection."""
3207
+ if not shutil.which("HandBrakeCLI"):
3208
+ return None
3209
+ try:
3210
+ r = subprocess.run(
3211
+ ["HandBrakeCLI", "--json", "--scan", "-i", device, "-t", "0"],
3212
+ capture_output=True, text=True, timeout=180,
3213
+ )
3214
+ except (OSError, subprocess.TimeoutExpired) as e:
3215
+ print(f"HandBrake scan failed: {e}")
3216
+ return None
3217
+ ts = _handbrake_json_blocks(ensure_text(r.stdout) + ensure_text(r.stderr)).get("JSON Title Set")
3218
+ if not ts or not ts.get("TitleList"):
3219
+ return None
3220
+ titles = []
3221
+ for t in ts["TitleList"]:
3222
+ dur = t.get("Duration") or {}
3223
+ secs = dur.get("Hours", 0) * 3600 + dur.get("Minutes", 0) * 60 + dur.get("Seconds", 0)
3224
+ titles.append({
3225
+ "index": t.get("Index"),
3226
+ "duration_s": secs,
3227
+ "chapters": len(t.get("ChapterList") or []),
3228
+ })
3229
+ main = ts.get("MainFeature")
3230
+ if main is None and titles:
3231
+ main = max(titles, key=lambda x: x["duration_s"])["index"]
3232
+ return {"main_feature": main, "titles": titles}
3233
+
3234
+
3235
+ def handbrake_rip_main_feature(ser, device, out_dir, title_index):
3236
+ """Transcode one DVD title to MKV (H.264) with HandBrakeCLI, streaming its
3237
+ JSON progress to the ESP32."""
3238
+ dest = out_dir / "main_feature.mkv"
3239
+ send(ser, "STATUS:Ripping main feature")
3240
+ send(ser, f"INFO:HandBrake title {title_index}")
3241
+ send(ser, "PROGRESS:0%")
3242
+ proc = subprocess.Popen(
3243
+ ["HandBrakeCLI", "--json", "-i", device, "-o", str(dest),
3244
+ "-t", str(title_index), "-e", "x264", "-q", "20",
3245
+ "--all-audio", "--all-subtitles"],
3246
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
3247
+ )
3248
+ last_pct = -1
3249
+ try:
3250
+ for event in iter_process_events(proc, ser=ser):
3251
+ if event is None:
3252
+ continue
3253
+ m = re.search(r'"Progress":\s*([0-9.]+)', event)
3254
+ if m:
3255
+ pct = int(float(m.group(1)) * 100)
3256
+ if pct > last_pct:
3257
+ last_pct = pct
3258
+ send(ser, f"PROGRESS:{min(pct, 99)}%")
3259
+ except CancelError:
3260
+ discstation_burn.stop_process(proc)
3261
+ safe_send(ser, "CANCELLED:Rip cancelled")
3262
+ raise
3263
+ if proc.wait() != 0 or not dest.exists():
3264
+ raise RuntimeError("HandBrake rip failed")
3265
+ return dest
3266
+
3267
+
3268
+ def rip_flow(ser, artist_hint=None, album_hint=None):
3269
+ device = discstation_burn.disc_device()
3270
+ kind = disc_kind(device)
3271
+
3272
+ if kind == "audio_cd" and discstation_host.system_name() == "darwin":
3273
+ raise RuntimeError("Apple Music handles audio CD ripping on macOS")
3274
+ if kind == "audio_cd":
3275
+ rip_audio_cd(ser, device, artist_hint, album_hint)
3276
+ return
3277
+
3278
+ if kind in ("vcd", "svcd", "video_data"):
3279
+ rip_video_disc(ser, device, kind)
3280
+ return
3281
+
3282
+ if kind == "dvd_video" and discstation_host.system_name() == "darwin":
3283
+ rip_dvd_video_macos(ser, device)
3284
+ return
3285
+
3286
+ if kind != "dvd_video":
3287
+ raise RuntimeError(f"Unsupported disc: {kind}")
3288
+
3289
+ out_dir = RIP_ROOT / time.strftime("rip_%Y%m%d_%H%M%S")
3290
+ out_dir.mkdir(parents=True, exist_ok=True)
3291
+
3292
+ scan = handbrake_scan(device)
3293
+ if scan:
3294
+ main = scan["main_feature"]
3295
+ mins = next((t["duration_s"] // 60 for t in scan["titles"] if t["index"] == main), 0)
3296
+ send(ser, f"INFO:{len(scan['titles'])} titles, main #{main} ~{mins}m")
3297
+ print(f"HandBrake scan: {len(scan['titles'])} titles; main feature #{main} (~{mins}m)")
3298
+
3299
+ # Opt-in: transcode just the main feature to MKV instead of a full mirror.
3300
+ if os.environ.get("DISCSTATION_DVD_RIP_MODE", "").lower() == "mkv" and scan and scan["main_feature"]:
3301
+ handbrake_rip_main_feature(ser, device, out_dir, scan["main_feature"])
3302
+ (out_dir / "disc_info.json").write_text(
3303
+ json.dumps({"kind": "dvd_video", "mode": "handbrake-main-feature",
3304
+ "title": scan["main_feature"], "files": ["main_feature.mkv"]}, indent=2),
3305
+ )
3306
+ safe_send(ser, "PROGRESS:100%")
3307
+ safe_send(ser, "DONE:Rip complete!")
3308
+ print(f"Rip complete: {out_dir}")
3309
+ out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
3310
+ chown_to_sudo_user(out_dir)
3311
+ time.sleep(3)
3312
+ return
3313
+
3314
+ if not shutil.which("dvdbackup"):
3315
+ raise RuntimeError("dvdbackup not found")
3316
+
3317
+ send(ser, "STATUS:Ripping disc...")
3318
+ send(ser, "INFO:Full VIDEO_TS copy")
3319
+ send(ser, "PROGRESS:0%")
3320
+ print(f"Ripping {device} to {out_dir}")
3321
+
3322
+ proc = subprocess.Popen(
3323
+ ["dvdbackup", "-M", "-p", "-i", device, "-o", str(out_dir)],
3324
+ stdout=subprocess.PIPE,
3325
+ stderr=subprocess.STDOUT,
3326
+ text=True,
3327
+ )
3328
+
3329
+ disc_bytes = device_size_bytes(device)
3330
+ last_pct = -1
3331
+
3332
+ try:
3333
+ for event in iter_process_events(proc, ser=ser):
3334
+ if event is None:
3335
+ if disc_bytes > 0:
3336
+ pct = min(int(directory_size_bytes(out_dir) / disc_bytes * 100), 99)
3337
+ if pct > last_pct:
3338
+ last_pct = pct
3339
+ send(ser, f"PROGRESS:{pct}%")
3340
+ continue
3341
+
3342
+ line = event.strip()
3343
+ print(line)
3344
+ match = re.search(r"(\d+(?:\.\d+)?)\s*%", line)
3345
+ if match:
3346
+ last_pct = int(float(match.group(1)))
3347
+ send(ser, f"PROGRESS:{match.group(1)}%")
3348
+ elif "Copying" in line:
3349
+ send(ser, "PROGRESS:" + line[:20])
3350
+ except CancelError:
3351
+ safe_send(ser, "CANCELLED:Rip cancelled")
3352
+ print("Rip cancelled by user")
3353
+ return
3354
+ except (KeyboardInterrupt, SystemExit):
3355
+ discstation_burn.stop_process(proc)
3356
+ safe_send(ser, "CANCELLED:Rip stopped")
3357
+ raise
3358
+
3359
+ if proc.wait() != 0:
3360
+ if not disc_present(device):
3361
+ raise RuntimeError("Disc was removed during rip")
3362
+ raise RuntimeError("Rip failed")
3363
+
3364
+ safe_send(ser, "PROGRESS:100%")
3365
+ safe_send(ser, "DONE:Rip complete!")
3366
+ print(f"Rip complete: {out_dir}")
3367
+ out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
3368
+ chown_to_sudo_user(out_dir)
3369
+ time.sleep(3)
3370
+
3371
+
3372
+ def remux_or_copy_video(src, dest):
3373
+ result = subprocess.run(
3374
+ ["ffmpeg", "-y", "-nostdin", "-i", str(src), "-c", "copy", str(dest)],
3375
+ stdout=subprocess.DEVNULL,
3376
+ stderr=subprocess.DEVNULL,
3377
+ )
3378
+ if result.returncode != 0:
3379
+ shutil.copy2(src, dest.with_suffix(src.suffix.lower()))
3380
+
3381
+
3382
+ def rip_dvd_video_macos(ser, device):
3383
+ out_dir = RIP_ROOT / f"dvd_video_{time.strftime('%Y%m%d_%H%M%S')}"
3384
+ out_dir.mkdir(parents=True, exist_ok=True)
3385
+ send(ser, "STATUS:Ripping DVD")
3386
+ send(ser, "INFO:Copying VIDEO_TS")
3387
+ send(ser, "PROGRESS:0%")
3388
+
3389
+ with mounted_disc(device) as mount_dir:
3390
+ source_dir = mount_dir / "VIDEO_TS"
3391
+ if not source_dir.is_dir():
3392
+ raise RuntimeError("VIDEO_TS directory not found")
3393
+ files = sorted(path for path in source_dir.rglob("*") if path.is_file())
3394
+ total_bytes = sum(path.stat().st_size for path in files)
3395
+ copied_bytes = 0
3396
+ for source in files:
3397
+ relative = source.relative_to(source_dir)
3398
+ destination = out_dir / "VIDEO_TS" / relative
3399
+ destination.parent.mkdir(parents=True, exist_ok=True)
3400
+ span = (source.stat().st_size / total_bytes * 100) if total_bytes else 0
3401
+ discstation_burn.copy_with_keepalive(
3402
+ ser,
3403
+ source,
3404
+ destination,
3405
+ base_pct=(copied_bytes / total_bytes * 100) if total_bytes else 0,
3406
+ pct_span=span,
3407
+ )
3408
+ copied_bytes += source.stat().st_size
3409
+
3410
+ (out_dir / "disc_info.json").write_text(
3411
+ json.dumps({"kind": "dvd_video", "files": [str(path.relative_to(out_dir)) for path in (out_dir / "VIDEO_TS").rglob("*") if path.is_file()]}, indent=2),
3412
+ )
3413
+ safe_send(ser, "PROGRESS:100%")
3414
+ safe_send(ser, "DONE:Rip complete!")
3415
+ print(f"DVD rip complete: {out_dir}")
3416
+ out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
3417
+ chown_to_sudo_user(out_dir)
3418
+ time.sleep(3)
3419
+
3420
+
3421
+ def rip_video_disc(ser, device, kind):
3422
+ out_dir = RIP_ROOT / f"{kind}_{time.strftime('%Y%m%d_%H%M%S')}"
3423
+ out_dir.mkdir(parents=True, exist_ok=True)
3424
+
3425
+ send(ser, f"STATUS:Ripping {kind.upper()}")
3426
+ print(f"Ripping {kind} from {device} to {out_dir}")
3427
+
3428
+ with mounted_disc(device) as mount_dir:
3429
+ _, files = disc_video_files(mount_dir)
3430
+ if not files:
3431
+ raise RuntimeError("No video files found")
3432
+
3433
+ for index, src in enumerate(files, start=1):
3434
+ send(ser, f"PROGRESS:File {index}/{len(files)}")
3435
+ dest = out_dir / f"{index:02d} - {safe_path_name(src.stem)}.mpg"
3436
+ print(f"Ripping {src.name} -> {dest.name}")
3437
+ remux_or_copy_video(src, dest)
3438
+
3439
+ (out_dir / "disc_info.json").write_text(
3440
+ json.dumps({"kind": kind, "files": [path.name for path in files]}, indent=2),
3441
+ )
3442
+ safe_send(ser, "PROGRESS:100%")
3443
+ safe_send(ser, "DONE:Rip complete!")
3444
+ print(f"Video rip complete: {out_dir}")
3445
+ out_dir = _finalize_video_rip(ser, out_dir, device, kind)
3446
+ chown_to_sudo_user(out_dir)
3447
+ time.sleep(3)
3448
+
3449
+
3450
+ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
3451
+ if not chapters:
3452
+ raise RuntimeError("No audio CD tracks found")
3453
+ wav_dir = out_dir / ".wav"
3454
+ wav_dir.mkdir(parents=True, exist_ok=True)
3455
+ command = [
3456
+ discstation_burn.tool("cdda2wav"), "-D",
3457
+ discstation_host.cdrdao_device(device), "-B", "-O", "wav", "-x",
3458
+ ]
3459
+ send(ser, "STATUS:RIPPING AUDIO CD")
3460
+ send(ser, "PROGRESS:0%")
3461
+ proc = subprocess.Popen(
3462
+ command,
3463
+ cwd=str(wav_dir),
3464
+ stdout=subprocess.PIPE,
3465
+ stderr=subprocess.STDOUT,
3466
+ text=True,
3467
+ )
3468
+ output = []
3469
+ try:
3470
+ for line in _iter_proc_lines(proc, ser):
3471
+ output.append(line)
3472
+ count = len(list(wav_dir.glob("*.wav")))
3473
+ if count:
3474
+ send(ser, f"PROGRESS:{min(int(count / len(chapters) * 60), 60)}%")
3475
+ except (KeyboardInterrupt, SystemExit):
3476
+ discstation_burn.stop_process(proc)
3477
+ raise
3478
+ proc.wait()
3479
+ if proc.returncode != 0:
3480
+ detail = next((line.strip() for line in reversed(output) if line.strip()), "cdda2wav failed")
3481
+ raise RuntimeError(f"Audio CD rip failed: {detail[:80]}")
3482
+
3483
+ wav_files = sorted(wav_dir.glob("*.wav"))
3484
+ if len(wav_files) < len(chapters):
3485
+ raise RuntimeError(f"Only ripped {len(wav_files)}/{len(chapters)} tracks")
3486
+ for index, wav in enumerate(wav_files[:len(chapters)], start=1):
3487
+ chapter = chapters[index - 1]
3488
+ if metadata and index <= len(metadata["tracks"]):
3489
+ track_meta = metadata["tracks"][index - 1]
3490
+ title = track_meta["title"]
3491
+ else:
3492
+ title = chapter.get("tags", {}).get("title", f"track {index:02d}")
3493
+ track_meta = {
3494
+ "number": index,
3495
+ "title": title,
3496
+ "artist": metadata["album_artist"] if metadata else "Unknown Artist",
3497
+ "recording_id": None,
3498
+ "release_track_id": None,
3499
+ }
3500
+ out_file = out_dir / f"{index:02d} - {safe_path_name(title)}.flac"
3501
+ subprocess.run(
3502
+ [discstation_burn.tool("ffmpeg"), "-y", "-i", str(wav), "-c:a", "flac", str(out_file)],
3503
+ capture_output=True,
3504
+ check=True,
3505
+ )
3506
+ if metadata:
3507
+ tag_flac(out_file, track_meta, metadata, cover_path)
3508
+ wav.unlink(missing_ok=True)
3509
+ send(ser, f"PROGRESS:{60 + int(index / len(chapters) * 40)}%")
3510
+ shutil.rmtree(str(wav_dir), ignore_errors=True)
3511
+ safe_send(ser, "PROGRESS:100%")
3512
+ safe_send(ser, "DONE:Rip complete!")
3513
+ chown_to_sudo_user(out_dir)
3514
+ time.sleep(3)
3515
+
3516
+
3517
+ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
3518
+ chapters = audio_cd_chapters(device)
3519
+ metadata = None
3520
+ cover_path = None
3521
+
3522
+ send(ser, "STATUS:Looking up CD...")
3523
+ metadata = audio_metadata_lookup(device, len(chapters), artist_hint, album_hint)
3524
+
3525
+ if metadata:
3526
+ album_folder = safe_path_name(f"{metadata['album_artist']} - {metadata['album']}")
3527
+ out_dir = unique_dir(RIP_ROOT / album_folder)
3528
+ else:
3529
+ out_dir = RIP_ROOT / time.strftime("audio_cd_%Y%m%d_%H%M%S")
3530
+
3531
+ out_dir.mkdir(parents=True, exist_ok=True)
3532
+ tracks_path = write_audio_tracks_file(out_dir, chapters)
3533
+ write_album_info(out_dir, metadata)
3534
+
3535
+ if metadata:
3536
+ send(ser, "STATUS:Downloading art")
3537
+ cover_path = download_cover_art(
3538
+ metadata.get("release_id"),
3539
+ out_dir,
3540
+ metadata.get("release_group_id"),
3541
+ )
3542
+
3543
+ send(ser, "STATUS:Ripping audio CD")
3544
+ if metadata:
3545
+ send(ser, f"INFO:{metadata['album'][:20]}")
3546
+ else:
3547
+ send(ser, f"INFO:{len(chapters)} tracks")
3548
+ print(f"Ripping audio CD from {device} to {out_dir}")
3549
+ print(f"Track list: {tracks_path}")
3550
+ if metadata:
3551
+ print(f"Album: {metadata['album_artist']} - {metadata['album']}")
3552
+ print(f"Metadata: {metadata.get('source', 'unknown')}")
3553
+ print(f"Cover: {cover_path or 'not found'}")
3554
+ else:
3555
+ print("No MusicBrainz match; using generic track names.")
3556
+
3557
+ if discstation_host.system_name() == "darwin":
3558
+ _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir)
3559
+ return
3560
+
3561
+ if not chapters:
3562
+ raise RuntimeError("No audio CD tracks found")
3563
+
3564
+ total_tracks = len(chapters)
3565
+ disc_duration = max((float(ch.get("end_time", 0)) for ch in chapters), default=0)
3566
+ rip_duration = max(disc_duration - 1.0, 1.0) if disc_duration > 0 else 0
3567
+ split_times = [
3568
+ float(chapter.get("end_time", 0))
3569
+ for chapter in chapters[:-1]
3570
+ if float(chapter.get("end_time", 0)) < rip_duration
3571
+ ]
3572
+ segment_template = out_dir / "track_%03d.flac"
3573
+
3574
+ send(ser, "STATUS:Ripping audio CD")
3575
+ send(ser, "PROGRESS:0%")
3576
+ print("Ripping audio CD in one pass and splitting by track markers.")
3577
+
3578
+ proc = subprocess.Popen(
3579
+ [
3580
+ "ffmpeg", "-y", "-nostdin",
3581
+ "-f", "libcdio", "-i", device,
3582
+ *(["-t", f"{rip_duration:.3f}"] if rip_duration else []),
3583
+ "-map", "0:a:0",
3584
+ "-c:a", "flac",
3585
+ "-f", "segment",
3586
+ "-segment_format", "flac",
3587
+ "-segment_times", ",".join(f"{value:.3f}" for value in split_times),
3588
+ "-reset_timestamps", "1",
3589
+ str(segment_template),
3590
+ ],
3591
+ stdout=subprocess.PIPE,
3592
+ stderr=subprocess.STDOUT,
3593
+ text=True,
3594
+ )
3595
+
3596
+ try:
3597
+ for line in _iter_proc_lines(proc, ser):
3598
+ print(line, end="")
3599
+ secs = parse_ffmpeg_time(line)
3600
+ if secs is not None and rip_duration > 0:
3601
+ pct = min(int(secs / rip_duration * 100), 99)
3602
+ send(ser, f"PROGRESS:{pct}%")
3603
+ except (KeyboardInterrupt, SystemExit):
3604
+ discstation_burn.stop_process(proc)
3605
+ safe_send(ser, "CANCELLED:Rip stopped")
3606
+ raise
3607
+
3608
+ proc.wait()
3609
+ if proc.returncode == -15:
3610
+ print("Rip cancelled by user")
3611
+ safe_send(ser, "CANCELLED:Rip cancelled")
3612
+ return
3613
+ elif proc.returncode != 0:
3614
+ if not disc_present(device):
3615
+ raise RuntimeError("Disc was removed during rip")
3616
+ raise RuntimeError("Audio CD rip failed")
3617
+
3618
+ segment_files = sorted(out_dir.glob("track_*.flac"))
3619
+ if len(segment_files) < total_tracks:
3620
+ raise RuntimeError(f"Only ripped {len(segment_files)}/{total_tracks} tracks")
3621
+
3622
+ for index, segment in enumerate(segment_files[:total_tracks], start=1):
3623
+ chapter = chapters[index - 1]
3624
+ if metadata and index <= len(metadata["tracks"]):
3625
+ track_meta = metadata["tracks"][index - 1]
3626
+ title = track_meta["title"]
3627
+ else:
3628
+ title = chapter.get("tags", {}).get("title", f"track {index:02d}")
3629
+ track_meta = {
3630
+ "number": index,
3631
+ "title": title,
3632
+ "artist": metadata["album_artist"] if metadata else "Unknown Artist",
3633
+ "recording_id": None,
3634
+ "release_track_id": None,
3635
+ }
3636
+
3637
+ out_file = out_dir / f"{index:02d} - {safe_path_name(title)}.flac"
3638
+ if out_file.exists():
3639
+ out_file.unlink()
3640
+ segment.rename(out_file)
3641
+
3642
+ if metadata:
3643
+ tag_flac(out_file, track_meta, metadata, cover_path)
3644
+
3645
+ send(ser, f"PROGRESS:Tagged {index}/{total_tracks}")
3646
+
3647
+ safe_send(ser, "PROGRESS:100%")
3648
+ safe_send(ser, "DONE:Rip complete!")
3649
+ print(f"Audio rip complete: {out_dir}")
3650
+ chown_to_sudo_user(out_dir)
3651
+ time.sleep(3)
3652
+
3653
+
3654
+ def station_loop(ser, url, artist_hint=None, album_hint=None):
3655
+ global _last_burn_result, _last_burn_result_time, _tray_open, _tray_open_since
3656
+ discstation_burn.cleanup_old_jobs()
3657
+ device = discstation_burn.disc_device()
3658
+
3659
+ for _ in range(50):
3660
+ line = read_serial_line(ser, timeout=0.2)
3661
+ if not line:
3662
+ break
3663
+ print(f"ESP32: {line}")
3664
+ if "DISCSTATION_READY" in line:
3665
+ break
3666
+
3667
+ safe_send(ser, "STANDBY:Starting...")
3668
+
3669
+ last_disc_line = None
3670
+ last_disc_poll = 0
3671
+ standby = False
3672
+ print("DiscStation menu ready.")
3673
+
3674
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
3675
+ fut = pool.submit(disc_status_line, device)
3676
+ next_disc_line = None
3677
+ probe_deadline = time.time() + 25
3678
+ while time.time() < probe_deadline:
3679
+ if fut.done():
3680
+ try:
3681
+ next_disc_line = fut.result()
3682
+ except Exception as e:
3683
+ next_disc_line = "Disc: reading..."
3684
+ print(f"Disc probe error: {e}")
3685
+ break
3686
+ safe_send(ser, "PING")
3687
+ time.sleep(4)
3688
+ if next_disc_line is None:
3689
+ next_disc_line = "Disc: reading..."
3690
+ print("Disc probe timed out")
3691
+ if next_disc_line != last_disc_line:
3692
+ prev_disc_line = last_disc_line
3693
+ last_disc_line = next_disc_line
3694
+ send_disc_info(ser, device, next_disc_line)
3695
+ print(next_disc_line)
3696
+ has_disc = "none" not in next_disc_line.lower() and "checking" not in next_disc_line.lower()
3697
+ if has_disc or "blank" in next_disc_line.lower():
3698
+ show_home(ser)
3699
+ else:
3700
+ show_standby(ser)
3701
+ standby = True
3702
+ last_disc_poll = time.time()
3703
+ discstation_burn.note_serial_activity()
3704
+ last_ping = time.time()
3705
+ _disc_poll_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
3706
+ atexit.register(_disc_poll_pool.shutdown, wait=False)
3707
+ _disc_poll_future = None
3708
+ _disc_poll_start = 0
3709
+
3710
+ def _poll_disc():
3711
+ current_device = discstation_burn.disc_device()
3712
+ return disc_status_line(current_device), current_device
3713
+
3714
+ def apply_disc_line(new_line):
3715
+ nonlocal last_disc_line, prev_disc_line, standby
3716
+ if not new_line or new_line == last_disc_line:
3717
+ return
3718
+ prev_disc_line = last_disc_line
3719
+ last_disc_line = new_line
3720
+ send_disc_info(ser, device, new_line)
3721
+ print(new_line)
3722
+ low = new_line.lower()
3723
+ transient = any(w in low for w in ("none", "checking", "reading", "tray open"))
3724
+ if not transient or "blank" in low:
3725
+ prev_empty = prev_disc_line is None or (prev_disc_line and "none" in prev_disc_line.lower())
3726
+ if standby or prev_empty:
3727
+ show_home(ser)
3728
+ standby = False
3729
+ elif not standby:
3730
+ show_standby(ser)
3731
+ standby = True
3732
+
3733
+ last_status_check = 0.0
3734
+ last_status = None
3735
+
3736
+ while True:
3737
+ now = time.time()
3738
+
3739
+ # --- fast drive-state check (~1.5s), independent of the 5s ping ---------
3740
+ # CDROM_DRIVE_STATUS is a cheap ioctl that reports tray/media state
3741
+ # reliably on this USB bridge (udev's ID_CDROM_MEDIA is stale here).
3742
+ if now - last_status_check >= 1.5:
3743
+ last_status_check = now
3744
+ in_eject_guard = _tray_open and (time.monotonic() - _tray_open_since) < 8
3745
+ if not in_eject_guard:
3746
+ st = drive_status(device)
3747
+ if st == "open":
3748
+ _tray_open = True
3749
+ _disc_poll_future = None
3750
+ apply_disc_line("Disc: Tray open")
3751
+ elif st == "no_disc":
3752
+ _tray_open = False
3753
+ _disc_poll_future = None
3754
+ apply_disc_line("Disc: none")
3755
+ elif st == "loading":
3756
+ _tray_open = False
3757
+ if (last_disc_line or "").lower().find("none") >= 0 or last_disc_line is None:
3758
+ apply_disc_line("Disc: reading...")
3759
+ elif st == "disc":
3760
+ _tray_open = False
3761
+ have_line = last_disc_line and not any(
3762
+ w in last_disc_line.lower()
3763
+ for w in ("none", "checking", "reading", "tray open"))
3764
+ if not have_line and _disc_poll_future is None:
3765
+ _disc_poll_start = now
3766
+ last_disc_poll = now
3767
+ _detect_cache.pop(device, None) # force a fresh classify
3768
+ _disc_poll_future = _disc_poll_pool.submit(_poll_disc)
3769
+ if st != "unknown":
3770
+ last_status = st
3771
+
3772
+ if now - last_ping >= 5:
3773
+ last_ping = now
3774
+ safe_send(ser, "PING")
3775
+ check_serial_alive(ser)
3776
+
3777
+ # Slow full classify as a backstop (type changes, stuck "reading...").
3778
+ if (not _tray_open and _disc_poll_future is None
3779
+ and now - last_disc_poll >= DISC_POLL_SECONDS):
3780
+ last_disc_poll = now
3781
+ _disc_poll_start = now
3782
+ _disc_poll_future = _disc_poll_pool.submit(_poll_disc)
3783
+
3784
+ if _disc_poll_future is not None:
3785
+ next_disc_line = None
3786
+ if _disc_poll_future.done():
3787
+ try:
3788
+ next_disc_line, polled_device = _disc_poll_future.result()
3789
+ if polled_device:
3790
+ device = polled_device
3791
+ except Exception as e:
3792
+ next_disc_line = "Disc: reading..."
3793
+ print(f"Disc poll error: {e}")
3794
+ _disc_poll_future = None
3795
+ elif now - _disc_poll_start > 25:
3796
+ _disc_poll_future = None
3797
+ next_disc_line = "Disc: reading..."
3798
+ print("Disc poll timed out (async)")
3799
+ if not _tray_open:
3800
+ apply_disc_line(next_disc_line)
3801
+
3802
+ line = read_serial_line(ser, timeout=0.1)
3803
+ if not line:
3804
+ continue
3805
+
3806
+ if line == "PONG":
3807
+ continue
3808
+
3809
+ if line.startswith("MENU:"):
3810
+ print(f"Menu: {line.split(':', 1)[1]}")
3811
+ continue
3812
+
3813
+ if line == "EJECT":
3814
+ try:
3815
+ device = discstation_burn.disc_device()
3816
+ except FileNotFoundError:
3817
+ if discstation_host.system_name() == "darwin":
3818
+ device = None
3819
+ else:
3820
+ raise
3821
+ safe_send(ser, "STATUS:Ejecting...")
3822
+ try:
3823
+ eject_disc(ser, device)
3824
+ except Exception as e:
3825
+ print(f"Eject handler error: {e}")
3826
+ safe_send(ser, "ERROR:Eject failed")
3827
+ time.sleep(2)
3828
+ safe_send(ser, "STANDBY:Error")
3829
+ # eject_disc talks to the OLED directly and may leave the tray in any
3830
+ # state — force station_loop to re-detect from scratch next tick.
3831
+ last_disc_line = None
3832
+ last_status = None
3833
+ last_status_check = 0.0
3834
+ _detect_cache.pop(device, None)
3835
+ continue
3836
+
3837
+ if not line.startswith("SELECT:"):
3838
+ if line.startswith("WiFi") or line.startswith("IP:") or "ip:" in line.lower():
3839
+ print(f"ESP32: {line}")
3840
+ continue
3841
+
3842
+ mode = line.split(":", 1)[1].strip().upper()
3843
+ print(f"Selected: {mode}")
3844
+
3845
+ # The user picked a mode — they want to act on a disc, so the drive is
3846
+ # fair game again even if it was ejected from the OLED earlier.
3847
+ _tray_open = False
3848
+ try:
3849
+ if mode == "BURN":
3850
+ burn_flow(ser, url)
3851
+ _last_burn_result = "Burn complete"
3852
+ elif mode == "PLAY":
3853
+ play_flow(ser)
3854
+ elif mode == "APPLE MUSIC":
3855
+ safe_send(ser, "STATUS:Apple Music handles this audio CD")
3856
+ time.sleep(2)
3857
+ elif mode == "RIP":
3858
+ rip_flow(ser, artist_hint, album_hint)
3859
+ _last_burn_result = "Rip complete"
3860
+ elif mode == "BURN MPG":
3861
+ burn_mpg_flow(ser)
3862
+ _last_burn_result = "Burn complete"
3863
+ elif mode == "BURN DATA":
3864
+ burn_data_flow(ser)
3865
+ _last_burn_result = "Burn complete"
3866
+ elif mode == "BURN AUDIO":
3867
+ burn_audio_flow(ser)
3868
+ _last_burn_result = "Burn complete"
3869
+ else:
3870
+ print(f"Ignoring stale menu selection: {mode}")
3871
+ refresh_main_menu(ser)
3872
+ continue
3873
+
3874
+ except KeyboardInterrupt:
3875
+ raise
3876
+ except Exception as e:
3877
+ safe_send(ser, f"ERROR:{str(e)[:50]}")
3878
+ _last_burn_result = f"ERROR: {e}"
3879
+ print(f"Error in {mode}: {e}")
3880
+ time.sleep(4)
3881
+
3882
+ _last_burn_result_time = time.time()
3883
+ refresh_main_menu(ser)
3884
+
3885
+
3886
+ PIDFILE = "/tmp/discstation.pid"
3887
+
3888
+
3889
+ def check_pidfile():
3890
+ try:
3891
+ if os.path.exists(PIDFILE):
3892
+ with open(PIDFILE) as f:
3893
+ old_pid = int(f.read().strip())
3894
+ try:
3895
+ os.kill(old_pid, 0)
3896
+ with open(f"/proc/{old_pid}/cmdline") as f:
3897
+ if "discstation" in f.read():
3898
+ print(f"Already running (PID {old_pid}), exiting")
3899
+ sys.exit(0)
3900
+ except (OSError, IOError):
3901
+ pass
3902
+ except (ValueError, OSError):
3903
+ pass
3904
+ with open(PIDFILE, "w") as f:
3905
+ f.write(str(os.getpid()))
3906
+
3907
+
3908
+ def parse_args():
3909
+ parser = argparse.ArgumentParser(description="Physical DVD station controller")
3910
+ parser.add_argument("--artist", help="Audio CD album artist hint for metadata fallback")
3911
+ parser.add_argument("--album", help="Audio CD album title hint for metadata fallback")
3912
+ parser.add_argument(
3913
+ "--retag-latest-audio",
3914
+ action="store_true",
3915
+ help="Retag the newest generic audio_cd_* rip using --artist/--album, then exit",
3916
+ )
3917
+ parser.add_argument("--port", type=int, default=8080, help="Web interface port")
3918
+ parser.add_argument("url", nargs="?", help="YouTube URL or file path for burn mode")
3919
+ return parser.parse_args()
3920
+
3921
+
3922
+ def main():
3923
+ global _active_ser, _line_buf
3924
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
3925
+ check_pidfile()
3926
+
3927
+ args = parse_args()
3928
+ ser = None
3929
+ exit_code = 0
3930
+
3931
+ try:
3932
+ if args.retag_latest_audio:
3933
+ rip_dir = latest_audio_rip_dir()
3934
+ new_dir, cover_path, renamed = retag_audio_rip(rip_dir, args.artist, args.album)
3935
+ print(f"Retagged: {new_dir}")
3936
+ print(f"Cover: {cover_path or 'not found'}")
3937
+ for path in renamed:
3938
+ print(path.name)
3939
+ return
3940
+
3941
+ start_web_server(args.port)
3942
+
3943
+ while True:
3944
+ try:
3945
+ _line_buf = b""
3946
+ port = discstation_host.serial_port()
3947
+ if not port:
3948
+ raise serial.SerialException("No ESP32 serial port found")
3949
+ print(f"Using ESP32 serial port: {port}")
3950
+ ser = serial.Serial(port, discstation_burn.BAUD, timeout=1, write_timeout=1)
3951
+ if discstation_host.system_name() == "linux":
3952
+ ser.setDTR(False)
3953
+ time.sleep(0.1)
3954
+ ser.setDTR(True)
3955
+ time.sleep(2)
3956
+ discstation_burn.reset_serial_state()
3957
+ _active_ser = ser
3958
+ station_loop(ser, args.url, args.artist, args.album)
3959
+ except (serial.SerialException, OSError, termios.error) as e:
3960
+ print(f"Disconnected ({e}), reconnecting in 3s...")
3961
+ time.sleep(3)
3962
+ except KeyboardInterrupt:
3963
+ raise
3964
+ finally:
3965
+ _active_ser = None
3966
+ if ser:
3967
+ try:
3968
+ ser.close()
3969
+ except Exception:
3970
+ pass
3971
+ ser = None
3972
+ except KeyboardInterrupt:
3973
+ print("\nStopped.")
3974
+ exit_code = 130
3975
+ except Exception as e:
3976
+ print(f"Error: {e}")
3977
+ exit_code = 1
3978
+
3979
+ if exit_code:
3980
+ sys.exit(exit_code)
3981
+
3982
+
3983
+ if __name__ == "__main__":
3984
+ main()