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.

282 satır
6.8KB

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