From c3575c712e3e8a011e346f7a53102fc502131082 Mon Sep 17 00:00:00 2001 From: Jonathan Schuster Date: Mon, 27 Apr 2026 00:14:56 +0200 Subject: [PATCH] feat: add delete button for recordings in web UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a DELETE /api/files/ endpoint that refuses to remove files currently being recorded (409). The UI shows a red '✕ Delete' button per row (disabled while REC), confirms before proceeding, and removes both the data row and the hidden player row from the DOM on success without a full page reload. README updated accordingly. --- README.md | 3 ++- web.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5674f39..db91d7c 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ Shows a table of all recordings sorted newest-first. Features: - **Inline playback** — collapsible `▶ Play` button per row; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support. - **Waveform analysis** — on demand per file; computes RMS per 100 ms window and highlights loud sections. Supported for WAV and FLAC (FLAC requires `numpy` + `soundfile`). Pure-Python fallback for WAV when numpy is absent. +- **Delete** — `✕ Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/` and removes the row without a full page reload. - **Live REC badge** — files currently being written by `isr.py` show an animated REC indicator, polled every 5 seconds via `/api/status`. - **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management, `role=img` on SVG waveforms. @@ -203,7 +204,7 @@ docker compose down && docker compose up -d --build **Log file in Docker:** The recorder always logs to stdout, so `docker compose logs -f` shows live output. To persist logs on the host, set `log_file = /app/recordings/recorder.log` in `config.ini` (the `recordings` directory is the bind mount). -**File retention:** ISR never deletes recordings. Add a cron job on the host if needed: +**File retention:** Individual recordings can be deleted from the web UI. For bulk / automated cleanup, add a cron job on the host: ```bash # Delete recordings older than 30 days find recordings/ -type f -mtime +30 -delete diff --git a/web.py b/web.py index 4491d5f..91357ad 100644 --- a/web.py +++ b/web.py @@ -249,6 +249,14 @@ class _Handler(BaseHTTPRequestHandler): recordings_dir: str = 'recordings' threshold: float = LOUD_THRESHOLD + def do_DELETE(self): + parsed = urlparse(self.path) + p = parsed.path + if p.startswith('/api/files/'): + self._api_delete(unquote(p[len('/api/files/'):])) + else: + self._send(404, b'Not found', 'text/plain') + def do_GET(self): parsed = urlparse(self.path) qs = parse_qs(parsed.query) @@ -419,6 +427,28 @@ class _Handler(BaseHTTPRequestHandler): data = json.dumps({'threshold': self.threshold}) 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 + + path = self._safe_path(filename) + if path is None: + return + + try: + path.unlink() + except Exception as e: + self._json_err(500, f'Failed to delete: {e}') + return + + self._send(200, json.dumps({'deleted': filename}).encode(), 'application/json') + def _safe_path(self, filename: str): base = Path(self.recordings_dir).resolve() try: @@ -512,6 +542,8 @@ a.dl{color:var(--accent);text-decoration:none;font-size:13px} a.dl:hover{text-decoration:underline} a.dl:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px} .actions{display:flex;gap:6px;align-items:center} +button.del{color:var(--red);border-color:#7f1d1d} +button.del:hover:not(:disabled){background:#2d0808} /* waveform */ .wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px} svg.wave{display:block;width:100%;height:56px} @@ -686,6 +718,33 @@ async function analyse(filename, cell, btn) { } } +async function deleteFile(idx, filename) { + if (!confirm(`Delete "${filename}"?\nThis cannot be undone.`)) return; + const btn = document.getElementById('delbtn-'+idx); + btn.disabled = true; + btn.textContent = '…'; + try { + const r = await fetch('/api/files/'+encodeURIComponent(filename), {method:'DELETE'}); + if (r.ok) { + document.getElementById('row-'+idx)?.remove(); + document.getElementById('prow-'+idx)?.remove(); + recMap.delete(idx); + const remaining = document.querySelectorAll('tr.data-row').length; + document.getElementById('subtitle').textContent = + `${remaining} recording${remaining!==1?'s':''} found`; + if (!remaining) document.getElementById('empty').style.display = ''; + updateStorage(); + } else { + const d = await r.json().catch(()=>({})); + alert('Delete failed: '+(d.error||r.statusText)); + btn.disabled = false; btn.textContent = '✕ Delete'; + } + } catch(e) { + alert('Delete failed: '+e.message); + btn.disabled = false; btn.textContent = '✕ Delete'; + } +} + async function updateStorage() { try { const s = await (await fetch('/api/storage')).json(); @@ -753,6 +812,9 @@ async function load() { aria-label="Play ${esc(f.name)}">▶ Play ↓ Download + `; tbody.appendChild(tr); @@ -787,6 +849,12 @@ async function load() { document.getElementById('pbtn-'+i) .addEventListener('click', () => togglePlayer(i, f.name)); + // ---- attach delete button handler ---- + if (!isRec) { + document.getElementById('delbtn-'+i) + .addEventListener('click', () => deleteFile(i, f.name)); + } + // ---- register for live-status polling ---- recMap.set(i, f.name); });