49c2cc7983
go-librespot gained first-party WASAPI output for Windows in v0.9.0 (cliamp was pinned to v0.7.1, which doesn't compile on Windows). Bump the dependency and remove the Windows CGO stub so the real provider builds on all platforms. CI now installs a MinGW toolchain via MSYS2 and builds/tests with CGO_ENABLED=1 on windows-2025, including a workaround for an MSYS2 libogg packaging issue where libogg-0.dll's export table is missing ogg_stream_iovecin even though it's present in the static libogg.a. Verified locally end-to-end on Windows: native CGO build, full test suite, and real Spotify Premium playback. Fixes #299 Not in scope for this PR: release.yml still builds Windows with CGO_ENABLED=0, so Releases binaries won't include Spotify until that pipeline is updated separately (needs a packaging decision: bundle the MSYS2 DLLs or pursue a fully static build).
359 lines
8.7 KiB
Go
359 lines
8.7 KiB
Go
package spotify
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"runtime"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
librespotPlayer "github.com/devgianlu/go-librespot/player"
|
|
)
|
|
|
|
type closeErrorSource struct {
|
|
closeCalls int
|
|
err error
|
|
onClose func()
|
|
}
|
|
|
|
func (*closeErrorSource) Read([]float32) (int, error) { return 0, nil }
|
|
func (*closeErrorSource) SetPositionMs(int64) error { return nil }
|
|
func (*closeErrorSource) PositionMs() int64 { return 0 }
|
|
func (s *closeErrorSource) Close() error {
|
|
s.closeCalls++
|
|
if s.onClose != nil {
|
|
s.onClose()
|
|
}
|
|
return s.err
|
|
}
|
|
|
|
type closeSource struct {
|
|
closeCalls int
|
|
}
|
|
|
|
func (*closeSource) Read([]float32) (int, error) { return 0, nil }
|
|
func (*closeSource) SetPositionMs(int64) error { return nil }
|
|
func (*closeSource) PositionMs() int64 { return 0 }
|
|
func (s *closeSource) Close() { s.closeCalls++ }
|
|
|
|
type noCloseSource struct{}
|
|
|
|
func (*noCloseSource) Read([]float32) (int, error) { return 0, nil }
|
|
func (*noCloseSource) SetPositionMs(int64) error { return nil }
|
|
func (*noCloseSource) PositionMs() int64 { return 0 }
|
|
|
|
func TestSpotifyStreamerCloseReleasesReferencesWithoutClosingDecoder(t *testing.T) {
|
|
source := &closeErrorSource{err: errors.New("close source")}
|
|
stream := &librespotPlayer.Stream{Source: source}
|
|
s := newSpotifyStreamer(stream, nil)
|
|
s.buf = make([]float32, 16)
|
|
|
|
if err := s.Close(); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
if err := s.Close(); err != nil {
|
|
t.Fatalf("second Close() error = %v", err)
|
|
}
|
|
if source.closeCalls != 0 {
|
|
t.Fatalf("source Close calls = %d, want 0", source.closeCalls)
|
|
}
|
|
if s.source != nil || s.buf != nil {
|
|
t.Fatalf("Close() retained resources: source=%v buf=%v", s.source, s.buf)
|
|
}
|
|
}
|
|
|
|
func TestSpotifyStreamerCloseWithoutCloseMethod(t *testing.T) {
|
|
source := &closeSource{}
|
|
stream := &librespotPlayer.Stream{Source: source}
|
|
s := newSpotifyStreamer(stream, nil)
|
|
s.buf = make([]float32, 16)
|
|
|
|
if err := s.Close(); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
if err := s.Close(); err != nil {
|
|
t.Fatalf("second Close() error = %v", err)
|
|
}
|
|
if source.closeCalls != 0 {
|
|
t.Fatalf("source Close calls = %d, want 0", source.closeCalls)
|
|
}
|
|
if s.source != nil || s.buf != nil {
|
|
t.Fatalf("Close() retained resources: source=%v buf=%v", s.source, s.buf)
|
|
}
|
|
}
|
|
|
|
func TestSpotifyStreamerCloseWithNonClosableSource(t *testing.T) {
|
|
s := newSpotifyStreamer(&librespotPlayer.Stream{Source: &noCloseSource{}}, nil)
|
|
s.buf = make([]float32, 16)
|
|
|
|
if err := s.Close(); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
if s.source != nil || s.buf != nil {
|
|
t.Fatalf("Close() retained resources: source=%v buf=%v", s.source, s.buf)
|
|
}
|
|
}
|
|
|
|
func TestSpotifyStreamerCloseConcurrent(t *testing.T) {
|
|
source := &closeErrorSource{err: errors.New("close source")}
|
|
s := newSpotifyStreamer(&librespotPlayer.Stream{Source: source}, nil)
|
|
|
|
const callers = 16
|
|
errs := make(chan error, callers)
|
|
var wg sync.WaitGroup
|
|
for range callers {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
errs <- s.Close()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(errs)
|
|
|
|
for err := range errs {
|
|
if err != nil {
|
|
t.Errorf("Close() error = %v, want nil", err)
|
|
}
|
|
}
|
|
if source.closeCalls != 0 {
|
|
t.Fatalf("source Close calls = %d, want 0", source.closeCalls)
|
|
}
|
|
}
|
|
|
|
func TestSpotifyStreamerCloseCancelsTransport(t *testing.T) {
|
|
requestStarted := make(chan struct{})
|
|
requestDone := make(chan error, 1)
|
|
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
close(requestStarted)
|
|
<-req.Context().Done()
|
|
return nil, req.Context().Err()
|
|
})
|
|
streamCtx, cancel := context.WithCancel(context.Background())
|
|
client := newSpotifyStreamHTTPClient(streamCtx, transport)
|
|
|
|
source := &closeErrorSource{}
|
|
s := newSpotifyStreamer(&librespotPlayer.Stream{Source: source}, cancel)
|
|
|
|
go func() {
|
|
req, err := http.NewRequest(http.MethodGet, "https://audio.example/chunk", nil)
|
|
if err != nil {
|
|
requestDone <- err
|
|
return
|
|
}
|
|
resp, err := client.Do(req)
|
|
if resp != nil {
|
|
_ = resp.Body.Close()
|
|
}
|
|
requestDone <- err
|
|
}()
|
|
|
|
select {
|
|
case <-requestStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("transport request did not start")
|
|
}
|
|
if err := s.Close(); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
if !errors.Is(streamCtx.Err(), context.Canceled) {
|
|
t.Fatal("stream context was not canceled")
|
|
}
|
|
|
|
select {
|
|
case err := <-requestDone:
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("transport error = %v, want context.Canceled", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("transport request was not canceled")
|
|
}
|
|
}
|
|
|
|
type blockingSource struct {
|
|
readStarted chan struct{}
|
|
releaseRead chan struct{}
|
|
closeCalls int
|
|
position int64
|
|
}
|
|
|
|
func (s *blockingSource) Read(p []float32) (int, error) {
|
|
close(s.readStarted)
|
|
<-s.releaseRead
|
|
for i := range p {
|
|
p[i] = float32(i)
|
|
}
|
|
s.position++
|
|
return len(p), nil
|
|
}
|
|
|
|
func (s *blockingSource) SetPositionMs(position int64) error {
|
|
s.position = position
|
|
return nil
|
|
}
|
|
|
|
func (s *blockingSource) PositionMs() int64 { return s.position }
|
|
|
|
func (s *blockingSource) Close() error {
|
|
s.closeCalls++
|
|
return nil
|
|
}
|
|
|
|
func TestSpotifyStreamerCloseConcurrentOperations(t *testing.T) {
|
|
source := &blockingSource{
|
|
readStarted: make(chan struct{}),
|
|
releaseRead: make(chan struct{}),
|
|
}
|
|
canceled := make(chan struct{})
|
|
s := newSpotifyStreamer(&librespotPlayer.Stream{Source: source}, func() { close(canceled) })
|
|
|
|
streamDone := make(chan struct{})
|
|
go func() {
|
|
defer close(streamDone)
|
|
s.Stream(make([][2]float64, 8))
|
|
}()
|
|
<-source.readStarted
|
|
|
|
closeDone := make(chan error, 1)
|
|
go func() { closeDone <- s.Close() }()
|
|
<-canceled
|
|
|
|
positionDone := make(chan int, 1)
|
|
seekDone := make(chan error, 1)
|
|
go func() { positionDone <- s.Position() }()
|
|
go func() { seekDone <- s.Seek(spotifySampleRate) }()
|
|
|
|
select {
|
|
case err := <-closeDone:
|
|
t.Fatalf("Close() returned before decoder read completed: %v", err)
|
|
default:
|
|
}
|
|
close(source.releaseRead)
|
|
<-streamDone
|
|
if err := <-closeDone; err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
if position := <-positionDone; position != 0 {
|
|
t.Errorf("Position() after close request = %d, want 0", position)
|
|
}
|
|
if err := <-seekDone; !errors.Is(err, net.ErrClosed) {
|
|
t.Errorf("Seek() after close request error = %v, want net.ErrClosed", err)
|
|
}
|
|
|
|
if n, ok := s.Stream(make([][2]float64, 1)); n != 0 || ok {
|
|
t.Errorf("Stream() after Close() = (%d, %v), want (0, false)", n, ok)
|
|
}
|
|
if err := s.Err(); err != nil {
|
|
t.Errorf("Err() after Close() = %v, want nil", err)
|
|
}
|
|
if position := s.Position(); position != 0 {
|
|
t.Errorf("Position() after Close() = %d, want 0", position)
|
|
}
|
|
if err := s.Seek(0); !errors.Is(err, net.ErrClosed) {
|
|
t.Errorf("Seek() after Close() error = %v, want net.ErrClosed", err)
|
|
}
|
|
if err := s.Close(); err != nil {
|
|
t.Errorf("second Close() error = %v", err)
|
|
}
|
|
if source.closeCalls != 0 {
|
|
t.Errorf("source Close calls = %d, want 0", source.closeCalls)
|
|
}
|
|
}
|
|
|
|
type racingSource struct {
|
|
value int64
|
|
closed bool
|
|
}
|
|
|
|
func (s *racingSource) Read(p []float32) (int, error) {
|
|
if s.closed {
|
|
return 0, net.ErrClosed
|
|
}
|
|
s.value++
|
|
runtime.Gosched()
|
|
return len(p), nil
|
|
}
|
|
|
|
func (s *racingSource) SetPositionMs(position int64) error {
|
|
if s.closed {
|
|
return net.ErrClosed
|
|
}
|
|
s.value = position
|
|
runtime.Gosched()
|
|
return nil
|
|
}
|
|
|
|
func (s *racingSource) PositionMs() int64 {
|
|
runtime.Gosched()
|
|
return s.value
|
|
}
|
|
|
|
func (s *racingSource) Close() error {
|
|
s.closed = true
|
|
return nil
|
|
}
|
|
|
|
func TestSpotifyStreamerRaceCloseAndOperations(t *testing.T) {
|
|
s := newSpotifyStreamer(&librespotPlayer.Stream{Source: &racingSource{}}, nil)
|
|
start := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
|
|
for range 4 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
for range 100 {
|
|
s.Stream(make([][2]float64, 4))
|
|
}
|
|
}()
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
for range 100 {
|
|
s.Position()
|
|
_ = s.Seek(spotifySampleRate)
|
|
_ = s.Err()
|
|
}
|
|
}()
|
|
}
|
|
|
|
close(start)
|
|
runtime.Gosched()
|
|
if err := s.Close(); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
type fullSource struct{}
|
|
|
|
func (*fullSource) Read(p []float32) (int, error) {
|
|
for i := range p {
|
|
p[i] = float32(i)
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func (*fullSource) SetPositionMs(int64) error { return nil }
|
|
func (*fullSource) PositionMs() int64 { return 0 }
|
|
|
|
func TestSpotifyStreamerStreamAllocations(t *testing.T) {
|
|
const sampleCount = 256
|
|
s := newSpotifyStreamer(&librespotPlayer.Stream{Source: &fullSource{}}, nil)
|
|
s.buf = make([]float32, sampleCount*spotifyChannels)
|
|
samples := make([][2]float64, sampleCount)
|
|
|
|
if allocs := testing.AllocsPerRun(100, func() {
|
|
if n, ok := s.Stream(samples); n != sampleCount || !ok {
|
|
panic("unexpected stream result")
|
|
}
|
|
}); allocs != 0 {
|
|
t.Errorf("Stream() allocations = %v, want 0", allocs)
|
|
}
|
|
}
|