@@ -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
@@ -222,10 +224,14 @@ def list_files(recordings_dir: str):
for path in base . rglob ( ' * ' ) :
if path . suffix . lower ( ) not in AUDIO_EXTENSIONS :
continue
stat = path . stat ( )
rel = str ( path . relative_to ( base ) ) . replace ( ' \\ ' , ' / ' )
stat = path . stat ( )
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 ( {
' 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 ' ) ,
' duration ' : duration ,
' ext ' : path . suffix . lower ( ) . lstrip ( ' . ' ) ,
' recording ' : rel in active_files ,
' recording ' : is_active ,
} )
files . sort ( key = lambda f : f [ ' mtime ' ] , reverse = True )
@@ -274,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/ ' ) :
@@ -449,6 +457,86 @@ 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 } '
# 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 ) :
base = Path ( self . recordings_dir ) . resolve ( )
try :
@@ -550,6 +638,9 @@ svg.wave{display:block;width:100%;height:56px}
.chips { display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
.chip { background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px;
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}
.spin { color:var(--muted);font-style:italic;font-size:12px;padding:6px 0}
.empty { text-align:center;padding:60px;color:var(--muted)}
@@ -557,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)}
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}
.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>
</head>
<body>
<div id= " sr-announce " aria-live= " polite " aria-atomic= " true " class= " sr " ></div>
<a href= " #main " class= " skip " >Skip to content</a>
<header>
<h1>ISR Archive</h1>
@@ -572,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 "
aria-describedby= " threshold-hint " >
<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 class= " wrap " id= " main " >
<table aria-label= " Recordings archive " >
@@ -611,8 +735,30 @@ const fmtT = s => {
};
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
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) {
const prow = document.getElementById( ' prow- ' +idx);
@@ -622,14 +768,16 @@ function togglePlayer(idx, filename) {
if (!open) {
if (!audio.getAttribute( ' data-src-set ' )) {
audio.preload = ' metadata ' ;
audio.src = ' /stream/ ' + encodeURIComponent(filename);
audio.load();
audio.setAttribute( ' data-src-set ' , ' 1 ' );
}
activePlayerIdx = idx;
prow.hidden = false;
btn.setAttribute( ' aria-expanded ' , ' true ' );
btn.textContent = ' ⏹ Hide ' ;
btn.setAttribute( ' aria-label ' , ' Hide player for ' +filename);
// Move focus to audio control so keyboard users can operate it immediately
audio.focus();
} else {
audio.pause();
@@ -676,7 +824,32 @@ function drawWave(rms, sections, duration, filename) {
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.textContent = ' … ' ;
cell.innerHTML = ' <div class= " spin " aria-live= " polite " aria-busy= " true " >Analysing…</div> ' ;
@@ -696,15 +869,19 @@ async function analyse(filename, cell, btn) {
const chips = document.createElement( ' div ' );
chips.className= ' chips ' ;
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) {
d. sections.forEach(s => {
const c = document.createElement( ' span ' );
sectionMap.set(idx, d.sections);
d.sections.forEach((s, si) => {
const c = document.createElement( ' button ' );
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, s.end, si));
chips.appendChild(c);
});
} else {
sectionMap.delete(idx);
const q = document.createElement( ' span ' );
q.className= ' quiet ' ; q.setAttribute( ' role ' , ' listitem ' );
q.textContent= ' No loud sections found ' ;
@@ -718,6 +895,49 @@ 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);
@@ -729,10 +949,13 @@ async function deleteFile(idx, filename) {
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 = ' ' ;
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(()=>( {} ));
@@ -756,32 +979,21 @@ async function updateStorage() {
} catch(e) {}
}
async function load( ) {
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;
}
function renderFiles(files ) {
const tbody = document.getElementById( ' tbody ' );
tbody.innerHTML = ' ' ;
recMap.clear();
sectionMap.clear();
const n = f iles.length;
document.getElementById( ' subtitle ' ).textContent =
`$ {n} recording$ { n!==1? ' s ' : ' ' } found`;
document.getElementById( ' empty ' ).style.display = n ? ' none ' : ' ' ;
updateStorage() ;
if (!n) { refreshBtn.disabled = false; return; }
const total = allF iles.length;
const visible = files.length;
document.getElementById( ' subtitle ' ).textContent = total === visible
? `$ {total} recording$ { total!==1? ' s ' : ' ' } found`
: `$ {visible} of $ {total} recording$ { total!==1? ' s ' : ' ' } shown` ;
document.getElementById( ' empty ' ).style.display = visible ? ' none ' : ' ' ;
files.forEach((f, i) => {
files.forEach(f => {
const i = f._idx;
const ext = f.ext;
const canAnalyse = ext === ' wav ' || ext === ' flac ' ;
const isRec = !!f.recording;
@@ -812,6 +1024,9 @@ async function load() {
aria-label= " Play $ { esc(f.name)} " >▶ Play</button>
<a class= " dl " href= " /download/$ { encodeURIComponent(f.name)} "
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>
@@ -824,9 +1039,24 @@ async function load() {
prow.className = ' player-row ' ;
prow.id = ' prow- ' +i;
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 " >
<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>`;
tbody.appendChild(prow);
@@ -840,7 +1070,7 @@ async function load() {
abtn.disabled = true;
abtn.title = ' Recording in progress — analyse after recording stops ' ;
} else {
abtn.addEventListener( ' click ' , () => analyse(f.name, cell, abtn));
abtn.addEventListener( ' click ' , () => analyse(i, f.name, cell, abtn));
}
cell.appendChild(abtn);
}
@@ -849,16 +1079,72 @@ async function load() {
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)
.addEventListener( ' click ' , () => deleteFile(i, f.name));
}
// ---- register for live-status polling ----
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;
}
@@ -879,6 +1165,16 @@ async function pollStatus() {
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
fetch( ' /api/config ' ).then(r => r.json()).then(cfg => {
if (cfg.threshold != null)
@@ -913,7 +1209,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 ( ) } " )