Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.

654 satır
18KB

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