Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

167 lines
4.4 KiB
Go

package player
import (
"bytes"
"io"
"os/exec"
"strings"
"testing"
"time"
"github.com/gopxl/beep/v2"
)
func TestParseStreamTitle(t *testing.T) {
tests := []struct {
name string
meta string
want string
}{
{"artist and title", "StreamTitle='Daft Punk - Aerodynamic';StreamUrl='';", "Daft Punk - Aerodynamic"},
{"title only", "StreamTitle='Some Show';", "Some Show"},
{"empty title", "StreamTitle='';StreamUrl='';", ""},
{"no stream title key", "StreamUrl='https://example.com';", ""},
{"empty block", "", ""},
{"missing trailing semicolon", "StreamTitle='No Semicolon'", "No Semicolon"},
{"title containing semicolon", "StreamTitle='A; B - C';StreamUrl='';", "A; B - C"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseStreamTitle(tt.meta); got != tt.want {
t.Errorf("parseStreamTitle(%q) = %q, want %q", tt.meta, got, tt.want)
}
})
}
}
// icyBlock encodes one metadata block: a 1-byte length prefix (size/16) followed
// by the null-padded metadata, matching the SHOUTcast/Icecast wire format.
func icyBlock(meta string) []byte {
if meta == "" {
return []byte{0}
}
n := (len(meta) + 15) / 16
out := make([]byte, 1+n*16)
out[0] = byte(n)
copy(out[1:], meta)
return out
}
func TestIcyReaderStripsMetadataAndReportsTitles(t *testing.T) {
const metaInt = 8
var raw bytes.Buffer
raw.WriteString("AAAAAAAA") // audio block 1
raw.Write(icyBlock("StreamTitle='Song One';"))
raw.WriteString("BBBBBBBB") // audio block 2
raw.Write(icyBlock("")) // empty metadata block (no change)
raw.WriteString("CCCCCCCC") // audio block 3
raw.Write(icyBlock("StreamTitle='Song Two';"))
raw.WriteString("DDDDDDDD") // audio block 4 (partial, no trailing meta)
var titles []string
r := newIcyReader(io.NopCloser(&raw), metaInt, func(s string) {
titles = append(titles, s)
})
// Read in small chunks to exercise the metaint-boundary clamping.
got, err := io.ReadAll(&chunkedReader{r: r, n: 3})
if err != nil {
t.Fatalf("ReadAll: %v", err)
}
const wantAudio = "AAAAAAAABBBBBBBBCCCCCCCCDDDDDDDD"
if string(got) != wantAudio {
t.Errorf("audio = %q, want %q", got, wantAudio)
}
wantTitles := []string{"Song One", "Song Two"}
if len(titles) != len(wantTitles) {
t.Fatalf("titles = %v, want %v", titles, wantTitles)
}
for i, w := range wantTitles {
if titles[i] != w {
t.Errorf("titles[%d] = %q, want %q", i, titles[i], w)
}
}
}
// chunkedReader caps each Read at n bytes so tests can drive a reader with many
// small reads, exercising boundary handling.
type chunkedReader struct {
r io.Reader
n int
}
func (c *chunkedReader) Read(p []byte) (int, error) {
if len(p) > c.n {
p = p[:c.n]
}
return c.r.Read(p)
}
// TestFFmpegPipeStreamCloseUnblocks verifies that closing the stdin-fed ffmpeg
// streamer does not hang when its source is a live stream parked in Read. os/exec
// copies src -> ffmpeg stdin in a goroutine that cmd.Wait() joins, so Close must
// close src first to unblock it. Regression guard for the AAC ICY fix.
func TestFFmpegPipeStreamCloseUnblocks(t *testing.T) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not installed")
}
// io.Pipe with no writer simulates an open-but-idle radio body: Read blocks.
pr, pw := io.Pipe()
t.Cleanup(func() { pw.Close() })
dec, _, err := decodeFFmpegPipeStream(pr, beep.SampleRate(44100), 16)
if err != nil {
t.Fatalf("decodeFFmpegPipeStream: %v", err)
}
done := make(chan struct{})
go func() {
dec.Close()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("Close() hung: stdin-copy goroutine was not unblocked")
}
}
func TestFFmpegPipeStreamInitialAudioTimeoutCloses(t *testing.T) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not installed")
}
pr, pw := io.Pipe()
t.Cleanup(func() { pw.Close() })
dec, _, err := decodeFFmpegPipeStream(pr, beep.SampleRate(44100), 16)
if err != nil {
t.Fatalf("decodeFFmpegPipeStream: %v", err)
}
err = dec.waitForInitialAudio(100 * time.Millisecond)
if err == nil {
t.Fatal("waitForInitialAudio returned nil, want timeout")
}
if !strings.Contains(err.Error(), "timed out waiting for audio data") {
t.Fatalf("waitForInitialAudio error = %v, want timeout", err)
}
done := make(chan struct{})
go func() {
dec.Close()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("Close() hung after initial audio timeout")
}
}