discstation 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/arduino/c6/DiscStation_C6.ino +914 -0
- package/arduino/v1/DiscStation.ino +957 -0
- package/discstation.env.example +19 -0
- package/docs/PLATFORM_SUPPORT.md +39 -0
- package/install-macos.sh +80 -0
- package/install-windows.ps1 +24 -0
- package/install.sh +56 -0
- package/package.json +48 -0
- package/requirements.txt +20 -0
- package/scripts/setup.mjs +78 -0
- package/src/discstation.py +3984 -0
- package/src/discstation_burn.py +1697 -0
- package/src/discstation_host.py +380 -0
- package/src/discstation_meta.py +150 -0
- package/src/static/app.js +268 -0
- package/src/static/index.html +111 -0
- package/src/static/style.css +328 -0
- package/systemd/discstation.service +13 -0
|
@@ -0,0 +1,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()
|