discstation 0.1.35 → 0.1.37
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 +13 -18
- package/discstation.env.example +1 -1
- package/docs/PLATFORM_SUPPORT.md +13 -31
- package/install-macos.sh +3 -6
- package/install.sh +2 -1
- package/package.json +1 -1
- package/requirements-optional.txt +5 -7
- package/scripts/check.sh +61 -0
- package/src/discstation.py +141 -707
- 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:
|
|
@@ -1174,6 +1125,67 @@ def append_burn_history(entry):
|
|
|
1174
1125
|
f.write(json.dumps(entry) + "\n")
|
|
1175
1126
|
|
|
1176
1127
|
|
|
1128
|
+
@contextlib.contextmanager
|
|
1129
|
+
def _burn_history(entry, swallow_cancel=False):
|
|
1130
|
+
"""Record a burn attempt in the history file however the block ends
|
|
1131
|
+
(success / cancelled / error). `entry` holds the fixed fields (title,
|
|
1132
|
+
disc_type, mode, speed, ...). A cancelled burn re-raises unless
|
|
1133
|
+
swallow_cancel, in which case the caller checks `.cancelled` and returns."""
|
|
1134
|
+
start = time.time()
|
|
1135
|
+
outcome = types.SimpleNamespace(cancelled=False)
|
|
1136
|
+
|
|
1137
|
+
def record(ok, error=None):
|
|
1138
|
+
row = {"timestamp": datetime.datetime.now().isoformat(), **entry,
|
|
1139
|
+
"success": ok, "duration_s": round(time.time() - start)}
|
|
1140
|
+
if error:
|
|
1141
|
+
row["error"] = error
|
|
1142
|
+
append_burn_history(row)
|
|
1143
|
+
|
|
1144
|
+
try:
|
|
1145
|
+
yield outcome
|
|
1146
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1147
|
+
record(False, "Cancelled")
|
|
1148
|
+
raise
|
|
1149
|
+
except CancelError:
|
|
1150
|
+
record(False, "Cancelled")
|
|
1151
|
+
if not swallow_cancel:
|
|
1152
|
+
raise
|
|
1153
|
+
outcome.cancelled = True
|
|
1154
|
+
except Exception as e:
|
|
1155
|
+
record(False, str(e)[:100])
|
|
1156
|
+
raise
|
|
1157
|
+
else:
|
|
1158
|
+
record(True)
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _wait_for_start(ser, starting=None, mode=None):
|
|
1162
|
+
"""Wait on the remote for START (CANCEL/STOP aborts). Returns
|
|
1163
|
+
(mode, speed) - speed is None unless a SPEED: line came first - or None if
|
|
1164
|
+
the user cancelled. A non-None `mode` (the video flow's) may also be set by
|
|
1165
|
+
MODE:<m> / START:<m>. `starting` names the burn in the status line
|
|
1166
|
+
(defaults to the mode)."""
|
|
1167
|
+
speed = None
|
|
1168
|
+
print("Waiting for burn START button...")
|
|
1169
|
+
while True:
|
|
1170
|
+
line = wait_for_button(ser)
|
|
1171
|
+
if line in ("CANCEL", "PLAY_STOP"):
|
|
1172
|
+
safe_send(ser, "CANCELLED:Cancelled")
|
|
1173
|
+
print("Burn cancelled by user")
|
|
1174
|
+
return None
|
|
1175
|
+
if line.startswith("MODE:") and mode is not None:
|
|
1176
|
+
mode = discstation_burn.normalize_mode(line.split(":", 1)[1])
|
|
1177
|
+
print(f"Burn mode: {mode}")
|
|
1178
|
+
elif line.startswith("SPEED:"):
|
|
1179
|
+
speed = line.split(":", 1)[1].strip()
|
|
1180
|
+
print(f"Burn speed: {speed}")
|
|
1181
|
+
elif line == "START" or line.startswith("START:"):
|
|
1182
|
+
if mode is not None and ":" in line:
|
|
1183
|
+
mode = discstation_burn.normalize_mode(line.split(":", 1)[1])
|
|
1184
|
+
print(f"Starting {starting or mode} burn")
|
|
1185
|
+
send(ser, f"STATUS:Starting {starting or mode}...")
|
|
1186
|
+
return mode, speed
|
|
1187
|
+
|
|
1188
|
+
|
|
1177
1189
|
def run_probe(cmd, timeout=8, name=None):
|
|
1178
1190
|
try:
|
|
1179
1191
|
return subprocess.run(
|
|
@@ -1293,60 +1305,6 @@ def _device_present(device):
|
|
|
1293
1305
|
return False
|
|
1294
1306
|
|
|
1295
1307
|
|
|
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
1308
|
def is_blank_disc(device):
|
|
1351
1309
|
if not device:
|
|
1352
1310
|
return False
|
|
@@ -1915,9 +1873,6 @@ def menu_items_for_disc(device):
|
|
|
1915
1873
|
props = udev_cdrom_properties(device)
|
|
1916
1874
|
is_cd = props.get("ID_CDROM_MEDIA_CD_R") == "1" or props.get("ID_CDROM_MEDIA_CD_RW") == "1"
|
|
1917
1875
|
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
1876
|
elif kind in ("dvd_video", "audio_cd", "vcd", "svcd", "video_data", "data_disc", "data_cd"):
|
|
1922
1877
|
items = ["PLAY", "RIP"]
|
|
1923
1878
|
else:
|
|
@@ -2217,10 +2172,6 @@ def _release_meta(release, track_count, toc=None):
|
|
|
2217
2172
|
return None
|
|
2218
2173
|
|
|
2219
2174
|
|
|
2220
|
-
# Back-compat alias (older name).
|
|
2221
|
-
metadata_from_musicbrainz_release = _release_meta
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
2175
|
def musicbrainz_release_details(release_id):
|
|
2225
2176
|
if _mb is not None:
|
|
2226
2177
|
return _mb.get_release_by_id(
|
|
@@ -2277,58 +2228,6 @@ def musicbrainz_lookup(device, track_count):
|
|
|
2277
2228
|
return None
|
|
2278
2229
|
|
|
2279
2230
|
|
|
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
2231
|
def cddb_sum(value):
|
|
2333
2232
|
return sum(int(ch) for ch in str(value))
|
|
2334
2233
|
|
|
@@ -2461,7 +2360,7 @@ def musicbrainz_release_id_search(album_artist, album):
|
|
|
2461
2360
|
return releases[0].get("id") if releases else None
|
|
2462
2361
|
|
|
2463
2362
|
|
|
2464
|
-
def audio_metadata_lookup(device, track_count
|
|
2363
|
+
def audio_metadata_lookup(device, track_count):
|
|
2465
2364
|
metadata = None
|
|
2466
2365
|
|
|
2467
2366
|
for attempt in range(2):
|
|
@@ -2474,17 +2373,6 @@ def audio_metadata_lookup(device, track_count, artist_hint=None, album_hint=None
|
|
|
2474
2373
|
print(f"MusicBrainz lookup failed, retrying... ({e})")
|
|
2475
2374
|
time.sleep(1)
|
|
2476
2375
|
|
|
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
2376
|
if metadata and not metadata.get("release_id"):
|
|
2489
2377
|
try:
|
|
2490
2378
|
metadata["release_id"] = musicbrainz_release_id_search(
|
|
@@ -2599,60 +2487,6 @@ def tag_flac(path, track_meta, album_meta, cover_path):
|
|
|
2599
2487
|
audio.save()
|
|
2600
2488
|
|
|
2601
2489
|
|
|
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
2490
|
def parse_ffmpeg_time(line):
|
|
2657
2491
|
marker = "time="
|
|
2658
2492
|
if marker not in line:
|
|
@@ -2668,41 +2502,22 @@ def parse_ffmpeg_time(line):
|
|
|
2668
2502
|
from discstation_burn import CancelError
|
|
2669
2503
|
|
|
2670
2504
|
|
|
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
2505
|
def _raise_if_cancelled(ser):
|
|
2680
2506
|
"""Poll for a CANCEL press between blocking phases that have no
|
|
2681
2507
|
subprocess loop of their own (metadata lookups, scans, cover-art
|
|
2682
2508
|
downloads). Doesn't interrupt a call in progress, but catches the press
|
|
2683
2509
|
the moment the phase returns."""
|
|
2684
|
-
if
|
|
2510
|
+
if discstation_burn.check_cancel(ser):
|
|
2685
2511
|
raise CancelError
|
|
2686
2512
|
|
|
2687
2513
|
|
|
2688
2514
|
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()
|
|
2515
|
+
lines, finished = discstation_burn.proc_line_queue(proc)
|
|
2701
2516
|
last_ping = time.time()
|
|
2702
2517
|
output_done = False
|
|
2703
2518
|
while proc.poll() is None or not output_done:
|
|
2704
2519
|
if ser is not None:
|
|
2705
|
-
if
|
|
2520
|
+
if discstation_burn.check_cancel(ser):
|
|
2706
2521
|
discstation_burn.stop_process(proc)
|
|
2707
2522
|
raise CancelError
|
|
2708
2523
|
if time.time() - last_ping >= 5:
|
|
@@ -2717,7 +2532,6 @@ def iter_process_events(proc, idle_seconds=1.0, ser=None):
|
|
|
2717
2532
|
output_done = True
|
|
2718
2533
|
else:
|
|
2719
2534
|
yield line
|
|
2720
|
-
reader.join(timeout=1)
|
|
2721
2535
|
|
|
2722
2536
|
|
|
2723
2537
|
def rip_device(device):
|
|
@@ -2751,33 +2565,22 @@ def directory_size_bytes(path):
|
|
|
2751
2565
|
return total
|
|
2752
2566
|
|
|
2753
2567
|
|
|
2754
|
-
def
|
|
2755
|
-
"""
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2568
|
+
def _wait_for_source(ser, missing="URL or file path"):
|
|
2569
|
+
"""Block until the web remote supplies a source (an upload dir or a pasted
|
|
2570
|
+
URL/path). Returns None - after telling the remote why - if the user
|
|
2571
|
+
cancelled or sent nothing."""
|
|
2572
|
+
url = wait_for_web_url(ser)
|
|
2573
|
+
if url is None:
|
|
2574
|
+
safe_send(ser, "CANCELLED:Cancelled")
|
|
2575
|
+
elif not url:
|
|
2576
|
+
safe_send(ser, f"ERROR:Need {missing}")
|
|
2577
|
+
return url or None
|
|
2761
2578
|
|
|
2762
2579
|
|
|
2763
|
-
def burn_flow(ser
|
|
2580
|
+
def burn_flow(ser):
|
|
2581
|
+
url = _wait_for_source(ser)
|
|
2764
2582
|
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
|
|
2583
|
+
return
|
|
2781
2584
|
|
|
2782
2585
|
device = discstation_burn.disc_device()
|
|
2783
2586
|
disc_bytes = discstation_burn.disc_capacity_bytes(device)
|
|
@@ -2837,31 +2640,13 @@ def burn_flow(ser, url):
|
|
|
2837
2640
|
except RuntimeError:
|
|
2838
2641
|
pass
|
|
2839
2642
|
|
|
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
|
|
2643
|
+
started = _wait_for_start(ser, mode="AUTO")
|
|
2644
|
+
if not started:
|
|
2645
|
+
return
|
|
2646
|
+
selected_mode, burn_speed = started
|
|
2861
2647
|
|
|
2862
|
-
start_time = time.time()
|
|
2863
2648
|
disc_type_label = "DL" if dl_info["is_dual_layer"] else "SL"
|
|
2864
|
-
|
|
2649
|
+
with _burn_history({"title": title, "disc_type": disc_type_label, "mode": selected_mode, "speed": burn_speed or "Auto"}):
|
|
2865
2650
|
plan = discstation_burn.bitrate_plan(duration, selected_mode, disc_bytes)
|
|
2866
2651
|
video = discstation_burn.download(ser, url, job_dir)
|
|
2867
2652
|
mpg, dvd_aspect = discstation_burn.convert(ser, video, job_dir, selected_mode, disc_bytes)
|
|
@@ -2885,121 +2670,9 @@ def burn_flow(ser, url):
|
|
|
2885
2670
|
safe_send(ser, "DONE:Test complete!")
|
|
2886
2671
|
print(f"Test complete. DVD folder: {dvd_dir}")
|
|
2887
2672
|
|
|
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
2673
|
time.sleep(3)
|
|
2923
2674
|
|
|
2924
2675
|
|
|
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
2676
|
def _copy_to_job(ser, src, dst_dir):
|
|
3004
2677
|
if src.is_dir():
|
|
3005
2678
|
items = sorted(src.iterdir())
|
|
@@ -3015,25 +2688,9 @@ def _copy_to_job(ser, src, dst_dir):
|
|
|
3015
2688
|
def burn_data_flow(ser):
|
|
3016
2689
|
global _last_upload_label
|
|
3017
2690
|
|
|
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
|
|
2691
|
+
url = _wait_for_source(ser)
|
|
2692
|
+
if not url:
|
|
2693
|
+
return
|
|
3037
2694
|
|
|
3038
2695
|
device = discstation_burn.disc_device()
|
|
3039
2696
|
dl_info = discstation_burn.detect_disc_type(device)
|
|
@@ -3062,21 +2719,10 @@ def burn_data_flow(ser):
|
|
|
3062
2719
|
|
|
3063
2720
|
send(ser, f"TITLE:{title}")
|
|
3064
2721
|
|
|
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
|
|
2722
|
+
started = _wait_for_start(ser, "data burn")
|
|
2723
|
+
if not started:
|
|
2724
|
+
return
|
|
2725
|
+
burn_speed = started[1]
|
|
3080
2726
|
|
|
3081
2727
|
if not can_burn_disc(device):
|
|
3082
2728
|
raise RuntimeError("No writable disc in drive")
|
|
@@ -3087,8 +2733,7 @@ def burn_data_flow(ser):
|
|
|
3087
2733
|
download_dir = job_dir / "download"
|
|
3088
2734
|
download_dir.mkdir()
|
|
3089
2735
|
|
|
3090
|
-
|
|
3091
|
-
try:
|
|
2736
|
+
with _burn_history({"title": title, "disc_type": "Data DVD", "mode": "DATA", "speed": burn_speed or "Auto"}, swallow_cancel=True) as h:
|
|
3092
2737
|
is_dir = local_path.is_dir() if local_path.exists() else False
|
|
3093
2738
|
if is_dir:
|
|
3094
2739
|
files_to_burn = [local_path]
|
|
@@ -3130,75 +2775,15 @@ def burn_data_flow(ser):
|
|
|
3130
2775
|
safe_send(ser, "DONE:Data disc complete!")
|
|
3131
2776
|
print("Data burn complete.")
|
|
3132
2777
|
|
|
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
|
-
})
|
|
2778
|
+
if h.cancelled:
|
|
3165
2779
|
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
2780
|
time.sleep(3)
|
|
3180
2781
|
|
|
3181
2782
|
|
|
3182
2783
|
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
|
|
2784
|
+
url = _wait_for_source(ser, "path to audio files")
|
|
2785
|
+
if not url:
|
|
2786
|
+
return
|
|
3202
2787
|
|
|
3203
2788
|
src_path = Path(url)
|
|
3204
2789
|
if not src_path.exists():
|
|
@@ -3261,18 +2846,10 @@ def burn_audio_flow(ser):
|
|
|
3261
2846
|
send(ser, f"META:Dur {mins}m{secs}s")
|
|
3262
2847
|
send(ser, f"FIT:CD-R {fits}")
|
|
3263
2848
|
|
|
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
|
|
2849
|
+
started = _wait_for_start(ser, "audio burn")
|
|
2850
|
+
if not started:
|
|
2851
|
+
return
|
|
2852
|
+
burn_speed = started[1]
|
|
3276
2853
|
|
|
3277
2854
|
device = discstation_burn.disc_device()
|
|
3278
2855
|
if not can_burn_disc(device):
|
|
@@ -3280,95 +2857,23 @@ def burn_audio_flow(ser):
|
|
|
3280
2857
|
if total_dur > 4740:
|
|
3281
2858
|
raise RuntimeError(f"Too long for CD-R: {int(total_dur/60)}m{int(total_dur%60)}s > 79m")
|
|
3282
2859
|
|
|
3283
|
-
|
|
3284
|
-
|
|
2860
|
+
entry = {"title": disc_label, "fingerprint": fingerprint, "track_titles": track_titles,
|
|
2861
|
+
"disc_type": "Audio CD", "mode": "AUDIO", "speed": burn_speed or "Auto"}
|
|
2862
|
+
with _burn_history(entry, swallow_cancel=True) as h:
|
|
3285
2863
|
discstation_burn.burn_audio_cd(ser, audio_files, disc_label, burn_speed)
|
|
3286
2864
|
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
|
-
})
|
|
2865
|
+
if h.cancelled:
|
|
3313
2866
|
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
2867
|
time.sleep(3)
|
|
3330
2868
|
|
|
3331
2869
|
|
|
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
2870
|
# --- OLED spectrum visualizer -----------------------------------------------
|
|
3366
2871
|
# 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
|
|
2872
|
+
# Linux - not a simulation, and streams it to the remote as
|
|
3369
2873
|
# "VU:<16 comma-separated 0-63 levels>" at ~15fps. Needs numpy; anywhere else
|
|
3370
|
-
# (
|
|
3371
|
-
#
|
|
2874
|
+
# (macOS can't capture system audio without a signed helper - see
|
|
2875
|
+
# docs/PLATFORM_SUPPORT.md) this quietly no-ops and the remote just shows its
|
|
2876
|
+
# normal PLAY text screen.
|
|
3372
2877
|
VU_BARS = 16 # must match the firmware's VU_BARS
|
|
3373
2878
|
VU_RATE_HZ = 15
|
|
3374
2879
|
VU_SAMPLE_RATE = 22050
|
|
@@ -3386,55 +2891,19 @@ def _pulse_default_monitor():
|
|
|
3386
2891
|
return f"{sink}.monitor" if sink else None
|
|
3387
2892
|
|
|
3388
2893
|
|
|
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
2894
|
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
|
|
2895
|
+
"""parec argv streaming raw s16le mono PCM at VU_SAMPLE_RATE from the
|
|
2896
|
+
default sink's monitor, or None if PulseAudio has no default sink."""
|
|
2897
|
+
monitor = _pulse_default_monitor()
|
|
2898
|
+
if not monitor:
|
|
2899
|
+
return None
|
|
2900
|
+
# --latency-msec=50: PulseAudio's default capture buffer is several
|
|
2901
|
+
# hundred ms to seconds (tuned for robust recording, not streaming) -
|
|
2902
|
+
# without this, parec hands us data in ~1.5-2s bursts instead of a
|
|
2903
|
+
# steady trickle, which starves the visualizer for longer than the
|
|
2904
|
+
# firmware's fallback timeout and flickers back to the text screen.
|
|
2905
|
+
return ["parec", "--format=s16le", f"--rate={VU_SAMPLE_RATE}", "--channels=1",
|
|
2906
|
+
"--latency-msec=50", "-d", monitor]
|
|
3438
2907
|
|
|
3439
2908
|
|
|
3440
2909
|
def _vu_loop(ser, stop_event, pause_event):
|
|
@@ -3494,11 +2963,9 @@ def _vu_loop(ser, stop_event, pause_event):
|
|
|
3494
2963
|
|
|
3495
2964
|
def start_vu_visualizer(ser):
|
|
3496
2965
|
"""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"):
|
|
2966
|
+
visualizer can't run here (no numpy, no PulseAudio, or a web-only link).
|
|
2967
|
+
Linux only."""
|
|
2968
|
+
if _np is None or isinstance(ser, VirtualSerial) or discstation_host.system_name() != "linux":
|
|
3502
2969
|
return None, None
|
|
3503
2970
|
stop_event = threading.Event()
|
|
3504
2971
|
pause_event = threading.Event()
|
|
@@ -3551,7 +3018,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None, s
|
|
|
3551
3018
|
# floods journald until the pipe backs up and our own print()/status
|
|
3552
3019
|
# writes block, wedging the whole play loop. We drive mpv over the IPC
|
|
3553
3020
|
# socket, so none of that output is wanted.
|
|
3554
|
-
proc = subprocess.Popen(
|
|
3021
|
+
proc = subprocess.Popen(cmd, env=env,
|
|
3555
3022
|
stdin=(stdin_proc.stdout if stdin_proc else None),
|
|
3556
3023
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
3557
3024
|
if stdin_proc:
|
|
@@ -3721,16 +3188,7 @@ def _play_audio_cd_windows(ser, device, track_titles):
|
|
|
3721
3188
|
cmd, kwargs = discstation_host.ps_cmd("play-audio-cd.ps1", device, str(cmd_file))
|
|
3722
3189
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **kwargs)
|
|
3723
3190
|
|
|
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()
|
|
3191
|
+
lines, eof = discstation_burn.proc_line_queue(proc)
|
|
3734
3192
|
|
|
3735
3193
|
def send_cmd(text):
|
|
3736
3194
|
cmd_file.write_text(text + "\n")
|
|
@@ -3753,6 +3211,8 @@ def _play_audio_cd_windows(ser, device, track_titles):
|
|
|
3753
3211
|
line = lines.get(timeout=0.1)
|
|
3754
3212
|
except Empty:
|
|
3755
3213
|
line = None
|
|
3214
|
+
if line is eof:
|
|
3215
|
+
line = None
|
|
3756
3216
|
if line:
|
|
3757
3217
|
if line.startswith("TRACK:"):
|
|
3758
3218
|
try:
|
|
@@ -4099,12 +3559,12 @@ def handbrake_rip_main_feature(ser, device, out_dir, title_index):
|
|
|
4099
3559
|
return dest
|
|
4100
3560
|
|
|
4101
3561
|
|
|
4102
|
-
def rip_flow(ser
|
|
3562
|
+
def rip_flow(ser):
|
|
4103
3563
|
device = discstation_burn.disc_device()
|
|
4104
3564
|
kind = disc_kind(device)
|
|
4105
3565
|
|
|
4106
3566
|
if kind == "audio_cd":
|
|
4107
|
-
rip_audio_cd(ser, device
|
|
3567
|
+
rip_audio_cd(ser, device)
|
|
4108
3568
|
return
|
|
4109
3569
|
|
|
4110
3570
|
if kind in ("vcd", "svcd", "video_data"):
|
|
@@ -4162,7 +3622,6 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
|
|
|
4162
3622
|
safe_send(ser, "DONE:Rip complete!")
|
|
4163
3623
|
print(f"Rip complete: {out_dir}")
|
|
4164
3624
|
out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
|
|
4165
|
-
chown_to_sudo_user(out_dir)
|
|
4166
3625
|
time.sleep(3)
|
|
4167
3626
|
return
|
|
4168
3627
|
|
|
@@ -4187,7 +3646,6 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
|
|
|
4187
3646
|
safe_send(ser, "DONE:Rip complete!")
|
|
4188
3647
|
print(f"Rip complete: {out_dir}")
|
|
4189
3648
|
out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
|
|
4190
|
-
chown_to_sudo_user(out_dir)
|
|
4191
3649
|
time.sleep(3)
|
|
4192
3650
|
return
|
|
4193
3651
|
|
|
@@ -4237,7 +3695,7 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
|
|
|
4237
3695
|
raise
|
|
4238
3696
|
|
|
4239
3697
|
if proc.wait() != 0:
|
|
4240
|
-
if
|
|
3698
|
+
if drive_status(device) in ("open", "no_disc"):
|
|
4241
3699
|
raise RuntimeError("Disc was removed during rip")
|
|
4242
3700
|
raise RuntimeError("Rip failed")
|
|
4243
3701
|
|
|
@@ -4245,7 +3703,6 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
|
|
|
4245
3703
|
safe_send(ser, "DONE:Rip complete!")
|
|
4246
3704
|
print(f"Rip complete: {out_dir}")
|
|
4247
3705
|
out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
|
|
4248
|
-
chown_to_sudo_user(out_dir)
|
|
4249
3706
|
time.sleep(3)
|
|
4250
3707
|
|
|
4251
3708
|
|
|
@@ -4285,7 +3742,6 @@ def rip_video_disc(ser, device, kind):
|
|
|
4285
3742
|
safe_send(ser, "DONE:Rip complete!")
|
|
4286
3743
|
print(f"Video rip complete: {out_dir}")
|
|
4287
3744
|
out_dir = _finalize_video_rip(ser, out_dir, device, kind)
|
|
4288
|
-
chown_to_sudo_user(out_dir)
|
|
4289
3745
|
time.sleep(3)
|
|
4290
3746
|
|
|
4291
3747
|
|
|
@@ -4316,7 +3772,7 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
|
|
|
4316
3772
|
)
|
|
4317
3773
|
output = []
|
|
4318
3774
|
try:
|
|
4319
|
-
for line in
|
|
3775
|
+
for line in discstation_burn.iter_proc_or_cancel(proc, ser):
|
|
4320
3776
|
output.append(line)
|
|
4321
3777
|
count = len(list(wav_dir.glob("*.wav")))
|
|
4322
3778
|
if count:
|
|
@@ -4360,18 +3816,17 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
|
|
|
4360
3816
|
shutil.rmtree(str(wav_dir), ignore_errors=True)
|
|
4361
3817
|
safe_send(ser, "PROGRESS:100%")
|
|
4362
3818
|
safe_send(ser, "DONE:Rip complete!")
|
|
4363
|
-
chown_to_sudo_user(out_dir)
|
|
4364
3819
|
time.sleep(3)
|
|
4365
3820
|
|
|
4366
3821
|
|
|
4367
|
-
def rip_audio_cd(ser, device
|
|
3822
|
+
def rip_audio_cd(ser, device):
|
|
4368
3823
|
chapters = audio_cd_chapters(device)
|
|
4369
3824
|
metadata = None
|
|
4370
3825
|
cover_path = None
|
|
4371
3826
|
|
|
4372
3827
|
_raise_if_cancelled(ser)
|
|
4373
3828
|
send(ser, "STATUS:Looking up CD...")
|
|
4374
|
-
metadata = audio_metadata_lookup(device, len(chapters)
|
|
3829
|
+
metadata = audio_metadata_lookup(device, len(chapters))
|
|
4375
3830
|
_raise_if_cancelled(ser)
|
|
4376
3831
|
|
|
4377
3832
|
if metadata:
|
|
@@ -4447,7 +3902,7 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
|
|
|
4447
3902
|
)
|
|
4448
3903
|
|
|
4449
3904
|
try:
|
|
4450
|
-
for line in
|
|
3905
|
+
for line in discstation_burn.iter_proc_or_cancel(proc, ser):
|
|
4451
3906
|
print(line, end="")
|
|
4452
3907
|
secs = parse_ffmpeg_time(line)
|
|
4453
3908
|
if secs is not None and rip_duration > 0:
|
|
@@ -4464,7 +3919,7 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
|
|
|
4464
3919
|
safe_send(ser, "CANCELLED:Rip cancelled")
|
|
4465
3920
|
return
|
|
4466
3921
|
elif proc.returncode != 0:
|
|
4467
|
-
if
|
|
3922
|
+
if drive_status(device) in ("open", "no_disc"):
|
|
4468
3923
|
raise RuntimeError("Disc was removed during rip")
|
|
4469
3924
|
raise RuntimeError("Audio CD rip failed")
|
|
4470
3925
|
|
|
@@ -4500,11 +3955,10 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
|
|
|
4500
3955
|
safe_send(ser, "PROGRESS:100%")
|
|
4501
3956
|
safe_send(ser, "DONE:Rip complete!")
|
|
4502
3957
|
print(f"Audio rip complete: {out_dir}")
|
|
4503
|
-
chown_to_sudo_user(out_dir)
|
|
4504
3958
|
time.sleep(3)
|
|
4505
3959
|
|
|
4506
3960
|
|
|
4507
|
-
def station_loop(ser
|
|
3961
|
+
def station_loop(ser):
|
|
4508
3962
|
global _last_burn_result, _last_burn_result_time, _tray_open, _tray_open_since, _operation_active, _web_op_verb
|
|
4509
3963
|
discstation_burn.cleanup_old_jobs()
|
|
4510
3964
|
try:
|
|
@@ -4748,16 +4202,13 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
|
|
|
4748
4202
|
_operation_active = True # stop /disc-info probing the drive during the flow
|
|
4749
4203
|
try:
|
|
4750
4204
|
if mode == "BURN":
|
|
4751
|
-
burn_flow(ser
|
|
4205
|
+
burn_flow(ser)
|
|
4752
4206
|
_last_burn_result = "Burn complete"
|
|
4753
4207
|
elif mode == "PLAY":
|
|
4754
4208
|
play_flow(ser)
|
|
4755
4209
|
elif mode == "RIP":
|
|
4756
|
-
rip_flow(ser
|
|
4210
|
+
rip_flow(ser)
|
|
4757
4211
|
_last_burn_result = "Rip complete"
|
|
4758
|
-
elif mode == "BURN MPG":
|
|
4759
|
-
burn_mpg_flow(ser)
|
|
4760
|
-
_last_burn_result = "Burn complete"
|
|
4761
4212
|
elif mode == "BURN DATA":
|
|
4762
4213
|
burn_data_flow(ser)
|
|
4763
4214
|
_last_burn_result = "Burn complete"
|
|
@@ -4825,15 +4276,7 @@ def check_pidfile():
|
|
|
4825
4276
|
|
|
4826
4277
|
def parse_args():
|
|
4827
4278
|
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
4279
|
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
4280
|
return parser.parse_args()
|
|
4838
4281
|
|
|
4839
4282
|
|
|
@@ -4847,15 +4290,6 @@ def main():
|
|
|
4847
4290
|
exit_code = 0
|
|
4848
4291
|
|
|
4849
4292
|
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
4293
|
start_web_server(args.port)
|
|
4860
4294
|
|
|
4861
4295
|
while True:
|
|
@@ -4901,7 +4335,7 @@ def main():
|
|
|
4901
4335
|
# one now so a connected web remote learns about a newly-attached
|
|
4902
4336
|
# ESP32 immediately instead of only on its next reload.
|
|
4903
4337
|
_sse_publish(_status_snapshot())
|
|
4904
|
-
station_loop(ser
|
|
4338
|
+
station_loop(ser)
|
|
4905
4339
|
except _HardwareAttached:
|
|
4906
4340
|
print("ESP32 detected - handing off from the web remote to hardware.")
|
|
4907
4341
|
except (serial.SerialException, OSError, termios.error) as e:
|