Files
silo-server/internal/playback/directplay.go
T
b7292a9473 fix(streaming): stop killing healthy streams at the server WriteTimeout (#361)
The main API server's WriteTimeout (120s) is an absolute deadline from
request start, so every streaming response still being written at T+120s
was cut mid-body with a clean close. Clients saw multi-GB direct streams
truncate every two minutes; the Apple client's cursor-resume reconnect
absorbed most kills silently, but one landing during backpressure or a
demuxer resync exhausted its retry budget and forced a full player
teardown (visible stop + historical audio desync seeding).

Fix: internal/httpstream.RollingDeadlineWriter pushes the connection's
write deadline forward with progress via http.ResponseController — a
response that keeps moving lives indefinitely, a stalled one is still
reaped within the window (180s default, SILO_STREAM_WRITE_STALL_TIMEOUT
to override). ReadFrom delegates in bounded slices so http.ServeContent
keeps its sendfile fast path. Wired into direct play, remux, downloads,
the transcode-node proxy, and ebook serving; the server-level 120s guard
stays for every other route.

The metrics and request-logger response writers now implement Unwrap —
without it http.ResponseController cannot traverse to the connection and
SetWriteDeadline fails, silently disabling the fix (exactly what the
first dev deploy showed). A middleware-chain integration test locks the
whole path down against future wrappers missing Unwrap.

Validated on dev: 200s/512MB direct and 300s/768MB via CDN sustained
range-GETs (previously dying at 120s), zero duration_ms=120000 stream
entries since deploy.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 23:07:16 -04:00

81 lines
2.0 KiB
Go

package playback
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/Silo-Server/silo-server/internal/httpstream"
)
// MimeFromExtension returns a MIME type based on the file extension.
// Falls back to "application/octet-stream" for unknown extensions.
func MimeFromExtension(name string) string {
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".mp4", ".m4v":
return "video/mp4"
case ".mkv":
return "video/x-matroska"
case ".webm":
return "video/webm"
case ".avi":
return "video/x-msvideo"
case ".mov":
return "video/quicktime"
case ".ts":
return "video/mp2t"
case ".flv":
return "video/x-flv"
case ".wmv":
return "video/x-ms-wmv"
case ".m4b", ".m4a":
return "audio/mp4"
case ".mp3":
return "audio/mpeg"
case ".flac":
return "audio/flac"
case ".opus", ".ogg":
return "audio/ogg"
case ".wav":
return "audio/wav"
case ".aac":
return "audio/aac"
default:
return "application/octet-stream"
}
}
// ServeDirectPlay serves a media file with HTTP byte-range support.
// Uses http.ServeContent for proper range handling, which supports
// Range requests, conditional requests (If-Modified-Since, If-None-Match),
// and Content-Type detection.
func ServeDirectPlay(w http.ResponseWriter, r *http.Request, filePath string) error {
// Media bodies routinely take longer than the server's absolute
// WriteTimeout; roll the write deadline with progress instead.
w = httpstream.NewRollingDeadlineWriter(w)
f, err := os.Open(filePath)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "file not found", http.StatusNotFound)
return err
}
http.Error(w, "internal server error", http.StatusInternalServerError)
return err
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return err
}
// Set Content-Type explicitly so ServeContent does not sniff.
w.Header().Set("Content-Type", MimeFromExtension(filePath))
http.ServeContent(w, r, stat.Name(), stat.ModTime(), f)
return nil
}