fix(fmrelay): start path + JSON API + mounts XML + inline live preview

Four bugs surfaced when actually running a relay live (#377):

1. cmd_start sanity check still referenced lame_present after the
   ffmpeg switch — every start failed with
   `fmrelayctl: line 209: lame_present: command not found`.
   Renamed to ffmpeg_present.

2. log()/warn() wrote the human banner to stdout, which the API's
   subprocess.check_output(..., stderr=subprocess.STDOUT) merged into
   the JSON parser. /start returned 500 even when the runner spawned
   correctly. log() now writes to stderr; api/_ctl_json switches to
   subprocess.run(capture_output=True) so stdout stays clean JSON
   and stderr is only surfaced on failure.

3. emit_mounts_json grep'd `<mount>...</mount>` elements but icecast2
   /admin/listmounts uses `<source mount="...">` attribute form. The
   mounts table always showed empty. New parser handles the real XML
   shape + reports connected_sec + content_type alongside listeners.

4. admin webui had only the "Open Webradio →" link (which targets a
   vhost that doesn't exist yet). Added a card with `<audio controls>`
   that auto-binds to the active LAN mount via /access + /mounts,
   so the operator can verify playback without leaving the dashboard.
This commit is contained in:
CyberMind-FR 2026-05-24 15:13:26 +02:00
parent 79ee6468e8
commit 14a7590c82
3 changed files with 66 additions and 10 deletions

View File

@ -45,18 +45,22 @@ app = FastAPI(
def _ctl_json(*args: str) -> Dict[str, Any]:
"""Invoke fmrelayctl with --json and parse the result."""
"""Invoke fmrelayctl with --json and parse the result.
stderr is captured separately so human-readable log lines from
`log()` don't pollute the JSON parser on stdout. The captured
stderr is only surfaced when the call fails."""
cmd = [CTL, *args, "--json"]
try:
out = subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=15)
proc = subprocess.run(cmd, capture_output=True, timeout=15, check=True)
except subprocess.CalledProcessError as e:
raise HTTPException(status_code=500, detail=f"fmrelayctl failed: {e.output!r}")
raise HTTPException(status_code=500, detail=f"fmrelayctl failed: {e.stderr!r}")
except FileNotFoundError:
raise HTTPException(status_code=500, detail=f"fmrelayctl not found at {CTL}")
try:
return json.loads(out.decode())
return json.loads(proc.stdout.decode())
except json.JSONDecodeError as e:
raise HTTPException(status_code=500, detail=f"fmrelayctl non-JSON: {e}; raw={out!r}")
raise HTTPException(status_code=500, detail=f"fmrelayctl non-JSON: {e}; stdout={proc.stdout!r}; stderr={proc.stderr!r}")
def _ctl_text(*args: str) -> str:

View File

@ -31,7 +31,7 @@ readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly NC='\033[0m'
log() { printf '%b[fmrelay]%b %s\n' "$GREEN" "$NC" "$*"; }
log() { printf '%b[fmrelay]%b %s\n' "$GREEN" "$NC" "$*" >&2; }
warn() { printf '%b[warn]%b %s\n' "$YELLOW" "$NC" "$*" >&2; }
err() { printf '%b[error]%b %s\n' "$RED" "$NC" "$*" >&2; }
@ -165,13 +165,28 @@ emit_mounts_json() {
return
fi
# Hand-parse the XML response — full XML parser would need lxml as
# a hard dep on the host. icecast's <listmounts> shape is stable.
# a hard dep on the host. icecast2 /admin/listmounts shape:
# <source mount="/foo.mp3">
# <listeners>0</listeners>
# <Connected>63</Connected>
# <content-type>audio/mpeg</content-type>
# </source>
python3 - <<PY
import re, json
xml = """${raw//\"/\\\"}"""
mounts = []
for m in re.finditer(r"<mount>([^<]+)</mount>.*?<listeners>(\d+)</listeners>", xml, re.S):
mounts.append({"mount": m.group(1), "listeners": int(m.group(2))})
for m in re.finditer(r'<source\b[^>]*\bmount="([^"]+)"[^>]*>(.*?)</source>', xml, re.S):
mount, body = m.group(1), m.group(2)
def field(name):
mm = re.search(rf"<{name}>([^<]*)</{name}>", body)
return mm.group(1) if mm else ""
listeners = field("listeners") or "0"
mounts.append({
"mount": mount,
"listeners": int(listeners),
"connected_sec": int(field("Connected") or 0),
"content_type": field("content-type"),
})
print(json.dumps({"module": "fmrelay", "mounts": mounts}))
PY
}
@ -206,7 +221,7 @@ cmd_start() {
if ! icecast_running; then err "icecast2 not running on ${ICECAST_HOST}:${ICECAST_PORT}"; exit 2; fi
if ! rtl_fm_present; then err "rtl_fm not installed (apt install rtl-sdr)"; exit 2; fi
if ! lame_present; then err "lame not installed (apt install lame)"; exit 2; fi
if ! ffmpeg_present; then err "ffmpeg not installed (apt install ffmpeg)"; exit 2; fi
log "Starting fmrelay: freq=$freq mount=$mount bitrate=${bitrate}k gain=${gain}dB ppm=${ppm}"
echo "$freq" > "$CURRENT_FREQ_FILE"

View File

@ -67,6 +67,18 @@
</a>
</header>
<section class="card" style="margin-bottom: 1rem;">
<h3>Live preview</h3>
<p class="hint" style="margin-bottom: 0.5rem;">
Direct playback of the active LAN mount — handy to verify the stream
is healthy without leaving the dashboard.
</p>
<audio id="live-audio" controls preload="none" style="width: 100%; max-width: 500px;"></audio>
<div id="live-audio-meta" style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--muted);">
no active mount
</div>
</section>
<section class="cards">
<article class="card">
<h3>Status</h3>
@ -180,6 +192,11 @@
const target = (pub && pub.url) || (lan && lan.url) || "#";
const btn = document.getElementById("open-webradio");
if (btn) btn.href = target;
// Cache the LAN base for refreshLivePreview() (called by loadMounts
// once the mounts list is in — avoids relying on poll order).
window._fmrelayLanBase = (lan && lan.url) ? lan.url.replace(/\/$/, "") : "";
refreshLivePreview();
} catch (e) {
document.getElementById("access-v").innerHTML = `<span class="empty">${esc(e.message)}</span>`;
}
@ -209,6 +226,8 @@
try {
const d = await api("/mounts");
const list = d.mounts || [];
window._fmrelayLastMounts = list;
refreshLivePreview();
if (!list.length) {
wrap.innerHTML = `<div class="empty">no active mounts${d.reason ? " — " + esc(d.reason) : ""}</div>`;
return;
@ -257,6 +276,24 @@
}
}
function refreshLivePreview() {
try {
const audio = document.getElementById("live-audio");
const meta = document.getElementById("live-audio-meta");
const base = window._fmrelayLanBase || "";
const mounts = window._fmrelayLastMounts || [];
const liveMount = mounts.find(m => (m.connected_sec || 0) > 0);
if (audio && base && liveMount) {
const url = base + liveMount.mount;
if (audio.getAttribute("src") !== url) audio.setAttribute("src", url);
if (meta) meta.textContent = `mount ${liveMount.mount} · ${liveMount.connected_sec}s · ${liveMount.content_type || ""}`;
} else if (audio) {
audio.removeAttribute("src");
if (meta) meta.textContent = "no active mount — start a relay below";
}
} catch (_) {}
}
function loadAll() {
loadStatus();
loadComponents();