discstation 0.1.34 → 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.
@@ -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
@@ -59,6 +57,7 @@ _web_status = "READY"
59
57
  _web_progress = -1
60
58
  _web_progress_active = False
61
59
  _web_playing = False # a play_flow is currently active (transport controls apply)
60
+ _web_op_verb = "BURNING" # progress-bar verb for the current PROGRESS: stream
62
61
  _operation_active = False # a burn/rip/play flow is holding the drive
63
62
  _last_disc_info = {"disc_present": False, "capacity_bytes": 0, "capacity_gb": 0, "type": "none"}
64
63
  _active_ser = None
@@ -85,6 +84,14 @@ class VirtualSerial:
85
84
  with self._lock:
86
85
  self._buf += text.encode(errors="ignore").strip() + b"\n"
87
86
 
87
+ def clear(self):
88
+ """Drop any unconsumed input - used when a fresh SELECT: comes in so a
89
+ START that was queued for an abandoned earlier selection (the web
90
+ remote's mode buttons queue it right behind SELECT: so a one-click
91
+ burn works) can't leak forward and fire a different, unintended burn."""
92
+ with self._lock:
93
+ self._buf = b""
94
+
88
95
  @property
89
96
  def in_waiting(self):
90
97
  with self._lock:
@@ -296,7 +303,14 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
296
303
  _burn_url_queue.put(str(upload_dir))
297
304
  size_str = f"{total / 1e6:.1f}MB" if total > 1e6 else f"{total / 1e3:.0f}KB"
298
305
  _set_web_progress("UPLOAD READY", 100)
299
- self._respond(200, f'{len(files)} file(s) uploaded ({size_str}). Select BURN DATA on remote.')
306
+ # Used to hardcode "Select BURN DATA on remote" - wrong mode name for
307
+ # an audio upload, and assumes hardware that may not exist. A mode
308
+ # may also already be selected (the one-click remote flow queues the
309
+ # burn before upload finishes), so this is just a status line, not
310
+ # an instruction to a specific next step.
311
+ tip = ("Select a mode on your remote." if _appliance_mode == "hardware"
312
+ else "Choose a burn mode below to start.")
313
+ self._respond(200, f'{len(files)} file(s) uploaded ({size_str}). {tip}')
300
314
 
301
315
  def _handle_remote_button(self):
302
316
  """Web on-screen remote -> the exact same text-line protocol the
@@ -319,7 +333,9 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
319
333
  def _serve_disc_info(self):
320
334
  if _operation_active:
321
335
  # a burn/rip/play holds the drive — don't probe it, serve last-known.
322
- self._respond(200, json.dumps({**_last_disc_info, "busy": True, "appliance": _appliance_mode}), "application/json")
336
+ # menu_items empty: don't invite starting a second op on top of
337
+ # the one already running (CANCEL/EJECT stay available regardless).
338
+ self._respond(200, json.dumps({**_last_disc_info, "busy": True, "menu_items": [], "appliance": _appliance_mode}), "application/json")
323
339
  return
324
340
  info = {"disc_present": False, "capacity_bytes": 0, "capacity_gb": 0, "type": "none"}
325
341
  try:
@@ -336,6 +352,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
336
352
  info["type"] = di.web_type
337
353
  info["kind"] = di.kind
338
354
  info["label"] = di.label
355
+ info["menu_items"] = menu_items_for_disc(device) if di.present and not di.transient else []
339
356
  except Exception as e:
340
357
  print(f"Disc info error: {e}")
341
358
  info["appliance"] = _appliance_mode
@@ -564,14 +581,13 @@ def start_web_server(port=8080):
564
581
 
565
582
 
566
583
  def local_ip():
567
- try:
568
- result = subprocess.run(['hostname', '-I'], capture_output=True, text=True, timeout=2)
569
- ips = result.stdout.strip().split()
570
- for ip in ips:
571
- if ip.count('.') == 3 and not ip.startswith('127.'):
572
- return ip
573
- except Exception:
574
- pass
584
+ # `hostname -I`'s first non-loopback address used to be the shortcut here,
585
+ # but it lists every interface with no notion of "the real one" - once
586
+ # Docker's docker0/br-* bridges (172.17/18.x, unreachable from outside
587
+ # this machine) exist, they can sort before the actual LAN NIC and get
588
+ # picked instead, showing a dead URL on the OLED/web remote. Asking the
589
+ # kernel what source address it'd use to reach the outside world sidesteps
590
+ # that entirely - Docker's bridges aren't in that route, no filtering needed.
575
591
  s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
576
592
  try:
577
593
  s.connect(('8.8.8.8', 80))
@@ -758,7 +774,7 @@ def _record_web_status(msg):
758
774
  _web_progress_active = True
759
775
  elif msg.startswith("PROGRESS:"):
760
776
  value = msg[9:].strip()
761
- _web_status = f"BURNING {value}"
777
+ _web_status = f"{_web_op_verb} {value}"
762
778
  match = re.search(r"(\d+(?:\.\d+)?)", value)
763
779
  if match:
764
780
  _web_progress = min(100, max(0, int(float(match.group(1)))))
@@ -801,53 +817,6 @@ def safe_send(ser, msg):
801
817
  discstation_burn.status_sink = _record_web_status
802
818
 
803
819
 
804
- def run_as_desktop_user(cmd):
805
- if os.name != "posix" or pwd is None:
806
- return cmd
807
- sudo_user = os.environ.get("SUDO_USER")
808
- if os.geteuid() == 0 and sudo_user and sudo_user != "root":
809
- home = Path(discstation_burn.USER_HOME)
810
- uid = pwd.getpwnam(sudo_user).pw_uid
811
- return [
812
- "sudo", "-u", sudo_user,
813
- "env",
814
- "DISPLAY=" + os.environ.get("DISPLAY", ":0"),
815
- "XAUTHORITY=" + str(home / ".Xauthority"),
816
- "XDG_RUNTIME_DIR=/run/user/" + str(uid),
817
- *cmd,
818
- ]
819
- return cmd
820
-
821
-
822
- def chown_to_sudo_user(path):
823
- sudo_user = os.environ.get("SUDO_USER")
824
- if os.name != "posix" or pwd is None or not hasattr(os, "geteuid"):
825
- return
826
- if os.geteuid() != 0 or not sudo_user or sudo_user == "root":
827
- return
828
-
829
- try:
830
- pw_record = pwd.getpwnam(sudo_user)
831
- except KeyError:
832
- return
833
-
834
- uid = pw_record.pw_uid
835
- gid = pw_record.pw_gid
836
- root_path = Path(path)
837
-
838
- for current_root, dirs, files in os.walk(root_path):
839
- try:
840
- os.chown(current_root, uid, gid)
841
- except OSError:
842
- pass
843
- for name in dirs + files:
844
- item = Path(current_root) / name
845
- try:
846
- os.chown(item, uid, gid)
847
- except OSError:
848
- pass
849
-
850
-
851
820
  def _mpv_ipc(payload, timeout=None):
852
821
  """Send one JSON line to mpv's IPC endpoint. Windows = named pipe, POSIX =
853
822
  AF_UNIX socket. Returns the raw reply bytes (b"" if not read), or raises OSError."""
@@ -918,7 +887,7 @@ def read_serial_line(ser, timeout=0.1):
918
887
  # Always make at least one non-blocking pass, even for timeout<=0 - the
919
888
  # `remaining <= 0: break` at the bottom still ends it after that pass.
920
889
  # (`while time.monotonic() < deadline` used to skip the body entirely for
921
- # 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
922
891
  # detection during rips silently never read the port.)
923
892
  while True:
924
893
  if _line_buf:
@@ -973,7 +942,16 @@ def check_serial_alive(ser=None):
973
942
  """Raise serial.SerialException if the ESP32 link looks dead, so main()'s
974
943
  reconnect loop can re-scan for the (possibly renumbered) serial port.
975
944
  Call this inside any long poll loop that would otherwise spin forever on a
