From 6cafbdd3923b616ff28c348a37642f17d0025303 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 20:19:47 +0200 Subject: [PATCH] control: add web ingest config save and hard reload --- cmd/fmrtx/main.go | 19 +- internal/config/config.go | 15 + internal/control/control.go | 82 ++++++ internal/control/control_test.go | 111 ++++++++ internal/control/ui.html | 465 ++++++++++++++++++++++++++++++- 5 files changed, 689 insertions(+), 3 deletions(-) diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 6617414..700913f 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -101,11 +101,12 @@ func main() { if driver == nil { log.Fatal("no hardware driver available - build with -tags pluto (or -tags soapy)") } - runTXMode(cfg, driver, *txAutoStart, *audioStdin, *audioRate, *audioHTTP) + runTXMode(cfg, *configPath, driver, *txAutoStart, *audioStdin, *audioRate, *audioHTTP) return } srv := ctrlpkg.NewServer(cfg) + configureControlPlanePersistence(srv, *configPath) server := ctrlpkg.NewHTTPServer(cfg, srv.Handler()) log.Printf("fm-rds-tx listening on %s (TX default: off, use --tx for hardware)", server.Addr) log.Fatal(server.ListenAndServe()) @@ -140,7 +141,7 @@ func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver { return nil } -func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, audioStdin bool, audioRate int, audioHTTP bool) { +func runTXMode(cfg cfgpkg.Config, configPath string, driver platform.SoapyDriver, autoStart bool, audioStdin bool, audioRate int, audioHTTP bool) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -226,6 +227,7 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a } srv := ctrlpkg.NewServer(cfg) + configureControlPlanePersistence(srv, configPath) srv.SetDriver(driver) srv.SetTXController(&txBridge{engine: engine}) if streamSrc != nil { @@ -269,6 +271,19 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a log.Println("shutdown complete") } +func configureControlPlanePersistence(srv *ctrlpkg.Server, configPath string) { + if strings.TrimSpace(configPath) == "" { + return + } + srv.SetConfigSaver(func(next cfgpkg.Config) error { + return cfgpkg.Save(configPath, next) + }) + srv.SetHardReload(func() { + log.Printf("control: hard reload requested after config save, exiting process") + os.Exit(0) + }) +} + func ingestEnabled(kind string) bool { normalized := strings.ToLower(strings.TrimSpace(kind)) return normalized != "" && normalized != "none" diff --git a/internal/config/config.go b/internal/config/config.go index 2d469ca..45f64bd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -237,6 +237,21 @@ func Load(path string) (Config, error) { return cfg, cfg.Validate() } +func Save(path string, cfg Config) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("config path is required") + } + if err := cfg.Validate(); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o644) +} + func (c Config) Validate() error { if c.Audio.Gain < 0 || c.Audio.Gain > 4 { return fmt.Errorf("audio.gain out of range") diff --git a/internal/control/control.go b/internal/control/control.go index 131c473..1e9bd9d 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -10,6 +10,7 @@ import ( "strings" "sync" "sync/atomic" + "time" "github.com/jan/fm-rds-tx/internal/audio" "github.com/jan/fm-rds-tx/internal/config" @@ -54,6 +55,8 @@ type Server struct { streamSrc *audio.StreamSource // optional, for live audio ring stats audioIngress AudioIngress // optional, for /audio/stream ingestRt IngestRuntime // optional, for /runtime ingest stats + saveConfig func(config.Config) error + hardReload func() audit auditCounters } @@ -125,6 +128,10 @@ type ConfigPatch struct { LimiterCeiling *float64 `json:"limiterCeiling,omitempty"` } +type IngestSaveRequest struct { + Ingest config.IngestConfig `json:"ingest"` +} + func NewServer(cfg config.Config) *Server { return &Server{cfg: cfg} } @@ -219,6 +226,18 @@ func (s *Server) SetIngestRuntime(rt IngestRuntime) { s.mu.Unlock() } +func (s *Server) SetConfigSaver(save func(config.Config) error) { + s.mu.Lock() + s.saveConfig = save + s.mu.Unlock() +} + +func (s *Server) SetHardReload(fn func()) { + s.mu.Lock() + s.hardReload = fn + s.mu.Unlock() +} + func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/", s.handleUI) @@ -226,6 +245,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/status", s.handleStatus) mux.HandleFunc("/dry-run", s.handleDryRun) mux.HandleFunc("/config", s.handleConfig) + mux.HandleFunc("/config/ingest/save", s.handleIngestSave) mux.HandleFunc("/runtime", s.handleRuntime) mux.HandleFunc("/runtime/fault/reset", s.handleRuntimeFaultReset) mux.HandleFunc("/tx/start", s.handleTXStart) @@ -561,3 +581,65 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } + +func (s *Server) handleIngestSave(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + s.recordAudit(auditMethodNotAllowed) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !isJSONContentType(r) { + s.recordAudit(auditUnsupportedMediaType) + http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType) + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxConfigBodyBytes) + + var req IngestSaveRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + statusCode := http.StatusBadRequest + if strings.Contains(err.Error(), "http: request body too large") { + statusCode = http.StatusRequestEntityTooLarge + s.recordAudit(auditBodyTooLarge) + } + http.Error(w, err.Error(), statusCode) + return + } + + s.mu.Lock() + next := s.cfg + next.Ingest = req.Ingest + if err := next.Validate(); err != nil { + s.mu.Unlock() + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + save := s.saveConfig + reload := s.hardReload + if save == nil { + s.mu.Unlock() + http.Error(w, "config save is not configured (start with --config )", http.StatusServiceUnavailable) + return + } + if err := save(next); err != nil { + s.mu.Unlock() + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + s.cfg = next + s.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + reloadScheduled := reload != nil + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "saved": true, + "reloadScheduled": reloadScheduled, + }) + if reloadScheduled { + go func(fn func()) { + time.Sleep(250 * time.Millisecond) + fn() + }(reload) + } +} diff --git a/internal/control/control_test.go b/internal/control/control_test.go index 11fa3a8..6134ca2 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -6,8 +6,11 @@ import ( "errors" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" + "time" cfgpkg "github.com/jan/fm-rds-tx/internal/config" "github.com/jan/fm-rds-tx/internal/ingest" @@ -168,6 +171,108 @@ func TestConfigPatchRejectsNonJSONContentType(t *testing.T) { } } +func TestIngestSavePersistsAndSchedulesReload(t *testing.T) { + cfg := cfgpkg.Default() + cfg.Ingest.Kind = "icecast" + cfg.Ingest.Icecast.URL = "https://example.invalid/live" + srv := NewServer(cfg) + + dir := t.TempDir() + configPath := filepath.Join(dir, "saved.json") + reloadDone := make(chan struct{}, 1) + srv.SetConfigSaver(func(next cfgpkg.Config) error { + return cfgpkg.Save(configPath, next) + }) + srv.SetHardReload(func() { + select { + case reloadDone <- struct{}{}: + default: + } + }) + + nextIngest := cfgpkg.Default().Ingest + nextIngest.Kind = "srt" + nextIngest.PrebufferMs = 1000 + nextIngest.StallTimeoutMs = 2500 + nextIngest.Reconnect.Enabled = true + nextIngest.Reconnect.InitialBackoffMs = 500 + nextIngest.Reconnect.MaxBackoffMs = 5000 + nextIngest.SRT.URL = "srt://0.0.0.0:9000?mode=listener" + body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest}) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body)) + if rec.Code != http.StatusOK { + t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String()) + } + select { + case <-reloadDone: + case <-time.After(2 * time.Second): + t.Fatal("expected hard reload callback") + } + saved, err := cfgpkg.Load(configPath) + if err != nil { + t.Fatalf("load saved config: %v", err) + } + if saved.Ingest.Kind != "srt" { + t.Fatalf("expected saved ingest kind srt, got %q", saved.Ingest.Kind) + } + if saved.Ingest.SRT.URL != "srt://0.0.0.0:9000?mode=listener" { + t.Fatalf("expected saved ingest.srt.url, got %q", saved.Ingest.SRT.URL) + } +} + +func TestIngestSaveRejectsWhenSaverMissing(t *testing.T) { + cfg := cfgpkg.Default() + cfg.Ingest.Kind = "icecast" + cfg.Ingest.Icecast.URL = "https://example.invalid/live" + srv := NewServer(cfg) + rec := httptest.NewRecorder() + nextIngest := cfgpkg.Default().Ingest + nextIngest.Kind = "icecast" + nextIngest.Icecast.URL = "https://example.invalid/live" + body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest}) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestIngestSaveUsesValidationErrors(t *testing.T) { + cfg := cfgpkg.Default() + cfg.Ingest.Kind = "icecast" + cfg.Ingest.Icecast.URL = "https://example.invalid/live" + srv := NewServer(cfg) + dir := t.TempDir() + configPath := filepath.Join(dir, "saved.json") + srv.SetConfigSaver(func(next cfgpkg.Config) error { + return cfgpkg.Save(configPath, next) + }) + rec := httptest.NewRecorder() + nextIngest := cfgpkg.Default().Ingest + nextIngest.Kind = "srt" + nextIngest.SRT.URL = "" + body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest}) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "ingest.srt.url is required") { + t.Fatalf("expected existing validation error, got %q", rec.Body.String()) + } + if _, err := os.Stat(configPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected no config file to be written, stat err=%v", err) + } +} + func TestRuntimeWithoutDriver(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder() @@ -732,6 +837,12 @@ func newConfigPostRequest(body []byte) *http.Request { return req } +func newIngestSavePostRequest(body []byte) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/config/ingest/save", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + return req +} + type fakeTXController struct { updateErr error resetErr error diff --git a/internal/control/ui.html b/internal/control/ui.html index 792962b..f6f5784 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -587,7 +587,7 @@ input[type="range"]::-webkit-slider-thumb:hover { background: var(--accent); transform: scale(1.06); } -input[type="number"], input[type="text"] { +input[type="number"], input[type="text"], select { background: #fff; border: 1px solid var(--border); border-radius: 6px; @@ -601,6 +601,10 @@ input[type="number"] { text-align: right; } input[type="text"] { width: 100%; } +select { + min-width: 140px; + width: 100%; +} input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(31,77,157,.12); @@ -737,6 +741,39 @@ input.input-error { flex-wrap: wrap; margin-top: 14px; } + +.ingest-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.ingest-grid .ctrl-row { + align-items: flex-start; + padding: 0; + border-bottom: none; +} + +.ingest-grid .ctrl-label-wrap { + min-width: 0; + gap: 4px; +} + +.ingest-group { + margin-top: 12px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface2); +} + +.ingest-group-title { + margin-bottom: 8px; + font-size: 10px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: .08em; +} .apply-btn { background: var(--accent); border-color: transparent; @@ -975,6 +1012,7 @@ input.input-error { .ctrl-row { flex-direction: column; align-items: stretch; } .ctrl-label-wrap { min-width: auto; } .ctrl-input { flex-wrap: wrap; } + .ingest-grid { grid-template-columns: 1fr; } input[type="number"] { width: 100%; text-align: left; } .actions-row, .tx-actions { flex-direction: column; } .tx-btn, .ghost-btn, .apply-btn, .preset-btn, .danger-btn { width: 100%; } @@ -1244,6 +1282,230 @@ input.input-error { +
+
+
+

Ingest Config

+
Saved config
+ +
+
+
Edit ingest source settings, save to config file, then force a hard reload so the runtime restarts with the new ingest path.
+ +
+
+
+ Ingest Kind +
+
+ +
+
+ +
+
+ Prebuffer + ms +
+
+ +
+
+ +
+
+ Stall Timeout + ms +
+
+ +
+
+ +
+
+ Reconnect +
+
+ +
+
+ +
+
+ Backoff Initial + ms +
+
+ +
+
+ +
+
+ Backoff Max + ms +
+
+ +
+
+
+ +
+
Icecast
+
+
+
URL
+
+
+
+
Decoder
+
+ +
+
+
+
RadioText Relay
+
+ +
+
+
+
RT Prefix
+
+
+
+
RT MaxLen
+
+
+
+
RT Only On Change
+
+ +
+
+
+
+ +
+
SRT
+
+
+
URL
+
+
+
+
Mode
+
+ +
+
+
+
Sample Rate
+
+
+
+
Channels
+
+
+
+
+ +
+
AES67
+
+
+
SDP Path
+
+
+
+
SDP Inline
+
+
+
+
Multicast Group
+
+
+
+
Port
+
+
+
+
Payload Type
+
+
+
+
Sample Rate
+
+
+
+
Channels
+
+
+
+
Encoding
+
+
+
+
Packet Time
+
+
+
+
Jitter Depth
+
+
+
+
Read Buffer
+
+
+
+
Discovery
+
+
+
+
Discovery Name
+
+
+
+
Discovery Timeout
+
+
+
+
SAP Group
+
+
+
+
SAP Port
+
+
+
+
+ +
+
+ + +
+
+
+
@@ -1437,6 +1699,10 @@ const state = { toggleBusy: {}, pollersStarted: false, mobilePanelsApplied: false, + ingestDraft: null, + ingestDirty: false, + ingestSaving: false, + ingestError: '', charts: { audio: [], underruns: [], @@ -1462,6 +1728,68 @@ function nearlyEqual(a, b, eps = 1e-9) { function nowTs() { return Date.now(); } +function deepClone(obj) { + return JSON.parse(JSON.stringify(obj ?? {})); +} + +function getPathValue(obj, path) { + if (!obj) return undefined; + const parts = String(path || '').split('.'); + let cur = obj; + for (const part of parts) { + if (!part) continue; + if (cur == null || typeof cur !== 'object') return undefined; + cur = cur[part]; + } + return cur; +} + +function setPathValue(obj, path, value) { + const parts = String(path || '').split('.'); + let cur = obj; + for (let i = 0; i < parts.length; i += 1) { + const part = parts[i]; + if (!part) continue; + if (i === parts.length - 1) { + cur[part] = value; + return; + } + if (!cur[part] || typeof cur[part] !== 'object') cur[part] = {}; + cur = cur[part]; + } +} + +function ingestFromServer() { + return state.server.config?.ingest || {}; +} + +function updateIngestDirtyFromServer() { + const serverRaw = ingestFromServer(); + const draftRaw = state.ingestDraft || {}; + state.ingestDirty = JSON.stringify(draftRaw) !== JSON.stringify(serverRaw); +} + +function syncIngestDraftFromConfig(force = false) { + if (!state.server.config) return; + if (!state.ingestDraft || force || !state.ingestDirty) { + state.ingestDraft = deepClone(ingestFromServer()); + state.ingestError = ''; + } + updateIngestDirtyFromServer(); +} + +function ingestFieldValue(path) { + return getPathValue(state.ingestDraft || {}, path); +} + +function setIngestField(path, value) { + if (!state.ingestDraft) state.ingestDraft = deepClone(ingestFromServer()); + setPathValue(state.ingestDraft, path, value); + updateIngestDirtyFromServer(); + state.ingestError = ''; + render(); +} + function serverValue(key) { const cfg = state.server.config; if (!cfg) return undefined; @@ -1578,6 +1906,7 @@ async function loadConfig({ silent = false } = {}) { state.server.config = cfg; state.server.configOk = true; state.server.lastConfigAt = nowTs(); + syncIngestDraftFromConfig(); syncFreqPresetIndex(cfg.fm?.frequencyMHz); setConnection(true, state.pendingRequests > 0 ? 'busy' : 'connected'); render(); @@ -1790,6 +2119,52 @@ function resetSection(section) { toast('Draft reset', 'info'); } +async function saveIngestConfig() { + if (state.ingestSaving) return; + if (!state.ingestDirty) { + toast('No ingest changes to save', 'info'); + return; + } + if (!state.ingestDraft) { + toast('Ingest draft not ready yet', 'warn'); + return; + } + state.ingestSaving = true; + state.ingestError = ''; + beginRequest(); + render(); + try { + const result = await api('/config/ingest/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ingest: state.ingestDraft }), + }); + state.ingestDirty = false; + toast(result.reloadScheduled ? 'Ingest saved, hard reload scheduled' : 'Ingest saved', 'ok'); + log('INGEST save accepted' + (result.reloadScheduled ? ' [hard-reload]' : ''), 'ok'); + if (result.reloadScheduled) { + setTimeout(() => { + window.location.reload(); + }, 1500); + } + } catch (error) { + state.ingestError = error.message; + toast(error.message, 'err'); + log('INGEST save failed: ' + error.message, 'err'); + } finally { + state.ingestSaving = false; + endRequest(); + render(); + } +} + +function resetIngestDraft() { + syncIngestDraftFromConfig(true); + toast('Ingest draft reset', 'info'); + log('INGEST draft reset', 'warn'); + render(); +} + async function setToggle(key, nextValue) { if (state.toggleBusy[key]) return; state.toggleBusy[key] = true; @@ -1920,6 +2295,21 @@ function syncDirtyInput(id, key, transform = (v) => v) { el.classList.toggle('input-error', !!state.errors[key]); } +function syncIngestInput(id, path, transform = (v) => v) { + const el = $(id); + if (!el) return; + const value = transform(ingestFieldValue(path)); + const asString = value == null ? '' : String(value); + const isFocused = document.activeElement === el; + if (!isFocused && el.value !== asString) el.value = asString; +} + +function syncIngestCheckbox(id, path) { + const el = $(id); + if (!el) return; + el.checked = !!ingestFieldValue(path); +} + function renderFieldErrors() { renderFieldError('freq-error', state.errors.frequencyMHz); renderFieldError('ps-error', state.errors.ps); @@ -2075,6 +2465,41 @@ function render() { syncDirtyInput('freq-num', 'frequencyMHz', (v) => typeof v === 'number' ? v.toFixed(1) : '100.0'); syncDirtyInput('rds-ps', 'ps', (v) => String(v ?? '')); syncDirtyInput('rds-rt', 'radioText', (v) => String(v ?? '')); + syncIngestInput('ing-kind', 'kind', (v) => String(v ?? 'none')); + syncIngestInput('ing-prebuffer', 'prebufferMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 0); + syncIngestInput('ing-stall-timeout', 'stallTimeoutMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 0); + syncIngestCheckbox('ing-reconnect-enabled', 'reconnect.enabled'); + syncIngestInput('ing-reconnect-initial', 'reconnect.initialBackoffMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 0); + syncIngestInput('ing-reconnect-max', 'reconnect.maxBackoffMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 0); + + syncIngestInput('ing-icecast-url', 'icecast.url', (v) => String(v ?? '')); + syncIngestInput('ing-icecast-decoder', 'icecast.decoder', (v) => String(v ?? 'auto')); + syncIngestCheckbox('ing-icecast-rt-enabled', 'icecast.radioText.enabled'); + syncIngestInput('ing-icecast-rt-prefix', 'icecast.radioText.prefix', (v) => String(v ?? '')); + syncIngestInput('ing-icecast-rt-maxlen', 'icecast.radioText.maxLen', (v) => Number.isFinite(Number(v)) ? Number(v) : 64); + syncIngestCheckbox('ing-icecast-rt-only-change', 'icecast.radioText.onlyOnChange'); + + syncIngestInput('ing-srt-url', 'srt.url', (v) => String(v ?? '')); + syncIngestInput('ing-srt-mode', 'srt.mode', (v) => String(v ?? 'listener')); + syncIngestInput('ing-srt-rate', 'srt.sampleRateHz', (v) => Number.isFinite(Number(v)) ? Number(v) : 48000); + syncIngestInput('ing-srt-channels', 'srt.channels', (v) => Number.isFinite(Number(v)) ? Number(v) : 2); + + syncIngestInput('ing-aes67-sdppath', 'aes67.sdpPath', (v) => String(v ?? '')); + syncIngestInput('ing-aes67-sdp', 'aes67.sdp', (v) => String(v ?? '')); + syncIngestInput('ing-aes67-group', 'aes67.multicastGroup', (v) => String(v ?? '')); + syncIngestInput('ing-aes67-port', 'aes67.port', (v) => Number.isFinite(Number(v)) ? Number(v) : 5004); + syncIngestInput('ing-aes67-pt', 'aes67.payloadType', (v) => Number.isFinite(Number(v)) ? Number(v) : 97); + syncIngestInput('ing-aes67-rate', 'aes67.sampleRateHz', (v) => Number.isFinite(Number(v)) ? Number(v) : 48000); + syncIngestInput('ing-aes67-channels', 'aes67.channels', (v) => Number.isFinite(Number(v)) ? Number(v) : 2); + syncIngestInput('ing-aes67-encoding', 'aes67.encoding', (v) => String(v ?? 'L24')); + syncIngestInput('ing-aes67-ptime', 'aes67.packetTimeMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 1); + syncIngestInput('ing-aes67-jitter', 'aes67.jitterDepthPackets', (v) => Number.isFinite(Number(v)) ? Number(v) : 8); + syncIngestInput('ing-aes67-readbuf', 'aes67.readBufferBytes', (v) => Number.isFinite(Number(v)) ? Number(v) : 1048576); + syncIngestCheckbox('ing-aes67-discovery-enabled', 'aes67.discovery.enabled'); + syncIngestInput('ing-aes67-discovery-name', 'aes67.discovery.streamName', (v) => String(v ?? '')); + syncIngestInput('ing-aes67-discovery-timeout', 'aes67.discovery.timeoutMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 3000); + syncIngestInput('ing-aes67-discovery-group', 'aes67.discovery.sapGroup', (v) => String(v ?? '')); + syncIngestInput('ing-aes67-discovery-port', 'aes67.discovery.sapPort', (v) => Number.isFinite(Number(v)) ? Number(v) : 0); const psValue = String(effectiveValue('ps') ?? cfg.rds?.ps ?? ''); const rtValue = String(effectiveValue('radioText') ?? cfg.rds?.radioText ?? ''); @@ -2093,9 +2518,20 @@ function render() { const rdsDirty = isDirtySection('rds'); $('rds-apply').disabled = !rdsDirty || sectionHasErrors('rds'); $('rds-reset').disabled = !rdsDirty; + const ingestKind = String(ingestFieldValue('kind') || 'none').toLowerCase(); + $('ing-group-icecast').style.display = ingestKind === 'icecast' ? '' : 'none'; + $('ing-group-srt').style.display = ingestKind === 'srt' ? '' : 'none'; + $('ing-group-aes67').style.display = ingestKind === 'aes67' ? '' : 'none'; + $('ingest-save-reload').disabled = !state.ingestDirty || state.ingestSaving || !state.server.configOk; + $('ingest-save-reload').textContent = state.ingestSaving ? 'Saving...' : 'Save + Hard Reload'; + $('ingest-reset').disabled = !state.ingestDirty || state.ingestSaving; + const ingestErr = $('ingest-error'); + ingestErr.textContent = state.ingestError || ''; + ingestErr.classList.toggle('show', !!state.ingestError); updateText('freq-meta', sectionHasErrors('freq') ? 'Validation error' : (freqDirty ? 'Unsaved changes' : 'Live-tunable')); updateText('rds-meta', sectionHasErrors('rds') ? 'Validation error' : (rdsDirty ? `${Object.keys(getSectionPatch('rds')).length} unsaved` : 'PS + RT')); + updateText('ingest-meta', state.ingestSaving ? 'Saving' : (state.ingestDirty ? 'Unsaved changes' : 'Saved config')); updateText('info-backend', cfg.backend?.kind || cfg.backend || '--'); updateText('info-freq', fmtFreq(cfg.fm?.frequencyMHz)); @@ -2532,6 +2968,33 @@ function bindInputs() { $('rds-apply').addEventListener('click', () => applySection('rds')); $('freq-reset').addEventListener('click', () => resetSection('freq')); $('rds-reset').addEventListener('click', () => resetSection('rds')); + $('ingest-save-reload').addEventListener('click', () => saveIngestConfig()); + $('ingest-reset').addEventListener('click', () => resetIngestDraft()); + + document.querySelectorAll('[data-ingest-path]').forEach((el) => { + const path = el.dataset.ingestPath; + const tag = (el.tagName || '').toLowerCase(); + const type = String(el.getAttribute('type') || '').toLowerCase(); + const isCheckbox = type === 'checkbox'; + const isNumber = type === 'number'; + const evt = isCheckbox ? 'change' : 'input'; + el.addEventListener(evt, () => { + if (isCheckbox) { + setIngestField(path, !!el.checked); + return; + } + if (isNumber) { + const n = Number(el.value); + setIngestField(path, Number.isFinite(n) ? Math.trunc(n) : 0); + return; + } + if (tag === 'select') { + setIngestField(path, String(el.value || '')); + return; + } + setIngestField(path, String(el.value || '')); + }); + }); $('btn-start').addEventListener('click', () => txAction('start')); $('btn-stop').addEventListener('click', () => txAction('stop'));