| @@ -75,7 +75,9 @@ func New(id, url string, client *http.Client, reconn ReconnectConfig, opts ...Op | |||
| id = "icecast-main" | |||
| } | |||
| if client == nil { | |||
| client = &http.Client{Timeout: 20 * time.Second} | |||
| // Streaming responses are long-lived; a global client timeout would | |||
| // terminate the body read after a fixed duration. | |||
| client = &http.Client{} | |||
| } | |||
| s := &Source{ | |||
| id: id, | |||
| @@ -202,9 +204,6 @@ func (s *Source) loop(ctx context.Context) { | |||
| if err == nil { | |||
| err = errStreamEnded | |||
| } | |||
| if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { | |||
| return | |||
| } | |||
| s.connected.Store(false) | |||
| s.lastError.Store(err.Error()) | |||
| select { | |||
| @@ -511,6 +511,57 @@ func TestSourceClearsLastErrorAfterSuccessfulReconnect(t *testing.T) { | |||
| } | |||
| } | |||
| func TestNewWithoutClientUsesStreamingSafeHTTPClient(t *testing.T) { | |||
| src := New("ice-test", "http://example", nil, ReconnectConfig{}) | |||
| if src.client == nil { | |||
| t.Fatal("expected default http client") | |||
| } | |||
| if src.client.Timeout != 0 { | |||
| t.Fatalf("client timeout=%v want 0 for streaming", src.client.Timeout) | |||
| } | |||
| } | |||
| func TestSourceReconnectsAfterDeadlineExceededError(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{ | |||
| {err: context.DeadlineExceeded}, // first attempt fails transiently | |||
| {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() | |||
| waitForCondition(t, func() bool { return dec.callCount() >= 2 }, "second decode call after deadline exceeded") | |||
| 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 waitForCondition(t *testing.T, cond func() bool, label string) { | |||
| t.Helper() | |||
| deadline := time.Now().Add(2 * time.Second) | |||
| @@ -161,6 +161,12 @@ func (r *Runtime) handleChunk(chunk PCMChunk) { | |||
| } | |||
| } | |||
| r.mu.Lock() | |||
| if chunk.SampleRateHz > 0 { | |||
| r.active.SampleRateHz = chunk.SampleRateHz | |||
| } | |||
| if chunk.Channels > 0 { | |||
| r.active.Channels = chunk.Channels | |||
| } | |||
| r.stats.State = "running" | |||
| r.stats.LastChunkAt = time.Now() | |||
| r.stats.DroppedFrames += dropped | |||
| @@ -164,6 +164,37 @@ func TestRuntimeStatsExposeActiveDescriptorAndSourceReconnectState(t *testing.T) | |||
| } | |||
| } | |||
| func TestRuntimeUpdatesActiveDescriptorFromChunkMetadata(t *testing.T) { | |||
| sink := audio.NewStreamSource(128, 44100) | |||
| src := newFakeSource() | |||
| src.desc = SourceDescriptor{ | |||
| ID: "icecast-primary", | |||
| Kind: "icecast", | |||
| Channels: 0, | |||
| SampleRateHz: 0, | |||
| } | |||
| rt := NewRuntime(sink, src) | |||
| if err := rt.Start(context.Background()); err != nil { | |||
| t.Fatalf("start: %v", err) | |||
| } | |||
| defer rt.Stop() | |||
| src.chunks <- PCMChunk{ | |||
| Channels: 2, | |||
| SampleRateHz: 48000, | |||
| Samples: []int32{100 << 16, -100 << 16}, | |||
| } | |||
| waitForRuntimeState(t, rt, "running") | |||
| stats := rt.Stats() | |||
| if stats.Active.SampleRateHz != 48000 { | |||
| t.Fatalf("active sampleRateHz=%d want 48000", stats.Active.SampleRateHz) | |||
| } | |||
| if stats.Active.Channels != 2 { | |||
| t.Fatalf("active channels=%d want 2", stats.Active.Channels) | |||
| } | |||
| } | |||
| func TestRuntimeForwardsStreamTitleUpdatesToHandler(t *testing.T) { | |||
| sink := audio.NewStreamSource(128, 44100) | |||
| src := newFakeSource() | |||