feat(stream): trackingReader with byte-based progress and rate limiting
Replace Range-header-based progress tracking with a trackingReader that measures actual bytes read per connection. This gives accurate playback position even for local/NAS files where VLC buffers aggressively. - Token bucket rate limiter at 2x video bitrate (from ffprobe) - CAS loops for lock-free atomic progress updates without regression - probeMediaInfo extracts bitrate + duration via ffprobe (3s timeout) - Defense-in-depth: only probe regular files, reject FIFOs/devices - Remove dead parseRangeStart function - Consistent [stream] log prefix
This commit is contained in:
parent
64734cad1f
commit
2dfe144df1
1 changed files with 227 additions and 41 deletions
|
|
@ -53,8 +53,12 @@ type StreamServer struct {
|
||||||
disableUPnP bool
|
disableUPnP bool
|
||||||
|
|
||||||
lastActivity atomic.Int64
|
lastActivity atomic.Int64
|
||||||
maxByteOffset atomic.Int64
|
maxByteOffset atomic.Int64 // highest sequential read position (main playback connection)
|
||||||
totalFileSize atomic.Int64
|
totalFileSize atomic.Int64
|
||||||
|
bitrateBps atomic.Int64 // video bitrate in bits/sec (from ffprobe, 0 = unknown)
|
||||||
|
durationSec atomic.Int64 // video duration in seconds (from ffprobe, 0 = unknown)
|
||||||
|
topReaderID atomic.Int64 // ID of the reader that set maxByteOffset (only it can advance it)
|
||||||
|
readerCounter atomic.Int64 // monotonic counter for assigning reader IDs
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStreamServer creates a stream server bound to the given port.
|
// NewStreamServer creates a stream server bound to the given port.
|
||||||
|
|
@ -153,6 +157,23 @@ func (ss *StreamServer) SetFile(provider FileProvider, taskID string) {
|
||||||
ss.totalFileSize.Store(provider.FileSize())
|
ss.totalFileSize.Store(provider.FileSize())
|
||||||
ss.lastActivity.Store(time.Now().UnixNano())
|
ss.lastActivity.Store(time.Now().UnixNano())
|
||||||
ss.maxByteOffset.Store(0)
|
ss.maxByteOffset.Store(0)
|
||||||
|
ss.topReaderID.Store(0)
|
||||||
|
ss.bitrateBps.Store(0)
|
||||||
|
ss.durationSec.Store(0)
|
||||||
|
|
||||||
|
// Probe bitrate + duration synchronously so rate-limiting and duration
|
||||||
|
// are available before the first HTTP request arrives.
|
||||||
|
if dp, ok := provider.(*diskFileProvider); ok {
|
||||||
|
pm := probeMediaInfo(dp.path)
|
||||||
|
if pm.bitrateBps > 0 {
|
||||||
|
ss.bitrateBps.Store(pm.bitrateBps)
|
||||||
|
log.Printf("[stream] detected bitrate: %.1f Mbps → throttle at %.1f Mbps",
|
||||||
|
float64(pm.bitrateBps)/1e6, float64(pm.bitrateBps)*2/1e6)
|
||||||
|
}
|
||||||
|
if pm.durationSec > 0 {
|
||||||
|
ss.durationSec.Store(pm.durationSec)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearFile stops serving any file. Subsequent requests return 404.
|
// ClearFile stops serving any file. Subsequent requests return 404.
|
||||||
|
|
@ -163,6 +184,9 @@ func (ss *StreamServer) ClearFile() {
|
||||||
ss.mu.Unlock()
|
ss.mu.Unlock()
|
||||||
ss.totalFileSize.Store(0)
|
ss.totalFileSize.Store(0)
|
||||||
ss.maxByteOffset.Store(0)
|
ss.maxByteOffset.Store(0)
|
||||||
|
ss.topReaderID.Store(0)
|
||||||
|
ss.bitrateBps.Store(0)
|
||||||
|
ss.durationSec.Store(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CurrentTaskID returns the task ID of the file currently being served.
|
// CurrentTaskID returns the task ID of the file currently being served.
|
||||||
|
|
@ -213,18 +237,6 @@ func (ss *StreamServer) Shutdown(ctx context.Context) error {
|
||||||
func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
|
func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
|
||||||
ss.lastActivity.Store(time.Now().UnixNano())
|
ss.lastActivity.Store(time.Now().UnixNano())
|
||||||
|
|
||||||
// Track Range header for watch progress estimation
|
|
||||||
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
|
|
||||||
if start := parseRangeStart(rangeHeader); start >= 0 {
|
|
||||||
for {
|
|
||||||
cur := ss.maxByteOffset.Load()
|
|
||||||
if start <= cur || ss.maxByteOffset.CompareAndSwap(cur, start) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current provider (may be nil if no file is being served)
|
// Get current provider (may be nil if no file is being served)
|
||||||
ss.mu.RLock()
|
ss.mu.RLock()
|
||||||
provider := ss.provider
|
provider := ss.provider
|
||||||
|
|
@ -248,12 +260,34 @@ func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reader := provider.NewFileReader(r.Context())
|
rawReader := provider.NewFileReader(r.Context())
|
||||||
if reader == nil {
|
if rawReader == nil {
|
||||||
http.Error(w, "file not found", http.StatusNotFound)
|
http.Error(w, "file not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer reader.Close()
|
defer rawReader.Close()
|
||||||
|
|
||||||
|
// Wrap reader to track bytes read for progress estimation + rate limit.
|
||||||
|
// Rate limiting at ~2x bitrate ensures VLC can't download far ahead of
|
||||||
|
// playback, so bytes-read ≈ playback position (like Netflix/YouTube).
|
||||||
|
bps := ss.bitrateBps.Load()
|
||||||
|
var bytesPerSec int64
|
||||||
|
if bps > 0 {
|
||||||
|
bytesPerSec = bps / 8 * 2 // 2x bitrate in bytes/sec
|
||||||
|
}
|
||||||
|
var burstSize int64
|
||||||
|
if bytesPerSec > 0 {
|
||||||
|
burstSize = bytesPerSec * 30
|
||||||
|
}
|
||||||
|
reader := &trackingReader{
|
||||||
|
inner: rawReader,
|
||||||
|
server: ss,
|
||||||
|
id: ss.readerCounter.Add(1),
|
||||||
|
bytesPerSec: bytesPerSec,
|
||||||
|
burstSize: burstSize,
|
||||||
|
tokens: burstSize,
|
||||||
|
lastFill: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", mimeTypeFromExt(provider.FileName()))
|
w.Header().Set("Content-Type", mimeTypeFromExt(provider.FileName()))
|
||||||
// "inline" for play requests (VLC/mpv), "attachment" for download requests.
|
// "inline" for play requests (VLC/mpv), "attachment" for download requests.
|
||||||
|
|
@ -272,35 +306,19 @@ func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
|
||||||
http.ServeContent(w, r, provider.FileName(), time.Time{}, reader)
|
http.ServeContent(w, r, provider.FileName(), time.Time{}, reader)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EstimatedProgress returns an estimated watch progress based on HTTP Range requests.
|
// EstimatedProgress returns estimated watch progress percentage (0-100)
|
||||||
func (ss *StreamServer) EstimatedProgress() (position int, duration int) {
|
// and the total duration in seconds (0 if unknown).
|
||||||
|
func (ss *StreamServer) EstimatedProgress() (pct int, durationSec int) {
|
||||||
total := ss.totalFileSize.Load()
|
total := ss.totalFileSize.Load()
|
||||||
if total <= 0 {
|
if total <= 0 {
|
||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
maxOffset := ss.maxByteOffset.Load()
|
maxOffset := ss.maxByteOffset.Load()
|
||||||
pct := int(float64(maxOffset) / float64(total) * 100)
|
p := int(float64(maxOffset) / float64(total) * 100)
|
||||||
if pct > 100 {
|
if p > 100 {
|
||||||
pct = 100
|
p = 100
|
||||||
}
|
}
|
||||||
return pct, 100
|
return p, int(ss.durationSec.Load())
|
||||||
}
|
|
||||||
|
|
||||||
// parseRangeStart extracts the start byte from a "Range: bytes=START-" header.
|
|
||||||
func parseRangeStart(rangeHeader string) int64 {
|
|
||||||
after, found := strings.CutPrefix(rangeHeader, "bytes=")
|
|
||||||
if !found {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
dashIdx := strings.IndexByte(after, '-')
|
|
||||||
if dashIdx < 0 {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
start, err := strconv.ParseInt(after[:dashIdx], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
return start
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- File Providers ---
|
// --- File Providers ---
|
||||||
|
|
@ -322,7 +340,7 @@ type diskFileProvider struct {
|
||||||
func (p *diskFileProvider) NewFileReader(_ context.Context) io.ReadSeekCloser {
|
func (p *diskFileProvider) NewFileReader(_ context.Context) io.ReadSeekCloser {
|
||||||
f, err := os.Open(p.path)
|
f, err := os.Open(p.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("stream: failed to open %q: %v", p.path, err)
|
log.Printf("[stream] failed to open %q: %v", p.path, err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return f
|
return f
|
||||||
|
|
@ -333,7 +351,7 @@ func (p *diskFileProvider) FileName() string { return p.name }
|
||||||
func (p *diskFileProvider) FileSize() int64 {
|
func (p *diskFileProvider) FileSize() int64 {
|
||||||
fi, err := os.Stat(p.path)
|
fi, err := os.Stat(p.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("stream: failed to stat %q: %v", p.path, err)
|
log.Printf("[stream] failed to stat %q: %v", p.path, err)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return fi.Size()
|
return fi.Size()
|
||||||
|
|
@ -416,6 +434,174 @@ func TailscaleIP() string {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trackingReader wraps an io.ReadSeekCloser with:
|
||||||
|
// - Progress tracking: atomically updates maxByteOffset on Read (not Seek).
|
||||||
|
// - Rate limiting: token bucket throttle at ~2x video bitrate so that
|
||||||
|
// bytes-read ≈ playback position. Without this, local/NAS files get
|
||||||
|
// downloaded instantly and progress jumps to 100%.
|
||||||
|
//
|
||||||
|
// Rate limiting happens AFTER each Read (sleep to pace), never before.
|
||||||
|
// This ensures the client always receives data and never times out.
|
||||||
|
type trackingReader struct {
|
||||||
|
inner io.ReadSeekCloser
|
||||||
|
server *StreamServer
|
||||||
|
id int64 // unique ID for this reader
|
||||||
|
pos int64 // current read position
|
||||||
|
bytesRead int64 // total bytes read by THIS connection (measures sequential progress)
|
||||||
|
bytesPerSec int64 // 0 = unlimited (remote/torrent), >0 = throttled (local disk)
|
||||||
|
|
||||||
|
// Token bucket state
|
||||||
|
tokens int64 // available bytes to serve (can go negative = we're ahead)
|
||||||
|
lastFill time.Time // last time tokens were replenished
|
||||||
|
burstSize int64 // max token accumulation (caps how far ahead VLC can buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *trackingReader) Read(p []byte) (int, error) {
|
||||||
|
// Always read immediately — never block before serving data to the client.
|
||||||
|
n, err := t.inner.Read(p)
|
||||||
|
if n > 0 {
|
||||||
|
t.pos += int64(n)
|
||||||
|
t.bytesRead += int64(n)
|
||||||
|
|
||||||
|
// Only the reader that has read the most bytes can update progress.
|
||||||
|
// This prevents VLC's metadata/index requests (which read near EOF)
|
||||||
|
// from inflating progress to 100%.
|
||||||
|
if t.server.topReaderID.Load() == t.id {
|
||||||
|
// We own the progress — advance it (never regress)
|
||||||
|
for {
|
||||||
|
cur := t.server.maxByteOffset.Load()
|
||||||
|
if t.pos <= cur || t.server.maxByteOffset.CompareAndSwap(cur, t.pos) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try to take over if we've read more than the current progress.
|
||||||
|
// CAS loop prevents two goroutines from interleaving their stores.
|
||||||
|
for {
|
||||||
|
cur := t.server.maxByteOffset.Load()
|
||||||
|
if t.bytesRead <= cur {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if t.server.maxByteOffset.CompareAndSwap(cur, t.pos) {
|
||||||
|
t.server.topReaderID.Store(t.id)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit: sleep AFTER read to pace throughput.
|
||||||
|
if t.bytesPerSec > 0 {
|
||||||
|
t.fillTokens()
|
||||||
|
t.tokens -= int64(n)
|
||||||
|
if t.tokens < 0 {
|
||||||
|
deficit := -t.tokens
|
||||||
|
sleepNs := (deficit * int64(time.Second)) / t.bytesPerSec
|
||||||
|
if sleepNs > int64(time.Second) {
|
||||||
|
sleepNs = int64(time.Second)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(sleepNs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *trackingReader) Seek(offset int64, whence int) (int64, error) {
|
||||||
|
newPos, err := t.inner.Seek(offset, whence)
|
||||||
|
if err == nil {
|
||||||
|
t.pos = newPos
|
||||||
|
// Don't update maxByteOffset on Seek — http.ServeContent seeks to EOF
|
||||||
|
// to determine size, which would instantly mark progress as 100%.
|
||||||
|
// Don't reset tokens — prevents clients from bypassing rate limiting
|
||||||
|
// by issuing repeated seeks to refill the token bucket.
|
||||||
|
}
|
||||||
|
return newPos, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *trackingReader) Close() error { return t.inner.Close() }
|
||||||
|
|
||||||
|
func (t *trackingReader) fillTokens() {
|
||||||
|
now := time.Now()
|
||||||
|
elapsed := now.Sub(t.lastFill)
|
||||||
|
if elapsed <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newTokens := int64(elapsed.Seconds() * float64(t.bytesPerSec))
|
||||||
|
t.tokens += newTokens
|
||||||
|
if t.tokens > t.burstSize {
|
||||||
|
t.tokens = t.burstSize
|
||||||
|
}
|
||||||
|
t.lastFill = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeMedia holds bitrate and duration extracted by ffprobe.
|
||||||
|
type probeMedia struct {
|
||||||
|
bitrateBps int64 // bits per second
|
||||||
|
durationSec int64 // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeBitrate uses ffprobe to detect the video bitrate and duration.
|
||||||
|
// Returns zero values if ffprobe is not available or the file can't be probed.
|
||||||
|
func probeMediaInfo(filePath string) probeMedia {
|
||||||
|
// Defense-in-depth: only probe regular files (not FIFOs, devices, etc.)
|
||||||
|
if fi, err := os.Stat(filePath); err != nil || !fi.Mode().IsRegular() {
|
||||||
|
return probeMedia{}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := exec.CommandContext(ctx, "ffprobe",
|
||||||
|
"-v", "quiet",
|
||||||
|
"-print_format", "json",
|
||||||
|
"-show_format",
|
||||||
|
filePath,
|
||||||
|
).Output()
|
||||||
|
if err != nil {
|
||||||
|
return probeMedia{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Format struct {
|
||||||
|
BitRate string `json:"bit_rate"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
Size string `json:"size"`
|
||||||
|
} `json:"format"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &result); err != nil {
|
||||||
|
return probeMedia{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var pm probeMedia
|
||||||
|
|
||||||
|
// Parse duration
|
||||||
|
if result.Format.Duration != "" {
|
||||||
|
dur, _ := strconv.ParseFloat(result.Format.Duration, 64)
|
||||||
|
if dur > 0 {
|
||||||
|
pm.durationSec = int64(dur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer explicit bit_rate from ffprobe
|
||||||
|
if result.Format.BitRate != "" {
|
||||||
|
bps, _ := strconv.ParseInt(result.Format.BitRate, 10, 64)
|
||||||
|
if bps > 0 {
|
||||||
|
pm.bitrateBps = bps
|
||||||
|
return pm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: estimate bitrate from size / duration
|
||||||
|
if result.Format.Size != "" && pm.durationSec > 0 {
|
||||||
|
size, _ := strconv.ParseInt(result.Format.Size, 10, 64)
|
||||||
|
if size > 0 {
|
||||||
|
pm.bitrateBps = int64(float64(size) * 8 / float64(pm.durationSec))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pm
|
||||||
|
}
|
||||||
|
|
||||||
func mimeTypeFromExt(filename string) string {
|
func mimeTypeFromExt(filename string) string {
|
||||||
ext := strings.ToLower(filepath.Ext(filename))
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
switch ext {
|
switch ext {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue