refactor: deduplicate web.py server code

- Extract _is_active() helper for the three duplicated status.json
  active-recording checks (analyze, delete, cut)
- Extract _copy_to_response() for the four duplicated 64 KB chunk
  copy loops (download, stream full/range, cut)
- Inline _get_wav_info into _get_audio_duration (sample rate and
  channels were never used)
- Remove unused HTTPServer import, dead frame_pos counter, and the
  redundant (ValueError, Exception) catch in _safe_path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 11:42:45 +02:00
parent 792f2b1fd5
commit b9089f9c18
+39 -61
View File
@@ -23,7 +23,7 @@ import subprocess
import tempfile import tempfile
import wave import wave
from datetime import datetime from datetime import datetime
from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse from urllib.parse import parse_qs, unquote, urlparse
@@ -62,21 +62,15 @@ MIME_TYPES = {
# Audio analysis helpers # Audio analysis helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _get_wav_info(path: Path):
"""Return (duration_seconds, sample_rate, channels) or (None, None, None)."""
try:
with wave.open(str(path), 'rb') as wf:
return wf.getnframes() / wf.getframerate(), wf.getframerate(), wf.getnchannels()
except Exception:
return None, None, None
def _get_audio_duration(path: Path): def _get_audio_duration(path: Path):
"""Return duration in seconds for any supported audio file, or None.""" """Return duration in seconds for any supported audio file, or None."""
ext = path.suffix.lower() ext = path.suffix.lower()
if ext == '.wav': if ext == '.wav':
dur, _, _ = _get_wav_info(path) try:
return dur with wave.open(str(path), 'rb') as wf:
return wf.getnframes() / wf.getframerate()
except Exception:
return None
if SOUNDFILE_AVAILABLE and ext in ('.flac', '.ogg', '.opus'): if SOUNDFILE_AVAILABLE and ext in ('.flac', '.ogg', '.opus'):
try: try:
with sf.SoundFile(path) as f: with sf.SoundFile(path) as f:
@@ -89,7 +83,6 @@ def _get_audio_duration(path: Path):
def _compute_rms_windows_wav(wf, channels: int, sampwidth: int, framerate: int, def _compute_rms_windows_wav(wf, channels: int, sampwidth: int, framerate: int,
window_samples: int): window_samples: int):
"""Yield rms_0_to_1 for every window in the open wave file.""" """Yield rms_0_to_1 for every window in the open wave file."""
frame_pos = 0
while True: while True:
raw = wf.readframes(window_samples) raw = wf.readframes(window_samples)
if not raw: if not raw:
@@ -110,7 +103,6 @@ def _compute_rms_windows_wav(wf, channels: int, sampwidth: int, framerate: int,
rms = math.sqrt(sum(s * s for s in mono) / len(mono)) / 32768.0 rms = math.sqrt(sum(s * s for s in mono) / len(mono)) / 32768.0
yield round(rms, 5) yield round(rms, 5)
frame_pos += window_samples
def _loud_sections(rms_values: list, window_dur: float, duration: float, def _loud_sections(rms_values: list, window_dur: float, duration: float,
@@ -365,14 +357,9 @@ class _Handler(BaseHTTPRequestHandler):
except (ValueError, TypeError): except (ValueError, TypeError):
min_gap = self.min_gap min_gap = self.min_gap
status_path = Path(self.recordings_dir) / 'status.json' if self._is_active(filename):
try: self._json_err(409, 'File is currently being recorded — analysis unavailable until recording stops')
with open(status_path) as fh: return
if filename in set(json.load(fh).get('active', [])):
self._json_err(409, 'File is currently being recorded — analysis unavailable until recording stops')
return
except Exception:
pass
recordings_base = Path(self.recordings_dir).resolve() recordings_base = Path(self.recordings_dir).resolve()
analyses_base = Path(self.analyses_dir).resolve() analyses_base = Path(self.analyses_dir).resolve()
@@ -431,11 +418,7 @@ class _Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
with open(path, 'rb') as fh: with open(path, 'rb') as fh:
while True: self._copy_to_response(fh)
chunk = fh.read(65536)
if not chunk:
break
self.wfile.write(chunk)
def _stream(self, filename: str): def _stream(self, filename: str):
"""Serve audio for inline playback with HTTP Range support.""" """Serve audio for inline playback with HTTP Range support."""
@@ -466,13 +449,7 @@ class _Handler(BaseHTTPRequestHandler):
with open(path, 'rb') as fh: with open(path, 'rb') as fh:
fh.seek(start) fh.seek(start)
remaining = length self._copy_to_response(fh, length)
while remaining > 0:
chunk = fh.read(min(65536, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
else: else:
self.send_response(200) self.send_response(200)
self.send_header('Content-Type', content_type) self.send_header('Content-Type', content_type)
@@ -481,11 +458,7 @@ class _Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
with open(path, 'rb') as fh: with open(path, 'rb') as fh:
while True: self._copy_to_response(fh)
chunk = fh.read(65536)
if not chunk:
break
self.wfile.write(chunk)
def _api_storage(self): def _api_storage(self):
base = Path(self.recordings_dir) base = Path(self.recordings_dir)
@@ -509,14 +482,9 @@ class _Handler(BaseHTTPRequestHandler):
self._send(200, data.encode(), 'application/json') self._send(200, data.encode(), 'application/json')
def _api_delete(self, filename: str): def _api_delete(self, filename: str):
status_path = Path(self.recordings_dir) / 'status.json' if self._is_active(filename):
try: self._json_err(409, 'Cannot delete a file that is currently being recorded')
with open(status_path) as fh: return
if filename in set(json.load(fh).get('active', [])):
self._json_err(409, 'Cannot delete a file that is currently being recorded')
return
except Exception:
pass
path = self._safe_path(filename) path = self._safe_path(filename)
if path is None: if path is None:
@@ -563,14 +531,9 @@ class _Handler(BaseHTTPRequestHandler):
if path is None: if path is None:
return return
status_path = Path(self.recordings_dir) / 'status.json' if self._is_active(filename):
try: self._json_err(409, 'Cannot cut a file that is currently being recorded')
with open(status_path) as fh: return
if filename in set(json.load(fh).get('active', [])):
self._json_err(409, 'Cannot cut a file that is currently being recorded')
return
except Exception:
pass
if not shutil.which('ffmpeg'): if not shutil.which('ffmpeg'):
self._json_err(500, 'ffmpeg is not available on this server') self._json_err(500, 'ffmpeg is not available on this server')
@@ -606,11 +569,7 @@ class _Handler(BaseHTTPRequestHandler):
self.send_header('Content-Length', str(tmp_size)) self.send_header('Content-Length', str(tmp_size))
self.end_headers() self.end_headers()
with open(tmp_path, 'rb') as fh: with open(tmp_path, 'rb') as fh:
while True: self._copy_to_response(fh)
chunk = fh.read(65536)
if not chunk:
break
self.wfile.write(chunk)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
self._json_err(504, 'ffmpeg timed out — file may be too large') self._json_err(504, 'ffmpeg timed out — file may be too large')
finally: finally:
@@ -619,12 +578,31 @@ class _Handler(BaseHTTPRequestHandler):
except Exception: except Exception:
pass pass
def _is_active(self, filename: str) -> bool:
"""True if isr.py reports this file as currently being recorded."""
try:
with open(Path(self.recordings_dir) / 'status.json') as fh:
return filename in json.load(fh).get('active', [])
except Exception:
return False
def _copy_to_response(self, fh, length=None):
"""Stream an open binary file to the client in 64 KB chunks."""
remaining = length
while remaining is None or remaining > 0:
chunk = fh.read(65536 if remaining is None else min(65536, remaining))
if not chunk:
break
self.wfile.write(chunk)
if remaining is not None:
remaining -= len(chunk)
def _safe_path(self, filename: str): def _safe_path(self, filename: str):
base = Path(self.recordings_dir).resolve() base = Path(self.recordings_dir).resolve()
try: try:
path = (base / filename).resolve() path = (base / filename).resolve()
path.relative_to(base) path.relative_to(base)
except (ValueError, Exception): except Exception:
self._send(403, b'Forbidden', 'text/plain') self._send(403, b'Forbidden', 'text/plain')
return None return None