976
- stale handle (writes to a re-enumerated /dev/ttyUSBN fail silently)."""
945
+ stale handle (writes to a re-enumerated /dev/ttyUSBN fail silently).
946
+
947
+ Meaningless (and actively harmful) in pure web/software mode - there's no
948
+ ESP32 to go quiet, and serial_activity_age() only advances on real
949
+ incoming bytes, so a user just reading the screen for >35s before
950
+ clicking the next button on the web remote looked identical to a dead
951
+ link and killed the burn ("ESP32 not responding") with no ESP32 in the
952
+ picture at all."""
953
+ if isinstance(ser, VirtualSerial):
954
+ return
977
955
  if discstation_burn.serial_write_failed():
978
956
  raise serial.SerialException("serial write failed (ESP32 link lost)")
979
957
  if discstation_burn.serial_activity_age() >= 35:
@@ -1048,10 +1026,16 @@ def eject_disc(ser, device):
1048
1026
  _tray_open = True
1049
1027
  _tray_open_since = time.monotonic()
1050
1028
  safe_send(ser, "WAITING:Press SELECT/to close tray")
1029
+ # WAITING: isn't in _record_web_status()'s prefix whitelist, so the
1030
+ # send above never reaches the web page - _tray_open has to be
1031
+ # published explicitly here, same as DISC: gets its own dedicated
1032
+ # publish for the same reason.
1033
+ _sse_publish(_status_snapshot())
1051
1034
  last_ping = time.time()
1052
1035
  deadline = time.time() + 60
1053
1036
  tray_was_cancelled = False
1054
1037
  last_status_check = 0
1038
+ closed_confirms = 0
1055
1039
  # Let the eject settle before touching the drive again (the reclose guard).
1056
1040
  settle_until = time.time() + 3
1057
1041
  while time.time() < deadline:
@@ -1061,9 +1045,20 @@ def eject_disc(ser, device):
1061
1045
  if time.time() >= settle_until and time.time() - last_status_check >= 1.5:
1062
1046
  last_status_check = time.time()
1063
1047
  if drive_status(device) in ("disc", "no_disc"):
1064
- print("Tray closed — continuing")
1065
- _tray_open = False
1066
- break
1048
+ # This USB-ATAPI bridge's status reporting is known flaky
1049
+ # (see udev_cdrom_properties' own docstring) - one read
1050
+ # right after an eject was seen live to falsely report
1051
+ # closed, reclosing the wait loop within ~11s of a real
1052
+ # eject. Require two consecutive agreeing reads before
1053
+ # believing it.
1054
+ closed_confirms += 1
1055
+ if closed_confirms >= 2:
1056
+ print("Tray closed — continuing")
1057
+ _tray_open = False
1058
+ _sse_publish(_status_snapshot())
1059
+ break
1060
+ else:
1061
+ closed_confirms = 0
1067
1062
  line = read_serial_line(ser, timeout=0.1)
1068
1063
  if not line:
1069
1064
  continue
@@ -1080,6 +1075,7 @@ def eject_disc(ser, device):
1080
1075
  r = subprocess.run(close_cmd, timeout=10, capture_output=True)
1081
1076
  if r.returncode == 0:
1082
1077
  _tray_open = False
1078
+ _sse_publish(_status_snapshot())
1083
1079
  break
1084
1080
  except Exception:
1085
1081
  pass
@@ -1129,6 +1125,67 @@ def append_burn_history(entry):
1129
1125
  f.write(json.dumps(entry) + "\n")
1130
1126
 
1131
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
+
1132
1189
  def run_probe(cmd, timeout=8, name=None):
1133
1190
  try:
