diff --git a/web.py b/web.py index 6207d61..6a7c190 100644 --- a/web.py +++ b/web.py @@ -23,7 +23,7 @@ import subprocess import tempfile import wave from datetime import datetime -from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, unquote, urlparse @@ -62,21 +62,15 @@ MIME_TYPES = { # 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): """Return duration in seconds for any supported audio file, or None.""" ext = path.suffix.lower() if ext == '.wav': - dur, _, _ = _get_wav_info(path) - return dur + try: + 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'): try: 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, window_samples: int): """Yield rms_0_to_1 for every window in the open wave file.""" - frame_pos = 0 while True: raw = wf.readframes(window_samples) 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 yield round(rms, 5) - frame_pos += window_samples def _loud_sections(rms_values: list, window_dur: float, duration: float, @@ -365,14 +357,9 @@ class _Handler(BaseHTTPRequestHandler): except (ValueError, TypeError): min_gap = self.min_gap - status_path = Path(self.recordings_dir) / 'status.json' - try: - with open(status_path) as fh: - 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 + if self._is_active(filename): + self._json_err(409, 'File is currently being recorded — analysis unavailable until recording stops') + return recordings_base = Path(self.recordings_dir).resolve() analyses_base = Path(self.analyses_dir).resolve() @@ -431,11 +418,7 @@ class _Handler(BaseHTTPRequestHandler): self.end_headers() with open(path, 'rb') as fh: - while True: - chunk = fh.read(65536) - if not chunk: - break - self.wfile.write(chunk) + self._copy_to_response(fh) def _stream(self, filename: str): """Serve audio for inline playback with HTTP Range support.""" @@ -466,13 +449,7 @@ class _Handler(BaseHTTPRequestHandler): with open(path, 'rb') as fh: fh.seek(start) - remaining = length - while remaining > 0: - chunk = fh.read(min(65536, remaining)) - if not chunk: - break - self.wfile.write(chunk) - remaining -= len(chunk) + self._copy_to_response(fh, length) else: self.send_response(200) self.send_header('Content-Type', content_type) @@ -481,11 +458,7 @@ class _Handler(BaseHTTPRequestHandler): self.end_headers() with open(path, 'rb') as fh: - while True: - chunk = fh.read(65536) - if not chunk: - break - self.wfile.write(chunk) + self._copy_to_response(fh) def _api_storage(self): base = Path(self.recordings_dir) @@ -509,14 +482,9 @@ class _Handler(BaseHTTPRequestHandler): self._send(200, data.encode(), 'application/json') def _api_delete(self, filename: str): - status_path = Path(self.recordings_dir) / 'status.json' - try: - with open(status_path) as fh: - 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 + if self._is_active(filename): + self._json_err(409, 'Cannot delete a file that is currently being recorded') + return path = self._safe_path(filename) if path is None: @@ -563,14 +531,9 @@ class _Handler(BaseHTTPRequestHandler): if path is None: return - status_path = Path(self.recordings_dir) / 'status.json' - try: - with open(status_path) as fh: - 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 self._is_active(filename): + self._json_err(409, 'Cannot cut a file that is currently being recorded') + return if not shutil.which('ffmpeg'): 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.end_headers() with open(tmp_path, 'rb') as fh: - while True: - chunk = fh.read(65536) - if not chunk: - break - self.wfile.write(chunk) + self._copy_to_response(fh) except subprocess.TimeoutExpired: self._json_err(504, 'ffmpeg timed out — file may be too large') finally: @@ -619,12 +578,31 @@ class _Handler(BaseHTTPRequestHandler): except Exception: 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): base = Path(self.recordings_dir).resolve() try: path = (base / filename).resolve() path.relative_to(base) - except (ValueError, Exception): + except Exception: self._send(403, b'Forbidden', 'text/plain') return None