Files
silo-server/internal/playback/directplay.go
QuickandGitHub 90bdbfeb84 fix(playback): expose conditional range outcomes (#594)
* fix(playback): expose conditional range outcomes

* fix(playback): classify rejected If-Range requests

* fix(playback): evaluate If-Range diagnostics directly
2026-08-11 10:31:39 -04:00

256 lines
7.5 KiB
Go

package playback
import (
"crypto/sha256"
"fmt"
"log/slog"
"net/http"
"net/textproto"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"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 (including If-Match, If-Range, and
// 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.
streamWriter := httpstream.NewRollingDeadlineWriter(w)
w = streamWriter
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
}
w = &directPlayResponseWriter{
RollingDeadlineWriter: streamWriter,
size: stat.Size(),
}
w.Header().Del("ETag")
etag := directPlayEntityTag(f, stat)
if etag != "" {
w.Header().Set("ETag", etag)
}
// Set Content-Type explicitly so ServeContent does not sniff.
w.Header().Set("Content-Type", MimeFromExtension(filePath))
hadRange := len(r.Header.Values("Range")) > 0
hadIfMatch := len(r.Header.Values("If-Match")) > 0
hadIfRange := len(r.Header.Values("If-Range")) > 0
ifRangeResult := directStreamIfRangeResult(r, etag, stat.ModTime())
directStreamActive.Inc()
http.ServeContent(w, r, stat.Name(), stat.ModTime(), f)
outcome := streamWriter.Outcome(r.Context())
status := streamWriter.StatusCode()
bytesSent := streamWriter.BytesWritten()
rangeStart := directStreamRangeStart(status, w.Header().Get("Content-Range"))
recordDirectStreamEnd(outcome, status, bytesSent, rangeStart)
logAttrs := []any{
"component", "playback",
"outcome", outcome,
"status", status,
"bytes_sent", bytesSent,
"range_requested", hadRange,
"range_start", rangeStart,
"had_if_match", hadIfMatch,
"had_if_range", hadIfRange,
"conditional_result", directStreamConditionalResult(status, hadIfMatch, hadIfRange, ifRangeResult),
}
if fingerprint := directStreamValidatorFingerprint(etag); fingerprint != "" {
logAttrs = append(logAttrs, "etag_fingerprint", fingerprint)
}
if fingerprint := directStreamHeaderFingerprint(r.Header, "If-Match"); fingerprint != "" {
logAttrs = append(logAttrs, "if_match_fingerprint", fingerprint)
}
if fingerprint := directStreamHeaderFingerprint(r.Header, "If-Range"); fingerprint != "" {
logAttrs = append(logAttrs, "if_range_fingerprint", fingerprint)
}
slog.InfoContext(r.Context(), "direct stream ended", logAttrs...)
return nil
}
type directPlayResponseWriter struct {
*httpstream.RollingDeadlineWriter
size int64
}
func (w *directPlayResponseWriter) WriteHeader(status int) {
if status == http.StatusRequestedRangeNotSatisfiable {
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", w.size))
}
w.RollingDeadlineWriter.WriteHeader(status)
}
func directStreamRangeStart(status int, contentRange string) int64 {
if status != http.StatusPartialContent {
return -1
}
value, ok := strings.CutPrefix(strings.TrimSpace(contentRange), "bytes ")
if !ok {
return -1
}
bounds, _, ok := strings.Cut(value, "/")
if !ok {
return -1
}
start, _, ok := strings.Cut(strings.TrimSpace(bounds), "-")
if !ok || start == "" {
return -1
}
parsedStart, err := strconv.ParseInt(strings.TrimSpace(start), 10, 64)
if err != nil || parsedStart < 0 {
return -1
}
return parsedStart
}
const (
directStreamConditionalNone = "none"
directStreamConditionalIfMatchPassed = "if_match_passed"
directStreamConditionalIfMatchFailed = "if_match_failed"
directStreamConditionalIfRangeMatched = "if_range_matched"
directStreamConditionalIfRangeMismatched = "if_range_mismatched"
directStreamConditionalIfRangeNotEvaluated = "if_range_not_evaluated"
)
func directStreamConditionalResult(status int, hadIfMatch, hadIfRange bool, ifRangeResult string) string {
switch {
case hadIfMatch && status == http.StatusPreconditionFailed:
return directStreamConditionalIfMatchFailed
case ifRangeResult != "" && status != http.StatusNotModified && status != http.StatusPreconditionFailed:
return ifRangeResult
case hadIfMatch:
return directStreamConditionalIfMatchPassed
case hadIfRange:
return directStreamConditionalIfRangeNotEvaluated
default:
return directStreamConditionalNone
}
}
// directStreamIfRangeResult mirrors the If-Range decision made by
// http.ServeContent before range parsing. The final status cannot encode that
// decision reliably: ServeContent may reject a matched range with 416 or
// deliberately ignore matched aggregate ranges and return 200.
func directStreamIfRangeResult(r *http.Request, etag string, modtime time.Time) string {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
return ""
}
if r.Header.Get("Range") == "" {
return ""
}
validator := r.Header.Get("If-Range")
if validator == "" {
return ""
}
if validatorETag := directStreamScanEntityTag(validator); validatorETag != "" {
if validatorETag == etag && validatorETag[0] == '"' {
return directStreamConditionalIfRangeMatched
}
return directStreamConditionalIfRangeMismatched
}
if modtime.IsZero() {
return directStreamConditionalIfRangeMismatched
}
validatorTime, err := http.ParseTime(validator)
if err == nil && validatorTime.Unix() == modtime.Unix() {
return directStreamConditionalIfRangeMatched
}
return directStreamConditionalIfRangeMismatched
}
// directStreamScanEntityTag is the narrow ETag scanner needed to mirror
// net/http's unexported scanETag behavior for If-Range diagnostics.
func directStreamScanEntityTag(value string) string {
value = textproto.TrimString(value)
start := 0
if strings.HasPrefix(value, "W/") {
start = 2
}
if len(value[start:]) < 2 || value[start] != '"' {
return ""
}
for i := start + 1; i < len(value); i++ {
character := value[i]
switch {
case character == 0x21 || character >= 0x23 && character <= 0x7e || character >= 0x80:
case character == '"':
return value[:i+1]
default:
return ""
}
}
return ""
}
func directStreamHeaderFingerprint(header http.Header, name string) string {
return directStreamValidatorFingerprint(strings.Join(header.Values(name), "\x00"))
}
func directStreamValidatorFingerprint(validator string) string {
if strings.TrimSpace(validator) == "" {
return ""
}
digest := sha256.Sum256([]byte(validator))
return fmt.Sprintf("%x", digest[:8])
}