Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

314 line
7.6KB

  1. package icecast
  2. import (
  3. "bytes"
  4. "context"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "net/http"
  9. "strings"
  10. "sync"
  11. "sync/atomic"
  12. "time"
  13. "github.com/jan/fm-rds-tx/internal/ingest"
  14. "github.com/jan/fm-rds-tx/internal/ingest/decoder"
  15. "github.com/jan/fm-rds-tx/internal/ingest/decoder/aac"
  16. "github.com/jan/fm-rds-tx/internal/ingest/decoder/fallback"
  17. "github.com/jan/fm-rds-tx/internal/ingest/decoder/mp3"
  18. "github.com/jan/fm-rds-tx/internal/ingest/decoder/oggvorbis"
  19. )
  20. type Source struct {
  21. id string
  22. url string
  23. client *http.Client
  24. decReg *decoder.Registry
  25. reconn ReconnectConfig
  26. decoderPreference string
  27. chunks chan ingest.PCMChunk
  28. errs chan error
  29. cancel context.CancelFunc
  30. wg sync.WaitGroup
  31. state atomic.Value // string
  32. connected atomic.Bool
  33. chunksIn atomic.Uint64
  34. samplesIn atomic.Uint64
  35. reconnects atomic.Uint64
  36. discontinuities atomic.Uint64
  37. lastChunkAtUnix atomic.Int64
  38. lastError atomic.Value // string
  39. }
  40. var errStreamEnded = errors.New("icecast stream ended")
  41. type Option func(*Source)
  42. func WithDecoderPreference(pref string) Option {
  43. return func(s *Source) {
  44. s.decoderPreference = normalizeDecoderPreference(pref)
  45. }
  46. }
  47. func WithDecoderRegistry(reg *decoder.Registry) Option {
  48. return func(s *Source) {
  49. if reg != nil {
  50. s.decReg = reg
  51. }
  52. }
  53. }
  54. func New(id, url string, client *http.Client, reconn ReconnectConfig, opts ...Option) *Source {
  55. if id == "" {
  56. id = "icecast-main"
  57. }
  58. if client == nil {
  59. client = &http.Client{Timeout: 20 * time.Second}
  60. }
  61. s := &Source{
  62. id: id,
  63. url: strings.TrimSpace(url),
  64. client: client,
  65. reconn: reconn,
  66. chunks: make(chan ingest.PCMChunk, 64),
  67. errs: make(chan error, 8),
  68. decReg: defaultRegistry(),
  69. decoderPreference: "auto",
  70. }
  71. for _, opt := range opts {
  72. if opt != nil {
  73. opt(s)
  74. }
  75. }
  76. s.decoderPreference = normalizeDecoderPreference(s.decoderPreference)
  77. s.state.Store("idle")
  78. return s
  79. }
  80. func defaultRegistry() *decoder.Registry {
  81. r := decoder.NewRegistry()
  82. r.Register("mp3", func() decoder.Decoder { return mp3.New() })
  83. r.Register("oggvorbis", func() decoder.Decoder { return oggvorbis.New() })
  84. r.Register("aac", func() decoder.Decoder { return aac.New() })
  85. r.Register("ffmpeg", func() decoder.Decoder { return fallback.NewFFmpeg() })
  86. return r
  87. }
  88. func (s *Source) Descriptor() ingest.SourceDescriptor {
  89. return ingest.SourceDescriptor{
  90. ID: s.id,
  91. Kind: "icecast",
  92. Family: "streaming",
  93. Transport: "http",
  94. Codec: s.decoderPreference,
  95. Detail: s.url,
  96. }
  97. }
  98. func (s *Source) Start(ctx context.Context) error {
  99. if s.url == "" {
  100. return fmt.Errorf("icecast url is required")
  101. }
  102. runCtx, cancel := context.WithCancel(ctx)
  103. s.cancel = cancel
  104. s.lastError.Store("")
  105. s.state.Store("connecting")
  106. s.wg.Add(1)
  107. go s.loop(runCtx)
  108. return nil
  109. }
  110. func (s *Source) Stop() error {
  111. if s.cancel != nil {
  112. s.cancel()
  113. }
  114. s.wg.Wait()
  115. s.state.Store("stopped")
  116. return nil
  117. }
  118. func (s *Source) Chunks() <-chan ingest.PCMChunk { return s.chunks }
  119. func (s *Source) Errors() <-chan error { return s.errs }
  120. func (s *Source) Stats() ingest.SourceStats {
  121. state, _ := s.state.Load().(string)
  122. last := s.lastChunkAtUnix.Load()
  123. errStr, _ := s.lastError.Load().(string)
  124. var lastChunkAt time.Time
  125. if last > 0 {
  126. lastChunkAt = time.Unix(0, last)
  127. }
  128. return ingest.SourceStats{
  129. State: state,
  130. Connected: s.connected.Load(),
  131. LastChunkAt: lastChunkAt,
  132. ChunksIn: s.chunksIn.Load(),
  133. SamplesIn: s.samplesIn.Load(),
  134. Reconnects: s.reconnects.Load(),
  135. Discontinuities: s.discontinuities.Load(),
  136. LastError: errStr,
  137. }
  138. }
  139. func (s *Source) loop(ctx context.Context) {
  140. defer s.wg.Done()
  141. defer close(s.chunks)
  142. defer close(s.errs)
  143. attempt := 0
  144. for {
  145. select {
  146. case <-ctx.Done():
  147. return
  148. default:
  149. }
  150. s.state.Store("connecting")
  151. err := s.connectAndRun(ctx)
  152. if ctx.Err() != nil {
  153. return
  154. }
  155. if err == nil {
  156. err = errStreamEnded
  157. }
  158. if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
  159. return
  160. }
  161. s.connected.Store(false)
  162. s.lastError.Store(err.Error())
  163. select {
  164. case s.errs <- err:
  165. default:
  166. }
  167. s.state.Store("reconnecting")
  168. attempt++
  169. s.reconnects.Add(1)
  170. backoff := s.reconn.nextBackoff(attempt)
  171. if backoff <= 0 {
  172. s.state.Store("failed")
  173. return
  174. }
  175. select {
  176. case <-time.After(backoff):
  177. case <-ctx.Done():
  178. return
  179. }
  180. }
  181. }
  182. func (s *Source) connectAndRun(ctx context.Context) error {
  183. req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url, nil)
  184. if err != nil {
  185. return err
  186. }
  187. req.Header.Set("Icy-MetaData", "0")
  188. resp, err := s.client.Do(req)
  189. if err != nil {
  190. return fmt.Errorf("icecast connect: %w", err)
  191. }
  192. defer resp.Body.Close()
  193. if resp.StatusCode != http.StatusOK {
  194. return fmt.Errorf("icecast status: %s", resp.Status)
  195. }
  196. s.connected.Store(true)
  197. s.state.Store("buffering")
  198. s.lastError.Store("")
  199. s.state.Store("running")
  200. return s.decodeWithPreference(ctx, resp.Body, decoder.StreamMeta{
  201. ContentType: resp.Header.Get("Content-Type"),
  202. SourceID: s.id,
  203. SampleRateHz: 44100,
  204. Channels: 2,
  205. })
  206. }
  207. func (s *Source) emitChunk(chunk ingest.PCMChunk) error {
  208. select {
  209. case s.chunks <- chunk:
  210. default:
  211. s.discontinuities.Add(1)
  212. return io.ErrShortBuffer
  213. }
  214. s.chunksIn.Add(1)
  215. s.samplesIn.Add(uint64(len(chunk.Samples)))
  216. s.lastChunkAtUnix.Store(time.Now().UnixNano())
  217. return nil
  218. }
  219. func (s *Source) decodeWithPreference(ctx context.Context, stream io.Reader, meta decoder.StreamMeta) error {
  220. mode := normalizeDecoderPreference(s.decoderPreference)
  221. switch mode {
  222. case "ffmpeg":
  223. return s.decodeNamed(ctx, "ffmpeg", stream, meta)
  224. case "native":
  225. native, err := s.decReg.SelectByContentType(meta.ContentType)
  226. if err != nil {
  227. return fmt.Errorf("icecast native decoder select: %w", err)
  228. }
  229. return native.DecodeStream(ctx, stream, meta, s.emitChunk)
  230. case "auto":
  231. // Phase-1 policy: try native decoder first, then fall back to ffmpeg
  232. // only when native selection/decode reports "unsupported".
  233. native, err := s.decReg.SelectByContentType(meta.ContentType)
  234. if err == nil {
  235. captured := &capturingReader{r: stream}
  236. if err := native.DecodeStream(ctx, captured, meta, s.emitChunk); err == nil {
  237. return nil
  238. } else if !errors.Is(err, decoder.ErrUnsupported) {
  239. return err
  240. }
  241. // Native decode can consume stream bytes before returning "unsupported".
  242. // Reconstruct a full reader for fallback: consumed prefix + remaining stream.
  243. stream = io.MultiReader(bytes.NewReader(captured.Bytes()), stream)
  244. } else if !errors.Is(err, decoder.ErrUnsupported) {
  245. return fmt.Errorf("icecast decoder select: %w", err)
  246. }
  247. return s.decodeNamed(ctx, "ffmpeg", stream, meta)
  248. default:
  249. return fmt.Errorf("unsupported icecast decoder mode: %s", mode)
  250. }
  251. }
  252. type capturingReader struct {
  253. r io.Reader
  254. buf bytes.Buffer
  255. }
  256. func (r *capturingReader) Read(p []byte) (int, error) {
  257. n, err := r.r.Read(p)
  258. if n > 0 {
  259. _, _ = r.buf.Write(p[:n])
  260. }
  261. return n, err
  262. }
  263. func (r *capturingReader) Bytes() []byte {
  264. return r.buf.Bytes()
  265. }
  266. func (s *Source) decodeNamed(ctx context.Context, name string, stream io.Reader, meta decoder.StreamMeta) error {
  267. dec, err := s.decReg.Create(name)
  268. if err != nil {
  269. return fmt.Errorf("icecast decoder=%s unavailable: %w", name, err)
  270. }
  271. return dec.DecodeStream(ctx, stream, meta, s.emitChunk)
  272. }
  273. func normalizeDecoderPreference(pref string) string {
  274. switch strings.ToLower(strings.TrimSpace(pref)) {
  275. case "", "auto":
  276. return "auto"
  277. case "native":
  278. return "native"
  279. case "ffmpeg", "fallback":
  280. return "ffmpeg"
  281. default:
  282. return strings.ToLower(strings.TrimSpace(pref))
  283. }
  284. }