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

233 lines
6.7 KiB
Go

package player
import (
"context"
"fmt"
"io"
"net/http"
"sync"
"sync/atomic"
"time"
)
// readStallTimeout is how long Read/Seek wait for new data before returning
// an error. The deadline resets whenever the download goroutine delivers new
// bytes, so slow-but-progressing downloads are not affected. Only a true
// stall (no new data for this duration) triggers the timeout.
const readStallTimeout = 5 * time.Second
// navBuffer is an io.ReadSeekCloser backed by a background HTTP download.
// Bytes are appended to data as they arrive. Read and Seek block via cond
// if the requested position has not yet been downloaded.
//
// Lock ordering: navBuffer.mu is a leaf lock. It must never be acquired
// while holding speaker.Lock() or player.mu.
type navBuffer struct {
mu sync.Mutex
cond *sync.Cond
data []byte // all bytes downloaded so far
total int64 // Content-Length from HTTP response (-1 if unknown)
pos int64 // current read cursor
done bool // true when download goroutine has finished
err error // first error from download goroutine
cancel context.CancelFunc
contentType string // HTTP Content-Type header value
bytesIn atomic.Int64 // mirrors len(data); safe for unsynchronised UI reads
}
// newNavBuffer opens an HTTP GET request for rawURL, starts downloading in a
// background goroutine, and returns immediately. The caller may begin reading
// from the buffer before the download completes.
//
// Returns (buffer, contentLength, error). contentLength is -1 when the server
// does not send a Content-Length header (e.g. chunked transfer encoding).
func newNavBuffer(rawURL string) (*navBuffer, int64, error) {
ctx, cancel := context.WithCancel(context.Background())
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
if err != nil {
cancel()
return nil, 0, fmt.Errorf("nav buffer request: %w", err)
}
req.Header.Set("User-Agent", "cliamp/1.0 (https://github.com/bjarneo/cliamp)")
resp, err := httpClient.Do(req)
if err != nil {
cancel()
return nil, 0, fmt.Errorf("nav buffer connect: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
cancel()
return nil, 0, fmt.Errorf("nav buffer: http status %s", resp.Status)
}
b := &navBuffer{
total: resp.ContentLength, // -1 if unknown
cancel: cancel,
contentType: resp.Header.Get("Content-Type"),
}
b.cond = sync.NewCond(&b.mu)
go b.download(resp.Body)
return b, resp.ContentLength, nil
}
// download reads the HTTP response body in chunks and appends to data.
// Broadcasts on cond after each chunk so blocked Read/Seek calls wake up.
func (b *navBuffer) download(body io.ReadCloser) {
defer body.Close()
chunk := make([]byte, 32*1024)
for {
n, err := body.Read(chunk)
if n > 0 {
b.mu.Lock()
b.data = append(b.data, chunk[:n]...)
b.mu.Unlock()
b.bytesIn.Add(int64(n))
b.cond.Broadcast()
}
if err == io.EOF {
break
}
if err != nil {
b.mu.Lock()
b.err = err
b.mu.Unlock()
b.cond.Broadcast()
return
}
}
b.mu.Lock()
b.done = true
b.mu.Unlock()
b.cond.Broadcast()
}
// Read implements io.Reader.
// Blocks until at least one byte at pos is available or the download ends.
// Returns an error if the download stalls (no new data for readStallTimeout).
func (b *navBuffer) Read(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
lastLen := int64(len(b.data))
deadline := time.Now().Add(readStallTimeout)
for int64(len(b.data)) <= b.pos && !b.done {
if b.err != nil {
return 0, b.err
}
// Reset deadline when new data arrives (download is progressing).
if curLen := int64(len(b.data)); curLen > lastLen {
lastLen = curLen
deadline = time.Now().Add(readStallTimeout)
} else if time.Now().After(deadline) {
return 0, fmt.Errorf("nav buffer: read stalled waiting for data")
}
// sync.Cond has no timeout support. Schedule a broadcast so this
// goroutine wakes periodically to check the deadline.
t := time.AfterFunc(250*time.Millisecond, func() { b.cond.Broadcast() })
b.cond.Wait()
t.Stop()
}
if b.err != nil && int64(len(b.data)) <= b.pos {
return 0, b.err
}
if int64(len(b.data)) <= b.pos {
return 0, io.EOF
}
n := copy(p, b.data[b.pos:])
b.pos += int64(n)
return n, nil
}
// Seek implements io.Seeker.
// If the target position is beyond what has been downloaded, blocks until
// the download reaches it. Returns an error if the download stalls.
func (b *navBuffer) Seek(offset int64, whence int) (int64, error) {
b.mu.Lock()
defer b.mu.Unlock()
var target int64
switch whence {
case io.SeekStart:
target = offset
case io.SeekCurrent:
target = b.pos + offset
case io.SeekEnd:
// If Content-Length is known, use it immediately — no need to wait for
// the full download. This is the common case with format=raw, and avoids
// blocking the FLAC decoder which seeks to SeekEnd during header parsing
// just to determine the file size.
if b.total >= 0 {
target = b.total + offset
} else {
// Content-Length unknown (chunked): must wait for the full download.
lastLen := int64(len(b.data))
deadline := time.Now().Add(readStallTimeout)
for !b.done {
if b.err != nil {
return 0, b.err
}
if curLen := int64(len(b.data)); curLen > lastLen {
lastLen = curLen
deadline = time.Now().Add(readStallTimeout)
} else if time.Now().After(deadline) {
return 0, fmt.Errorf("nav buffer: seek stalled waiting for download")
}
t := time.AfterFunc(250*time.Millisecond, func() { b.cond.Broadcast() })
b.cond.Wait()
t.Stop()
}
target = int64(len(b.data)) + offset
}
default:
return 0, fmt.Errorf("nav buffer: invalid whence %d", whence)
}
if target < 0 {
target = 0
}
// Block until the buffer reaches the target position.
lastLen := int64(len(b.data))
deadline := time.Now().Add(readStallTimeout)
for int64(len(b.data)) < target && !b.done {
if b.err != nil {
return 0, b.err
}
if curLen := int64(len(b.data)); curLen > lastLen {
lastLen = curLen
deadline = time.Now().Add(readStallTimeout)
} else if time.Now().After(deadline) {
return 0, fmt.Errorf("nav buffer: seek stalled waiting for data at offset %d", target)
}
t := time.AfterFunc(250*time.Millisecond, func() { b.cond.Broadcast() })
b.cond.Wait()
t.Stop()
}
if target > int64(len(b.data)) {
target = int64(len(b.data))
}
b.pos = target
return b.pos, nil
}
// Close cancels the download goroutine and unblocks all waiters.
func (b *navBuffer) Close() error {
b.cancel()
b.mu.Lock()
if !b.done {
b.done = true
b.err = fmt.Errorf("nav buffer: closed")
}
b.mu.Unlock()
b.cond.Broadcast()
return nil
}
// ContentType returns the HTTP Content-Type from the server response.
func (b *navBuffer) ContentType() string {
return b.contentType
}