Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

459 строки
13KB

  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. type scriptedLoopDecoder struct {
  267. mu sync.Mutex
  268. actions []decodeAction
  269. calls int
  270. totalBytesRead int
  271. }
  272. type decodeAction struct {
  273. err error
  274. blockUntilStop bool
  275. }
  276. func (d *scriptedLoopDecoder) Name() string { return "scripted-loop" }
  277. func (d *scriptedLoopDecoder) DecodeStream(ctx context.Context, r io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
  278. data, err := io.ReadAll(r)
  279. if err != nil {
  280. return err
  281. }
  282. d.mu.Lock()
  283. d.calls++
  284. d.totalBytesRead += len(data)
  285. callIdx := d.calls - 1
  286. action := decodeAction{}
  287. if callIdx < len(d.actions) {
  288. action = d.actions[callIdx]
  289. }
  290. d.mu.Unlock()
  291. if action.blockUntilStop {
  292. <-ctx.Done()
  293. return nil
  294. }
  295. return action.err
  296. }
  297. func (d *scriptedLoopDecoder) callCount() int {
  298. d.mu.Lock()
  299. defer d.mu.Unlock()
  300. return d.calls
  301. }
  302. func TestSourceReconnectsWhenStreamEndsCleanly(t *testing.T) {
  303. var requests atomic.Int64
  304. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  305. requests.Add(1)
  306. w.Header().Set("Content-Type", "audio/mpeg")
  307. _, _ = w.Write([]byte("test-stream"))
  308. }))
  309. defer srv.Close()
  310. dec := &scriptedLoopDecoder{
  311. actions: []decodeAction{
  312. {}, // first connection ends cleanly (EOS-like)
  313. {blockUntilStop: true},
  314. },
  315. }
  316. reg := decoder.NewRegistry()
  317. reg.Register("mp3", func() decoder.Decoder { return dec })
  318. reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} })
  319. src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{
  320. Enabled: true,
  321. InitialBackoffMs: 1,
  322. MaxBackoffMs: 1,
  323. }, WithDecoderRegistry(reg), WithDecoderPreference("auto"))
  324. if err := src.Start(context.Background()); err != nil {
  325. t.Fatalf("start: %v", err)
  326. }
  327. defer src.Stop()
  328. waitForCondition(t, func() bool { return dec.callCount() >= 2 }, "second decode call after clean EOS")
  329. stats := src.Stats()
  330. if stats.Reconnects < 1 {
  331. t.Fatalf("reconnects=%d want >=1", stats.Reconnects)
  332. }
  333. if got := requests.Load(); got < 2 {
  334. t.Fatalf("requests=%d want >=2", got)
  335. }
  336. }
  337. func TestSourceClearsLastErrorAfterSuccessfulReconnect(t *testing.T) {
  338. const boom = "decoder boom"
  339. var requests atomic.Int64
  340. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  341. requests.Add(1)
  342. w.Header().Set("Content-Type", "audio/mpeg")
  343. _, _ = w.Write([]byte("test-stream"))
  344. }))
  345. defer srv.Close()
  346. dec := &scriptedLoopDecoder{
  347. actions: []decodeAction{
  348. {err: errors.New(boom)}, // first attempt fails
  349. {blockUntilStop: true}, // second attempt recovers and stays running
  350. },
  351. }
  352. reg := decoder.NewRegistry()
  353. reg.Register("mp3", func() decoder.Decoder { return dec })
  354. reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} })
  355. src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{
  356. Enabled: true,
  357. InitialBackoffMs: 1,
  358. MaxBackoffMs: 1,
  359. }, WithDecoderRegistry(reg), WithDecoderPreference("auto"))
  360. if err := src.Start(context.Background()); err != nil {
  361. t.Fatalf("start: %v", err)
  362. }
  363. defer src.Stop()
  364. select {
  365. case err := <-src.Errors():
  366. if err == nil || !strings.Contains(err.Error(), boom) {
  367. t.Fatalf("error=%v want contains %q", err, boom)
  368. }
  369. case <-time.After(1 * time.Second):
  370. t.Fatal("timed out waiting for source error reporting")
  371. }
  372. waitForCondition(t, func() bool {
  373. st := src.Stats()
  374. return dec.callCount() >= 2 && st.LastError == ""
  375. }, "lastError cleared after successful reconnect")
  376. if got := requests.Load(); got < 2 {
  377. t.Fatalf("requests=%d want >=2", got)
  378. }
  379. }
  380. func waitForCondition(t *testing.T, cond func() bool, label string) {
  381. t.Helper()
  382. deadline := time.Now().Add(2 * time.Second)
  383. for time.Now().Before(deadline) {
  384. if cond() {
  385. return
  386. }
  387. time.Sleep(10 * time.Millisecond)
  388. }
  389. t.Fatalf("timeout waiting for condition: %s", label)
  390. }