|
- package icecast
-
- import (
- "bytes"
- "context"
- "errors"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/jan/fm-rds-tx/internal/ingest"
- "github.com/jan/fm-rds-tx/internal/ingest/decoder"
- )
-
- type testDecoder struct {
- name string
- err error
- called int
- }
-
- func (d *testDecoder) Name() string { return d.name }
-
- func (d *testDecoder) DecodeStream(_ context.Context, _ io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
- d.called++
- return d.err
- }
-
- type consumingUnsupportedDecoder struct {
- n int
- called int
- }
-
- func (d *consumingUnsupportedDecoder) Name() string { return "native-consuming-unsupported" }
-
- func (d *consumingUnsupportedDecoder) DecodeStream(_ context.Context, r io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
- d.called++
- buf := make([]byte, d.n)
- _, _ = io.ReadFull(r, buf)
- return decoder.ErrUnsupported
- }
-
- type captureStreamDecoder struct {
- name string
- called int
- payload []byte
- }
-
- func (d *captureStreamDecoder) Name() string { return d.name }
-
- func (d *captureStreamDecoder) DecodeStream(_ context.Context, r io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
- d.called++
- data, err := io.ReadAll(r)
- if err != nil {
- return err
- }
- d.payload = data
- return nil
- }
-
- func TestDecodeWithPreferenceAutoFallsBackFromNativeUnsupported(t *testing.T) {
- native := &testDecoder{name: "native", err: decoder.ErrUnsupported}
- fallback := &testDecoder{name: "ffmpeg"}
-
- reg := decoder.NewRegistry()
- reg.Register("mp3", func() decoder.Decoder { return native })
- reg.Register("ffmpeg", func() decoder.Decoder { return fallback })
-
- src := New("ice-test", "http://example", nil, ReconnectConfig{},
- WithDecoderRegistry(reg),
- WithDecoderPreference("auto"),
- )
-
- err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
- ContentType: "audio/mpeg",
- SourceID: "ice-test",
- })
- if err != nil {
- t.Fatalf("decode: %v", err)
- }
- if native.called != 1 {
- t.Fatalf("native called %d times", native.called)
- }
- if fallback.called != 1 {
- t.Fatalf("fallback called %d times", fallback.called)
- }
- }
-
- func TestDecodeWithPreferenceNativeDoesNotFallback(t *testing.T) {
- nativeErr := errors.New("decode failed")
- native := &testDecoder{name: "native", err: nativeErr}
- fallback := &testDecoder{name: "ffmpeg"}
-
- reg := decoder.NewRegistry()
- reg.Register("mp3", func() decoder.Decoder { return native })
- reg.Register("ffmpeg", func() decoder.Decoder { return fallback })
-
- src := New("ice-test", "http://example", nil, ReconnectConfig{},
- WithDecoderRegistry(reg),
- WithDecoderPreference("native"),
- )
-
- err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
- ContentType: "audio/mpeg",
- SourceID: "ice-test",
- })
- if !errors.Is(err, nativeErr) {
- t.Fatalf("expected native error, got %v", err)
- }
- if fallback.called != 0 {
- t.Fatalf("fallback should not be called, got %d", fallback.called)
- }
- }
-
- func TestDecodeWithPreferenceFFmpegOnly(t *testing.T) {
- native := &testDecoder{name: "native"}
- fallback := &testDecoder{name: "ffmpeg"}
-
- reg := decoder.NewRegistry()
- reg.Register("mp3", func() decoder.Decoder { return native })
- reg.Register("ffmpeg", func() decoder.Decoder { return fallback })
-
- src := New("ice-test", "http://example", nil, ReconnectConfig{},
- WithDecoderRegistry(reg),
- WithDecoderPreference("ffmpeg"),
- )
-
- err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
- ContentType: "audio/mpeg",
- SourceID: "ice-test",
- })
- if err != nil {
- t.Fatalf("decode: %v", err)
- }
- if native.called != 0 {
- t.Fatalf("native should not be called in ffmpeg mode, got %d", native.called)
- }
- if fallback.called != 1 {
- t.Fatalf("fallback called %d times", fallback.called)
- }
- }
-
- func TestDecodeWithPreferenceAutoUnsupportedContentTypeFallsBack(t *testing.T) {
- fallback := &testDecoder{name: "ffmpeg"}
- reg := decoder.NewRegistry()
- reg.Register("ffmpeg", func() decoder.Decoder { return fallback })
-
- src := New("ice-test", "http://example", nil, ReconnectConfig{},
- WithDecoderRegistry(reg),
- WithDecoderPreference("auto"),
- )
-
- err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
- ContentType: "application/octet-stream",
- SourceID: "ice-test",
- })
- if err != nil {
- t.Fatalf("decode: %v", err)
- }
- if fallback.called != 1 {
- t.Fatalf("fallback called %d times", fallback.called)
- }
- }
-
- func TestDecodeWithPreferenceAutoUsesOggNativeForOggContentType(t *testing.T) {
- ogg := &testDecoder{name: "oggvorbis"}
- fallback := &testDecoder{name: "ffmpeg"}
-
- reg := decoder.NewRegistry()
- reg.Register("oggvorbis", func() decoder.Decoder { return ogg })
- reg.Register("ffmpeg", func() decoder.Decoder { return fallback })
-
- src := New("ice-test", "http://example", nil, ReconnectConfig{},
- WithDecoderRegistry(reg),
- WithDecoderPreference("auto"),
- )
-
- err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
- ContentType: "audio/ogg",
- SourceID: "ice-test",
- })
- if err != nil {
- t.Fatalf("decode: %v", err)
- }
- if ogg.called != 1 {
- t.Fatalf("ogg decoder called %d times", ogg.called)
- }
- if fallback.called != 0 {
- t.Fatalf("fallback should not be called, got %d", fallback.called)
- }
- }
-
- func TestDecodeWithPreferenceAutoUsesMP3NativeForMPEGContentType(t *testing.T) {
- mp3Native := &testDecoder{name: "mp3"}
- fallback := &testDecoder{name: "ffmpeg"}
-
- reg := decoder.NewRegistry()
- reg.Register("mp3", func() decoder.Decoder { return mp3Native })
- reg.Register("ffmpeg", func() decoder.Decoder { return fallback })
-
- src := New("ice-test", "http://example", nil, ReconnectConfig{},
- WithDecoderRegistry(reg),
- WithDecoderPreference("auto"),
- )
-
- err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
- ContentType: "audio/mpeg; charset=utf-8",
- SourceID: "ice-test",
- })
- if err != nil {
- t.Fatalf("decode: %v", err)
- }
- if mp3Native.called != 1 {
- t.Fatalf("mp3 native decoder called %d times", mp3Native.called)
- }
- if fallback.called != 0 {
- t.Fatalf("fallback should not be called, got %d", fallback.called)
- }
- }
-
- func TestDecodeWithPreferenceAutoNativeErrorDoesNotFallback(t *testing.T) {
- nativeErr := errors.New("native hard failure")
- mp3Native := &testDecoder{name: "mp3", err: nativeErr}
- fallback := &testDecoder{name: "ffmpeg"}
-
- reg := decoder.NewRegistry()
- reg.Register("mp3", func() decoder.Decoder { return mp3Native })
- reg.Register("ffmpeg", func() decoder.Decoder { return fallback })
-
- src := New("ice-test", "http://example", nil, ReconnectConfig{},
- WithDecoderRegistry(reg),
- WithDecoderPreference("auto"),
- )
-
- err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
- ContentType: "audio/mpeg",
- SourceID: "ice-test",
- })
- if !errors.Is(err, nativeErr) {
- t.Fatalf("expected native error, got %v", err)
- }
- if fallback.called != 0 {
- t.Fatalf("fallback should not be called on native hard error, got %d", fallback.called)
- }
- }
-
- func TestDecodeWithPreferenceAutoFallbackSeesFullStreamAfterNativeConsumesPrefix(t *testing.T) {
- const consumed = 4
- input := []byte("0123456789abcdef")
-
- native := &consumingUnsupportedDecoder{n: consumed}
- fallback := &captureStreamDecoder{name: "ffmpeg"}
-
- reg := decoder.NewRegistry()
- reg.Register("mp3", func() decoder.Decoder { return native })
- reg.Register("ffmpeg", func() decoder.Decoder { return fallback })
-
- src := New("ice-test", "http://example", nil, ReconnectConfig{},
- WithDecoderRegistry(reg),
- WithDecoderPreference("auto"),
- )
-
- err := src.decodeWithPreference(context.Background(), bytes.NewReader(input), decoder.StreamMeta{
- ContentType: "audio/mpeg",
- SourceID: "ice-test",
- })
- if err != nil {
- t.Fatalf("decode: %v", err)
- }
- if native.called != 1 {
- t.Fatalf("native called %d times", native.called)
- }
- if fallback.called != 1 {
- t.Fatalf("fallback called %d times", fallback.called)
- }
- if !bytes.Equal(fallback.payload, input) {
- t.Fatalf("fallback payload mismatch: got %q want %q", string(fallback.payload), string(input))
- }
- }
-
- func TestDecodeWithPreferenceNativeUnsupportedContentTypeFailsWithoutFallback(t *testing.T) {
- fallback := &testDecoder{name: "ffmpeg"}
- reg := decoder.NewRegistry()
- reg.Register("ffmpeg", func() decoder.Decoder { return fallback })
-
- src := New("ice-test", "http://example", nil, ReconnectConfig{},
- WithDecoderRegistry(reg),
- WithDecoderPreference("native"),
- )
-
- err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
- ContentType: "application/octet-stream",
- SourceID: "ice-test",
- })
- if err == nil {
- t.Fatal("expected native-mode select error for unsupported content-type")
- }
- if fallback.called != 0 {
- t.Fatalf("fallback should not be called in native mode, got %d", fallback.called)
- }
- }
-
- func TestWithDecoderPreferenceFallbackAliasNormalizesToFFmpeg(t *testing.T) {
- src := New("ice-test", "http://example", nil, ReconnectConfig{}, WithDecoderPreference("fallback"))
- if got := src.Descriptor().Codec; got != "ffmpeg" {
- t.Fatalf("codec=%s want ffmpeg", got)
- }
- }
-
- func TestConnectAndRunRequestsICYAndPublishesStreamTitle(t *testing.T) {
- const (
- audioPrefix = "ABCD"
- audioSuffix = "EFGH"
- title = "Artist - Track"
- )
- var reqIcyHeader atomic.Value
- reqIcyHeader.Store("")
-
- metadata := buildICYMetadataBlock("StreamTitle='" + title + "';")
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- reqIcyHeader.Store(r.Header.Get("Icy-Metadata"))
- w.Header().Set("Content-Type", "audio/mpeg")
- w.Header().Set("icy-metaint", "4")
- _, _ = w.Write([]byte(audioPrefix))
- _, _ = w.Write([]byte{byte(len(metadata) / 16)})
- _, _ = w.Write(metadata)
- _, _ = w.Write([]byte(audioSuffix))
- }))
- defer srv.Close()
-
- native := &captureStreamDecoder{name: "mp3"}
- reg := decoder.NewRegistry()
- reg.Register("mp3", func() decoder.Decoder { return native })
- reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} })
-
- src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{},
- WithDecoderRegistry(reg),
- WithDecoderPreference("auto"),
- )
-
- if err := src.connectAndRun(context.Background()); err != nil {
- t.Fatalf("connectAndRun: %v", err)
- }
- if got := reqIcyHeader.Load().(string); got != "1" {
- t.Fatalf("Icy-Metadata header=%q want 1", got)
- }
- if got := string(native.payload); got != audioPrefix+audioSuffix {
- t.Fatalf("decoded payload=%q want %q", got, audioPrefix+audioSuffix)
- }
- stats := src.Stats()
- if stats.StreamTitle != title {
- t.Fatalf("streamTitle=%q want %q", stats.StreamTitle, title)
- }
- if stats.MetadataUpdates < 1 {
- t.Fatalf("metadataUpdates=%d want >=1", stats.MetadataUpdates)
- }
- if stats.IcyMetaInt != 4 {
- t.Fatalf("icyMetaInt=%d want 4", stats.IcyMetaInt)
- }
- }
-
- type scriptedLoopDecoder struct {
- mu sync.Mutex
- actions []decodeAction
- calls int
- totalBytesRead int
- }
-
- type decodeAction struct {
- err error
- blockUntilStop bool
- }
-
- func (d *scriptedLoopDecoder) Name() string { return "scripted-loop" }
-
- func (d *scriptedLoopDecoder) DecodeStream(ctx context.Context, r io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
- data, err := io.ReadAll(r)
- if err != nil {
- return err
- }
-
- d.mu.Lock()
- d.calls++
- d.totalBytesRead += len(data)
- callIdx := d.calls - 1
- action := decodeAction{}
- if callIdx < len(d.actions) {
- action = d.actions[callIdx]
- }
- d.mu.Unlock()
-
- if action.blockUntilStop {
- <-ctx.Done()
- return nil
- }
- return action.err
- }
-
- func (d *scriptedLoopDecoder) callCount() int {
- d.mu.Lock()
- defer d.mu.Unlock()
- return d.calls
- }
-
- func TestSourceReconnectsWhenStreamEndsCleanly(t *testing.T) {
- var requests atomic.Int64
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- requests.Add(1)
- w.Header().Set("Content-Type", "audio/mpeg")
- _, _ = w.Write([]byte("test-stream"))
- }))
- defer srv.Close()
-
- dec := &scriptedLoopDecoder{
- actions: []decodeAction{
- {}, // first connection ends cleanly (EOS-like)
- {blockUntilStop: true},
- },
- }
- reg := decoder.NewRegistry()
- reg.Register("mp3", func() decoder.Decoder { return dec })
- reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} })
-
- src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{
- Enabled: true,
- InitialBackoffMs: 1,
- MaxBackoffMs: 1,
- }, WithDecoderRegistry(reg), WithDecoderPreference("auto"))
-
- if err := src.Start(context.Background()); err != nil {
- t.Fatalf("start: %v", err)
- }
- defer src.Stop()
-
- waitForCondition(t, func() bool { return dec.callCount() >= 2 }, "second decode call after clean EOS")
-
- stats := src.Stats()
- if stats.Reconnects < 1 {
- t.Fatalf("reconnects=%d want >=1", stats.Reconnects)
- }
- if got := requests.Load(); got < 2 {
- t.Fatalf("requests=%d want >=2", got)
- }
- }
-
- func TestSourceClearsLastErrorAfterSuccessfulReconnect(t *testing.T) {
- const boom = "decoder boom"
- var requests atomic.Int64
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- requests.Add(1)
- w.Header().Set("Content-Type", "audio/mpeg")
- _, _ = w.Write([]byte("test-stream"))
- }))
- defer srv.Close()
-
- dec := &scriptedLoopDecoder{
- actions: []decodeAction{
- {err: errors.New(boom)}, // first attempt fails
- {blockUntilStop: true}, // second attempt recovers and stays running
- },
- }
- reg := decoder.NewRegistry()
- reg.Register("mp3", func() decoder.Decoder { return dec })
- reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} })
-
- src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{
- Enabled: true,
- InitialBackoffMs: 1,
- MaxBackoffMs: 1,
- }, WithDecoderRegistry(reg), WithDecoderPreference("auto"))
-
- if err := src.Start(context.Background()); err != nil {
- t.Fatalf("start: %v", err)
- }
- defer src.Stop()
-
- select {
- case err := <-src.Errors():
- if err == nil || !strings.Contains(err.Error(), boom) {
- t.Fatalf("error=%v want contains %q", err, boom)
- }
- case <-time.After(1 * time.Second):
- t.Fatal("timed out waiting for source error reporting")
- }
-
- waitForCondition(t, func() bool {
- st := src.Stats()
- return dec.callCount() >= 2 && st.LastError == ""
- }, "lastError cleared after successful reconnect")
-
- if got := requests.Load(); got < 2 {
- t.Fatalf("requests=%d want >=2", got)
- }
- }
-
- func waitForCondition(t *testing.T, cond func() bool, label string) {
- t.Helper()
- deadline := time.Now().Add(2 * time.Second)
- for time.Now().Before(deadline) {
- if cond() {
- return
- }
- time.Sleep(10 * time.Millisecond)
- }
- t.Fatalf("timeout waiting for condition: %s", label)
- }
|