| @@ -17,6 +17,7 @@ import ( | |||||
| ctrlpkg "github.com/jan/fm-rds-tx/internal/control" | ctrlpkg "github.com/jan/fm-rds-tx/internal/control" | ||||
| drypkg "github.com/jan/fm-rds-tx/internal/dryrun" | drypkg "github.com/jan/fm-rds-tx/internal/dryrun" | ||||
| "github.com/jan/fm-rds-tx/internal/ingest" | "github.com/jan/fm-rds-tx/internal/ingest" | ||||
| "github.com/jan/fm-rds-tx/internal/ingest/adapters/icecast" | |||||
| ingestfactory "github.com/jan/fm-rds-tx/internal/ingest/factory" | ingestfactory "github.com/jan/fm-rds-tx/internal/ingest/factory" | ||||
| "github.com/jan/fm-rds-tx/internal/platform" | "github.com/jan/fm-rds-tx/internal/platform" | ||||
| "github.com/jan/fm-rds-tx/internal/platform/plutosdr" | "github.com/jan/fm-rds-tx/internal/platform/plutosdr" | ||||
| @@ -190,7 +191,33 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a | |||||
| if err != nil { | if err != nil { | ||||
| log.Fatalf("ingest source: %v", err) | log.Fatalf("ingest source: %v", err) | ||||
| } | } | ||||
| ingestRuntime = ingest.NewRuntime(streamSrc, source) | |||||
| runtimeOpts := []ingest.RuntimeOption{} | |||||
| if cfg.Ingest.Icecast.RadioText.Enabled { | |||||
| relay := icecast.NewRadioTextRelay( | |||||
| icecast.RadioTextOptions{ | |||||
| Enabled: true, | |||||
| Prefix: cfg.Ingest.Icecast.RadioText.Prefix, | |||||
| MaxLen: cfg.Ingest.Icecast.RadioText.MaxLen, | |||||
| OnlyOnChange: cfg.Ingest.Icecast.RadioText.OnlyOnChange, | |||||
| }, | |||||
| cfg.RDS.RadioText, | |||||
| func(rt string) error { | |||||
| return engine.UpdateConfig(apppkg.LiveConfigUpdate{RadioText: &rt}) | |||||
| }, | |||||
| ) | |||||
| runtimeOpts = append(runtimeOpts, ingest.WithStreamTitleHandler(func(streamTitle string) { | |||||
| if err := relay.HandleStreamTitle(streamTitle); err != nil { | |||||
| log.Printf("ingest: failed to forward StreamTitle to RDS RadioText: %v", err) | |||||
| } | |||||
| })) | |||||
| log.Printf( | |||||
| "ingest: ICY StreamTitle->RDS enabled (maxLen=%d onlyOnChange=%t prefix=%q)", | |||||
| cfg.Ingest.Icecast.RadioText.MaxLen, | |||||
| cfg.Ingest.Icecast.RadioText.OnlyOnChange, | |||||
| cfg.Ingest.Icecast.RadioText.Prefix, | |||||
| ) | |||||
| } | |||||
| ingestRuntime = ingest.NewRuntime(streamSrc, source, runtimeOpts...) | |||||
| if err := ingestRuntime.Start(ctx); err != nil { | if err := ingestRuntime.Start(ctx); err != nil { | ||||
| log.Fatalf("ingest start: %v", err) | log.Fatalf("ingest start: %v", err) | ||||
| } | } | ||||
| @@ -92,8 +92,16 @@ type IngestPCMConfig struct { | |||||
| } | } | ||||
| type IngestIcecastConfig struct { | type IngestIcecastConfig struct { | ||||
| URL string `json:"url"` | |||||
| Decoder string `json:"decoder"` | |||||
| URL string `json:"url"` | |||||
| Decoder string `json:"decoder"` | |||||
| RadioText IngestIcecastRadioTextConfig `json:"radioText"` | |||||
| } | |||||
| type IngestIcecastRadioTextConfig struct { | |||||
| Enabled bool `json:"enabled"` | |||||
| Prefix string `json:"prefix"` | |||||
| MaxLen int `json:"maxLen"` | |||||
| OnlyOnChange bool `json:"onlyOnChange"` | |||||
| } | } | ||||
| func Default() Config { | func Default() Config { | ||||
| @@ -138,6 +146,11 @@ func Default() Config { | |||||
| }, | }, | ||||
| Icecast: IngestIcecastConfig{ | Icecast: IngestIcecastConfig{ | ||||
| Decoder: "auto", | Decoder: "auto", | ||||
| RadioText: IngestIcecastRadioTextConfig{ | |||||
| Enabled: false, | |||||
| MaxLen: 64, | |||||
| OnlyOnChange: true, | |||||
| }, | |||||
| }, | }, | ||||
| }, | }, | ||||
| } | } | ||||
| @@ -265,6 +278,9 @@ func (c Config) Validate() error { | |||||
| default: | default: | ||||
| return fmt.Errorf("ingest.icecast.decoder unsupported: %s", c.Ingest.Icecast.Decoder) | return fmt.Errorf("ingest.icecast.decoder unsupported: %s", c.Ingest.Icecast.Decoder) | ||||
| } | } | ||||
| if c.Ingest.Icecast.RadioText.MaxLen < 0 || c.Ingest.Icecast.RadioText.MaxLen > 64 { | |||||
| return fmt.Errorf("ingest.icecast.radioText.maxLen out of range (0-64)") | |||||
| } | |||||
| // Fail-loud PI validation | // Fail-loud PI validation | ||||
| if c.RDS.Enabled { | if c.RDS.Enabled { | ||||
| if _, err := ParsePI(c.RDS.PI); err != nil { | if _, err := ParsePI(c.RDS.PI); err != nil { | ||||
| @@ -168,6 +168,14 @@ func TestValidateAcceptsIcecastDecoderFallbackAlias(t *testing.T) { | |||||
| } | } | ||||
| } | } | ||||
| func TestValidateRejectsIcecastRadioTextMaxLenOutOfRange(t *testing.T) { | |||||
| cfg := Default() | |||||
| cfg.Ingest.Icecast.RadioText.MaxLen = 65 | |||||
| if err := cfg.Validate(); err == nil { | |||||
| t.Fatal("expected maxLen error") | |||||
| } | |||||
| } | |||||
| func TestValidateRejectsReconnectWithMissingBackoff(t *testing.T) { | func TestValidateRejectsReconnectWithMissingBackoff(t *testing.T) { | ||||
| cfg := Default() | cfg := Default() | ||||
| cfg.Ingest.Reconnect.Enabled = true | cfg.Ingest.Reconnect.Enabled = true | ||||
| @@ -0,0 +1,109 @@ | |||||
| package icecast | |||||
| import ( | |||||
| "bytes" | |||||
| "fmt" | |||||
| "io" | |||||
| "strconv" | |||||
| "strings" | |||||
| ) | |||||
| type icyMetadata struct { | |||||
| StreamTitle string | |||||
| } | |||||
| type icyReader struct { | |||||
| r io.Reader | |||||
| metaInt int | |||||
| audioLeft int | |||||
| onMetadata func(icyMetadata) | |||||
| } | |||||
| func newICYReader(r io.Reader, metaInt int, onMetadata func(icyMetadata)) io.Reader { | |||||
| if r == nil || metaInt <= 0 { | |||||
| return r | |||||
| } | |||||
| return &icyReader{ | |||||
| r: r, | |||||
| metaInt: metaInt, | |||||
| audioLeft: metaInt, | |||||
| onMetadata: onMetadata, | |||||
| } | |||||
| } | |||||
| func (r *icyReader) Read(p []byte) (int, error) { | |||||
| if len(p) == 0 { | |||||
| return 0, nil | |||||
| } | |||||
| for { | |||||
| if r.audioLeft == 0 { | |||||
| if err := r.readMetadataBlock(); err != nil { | |||||
| return 0, err | |||||
| } | |||||
| r.audioLeft = r.metaInt | |||||
| continue | |||||
| } | |||||
| want := len(p) | |||||
| if want > r.audioLeft { | |||||
| want = r.audioLeft | |||||
| } | |||||
| n, err := r.r.Read(p[:want]) | |||||
| if n > 0 { | |||||
| r.audioLeft -= n | |||||
| return n, nil | |||||
| } | |||||
| if err != nil { | |||||
| return 0, err | |||||
| } | |||||
| } | |||||
| } | |||||
| func (r *icyReader) readMetadataBlock() error { | |||||
| var lenBuf [1]byte | |||||
| if _, err := io.ReadFull(r.r, lenBuf[:]); err != nil { | |||||
| return err | |||||
| } | |||||
| blockLen := int(lenBuf[0]) * 16 | |||||
| if blockLen == 0 { | |||||
| return nil | |||||
| } | |||||
| block := make([]byte, blockLen) | |||||
| if _, err := io.ReadFull(r.r, block); err != nil { | |||||
| return err | |||||
| } | |||||
| if r.onMetadata != nil { | |||||
| r.onMetadata(parseICYMetadata(block)) | |||||
| } | |||||
| return nil | |||||
| } | |||||
| func parseICYMetadata(block []byte) icyMetadata { | |||||
| raw := strings.TrimRight(string(bytes.Trim(block, "\x00")), "\x00") | |||||
| meta := icyMetadata{} | |||||
| for _, field := range strings.Split(raw, ";") { | |||||
| field = strings.TrimSpace(field) | |||||
| if !strings.HasPrefix(field, "StreamTitle=") { | |||||
| continue | |||||
| } | |||||
| v := strings.TrimPrefix(field, "StreamTitle=") | |||||
| v = strings.TrimSpace(v) | |||||
| if len(v) >= 2 && ((v[0] == '\'' && v[len(v)-1] == '\'') || (v[0] == '"' && v[len(v)-1] == '"')) { | |||||
| v = v[1 : len(v)-1] | |||||
| } | |||||
| meta.StreamTitle = v | |||||
| break | |||||
| } | |||||
| return meta | |||||
| } | |||||
| func parseICYMetaInt(raw string) (int, error) { | |||||
| raw = strings.TrimSpace(raw) | |||||
| if raw == "" { | |||||
| return 0, nil | |||||
| } | |||||
| n, err := strconv.Atoi(raw) | |||||
| if err != nil || n < 0 { | |||||
| return 0, fmt.Errorf("invalid icy-metaint: %q", raw) | |||||
| } | |||||
| return n, nil | |||||
| } | |||||
| @@ -0,0 +1,77 @@ | |||||
| package icecast | |||||
| import ( | |||||
| "bytes" | |||||
| "io" | |||||
| "testing" | |||||
| ) | |||||
| func TestParseICYMetadataExtractsStreamTitle(t *testing.T) { | |||||
| meta := parseICYMetadata([]byte("StreamTitle='Artist - Track';StreamUrl='';")) | |||||
| if meta.StreamTitle != "Artist - Track" { | |||||
| t.Fatalf("streamTitle=%q want %q", meta.StreamTitle, "Artist - Track") | |||||
| } | |||||
| } | |||||
| func TestICYReaderStripsMetadataAndEmitsTitle(t *testing.T) { | |||||
| block := buildICYMetadataBlock("StreamTitle='Unit Test';") | |||||
| wire := append([]byte("ABCD"), byte(len(block)/16)) | |||||
| wire = append(wire, block...) | |||||
| wire = append(wire, []byte("EFGH")...) | |||||
| var got icyMetadata | |||||
| r := newICYReader(bytes.NewReader(wire), 4, func(meta icyMetadata) { | |||||
| got = meta | |||||
| }) | |||||
| audio, err := io.ReadAll(r) | |||||
| if err != nil { | |||||
| t.Fatalf("read: %v", err) | |||||
| } | |||||
| if string(audio) != "ABCDEFGH" { | |||||
| t.Fatalf("audio=%q want %q", string(audio), "ABCDEFGH") | |||||
| } | |||||
| if got.StreamTitle != "Unit Test" { | |||||
| t.Fatalf("streamTitle=%q want %q", got.StreamTitle, "Unit Test") | |||||
| } | |||||
| } | |||||
| func TestParseICYMetaInt(t *testing.T) { | |||||
| tests := []struct { | |||||
| name string | |||||
| in string | |||||
| want int | |||||
| wantErr bool | |||||
| }{ | |||||
| {name: "empty", in: "", want: 0}, | |||||
| {name: "valid", in: "16000", want: 16000}, | |||||
| {name: "invalid", in: "x", wantErr: true}, | |||||
| {name: "negative", in: "-1", wantErr: true}, | |||||
| } | |||||
| for _, tc := range tests { | |||||
| tc := tc | |||||
| t.Run(tc.name, func(t *testing.T) { | |||||
| got, err := parseICYMetaInt(tc.in) | |||||
| if tc.wantErr { | |||||
| if err == nil { | |||||
| t.Fatalf("expected error for %q", tc.in) | |||||
| } | |||||
| return | |||||
| } | |||||
| if err != nil { | |||||
| t.Fatalf("parse: %v", err) | |||||
| } | |||||
| if got != tc.want { | |||||
| t.Fatalf("got=%d want %d", got, tc.want) | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| func buildICYMetadataBlock(raw string) []byte { | |||||
| b := []byte(raw) | |||||
| if rem := len(b) % 16; rem != 0 { | |||||
| b = append(b, bytes.Repeat([]byte{0x00}, 16-rem)...) | |||||
| } | |||||
| return b | |||||
| } | |||||
| @@ -0,0 +1,106 @@ | |||||
| package icecast | |||||
| import ( | |||||
| "strings" | |||||
| "sync" | |||||
| ) | |||||
| type RadioTextOptions struct { | |||||
| Enabled bool | |||||
| Prefix string | |||||
| MaxLen int | |||||
| OnlyOnChange bool | |||||
| } | |||||
| func mapStreamTitleToRadioText(streamTitle string, opts RadioTextOptions) string { | |||||
| if !opts.Enabled { | |||||
| return "" | |||||
| } | |||||
| maxLen := opts.MaxLen | |||||
| if maxLen <= 0 || maxLen > 64 { | |||||
| maxLen = 64 | |||||
| } | |||||
| title := sanitizeASCII(streamTitle) | |||||
| if title == "" { | |||||
| return "" | |||||
| } | |||||
| prefixRaw := opts.Prefix | |||||
| prefixHadTrailingSpace := strings.TrimRight(prefixRaw, " \t\r\n") != prefixRaw | |||||
| prefix := sanitizeASCII(opts.Prefix) | |||||
| if prefix != "" && prefixHadTrailingSpace { | |||||
| prefix += " " | |||||
| } | |||||
| rt := title | |||||
| if prefix != "" { | |||||
| rt = prefix + title | |||||
| } | |||||
| if len(rt) > maxLen { | |||||
| rt = strings.TrimSpace(rt[:maxLen]) | |||||
| } | |||||
| return rt | |||||
| } | |||||
| func sanitizeASCII(raw string) string { | |||||
| raw = strings.TrimSpace(raw) | |||||
| if raw == "" { | |||||
| return "" | |||||
| } | |||||
| var b strings.Builder | |||||
| b.Grow(len(raw)) | |||||
| prevSpace := true | |||||
| for _, r := range raw { | |||||
| switch r { | |||||
| case '\n', '\r', '\t': | |||||
| r = ' ' | |||||
| } | |||||
| if r < 0x20 || r == 0x7f || r > 0x7e { | |||||
| continue | |||||
| } | |||||
| if r == ' ' { | |||||
| if prevSpace { | |||||
| continue | |||||
| } | |||||
| prevSpace = true | |||||
| b.WriteByte(' ') | |||||
| continue | |||||
| } | |||||
| prevSpace = false | |||||
| b.WriteByte(byte(r)) | |||||
| } | |||||
| return strings.TrimSpace(b.String()) | |||||
| } | |||||
| type RadioTextRelay struct { | |||||
| opts RadioTextOptions | |||||
| apply func(string) error | |||||
| mu sync.Mutex | |||||
| lastRT string | |||||
| } | |||||
| func NewRadioTextRelay(opts RadioTextOptions, initialRT string, apply func(string) error) *RadioTextRelay { | |||||
| return &RadioTextRelay{ | |||||
| opts: opts, | |||||
| apply: apply, | |||||
| lastRT: sanitizeASCII(initialRT), | |||||
| } | |||||
| } | |||||
| func (r *RadioTextRelay) HandleStreamTitle(streamTitle string) error { | |||||
| if r == nil || r.apply == nil { | |||||
| return nil | |||||
| } | |||||
| next := mapStreamTitleToRadioText(streamTitle, r.opts) | |||||
| if next == "" { | |||||
| return nil | |||||
| } | |||||
| r.mu.Lock() | |||||
| skip := r.opts.OnlyOnChange && next == r.lastRT | |||||
| if !skip { | |||||
| r.lastRT = next | |||||
| } | |||||
| r.mu.Unlock() | |||||
| if skip { | |||||
| return nil | |||||
| } | |||||
| return r.apply(next) | |||||
| } | |||||
| @@ -0,0 +1,65 @@ | |||||
| package icecast | |||||
| import "testing" | |||||
| func TestMapStreamTitleToRadioTextSanitizeAndTruncate(t *testing.T) { | |||||
| got := mapStreamTitleToRadioText(" Artist\t-\nSong \u2603 ", RadioTextOptions{ | |||||
| Enabled: true, | |||||
| Prefix: "Now: ", | |||||
| MaxLen: 13, | |||||
| }) | |||||
| if got != "Now: Artist -" { | |||||
| t.Fatalf("mapped=%q want %q", got, "Now: Artist -") | |||||
| } | |||||
| } | |||||
| func TestMapStreamTitleToRadioTextDisabledReturnsEmpty(t *testing.T) { | |||||
| got := mapStreamTitleToRadioText("Artist - Song", RadioTextOptions{Enabled: false}) | |||||
| if got != "" { | |||||
| t.Fatalf("mapped=%q want empty", got) | |||||
| } | |||||
| } | |||||
| func TestRadioTextRelayOnlyOnChange(t *testing.T) { | |||||
| calls := 0 | |||||
| last := "" | |||||
| relay := NewRadioTextRelay(RadioTextOptions{ | |||||
| Enabled: true, | |||||
| OnlyOnChange: true, | |||||
| }, "", func(rt string) error { | |||||
| calls++ | |||||
| last = rt | |||||
| return nil | |||||
| }) | |||||
| if err := relay.HandleStreamTitle("Artist - Song"); err != nil { | |||||
| t.Fatalf("first handle: %v", err) | |||||
| } | |||||
| if err := relay.HandleStreamTitle("Artist - Song"); err != nil { | |||||
| t.Fatalf("second handle: %v", err) | |||||
| } | |||||
| if calls != 1 { | |||||
| t.Fatalf("calls=%d want 1", calls) | |||||
| } | |||||
| if last != "Artist - Song" { | |||||
| t.Fatalf("last=%q want %q", last, "Artist - Song") | |||||
| } | |||||
| } | |||||
| func TestRadioTextRelayInitialSuppressesSameUpdate(t *testing.T) { | |||||
| calls := 0 | |||||
| relay := NewRadioTextRelay(RadioTextOptions{ | |||||
| Enabled: true, | |||||
| OnlyOnChange: true, | |||||
| }, "Station default", func(string) error { | |||||
| calls++ | |||||
| return nil | |||||
| }) | |||||
| if err := relay.HandleStreamTitle("Station default"); err != nil { | |||||
| t.Fatalf("handle: %v", err) | |||||
| } | |||||
| if calls != 0 { | |||||
| t.Fatalf("calls=%d want 0", calls) | |||||
| } | |||||
| } | |||||
| @@ -32,6 +32,7 @@ type Source struct { | |||||
| chunks chan ingest.PCMChunk | chunks chan ingest.PCMChunk | ||||
| errs chan error | errs chan error | ||||
| title chan string | |||||
| cancel context.CancelFunc | cancel context.CancelFunc | ||||
| wg sync.WaitGroup | wg sync.WaitGroup | ||||
| @@ -43,7 +44,11 @@ type Source struct { | |||||
| reconnects atomic.Uint64 | reconnects atomic.Uint64 | ||||
| discontinuities atomic.Uint64 | discontinuities atomic.Uint64 | ||||
| lastChunkAtUnix atomic.Int64 | lastChunkAtUnix atomic.Int64 | ||||
| lastMetaAtUnix atomic.Int64 | |||||
| metadataUpdates atomic.Uint64 | |||||
| icyMetaInt atomic.Int64 | |||||
| lastError atomic.Value // string | lastError atomic.Value // string | ||||
| streamTitle atomic.Value // string | |||||
| } | } | ||||
| var errStreamEnded = errors.New("icecast stream ended") | var errStreamEnded = errors.New("icecast stream ended") | ||||
| @@ -78,6 +83,7 @@ func New(id, url string, client *http.Client, reconn ReconnectConfig, opts ...Op | |||||
| reconn: reconn, | reconn: reconn, | ||||
| chunks: make(chan ingest.PCMChunk, 64), | chunks: make(chan ingest.PCMChunk, 64), | ||||
| errs: make(chan error, 8), | errs: make(chan error, 8), | ||||
| title: make(chan string, 16), | |||||
| decReg: defaultRegistry(), | decReg: defaultRegistry(), | ||||
| decoderPreference: "auto", | decoderPreference: "auto", | ||||
| } | } | ||||
| @@ -88,6 +94,7 @@ func New(id, url string, client *http.Client, reconn ReconnectConfig, opts ...Op | |||||
| } | } | ||||
| s.decoderPreference = normalizeDecoderPreference(s.decoderPreference) | s.decoderPreference = normalizeDecoderPreference(s.decoderPreference) | ||||
| s.state.Store("idle") | s.state.Store("idle") | ||||
| s.streamTitle.Store("") | |||||
| return s | return s | ||||
| } | } | ||||
| @@ -135,19 +142,32 @@ func (s *Source) Stop() error { | |||||
| func (s *Source) Chunks() <-chan ingest.PCMChunk { return s.chunks } | func (s *Source) Chunks() <-chan ingest.PCMChunk { return s.chunks } | ||||
| func (s *Source) Errors() <-chan error { return s.errs } | func (s *Source) Errors() <-chan error { return s.errs } | ||||
| func (s *Source) StreamTitleUpdates() <-chan string { | |||||
| return s.title | |||||
| } | |||||
| func (s *Source) Stats() ingest.SourceStats { | func (s *Source) Stats() ingest.SourceStats { | ||||
| state, _ := s.state.Load().(string) | state, _ := s.state.Load().(string) | ||||
| last := s.lastChunkAtUnix.Load() | last := s.lastChunkAtUnix.Load() | ||||
| lastMeta := s.lastMetaAtUnix.Load() | |||||
| errStr, _ := s.lastError.Load().(string) | errStr, _ := s.lastError.Load().(string) | ||||
| streamTitle, _ := s.streamTitle.Load().(string) | |||||
| var lastChunkAt time.Time | var lastChunkAt time.Time | ||||
| var lastMetaAt time.Time | |||||
| if last > 0 { | if last > 0 { | ||||
| lastChunkAt = time.Unix(0, last) | lastChunkAt = time.Unix(0, last) | ||||
| } | } | ||||
| if lastMeta > 0 { | |||||
| lastMetaAt = time.Unix(0, lastMeta) | |||||
| } | |||||
| return ingest.SourceStats{ | return ingest.SourceStats{ | ||||
| State: state, | State: state, | ||||
| Connected: s.connected.Load(), | Connected: s.connected.Load(), | ||||
| LastChunkAt: lastChunkAt, | LastChunkAt: lastChunkAt, | ||||
| LastMetaAt: lastMetaAt, | |||||
| StreamTitle: streamTitle, | |||||
| MetadataUpdates: s.metadataUpdates.Load(), | |||||
| IcyMetaInt: int(s.icyMetaInt.Load()), | |||||
| ChunksIn: s.chunksIn.Load(), | ChunksIn: s.chunksIn.Load(), | ||||
| SamplesIn: s.samplesIn.Load(), | SamplesIn: s.samplesIn.Load(), | ||||
| Reconnects: s.reconnects.Load(), | Reconnects: s.reconnects.Load(), | ||||
| @@ -160,6 +180,7 @@ func (s *Source) loop(ctx context.Context) { | |||||
| defer s.wg.Done() | defer s.wg.Done() | ||||
| defer close(s.chunks) | defer close(s.chunks) | ||||
| defer close(s.errs) | defer close(s.errs) | ||||
| defer close(s.title) | |||||
| attempt := 0 | attempt := 0 | ||||
| for { | for { | ||||
| select { | select { | ||||
| @@ -206,7 +227,7 @@ func (s *Source) connectAndRun(ctx context.Context) error { | |||||
| if err != nil { | if err != nil { | ||||
| return err | return err | ||||
| } | } | ||||
| req.Header.Set("Icy-MetaData", "0") | |||||
| req.Header.Set("Icy-MetaData", "1") | |||||
| resp, err := s.client.Do(req) | resp, err := s.client.Do(req) | ||||
| if err != nil { | if err != nil { | ||||
| return fmt.Errorf("icecast connect: %w", err) | return fmt.Errorf("icecast connect: %w", err) | ||||
| @@ -218,8 +239,11 @@ func (s *Source) connectAndRun(ctx context.Context) error { | |||||
| s.connected.Store(true) | s.connected.Store(true) | ||||
| s.state.Store("buffering") | s.state.Store("buffering") | ||||
| s.lastError.Store("") | s.lastError.Store("") | ||||
| icyMetaInt, _ := parseICYMetaInt(resp.Header.Get("icy-metaint")) | |||||
| s.icyMetaInt.Store(int64(icyMetaInt)) | |||||
| stream := newICYReader(resp.Body, icyMetaInt, s.onMetadata) | |||||
| s.state.Store("running") | s.state.Store("running") | ||||
| return s.decodeWithPreference(ctx, resp.Body, decoder.StreamMeta{ | |||||
| return s.decodeWithPreference(ctx, stream, decoder.StreamMeta{ | |||||
| ContentType: resp.Header.Get("Content-Type"), | ContentType: resp.Header.Get("Content-Type"), | ||||
| SourceID: s.id, | SourceID: s.id, | ||||
| SampleRateHz: 44100, | SampleRateHz: 44100, | ||||
| @@ -227,6 +251,16 @@ func (s *Source) connectAndRun(ctx context.Context) error { | |||||
| }) | }) | ||||
| } | } | ||||
| func (s *Source) onMetadata(meta icyMetadata) { | |||||
| s.streamTitle.Store(meta.StreamTitle) | |||||
| s.metadataUpdates.Add(1) | |||||
| s.lastMetaAtUnix.Store(time.Now().UnixNano()) | |||||
| select { | |||||
| case s.title <- meta.StreamTitle: | |||||
| default: | |||||
| } | |||||
| } | |||||
| func (s *Source) emitChunk(chunk ingest.PCMChunk) error { | func (s *Source) emitChunk(chunk ingest.PCMChunk) error { | ||||
| select { | select { | ||||
| case s.chunks <- chunk: | case s.chunks <- chunk: | ||||
| @@ -311,6 +311,58 @@ func TestWithDecoderPreferenceFallbackAliasNormalizesToFFmpeg(t *testing.T) { | |||||
| } | } | ||||
| } | } | ||||
| 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 { | type scriptedLoopDecoder struct { | ||||
| mu sync.Mutex | mu sync.Mutex | ||||
| actions []decodeAction | actions []decodeAction | ||||
| @@ -13,6 +13,7 @@ type Runtime struct { | |||||
| sink *audio.StreamSource | sink *audio.StreamSource | ||||
| source Source | source Source | ||||
| started atomic.Bool | started atomic.Bool | ||||
| onTitle func(string) | |||||
| ctx context.Context | ctx context.Context | ||||
| cancel context.CancelFunc | cancel context.CancelFunc | ||||
| @@ -23,14 +24,28 @@ type Runtime struct { | |||||
| stats RuntimeStats | stats RuntimeStats | ||||
| } | } | ||||
| func NewRuntime(sink *audio.StreamSource, src Source) *Runtime { | |||||
| return &Runtime{ | |||||
| type RuntimeOption func(*Runtime) | |||||
| func WithStreamTitleHandler(handler func(string)) RuntimeOption { | |||||
| return func(r *Runtime) { | |||||
| r.onTitle = handler | |||||
| } | |||||
| } | |||||
| func NewRuntime(sink *audio.StreamSource, src Source, opts ...RuntimeOption) *Runtime { | |||||
| r := &Runtime{ | |||||
| sink: sink, | sink: sink, | ||||
| source: src, | source: src, | ||||
| stats: RuntimeStats{ | stats: RuntimeStats{ | ||||
| State: "idle", | State: "idle", | ||||
| }, | }, | ||||
| } | } | ||||
| for _, opt := range opts { | |||||
| if opt != nil { | |||||
| opt(r) | |||||
| } | |||||
| } | |||||
| return r | |||||
| } | } | ||||
| func (r *Runtime) Start(ctx context.Context) error { | func (r *Runtime) Start(ctx context.Context) error { | ||||
| @@ -93,6 +108,10 @@ func (r *Runtime) run() { | |||||
| ch := r.source.Chunks() | ch := r.source.Chunks() | ||||
| errCh := r.source.Errors() | errCh := r.source.Errors() | ||||
| var titleCh <-chan string | |||||
| if src, ok := r.source.(StreamTitleSource); ok && r.onTitle != nil { | |||||
| titleCh = src.StreamTitleUpdates() | |||||
| } | |||||
| for { | for { | ||||
| select { | select { | ||||
| case <-r.ctx.Done(): | case <-r.ctx.Done(): | ||||
| @@ -116,6 +135,12 @@ func (r *Runtime) run() { | |||||
| return | return | ||||
| } | } | ||||
| r.handleChunk(chunk) | r.handleChunk(chunk) | ||||
| case title, ok := <-titleCh: | |||||
| if !ok { | |||||
| titleCh = nil | |||||
| continue | |||||
| } | |||||
| r.onTitle(title) | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| @@ -14,6 +14,7 @@ type fakeSource struct { | |||||
| desc SourceDescriptor | desc SourceDescriptor | ||||
| chunks chan PCMChunk | chunks chan PCMChunk | ||||
| errs chan error | errs chan error | ||||
| title chan string | |||||
| stats SourceStats | stats SourceStats | ||||
| once sync.Once | once sync.Once | ||||
| } | } | ||||
| @@ -23,6 +24,7 @@ func newFakeSource() *fakeSource { | |||||
| desc: SourceDescriptor{ID: "fake", Kind: "stdin-pcm"}, | desc: SourceDescriptor{ID: "fake", Kind: "stdin-pcm"}, | ||||
| chunks: make(chan PCMChunk, 4), | chunks: make(chan PCMChunk, 4), | ||||
| errs: make(chan error, 1), | errs: make(chan error, 1), | ||||
| title: make(chan string, 4), | |||||
| stats: SourceStats{State: "running", Connected: true}, | stats: SourceStats{State: "running", Connected: true}, | ||||
| } | } | ||||
| } | } | ||||
| @@ -32,7 +34,10 @@ func (s *fakeSource) Start(context.Context) error { return nil } | |||||
| func (s *fakeSource) Stop() error { s.once.Do(func() { close(s.chunks) }); return nil } | func (s *fakeSource) Stop() error { s.once.Do(func() { close(s.chunks) }); return nil } | ||||
| func (s *fakeSource) Chunks() <-chan PCMChunk { return s.chunks } | func (s *fakeSource) Chunks() <-chan PCMChunk { return s.chunks } | ||||
| func (s *fakeSource) Errors() <-chan error { return s.errs } | func (s *fakeSource) Errors() <-chan error { return s.errs } | ||||
| func (s *fakeSource) Stats() SourceStats { return s.stats } | |||||
| func (s *fakeSource) StreamTitleUpdates() <-chan string { | |||||
| return s.title | |||||
| } | |||||
| func (s *fakeSource) Stats() SourceStats { return s.stats } | |||||
| func TestRuntimeWritesFramesToStreamSink(t *testing.T) { | func TestRuntimeWritesFramesToStreamSink(t *testing.T) { | ||||
| sink := audio.NewStreamSource(128, 44100) | sink := audio.NewStreamSource(128, 44100) | ||||
| @@ -159,6 +164,30 @@ func TestRuntimeStatsExposeActiveDescriptorAndSourceReconnectState(t *testing.T) | |||||
| } | } | ||||
| } | } | ||||
| func TestRuntimeForwardsStreamTitleUpdatesToHandler(t *testing.T) { | |||||
| sink := audio.NewStreamSource(128, 44100) | |||||
| src := newFakeSource() | |||||
| got := make(chan string, 1) | |||||
| rt := NewRuntime(sink, src, WithStreamTitleHandler(func(title string) { | |||||
| got <- title | |||||
| })) | |||||
| if err := rt.Start(context.Background()); err != nil { | |||||
| t.Fatalf("start: %v", err) | |||||
| } | |||||
| defer rt.Stop() | |||||
| src.title <- "Artist - Song" | |||||
| select { | |||||
| case title := <-got: | |||||
| if title != "Artist - Song" { | |||||
| t.Fatalf("title=%q want %q", title, "Artist - Song") | |||||
| } | |||||
| case <-time.After(1 * time.Second): | |||||
| t.Fatal("timed out waiting for forwarded title") | |||||
| } | |||||
| } | |||||
| func waitForRuntimeState(t *testing.T, rt *Runtime, want string) { | func waitForRuntimeState(t *testing.T, rt *Runtime, want string) { | ||||
| t.Helper() | t.Helper() | ||||
| deadline := time.Now().Add(1 * time.Second) | deadline := time.Now().Add(1 * time.Second) | ||||
| @@ -10,3 +10,9 @@ type Source interface { | |||||
| Errors() <-chan error | Errors() <-chan error | ||||
| Stats() SourceStats | Stats() SourceStats | ||||
| } | } | ||||
| // StreamTitleSource is an optional extension for sources that expose | |||||
| // title/metadata updates (for example ICY StreamTitle). | |||||
| type StreamTitleSource interface { | |||||
| StreamTitleUpdates() <-chan string | |||||
| } | |||||
| @@ -6,6 +6,10 @@ type SourceStats struct { | |||||
| State string `json:"state"` | State string `json:"state"` | ||||
| Connected bool `json:"connected"` | Connected bool `json:"connected"` | ||||
| LastChunkAt time.Time `json:"lastChunkAt,omitempty"` | LastChunkAt time.Time `json:"lastChunkAt,omitempty"` | ||||
| LastMetaAt time.Time `json:"lastMetaAt,omitempty"` | |||||
| StreamTitle string `json:"streamTitle,omitempty"` | |||||
| MetadataUpdates uint64 `json:"metadataUpdates,omitempty"` | |||||
| IcyMetaInt int `json:"icyMetaInt,omitempty"` | |||||
| ChunksIn uint64 `json:"chunksIn"` | ChunksIn uint64 `json:"chunksIn"` | ||||
| SamplesIn uint64 `json:"samplesIn"` | SamplesIn uint64 `json:"samplesIn"` | ||||
| BufferedSeconds float64 `json:"bufferedSeconds"` | BufferedSeconds float64 `json:"bufferedSeconds"` | ||||