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 } // parseICYMetadata parses the ICY inline metadata block. // // ICY metadata is a semicolon-delimited key=value format where values are // single-quoted strings. A naive strings.Split(raw, ";") breaks when the // StreamTitle itself contains semicolons (e.g. "Artist - Title; Live Edit"). // This parser is quote-aware: it only splits on semicolons that appear // outside of single-quoted value strings. func parseICYMetadata(block []byte) icyMetadata { raw := strings.TrimRight(string(bytes.Trim(block, "\x00")), "\x00") meta := icyMetadata{} fields := splitICYFields(raw) for _, field := range fields { field = strings.TrimSpace(field) if !strings.HasPrefix(field, "StreamTitle=") { continue } v := strings.TrimPrefix(field, "StreamTitle=") v = strings.TrimSpace(v) // Strip enclosing single or double quotes. if len(v) >= 2 { if (v[0] == '\'' && v[len(v)-1] == '\'') || (v[0] == '"' && v[len(v)-1] == '"') { v = v[1 : len(v)-1] } } meta.StreamTitle = v break } return meta } // splitICYFields splits an ICY metadata string on semicolons that appear // outside of single-quoted value strings. Semicolons inside quotes (e.g. // StreamTitle='Artist - Song; Live';) are preserved as part of the value. func splitICYFields(s string) []string { var fields []string inQuote := false start := 0 for i := 0; i < len(s); i++ { c := s[i] if c == '\'' { inQuote = !inQuote } if c == ';' && !inQuote { fields = append(fields, s[start:i]) start = i + 1 } } if start < len(s) { fields = append(fields, s[start:]) } return fields } 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 }