Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

711 lines
20KB

  1. package control
  2. import (
  3. _ "embed"
  4. "encoding/json"
  5. "errors"
  6. "io"
  7. "mime"
  8. "net/http"
  9. "strings"
  10. "sync"
  11. "sync/atomic"
  12. "time"
  13. "github.com/jan/fm-rds-tx/internal/audio"
  14. "github.com/jan/fm-rds-tx/internal/config"
  15. drypkg "github.com/jan/fm-rds-tx/internal/dryrun"
  16. "github.com/jan/fm-rds-tx/internal/ingest"
  17. "github.com/jan/fm-rds-tx/internal/platform"
  18. )
  19. //go:embed ui.html
  20. var uiHTML []byte
  21. // TXController is an optional interface the Server uses to start/stop TX
  22. // and apply live config changes.
  23. type TXController interface {
  24. StartTX() error
  25. StopTX() error
  26. TXStats() map[string]any
  27. UpdateConfig(patch LivePatch) error
  28. ResetFault() error
  29. }
  30. // LivePatch mirrors the patchable fields from ConfigPatch for the engine.
  31. // nil = no change.
  32. type LivePatch struct {
  33. FrequencyMHz *float64
  34. OutputDrive *float64
  35. StereoEnabled *bool
  36. PilotLevel *float64
  37. RDSInjection *float64
  38. RDSEnabled *bool
  39. LimiterEnabled *bool
  40. LimiterCeiling *float64
  41. PS *string
  42. RadioText *string
  43. ToneLeftHz *float64
  44. ToneRightHz *float64
  45. ToneAmplitude *float64
  46. AudioGain *float64
  47. }
  48. type Server struct {
  49. mu sync.RWMutex
  50. cfg config.Config
  51. tx TXController
  52. drv platform.SoapyDriver // optional, for runtime stats
  53. streamSrc *audio.StreamSource // optional, for live audio ring stats
  54. audioIngress AudioIngress // optional, for /audio/stream
  55. ingestRt IngestRuntime // optional, for /runtime ingest stats
  56. saveConfig func(config.Config) error
  57. hardReload func()
  58. // BUG-F fix: reloadPending prevents multiple concurrent goroutines from
  59. // calling hardReload when handleIngestSave is hit multiple times quickly.
  60. reloadPending atomic.Bool
  61. audit auditCounters
  62. }
  63. type AudioIngress interface {
  64. WritePCM16(data []byte) (int, error)
  65. }
  66. type IngestRuntime interface {
  67. Stats() ingest.Stats
  68. }
  69. type auditEvent string
  70. const (
  71. auditMethodNotAllowed auditEvent = "methodNotAllowed"
  72. auditUnsupportedMediaType auditEvent = "unsupportedMediaType"
  73. auditBodyTooLarge auditEvent = "bodyTooLarge"
  74. auditUnexpectedBody auditEvent = "unexpectedBody"
  75. )
  76. type auditCounters struct {
  77. methodNotAllowed uint64
  78. unsupportedMediaType uint64
  79. bodyTooLarge uint64
  80. unexpectedBody uint64
  81. }
  82. const (
  83. maxConfigBodyBytes = 64 << 10 // 64 KiB
  84. configContentTypeHeader = "application/json"
  85. noBodyErrMsg = "request must not include a body"
  86. audioStreamContentTypeError = "Content-Type must be application/octet-stream or audio/L16"
  87. audioStreamBodyLimitDefault = 512 << 20 // 512 MiB
  88. )
  89. var audioStreamAllowedMediaTypes = []string{
  90. "application/octet-stream",
  91. "audio/l16",
  92. }
  93. var audioStreamBodyLimit = int64(audioStreamBodyLimitDefault) // bytes allowed per /audio/stream request; tests may override.
  94. func isJSONContentType(r *http.Request) bool {
  95. ct := strings.TrimSpace(r.Header.Get("Content-Type"))
  96. if ct == "" {
  97. return false
  98. }
  99. mediaType, _, err := mime.ParseMediaType(ct)
  100. if err != nil {
  101. return false
  102. }
  103. return strings.EqualFold(mediaType, configContentTypeHeader)
  104. }
  105. type ConfigPatch struct {
  106. FrequencyMHz *float64 `json:"frequencyMHz,omitempty"`
  107. OutputDrive *float64 `json:"outputDrive,omitempty"`
  108. StereoEnabled *bool `json:"stereoEnabled,omitempty"`
  109. PilotLevel *float64 `json:"pilotLevel,omitempty"`
  110. RDSInjection *float64 `json:"rdsInjection,omitempty"`
  111. RDSEnabled *bool `json:"rdsEnabled,omitempty"`
  112. ToneLeftHz *float64 `json:"toneLeftHz,omitempty"`
  113. ToneRightHz *float64 `json:"toneRightHz,omitempty"`
  114. ToneAmplitude *float64 `json:"toneAmplitude,omitempty"`
  115. PS *string `json:"ps,omitempty"`
  116. RadioText *string `json:"radioText,omitempty"`
  117. PreEmphasisTauUS *float64 `json:"preEmphasisTauUS,omitempty"`
  118. LimiterEnabled *bool `json:"limiterEnabled,omitempty"`
  119. LimiterCeiling *float64 `json:"limiterCeiling,omitempty"`
  120. AudioGain *float64 `json:"audioGain,omitempty"`
  121. PI *string `json:"pi,omitempty"`
  122. PTY *int `json:"pty,omitempty"`
  123. BS412Enabled *bool `json:"bs412Enabled,omitempty"`
  124. BS412ThresholdDBr *float64 `json:"bs412ThresholdDBr,omitempty"`
  125. MpxGain *float64 `json:"mpxGain,omitempty"`
  126. }
  127. type IngestSaveRequest struct {
  128. Ingest config.IngestConfig `json:"ingest"`
  129. }
  130. func NewServer(cfg config.Config) *Server {
  131. return &Server{cfg: cfg}
  132. }
  133. func hasRequestBody(r *http.Request) bool {
  134. if r.ContentLength > 0 {
  135. return true
  136. }
  137. for _, te := range r.TransferEncoding {
  138. if strings.EqualFold(te, "chunked") {
  139. return true
  140. }
  141. }
  142. return false
  143. }
  144. func (s *Server) rejectBody(w http.ResponseWriter, r *http.Request) bool {
  145. // Returns true when the request has an unexpected body and the error response
  146. // has already been written — callers should return immediately in that case.
  147. // Returns false when there is no body (happy path — request should proceed).
  148. if !hasRequestBody(r) {
  149. return false
  150. }
  151. s.recordAudit(auditUnexpectedBody)
  152. http.Error(w, noBodyErrMsg, http.StatusBadRequest)
  153. return true
  154. }
  155. func (s *Server) recordAudit(evt auditEvent) {
  156. switch evt {
  157. case auditMethodNotAllowed:
  158. atomic.AddUint64(&s.audit.methodNotAllowed, 1)
  159. case auditUnsupportedMediaType:
  160. atomic.AddUint64(&s.audit.unsupportedMediaType, 1)
  161. case auditBodyTooLarge:
  162. atomic.AddUint64(&s.audit.bodyTooLarge, 1)
  163. case auditUnexpectedBody:
  164. atomic.AddUint64(&s.audit.unexpectedBody, 1)
  165. }
  166. }
  167. func (s *Server) auditSnapshot() map[string]uint64 {
  168. return map[string]uint64{
  169. "methodNotAllowed": atomic.LoadUint64(&s.audit.methodNotAllowed),
  170. "unsupportedMediaType": atomic.LoadUint64(&s.audit.unsupportedMediaType),
  171. "bodyTooLarge": atomic.LoadUint64(&s.audit.bodyTooLarge),
  172. "unexpectedBody": atomic.LoadUint64(&s.audit.unexpectedBody),
  173. }
  174. }
  175. func isAudioStreamContentType(r *http.Request) bool {
  176. ct := strings.TrimSpace(r.Header.Get("Content-Type"))
  177. if ct == "" {
  178. return false
  179. }
  180. mediaType, _, err := mime.ParseMediaType(ct)
  181. if err != nil {
  182. return false
  183. }
  184. for _, allowed := range audioStreamAllowedMediaTypes {
  185. if strings.EqualFold(mediaType, allowed) {
  186. return true
  187. }
  188. }
  189. return false
  190. }
  191. func (s *Server) SetTXController(tx TXController) {
  192. s.mu.Lock()
  193. s.tx = tx
  194. s.mu.Unlock()
  195. }
  196. func (s *Server) SetDriver(drv platform.SoapyDriver) {
  197. s.mu.Lock()
  198. s.drv = drv
  199. s.mu.Unlock()
  200. }
  201. func (s *Server) SetStreamSource(src *audio.StreamSource) {
  202. s.mu.Lock()
  203. s.streamSrc = src
  204. s.mu.Unlock()
  205. }
  206. func (s *Server) SetAudioIngress(ingress AudioIngress) {
  207. s.mu.Lock()
  208. s.audioIngress = ingress
  209. s.mu.Unlock()
  210. }
  211. func (s *Server) SetIngestRuntime(rt IngestRuntime) {
  212. s.mu.Lock()
  213. s.ingestRt = rt
  214. s.mu.Unlock()
  215. }
  216. func (s *Server) SetConfigSaver(save func(config.Config) error) {
  217. s.mu.Lock()
  218. s.saveConfig = save
  219. s.mu.Unlock()
  220. }
  221. func (s *Server) SetHardReload(fn func()) {
  222. s.mu.Lock()
  223. s.hardReload = fn
  224. s.mu.Unlock()
  225. }
  226. func (s *Server) Handler() http.Handler {
  227. mux := http.NewServeMux()
  228. mux.HandleFunc("/", s.handleUI)
  229. mux.HandleFunc("/healthz", s.handleHealth)
  230. mux.HandleFunc("/status", s.handleStatus)
  231. mux.HandleFunc("/dry-run", s.handleDryRun)
  232. mux.HandleFunc("/config", s.handleConfig)
  233. mux.HandleFunc("/config/ingest/save", s.handleIngestSave)
  234. mux.HandleFunc("/runtime", s.handleRuntime)
  235. mux.HandleFunc("/runtime/fault/reset", s.handleRuntimeFaultReset)
  236. mux.HandleFunc("/tx/start", s.handleTXStart)
  237. mux.HandleFunc("/tx/stop", s.handleTXStop)
  238. mux.HandleFunc("/audio/stream", s.handleAudioStream)
  239. return mux
  240. }
  241. func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
  242. w.Header().Set("Content-Type", "application/json")
  243. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
  244. }
  245. func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
  246. if r.URL.Path != "/" {
  247. http.NotFound(w, r)
  248. return
  249. }
  250. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  251. w.Header().Set("Cache-Control", "no-cache")
  252. w.Write(uiHTML)
  253. }
  254. func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
  255. s.mu.RLock()
  256. cfg := s.cfg
  257. tx := s.tx
  258. s.mu.RUnlock()
  259. status := map[string]any{
  260. "service": "fm-rds-tx",
  261. "backend": cfg.Backend.Kind,
  262. "frequencyMHz": cfg.FM.FrequencyMHz,
  263. "stereoEnabled": cfg.FM.StereoEnabled,
  264. "rdsEnabled": cfg.RDS.Enabled,
  265. "preEmphasisTauUS": cfg.FM.PreEmphasisTauUS,
  266. "limiterEnabled": cfg.FM.LimiterEnabled,
  267. "fmModulationEnabled": cfg.FM.FMModulationEnabled,
  268. }
  269. if tx != nil {
  270. if stats := tx.TXStats(); stats != nil {
  271. if ri, ok := stats["runtimeIndicator"]; ok {
  272. status["runtimeIndicator"] = ri
  273. }
  274. if alert, ok := stats["runtimeAlert"]; ok {
  275. status["runtimeAlert"] = alert
  276. }
  277. if queue, ok := stats["queue"]; ok {
  278. status["queue"] = queue
  279. }
  280. if runtimeState, ok := stats["state"]; ok {
  281. status["runtimeState"] = runtimeState
  282. }
  283. }
  284. }
  285. w.Header().Set("Content-Type", "application/json")
  286. _ = json.NewEncoder(w).Encode(status)
  287. }
  288. func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) {
  289. s.mu.RLock()
  290. drv := s.drv
  291. tx := s.tx
  292. stream := s.streamSrc
  293. ingestRt := s.ingestRt
  294. s.mu.RUnlock()
  295. result := map[string]any{}
  296. if drv != nil {
  297. result["driver"] = drv.Stats()
  298. }
  299. if tx != nil {
  300. if stats := tx.TXStats(); stats != nil {
  301. result["engine"] = stats
  302. }
  303. }
  304. if stream != nil {
  305. result["audioStream"] = stream.Stats()
  306. }
  307. if ingestRt != nil {
  308. result["ingest"] = ingestRt.Stats()
  309. }
  310. result["controlAudit"] = s.auditSnapshot()
  311. w.Header().Set("Content-Type", "application/json")
  312. _ = json.NewEncoder(w).Encode(result)
  313. }
  314. func (s *Server) handleRuntimeFaultReset(w http.ResponseWriter, r *http.Request) {
  315. if r.Method != http.MethodPost {
  316. s.recordAudit(auditMethodNotAllowed)
  317. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  318. return
  319. }
  320. if s.rejectBody(w, r) { // BUG-01 fix: rejectBody returns true when rejected
  321. return
  322. }
  323. s.mu.RLock()
  324. tx := s.tx
  325. s.mu.RUnlock()
  326. if tx == nil {
  327. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  328. return
  329. }
  330. if err := tx.ResetFault(); err != nil {
  331. http.Error(w, err.Error(), http.StatusConflict)
  332. return
  333. }
  334. w.Header().Set("Content-Type", "application/json")
  335. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
  336. }
  337. // handleAudioStream accepts raw S16LE PCM via HTTP POST and pushes
  338. // it into the configured ingest http-raw source. Use with:
  339. //
  340. // curl -X POST --data-binary @- http://host:8088/audio/stream < audio.raw
  341. // ffmpeg ... -f s16le -ar 44100 -ac 2 - | curl -X POST --data-binary @- http://host:8088/audio/stream
  342. func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
  343. if r.Method != http.MethodPost {
  344. s.recordAudit(auditMethodNotAllowed)
  345. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  346. return
  347. }
  348. if !isAudioStreamContentType(r) {
  349. s.recordAudit(auditUnsupportedMediaType)
  350. http.Error(w, audioStreamContentTypeError, http.StatusUnsupportedMediaType)
  351. return
  352. }
  353. s.mu.RLock()
  354. ingress := s.audioIngress
  355. s.mu.RUnlock()
  356. if ingress == nil {
  357. http.Error(w, "audio ingest not configured (use --audio-http with ingest runtime)", http.StatusServiceUnavailable)
  358. return
  359. }
  360. // BUG-10 fix: /audio/stream is a long-lived streaming endpoint.
  361. // The global HTTP server ReadTimeout (5s) and WriteTimeout (10s) would
  362. // kill connections mid-stream. Disable them per-request via ResponseController
  363. // (requires Go 1.20+, confirmed Go 1.22).
  364. rc := http.NewResponseController(w)
  365. _ = rc.SetReadDeadline(time.Time{})
  366. _ = rc.SetWriteDeadline(time.Time{})
  367. r.Body = http.MaxBytesReader(w, r.Body, audioStreamBodyLimit)
  368. // Read body in chunks and push to ring buffer
  369. buf := make([]byte, 32768)
  370. totalFrames := 0
  371. for {
  372. n, err := r.Body.Read(buf)
  373. if n > 0 {
  374. written, writeErr := ingress.WritePCM16(buf[:n])
  375. totalFrames += written
  376. if writeErr != nil {
  377. http.Error(w, writeErr.Error(), http.StatusServiceUnavailable)
  378. return
  379. }
  380. }
  381. if err != nil {
  382. if err == io.EOF {
  383. break
  384. }
  385. var maxErr *http.MaxBytesError
  386. if errors.As(err, &maxErr) {
  387. s.recordAudit(auditBodyTooLarge)
  388. http.Error(w, maxErr.Error(), http.StatusRequestEntityTooLarge)
  389. return
  390. }
  391. http.Error(w, err.Error(), http.StatusInternalServerError)
  392. return
  393. }
  394. }
  395. w.Header().Set("Content-Type", "application/json")
  396. _ = json.NewEncoder(w).Encode(map[string]any{
  397. "ok": true,
  398. "frames": totalFrames,
  399. })
  400. }
  401. func (s *Server) handleTXStart(w http.ResponseWriter, r *http.Request) {
  402. if r.Method != http.MethodPost {
  403. s.recordAudit(auditMethodNotAllowed)
  404. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  405. return
  406. }
  407. if s.rejectBody(w, r) { // BUG-01 fix: rejectBody returns true when rejected
  408. return
  409. }
  410. s.mu.RLock()
  411. tx := s.tx
  412. s.mu.RUnlock()
  413. if tx == nil {
  414. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  415. return
  416. }
  417. if err := tx.StartTX(); err != nil {
  418. http.Error(w, err.Error(), http.StatusConflict)
  419. return
  420. }
  421. w.Header().Set("Content-Type", "application/json")
  422. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "started"})
  423. }
  424. func (s *Server) handleTXStop(w http.ResponseWriter, r *http.Request) {
  425. if r.Method != http.MethodPost {
  426. s.recordAudit(auditMethodNotAllowed)
  427. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  428. return
  429. }
  430. if s.rejectBody(w, r) { // BUG-01 fix: rejectBody returns true when rejected
  431. return
  432. }
  433. s.mu.RLock()
  434. tx := s.tx
  435. s.mu.RUnlock()
  436. if tx == nil {
  437. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  438. return
  439. }
  440. go func() {
  441. _ = tx.StopTX()
  442. }()
  443. w.Header().Set("Content-Type", "application/json")
  444. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "stop-requested"})
  445. }
  446. func (s *Server) handleDryRun(w http.ResponseWriter, _ *http.Request) {
  447. s.mu.RLock()
  448. cfg := s.cfg
  449. s.mu.RUnlock()
  450. w.Header().Set("Content-Type", "application/json")
  451. _ = json.NewEncoder(w).Encode(drypkg.Generate(cfg))
  452. }
  453. func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
  454. switch r.Method {
  455. case http.MethodGet:
  456. s.mu.RLock()
  457. cfg := s.cfg
  458. s.mu.RUnlock()
  459. w.Header().Set("Content-Type", "application/json")
  460. _ = json.NewEncoder(w).Encode(cfg)
  461. case http.MethodPost:
  462. if !isJSONContentType(r) {
  463. s.recordAudit(auditUnsupportedMediaType)
  464. http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
  465. return
  466. }
  467. r.Body = http.MaxBytesReader(w, r.Body, maxConfigBodyBytes)
  468. var patch ConfigPatch
  469. // BUG-4 fix: reject unknown JSON fields (typos) with 400 rather than
  470. // silently ignoring them (e.g. "outputDrvie" would succeed and do nothing).
  471. dec := json.NewDecoder(r.Body)
  472. dec.DisallowUnknownFields()
  473. if err := dec.Decode(&patch); err != nil {
  474. statusCode := http.StatusBadRequest
  475. if strings.Contains(err.Error(), "http: request body too large") {
  476. statusCode = http.StatusRequestEntityTooLarge
  477. s.recordAudit(auditBodyTooLarge)
  478. }
  479. http.Error(w, err.Error(), statusCode)
  480. return
  481. }
  482. // Update the server's config snapshot (for GET /config and /status)
  483. s.mu.Lock()
  484. next := s.cfg
  485. if patch.FrequencyMHz != nil {
  486. next.FM.FrequencyMHz = *patch.FrequencyMHz
  487. }
  488. if patch.OutputDrive != nil {
  489. next.FM.OutputDrive = *patch.OutputDrive
  490. }
  491. if patch.ToneLeftHz != nil {
  492. next.Audio.ToneLeftHz = *patch.ToneLeftHz
  493. }
  494. if patch.ToneRightHz != nil {
  495. next.Audio.ToneRightHz = *patch.ToneRightHz
  496. }
  497. if patch.ToneAmplitude != nil {
  498. next.Audio.ToneAmplitude = *patch.ToneAmplitude
  499. }
  500. if patch.AudioGain != nil {
  501. next.Audio.Gain = *patch.AudioGain
  502. }
  503. if patch.PS != nil {
  504. next.RDS.PS = *patch.PS
  505. }
  506. if patch.RadioText != nil {
  507. next.RDS.RadioText = *patch.RadioText
  508. }
  509. if patch.PI != nil {
  510. next.RDS.PI = *patch.PI
  511. }
  512. if patch.PTY != nil {
  513. next.RDS.PTY = *patch.PTY
  514. }
  515. if patch.PreEmphasisTauUS != nil {
  516. next.FM.PreEmphasisTauUS = *patch.PreEmphasisTauUS
  517. }
  518. if patch.StereoEnabled != nil {
  519. next.FM.StereoEnabled = *patch.StereoEnabled
  520. }
  521. if patch.LimiterEnabled != nil {
  522. next.FM.LimiterEnabled = *patch.LimiterEnabled
  523. }
  524. if patch.LimiterCeiling != nil {
  525. next.FM.LimiterCeiling = *patch.LimiterCeiling
  526. }
  527. if patch.RDSEnabled != nil {
  528. next.RDS.Enabled = *patch.RDSEnabled
  529. }
  530. if patch.PilotLevel != nil {
  531. next.FM.PilotLevel = *patch.PilotLevel
  532. }
  533. if patch.RDSInjection != nil {
  534. next.FM.RDSInjection = *patch.RDSInjection
  535. }
  536. if patch.BS412Enabled != nil {
  537. next.FM.BS412Enabled = *patch.BS412Enabled
  538. }
  539. if patch.BS412ThresholdDBr != nil {
  540. next.FM.BS412ThresholdDBr = *patch.BS412ThresholdDBr
  541. }
  542. if patch.MpxGain != nil {
  543. next.FM.MpxGain = *patch.MpxGain
  544. }
  545. if err := next.Validate(); err != nil {
  546. s.mu.Unlock()
  547. http.Error(w, err.Error(), http.StatusBadRequest)
  548. return
  549. }
  550. lp := LivePatch{
  551. FrequencyMHz: patch.FrequencyMHz,
  552. OutputDrive: patch.OutputDrive,
  553. StereoEnabled: patch.StereoEnabled,
  554. PilotLevel: patch.PilotLevel,
  555. RDSInjection: patch.RDSInjection,
  556. RDSEnabled: patch.RDSEnabled,
  557. LimiterEnabled: patch.LimiterEnabled,
  558. LimiterCeiling: patch.LimiterCeiling,
  559. PS: patch.PS,
  560. RadioText: patch.RadioText,
  561. ToneLeftHz: patch.ToneLeftHz,
  562. ToneRightHz: patch.ToneRightHz,
  563. ToneAmplitude: patch.ToneAmplitude,
  564. AudioGain: patch.AudioGain,
  565. }
  566. // NEU-02 fix: determine whether any live-patchable fields are present,
  567. // then release the lock before calling UpdateConfig to avoid holding
  568. // s.mu across a potentially blocking engine call.
  569. tx := s.tx
  570. hasLiveFields := patch.FrequencyMHz != nil || patch.OutputDrive != nil ||
  571. patch.StereoEnabled != nil || patch.PilotLevel != nil ||
  572. patch.RDSInjection != nil || patch.RDSEnabled != nil ||
  573. patch.LimiterEnabled != nil || patch.LimiterCeiling != nil ||
  574. patch.PS != nil || patch.RadioText != nil ||
  575. patch.ToneLeftHz != nil || patch.ToneRightHz != nil ||
  576. patch.ToneAmplitude != nil || patch.AudioGain != nil
  577. s.cfg = next
  578. s.mu.Unlock()
  579. // Apply live fields to running engine outside the lock.
  580. var updateErr error
  581. if tx != nil && hasLiveFields {
  582. if err := tx.UpdateConfig(lp); err != nil {
  583. updateErr = err
  584. }
  585. }
  586. if updateErr != nil {
  587. http.Error(w, updateErr.Error(), http.StatusBadRequest)
  588. return
  589. }
  590. // NEU-03 fix: report live=true only when live-patchable fields were applied.
  591. live := tx != nil && hasLiveFields
  592. w.Header().Set("Content-Type", "application/json")
  593. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "live": live})
  594. default:
  595. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  596. }
  597. }
  598. func (s *Server) handleIngestSave(w http.ResponseWriter, r *http.Request) {
  599. if r.Method != http.MethodPost {
  600. s.recordAudit(auditMethodNotAllowed)
  601. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  602. return
  603. }
  604. if !isJSONContentType(r) {
  605. s.recordAudit(auditUnsupportedMediaType)
  606. http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
  607. return
  608. }
  609. r.Body = http.MaxBytesReader(w, r.Body, maxConfigBodyBytes)
  610. var req IngestSaveRequest
  611. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  612. statusCode := http.StatusBadRequest
  613. if strings.Contains(err.Error(), "http: request body too large") {
  614. statusCode = http.StatusRequestEntityTooLarge
  615. s.recordAudit(auditBodyTooLarge)
  616. }
  617. http.Error(w, err.Error(), statusCode)
  618. return
  619. }
  620. s.mu.Lock()
  621. next := s.cfg
  622. next.Ingest = req.Ingest
  623. if err := next.Validate(); err != nil {
  624. s.mu.Unlock()
  625. http.Error(w, err.Error(), http.StatusBadRequest)
  626. return
  627. }
  628. save := s.saveConfig
  629. reload := s.hardReload
  630. if save == nil {
  631. s.mu.Unlock()
  632. http.Error(w, "config save is not configured (start with --config <path>)", http.StatusServiceUnavailable)
  633. return
  634. }
  635. if err := save(next); err != nil {
  636. s.mu.Unlock()
  637. http.Error(w, err.Error(), http.StatusInternalServerError)
  638. return
  639. }
  640. s.cfg = next
  641. s.mu.Unlock()
  642. w.Header().Set("Content-Type", "application/json")
  643. reloadScheduled := reload != nil
  644. _ = json.NewEncoder(w).Encode(map[string]any{
  645. "ok": true,
  646. "saved": true,
  647. "reloadScheduled": reloadScheduled,
  648. })
  649. if reloadScheduled && s.reloadPending.CompareAndSwap(false, true) {
  650. go func(fn func()) {
  651. time.Sleep(250 * time.Millisecond)
  652. s.reloadPending.Store(false)
  653. fn()
  654. }(reload)
  655. }
  656. }