* fix(playback): expose conditional range outcomes * fix(playback): classify rejected If-Range requests * fix(playback): evaluate If-Range diagnostics directly
256 lines
7.5 KiB
Go
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])
|
|
}
|