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>
81 lines
2.0 KiB
Go
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
|
|
}
|