Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.

576 líneas
17KB

  1. package icecast
  2. import (
  3. "bytes"
  4. "context"
  5. "errors"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "sync"
  11. "sync/atomic"
  12. "testing"
  13. "time"
  14. "github.com/jan/fm-rds-tx/internal/ingest"
  15. "github.com/jan/fm-rds-tx/internal/ingest/decoder"
  16. )
  17. type testDecoder struct {
  18. name string
  19. err error
  20. called int
  21. }
  22. func (d *testDecoder) Name() string { return d.name }
  23. func (d *testDecoder) DecodeStream(_ context.Context, _ io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
  24. d.called++
  25. return d.err
  26. }
  27. type consumingUnsupportedDecoder struct {
  28. n int
  29. called int
  30. }
  31. func (d *consumingUnsupportedDecoder) Name() string { return "native-consuming-unsupported" }
  32. func (d *consumingUnsupportedDecoder) DecodeStream(_ context.Context, r io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
  33. d.called++
  34. buf := make([]byte, d.n)
  35. _, _ = io.ReadFull(r, buf)
  36. return decoder.ErrUnsupported
  37. }
  38. type captureStreamDecoder struct {
  39. name string
  40. called int
  41. payload []byte
  42. }
  43. func (d *captureStreamDecoder) Name() string { return d.name }
  44. func (d *captureStreamDecoder) DecodeStream(_ context.Context, r io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
  45. d.called++
  46. data, err := io.ReadAll(r)
  47. if err != nil {
  48. return err
  49. }
  50. d.payload = data
  51. return nil
  52. }
  53. func TestDecodeWithPreferenceAutoFallsBackFromNativeUnsupported(t *testing.T) {
  54. native := &testDecoder{name: "native", err: decoder.ErrUnsupported}
  55. fallback := &testDecoder{name: "ffmpeg"}
  56. reg := decoder.NewRegistry()
  57. reg.Register("mp3", func() decoder.Decoder { return native })
  58. reg.Register("ffmpeg", func() decoder.Decoder { return fallback })
  59. src := New("ice-test", "http://example", nil, ReconnectConfig{},
  60. WithDecoderRegistry(reg),
  61. WithDecoderPreference("auto"),
  62. )
  63. err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
  64. ContentType: "audio/mpeg",
  65. SourceID: "ice-test",
  66. })
  67. if err != nil {
  68. t.Fatalf("decode: %v", err)
  69. }
  70. if native.called != 1 {
  71. t.Fatalf("native called %d times", native.called)
  72. }
  73. if fallback.called != 1 {
  74. t.Fatalf("fallback called %d times", fallback.called)
  75. }
  76. }
  77. func TestDecodeWithPreferenceNativeDoesNotFallback(t *testing.T) {
  78. nativeErr := errors.New("decode failed")
  79. native := &testDecoder{name: "native", err: nativeErr}
  80. fallback := &testDecoder{name: "ffmpeg"}
  81. reg := decoder.NewRegistry()
  82. reg.Register("mp3", func() decoder.Decoder { return native })
  83. reg.Register("ffmpeg", func() decoder.Decoder { return fallback })
  84. src := New("ice-test", "http://example", nil, ReconnectConfig{},
  85. WithDecoderRegistry(reg),
  86. WithDecoderPreference("native"),
  87. )
  88. err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
  89. ContentType: "audio/mpeg",
  90. SourceID: "ice-test",
  91. })
  92. if !errors.Is(err, nativeErr) {
  93. t.Fatalf("expected native error, got %v", err)
  94. }
  95. if fallback.called != 0 {
  96. t.Fatalf("fallback should not be called, got %d", fallback.called)
  97. }
  98. }
  99. func TestDecodeWithPreferenceFFmpegOnly(t *testing.T) {
  100. native := &testDecoder{name: "native"}
  101. fallback := &testDecoder{name: "ffmpeg"}
  102. reg := decoder.NewRegistry()
  103. reg.Register("mp3", func() decoder.Decoder { return native })
  104. reg.Register("ffmpeg", func() decoder.Decoder { return fallback })
  105. src := New("ice-test", "http://example", nil, ReconnectConfig{},
  106. WithDecoderRegistry(reg),
  107. WithDecoderPreference("ffmpeg"),
  108. )
  109. err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
  110. ContentType: "audio/mpeg",
  111. SourceID: "ice-test",
  112. })
  113. if err != nil {
  114. t.Fatalf("decode: %v", err)
  115. }
  116. if native.called != 0 {
  117. t.Fatalf("native should not be called in ffmpeg mode, got %d", native.called)
  118. }
  119. if fallback.called != 1 {
  120. t.Fatalf("fallback called %d times", fallback.called)
  121. }
  122. }
  123. func TestDecodeWithPreferenceAutoUnsupportedContentTypeFallsBack(t *testing.T) {
  124. fallback := &testDecoder{name: "ffmpeg"}
  125. reg := decoder.NewRegistry()
  126. reg.Register("ffmpeg", func() decoder.Decoder { return fallback })
  127. src := New("ice-test", "http://example", nil, ReconnectConfig{},
  128. WithDecoderRegistry(reg),
  129. WithDecoderPreference("auto"),
  130. )
  131. err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
  132. ContentType: "application/octet-stream",
  133. SourceID: "ice-test",
  134. })
  135. if err != nil {
  136. t.Fatalf("decode: %v", err)
  137. }
  138. if fallback.called != 1 {
  139. t.Fatalf("fallback called %d times", fallback.called)
  140. }
  141. }
  142. func TestDecodeWithPreferenceAutoUsesOggNativeForOggContentType(t *testing.T) {
  143. ogg := &testDecoder{name: "oggvorbis"}
  144. fallback := &testDecoder{name: "ffmpeg"}
  145. reg := decoder.NewRegistry()
  146. reg.Register("oggvorbis", func() decoder.Decoder { return ogg })
  147. reg.Register("ffmpeg", func() decoder.Decoder { return fallback })
  148. src := New("ice-test", "http://example", nil, ReconnectConfig{},
  149. WithDecoderRegistry(reg),
  150. WithDecoderPreference("auto"),
  151. )
  152. err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
  153. ContentType: "audio/ogg",
  154. SourceID: "ice-test",
  155. })
  156. if err != nil {
  157. t.Fatalf("decode: %v", err)
  158. }
  159. if ogg.called != 1 {
  160. t.Fatalf("ogg decoder called %d times", ogg.called)
  161. }
  162. if fallback.called != 0 {
  163. t.Fatalf("fallback should not be called, got %d", fallback.called)
  164. }
  165. }
  166. func TestDecodeWithPreferenceAutoUsesMP3NativeForMPEGContentType(t *testing.T) {
  167. mp3Native := &testDecoder{name: "mp3"}
  168. fallback := &testDecoder{name: "ffmpeg"}
  169. reg := decoder.NewRegistry()
  170. reg.Register("mp3", func() decoder.Decoder { return mp3Native })
  171. reg.Register("ffmpeg", func() decoder.Decoder { return fallback })
  172. src := New("ice-test", "http://example", nil, ReconnectConfig{},
  173. WithDecoderRegistry(reg),
  174. WithDecoderPreference("auto"),
  175. )
  176. err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
  177. ContentType: "audio/mpeg; charset=utf-8",
  178. SourceID: "ice-test",
  179. })
  180. if err != nil {
  181. t.Fatalf("decode: %v", err)
  182. }
  183. if mp3Native.called != 1 {
  184. t.Fatalf("mp3 native decoder called %d times", mp3Native.called)
  185. }
  186. if fallback.called != 0 {
  187. t.Fatalf("fallback should not be called, got %d", fallback.called)
  188. }
  189. }
  190. func TestDecodeWithPreferenceAutoNativeErrorDoesNotFallback(t *testing.T) {
  191. nativeErr := errors.New("native hard failure")
  192. mp3Native := &testDecoder{name: "mp3", err: nativeErr}
  193. fallback := &testDecoder{name: "ffmpeg"}
  194. reg := decoder.NewRegistry()
  195. reg.Register("mp3", func() decoder.Decoder { return mp3Native })
  196. reg.Register("ffmpeg", func() decoder.Decoder { return fallback })
  197. src := New("ice-test", "http://example", nil, ReconnectConfig{},
  198. WithDecoderRegistry(reg),
  199. WithDecoderPreference("auto"),
  200. )
  201. err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
  202. ContentType: "audio/mpeg",
  203. SourceID: "ice-test",
  204. })
  205. if !errors.Is(err, nativeErr) {
  206. t.Fatalf("expected native error, got %v", err)
  207. }
  208. if fallback.called != 0 {
  209. t.Fatalf("fallback should not be called on native hard error, got %d", fallback.called)
  210. }
  211. }
  212. func TestDecodeWithPreferenceAutoFallbackSeesFullStreamAfterNativeConsumesPrefix(t *testing.T) {
  213. const consumed = 4
  214. input := []byte("0123456789abcdef")
  215. native := &consumingUnsupportedDecoder{n: consumed}
  216. fallback := &captureStreamDecoder{name: "ffmpeg"}
  217. reg := decoder.NewRegistry()
  218. reg.Register("mp3", func() decoder.Decoder { return native })
  219. reg.Register("ffmpeg", func() decoder.Decoder { return fallback })
  220. src := New("ice-test", "http://example", nil, ReconnectConfig{},
  221. WithDecoderRegistry(reg),
  222. WithDecoderPreference("auto"),
  223. )
  224. err := src.decodeWithPreference(context.Background(), bytes.NewReader(input), decoder.StreamMeta{
  225. ContentType: "audio/mpeg",
  226. SourceID: "ice-test",
  227. })
  228. if err != nil {
  229. t.Fatalf("decode: %v", err)
  230. }
  231. if native.called != 1 {
  232. t.Fatalf("native called %d times", native.called)
  233. }
  234. if fallback.called != 1 {
  235. t.Fatalf("fallback called %d times", fallback.called)
  236. }
  237. if !bytes.Equal(fallback.payload, input) {
  238. t.Fatalf("fallback payload mismatch: got %q want %q", string(fallback.payload), string(input))
  239. }
  240. }
  241. func TestDecodeWithPreferenceNativeUnsupportedContentTypeFailsWithoutFallback(t *testing.T) {
  242. fallback := &testDecoder{name: "ffmpeg"}
  243. reg := decoder.NewRegistry()
  244. reg.Register("ffmpeg", func() decoder.Decoder { return fallback })
  245. src := New("ice-test", "http://example", nil, ReconnectConfig{},
  246. WithDecoderRegistry(reg),
  247. WithDecoderPreference("native"),
  248. )
  249. err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
  250. ContentType: "application/octet-stream",
  251. SourceID: "ice-test",
  252. })
  253. if err == nil {
  254. t.Fatal("expected native-mode select error for unsupported content-type")
  255. }
  256. if fallback.called != 0 {
  257. t.Fatalf("fallback should not be called in native mode, got %d", fallback.called)
  258. }
  259. }
  260. func TestWithDecoderPreferenceFallbackAliasNormalizesToFFmpeg(t *testing.T) {
  261. src := New("ice-test", "http://example", nil, ReconnectConfig{}, WithDecoderPreference("fallback"))
  262. if got := src.Descriptor().Codec; got != "ffmpeg" {
  263. t.Fatalf("codec=%s want ffmpeg", got)
  264. }
  265. }
  266. func TestDescriptorOriginRedactsCredentialsAndQuery(t *testing.T) {
  267. src := New("ice-test", "http://user:secret@example.org:8000/live.mp3?token=abc", nil, ReconnectConfig{})
  268. desc := src.Descriptor()
  269. if desc.Origin == nil {
  270. t.Fatalf("expected descriptor origin")
  271. }
  272. if desc.Origin.Kind != "url" {
  273. t.Fatalf("origin kind=%q want url", desc.Origin.Kind)
  274. }
  275. if desc.Origin.Endpoint != "http://example.org:8000/live.mp3" {
  276. t.Fatalf("origin endpoint=%q", desc.Origin.Endpoint)
  277. }
  278. }
  279. func TestConnectAndRunRequestsICYAndPublishesStreamTitle(t *testing.T) {
  280. const (
  281. audioPrefix = "ABCD"
  282. audioSuffix = "EFGH"
  283. title = "Artist - Track"
  284. )
  285. var reqIcyHeader atomic.Value
  286. reqIcyHeader.Store("")
  287. metadata := buildICYMetadataBlock("StreamTitle='" + title + "';")
  288. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  289. reqIcyHeader.Store(r.Header.Get("Icy-Metadata"))
  290. w.Header().Set("Content-Type", "audio/mpeg")
  291. w.Header().Set("icy-metaint", "4")
  292. _, _ = w.Write([]byte(audioPrefix))
  293. _, _ = w.Write([]byte{byte(len(metadata) / 16)})
  294. _, _ = w.Write(metadata)
  295. _, _ = w.Write([]byte(audioSuffix))
  296. }))
  297. defer srv.Close()
  298. native := &captureStreamDecoder{name: "mp3"}
  299. reg := decoder.NewRegistry()
  300. reg.Register("mp3", func() decoder.Decoder { return native })
  301. reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} })
  302. src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{},
  303. WithDecoderRegistry(reg),
  304. WithDecoderPreference("auto"),
  305. )
  306. if err := src.connectAndRun(context.Background()); err != nil {
  307. t.Fatalf("connectAndRun: %v", err)
  308. }
  309. if got := reqIcyHeader.Load().(string); got != "1" {
  310. t.Fatalf("Icy-Metadata header=%q want 1", got)
  311. }
  312. if got := string(native.payload); got != audioPrefix+audioSuffix {
  313. t.Fatalf("decoded payload=%q want %q", got, audioPrefix+audioSuffix)
  314. }
  315. stats := src.Stats()
  316. if stats.StreamTitle != title {
  317. t.Fatalf("streamTitle=%q want %q", stats.StreamTitle, title)
  318. }
  319. if stats.MetadataUpdates < 1 {
  320. t.Fatalf("metadataUpdates=%d want >=1", stats.MetadataUpdates)
  321. }
  322. if stats.IcyMetaInt != 4 {
  323. t.Fatalf("icyMetaInt=%d want 4", stats.IcyMetaInt)
  324. }
  325. }
  326. type scriptedLoopDecoder struct {
  327. mu sync.Mutex
  328. actions []decodeAction
  329. calls int
  330. totalBytesRead int
  331. }
  332. type decodeAction struct {
  333. err error
  334. blockUntilStop bool
  335. }
  336. func (d *scriptedLoopDecoder) Name() string { return "scripted-loop" }
  337. func (d *scriptedLoopDecoder) DecodeStream(ctx context.Context, r io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
  338. data, err := io.ReadAll(r)
  339. if err != nil {
  340. return err
  341. }
  342. d.mu.Lock()
  343. d.calls++
  344. d.totalBytesRead += len(data)
  345. callIdx := d.calls - 1
  346. action := decodeAction{}
  347. if callIdx < len(d.actions) {
  348. action = d.actions[callIdx]
  349. }
  350. d.mu.Unlock()
  351. if action.blockUntilStop {
  352. <-ctx.Done()
  353. return nil
  354. }
  355. return action.err
  356. }
  357. func (d *scriptedLoopDecoder) callCount() int {
  358. d.mu.Lock()
  359. defer d.mu.Unlock()
  360. return d.calls
  361. }
  362. func TestSourceReconnectsWhenStreamEndsCleanly(t *testing.T) {
  363. var requests atomic.Int64
  364. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  365. requests.Add(1)
  366. w.Header().Set("Content-Type", "audio/mpeg")
  367. _, _ = w.Write([]byte("test-stream"))
  368. }))
  369. defer srv.Close()
  370. dec := &scriptedLoopDecoder{
  371. actions: []decodeAction{
  372. {}, // first connection ends cleanly (EOS-like)
  373. {blockUntilStop: true},
  374. },
  375. }
  376. reg := decoder.NewRegistry()
  377. reg.Register("mp3", func() decoder.Decoder { return dec })
  378. reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} })
  379. src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{
  380. Enabled: true,
  381. InitialBackoffMs: 1,
  382. MaxBackoffMs: 1,
  383. }, WithDecoderRegistry(reg), WithDecoderPreference("auto"))
  384. if err := src.Start(context.Background()); err != nil {
  385. t.Fatalf("start: %v", err)
  386. }
  387. defer src.Stop()
  388. waitForCondition(t, func() bool { return dec.callCount() >= 2 }, "second decode call after clean EOS")
  389. stats := src.Stats()
  390. if stats.Reconnects < 1 {
  391. t.Fatalf("reconnects=%d want >=1", stats.Reconnects)
  392. }
  393. if got := requests.Load(); got < 2 {
  394. t.Fatalf("requests=%d want >=2", got)
  395. }
  396. }
  397. func TestSourceClearsLastErrorAfterSuccessfulReconnect(t *testing.T) {
  398. const boom = "decoder boom"
  399. var requests atomic.Int64
  400. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  401. requests.Add(1)
  402. w.Header().Set("Content-Type", "audio/mpeg")
  403. _, _ = w.Write([]byte("test-stream"))
  404. }))
  405. defer srv.Close()
  406. dec := &scriptedLoopDecoder{
  407. actions: []decodeAction{
  408. {err: errors.New(boom)}, // first attempt fails
  409. {blockUntilStop: true}, // second attempt recovers and stays running
  410. },
  411. }
  412. reg := decoder.NewRegistry()
  413. reg.Register("mp3", func() decoder.Decoder { return dec })
  414. reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} })
  415. src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{
  416. Enabled: true,
  417. InitialBackoffMs: 1,
  418. MaxBackoffMs: 1,
  419. }, WithDecoderRegistry(reg), WithDecoderPreference("auto"))
  420. if err := src.Start(context.Background()); err != nil {
  421. t.Fatalf("start: %v", err)
  422. }
  423. defer src.Stop()
  424. select {
  425. case err := <-src.Errors():
  426. if err == nil || !strings.Contains(err.Error(), boom) {
  427. t.Fatalf("error=%v want contains %q", err, boom)
  428. }
  429. case <-time.After(1 * time.Second):
  430. t.Fatal("timed out waiting for source error reporting")
  431. }
  432. waitForCondition(t, func() bool {
  433. st := src.Stats()
  434. return dec.callCount() >= 2 && st.LastError == ""
  435. }, "lastError cleared after successful reconnect")
  436. if got := requests.Load(); got < 2 {
  437. t.Fatalf("requests=%d want >=2", got)
  438. }
  439. }
  440. func TestNewWithoutClientUsesStreamingSafeHTTPClient(t *testing.T) {
  441. src := New("ice-test", "http://example", nil, ReconnectConfig{})
  442. if src.client == nil {
  443. t.Fatal("expected default http client")
  444. }
  445. if src.client.Timeout != 0 {
  446. t.Fatalf("client timeout=%v want 0 for streaming", src.client.Timeout)
  447. }
  448. }
  449. func TestSourceReconnectsAfterDeadlineExceededError(t *testing.T) {
  450. var requests atomic.Int64
  451. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  452. requests.Add(1)
  453. w.Header().Set("Content-Type", "audio/mpeg")
  454. _, _ = w.Write([]byte("test-stream"))
  455. }))
  456. defer srv.Close()
  457. dec := &scriptedLoopDecoder{
  458. actions: []decodeAction{
  459. {err: context.DeadlineExceeded}, // first attempt fails transiently
  460. {blockUntilStop: true}, // second attempt recovers and stays running
  461. },
  462. }
  463. reg := decoder.NewRegistry()
  464. reg.Register("mp3", func() decoder.Decoder { return dec })
  465. reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} })
  466. src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{
  467. Enabled: true,
  468. InitialBackoffMs: 1,
  469. MaxBackoffMs: 1,
  470. }, WithDecoderRegistry(reg), WithDecoderPreference("auto"))
  471. if err := src.Start(context.Background()); err != nil {
  472. t.Fatalf("start: %v", err)
  473. }
  474. defer src.Stop()
  475. waitForCondition(t, func() bool { return dec.callCount() >= 2 }, "second decode call after deadline exceeded")
  476. stats := src.Stats()
  477. if stats.Reconnects < 1 {
  478. t.Fatalf("reconnects=%d want >=1", stats.Reconnects)
  479. }
  480. if got := requests.Load(); got < 2 {
  481. t.Fatalf("requests=%d want >=2", got)
  482. }
  483. }
  484. func waitForCondition(t *testing.T, cond func() bool, label string) {
  485. t.Helper()
  486. deadline := time.Now().Add(2 * time.Second)
  487. for time.Now().Before(deadline) {
  488. if cond() {
  489. return
  490. }
  491. time.Sleep(10 * time.Millisecond)
  492. }
  493. t.Fatalf("timeout waiting for condition: %s", label)
  494. }