Kaynağa Gözat

control: add web ingest config save and hard reload

main
Jan 1 ay önce
ebeveyn
işleme
6cafbdd392
5 değiştirilmiş dosya ile 689 ekleme ve 3 silme
  1. +17
    -2
      cmd/fmrtx/main.go
  2. +15
    -0
      internal/config/config.go
  3. +82
    -0
      internal/control/control.go
  4. +111
    -0
      internal/control/control_test.go
  5. +464
    -1
      internal/control/ui.html

+ 17
- 2
cmd/fmrtx/main.go Dosyayı Görüntüle

@@ -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"


+ 15
- 0
internal/config/config.go Dosyayı Görüntüle

@@ -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")


+ 82
- 0
internal/control/control.go Dosyayı Görüntüle

@@ -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 <path>)", 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)
}
}

+ 111
- 0
internal/control/control_test.go Dosyayı Görüntüle

@@ -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


+ 464
- 1
internal/control/ui.html Dosyayı Görüntüle

@@ -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 {
</div>
</div>

<div class="card panel" data-panel-key="ingest">
<div class="panel-head" data-panel>
<div class="led on-blue" style="width:6px;height:6px"></div>
<h2>Ingest Config</h2>
<div class="meta" id="ingest-meta">Saved config</div>
<span class="chevron">▼</span>
</div>
<div class="panel-body">
<div class="section-note">Edit ingest source settings, save to config file, then force a hard reload so the runtime restarts with the new ingest path.</div>

<div class="ingest-grid">
<div class="ctrl-row">
<div class="ctrl-label-wrap">
<span class="ctrl-label">Ingest Kind</span>
</div>
<div class="ctrl-input">
<select id="ing-kind" data-ingest-path="kind">
<option value="none">none</option>
<option value="icecast">icecast</option>
<option value="srt">srt</option>
<option value="aes67">aes67</option>
<option value="stdin">stdin</option>
<option value="http-raw">http-raw</option>
</select>
</div>
</div>

<div class="ctrl-row">
<div class="ctrl-label-wrap">
<span class="ctrl-label">Prebuffer</span>
<span class="ctrl-sub">ms</span>
</div>
<div class="ctrl-input">
<input type="number" id="ing-prebuffer" data-ingest-path="prebufferMs">
</div>
</div>

<div class="ctrl-row">
<div class="ctrl-label-wrap">
<span class="ctrl-label">Stall Timeout</span>
<span class="ctrl-sub">ms</span>
</div>
<div class="ctrl-input">
<input type="number" id="ing-stall-timeout" data-ingest-path="stallTimeoutMs">
</div>
</div>

<div class="ctrl-row">
<div class="ctrl-label-wrap">
<span class="ctrl-label">Reconnect</span>
</div>
<div class="ctrl-input">
<label><input type="checkbox" id="ing-reconnect-enabled" data-ingest-path="reconnect.enabled"> Enabled</label>
</div>
</div>

<div class="ctrl-row">
<div class="ctrl-label-wrap">
<span class="ctrl-label">Backoff Initial</span>
<span class="ctrl-sub">ms</span>
</div>
<div class="ctrl-input">
<input type="number" id="ing-reconnect-initial" data-ingest-path="reconnect.initialBackoffMs">
</div>
</div>

<div class="ctrl-row">
<div class="ctrl-label-wrap">
<span class="ctrl-label">Backoff Max</span>
<span class="ctrl-sub">ms</span>
</div>
<div class="ctrl-input">
<input type="number" id="ing-reconnect-max" data-ingest-path="reconnect.maxBackoffMs">
</div>
</div>
</div>

<div class="ingest-group" id="ing-group-icecast">
<div class="ingest-group-title">Icecast</div>
<div class="ingest-grid">
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">URL</span></div>
<div class="ctrl-input"><input type="text" id="ing-icecast-url" data-ingest-path="icecast.url" spellcheck="false"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Decoder</span></div>
<div class="ctrl-input">
<select id="ing-icecast-decoder" data-ingest-path="icecast.decoder">
<option value="auto">auto</option>
<option value="native">native</option>
<option value="ffmpeg">ffmpeg</option>
<option value="fallback">fallback</option>
</select>
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">RadioText Relay</span></div>
<div class="ctrl-input">
<label><input type="checkbox" id="ing-icecast-rt-enabled" data-ingest-path="icecast.radioText.enabled"> Enabled</label>
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">RT Prefix</span></div>
<div class="ctrl-input"><input type="text" id="ing-icecast-rt-prefix" data-ingest-path="icecast.radioText.prefix"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">RT MaxLen</span></div>
<div class="ctrl-input"><input type="number" id="ing-icecast-rt-maxlen" data-ingest-path="icecast.radioText.maxLen"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">RT Only On Change</span></div>
<div class="ctrl-input">
<label><input type="checkbox" id="ing-icecast-rt-only-change" data-ingest-path="icecast.radioText.onlyOnChange"> Enabled</label>
</div>
</div>
</div>
</div>

<div class="ingest-group" id="ing-group-srt">
<div class="ingest-group-title">SRT</div>
<div class="ingest-grid">
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">URL</span></div>
<div class="ctrl-input"><input type="text" id="ing-srt-url" data-ingest-path="srt.url" spellcheck="false"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Mode</span></div>
<div class="ctrl-input">
<select id="ing-srt-mode" data-ingest-path="srt.mode">
<option value="listener">listener</option>
<option value="caller">caller</option>
<option value="rendezvous">rendezvous</option>
</select>
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Sample Rate</span></div>
<div class="ctrl-input"><input type="number" id="ing-srt-rate" data-ingest-path="srt.sampleRateHz"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Channels</span></div>
<div class="ctrl-input"><input type="number" id="ing-srt-channels" data-ingest-path="srt.channels"></div>
</div>
</div>
</div>

<div class="ingest-group" id="ing-group-aes67">
<div class="ingest-group-title">AES67</div>
<div class="ingest-grid">
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">SDP Path</span></div>
<div class="ctrl-input"><input type="text" id="ing-aes67-sdppath" data-ingest-path="aes67.sdpPath" spellcheck="false"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">SDP Inline</span></div>
<div class="ctrl-input"><input type="text" id="ing-aes67-sdp" data-ingest-path="aes67.sdp" spellcheck="false"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Multicast Group</span></div>
<div class="ctrl-input"><input type="text" id="ing-aes67-group" data-ingest-path="aes67.multicastGroup" spellcheck="false"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Port</span></div>
<div class="ctrl-input"><input type="number" id="ing-aes67-port" data-ingest-path="aes67.port"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Payload Type</span></div>
<div class="ctrl-input"><input type="number" id="ing-aes67-pt" data-ingest-path="aes67.payloadType"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Sample Rate</span></div>
<div class="ctrl-input"><input type="number" id="ing-aes67-rate" data-ingest-path="aes67.sampleRateHz"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Channels</span></div>
<div class="ctrl-input"><input type="number" id="ing-aes67-channels" data-ingest-path="aes67.channels"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Encoding</span></div>
<div class="ctrl-input"><input type="text" id="ing-aes67-encoding" data-ingest-path="aes67.encoding"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Packet Time</span></div>
<div class="ctrl-input"><input type="number" id="ing-aes67-ptime" data-ingest-path="aes67.packetTimeMs"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Jitter Depth</span></div>
<div class="ctrl-input"><input type="number" id="ing-aes67-jitter" data-ingest-path="aes67.jitterDepthPackets"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Read Buffer</span></div>
<div class="ctrl-input"><input type="number" id="ing-aes67-readbuf" data-ingest-path="aes67.readBufferBytes"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Discovery</span></div>
<div class="ctrl-input"><label><input type="checkbox" id="ing-aes67-discovery-enabled" data-ingest-path="aes67.discovery.enabled"> Enabled</label></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Discovery Name</span></div>
<div class="ctrl-input"><input type="text" id="ing-aes67-discovery-name" data-ingest-path="aes67.discovery.streamName"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Discovery Timeout</span></div>
<div class="ctrl-input"><input type="number" id="ing-aes67-discovery-timeout" data-ingest-path="aes67.discovery.timeoutMs"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">SAP Group</span></div>
<div class="ctrl-input"><input type="text" id="ing-aes67-discovery-group" data-ingest-path="aes67.discovery.sapGroup"></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">SAP Port</span></div>
<div class="ctrl-input"><input type="number" id="ing-aes67-discovery-port" data-ingest-path="aes67.discovery.sapPort"></div>
</div>
</div>
</div>

<div class="field-error" id="ingest-error"></div>
<div class="actions-row">
<button class="apply-btn" id="ingest-save-reload" type="button">Save + Hard Reload</button>
<button class="apply-btn secondary" id="ingest-reset" type="button">Reset Draft</button>
</div>
</div>
</div>

</div>
<div class="stack">
<div class="card panel" data-panel-key="shortcuts">
@@ -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'));


Yükleniyor…
İptal
Kaydet