1134
1191
  return subprocess.run(
@@ -1248,60 +1305,6 @@ def _device_present(device):
1248
1305
  return False
1249
1306
 
1250
1307
 
1251
- def _tray_closed_with_disc(device):
1252
- if not device:
1253
- return False
1254
- global _tray_open
1255
- if not _device_present(device):
1256
- return False
1257
- properties = udev_cdrom_properties(device)
1258
- if properties.get("ID_CDROM_MEDIA") == "1":
1259
- _tray_open = False
1260
- return True
1261
- if properties.get("ID_CDROM_MEDIA_STATE") == "blank":
1262
- _tray_open = False
1263
- return True
1264
- return False
1265
-
1266
-
1267
- def disc_present(device):
1268
- if not device:
1269
- return False
1270
- if _tray_closed_with_disc(device):
1271
- return True
1272
- if _tray_open:
1273
- return False
1274
- if not _device_present(device):
1275
- return False
1276
- if discstation_host.system_name() != "linux":
1277
- properties = udev_cdrom_properties(device)
1278
- return properties.get("ID_CDROM_MEDIA") == "1"
1279
-
1280
- toc = run_probe(["wodim", "-toc", "dev=" + device], name="wodim-toc", timeout=PROBE_TIMEOUT_WODIM_TOC)
1281
- toc_text = ensure_text(toc.stdout) + ensure_text(toc.stderr)
1282
- no_media_markers = _NO_MEDIA_MARKERS
1283
- if toc.returncode == 124:
1284
- return is_blank_disc(device)
1285
- if toc_text.strip() and any(marker in toc_text.lower() for marker in no_media_markers):
1286
- return False
1287
-
1288
- dvd = run_probe(["lsdvd", device], name="lsdvd", timeout=PROBE_TIMEOUT_LSDVD)
1289
- if dvd.returncode == 0:
1290
- return True
1291
-
1292
- fs = run_probe(["blkid", "-o", "value", "-s", "TYPE", device], name="blkid", timeout=PROBE_TIMEOUT_BLKID)
1293
- if fs.returncode == 0 and ensure_text(fs.stdout).strip():
1294
- return True
1295
-
1296
- if "first:" in toc_text and "track:" in toc_text:
1297
- return True
1298
-
1299
- if toc_text.strip() and not any(marker in toc_text.lower() for marker in no_media_markers):
1300
- return True
1301
-
1302
- return is_blank_disc(device)
1303
-
1304
-
1305
1308
  def is_blank_disc(device):
1306
1309
  if not device:
1307
1310
  return False
@@ -1863,10 +1866,13 @@ def menu_items_for_disc(device):
1863
1866
  kind = disc_kind(device)
1864
1867
  items = []
1865
1868
  if kind == "blank" or is_rewritable_disc(device):
1866
- items = ["BURN", "BURN DATA", "BURN AUDIO"]
1867
- had = discstation_burn.WORK.rglob("movie.mpg")
1868
- if any(True for _ in had):
1869
- items.append("BURN MPG")
1869
+ # CD-R/RW can't hold a DVD-video authoring job (nowhere near the
1870
+ # space) and DVD blanks can't take Red Book audio (wrong format
1871
+ # entirely, would just fail) - offer only what's physically possible
1872
+ # for the media that's actually in the drive.
1873
+ props = udev_cdrom_properties(device)
1874
+ is_cd = props.get("ID_CDROM_MEDIA_CD_R") == "1" or props.get("ID_CDROM_MEDIA_CD_RW") == "1"
1875
+ items = ["BURN DATA", "BURN AUDIO"] if is_cd else ["BURN", "BURN DATA"]
1870
1876
  elif kind in ("dvd_video", "audio_cd", "vcd", "svcd", "video_data", "data_disc", "data_cd"):
1871
1877
  items = ["PLAY", "RIP"]
1872
1878
  else:
@@ -2166,10 +2172,6 @@ def _release_meta(release, track_count, toc=None):
2166
2172
  return None
2167
2173
 
2168
2174
 
2169
- # Back-compat alias (older name).
2170
- metadata_from_musicbrainz_release = _release_meta
2171
-
2172
-
2173
2175
  def musicbrainz_release_details(release_id):
2174
2176
  if _mb is not None:
2175
2177
  return _mb.get_release_by_id(
@@ -2226,58 +2228,6 @@ def musicbrainz_lookup(device, track_count):
2226
2228
  return None
2227
2229
 
2228
2230
 
2229
- def musicbrainz_lookup_by_album_hints(album_artist, album, track_count):
2230
- if not album:
2231
- return None
2232
-
2233
- if _mb is not None:
2234
- fields = {"release": album}
2235
- if album_artist:
2236
- fields["artist"] = album_artist
2237
- try:
2238
- hits = _mb.search_releases(limit=8, **fields).get("release-list", [])
2239
- except _mb.WebServiceError as e:
2240
- print(f"MusicBrainz search failed: {e}")
2241
- hits = []
2242
- for hit in hits:
2243
- release_id = hit.get("id")
2244
- if not release_id:
2245
- continue
2246
- try:
2247
- details = musicbrainz_release_details(release_id)
2248
- except Exception:
2249
- continue
2250
- metadata = _release_meta(details, track_count)
2251
- if metadata:
2252
- metadata["source"] = "musicbrainz-search"
2253
- return metadata
2254
- return None
2255
-
2256
- # --- fallback: raw ws/2 search ---
2257
- query_parts = [f'release:"{album}"']
2258
- if album_artist:
2259
- query_parts.append(f'artist:"{album_artist}"')
2260
- response = requests.get(
2261
- "https://musicbrainz.org/ws/2/release/",
2262
- params={"query": " AND ".join(query_parts), "fmt": "json", "limit": 8},
2263
- headers={"User-Agent": USER_AGENT}, timeout=20,
2264
- )
2265
- response.raise_for_status()
2266
- for release in response.json().get("releases", []):
2267
- release_id = release.get("id")
2268
- if not release_id:
2269
- continue
2270
- try:
2271
- details = musicbrainz_release_details(release_id)
2272
- except Exception:
2273
- continue
2274
- metadata = _release_meta(details, track_count)
2275
- if metadata:
2276
- metadata["source"] = "musicbrainz-search"
2277
- return metadata
2278
- time.sleep(1)
2279
- return None
2280
-
2281
2231
  def cddb_sum(value):
2282
2232
  return sum(int(ch) for ch in str(value))
2283
2233
 
@@ -2410,7 +2360,7 @@ def musicbrainz_release_id_search(album_artist, album):
2410
2360
  return releases[0].get("id") if releases else None
2411
2361
 
2412
2362
 
2413
- def audio_metadata_lookup(device, track_count, artist_hint=None, album_hint=None):
2363
+ def audio_metadata_lookup(device, track_count):
2414
2364
  metadata = None
2415
2365
 
2416
2366
  for attempt in range(2):
@@ -2423,17 +2373,6 @@ def audio_metadata_lookup(device, track_count, artist_hint=None, album_hint=None
2423
2373
  print(f"MusicBrainz lookup failed, retrying... ({e})")
2424
2374
  time.sleep(1)
2425
2375
 
2426
- if not metadata and album_hint:
2427
- for attempt in range(2):
2428
- try:
2429
- metadata = musicbrainz_lookup_by_album_hints(artist_hint, album_hint, track_count)
2430
- if metadata:
2431
- break
2432
- except Exception as e:
2433
- if attempt == 0:
2434
- print(f"MusicBrainz album search failed, retrying... ({e})")
2435
- time.sleep(1)
2436
-
2437
2376
  if metadata and not metadata.get("release_id"):
2438
2377
  try:
2439
2378
  metadata["release_id"] = musicbrainz_release_id_search(
@@ -2548,60 +2487,6 @@ def tag_flac(path, track_meta, album_meta, cover_path):
2548
2487
  audio.save()
2549
2488
 
2550
2489
 
2551
- def retag_audio_rip(rip_dir, artist_hint, album_hint):
2552
- rip_dir = Path(rip_dir)
2553
- flacs = sorted(rip_dir.glob("*.flac"))
2554
- if not flacs:
2555
- raise RuntimeError(f"No FLAC files found in {rip_dir}")
2556
- if not album_hint:
2557
- raise RuntimeError("Retag needs --album")
2558
-
2559
- metadata = musicbrainz_lookup_by_album_hints(artist_hint, album_hint, len(flacs))
2560
- if not metadata:
2561
- raise RuntimeError("Could not find album metadata")
2562
-
2563
- target_dir = unique_dir(RIP_ROOT / safe_path_name(f"{metadata['album_artist']} - {metadata['album']}"))
2564
- if rip_dir != target_dir:
2565
- rip_dir.rename(target_dir)
2566
- else:
2567
- target_dir = rip_dir
2568
-
2569
- write_album_info(target_dir, metadata)
2570
- cover_path = download_cover_art(
2571
- metadata.get("release_id"),
2572
- target_dir,
2573
- metadata.get("release_group_id"),
2574
- )
2575
-
2576
- renamed = []
2577
- for index, src in enumerate(sorted(target_dir.glob("*.flac")), start=1):
2578
- if index > len(metadata["tracks"]):
2579
- break
2580
-
2581
- track_meta = metadata["tracks"][index - 1]
2582
- out_file = target_dir / f"{index:02d} - {safe_path_name(track_meta['title'])}.flac"
2583
- if src != out_file:
2584
- if out_file.exists():
2585
- out_file.unlink()
2586
- src.rename(out_file)
2587
-
2588
- tag_flac(out_file, track_meta, metadata, cover_path)
2589
- renamed.append(out_file)
2590
-
2591
- chown_to_sudo_user(target_dir)
2592
- return target_dir, cover_path, renamed
2593
-
2594
-
2595
- def latest_audio_rip_dir():
2596
- candidates = sorted(
2597
- path for path in RIP_ROOT.glob("audio_cd_*")
2598
- if path.is_dir() and list(path.glob("*.flac"))
2599
- )
2600
- if not candidates:
2601
- raise RuntimeError("No audio_cd_* rip folders found")
2602
- return candidates[-1]
2603
-
2604
-
2605
2490
  def parse_ffmpeg_time(line):
2606
2491
  marker = "time="
2607
2492
  if marker not in line:
@@ -2617,41 +2502,22 @@ def parse_ffmpeg_time(line):
2617
2502
  from discstation_burn import CancelError
2618
2503
 
2619
2504
 
2620
- def _check_cancel(ser):
2621
- try:
2622
- line = read_serial_line(ser, timeout=0)
2623
- return line in ("CANCEL", "PLAY_STOP") if line else False
2624
- except OSError:
2625
- return False
2626
-
2627
-
2628
2505
  def _raise_if_cancelled(ser):
2629
2506
  """Poll for a CANCEL press between blocking phases that have no
2630
2507
  subprocess loop of their own (metadata lookups, scans, cover-art
2631
2508
  downloads). Doesn't interrupt a call in progress, but catches the press
2632
2509
  the moment the phase returns."""
2633
- if _check_cancel(ser):
2510
+ if discstation_burn.check_cancel(ser):
2634
2511
  raise CancelError
2635
2512
 
2636
2513
 
2637
2514
  def iter_process_events(proc, idle_seconds=1.0, ser=None):
2638
- lines = Queue()
2639
- finished = object()
2640
-
2641
- def read_output():
2642
- try:
2643
- for line in proc.stdout:
2644
- lines.put(line.rstrip("\r\n"))
2645
- finally:
2646
- lines.put(finished)
2647
-
2648
- reader = threading.Thread(target=read_output, daemon=True)
2649
- reader.start()
2515
+ lines, finished = discstation_burn.proc_line_queue(proc)
2650
2516
  last_ping = time.time()
2651
2517
  output_done = False
2652
2518
  while proc.poll() is None or not output_done:
2653
2519
  if ser is not None:
2654
- if _check_cancel(ser):
2520
+ if discstation_burn.check_cancel(ser):
2655
2521
  discstation_burn.stop_process(proc)
2656
2522
  raise CancelError
2657
2523
  if time.time() - last_ping >= 5:
@@ -2666,7 +2532,6 @@ def iter_process_events(proc, idle_seconds=1.0, ser=None):
2666
2532
  output_done = True
2667
2533
  else:
2668
2534
  yield line
2669
- reader.join(timeout=1)
2670
2535
 
2671
2536
 
2672
2537
  def rip_device(device):
@@ -2700,33 +2565,22 @@ def directory_size_bytes(path):
2700
2565
  return total
2701
2566
 
2702
2567
 
2703
- def _stdin_is_tty():
2704
- """sys.stdin is None under pythonw.exe (no console) - plain .isatty() would
2705
- AttributeError. Also guards a closed/redirected stdin under systemd/launchd."""
2706
- try:
2707
- return sys.stdin is not None and sys.stdin.isatty()
2708
- except (AttributeError, ValueError, OSError):
2709
- return False
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
2710
2578
 
2711
2579
 
2712
- def burn_flow(ser, url):
2580
+ def burn_flow(ser):
2581
+ url = _wait_for_source(ser)
2713
2582
  if not url:
2714
- if _stdin_is_tty():
2715
- safe_send(ser, "STATUS:Enter URL or file path in terminal")
2716
- print("=== Enter URL or file path below, then press Enter ===")
2717
- try:
2718
- url = sys.stdin.readline().strip()
2719
- except (EOFError, KeyboardInterrupt, OSError):
2720
- safe_send(ser, "CANCELLED:Cancelled")
2721
- return
2722
- else:
2723
- url = wait_for_web_url(ser)
2724
- if url is None:
2725
- safe_send(ser, "CANCELLED:Cancelled")
2726
- return
2727
- if not url:
2728
- safe_send(ser, "ERROR:Need URL or file path")
2729
- return
2583
+ return
2730
2584
 
2731
2585
  device = discstation_burn.disc_device()
2732
2586
  disc_bytes = discstation_burn.disc_capacity_bytes(device)
@@ -2786,31 +2640,13 @@ def burn_flow(ser, url):
2786
2640
  except RuntimeError:
2787
2641
  pass
2788
2642
 
2789
- selected_mode = "AUTO"
2790
- burn_speed = None
2791
- print("Waiting for burn START button...")
2792
- while True:
2793
- line = wait_for_button(ser)
2794
- if line == "CANCEL" or line == "PLAY_STOP":
2795
- safe_send(ser, "CANCELLED:Cancelled")
2796
- print("Burn cancelled by user")
2797
- return
2798
- if line.startswith("MODE:"):
2799
- selected_mode = discstation_burn.normalize_mode(line.split(":", 1)[1])
2800
- print(f"Burn mode: {selected_mode}")
2801
- elif line.startswith("SPEED:"):
2802
- burn_speed = line.split(":", 1)[1].strip()
2803
- print(f"Burn speed: {burn_speed}")
2804
- elif line == "START" or line.startswith("START:"):
2805
- if ":" in line:
2806
- selected_mode = discstation_burn.normalize_mode(line.split(":", 1)[1])
2807
- print(f"Starting burn flow in {selected_mode} mode")
2808
- send(ser, f"STATUS:Starting {selected_mode}...")
2809
- break
2643
+ started = _wait_for_start(ser, mode="AUTO")
2644
+ if not started:
2645
+ return
2646
+ selected_mode, burn_speed = started
2810
2647
 
2811
- start_time = time.time()
2812
2648
  disc_type_label = "DL" if dl_info["is_dual_layer"] else "SL"
2813
- try:
2649
+ with _burn_history({"title": title, "disc_type": disc_type_label, "mode": selected_mode, "speed": burn_speed or "Auto"}):
2814
2650
  plan = discstation_burn.bitrate_plan(duration, selected_mode, disc_bytes)
2815
2651
  video = discstation_burn.download(ser, url, job_dir)
2816
2652
  mpg, dvd_aspect = discstation_burn.convert(ser, video, job_dir, selected_mode, disc_bytes)
@@ -2834,121 +2670,9 @@ def burn_flow(ser, url):
2834
2670
  safe_send(ser, "DONE:Test complete!")
2835
2671
  print(f"Test complete. DVD folder: {dvd_dir}")
2836
2672
 
2837
- append_burn_history({
2838
- "timestamp": datetime.datetime.now().isoformat(),
2839
- "title": title,
2840
- "disc_type": disc_type_label,
2841
- "mode": selected_mode,
2842
- "speed": burn_speed or "Auto",
2843
- "success": True,
2844
- "duration_s": round(time.time() - start_time),
2845
- })
2846
- except (KeyboardInterrupt, SystemExit):
2847
- append_burn_history({
2848
- "timestamp": datetime.datetime.now().isoformat(),
2849
- "title": title,
2850
- "disc_type": disc_type_label,
2851
- "mode": selected_mode,
2852
- "speed": burn_speed or "Auto",
2853
- "success": False,
2854
- "error": "Cancelled",
2855
- "duration_s": round(time.time() - start_time),
2856
- })
2857
- raise
2858
- except Exception as e:
2859
- append_burn_history({
2860
- "timestamp": datetime.datetime.now().isoformat(),
2861
- "title": title,
2862
- "disc_type": disc_type_label,
2863
- "mode": selected_mode,
2864
- "speed": burn_speed or "Auto",
2865
- "success": False,
2866
- "error": str(e)[:100],
2867
- "duration_s": round(time.time() - start_time),
2868
- })
2869
- raise
2870
-
2871
2673
  time.sleep(3)
2872
2674
 
2873
2675
 
2874
- def _mpg_label(job_dir):
2875
- label = "DVD_VIDEO"
2876
- dl = job_dir / "download"
2877
- if dl.is_dir():
2878
- for f in sorted(dl.iterdir()):
2879
- if f.suffix.lower() in discstation_burn.VIDEO_EXTS:
2880
- label = discstation_burn.sanitize_disc_label(f.stem)
2881
- break
2882
- else:
2883
- for f in sorted(dl.iterdir()):
2884
- label = discstation_burn.sanitize_disc_label(f.stem)
2885
- break
2886
- if label == "DVD_VIDEO" or not label:
2887
- label = discstation_burn.sanitize_disc_label(job_dir.name)
2888
- return label
2889
-
2890
-
2891
- def burn_mpg_flow(ser):
2892
- jobs = sorted(discstation_burn.WORK.glob("job_*"), reverse=True)
2893
- candidates = []
2894
- for jd in jobs:
2895
- mpg = jd / "movie.mpg"
2896
- if mpg.exists():
2897
- label = _mpg_label(jd)
2898
- candidates.append((mpg, label))
2899
- names = [c[1][:20] for c in candidates] + ["Enter path..."]
2900
- safe_send(ser, f"MENU_ITEMS:{','.join(names)}")
2901
- safe_send(ser, "HOME:Select MPG to burn")
2902
- mpg = None
2903
- disc_label = None
2904
- while True:
2905
- line = read_serial_line(ser, timeout=0.5)
2906
- if not line:
2907
- continue
2908
- if line.startswith("SELECT:"):
2909
- sel = line.split(":", 1)[1].strip()
2910
- if sel == "Enter path...":
2911
- path_str = wait_for_web_url(ser)
2912
- if path_str is None:
2913
- refresh_main_menu(ser)
2914
- return
2915
- path_str = path_str.strip()
2916
- p = Path(path_str)
2917
- if not p.exists():
2918
- safe_send(ser, "ERROR:Path not found")
2919
- time.sleep(2)
2920
- continue
2921
- if p.is_dir():
2922
- mpg = p / "movie.mpg"
2923
- if not mpg.exists():
2924
- safe_send(ser, "ERROR:No movie.mpg in dir")
2925
- time.sleep(2)
2926
- continue
2927
- elif p.suffix.lower() == ".mpg":
2928
- mpg = p
2929
- else:
2930
- safe_send(ser, "ERROR:Not an .mpg file")
2931
- time.sleep(2)
2932
- continue
2933
- disc_label = discstation_burn.sanitize_disc_label(mpg.stem)
2934
- break
2935
- else:
2936
- idx = next((i for i, n in enumerate(candidates) if n[1][:20] == sel), None)
2937
- if idx is not None:
2938
- mpg, disc_label = candidates[idx]
2939
- break
2940
- elif line == "CANCEL":
2941
- safe_send(ser, "CANCELLED:Cancelled")
2942
- refresh_main_menu(ser)
2943
- return
2944
- time.sleep(0.05)
2945
-
2946
- device = discstation_burn.disc_device()
2947
- dl_info = discstation_burn.detect_disc_type(device)
2948
- disc_bytes = dl_info["capacity"]
2949
- discstation_burn.remux_and_burn(ser, mpg, disc_label, disc_bytes, dl_info)
2950
-
2951
-
2952
2676
  def _copy_to_job(ser, src, dst_dir):
2953
2677
  if src.is_dir():
2954
2678
  items = sorted(src.iterdir())
@@ -2964,25 +2688,9 @@ def _copy_to_job(ser, src, dst_dir):
2964
2688
  def burn_data_flow(ser):
2965
2689
  global _last_upload_label
2966
2690
 
2967
- if _stdin_is_tty():
2968
- safe_send(ser, "STATUS:Enter URL or file path in terminal")
2969
- print("=== Enter URL or file path below, then press Enter ===")
2970
- try:
2971
- url = sys.stdin.readline().strip()
2972
- except (EOFError, KeyboardInterrupt, OSError):
2973
- safe_send(ser, "CANCELLED:Cancelled")
2974
- return
2975
- if not url:
2976
- safe_send(ser, "ERROR:Need URL or file path")
2977
- return
2978
- else:
2979
- url = wait_for_web_url(ser)
2980
- if url is None:
2981
- safe_send(ser, "CANCELLED:Cancelled")
2982
- return
2983
- if not url:
2984
- safe_send(ser, "ERROR:Need URL or file path")
2985
- return
2691
+ url = _wait_for_source(ser)
2692
+ if not url:
2693
+ return
2986
2694
 
2987
2695
  device = discstation_burn.disc_device()
2988
2696
  dl_info = discstation_burn.detect_disc_type(device)
@@ -3011,21 +2719,10 @@ def burn_data_flow(ser):
3011
2719
 
3012
2720
  send(ser, f"TITLE:{title}")
3013
2721
 
3014
- burn_speed = None
3015
- print("Waiting for burn START button...")
3016
- while True:
3017
- line = wait_for_button(ser)
3018
- if line == "CANCEL" or line == "PLAY_STOP":
3019
- safe_send(ser, "CANCELLED:Cancelled")
3020
- print("Burn cancelled by user")
3021
- return
3022
- if line.startswith("SPEED:"):
3023
- burn_speed = line.split(":", 1)[1].strip()
3024
- print(f"Burn speed: {burn_speed}")
3025
- elif line == "START" or line.startswith("START:"):
3026
- print("Starting data burn...")
3027
- send(ser, "STATUS:Starting data burn...")
3028
- break
2722
+ started = _wait_for_start(ser, "data burn")
2723
+ if not started:
2724
+ return
2725
+ burn_speed = started[1]
3029
2726
 
3030
2727
  if not can_burn_disc(device):
3031
2728
  raise RuntimeError("No writable disc in drive")
@@ -3036,8 +2733,7 @@ def burn_data_flow(ser):
3036
2733
  download_dir = job_dir / "download"
3037
2734
  download_dir.mkdir()
3038
2735
 
3039
- start_time = time.time()
3040
- try:
2736
+ with _burn_history({"title": title, "disc_type": "Data DVD", "mode": "DATA", "speed": burn_speed or "Auto"}, swallow_cancel=True) as h:
3041
2737
  is_dir = local_path.is_dir() if local_path.exists() else False
3042
2738
  if is_dir:
3043
2739
  files_to_burn = [local_path]
@@ -3079,75 +2775,15 @@ def burn_data_flow(ser):
3079
2775
  safe_send(ser, "DONE:Data disc complete!")
3080
2776
  print("Data burn complete.")
3081
2777
 
3082
- append_burn_history({
3083
- "timestamp": datetime.datetime.now().isoformat(),
3084
- "title": title,
3085
- "disc_type": "Data DVD",
3086
- "mode": "DATA",
3087
- "speed": burn_speed or "Auto",
3088
- "success": True,
3089
- "duration_s": round(time.time() - start_time),
3090
- })
3091
- except (KeyboardInterrupt, SystemExit):
3092
- append_burn_history({
3093
- "timestamp": datetime.datetime.now().isoformat(),
3094
- "title": title,
3095
- "disc_type": "Data DVD",
3096
- "mode": "DATA",
3097
- "speed": burn_speed or "Auto",
3098
- "success": False,
3099
- "error": "Cancelled",
3100
- "duration_s": round(time.time() - start_time),
3101
- })
3102
- raise
3103
- except CancelError:
3104
- append_burn_history({
3105
- "timestamp": datetime.datetime.now().isoformat(),
3106
- "title": title,
3107
- "disc_type": "Data DVD",
3108
- "mode": "DATA",
3109
- "speed": burn_speed or "Auto",
3110
- "success": False,
3111
- "error": "Cancelled",
3112
- "duration_s": round(time.time() - start_time),
3113
- })
2778
+ if h.cancelled:
3114
2779
  return
3115
- except Exception as e:
3116
- append_burn_history({
3117
- "timestamp": datetime.datetime.now().isoformat(),
3118
- "title": title,
3119
- "disc_type": "Data DVD",
3120
- "mode": "DATA",
3121
- "speed": burn_speed or "Auto",
3122
- "success": False,
3123
- "error": str(e)[:100],
3124
- "duration_s": round(time.time() - start_time),
3125
- })
3126
- raise
3127
-
3128
2780
  time.sleep(3)
3129
2781
 
3130
2782
 
3131
2783
  def burn_audio_flow(ser):
3132
- if _stdin_is_tty():
3133
- safe_send(ser, "STATUS:Enter path to audio files in terminal")
3134
- print("=== Enter path to audio files/folder, then press Enter ===")
3135
- try:
3136
- url = sys.stdin.readline().strip()
3137
- except (EOFError, KeyboardInterrupt, OSError):
3138
- safe_send(ser, "CANCELLED:Cancelled")
3139
- return
3140
- if not url:
3141
- safe_send(ser, "ERROR:Need path to audio files")
3142
- return
3143
- else:
3144
- url = wait_for_web_url(ser)
3145
- if url is None:
3146
- safe_send(ser, "CANCELLED:Cancelled")
3147
- return
3148
- if not url:
3149
- safe_send(ser, "ERROR:Need path to audio files")
3150
- return
2784
+ url = _wait_for_source(ser, "path to audio files")
2785
+ if not url:
2786
+ return
3151
2787
 
3152
2788
  src_path = Path(url)
3153
2789
  if not src_path.exists():
@@ -3210,18 +2846,10 @@ def burn_audio_flow(ser):
3210
2846
  send(ser, f"META:Dur {mins}m{secs}s")
3211
2847
  send(ser, f"FIT:CD-R {fits}")
3212
2848
 
3213
- burn_speed = None
3214
- print("Waiting for START button...")
3215
- while True:
3216
- line = wait_for_button(ser)
3217
- if line == "CANCEL" or line == "PLAY_STOP":
3218
- safe_send(ser, "CANCELLED:Cancelled")
3219
- return
3220
- if line.startswith("SPEED:"):
3221
- burn_speed = line.split(":", 1)[1].strip()
3222
- elif line == "START" or line.startswith("START:"):
3223
- send(ser, "STATUS:Starting audio burn...")
3224
- break
2849
+ started = _wait_for_start(ser, "audio burn")
2850
+ if not started:
2851
+ return
2852
+ burn_speed = started[1]
3225
2853
 
3226
2854
  device = discstation_burn.disc_device()
3227
2855
  if not can_burn_disc(device):
@@ -3229,95 +2857,23 @@ def burn_audio_flow(ser):
3229
2857
  if total_dur > 4740:
3230
2858
  raise RuntimeError(f"Too long for CD-R: {int(total_dur/60)}m{int(total_dur%60)}s > 79m")
3231
2859
 
3232
- start_time = time.time()
3233
- try:
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:
3234
2863
  discstation_burn.burn_audio_cd(ser, audio_files, disc_label, burn_speed)
3235
2864
  safe_send(ser, "DONE:Audio CD complete!")
3236
- append_burn_history({
3237
- "timestamp": datetime.datetime.now().isoformat(),
3238
- "title": disc_label,
3239
- "fingerprint": fingerprint,
3240
- "track_titles": track_titles,
3241
- "disc_type": "Audio CD",
3242
- "mode": "AUDIO",
3243
- "speed": burn_speed or "Auto",
3244
- "success": True,
3245
- "duration_s": round(time.time() - start_time),
3246
- })
3247
- except (KeyboardInterrupt, SystemExit):
3248
- raise
3249
- except CancelError:
3250
- append_burn_history({
3251
- "timestamp": datetime.datetime.now().isoformat(),
3252
- "title": disc_label,
3253
- "fingerprint": fingerprint,
3254
- "track_titles": track_titles,
3255
- "disc_type": "Audio CD",
3256
- "mode": "AUDIO",
3257
- "speed": burn_speed or "Auto",
3258
- "success": False,
3259
- "error": "Cancelled",
3260
- "duration_s": round(time.time() - start_time),
3261
- })
2865
+ if h.cancelled:
3262
2866
  return
3263
- except Exception as e:
3264
- append_burn_history({
3265
- "timestamp": datetime.datetime.now().isoformat(),
3266
- "title": disc_label,
3267
- "fingerprint": fingerprint,
3268
- "track_titles": track_titles,
3269
- "disc_type": "Audio CD",
3270
- "mode": "AUDIO",
3271
- "speed": burn_speed or "Auto",
3272
- "success": False,
3273
- "error": str(e)[:100],
3274
- "duration_s": round(time.time() - start_time),
3275
- })
3276
- raise
3277
-
3278
2867
  time.sleep(3)
3279
2868
 
3280
2869
 
3281
- def _iter_proc_lines(proc, ser):
3282
- lines = Queue()
3283
- finished = object()
3284
-
3285
- def read_output():
3286
- try:
3287
- for line in proc.stdout:
3288
- lines.put(line.rstrip("\r\n"))
3289
- finally:
3290
- lines.put(finished)
3291
-
3292
- reader = threading.Thread(target=read_output, daemon=True)
3293
- reader.start()
3294
- last_ping = time.time()
3295
- output_done = False
3296
- while proc.poll() is None or not output_done:
3297
- if time.time() - last_ping >= 5:
3298
- discstation_burn.send(ser, "PING")
3299
- last_ping = time.time()
3300
- if _check_cancel(ser):
3301
- discstation_burn.stop_process(proc)
3302
- return
3303
- try:
3304
- line = lines.get(timeout=0.5)
3305
- except Empty:
3306
- continue
3307
- if line is finished:
3308
- output_done = True
3309
- else:
3310
- yield line
3311
- reader.join(timeout=1)
3312
-
3313
-
3314
2870
  # --- OLED spectrum visualizer -----------------------------------------------
3315
2871
  # Taps the real audio the host is playing - PulseAudio's monitor source on
3316
- # Linux, a BlackHole loopback device on macOS (see docs/PLATFORM_SUPPORT.md
3317
- # 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
3318
2873
  # "VU:<16 comma-separated 0-63 levels>" at ~15fps. Needs numpy; anywhere else
3319
- # (or without the capture source set up) this quietly no-ops and the remote
3320
- # just shows its normal PLAY text screen.
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.
3321
2877
  VU_BARS = 16 # must match the firmware's VU_BARS
3322
2878
  VU_RATE_HZ = 15
3323
2879
  VU_SAMPLE_RATE = 22050
@@ -3335,55 +2891,19 @@ def _pulse_default_monitor():
3335
2891
  return f"{sink}.monitor" if sink else None
3336
2892
 
3337
2893
 
3338
- def _darwin_blackhole_input():
3339
- """Index of the 'BlackHole' avfoundation audio device, or None if it's not
3340
- installed. macOS has no built-in loopback source - this requires the user
3341
- to `brew install blackhole-2ch` and set a Multi-Output Device (BlackHole +
3342
- real speakers) as the system's default output, so audio is both audible
3343
- and tapped (see docs/PLATFORM_SUPPORT.md)."""
3344
- try:
3345
- # Device list is on stderr; ffmpeg exits non-zero here, that's normal.
3346
- out = subprocess.run(["ffmpeg", "-f", "avfoundation", "-list_devices", "true", "-i", ""],
3347
- capture_output=True, text=True, timeout=5).stderr
3348
- except Exception:
3349
- return None
3350
- in_audio = False
3351
- for line in out.splitlines():
3352
- if "AVFoundation audio devices" in line:
3353
- in_audio = True
3354
- continue
3355
- if in_audio:
3356
- m = re.search(r"\[(\d+)\]\s+(.*)", line)
3357
- if m and "blackhole" in m.group(2).lower():
3358
- return m.group(1)
3359
- return None
3360
-
3361
-
3362
2894
  def _vu_capture_cmd():
3363
- """subprocess argv that streams raw s16le mono PCM at VU_SAMPLE_RATE on
3364
- stdout for whatever's currently playing, or None if this OS/setup can't
3365
- do it. One capture source per platform; the FFT/scaling pipeline below is
3366
- the same regardless of where the bytes came from."""
3367
- system = discstation_host.system_name()
3368
- if system == "linux":
3369
- monitor = _pulse_default_monitor()
3370
- if not monitor:
3371
- return None
3372
- # --latency-msec=50: PulseAudio's default capture buffer is several
3373
- # hundred ms to seconds (tuned for robust recording, not streaming) -
3374
- # without this, parec hands us data in ~1.5-2s bursts instead of a
3375
- # steady trickle, which starves the visualizer for longer than the
3376
- # firmware's fallback timeout and flickers back to the text screen.
3377
- return ["parec", "--format=s16le", f"--rate={VU_SAMPLE_RATE}", "--channels=1",
3378
- "--latency-msec=50", "-d", monitor]
3379
- if system == "darwin":
3380
- idx = _darwin_blackhole_input()
3381
- if idx is None:
3382
- return None
3383
- return ["ffmpeg", "-f", "avfoundation", "-i", f":{idx}",
3384
- "-ac", "1", "-ar", str(VU_SAMPLE_RATE), "-f", "s16le",
3385
- "-loglevel", "error", "-"]
3386
- 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]
3387
2907
 
3388
2908
 
3389
2909
  def _vu_loop(ser, stop_event, pause_event):
@@ -3443,11 +2963,9 @@ def _vu_loop(ser, stop_event, pause_event):
3443
2963
 
3444
2964
  def start_vu_visualizer(ser):
3445
2965
  """Best-effort: returns (stop_event, pause_event), or (None, None) if the
3446
- visualizer can't run here (no numpy, no capture source, or a web-only
3447
- link). Linux (PulseAudio) and macOS (BlackHole, see
3448
- docs/PLATFORM_SUPPORT.md) only - _vu_capture_cmd() returns None anywhere
3449
- else, or if the OS-specific capture device isn't set up."""
3450
- 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":
3451
2969
  return None, None
3452
2970
  stop_event = threading.Event()
3453
2971
  pause_event = threading.Event()
@@ -3500,7 +3018,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None, s
3500
3018
  # floods journald until the pipe backs up and our own print()/status
3501
3019
  # writes block, wedging the whole play loop. We drive mpv over the IPC
3502
3020
  # socket, so none of that output is wanted.
3503
- proc = subprocess.Popen(run_as_desktop_user(cmd), env=env,
3021
+ proc = subprocess.Popen(cmd, env=env,
3504
3022
  stdin=(stdin_proc.stdout if stdin_proc else None),
3505
3023
  stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
3506
3024
  if stdin_proc:
@@ -3524,6 +3042,10 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None, s
3524
3042
  current_volume = None
3525
3043
  current_track = None
3526
3044
  last_track_poll = 0
3045
+ # Set to "eject" below when EJECT (not PLAY_STOP/CANCEL/HOME) is what
3046
+ # ended playback - returned to the caller so it can actually eject
3047
+ # the tray afterward, not just return to the menu.
3048
+ stop_reason = None
3527
3049
  track_titles = track_titles or []
3528
3050
  track_starts = track_starts or []
3529
3051
  send(ser, "PLAY_MODE:AUDIO_CD" if kind == "audio_cd" else "PLAY_MODE:DEFAULT")
@@ -3566,12 +3088,18 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None, s
3566
3088
  vu_pause.set() if paused else vu_pause.clear()
3567
3089
  send(ser, "PLAY_STATUS:PAUSED" if paused else "PLAY_STATUS:PLAYING")
3568
3090
 
3569
- elif line in ("PLAY_STOP", "EJECT"):
3570
- # EJECT during playback = stop first; the web remote has no
3571
- # separate stop button, and without this the command is
3572
- # silently dropped here and playback never ends.
3091
+ elif line in ("PLAY_STOP", "EJECT", "CANCEL", "HOME"):
3092
+ # All four end playback - EJECT alone also pops the tray
3093
+ # afterward (the web remote has its own dedicated STOP
3094
+ # button now, so EJECT no longer needs to double as one -
3095
+ # a user pressing eject while music plays wants the disc
3096
+ # out, not just silence). CANCEL/HOME return to the menu
3097
+ # exactly like they do everywhere else - previously
3098
+ # unhandled here, so they were silently dropped mid-play.
3573
3099
  send(ser, "STATUS:Stopping play")
3574
3100
  discstation_burn.stop_process(proc)
3101
+ if line == "EJECT":
3102
+ stop_reason = "eject"
3575
3103
  break
3576
3104
 
3577
3105
  elif line == "FF:BIG":
@@ -3646,6 +3174,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None, s
3646
3174
  os.unlink(MPV_SOCKET)
3647
3175
  except OSError:
3648
3176
  pass
3177
+ return stop_reason
3649
3178
 
3650
3179
 
3651
3180
  def _play_audio_cd_windows(ser, device, track_titles):
@@ -3659,16 +3188,7 @@ def _play_audio_cd_windows(ser, device, track_titles):
3659
3188
  cmd, kwargs = discstation_host.ps_cmd("play-audio-cd.ps1", device, str(cmd_file))
3660
3189
  proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **kwargs)
3661
3190
 
3662
- lines = Queue()
3663
-
3664
- def read_output():
3665
- try:
3666
- for line in proc.stdout:
3667
- lines.put(line.rstrip("\r\n"))
3668
- finally:
3669
- lines.put(None)
3670
-
3671
- threading.Thread(target=read_output, daemon=True).start()
3191
+ lines, eof = discstation_burn.proc_line_queue(proc)
3672
3192
 
3673
3193
  def send_cmd(text):
3674
3194
  cmd_file.write_text(text + "\n")
@@ -3691,6 +3211,8 @@ def _play_audio_cd_windows(ser, device, track_titles):
3691
3211
  line = lines.get(timeout=0.1)
3692
3212
  except Empty:
3693
3213
  line = None
3214
+ if line is eof:
3215
+ line = None
3694
3216
  if line:
3695
3217
  if line.startswith("TRACK:"):
3696
3218
  try:
@@ -3766,7 +3288,12 @@ def play_flow(ser):
3766
3288
  except FileNotFoundError:
3767
3289
  raise RuntimeError("mpv not found")
3768
3290
 
3291
+ # "eject" once a play loop reports EJECT ended it (see _run_mpv) - acted
3292
+ # on once, after the kind dispatch below, regardless of which branch ran.
3293
+ stop_reason = None
3294
+
3769
3295
  def play_vob_fallback():
3296
+ nonlocal stop_reason
3770
3297
  # No DVD-menu engine available (libdvdnav missing, or on Windows
3771
3298
  # where the plain mpv build never has it) - play the main title's
3772
3299
  # VOBs directly off the mounted volume instead (no menus).
@@ -3786,7 +3313,7 @@ def play_flow(ser):
3786
3313
  "--idle=no",
3787
3314
  *[str(path) for path in files],
3788
3315
  ]
3789
- _run_mpv(ser, cmd, "Playing DVD", kind)
3316
+ stop_reason = _run_mpv(ser, cmd, "Playing DVD", kind)
3790
3317
 
3791
3318
  if kind == "dvd_video":
3792
3319
  if discstation_host.system_name() == "darwin":
@@ -3799,7 +3326,7 @@ def play_flow(ser):
3799
3326
  "--dvd-device=" + rip_device(device),
3800
3327
  "dvdnav://",
3801
3328
  ]
3802
- _run_mpv(ser, cmd, "Playing DVD", kind)
3329
+ stop_reason = _run_mpv(ser, cmd, "Playing DVD", kind)
3803
3330
  except RuntimeError:
3804
3331
  # libdvdnav couldn't open the disc.
3805
3332
  play_vob_fallback()
@@ -3845,7 +3372,7 @@ def play_flow(ser):
3845
3372
  if audio_device:
3846
3373
  cmd.insert(1, "--audio-device=" + audio_device)
3847
3374
  print(f"Audio CD output: {audio_device}")
3848
- _run_mpv(ser, cmd, "Playing audio CD", kind, track_titles, track_starts, stdin_proc=rip_proc)
3375
+ stop_reason = _run_mpv(ser, cmd, "Playing audio CD", kind, track_titles, track_starts, stdin_proc=rip_proc)
3849
3376
  else:
3850
3377
  audio_device = discstation_host.audio_output_device()
3851
3378
  cmd = [
@@ -3860,7 +3387,7 @@ def play_flow(ser):
3860
3387
  if audio_device:
3861
3388
  cmd.insert(1, "--audio-device=" + audio_device)
3862
3389
  print(f"Audio CD output: {audio_device}")
3863
- _run_mpv(ser, cmd, "Playing audio CD", kind, track_titles, track_starts)
3390
+ stop_reason = _run_mpv(ser, cmd, "Playing audio CD", kind, track_titles, track_starts)
3864
3391
 
3865
3392
  elif kind in ("vcd", "svcd", "video_data"):
3866
3393
  with mounted_disc(device) as mount_dir:
@@ -3874,11 +3401,23 @@ def play_flow(ser):
3874
3401
  "--idle=no",
3875
3402
  *[str(path) for path in files],
3876
3403
  ]
3877
- _run_mpv(ser, cmd, f"Playing {kind.upper()}", kind)
3404
+ stop_reason = _run_mpv(ser, cmd, f"Playing {kind.upper()}", kind)
3878
3405
 
3879
3406
  else:
3880
3407
  raise RuntimeError(f"Unsupported disc: {kind}")
3881
3408
 
3409
+ if stop_reason == "eject":
3410
+ safe_send(ser, "STATUS:Ejecting...")
3411
+ # mpv/cd-paranoia just got killed above - give the OS a moment to
3412
+ # actually release the device handle before touching it again, or
3413
+ # the primary `eject` command can hit "Device or resource busy" and
3414
+ # fall back to the raw SCSI path, seen live to behave differently
3415
+ # (the tray got marked closed again within ~11s of a real eject).
3416
+ time.sleep(1)
3417
+ try:
3418
+ eject_disc(ser, device)
3419
+ except Exception as e:
3420
+ print(f"Eject after play failed: {e}")
3882
3421
  safe_send(ser, "DONE:Playback stopped")
3883
3422
  time.sleep(3)
3884
3423
 
@@ -4020,12 +3559,12 @@ def handbrake_rip_main_feature(ser, device, out_dir, title_index):
4020
3559
  return dest
4021
3560
 
4022
3561
 
4023
- def rip_flow(ser, artist_hint=None, album_hint=None):
3562
+ def rip_flow(ser):
4024
3563
  device = discstation_burn.disc_device()
4025
3564
  kind = disc_kind(device)
4026
3565
 
4027
3566
  if kind == "audio_cd":
4028
- rip_audio_cd(ser, device, artist_hint, album_hint)
3567
+ rip_audio_cd(ser, device)
4029
3568
  return
4030
3569
 
4031
3570
  if kind in ("vcd", "svcd", "video_data"):
@@ -4083,7 +3622,6 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
4083
3622
  safe_send(ser, "DONE:Rip complete!")
4084
3623
  print(f"Rip complete: {out_dir}")
4085
3624
  out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
4086
- chown_to_sudo_user(out_dir)
4087
3625
  time.sleep(3)
4088
3626
  return
4089
3627
 
@@ -4108,7 +3646,6 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
4108
3646
  safe_send(ser, "DONE:Rip complete!")
4109
3647
  print(f"Rip complete: {out_dir}")
4110
3648
  out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
4111
- chown_to_sudo_user(out_dir)
4112
3649
  time.sleep(3)
4113
3650
  return
4114
3651
 
@@ -4158,7 +3695,7 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
4158
3695
  raise
4159
3696
 
4160
3697
  if proc.wait() != 0:
4161
- if not disc_present(device):
3698
+ if drive_status(device) in ("open", "no_disc"):
4162
3699
  raise RuntimeError("Disc was removed during rip")
4163
3700
  raise RuntimeError("Rip failed")
4164
3701
 
@@ -4166,7 +3703,6 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
4166
3703
  safe_send(ser, "DONE:Rip complete!")
4167
3704
  print(f"Rip complete: {out_dir}")
4168
3705
  out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
4169
- chown_to_sudo_user(out_dir)
4170
3706
  time.sleep(3)
4171
3707
 
4172
3708
 
@@ -4206,7 +3742,6 @@ def rip_video_disc(ser, device, kind):
4206
3742
  safe_send(ser, "DONE:Rip complete!")
4207
3743
  print(f"Video rip complete: {out_dir}")
4208
3744
  out_dir = _finalize_video_rip(ser, out_dir, device, kind)
4209
- chown_to_sudo_user(out_dir)
4210
3745
  time.sleep(3)
4211
3746
 
4212
3747
 
@@ -4237,7 +3772,7 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
4237
3772
  )
4238
3773
  output = []
4239
3774
  try:
4240
- for line in _iter_proc_lines(proc, ser):
3775
+ for line in discstation_burn.iter_proc_or_cancel(proc, ser):
4241
3776
  output.append(line)
4242
3777
  count = len(list(wav_dir.glob("*.wav")))
4243
3778
  if count:
@@ -4281,18 +3816,17 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
4281
3816
  shutil.rmtree(str(wav_dir), ignore_errors=True)
4282
3817
  safe_send(ser, "PROGRESS:100%")
4283
3818
  safe_send(ser, "DONE:Rip complete!")
4284
- chown_to_sudo_user(out_dir)
4285
3819
  time.sleep(3)
4286
3820
 
4287
3821
 
4288
- def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
3822
+ def rip_audio_cd(ser, device):
4289
3823
  chapters = audio_cd_chapters(device)
4290
3824
  metadata = None
4291
3825
  cover_path = None
4292
3826
 
4293
3827
  _raise_if_cancelled(ser)
4294
3828
  send(ser, "STATUS:Looking up CD...")
4295
- metadata = audio_metadata_lookup(device, len(chapters), artist_hint, album_hint)
3829
+ metadata = audio_metadata_lookup(device, len(chapters))
4296
3830
  _raise_if_cancelled(ser)
4297
3831
 
4298
3832
  if metadata:
@@ -4368,7 +3902,7 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
4368
3902
  )
