Browse Source

ingest: reconnect icecast streams and propagate chunk metadata

main
Jan 1 month ago
parent
commit
faf1aed472
4 changed files with 91 additions and 4 deletions
  1. +3
    -4
      internal/ingest/adapters/icecast/source.go
  2. +51
    -0
      internal/ingest/adapters/icecast/source_test.go
  3. +6
    -0
      internal/ingest/runtime.go
  4. +31
    -0
      internal/ingest/runtime_test.go

+ 3
- 4
internal/ingest/adapters/icecast/source.go View File

@@ -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 {


+ 51
- 0
internal/ingest/adapters/icecast/source_test.go View File

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


+ 6
- 0
internal/ingest/runtime.go View File

@@ -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


+ 31
- 0
internal/ingest/runtime_test.go View File

@@ -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()


Loading…
Cancel
Save