diff --git a/Dockerfile b/Dockerfile index 9c8204f..33a9dae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,7 @@ FROM python:3.12-slim RUN apt-get update && apt-get install -y --no-install-recommends \ alsa-utils \ + ffmpeg \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/web.py b/web.py index 0415f49..73b7947 100644 --- a/web.py +++ b/web.py @@ -19,9 +19,11 @@ import os import re import shutil import struct +import subprocess +import tempfile import wave from datetime import datetime -from http.server import BaseHTTPRequestHandler, HTTPServer +from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, unquote, urlparse @@ -278,6 +280,8 @@ class _Handler(BaseHTTPRequestHandler): self._api_storage() elif p == '/api/config': self._api_config() + elif p == '/api/cut': + self._api_cut(qs) elif p.startswith('/download/'): self._download(unquote(p[len('/download/'):])) elif p.startswith('/stream/'): @@ -453,6 +457,79 @@ class _Handler(BaseHTTPRequestHandler): self._send(200, json.dumps({'deleted': filename}).encode(), 'application/json') + def _api_cut(self, qs): + filename = qs.get('file', [None])[0] + start_s = qs.get('start', [None])[0] + end_s = qs.get('end', [None])[0] + + if not filename or start_s is None or end_s is None: + self._json_err(400, 'missing file, start, or end parameter') + return + + try: + start = float(start_s) + end = float(end_s) + except (ValueError, TypeError): + self._json_err(400, 'start and end must be numbers (seconds)') + return + + if start < 0 or end <= start: + self._json_err(400, 'start must be ≥ 0 and end must be > start') + return + + path = self._safe_path(filename) + 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 not shutil.which('ffmpeg'): + self._json_err(500, 'ffmpeg is not available on this server') + return + + ext = path.suffix.lower() + out_name = f'{path.stem}_cut_{int(start)}s-{int(end)}s{ext}' + + fd, tmp_path = tempfile.mkstemp(suffix=ext) + os.close(fd) + try: + cmd = ['ffmpeg', '-y', '-i', str(path), + '-ss', str(start), '-to', str(end), + '-c', 'copy', tmp_path] + result = subprocess.run(cmd, capture_output=True, timeout=120) + if result.returncode != 0: + err = result.stderr.decode('utf-8', errors='replace')[-400:] + self._json_err(500, f'ffmpeg error: {err}') + return + + tmp_size = os.path.getsize(tmp_path) + content_type = MIME_TYPES.get(ext, 'application/octet-stream') + self.send_response(200) + self.send_header('Content-Type', content_type) + self.send_header('Content-Disposition', f'attachment; filename="{out_name}"') + 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) + except subprocess.TimeoutExpired: + self._json_err(504, 'ffmpeg timed out — file may be too large') + finally: + try: + os.unlink(tmp_path) + except Exception: + pass + def _safe_path(self, filename: str): base = Path(self.recordings_dir).resolve() try: @@ -564,6 +641,16 @@ button.chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px} .player-row td{padding:0 10px 10px;background:var(--bg);border-bottom:1px solid var(--brd)} audio{width:100%;height:36px;border-radius:4px;display:block; color-scheme:dark;accent-color:var(--accent)} +/* cut panel */ +.cut-panel{display:flex;align-items:center;gap:8px;margin-top:8px;flex-wrap:wrap; + padding-top:8px;border-top:1px solid var(--brd)} +.cut-label{font-size:12px;color:var(--muted);white-space:nowrap} +.cut-field{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--muted)} +.cut-time{width:90px;background:var(--bg);border:1px solid var(--brd);color:var(--txt); + padding:3px 6px;border-radius:4px;font-size:12px;font-family:ui-monospace,monospace} +.cut-time:focus{outline:2px solid var(--accent);outline-offset:1px} +button.cut{color:var(--accent);border-color:#1e40af;background:#0c1a40} +button.cut:hover:not(:disabled){background:#1e3a8a} /* filter bar */ .filter-bar{display:flex;align-items:center;gap:10px;padding:8px 28px; border-bottom:1px solid var(--brd);background:var(--surf);flex-wrap:wrap} @@ -708,7 +795,16 @@ function drawWave(rms, sections, duration, filename) { return svg; } -function seekToSection(idx, filename, startSec) { +function parseTime(s) { + if (!s || !s.trim()) return null; + const parts = s.trim().split(':').map(v => parseFloat(v)); + if (parts.some(isNaN) || parts.length < 1 || parts.length > 3) return null; + if (parts.length === 3) return parts[0]*3600 + parts[1]*60 + parts[2]; + if (parts.length === 2) return parts[0]*60 + parts[1]; + return parts[0]; +} + +function seekToSection(idx, filename, startSec, endSec) { const pbtn = document.getElementById('pbtn-'+idx); if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename); activePlayerIdx = idx; @@ -716,6 +812,11 @@ function seekToSection(idx, filename, startSec) { const doSeek = () => { audio.currentTime = startSec; }; if (audio.readyState >= 1) doSeek(); else audio.addEventListener('loadedmetadata', doSeek, {once: true}); + // Pre-fill cut panel fields with section boundaries + const startEl = document.getElementById('cut-start-'+idx); + const endEl = document.getElementById('cut-end-'+idx); + if (startEl) startEl.value = fmtT(startSec); + if (endEl && endSec != null) endEl.value = fmtT(endSec); } async function analyse(idx, filename, cell, btn) { @@ -746,7 +847,7 @@ async function analyse(idx, filename, cell, btn) { c.className='chip'; c.setAttribute('role','listitem'); c.title = 'Jump to this section (or use J/K keys)'; c.textContent = `${fmtT(s.start)} – ${fmtT(s.end)}`; - c.addEventListener('click', () => seekToSection(idx, filename, s.start)); + c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end)); chips.appendChild(c); }); } else { @@ -875,6 +976,9 @@ function renderFiles(files) { aria-label="Play ${esc(f.name)}">▶ Play ↓ Download + @@ -893,6 +997,18 @@ function renderFiles(files) { prow.innerHTML = ` ${durLabel} +
+ ✂ Cut: + + + +
`; tbody.appendChild(prow); @@ -915,6 +1031,32 @@ function renderFiles(files) { document.getElementById('pbtn-'+i) .addEventListener('click', () => togglePlayer(i, f.name)); + // ---- attach cut button handler (opens player row, focuses start field) ---- + if (!isRec) { + document.getElementById('cutbtn-'+i).addEventListener('click', () => { + const pbtn = document.getElementById('pbtn-'+i); + if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(i, f.name); + document.getElementById('cut-start-'+i)?.focus(); + }); + document.getElementById('cut-dl-'+i).addEventListener('click', () => { + const startStr = document.getElementById('cut-start-'+i).value; + const endStr = document.getElementById('cut-end-'+i).value; + const start = parseTime(startStr); + const end = parseTime(endStr); + if (start === null || end === null) { + alert('Enter start and end times, e.g. 1:30 or 0:01:30'); + return; + } + if (start >= end) { + alert('Start must be before end'); + return; + } + window.location.href = + '/api/cut?file=' + encodeURIComponent(f.name) + + '&start=' + start + '&end=' + end; + }); + } + // ---- attach delete button handler ---- if (!isRec) { document.getElementById('delbtn-'+i) @@ -1019,7 +1161,7 @@ def main(): recordings_dir = str(rec_dir.resolve()) threshold = args.threshold - server = HTTPServer((args.host, args.port), Handler) + server = ThreadingHTTPServer((args.host, args.port), Handler) print(f"ISR Web running → http://{args.host}:{args.port}/") print(f"Recordings dir → {rec_dir.resolve()}")