Compare commits
10 Commits
8121564e8c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 47ce682821 | |||
| a70701f260 | |||
| abfe81e734 | |||
| eff9240b5d | |||
| de667821b7 | |||
| d583620f8c | |||
| 6d16b2c0a3 | |||
| 7db0e0870f | |||
| 476a8d2752 | |||
| c3575c712e |
@@ -2,6 +2,7 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
alsa-utils \
|
alsa-utils \
|
||||||
|
ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -155,9 +155,13 @@ python web.py --threshold 0.03 # loudness threshold 0–1 (default 0.05)
|
|||||||
|
|
||||||
Shows a table of all recordings sorted newest-first. Features:
|
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.
|
- **Inline playback** — collapsible `▶ Play` button per row; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support. Metadata is fetched immediately so the duration is visible without pressing play.
|
||||||
- **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.
|
- **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.
|
||||||
- **Live REC badge** — files currently being written by `isr.py` show an animated REC indicator, polled every 5 seconds via `/api/status`.
|
- **Timestamp jump** — after analysis, click any loud-section chip to seek the player to that position and pre-fill the cut panel. Use **J** / **K** keyboard shortcuts to jump to the previous / next section while audio is playing.
|
||||||
|
- **Cut & download** — `✂ Cut` button opens the player row and reveals a cut panel. Enter start and end times in `m:ss` or `h:mm:ss` format and click **↓ Download cut** to receive an ffmpeg-trimmed copy without re-encoding. Requires ffmpeg (included in the Docker image).
|
||||||
|
- **Filters** — live filename search and from/to date pickers above the table; applied client-side with no additional requests. Shows `N of M shown` when a filter is active.
|
||||||
|
- **Delete** — `✕ Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>` 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`. Duration for in-progress files shows `—` (header is unfinalized until recording stops).
|
||||||
- **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management, `role=img` on SVG waveforms.
|
- **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management, `role=img` on SVG waveforms.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -203,7 +207,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).
|
**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
|
```bash
|
||||||
# Delete recordings older than 30 days
|
# Delete recordings older than 30 days
|
||||||
find recordings/ -type f -mtime +30 -delete
|
find recordings/ -type f -mtime +30 -delete
|
||||||
|
|||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
# ISR Roadmap
|
||||||
|
|
||||||
|
## notify.py — NTFY Loudness Notifications
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
Street ambience recorder. Goal: detect notable audio events (speech, thunder,
|
||||||
|
sustained unusual sounds) in hourly recording files and push a notification via
|
||||||
|
a self-hosted NTFY server. Generic short events (car horn, passing vehicle)
|
||||||
|
should be filtered out by a minimum section duration.
|
||||||
|
|
||||||
|
### Design decisions
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Detection | RMS + minimum section duration filter (KISS — no FFT for now) |
|
||||||
|
| Timing | Configurable: `immediate` / `daily` / `both` |
|
||||||
|
| Config | `[notify]` section in existing `config.ini` |
|
||||||
|
| Code structure | `notify.py` imports `analyze_wav` / `analyze_flac` from `web.py` (DRY) |
|
||||||
|
| Source name | Included in notification body; configurable display name per source |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Config additions (`config.example.ini`)
|
||||||
|
|
||||||
|
Add a `[notify]` section to `config.ini`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[notify]
|
||||||
|
enabled = true
|
||||||
|
ntfy_url = https://ntfy.example.com/mytopic ; full URL incl. topic
|
||||||
|
mode = immediate ; immediate | daily | both
|
||||||
|
daily_time = 08:00 ; HH:MM — used in daily and both modes
|
||||||
|
debounce_minutes = 60 ; immediate mode: suppress repeat notifications within this window
|
||||||
|
min_section_duration = 2.0 ; seconds — sections shorter than this are ignored (filters car horns etc.)
|
||||||
|
min_sections = 1 ; number of qualifying sections required to trigger a notification
|
||||||
|
loudness_threshold = 0.05 ; RMS 0–1, same scale as web.py analysis threshold
|
||||||
|
```
|
||||||
|
|
||||||
|
Per recording source, add an optional `display_name`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[radio1]
|
||||||
|
type = stream
|
||||||
|
url = http://icecast.example.com:8000/live
|
||||||
|
display_name = Street mic north ; shown in notification; defaults to section name [radio1]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Notification format
|
||||||
|
|
||||||
|
```
|
||||||
|
Title: ISR — Notable audio · Street mic north
|
||||||
|
Body: radio1_20260427_0300.wav
|
||||||
|
3 notable sections (≥ 2.0 s each)
|
||||||
|
→ 00:12 – 00:18
|
||||||
|
→ 01:45 – 01:52
|
||||||
|
→ 47:03 – 47:11
|
||||||
|
Peak RMS: 0.312
|
||||||
|
```
|
||||||
|
|
||||||
|
Daily digest example:
|
||||||
|
|
||||||
|
```
|
||||||
|
Title: ISR Daily Digest · 2026-04-27
|
||||||
|
Body: Street mic north — 4 files with notable events
|
||||||
|
03:00 file · 3 sections (peak 0.312)
|
||||||
|
07:00 file · 1 section (peak 0.091)
|
||||||
|
14:00 file · 2 sections (peak 0.204)
|
||||||
|
21:00 file · 1 section (peak 0.178)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Implementation plan
|
||||||
|
|
||||||
|
#### Phase 1 — Core
|
||||||
|
|
||||||
|
1. **`config.example.ini`** — add `[notify]` section and `display_name` key to
|
||||||
|
source section examples (as shown above).
|
||||||
|
|
||||||
|
2. **`notify.py` — file watcher**
|
||||||
|
- Polls `recordings/status.json` every 30 s.
|
||||||
|
- Tracks which files were in `active` on the previous poll.
|
||||||
|
- When a file disappears from `active` it was just closed → queues it for
|
||||||
|
analysis.
|
||||||
|
- Skips files with extensions that cannot be analysed (anything other than
|
||||||
|
`.wav` / `.flac`).
|
||||||
|
|
||||||
|
3. **`notify.py` — analysis + filter**
|
||||||
|
- Imports `analyze_wav` / `analyze_flac` from `web.py`.
|
||||||
|
- Applies `loudness_threshold` from `[notify]` config.
|
||||||
|
- Filters resulting sections to those with duration ≥ `min_section_duration`.
|
||||||
|
- Counts filtered sections against `min_sections` threshold.
|
||||||
|
|
||||||
|
4. **`notify.py` — NTFY HTTP POST**
|
||||||
|
- Plain `urllib` POST to `ntfy_url` (no extra dependencies).
|
||||||
|
- Sets `Title` and message body as described above.
|
||||||
|
- Logs success / failure to stdout.
|
||||||
|
|
||||||
|
#### Phase 2 — Cadence modes
|
||||||
|
|
||||||
|
5. **Immediate mode with debounce**
|
||||||
|
- Fires right after the file closes and analysis passes.
|
||||||
|
- Persists last-notification timestamp per source to a small
|
||||||
|
`notify_state.json` in the recordings directory.
|
||||||
|
- Suppresses sending if last notification for that source was within
|
||||||
|
`debounce_minutes`.
|
||||||
|
|
||||||
|
6. **Daily digest mode**
|
||||||
|
- Appends qualifying events to `notify_log.jsonl` in the recordings
|
||||||
|
directory (one JSON line per event: timestamp, source, filename, sections,
|
||||||
|
peak RMS).
|
||||||
|
- On each poll checks whether `daily_time` has passed today and no digest
|
||||||
|
has been sent yet (tracked in `notify_state.json`).
|
||||||
|
- Reads all undigested entries from `notify_log.jsonl`, groups by
|
||||||
|
`display_name`, sends one notification per source with notable activity.
|
||||||
|
- Marks entries as digested.
|
||||||
|
|
||||||
|
7. **Both mode**
|
||||||
|
- Immediate path: only fires when peak RMS exceeds a second, higher
|
||||||
|
threshold (`alarm_threshold`, default `0.3`; add to `[notify]` config).
|
||||||
|
- Daily digest path: fires for everything that passes `min_sections`.
|
||||||
|
|
||||||
|
#### Phase 3 — Integration
|
||||||
|
|
||||||
|
8. **Docker** — optional `notify` service in `docker-compose.yml`:
|
||||||
|
```yaml
|
||||||
|
notify:
|
||||||
|
build: .
|
||||||
|
command: python notify.py
|
||||||
|
volumes:
|
||||||
|
- ./recordings:/app/recordings
|
||||||
|
- ./config.ini:/app/config.ini:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
9. **README** — new section documenting `notify.py` usage, config keys, and
|
||||||
|
Docker setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Open questions (decide before implementing)
|
||||||
|
|
||||||
|
- **Log rotation**: `notify_log.jsonl` grows indefinitely. Options: cap at N
|
||||||
|
days (configurable), cap at N MB, or leave cleanup to the user. No decision
|
||||||
|
made yet.
|
||||||
|
- **Multiple NTFY topics per source**: current design uses one global topic.
|
||||||
|
If per-source topics are ever needed, `ntfy_url` could be moved to the source
|
||||||
|
section and override the global one.
|
||||||
|
- **FFT / frequency analysis** (future): distinguishing thunder (low rumble,
|
||||||
|
50–200 Hz) from speech (300–3000 Hz) from vehicles would reduce false
|
||||||
|
positives further. Deferred — requires `numpy` and adds meaningful complexity.
|
||||||
@@ -19,9 +19,11 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import struct
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import wave
|
import wave
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
from http.server import BaseHTTPRequestHandler, HTTPServer, 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
|
||||||
|
|
||||||
@@ -222,10 +224,14 @@ def list_files(recordings_dir: str):
|
|||||||
for path in base.rglob('*'):
|
for path in base.rglob('*'):
|
||||||
if path.suffix.lower() not in AUDIO_EXTENSIONS:
|
if path.suffix.lower() not in AUDIO_EXTENSIONS:
|
||||||
continue
|
continue
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
rel = str(path.relative_to(base)).replace('\\', '/')
|
rel = str(path.relative_to(base)).replace('\\', '/')
|
||||||
|
is_active = rel in active_files
|
||||||
|
|
||||||
duration = _get_audio_duration(path)
|
# Skip reading partial headers for in-progress files — the WAV nframes
|
||||||
|
# field and FLAC total_samples are both unfinalized while recording,
|
||||||
|
# producing wildly incorrect values (e.g. 53375995583:39:01).
|
||||||
|
duration = None if is_active else _get_audio_duration(path)
|
||||||
|
|
||||||
files.append({
|
files.append({
|
||||||
'name': rel,
|
'name': rel,
|
||||||
@@ -234,7 +240,7 @@ def list_files(recordings_dir: str):
|
|||||||
'date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
|
'date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
'duration': duration,
|
'duration': duration,
|
||||||
'ext': path.suffix.lower().lstrip('.'),
|
'ext': path.suffix.lower().lstrip('.'),
|
||||||
'recording': rel in active_files,
|
'recording': is_active,
|
||||||
})
|
})
|
||||||
|
|
||||||
files.sort(key=lambda f: f['mtime'], reverse=True)
|
files.sort(key=lambda f: f['mtime'], reverse=True)
|
||||||
@@ -249,6 +255,14 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
recordings_dir: str = 'recordings'
|
recordings_dir: str = 'recordings'
|
||||||
threshold: float = LOUD_THRESHOLD
|
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):
|
def do_GET(self):
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
qs = parse_qs(parsed.query)
|
qs = parse_qs(parsed.query)
|
||||||
@@ -266,6 +280,8 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
self._api_storage()
|
self._api_storage()
|
||||||
elif p == '/api/config':
|
elif p == '/api/config':
|
||||||
self._api_config()
|
self._api_config()
|
||||||
|
elif p == '/api/cut':
|
||||||
|
self._api_cut(qs)
|
||||||
elif p.startswith('/download/'):
|
elif p.startswith('/download/'):
|
||||||
self._download(unquote(p[len('/download/'):]))
|
self._download(unquote(p[len('/download/'):]))
|
||||||
elif p.startswith('/stream/'):
|
elif p.startswith('/stream/'):
|
||||||
@@ -419,6 +435,108 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
data = json.dumps({'threshold': self.threshold})
|
data = json.dumps({'threshold': self.threshold})
|
||||||
self._send(200, data.encode(), 'application/json')
|
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 _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}'
|
||||||
|
|
||||||
|
# For lossless formats, re-encode (not copy) so the container header
|
||||||
|
# is rewritten with the correct duration/size. For lossy formats,
|
||||||
|
# copy is fine — the audio stops at the right frame regardless.
|
||||||
|
_lossless = {'.wav': ['-c:a', 'pcm_s16le'], '.flac': ['-c:a', 'flac']}
|
||||||
|
codec_args = _lossless.get(ext, ['-c', 'copy'])
|
||||||
|
|
||||||
|
fd, tmp_path = tempfile.mkstemp(suffix=ext)
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
cmd = ['ffmpeg', '-y',
|
||||||
|
'-i', str(path),
|
||||||
|
'-ss', str(start), '-to', str(end),
|
||||||
|
'-vn'] + codec_args + [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):
|
def _safe_path(self, filename: str):
|
||||||
base = Path(self.recordings_dir).resolve()
|
base = Path(self.recordings_dir).resolve()
|
||||||
try:
|
try:
|
||||||
@@ -512,12 +630,17 @@ a.dl{color:var(--accent);text-decoration:none;font-size:13px}
|
|||||||
a.dl:hover{text-decoration:underline}
|
a.dl:hover{text-decoration:underline}
|
||||||
a.dl:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}
|
a.dl:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}
|
||||||
.actions{display:flex;gap:6px;align-items:center}
|
.actions{display:flex;gap:6px;align-items:center}
|
||||||
|
button.del{color:var(--red);border-color:#7f1d1d}
|
||||||
|
button.del:hover:not(:disabled){background:#2d0808}
|
||||||
/* waveform */
|
/* waveform */
|
||||||
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px}
|
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px}
|
||||||
svg.wave{display:block;width:100%;height:56px}
|
svg.wave{display:block;width:100%;height:56px}
|
||||||
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
|
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
|
||||||
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px;
|
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px;
|
||||||
padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace}
|
padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace}
|
||||||
|
button.chip{cursor:pointer}
|
||||||
|
button.chip:hover{background:#6c1f08;border-color:#9a3412}
|
||||||
|
button.chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||||
.quiet{color:var(--muted);font-size:12px;margin-top:6px}
|
.quiet{color:var(--muted);font-size:12px;margin-top:6px}
|
||||||
.spin{color:var(--muted);font-style:italic;font-size:12px;padding:6px 0}
|
.spin{color:var(--muted);font-style:italic;font-size:12px;padding:6px 0}
|
||||||
.empty{text-align:center;padding:60px;color:var(--muted)}
|
.empty{text-align:center;padding:60px;color:var(--muted)}
|
||||||
@@ -525,9 +648,29 @@ svg.wave{display:block;width:100%;height:56px}
|
|||||||
.player-row td{padding:0 10px 10px;background:var(--bg);border-bottom:1px solid var(--brd)}
|
.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;
|
audio{width:100%;height:36px;border-radius:4px;display:block;
|
||||||
color-scheme:dark;accent-color:var(--accent)}
|
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}
|
||||||
|
.filter-bar label{font-size:13px;color:var(--muted);white-space:nowrap}
|
||||||
|
.filter-bar input[type=text]{width:180px;background:var(--bg);border:1px solid var(--brd);
|
||||||
|
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px}
|
||||||
|
.filter-bar input[type=date]{background:var(--bg);border:1px solid var(--brd);
|
||||||
|
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px;color-scheme:dark}
|
||||||
|
.filter-bar input:focus{outline:2px solid var(--accent);outline-offset:1px}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="sr-announce" aria-live="polite" aria-atomic="true" class="sr"></div>
|
||||||
<a href="#main" class="skip">Skip to content</a>
|
<a href="#main" class="skip">Skip to content</a>
|
||||||
<header>
|
<header>
|
||||||
<h1>ISR Archive</h1>
|
<h1>ISR Archive</h1>
|
||||||
@@ -540,6 +683,19 @@ audio{width:100%;height:36px;border-radius:4px;display:block;
|
|||||||
<input type="number" id="threshold-input" min="0" max="1" step="0.005" value="0.05"
|
<input type="number" id="threshold-input" min="0" max="1" step="0.005" value="0.05"
|
||||||
aria-describedby="threshold-hint">
|
aria-describedby="threshold-hint">
|
||||||
<span id="threshold-hint" class="controls-hint">RMS 0–1 · sections above this value are marked loud</span>
|
<span id="threshold-hint" class="controls-hint">RMS 0–1 · sections above this value are marked loud</span>
|
||||||
|
<label for="preroll-input" style="margin-left:16px">Pre-roll:</label>
|
||||||
|
<input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3"
|
||||||
|
aria-describedby="preroll-hint">
|
||||||
|
<span id="preroll-hint" class="controls-hint">seconds to rewind before section start</span>
|
||||||
|
</div>
|
||||||
|
<div class="filter-bar" role="search" aria-label="Filter recordings">
|
||||||
|
<label for="filter-name">Search:</label>
|
||||||
|
<input type="text" id="filter-name" placeholder="filename…" aria-label="Filter by filename">
|
||||||
|
<label for="filter-from">From:</label>
|
||||||
|
<input type="date" id="filter-from" aria-label="From date">
|
||||||
|
<label for="filter-to">To:</label>
|
||||||
|
<input type="date" id="filter-to" aria-label="To date">
|
||||||
|
<button id="filter-clear" aria-label="Clear all filters">✕ Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="wrap" id="main">
|
<div class="wrap" id="main">
|
||||||
<table aria-label="Recordings archive">
|
<table aria-label="Recordings archive">
|
||||||
@@ -579,8 +735,30 @@ const fmtT = s => {
|
|||||||
};
|
};
|
||||||
const pad = n => String(n).padStart(2,'0');
|
const pad = n => String(n).padStart(2,'0');
|
||||||
|
|
||||||
|
function announce(msg) {
|
||||||
|
const el = document.getElementById('sr-announce');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = ''; // clear first so same text re-triggers
|
||||||
|
setTimeout(() => { el.textContent = msg; }, 50);
|
||||||
|
}
|
||||||
|
const getPreroll = () => {
|
||||||
|
const v = parseFloat(document.getElementById('preroll-input').value);
|
||||||
|
return isNaN(v) || v < 0 ? 0 : v;
|
||||||
|
};
|
||||||
|
function setCutFields(idx, startSec, endSec) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// idx -> filename, for live-status polling
|
// idx -> filename, for live-status polling
|
||||||
const recMap = new Map();
|
const recMap = new Map();
|
||||||
|
// idx -> [{start,end}], populated after analysis
|
||||||
|
const sectionMap = new Map();
|
||||||
|
let activePlayerIdx = null;
|
||||||
|
// full file list from server, annotated with stable _idx
|
||||||
|
let allFiles = [];
|
||||||
|
|
||||||
function togglePlayer(idx, filename) {
|
function togglePlayer(idx, filename) {
|
||||||
const prow = document.getElementById('prow-'+idx);
|
const prow = document.getElementById('prow-'+idx);
|
||||||
@@ -590,14 +768,16 @@ function togglePlayer(idx, filename) {
|
|||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
if (!audio.getAttribute('data-src-set')) {
|
if (!audio.getAttribute('data-src-set')) {
|
||||||
|
audio.preload = 'metadata';
|
||||||
audio.src = '/stream/' + encodeURIComponent(filename);
|
audio.src = '/stream/' + encodeURIComponent(filename);
|
||||||
|
audio.load();
|
||||||
audio.setAttribute('data-src-set','1');
|
audio.setAttribute('data-src-set','1');
|
||||||
}
|
}
|
||||||
|
activePlayerIdx = idx;
|
||||||
prow.hidden = false;
|
prow.hidden = false;
|
||||||
btn.setAttribute('aria-expanded','true');
|
btn.setAttribute('aria-expanded','true');
|
||||||
btn.textContent = '⏹ Hide';
|
btn.textContent = '⏹ Hide';
|
||||||
btn.setAttribute('aria-label','Hide player for '+filename);
|
btn.setAttribute('aria-label','Hide player for '+filename);
|
||||||
// Move focus to audio control so keyboard users can operate it immediately
|
|
||||||
audio.focus();
|
audio.focus();
|
||||||
} else {
|
} else {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
@@ -644,7 +824,32 @@ function drawWave(rms, sections, duration, filename) {
|
|||||||
return svg;
|
return svg;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function analyse(filename, cell, btn) {
|
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, sectionIdx) {
|
||||||
|
const pbtn = document.getElementById('pbtn-'+idx);
|
||||||
|
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename);
|
||||||
|
activePlayerIdx = idx;
|
||||||
|
const audio = document.getElementById('aud-'+idx);
|
||||||
|
const seekTo = Math.max(0, startSec - getPreroll());
|
||||||
|
const doSeek = () => { audio.currentTime = seekTo; };
|
||||||
|
if (audio.readyState >= 1) doSeek();
|
||||||
|
else audio.addEventListener('loadedmetadata', doSeek, {once: true});
|
||||||
|
setCutFields(idx, startSec, endSec);
|
||||||
|
if (sectionIdx != null) {
|
||||||
|
const total = (sectionMap.get(idx) || []).length;
|
||||||
|
announce(`Section ${sectionIdx + 1} of ${total}: ${fmtT(startSec)} to ${fmtT(endSec)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analyse(idx, filename, cell, btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = '…';
|
btn.textContent = '…';
|
||||||
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
|
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
|
||||||
@@ -664,15 +869,19 @@ async function analyse(filename, cell, btn) {
|
|||||||
const chips = document.createElement('div');
|
const chips = document.createElement('div');
|
||||||
chips.className='chips';
|
chips.className='chips';
|
||||||
chips.setAttribute('role','list');
|
chips.setAttribute('role','list');
|
||||||
chips.setAttribute('aria-label','Loud sections');
|
chips.setAttribute('aria-label','Loud sections — click to jump, J/K to step');
|
||||||
if (d.sections && d.sections.length) {
|
if (d.sections && d.sections.length) {
|
||||||
d.sections.forEach(s => {
|
sectionMap.set(idx, d.sections);
|
||||||
const c = document.createElement('span');
|
d.sections.forEach((s, si) => {
|
||||||
|
const c = document.createElement('button');
|
||||||
c.className='chip'; c.setAttribute('role','listitem');
|
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.textContent = `${fmtT(s.start)} – ${fmtT(s.end)}`;
|
||||||
|
c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end, si));
|
||||||
chips.appendChild(c);
|
chips.appendChild(c);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
sectionMap.delete(idx);
|
||||||
const q = document.createElement('span');
|
const q = document.createElement('span');
|
||||||
q.className='quiet'; q.setAttribute('role','listitem');
|
q.className='quiet'; q.setAttribute('role','listitem');
|
||||||
q.textContent='No loud sections found';
|
q.textContent='No loud sections found';
|
||||||
@@ -686,6 +895,79 @@ async function analyse(filename, cell, btn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// J = previous section, K = next section (only when focus is not in an input)
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
|
if (activePlayerIdx === null) return;
|
||||||
|
const sections = sectionMap.get(activePlayerIdx) || [];
|
||||||
|
if (!sections.length) return;
|
||||||
|
const audio = document.getElementById('aud-'+activePlayerIdx);
|
||||||
|
if (!audio) return;
|
||||||
|
const preroll = getPreroll();
|
||||||
|
|
||||||
|
if (e.key === 'j' || e.key === 'J') {
|
||||||
|
e.preventDefault();
|
||||||
|
const cur = audio.currentTime;
|
||||||
|
let targetIdx = -1;
|
||||||
|
for (let i = sections.length - 1; i >= 0; i--) {
|
||||||
|
if (sections[i].start < cur - 1) { targetIdx = i; break; }
|
||||||
|
}
|
||||||
|
if (targetIdx >= 0) {
|
||||||
|
const s = sections[targetIdx];
|
||||||
|
audio.currentTime = Math.max(0, s.start - preroll);
|
||||||
|
setCutFields(activePlayerIdx, s.start, s.end);
|
||||||
|
announce(`Section ${targetIdx + 1} of ${sections.length}: ${fmtT(s.start)} to ${fmtT(s.end)}`);
|
||||||
|
} else {
|
||||||
|
announce('Beginning of sections');
|
||||||
|
}
|
||||||
|
} else if (e.key === 'k' || e.key === 'K') {
|
||||||
|
e.preventDefault();
|
||||||
|
const cur = audio.currentTime;
|
||||||
|
let jumped = false;
|
||||||
|
for (let i = 0; i < sections.length; i++) {
|
||||||
|
if (sections[i].start > cur + preroll) {
|
||||||
|
const s = sections[i];
|
||||||
|
audio.currentTime = Math.max(0, s.start - preroll);
|
||||||
|
setCutFields(activePlayerIdx, s.start, s.end);
|
||||||
|
announce(`Section ${i + 1} of ${sections.length}: ${fmtT(s.start)} to ${fmtT(s.end)}`);
|
||||||
|
jumped = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!jumped) announce('End of sections');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
allFiles = allFiles.filter(f => f._idx !== idx);
|
||||||
|
const visible = document.querySelectorAll('tr.data-row').length;
|
||||||
|
const total = allFiles.length;
|
||||||
|
document.getElementById('subtitle').textContent = total === visible
|
||||||
|
? `${total} recording${total!==1?'s':''} found`
|
||||||
|
: `${visible} of ${total} recording${total!==1?'s':''} shown`;
|
||||||
|
if (!visible) 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() {
|
async function updateStorage() {
|
||||||
try {
|
try {
|
||||||
const s = await (await fetch('/api/storage')).json();
|
const s = await (await fetch('/api/storage')).json();
|
||||||
@@ -697,32 +979,21 @@ async function updateStorage() {
|
|||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
function renderFiles(files) {
|
||||||
const refreshBtn = document.getElementById('refresh-btn');
|
|
||||||
refreshBtn.disabled = true;
|
|
||||||
document.getElementById('subtitle').textContent = 'Loading…';
|
|
||||||
recMap.clear();
|
|
||||||
|
|
||||||
let files;
|
|
||||||
try {
|
|
||||||
files = await (await fetch('/api/files')).json();
|
|
||||||
} catch(e) {
|
|
||||||
document.getElementById('subtitle').textContent = 'Error loading files';
|
|
||||||
refreshBtn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tbody = document.getElementById('tbody');
|
const tbody = document.getElementById('tbody');
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
|
recMap.clear();
|
||||||
|
sectionMap.clear();
|
||||||
|
|
||||||
const n = files.length;
|
const total = allFiles.length;
|
||||||
document.getElementById('subtitle').textContent =
|
const visible = files.length;
|
||||||
`${n} recording${n!==1?'s':''} found`;
|
document.getElementById('subtitle').textContent = total === visible
|
||||||
document.getElementById('empty').style.display = n ? 'none' : '';
|
? `${total} recording${total!==1?'s':''} found`
|
||||||
updateStorage();
|
: `${visible} of ${total} recording${total!==1?'s':''} shown`;
|
||||||
if (!n) { refreshBtn.disabled = false; return; }
|
document.getElementById('empty').style.display = visible ? 'none' : '';
|
||||||
|
|
||||||
files.forEach((f, i) => {
|
files.forEach(f => {
|
||||||
|
const i = f._idx;
|
||||||
const ext = f.ext;
|
const ext = f.ext;
|
||||||
const canAnalyse = ext === 'wav' || ext === 'flac';
|
const canAnalyse = ext === 'wav' || ext === 'flac';
|
||||||
const isRec = !!f.recording;
|
const isRec = !!f.recording;
|
||||||
@@ -753,6 +1024,12 @@ async function load() {
|
|||||||
aria-label="Play ${esc(f.name)}">▶ Play</button>
|
aria-label="Play ${esc(f.name)}">▶ Play</button>
|
||||||
<a class="dl" href="/download/${encodeURIComponent(f.name)}"
|
<a class="dl" href="/download/${encodeURIComponent(f.name)}"
|
||||||
aria-label="Download ${esc(f.name)}">↓ Download</a>
|
aria-label="Download ${esc(f.name)}">↓ Download</a>
|
||||||
|
<button id="cutbtn-${i}" class="cut"
|
||||||
|
aria-label="Cut ${esc(f.name)}"
|
||||||
|
${isRec ? 'disabled title="Cannot cut while recording"' : ''}>✂ Cut</button>
|
||||||
|
<button id="delbtn-${i}" class="del"
|
||||||
|
aria-label="Delete ${esc(f.name)}"
|
||||||
|
${isRec ? 'disabled title="Cannot delete while recording"' : ''}>✕ Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</td>`;
|
</td>`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
@@ -762,9 +1039,24 @@ async function load() {
|
|||||||
prow.className = 'player-row';
|
prow.className = 'player-row';
|
||||||
prow.id = 'prow-'+i;
|
prow.id = 'prow-'+i;
|
||||||
prow.hidden = true;
|
prow.hidden = true;
|
||||||
|
const durLabel = f.duration != null
|
||||||
|
? `<div class="muted" style="font-size:11px;margin-top:3px">Duration: ${fmtDur(f.duration)}</div>`
|
||||||
|
: '';
|
||||||
prow.innerHTML = `<td colspan="6">
|
prow.innerHTML = `<td colspan="6">
|
||||||
<audio id="aud-${i}" controls preload="none"
|
<audio id="aud-${i}" controls preload="none"
|
||||||
aria-label="Playback: ${esc(f.name)}"></audio>
|
aria-label="Playback: ${esc(f.name)}"></audio>${durLabel}
|
||||||
|
<div class="cut-panel">
|
||||||
|
<span class="cut-label">✂ Cut:</span>
|
||||||
|
<label class="cut-field">Start
|
||||||
|
<input type="text" id="cut-start-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
|
||||||
|
</label>
|
||||||
|
<label class="cut-field">End
|
||||||
|
<input type="text" id="cut-end-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
|
||||||
|
</label>
|
||||||
|
<button id="cut-dl-${i}" class="cut"
|
||||||
|
${isRec ? 'disabled title="Cannot cut while recording"' : ''}
|
||||||
|
aria-label="Download cut of ${esc(f.name)}">↓ Download cut</button>
|
||||||
|
</div>
|
||||||
</td>`;
|
</td>`;
|
||||||
tbody.appendChild(prow);
|
tbody.appendChild(prow);
|
||||||
|
|
||||||
@@ -778,7 +1070,7 @@ async function load() {
|
|||||||
abtn.disabled = true;
|
abtn.disabled = true;
|
||||||
abtn.title = 'Recording in progress — analyse after recording stops';
|
abtn.title = 'Recording in progress — analyse after recording stops';
|
||||||
} else {
|
} else {
|
||||||
abtn.addEventListener('click', () => analyse(f.name, cell, abtn));
|
abtn.addEventListener('click', () => analyse(i, f.name, cell, abtn));
|
||||||
}
|
}
|
||||||
cell.appendChild(abtn);
|
cell.appendChild(abtn);
|
||||||
}
|
}
|
||||||
@@ -787,10 +1079,72 @@ async function load() {
|
|||||||
document.getElementById('pbtn-'+i)
|
document.getElementById('pbtn-'+i)
|
||||||
.addEventListener('click', () => togglePlayer(i, f.name));
|
.addEventListener('click', () => togglePlayer(i, f.name));
|
||||||
|
|
||||||
// ---- register for live-status polling ----
|
// ---- 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)
|
||||||
|
.addEventListener('click', () => deleteFile(i, f.name));
|
||||||
|
}
|
||||||
|
|
||||||
recMap.set(i, f.name);
|
recMap.set(i, f.name);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const nameQ = document.getElementById('filter-name').value.toLowerCase().trim();
|
||||||
|
const fromD = document.getElementById('filter-from').value;
|
||||||
|
const toD = document.getElementById('filter-to').value;
|
||||||
|
const filtered = allFiles.filter(f => {
|
||||||
|
if (nameQ && !f.name.toLowerCase().includes(nameQ)) return false;
|
||||||
|
if (fromD && f.date < fromD + ' 00:00:00') return false;
|
||||||
|
if (toD && f.date > toD + ' 23:59:59') return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
renderFiles(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const refreshBtn = document.getElementById('refresh-btn');
|
||||||
|
refreshBtn.disabled = true;
|
||||||
|
document.getElementById('subtitle').textContent = 'Loading…';
|
||||||
|
|
||||||
|
let files;
|
||||||
|
try {
|
||||||
|
files = await (await fetch('/api/files')).json();
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('subtitle').textContent = 'Error loading files';
|
||||||
|
refreshBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
allFiles = files.map((f, i) => ({...f, _idx: i}));
|
||||||
|
updateStorage();
|
||||||
|
applyFilters();
|
||||||
refreshBtn.disabled = false;
|
refreshBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -811,6 +1165,16 @@ async function pollStatus() {
|
|||||||
|
|
||||||
document.getElementById('refresh-btn').addEventListener('click', load);
|
document.getElementById('refresh-btn').addEventListener('click', load);
|
||||||
|
|
||||||
|
document.getElementById('filter-name').addEventListener('input', applyFilters);
|
||||||
|
document.getElementById('filter-from').addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('filter-to').addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('filter-clear').addEventListener('click', () => {
|
||||||
|
document.getElementById('filter-name').value = '';
|
||||||
|
document.getElementById('filter-from').value = '';
|
||||||
|
document.getElementById('filter-to').value = '';
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
// Seed threshold input from server config, then start
|
// Seed threshold input from server config, then start
|
||||||
fetch('/api/config').then(r => r.json()).then(cfg => {
|
fetch('/api/config').then(r => r.json()).then(cfg => {
|
||||||
if (cfg.threshold != null)
|
if (cfg.threshold != null)
|
||||||
@@ -845,7 +1209,7 @@ def main():
|
|||||||
recordings_dir = str(rec_dir.resolve())
|
recordings_dir = str(rec_dir.resolve())
|
||||||
threshold = args.threshold
|
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"ISR Web running → http://{args.host}:{args.port}/")
|
||||||
print(f"Recordings dir → {rec_dir.resolve()}")
|
print(f"Recordings dir → {rec_dir.resolve()}")
|
||||||
|
|||||||
Reference in New Issue
Block a user