4369
3903
 
4370
3904
  try:
4371
- for line in _iter_proc_lines(proc, ser):
3905
+ for line in discstation_burn.iter_proc_or_cancel(proc, ser):
4372
3906
  print(line, end="")
4373
3907
  secs = parse_ffmpeg_time(line)
4374
3908
  if secs is not None and rip_duration > 0:
@@ -4385,7 +3919,7 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
4385
3919
  safe_send(ser, "CANCELLED:Rip cancelled")
4386
3920
  return
4387
3921
  elif proc.returncode != 0:
4388
- if not disc_present(device):
3922
+ if drive_status(device) in ("open", "no_disc"):
4389
3923
  raise RuntimeError("Disc was removed during rip")
4390
3924
  raise RuntimeError("Audio CD rip failed")
4391
3925
 
@@ -4421,12 +3955,11 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
4421
3955
  safe_send(ser, "PROGRESS:100%")
4422
3956
  safe_send(ser, "DONE:Rip complete!")
4423
3957
  print(f"Audio rip complete: {out_dir}")
4424
- chown_to_sudo_user(out_dir)
4425
3958
  time.sleep(3)
4426
3959
 
4427
3960
 
4428
- def station_loop(ser, url, artist_hint=None, album_hint=None):
4429
- global _last_burn_result, _last_burn_result_time, _tray_open, _tray_open_since, _operation_active
3961
+ def station_loop(ser):
3962
+ global _last_burn_result, _last_burn_result_time, _tray_open, _tray_open_since, _operation_active, _web_op_verb
4430
3963
  discstation_burn.cleanup_old_jobs()
4431
3964
  try:
4432
3965
  device = discstation_burn.disc_device()
@@ -4654,6 +4187,14 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
4654
4187
 
4655
4188
  mode = line.split(":", 1)[1].strip().upper()
4656
4189
  print(f"Selected: {mode}")
4190
+ _web_op_verb = "RIPPING" if mode == "RIP" else "BURNING"
4191
+ # The web remote's mode buttons queue START right behind SELECT: for a
4192
+ # one-click burn (see app.js) - if an earlier selection was abandoned
4193
+ # before its own START got consumed (e.g. never uploaded/confirmed a
4194
+ # URL), that stale START would otherwise sit buffered and fire this
4195
+ # new, different selection instead. Drop anything unconsumed first.
4196
+ if isinstance(ser, VirtualSerial):
4197
+ ser.clear()
4657
4198
 
4658
4199
  # The user picked a mode — they want to act on a disc, so the drive is
4659
4200
  # fair game again even if it was ejected from the OLED earlier.
@@ -4661,16 +4202,13 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
4661
4202
  _operation_active = True # stop /disc-info probing the drive during the flow
4662
4203
  try:
4663
4204
  if mode == "BURN":
4664
- burn_flow(ser, url)
4205
+ burn_flow(ser)
4665
4206
  _last_burn_result = "Burn complete"
4666
4207
  elif mode == "PLAY":
4667
4208
  play_flow(ser)
4668
4209
  elif mode == "RIP":
4669
- rip_flow(ser, artist_hint, album_hint)
4210
+ rip_flow(ser)
4670
4211
  _last_burn_result = "Rip complete"
4671
- elif mode == "BURN MPG":
4672
- burn_mpg_flow(ser)
4673
- _last_burn_result = "Burn complete"
4674
4212
  elif mode == "BURN DATA":
4675
4213
  burn_data_flow(ser)
4676
4214
  _last_burn_result = "Burn complete"
@@ -4738,15 +4276,7 @@ def check_pidfile():
4738
4276
 
4739
4277
  def parse_args():
4740
4278
  parser = argparse.ArgumentParser(description="Physical DVD station controller")
4741
- parser.add_argument("--artist", help="Audio CD album artist hint for metadata fallback")
4742
- parser.add_argument("--album", help="Audio CD album title hint for metadata fallback")
4743
- parser.add_argument(
4744
- "--retag-latest-audio",
4745
- action="store_true",
4746
- help="Retag the newest generic audio_cd_* rip using --artist/--album, then exit",
4747
- )
4748
4279
  parser.add_argument("--port", type=int, default=8080, help="Web interface port")
4749
- parser.add_argument("url", nargs="?", help="YouTube URL or file path for burn mode")
4750
4280
  return parser.parse_args()
4751
4281
 
4752
4282
 
@@ -4760,15 +4290,6 @@ def main():
4760
4290
  exit_code = 0
4761
4291
 
4762
4292
  try:
4763
- if args.retag_latest_audio:
4764
- rip_dir = latest_audio_rip_dir()
4765
- new_dir, cover_path, renamed = retag_audio_rip(rip_dir, args.artist, args.album)
4766
- print(f"Retagged: {new_dir}")
4767
- print(f"Cover: {cover_path or 'not found'}")
4768
- for path in renamed:
4769
- print(path.name)
4770
- return
4771
-
4772
4293
  start_web_server(args.port)
4773
4294
 
4774
4295
  while True:
@@ -4814,7 +4335,7 @@ def main():
4814
4335
  # one now so a connected web remote learns about a newly-attached
4815
4336
  # ESP32 immediately instead of only on its next reload.
4816
4337
  _sse_publish(_status_snapshot())
4817
- station_loop(ser, args.url, args.artist, args.album)
4338
+ station_loop(ser)
4818
4339
  except _HardwareAttached:
4819
4340
  print("ESP32 detected - handing off from the web remote to hardware.")
4820
4341
  except (serial.SerialException, OSError, termios.error) as e: