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) } } 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) }