discstation 0.1.22 → 0.1.26

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.
@@ -114,9 +114,87 @@ class VirtualSerial:
114
114
  pass
115
115
 
116
116
 
117
+ class TcpSerial:
118
+ """serial.Serial look-alike over a TCP socket to the ESP32's Wi-Fi link
119
+ (firmware's WiFiServer on port 2323). Exposes the same tiny surface
120
+ station_loop and the flow functions use - write / readline / in_waiting /
121
+ read / close / setDTR - so nothing downstream knows it isn't a wire.
122
+ A dead link raises serial.SerialException from in_waiting/write, which is
123
+ what check_serial_alive() / main()'s reconnect loop already expect."""
124
+
125
+ def __init__(self, host, port=2323, connect_timeout=5):
126
+ if host.count(":") == 1 and not host.startswith("["): # "ip:port"
127
+ host, _, p = host.rpartition(":")
128
+ if p.isdigit():
129
+ port = int(p)
130
+ self._sock = socket.create_connection((host, port), timeout=connect_timeout)
131
+ self._sock.settimeout(0.05)
132
+ self._buf = b""
133
+ self._lock = threading.Lock()
134
+ self._alive = True
135
+ self._reader = threading.Thread(target=self._pump, daemon=True)
136
+ self._reader.start()
137
+
138
+ def _pump(self):
139
+ while self._alive:
140
+ try:
141
+ chunk = self._sock.recv(4096)
142
+ except socket.timeout:
143
+ continue
144
+ except OSError:
145
+ break
146
+ if not chunk:
147
+ break
148
+ with self._lock:
149
+ self._buf += chunk
150
+ self._alive = False
151
+
152
+ @property
153
+ def in_waiting(self):
154
+ if not self._alive:
155
+ raise serial.SerialException("Wi-Fi remote link closed")
156
+ with self._lock:
157
+ return len(self._buf)
158
+
159
+ def read(self, n=1):
160
+ with self._lock:
161
+ data, self._buf = self._buf[:n], self._buf[n:]
162
+ return data
163
+
164
+ def readline(self):
165
+ with self._lock:
166
+ idx = self._buf.find(b"\n")
167
+ if idx < 0:
168
+ data, self._buf = self._buf, b""
169
+ return data
170
+ line, self._buf = self._buf[:idx + 1], self._buf[idx + 1:]
171
+ return line
172
+
173
+ def write(self, data):
174
+ if not self._alive:
175
+ raise serial.SerialException("Wi-Fi remote link closed")
176
+ try:
177
+ self._sock.sendall(data)
178
+ return len(data) if data else 0
179
+ except OSError as e:
180
+ self._alive = False
181
+ raise serial.SerialException(f"Wi-Fi remote write failed: {e}") from e
182
+
183
+ def close(self):
184
+ self._alive = False
185
+ try:
186
+ self._sock.close()
187
+ except OSError:
188
+ pass
189
+
190
+ def setDTR(self, value):
191
+ pass
192
+
193
+
117
194
  class _HardwareAttached(Exception):
118
- """Raised out of station_loop when a real ESP32 appears while running on
119
- a VirtualSerial, so main() can hand control over to it."""
195
+ """Raised out of station_loop when a real link (USB serial or the Wi-Fi
196
+ remote) appears while running on a VirtualSerial, so main() can hand
197
+ control over to it."""
120
198
 
121
199
 
122
200
  class _WebHandler(http.server.BaseHTTPRequestHandler):
@@ -825,7 +903,12 @@ _line_buf = b""
825
903
  def read_serial_line(ser, timeout=0.1):
826
904
  global _line_buf
827
905
  deadline = time.monotonic() + timeout
828
- while time.monotonic() < deadline:
906
+ # Always make at least one non-blocking pass, even for timeout<=0 - the
907
+ # `remaining <= 0: break` at the bottom still ends it after that pass.
908
+ # (`while time.monotonic() < deadline` used to skip the body entirely for
909
+ # timeout=0, which is exactly how _check_cancel() calls this - so cancel
910
+ # detection during rips silently never read the port.)
911
+ while True:
829
912
  if _line_buf:
830
913
  _line_buf = _line_buf.lstrip(b'\r\n')
831
914
  if _line_buf:
@@ -870,6 +953,10 @@ def read_serial_line(ser, timeout=0.1):
870
953
  return None
871
954
 
872
955
 
956
+ # Let the burn pipeline's check_cancel() share this buffered reader.
957
+ discstation_burn.line_reader = read_serial_line
958
+
959
+
873
960
  def check_serial_alive(ser=None):
874
961
  """Raise serial.SerialException if the ESP32 link looks dead, so main()'s
875
962
  reconnect loop can re-scan for the (possibly renumbered) serial port.
@@ -2526,6 +2613,15 @@ def _check_cancel(ser):
2526
2613
  return False
2527
2614
 
2528
2615
 
2616
+ def _raise_if_cancelled(ser):
2617
+ """Poll for a CANCEL press between blocking phases that have no
2618
+ subprocess loop of their own (metadata lookups, scans, cover-art
2619
+ downloads). Doesn't interrupt a call in progress, but catches the press
2620
+ the moment the phase returns."""
2621
+ if _check_cancel(ser):
2622
+ raise CancelError
2623
+
2624
+
2529
2625
  def iter_process_events(proc, idle_seconds=1.0, ser=None):
2530
2626
  lines = Queue()
2531
2627
  finished = object()
@@ -3221,7 +3317,13 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3221
3317
  except Exception:
3222
3318
  pass
3223
3319
 
3224
- proc = subprocess.Popen(run_as_desktop_user(cmd), env=env)
3320
+ # Discard mpv's own output. Its terminal status line ("A: 00:04 / 00:15
3321
+ # ...") prints several times a second; left inheriting our stdout it
3322
+ # floods journald until the pipe backs up and our own print()/status
3323
+ # writes block, wedging the whole play loop. We drive mpv over the IPC
3324
+ # socket, so none of that output is wanted.
3325
+ proc = subprocess.Popen(run_as_desktop_user(cmd), env=env,
3326
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
3225
3327
 
3226
3328
  try:
3227
3329
  if not wait_for_socket(MPV_SOCKET, proc):
@@ -3275,7 +3377,10 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3275
3377
  mpv_command(["set_property", "speed", 1.0])
3276
3378
  send(ser, "PLAY_STATUS:PAUSED" if paused else "PLAY_STATUS:PLAYING")
3277
3379
 
3278
- elif line == "PLAY_STOP":
3380
+ elif line in ("PLAY_STOP", "EJECT"):
3381
+ # EJECT during playback = stop first; the web remote has no
3382
+ # separate stop button, and without this the command is
3383
+ # silently dropped here and playback never ends.
3279
3384
  send(ser, "STATUS:Stopping play")
3280
3385
  discstation_burn.stop_process(proc)
3281
3386
  break
@@ -3760,7 +3865,10 @@ def rip_flow(ser, artist_hint=None, album_hint=None):
3760
3865
  time.sleep(3)
3761
3866
  return
3762
3867
 
3868
+ _raise_if_cancelled(ser)
3869
+ send(ser, "STATUS:Scanning disc...")
3763
3870
  scan = handbrake_scan(device)
3871
+ _raise_if_cancelled(ser)
3764
3872
  if scan:
3765
3873
  main = scan["main_feature"]
3766
3874
  mins = next((t["duration_s"] // 60 for t in scan["titles"] if t["index"] == main), 0)
@@ -3863,6 +3971,7 @@ def rip_video_disc(ser, device, kind):
3863
3971
  raise RuntimeError("No video files found")
3864
3972
 
3865
3973
  for index, src in enumerate(files, start=1):
3974
+ _raise_if_cancelled(ser)
3866
3975
  send(ser, f"PROGRESS:File {index}/{len(files)}")
3867
3976
  dest = out_dir / f"{index:02d} - {safe_path_name(src.stem)}.mpg"
3868
3977
  print(f"Ripping {src.name} -> {dest.name}")
@@ -3923,6 +4032,7 @@ def _rip_audio_cd_macos(ser, device, chapters, metadata, cover_path, out_dir):
3923
4032
  if len(wav_files) < len(chapters):
3924
4033
  raise RuntimeError(f"Only ripped {len(wav_files)}/{len(chapters)} tracks")
3925
4034
  for index, wav in enumerate(wav_files[:len(chapters)], start=1):
4035
+ _raise_if_cancelled(ser)
3926
4036
  chapter = chapters[index - 1]
3927
4037
  if metadata and index <= len(metadata["tracks"]):
3928
4038
  track_meta = metadata["tracks"][index - 1]
@@ -3958,8 +4068,10 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
3958
4068
  metadata = None
3959
4069
  cover_path = None
3960
4070
 
4071
+ _raise_if_cancelled(ser)
3961
4072
  send(ser, "STATUS:Looking up CD...")
3962
4073
  metadata = audio_metadata_lookup(device, len(chapters), artist_hint, album_hint)
4074
+ _raise_if_cancelled(ser)
3963
4075
 
3964
4076
  if metadata:
3965
4077
  album_folder = safe_path_name(f"{metadata['album_artist']} - {metadata['album']}")
@@ -3978,6 +4090,7 @@ def rip_audio_cd(ser, device, artist_hint=None, album_hint=None):
3978
4090
  out_dir,
3979
4091
  metadata.get("release_group_id"),
3980
4092
  )
4093
+ _raise_if_cancelled(ser)
3981
4094
 
3982
4095
  send(ser, "STATUS:Ripping audio CD")
3983
4096
  if metadata:
@@ -4222,14 +4335,15 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
4222
4335
  last_ping = now
4223
4336
  safe_send(ser, "PING")
4224
4337
  if isinstance(ser, VirtualSerial):
4225
- # Safe point (no flow active) to check whether a real ESP32
4226
- # has appeared - hand off to it instead of the web remote.
4338
+ # Safe point (no flow active) to check whether a real link -
4339
+ # USB serial or the Wi-Fi remote - has appeared, and hand off
4340
+ # to it instead of the web remote.
4227
4341
  try:
4228
- port = discstation_host.serial_port()
4342
+ link = discstation_host.serial_port() or discstation_host.remote_host()
4229
4343
  except Exception:
4230
- port = None
4231
- if port:
4232
- raise _HardwareAttached(port)
4344
+ link = None
4345
+ if link:
4346
+ raise _HardwareAttached(link)
4233
4347
  else:
4234
4348
  check_serial_alive(ser)
4235
4349
 
@@ -4348,6 +4462,11 @@ def station_loop(ser, url, artist_hint=None, album_hint=None):
4348
4462
 
4349
4463
  except KeyboardInterrupt:
4350
4464
  raise
4465
+ except CancelError:
4466
+ safe_send(ser, "CANCELLED:Cancelled")
4467
+ _last_burn_result = "Cancelled"
4468
+ print(f"{mode} cancelled by user")
4469
+ time.sleep(2)
4351
4470
  except Exception as e:
4352
4471
  safe_send(ser, f"ERROR:{str(e)[:50]}")
4353
4472
  _last_burn_result = f"ERROR: {e}"
@@ -4433,6 +4552,9 @@ def main():
4433
4552
  while True:
4434
4553
  try:
4435
4554
  _line_buf = b""
4555
+ ser = None
4556
+
4557
+ # 1. USB serial wins whenever it's present (no mDNS scan then).
4436
4558
  port = discstation_host.serial_port()
4437
4559
  if port:
4438
4560
  print(f"Using ESP32 serial port: {port}")
@@ -4444,10 +4566,23 @@ def main():
4444
4566
  time.sleep(2)
4445
4567
  discstation_burn.reset_serial_state()
4446
4568
  _appliance_mode = "hardware"
4447
- else:
4448
- # No ESP32 found - run fully useful off the on-screen web
4449
- # remote instead of retrying forever (station_loop already
4450
- # publishes status via status_sink regardless of ser).
4569
+
4570
+ # 2. Else look for a Wi-Fi remote (DISC_REMOTE_HOST, default auto/mDNS).
4571
+ if ser is None:
4572
+ remote = discstation_host.remote_host()
4573
+ if remote:
4574
+ try:
4575
+ print(f"Connecting to Wi-Fi remote at {remote}:2323 ...")
4576
+ ser = TcpSerial(remote)
4577
+ discstation_burn.reset_serial_state()
4578
+ _appliance_mode = "hardware"
4579
+ print(f"Wi-Fi remote link up ({remote}).")
4580
+ except OSError as e:
4581
+ print(f"Wi-Fi remote {remote} unreachable ({e}); using web remote.")
4582
+ ser = None
4583
+
4584
+ # 3. Else the on-screen web/app remote is the control surface.
4585
+ if ser is None:
4451
4586
  print("No ESP32 found - running in software-only mode (web remote).")
4452
4587
  ser = VirtualSerial()
4453
4588
  _appliance_mode = "software"
@@ -192,6 +192,9 @@ BAUD = 115200
192
192
 
193
193
  def check_cancel(ser):
194
194
  try:
195
+ if line_reader is not None:
196
+ line = line_reader(ser, 0)
197
+ return bool(line) and line in ("CANCEL", "PLAY_STOP")
195
198
  if ser and ser.in_waiting:
196
199
  line = ser.readline().decode(errors="ignore").strip()
197
200
  note_serial_activity()
@@ -517,6 +520,12 @@ def detect_disc_type(device):
517
520
  # pipeline emits also updates the web/SSE status in real time.
518
521
  status_sink = None
519
522
 
523
+ # discstation.py sets this to its buffered read_serial_line so check_cancel
524
+ # reads the same way station_loop does (partial lines get buffered and
525
+ # reassembled). A bare ser.readline() here can slice a "CANCEL\n" in half
526
+ # on a mid-transmission read and then never match it.
527
+ line_reader = None
528
+
520
529
 
521
530
  def send(ser, msg):
522
531
  global _serial_write_failed
@@ -8,9 +8,11 @@ import os
8
8
  import platform
9
9
  import re
10
10
  import shutil
11
+ import socket
11
12
  import subprocess
12
13
  import sys
13
14
  import threading
15
+ import time
14
16
  from pathlib import Path
15
17
 
16
18
  from serial.tools import list_ports
@@ -67,6 +69,77 @@ def serial_port():
67
69
  return _stable_serial_path(sorted(preferred)[0])
68
70
 
69
71
 
72
+ _REMOTE_MDNS_TYPE = "_discstation._tcp.local."
73
+ _REMOTE_DEFAULT_HOST = "discstation.local"
74
+ _remote_cache = {"at": 0.0, "value": None}
75
+
76
+
77
+ def remote_host():
78
+ """Address of the Wi-Fi appliance remote, or None.
79
+
80
+ `DISC_REMOTE_HOST` unset / `auto` (default) -> mDNS browse for the
81
+ firmware's `_discstation._tcp` advert, falling back to resolving
82
+ `discstation.local` via the OS resolver. Result cached ~60s so the
83
+ reconnect loop / hot-swap poll don't hammer mDNS.
84
+ `DISC_REMOTE_HOST=<ip|host>` -> that address, verbatim, instantly.
85
+ `DISC_REMOTE_HOST=off` / `none` / `0` -> None (never look; USB / web only).
86
+
87
+ Only consulted by main() when no USB serial is present. Best-effort -
88
+ main() tries to connect and falls back on failure."""
89
+ setting = (os.environ.get("DISC_REMOTE_HOST") or "").strip().lower()
90
+ if setting in ("off", "none", "no", "false", "0", "disabled"):
91
+ return None
92
+ if setting and setting != "auto":
93
+ return (os.environ.get("DISC_REMOTE_HOST") or "").strip()
94
+
95
+ if time.time() - _remote_cache["at"] < 60:
96
+ return _remote_cache["value"]
97
+
98
+ value = _discover_remote()
99
+ _remote_cache["at"] = time.time()
100
+ _remote_cache["value"] = value
101
+ return value
102
+
103
+
104
+ def _discover_remote():
105
+ try:
106
+ from zeroconf import Zeroconf, ServiceBrowser
107
+ except ImportError:
108
+ Zeroconf = None
109
+ if Zeroconf is not None:
110
+ found = {}
111
+
112
+ class _Listener:
113
+ def add_service(self, zc, type_, name):
114
+ info = zc.get_service_info(type_, name, timeout=1500)
115
+ if info:
116
+ for addr in info.parsed_addresses():
117
+ if "." in addr: # IPv4
118
+ found["addr"] = addr
119
+ return
120
+
121
+ update_service = add_service
122
+
123
+ def remove_service(self, *a):
124
+ pass
125
+
126
+ zc = Zeroconf()
127
+ try:
128
+ ServiceBrowser(zc, _REMOTE_MDNS_TYPE, _Listener())
129
+ deadline = time.time() + 2.5
130
+ while time.time() < deadline and "addr" not in found:
131
+ time.sleep(0.1)
132
+ finally:
133
+ zc.close()
134
+ if found.get("addr"):
135
+ return found["addr"]
136
+
137
+ try:
138
+ return socket.gethostbyname(_REMOTE_DEFAULT_HOST)
139
+ except OSError:
140
+ return None
141
+
142
+
70
143
  def _stable_serial_path(device):
71
144
  """Map a volatile /dev/ttyUSBN to its stable /dev/serial/by-id/ symlink so a
72
145
  USB re-enumeration (ttyUSB1 -> ttyUSB0) doesn't strand the reconnect loop."""