@@ -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
@@ -224,8 +226,12 @@ def list_files(recordings_dir: str):
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 )
@@ -274,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/ ' ) :
@@ -449,6 +457,79 @@ class _Handler(BaseHTTPRequestHandler):
self . _send ( 200 , json . dumps ( { ' deleted ' : filename } ) . encode ( ) , ' application/json ' )
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 ) :
def _safe_path ( self , filename : str ) :
base = Path ( self . recordings_dir ) . resolve ( )
base = Path ( self . recordings_dir ) . resolve ( )
try :
try :
@@ -550,6 +631,9 @@ 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)}
@@ -557,6 +641,25 @@ 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>
@@ -573,6 +676,15 @@ audio{width:100%;height:36px;border-radius:4px;display:block;
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>
</div>
</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 class= " wrap " id= " main " >
<div class= " wrap " id= " main " >
<table aria-label= " Recordings archive " >
<table aria-label= " Recordings archive " >
<thead>
<thead>
@@ -613,6 +725,11 @@ const pad = n => String(n).padStart(2,'0');
// 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);
@@ -622,14 +739,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();
@@ -676,7 +795,31 @@ 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) {
const pbtn = document.getElementById( ' pbtn- ' +idx);
if (pbtn.getAttribute( ' aria-expanded ' ) !== ' true ' ) togglePlayer(idx, filename);
activePlayerIdx = idx;
const audio = document.getElementById( ' aud- ' +idx);
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) {
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> ' ;
@@ -696,15 +839,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) {
sectionMap.set(idx, d.sections);
d.sections.forEach(s => {
d.sections.forEach(s => {
const c = document.createElement( ' span ' );
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));
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 ' ;
@@ -718,6 +865,31 @@ 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;
if (e.key === ' j ' || e.key === ' J ' ) {
const cur = audio.currentTime;
let target = sections[0].start;
for (let i = sections.length - 1; i >= 0; i--) {
if (sections[i].start < cur - 1) { target = sections[i].start; break; }
}
audio.currentTime = target;
e.preventDefault();
} else if (e.key === ' k ' || e.key === ' K ' ) {
const cur = audio.currentTime;
for (const s of sections) {
if (s.start > cur + 0.5) { audio.currentTime = s.start; break; }
}
e.preventDefault();
}
});
async function deleteFile(idx, filename) {
async function deleteFile(idx, filename) {
if (!confirm(`Delete " $ {filename} " ? \ nThis cannot be undone.`)) return;
if (!confirm(`Delete " $ {filename} " ? \ nThis cannot be undone.`)) return;
const btn = document.getElementById( ' delbtn- ' +idx);
const btn = document.getElementById( ' delbtn- ' +idx);
@@ -729,10 +901,13 @@ async function deleteFile(idx, filename) {
document.getElementById( ' row- ' +idx)?.remove();
document.getElementById( ' row- ' +idx)?.remove();
document.getElementById( ' prow- ' +idx)?.remove();
document.getElementById( ' prow- ' +idx)?.remove();
recMap.delete(idx);
recMap.delete(idx);
const remaining = document.querySelectorAll( ' tr.data-row ' ).length ;
allFiles = allFiles.filter(f => f._idx !== idx) ;
document.getElementById ( ' subtitle ' ).textContent =
const visible = document.querySelectorAll ( ' tr.data-row ' ).length;
`$ {remaining} recording$ { remaining!==1? ' s ' : ' ' } found` ;
const total = allFiles.length ;
if (!remaining) document.getElementById( ' empty ' ).style.display = ' ' ;
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();
updateStorage();
} else {
} else {
const d = await r.json().catch(()=>( {} ));
const d = await r.json().catch(()=>( {} ));
@@ -756,32 +931,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 = f iles.length;
const total = allF iles.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;
@@ -812,6 +976,9 @@ 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 "
<button id= " delbtn-$ {i} " class= " del "
aria-label= " Delete $ { esc(f.name)} "
aria-label= " Delete $ { esc(f.name)} "
$ { isRec ? ' disabled title= " Cannot delete while recording " ' : ' ' }>✕ Delete</button>
$ { isRec ? ' disabled title= " Cannot delete while recording " ' : ' ' }>✕ Delete</button>
@@ -824,9 +991,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);
@@ -840,7 +1022,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);
}
}
@@ -849,16 +1031,72 @@ async function load() {
document.getElementById( ' pbtn- ' +i)
document.getElementById( ' pbtn- ' +i)
.addEventListener( ' click ' , () => togglePlayer(i, f.name));
.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 ----
// ---- attach delete button handler ----
if (!isRec) {
if (!isRec) {
document.getElementById( ' delbtn- ' +i)
document.getElementById( ' delbtn- ' +i)
.addEventListener( ' click ' , () => deleteFile(i, f.name));
.addEventListener( ' click ' , () => deleteFile(i, f.name));
}
}
// ---- register for live-status polling ----
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;
}
}
@@ -879,6 +1117,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)
@@ -913,7 +1161,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 ( ) } " )