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.
@@ -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 _check_cancel() calls this - so cancel
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
- subprocess.run(["sync"], timeout=5)
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
- subprocess.run(["sg_raw", device, "1e", "00", "00", "00", "00", "00"],
1057
- timeout=5, capture_output=True)
1017
+ subprocess.run(["sg_raw", device, "1e", "00", "00", "00", "00", "00"],
1018
+ timeout=5, capture_output=True)
1058
1019
 
1059
- ok = False
1060
- for cmd in (["eject", device], ["sg_raw", device, "1b", "00", "00", "00", "02", "00"]):
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
- print(f"{cmd[0]} eject ok")
1068
- else:
1069
- err = (r.stderr or r.stdout or b"failed").decode(errors="ignore").strip()[:40]
1070
- print(f"{cmd[0]} eject failed: {err}")
1071
- except Exception as e:
1072
- print(f"{cmd[0]} eject error: {e}")
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 SELECT/to close tray")
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 == "CONFIRM":
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, artist_hint=None, album_hint=None):
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 _check_cancel(ser):
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 = Queue()
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 _check_cancel(ser):
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 _stdin_is_tty():
2755
- """sys.stdin is None under pythonw.exe (no console) - plain .isatty() would
2756
- AttributeError. Also guards a closed/redirected stdin under systemd/launchd."""
2757
- try:
2758
- return sys.stdin is not None and sys.stdin.isatty()
2759
- except (AttributeError, ValueError, OSError):
2760
- return False
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, url):
2593
+ def burn_flow(ser):
2594
+ url = _wait_for_source(ser)
2764
2595
  if not url:
2765
- if _stdin_is_tty():
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
- selected_mode = "AUTO"
2841
- burn_speed = None
2842
- print("Waiting for burn START button...")
2843
- while True:
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
- try:
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
- if _stdin_is_tty():
3019
- safe_send(ser, "STATUS:Enter URL or file path in terminal")
3020
- print("=== Enter URL or file path below, then press Enter ===")
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
- burn_speed = None
3066
- print("Waiting for burn START button...")
3067
- while True:
3068
- line = wait_for_button(ser)
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
- start_time = time.time()
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
- append_burn_history({
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
- if _stdin_is_tty():
3184
- safe_send(ser, "STATUS:Enter path to audio files in terminal")
3185
- print("=== Enter path to audio files/folder, then press Enter ===")
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
- burn_speed = None
3265
- print("Waiting for START button...")
3266
- while True:
3267
- line = wait_for_button(ser)
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
- start_time = time.time()
3284
- try:
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
- append_burn_history({
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, a BlackHole loopback device on macOS (see docs/PLATFORM_SUPPORT.md
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
- # (or without the capture source set up) this quietly no-ops and the remote
3371
- # just shows its normal PLAY text screen.
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
- """subprocess argv that streams raw s16le mono PCM at VU_SAMPLE_RATE on
3415
- stdout for whatever's currently playing, or None if this OS/setup can't
3416
- do it. One capture source per platform; the FFT/scaling pipeline below is
3417
- the same regardless of where the bytes came from."""
3418
- system = discstation_host.system_name()
3419
- if system == "linux":
3420
- monitor = _pulse_default_monitor()
3421
- if not monitor:
3422
- return None
3423
- # --latency-msec=50: PulseAudio's default capture buffer is several
3424
- # hundred ms to seconds (tuned for robust recording, not streaming) -
3425
- # without this, parec hands us data in ~1.5-2s bursts instead of a
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 capture source, or a web-only
3498
- link). Linux (PulseAudio) and macOS (BlackHole, see
3499
- docs/PLATFORM_SUPPORT.md) only - _vu_capture_cmd() returns None anywhere
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(run_as_desktop_user(cmd), env=env,
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 = Queue()
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, artist_hint=None, album_hint=None):
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, artist_hint, album_hint)
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 not disc_present(device):
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 _iter_proc_lines(proc, ser):
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, artist_hint=None, album_hint=None):
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), artist_hint, album_hint)
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 _iter_proc_lines(proc, ser):
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 not disc_present(device):
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, url, artist_hint=None, album_hint=None):
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, url)
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, artist_hint, album_hint)
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, args.url, args.artist, args.album)
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: