discstation 0.1.35 → 0.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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:
@@ -1174,6 +1125,67 @@ def append_burn_history(entry):
1174
1125
  f.write(json.dumps(entry) + "\n")
1175
1126
 
1176
1127
 
1128
+ @contextlib.contextmanager
1129
+ def _burn_history(entry, swallow_cancel=False):
1130
+ """Record a burn attempt in the history file however the block ends
1131
+ (success / cancelled / error). `entry` holds the fixed fields (title,
1132
+ disc_type, mode, speed, ...). A cancelled burn re-raises unless
1133
+ swallow_cancel, in which case the caller checks `.cancelled` and returns."""
1134
+ start = time.time()
1135
+ outcome = types.SimpleNamespace(cancelled=False)
1136
+
1137
+ def record(ok, error=None):
1138
+ row = {"timestamp": datetime.datetime.now().isoformat(), **entry,
1139
+ "success": ok, "duration_s": round(time.time() - start)}
1140
+ if error:
1141
+ row["error"] = error
1142
+ append_burn_history(row)
1143
+
1144
+ try:
1145
+ yield outcome
1146
+ except (KeyboardInterrupt, SystemExit):
1147
+ record(False, "Cancelled")
1148
+ raise
1149
+ except CancelError:
1150
+ record(False, "Cancelled")
1151
+ if not swallow_cancel:
1152
+ raise
1153
+ outcome.cancelled = True
1154
+ except Exception as e:
1155
+ record(False, str(e)[:100])
1156
+ raise
1157
+ else:
1158
+ record(True)
1159
+
1160
+
1161
+ def _wait_for_start(ser, starting=None, mode=None):
1162
+ """Wait on the remote for START (CANCEL/STOP aborts). Returns
1163
+ (mode, speed) - speed is None unless a SPEED: line came first - or None if
1164
+ the user cancelled. A non-None `mode` (the video flow's) may also be set by
1165
+ MODE:<m> / START:<m>. `starting` names the burn in the status line
1166
+ (defaults to the mode)."""
1167
+ speed = None
1168
+ print("Waiting for burn START button...")
1169
+ while True:
1170
+ line = wait_for_button(ser)
1171
+ if line in ("CANCEL", "PLAY_STOP"):
1172
+ safe_send(ser, "CANCELLED:Cancelled")
1173
+ print("Burn cancelled by user")
1174
+ return None
1175
+ if line.startswith("MODE:") and mode is not None:
1176
+ mode = discstation_burn.normalize_mode(line.split(":", 1)[1])
1177
+ print(f"Burn mode: {mode}")
1178
+ elif line.startswith("SPEED:"):
1179
+ speed = line.split(":", 1)[1].strip()
1180
+ print(f"Burn speed: {speed}")
1181
+ elif line == "START" or line.startswith("START:"):
1182
+ if mode is not None and ":" in line:
1183
+ mode = discstation_burn.normalize_mode(line.split(":", 1)[1])
1184
+ print(f"Starting {starting or mode} burn")
1185
+ send(ser, f"STATUS:Starting {starting or mode}...")
1186
+ return mode, speed
1187
+
1188
+
1177
1189
  def run_probe(cmd, timeout=8, name=None):
1178
1190
  try:
1179
1191
  return subprocess.run(
@@ -1293,60 +1305,6 @@ def _device_present(device):
1293
1305
  return False
1294
1306
 
1295
1307
 
1296
- def _tray_closed_with_disc(device):
1297
- if not device:
1298
- return False
1299
- global _tray_open
1300
- if not _device_present(device):
1301
- return False
1302
- properties = udev_cdrom_properties(device)
1303
- if properties.get("ID_CDROM_MEDIA") == "1":
1304
- _tray_open = False
1305
- return True
1306
- if properties.get("ID_CDROM_MEDIA_STATE") == "blank":
1307
- _tray_open = False
1308
- return True
1309
- return False
1310
-
1311
-
1312
- def disc_present(device):
1313
- if not device:
1314
- return False
1315
- if _tray_closed_with_disc(device):
1316
- return True
1317
- if _tray_open:
1318
- return False
1319
- if not _device_present(device):
1320
- return False
1321
- if discstation_host.system_name() != "linux":
1322
- properties = udev_cdrom_properties(device)
1323
- return properties.get("ID_CDROM_MEDIA") == "1"
1324
-
1325
- toc = run_probe(["wodim", "-toc", "dev=" + device], name="wodim-toc", timeout=PROBE_TIMEOUT_WODIM_TOC)
1326
- toc_text = ensure_text(toc.stdout) + ensure_text(toc.stderr)
1327
- no_media_markers = _NO_MEDIA_MARKERS
1328
- if toc.returncode == 124:
1329
- return is_blank_disc(device)
1330
- if toc_text.strip() and any(marker in toc_text.lower() for marker in no_media_markers):
1331
- return False
1332
-
1333
- dvd = run_probe(["lsdvd", device], name="lsdvd", timeout=PROBE_TIMEOUT_LSDVD)
1334
- if dvd.returncode == 0:
1335
- return True
1336
-
1337
- fs = run_probe(["blkid", "-o", "value", "-s", "TYPE", device], name="blkid", timeout=PROBE_TIMEOUT_BLKID)
1338
- if fs.returncode == 0 and ensure_text(fs.stdout).strip():
1339
- return True
1340
-
1341
- if "first:" in toc_text and "track:" in toc_text:
1342
- return True
1343
-
1344
- if toc_text.strip() and not any(marker in toc_text.lower() for marker in no_media_markers):
1345
- return True
1346
-
1347
- return is_blank_disc(device)
1348
-
1349
-
1350
1308
  def is_blank_disc(device):
1351
1309
  if not device:
1352
1310
  return False
@@ -1915,9 +1873,6 @@ def menu_items_for_disc(device):
1915
1873
  props = udev_cdrom_properties(device)
1916
1874
  is_cd = props.get("ID_CDROM_MEDIA_CD_R") == "1" or props.get("ID_CDROM_MEDIA_CD_RW") == "1"
1917
1875
  items = ["BURN DATA", "BURN AUDIO"] if is_cd else ["BURN", "BURN DATA"]
1918
- had = discstation_burn.WORK.rglob("movie.mpg")
1919
- if any(True for _ in had):
1920
- items.append("BURN MPG")
1921
1876
  elif kind in ("dvd_video", "audio_cd", "vcd", "svcd", "video_data", "data_disc", "data_cd"):
1922
1877
  items = ["PLAY", "RIP"]
1923
1878
  else:
@@ -2217,10 +2172,6 @@ def _release_meta(release, track_count, toc=None):
2217
2172
  return None
2218
2173
 
2219
2174
 
2220
- # Back-compat alias (older name).
2221
- metadata_from_musicbrainz_release = _release_meta
2222
-
2223
-
2224
2175
  def musicbrainz_release_details(release_id):
2225
2176
  if _mb is not None:
2226
2177
  return _mb.get_release_by_id(
@@ -2277,58 +2228,6 @@ def musicbrainz_lookup(device, track_count):
2277
2228
  return None
2278
2229
 
2279
2230
 
2280
- def musicbrainz_lookup_by_album_hints(album_artist, album, track_count):
2281
- if not album:
2282
- return None
2283
-
2284
- if _mb is not None:
2285
- fields = {"release": album}
2286
- if album_artist:
2287
- fields["artist"] = album_artist
2288
- try:
2289
- hits = _mb.search_releases(limit=8, **fields).get("release-list", [])
2290
- except _mb.WebServiceError as e:
2291
- print(f"MusicBrainz search failed: {e}")
2292
- hits = []
2293
- for hit in hits:
2294
- release_id = hit.get("id")
2295
- if not release_id:
2296
- continue
2297
- try:
2298
- details = musicbrainz_release_details(release_id)
2299
- except Exception:
2300
- continue
2301
- metadata = _release_meta(details, track_count)
2302
- if metadata:
2303
- metadata["source"] = "musicbrainz-search"
2304
- return metadata
2305
- return None
2306
-
2307
- # --- fallback: raw ws/2 search ---
2308
- query_parts = [f'release:"{album}"']
2309
- if album_artist:
2310
- query_parts.append(f'artist:"{album_artist}"')
2311
- response = requests.get(
2312
- "https://musicbrainz.org/ws/2/release/",
2313
- params={"query": " AND ".join(query_parts), "fmt": "json", "limit": 8},
2314
- headers={"User-Agent": USER_AGENT}, timeout=20,
2315
- )
2316
- response.raise_for_status()
2317
- for release in response.json().get("releases", []):
2318
- release_id = release.get("id")
2319
- if not release_id:
2320
- continue
2321
- try:
2322
- details = musicbrainz_release_details(release_id)
2323
- except Exception:
2324
- continue
2325
- metadata = _release_meta(details, track_count)
2326
- if metadata:
2327
- metadata["source"] = "musicbrainz-search"
2328
- return metadata
2329
- time.sleep(1)
2330
- return None
2331
-
2332
2231
  def cddb_sum(value):
2333
2232
  return sum(int(ch) for ch in str(value))
2334
2233
 
@@ -2461,7 +2360,7 @@ def musicbrainz_release_id_search(album_artist, album):
2461
2360
  return releases[0].get("id") if releases else None
2462
2361
 
2463
2362
 
2464
- def audio_metadata_lookup(device, track_count, artist_hint=None, album_hint=None):
2363
+ def audio_metadata_lookup(device, track_count):
2465
2364
  metadata = None
2466
2365
 
2467
2366
  for attempt in range(2):
@@ -2474,17 +2373,6 @@ def audio_metadata_lookup(device, track_count, artist_hint=None, album_hint=None
2474
2373
  print(f"MusicBrainz lookup failed, retrying... ({e})")
2475
2374
  time.sleep(1)
2476
2375
 
2477
- if not metadata and album_hint:
2478
- for attempt in range(2):
2479
- try:
2480
- metadata = musicbrainz_lookup_by_album_hints(artist_hint, album_hint, track_count)
2481
- if metadata:
2482
- break
2483
- except Exception as e:
2484
- if attempt == 0:
2485
- print(f"MusicBrainz album search failed, retrying... ({e})")
2486
- time.sleep(1)
2487
-
2488
2376
  if metadata and not metadata.get("release_id"):
2489
2377
  try:
2490
2378
  metadata["release_id"] = musicbrainz_release_id_search(
@@ -2599,60 +2487,6 @@ def tag_flac(path, track_meta, album_meta, cover_path):
2599
2487
  audio.save()
2600
2488
 
2601
2489
 
2602
- def retag_audio_rip(rip_dir, artist_hint, album_hint):
2603
- rip_dir = Path(rip_dir)
2604
- flacs = sorted(rip_dir.glob("*.flac"))
2605
- if not flacs:
2606
- raise RuntimeError(f"No FLAC files found in {rip_dir}")
2607
- if not album_hint:
2608
- raise RuntimeError("Retag needs --album")
2609
-
2610
- metadata = musicbrainz_lookup_by_album_hints(artist_hint, album_hint, len(flacs))
2611
- if not metadata:
2612
- raise RuntimeError("Could not find album metadata")
2613
-
2614
- target_dir = unique_dir(RIP_ROOT / safe_path_name(f"{metadata['album_artist']} - {metadata['album']}"))
2615
- if rip_dir != target_dir:
2616
- rip_dir.rename(target_dir)
2617
- else:
2618
- target_dir = rip_dir
2619
-
2620
- write_album_info(target_dir, metadata)
2621
- cover_path = download_cover_art(
2622
- metadata.get("release_id"),
2623
- target_dir,
2624
- metadata.get("release_group_id"),
2625
- )
2626
-
2627
- renamed = []
2628
- for index, src in enumerate(sorted(target_dir.glob("*.flac")), start=1):
2629
- if index > len(metadata["tracks"]):
2630
- break
2631
-
2632
- track_meta = metadata["tracks"][index - 1]
2633
- out_file = target_dir / f"{index:02d} - {safe_path_name(track_meta['title'])}.flac"
2634
- if src != out_file:
2635
- if out_file.exists():
2636
- out_file.unlink()
2637
- src.rename(out_file)
2638
-
2639
- tag_flac(out_file, track_meta, metadata, cover_path)
2640
- renamed.append(out_file)
2641
-
2642
- chown_to_sudo_user(target_dir)
2643
- return target_dir, cover_path, renamed
2644
-
2645
-
2646
- def latest_audio_rip_dir():
2647
- candidates = sorted(
2648
- path for path in RIP_ROOT.glob("audio_cd_*")
2649
- if path.is_dir() and list(path.glob("*.flac"))
2650
- )
2651
- if not candidates:
2652
- raise RuntimeError("No audio_cd_* rip folders found")
2653
- return candidates[-1]
2654
-
2655
-
2656
2490
  def parse_ffmpeg_time(line):
2657
2491
  marker = "time="
2658
2492
  if marker not in line:
@@ -2668,41 +2502,22 @@ def parse_ffmpeg_time(line):
2668
2502
  from discstation_burn import CancelError
2669
2503
 
2670
2504
 
2671
- def _check_cancel(ser):
2672
- try:
2673
- line = read_serial_line(ser, timeout=0)
2674
- return line in ("CANCEL", "PLAY_STOP") if line else False
2675
- except OSError:
2676
- return False
2677
-
2678
-
2679
2505
  def _raise_if_cancelled(ser):
2680
2506
  """Poll for a CANCEL press between blocking phases that have no
2681
2507
  subprocess loop of their own (metadata lookups, scans, cover-art
2682
2508
  downloads). Doesn't interrupt a call in progress, but catches the press
2683
2509
  the moment the phase returns."""
2684
- if _check_cancel(ser):
2510
+ if discstation_burn.check_cancel(ser):
2685
2511
  raise CancelError
2686
2512
 
2687
2513
 
2688
2514
  def iter_process_events(proc, idle_seconds=1.0, ser=None):
2689
- lines = 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()
2515
+ lines, finished = discstation_burn.proc_line_queue(proc)
2701
2516
  last_ping = time.time()
2702
2517
  output_done = False
2703
2518
  while proc.poll() is None or not output_done:
2704
2519
  if ser is not None:
2705
- if _check_cancel(ser):
2520
+ if discstation_burn.check_cancel(ser):
2706
2521
  discstation_burn.stop_process(proc)
2707
2522
  raise CancelError
2708
2523
  if time.time() - last_ping >= 5:
@@ -2717,7 +2532,6 @@ def iter_process_events(proc, idle_seconds=1.0, ser=None):
2717
2532
  output_done = True
2718
2533
  else:
2719
2534
  yield line
2720
- reader.join(timeout=1)
2721
2535
 
2722
2536
 
2723
2537
  def rip_device(device):
@@ -2751,33 +2565,22 @@ def directory_size_bytes(path):
2751
2565
  return total
2752
2566
 
2753
2567
 
2754
- def _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
2568
+ def _wait_for_source(ser, missing="URL or file path"):
2569
+ """Block until the web remote supplies a source (an upload dir or a pasted
2570
+ URL/path). Returns None - after telling the remote why - if the user
2571
+ cancelled or sent nothing."""
2572
+ url = wait_for_web_url(ser)
2573
+ if url is None:
2574
+ safe_send(ser, "CANCELLED:Cancelled")
2575
+ elif not url:
2576
+ safe_send(ser, f"ERROR:Need {missing}")
2577
+ return url or None
2761
2578
 
2762
2579
 
2763
- def burn_flow(ser, url):
2580
+ def burn_flow(ser):
2581
+ url = _wait_for_source(ser)
2764
2582
  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
2583
+ return
2781
2584
 
2782
2585
  device = discstation_burn.disc_device()
2783
2586
  disc_bytes = discstation_burn.disc_capacity_bytes(device)
@@ -2837,31 +2640,13 @@ def burn_flow(ser, url):
2837
2640
  except RuntimeError:
2838
2641
  pass
2839
2642
 
2840
- 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
2643
+ started = _wait_for_start(ser, mode="AUTO")
2644
+ if not started:
2645
+ return
2646
+ selected_mode, burn_speed = started
2861
2647
 
2862
- start_time = time.time()
2863
2648
  disc_type_label = "DL" if dl_info["is_dual_layer"] else "SL"
2864
- try:
2649
+ with _burn_history({"title": title, "disc_type": disc_type_label, "mode": selected_mode, "speed": burn_speed or "Auto"}):
2865
2650
  plan = discstation_burn.bitrate_plan(duration, selected_mode, disc_bytes)
2866
2651
  video = discstation_burn.download(ser, url, job_dir)
2867
2652
  mpg, dvd_aspect = discstation_burn.convert(ser, video, job_dir, selected_mode, disc_bytes)
@@ -2885,121 +2670,9 @@ def burn_flow(ser, url):
2885
2670
  safe_send(ser, "DONE:Test complete!")
2886
2671
  print(f"Test complete. DVD folder: {dvd_dir}")
2887
2672
 
2888
- append_burn_history({
2889
- "timestamp": datetime.datetime.now().isoformat(),
2890
- "title": title,
2891
- "disc_type": disc_type_label,
2892
- "mode": selected_mode,
2893
- "speed": burn_speed or "Auto",
2894
- "success": True,
2895
- "duration_s": round(time.time() - start_time),
2896
- })
2897
- except (KeyboardInterrupt, SystemExit):
2898
- append_burn_history({
2899
- "timestamp": datetime.datetime.now().isoformat(),
2900
- "title": title,
2901
- "disc_type": disc_type_label,
2902
- "mode": selected_mode,
2903
- "speed": burn_speed or "Auto",
2904
- "success": False,
2905
- "error": "Cancelled",
2906
- "duration_s": round(time.time() - start_time),
2907
- })
2908
- raise
2909
- except Exception as e:
2910
- append_burn_history({
2911
- "timestamp": datetime.datetime.now().isoformat(),
2912
- "title": title,
2913
- "disc_type": disc_type_label,
2914
- "mode": selected_mode,
2915
- "speed": burn_speed or "Auto",
2916
- "success": False,
2917
- "error": str(e)[:100],
2918
- "duration_s": round(time.time() - start_time),
2919
- })
2920
- raise
2921
-
2922
2673
  time.sleep(3)
2923
2674
 
2924
2675
 
2925
- def _mpg_label(job_dir):
2926
- label = "DVD_VIDEO"
2927
- dl = job_dir / "download"
2928
- if dl.is_dir():
2929
- for f in sorted(dl.iterdir()):
2930
- if f.suffix.lower() in discstation_burn.VIDEO_EXTS:
2931
- label = discstation_burn.sanitize_disc_label(f.stem)
2932
- break
2933
- else:
2934
- for f in sorted(dl.iterdir()):
2935
- label = discstation_burn.sanitize_disc_label(f.stem)
2936
- break
2937
- if label == "DVD_VIDEO" or not label:
2938
- label = discstation_burn.sanitize_disc_label(job_dir.name)
2939
- return label
2940
-
2941
-
2942
- def burn_mpg_flow(ser):
2943
- jobs = sorted(discstation_burn.WORK.glob("job_*"), reverse=True)
2944
- candidates = []
2945
- for jd in jobs:
2946
- mpg = jd / "movie.mpg"
2947
- if mpg.exists():
2948
- label = _mpg_label(jd)
2949
- candidates.append((mpg, label))
2950
- names = [c[1][:20] for c in candidates] + ["Enter path..."]
2951
- safe_send(ser, f"MENU_ITEMS:{','.join(names)}")
2952
- safe_send(ser, "HOME:Select MPG to burn")
2953
- mpg = None
2954
- disc_label = None
2955
- while True:
2956
- line = read_serial_line(ser, timeout=0.5)
2957
- if not line:
2958
- continue
2959
- if line.startswith("SELECT:"):
2960
- sel = line.split(":", 1)[1].strip()
2961
- if sel == "Enter path...":
2962
- path_str = wait_for_web_url(ser)
2963
- if path_str is None:
2964
- refresh_main_menu(ser)
2965
- return
2966
- path_str = path_str.strip()
2967
- p = Path(path_str)
2968
- if not p.exists():
2969
- safe_send(ser, "ERROR:Path not found")
2970
- time.sleep(2)
2971
- continue
2972
- if p.is_dir():
2973
- mpg = p / "movie.mpg"
2974
- if not mpg.exists():
2975
- safe_send(ser, "ERROR:No movie.mpg in dir")
2976
- time.sleep(2)
2977
- continue
2978
- elif p.suffix.lower() == ".mpg":
2979
- mpg = p
2980
- else:
2981
- safe_send(ser, "ERROR:Not an .mpg file")
2982
- time.sleep(2)
2983
- continue
2984
- disc_label = discstation_burn.sanitize_disc_label(mpg.stem)
2985
- break
2986
- else:
2987
- idx = next((i for i, n in enumerate(candidates) if n[1][:20] == sel), None)
2988
- if idx is not None:
2989
- mpg, disc_label = candidates[idx]
2990
- break
2991
- elif line == "CANCEL":
2992
- safe_send(ser, "CANCELLED:Cancelled")
2993
- refresh_main_menu(ser)
2994
- return
2995
- time.sleep(0.05)
2996
-
2997
- device = discstation_burn.disc_device()
2998
- dl_info = discstation_burn.detect_disc_type(device)
2999
- disc_bytes = dl_info["capacity"]
3000
- discstation_burn.remux_and_burn(ser, mpg, disc_label, disc_bytes, dl_info)
3001
-
3002
-
3003
2676
  def _copy_to_job(ser, src, dst_dir):
3004
2677
  if src.is_dir():
3005
2678
  items = sorted(src.iterdir())
@@ -3015,25 +2688,9 @@ def _copy_to_job(ser, src, dst_dir):
3015
2688
  def burn_data_flow(ser):
3016
2689
  global _last_upload_label
3017
2690
 
3018
- 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
2691
+ url = _wait_for_source(ser)
2692
+ if not url:
2693
+ return
3037
2694
 
3038
2695
  device = discstation_burn.disc_device()
3039
2696
  dl_info = discstation_burn.detect_disc_type(device)
@@ -3062,21 +2719,10 @@ def burn_data_flow(ser):
3062
2719
 
3063
2720
  send(ser, f"TITLE:{title}")
3064
2721
 
3065
- 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
2722
+ started = _wait_for_start(ser, "data burn")
2723
+ if not started:
2724
+ return
2725
+ burn_speed = started[1]
3080
2726
 
3081
2727
  if not can_burn_disc(device):
3082
2728
  raise RuntimeError("No writable disc in drive")
@@ -3087,8 +2733,7 @@ def burn_data_flow(ser):
3087
2733
  download_dir = job_dir / "download"
3088
2734
  download_dir.mkdir()
3089
2735
 
3090
- start_time = time.time()
3091
- try:
2736
+ with _burn_history({"title": title, "disc_type": "Data DVD", "mode": "DATA", "speed": burn_speed or "Auto"}, swallow_cancel=True) as h:
3092
2737
  is_dir = local_path.is_dir() if local_path.exists() else False
3093
2738
  if is_dir:
3094
2739
  files_to_burn = [local_path]
@@ -3130,75 +2775,15 @@ def burn_data_flow(ser):
3130
2775
  safe_send(ser, "DONE:Data disc complete!")
3131
2776
  print("Data burn complete.")
3132
2777
 
3133
- 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
- })
2778
+ if h.cancelled:
3165
2779
  return
3166
- except Exception as e:
3167
- append_burn_history({
3168
- "timestamp": datetime.datetime.now().isoformat(),
3169
- "title": title,
3170
- "disc_type": "Data DVD",
3171
- "mode": "DATA",
3172
- "speed": burn_speed or "Auto",
3173
- "success": False,
3174
- "error": str(e)[:100],
3175
- "duration_s": round(time.time() - start_time),
3176
- })
3177
- raise
3178
-
3179
2780
  time.sleep(3)
3180
2781
 
3181
2782
 
3182
2783
  def burn_audio_flow(ser):
3183
- 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
2784
+ url = _wait_for_source(ser, "path to audio files")
2785
+ if not url:
2786
+ return
3202
2787
 
3203
2788
  src_path = Path(url)
3204
2789
  if not src_path.exists():
@@ -3261,18 +2846,10 @@ def burn_audio_flow(ser):
3261
2846
  send(ser, f"META:Dur {mins}m{secs}s")
3262
2847
  send(ser, f"FIT:CD-R {fits}")
3263
2848
 
3264
- 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
2849
+ started = _wait_for_start(ser, "audio burn")
2850
+ if not started:
2851
+ return
2852
+ burn_speed = started[1]
3276
2853
 
3277
2854
  device = discstation_burn.disc_device()
3278
2855
  if not can_burn_disc(device):
@@ -3280,95 +2857,23 @@ def burn_audio_flow(ser):
3280
2857
  if total_dur > 4740:
3281
2858
  raise RuntimeError(f"Too long for CD-R: {int(total_dur/60)}m{int(total_dur%60)}s > 79m")
3282
2859
 
3283
- start_time = time.time()
3284
- 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:
3285
2863
  discstation_burn.burn_audio_cd(ser, audio_files, disc_label, burn_speed)
3286
2864
  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
- })
2865
+ if h.cancelled:
3313
2866
  return
3314
- except Exception as e:
3315
- append_burn_history({
3316
- "timestamp": datetime.datetime.now().isoformat(),
3317
- "title": disc_label,
3318
- "fingerprint": fingerprint,
3319
- "track_titles": track_titles,
3320
- "disc_type": "Audio CD",
3321
- "mode": "AUDIO",
3322
- "speed": burn_speed or "Auto",
3323
- "success": False,
3324
- "error": str(e)[:100],
3325
- "duration_s": round(time.time() - start_time),
3326
- })
3327
- raise
3328
-
3329
2867
  time.sleep(3)
3330
2868
 
3331
2869
 
3332
- def _iter_proc_lines(proc, ser):
3333
- lines = Queue()
3334
- finished = object()
3335
-
3336
- def read_output():
3337
- try:
3338
- for line in proc.stdout:
3339
- lines.put(line.rstrip("\r\n"))
3340
- finally:
3341
- lines.put(finished)
3342
-
3343
- reader = threading.Thread(target=read_output, daemon=True)
3344
- reader.start()
3345
- last_ping = time.time()
3346
- output_done = False
3347
- while proc.poll() is None or not output_done:
3348
- if time.time() - last_ping >= 5:
3349
- discstation_burn.send(ser, "PING")
3350
- last_ping = time.time()
3351
- if _check_cancel(ser):
3352
- discstation_burn.stop_process(proc)
3353
- return
3354
- try:
3355
- line = lines.get(timeout=0.5)
3356
- except Empty:
3357
- continue
3358
- if line is finished:
3359
- output_done = True
3360
- else:
3361
- yield line
3362
- reader.join(timeout=1)
3363
-
3364
-
3365
2870
  # --- OLED spectrum visualizer -----------------------------------------------
3366
2871
  # Taps the real audio the host is playing - PulseAudio's monitor source on
3367
- # Linux, 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
2872
+ # Linux - not a simulation, and streams it to the remote as
3369
2873
  # "VU:<16 comma-separated 0-63 levels>" at ~15fps. Needs numpy; anywhere else
3370
- # (or without the capture source set up) this quietly no-ops and the remote
3371
- # 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.
3372
2877
  VU_BARS = 16 # must match the firmware's VU_BARS
3373
2878
  VU_RATE_HZ = 15
3374
2879
  VU_SAMPLE_RATE = 22050
@@ -3386,55 +2891,19 @@ def _pulse_default_monitor():
3386
2891
  return f"{sink}.monitor" if sink else None
3387
2892
 
3388
2893
 
3389
- def _darwin_blackhole_input():
3390
- """Index of the 'BlackHole' avfoundation audio device, or None if it's not
3391
- installed. macOS has no built-in loopback source - this requires the user
3392
- to `brew install blackhole-2ch` and set a Multi-Output Device (BlackHole +
3393
- real speakers) as the system's default output, so audio is both audible
3394
- and tapped (see docs/PLATFORM_SUPPORT.md)."""
3395
- try:
3396
- # Device list is on stderr; ffmpeg exits non-zero here, that's normal.
3397
- out = subprocess.run(["ffmpeg", "-f", "avfoundation", "-list_devices", "true", "-i", ""],
3398
- capture_output=True, text=True, timeout=5).stderr
3399
- except Exception:
3400
- return None
3401
- in_audio = False
3402
- for line in out.splitlines():
3403
- if "AVFoundation audio devices" in line:
3404
- in_audio = True
3405
- continue
3406
- if in_audio:
3407
- m = re.search(r"\[(\d+)\]\s+(.*)", line)
3408
- if m and "blackhole" in m.group(2).lower():
3409
- return m.group(1)
3410
- return None
3411
-
3412
-
3413
2894
  def _vu_capture_cmd():
3414
- """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
2895
+ """parec argv streaming raw s16le mono PCM at VU_SAMPLE_RATE from the
2896
+ default sink's monitor, or None if PulseAudio has no default sink."""
2897
+ monitor = _pulse_default_monitor()
2898
+ if not monitor:
2899
+ return None
2900
+ # --latency-msec=50: PulseAudio's default capture buffer is several
2901
+ # hundred ms to seconds (tuned for robust recording, not streaming) -
2902
+ # without this, parec hands us data in ~1.5-2s bursts instead of a
2903
+ # steady trickle, which starves the visualizer for longer than the
2904
+ # firmware's fallback timeout and flickers back to the text screen.
2905
+ return ["parec", "--format=s16le", f"--rate={VU_SAMPLE_RATE}", "--channels=1",
2906
+ "--latency-msec=50", "-d", monitor]
3438
2907
 
3439
2908
 
3440
2909
  def _vu_loop(ser, stop_event, pause_event):
@@ -3494,11 +2963,9 @@ def _vu_loop(ser, stop_event, pause_event):
3494
2963
 
3495
2964
  def start_vu_visualizer(ser):
3496
2965
  """Best-effort: returns (stop_event, pause_event), or (None, None) if the
3497
- visualizer can't run here (no numpy, no 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"):
2966
+ visualizer can't run here (no numpy, no PulseAudio, or a web-only link).
2967
+ Linux only."""
2968
+ if _np is None or isinstance(ser, VirtualSerial) or discstation_host.system_name() != "linux":
3502
2969
  return None, None
3503
2970
  stop_event = threading.Event()
3504
2971
  pause_event = threading.Event()
@@ -3551,7 +3018,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None, s
3551
3018
  # floods journald until the pipe backs up and our own print()/status
3552
3019
  # writes block, wedging the whole play loop. We drive mpv over the IPC
3553
3020
  # socket, so none of that output is wanted.
3554
- proc = subprocess.Popen(run_as_desktop_user(cmd), env=env,
3021
+ proc = subprocess.Popen(cmd, env=env,
3555
3022
  stdin=(stdin_proc.stdout if stdin_proc else None),
3556
3023
  stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
3557
3024
  if stdin_proc:
@@ -3721,16 +3188,7 @@ def _play_audio_cd_windows(ser, device, track_titles):
3721
3188
  cmd, kwargs = discstation_host.ps_cmd("play-audio-cd.ps1", device, str(cmd_file))
3722
3189
  proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **kwargs)
3723
3190
 
3724
- lines = 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()
3191
+ lines, eof = discstation_burn.proc_line_queue(proc)
3734
3192
 
3735
3193
  def send_cmd(text):
3736
3194
  cmd_file.write_text(text + "\n")
@@ -3753,6 +3211,8 @@ def _play_audio_cd_windows(ser, device, track_titles):
3753
3211
  line = lines.get(timeout=0.1)
3754
3212
  except Empty:
3755
3213
  line = None
3214
+ if line is eof:
3215
+ line = None
3756
3216
  if line:
3757
3217
  if line.startswith("TRACK:"):
3758
3218
  try:
@@ -4099,12 +3559,12 @@ def handbrake_rip_main_feature(ser, device, out_dir, title_index):
4099
3559
  return dest
4100
3560
 
4101
3561
 
4102
- def rip_flow(ser, artist_hint=None, album_hint=None):
3562
+ def rip_flow(ser):
4103
3563
  device = discstation_burn.disc_device()
4104
3564
  kind = disc_kind(device)
4105
3565
 
4106
3566
  if kind == "audio_cd":
4107
- rip_audio_cd(ser, device, artist_hint, album_hint)
3567
+ rip_audio_cd(ser, device)
4108
3568
  return
4109
3569
 
4110
3570
  if kind in ("vcd", "svcd", "video_data"):
@@ -4162,7 +3622,6 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
4162
3622
  safe_send(ser, "DONE:Rip complete!")
4163
3623
  print(f"Rip complete: {out_dir}")
4164
3624
  out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
4165
- chown_to_sudo_user(out_dir)
4166
3625
  time.sleep(3)
4167
3626
  return
4168
3627
 
@@ -4187,7 +3646,6 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
4187
3646
  safe_send(ser, "DONE:Rip complete!")
4188
3647
  print(f"Rip complete: {out_dir}")
4189
3648
  out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
4190
- chown_to_sudo_user(out_dir)
4191
3649
  time.sleep(3)
4192
3650
  return
4193
3651
 
@@ -4237,7 +3695,7 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
4237
3695
  raise
4238
3696
 
4239
3697
  if proc.wait() != 0:
4240
- if not disc_present(device):
3698
+ if drive_status(device) in ("open", "no_disc"):
4241
3699
  raise RuntimeError("Disc was removed during rip")
4242
3700
  raise RuntimeError("Rip failed")
4243
3701
 
@@ -4245,7 +3703,6 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
4245
3703
  safe_send(ser, "DONE:Rip complete!")
4246
3704
  print(f"Rip complete: {out_dir}")
4247
3705
  out_dir = _finalize_video_rip(ser, out_dir, device, "dvd_video")
4248
- chown_to_sudo_user(out_dir)
4249
3706
  time.sleep(3)
4250
3707
 
4251
3708
 
@@ -4285,7 +3742,6 @@ def rip_video_disc(ser, device, kind):
4285
3742
  safe_send(ser, "DONE:Rip complete!")
4286
3743
  print(f"Video rip complete: {out_dir}")
4287
3744
  out_dir = _finalize_video_rip(ser, out_dir, device, kind)
4288
- chown_to_sudo_user(out_dir)
4289
3745
  time.sleep(3)
4290
3746
 
4291
3747
 
@@ -4316,7 +3772,7 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
4316
3772
  )
4317
3773
  output = []
4318
3774
  try:
4319
- for line in _iter_proc_lines(proc, ser):
3775
+ for line in discstation_burn.iter_proc_or_cancel(proc, ser):
4320
3776
  output.append(line)
4321
3777
  count = len(list(wav_dir.glob("*.wav")))
4322
3778
  if count:
@@ -4360,18 +3816,17 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
4360
3816
  shutil.rmtree(str(wav_dir), ignore_errors=True)
4361
3817
  safe_send(ser, "PROGRESS:100%")
4362
3818
  safe_send(ser, "DONE:Rip complete!")
4363
- chown_to_sudo_user(out_dir)
4364
3819
  time.sleep(3)
4365
3820
 
4366
3821
 
4367
- def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
3822
+ def rip_audio_cd(ser, device):
4368
3823
  chapters = audio_cd_chapters(device)
4369
3824
  metadata = None
4370
3825
  cover_path = None
4371
3826
 
4372
3827
  _raise_if_cancelled(ser)
4373
3828
  send(ser, "STATUS:Looking up CD...")
4374
- metadata = audio_metadata_lookup(device, len(chapters), artist_hint, album_hint)
3829
+ metadata = audio_metadata_lookup(device, len(chapters))
4375
3830
  _raise_if_cancelled(ser)
4376
3831
 
4377
3832
  if metadata:
@@ -4447,7 +3902,7 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
4447
3902
  )
4448
3903
 
4449
3904
  try:
4450
- for line in _iter_proc_lines(proc, ser):
3905
+ for line in discstation_burn.iter_proc_or_cancel(proc, ser):
4451
3906
  print(line, end="")
4452
3907
  secs = parse_ffmpeg_time(line)
4453
3908
  if secs is not None and rip_duration > 0:
@@ -4464,7 +3919,7 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
4464
3919
  safe_send(ser, "CANCELLED:Rip cancelled")
4465
3920
  return
4466
3921
  elif proc.returncode != 0:
4467
- if not disc_present(device):
3922
+ if drive_status(device) in ("open", "no_disc"):
4468
3923
  raise RuntimeError("Disc was removed during rip")
4469
3924
  raise RuntimeError("Audio CD rip failed")
4470
3925
 
@@ -4500,11 +3955,10 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
4500
3955
  safe_send(ser, "PROGRESS:100%")
4501
3956
  safe_send(ser, "DONE:Rip complete!")
4502
3957
  print(f"Audio rip complete: {out_dir}")
4503
- chown_to_sudo_user(out_dir)
4504
3958
  time.sleep(3)
4505
3959
 
4506
3960
 
4507
- def station_loop(ser, url, artist_hint=None, album_hint=None):
3961
+ def station_loop(ser):
4508
3962
  global _last_burn_result, _last_burn_result_time, _tray_open, _tray_open_since, _operation_active, _web_op_verb
4509
3963
  discstation_burn.cleanup_old_jobs()
4510
3964
  try:
@@ -4748,16 +4202,13 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
4748
4202
  _operation_active = True # stop /disc-info probing the drive during the flow
4749
4203
  try:
4750
4204
  if mode == "BURN":
4751
- burn_flow(ser, url)
4205
+ burn_flow(ser)
4752
4206
  _last_burn_result = "Burn complete"
4753
4207
  elif mode == "PLAY":
4754
4208
  play_flow(ser)
4755
4209
  elif mode == "RIP":
4756
- rip_flow(ser, artist_hint, album_hint)
4210
+ rip_flow(ser)
4757
4211
  _last_burn_result = "Rip complete"
4758
- elif mode == "BURN MPG":
4759
- burn_mpg_flow(ser)
4760
- _last_burn_result = "Burn complete"
4761
4212
  elif mode == "BURN DATA":
4762
4213
  burn_data_flow(ser)
4763
4214
  _last_burn_result = "Burn complete"
@@ -4825,15 +4276,7 @@ def check_pidfile():
4825
4276
 
4826
4277
  def parse_args():
4827
4278
  parser = argparse.ArgumentParser(description="Physical DVD station controller")
4828
- parser.add_argument("--artist", help="Audio CD album artist hint for metadata fallback")
4829
- parser.add_argument("--album", help="Audio CD album title hint for metadata fallback")
4830
- parser.add_argument(
4831
- "--retag-latest-audio",
4832
- action="store_true",
4833
- help="Retag the newest generic audio_cd_* rip using --artist/--album, then exit",
4834
- )
4835
4279
  parser.add_argument("--port", type=int, default=8080, help="Web interface port")
4836
- parser.add_argument("url", nargs="?", help="YouTube URL or file path for burn mode")
4837
4280
  return parser.parse_args()
4838
4281
 
4839
4282
 
@@ -4847,15 +4290,6 @@ def main():
4847
4290
  exit_code = 0
4848
4291
 
4849
4292
  try:
4850
- if args.retag_latest_audio:
4851
- rip_dir = latest_audio_rip_dir()
4852
- new_dir, cover_path, renamed = retag_audio_rip(rip_dir, args.artist, args.album)
4853
- print(f"Retagged: {new_dir}")
4854
- print(f"Cover: {cover_path or 'not found'}")
4855
- for path in renamed:
4856
- print(path.name)
4857
- return
4858
-
4859
4293
  start_web_server(args.port)
4860
4294
 
4861
4295
  while True:
@@ -4901,7 +4335,7 @@ def main():
4901
4335
  # one now so a connected web remote learns about a newly-attached
4902
4336
  # ESP32 immediately instead of only on its next reload.
4903
4337
  _sse_publish(_status_snapshot())
4904
- station_loop(ser, args.url, args.artist, args.album)
4338
+ station_loop(ser)
4905
4339
  except _HardwareAttached:
4906
4340
  print("ESP32 detected - handing off from the web remote to hardware.")
4907
4341
  except (serial.SerialException, OSError, termios.error) as e: