discstation 0.1.35 → 0.1.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/arduino/DiscStation/DiscStation.ino +34 -30
- package/discstation.env.example +1 -1
- package/docs/PLATFORM_SUPPORT.md +13 -31
- package/install-macos.sh +3 -6
- package/install.sh +6 -1
- package/package.json +1 -1
- package/requirements-optional.txt +5 -7
- package/scripts/check.sh +61 -0
- package/src/discstation.py +179 -729
- package/src/discstation_burn.py +18 -207
- package/src/static/style.css +0 -7
- package/src/win/_imapi.ps1 +66 -0
- package/src/win/audio-toc.ps1 +1 -9
- package/src/win/burn-audio.ps1 +2 -14
- package/src/win/burn-data.ps1 +4 -47
- package/src/win/burn-image.ps1 +4 -53
package/src/discstation.py
CHANGED
|
@@ -3,6 +3,7 @@ import argparse
|
|
|
3
3
|
import atexit
|
|
4
4
|
import collections
|
|
5
5
|
import concurrent.futures
|
|
6
|
+
import contextlib
|
|
6
7
|
import errno
|
|
7
8
|
try:
|
|
8
9
|
import fcntl # POSIX-only; used only in drive_status()'s Linux branch
|
|
@@ -12,10 +13,6 @@ import json
|
|
|
12
13
|
import mimetypes
|
|
13
14
|
import os
|
|
14
15
|
import signal
|
|
15
|
-
try:
|
|
16
|
-
import pwd
|
|
17
|
-
except ImportError:
|
|
18
|
-
pwd = None
|
|
19
16
|
import re
|
|
20
17
|
import shutil
|
|
21
18
|
import socket
|
|
@@ -27,6 +24,7 @@ import subprocess
|
|
|
27
24
|
import sys
|
|
28
25
|
import tempfile
|
|
29
26
|
import time
|
|
27
|
+
import types
|
|
30
28
|
import datetime
|
|
31
29
|
from pathlib import Path
|
|
32
30
|
from queue import Queue, Empty, Full
|
|
@@ -819,53 +817,6 @@ def safe_send(ser, msg):
|
|
|
819
817
|
discstation_burn.status_sink = _record_web_status
|
|
820
818
|
|
|
821
819
|
|
|
822
|
-
def run_as_desktop_user(cmd):
|
|
823
|
-
if os.name != "posix" or pwd is None:
|
|
824
|
-
return cmd
|
|
825
|
-
sudo_user = os.environ.get("SUDO_USER")
|
|
826
|
-
if os.geteuid() == 0 and sudo_user and sudo_user != "root":
|
|
827
|
-
home = Path(discstation_burn.USER_HOME)
|
|
828
|
-
uid = pwd.getpwnam(sudo_user).pw_uid
|
|
829
|
-
return [
|
|
830
|
-
"sudo", "-u", sudo_user,
|
|
831
|
-
"env",
|
|
832
|
-
"DISPLAY=" + os.environ.get("DISPLAY", ":0"),
|
|
833
|
-
"XAUTHORITY=" + str(home / ".Xauthority"),
|
|
834
|
-
"XDG_RUNTIME_DIR=/run/user/" + str(uid),
|
|
835
|
-
*cmd,
|
|
836
|
-
]
|
|
837
|
-
return cmd
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
def chown_to_sudo_user(path):
|
|
841
|
-
sudo_user = os.environ.get("SUDO_USER")
|
|
842
|
-
if os.name != "posix" or pwd is None or not hasattr(os, "geteuid"):
|
|
843
|
-
return
|
|
844
|
-
if os.geteuid() != 0 or not sudo_user or sudo_user == "root":
|
|
845
|
-
return
|
|
846
|
-
|
|
847
|
-
try:
|
|
848
|
-
pw_record = pwd.getpwnam(sudo_user)
|
|
849
|
-
except KeyError:
|
|
850
|
-
return
|
|
851
|
-
|
|
852
|
-
uid = pw_record.pw_uid
|
|
853
|
-
gid = pw_record.pw_gid
|
|
854
|
-
root_path = Path(path)
|
|
855
|
-
|
|
856
|
-
for current_root, dirs, files in os.walk(root_path):
|
|
857
|
-
try:
|
|
858
|
-
os.chown(current_root, uid, gid)
|
|
859
|
-
except OSError:
|
|
860
|
-
pass
|
|
861
|
-
for name in dirs + files:
|
|
862
|
-
item = Path(current_root) / name
|
|
863
|
-
try:
|
|
864
|
-
os.chown(item, uid, gid)
|
|
865
|
-
except OSError:
|
|
866
|
-
pass
|
|
867
|
-
|
|
868
|
-
|
|
869
820
|
def _mpv_ipc(payload, timeout=None):
|
|
870
821
|
"""Send one JSON line to mpv's IPC endpoint. Windows = named pipe, POSIX =
|
|
871
822
|
AF_UNIX socket. Returns the raw reply bytes (b"" if not read), or raises OSError."""
|
|
@@ -936,7 +887,7 @@ def read_serial_line(ser, timeout=0.1):
|
|
|
936
887
|
# Always make at least one non-blocking pass, even for timeout<=0 - the
|
|
937
888
|
# `remaining <= 0: break` at the bottom still ends it after that pass.
|
|
938
889
|
# (`while time.monotonic() < deadline` used to skip the body entirely for
|
|
939
|
-
# timeout=0, which is exactly how
|
|
890
|
+
# timeout=0, which is exactly how check_cancel() calls this - so cancel
|
|
940
891
|
# detection during rips silently never read the port.)
|
|
941
892
|
while True:
|
|
942
893
|
if _line_buf:
|
|
@@ -1051,30 +1002,41 @@ def eject_disc(ser, device):
|
|
|
1051
1002
|
safe_send(ser, "ERROR:Eject failed")
|
|
1052
1003
|
safe_send(ser, "STANDBY:Insert disc")
|
|
1053
1004
|
return ok
|
|
1054
|
-
|
|
1005
|
+
# The kernel closes the tray again (dev.cdrom.autoclose) when anything opens
|
|
1006
|
+
# the drive while it's out, and every disc probe (blkid/lsdvd/wodim/mediainfo,
|
|
1007
|
+
# driven by the background poll and by the web/phone /disc-info requests)
|
|
1008
|
+
# does exactly that. So: mark the tray open BEFORE the eject command runs (new
|
|
1009
|
+
# probes now bail out) and hold _detect_lock so any probe already in flight
|
|
1010
|
+
# finishes first - otherwise a probe landing inside the few seconds the eject
|
|
1011
|
+
# takes shuts the tray right after it opens.
|
|
1012
|
+
with _detect_lock:
|
|
1013
|
+
_tray_open = True
|
|
1014
|
+
_tray_open_since = time.monotonic()
|
|
1015
|
+
subprocess.run(["sync"], timeout=5)
|
|
1055
1016
|
|
|
1056
|
-
|
|
1057
|
-
|
|
1017
|
+
subprocess.run(["sg_raw", device, "1e", "00", "00", "00", "00", "00"],
|
|
1018
|
+
timeout=5, capture_output=True)
|
|
1058
1019
|
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
if ok:
|
|
1062
|
-
break
|
|
1063
|
-
try:
|
|
1064
|
-
r = subprocess.run(cmd, timeout=10, capture_output=True)
|
|
1065
|
-
ok = r.returncode == 0
|
|
1020
|
+
ok = False
|
|
1021
|
+
for cmd in (["eject", device], ["sg_raw", device, "1b", "00", "00", "00", "02", "00"]):
|
|
1066
1022
|
if ok:
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1023
|
+
break
|
|
1024
|
+
try:
|
|
1025
|
+
r = subprocess.run(cmd, timeout=10, capture_output=True)
|
|
1026
|
+
ok = r.returncode == 0
|
|
1027
|
+
if ok:
|
|
1028
|
+
print(f"{cmd[0]} eject ok")
|
|
1029
|
+
else:
|
|
1030
|
+
err = (r.stderr or r.stdout or b"failed").decode(errors="ignore").strip()[:40]
|
|
1031
|
+
print(f"{cmd[0]} eject failed: {err}")
|
|
1032
|
+
except Exception as e:
|
|
1033
|
+
print(f"{cmd[0]} eject error: {e}")
|
|
1034
|
+
if not ok:
|
|
1035
|
+
_tray_open = False
|
|
1073
1036
|
|
|
1074
1037
|
if ok:
|
|
1075
|
-
_tray_open = True
|
|
1076
1038
|
_tray_open_since = time.monotonic()
|
|
1077
|
-
safe_send(ser, "WAITING:Press
|
|
1039
|
+
safe_send(ser, "WAITING:Press EJECT/to close tray")
|
|
1078
1040
|
# WAITING: isn't in _record_web_status()'s prefix whitelist, so the
|
|
1079
1041
|
# send above never reaches the web page - _tray_open has to be
|
|
1080
1042
|
# published explicitly here, same as DISC: gets its own dedicated
|
|
@@ -1113,7 +1075,7 @@ def eject_disc(ser, device):
|
|
|
1113
1075
|
continue
|
|
1114
1076
|
if line == "PONG":
|
|
1115
1077
|
continue
|
|
1116
|
-
if line
|
|
1078
|
+
if line in ("CONFIRM", "EJECT"): # EJECT button doubles as close while the tray is out
|
|
1117
1079
|
print("Closing tray...")
|
|
1118
1080
|
safe_send(ser, "STATUS:Closing tray...")
|
|
1119
1081
|
for close_cmd in (
|
|
@@ -1174,6 +1136,67 @@ def append_burn_history(entry):
|
|
|
1174
1136
|
f.write(json.dumps(entry) + "\n")
|
|
1175
1137
|
|
|
1176
1138
|
|
|
1139
|
+
@contextlib.contextmanager
|
|
1140
|
+
def _burn_history(entry, swallow_cancel=False):
|
|
1141
|
+
"""Record a burn attempt in the history file however the block ends
|
|
1142
|
+
(success / cancelled / error). `entry` holds the fixed fields (title,
|
|
1143
|
+
disc_type, mode, speed, ...). A cancelled burn re-raises unless
|
|
1144
|
+
swallow_cancel, in which case the caller checks `.cancelled` and returns."""
|
|
1145
|
+
start = time.time()
|
|
1146
|
+
outcome = types.SimpleNamespace(cancelled=False)
|
|
1147
|
+
|
|
1148
|
+
def record(ok, error=None):
|
|
1149
|
+
row = {"timestamp": datetime.datetime.now().isoformat(), **entry,
|
|
1150
|
+
"success": ok, "duration_s": round(time.time() - start)}
|
|
1151
|
+
if error:
|
|
1152
|
+
row["error"] = error
|
|
1153
|
+
append_burn_history(row)
|
|
1154
|
+
|
|
1155
|
+
try:
|
|
1156
|
+
yield outcome
|
|
1157
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1158
|
+
record(False, "Cancelled")
|
|
1159
|
+
raise
|
|
1160
|
+
except CancelError:
|
|
1161
|
+
record(False, "Cancelled")
|
|
1162
|
+
if not swallow_cancel:
|
|
1163
|
+
raise
|
|
1164
|
+
outcome.cancelled = True
|
|
1165
|
+
except Exception as e:
|
|
1166
|
+
record(False, str(e)[:100])
|
|
1167
|
+
raise
|
|
1168
|
+
else:
|
|
1169
|
+
record(True)
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _wait_for_start(ser, starting=None, mode=None):
|
|
1173
|
+
"""Wait on the remote for START (CANCEL/STOP aborts). Returns
|
|
1174
|
+
(mode, speed) - speed is None unless a SPEED: line came first - or None if
|
|
1175
|
+
the user cancelled. A non-None `mode` (the video flow's) may also be set by
|
|
1176
|
+
MODE:<m> / START:<m>. `starting` names the burn in the status line
|
|
1177
|
+
(defaults to the mode)."""
|
|
1178
|
+
speed = None
|
|
1179
|
+
print("Waiting for burn START button...")
|
|
1180
|
+
while True:
|
|
1181
|
+
line = wait_for_button(ser)
|
|
1182
|
+
if line in ("CANCEL", "PLAY_STOP"):
|
|
1183
|
+
safe_send(ser, "CANCELLED:Cancelled")
|
|
1184
|
+
print("Burn cancelled by user")
|
|
1185
|
+
return None
|
|
1186
|
+
if line.startswith("MODE:") and mode is not None:
|
|
1187
|
+
mode = discstation_burn.normalize_mode(line.split(":", 1)[1])
|
|
1188
|
+
print(f"Burn mode: {mode}")
|
|
1189
|
+
elif line.startswith("SPEED:"):
|
|
1190
|
+
speed = line.split(":", 1)[1].strip()
|
|
1191
|
+
print(f"Burn speed: {speed}")
|
|
1192
|
+
elif line == "START" or line.startswith("START:"):
|
|
1193
|
+
if mode is not None and ":" in line:
|
|
1194
|
+
mode = discstation_burn.normalize_mode(line.split(":", 1)[1])
|
|
1195
|
+
print(f"Starting {starting or mode} burn")
|
|
1196
|
+
send(ser, f"STATUS:Starting {starting or mode}...")
|
|
1197
|
+
return mode, speed
|
|
1198
|
+
|
|
1199
|
+
|
|
1177
1200
|
def run_probe(cmd, timeout=8, name=None):
|
|
1178
1201
|
try:
|
|
1179
1202
|
return subprocess.run(
|
|
@@ -1253,7 +1276,7 @@ def _refresh_udev(device):
|
|
|
1253
1276
|
def udev_cdrom_properties(device, refresh=False):
|
|
1254
1277
|
if discstation_host.system_name() != "linux":
|
|
1255
1278
|
return discstation_host.media_properties(device)
|
|
1256
|
-
overlay = _refresh_udev(device) if refresh else {}
|
|
1279
|
+
overlay = _refresh_udev(device) if refresh and not _tray_open else {}
|
|
1257
1280
|
properties = _udev_props_via_pyudev(device)
|
|
1258
1281
|
if properties is None:
|
|
1259
1282
|
# pyudev unavailable — fall back to parsing `udevadm info` output.
|
|
@@ -1293,62 +1316,8 @@ def _device_present(device):
|
|
|
1293
1316
|
return False
|
|
1294
1317
|
|
|
1295
1318
|
|
|
1296
|
-
def _tray_closed_with_disc(device):
|
|
1297
|
-
if not device:
|
|
1298
|
-
return False
|
|
1299
|
-
global _tray_open
|
|
1300
|
-
if not _device_present(device):
|
|
1301
|
-
return False
|
|
1302
|
-
properties = udev_cdrom_properties(device)
|
|
1303
|
-
if properties.get("ID_CDROM_MEDIA") == "1":
|
|
1304
|
-
_tray_open = False
|
|
1305
|
-
return True
|
|
1306
|
-
if properties.get("ID_CDROM_MEDIA_STATE") == "blank":
|
|
1307
|
-
_tray_open = False
|
|
1308
|
-
return True
|
|
1309
|
-
return False
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
def disc_present(device):
|
|
1313
|
-
if not device:
|
|
1314
|
-
return False
|
|
1315
|
-
if _tray_closed_with_disc(device):
|
|
1316
|
-
return True
|
|
1317
|
-
if _tray_open:
|
|
1318
|
-
return False
|
|
1319
|
-
if not _device_present(device):
|
|
1320
|
-
return False
|
|
1321
|
-
if discstation_host.system_name() != "linux":
|
|
1322
|
-
properties = udev_cdrom_properties(device)
|
|
1323
|
-
return properties.get("ID_CDROM_MEDIA") == "1"
|
|
1324
|
-
|
|
1325
|
-
toc = run_probe(["wodim", "-toc", "dev=" + device], name="wodim-toc", timeout=PROBE_TIMEOUT_WODIM_TOC)
|
|
1326
|
-
toc_text = ensure_text(toc.stdout) + ensure_text(toc.stderr)
|
|
1327
|
-
no_media_markers = _NO_MEDIA_MARKERS
|
|
1328
|
-
if toc.returncode == 124:
|
|
1329
|
-
return is_blank_disc(device)
|
|
1330
|
-
if toc_text.strip() and any(marker in toc_text.lower() for marker in no_media_markers):
|
|
1331
|
-
return False
|
|
1332
|
-
|
|
1333
|
-
dvd = run_probe(["lsdvd", device], name="lsdvd", timeout=PROBE_TIMEOUT_LSDVD)
|
|
1334
|
-
if dvd.returncode == 0:
|
|
1335
|
-
return True
|
|
1336
|
-
|
|
1337
|
-
fs = run_probe(["blkid", "-o", "value", "-s", "TYPE", device], name="blkid", timeout=PROBE_TIMEOUT_BLKID)
|
|
1338
|
-
if fs.returncode == 0 and ensure_text(fs.stdout).strip():
|
|
1339
|
-
return True
|
|
1340
|
-
|
|
1341
|
-
if "first:" in toc_text and "track:" in toc_text:
|
|
1342
|
-
return True
|
|
1343
|
-
|
|
1344
|
-
if toc_text.strip() and not any(marker in toc_text.lower() for marker in no_media_markers):
|
|
1345
|
-
return True
|
|
1346
|
-
|
|
1347
|
-
return is_blank_disc(device)
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
1319
|
def is_blank_disc(device):
|
|
1351
|
-
if not device:
|
|
1320
|
+
if not device or _tray_open: # probing an open tray re-closes it
|
|
1352
1321
|
return False
|
|
1353
1322
|
if not _device_present(device):
|
|
1354
1323
|
return False
|
|
@@ -1399,7 +1368,7 @@ def is_blank_disc(device):
|
|
|
1399
1368
|
|
|
1400
1369
|
|
|
1401
1370
|
def is_rewritable_disc(device):
|
|
1402
|
-
if not device:
|
|
1371
|
+
if not device or _tray_open: # probing an open tray re-closes it
|
|
1403
1372
|
return False
|
|
1404
1373
|
"""Return whether the inserted medium can be overwritten."""
|
|
1405
1374
|
if not _device_present(device):
|
|
@@ -1756,6 +1725,8 @@ def _maybe_reset_stuck_drive(device, failed_probes):
|
|
|
1756
1725
|
|
|
1757
1726
|
|
|
1758
1727
|
def _detect_disc_locked(device, settle, budget):
|
|
1728
|
+
if _tray_open: # an eject started while we waited for the lock
|
|
1729
|
+
return _disc_info(False, "none")
|
|
1759
1730
|
deadline = time.monotonic() + (DISC_DETECT_BUDGET if budget is None else budget)
|
|
1760
1731
|
props = udev_cdrom_properties(device, refresh=True)
|
|
1761
1732
|
|
|
@@ -1915,9 +1886,6 @@ def menu_items_for_disc(device):
|
|
|
1915
1886
|
props = udev_cdrom_properties(device)
|
|
1916
1887
|
is_cd = props.get("ID_CDROM_MEDIA_CD_R") == "1" or props.get("ID_CDROM_MEDIA_CD_RW") == "1"
|
|
1917
1888
|
items = ["BURN DATA", "BURN AUDIO"] if is_cd else ["BURN", "BURN DATA"]
|
|
1918
|
-
had = discstation_burn.WORK.rglob("movie.mpg")
|
|
1919
|
-
if any(True for _ in had):
|
|
1920
|
-
items.append("BURN MPG")
|
|
1921
1889
|
elif kind in ("dvd_video", "audio_cd", "vcd", "svcd", "video_data", "data_disc", "data_cd"):
|
|
1922
1890
|
items = ["PLAY", "RIP"]
|
|
1923
1891
|
else:
|
|
@@ -2217,10 +2185,6 @@ def _release_meta(release, track_count, toc=None):
|
|
|
2217
2185
|
return None
|
|
2218
2186
|
|
|
2219
2187
|
|
|
2220
|
-
# Back-compat alias (older name).
|
|
2221
|
-
metadata_from_musicbrainz_release = _release_meta
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
2188
|
def musicbrainz_release_details(release_id):
|
|
2225
2189
|
if _mb is not None:
|
|
2226
2190
|
return _mb.get_release_by_id(
|
|
@@ -2277,58 +2241,6 @@ def musicbrainz_lookup(device, track_count):
|
|
|
2277
2241
|
return None
|
|
2278
2242
|
|
|
2279
2243
|
|
|
2280
|
-
def musicbrainz_lookup_by_album_hints(album_artist, album, track_count):
|
|
2281
|
-
if not album:
|
|
2282
|
-
return None
|
|
2283
|
-
|
|
2284
|
-
if _mb is not None:
|
|
2285
|
-
fields = {"release": album}
|
|
2286
|
-
if album_artist:
|
|
2287
|
-
fields["artist"] = album_artist
|
|
2288
|
-
try:
|
|
2289
|
-
hits = _mb.search_releases(limit=8, **fields).get("release-list", [])
|
|
2290
|
-
except _mb.WebServiceError as e:
|
|
2291
|
-
print(f"MusicBrainz search failed: {e}")
|
|
2292
|
-
hits = []
|
|
2293
|
-
for hit in hits:
|
|
2294
|
-
release_id = hit.get("id")
|
|
2295
|
-
if not release_id:
|
|
2296
|
-
continue
|
|
2297
|
-
try:
|
|
2298
|
-
details = musicbrainz_release_details(release_id)
|
|
2299
|
-
except Exception:
|
|
2300
|
-
continue
|
|
2301
|
-
metadata = _release_meta(details, track_count)
|
|
2302
|
-
if metadata:
|
|
2303
|
-
metadata["source"] = "musicbrainz-search"
|
|
2304
|
-
return metadata
|
|
2305
|
-
return None
|
|
2306
|
-
|
|
2307
|
-
# --- fallback: raw ws/2 search ---
|
|
2308
|
-
query_parts = [f'release:"{album}"']
|
|
2309
|
-
if album_artist:
|
|
2310
|
-
query_parts.append(f'artist:"{album_artist}"')
|
|
2311
|
-
response = requests.get(
|
|
2312
|
-
"https://musicbrainz.org/ws/2/release/",
|
|
2313
|
-
params={"query": " AND ".join(query_parts), "fmt": "json", "limit": 8},
|
|
2314
|
-
headers={"User-Agent": USER_AGENT}, timeout=20,
|
|
2315
|
-
)
|
|
2316
|
-
response.raise_for_status()
|
|
2317
|
-
for release in response.json().get("releases", []):
|
|
2318
|
-
release_id = release.get("id")
|
|
2319
|
-
if not release_id:
|
|
2320
|
-
continue
|
|
2321
|
-
try:
|
|
2322
|
-
details = musicbrainz_release_details(release_id)
|
|
2323
|
-
except Exception:
|
|
2324
|
-
continue
|
|
2325
|
-
metadata = _release_meta(details, track_count)
|
|
2326
|
-
if metadata:
|
|
2327
|
-
metadata["source"] = "musicbrainz-search"
|
|
2328
|
-
return metadata
|
|
2329
|
-
time.sleep(1)
|
|
2330
|
-
return None
|
|
2331
|
-
|
|
2332
2244
|
def cddb_sum(value):
|
|
2333
2245
|
return sum(int(ch) for ch in str(value))
|
|
2334
2246
|
|
|
@@ -2461,7 +2373,7 @@ def musicbrainz_release_id_search(album_artist, album):
|
|
|
2461
2373
|
return releases[0].get("id") if releases else None
|
|
2462
2374
|
|
|
2463
2375
|
|
|
2464
|
-
def audio_metadata_lookup(device, track_count
|
|
2376
|
+
def audio_metadata_lookup(device, track_count):
|
|
2465
2377
|
metadata = None
|
|
2466
2378
|
|
|
2467
2379
|
for attempt in range(2):
|
|
@@ -2474,17 +2386,6 @@ def audio_metadata_lookup(device, track_count, artist_hint=None, album_hint=None
|
|
|
2474
2386
|
print(f"MusicBrainz lookup failed, retrying... ({e})")
|
|
2475
2387
|
time.sleep(1)
|
|
2476
2388
|
|
|
2477
|
-
if not metadata and album_hint:
|
|
2478
|
-
for attempt in range(2):
|
|
2479
|
-
try:
|
|
2480
|
-
metadata = musicbrainz_lookup_by_album_hints(artist_hint, album_hint, track_count)
|
|
2481
|
-
if metadata:
|
|
2482
|
-
break
|
|
2483
|
-
except Exception as e:
|
|
2484
|
-
if attempt == 0:
|
|
2485
|
-
print(f"MusicBrainz album search failed, retrying... ({e})")
|
|
2486
|
-
time.sleep(1)
|
|
2487
|
-
|
|
2488
2389
|
if metadata and not metadata.get("release_id"):
|
|
2489
2390
|
try:
|
|
2490
2391
|
metadata["release_id"] = musicbrainz_release_id_search(
|
|
@@ -2599,60 +2500,6 @@ def tag_flac(path, track_meta, album_meta, cover_path):
|
|
|
2599
2500
|
audio.save()
|
|
2600
2501
|
|
|
2601
2502
|
|
|
2602
|
-
def retag_audio_rip(rip_dir, artist_hint, album_hint):
|
|
2603
|
-
rip_dir = Path(rip_dir)
|
|
2604
|
-
flacs = sorted(rip_dir.glob("*.flac"))
|
|
2605
|
-
if not flacs:
|
|
2606
|
-
raise RuntimeError(f"No FLAC files found in {rip_dir}")
|
|
2607
|
-
if not album_hint:
|
|
2608
|
-
raise RuntimeError("Retag needs --album")
|
|
2609
|
-
|
|
2610
|
-
metadata = musicbrainz_lookup_by_album_hints(artist_hint, album_hint, len(flacs))
|
|
2611
|
-
if not metadata:
|
|
2612
|
-
raise RuntimeError("Could not find album metadata")
|
|
2613
|
-
|
|
2614
|
-
target_dir = unique_dir(RIP_ROOT / safe_path_name(f"{metadata['album_artist']} - {metadata['album']}"))
|
|
2615
|
-
if rip_dir != target_dir:
|
|
2616
|
-
rip_dir.rename(target_dir)
|
|
2617
|
-
else:
|
|
2618
|
-
target_dir = rip_dir
|
|
2619
|
-
|
|
2620
|
-
write_album_info(target_dir, metadata)
|
|
2621
|
-
cover_path = download_cover_art(
|
|
2622
|
-
metadata.get("release_id"),
|
|
2623
|
-
target_dir,
|
|
2624
|
-
metadata.get("release_group_id"),
|
|
2625
|
-
)
|
|
2626
|
-
|
|
2627
|
-
renamed = []
|
|
2628
|
-
for index, src in enumerate(sorted(target_dir.glob("*.flac")), start=1):
|
|
2629
|
-
if index > len(metadata["tracks"]):
|
|
2630
|
-
break
|
|
2631
|
-
|
|
2632
|
-
track_meta = metadata["tracks"][index - 1]
|
|
2633
|
-
out_file = target_dir / f"{index:02d} - {safe_path_name(track_meta['title'])}.flac"
|
|
2634
|
-
if src != out_file:
|
|
2635
|
-
if out_file.exists():
|
|
2636
|
-
out_file.unlink()
|
|
2637
|
-
src.rename(out_file)
|
|
2638
|
-
|
|
2639
|
-
tag_flac(out_file, track_meta, metadata, cover_path)
|
|
2640
|
-
renamed.append(out_file)
|
|
2641
|
-
|
|
2642
|
-
chown_to_sudo_user(target_dir)
|
|
2643
|
-
return target_dir, cover_path, renamed
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
def latest_audio_rip_dir():
|
|
2647
|
-
candidates = sorted(
|
|
2648
|
-
path for path in RIP_ROOT.glob("audio_cd_*")
|
|
2649
|
-
if path.is_dir() and list(path.glob("*.flac"))
|
|
2650
|
-
)
|
|
2651
|
-
if not candidates:
|
|
2652
|
-
raise RuntimeError("No audio_cd_* rip folders found")
|
|
2653
|
-
return candidates[-1]
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
2503
|
def parse_ffmpeg_time(line):
|
|
2657
2504
|
marker = "time="
|
|
2658
2505
|
if marker not in line:
|
|
@@ -2668,41 +2515,22 @@ def parse_ffmpeg_time(line):
|
|
|
2668
2515
|
from discstation_burn import CancelError
|
|
2669
2516
|
|
|
2670
2517
|
|
|
2671
|
-
def _check_cancel(ser):
|
|
2672
|
-
try:
|
|
2673
|
-
line = read_serial_line(ser, timeout=0)
|
|
2674
|
-
return line in ("CANCEL", "PLAY_STOP") if line else False
|
|
2675
|
-
except OSError:
|
|
2676
|
-
return False
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
2518
|
def _raise_if_cancelled(ser):
|
|
2680
2519
|
"""Poll for a CANCEL press between blocking phases that have no
|
|
2681
2520
|
subprocess loop of their own (metadata lookups, scans, cover-art
|
|
2682
2521
|
downloads). Doesn't interrupt a call in progress, but catches the press
|
|
2683
2522
|
the moment the phase returns."""
|
|
2684
|
-
if
|
|
2523
|
+
if discstation_burn.check_cancel(ser):
|
|
2685
2524
|
raise CancelError
|
|
2686
2525
|
|
|
2687
2526
|
|
|
2688
2527
|
def iter_process_events(proc, idle_seconds=1.0, ser=None):
|
|
2689
|
-
lines =
|
|
2690
|
-
finished = object()
|
|
2691
|
-
|
|
2692
|
-
def read_output():
|
|
2693
|
-
try:
|
|
2694
|
-
for line in proc.stdout:
|
|
2695
|
-
lines.put(line.rstrip("\r\n"))
|
|
2696
|
-
finally:
|
|
2697
|
-
lines.put(finished)
|
|
2698
|
-
|
|
2699
|
-
reader = threading.Thread(target=read_output, daemon=True)
|
|
2700
|
-
reader.start()
|
|
2528
|
+
lines, finished = discstation_burn.proc_line_queue(proc)
|
|
2701
2529
|
last_ping = time.time()
|
|
2702
2530
|
output_done = False
|
|
2703
2531
|
while proc.poll() is None or not output_done:
|
|
2704
2532
|
if ser is not None:
|
|
2705
|
-
if
|
|
2533
|
+
if discstation_burn.check_cancel(ser):
|
|
2706
2534
|
discstation_burn.stop_process(proc)
|
|
2707
2535
|
raise CancelError
|
|
2708
2536
|
if time.time() - last_ping >= 5:
|
|
@@ -2717,7 +2545,6 @@ def iter_process_events(proc, idle_seconds=1.0, ser=None):
|
|
|
2717
2545
|
output_done = True
|
|
2718
2546
|
else:
|
|
2719
2547
|
yield line
|
|
2720
|
-
reader.join(timeout=1)
|
|
2721
2548
|
|
|
2722
2549
|
|
|
2723
2550
|
def rip_device(device):
|
|
@@ -2751,33 +2578,22 @@ def directory_size_bytes(path):
|
|
|
2751
2578
|
return total
|
|
2752
2579
|
|
|
2753
2580
|
|
|
2754
|
-
def
|
|
2755
|
-
"""
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2581
|
+
def _wait_for_source(ser, missing="URL or file path"):
|
|
2582
|
+
"""Block until the web remote supplies a source (an upload dir or a pasted
|
|
2583
|
+
URL/path). Returns None - after telling the remote why - if the user
|
|
2584
|
+
cancelled or sent nothing."""
|
|
2585
|
+
url = wait_for_web_url(ser)
|
|
2586
|
+
if url is None:
|
|
2587
|
+
safe_send(ser, "CANCELLED:Cancelled")
|
|
2588
|
+
elif not url:
|
|
2589
|
+
safe_send(ser, f"ERROR:Need {missing}")
|
|
2590
|
+
return url or None
|
|
2761
2591
|
|
|
2762
2592
|
|
|
2763
|
-
def burn_flow(ser
|
|
2593
|
+
def burn_flow(ser):
|
|
2594
|
+
url = _wait_for_source(ser)
|
|
2764
2595
|
if not url:
|
|
2765
|
-
|
|
2766
|
-
safe_send(ser, "STATUS:Enter URL or file path in terminal")
|
|
2767
|
-
print("=== Enter URL or file path below, then press Enter ===")
|
|
2768
|
-
try:
|
|
2769
|
-
url = sys.stdin.readline().strip()
|
|
2770
|
-
except (EOFError, KeyboardInterrupt, OSError):
|
|
2771
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
2772
|
-
return
|
|
2773
|
-
else:
|
|
2774
|
-
url = wait_for_web_url(ser)
|
|
2775
|
-
if url is None:
|
|
2776
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
2777
|
-
return
|
|
2778
|
-
if not url:
|
|
2779
|
-
safe_send(ser, "ERROR:Need URL or file path")
|
|
2780
|
-
return
|
|
2596
|
+
return
|
|
2781
2597
|
|
|
2782
2598
|
device = discstation_burn.disc_device()
|
|
2783
2599
|
disc_bytes = discstation_burn.disc_capacity_bytes(device)
|
|
@@ -2837,31 +2653,13 @@ def burn_flow(ser, url):
|
|
|
2837
2653
|
except RuntimeError:
|
|
2838
2654
|
pass
|
|
2839
2655
|
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
line = wait_for_button(ser)
|
|
2845
|
-
if line == "CANCEL" or line == "PLAY_STOP":
|
|
2846
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
2847
|
-
print("Burn cancelled by user")
|
|
2848
|
-
return
|
|
2849
|
-
if line.startswith("MODE:"):
|
|
2850
|
-
selected_mode = discstation_burn.normalize_mode(line.split(":", 1)[1])
|
|
2851
|
-
print(f"Burn mode: {selected_mode}")
|
|
2852
|
-
elif line.startswith("SPEED:"):
|
|
2853
|
-
burn_speed = line.split(":", 1)[1].strip()
|
|
2854
|
-
print(f"Burn speed: {burn_speed}")
|
|
2855
|
-
elif line == "START" or line.startswith("START:"):
|
|
2856
|
-
if ":" in line:
|
|
2857
|
-
selected_mode = discstation_burn.normalize_mode(line.split(":", 1)[1])
|
|
2858
|
-
print(f"Starting burn flow in {selected_mode} mode")
|
|
2859
|
-
send(ser, f"STATUS:Starting {selected_mode}...")
|
|
2860
|
-
break
|
|
2656
|
+
started = _wait_for_start(ser, mode="AUTO")
|
|
2657
|
+
if not started:
|
|
2658
|
+
return
|
|
2659
|
+
selected_mode, burn_speed = started
|
|
2861
2660
|
|
|
2862
|
-
start_time = time.time()
|
|
2863
2661
|
disc_type_label = "DL" if dl_info["is_dual_layer"] else "SL"
|
|
2864
|
-
|
|
2662
|
+
with _burn_history({"title": title, "disc_type": disc_type_label, "mode": selected_mode, "speed": burn_speed or "Auto"}):
|
|
2865
2663
|
plan = discstation_burn.bitrate_plan(duration, selected_mode, disc_bytes)
|
|
2866
2664
|
video = discstation_burn.download(ser, url, job_dir)
|
|
2867
2665
|
mpg, dvd_aspect = discstation_burn.convert(ser, video, job_dir, selected_mode, disc_bytes)
|
|
@@ -2885,121 +2683,9 @@ def burn_flow(ser, url):
|
|
|
2885
2683
|
safe_send(ser, "DONE:Test complete!")
|
|
2886
2684
|
print(f"Test complete. DVD folder: {dvd_dir}")
|
|
2887
2685
|
|
|
2888
|
-
append_burn_history({
|
|
2889
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
2890
|
-
"title": title,
|
|
2891
|
-
"disc_type": disc_type_label,
|
|
2892
|
-
"mode": selected_mode,
|
|
2893
|
-
"speed": burn_speed or "Auto",
|
|
2894
|
-
"success": True,
|
|
2895
|
-
"duration_s": round(time.time() - start_time),
|
|
2896
|
-
})
|
|
2897
|
-
except (KeyboardInterrupt, SystemExit):
|
|
2898
|
-
append_burn_history({
|
|
2899
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
2900
|
-
"title": title,
|
|
2901
|
-
"disc_type": disc_type_label,
|
|
2902
|
-
"mode": selected_mode,
|
|
2903
|
-
"speed": burn_speed or "Auto",
|
|
2904
|
-
"success": False,
|
|
2905
|
-
"error": "Cancelled",
|
|
2906
|
-
"duration_s": round(time.time() - start_time),
|
|
2907
|
-
})
|
|
2908
|
-
raise
|
|
2909
|
-
except Exception as e:
|
|
2910
|
-
append_burn_history({
|
|
2911
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
2912
|
-
"title": title,
|
|
2913
|
-
"disc_type": disc_type_label,
|
|
2914
|
-
"mode": selected_mode,
|
|
2915
|
-
"speed": burn_speed or "Auto",
|
|
2916
|
-
"success": False,
|
|
2917
|
-
"error": str(e)[:100],
|
|
2918
|
-
"duration_s": round(time.time() - start_time),
|
|
2919
|
-
})
|
|
2920
|
-
raise
|
|
2921
|
-
|
|
2922
2686
|
time.sleep(3)
|
|
2923
2687
|
|
|
2924
2688
|
|
|
2925
|
-
def _mpg_label(job_dir):
|
|
2926
|
-
label = "DVD_VIDEO"
|
|
2927
|
-
dl = job_dir / "download"
|
|
2928
|
-
if dl.is_dir():
|
|
2929
|
-
for f in sorted(dl.iterdir()):
|
|
2930
|
-
if f.suffix.lower() in discstation_burn.VIDEO_EXTS:
|
|
2931
|
-
label = discstation_burn.sanitize_disc_label(f.stem)
|
|
2932
|
-
break
|
|
2933
|
-
else:
|
|
2934
|
-
for f in sorted(dl.iterdir()):
|
|
2935
|
-
label = discstation_burn.sanitize_disc_label(f.stem)
|
|
2936
|
-
break
|
|
2937
|
-
if label == "DVD_VIDEO" or not label:
|
|
2938
|
-
label = discstation_burn.sanitize_disc_label(job_dir.name)
|
|
2939
|
-
return label
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
def burn_mpg_flow(ser):
|
|
2943
|
-
jobs = sorted(discstation_burn.WORK.glob("job_*"), reverse=True)
|
|
2944
|
-
candidates = []
|
|
2945
|
-
for jd in jobs:
|
|
2946
|
-
mpg = jd / "movie.mpg"
|
|
2947
|
-
if mpg.exists():
|
|
2948
|
-
label = _mpg_label(jd)
|
|
2949
|
-
candidates.append((mpg, label))
|
|
2950
|
-
names = [c[1][:20] for c in candidates] + ["Enter path..."]
|
|
2951
|
-
safe_send(ser, f"MENU_ITEMS:{','.join(names)}")
|
|
2952
|
-
safe_send(ser, "HOME:Select MPG to burn")
|
|
2953
|
-
mpg = None
|
|
2954
|
-
disc_label = None
|
|
2955
|
-
while True:
|
|
2956
|
-
line = read_serial_line(ser, timeout=0.5)
|
|
2957
|
-
if not line:
|
|
2958
|
-
continue
|
|
2959
|
-
if line.startswith("SELECT:"):
|
|
2960
|
-
sel = line.split(":", 1)[1].strip()
|
|
2961
|
-
if sel == "Enter path...":
|
|
2962
|
-
path_str = wait_for_web_url(ser)
|
|
2963
|
-
if path_str is None:
|
|
2964
|
-
refresh_main_menu(ser)
|
|
2965
|
-
return
|
|
2966
|
-
path_str = path_str.strip()
|
|
2967
|
-
p = Path(path_str)
|
|
2968
|
-
if not p.exists():
|
|
2969
|
-
safe_send(ser, "ERROR:Path not found")
|
|
2970
|
-
time.sleep(2)
|
|
2971
|
-
continue
|
|
2972
|
-
if p.is_dir():
|
|
2973
|
-
mpg = p / "movie.mpg"
|
|
2974
|
-
if not mpg.exists():
|
|
2975
|
-
safe_send(ser, "ERROR:No movie.mpg in dir")
|
|
2976
|
-
time.sleep(2)
|
|
2977
|
-
continue
|
|
2978
|
-
elif p.suffix.lower() == ".mpg":
|
|
2979
|
-
mpg = p
|
|
2980
|
-
else:
|
|
2981
|
-
safe_send(ser, "ERROR:Not an .mpg file")
|
|
2982
|
-
time.sleep(2)
|
|
2983
|
-
continue
|
|
2984
|
-
disc_label = discstation_burn.sanitize_disc_label(mpg.stem)
|
|
2985
|
-
break
|
|
2986
|
-
else:
|
|
2987
|
-
idx = next((i for i, n in enumerate(candidates) if n[1][:20] == sel), None)
|
|
2988
|
-
if idx is not None:
|
|
2989
|
-
mpg, disc_label = candidates[idx]
|
|
2990
|
-
break
|
|
2991
|
-
elif line == "CANCEL":
|
|
2992
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
2993
|
-
refresh_main_menu(ser)
|
|
2994
|
-
return
|
|
2995
|
-
time.sleep(0.05)
|
|
2996
|
-
|
|
2997
|
-
device = discstation_burn.disc_device()
|
|
2998
|
-
dl_info = discstation_burn.detect_disc_type(device)
|
|
2999
|
-
disc_bytes = dl_info["capacity"]
|
|
3000
|
-
discstation_burn.remux_and_burn(ser, mpg, disc_label, disc_bytes, dl_info)
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
2689
|
def _copy_to_job(ser, src, dst_dir):
|
|
3004
2690
|
if src.is_dir():
|
|
3005
2691
|
items = sorted(src.iterdir())
|
|
@@ -3015,25 +2701,9 @@ def _copy_to_job(ser, src, dst_dir):
|
|
|
3015
2701
|
def burn_data_flow(ser):
|
|
3016
2702
|
global _last_upload_label
|
|
3017
2703
|
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
try:
|
|
3022
|
-
url = sys.stdin.readline().strip()
|
|
3023
|
-
except (EOFError, KeyboardInterrupt, OSError):
|
|
3024
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
3025
|
-
return
|
|
3026
|
-
if not url:
|
|
3027
|
-
safe_send(ser, "ERROR:Need URL or file path")
|
|
3028
|
-
return
|
|
3029
|
-
else:
|
|
3030
|
-
url = wait_for_web_url(ser)
|
|
3031
|
-
if url is None:
|
|
3032
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
3033
|
-
return
|
|
3034
|
-
if not url:
|
|
3035
|
-
safe_send(ser, "ERROR:Need URL or file path")
|
|
3036
|
-
return
|
|
2704
|
+
url = _wait_for_source(ser)
|
|
2705
|
+
if not url:
|
|
2706
|
+
return
|
|
3037
2707
|
|
|
3038
2708
|
device = discstation_burn.disc_device()
|
|
3039
2709
|
dl_info = discstation_burn.detect_disc_type(device)
|
|
@@ -3062,21 +2732,10 @@ def burn_data_flow(ser):
|
|
|
3062
2732
|
|
|
3063
2733
|
send(ser, f"TITLE:{title}")
|
|
3064
2734
|
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
if line == "CANCEL" or line == "PLAY_STOP":
|
|
3070
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
3071
|
-
print("Burn cancelled by user")
|
|
3072
|
-
return
|
|
3073
|
-
if line.startswith("SPEED:"):
|
|
3074
|
-
burn_speed = line.split(":", 1)[1].strip()
|
|
3075
|
-
print(f"Burn speed: {burn_speed}")
|
|
3076
|
-
elif line == "START" or line.startswith("START:"):
|
|
3077
|
-
print("Starting data burn...")
|
|
3078
|
-
send(ser, "STATUS:Starting data burn...")
|
|
3079
|
-
break
|
|
2735
|
+
started = _wait_for_start(ser, "data burn")
|
|
2736
|
+
if not started:
|
|
2737
|
+
return
|
|
2738
|
+
burn_speed = started[1]
|
|
3080
2739
|
|
|
3081
2740
|
if not can_burn_disc(device):
|
|
3082
2741
|
raise RuntimeError("No writable disc in drive")
|
|
@@ -3087,8 +2746,7 @@ def burn_data_flow(ser):
|
|
|
3087
2746
|
download_dir = job_dir / "download"
|
|
3088
2747
|
download_dir.mkdir()
|
|
3089
2748
|
|
|
3090
|
-
|
|
3091
|
-
try:
|
|
2749
|
+
with _burn_history({"title": title, "disc_type": "Data DVD", "mode": "DATA", "speed": burn_speed or "Auto"}, swallow_cancel=True) as h:
|
|
3092
2750
|
is_dir = local_path.is_dir() if local_path.exists() else False
|
|
3093
2751
|
if is_dir:
|
|
3094
2752
|
files_to_burn = [local_path]
|
|
@@ -3130,75 +2788,15 @@ def burn_data_flow(ser):
|
|
|
3130
2788
|
safe_send(ser, "DONE:Data disc complete!")
|
|
3131
2789
|
print("Data burn complete.")
|
|
3132
2790
|
|
|
3133
|
-
|
|
3134
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
3135
|
-
"title": title,
|
|
3136
|
-
"disc_type": "Data DVD",
|
|
3137
|
-
"mode": "DATA",
|
|
3138
|
-
"speed": burn_speed or "Auto",
|
|
3139
|
-
"success": True,
|
|
3140
|
-
"duration_s": round(time.time() - start_time),
|
|
3141
|
-
})
|
|
3142
|
-
except (KeyboardInterrupt, SystemExit):
|
|
3143
|
-
append_burn_history({
|
|
3144
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
3145
|
-
"title": title,
|
|
3146
|
-
"disc_type": "Data DVD",
|
|
3147
|
-
"mode": "DATA",
|
|
3148
|
-
"speed": burn_speed or "Auto",
|
|
3149
|
-
"success": False,
|
|
3150
|
-
"error": "Cancelled",
|
|
3151
|
-
"duration_s": round(time.time() - start_time),
|
|
3152
|
-
})
|
|
3153
|
-
raise
|
|
3154
|
-
except CancelError:
|
|
3155
|
-
append_burn_history({
|
|
3156
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
3157
|
-
"title": title,
|
|
3158
|
-
"disc_type": "Data DVD",
|
|
3159
|
-
"mode": "DATA",
|
|
3160
|
-
"speed": burn_speed or "Auto",
|
|
3161
|
-
"success": False,
|
|
3162
|
-
"error": "Cancelled",
|
|
3163
|
-
"duration_s": round(time.time() - start_time),
|
|
3164
|
-
})
|
|
2791
|
+
if h.cancelled:
|
|
3165
2792
|
return
|
|
3166
|
-
except Exception as e:
|
|
3167
|
-
append_burn_history({
|
|
3168
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
3169
|
-
"title": title,
|
|
3170
|
-
"disc_type": "Data DVD",
|
|
3171
|
-
"mode": "DATA",
|
|
3172
|
-
"speed": burn_speed or "Auto",
|
|
3173
|
-
"success": False,
|
|
3174
|
-
"error": str(e)[:100],
|
|
3175
|
-
"duration_s": round(time.time() - start_time),
|
|
3176
|
-
})
|
|
3177
|
-
raise
|
|
3178
|
-
|
|
3179
2793
|
time.sleep(3)
|
|
3180
2794
|
|
|
3181
2795
|
|
|
3182
2796
|
def burn_audio_flow(ser):
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
try:
|
|
3187
|
-
url = sys.stdin.readline().strip()
|
|
3188
|
-
except (EOFError, KeyboardInterrupt, OSError):
|
|
3189
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
3190
|
-
return
|
|
3191
|
-
if not url:
|
|
3192
|
-
safe_send(ser, "ERROR:Need path to audio files")
|
|
3193
|
-
return
|
|
3194
|
-
else:
|
|
3195
|
-
url = wait_for_web_url(ser)
|
|
3196
|
-
if url is None:
|
|
3197
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
3198
|
-
return
|
|
3199
|
-
if not url:
|
|
3200
|
-
safe_send(ser, "ERROR:Need path to audio files")
|
|
3201
|
-
return
|
|
2797
|
+
url = _wait_for_source(ser, "path to audio files")
|
|
2798
|
+
if not url:
|
|
2799
|
+
return
|
|
3202
2800
|
|
|
3203
2801
|
src_path = Path(url)
|
|
3204
2802
|
if not src_path.exists():
|
|
@@ -3261,18 +2859,10 @@ def burn_audio_flow(ser):
|
|
|
3261
2859
|
send(ser, f"META:Dur {mins}m{secs}s")
|
|
3262
2860
|
send(ser, f"FIT:CD-R {fits}")
|
|
3263
2861
|
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
if line == "CANCEL" or line == "PLAY_STOP":
|
|
3269
|
-
safe_send(ser, "CANCELLED:Cancelled")
|
|
3270
|
-
return
|
|
3271
|
-
if line.startswith("SPEED:"):
|
|
3272
|
-
burn_speed = line.split(":", 1)[1].strip()
|
|
3273
|
-
elif line == "START" or line.startswith("START:"):
|
|
3274
|
-
send(ser, "STATUS:Starting audio burn...")
|
|
3275
|
-
break
|
|
2862
|
+
started = _wait_for_start(ser, "audio burn")
|
|
2863
|
+
if not started:
|
|
2864
|
+
return
|
|
2865
|
+
burn_speed = started[1]
|
|
3276
2866
|
|
|
3277
2867
|
device = discstation_burn.disc_device()
|
|
3278
2868
|
if not can_burn_disc(device):
|
|
@@ -3280,95 +2870,23 @@ def burn_audio_flow(ser):
|
|
|
3280
2870
|
if total_dur > 4740:
|
|
3281
2871
|
raise RuntimeError(f"Too long for CD-R: {int(total_dur/60)}m{int(total_dur%60)}s > 79m")
|
|
3282
2872
|
|
|
3283
|
-
|
|
3284
|
-
|
|
2873
|
+
entry = {"title": disc_label, "fingerprint": fingerprint, "track_titles": track_titles,
|
|
2874
|
+
"disc_type": "Audio CD", "mode": "AUDIO", "speed": burn_speed or "Auto"}
|
|
2875
|
+
with _burn_history(entry, swallow_cancel=True) as h:
|
|
3285
2876
|
discstation_burn.burn_audio_cd(ser, audio_files, disc_label, burn_speed)
|
|
3286
2877
|
safe_send(ser, "DONE:Audio CD complete!")
|
|
3287
|
-
|
|
3288
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
3289
|
-
"title": disc_label,
|
|
3290
|
-
"fingerprint": fingerprint,
|
|
3291
|
-
"track_titles": track_titles,
|
|
3292
|
-
"disc_type": "Audio CD",
|
|
3293
|
-
"mode": "AUDIO",
|
|
3294
|
-
"speed": burn_speed or "Auto",
|
|
3295
|
-
"success": True,
|
|
3296
|
-
"duration_s": round(time.time() - start_time),
|
|
3297
|
-
})
|
|
3298
|
-
except (KeyboardInterrupt, SystemExit):
|
|
3299
|
-
raise
|
|
3300
|
-
except CancelError:
|
|
3301
|
-
append_burn_history({
|
|
3302
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
3303
|
-
"title": disc_label,
|
|
3304
|
-
"fingerprint": fingerprint,
|
|
3305
|
-
"track_titles": track_titles,
|
|
3306
|
-
"disc_type": "Audio CD",
|
|
3307
|
-
"mode": "AUDIO",
|
|
3308
|
-
"speed": burn_speed or "Auto",
|
|
3309
|
-
"success": False,
|
|
3310
|
-
"error": "Cancelled",
|
|
3311
|
-
"duration_s": round(time.time() - start_time),
|
|
3312
|
-
})
|
|
2878
|
+
if h.cancelled:
|
|
3313
2879
|
return
|
|
3314
|
-
except Exception as e:
|
|
3315
|
-
append_burn_history({
|
|
3316
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
|
3317
|
-
"title": disc_label,
|
|
3318
|
-
"fingerprint": fingerprint,
|
|
3319
|
-
"track_titles": track_titles,
|
|
3320
|
-
"disc_type": "Audio CD",
|
|
3321
|
-
"mode": "AUDIO",
|
|
3322
|
-
"speed": burn_speed or "Auto",
|
|
3323
|
-
"success": False,
|
|
3324
|
-
"error": str(e)[:100],
|
|
3325
|
-
"duration_s": round(time.time() - start_time),
|
|
3326
|
-
})
|
|
3327
|
-
raise
|
|
3328
|
-
|
|
3329
2880
|
time.sleep(3)
|
|
3330
2881
|
|
|
3331
2882
|
|
|
3332
|
-
def _iter_proc_lines(proc, ser):
|
|
3333
|
-
lines = Queue()
|
|
3334
|
-
finished = object()
|
|
3335
|
-
|
|
3336
|
-
def read_output():
|
|
3337
|
-
try:
|
|
3338
|
-
for line in proc.stdout:
|
|
3339
|
-
lines.put(line.rstrip("\r\n"))
|
|
3340
|
-
finally:
|
|
3341
|
-
lines.put(finished)
|
|
3342
|
-
|
|
3343
|
-
reader = threading.Thread(target=read_output, daemon=True)
|
|
3344
|
-
reader.start()
|
|
3345
|
-
last_ping = time.time()
|
|
3346
|
-
output_done = False
|
|
3347
|
-
while proc.poll() is None or not output_done:
|
|
3348
|
-
if time.time() - last_ping >= 5:
|
|
3349
|
-
discstation_burn.send(ser, "PING")
|
|
3350
|
-
last_ping = time.time()
|
|
3351
|
-
if _check_cancel(ser):
|
|
3352
|
-
discstation_burn.stop_process(proc)
|
|
3353
|
-
return
|
|
3354
|
-
try:
|
|
3355
|
-
line = lines.get(timeout=0.5)
|
|
3356
|
-
except Empty:
|
|
3357
|
-
continue
|
|
3358
|
-
if line is finished:
|
|
3359
|
-
output_done = True
|
|
3360
|
-
else:
|
|
3361
|
-
yield line
|
|
3362
|
-
reader.join(timeout=1)
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
2883
|
# --- OLED spectrum visualizer -----------------------------------------------
|
|
3366
2884
|
# Taps the real audio the host is playing - PulseAudio's monitor source on
|
|
3367
|
-
# Linux
|
|
3368
|
-
# for the one-time setup) - not a simulation, and streams it to the remote as
|
|
2885
|
+
# Linux - not a simulation, and streams it to the remote as
|
|
3369
2886
|
# "VU:<16 comma-separated 0-63 levels>" at ~15fps. Needs numpy; anywhere else
|
|
3370
|
-
# (
|
|
3371
|
-
#
|
|
2887
|
+
# (macOS can't capture system audio without a signed helper - see
|
|
2888
|
+
# docs/PLATFORM_SUPPORT.md) this quietly no-ops and the remote just shows its
|
|
2889
|
+
# normal PLAY text screen.
|
|
3372
2890
|
VU_BARS = 16 # must match the firmware's VU_BARS
|
|
3373
2891
|
VU_RATE_HZ = 15
|
|
3374
2892
|
VU_SAMPLE_RATE = 22050
|
|
@@ -3386,55 +2904,19 @@ def _pulse_default_monitor():
|
|
|
3386
2904
|
return f"{sink}.monitor" if sink else None
|
|
3387
2905
|
|
|
3388
2906
|
|
|
3389
|
-
def _darwin_blackhole_input():
|
|
3390
|
-
"""Index of the 'BlackHole' avfoundation audio device, or None if it's not
|
|
3391
|
-
installed. macOS has no built-in loopback source - this requires the user
|
|
3392
|
-
to `brew install blackhole-2ch` and set a Multi-Output Device (BlackHole +
|
|
3393
|
-
real speakers) as the system's default output, so audio is both audible
|
|
3394
|
-
and tapped (see docs/PLATFORM_SUPPORT.md)."""
|
|
3395
|
-
try:
|
|
3396
|
-
# Device list is on stderr; ffmpeg exits non-zero here, that's normal.
|
|
3397
|
-
out = subprocess.run(["ffmpeg", "-f", "avfoundation", "-list_devices", "true", "-i", ""],
|
|
3398
|
-
capture_output=True, text=True, timeout=5).stderr
|
|
3399
|
-
except Exception:
|
|
3400
|
-
return None
|
|
3401
|
-
in_audio = False
|
|
3402
|
-
for line in out.splitlines():
|
|
3403
|
-
if "AVFoundation audio devices" in line:
|
|
3404
|
-
in_audio = True
|
|
3405
|
-
continue
|
|
3406
|
-
if in_audio:
|
|
3407
|
-
m = re.search(r"\[(\d+)\]\s+(.*)", line)
|
|
3408
|
-
if m and "blackhole" in m.group(2).lower():
|
|
3409
|
-
return m.group(1)
|
|
3410
|
-
return None
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
2907
|
def _vu_capture_cmd():
|
|
3414
|
-
"""
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
# steady trickle, which starves the visualizer for longer than the
|
|
3427
|
-
# firmware's fallback timeout and flickers back to the text screen.
|
|
3428
|
-
return ["parec", "--format=s16le", f"--rate={VU_SAMPLE_RATE}", "--channels=1",
|
|
3429
|
-
"--latency-msec=50", "-d", monitor]
|
|
3430
|
-
if system == "darwin":
|
|
3431
|
-
idx = _darwin_blackhole_input()
|
|
3432
|
-
if idx is None:
|
|
3433
|
-
return None
|
|
3434
|
-
return ["ffmpeg", "-f", "avfoundation", "-i", f":{idx}",
|
|
3435
|
-
"-ac", "1", "-ar", str(VU_SAMPLE_RATE), "-f", "s16le",
|
|
3436
|
-
"-loglevel", "error", "-"]
|
|
3437
|
-
return None
|
|
2908
|
+
"""parec argv streaming raw s16le mono PCM at VU_SAMPLE_RATE from the
|
|
2909
|
+
default sink's monitor, or None if PulseAudio has no default sink."""
|
|
2910
|
+
monitor = _pulse_default_monitor()
|
|
2911
|
+
if not monitor:
|
|
2912
|
+
return None
|
|
2913
|
+
# --latency-msec=50: PulseAudio's default capture buffer is several
|
|
2914
|
+
# hundred ms to seconds (tuned for robust recording, not streaming) -
|
|
2915
|
+
# without this, parec hands us data in ~1.5-2s bursts instead of a
|
|
2916
|
+
# steady trickle, which starves the visualizer for longer than the
|
|
2917
|
+
# firmware's fallback timeout and flickers back to the text screen.
|
|
2918
|
+
return ["parec", "--format=s16le", f"--rate={VU_SAMPLE_RATE}", "--channels=1",
|
|
2919
|
+
"--latency-msec=50", "-d", monitor]
|
|
3438
2920
|
|
|
3439
2921
|
|
|
3440
2922
|
def _vu_loop(ser, stop_event, pause_event):
|
|
@@ -3494,11 +2976,9 @@ def _vu_loop(ser, stop_event, pause_event):
|
|
|
3494
2976
|
|
|
3495
2977
|
def start_vu_visualizer(ser):
|
|
3496
2978
|
"""Best-effort: returns (stop_event, pause_event), or (None, None) if the
|
|
3497
|
-
visualizer can't run here (no numpy, no
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
else, or if the OS-specific capture device isn't set up."""
|
|
3501
|
-
if _np is None or isinstance(ser, VirtualSerial) or discstation_host.system_name() not in ("linux", "darwin"):
|
|
2979
|
+
visualizer can't run here (no numpy, no PulseAudio, or a web-only link).
|
|
2980
|
+
Linux only."""
|
|
2981
|
+
if _np is None or isinstance(ser, VirtualSerial) or discstation_host.system_name() != "linux":
|
|
3502
2982
|
return None, None
|
|
3503
2983
|
stop_event = threading.Event()
|
|
3504
2984
|
pause_event = threading.Event()
|
|
@@ -3551,7 +3031,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None, s
|
|
|
3551
3031
|
# floods journald until the pipe backs up and our own print()/status
|
|
3552
3032
|
# writes block, wedging the whole play loop. We drive mpv over the IPC
|
|
3553
3033
|
# socket, so none of that output is wanted.
|
|
3554
|
-
proc = subprocess.Popen(
|
|
3034
|
+
proc = subprocess.Popen(cmd, env=env,
|
|
3555
3035
|
stdin=(stdin_proc.stdout if stdin_proc else None),
|
|
3556
3036
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
3557
3037
|
if stdin_proc:
|
|
@@ -3721,16 +3201,7 @@ def _play_audio_cd_windows(ser, device, track_titles):
|
|
|
3721
3201
|
cmd, kwargs = discstation_host.ps_cmd("play-audio-cd.ps1", device, str(cmd_file))
|
|
3722
3202
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **kwargs)
|
|
3723
3203
|
|
|
3724
|
-
lines =
|
|
3725
|
-
|
|
3726
|
-
def read_output():
|
|
3727
|
-
try:
|
|
3728
|
-
for line in proc.stdout:
|
|
3729
|
-
lines.put(line.rstrip("\r\n"))
|
|
3730
|
-
finally:
|
|
3731
|
-
lines.put(None)
|
|
3732
|
-
|
|
3733
|
-
threading.Thread(target=read_output, daemon=True).start()
|
|
3204
|
+
lines, eof = discstation_burn.proc_line_queue(proc)
|
|
3734
3205
|
|
|
3735
3206
|
def send_cmd(text):
|
|
3736
3207
|
cmd_file.write_text(text + "\n")
|
|
@@ -3753,6 +3224,8 @@ def _play_audio_cd_windows(ser, device, track_titles):
|
|
|
3753
3224
|
line = lines.get(timeout=0.1)
|
|
3754
3225
|
except Empty:
|
|
3755
3226
|
line = None
|
|
3227
|
+
if line is eof:
|
|
3228
|
+
line = None
|
|
3756
3229
|
if line:
|
|
3757
3230
|
if line.startswith("TRACK:"):
|
|
3758
3231
|
try:
|
|
@@ -4099,12 +3572,12 @@ def handbrake_rip_main_feature(ser, device, out_dir, title_index):
|
|
|
4099
3572
|
return dest
|
|
4100
3573
|
|
|
4101
3574
|
|
|
4102
|
-
def rip_flow(ser
|
|
3575
|
+
def rip_flow(ser):
|
|
4103
3576
|
device = discstation_burn.disc_device()
|
|
4104
3577
|
kind = disc_kind(device)
|
|
4105
3578
|
|
|
4106
3579
|
if kind == "audio_cd":
|
|
4107
|
-
rip_audio_cd(ser, device
|
|
3580
|
+
rip_audio_cd(ser, device)
|
|
4108
3581
|
return
|
|
4109
3582
|
|
|
4110
3583
|
if kind in ("vcd", "svcd", "video_data"):
|
|
@@ -4162,7 +3635,6 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
|
|
|
4162
3635
|
safe_send(ser, "DONE:Rip complete!")
|
|
4163
3636
|
print(f"Rip complete: {out_dir}")
|
|
4164
3637
|
out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
|
|
4165
|
-
chown_to_sudo_user(out_dir)
|
|
4166
3638
|
time.sleep(3)
|
|
4167
3639
|
return
|
|
4168
3640
|
|
|
@@ -4187,7 +3659,6 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
|
|
|
4187
3659
|
safe_send(ser, "DONE:Rip complete!")
|
|
4188
3660
|
print(f"Rip complete: {out_dir}")
|
|
4189
3661
|
out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
|
|
4190
|
-
chown_to_sudo_user(out_dir)
|
|
4191
3662
|
time.sleep(3)
|
|
4192
3663
|
return
|
|
4193
3664
|
|
|
@@ -4237,7 +3708,7 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
|
|
|
4237
3708
|
raise
|
|
4238
3709
|
|
|
4239
3710
|
if proc.wait() != 0:
|
|
4240
|
-
if
|
|
3711
|
+
if drive_status(device) in ("open", "no_disc"):
|
|
4241
3712
|
raise RuntimeError("Disc was removed during rip")
|
|
4242
3713
|
raise RuntimeError("Rip failed")
|
|
4243
3714
|
|
|
@@ -4245,7 +3716,6 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
|
|
|
4245
3716
|
safe_send(ser, "DONE:Rip complete!")
|
|
4246
3717
|
print(f"Rip complete: {out_dir}")
|
|
4247
3718
|
out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
|
|
4248
|
-
chown_to_sudo_user(out_dir)
|
|
4249
3719
|
time.sleep(3)
|
|
4250
3720
|
|
|
4251
3721
|
|
|
@@ -4285,7 +3755,6 @@ def rip_video_disc(ser, device, kind):
|
|
|
4285
3755
|
safe_send(ser, "DONE:Rip complete!")
|
|
4286
3756
|
print(f"Video rip complete: {out_dir}")
|
|
4287
3757
|
out_dir = _finalize_video_rip(ser, out_dir, device, kind)
|
|
4288
|
-
chown_to_sudo_user(out_dir)
|
|
4289
3758
|
time.sleep(3)
|
|
4290
3759
|
|
|
4291
3760
|
|
|
@@ -4316,7 +3785,7 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
|
|
|
4316
3785
|
)
|
|
4317
3786
|
output = []
|
|
4318
3787
|
try:
|
|
4319
|
-
for line in
|
|
3788
|
+
for line in discstation_burn.iter_proc_or_cancel(proc, ser):
|
|
4320
3789
|
output.append(line)
|
|
4321
3790
|
count = len(list(wav_dir.glob("*.wav")))
|
|
4322
3791
|
if count:
|
|
@@ -4360,18 +3829,17 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
|
|
|
4360
3829
|
shutil.rmtree(str(wav_dir), ignore_errors=True)
|
|
4361
3830
|
safe_send(ser, "PROGRESS:100%")
|
|
4362
3831
|
safe_send(ser, "DONE:Rip complete!")
|
|
4363
|
-
chown_to_sudo_user(out_dir)
|
|
4364
3832
|
time.sleep(3)
|
|
4365
3833
|
|
|
4366
3834
|
|
|
4367
|
-
def rip_audio_cd(ser, device
|
|
3835
|
+
def rip_audio_cd(ser, device):
|
|
4368
3836
|
chapters = audio_cd_chapters(device)
|
|
4369
3837
|
metadata = None
|
|
4370
3838
|
cover_path = None
|
|
4371
3839
|
|
|
4372
3840
|
_raise_if_cancelled(ser)
|
|
4373
3841
|
send(ser, "STATUS:Looking up CD...")
|
|
4374
|
-
metadata = audio_metadata_lookup(device, len(chapters)
|
|
3842
|
+
metadata = audio_metadata_lookup(device, len(chapters))
|
|
4375
3843
|
_raise_if_cancelled(ser)
|
|
4376
3844
|
|
|
4377
3845
|
if metadata:
|
|
@@ -4447,7 +3915,7 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
|
|
|
4447
3915
|
)
|
|
4448
3916
|
|
|
4449
3917
|
try:
|
|
4450
|
-
for line in
|
|
3918
|
+
for line in discstation_burn.iter_proc_or_cancel(proc, ser):
|
|
4451
3919
|
print(line, end="")
|
|
4452
3920
|
secs = parse_ffmpeg_time(line)
|
|
4453
3921
|
if secs is not None and rip_duration > 0:
|
|
@@ -4464,7 +3932,7 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
|
|
|
4464
3932
|
safe_send(ser, "CANCELLED:Rip cancelled")
|
|
4465
3933
|
return
|
|
4466
3934
|
elif proc.returncode != 0:
|
|
4467
|
-
if
|
|
3935
|
+
if drive_status(device) in ("open", "no_disc"):
|
|
4468
3936
|
raise RuntimeError("Disc was removed during rip")
|
|
4469
3937
|
raise RuntimeError("Audio CD rip failed")
|
|
4470
3938
|
|
|
@@ -4500,11 +3968,10 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
|
|
|
4500
3968
|
safe_send(ser, "PROGRESS:100%")
|
|
4501
3969
|
safe_send(ser, "DONE:Rip complete!")
|
|
4502
3970
|
print(f"Audio rip complete: {out_dir}")
|
|
4503
|
-
chown_to_sudo_user(out_dir)
|
|
4504
3971
|
time.sleep(3)
|
|
4505
3972
|
|
|
4506
3973
|
|
|
4507
|
-
def station_loop(ser
|
|
3974
|
+
def station_loop(ser):
|
|
4508
3975
|
global _last_burn_result, _last_burn_result_time, _tray_open, _tray_open_since, _operation_active, _web_op_verb
|
|
4509
3976
|
discstation_burn.cleanup_old_jobs()
|
|
4510
3977
|
try:
|
|
@@ -4684,6 +4151,9 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
|
|
|
4684
4151
|
print(f"Menu: {line.split(':', 1)[1]}")
|
|
4685
4152
|
continue
|
|
4686
4153
|
|
|
4154
|
+
if line == "EJECT" and _tray_open:
|
|
4155
|
+
line = "CONFIRM" # EJECT is a toggle: with the tray already out, close it
|
|
4156
|
+
|
|
4687
4157
|
if line == "EJECT":
|
|
4688
4158
|
try:
|
|
4689
4159
|
device = discstation_burn.disc_device()
|
|
@@ -4748,16 +4218,13 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
|
|
|
4748
4218
|
_operation_active = True # stop /disc-info probing the drive during the flow
|
|
4749
4219
|
try:
|
|
4750
4220
|
if mode == "BURN":
|
|
4751
|
-
burn_flow(ser
|
|
4221
|
+
burn_flow(ser)
|
|
4752
4222
|
_last_burn_result = "Burn complete"
|
|
4753
4223
|
elif mode == "PLAY":
|
|
4754
4224
|
play_flow(ser)
|
|
4755
4225
|
elif mode == "RIP":
|
|
4756
|
-
rip_flow(ser
|
|
4226
|
+
rip_flow(ser)
|
|
4757
4227
|
_last_burn_result = "Rip complete"
|
|
4758
|
-
elif mode == "BURN MPG":
|
|
4759
|
-
burn_mpg_flow(ser)
|
|
4760
|
-
_last_burn_result = "Burn complete"
|
|
4761
4228
|
elif mode == "BURN DATA":
|
|
4762
4229
|
burn_data_flow(ser)
|
|
4763
4230
|
_last_burn_result = "Burn complete"
|
|
@@ -4825,15 +4292,7 @@ def check_pidfile():
|
|
|
4825
4292
|
|
|
4826
4293
|
def parse_args():
|
|
4827
4294
|
parser = argparse.ArgumentParser(description="Physical DVD station controller")
|
|
4828
|
-
parser.add_argument("--artist", help="Audio CD album artist hint for metadata fallback")
|
|
4829
|
-
parser.add_argument("--album", help="Audio CD album title hint for metadata fallback")
|
|
4830
|
-
parser.add_argument(
|
|
4831
|
-
"--retag-latest-audio",
|
|
4832
|
-
action="store_true",
|
|
4833
|
-
help="Retag the newest generic audio_cd_* rip using --artist/--album, then exit",
|
|
4834
|
-
)
|
|
4835
4295
|
parser.add_argument("--port", type=int, default=8080, help="Web interface port")
|
|
4836
|
-
parser.add_argument("url", nargs="?", help="YouTube URL or file path for burn mode")
|
|
4837
4296
|
return parser.parse_args()
|
|
4838
4297
|
|
|
4839
4298
|
|
|
@@ -4847,15 +4306,6 @@ def main():
|
|
|
4847
4306
|
exit_code = 0
|
|
4848
4307
|
|
|
4849
4308
|
try:
|
|
4850
|
-
if args.retag_latest_audio:
|
|
4851
|
-
rip_dir = latest_audio_rip_dir()
|
|
4852
|
-
new_dir, cover_path, renamed = retag_audio_rip(rip_dir, args.artist, args.album)
|
|
4853
|
-
print(f"Retagged: {new_dir}")
|
|
4854
|
-
print(f"Cover: {cover_path or 'not found'}")
|
|
4855
|
-
for path in renamed:
|
|
4856
|
-
print(path.name)
|
|
4857
|
-
return
|
|
4858
|
-
|
|
4859
4309
|
start_web_server(args.port)
|
|
4860
4310
|
|
|
4861
4311
|
while True:
|
|
@@ -4901,7 +4351,7 @@ def main():
|
|
|
4901
4351
|
# one now so a connected web remote learns about a newly-attached
|
|
4902
4352
|
# ESP32 immediately instead of only on its next reload.
|
|
4903
4353
|
_sse_publish(_status_snapshot())
|
|
4904
|
-
station_loop(ser
|
|
4354
|
+
station_loop(ser)
|
|
4905
4355
|
except _HardwareAttached:
|
|
4906
4356
|
print("ESP32 detected - handing off from the web remote to hardware.")
|
|
4907
4357
|
except (serial.SerialException, OSError, termios.error) as e:
|